Two-person approval (maker-checker)
Some decisions should never rest on one person: accepting a model whose quality gates failed, approving the contract that decides what a model may learn from, or serving a bundle whose files no longer match their recorded fingerprints. A project can require a second person for each of them. The person who starts the action is the maker; the person who approves it is the checker.
Two-person approval is off until an admin turns it on. With it off, every operation behaves exactly as described elsewhere in these docs.
The three control points
| Policy flag | Held operations | Audit action it protects |
|---|---|---|
promotion_acknowledge | createModelBundle, createModelHazardBundle, createEventModelBundle, createEntityBaselineBundle and importSharedCheckpoint, when the request has acknowledge_gates or acknowledge_findings | gate.override, bundle.finding_acknowledged |
contract_approve | approveContract, approveSourceOnboarding, and updateSourceSemanticBinding (a reviewed binding correction approves a new contract). Automatic onboarding no longer approves on its own: it parks its proposal for review (needs_attention, manual_review_required) exactly as guided onboarding does, and the person's approveSourceOnboarding is held | contract.approve |
fingerprint_override | activateSlot with allow_mismatch: true | fingerprint.override |
A request that does not use its point is never held: a bundle with no
acknowledgement and an activation without allow_mismatch run as before.
The policy belongs to the project and covers both environments: a request
held in test that turns a point off turns it off for live too, and its
listing says environment: test only because that is where it was made.
Turn it on
The policy is per project and shared by both environments. Any project admin (or an admin API key) can turn a point on:
curl -s -X PUT "$TESSERA_API_BASE/dual-control-policy" -H "authorization: Bearer $TESSERA_API_KEY" -H "content-type: application/json" \
-d '{"promotion_acknowledge": true, "contract_approve": true, "fingerprint_override": true}'
The body names all three flags. GET /dual-control-policy reads the policy,
and every change records a dual_control_policy.update audit event with the
values before and after. Turning a point off is itself held for a second
person (see Turning a point off).
What the maker sees
With a point on, the maker's call does nothing except record a pending
request. It answers 400 failed_precondition with one dual_control detail:
{"type": "dual_control", "reason": "second_actor_required", "operation": "createModelBundle",
"control_point": "promotion_acknowledge", "resource_id": "mdl_…", "request_fingerprint": "3f1c…",
"expires_at": "2026-10-25T09:00:00Z"}
The request pins the exact call (its path and body) and the state of the
resource it acts on, such as the fingerprint of the evaluation report a bundle
acknowledges. Repeating the same call returns the same pending request. The
dual_control.request audit event names the maker.
Approve or reject (the checker)
Members, admins and admin-scoped keys list what is waiting in the project and environment they are scoped to (the pinned bodies carry acknowledgement reasons and bindings, so viewers do not see them):
curl -s "$TESSERA_API_BASE/dual-control-requests?status=pending" -H "authorization: Bearer $SESSION_TOKEN" \
-H "x-tessera-project: $PROJECT_ID" -H "x-tessera-environment: live"
Each item shows the operation, the pinned request, the pinned state, the maker
and the expiry; an override request also shows the fingerprint mismatches it
would accept (state.observed_mismatches). To approve, send the request's address:
curl -s -X POST "$TESSERA_API_BASE/dual-control-requests:approve" -H "authorization: Bearer $SESSION_TOKEN" -H "x-tessera-project: $PROJECT_ID" -H "x-tessera-environment: live" \
-H "content-type: application/json" -d '{"operation": "createModelBundle", "resource_id": "mdl_…", "request_fingerprint": "3f1c…"}'
The approval runs the pinned call as the checker. Its answer is
{request, result}, where result is the operation's own response (a contract
in it never carries profile sample values; read the contract for them).
The dual_control.approve event and the operation's own events (for example
gate.override and bundle.create) are written in one transaction: either all
of them are recorded or none is. The operation's *_by fields, such as
acknowledged_by, approved_by and activated_by, name the checker (so does
the model inventory's owner, the bundle.create actor); every audit event the
approval records carries details.dual_control with the maker, the checker and
the request fingerprint, and the request's own events name both people.
POST /dual-control-requests:reject takes the same address and an optional
reason, closes the request without running it and records
dual_control.reject. The maker may also reject (withdraw) their own request
(recorded with details.withdrawn). Withdrawing needs the reject permission
(admins), so a member who made a held bundle gets 403: an admin rejects it
for them, or it expires after 72 hours. The maker can ask again.
Who may decide
- A different person. The maker cannot decide their own request, and
neither can the person who created the API key that made it (400
failed_precondition,self_approval). Two accounts held by one person are a matter for your identity provider. A key answers to its creator only: the creator of a key it replaced is not its maker, and anyone holding a shared key can approve what that key asked for, so give guarded calls their own keys. - A person, never a key. Approve and reject accept a console session only.
- With the permissions. The checker needs
dual_control.requests.approve(ordual_control.requests.reject), which owners and admins have and a custom role can grant, and the permission of the held operation itself (for examplemodels.bundles.create). Both are read again inside the approval, under the same lock every role change takes, so a checker whose role was just removed is refused (403permission_denied). - In the same project and environment. Any other address, including one
from another organization, is 404
not_found.
When an approval is refused
| Reason | Meaning |
|---|---|
request_consumed | The request was already approved or rejected. Requests are single use; if two checkers approve at once, one runs it and the other gets this. |
request_expired | More than 72 hours passed since the maker's call. The maker repeats the call to ask again. |
request_stale | The resource changed since the maker asked (for example the model's evaluation report was replaced, the contract draft was edited, or an override now finds another fingerprint mismatch set than the request shows). The maker asks again so the checker sees what will actually run. |
maker_revoked | The API key that made the request was revoked, or the maker is no longer a member. The maker asks again with a live credential. |
A refusal from the operation itself (for example a gate that blocks) is
returned unchanged and leaves the request pending. A retry of an approval
with the same Idempotency-Key returns the first answer.
Turning a point off
Raising the policy is one admin's call; weakening it is not. A PUT that turns
any enabled point off is held like any other guarded call (operation: putDualControlPolicy, resource_id: the project id), and a second admin
applies it by approving the request.
Recovery: a project can never lock itself out
Only the operations in the table above are held. Inviting members, changing roles and creating custom roles are not, so an organization owner can always:
- invite a second person as admin (or grant a custom role with
dual_control.requests.approveand the held operation's permission); and - have that person approve the waiting request, including a request to turn a point off.
If the only owner has left the organization, recover the owner account through your identity provider first: the product has no way around the policy.
Not covered
- There is no console screen for the policy or the pending requests yet; use the API.
- The policy is set per project; there is no organization-wide default.