Docs / Explanation / Governance
Explanation For everyone

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.

Documentation for version 0.2.0 Reviewed 2026-10-19 Covers dpf/domain/release.py, dpf/domain/rbac.py, dpf/audit.py