think
16px
820px

Delegation & Assistants (secretary features)

Two related-but-distinct powers let a boss's assistant help with workflow load.
They share one UI vocabulary ("assistants") but are deliberately separate
mechanisms, because they answer different questions:

Power Question it answers Mechanism Boss keeps authority?
Delegation "Who may act for my position while I'm away?" delegations (directory) ❌ the delegate acts — with attribution
Screening "Who filters what reaches my attention?" gatekeeper_gates + triage_items ✅ every task stays the boss's

Migration: 00167_delegation_assistants.sql.

Delegation

A delegation binds a position to a substitute user over a half-open window
[valid_from, valid_to) (nil valid_to = open-ended). While active, workflow
fan-out gives the substitute their own task alongside the position's
holders — it is additive, never a handover; the holder keeps their task.

The coverage doctrine (the important part)

workflowdomain.DelegableStepKind(kind, powers):

  • Always delegable: approve, number, send, and any forwarded-role
    task. Approving, numbering, dispatching are office duties.
  • Per-power opt-ins (mig 00169; all default false): each identity-bearing
    kind gates on its OWN flag — sign/sign_external on may_sign, meterai
    on may_meterai, stamp on may_stamp. These produce an act carrying the
    actor's own legal identity, so none is ever implied and none implies another.
    (Until 00169 one may_sign bool bundled all three; the migration backfills
    the new columns from it, so old delegations keep their exact coverage.)

Every path honours the same doctrine: definition-driven fan-out, the built-in
Start, sub-workflows, named-assignee coverage (a delegation on a position
the named user holds follows them), and the disabled-account auto-substitute.
The meterai/stamp affix permission checks (meterai.affix / stamp.affix)
still apply to delegates on top of may_sign — layered, not replaced.

Attribution

A delegated task carries on_behalf_kind (position | user),
on_behalf_id, and delegation_id. When the delegate acts, the workflow
transition is stamped with the same on_behalf_* — "approved by Siti for the
Director" is queryable history (workflow_transitions), not a locale-bound
note string. The inbox shows a "for {name}" badge (label resolved
server-side).

Lifecycle

  • Create — admin: POST /delegations (directory.admin); self-serve:
    POST /me/delegations (any authed holder; from_position_id must be a
    position the caller actively holds). Body:
    {from_position_id, to_user_id, valid_from?, valid_to?, may_sign?, may_meterai?, may_stamp?} (RFC3339).
  • ListGET /delegations (admin, all) / GET /me/delegations
    (self-serve; also returns the caller's positions for the picker). Rows carry
    position/user labels and a computed status:
    active | scheduled | expired | revoked.
  • RevokeDELETE /delegations/{id} / DELETE /me/delegations/{id}.
    Soft: valid_to closes at the revocation instant (a not-yet-started
    delegation collapses to never-active) and revoked_by/revoked_at are
    stamped. The row survives as history.

⚠️ Delegation binds at fan-out. Tasks that already exist when a delegation
is created are not re-fanned; the delegate sees only tasks created while the
delegation is active. (The profile UI says so.)

UI: Profile → Assistants → My delegates (self-serve) and
Admin → Delegations & assistants (org-wide, incl. history). Rows are never
edited in place — a delegation is a record of who could act, so changes are
revoke + create and the history stays honest.

Audit: delegation create/revoke and gate create/remove (admin AND
self-serve) append to the tamper-evident audit chain, partition assistants,
with actor, IP, both parties and the granted powers.

Screening (gatekeeper)

A gate = (principal, gatekeeper): the gatekeeper triages items addressed
to the principal. Standing arrangement, no window — "my secretary always
screens my inbox".

Feed

The outbox relay (workflow.task_assigned in wire.go) intakes every new
workflow task whose assignee has a gate into the gatekeeper's queue
(triage_items, ref_type="workflow_task"). Subject = the document/letter
title; attributes = {action_required, subject_type, subject_id, instance_id, on_behalf_*} — the fields auto-triage rules can condition on. Ungated users
grow no rows. Intake is best-effort: a triage failure logs and never blocks
the notification or the outbox.

Semantics — triage-reject ≠ workflow-reject

A screening verdict manages the boss's attention, never the workflow:

  • accept → the task appears normally in the boss's inbox.
  • reject (screen out) → the task drops to a de-emphasized "screened out"
    group in the boss's inbox, with the gatekeeper's note. The workflow is
    untouched; SLA clocks and escalation run as always.
  • forward → reassigns the screening to another user (item stays
    pending); the underlying task does not move.
  • reopen → accepted/rejected → pending (undo).
  • expired → the referenced task was completed/cancelled elsewhere; swept
    lazily (status-guarded) when the queue is listed.

The principal outranks the screen: they may decide/reopen items addressed
to them (guardOwner), and the inbox's "Restore" button is exactly that —
the principal accepting their own item.

While an item is pending, the boss's inbox shows the task under "being
screened" — visible and actable, so an absent secretary can never block the
boss.

The derivative visa

A gatekeeper may read the documents their principals currently have
pending tasks on — a screener who can only read subject lines is half-blind.
Same shape as the task-holder visa: read only, alive only while a principal's
task is pending, an explicit ACL deny on the gatekeeper still wins, DLP /
step-up run against the gatekeeper themselves, and it is not transitive
(only direct gates count). Enforced in requireAccess beside the holder's
visa.

RBAC-aware assignment

Naming an assignee who cannot read the subject document is an act of
disclosure, and the system treats it as one:

  • Picker: the start step list probes per-user read access (POST
    /documents/{id}/access-check, editor-gated, full subject expansion — user
  • groups + active positions). Can-view people sort first; no-access people
    carry a lock hint; named roster rows wear "read will be granted" /
    "cannot read this document" badges.
  • Gate: starting a run (definition / custom / request-signatures) that
    names no-access users needs workflow.assign_beyond_acl — deliberately
    not in the member set — otherwise 403 listing the actual names.
  • Durable grant: when permitted, the start grants ACL read to those
    users after it commits (never before — a failed start leaves no grants),
    each grant audited to the assistants partition
    (workflow.assignment.read_granted).

Positions stay ungated — holders churn at fan-out and read via the task
visa. Mid-run dispose/forward stay visa-covered. /me exposes
can_assign_beyond_acl for the picker affordance.

Auto-triage rules

Per-gatekeeper gatekeeper_rules: conditions (AND; eq|neq|contains over
attributes + synthetic subject/ref_type) → first action of the
highest-priority enabled match (accept|reject|forward), applied at intake
with decided_by="gatekeeper:rule:<id>". Self-serve CRUD over one's own
queue: /me/triage-rules (the Triage tab's Rules section). Admin rule CRUD
stays on /gatekeeper/rules (gatekeeper.admin).

Routes (new in this round)

POST   /gatekeeper/triage/{id}/reopen    gatekeeper.triage (owner/principal/admin)
GET    /me/assistants                     authed   my screeners (I'm principal)
POST   /me/assistants                     authed   appoint my screener
DELETE /me/assistants/{gateID}            authed   remove my screener
GET    /me/gatekeeping                    gatekeeper.triage   whose inboxes I screen
GET/POST /me/triage-rules,
DELETE /me/triage-rules/{ruleID}          gatekeeper.triage   own queue's rules
GET/POST/DELETE /me/delegations[...]      authed   own positions' delegations
GET    /delegations                       directory.admin
DELETE /delegations/{id}                  directory.admin

No new permission keys — perm-guard unchanged (52 enforced ↔ catalogue).

UI: Inbox → Triage (tab appears only for appointed gatekeepers; badge =
pending count), Profile → Assistants → My assistants, Admin →
Delegations & assistants → Screening gates
.

Verifying the delegate path for real (not via admin bypass)

Every demo user is admin, and ActDefinition has override := isAdmin — an
admin passes the act authorization without holding a delegated task. A
naive e2e therefore "proves" nothing. To prove the real path:

  1. Create two users and a role with only workflow.read + workflow.act
    (+ gatekeeper.intake/gatekeeper.triage for the screening half); make
    sure they are NOT admins.
  2. Boss holds position P; a definition routes an approve step to P. Delegate
    D gets a delegation on P (no may_sign).
  3. Start an instance → D's inbox shows the task with OnBehalfKind=position;
    D approves → the transition row carries on_behalf_id = P, actor = D.
  4. Add a sign step to the definition → D must NOT receive the sign task
    (only the holder does). Re-create the delegation with may_sign → D does.
  5. Gate boss→S (secretary). Start an instance → S's Triage tab shows the item;
    reject it → boss's inbox shows it under "screened out" with the note;
    boss restores → normal list.

Verified on the demo (2026-08-05, non-admin actors)

Run with three purpose-made non-admin users (is_admin=false), so nothing
below passed through the override := isAdmin bypass:

Check Result
Boss self-serves a delegation (no admin) ✅ created, listed, labelled
Delegate delegates someone else's position ✅ 403 not_your_position
Delegate to self ✅ 400 directory.delegation.self
Approve task reaches the delegate ✅ badged position / "E2E Director"
Delegate (non-admin) approves ✅ 204; transition actor=<delegate> on_behalf=position:<pos>
Sign step, may_sign=false delegate gets 0 sign tasks; holder gets 1
Sign step, may_sign=true ✅ delegate gets the sign task, attributed
Soft revoke ✅ status revoked, revoked_by recorded, row kept
Gate appointed self-serve, both directions
Triage queue actually fed ✅ real item, document title, principal resolved
Triage-reject does not reject the workflow ✅ instance still submitted, tasks still pending
Boss restores a screened-out task ✅ (after the fix below)
Gatekeeper overruling a decided item ✅ 409 not_pending — only the principal may
Non-principal touching another's item ✅ 403 not_owner
Auto-triage rule at intake decided_by=gatekeeper:rule:<id>

One bug this caught and fixed: accept ran through the pending-only guard,
so the boss's Restore button 409'd — the principal could not overrule their own
assistant, the one thing the doctrine promises. CanDecideBy now lets the
principal overrule an accepted/rejected item while a gatekeeper still decides
only pending ones, and the persist guards on the status the caller read.

Known edges (deliberate)

  • Escalation targets still resolve through ManagerCandidates, which
    unions delegates of ancestor positions without kind gating — an overdue
    sign task escalated to a manager's non-may_sign delegate is a
    double-indirection we accept for now (the ceremony perm checks still apply).
  • Notifications for delegated tasks read like ordinary assignments (no
    "on behalf of" in the email body yet).
  • Rule-forwarded screening hands the item to a user who cannot act on the
    underlying task (it isn't theirs) — their accept/reject still only manages
    the principal's inbox, which is consistent, just worth knowing.