ADR-036: The approval object: gate_decisions owns the lifecycle, decision_records owns the audit artifact
Status: Proposed. Extends sv0-platform#1780. The confirmed_by tamper-evidence mechanism is a
platform-wide integrity item filed separately; it does not gate a T1 advisory release. It does
gate the first T2 hold on a production approval (Decision 3). Consequence for the pilot: the approval is
recorded, not attested with respect to who confirmed — say so wherever the approval is presented.
Separately, and not covered by that caveat: who is permitted to confirm is enforced server-side
before the first T2 release (Decision 5). Recorded-not-attested is a statement about attribution
integrity, never a licence to skip authorization.
Context
The gate needs an object that exists from "GitHub asked" to "we answered and verified", survives a
restart, and is the thing a human confirms. Today the platform has three near-misses and no fit:
MitigationActionDoc is a 7-status remediation-task tracker with free-text external_ticket_id and no
integration client; AttestationDoc (accepted / accepted_with_risk / … + context_hash +
valid_until) is the closest analogue but is finding/path-scoped with no callback; DecisionRecordDoc
is the right integrity object but is chained per (tenant_id, workload_id).
Three facts constrain the design, all verified:
DecisionRecordDoc.confirmed_byis spec-only: always null, zero populate paths, and the type doc states it must NOT be populated until it gains its own tamper-evident mechanism (src/services/verdict-engine.ts:1308;src/domain/decision-records/types.ts:321-328). A gate approval is exactly a confirmed production approval — this field is the one the gate wants and it is fenced.- The chain is not hash-chained.
decision_recordsis insert-only, pointer-linked, with an unkeyed integrity checksum — the preimage carries the predecessor's_id, not its hash (src/evidence/integrity.ts:42-59). The module's own doc-comment is the governing statement: "this is an UNKEYED checksum, not a cryptographic authenticator … a write-capable actor who recomputes hashes after editing defeats it; a keyed MAC or signature plus predecessor-hash chaining is the documented future step before decision records back a confirmed production approval" (src/evidence/integrity.ts:25-36). A T2 gate hold is that use case by definition. Nothing in this family may call the gate's audit artifact hash-chained, cryptographically authenticated, or tamper-evident against a write-capable actor. - The sealer cannot take a gate record as it stands.
DecisionRecordContentis a single closed interface with a fixed field set and no discriminator (src/domain/decision-records/types.ts:229-284), andsealDecisionRecordaccepts only an existingVerdictResult(src/services/verdict-engine.ts:1267). An earlier draft of this family claimed no widening was needed; that claim was false and is corrected in Decision 7. - The natural key mismatch. Records chain per workload with unique indexes
(
src/storage/mongo/schema.ts:216-221); a gate fires per deployment, concurrently across repositories. Putting a per-deployment hold lifecycle inside that chain makes two concurrent deploys of one workload contend on every state transition — which is precisely why the lifecycle does not live there (Decision 1). - The webhook payload contains no
run_idand noworkflow_runobject; the only carrier isdeployment_callback_url. GitHub documents no format for it, but ours is pinned against a captured delivery (2026-07-28, fixture insv0-platform):https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule.run_attemptand workflow identity cost one extraGET /actions/runs/{run_id}.deploymentis nullable and will null-deref naive parsers. - GitHub does not redeliver a successful delivery. Only failed deliveries are redeliverable. So a 2XX returned before the event is durably stored is an event lost permanently, with a deployment left held until GitHub's 30-day timeout fails the job. This, not throughput, is why the receiving adapter needs a durable inbox of its own (Decision 4; ADR-035 Decision 3).
- The commit GitHub reports is not necessarily the artifact deployed. Our own workflows prove it:
deploy-azure-staging.yml:67-71re-resolves currentmainHEAD after the triggering run, anddeploy-prod.yml:6, :55takesinputs.image_tagfrom the operator. Binding the approval to a SHA alone approves something other than what ships (Decision 6).
Decision
-
gate_decisionsis the lifecycle object — a new collection, one document per deployment hold, on the hot path and concurrent across repositories. It is keyed on the deployment:(tenant_id, installation_id, repository_id, environment_name, run_id, run_attempt), and carries the head SHA, the immutable artifact digest (Decision 6), callback URL, received-at, our decision deadline, the confirming human, theApprovalPayload(Decision 2) and the hashes from ADR-034 item 2. All state transitions live here and nowhere else. The state machine separates deciding from delivering, because the two fail differently. This union is the contract — every other document in this family renders exactly it, no more and no less:received → computing → held
held → changes_requested → held
held → release_authorized | refusal_authorized
release_authorized | refusal_authorized → dispatching → released | refused
dispatching → delivery_failed | ambiguous
held → overdue | timed_out_by_github | superseded
* → unboundreceivedis written to the durable inbox before the 2XX (Decision 4a);dispatchingis leased.overdueis past our deadline and is still a holding state (ADR-037 Decision 4);timed_out_by_githubis GitHub's own 30-day timeout failing the job;supersededis a later delivery for the same deployment displacing this one;unboundis reachable from any state when the installation or environment binding is lost.Verification is not a lifecycle state. Whether what ran matches what was approved is a separate field on the same document —
verification: "pending" | "verified" | "drifted"— kept off the lifecycle precisely so a verification outcome can never overwrite a terminal disposition.*_authorizedmeans a decision is committed; it does not mean GitHub has been told. The split is what makes a crashed dispatcher recoverable without re-deciding.dispatchingis leased: a worker claims the record atomically with an expiring lease, and only the lease holder may POST. Multiple replicas polling the same queue is the expected deployment shape, not an edge case.ambiguousis a first-class terminal-pending state, not an error string. A POST timeout or a 4xx does not prove the deployment is still held;ambiguousis where a hold sits until reconciliation resolves it, and it pages.- Refusal has the same lifecycle as release, symmetrically. An undelivered refusal is exactly as dangerous as an undelivered release — the run stays waiting and eventually fails on GitHub's clock with no record of why.
changes_requestedremains a third disposition offheld: it keeps the hold in place, posts a status report on the run, calls neither approve nor reject, returns toheld, and seals nothing. It is the disposition sv0-platform#1852 names alongside approve and reject. Which computed verdicts may reach which of these dispositions is an open working-session decision — see the matrix in ADR-039.
-
ApprovalPayloadis the contract; the hash is only its fingerprint. It is the complete immutable object rendered to the approver, stored verbatim on thegate_decisionsdocument, and re-rendered byte-for-byte if the hold is re-presented.Field Source In approval_content_hash?tenant_id,installation_id,repository_id,environment_namebinding ( gate_installations)yes run_id,run_attempt,workflow_path,workflow_refGET /actions/runs/{run_id}yes head_shawebhook + run lookup yes artifact_digestresolved before the hold (Decision 6) yes subject— the workload/agent identity being gated, and the enforcement pointADR-039 mapper input yes named_authority_deltasrc/rehearsal/engine.ts:1299-1315yes computed_outcome(Approve / Constrain / Reject / Unknown)ADR-039 mapper yes policy_row_id,policy_version,threshold_setgate_policiesyes mapper_version,rehearsal_engine_versionconstants yes freshness_class(quantized band)ADR-038 yes per- source_systemrawlast_synced_atADR-038 no — CORRECTED 2026-07-25. Raw timestamps move on every sync, including syncs that make the data fresher. Comparing them refuses valid releases for a reason unconnected to the deployment — the identical argument this ADR already uses to exclude as_of. Sealed in the transcript, rendered to the approver, never comparedcoverage_factors(control-subtraction, correlation-asymmetry, bridge coverage)src/rehearsal/coverage.tsno, as a structure — CORRECTED 2026-07-25. The #1891 variance probe measured coverage as the most volatile structure in the verdict: a 30-day unrelated re-scan crosses the ingest-staleness threshold for every other source, inserts a factor, shifts every later array index and rewrites the statement array — 43 paths on revocation, with no change to any reach, pairing or grant. What is compared is an explicit allowlist of stable decision-semantic factor codes (those that change the outcome), keyed by code rather than array position. The exact prose the approver read is sealed in the transcript The allowlist is closed-world. A factor code that is not on the versioned allowlist and that can affect the decision must force
Unknown— it must never be silently dropped to transcript-only. Otherwise the risk explanation can change materially while the computed outcome stays identical, and a human confirms against evidence that no longer matches what they read. Requires: an explicit versioned allowlist; an exhaustiveness test over every factor that can affect the outcome; and runtime behaviour where an unknown decision-affecting factor, or an allowlist version skew between hold and confirm, holds or returnsUnknownrather than proceeding. |approval_owner_set(primary + fallback, immutable user ids) |gate_policies| yes | |rehearsal_projection_hash| ADR-034 item 2 — the verdict withas_ofand both tenant-wide totals removed | yes | |as_of|deriveAsOf, tenant-wide | no — evidence and drift label only | |totals.entities_materialized,totals.baseline_paths| tenant-wide counters | no — same reason |rehearsal_verdict_hashis not a field of this payload at all. It is stored beside the payload on thegate_decisionsdocument, as evidence and for drift labelling only (ADR-034 items 2 and 4). The payload carriesrehearsal_projection_hashinstead, and the reason is a rule that governs the whole table: no field insideapproval_content_hashmay be derived from an excluded field. A nested hash re-imports its entire preimage, sorehearsal_verdict_hash— computed over a verdict that containsas_ofand both totals — would smuggle the excluded volatility back into the compared hash and make the exclusions above meaningless. Any digest or fingerprint added to this payload later must be computed over the same projection the payload uses.The exclusions are not cosmetic: they are tenant-wide values that move on ingest unrelated to this deployment, and including them refuses releases for reasons that have nothing to do with the decision (ADR-034 Context). Adding a field to this table is a versioned change to
ApprovalPayload, never a silent one. -
decision_recordsis the audit artifact, and the gate seals exactly one summary row into it — at the moment the human's disposition is committed. CORRECTED 2026-07-25. This previously said sealing happens onreleased/refused— after GitHub has accepted the call — and that a sealing failure leaves the deployment held. Those two statements cannot both be true: once GitHub has accepted a release, no subsequent failure on our side can restore the hold, so a seal that runs after delivery can fail with the release already gone and no audit row written. The seal therefore commits atrelease_authorized/refusal_authorized, in the same platform-Mongo transaction as the human disposition and the immutable dispatch intent (#1911 G3). GitHub's delivery outcome is a later operational fact recorded against the already-sealed row, not a precondition of sealing. What is sealed is what the human decided, which is knowable at that instant; whether the provider accepted it is a separate, retryable question. On that commit the gate seals one row into the existing #1780 chain, carrying the deployment identity, all three hashes from ADR-034 item 2 (approval_content_hash,rehearsal_projection_hash,rehearsal_verdict_hash), the policy row id and the confirming human, and stamps that record's_idback onto thegate_decisionsdocument. Nothing is sealed while a hold is in flight;changes_requestedseals nothing. Because the per-deployment lifecycle never touches the chain, the hold path has no chain contention at all — the split is what removes it, so this ADR carries no gate-specific retry design. The terminal write uses the shipped chain semantics unchanged; a seal that cannot be written leaves the deployment held and raises an operational alert, never a release without a record.gate_installationsholds App installation and per-environment binding.gate_policiesholds one document per (tenant,repository_id, environment) carrying the enforcement tier, the threshold table, the decision deadline and the kill-switch state; the gate seals the policy row id into the summary row so a decision can be read back against the policy that produced it.Storage vocabulary, in full, and it spans two stores:
Store Collections Platform Mongo gate_decisions,gate_installations,gate_policies,decision_recordsThe response adapter's own separately credentialed store (ADR-035 Decision 3) gate_inbox,gate_dispatchThe adapter receives the webhook and therefore owns the durable inbox and the dispatch/lease records; they are its state, not embedded sub-documents of
gate_decisions, because an adapter that must ask the platform for them is exactly the adapter that cannot function when the platform is unreachable. What the adapter still holds is no handle to the platform's tenant graph. An earlier draft of this family placed the inbox insidegate_decisions; that is retired.confirmed_bystays fenced until its integrity mechanism ships. For the pilot the human confirmation is recorded on thegate_decisionsdocument (confirmed_by_user_id,confirmed_at,confirmation_channel), and the sealed record references it; the type doc requires the field to gain its own tamper-evident mechanism before any code path populates it (src/domain/decision-records/types.ts:321-328). Budget that mechanism — keyed MAC or signature plus predecessor-hash chaining — as substrate work, not as a field rename, and treat it as a named prerequisite before a T2 hold backs a production approval, per the module's own statement atsrc/evidence/integrity.ts:25-36. Until it ships, the honest description of the artifact is insert-only, pointer-linked, unkeyed integrity checksum: it detects corruption, cross-tenant or cross-workload transplant and re-parenting, and it does not detect a write-capable actor who recomputes the hashes. -
Reliability substrate — durable inbox, versioned CAS, leased dispatch. This is a prerequisite of T2, not a hardening pass. Three defects with one root: the lifecycle above has no crash or concurrency safety.
a. Durable inbox before the ACK, and the adapter is what receives. The response adapter (ADR-035) holds the App credential, validates the delivery HMAC, and returns 2XX only after the delivery is durably written to its own
gate_inbox(or a durable platform acceptance is confirmed). GitHub does not redeliver a delivery it considers successful, so an ACK-then-crash loses the event permanently. A sweeper re-drivesreceivedrecords that have no evaluation job. The test is a process kill on both sides of the ACK/enqueue boundary, not a unit test of the handler. b. Every transition is compare-and-swap.gate_decisionscarries aversion; every transition is a single atomicfindOneAndUpdatematched on both the expectedversionand the allowed prior state, returning null → 409 → re-read. The unique natural key deduplicates creation only; without CAS, a Release and a Refuse — or an owner's release racing arelease_alldrain — can both readheldand both commit. The shipped precedent to follow issrc/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-55(optimistic-locked update, null on version mismatch, caller surfaces 409). c. A terminal disposition is a one-way lock. Oncerelease_authorizedorrefusal_authorizedis committed, no transition may move the record to the other. In particular the kill switch loses to an already-committed human terminal decision —release_allandhold_all(ADR-037) drain only records that have not reached an authorized disposition. An operational switch must never overwrite a decision a named human already made. d. Dispatch is leased and reconciliation-first. A dispatcher claims an authorized record with an atomic claim and an expiring lease, recorded in the adapter'sgate_dispatch, before any POST. Before every retry it reads GitHub's current approval state (GET .../runs/{run_id}/approvals) and reconciles — it never blind-retries a POST that may already have landed. Lease expiry returns the record to the queue; a POST whose outcome cannot be established goes toambiguousand pages rather than being retried into a double decision. -
Who may approve is enforced server-side, before the first T2 release. This was previously written as a Track 2 nicety on the grounds that no gate route carries
permissionMiddleware/Permission.*today (grep across rehearsal, verdict and mitigation routes: zero matches) and authorization is tenant membership plus a per-tenant feature flag. That is an accurate description of today and an unacceptable design for a production release gate: the demo's whole differentiator is that the workload owner approves without GitHub access, which is a claim about authorization, not about routing. Required before the first T2 release:- the human-confirmation route rejects every M2M and delegated-agent identity. Machine principals
carry no membership and early-return from the membership middleware — their authorization derives
from token scopes and the route handler must enforce it itself
(
src/api/middleware/auth-middleware.ts:375-390). A confirmation route that only checks membership is open to any M2M token; - the primary and fallback approver sets in
gate_policiesare resolved to immutable user ids — never email, never display name — and enforced at the route; - self-approval is blocked where the selected policy requires it.
The pilot may still describe the approval as recorded, not attested with respect to
confirmed_by(Decision 3). Who may approve is enforced regardless — the two are different properties and must not be traded against each other. - the human-confirmation route rejects every M2M and delegated-agent identity. Machine principals
carry no membership and early-return from the membership middleware — their authorization derives
from token scopes and the route handler must enforce it itself
(
-
Bind the artifact, not just the commit. The immutable artifact digest — the image digest for a container deploy, the equivalent content-addressed identity otherwise — is resolved before the hold is created, stored in the
ApprovalPayload, and the gated job is required to deploy exactly that digest. Digest, workflow, run, attempt and environment travel together; a SHA alone is not the deployed thing (Context). This is artifact binding, not image scanning — we assert identity between what was approved and what ships, and we assert nothing about the contents of the image. Image scanning remains an explicit non-goal; do not let the digest field read as one. -
The sealed-record widening is a versioned discriminated
record_kind, and it costs 0.6 lane-weeks [assumed].DecisionRecordContentis closed andsealDecisionRecordtakes only aVerdictResult, so a gate record cannot be sealed today. Decided here, not deferred — this is an engineering call about a schema shape, not a product question, so it does not go to the working session:- Adopted: a versioned discriminated record kind. Add a
record_kinddiscriminator to the sealed content with agate_decision_v1variant carrying gate-specific sealed content, and a sealer entry point that accepts it. Every sealed record stays self-describing and the verifier can reject an unknown kind loudly. Costs a migration-visible schema-version bump and touches the shippedverdictpath. - Rejected: a generic sealed-envelope primitive. Extracting the integrity mechanism
(
src/evidence/integrity.ts) behind a content-agnostic envelope leaves the shipped verdict content shape untouched, but it gives up the verifier's ability to reject content it does not understand — which is the property worth paying for in an audit artifact — and it still costs a new primitive plus the re-verification tests that today assumeVerdictResult.
Price: 0.6 lane-weeks [assumed], and the implementation plan carries the same figure for this line so the two do not drift. That is work the published estimate showed as free, so that estimate is an undercount — say so, and re-estimate rather than adjusting the number. The keyed-MAC/signature plus predecessor-hash-chaining work in Decision 3 lands in the same schema change and should be sequenced with it.
- Adopted: a versioned discriminated record kind. Add a
-
Empirical spike before the data model is frozen (throwaway private Enterprise repo). Two of its questions are answered by the delivery captured on 2026-07-28 and recorded on the dispatch-attempt issue (sv0-platform#1920): the exact
deployment_callback_urlformat, and whether a re-run re-firesdeployment_protection_ruleor reuses a prior approval — it re-fires,run_attemptincrements, and the URL is byte-identical across attempts (see Consequences). Still to answer: what happens when two runs wait on one environment; the nested shape ofdeploymentandpull_requests; callback retry and idempotency behaviour under a repeated POST; whether an approval already posted is visible viaGET .../runs/{run_id}/approvalsin the form reconciliation needs; and how the exact artifact identity is obtained before the hold.
Alternatives considered
- Reuse
MitigationActionDoc. Rejected — it is a task tracker, its external linkage is free text, and #1611's constraint ("we execute nothing") stops being true the moment a response adapter exists. - Reuse
AttestationDoc. Rejected as the lifecycle object; borrow itscontext_hash/valid_until/stalepattern instead. - Put the hold state only in the decision record. Rejected — records are insert-only by contract, and a
hold has a mutable lifecycle (
received→computing→held→dispatching→released), and a per-deployment lifecycle inside a workload-keyed chain contends on every transition. - Give
gate_decisionsits own keyed-MAC chain and skipdecision_recordsentirely. Rejected: it builds a second audit mechanism beside the sealed chain that already ships (#1780), and the auditable artifact is exactly the thing that should not be bespoke to the gate. Note this is a rejection of a second mechanism, not of keyed MACs — the keyed mechanism is wanted, in the shipped chain, per Decision 3. - Key the chain on the deployment instead of the workload. Rejected for slice 1: it changes shipped indexes and the semantics of every existing record.
- Rely on the unique natural key for transition safety. Rejected — it deduplicates creation only.
Two different terminal decisions on one existing hold both read
heldand both commit. Decision 4(b). - Retry the GitHub POST idempotently and treat a 4xx as "still held". Rejected — a timeout or a 4xx
does not prove the state of the deployment, and blind retry can produce a second decision. Reconcile
first, and use
ambiguouswhen the state cannot be established (Decision 4(d)). - Claim
no widening neededon the sealed record. Not an alternative — a factual error in an earlier draft, corrected in Decision 7 and recorded so the estimate is re-run rather than quietly kept.
Consequences
- Re-run semantics are still undocumented by GitHub, but no longer unknown to us. The delivery
captured on 2026-07-28 (fixture committed in
sv0-platform, recorded on the dispatch-attempt issue, sv0-platform#1920) shows a re-run re-holding: a fresh delivery arrives andrun_attemptincrements, so a prior approval is not silently reused. The same capture shows the residual hazard — the callback URL is byte-identical across attempts, so the URL alone cannot say which attempt a decision will land on. The pilot therefore treats everydeployment_protection_ruledelivery as a fresh decision, keepsrun_attemptin the natural key, and re-reads the run's current attempt before dispatching (ADR-034 item 3 covers the detection). - The reliability substrate (Decision 4), the approver enforcement (Decision 5) and the sealed-record widening (Decision 7) are all prerequisites of T2 and none of them were in the published estimate. That estimate is therefore an undercount. Re-estimate with them included; do not adjust the figure in place. Sequence: substrate before any T2 hold, per the re-sequenced plan.
- The audit artifact the gate produces is insert-only, pointer-linked and unkeyed. Every surface that presents a gate decision — status report, UI, exported record — must describe it that way. It detects corruption, transplant and re-parenting; it does not withstand a write-capable actor. Wording that claims otherwise is a defect, not a copy preference.
- The state machine is wider than a demo needs, deliberately.
dispatching,delivery_failedandambiguousexist because a single-replica happy path hides all three, and the pilot will not run single-replica forever.ambiguousis an operational page with a runbook, not a status badge. - Deep-linking a reviewer is missing today: the Promotion tab has no URL parameter —
agentModeisuseState(ui/src/pages/DeploymentRehearsalPage.tsx:124) andsearchParams.getappears once, forrole(:173). A check-run link needs either?mode=promotion&promotion_agent_id=or arehearsal/gates/:idroute. This is gate substrate, not polish. - Best home for the pending-approvals surface: a sibling route under
rehearsal/in the Governance nav group, next to the existing Deployment Rehearsal entry (ui/src/components/Layout.tsx:146). - Do not file this as a new "record the human disposition" issue — sv0-platform#1852 already covers it
and documents that today's
POST /entities/:id/verdictcomputes a disposition and does not accept a human choice. Thechanges_requesteddisposition in Decision 1 is the gate's instance of that triple. - Retired names. Earlier drafts in this family used
GateHoldDoc,deployment_holdsandgate_bindings. They are retired: any document or issue still using them is describing a design that was not adopted. Also retired:evaluatingas a state name (it iscomputing),verification_pending/verified/driftedas lifecycle states (verification is the separateverificationfield in Decision 1), and the inbox and dispatch queue as sub-documents ofgate_decisions(they aregate_inboxandgate_dispatchin the adapter's own store).