Governance
Three gates end the three phases, and each is a different question, asked of a different person, because collapsing them into one question answered by one role is the failure this whole application exists to prevent — see decision 0005.
Three gates, three questions
| Gate | The question | Who answers it |
|---|---|---|
| approval to load | May this product read this data? | the source owner, and only for the system they own |
| agreement | Is this what we are promising? | the use case team, by accepting the contract |
| sign-off | Having tested it, does it actually work? | the use case team again, but never the person who asked for the signature |
The first gate sits across a boundary this application did not invent: the source
owner answers for their own system and nobody else's, which is what
rbac.owns_system checks on every decision rather than trusting the role alone. The
second and third gates both belong to the same role — the consumer — because
accepting a promise and verifying it held are genuinely different questions, asked
at different times, of whoever is standing in for the use case. Roles and
permissions has the full table of who decides
what; rbac.py is where a permission is placed against a role deliberately, never
inherited from whatever the last one had.
Four-eyes compares the signed-in person, not the role
A role switcher makes separation of duties easy to defeat: switch from the team that
asked to the system that answers, click the second button, and one person has closed
a gate against themselves wearing two hats. The control is same_hand in
dpf/web/views.py. It does not compare the name on a form — a free-text field, and
whatever was typed into it — it asks who was actually signed in when the earlier
action happened, by reading principal off the audit rows for that object. Switching
role changes actor, the hat; it does not change principal, the hand, and every
four-eyes check reads the hand.
It guards three decisions today: signing a section of the data contract v0.1, deciding an access request, and signing off the integration test. Each refusal names the reason rather than failing silently — "You sent this request while acting as somebody else. Switching role does not make you a second person" — because a disabled button with no explanation reads as a bug, and the person reading it needs to know who should be pressing it instead.
The audit log doesn't forget, and doesn't guess
audit_log is append-only — nothing in the schema updates or deletes a row — and
every event carries two identities, not one: actor is the name the record
credits — the person, or the role persona, being acted as — and principal is
who was actually signed in. The distinction exists
because a log that only recorded the role could not tell a demo's role-switch apart
from four different humans, and a log that is only sometimes complete is a log that
has to be caveated, which defeats the point of keeping one. Audit events
lists every code the log can write and the sentence shown for it.
Status is computed, not trusted
Whether a release may proceed is answered fresh every time it is asked, from
whatever the record currently says — never from a flag set once and believed from
then on. domain/release.py's gates take an Evidence snapshot (who granted access,
who refused it, who has and has not countersigned) and recompute the answer on every
check. The reason is specific: the three approvals used to be recorded, displayed, and
then ignored at the one moment they should have counted, because a refusal sitting
unread in the database is not a control. A control that is written down and never
evaluated is worse than no control, because it is mistaken for one.
Simulated answers are part of that evidence, and they block rather than help. The
demo device that lets a workspace be walked end to end — simulate the owner's
answer, simulate a signature — writes a real row, marked simulated, so the flow
has something to show. The promotion gate reads that flag and refuses: "the approval
from X was simulated — record a real decision before promoting." An access record
indistinguishable from a fabricated one would be worth less than no record at all,
and this is the one place that distinction has to hold.
What this does not yet catch
Release approval checks the role, not the person. The same-hand guard sits on the
v0.1 signature, the access decision and the sign-off; nothing yet stops whoever cut
a release from also being the one who approves it, beyond the fact that release.promote
is a single role's permission. A different person approving than built is not
enforced, only conventional.
Sign-off four-eyes needs somebody signed in to compare against. same_hand
refuses to answer at all when nobody is signed in — in open mode, no accounts and
no sign-in, every principal is empty, by design: there is nobody for the application
to have authenticated, so there is nothing to compare and nothing it can refuse. It
starts refusing the same hand once a deployment turns sign-in on.