Skip to main content

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_by is 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_records is 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. DecisionRecordContent is a single closed interface with a fixed field set and no discriminator (src/domain/decision-records/types.ts:229-284), and sealDecisionRecord accepts only an existing VerdictResult (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_id and no workflow_run object; the only carrier is deployment_callback_url. GitHub documents no format for it, but ours is pinned against a captured delivery (2026-07-28, fixture in sv0-platform): https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule. run_attempt and workflow identity cost one extra GET /actions/runs/{run_id}. deployment is 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-71 re-resolves current main HEAD after the triggering run, and deploy-prod.yml:6, :55 takes inputs.image_tag from the operator. Binding the approval to a SHA alone approves something other than what ships (Decision 6).

Decision

  1. gate_decisions is 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, the ApprovalPayload (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
    * → unbound

    received is written to the durable inbox before the 2XX (Decision 4a); dispatching is leased. overdue is past our deadline and is still a holding state (ADR-037 Decision 4); timed_out_by_github is GitHub's own 30-day timeout failing the job; superseded is a later delivery for the same deployment displacing this one; unbound is 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.

    • *_authorized means a decision is committed; it does not mean GitHub has been told. The split is what makes a crashed dispatcher recoverable without re-deciding.
    • dispatching is 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.
    • ambiguous is a first-class terminal-pending state, not an error string. A POST timeout or a 4xx does not prove the deployment is still held; ambiguous is 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_requested remains a third disposition off held: it keeps the hold in place, posts a status report on the run, calls neither approve nor reject, returns to held, 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.
  2. ApprovalPayload is the contract; the hash is only its fingerprint. It is the complete immutable object rendered to the approver, stored verbatim on the gate_decisions document, and re-rendered byte-for-byte if the hold is re-presented.

    FieldSourceIn 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 lookupyes
    artifact_digestresolved before the hold (Decision 6)yes
    subject — the workload/agent identity being gated, and the enforcement pointADR-039 mapper inputyes
    named_authority_deltasrc/rehearsal/engine.ts:1299-1315yes
    computed_outcome (Approve / Constrain / Reject / Unknown)ADR-039 mapperyes
    policy_row_id, policy_version, threshold_setgate_policiesyes
    mapper_version, rehearsal_engine_versionconstantsyes
    freshness_class (quantized band)ADR-038yes
    per-source_system raw last_synced_atADR-038noCORRECTED 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 compared
    coverage_factors (control-subtraction, correlation-asymmetry, bridge coverage)src/rehearsal/coverage.tsno, as a structureCORRECTED 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 returns Unknown rather than proceeding. | approval_owner_set (primary + fallback, immutable user ids) | gate_policies | yes | | rehearsal_projection_hash | ADR-034 item 2 — the verdict with as_of and 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_hash is not a field of this payload at all. It is stored beside the payload on the gate_decisions document, as evidence and for drift labelling only (ADR-034 items 2 and 4). The payload carries rehearsal_projection_hash instead, and the reason is a rule that governs the whole table: no field inside approval_content_hash may be derived from an excluded field. A nested hash re-imports its entire preimage, so rehearsal_verdict_hash — computed over a verdict that contains as_of and 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.

  3. decision_records is 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 on released / 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 at release_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 _id back onto the gate_decisions document. Nothing is sealed while a hold is in flight; changes_requested seals 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_installations holds App installation and per-environment binding. gate_policies holds 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:

    StoreCollections
    Platform Mongogate_decisions, gate_installations, gate_policies, decision_records
    The response adapter's own separately credentialed store (ADR-035 Decision 3)gate_inbox, gate_dispatch

    The 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 inside gate_decisions; that is retired.

    confirmed_by stays fenced until its integrity mechanism ships. For the pilot the human confirmation is recorded on the gate_decisions document (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 at src/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.

  4. 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-drives received records 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_decisions carries a version; every transition is a single atomic findOneAndUpdate matched on both the expected version and 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 a release_all drain — can both read held and both commit. The shipped precedent to follow is src/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. Once release_authorized or refusal_authorized is committed, no transition may move the record to the other. In particular the kill switch loses to an already-committed human terminal decisionrelease_all and hold_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's gate_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 to ambiguous and pages rather than being retried into a double decision.

  5. 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_policies are 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.

  6. 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.

  7. The sealed-record widening is a versioned discriminated record_kind, and it costs 0.6 lane-weeks [assumed]. DecisionRecordContent is closed and sealDecisionRecord takes only a VerdictResult, 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_kind discriminator to the sealed content with a gate_decision_v1 variant 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 shipped verdict path.
    • 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 assume VerdictResult.

    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.

  8. 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_url format, and whether a re-run re-fires deployment_protection_rule or reuses a prior approval — it re-fires, run_attempt increments, 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 of deployment and pull_requests; callback retry and idempotency behaviour under a repeated POST; whether an approval already posted is visible via GET .../runs/{run_id}/approvals in 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 its context_hash / valid_until / stale pattern instead.
  • Put the hold state only in the decision record. Rejected — records are insert-only by contract, and a hold has a mutable lifecycle (receivedcomputinghelddispatchingreleased), and a per-deployment lifecycle inside a workload-keyed chain contends on every transition.
  • Give gate_decisions its own keyed-MAC chain and skip decision_records entirely. 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 held and 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 ambiguous when the state cannot be established (Decision 4(d)).
  • Claim no widening needed on 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 and run_attempt increments, 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 every deployment_protection_rule delivery as a fresh decision, keeps run_attempt in 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_failed and ambiguous exist because a single-replica happy path hides all three, and the pilot will not run single-replica forever. ambiguous is an operational page with a runbook, not a status badge.
  • Deep-linking a reviewer is missing today: the Promotion tab has no URL parameteragentMode is useState (ui/src/pages/DeploymentRehearsalPage.tsx:124) and searchParams.get appears once, for role (:173). A check-run link needs either ?mode=promotion&promotion_agent_id= or a rehearsal/gates/:id route. 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/verdict computes a disposition and does not accept a human choice. The changes_requested disposition in Decision 1 is the gate's instance of that triple.
  • Retired names. Earlier drafts in this family used GateHoldDoc, deployment_holds and gate_bindings. They are retired: any document or issue still using them is describing a design that was not adopted. Also retired: evaluating as a state name (it is computing), verification_pending / verified / drifted as lifecycle states (verification is the separate verification field in Decision 1), and the inbox and dispatch queue as sub-documents of gate_decisions (they are gate_inbox and gate_dispatch in the adapter's own store).