Skip to main content

GitHub Deployment Gate — implementation plan

Umbrella: SecurityV0/sv0-platform#1884 · Sibling feasibility issue: #1883 · Docs PR: SecurityV0/sv0-documentation#445 Status: committed build (Ivan, CTO, 2026-07-24). The use case is not selected; the build is. Author date: 2026-07-24

This is the engineering plan for work we are doing. Nothing here is conditional on the working session except the items explicitly marked Track 2. The gate runs in parallel with the Logic Apps trajectory shadow work and does not replace the Trajectory direction; both consume the same Authority foundation.

Every repository claim carries path:line. Provider claims cite docs.github.com. Anything that does not exist is marked DOES NOT EXIST. Effort is in lane-weeks (one work lane, one calendar week), each labelled measured / documented / assumed. Package manager in sv0-platform is npm.


0. Facts measured today (2026-07-24) that change the plan

Run against the live GitHub API with the keychain gh login. These are measured, not assumed.

FactValueCommandConsequence
SecurityV0 org planenterprisegh api orgs/SecurityV0 --jq .plan.nameThe hard prerequisite ("custom deployment protection rules in private or internal repositories require GitHub Enterprise", availability reusable on Configure custom protection rules) is already satisfied for our own org. The empirical spike and the dogfood phase needs no procurement and no partner.
SecurityV0/sv0-platform visibilityPRIVATEgh repo view --json visibilityDogfooding exercises the exact private-repo Enterprise path a customer would use.
Existing environmentsdev (branch_policy), prod (branch_policy), staging (no rules)gh api repos/SecurityV0/sv0-platform/environmentsA gated environment exists to extend; staging is the natural T1 Advisory target because it carries no protection rules today.
Custom protection rules installedtotal_count: 0gh api repos/.../environments/dev/deployment_protection_rulesGreenfield. No App conflicts, no 6-rule ceiling pressure.
Available rule integrationstotal_count: 0.../deployment_protection_rules/appsNo App is installed on the org yet — P1 registers the first.
Our own OIDC is already environment-scopedfederated subject pinned to repo:SecurityV0/sv0-platform:environment:dev.github/workflows/deploy-azure-dev.yml:10-13The credential-isolation pattern the pilot requires (StringEquals on an environment-scoped sub) is already how we deploy ourselves. We can prove it rather than describe it.

Therefore Phase 0 and Phase 1 have zero external preconditions and start today.


1. Scope and non-goals

1.1 In scope

A bounded enforcement proof with a named human in the loop:

  1. A GitHub App delivers deployment_protection_rule for a gated environment and the deployment is held. The adapter is the receiver — it holds the App credential, validates the HMAC, and lands the raw delivery in its own durable gate_inbox before acknowledging (§5.1); the platform's src/api never terminates a GitHub webhook. Before the hold is recorded, the immutable image digest the run will deploy is resolved and stored (§5.2a) — the commit GitHub reports is not the artifact that ships.
  2. The platform binds that deployment to a tenant, runs the shipped rehearsal engine, computes a deterministic named authority delta, and records a gate decision; at the terminal decision it seals exactly one summary row into the shipped, insert-only, pointer-linked decision-record log (§5.1).
  3. A named human — and only an approver the server has authorised (§7.2) — sees the delta in the product and confirms or refuses. Approve releases only after that confirmation; Constrain, Reject and Unknown stay held — grounds are the unconditional control-subtraction and correlation-asymmetry invariants at src/rehearsal/coverage.ts:458-493.
  4. A separately permissioned response adapter — the only component holding GitHub write scope — posts the decision back to GitHub.
  5. We verify the result four ways (§4 P6): the decision landed (GitHub's own approvals API), the estate landed as projected (post-deploy re-ingest), the gate itself still exists (protection-rule poll), and the approved artifact is the one that shipped (digest comparison). All four write the verification field; none of them can move a terminal disposition (§5.2).

1.2 Non-goals — explicit, and enforced by review

Non-goalWhyEnforcement
Automatic approval across arbitrary repos/workflowsThe gate binds to an explicit, operator-authored (installation, repository, environment) → tenant allowlist. An unbound installation never produces a decision.gate_installations lookup is fail-closed (§4 P2); unknown binding → ACK, record, no decision, alert.
A generic provider-adapter frameworkWe are shipping one enforcement point. Logic Apps and container workloads reuse the field contract (§2.4), not a plugin runtime.The enforcement_point union has exactly one member until the second consumer exists. A second member may only be added by the issue that ships it.
Image / artifact / container scanningNot our capability and not our claim. We reason over ingested identity and authority state, never over the deployment artifact's contents.No artifact is downloaded by the adapter or the platform. Artifact binding is not artifact scanning: we resolve and record the immutable image digest so the approval names exactly one artifact (§5.2a). We never read what is inside it.
Turning the platform into an inline gatewayThe platform is never on the request path of a customer's deployment. GitHub holds; we answer asynchronously; if we are absent, GitHub's own timeout governs.The adapter has no synchronous compute in the webhook handler (ACK < 10 s, best practices). No platform component proxies deployment traffic.
Writing to any source system on the connector pathHard constraint. deployments: write lives only in the response adapter.ESLint import boundary + isolation test (§4 P1), mirroring the shipped src/mcp pattern (eslint.config.js:55-110, test/mcp/isolation.test.ts).
A second verification mechanismSergey's instruction on #1568.The verification leg extends #1568; we file no parallel drift mechanism.
Rehearsal persistence as a browsable store§5.4. Decided, with a named revisit trigger. No overlay entity collection, no re-renderable pinned cone, no rehearsal-to-rehearsal comparison, no temporal replay.There is no rehearsal-store work item in either track. The gate decision record is a strict subset of the persistent shape, so the option stays open at zero rework.
A second audit or integrity mechanism§5.1. One sealed log, the shipped one.gate_decisions holds no integrity hash of its own and no MAC key; the only integrity-checked artifact is the terminal decision_records row, and that row is insert-only, pointer-linked, with an unkeyed integrity checksum — it is not tamper-evident against a write-capable actor (§5.1).

1.3 Language rules that bind this document and everything downstream

  • This is a hold-and-release mechanism. We do not call it "control" until enforcement and verification are proven in the bounded scenario. npm run ci includes check:vocab (package.json:23scripts/check-banned-vocabulary.ts); add no banned term.
  • Determinism, evidence packs and the read-only connector model are build properties, not the pitch.
  • The banned sequence term is not used; say "the earlier steps of the sequence".

2. THE COMMITMENT SPLIT — the organising idea

2.1 Track 1 — use-case-independent substrate. Starts immediately.

#Substrate itemWhere it lands
T1-1GitHub App registration + webhook receiver + HMAC validation + <10 s ACKsrc/gate-adapter/ (new)
T1-2Separately permissioned response adapter holding deployments: writesrc/gate-adapter/ (new), separate process + container
T1-3Redelivery poller (GitHub does not auto-redeliver — handling failed deliveries)src/gate-adapter/ (new)
T1-4Protection-rule callback (approve / reject / status report)src/gate-adapter/ (new)
T1-5Deployment/artifact binding: the identity tuple that keys a gate decision, plus the resolved immutable image digest carried in the ApprovalPayload (§5.2a)src/domain/gate/ (new)
T1-6Gate API path + tenant isolation + installation→tenant bindingsrc/api/routes/gate.ts (new), src/api/routes/admin/gate-installations.ts (new)
T1-7Decision-record contract: gate_decisions owns the hold lifecycle; at the terminal decision exactly one summary row is sealed into the shipped decision_records log. Requires a versioned discriminated record kind — the existing sealer cannot accept a gate record as shipped (T1-26)src/domain/gate/types.ts, src/services/gate-seal.ts (new, wraps sealDecisionRecord), src/domain/decision-records/types.ts, src/services/verdict-engine.ts
T1-8Rehearsal invocation path as a worker jobsrc/workers/handlers/gate-evaluate.ts (new)
T1-9Deterministic delta mapper with thresholds as datasrc/gate/policy.ts (new) + gate_policies collection
T1-10Human approval surface (index + detail + confirm), with three dispositions: release / request changes / refuseui/src/pages/GateDecisionsPage.tsx (new), route rehearsal/gates
T1-11Deep-linkability of a specific rehearsal (does not exist today)ui/src/pages/DeploymentRehearsalPage.tsx
T1-12Enforcement tiers T1–T3 + per-environment configuration + kill switch (default hold_all)src/gate/policy.ts, gate_policies
T1-13Our own decision deadline — posts and escalates, never decides (GitHub documents only 0 s → 30 d)src/workers/handlers/gate-deadline.ts (new)
T1-14Verification of result: approvals read-back, rule-removal poll, post-deploy reconcile (extends #1568)adapter + src/workers/handlers/gate-verify.ts (new)
T1-15Honesty stamping — BUILD, not shipped: absolute per-source last_synced_at, rehearsal engine version, materializer flag setsrc/rehearsal/service.ts, src/rehearsal/types.ts
T1-16Three hashes, one comparison. rehearsal_verdict_hash over the complete verdict (evidence and drift labelling only, never inside the payload); rehearsal_projection_hash over the same verdict with the excluded volatile fields removed — the only rehearsal hash the payload carries; and approval_content_hash over the complete immutable ApprovalPayload (§5.2a), recomputed and compared at confirm — the anti-substitution mechanismsrc/gate/confirm.ts (new), src/gate/approval-payload.ts (new)
T1-17Bridge one-sidedness → held/Unknown wiringsrc/rehearsal/coverage.ts + src/ingestion/stitching/bridge-coverage.ts
T1-18Connector string fix: github_actions vs github external-system mismatchsrc/domain/system-classification/types.ts
T1-19ADR-034 … ADR-039 (the six-ADR map, §P9)sv0-documentation
T1-20Approval-owner resolution + notification + SecurityV0 queue placement — resolve the owner from the ownership graph, notify, queuesrc/gate/owner-routing.ts (new), reuses computeOwnershipStatus (src/ingestion/authority-path-materializer.ts:396) and createEmailService (src/services/email-service.ts:30)
T1-21"Request changes" third disposition in the gate_decisions state machine and the record contractsrc/domain/gate/types.ts, src/api/routes/gate.ts, src/gate-adapter/status-report.ts
T1-22List surface over sealed gate decisions ("every deployment we gated, its verdict, its evidence, who approved it")GET /api/v1/gate/decisions + ui/src/pages/GateDecisionsPage.tsx
T1-23Reliability substrate — durable inbox before ACK, versioned CAS on every transition, dispatch leases, durable outbox, reconciliation-first retries, received-with-no-job sweeper (§4 P1.5). Precedes any T2 hold.Adapter store (gate_inbox, gate_dispatch): src/gate-adapter/inbox.ts, src/gate-adapter/outbox.ts, src/gate-adapter/dispatch.ts (all new). Platform side (gate_decisions): src/storage/mongo/adapters/gate-adapter.ts, src/workers/handlers/gate-sweeper.ts (new)
T1-24Approver authorization — human-only confirmation route (rejects M2M and delegated-agent identities), immutable user IDs, server-side primary + fallback approver sets, self-approval prevention. T2 prerequisite, not Track 2.src/api/routes/gate.ts, src/gate/approver-policy.ts (new), gate_policies
T1-25Determinism repair{ last_confirmed_at: -1, _id: 1 } in both correlation adapters, kept mirrored; a total comparator with a guaranteed-unique last key on every array serialized into the verdict; permutation testsrc/storage/mongo/adapters/correlation-adapter.ts, src/rehearsal/in-memory-adapter.ts, src/rehearsal/engine.ts
T1-26Sealed-record widening — a versioned discriminated record kind so DecisionRecordContent (a closed union today) and sealDecisionRecord (accepts only a VerdictResult today) can carry a gate recordsrc/domain/decision-records/types.ts, src/services/verdict-engine.ts, src/services/gate-seal.ts
T1-27The pipeline being gated — a private promotion repository in the SecurityV0 org, its two GitHub environments (production-infra ungated, production gated), the two-phase workflow, artifact identity threaded from phase 1 to phase 2, and the StringEquals OIDC trust in en-workloads. Every other item in this table gates something; nothing in it creates the something. The demo document specified this shape (docs/demos/github-deployment-gate.md §1) and then carried it only as a T-2 days checklist line, which is how a piece of critical-path work goes unowned. Verified 2026-07-25: the org holds nine repos and none is a promotion pipeline; sv0-demo-labs has no github_repository terraform. Not the spike repo — #1885/#1900 is throwaway and its experiments perform ten writesnew repo in the SecurityV0 org; OIDC trust terraform in sv0-demo-labs/labs/enterprise-nimbus/

Track 1 additions — estimate and acceptance, stated per ruling. These four are the items whose absence would have made the demo's strongest claims promises. Each is small and each has a criterion that can fail. T1-23 through T1-26 are separately priced in §2.1a — they are not small, and they were not in the original roll-up.

ItemLane-weeks [assumed]Lands inAcceptance criterion
T1-16 three hashes + recompute-and-compare at confirm0.15 → 0.5, and this is an UNDERCOUNTP2 (confirm route) + P3 (stamping) + P2.5 (determinism)A confirm request whose recomputed approval_content_hash over the ApprovalPayload (§5.2a) differs from the stored one returns 409, releases nothing, and re-presents the current computation with the reason "the decision changed since it was computed; review and confirm again". Proven by three integration tests, and the first two are a pair the earlier draft could not have passed: mutating an entity inside the decision's own reach cone 409s; mutating an unrelated entity does not 409 — which is only true because the payload carries rehearsal_projection_hash (the verdict minus the excluded volatile fields) and never rehearsal_verdict_hash; and mutating only the policy document 409s. The original 0.15 priced a one-field hash over the raw rehearsal verdict; it did not price building the ApprovalPayload, excluding the volatile tenant-wide fields, computing the projection hash, or the ordering repair the hash depends on (T1-25).
T1-20 approval-owner resolution + notification + queue0.4P4 (queue + surface), P3 (resolution at evaluate time)For a gate decision on a subject with an OWNED_BY edge, content.approval_owner names the resolved owner, one notification is dispatched through the shipped transport (console in dev, SMTP where configured), and the decision appears in that owner's SecurityV0 gate queue. An unresolvable owner records resolution: "unresolved", notifies the tenant's operator address, and still queues — it never silently drops.
T1-21 "request changes" third disposition0.2P2 (contract + route), P4 (UI), P5 (status report)Selecting Request changes keeps state: "held", posts a status report on the run carrying the named reason, and calls neither the approve nor the reject callback — asserted against a mocked GitHub client that fails the test if POST .../deployment_protection_rule is called with a state.
T1-15 absolute per-source last_synced_at stamping0.25P3Every gate verdict carries, per contributing source_system, the absolute last_synced_at and an absolute age in days; the UI and the status report render the absolute value. Proven by a test where a uniformly 60-day-old graph — which raises zero relative-freshness warnings today (src/rehearsal/coverage.ts:40, anchored on asOf, not wall-clock) — renders "60 days old" on the gate surface.

T1-15 was already inside the P2/P3 estimates in §4.1. T1-21 is absorbed by P2/P4. T1-20 is net-new work and is added to the roll-up (§4.1): +0.2 to P3 (resolution) and +0.2 to P4 (notification and queue placement). T1-16 is no longer absorbed — see §2.1a.

2.1a Track 1 items added by the round-2 review — priced, not free

The adversarial review found four bodies of work the plan had shown as free. They are not free, they are not optional, and three of them are prerequisites for the T2 hold tier. Every figure below is [assumed] and calibrated on the same two measured comparables as §4 (PR #1767, PR #1785).

ItemLane-weeks [assumed]Lands inWhy it is not free
T1-23 reliability substrate — durable inbox before ACK, version + CAS on every transition, terminal-disposition lock, dispatch leases, durable outbox, reconciliation-first retries, received-with-no-job sweeper1.4new phase P1.5The lifecycle as drafted had no concurrency or crash safety. GitHub redelivers only failed deliveries, so a 2XX returned before a durable write loses the event permanently. Release-vs-refuse and owner-release-vs-release_all could both read held and both commit. Multiple adapter replicas polled the same release queue with no claim or lease. Each of these is storage-schema work plus a worker, plus the kill-tests that prove it (§6.2).
T1-24 approver authorization — human-only route, immutable user IDs, server-side primary + fallback approver sets, self-approval prevention0.7P2.7, before P7 dogfood at T2There is no "who may approve" primitive on tenant routes today; the plan recorded the owner without enforcing them. M2M principals skip membership middleware entirely — src/api/middleware/auth-middleware.ts:380 states it: "Machine principals (M2M / API key) never carry a membership — their authorization derives from the token's scopes … This middleware early-returns for machine auth." A route that does not itself reject machine principals will accept one.
T1-25 determinism repair — mirrored _id tie-break in both correlation adapters, total comparators on every serialized array, permutation test0.4P2.5canonicalSerialize sorts object keys but deliberately preserves array order (src/rehearsal/engine.ts:1552-1556 — "arrays preserve the engine's deterministic ordering (already sorted above)"). queryCorrelations sorts on .sort({ last_confirmed_at: -1 }) with no secondary key (src/storage/mongo/adapters/correlation-adapter.ts:60), and the in-memory adapter deliberately mirrors that omission (src/rehearsal/in-memory-adapter.ts:133-140: "No _id tie-break is added — the Mongo adapter has none"). Without this, recompute-and-compare (T1-16) produces false 409s and the approval_content_hash is not a stable fingerprint.
T1-26 sealed-record widening — versioned discriminated record kind with gate-specific sealed content0.6P2 (merge-blocking with ADR-036)The claim that no widening was needed is false. DecisionRecordContent is a closed interface (src/domain/decision-records/types.ts:229) and sealDecisionRecord(result: VerdictResult, opts) accepts only an existing verdict result (src/services/verdict-engine.ts:1281, options type at :1267). Widening touches the sealed-content type, the sealer, the integrity preimage, verifyDecisionRecordIntegrity, and every existing reader.

Chosen approach for T1-26: a versioned discriminated record kind, not a generic sealed envelope. DecisionRecordContent gains a record_kind discriminant (the gate variant is gate_decision_v1) with schema_version, and sealDecisionRecord is generalised to accept a discriminated sealed-content value rather than a VerdictResult. Rejected alternative: a generic sealed-envelope primitive over opaque bytes — it would make every existing reader's type narrowing unsound and would hide the gate's field set from the integrity preimage's own schema. The versioned kind keeps computeDecisionIntegrityHash (src/evidence/integrity.ts:42-59) unchanged and keeps existing records valid and readable.

This is decided and priced, not a recommendation (R3.4). It is ADR-036 Decision 7, the shape is the versioned discriminated record_kind, and the price is 0.6 lane-weeks [assumed] inside P2. It is an engineering decision, so it does not go to Sergey's working session, and no artifact may describe the sealed-record shape as still to be chosen or as one of two options.

Total added by round 2: 3.1 lane-weeks [assumed], plus the T1-16 re-pricing (+0.35). See §4.1 — these are the reason the earlier 7.3 / 4.8 figures were an undercount.

2.2 Track 2 — use-case-dependent last mile. Scoped after Sergey's working session.

#ItemWhy it waits
T2-1Which workload is held (which rehearsal mode the gate calls)The selection. Both modes are already served by one route, src/api/routes/rehearsal.ts:340.
T2-2Which authority delta triggers Constrain vs RejectThreshold values in the policy table — the values, not the table.
T2-3Envelope definition (envelope_kind + reference)Candidate A uses promotion_counterpart_maps; candidate C would need a declared delta.
T2-4Approval owner: which owner is named for the selected use caseThe mechanism — resolve from the ownership graph, notify, queue — is Track 1 (T1-20), and enforcement of who may approve is now Track 1 too (T1-24), a T2 prerequisite, not a Track 2 nicety. What waits here is only the selected use case's owner identity and its fallback set, which populate a policy document.
T2-5Design partner + qualificationSergey + a customer conversation.
T2-6Demo narrative + demo estate refresh setDepends on the subject.
T2-7Roadmap placementSergey's.

2.2a Two product decisions that must be frozen before the substrate hardens — working session

These are not settled here, deliberately. They are step 2 of the re-sequenced plan (§4.0) and they block nothing before P1.5, but they must be answered before P2.7 and P7.

  1. Is an exception acceptable, and what does it cost? The plan states that Constrain, Reject and Unknown have no release affordance. Any demo or partner scenario in which an approver releases a run carrying a newly appeared destination contradicts that. The decidable artifact is a computed-verdict × allowed-human-disposition matrix. If exceptions are permitted at all, they are a distinct "accept exception" action with stronger authorization, a recorded reason, an explicit scope and an expiry — never a plain release. The matrix lives in ADR-039 (R3.5), published there with its cells empty and labelled as the working-session decision. This plan references it by name and neither reproduces it nor settles it by omission; it carries no implementation for the exception path until ADR-039's cells are filled.
  2. What is actually being gated — authority creation, or activation? If phase 1 of the selected use case creates an assumable production role and only the cutover is gated, we are gating activation, not authority creation. Three options, presented and not decided: (a) phase 1 creates a disabled / non-assumable draft identity and the gate holds its enablement; (b) the authority-bearing apply itself is gated via a declared-state rehearsal; (c) the claim narrows to "cutover gate" and we say so on every surface. Recommendation: (a) — it is the only one that makes the held operation the creation of usable authority without requiring a declared-delta artifact (which would pre-empt the use-case selection, §2.3). The decision is Sergey's.

2.3 Does any Track 1 decision pre-empt a use-case option?

Audited item by item. Three decisions can pre-empt; each is neutralised by a named discipline, and the discipline is a review gate on the PR that introduces it.

Track 1 decisionPre-empts a use case?Discipline that neutralises it
Verdict thresholdsYES if hardcoded. Hardcoding candidate A's "any appeared destination → hold" thresholds silently selects A.Thresholds live in a gate_policies document per (tenant, repository_id, environment), read at evaluation time. Code contains no numeric or set-shaped threshold. A test asserts that changing the document changes the outcome with no code change.
Gate payload schemaYES if the declared-delta artefact is required. Requiring it selects candidate C (A declares nothing).declared_delta is an optional enrichment on the gate payload. Absent is a first-class, non-degraded input.
Rehearsal modeYES if hardcoded to promotion_agent_id.The evaluate worker reads rehearsal_mode from the policy document and builds the query string from it. Both promotion_agent_id and existing_agent_id+role_id are already accepted by src/api/routes/rehearsal.ts:340.
Natural key = the deploymentNo — strict superset of the workload key. Every candidate's subject is reachable from (repository_id, environment, run_id, run_attempt) plus a resolved subject entity.
Decision-record field setNo, provided subject is a discriminated reference (workload / identity / role_grant) rather than a bare workload_id.§5 field table uses subject.kind.
Approval-owner resolutionNo — the substrate resolves an owner from the ownership graph and stores approval_owner_ref plus resolution_strategy_id (a string). The strategy the selected use case wants, and who that owner is, are Track 2.
Third disposition ("request changes")No — it is a state, not a policy. Every candidate needs a way for a human to say "not as proposed" without rejecting the deployment.
EnvelopeNo — substrate stores envelope_kind + envelope_ref + a bounded envelope_summary. Every candidate's envelope fits.
Enforcement tier / kill switchNo.
Honesty stamping (T1-15)No — needed under every candidate.
Immutable image digest in the payload (T1-5, R2.6)No — every candidate deploys something, and binding the approval to the exact artifact is right under all four. Where no container image exists, artifact.kind is "none" and the field records why.artifact is a discriminated reference, not a hardcoded image_digest string.
ApprovalPayload field set (T1-16)No — the payload is the union of fields every candidate already produces.Adding a use-case-specific field later is additive and bumps approval_payload_version; it never renames or re-interprets an existing field.
Approver-set enforcement (T1-24)No — who may approve is policy data per (tenant, repository_id, environment); the enforcement primitive is neutral.Approver sets live in gate_policies, resolved to immutable user IDs. Code contains no user identifier.
Reliability substrate (T1-23)No — crash and concurrency safety is orthogonal to the subject.

Nothing in Track 1 is blocked on the selection, and nothing in Track 1 is deferred to avoid pre-empting it. Substrate work starts at zero rework risk, and that claim covers all four candidate subjects — the recommended shortlist and the two recommended against, with reasons, which remain Sergey's call at the working session. Corroborating evidence: every candidate's subject is served by the same API route (src/api/routes/rehearsal.ts:340), the same UI page (ui/src/pages/DeploymentRehearsalPage.tsx), and produces the same verdict shape (RehearsalDeploymentVerdict, src/rehearsal/types.ts:381). None of the four requires a divergent substrate; what differs between them is the last mile in Track 2.

What the selection does gate: partner qualification and outreach. The qualification filter differs between candidates, so we do not qualify partners before D1 lands.

2.4 Relationship to the other two #1883 items — the shared contract

All three items in #1883 will be implemented. GitHub is first because it is the most demonstrable and the most straightforward to explain. The Logic Apps trajectory shadow pilot proceeds in parallel in shadow mode; containment there comes only after latency, attribution and pending-run behavior are proven. Container-workload portability follows.

They share one contract, defined once in Track 1 so it is not redone when Logic Apps becomes the second consumer:

// src/domain/gate/types.ts — the shared core. One enforcement_point member today.
interface GateDecisionCore {
tenant_id: string;
version: number; // optimistic lock — every transition is a CAS (T1-23)
subject: GateSubject; // discriminated: workload | identity | role_grant
enforcement_point: EnforcementPoint; // discriminated union — see below
artifact: DeployedArtifactRef; // immutable digest resolved BEFORE the hold (T1-5, §5.2a)
approval_payload: ApprovalPayload; // the complete object rendered to the approver — §5.2a
approval_content_hash: string; // sha256 over approval_payload — the compared fingerprint
rehearsal_projection_hash: string; // over the verdict with the excluded volatile fields REMOVED
// — the only rehearsal hash the ApprovalPayload carries §5.2a
rehearsal_verdict_hash: string; // over the COMPLETE verdict — evidence and drift labelling
// only, and NEVER inside approval_content_hash
authority_delta: AuthorityDelta; // named destinations + counts + actions, provider-neutral
verdict: "approve" | "constrain" | "reject" | "unknown";
coverage_factor_ids: string[]; // from src/rehearsal/coverage.ts, provider-neutral
evidence_anchors: EvidenceAnchors; // as_of (evidence only), per-source absolute last_synced_at,
// engine versions — as_of is NOT in approval_content_hash
approval_owner: ApprovalOwner | null; // resolved from the ownership graph (T1-20)
approver_set_ref: string; // which authorised approver set applied (T1-24)
confirmation: RecordedConfirmation | null;
verification: "pending" | "verified" | "drifted"; // its OWN field — NOT a lifecycle state (§5.2)
verification_outcome: VerificationOutcome | null; // the detail behind that field, appended by P6
sealed_decision_record_id: string | null; // set at the terminal decision — the audit artifact
}

type EnforcementPoint =
| { kind: "github_actions_environment"; repository_id: number; repository_full_name: string;
environment: string; run_id: number; run_attempt: number; head_sha: string; ref: string;
event: string; installation_id: number; deployment_id: number | null;
triggering_actor_id: number; triggering_actor_login: string;
workflow_path: string; workflow_id: number }
// Logic Apps adds { kind: "azure_logic_app_run"; ... } — same core, new member, no framework.
;

type DeployedArtifactRef =
| { kind: "oci_image"; registry: string; repository: string; digest: string; resolved_at: string;
resolved_from: "workflow_output" | "registry_lookup" }
| { kind: "none"; reason: string }; // no container artifact — recorded, never inferred

Why the actor is an id and not a login. CORRECTED 2026-07-25. This field previously read triggering_actor: string, which invites comparison by login name. GitHub logins are mutable and re-assignable; the numeric user id is not. The id is the only field a self-approval check may compare (#1912); the login is a display snapshot, sealed for rendering and never used in a comparison. Note also what this field is not for: approver authorization never touches it. Approvers are authorized by immutable SecurityV0 user ids, because the whole product distinction is that the workload owner approves inside SecurityV0 without needing a GitHub account at all. The GitHub-actor → SecurityV0-user mapping exists for exactly one question — is the approver also the person who triggered this deployment? — and its absence must degrade to "cannot determine self-approval", never to "cannot authorize".

The invariant that makes this a contract and not a framework: everything except enforcement_point is provider-neutral and is written once. The Logic Apps work adds one union member, one adapter, and reuses the collection, the decision-record binding, the confirmation envelope, the approval UI and the verification legs unchanged. The audit-field set (tenant_id, subject, artifact, approval_payload, approval_content_hash, rehearsal_projection_hash, rehearsal_verdict_hash, authority_delta, verdict, coverage_factor_ids, evidence_anchors, approval_owner, approver_set_ref, confirmation, verification, verification_outcome, sealed_decision_record_id) is frozen by ADR-036 (approval object) and is the same table a CISO reads regardless of which enforcement point produced the row.


3. Preconditions per phase

PhasePreconditionsStatus today
P0 SpikeA private repo in an Enterprise org; ability to register a GitHub App; a throwaway environmentMET (§0). Use a new throwaway repo SecurityV0/sv0-gate-spike, not a product repo. Starts today.
D0 Freeze product decisionsSergey's working session; P0 item (9) for the digest sourceBlocked on the working session by design. Blocks only P2.7's contents and P7.5.
P1 AdapterP0's captured payload fixture for the parse contract; a webhook-reachable URL for local dev (Cloudflare tunnel already in docker-compose.yml:110)Payload fixture from P0; everything else met. Starts today (skeleton + HMAC do not depend on the fixture; the parse and the durable-ACK boundary do).
P1.5 Reliability substrateP1's adapter process; P2's collections for the platform halfAdapter-side inbox starts with P1. Platform-side CAS follows P2's collections. Gates T2 (§7.6).
P2 Gate API + recordNone. Mongo + the shipped adapter surface.MET. Starts today. Includes the T1-26 sealed-record widening, which touches shipped types.
P2.5 Determinism + ApprovalPayloadP2's record; nothing from D0MET once P2's collections land. The adapter-ordering half (T1-25) has no dependency at all and can start today.
P2.7 Approver authorizationP2's confirm route; D0 for the approver-set contentsMechanism starts with P2; contents wait on D0. Dogfood populates the sets with the repo's own owners in the meantime.
P3 Evaluate workerP2's collections; a tenant with rehearsal_enabled (src/domain/tenants/feature-flags.ts:18)Flag write path exists (PATCH /api/v1/admin/tenants/:slug/features, src/api/routes/admin/tenant-features.ts:78); on deployed envs it is super-admin-only — see #1762 risk (§10).
P4 UIP2's API contract frozenFollows P2 by ~2 days.
P5 Release pathP0 (callback URL format), P1, P2, P3; the App installed on the spike repoAfter P3.
P6 VerificationP5 releasing on the spike repo; for the reconcile leg, a connector refresh path for the target estateReconcile leg is manual-trigger only until connector automation exists (src/workers/connector-driver.ts:172 hardcodes sv0-aws; Dockerfile has no Python) — stated as a limitation, not hidden.
P7 Dogfood at T1 AdvisoryA gated environment on a real SecurityV0 repo with Allow administrators to bypass deselected and verifiedEnvironments exist; the bypass toggle must be set and re-read via the API as evidence.
P7.5 T2 enablement gateP1.5 green, P2.7 green, P7 measured, D0 frozenNot startable until all four. This is the gate, not a phase to be compressed.
P8 Track 2 last mileSergey's D1 selectionBlocked on the working session by design.

Phases with zero preconditions, starting now: P0, P1, P2, plus the adapter-ordering half of P2.5 (T1-25 touches only two adapters and their tests). P3, P2.5's payload half and P2.7's mechanism start as soon as P2's collections land. P7.5 is the only phase that is gated on all of P1.5, P2.7 and D0, and nothing is promoted to T2 before it.


4. Work breakdown

Conventions: every item is built on a git worktree under .claude/worktrees/<name>, on a feature branch, merged by PR. npm run ci (lint + typecheck + check:vocab + tests + build, package.json:23) and cd ui && npm run ci are mandatory gates on every PR that touches their tree. Lane-week figures are assumed unless marked otherwise, anchored on two measured comparables: PR #1767 (promotion rehearsal — third engine mode, new collection, admin routes, UI tab) at +5081/−245 across 44 files, and PR #1785 (verdict engine + sealed decision records) at +6914/−88 across 44 files. Merge wall-clock on those PRs was ~13 h and ~1 day; that is not effort, so the anchors calibrate relative size, not duration. Review-round tail is budgeted at 30 % on every item that changes a security boundary.

4.0 The order of work — re-sequenced after the round-2 review

The governing rule is now the reliability substrate lands before anything holds a deployment for a human. Seven steps, in this order; every phase below maps onto exactly one of them.

StepWhatPhasesGate to the next step
1Expanded provider spike — real payloads, re-runs, matrix and same-environment jobs, callback retry behaviour, exact artifact identity, T1 trigger options, concurrency, and outage behaviourP0Every P0 question answered from a committed raw delivery, not from a document
2Freeze the product decisions — the held operation, the exception policy (§2.2a·1), the approver authorization model, subject binding, and the immutable-digest sourceD0 (working session)Sergey's D1 selection plus the two §2.2a answers — the disposition matrix filled in ADR-039 (R3.5), the rest written into the architecture doc. The sealed-record shape is not on this agenda: it is decided (R3.4, §2.1a)
3Reliability substrate — durable inbox before ACK, versioned CAS, atomic claims, dispatch leases, outbox, reconciliation-first retries, supersessionP1, P1.5The kill-tests of §6.2 pass: process killed on both sides of the ACK/enqueue boundary loses nothing
4Determinism + ApprovalPayload — total ordering, permutation test, approval_content_hash, policy snapshot and version, explicit engine-version-mismatch behaviourP2.5The permutation test (§6.1) is green and is a merge gate
5Storage + authentication — discriminated sealed-record schema, installation and subject binding, policy lifecycle API, dedicated M2M scopes, human-only confirmationP2, P2.7An M2M token cannot confirm; a non-approver cannot confirm; the sealer accepts a gate record
6Dogfood at T1 Advisory — the adapter approves immediately and we measure the brief hold honestlyP3, P4, P5, P6, P7A published measured p50/p95 of the advisory hold, with sample count
7T2 Hold-with-human — only after approver enforcement and the outage drillsP7.5§7.6 checklist, all boxes, including the keyed-MAC prerequisite if the gated environment is production

Two consequences worth stating rather than burying. P1.5, P2.5, P2.7 and P7.5 did not exist in the first draft of this plan — they are the round-2 work of §2.1a. And step 2 is a decision gate, not an engineering phase: steps 1 and 3 proceed while it is open, because neither the spike nor the reliability substrate can pre-empt a use-case option (§2.3).

P0 — Empirical spike (Track 1) — 0.3 → 0.6 lane-weeks [assumed]

FieldValue
RepoSecurityV0/sv0-gate-spike (new, throwaway) + artifacts into sv0-platform and sv0-documentation
FilesNew: test/fixtures/github/deployment-protection-rule.*.json (captured deliveries); sv0-documentation/docs/architecture/research/2026-07-25-github-protection-rule-spike.md
ChangesRegister a throwaway GitHub App (Actions: read, Deployments: read+write, subscribe to Deployment protection rule). Enable it on one environment. Fire a deployment. Capture and commit the raw delivery. Then answer, empirically: (1) exact deployment_callback_url string format — the only carrier of run_id, and GitHub documents no format. ANSWERED 2026-07-28 by the captured delivery: https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule, with no run identifier anywhere else in the payload; (2) whether a re-run / new attempt re-fires the event or reuses a prior approval. ANSWERED 2026-07-28: it re-fires and run_attempt increments, but the callback URL is byte-identical across attempts — see §10.2; (3) two concurrent runs waiting on one environment — still unobserved; (4) the nested schema of deployment (nullable) and pull_requests; (5) end-to-end latency, deployment requested → delivery received, measured, over ≥20 samples; (6) whether a passing custom rule gates environment-secret release (the documentation seam). Expanded by the round-2 review (step 1, §4.0): (7) a matrix job and several jobs targeting the same environment in one run — one delivery or many, one callback or many, and what the natural key must therefore be; (8) callback retry behaviour — POST the same decision twice, POST after the run has already been approved, POST after the run has failed, and record the exact status and body in each case, because reconciliation-first retries (§4 P1.5) depend on it; (9) exact artifact identity — whether the image digest the run will deploy can be resolved before the hold is answered, and from where (workflow output, registry lookup by tag, or not at all), which decides DeployedArtifactRef.resolved_from; (10) T1 trigger options — what, if anything, can produce a decision without the rule being enabled (there is no such event today, §8.1), so a later shadow mode has a named mechanism rather than an assumption; (11) outage behaviour — enable the rule, answer nothing, and record exactly what the run does and what the UI shows at 1 h, 24 h and beyond.
DependenciesNone
AcceptanceThe three data-model-blocking unknowns are answered with a committed raw payload and a parse function with a test that reads the captured fixture. Items (7)–(11) are each answered with a committed transcript or raw response, and item (9)'s answer is written into §5.2a before P2 merges. Latency reported as a measured p50/p95 with sample count. Any answer that contradicts the plan triggers a written amendment to §5 before P2 merges.

P0 is first because items (1)–(3) and (7)–(9) can each change the data model, and none of them depend on the use case. About a lane-day of measurement removes weeks of speculative design. The expansion is why this phase moved from 0.3 to 0.6 lane-weeks.

D0 — Freeze the product decisions (decision gate, not an engineering phase) — 0 lane-weeks

FieldValue
OwnerSergey (selection) + Ivan (engineering consequences)
InputsThe four candidate subjects against Sergey's eight fields; §2.2a·1 (exception policy) and §2.2a·2 (authority creation vs activation); the approver authorization model (§7.2); the subject-binding rule (§5.2); P0 item (9)'s answer on where the digest comes from
OutputsADR-039's computed-verdict × allowed-human-disposition matrix with its cells filled (the matrix is published there with empty cells before the session — R3.5); the named held operation; the primary and fallback approver sets for the pilot environment; a written answer to "creation or activation"
BlocksP2.7 and P7.5. It does not block P0, P1, P1.5, P2, P2.5 — nothing in those pre-empts a use-case option (§2.3).
AcceptanceThe filled matrix lands in ADR-039 and nowhere else; the remaining outputs are written into the architecture doc and referenced by ADR-036 — all before P2.7 opens a PR.

P1 — Response adapter / GitHub App (Track 1) — 0.8 lane-weeks [assumed]

FieldValue
Reposv0-platform
FilesNew: src/gate-adapter/index.ts, server.ts, webhook.ts, signature.ts, github-client.ts, install-token.ts, redelivery-poller.ts, release-poller.ts, config.ts. New Dockerfile.gate-adapter, a gate-adapter service in docker-compose.yml and docker-compose.deploy.yml. ESLint boundary blocks appended to eslint.config.js (mirroring the depth-laddered src/mcp blocks at eslint.config.js:55-110). New test/gate-adapter/isolation.test.ts mirroring test/mcp/isolation.test.ts.
ChangesAn isolated, extraction-ready module that imports nothing from the rest of src/ — the precedent and its rationale are stated verbatim at src/mcp/api-client.ts:13-15 ("Extraction-ready: this file imports nothing from the rest of src/ outside src/mcp; lint-enforced, mirrored by test/mcp/isolation.test.ts"). Responsibilities: receive the webhook — the adapter is the receiver, not the platform's src/api (R3.2): it holds the App credential, it is the only component that can validate the HMAC, and it is the only component that can answer its own rule; validate that HMAC; write the raw delivery to the durable inbox — gate_inbox, in the adapter's own separately credentialed store, never platform Mongo — and only then ACK 2XX, still inside 10 s — the ACK never precedes durability (P1.5); POST a normalised GateEvent to the platform gate API with a scoped machine bearer; poll the platform for release-authorised decisions; mint a per-decision installation token scoped by repository_ids + {"deployments":"write"} exactly as GitHub's own example does; call the decision endpoint; read back GET /repos/{o}/{r}/actions/runs/{run_id}/approvals; run the redelivery poller.
DependenciesP0 for the payload parse; nothing else
AcceptanceGiven the captured P0 fixture, a signature-valid delivery is durably recorded and then ACKed, inside 10 s (asserted with a fake clock), an invalid signature is rejected with no side effect, and a delivery for an unknown installation still ACKs and records. test/gate-adapter/isolation.test.ts resolves every relative import to a real path and fails if any escapes src/gate-adapter/. npm run ci green.

Why "no computing before ACK" is not the same as "ACK first". The original wording — ACK in under 10 s "without computing anything" — reads as acknowledge, then persist. GitHub redelivers failed deliveries only (handling failed deliveries), so a 2XX returned before the event is durable turns an adapter crash into permanent silent loss: the deployment stays held, and no component knows why. The rule is therefore durable first, ACK second, evaluate later — the durable write is a single small insert, not computation.

Direction of data — deliberate. The platform never calls the adapter. The adapter pulls release-authorised decisions. Consequence: there is exactly one inbound authenticated surface on the write-capable component (GitHub's webhook), and no platform-held credential can cause a GitHub write.

P1.5 — Reliability substrate (Track 1) — 1.4 lane-weeks [assumed] — NEW, and it gates T2

FieldValue
Reposv0-platform
FilesAdapter side, over the adapter's own separately credentialed store (R3.2): new src/gate-adapter/inbox.ts (the gate_inbox collection), src/gate-adapter/outbox.ts and src/gate-adapter/dispatch.ts (the gate_dispatch collection, its atomic claims and its leases), including their index declarations — these are not added to the platform's src/storage/mongo/schema.ts. Platform side: new src/workers/handlers/gate-sweeper.ts; edited src/storage/mongo/adapters/gate-adapter.ts (CAS transition method), src/storage/mongo/schema.ts (the gate_decisions index set only), src/domain/gate/types.ts (version and the state union of §5.2 — the lease fields are adapter-side, §5.2)
ChangesFour mechanisms, each answering a defect the review found. (1) Durable inbox, adapter-side. The adapter writes the raw signed delivery to gate_inbox in its own separately credentialed store before responding 2XX; a separate pump forwards it to the platform and marks it forwarded only on a confirmed durable platform acceptance. A sweeper, platform-side, finds received gate_decisions with no evaluation job and re-enqueues, because a lost enqueue is otherwise invisible. (2) Versioned CAS on every transition. gate_decisions gains version; every state change is an atomic findOneAndUpdate matching both the expected version and an allowed prior state, returning null on a lost race so the caller surfaces 409 and re-reads. The precedent is shipped and is followed exactly: src/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-56 — "Optimistic-locked update. Returns null when version does not match — caller should surface 409 Conflict and re-read." A one-way terminal-disposition lock means a decision that has reached release_authorized or refusal_authorized cannot be moved by anything else — including the kill switch, which loses to an already-committed human terminal decision. (3) Dispatch leases, adapter-side. Delivery to GitHub goes through gate_dispatch, the adapter's own durable outbox, with atomic claims and lease expiry, so multiple adapter replicas cannot both post. The lease lives there and not on gate_decisions — the adapter is the component that posts, and the platform never calls it. (4) Reconciliation-first retries. Before every retry the adapter reads GitHub's own approval state (GET /repos/{o}/{r}/actions/runs/{run_id}/approvals) — a timeout or a 4xx does not prove the deployment is still held. An outcome that cannot be established either way lands in ambiguous and alerts; it never silently retries into a double decision. Refusal gets the identical symmetric lifecycle to release — the earlier draft gave refusal no delivery states at all.
DependenciesP1 (the adapter process exists); P2's collections for the platform-side half — the adapter-side inbox does not wait
AcceptanceKill-tests, and they are the point of this phase. The adapter process is killed (a) after the HMAC check and before the inbox write — GitHub sees a failed delivery and redelivers; (b) after the inbox write and before the ACK — GitHub redelivers, the natural key dedupes, exactly one decision exists; (c) after the ACK and before the platform POST — the pump forwards on restart; (d) after the platform POST and before the local mark — the platform dedupes, exactly one decision exists. Two workers racing a release and a refuse on the same decision produce exactly one terminal state and one 409. release_all racing an owner's release on the same decision loses; the human's committed disposition stands and the drain records that it was skipped. A dispatch lease that expires mid-flight is reclaimed and the reclaiming worker reads GitHub's approval state before posting. A received record with no evaluation job is re-enqueued by the sweeper within one sweep interval, proven by deleting the job row directly.

This phase exists because the first draft of this plan had a lifecycle with no concurrency or crash safety, and shipped a T2 hold on top of it. No environment is promoted to T2 until every acceptance line above is green (§7.6).

P2 — Platform gate API + decision binding + sealed summary row (Track 1) — 1.2 → 1.8 lane-weeks [assumed]

FieldValue
Reposv0-platform
FilesNew: src/domain/gate/types.ts, src/services/gate-seal.ts, src/api/routes/gate.ts, src/api/routes/admin/gate-installations.ts, src/storage/mongo/adapters/gate-adapter.ts. Edited: src/storage/mongo/collections.ts (the three new platform collections after :93 — the adapter's gate_inbox / gate_dispatch are not registered here), src/storage/mongo/schema.ts (index sets, alongside the decision-record block at :211-220), src/storage/storage-adapter.ts (interface methods, alongside :883-896), src/api/app.ts (mount createGateRoutes in the storage-gated block near :331-346; mount the admin route pre-tenant-middleware alongside :234-241), scripts/lib/seed-via-api/tenant.ts + scripts/clean-tenant.ts reset lists. Also edited, and this is the T1-26 widening: src/domain/decision-records/types.ts (the DecisionRecordContent interface at :229 gains a record_kind discriminant + schema_version; the gate variant is gate_decision_v1) and src/services/verdict-engine.ts (sealDecisionRecord at :1281 currently takes result: VerdictResult; it is generalised to a discriminated sealed-content value, options type SealDecisionRecordOptions at :1267).
ChangesThree new platform collections — the platform side of the R3.2 split, joining the shipped decision_records to make the four that stay platform-side. The adapter's gate_inbox and gate_dispatch are not created here, are not in collections.ts, and are not reachable from platform code (§5.1). gate_decisions (the record, §5). gate_installations (operator-authored (installation_id, repository_id) → tenant_id binding, super-admin write via requireSuperAdminAllowingAgent, the same guard used at src/api/routes/admin/promotion-counterparts.ts:131). gate_policies (per (tenant, repository_id, environment): enforcement tier, rehearsal mode, threshold table, decision deadline, kill-switch state). Routes: POST /api/v1/gate/events (adapter → platform, machine bearer, tenant resolved from the binding never from the payload), GET /api/v1/gate/decisions (tenant-scoped list), GET /api/v1/gate/decisions/:id, POST /api/v1/gate/decisions/:id/confirm (the named human), GET /api/v1/gate/release-queue (adapter poll, machine bearer). Seal: we build no second audit mechanism — but the shipped one must be widened first. gate_decisions owns the hold lifecycle and carries all three hashes — approval_content_hash, the in-payload rehearsal_projection_hash and the evidence-only rehearsal_verdict_hash (§5.2a); at the terminal decision (release or refuse) src/services/gate-seal.ts writes exactly one summary row into the shipped decision_records log via sealDecisionRecord (src/services/verdict-engine.ts:1281, hash by computeDecisionIntegrityHash, src/evidence/integrity.ts:42-59), and stores its _id on the gate decision as sealed_decision_record_id. T1-26 is a precondition of that sentence, not an implementation detail of it: DecisionRecordContent is a closed interface today (src/domain/decision-records/types.ts:229) and the sealer's signature is sealDecisionRecord(result: VerdictResult, opts) (src/services/verdict-engine.ts:1281), so a gate record cannot be sealed without the versioned discriminated kind of §2.1a.
DependenciesNone to start; P0 fixes the enforcement_point and artifact field shapes before merge
AcceptanceIntegration test (Mongo): two deliveries with the same (repository_id, environment, run_id, run_attempt) produce one record (unique index, E11000 → replay 200); a terminal decision writes exactly one decision_records row and verifyDecisionRecordIntegrity (src/services/verdict-engine.ts:1321) recomputes it from stored bytes and detects a single-byte edit and a re-parenting — and a test asserts the honest limit too: a record edited and re-hashed verifies clean, which is why §5.1 calls the approval recorded, not attested; a non-terminal transition writes zero decision-record rows; DecisionRecordDoc.confirmed_by remains null on that row; a pre-widening row written without record_kind still verifies after it; an event for an unbound installation returns 202 with binding: "unbound" and creates no decision; a cross-tenant read of /gate/decisions/:id 404s.

P2.5 — Determinism repair + ApprovalPayload + approval_content_hash (Track 1) — 0.9 lane-weeks [assumed] — NEW

FieldValue
Reposv0-platform
FilesNew: src/gate/approval-payload.ts. Edited: src/storage/mongo/adapters/correlation-adapter.ts:60, src/rehearsal/in-memory-adapter.ts:133-147, src/rehearsal/engine.ts (comparators), src/gate/confirm.ts. New tests: test/rehearsal/permutation.test.ts.
Changes(1) Ordering, in both adapters, mirrored. queryCorrelations sorts .sort({ last_confirmed_at: -1 }) today with no secondary key (src/storage/mongo/adapters/correlation-adapter.ts:60), and Mongo does not guarantee a stable sort, so two identical queries can return equal-timestamp correlations in different orders. The in-memory adapter deliberately mirrors the omission and says so (src/rehearsal/in-memory-adapter.ts:133-140: "No _id tie-break is added — the Mongo adapter has none, and adding one would diverge from real behavior"). Both become { last_confirmed_at: -1, _id: 1 }, and the in-memory comment is rewritten to say the mirror is now to a total order. (2) Total comparators. Every array serialized into the verdict gets a comparator with a guaranteed-unique last key. The pattern to copy is already shipped and documented: sortReachConeByRisk (src/rehearsal/engine.ts:867-888) — "Key 5 makes the order TOTAL: no two distinct cone entries compare equal, so the result is input-iteration-order-independent." (3) ApprovalPayload + approval_content_hash + rehearsal_projection_hash (§5.2a) — build the payload, exclude the volatile tenant-wide fields, compute rehearsal_projection_hash over the verdict under the same exclusions and carry that — never rehearsal_verdict_hash — inside evidence_set, hash the payload with the shipped canonicalSerialize, and recompute-and-compare at confirm. The general rule this enforces: no field inside approval_content_hash may be derived from an excluded field. (4) Explicit engine-version-mismatch behaviour: a confirm whose recomputation runs under a different rehearsal_engine_version than the stored payload is a mismatch by definition and returns 409 with that reason named, rather than comparing hashes across engine versions and reporting a field diff that means nothing.
DependenciesP2 (the record exists to hash); does not wait on D0
AcceptanceThe permutation test is the gate. Load ≥100 correlations with identical last_confirmed_at, shuffle the loaded array, recompute the verdict, and assert the serialized output is byte-identical across shuffles. Note precisely what this test is and is not: it is a permutation test, not a 101-tie cutoff test. The 100-record cutoff the round-1 review described does not apply on this path — all four rehearsal call sites pass { limit: 0 } (src/rehearsal/service.ts:362, :486, :568, :661) and both adapters gate truncation on if (limit > 0) (correlation-adapter.ts:60-62, in-memory-adapter.ts:146-147), so nothing is sliced there. The real exposure is tie order, and only a permutation test finds it. Separately: any other paginated or limited read that ever feeds the verdict does carry the subset risk, so a lint-level check asserts no verdict-feeding read uses a non-zero limit. Plus the exclusion trio, and note that the first of the three could not have passed against the earlier payload shape: mutate one unrelated entity between compute and confirm and assert the confirm still passes — under the earlier shape an unrelated entity moved as_of, as_of moved rehearsal_verdict_hash, and the payload nested that hash, so the confirm 409'd for a reason with nothing to do with the decision; it passes now only because the payload's evidence field is rehearsal_projection_hash, and a companion assertion checks the serialized payload contains no rehearsal_verdict_hash at any depth. Then: mutate an entity inside the decision's own reach cone and assert the confirm 409s; mutate the policy document and assert the confirm 409s.

P2.7 — Approver authorization (Track 1) — 0.7 lane-weeks [assumed] — NEW, and it is a T2 prerequisite

FieldValue
Reposv0-platform
FilesNew: src/gate/approver-policy.ts. Edited: src/api/routes/gate.ts (the confirm route), gate_policies schema, src/domain/gate/types.ts (approver_set_ref, RecordedConfirmation.asserted_by becomes an immutable user ID plus a display copy).
ChangesFour requirements, all server-side, all enforced in the route handler. (1) Human-only route. POST /api/v1/gate/decisions/:id/confirm rejects all M2M and delegated-agent identities with 403 and a named reason. This must be enforced in the handler itself: machine principals never carry a membership and the membership middleware early-returns for them — src/api/middleware/auth-middleware.ts:380 states it directly ("Machine principals (M2M / API key) never carry a membership — their authorization derives from the token's scopes, not from a human membership row. This middleware early-returns for machine auth"), so a route that relies on middleware alone will accept a machine token. (2) Immutable user IDs. The approver set stores and compares immutable user identifiers, never an email address and never a display name; the display name is carried alongside for rendering only. (3) Primary and fallback approver sets, server-side. gate_policies carries approver_set: { primary: string[]; fallback: string[]; fallback_after_seconds: number } per (tenant, repository_id, environment); the fallback set is inert until the elapsed hold exceeds fallback_after_seconds. The identity of the sets is D0's output; the enforcement is not. (4) Self-approval prevention where the policy requires it: disallow_self_approval: boolean, compared against the run's triggering_actor_id (the immutable GitHub numeric user id) resolved into the same immutable SecurityV0 identifier space via #1912. Never compared by login. If the mapping is absent, self-approval is undetermined — surface that plainly; it must not silently pass the check, and it must not block authorization, which never depends on GitHub identity at all.
DependenciesP2; D0 for the set contents (the mechanism ships before D0, populated with the dogfood repo's own owners)
AcceptanceA confirm attempted with an M2M token 403s and changes nothing. A confirm by an authenticated human who is in neither set 403s and changes nothing, and the attempt is recorded. A confirm by a fallback approver before fallback_after_seconds 403s; after it, succeeds. With disallow_self_approval: true, the run's triggering actor cannot confirm their own deployment. Every one of these is an integration test against the real route, not a unit test of a helper.

P3 — Rehearsal invocation path + delta mapper + honesty stamping + owner resolution (Track 1) — 1.2 lane-weeks [assumed]

FieldValue
Reposv0-platform
FilesNew: src/workers/handlers/gate-evaluate.ts, src/workers/handlers/gate-deadline.ts, src/gate/policy.ts, src/gate/delta.ts, src/gate/confirm.ts, src/gate/owner-routing.ts. Edited: src/domain/workers/types.ts:17-25 and src/workers/runtime.ts:37-45 (two new job types — the list is duplicated verbatim in both, keep them in sync), src/rehearsal/service.ts:676-680 + :245-252 (emit materializer flag set, engine version, and absolute per-source last_synced_at), src/rehearsal/types.ts (three new emitted fields on the verdict).
Changesgate_evaluate runs the shipped rehearsal via runPromotionRehearsal (src/rehearsal/service.ts:653) or runExistingAgentDeploymentRehearsal (src/rehearsal/service.ts:559), chosen from the policy document. Precedent for a background job producing a sealed decision already exists: src/workers/handlers/evaluate-findings.ts:47-90 calls computeAndSealVerdict as a post-evaluation precompute gated on verdict_enabled + rehearsal_enabled. src/gate/delta.ts maps the RehearsalDeploymentVerdict to a named AuthorityDelta — destinations by name, counts, action-granularity widenings, unmapped destinations — and then to one of four outcomes using the policy thresholds. No threshold literal appears in code. gate_deadline implements our decision deadline, which posts a status report and escalates the notification — it never decides (§8.2); GitHub documents nothing between a 10 s ACK and a 30-day hold, so any shorter deadline is our behaviour and is labelled as ours in every surface. src/gate/owner-routing.ts resolves the approval owner from the ownership graph at evaluate time via computeOwnershipStatus (src/ingestion/authority-path-materializer.ts:396, the same output already sealed as content.ownership on shipped decision records, src/domain/decision-records/types.ts:265).
DependenciesP2
AcceptanceUnit: given a fixed RehearsalDeploymentVerdict fixture and two different policy documents, the mapper returns two different outcomes with zero code change. Integration: a gate_evaluate job on a seeded tenant produces a gate_decision whose coverage_factor_ids include promotion-control-subtraction — which src/rehearsal/coverage.ts:458-493 pushes unconditionally on every promotion verdict and forces to render first (:59-64). A tenant over SYNC_MAX = 10_000 (src/rehearsal/service.ts:55) produces verdict: "unknown" with a named tenant_too_large gap, never a release.

Honesty stamping is BUILD, not shipped — and it is not deferred. computeStaleSources compares last_synced_at against asOf = max(entity.updated_at) (src/rehearsal/service.ts:245-252, :276-310, threshold src/rehearsal/coverage.ts:40), never wall-clock — so a uniformly 60-day-old graph raises zero freshness warnings. A gate verdict that renders coverage factors as "data is current" would assert something the code does not check. EntityDoc.last_synced_at already carries the absolute value (src/domain/entities/types.ts:296); we emit it per contributing source_system and render it on every gate surface and every status report.

P4 — Approval surface in the UI + owner queue (Track 1) — 1.0 lane-weeks [assumed]

FieldValue
Reposv0-platform (ui/)
FilesNew: ui/src/pages/GateDecisionsPage.tsx, ui/src/pages/GateDecisionDetailPage.tsx, ui/src/hooks/use-gate-decisions.ts, ui/src/components/gate/AuthorityDeltaPanel.tsx, ui/src/components/gate/ConfirmReleaseDialog.tsx, ui/src/components/gate/DataFreshnessBanner.tsx. Edited: ui/src/App.tsx (routes rehearsal/gates and rehearsal/gates/:id, next to rehearsal/deployment at :277), ui/src/components/Layout.tsx (nav entry in the Governance group next to "Deployment Rehearsal" at :146 and the flat fallback at :58), ui/src/api/api-types.ts, ui/src/pages/DeploymentRehearsalPage.tsx (accept ?mode=promotion&promotion_agent_id= — today agentMode is useState only at :124 and searchParams.get appears once, for role, at :173, so a check-run cannot deep-link a reviewer to a specific rehearsal).
ChangesA Governance-group queue of held and decided deployments — this is the list surface Ivan asked for: every deployment we gated, its verdict, its evidence and who approved it, read from sealed bytes. A detail page showing the named authority delta, the coverage factors in the engine's deterministic render order, the absolute per-source data age, the resolved approval owner, the GitHub run link, and three actions that name the human: release, request changes, refuse. The queue is filterable to the signed-in user's owned subjects, so a notified owner lands on their own hold (T1-20). Read UX-GUIDE.md first; design-system tokens from ui/src/index.css only, no arbitrary Tailwind values.
DependenciesP2 contract frozen
Acceptancecd ui && npm run ci green. Visual verification is mandatory and must cite screenshot evidence per .claude/rules/visual-review-tooling.md; deterministic checks (pixel-diff %, computed styles) are the arbiter, and any FAIL in visual-report.md is fixed before the PR is claimed done. Functional acceptance: a Constrain decision renders with no affordance that releases it; Request changes is present and leaves the hold in place; the control-subtraction caveat renders above the fold; a decision whose data is 41 days old says so in absolute days; and a past decision opened from the list renders from stored bytes with an explicit label that this is the sealed verdict and its evidence, not a re-rendered live graph.

P5 — Release path (Track 1) — 0.7 lane-weeks [assumed]

FieldValue
Reposv0-platform (src/gate-adapter/)
Filessrc/gate-adapter/release-poller.ts, github-client.ts, status-report.ts
ChangesClaim a decision from the adapter's own gate_dispatch outbox under a lease (P1.5, §5.1 — the lease is adapter-side, and the platform-side effect is the CAS into state: "dispatching"), mint a per-decision installation token scoped by repository_ids + {"deployments":"write"}; POST /repos/{o}/{r}/actions/runs/{run_id}/deployment_protection_rule with environment_name, state, comment (expect 204); read back GET .../approvals; report the outcome to the platform. Every retry is reconciliation-first: read GitHub's approval state before re-posting, because a timeout or a 4xx does not prove the deployment is still held. Separately, while a deployment is held, post status reports on the same endpoint with state omitted — the documented budget is 10 posts of 1024 Markdown characters per deployment, and it is the channel that carries the named delta, the absolute data age, the approved artifact digest and the deep link into the product.
DependenciesP0 (callback URL parse), P1, P1.5 (leases and outbox), P2, P2.5, P3
AcceptanceAgainst a mocked GitHub API: a decision without a recorded confirmation from an authorised approver (P2.7) is never sent, and a changes_requested decision posts a comment but calls neither callback; the status-report budget is enforced client-side with the 10th post reserved for the decision rationale; a timeout or 4xx moves the decision to delivery_failed, and the retry path reads GET .../approvals first — if GitHub already shows our decision the outcome is recorded as delivered, not re-posted; an outcome that cannot be established either way lands in ambiguous and alerts. Against the live spike repo: a real held deployment is released, GET .../approvals shows our App's decision, and the deployed container's digest equals approval_payload.artifact.digest — asserted by reading the running image, not by trusting the tag.

P6 — Verification of the deployed result (Track 1) — 0.8 lane-weeks [assumed]

FieldValue
Reposv0-platform + sv0-connectors (runbook only)
FilesNew: src/workers/handlers/gate-verify.ts; src/gate-adapter/rule-presence-poller.ts; edited scripts/ probe (extends the #1630 nightly probe rather than adding a second harness)
ChangesFour independent legs, and all four write the verification field, never the state union (R3.3): each leg appends to verification_outcome and moves verification from pending to verified or drifted. A drifted result alerts and opens follow-up work; it can never rewrite the human's terminal disposition, which is why verification is a field and not a state. (a) Did the decision land — read GET /repos/{o}/{r}/actions/runs/{run_id}/approvals, which is readable by anyone with repo read and is therefore not our own log; reconcile it against our gate decision and alert on any GitHub approval with no matching recorded confirmation. (b) Did the estate land as projected — after a post-deploy re-ingest, compare the realized cone against the projected appeared set, both keyed on resource_key ?? resource_id (the canonicalisation already implemented at src/rehearsal/engine.ts:1299-1315); a destination we did not project sets verification: "drifted", alerts, and opens a new decision — it does not move the sealed one. This is issue #1568 and we extend it, we do not build a second mechanism. (c) Did the gate survive — poll GET /repos/{o}/{r}/environments/{env}/deployment_protection_rules, because DELETE on that path needs only repo admin. (d) Did the approved artifact ship — compare the digest actually deployed against approval_payload.artifact.digest. This leg exists because our own workflows make the commit an unreliable proxy for the artifact: .github/workflows/deploy-azure-staging.yml:67-70 re-resolves current main after the triggering run ("workflow_run on main success: re-resolve to current main HEAD"), and .github/workflows/deploy-prod.yml:55-56 accepts an operator-supplied image_tag input. A mismatch is a held-decision violation, alerted at the same severity as leg (a).
DependenciesP5
AcceptanceLeg (a) runs on every decision and its mismatch alert is proven by deliberately approving a held deployment through the GitHub UI and observing the alert. Leg (c) is proven by deleting the rule on the spike repo and observing detection within one poll interval. Leg (b) is manual-trigger only in the pilot and is documented as such: there is no automated connector refresh (src/workers/connector-driver.ts:172 hardcodes DEFAULT_BINARY = "sv0-aws"; the platform image is node:22-alpine with no Python — Dockerfile:1-29).

P7 — Dogfood at T1 Advisory + deployed-env probe (Track 1) — 0.6 lane-weeks [assumed]

FieldValue
Reposv0-platform (org configuration + workflows)
Files.github/workflows/ (a spike/gated workflow), environment configuration via API
ChangesEnable the rule on a real SecurityV0 environment at T1 Advisory, deselect "Allow administrators to bypass configured protection rules", and verify the setting by reading it back through the API. Measure end-to-end latency on our own deploys (commit → hold visible → verdict posted → release) and publish it as measured — GitHub publishes no latency SLO for deployment_protection_rule delivery, so this number cannot be cited from a document. Measure the advisory hold itself, honestly: at T1 the run is held, from the moment GitHub sends the event until our adapter posts approved. That window is short, but it is not zero and it is not optional — a rule that is enabled and never answers holds the run to GitHub's 30-day timeout.
DependenciesP5, P6
AcceptanceTen consecutive dogfood deploys produce ten gate decisions, each with a terminal sealed summary row; a hand review of the ten records finds zero cases where the computed verdict would have held on a delta a reviewer judges benign (a criterion that can fail); and a published p50/p95 with sample count for the advisory hold window specifically, stated as "deploys were held for a measured p50 of N seconds" rather than "advisory never holds".

P7.5 — T2 enablement gate (Track 1) — 0.3 lane-weeks [assumed] — NEW

FieldValue
Reposv0-platform (drills + configuration) + sv0-documentation (the recorded evidence)
ChangesThe outage drills that stand between T1 and T2, run against the spike repo and then dev: kill the adapter mid-hold and observe the run's behaviour and our alerting; revoke the App private key and observe that nothing can post approved and that we say so rather than promising fail-open; partition the platform from the adapter and confirm the held run's status report states plainly that no verdict was applied; exercise release_all against an already-committed human terminal decision and confirm the human's decision stands (P1.5). Then flip one environment's enforcement_tier to T2 by policy-document edit and record the actor.
DependenciesP1.5, P2.7, P7, D0
AcceptanceThe §7.6 checklist, every box, with cited evidence per box. This phase cannot be marked done by argument.

P8 — Connector-side items — 0.3 lane-weeks Track 1 [assumed] + Track 2 items

ItemTrackRepoFilesChangeAcceptance
External-system string fix1sv0-platformsrc/domain/system-classification/types.ts:59 (external_systems contains "github") vs the AWS connector stamping sourceSystem: "github_actions" (sv0-connectors/integrations/aws/src/sv0_aws/core/transformer.py:2852); the matcher compares the prefix before the first : (types.ts:97-100)The GitHub-OIDC connection node never matches the external-system rule. Fix the allowlist, not the connector, and add a test on the matcher.The evaluator's external-invocation-authority rule (src/evaluator/rules/external-invocation-authority.ts:49) fires on a GitHub-OIDC connection fixture.
OIDC sub decomposition2 (Track 1 only if the selected use case needs environment-specific matching)sv0-connectorsintegrations/aws/src/sv0_aws/core/trust_policy_parser.py:202-220Today the raw sub string (repo:org/repo:*) is stored undecomposed, so we cannot answer "does the production environment specifically reach this role". Decompose into structured properties. No new permission.A trust policy with repo:o/r:environment:production yields distinct repo / environment properties; repo:o/r:* is flagged as environment-unrestricted.
Entra federated identity credentials2sv0-connectorsshared/sv0_azure/sv0_azure/entra.py (client exposes SP/owners/appRoleAssignments/appRoles/sponsors/signin/user; zero federatedIdentityCredentials hits in either repo)The Azure analogue of the AWS OIDC edge. Application.Read.All is already requested and consented (shared/sv0_azure/sv0_azure/entra.py:8), so this is an extractor change, not a new permission ask. Cheapest high-leverage read we hold permission for.A GitHub→Entra workload-identity-federation edge appears in the graph for a test app.
A GitHub connectornot in this planNeeded only for questions about GitHub's own authority surface (environment secrets, org App installs, runner groups, reviewers). The webhook already answers what is being deployed, where, by whom. Any scheduled second connector also requires solving the sv0-aws-hardcoded driver and the missing connector runtime in the platform image first.

P9 — Docs and ADRs (Track 1) — 0.4 → 0.5 lane-weeks [assumed]

sv0-documentation. Next free ADR number is 034 — 033 is contested by three unmerged branches, so do not reuse it. The six-ADR map, which is authoritative and is the numbering every cross-reference in this document uses:

ADRSubject
034Rehearsal persistence (what is and is not reopenable — §5.4)
035Response-adapter write scope (one gate App, isolated write process, accepted risk of Deployments: read and write, preview-surface risk)
036The approval object — the frozen audit-field set of §2.4, the ApprovalPayload field table, the two rehearsal hashes (only rehearsal_projection_hash inside the payload) and the compared approval_content_hash (§5.2a), the immutable-artifact binding, the state union of §5.2 as Decision 1 — the contract this plan renders, with verification as its own field and not a state, the gate_decisions / decision_records split, the decided versioned discriminated sealed-record kind record_kind (T1-26, priced 0.6 lane-weeks — an engineering decision, not a working-session item), the three dispositions, and the approver-authorization model (T1-24)
037Failure posture — the two failure modes split by which component failed, tiers T1–T3 (there is no T0), deadline behaviour, kill switch and its loss to a committed human terminal decision — §8
040Gate reliability substrate — durable inbox before ACK, versioned CAS transitions, dispatch leases, outbox, reconciliation-first retries, the received-with-no-job sweeper (§4 P1.5). New, added by the round-2 review; merge-blocking before P1.5
038Verdict degradation (the promotion degrade taxonomy, bridge one-sidedness → Unknown)
039Gate signal and thresholds (what the delta is, thresholds as data) — and the home of the computed-verdict × allowed-human-disposition matrix (§2.2a·1, R3.5), published with its cells empty and labelled as the working-session decision. No other document reproduces or settles that matrix

035 and 036 are merge-blocking before P2 and 040 is merge-blocking before P1.5 — they carry the three irreversible decisions (write-scope placement, the record contract, and the crash/concurrency model). 039 is written now rather than deferred, because it is the home of the disposition matrix and that matrix has to exist — with empty cells — before D0 can fill it (R3.5). 034, 037 and 038 are recorded as decisions in the architecture doc immediately and promoted to ADRs after the P0 spike, because the spike can move them. Also: the §4.4 rule from the persistence research — persist the result, never the inputs-as-entities; render hypothetical overlays client-side from verdict bytes, as ui/src/lib/proposed-overlay.ts already does — is written into the architecture doc verbatim as a hard constraint. Add explicit mkdocs.yml nav entries: architecture/reviews/ is not in the nav at all today and architecture/12-deployment-approval.md is not either, so files added without a nav entry are not published.

4.1 Effort roll-up and critical path — and why the previous figures were an undercount

Read this before the table. The 7.3 lane-week / 4.8 critical-path figures published in the first draft of this plan were an UNDERCOUNT, and they are corrected here rather than quietly restated. They excluded three bodies of work that the round-2 review established are mandatory, not optional:

  1. The reliability substrate (P1.5, 1.4) — durable inbox, versioned CAS, dispatch leases, outbox, reconciliation-first retries, sweeper. The first draft assumed the worker runtime's at-least-once delivery covered the gate lifecycle. It does not: it does not make a webhook ACK durable, it does not compare-and-swap a terminal disposition, and it does not lease a GitHub callback.
  2. The sealed-record widening (inside P2, +0.6) — the first draft asserted that no widening was needed. That assertion was false: DecisionRecordContent is a closed interface (src/domain/decision-records/types.ts:229) and sealDecisionRecord accepts only a VerdictResult (src/services/verdict-engine.ts:1281).
  3. Approver enforcement (P2.7, 0.7) — the first draft recorded the approver and scheduled enforcement into Track 2. Enforcement is the demo's differentiator, so it moved to a T2 prerequisite.

Plus the determinism repair and the real ApprovalPayload (P2.5, 0.9), the expanded spike (P0, +0.3), the T2 enablement drills (P7.5, 0.3) and one more ADR (P9, +0.1).

PhaseTrackLane-weeks [assumed]Change vs the first draft
P0 spike (expanded)10.6+0.3 — six new empirical questions
D0 freeze the product decisions0new; a decision gate, not engineering
P1 adapter10.8unchanged in size; ACK semantics corrected
P1.5 reliability substrate11.4new
P2 gate API + record + sealed-record widening11.8+0.6 — T1-26
P2.5 determinism + ApprovalPayload10.9new (absorbs the re-priced T1-16)
P2.7 approver authorization10.7new; was Track 2
P3 evaluate + delta + stamping + owner resolution11.2unchanged
P4 UI + owner queue11.0unchanged
P5 release path10.7unchanged in size; retry semantics corrected
P6 verification10.8unchanged in size; leg (d) added
P7 dogfood at T1 Advisory10.6unchanged
P7.5 T2 enablement gate10.3new
P8 connector (Track 1 subset)10.3unchanged
P9 docs/ADRs10.5+0.1 — ADR-040
Track 1 total11.6was 7.3 — the difference is the three items above
P10 Track 2 last mile21.5–2.5unchanged

Critical path: P0 → P2 → P3 → P5 → P6 → P7 → P7.5 = 6.0 lane-weeks (was 4.8). P1.5 sits on a parallel chain (P0 → P1 → P1.5 → P5 = 3.5) and therefore does not extend the path — but it gates T2, so it constrains the schedule even where it does not lengthen it.

Arithmetic note, so the number can be checked. 7.3 + 4.3 = 11.6, where 4.3 = P1.5 1.4 + P2.5 0.9 + P2.7 0.7 + P2's widening 0.6 + P0's expansion 0.3 + P7.5 0.3 + P9 0.1. The 0.15 that T1-16 originally consumed inside P2/P3 is not deducted from those phases, so 11.6 is conservative by about that much. We leave it in rather than shaving an [assumed] figure to make the sum tidy.

Confidence. These are [assumed] figures on the same two measured comparables as §4 (PR #1767, PR #1785). The new items are less well anchored than the old ones, because the closest comparable for P1.5 is not a gate feature at all but the optimistic-lock work behind src/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-56. Treat 11.6 as the current best estimate, not a floor and not a commitment; the P0 spike can move it again.

Two-person parallelisation. Lane A (platform): P2 → P2.5 → P2.7 → P3 → P6 → P7. Lane B (adapter + UI): P0 → P1 → P1.5 → P4 → P5. The two lanes meet at exactly one contract — the GateEvent and release-queue shapes — which is frozen at the end of P2 and versioned thereafter. The critical path is the floor, so two lanes cannot beat it: Track 1 lands in ~6.0–7.5 calendar weeks [assumed]. P8's Track 1 item and P9 are unblocked filler for either lane.

What a third lane would buy: nothing on the critical path. A third person is better spent on the Logic Apps shadow pilot. The shadow work does not wait on P2; only the shared record contract of §2.4 does — and only at the point where Logic Apps wants to write a gate decision, which is after its shadow-mode latency, attribution and pending-run evidence is in.


5. The decision-record contract

This is the one artifact that connects proposed change → rehearsal → human approval → GitHub release → cloud execution → verified result. It is the exit criterion the umbrella names.

5.1 Two collections, one job each — and no second audit mechanism

gate_decisions owns the hold lifecycle. decision_records owns the audit artifact.

What decision_records actually is, stated correctly. It is insert-only, pointer-linked, with an unkeyed integrity checksum. It is not hash-chained in the cryptographic sense and it is not cryptographically authenticated. Each row is linked to its predecessor by previous_record_id, and that pointer is inside the hash preimage, so re-parenting is verify-loud — but the hash is a plain SHA256 over canonical bytes with no key, so anyone who can write the collection can edit a row and recompute its hash. The code says this in its own words at src/evidence/integrity.ts:30-36: "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." Every earlier description of this as an "immutable, hash-chained chain" was wrong and is corrected throughout this document.

Structurally, decision_records is linked per (tenant_id, workload_id) with two unique indexes — tenant_workload_content and tenant_workload_previous (src/storage/mongo/schema.ts:216-220) — enforcing one root and one successor per workload. A deployment gate fires per deployment: two concurrent deploys of the same workload on the hot path would collide as 409 CHAIN_ADVANCED. So the hot path does not touch that log at all. gate_decisions carries a per-deployment natural key and every state transition.

The sealer cannot accept a gate record as shipped. DecisionRecordContent is a closed interface (src/domain/decision-records/types.ts:229) and sealDecisionRecord(result: VerdictResult, opts) accepts only an existing verdict result (src/services/verdict-engine.ts:1281, options type at :1267). The earlier claim that no widening was needed is false. T1-26 introduces a versioned discriminated record kindrecord_kind, whose gate variant is gate_decision_v1, plus schema_version on the content, and a sealer generalised to a discriminated sealed-content value. The shape is decided (ADR-036 Decision 7), not one of two options and not deferred to the working session (R3.4); the generic sealed-envelope alternative is explicitly rejected (§2.1a). It is priced at 0.6 lane-weeks [assumed] inside P2 (§2.1a), and it is merge-blocking with ADR-036.

At the terminal decision — release or refuse — the gate seals exactly one summary row into decision_records through the shipped sealer (sealDecisionRecord, src/services/verdict-engine.ts:1281; hash by computeDecisionIntegrityHash, src/evidence/integrity.ts:42-59) and stores its _id on the gate decision as sealed_decision_record_id. One gate decision → at most one sealed row, written once, at the end. A 409 CHAIN_ADVANCED at that single append is retried against the advanced head, which is safe precisely because the row is a terminal summary and not the lifecycle state.

We build no keyed-MAC mechanism of our own. The earlier draft of this plan proposed one; it is dropped. Two audit mechanisms for one product is worse than one honest mechanism, and the shipped record is the one a CISO already reads. The consequence is stated plainly rather than engineered around: the shipped seal is an unkeyed checksum — it detects corruption, cross-tenant/cross-workload transplant, re-parenting, and any tampering that does not recompute the hash, but it is not tamper-evident against a write-capable actor (src/evidence/integrity.ts:30-36, src/domain/decision-records/types.ts:300-309). So the pilot's approval is recorded, not attested, and no gate surface, ADR, demo or partner document may say otherwise.

The keyed-MAC / signature upgrade is a NAMED PREREQUISITE, not an open-ended future item. The code's own framing names our exact use case as the thing the mechanism is not yet ready for: "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:34-36). Therefore:

  • T1 Advisory, and T2 on non-production environments (the spike repo, staging, dev): permitted now. The audit artifact is a recorded decision, labelled as such.
  • T2 holding back a production approval: blocked until the keyed-MAC / signature work lands. It is a platform-wide decision-record integrity item, not gate plumbing, and it is filed as its own issue outside this umbrella — but it is on the §7.6 T2 checklist for production environments, and it is not waivable by argument.

This replaces the earlier sentence "it does not gate the first release". It does not gate the first release at T1 or on a non-production environment; it does gate T2 in production.

We also do not populate DecisionRecordDoc.confirmed_by. It is spec-only, always null, has zero populate paths, and carries an explicit requirement that it "MUST gain its own integrity mechanism (a sealed confirmation event / counter-signed envelope) before any code path populates it" (src/domain/decision-records/types.ts:321-328). The gate's RecordedConfirmation (§5.2) lives on gate_decisions, and the terminal summary row carries the actor (as an immutable user id), the disposition and the approval_content_hash the human actually saw inside the sealed bytes — which respects that fence instead of quietly stepping over it.

Storage vocabulary, final — and it has two sides (R3.2). The receiver is the adapter: it holds the GitHub App credential, it is the only component that can validate the HMAC, and it is the only component that can answer its own rule. ADR-035 already gives it a separately credentialed durable store, so the inbox lives there and not in the platform's src/api.

StoreCollectionsOwner
Platform Mongogate_decisions, gate_installations, gate_policies, decision_recordsThe platform. Tenant-scoped, behind the standard route chain (§7.2). Three are new in P2; decision_records is shipped.
The adapter's own separately credentialed storegate_inbox, gate_dispatchThe gate adapter, and nothing else reads or writes them. gate_inbox is the durable webhook landing before the ACK; gate_dispatch is the outbox with its atomic claims and leases.

The claim that survives, and it should be stated in exactly these words: the adapter holds no handle to the platform's tenant graph. The older phrasing — "no MongoDB handle" — is retired, because it forbade the durable inbox the reliability substrate requires. What the adapter has is its own store for its own two collections, and a scoped platform bearer that can read the release queue and write decision outcomes for bound installations only.

Retired names. deployment_holds, gate_bindings and GateHoldDoc are not used anywhere in this plan or in any sibling artifact.

5.2 Field table — GateDecisionDoc

"In summary row" marks the fields carried verbatim into the single decision_records row sealed at the terminal decision. Everything else is lifecycle state on gate_decisions.

FieldTypeIn summary rowPurpose / note
_idstring (uuid)no
tenant_idstringyesResolved from gate_installations, never from the webhook payload.
schema_version"1"yes
versionnumbernoOptimistic lock (T1-23). Every state transition is an atomic findOneAndUpdate matching this value and an allowed prior state; a mismatch returns null and the caller surfaces 409 and re-reads. Precedent followed exactly: src/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-56. The unique natural key deduplicates creation only — it does nothing for transitions, which is why this field exists.
subject{ kind: "workload" | "identity" | "role_grant"; entity_id: string; display_name: string }yesDiscriminated so no use case is pre-empted. subject.entity_id is also what keys the summary row's workload_id on the decision_records log.
enforcement_pointunion, §2.4 (kind: "github_actions_environment" + repository_id, repository_full_name, environment, run_id, run_attempt, head_sha, ref, event, installation_id, deployment_id | null, triggering_actor_id, triggering_actor_login, workflow_path, workflow_id)yesrun_attempt and workflow identity cost one extra GET /actions/runs/{run_id}; they are not in the webhook payload. deployment is nullable — never dereference it unguarded.
artifactDeployedArtifactRef (§2.4) — { kind: "oci_image"; registry; repository; digest; resolved_at; resolved_from } or { kind: "none"; reason }yesR2.6 — bind the artifact, not just the commit. Resolved before the hold is answered. The commit GitHub reports is not reliably the container that ships: .github/workflows/deploy-azure-staging.yml:67-70 re-resolves current main after the triggering run, and .github/workflows/deploy-prod.yml:55-56 accepts an operator-supplied image_tag. The gated job is required to deploy exactly this digest, and P6 leg (d) checks it. This is artifact binding, not image scanning — image scanning is an explicit non-goal (§1.2) and nothing reads the artifact's contents.
natural_keystring = ${repository_id}:${environment}:${run_id}:${run_attempt}yesUnique index; makes at-least-once webhook delivery idempotent.
state"received" | "computing" | "held" | "changes_requested" | "release_authorized" | "refusal_authorized" | "dispatching" | "released" | "refused" | "delivery_failed" | "ambiguous" | "overdue" | "timed_out_by_github" | "superseded" | "unbound"noThis union renders ADR-036 Decision 1 exactly — fifteen states, no more and no fewer. ADR-036 is the contract; the architecture doc's mermaid and this union are two renderings of it, and any change to the set is an ADR amendment before it is a code edit. Lifecycle, owned by gate_decisions; every transition is a versioned CAS (see version). changes_requested is the third disposition (T1-21): the hold stays, a status report goes on the run, and neither callback is called. It is re-enterable — a subsequent human action returns it to held, from which release_authorized or refusal_authorized follow. Release and refusal now have symmetric delivery lifecycles (T1-23): release_authorized | refusal_authorizeddispatching (leased, with lease_owner + lease_expires_at) → released | refused, with delivery_failed for a retryable failure and ambiguous for an outcome we could not establish from GitHub's own approval state. The authorization states are the terminal decision; the delivery states are the terminal outcome. A summary row is sealed once, at release_authorized / refusal_authorized — the human's decision, not the network's. Verification is deliberately absent from this union — it is its own field (verification, below), which is what stops a late verification outcome from overwriting a terminal disposition.
content.verdict"approve" | "constrain" | "reject" | "unknown"yesSame four-outcome vocabulary the product already uses.
content.authority_delta{ appeared: NamedDestination[]; cancelled_with_wider_actions: NamedDestination[]; unmapped: NamedDestination[]; counts: {source_reach, target_reach, appeared}; excluded_scope_terminals: number }yesThe named delta. This is the signal, not a recommendation word.
content.rehearsal_verdict_hashstring (sha256:…)yessha256(canonicalSerialize(verdict)) over the complete, unmodified verdict — the shipped pattern at src/services/verdict-engine.ts:363-364, using canonicalSerialize from src/rehearsal/engine.ts:1555. Evidence and drift labelling only. It is NEVER a release gate on its own and it is NEVER carried inside the ApprovalPayload — it covers the raw rehearsal and not the decision the human approved, and it contains volatile tenant-wide fields (as_of, totals.entities_materialized, totals.baseline_paths) that move on unrelated ingest. Nesting it in the payload would re-import that volatility through the nest. See §5.2a.
content.rehearsal_projection_hashstring (sha256:…)yessha256(canonicalSerialize(verdict)) over the verdict with as_of, totals.entities_materialized and totals.baseline_paths removed — the same projection approval_content_hash applies. This is the rehearsal hash the ApprovalPayload carries (evidence_set.rehearsal_projection_hash, §5.2a), and it is the reason a connector scan touching an unrelated entity does not invalidate a pending approval.
content.approval_content_hashstring (sha256:…)yessha256(canonicalSerialize(ApprovalPayload)) over the complete immutable object rendered to the approver (§5.2a), excluding the volatile tenant-wide fields. This is the anti-substitution mechanism and the only hash compared at confirm.
content.approval_payload_version"1"yesBumped whenever a field is added to or removed from the ApprovalPayload. A confirm whose stored version differs from the current one is a mismatch by definition and 409s with that reason, rather than diffing across schemas.
content.coverage_factor_idsstring[], engine render orderyesAlways contains promotion-control-subtraction for a promotion verdict — unconditional engine invariant, src/rehearsal/coverage.ts:458-493, forced to render first at :59-64.
content.evidence_anchors{ as_of: string; sources: [{ source_system, last_synced_at, absolute_age_days }]; rehearsal_engine_version; verdict_engine_version; materializer_flags: {path_collapse_enabled, scope_terminals_enabled, chain_contract_enabled} }yesabsolute_age_days is wall-clock, not relative to as_of. Without it the verdict cannot honestly state data age (src/rehearsal/service.ts:245-252). Engine version + flag set are stamped now because they cannot be retrofitted onto records already written. as_of is stored here as evidence and for drift labelling and is deliberately OUTSIDE approval_content_hash — it is max(entity.updated_at) across the entire tenant (deriveAsOf, src/rehearsal/service.ts:245-252, over loadAllTenantEntities, :361), so any connector scan touching any unrelated entity moves it. The raw per-source last_synced_at stamps are outside the compared hash for the same class of reason — a stamp moves on every sync of its source, including one that makes the data fresher, so only the quantized freshness band derived from them is compared (ADR-036 Decision 2, ADR-038 Decision 3). The engine versions and the flag set are inside the hash.
approver_set_ref{ policy_id: string; policy_version: number; set: "primary" | "fallback" }yesT1-24. Which authorised approver set the confirming human belonged to at the moment of confirmation, resolved server-side.
content.envelope{ kind: string; ref: string | null; summary: object }yesTrack 2 fills kind/ref; the substrate stores whatever the selected use case defines.
content.policy{ policy_id, policy_version, enforcement_tier, rehearsal_mode, decision_deadline_seconds }yesWhich policy document produced the outcome, so a decision is reproducible without the current policy.
content.declared_deltaobject | nullyesOptional enrichment. Absent is first-class.
content.rationaleRationaleClause[] (reuse the shipped shape)yesOrdered clauses with clause_id, statement, strength, caveats.
content.approval_owner{ owner_entity_id: string | null; display_name: string | null; contact: string | null; ownership_status: OwnershipStatus; resolution_strategy_id: string; resolution: "resolved" | "unresolved" }yesT1-20. Resolved from the ownership graph via computeOwnershipStatus (src/ingestion/authority-path-materializer.ts:396) — the same output already sealed as content.ownership on shipped decision records (src/domain/decision-records/types.ts:265). unresolved is first-class and still queues.
content_jsonstringstableStringify(content) stored verbatim, so the display copy and the sealed bytes can be compared. Same contract as DecisionRecordDoc.content_json (src/domain/decision-records/types.ts:296-299).
sealed_decision_record_idstring | nullThe binding. Set once, at the terminal decision, to the _id of the single summary row appended to decision_records. Null while the deployment is held or while changes are requested.
confirmationRecordedConfirmation | null = { asserted_by_user_id, asserted_by_display, asserted_at, disposition: "release" | "request_changes" | "refuse", note, confirmed_approval_content_hash }yes (release / refuse only)The authorised human's action. asserted_by_user_id is an immutable user identifier — never an email address, never a display name (T1-24); asserted_by_display is carried for rendering only and is outside the compared hash. confirmed_approval_content_hash is the approval_content_hash the human actually saw, and it is what POST …/confirm recomputes and compares (§5.3). A request_changes confirmation is recorded on gate_decisions and is not terminal, so it seals no row.
verification"pending" | "verified" | "drifted"noIts own field, and deliberately not a lifecycle state. Defaults to pending and is set by gate-verify (P6) after the terminal outcome. Keeping it off the state union is what makes "a verification outcome can never overwrite a terminal disposition" structural rather than a convention.
verification_outcomeVerificationOutcome | null = { github_approvals_state, github_approver, github_decided_at, reconciled_at, projected_vs_realized: {matched[], unexpected[], missing[]}, rule_present, deployed_digest, digest_matches_approval, checked_at }noThe detail behind the field above. Written after the fact by gate-verify; it postdates the sealed row by construction, so it lives on gate_decisions and is append-only there. digest_matches_approval: false is a held-decision violation (P6 leg (d)), not an informational field, and it sets verification: "drifted".
— dispatch lease —Not a field on this document. The claim, the lease owner and the lease expiry live on the adapter's own gate_dispatch record (R3.2, §5.1), because the adapter is the component that posts to GitHub and the platform never calls it. gate_decisions carries only state: "dispatching", moved by the same versioned CAS as every other transition.
created_at, updated_atDatenoEnvelope display metadata.

Indexes, platform-side. unique(tenant_id, natural_key); (tenant_id, state, created_at); (tenant_id, verdict); (tenant_id, content.approval_owner.owner_entity_id, state) for the owner's queue; (tenant_id, state, created_at) also serves the received-with-no-job sweeper. The lease-expiry index belongs to the adapter's gate_dispatch collection, not here (R3.2). There is no link index on gate_decisions — the pointer-linked log lives in decision_records, which already has tenant_workload_content and tenant_workload_previous (src/storage/mongo/schema.ts:216-220).

5.2a ApprovalPayload — the contract the human approves

This is the contract. approval_content_hash is only its fingerprint. The earlier draft hashed canonicalSerialize(rehearsalVerdict) and called that the anti-substitution mechanism. It was wrong in both directions, and both errors are real:

  • Too narrow. The human does not approve a rehearsal. They approve an outcome derived from the policy document, its thresholds, the named authority delta, the freshness class, the coverage factors, the subject binding, the artifact and the approval owner. None of that is inside canonicalSerialize(rehearsalVerdict). This plan explicitly allows the same rehearsal to yield a different outcome under a different policy document (§2.3) — so the policy's effects sat entirely outside the hash.
  • Too broad. as_of is inside the hashed verdict (src/rehearsal/types.ts:342) and is derived by deriveAsOf (src/rehearsal/service.ts:245-252) as max(entity.updated_at) across loadAllTenantEntities (:361) — the entire tenant. Any connector scan touching any unrelated entity moves it, so the hash would mismatch and refuse a release for a reason with nothing to do with the decision. totals.entities_materialized and totals.baseline_paths (src/rehearsal/types.ts:364-365) have exactly the same property.

The rule that keeps the exclusion honest, stated generally so it is not re-broken: no field inside approval_content_hash may be derived from an excluded field. Any nested hash, digest or fingerprint placed in the payload must be computed over the same projection the payload uses. The round-2 draft broke exactly this rule: it excluded as_of and the two tenant-wide totals from the payload's own fields and then carried evidence_set.rehearsal_verdict_hash, which is computed over the whole verdict including those three fields — so the volatility came back through the nest and the "unrelated entity does not 409" acceptance test could not have passed.

Two rehearsal hashes, and only one of them is in the payload.

FieldComputed overIn approval_content_hash?
rehearsal_verdict_hashthe complete verdict, unmodifiedNO — evidence and drift labelling only. Stored on gate_decisions and sealed in the summary row; never nested inside the payload
rehearsal_projection_hashthe verdict with as_of, totals.entities_materialized and totals.baseline_paths removedYES — this is the one the payload carries, inside evidence_set

The payload, field by field. Every field below is inside approval_content_hash. The exclusions are listed after the table and they are exhaustive.

FieldTypeWhy the approver's decision depends on it
approval_payload_version"1"A schema change is a mismatch by definition, not a field diff.
tenant_idstringResolved from gate_installations, never from the webhook payload.
subject{ kind: "workload" | "identity" | "role_grant"; entity_id; display_name }What is being approved. A different subject is a different decision.
enforcement_point§2.4 union, including repository_id, environment, run_id, run_attempt, workflow_path, workflow_id, head_sha, ref, triggering_actor_idWhere it is being approved, and which run. run_attempt matters: a re-run is a new decision.
artifactDeployedArtifactRefWhich artifact. The commit is not the container (§5.2, R2.6).
verdict"approve" | "constrain" | "reject" | "unknown"The computed outcome itself.
authority_deltanamed appeared / cancelled_with_wider_actions / unmapped destinations, counts, excluded_scope_terminalsThe named delta is the signal the human reads. A destination appearing or disappearing changes the decision.
coverage_factor_idsstring[] in engine render orderThe caveats. Their order is part of what was shown, which is why total ordering (T1-25) is a prerequisite of this hash.
freshness{ class; sources: [{ source_system, last_synced_at, absolute_age_days }] }Absolute, wall-clock data age (T1-15). A verdict on 60-day-old data is a different decision from the same verdict on fresh data.
policy{ policy_id; policy_version; enforcement_tier; rehearsal_mode; thresholds_digest; decision_deadline_seconds }The thresholds that turned the delta into the outcome. Changing the policy must invalidate the approval.
engine{ rehearsal_engine_version; verdict_engine_version; materializer_flags }A different engine can produce a different cone from identical data.
evidence_set{ rehearsal_projection_hash; evidence_pack_ids: string[] }The evidence the human was shown, referenced by hash and id rather than embedded. rehearsal_projection_hash, never rehearsal_verdict_hash — the projection is computed over the same exclusions the payload applies, so the nest cannot re-import volatility (the table above).
approval_owner{ owner_entity_id; display_name; ownership_status; resolution_strategy_id; resolution }Who the system says owns this.
approver_set_ref{ policy_id; policy_version; set }Who was permitted to approve it (T1-24).
envelope{ kind; ref; summary }Track 2 fills it; a changed envelope is a changed decision.
declared_deltaobject | nullOptional enrichment; absent is first-class.
rationaleRationaleClause[]The ordered clauses rendered to the approver.

Excluded from approval_content_hash, exhaustively, and why. as_of; totals.entities_materialized; totals.baseline_paths; and any other tenant-scoped counter that moves on unrelated ingest — all four move without the decision changing. rehearsal_verdict_hash is excluded too, and for the same reason: it is derived from all three of the fields above, and the rule above forbids a payload field derived from an excluded field. Also excluded: created_at, updated_at, version, dispatch-lease fields, verification, verification_outcome, asserted_by_display, and anything wall-clock in the envelope. as_of and rehearsal_verdict_hash are stored on the gate decision (content.evidence_anchors.as_of, content.rehearsal_verdict_hash) as evidence and for drift labelling; neither is ever compared.

At confirmation, we recompute the full decision and compare approval_content_hash. Any difference in policy, freshness class, mapper version, subject, artifact, evidence set, owner, approver set, outcome or rehearsal requires a new human confirmation. It is never a silent pass, and it is never a warning the approver can click through — the confirm returns 409 and re-presents the current computation for a fresh decision.

5.3 The lifecycle, as one line per transition

Every arrow below is a versioned compare-and-swap matching the expected version and an allowed prior state (T1-23). A lost race returns null, surfaces 409, and re-reads. Nothing in this lifecycle is a bare updateOne.

webhook received  → the ADAPTER receives; durable write to its own gate_inbox,
THEN 2XX to GitHub (P1, P1.5)
(ACK never precedes durability — failed deliveries are the only
ones GitHub redelivers)
inbox pump → artifact digest resolved BEFORE the hold is recorded (P1.5, P2)
platform gate_decisions{state:"received"→"computing"→"held",
version:0, binding resolved or "unbound"}
sweeper → any "received" with no evaluation job is re-enqueued (P1.5)
gate_evaluate → content{} written, verdict computed, ApprovalPayload built,
approval_content_hash + rehearsal_projection_hash +
rehearsal_verdict_hash stamped — only the PROJECTION hash is
inside the payload; the verdict hash is evidence beside it,
approval owner resolved and notified, status report posted (P3, P5)
human: request → state:"changes_requested" — hold STAYS, comment posted on the run,
changes NEITHER callback called, no summary row sealed (P2, P4, P5)
human confirms → authorization check FIRST: human-only, in an authorised approver
set, not self-approving where forbidden → else 403 (P2.7)
then recompute the full decision → compare approval_content_hash (P2.5 confirm)
mismatch → 409, REFUSE TO RELEASE, re-present for a fresh decision
match → RecordedConfirmation written (immutable user id),
ONE summary row sealed into decision_records,
state:"release_authorized" [TERMINAL DECISION] (P2)
human refuses → same authorization check, RecordedConfirmation written,
ONE summary row sealed, state:"refusal_authorized"
[TERMINAL DECISION] (P2)
dispatch → adapter claims from its own gate_dispatch outbox under a LEASE
→ platform state:"dispatching" (P1.5, P5)
reconciliation-first: read GET .../approvals BEFORE every retry
→ POST .../deployment_protection_rule
→ state:"released" | "refused" [TERMINAL OUTCOME] (P5)
→ retryable failure → "delivery_failed" (lease released, re-claimable)
→ outcome not establishable → "ambiguous" + alert, never re-posted
gate_verify → verification_outcome{} appended (approvals read-back, rule
presence, deployed digest vs approval_payload.artifact.digest);
the verification FIELD moves "pending"→"verified"|"drifted" (P6)
— it is NOT a state, so it cannot overwrite the disposition
post-deploy → verification_outcome.projected_vs_realized appended (#1568) (P6, manual)

Two terminal points, deliberately distinct. The decision is terminal at release_authorized / refusal_authorized — that is the human's act, it is what gets sealed, and a one-way terminal-disposition lock means nothing can move it afterwards, including the kill switch, which loses to an already-committed human terminal decision (§8.2). The outcome is terminal at released / refused, which is a fact about GitHub, not about the human.

The hash-compare at confirm is the substitution defence, and it is the answer to the real defect: GitHub holds a deployment for up to 30 days, so a human confirms minutes-to-hours after the decision was computed, and under recompute-live they could otherwise approve a materially different decision than the one they were shown, with nothing recording the substitution. On mismatch we refuse to release and say so: the decision changed since it was computed; review and confirm again. It uses a hash pattern already shipped at src/services/verdict-engine.ts:363-364, stores no cone, and fails toward fresh state rather than blessing stale bytes. It is no longer "hours of work": the first draft priced it at 0.15 lane-weeks because it hashed one existing object. Hashing the right object means building the ApprovalPayload, excluding the volatile tenant-wide fields, and repairing the ordering the hash depends on — 0.9 lane-weeks in P2.5 (§2.1a), and that figure is [assumed].

5.4 Persistence — answered in two halves, and only one mechanism is built

Ivan asked whether a rehearsal should become a persistent, browsable, comparable entity. There are two questions inside that, and they get two different answers.

The correctness half — a real defect, solved without a rehearsal store. GitHub's hold can last up to 30 days, so the human confirms long after the check run posted. Under recompute-live they could approve a materially different computation than the one reported, with nothing recording the substitution. The fix is T1-16, not a store: build the ApprovalPayload (§5.2a) and stamp approval_content_hash = sha256(canonicalSerialize(ApprovalPayload)) (the shipped hash pattern at src/services/verdict-engine.ts:363-364, canonicalSerialize at src/rehearsal/engine.ts:1555), alongside the in-payload rehearsal_projection_hash and the evidence-only rehearsal_verdict_hash (§5.2a — the projection is what the payload carries, so no excluded field re-enters through the nest), the rehearsal engine version, the materializer flag set and the absolute per-source last_synced_at; then recompute and compare at confirm and refuse to release on mismatch. Shipped-pattern and Track 1 — but not small: it depends on the ordering repair (T1-25) and on excluding the volatile tenant-wide fields, and it is priced at 0.9 lane-weeks in P2.5 (§2.1a), not the 0.15 the first draft claimed.

The visibility half — what Ivan actually asked to see. "All the rehearsals created before, visible when a user logs in" is delivered by the sealed gate decision plus a list surface (T1-22, P4): every deployment we gated, with its verdict, its evidence and who approved it. This is cheap — #1780 already ships the immutable record, and queryDecisionRecords exists today with zero non-test callers (src/storage/storage-adapter.ts:893, src/storage/mongo/adapters/decision-record-adapter.ts:87), so the index is one route and one page.

Be honest about what reopening gives you. What you can reopen is the sealed verdict and its evidence — not a live graph. The bounded named delta (destinations by name, counts, action widenings, coverage factors, absolute data age) is stored and re-rendered from bytes. The full path-level cone is not stored and cannot be re-rendered as it was. Every surface says so in those words.

Not built now, explicitly: the overlay entity collection · a re-renderable pinned reach cone · rehearsal-to-rehearsal comparison · temporal graph replay. There is no rehearsal-store work item anywhere in Track 1 or Track 2. Persisting a cone into the reports surface would also ship "generated 2 hours ago" over a graph of unknown absolute age — ui/src/pages/ReportsPage.tsx and ui/src/pages/ReportDetailPage.tsx contain zero occurrences of stale / as_of / freshness.

Strict-subset guarantee. If we later persist the full verdict, the change is additive only: content.rehearsal_artifact_ref: string | null is populated alongside the existing content.rehearsal_verdict_hash, content.rehearsal_projection_hash and content.approval_content_hash, and every field above keeps its meaning. Adding it bumps approval_payload_version only if the reference enters the payload, which it need not. No field is renamed, no semantic is re-interpreted, and records written before persistence remain valid and comparable — which is exactly why rehearsal_engine_version and the materializer flag set are stamped now (they cannot be retrofitted).

Revisit trigger, named in advance and recorded in ADR-034: the pilot's first genuinely disputed hold, or a selected use case that demands a reopenable graph — whichever comes first. Until then the pilot is capped so the recoverable volume stays countable.


6. Test strategy

6.1 Unit (vitest, no Mongo)

  • Payload parsing against the P0-captured real delivery, including the deployment: null case and the deployment_callback_url parse. Never a regex written from memory.
  • Signature validation: valid, tampered body, wrong secret, missing header, timing-safe compare.
  • Delta mapper: fixed verdict fixture × two policy documents → two outcomes, zero code change. This test is what proves the "thresholds are data" discipline of §2.3.
  • Terminal seal: exactly one decision_records row per terminal gate decision and zero for non-terminal transitions; the shipped verifyDecisionRecordIntegrity detects a single-byte content edit and a re-parenting on that row; confirmed_by stays null.
  • Confirm path: approval_content_hash mismatch refuses release; a mutation to an unrelated entity does not mismatch (proving as_of and the tenant-wide totals are excluded — which holds only because the payload carries rehearsal_projection_hash, and a companion assertion checks the serialized payload contains no rehearsal_verdict_hash at any depth); a mutation to an entity inside the decision's own reach cone does mismatch; a mutation to the policy document does mismatch; an approval_payload_version or rehearsal_engine_version difference is a mismatch by definition and names that reason; request_changes seals nothing and calls neither callback.
  • Determinism / permutation (T1-25): shuffle the loaded correlation array and assert byte-identical serialized output. This is a permutation test, not a 101-tie cutoff test — see §6.7·1 for why the cutoff variant would test nothing on this path.
  • Approver authorization (T1-24): an M2M principal is rejected by the handler itself, not by middleware; a human outside both approver sets is rejected; a fallback approver is rejected before fallback_after_seconds and accepted after; self-approval is rejected where the policy forbids it; the approver is matched on an immutable user id and a changed email address does not change the outcome.
  • Versioned CAS (T1-23): a transition attempted against a stale version returns null and the caller surfaces 409; a release and a refuse racing on the same decision produce exactly one terminal state; release_all against an already-committed terminal disposition is a no-op that records the skip.
  • Owner routing: an OWNED_BY subject resolves to a named owner; an unowned subject records resolution: "unresolved", still notifies the operator address, and still queues.
  • Adapter isolation: test/gate-adapter/isolation.test.ts resolves every relative import at any nesting depth and fails if one escapes src/gate-adapter/ — the authoritative gate, with the ESLint blocks as the in-editor early warning. Copied wholesale from test/mcp/isolation.test.ts.

6.2 Integration (needs MongoDB — vitest.integration.config.ts, fileParallelism: false)

  • Idempotency: duplicate delivery → one record.
  • Terminal decision → exactly one sealed summary row; a 409 CHAIN_ADVANCED on that append retries against the advanced head and still produces exactly one row.
  • Unbound installation → 202, no decision, audit row.
  • Cross-tenant read → 404, and X-Tenant-Id mismatch never widens scope.
  • End-to-end within the platform: seeded tenant → gate_evaluate → owner resolved and notified → confirm → one sealed summary row → release-queue entry, with the GitHub client mocked at the HTTP boundary and the email transport swapped for the console one.
  • Reset-list regression: the three new platform collections (gate_decisions, gate_installations, gate_policies) are added to the seed/clean reset lists so test/scripts/seed-reset-collections.test.ts stays green. The adapter's gate_inbox and gate_dispatch are not in those lists — they are not in platform Mongo (§5.1).
  • Crash and outage tests, the P1.5 acceptance set — these are integration tests, not thought experiments. The process is killed at each of the four boundaries (before the inbox write, between the inbox write and the ACK, between the ACK and the platform POST, between the platform POST and the local mark) and in each case exactly one decision exists afterwards and nothing is silently lost. Separately: the platform is made unavailable while a decision is held and the run's status report is asserted to say plainly that no verdict was applied; the GitHub POST is made to time out and the retry is asserted to read GET .../approvals before re-posting; a lease is expired mid-flight and the reclaiming worker is asserted to reconcile first.
  • Sealed-record widening regression: a pre-widening row written without record_kind before the widening still verifies with verifyDecisionRecordIntegrity after it, and a deployment_gate row verifies too.

6.3 Fixture and mock strategy — and the fixture-masking risk

The failure mode we are guarding against is a component test passing against a hand-authored, compliant fixture while the real thing is broken. Three rules:

  1. Fixtures are captured, never authored. The deployment_protection_rule fixtures are raw deliveries recorded in P0 and committed verbatim. A hand-written payload is not admissible.
  2. GitHub is mocked at the HTTP boundary only (an interceptor over the real client), so the client's URL construction, auth header, token scoping and status handling are all exercised.
  3. The live gate is the arbiter. See below.

6.4 Testing a protection-rule callback without a real production deployment

Three levels, in order of fidelity (these are test-fidelity levels — the enforcement-tier vocabulary is T1–T3, §8.1):

  • Level A — replay. POST a captured delivery at the adapter with a recomputed signature. Fast, runs in CI, proves parsing and ACK timing. Cannot prove GitHub accepts our decision.
  • Level B — the spike repo, on demand. SecurityV0/sv0-gate-spike is private in an Enterprise org (§0), with a gate-test environment, a trivial workflow, and no production credentials anywhere. A workflow_dispatch fires a real deployment, our App receives a real event, and we release it for real. This is a genuine end-to-end protection-rule test with zero production blast radius.
  • Level C — the live gate, nightly. A scheduled job fires the Level B workflow against the deployed dev platform (not a local stack), and asserts: a decision row appears, its verdict is non-null, the status report is visible on the GitHub run, and GET .../approvals reports our App's decision. It fails loudly if any leg is silent. This extends the existing rehearsal nightly probe (#1630) rather than adding a second harness.

Level C is the fixture-masking detector. A component test on a compliant fixture proves nothing about the deployed endpoint; only firing a real deployment at a real gated environment and observing a real release does. It also catches the two silent-failure classes that fixtures structurally cannot: a partial connector refresh leaving a bridge unformed (src/ingestion/stitching/bridge-coverage.ts:5-11 — "no error, just silently missing reach"), and a counterpart fingerprint change turning the rehearsal into a 422 (src/rehearsal/promotion.ts:86-95).

6.5 Deployed-env probe

Scripts that hit deployed environments need a WorkOS bearer (npm run auth:login, see scripts/cli/README.md). The probe asserts against dev (dev.securityv0.com, Hetzner) only — environments have independent Mongo and nothing propagates between them. The visual-review cookie is read-only and 403s mutations, so the probe's write legs use a bearer.

6.6 Mandatory gates on every PR

npm run ci and, for any ui/ change, cd ui && npm run ci. UI changes additionally require visual verification with cited screenshot evidence; a FAIL in visual-report.md blocks the claim of done.

6.7 The three verification-first tests — write these before the features they cover

Named explicitly because each one tests a claim this plan previously made without evidence. None is optional and none is satisfied by a component test on an authored fixture.

1. Permutation determinism. Load 100+ correlations with identical last_confirmed_at, randomize the input order, recompute the verdict, and assert byte-identical serialized output across every permutation. Lands with P2.5; it is a merge gate on T1-25.

Read the mechanism correctly, because the round-1 review stated it wrong and the wrong version tests nothing. The exposure is not a 100-record cutoff selecting a different subset: on the rehearsal path all four call sites pass { limit: 0 } (src/rehearsal/service.ts:362, :486, :568, :661) and both adapters gate truncation on if (limit > 0) (correlation-adapter.ts:60-62, in-memory-adapter.ts:146-147), so no truncation occurs there. The exposure is tie ORDER: .sort({ last_confirmed_at: -1 }) has no secondary tie-break, Mongo does not guarantee a stable sort, and the in-memory adapter deliberately mirrors the omission. A 101-tie cutoff test would pass while the real defect stayed. The permutation test finds it. (Any other paginated or limited read that ever feeds the verdict does carry the subset risk as originally described — hence the lint-level check in P2.5's acceptance.)

2. Crash and outage tests around ACK, enqueue, GitHub POST, and outcome recording. The four kill points of §6.2, plus the platform-outage and lease-expiry drills. Lands with P1.5; it is the acceptance criterion for that phase and a T2 gate.

3. One real deployment, end to end, that proves both bindings at once. On the spike repo: a real held deployment where (a) the approved object carries the exact immutable image digest that the job then deploys — verified by reading the running image, not the tag — and (b) only the configured owner can release it, verified by a second authenticated human being refused. Lands with P5/P7; it is the last box on the §7.6 T2 checklist.


7. Security review points

7.1 Credential custody for the GitHub App

  • Three secrets: the App private key (PEM), the webhook secret, and the connection credential for the adapter's own store (gate_inbox / gate_dispatch, §5.1) — separately credentialed from platform Mongo, which is the point of it. All three live only on the gate-adapter container's environment, injected at deploy time. None is ever read by the platform API process, the worker process, or any connector, and the platform credential is never present in the adapter.
  • 1Password / op is not provisioned yet (workspace status as of 2026-06-28: no vaults, no service account, no .env.tpl). Interim custody is the deploy-host secret injection already used by docker-compose.deploy.yml, with a rotation ticket filed at the same time as the App is registered, and the key recorded in the GitHub-secrets inventory doc. This is stated as an interim, not a target.
  • One gate App; a future GitHub connector would be a second, different App. GitHub permits only the App that owns the rule to review it, so the gate is one App. If a GitHub connector is ever built it is a separate App with read-only permissions and a separate installation. The gate App's permission set is exactly Actions: read, Deployments: read+write, and the single deployment_protection_rule subscription.
  • Per-decision tokens are minted with repository_ids + {"deployments":"write"} — GitHub's own documented example — so a leaked token is scoped to one repo and one permission and expires.

7.2 Tenant isolation on every new route

  • POST /api/v1/gate/events resolves the tenant only from gate_installations keyed on (installation_id, repository_id). The webhook payload is never trusted for tenant identity. Unknown binding → 202 ACK, an audit row, no decision, an alert. Fail-closed: we never guess.
  • All tenant-facing gate routes sit behind the standard chain — bearer/session → tenant context → membership → requireTenant on /api/v1 (src/api/app.ts:252-253) — and read req.tenantId, never a query parameter, matching the rule stated at src/api/routes/rehearsal.ts:170-173.
  • The admin binding route is mounted pre-tenant-middleware behind requireSuperAdminAllowingAgent, the same guard used at src/api/routes/admin/promotion-counterparts.ts:131.
  • A per-tenant gate_enabled feature flag is added to TENANT_FEATURE_FLAGS (src/domain/tenants/feature-flags.ts:24-27) and is the server-side boundary, exactly as rehearsal_enabled is ("the UI hides the section, but the server is the only thing that decides whether the rehearsal runs", src/api/routes/rehearsal.ts:230-238).
  • Approver authorization — a T2 PREREQUISITE (T1-24, P2.7), no longer a Track 2 nicety. The demo's differentiator is that the workload owner approves without GitHub access. Recording the owner while letting anyone confirm makes that differentiator a claim the code does not support, so the confirm route enforces four things server-side:
    1. Human-only. All M2M and delegated-agent identities are rejected with 403. This is enforced in the handler itself, because middleware will not do it: machine principals never carry a membership and the membership middleware early-returns for them (src/api/middleware/auth-middleware.ts:380 — "their authorization derives from the token's scopes, not from a human membership row"), so a route relying on middleware alone accepts a machine token.
    2. Immutable user IDs. The approver set is compared on immutable identifiers, never on an email address or a display name.
    3. Primary and fallback approver sets, server-side, before the first T2 release. Stored on gate_policies per (tenant, repository_id, environment); the fallback set is inert until the hold exceeds fallback_after_seconds.
    4. Self-approval prevention where the policy requires it, compared against the run's triggering_actor_id in the same identifier space. This is a new primitive: today there is zero permissionMiddleware on the rehearsal, verdict and mitigation routes (src/api/routes/rehearsal.ts, verdict.ts, mitigation-actions.ts — no matches), and membership roles are owner | admin | member. What remains recorded rather than attested is confirmed_by on the sealed row and the integrity of the record itself (§5.1) — who may approve is enforced; the cryptographic attestation of the record is not. Those are two different claims and the documents must not blur them.

7.3 Failure behaviour — two modes, two answers, with audit

The two failure modes are different questions and never share a rule. Full treatment in §8.2.

  • Mode (b) — the human has not decided. The deployment stays held. If evaluation cannot complete — tenant too large (SYNC_MAX = 10_000, src/rehearsal/service.ts:55), counterpart map missing or changed (404/422), rehearsal disabled, bridge one-sided — the verdict is unknown and the deployment stays held. There is no auto-approve at any holding tier, ever, and no code path from an error to a release. Our decision deadline posts a status report and escalates the notification; it does not decide. GitHub's own 30-day timeout is the terminal fail-closed backstop, and it is GitHub's behaviour, not ours — it is labelled that way on every surface.

  • Mode (a) — SecurityV0 is unavailable or errored. There is no general "we fail open when SecurityV0 is down", and the earlier unconditional promise is deleted. The component that releases a hold obtains its authorization by polling the platform, so the behaviour depends entirely on which component failed. Platform evaluation errors while the adapter is healthy MAY fail open, after an explicit bounded timeout configured per environment, with a status report stating that no verdict was applied. Adapter, private key, network or GitHub API unavailable necessarily fails closed — nothing can post approved, so the run waits until GitHub's own timeout fails it or a customer admin bypasses. We say so; we do not promise otherwise. The full split is the table in §8.2.

    If platform-outage fail-open is required by a customer, it costs a cached, signed per-environment failure policy in the adapter plus the durable inbox (P1.5) — and even then the adapter cannot synchronously write the audit record while the audit platform is down, so the audit row is written on recovery and is labelled as reconstructed. That consequence is stated, not hidden.

  • The kill switch's semantics are declared per environment (§8.2), never implicit, its default is hold_all, and it loses to an already-committed human terminal decision (§5.3).

7.4 Blast radius of the write-capable adapter

Deployments: read and write is the narrowest scope GitHub offers for this endpoint; there is no "review a protection rule only" permission. That scope also permits creating deployments, deleting deployments and posting deployment statuses. This is an accepted risk, recorded in ADR-035 (response-adapter write scope), not glossed.

What a compromised gate service can do — stated plainly. An attacker holding the App private key can approve or reject any waiting deployment in every repository where the App is installed, and can create or delete deployments there. Nothing in our design removes that capability, because the capability is the App's, not the platform's. What we do is bound it and detect it:

BoundMechanism
Scope of installationInstall on the pilot repositories only, never org-wide. Enable the rule only on the specific environments in scope.
Scope per actionPer-decision tokens narrowed by repository_ids + permissions.
No lateral movement into the platformThe adapter holds no handle to the platform's tenant graph (R3.2 — the precise claim; the older "no MongoDB handle" is retired because it forbade the durable inbox the substrate requires). It has its own separately credentialed store for exactly two collections, gate_inbox and gate_dispatch, and no platform admin credential; its platform bearer can read the release queue and write decision outcomes for bound installations only. Enforced structurally by the import boundary (lint + isolation test), the same construction that makes src/mcp extraction-ready.
No credential exfiltrationThe adapter never receives environment secrets; GitHub does not send them.
DetectionEvery GitHub approval is reconciled against our recorded confirmations (P6 leg (a)); an approval with no matching RecordedConfirmation alerts. The reconciliation source is GitHub's own approvals API, which is readable independently of us.
ContainmentUninstall the App (org admin, seconds) or DELETE the protection rule (repo admin). Both are documented, both are one call.

What it cannot do: read environment secrets, mint cloud credentials, read or write any of the four platform collections directly — gate_decisions, gate_installations, gate_policies, decision_records — because it holds no handle to the platform's tenant graph and no platform admin credential (its own store contains only gate_inbox and gate_dispatch, §5.1), or approve a deployment in a repo where the App is not installed. Note the honest limit from §5.1: the sealed log is insert-only, pointer-linked and checksummed with an unkeyed hash — it resists corruption, transplant and re-parenting, but it is not tamper-evident against a platform-side write-capable actor who recomputes hashes. The keyed-MAC / signature upgrade is the named prerequisite before T2 holds back a production approval (§5.1, §7.6).

7.5 GitHub-side configuration that is load-bearing and must be verified, not assumed

  • Admin bypass is ON by default. Any claim that the gate holds requires "Allow administrators to bypass configured protection rules" to be deselected and re-read through the API as evidence.
  • The rule is admin-removable. DELETE .../deployment_protection_rules/{id} needs only repo admin. Detecting removal is our job (P6 leg (c)).
  • deployment: false on an environment makes a custom rule fail the job with an error while granting full environment-secret access. Exclude it from the gated environment and check it.
  • pull_request_target must be excluded from the gated environment: such a job can reference an environment and obtain an environment-scoped OIDC subject after our gate approves, so our verdict could authorize a job whose checked-out code is attacker-influenced.
  • Self-hosted runners weaken the "no production credentials before approval" guarantee — a previously compromised runner can retain secrets across jobs. Runner type is an explicit pilot precondition, recorded per environment.
  • Trust policies must use StringEquals on sub == repo:ORG/REPO:environment:<ExactName>, never StringLike with repo:ORG/REPO:*, which defeats the gate entirely. Our own deploy path already pins repo:SecurityV0/sv0-platform:environment:dev (.github/workflows/deploy-azure-dev.yml:10-13), so we can demonstrate this rather than merely recommend it.
  • Preview-surface risk: custom deployment protection rules are in public preview and "subject to change". Our enforcement point has no deprecation contract. Recorded in ADR-035 as product risk.

7.6 The T2 checklist — every box, with cited evidence

No environment is promoted from T1 Advisory to T2 Hold-with-human until every line here is green. This is P7.5's acceptance, and it is not waivable by argument.

#GateEvidence required
1Reliability substrate green (P1.5)The four kill-tests, the race tests and the lease-reclaim test, all passing in CI
2Approver authorization enforced (P2.7)Integration tests against the real route: M2M rejected, non-approver rejected, fallback windowed, self-approval blocked
3Determinism green (P2.5)The permutation test passing as a merge gate
4Artifact binding proven (P5, P6 leg d)One real deployment where the running image's digest equals approval_payload.artifact.digest
5Outage drills runAdapter killed mid-hold, key revoked, platform partitioned — each with the observed behaviour recorded, and no document anywhere claiming fail-open for the adapter-down case
6Admin bypass deselected and re-read via the APIThe API response, quoted
7Product decisions frozen (D0)ADR-039's disposition matrix filled; the held operation named; the approver sets populated
8Production only: keyed-MAC / signature on decision recordsThe upgrade landed. Until it does, T2 runs on non-production environments only (§5.1)

8. Rollout

8.1 Enforcement tiers, per environment — one vocabulary, three tiers

Tier is a field on the gate_policies document, keyed (tenant_id, repository_id, environment). There is no global tier and no code branch on environment name. This is the only tier vocabulary used in any gate artifact.

T0 Observe has been deleted from the vocabulary. It was defined as "the rule is not enabled on the environment; compute and record anyway", and it has no trigger: GitHub sends deployment_protection_rule only once a custom rule is enabled on the environment (configure custom protection rules), so a tier that requires the rule to be off has no event to compute from. A shadow mode remains a reasonable thing to want; if we want one it needs an explicit workflow or API trigger, which is P0 question (10) and would be filed as its own issue. We do not imply it exists.

TierNameBehaviour
T1AdvisoryRule enabled. The run is held — briefly — and the adapter posts approved immediately alongside a status report carrying the verdict.
T2Hold-with-humanThe pilot tier. Hold; release only on an authorised human's confirmation (§7.2).
T3AutomaticAuto-release on a clean verdict. Not in the pilot.
TierOn approveOn constrain / reject / unknownExit criterion to the next tier
T1 AdvisoryThe adapter posts approved as soon as it has a verdict, plus a status report naming the delta. Say "briefly held", never "never holds": the rule is enabled, so GitHub holds the run from the moment it sends the event until we answer, and a rule that never answers holds the run to GitHub's 30-day timeout. The hold window is measured and published (P7).The adapter still posts approved — advisory means the verdict is reported, not enforced — and the status report names the delta and states plainly that it was not enforced at this tier.The §7.6 T2 checklist, every box.
T2 Hold-with-humanHold; an authorised human confirms; release.Hold. An authorised human may refuse, or request changes (hold stays). No in-product override that releases without an authorised confirmation.Not applicable in the pilot — T3 is out of scope.
T3 AutomaticNot in the pilot. No code path enables it; the tier exists in the vocabulary so a later decision has a name, not a shortcut.

The human-in-the-loop constraint holds at T2 without exception: Approve releases only after an authorised human confirms, and Constrain / Reject / Unknown stay held. T1 holds only for as long as it takes us to answer, which is why it is safe to run on a real environment first — but that is a short hold, not no hold, and every surface says so.

We start every environment at T1 Advisory. staging (which carries no protection rules today, §0) is the first. Promotion between tiers is an explicit policy-document edit, recorded and reversible, and promotion to T2 additionally requires §7.6.

8.2 The two failure modes, and the kill switch

These are two different questions and they never share a rule.

(a) SecurityV0 is unavailable or errored. A general "we fail open when SecurityV0 is down" is impossible, and every unconditional version of that sentence has been deleted from this plan. The component that releases a hold gets its authorization by polling the platform; if the adapter, its credential, the network or the GitHub API is unavailable, nothing can post approved, and the run waits until GitHub's timeout fails it. Split by which component failed:

FailureBehaviourConfigurable?
Platform evaluation errors or times out, adapter healthyMAY fail open after an explicit bounded timeout, with a loud audit event and a status report stating that no verdict was appliedYes, per environment. This is the case where the procurement argument applies — an early-stage vendor sitting fail-closed inside a customer's deploy path is a blocker on its own — and it is the only case where fail-open is available
Adapter down, private key unavailable, network partition, or GitHub API unavailableNecessarily fails closed. The deployment stays held until recovery or a customer admin bypassNo. There is no configuration that changes this, because there is no component left that could post the approval

If a customer requires fail-open for the second row too, the honest answer is that it costs a cached, signed per-environment failure policy held by the adapter plus the durable inbox (P1.5) — and even then the adapter cannot synchronously write the audit record while the audit platform is down, so the record is reconstructed on recovery and labelled as reconstructed. We state that consequence rather than selling around it.

(b) The named human has not decided. The deployment stays held. There is no auto-approve at any holding tier, ever — that would void the human-in-the-loop property the pilot exists to prove. Our decision deadline posts a status report and escalates the notification; it does not decide. GitHub's own 30-day timeout is the terminal fail-closed backstop, and it is GitHub's behaviour, not ours — every surface labels it that way.

Kill-switch layerWhoEffectLatency
1. Policy kill switchTenant admin, in-productkill_switch: "hold_all" | "release_all". The default is hold_all. release_all is an explicit human-initiated per-tenant action: the actor is recorded on every drained decision, and every affected run gets a status report saying no verdict was applied. It loses to an already-committed human terminal decision — a decision at release_authorized or refusal_authorized is locked one-way (§5.3) and the drain records that it was skipped rather than overriding it.Next poll (seconds)
2. Tenant feature flagSuper-admingate_enabled: false stops evaluation. Held deployments stay held and are surfaced as an operational alert; the flag alone never drains the queue. Draining requires the layer-1 release_all action, with its recorded actor.Immediate
3. GitHub-side removalRepo/org adminUninstall the App, or DELETE the protection rule. Total, and not something we can prevent — by design.Immediate

The distinction that makes this coherent: mode (a) is about a component being absent, mode (b) is about a human being slow. A platform evaluation failure may fail open, loudly and on the record. An adapter, credential, network or GitHub failure cannot fail open at all — there is nothing left to post the approval. Slowness never releases anything.


9. Sub-issue proposal under umbrella #1884

Duplicate risk is high — the prior-art sweep found four issues that already cover parts of this. We extend them rather than filing look-alikes.

Six sub-issues are already filed under umbrella #1884: #1885 (spike), #1886 (adapter), #1887 (gate API + record), #1888 (honesty stamping), #1889 (owner routing), #1890 (classification fix). Four of the six now have expanded scope because of the round-2 rulings, marked SCOPE EXPANDED below — those issue bodies must be edited, not commented on, before work starts. Three sub-issues are new and are not yet filed: the reliability substrate, the determinism repair plus ApprovalPayload, and approver authorization.

#IssueTitleRepoTrackOne-line scopeDepends onSupersedes / extendsFile today?
S1#1885Spike: pin deployment_protection_rule payload, re-run and concurrency semanticssv0-platform1SCOPE EXPANDED (R2.11). Register a throwaway App on a private Enterprise spike repo; capture a real delivery; answer the three data-model-blocking unknowns; measure latency — plus matrix / same-environment jobs, callback retry behaviour, exact artifact identity, T1 trigger options, concurrency and outage behaviour (P0 items 7–11). 0.3 → 0.6 lane-weeksnewYES — start today, after editing the body
S2#1886Gate response adapter: isolated, separately permissioned GitHub App servicesv0-platform1SCOPE EXPANDED (R2.5). src/gate-adapter/, HMAC + install-token minting + redelivery poller + release poller; lint boundary + isolation test — and the ACK boundary is corrected: durable inbox write FIRST, 2XX second, still inside 10 s. The adapter is the receiver (R3.2) — not the platform's src/api — and the inbox is gate_inbox in the adapter's own separately credentialed storeS1 (parse)newYES — start today, after editing the body
S3#1887Gate API, installation binding, and the gate decision recordsv0-platform1SCOPE EXPANDED (R2.2, R2.5, R2.6, R2.8). Three platform collections (the adapter's gate_inbox / gate_dispatch belong to S2/S18, not here), five routes, and one summary row sealed at the terminal decision — plus the versioned discriminated record_kind without which the shipped sealer cannot accept a gate record (decided, priced 0.6, ADR-036 Decision 7), the version field and CAS transition method, the state union of §5.2 rendering ADR-036 Decision 1 exactly with verification as its own field rather than a state, the artifact digest field, and all three hashes — approval_content_hash, the in-payload rehearsal_projection_hash, and the evidence-only rehearsal_verdict_hash that is never nested inside the payload. 1.2 → 1.8 lane-weeksnew; extends #1780's sealed record rather than duplicating it, and respects the confirmed_by fence (src/domain/decision-records/types.ts:321-328)YES — start today, after editing the body
S4not filedGate evaluation worker + deterministic delta mapper with thresholds as datasv0-platform1gate_evaluate + gate_deadline job types; policy-driven mode and thresholds; no threshold literal in codeS3extends #1578 (approval-gate seam; unassigned, zero comments — claim it, do not duplicate). Sub-tasks (b) declared-delta and (c) MCP surface drop out of this sliceYES — start today
S5#1888Honesty stamping: absolute per-source last_synced_at, rehearsal engine version, materializer flag setsv0-platform1Unchanged by round 2. Three emitted fields on the rehearsal verdict (T1-15, BUILD not shipped); needed whether or not anything is persistednewYES — start today
S6not filedGate approval surface, three dispositions, and the sealed-decision listsv0-platform (ui + api)1SCOPE EXPANDED (R2.6, R2.7). rehearsal/gates index + detail + release / request changes / refuse; the list surface (T1-22); ?mode=promotion&promotion_agent_id= on the rehearsal page — plus the approved artifact digest rendered on the detail page and the confirm affordance hidden (and server-rejected) for anyone outside the authorised approver setS3, S20extends #1852 — #1852 is the "Approve / Request changes / Reject against a persisted record" issue, so the third disposition (T1-21) is filed there, not as a new issue; inherits its verdict_enabled ops dependency; touches #1770's launcher UXafter S3
S17#1889Approval-owner resolution, notification, and queue placementsv0-platform1Unchanged by round 2, but note the split: this issue resolves and routes the owner; enforcing who may confirm is S20, a separate issue and a T2 prerequisite. Resolve, notify, queue — not a notification platformS3new — no existing issue covers routing or notification; #1611/#1588 are the internal-lifecycle seam, not a delivery mechanism, and #1852 is the dispositionYES — start today (contract half; the queue lands with S6)
S7not filedRelease path + status reportssv0-platform1SCOPE EXPANDED (R2.5). Claim under a lease → mint token → decide → read back approvals; 10-post / 1024-char status budget — and every retry is reconciliation-first: read GitHub's approval state before re-posting; delivery_failed and ambiguous are real states, not error stringsS1, S2, S3, S4, S18newafter S4
S8not filedVerification of result: approvals reconcile, rule-presence poll, projected-vs-realized, artifact digestsv0-platform1SCOPE EXPANDED (R2.6). Four legs now; leg (d) compares the deployed digest against approval_payload.artifact.digest; the projected-vs-realized leg is manual-trigger in the pilotS7extends #1568 — Sergey explicitly said not to create a duplicate verification mechanismafter S7
S9not filedNightly live gate probesv0-platform1Fire a real gated deployment nightly against deployed dev; fail loudly on any silent legS7extends #1630 (rehearsal dev probe → nightly deployed smoke test)after S7
S10not filedBridge one-sidedness → held/Unknownsv0-platform1Feed analyzeBridgeCoverage into the rehearsal coverage factors so under-computed reach cannot present as a clean releaseS4new; the diagnostic exists (src/ingestion/stitching/bridge-coverage.ts:17-22) but does not feed the verdictafter S4
S11#1890Fix github_actions vs github external-system mismatchsv0-platform1Unchanged by round 2. One-line allowlist fix + a matcher testnew (bug)YES — start today
S12not filedADR-034 … ADR-040 + nav entriessv0-documentation1SCOPE EXPANDED (R2.1, R2.4, R2.9). The ADR map of §P9, now seven: 034 rehearsal persistence · 035 response-adapter write scope · 036 approval object (incl. ApprovalPayload, the two rehearsal hashes with only rehearsal_projection_hash in the payload, the §5.2 state union as Decision 1 with verification as its own field, the decided record_kind, artifact binding, approver authorization) · 037 failure posture (split by failed component; tiers T1–T3, no T0) · 038 verdict degradation · 039 gate signal and thresholds, and the home of the verdict × allowed-human-disposition matrix (cells empty) · 040 gate reliability substrate. 035 and 036 are merge-blocking before P2; 040 before P1.5. 034 is the next free number, 033 is contestedS1new; references docs#343 / PR #372 (read-IN / write-OUT / write-BACK taxonomy) as the ruling the adapter sits under, and does not re-derive itYES — start today
S18NEW — file todayGate reliability substrate: durable inbox, versioned CAS, dispatch leases, outbox, reconciliation-first retries, sweepersv0-platform1The round-2 ruling with no existing issue. Durable gate_inbox write before the webhook ACK, in the adapter's own separately credentialed store (R3.2) — the adapter receives, and the four platform collections stay platform-side; version + CAS on every transition of the platform's gate_decisions, with a one-way terminal-disposition lock that the kill switch loses to; atomic claims and lease expiry on GitHub delivery held in the adapter's gate_dispatch outbox; reconciliation-first retries; a platform-side sweeper for received records with no evaluation job. 1.4 lane-weeks [assumed]. It gates T2 (§7.6). Precedent to follow: src/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-56S2 (adapter half), S3 (platform half)new — nothing in the prior-art sweep covers crash or concurrency safety for this lifecycleYES — file today, and ADR-040 is merge-blocking before it
S19NEW — file todayDeterminism repair + ApprovalPayload + approval_content_hashsv0-platform1Mirrored { last_confirmed_at: -1, _id: 1 } in both correlation adapters; total comparators with a guaranteed-unique last key on every array serialized into the verdict; the ApprovalPayload contract (§5.2a); approval_content_hash recomputed and compared at confirm; rehearsal_projection_hash as the payload's only rehearsal hash, enforcing "no field inside the compared hash is derived from an excluded field"; explicit engine-version-mismatch behaviour; the permutation test as a merge gate, plus the unrelated-entity test that the earlier payload shape could not have passed. 0.9 lane-weeks [assumed]S3new — the ordering half touches shipped rehearsal code and can start today, independent of everything elseYES — file today
S20NEW — file todayApprover authorization: human-only confirmation, immutable user IDs, primary + fallback approver sets, self-approval preventionsv0-platform1Promoted out of Track 2 by R2.7 — it is a T2 prerequisite. The confirm route rejects all M2M and delegated-agent identities in the handler itself (src/api/middleware/auth-middleware.ts:380), resolves immutable user IDs, enforces server-side approver sets from gate_policies, and prevents self-approval where the policy requires it. 0.7 lane-weeks [assumed]S3; D0 for the set contents onlysupersedes S14, which had this as a post-S13 Track 2 itemYES — file today (mechanism; contents follow D0)
S21#1907Demo promotion repository and two-phase pipeline — the thing the gate actually gatesnew repo + sv0-demo-labs1The gap this table did not have. S1–S20 build the gate; none of them builds the pipeline it holds. Private repo in the SecurityV0 org; production-infra (ungated) + production (gated) environments; the two-phase workflow of demo §1; phase-1 artifact identity consumed by phase 2 rather than re-resolved; StringEquals OIDC trust on sub and aud in en-workloads — never StringLike; environment secrets only; admin bypass deselected and verified. The AWS half already exists (sv0-demo-labs/labs/enterprise-nimbus/account-en-workloads{,-uat}, matching Owner tags at account-en-workloads-uat/main.tf:70-74) — the destination is built, the journey is not. Explicitly not the spike repo (#1885/#1900 is throwaway, ten writes). 1.0 lane-weeks [assumed, treat as a floor]AWS lab deployable + connector refreshable between phases. Partially on D-cutover (§8.1 / #1901 Q4): options (a) and (b) share this phase structure and are buildable now; only option (c) restructures phase 2new — nothing in the prior-art sweep covers it, and the demo doc carried it as a checklist line rather than workYES — file today, and it is on the demo critical path
S13not filedTrack 2: selected-use-case last milesv0-platform2Rehearsal mode, thresholds, envelope binding, approval-owner identity, partner runbookSergey's D1newafter the working session
S14Track 2: approval-owner enforcement primitiveSuperseded by S20 and promoted to Track 1. Do not file it as a Track 2 item
S15not filedConnector: OIDC sub decomposition (AWS)sv0-connectors2Split repo:org/repo:environment:x into structured properties; no new permissionnewif the use case needs environment-specific matching
S16not filedConnector: Entra federated identity credentialssv0-connectors2The Azure analogue of the AWS OIDC edge; Application.Read.All already consentednewsecond vertical

Already filed (six): #1885 (S1), #1886 (S2), #1887 (S3), #1888 (S5), #1889 (S17), #1890 (S11). Four of those need their bodies edited before work starts — S1, S2, S3 and S12's scope all changed, and S6, S7 and S8 change when they are filed. Editing the issue body is the requirement, not adding a comment: the body is the canonical statement of scope.

To file today (four new): S18 (reliability substrate), S19 (determinism + ApprovalPayload), S20 (approver authorization), S21 (demo promotion repository). Two of the four gate T2; S21 gates the demo itself.

On S21, and why it was missing. Every item in §2.1 and every row above it gates something. Nothing created the something. The demo document specified the pipeline shape in detail and then carried it as a single unchecked line under "T-2 days — the repo and the gate", which is precisely how critical-path work goes unowned: it looked like setup, so it was never priced, never assigned, and never appeared in a dependency graph. Found 2026-07-25 by asking whether a demo repository existed; it does not.

Track 1 and startable immediately: S1, S2, S3, S4, S5, S11, S12, S17, S19 (its adapter-ordering half needs nothing), S21 (the phase structure it needs is common to two of the three D-cutover options), and S18's adapter-side inbox. They have no dependency on Sergey's selection and no dependency on each other beyond S1's payload fixture feeding S2's parse. S6, S7 and S20's mechanism start the moment S3's contract is frozen. S13, S15 and S16 are Track 2 and wait on the working session; S14 is superseded.

Duplicate check against the prior-art sweep, item by item — we extend, we do not re-file: #1578 (approval-gate seam; unassigned, zero comments) is claimed and re-scoped by S4; #1568 (post-approval closure / drift re-verification) is extended by S8, per Sergey's explicit instruction not to build a second verification mechanism; #1611 and #1588 are duplicates of each other and are referenced, not extended — they carry the "we execute nothing" internal-lifecycle constraint that a response adapter changes, which S12's ADR-036 must state explicitly; #1852 is extended by S6 and is where the third disposition lands; #1780 is extended by S3; #1630 by S9; #1770 and #1766 are touched, not duplicated. S18, S19 and S20 have no prior art — the sweep found nothing covering webhook durability, transition concurrency, verdict tie ordering or an approval-authorization primitive, which is consistent with those being the gaps the round-2 review found.

Housekeeping to do before filing anything: #1611 and #1588 are duplicates of each other (same parent, filed 90 minutes apart; the titles differ only by a suffix) — close one first. Attach #1762 (no sanctioned non-interactive path for cross-tenant admin writes on deployed environments) as a blocking dependency for any deployed-env demo, and file the counterpart-map provisioning gap against #1766 rather than as a new issue.

Not filed, deliberately: an MCP gate tool. MCP_TOOLS is a frozen six-tool contract (src/mcp/server.ts:33-46) and the only client a tool gets is GET-only by construction (src/mcp/api-client.ts:19, SAFE_METHOD), so an approve tool structurally cannot live there — which is consistent with the separately-permissioned adapter constraint. A read-side get_gate_decision tool is genuinely cheap, and the criterion for adding it is: a design partner asks for the held decision inside their assistant. Until then it is a contract change with no consumer.


10. Risks and the three highest-risk unknowns

10.1 Risk register

#RiskLikelihoodImpactMitigation
R1A decision is dispatched against a superseded run attempt. Measured 2026-07-28 (§10.2): a re-run does re-hold — a fresh delivery arrives and run_attempt increments — so a prior approval is not silently reused. The residual risk is the other half of the same observation: the callback URL is byte-identical across attempts, so nothing in the URL says which attempt a decision will land onCertain — it is an observed property of the callback URL, not a chance eventHigh (security-material)run_attempt is in the natural key and every attempt is evaluated afresh. The dispatcher re-reads the run's current attempt immediately before it posts and refuses to post against a superseded one; the approvals read-back then confirms which attempt the decision landed on.
R2Adapter down → deployment silently held up to 30 days (GitHub does not auto-redeliver)MediumHighThe redelivery poller is a required P1 component, not optional. Plus an alert on any held decision older than the deadline. This case cannot fail open — the adapter is the only component that can post approved, so there is nothing left to release the hold (§8.2). Recovery or a customer admin bypass are the only exits, and we say so rather than promising otherwise.
R3Verdict computed on a graph of unknown absolute ageHigh — no automated connector refresh existsHighT1-15 stamps absolute per-source last_synced_at on every verdict and every status report; an absolute-age threshold trips unknown (held). Never render "current".
R4Partial connector refresh → bridge unformed → under-computed reach presented as cleanMediumHighS10 wires one_sided to held/Unknown. Until then the coverage factors disclose it and the pilot runbook names the full refresh set per tenant.
R5Counterpart fingerprint change → 422 on exactly the deployments worth examiningMediumMediumMap staleness is already a coverage factor; the gate surfaces "re-confirm the map" as an actionable state rather than an error.
R6Admin bypass or rule deletion defeats the gateMediumHighBypass deselected and verified by API read-back; rule-presence poll (P6 leg (c)) with an alert.
R7Public-preview API changes under usLow–MediumHighRecorded in ADR-035; the adapter's GitHub surface is four calls, all behind one client, so a breaking change is a bounded fix.
R8#1762 blocks super-admin writes (feature flags, installation binding, counterpart map) on deployed environmentsHigh for any deployed demoMediumAttach #1762 as a blocking dependency; dogfood on dev where the path exists; do not schedule a deployed-env partner demo before it clears.
R9Rehearsal latency / tenant size — the engine materializes the tenant and rejects above SYNC_MAX = 10_000MediumMediumEvaluation is a worker job, not a request; over-size yields unknown (held), never a release. Measure on enterprise-nimbus early (§10.2 U3).
R10confirmed_by remains fenced and someone populates it to save timeLowHighThe gate writes its RecordedConfirmation on gate_decisions and carries the actor inside the terminal row's sealed bytes; a test asserts DecisionRecordDoc.confirmed_by still has zero populate paths.
R13The pilot's approval is recorded, not attesteddecision_records is insert-only and pointer-linked with an unkeyed checksum, not a cryptographic authenticator (src/evidence/integrity.ts:30-36)Certain (it is a stated property, not a chance)High for a production T2 hold, Medium below itSay it in the ADR, the demo and the partner runbook rather than implying cryptographic attestation; no gate surface claims tamper-evidence against a write-capable actor. The keyed-MAC / signature upgrade is a NAMED PREREQUISITE before T2 holds back a production approval (§5.1, §7.6 row 8) — the code names our exact use case as the thing it is not yet ready for. T1, and T2 on non-production environments, proceed now.
R14Webhook acknowledged then lost. The adapter returns 2XX and dies before the event is durable; GitHub redelivers only failed deliveries, so the deployment stays held with no record of whyMedium (it needs one crash in a 10 s window, and we deploy often)High — silent, and indistinguishable from "we never got it"S18: durable inbox write before the ACK, plus a sweeper for received records with no evaluation job. Proven by killing the process on both sides of the ACK/enqueue boundary (§6.2), not by inspection.
R15Verdict recomputation is not byte-deterministic, so recompute-and-compare produces false 409s and blocks legitimate releasesHigh until S19 lands — the tie-break is absent by construction in both adaptersHigh (it breaks the one mechanism that makes a delayed confirmation safe)S19: mirrored _id tie-break, total comparators, and the permutation test as a merge gate. Note the mechanism precisely — it is tie order, not a 100-record cutoff; all four rehearsal call sites pass { limit: 0 } so nothing is truncated there (§6.7·1).
R16The owner is recorded but anyone can confirm, so the demo's differentiator is a claim the code does not supportWas certain in the first draftHigh (it is the differentiator)S20 / T1-24, promoted to a T2 prerequisite. Enforced in the handler, because middleware early-returns for machine principals (src/api/middleware/auth-middleware.ts:380). §7.6 row 2 gates T2 on it.
R17The approved commit is not the deployed artifact. Our own workflows demonstrate the gap: deploy-azure-staging.yml:67-70 re-resolves current main; deploy-prod.yml:55-56 takes an operator-supplied image_tagHigh — it is how our own pipelines already workHigh (the approval names something other than what ships)Resolve the immutable digest before the hold and carry it in the ApprovalPayload (§5.2a); require the gated job to deploy exactly that digest; P6 leg (d) verifies it against the running image. This is artifact binding, not scanning.
R18Two components both commit a terminal decision — release vs refuse, or an owner release racing release_all — because the unique natural key deduplicates creation onlyMediumHigh (an audit record that disagrees with what GitHub was told)S18: version + CAS on every transition with an allowed-prior-state guard, and a one-way terminal-disposition lock that the kill switch loses to. Precedent: src/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-56.
R11Threshold values leak into code and quietly pre-empt the use-case selectionMediumHighThe "two policy documents, two outcomes, zero code change" test (§6.1) is a merge gate on S4.
R12Demo estate not live: the enterprise-nimbus full tier is brought up per demo, and en-security state is tainted (demo-labs#89) so an untargeted make up-full would destroy and recreate the org trailMediumMediumCheck AWS state before any demo commitment; run targeted applies only.

10.2 The highest-risk unknowns, each with the cheapest test

Two of the three original unknowns are now answered — by observation, not by documentation. A real deployment_protection_rule delivery was captured from the spike repo on 2026-07-28, committed as a fixture in sv0-platform, and recorded on the dispatch-attempt issue (sv0-platform#1920).

  • The callback-URL format is pinned: https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule. No run identifier and no workflow-run object appears anywhere else in the payload — the run id is embedded only inside that URL, so parsing it is load-bearing and the committed fixture is its regression test.
  • A re-run re-holds. A fresh delivery arrives and run_attempt increments, so a prior approval is not silently reused. The callback URL, however, is byte-identical across attempts and therefore cannot distinguish them. That is the stale-dispatch hazard: run_attempt stays in the natural key and the dispatcher re-reads the run's current attempt before it posts a decision. R1 above is rewritten around it.

GitHub has published nothing on either behaviour, so both are pinned observations that a provider change could invalidate — not documented contracts.

#UnknownWhy it is the highest riskCheapest test
U3Measured end-to-end latency, and whether the target tenant fits the rehearsal admission boundSergey's latency gate can only be answered with measured numbers — GitHub publishes no SLO — and a tenant over SYNC_MAX = 10_000 (src/rehearsal/service.ts:55) returns 413 rather than a verdictTwo independent probes: (a) time 20 spike deliveries end to end; (b) against deployed dev with a WorkOS bearer, GET /api/v1/entities?limit=1 for the tenant count and one timed GET /api/v1/rehearsal/deployment?promotion_agent_id=… on enterprise-nimbus. Probe (b) needs npm run auth:login

What remains is answered inside P0/P1, before any data model is frozen. That ordering is the single most important sequencing decision in this plan.

A fourth, added by the round-2 review. U4 — can the immutable image digest be resolved before the hold is answered, and from where? It matters because the approval must name the artifact, not the commit (R17), and because the answer decides DeployedArtifactRef.resolved_from and therefore the ApprovalPayload shape. This one is genuinely still open, and it has narrowed rather than closed. Two candidate carriers are eliminated: the webhook's deployment object carries nothing that identifies an artifact, and the pipeline makes no call to SecurityV0 — today the published identity exists only as a GitHub Actions job output passed between the two pipeline jobs. A third is now measured: an installation token holding only actions: read returns 200 from a run's artifact listing and from the run object, and 403 from the deployments and contents endpoints (measured 2026-08-05), so the gate could read a promotion record the pipeline uploads as a workflow artifact with the permission set it already holds — the build is on the pipeline side, not a wider App permission. What has not been measured is downloading artifact content: the spike runs upload no artifacts, so the listing endpoint returned an empty list. Cheapest remaining test: have the pipeline upload one promotion record and read it back end to end. If that does not work before the callback must be answered, the field is { kind: "none"; reason } and the plan's artifact claim narrows to "commit-bound" — which must then be said plainly everywhere rather than quietly.


Appendix A — verified-facts ledger (what this plan stands on)

Shipped and reused as-is: promotion / existing-agent / revocation rehearsal engine (src/rehearsal/engine.ts:1266, :1052, :486) · the three GET rehearsal routes (src/api/routes/rehearsal.ts:181, :340, :607) · unconditional control-subtraction and correlation-asymmetry invariants (src/rehearsal/coverage.ts:458-493, render order :59-64) · promotion_counterpart_maps + super-admin routes · sealed decision records and the verdict engine (src/services/verdict-engine.ts, decision_records at src/storage/mongo/collections.ts:93) · canonicalSerialize (src/rehearsal/engine.ts:1555) and the rehearsal-hash pattern (src/services/verdict-engine.ts:363-364) · the worker runtime with idempotency-key dedup and at-least-once delivery · the src/mcp isolation pattern (eslint.config.js:55-110, test/mcp/isolation.test.ts, src/mcp/api-client.ts:13-19) · per-tenant feature flags (src/domain/tenants/feature-flags.ts).

Spec-only, and therefore budgeted rather than assumed: DecisionRecordDoc.confirmed_by (src/domain/decision-records/types.ts:321-328, zero populate paths, requires its own integrity mechanism) · github_app identity subtype (src/domain/graph/identity-subtypes.ts:4, nothing emits it).

Does not exist today (all new work in this plan): any GitHub interaction anywhere in either repo · a hold/release mechanism · a rehearsal or gate worker job type (src/domain/workers/types.ts:17-25 has eight, none of them) · a rehearsal or gate collection · a URL parameter that deep-links a specific promotion rehearsal (ui/src/pages/DeploymentRehearsalPage.tsx:124, :173) · a "who may approve" primitive on tenant routes · any wiring from analyzeBridgeCoverage into a verdict · a GitHub connector · absolute per-source data age on any rehearsal surface (today's check is relative to asOf = max(entity.updated_at), src/rehearsal/service.ts:245-252; absolute stamping is T1-15, BUILD) · routing or notifying a hold to a resolved approval owner (T1-20, BUILD — the email transport exists at src/services/email-service.ts:30, nothing calls it for this) · a durable webhook inbox, a compare-and-swap transition on any gate-like lifecycle, or a dispatch lease (T1-23, BUILD; the optimistic-lock pattern is shipped at src/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-56 and is copied, not invented) · a "who may approve" primitive on tenant routes — zero permissionMiddleware on src/api/routes/rehearsal.ts, verdict.ts, mitigation-actions.ts (T1-24, BUILD) · a secondary tie-break on correlation reads — deliberately absent in both adapters today (src/storage/mongo/adapters/correlation-adapter.ts:60, src/rehearsal/in-memory-adapter.ts:133-140, T1-25, BUILD) · any resolution of a deployed image digest (T1-5 / R2.6, BUILD).

Shipped but deliberately not upgraded here: decision-record integrity is an unkeyed checksum over an insert-only, pointer-linked log — not a hash chain and not a cryptographic authenticator (src/evidence/integrity.ts:30-36, src/domain/decision-records/types.ts:300-309). This plan reuses it as-is and labels the pilot's approval recorded, not attested (R13). It does not build a keyed MAC — but the code's own words name the boundary: "a keyed MAC or signature plus predecessor-hash chaining is the documented future step before decision records back a confirmed production approval", which makes that upgrade a named prerequisite before T2 holds back a production approval (§5.1, §7.6 row 8), not an open-ended future item.

Claims this plan made in an earlier draft that were WRONG, and are corrected here. Recorded rather than silently edited, because the corrections change decisions. Rows marked [R3] were caught by the round-3 consistency gate; the rest by the round-2 adversarial review:

Earlier-draft claimCorrectionWhere
decision_records is "immutable, hash-chained"Insert-only, pointer-linked, unkeyed checksum. Not tamper-evident against a write-capable actor§5.1, R13
The verdict hash is the anti-substitution mechanismIt hashes the wrong object — too narrow (no policy, owner, artifact, subject) and too broad (as_of and tenant-wide totals move on unrelated ingest). Three hashes now; only approval_content_hash gates§5.2a
[R3] The payload could carry rehearsal_verdict_hash as its evidence fieldThat re-imported the excluded volatility through the nest — the verdict hash covers as_of and both totals — so the "unrelated entity does not 409" acceptance test could not have passed. The payload carries rehearsal_projection_hash, computed over the same projection; no field inside approval_content_hash may be derived from an excluded field§5.2a, P2.5
[R3] The receiver's placement was open, and the adapter had "no MongoDB handle"The adapter receives: it holds the App credential, validates the HMAC and answers its own rule. It owns gate_inbox and gate_dispatch in its own separately credentialed store; gate_decisions, gate_installations, gate_policies and decision_records stay platform-side. The surviving claim is no handle to the platform's tenant graph§5.1, §7.4
[R3] Verification was carried as a lifecycle stateIt is its own field, verification: "pending" | "verified" | "drifted", which is what stops a late verification outcome from overwriting a terminal disposition. The state union renders ADR-036 Decision 1 exactly§5.2
[R3] The sealed-record shape was a recommendation with a rejected alternative beside itIt is decided: the versioned discriminated record_kind, ADR-036 Decision 7, priced 0.6 lane-weeks inside P2. An engineering decision, not a working-session item, and no artifact may present it as still open§2.1a, §5.1
[R3] The verdict × allowed-human-disposition matrix "lives in the architecture doc"It lives in ADR-039, published with its cells empty and labelled as the working-session decision. This plan references it and neither reproduces nor settles it§2.2a·1, §P9
Recompute-and-compare is deterministic todayIt is not: array order is preserved by canonicalSerialize and correlation ties have no tie-break in either adapter§6.7·1
No decision-record widening is neededFalse. DecisionRecordContent is closed and the sealer takes only a VerdictResult§5.1, §2.1a
The pilot fails open when SecurityV0 is downImpossible as a general rule. Split by which component failed; the adapter-down case necessarily fails closed§8.2
T0 Observe is a tierIt has no trigger — GitHub sends the event only when the rule is enabled. Deleted§8.1
T1 Advisory never holdsIt holds briefly. A rule that never answers holds the run to GitHub's 30-day timeout§8.1
Approver enforcement is Track 2It is a T2 prerequisite (S20)§7.2
Track 1 is 7.3 lane-weeks, critical path 4.8An undercount — it excluded the reliability substrate, the sealed-record widening and approver enforcement. 11.6 / 6.0§4.1

Measured 2026-07-24 via the GitHub API: SecurityV0 org plan enterprise; sv0-platform private; environments dev / prod / staging; zero custom protection rules installed; zero available rule integrations.