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_externalonmay_sign,meterai
onmay_meterai,stamponmay_stamp. These produce an act carrying the
actor's own legal identity, so none is ever implied and none implies another.
(Until 00169 onemay_signbool 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_idmust 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). - List —
GET /delegations(admin, all) /GET /me/delegations
(self-serve; also returns the caller's positions for the picker). Rows carry
position/user labels and a computedstatus:
active | scheduled | expired | revoked. - Revoke —
DELETE /delegations/{id}/DELETE /me/delegations/{id}.
Soft:valid_tocloses at the revocation instant (a not-yet-started
delegation collapses to never-active) andrevoked_by/revoked_atare
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 needsworkflow.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 theassistantspartition
(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:
- Create two users and a role with only
workflow.read+workflow.act
(+gatekeeper.intake/gatekeeper.triagefor the screening half); make
sure they are NOT admins. - Boss holds position P; a definition routes an approve step to P. Delegate
D gets a delegation on P (nomay_sign). - Start an instance → D's inbox shows the task with
OnBehalfKind=position;
D approves → the transition row carrieson_behalf_id = P, actor = D. - Add a sign step to the definition → D must NOT receive the sign task
(only the holder does). Re-create the delegation withmay_sign→ D does. - 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_signdelegate 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.