ADR-0096: Transition Permissions — Always Visible, Deny Unless Granted¶
- Status: accepted — CONFIRMED unchanged by Nicolas's read-back 2026-07-26 ("ADR-096 sounds good. Please keep it as is"); the realization slice is cleared to push once gated
- Date: 2026-07-26
- Deciders: Nicolas Burri — the model is HIS proposal from the play-test round ("all transitions always visible… multi-select for each… Everything that is not set is automatically considered to be forbidden — Empty == nobody", plus the dead-end check), refined with three conditions he confirmed ("this is a real issue… Migration is not an issue… this needs to be considered as a loud error").
- Supersedes (semantics): the ADR-0030/0046-lineage transition-permission reading in which an UNCONFIGURED (scope, type) policy left every transition open and a CONFIGURED one silently denied whatever carried no row. Relates to: ADR-0064 (per-document workflow bindings — the template relationship below), ADR-0054 (unstaffed detection — reused), ADR-0053 (type defaults pattern), the wizard options note D-2/D-3 (presets + completeness read).
Context (the play-test finding, register #7)¶
rolesFor returned null (= open) only while NO policy row existed; ANY configured policy made
ungated transitions emptySet() = denied for everyone — invisibly, and with no UI path back to
the unconfigured state. Nicolas's live lockout (removing his experimental PERIODIC_REVIEW row
locked revision for ALL users including the role that previously held it) reproduced the trap
exactly. The old box also allowed duplicate rows, showed nondeterministic order, and hid the
open-until-first-row semantics entirely (register #3 a/b/c/d/g/i).
Decision¶
- Deny unless granted, uniformly and visibly. Every transition is ALWAYS on screen with a role multi-select; empty means NOBODY and renders as an explicit state ("⚠ nobody"), never as an absence. The "unconfigured = open" state is abolished; policy existence stops carrying semantics. (One row per transition + multi-select makes duplicates and ordering issues structurally impossible.)
- Two visually distinct sections — the box stops conflating template with gate:
- A. Workflow defaults for new documents — the document dialog's three-group editor REUSED VERBATIM (Authors / Reviewers-with-min-count / Releasers): the ADR-0064 D-1a copy-then-independent template, now LOOKING like what it is. The separate approval-requirements box merges into the Reviewers row; REJECT ceases to be a transition row — rejecting is a reviewer right (whoever may approve may reject).
- B. Standing permissions on this type (enforced directly, always): action-language labels — "Revise & periodic review", "Revoke", "Cancel" — never raw transition vocabulary.
- Tiered save validation, riding the setup-completeness read (ONE validator, wizard slice 1):
- empty REVOKE on a PRESCRIPTIVE type BLOCKS the save (a released procedure nobody can revoke is a compliance trap, never intended);
- empty REVOKE/CANCEL on RECORD/TEMPLATE types and empty revise-grants WARN loudly but save (occasionally intended);
- grants to roles with zero ACTIVE holders show the ADR-0054 unstaffed warning inline.
- Save honesty (register #3 e/f/h): the box carries the dirty-guard (DirtyStateService consumer #2), an explicit unsaved-changes affordance, and on a FAILED save re-syncs from the server (or visibly marks the divergence) — a failed save must never impersonate a success.
- Migration: none. The only existing instance is Nicolas's (drop-and-recreate culture, ADR-0058); the seed and the wizard lane presets (D-2) write complete grant sets so no scope is ever born locked.
Consequences¶
- Resolver:
rolesForloses its null branch. (Realization note, 2026-07-26: theconfiguredflag survives aspolicyExists— PRESENCE ONLY, never an authorization input; its two legitimate readers are the GET 404 and the setup-status finding.rolesFordoes not consult it.) LifecycleService's REVOKE/CANCEL/PERIODIC_REVIEW gates and the getVersion reflection read the same deny-unless-granted truth (action surface = enforcement truth, unchanged principle). - Seed + wizard presets updated to write explicit grants for every workflow-driven type.
- REQ rows at the next baseline; the scope-config e2e/screenshot suite reworked for the new box.
- Register items #3 a/b/c/d/g/i close structurally with this slice; #3 e/f/h close via §4.