ADR-0132: Dual-Hat Reviewer Satisfaction Is a Declared Policy Choice — the Strict Floor Stays the Default¶
- Status: accepted (2026-09-03 — register row 56. His proposal: "Could we change the approve
button to 'approve as
'. Then show the button once for each applicable role and allow the same user to approve multiple times in different roles. But keep the rule that the submitter must not approve their own submitted files in 4-eye mode" . His ruling on the counter-proposal — the same relief behind a declared, default-off policy flag rather than a global redefinition — "I prefre your counter proposal a lot". Sequencing ruled the same day: "agree with the post 1.0 implementation".) - Date: 2026-09-03
- Deciders: Nicolas Burri
- Refines: ADR-0030 point 4 — "a single user satisfies at most one required role-slot" becomes a declared per-(scope, type) policy choice, default off; the rest of point 4 (content-plane approvals, the content-hash binding, four-eyes) is untouched.
- Relates to: ADR-0064 (per-document workflow role bindings — D-4a's approve-as-role act, which this ADR keeps unchanged and finally makes visible), ADR-0047 (four-eyes, submitter-only exclusion — the invariant this ADR may not touch), ADR-0030 (the (scope, type) review policy this flag joins), ADR-0091 (release modes — the precedent for a policy-declared relaxation with a structural floor underneath), ADR-0114 / the arrival bundle's operating model (where the flag travels), ADR-0095 (governance acts on process configuration), ADR-0131 / ADR-0130 (substitution and timed staffing — §7 states the composition), ADR-0125 (the bundle format this extends).
Context¶
A project staffed with one Process Analyst and one QMB, both bound as reviewers with a min-count
of one, cannot release a document unless two distinct humans approve — even when one person
legitimately holds both roles. That is the ReleaseGate's cross-role distinct-person floor
(ReleaseGate.kt:87/:97): the per-role shortfalls are computed per role, and then a second,
independent condition demands that the count of distinct approvers reach the sum of the
min-counts. Two roles at one each therefore always mean two people.
The floor is not an accident of the code: ADR-0030 point 4 states it in words — "a single user
satisfies at most one required role-slot" — and ReleaseGate.kt:97 is that sentence compiled.
For Ergon that is right, and it is what its QMS says. For the two- and three-person customer teams LQMS is meant to serve it is a deadlock: the roles are correctly declared, the person is correctly qualified for both, and the release is unreachable. Break-glass does not help — ADR-0130/-0131 add hats, not heads, and this floor counts heads.
The discovery that shaped the decision: the wire is already approve-as-role, and the database
already permits one row per (person, role). LifecycleService.approve (:210-260) takes an
explicit roleId, validates it against the document's REVIEWER bindings and the caller's effective
roles, and records it on the approval row; approval_unique_per_round (V014) is
unique (version_id, approver_id, role_id, review_round) — the role is part of the key, so a
second approval by the same person in a different role is a distinct, permitted row. The entire
"one person, one approval" semantic lives in one deliberate line in the gate. Nothing needs
re-founding; a policy needs declaring.
Decision¶
1. The flag: a per-(scope, type) review-policy option, DEFAULT OFF¶
The review policy gains one option — one person may satisfy multiple role requirements —
alongside fourEyes, releaseMode and requireCommentsResolved.
- Default OFF. Off is today's behaviour exactly: the cross-role distinct-person floor applies, and no existing instance's bindings change meaning. A scope that never touches the setting never notices this ADR.
- Opting in is a witnessed governance act, gated by
CONFIGURE_REVIEW_POLICYand audited like every other policy edit (REVIEW_POLICY_CONFIGURED). The relaxation therefore has a name, a date and an actor — an auditor reading instance B can see who decided that one person may wear two reviewer hats here, and when. - It travels in the arrival bundle's operating model (a bundle-format field beside the review policies, ADR-0114/-0125). A small-team customer lands with the relaxation declared and audit-visible, not silently inherited and not manually re-created after arrival.
2. The three invariants the flag never touches¶
- (i) Within-role distinctness. A requirement of 2 × QMB always means two QMB humans. The
per-role count is
approvedApprovers(…, roleId).sizeover a distinct approver set — per-role counting is per-person by construction, and the flag does not go near it. - (ii) Four-eyes. The round's submitter never approves it (ADR-0047, enforced by
submitterUnderFourEyes). So every release involves at least two humans even at maximum hat-stacking: one submits, at least one other approves. This is the structural reason the flag is safe to offer at all. - (iii) One approval, one declared role. Each approval is cast as exactly one role the approver holds — ADR-0064 D-4a unchanged. A dual-hat reviewer does not cast one approval that counts twice; they cast two approvals, each naming its role, each its own witnessed act on its own row.
3. Gate semantics under the flag¶
The per-role shortfalls remain the whole check. The cross-role distinct-person floor
(approvalsRecorded >= approvalsRequired, ReleaseGate.kt:97) applies only when the flag is
off.
The resulting rule, in one sentence:
Each required role must be covered by that many distinct holders; whether one person may cover more than one role is the policy's declared choice.
4. The UI is unconditional: "Approve as <Role Name>"¶
The approve act names its role in all cases, flag or no flag. Today the console carries a role
picker only when more than one reviewer role applies and a generically-labelled Approve button
(review-console.ts:52-73); from here the act itself says which hat it is cast under — which makes
even the strict semantics visible, and is the half of his proposal that is right regardless of
the policy.
- One button per applicable held reviewer role where several apply.
- The console and the trail render approvals role-attributed.
- Two approvals by one person render as two witnessed acts, not as a bug or a duplicate.
5. Named implementation sweeps¶
Recorded here so the build lane cannot miss them: every consumer of hasApproved /
approvedApprovers currently assumes one approval per person per round.
LifecycleService.pendingApprovals(:130) drops the item once the caller has approved at all — under the flag it would hide the second role's button after the first approval. The queue must become per-(person, role) against the document's reviewer requirements.DocumentServiceversion detail (:2303) computesmyApprovalRecorded, which disables the approve button in the console (review-console.ts:71) — same per-role treatment; it becomes a per-role fact, not a per-person one.LifecycleTaskHandler(:75) resolves theAPPROVAL_REQUIREDtask per role from the per-role count — already role-keyed, and correct as it stands; verify, do not rewrite.ApprovalRepository.hasApprovedignoresrole_idtoday. Either it gains the role argument or its callers stop using it for role-scoped questions; the divergence this repository exists to prevent (its own KDoc: the 2026-07-02 counting divergence) is exactly what a half-migrated call site would reintroduce.- The AUTOMATIC-release path: a dual-hat reviewer's second click may release the version
(
LifecycleService.approve:246-258 evaluates the gate after each approval). That is correct and is stated here so it is never mistaken for a defect. - The export / audit surfaces that render approvals: role attribution must survive into the PDF identity header, the audit pack and the bundle, so two rows by one person read as two roles.
- The flag joins the schema-extension lifecycle sweep (policy read/write, bundle shape, seed,
the policy gap analysis, the release-gate test family incl.
ReleaseGateTest).
6. Composition with ADR-0130 / ADR-0131¶
Stated now rather than discovered later: a role held via timed substitution counts as held. Under the flag, one person plus one witnessed escalation can complete a review — minus the submit, which four-eyes still bars. That composition is the small-team relief working as designed, not a loophole: the substitution edge was a declared qualification judgment (ADR-0131 §2), the escalation is witnessed and self-expiring, the flag was a witnessed governance act, and every step is on the trail with a name and a time.
7. Sequencing: implementation post-1.0 — ruled¶
His ruling: "agree with the post 1.0 implementation". The ADR is the deliverable for now.
The gatekeeper logic behind the recommendation he accepted: a workaround exists — bind differently (one role at min-count 2, or a single reviewer role), or staff a second person — and Ergon's own instance is unaffected by default, since off is today's behaviour. The interim answer to a small team asking today is therefore the workaround, not a wait.
Rejected¶
- The global change (his original proposal). Allowing the same user to approve in several roles
everywhere deletes the floor for every existing binding. A shop that wrote "1 × PA + 1 × QMB"
meaning two people would silently start meaning possibly one — the process shape changing
under a QMS without anyone declaring it. This is the undeclared-semantic-weakening defect
class, the same one row 52 pins on the other side of the system (a fully VERIFIED requirement
chain landing as
VERIFICATION_PLANNED_ONLY, silently: the record saying less than it did, with nothing warning). A QMS may relax a control; it may never relax one quietly. - UI-only (the "Approve as <Role>" half alone). Honest labelling without the relief leaves the deadlock exactly where it was: the small team would now see precisely which second hat it holds and still be refused. Honesty is necessary and insufficient — so it ships (§4), but not alone.
Consequences¶
- One new review-policy field: storage, DTO, the
CONFIGURE_REVIEW_POLICY-gated read/write path, the auditedREVIEW_POLICY_CONFIGUREDpayload, the bundle-format slot, the seed and the policy editor's copy — which must say what the setting means for the record, not what it toggles. ReleaseGate.evaluategains the policy input and one conditional on the floor; the strict path keeps a pinned test proving the floor still bites when the flag is off, and the relaxed path gets its own case proving within-role distinctness and the four-eyes bar survive.- The role-attributed approve UI ships independently of the flag and touches every scope.
- The November-audit story gains an answer to "how do you handle a person qualified for two reviewer roles?" — a declared, witnessed, per-type policy choice, off unless someone with governance authority turned it on, with the decision itself on the trail.