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.
| Fact | Value | Command | Consequence |
|---|---|---|---|
| SecurityV0 org plan | enterprise | gh api orgs/SecurityV0 --jq .plan.name | The 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 visibility | PRIVATE | gh repo view --json visibility | Dogfooding exercises the exact private-repo Enterprise path a customer would use. |
| Existing environments | dev (branch_policy), prod (branch_policy), staging (no rules) | gh api repos/SecurityV0/sv0-platform/environments | A gated environment exists to extend; staging is the natural T1 Advisory target because it carries no protection rules today. |
| Custom protection rules installed | total_count: 0 | gh api repos/.../environments/dev/deployment_protection_rules | Greenfield. No App conflicts, no 6-rule ceiling pressure. |
| Available rule integrations | total_count: 0 | .../deployment_protection_rules/apps | No App is installed on the org yet — P1 registers the first. |
| Our own OIDC is already environment-scoped | federated subject pinned to repo:SecurityV0/sv0-platform:environment:dev | .github/workflows/deploy-azure-dev.yml:10-13 | The 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:
- A GitHub App delivers
deployment_protection_rulefor 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 durablegate_inboxbefore acknowledging (§5.1); the platform'ssrc/apinever 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. - 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).
- A named human — and only an approver the server has authorised (§7.2) — sees the delta in the
product and confirms or refuses.
Approvereleases only after that confirmation;Constrain,RejectandUnknownstay held — grounds are the unconditional control-subtraction and correlation-asymmetry invariants atsrc/rehearsal/coverage.ts:458-493. - A separately permissioned response adapter — the only component holding GitHub write scope — posts the decision back to GitHub.
- 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
verificationfield; none of them can move a terminal disposition (§5.2).
1.2 Non-goals — explicit, and enforced by review
| Non-goal | Why | Enforcement |
|---|---|---|
| Automatic approval across arbitrary repos/workflows | The 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 framework | We 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 scanning | Not 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 gateway | The 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 path | Hard 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 mechanism | Sergey'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 ciincludescheck:vocab(package.json:23→scripts/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 item | Where it lands |
|---|---|---|
| T1-1 | GitHub App registration + webhook receiver + HMAC validation + <10 s ACK | src/gate-adapter/ (new) |
| T1-2 | Separately permissioned response adapter holding deployments: write | src/gate-adapter/ (new), separate process + container |
| T1-3 | Redelivery poller (GitHub does not auto-redeliver — handling failed deliveries) | src/gate-adapter/ (new) |
| T1-4 | Protection-rule callback (approve / reject / status report) | src/gate-adapter/ (new) |
| T1-5 | Deployment/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-6 | Gate API path + tenant isolation + installation→tenant binding | src/api/routes/gate.ts (new), src/api/routes/admin/gate-installations.ts (new) |
| T1-7 | Decision-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-8 | Rehearsal invocation path as a worker job | src/workers/handlers/gate-evaluate.ts (new) |
| T1-9 | Deterministic delta mapper with thresholds as data | src/gate/policy.ts (new) + gate_policies collection |
| T1-10 | Human approval surface (index + detail + confirm), with three dispositions: release / request changes / refuse | ui/src/pages/GateDecisionsPage.tsx (new), route rehearsal/gates |
| T1-11 | Deep-linkability of a specific rehearsal (does not exist today) | ui/src/pages/DeploymentRehearsalPage.tsx |
| T1-12 | Enforcement tiers T1–T3 + per-environment configuration + kill switch (default hold_all) | src/gate/policy.ts, gate_policies |
| T1-13 | Our own decision deadline — posts and escalates, never decides (GitHub documents only 0 s → 30 d) | src/workers/handlers/gate-deadline.ts (new) |
| T1-14 | Verification of result: approvals read-back, rule-removal poll, post-deploy reconcile (extends #1568) | adapter + src/workers/handlers/gate-verify.ts (new) |
| T1-15 | Honesty stamping — BUILD, not shipped: absolute per-source last_synced_at, rehearsal engine version, materializer flag set | src/rehearsal/service.ts, src/rehearsal/types.ts |
| T1-16 | Three 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 mechanism | src/gate/confirm.ts (new), src/gate/approval-payload.ts (new) |
| T1-17 | Bridge one-sidedness → held/Unknown wiring | src/rehearsal/coverage.ts + src/ingestion/stitching/bridge-coverage.ts |
| T1-18 | Connector string fix: github_actions vs github external-system mismatch | src/domain/system-classification/types.ts |
| T1-19 | ADR-034 … ADR-039 (the six-ADR map, §P9) | sv0-documentation |
| T1-20 | Approval-owner resolution + notification + SecurityV0 queue placement — resolve the owner from the ownership graph, notify, queue | src/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 contract | src/domain/gate/types.ts, src/api/routes/gate.ts, src/gate-adapter/status-report.ts |
| T1-22 | List 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-23 | Reliability 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-24 | Approver 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-25 | Determinism 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 test | src/storage/mongo/adapters/correlation-adapter.ts, src/rehearsal/in-memory-adapter.ts, src/rehearsal/engine.ts |
| T1-26 | Sealed-record widening — a versioned discriminated record kind so DecisionRecordContent (a closed union today) and sealDecisionRecord (accepts only a VerdictResult today) can carry a gate record | src/domain/decision-records/types.ts, src/services/verdict-engine.ts, src/services/gate-seal.ts |
| T1-27 | The 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 writes | new 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.
| Item | Lane-weeks [assumed] | Lands in | Acceptance criterion |
|---|---|---|---|
| T1-16 three hashes + recompute-and-compare at confirm | 0.15 → 0.5, and this is an UNDERCOUNT | P2 (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 + queue | 0.4 | P4 (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 disposition | 0.2 | P2 (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 stamping | 0.25 | P3 | Every 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).
| Item | Lane-weeks [assumed] | Lands in | Why 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 sweeper | 1.4 | new phase P1.5 | The 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 prevention | 0.7 | P2.7, before P7 dogfood at T2 | There 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 test | 0.4 | P2.5 | canonicalSerialize 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 content | 0.6 | P2 (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.
| # | Item | Why it waits |
|---|---|---|
| T2-1 | Which 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-2 | Which authority delta triggers Constrain vs Reject | Threshold values in the policy table — the values, not the table. |
| T2-3 | Envelope definition (envelope_kind + reference) | Candidate A uses promotion_counterpart_maps; candidate C would need a declared delta. |
| T2-4 | Approval owner: which owner is named for the selected use case | The 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-5 | Design partner + qualification | Sergey + a customer conversation. |
| T2-6 | Demo narrative + demo estate refresh set | Depends on the subject. |
| T2-7 | Roadmap placement | Sergey'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.
- Is an exception acceptable, and what does it cost? The plan states that
Constrain,RejectandUnknownhave 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. - 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 decision | Pre-empts a use case? | Discipline that neutralises it |
|---|---|---|
| Verdict thresholds | YES 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 schema | YES 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 mode | YES 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 deployment | No — 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 set | No, provided subject is a discriminated reference (workload / identity / role_grant) rather than a bare workload_id. | §5 field table uses subject.kind. |
| Approval-owner resolution | No — 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. | — |
| Envelope | No — substrate stores envelope_kind + envelope_ref + a bounded envelope_summary. Every candidate's envelope fits. | — |
| Enforcement tier / kill switch | No. | — |
| 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
| Phase | Preconditions | Status today |
|---|---|---|
| P0 Spike | A private repo in an Enterprise org; ability to register a GitHub App; a throwaway environment | MET (§0). Use a new throwaway repo SecurityV0/sv0-gate-spike, not a product repo. Starts today. |
| D0 Freeze product decisions | Sergey's working session; P0 item (9) for the digest source | Blocked on the working session by design. Blocks only P2.7's contents and P7.5. |
| P1 Adapter | P0'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 substrate | P1's adapter process; P2's collections for the platform half | Adapter-side inbox starts with P1. Platform-side CAS follows P2's collections. Gates T2 (§7.6). |
| P2 Gate API + record | None. Mongo + the shipped adapter surface. | MET. Starts today. Includes the T1-26 sealed-record widening, which touches shipped types. |
P2.5 Determinism + ApprovalPayload | P2's record; nothing from D0 | MET once P2's collections land. The adapter-ordering half (T1-25) has no dependency at all and can start today. |
| P2.7 Approver authorization | P2's confirm route; D0 for the approver-set contents | Mechanism starts with P2; contents wait on D0. Dogfood populates the sets with the repo's own owners in the meantime. |
| P3 Evaluate worker | P2'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 UI | P2's API contract frozen | Follows P2 by ~2 days. |
| P5 Release path | P0 (callback URL format), P1, P2, P3; the App installed on the spike repo | After P3. |
| P6 Verification | P5 releasing on the spike repo; for the reconcile leg, a connector refresh path for the target estate | Reconcile 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 Advisory | A gated environment on a real SecurityV0 repo with Allow administrators to bypass deselected and verified | Environments exist; the bypass toggle must be set and re-read via the API as evidence. |
| P7.5 T2 enablement gate | P1.5 green, P2.7 green, P7 measured, D0 frozen | Not startable until all four. This is the gate, not a phase to be compressed. |
| P8 Track 2 last mile | Sergey's D1 selection | Blocked 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.
| Step | What | Phases | Gate to the next step |
|---|---|---|---|
| 1 | Expanded provider spike — real payloads, re-runs, matrix and same-environment jobs, callback retry behaviour, exact artifact identity, T1 trigger options, concurrency, and outage behaviour | P0 | Every P0 question answered from a committed raw delivery, not from a document |
| 2 | Freeze the product decisions — the held operation, the exception policy (§2.2a·1), the approver authorization model, subject binding, and the immutable-digest source | D0 (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) |
| 3 | Reliability substrate — durable inbox before ACK, versioned CAS, atomic claims, dispatch leases, outbox, reconciliation-first retries, supersession | P1, P1.5 | The kill-tests of §6.2 pass: process killed on both sides of the ACK/enqueue boundary loses nothing |
| 4 | Determinism + ApprovalPayload — total ordering, permutation test, approval_content_hash, policy snapshot and version, explicit engine-version-mismatch behaviour | P2.5 | The permutation test (§6.1) is green and is a merge gate |
| 5 | Storage + authentication — discriminated sealed-record schema, installation and subject binding, policy lifecycle API, dedicated M2M scopes, human-only confirmation | P2, P2.7 | An M2M token cannot confirm; a non-approver cannot confirm; the sealer accepts a gate record |
| 6 | Dogfood at T1 Advisory — the adapter approves immediately and we measure the brief hold honestly | P3, P4, P5, P6, P7 | A published measured p50/p95 of the advisory hold, with sample count |
| 7 | T2 Hold-with-human — only after approver enforcement and the outage drills | P7.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]
| Field | Value |
|---|---|
| Repo | SecurityV0/sv0-gate-spike (new, throwaway) + artifacts into sv0-platform and sv0-documentation |
| Files | New: test/fixtures/github/deployment-protection-rule.*.json (captured deliveries); sv0-documentation/docs/architecture/research/2026-07-25-github-protection-rule-spike.md |
| Changes | Register 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. |
| Dependencies | None |
| Acceptance | The 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
| Field | Value |
|---|---|
| Owner | Sergey (selection) + Ivan (engineering consequences) |
| Inputs | The 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 |
| Outputs | ADR-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" |
| Blocks | P2.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). |
| Acceptance | The 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]
| Field | Value |
|---|---|
| Repo | sv0-platform |
| Files | New: 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. |
| Changes | An 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. |
| Dependencies | P0 for the payload parse; nothing else |
| Acceptance | Given 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
| Field | Value |
|---|---|
| Repo | sv0-platform |
| Files | Adapter 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) |
| Changes | Four 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. |
| Dependencies | P1 (the adapter process exists); P2's collections for the platform-side half — the adapter-side inbox does not wait |
| Acceptance | Kill-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]
| Field | Value |
|---|---|
| Repo | sv0-platform |
| Files | New: 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). |
| Changes | Three 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. |
| Dependencies | None to start; P0 fixes the enforcement_point and artifact field shapes before merge |
| Acceptance | Integration 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
| Field | Value |
|---|---|
| Repo | sv0-platform |
| Files | New: 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. |
| Dependencies | P2 (the record exists to hash); does not wait on D0 |
| Acceptance | The 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
| Field | Value |
|---|---|
| Repo | sv0-platform |
| Files | New: 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). |
| Changes | Four 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. |
| Dependencies | P2; D0 for the set contents (the mechanism ships before D0, populated with the dogfood repo's own owners) |
| Acceptance | A 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]
| Field | Value |
|---|---|
| Repo | sv0-platform |
| Files | New: 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). |
| Changes | gate_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). |
| Dependencies | P2 |
| Acceptance | Unit: 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]
| Field | Value |
|---|---|
| Repo | sv0-platform (ui/) |
| Files | New: 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). |
| Changes | A 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. |
| Dependencies | P2 contract frozen |
| Acceptance | cd 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]
| Field | Value |
|---|---|
| Repo | sv0-platform (src/gate-adapter/) |
| Files | src/gate-adapter/release-poller.ts, github-client.ts, status-report.ts |
| Changes | Claim 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. |
| Dependencies | P0 (callback URL parse), P1, P1.5 (leases and outbox), P2, P2.5, P3 |
| Acceptance | Against 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]
| Field | Value |
|---|---|
| Repo | sv0-platform + sv0-connectors (runbook only) |
| Files | New: 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) |
| Changes | Four 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). |
| Dependencies | P5 |
| Acceptance | Leg (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]
| Field | Value |
|---|---|
| Repo | sv0-platform (org configuration + workflows) |
| Files | .github/workflows/ (a spike/gated workflow), environment configuration via API |
| Changes | Enable 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. |
| Dependencies | P5, P6 |
| Acceptance | Ten 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
| Field | Value |
|---|---|
| Repo | sv0-platform (drills + configuration) + sv0-documentation (the recorded evidence) |
| Changes | The 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. |
| Dependencies | P1.5, P2.7, P7, D0 |
| Acceptance | The §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
| Item | Track | Repo | Files | Change | Acceptance |
|---|---|---|---|---|---|
| External-system string fix | 1 | sv0-platform | src/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 decomposition | 2 (Track 1 only if the selected use case needs environment-specific matching) | sv0-connectors | integrations/aws/src/sv0_aws/core/trust_policy_parser.py:202-220 | Today 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 credentials | 2 | sv0-connectors | shared/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 connector | not in this plan | — | — | Needed 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:
| ADR | Subject |
|---|---|
| 034 | Rehearsal persistence (what is and is not reopenable — §5.4) |
| 035 | Response-adapter write scope (one gate App, isolated write process, accepted risk of Deployments: read and write, preview-surface risk) |
| 036 | The 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) |
| 037 | Failure 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 |
| 040 | Gate 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 |
| 038 | Verdict degradation (the promotion degrade taxonomy, bridge one-sidedness → Unknown) |
| 039 | Gate 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:
- 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.
- The sealed-record widening (inside P2, +0.6) — the first draft asserted that no widening was
needed. That assertion was false:
DecisionRecordContentis a closed interface (src/domain/decision-records/types.ts:229) andsealDecisionRecordaccepts only aVerdictResult(src/services/verdict-engine.ts:1281). - 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).
| Phase | Track | Lane-weeks [assumed] | Change vs the first draft |
|---|---|---|---|
| P0 spike (expanded) | 1 | 0.6 | +0.3 — six new empirical questions |
| D0 freeze the product decisions | — | 0 | new; a decision gate, not engineering |
| P1 adapter | 1 | 0.8 | unchanged in size; ACK semantics corrected |
| P1.5 reliability substrate | 1 | 1.4 | new |
| P2 gate API + record + sealed-record widening | 1 | 1.8 | +0.6 — T1-26 |
P2.5 determinism + ApprovalPayload | 1 | 0.9 | new (absorbs the re-priced T1-16) |
| P2.7 approver authorization | 1 | 0.7 | new; was Track 2 |
| P3 evaluate + delta + stamping + owner resolution | 1 | 1.2 | unchanged |
| P4 UI + owner queue | 1 | 1.0 | unchanged |
| P5 release path | 1 | 0.7 | unchanged in size; retry semantics corrected |
| P6 verification | 1 | 0.8 | unchanged in size; leg (d) added |
| P7 dogfood at T1 Advisory | 1 | 0.6 | unchanged |
| P7.5 T2 enablement gate | 1 | 0.3 | new |
| P8 connector (Track 1 subset) | 1 | 0.3 | unchanged |
| P9 docs/ADRs | 1 | 0.5 | +0.1 — ADR-040 |
| Track 1 total | 11.6 | was 7.3 — the difference is the three items above | |
| P10 Track 2 last mile | 2 | 1.5–2.5 | unchanged |
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 kind — record_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.
| Store | Collections | Owner |
|---|---|---|
| Platform Mongo | gate_decisions, gate_installations, gate_policies, decision_records | The 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 store | gate_inbox, gate_dispatch | The 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.
| Field | Type | In summary row | Purpose / note |
|---|---|---|---|
_id | string (uuid) | no | — |
tenant_id | string | yes | Resolved from gate_installations, never from the webhook payload. |
schema_version | "1" | yes | — |
version | number | no | Optimistic 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 } | yes | Discriminated 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_point | union, §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) | yes | run_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. |
artifact | DeployedArtifactRef (§2.4) — { kind: "oci_image"; registry; repository; digest; resolved_at; resolved_from } or { kind: "none"; reason } | yes | R2.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_key | string = ${repository_id}:${environment}:${run_id}:${run_attempt} | yes | Unique 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" | no | This 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_authorized → dispatching (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" | yes | Same 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 } | yes | The named delta. This is the signal, not a recommendation word. |
content.rehearsal_verdict_hash | string (sha256:…) | yes | sha256(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_hash | string (sha256:…) | yes | sha256(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_hash | string (sha256:…) | yes | sha256(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" | yes | Bumped 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_ids | string[], engine render order | yes | Always 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} } | yes | absolute_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" } | yes | T1-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 } | yes | Track 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 } | yes | Which policy document produced the outcome, so a decision is reproducible without the current policy. |
content.declared_delta | object | null | yes | Optional enrichment. Absent is first-class. |
content.rationale | RationaleClause[] (reuse the shipped shape) | yes | Ordered 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" } | yes | T1-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_json | string | — | stableStringify(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_id | string | null | — | The 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. |
confirmation | RecordedConfirmation | 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" | no | Its 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_outcome | VerificationOutcome | 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 } | no | The 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_at | Date | no | Envelope 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_ofis inside the hashed verdict (src/rehearsal/types.ts:342) and is derived byderiveAsOf(src/rehearsal/service.ts:245-252) asmax(entity.updated_at)acrossloadAllTenantEntities(: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_materializedandtotals.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.
| Field | Computed over | In approval_content_hash? |
|---|---|---|
rehearsal_verdict_hash | the complete verdict, unmodified | NO — evidence and drift labelling only. Stored on gate_decisions and sealed in the summary row; never nested inside the payload |
rehearsal_projection_hash | the verdict with as_of, totals.entities_materialized and totals.baseline_paths removed | YES — 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.
| Field | Type | Why the approver's decision depends on it |
|---|---|---|
approval_payload_version | "1" | A schema change is a mismatch by definition, not a field diff. |
tenant_id | string | Resolved 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_id | Where it is being approved, and which run. run_attempt matters: a re-run is a new decision. |
artifact | DeployedArtifactRef | Which artifact. The commit is not the container (§5.2, R2.6). |
verdict | "approve" | "constrain" | "reject" | "unknown" | The computed outcome itself. |
authority_delta | named appeared / cancelled_with_wider_actions / unmapped destinations, counts, excluded_scope_terminals | The named delta is the signal the human reads. A destination appearing or disappearing changes the decision. |
coverage_factor_ids | string[] in engine render order | The 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_delta | object | null | Optional enrichment; absent is first-class. |
rationale | RationaleClause[] | 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: nullcase and thedeployment_callback_urlparse. 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_recordsrow per terminal gate decision and zero for non-terminal transitions; the shippedverifyDecisionRecordIntegritydetects a single-byte content edit and a re-parenting on that row;confirmed_bystays null. - Confirm path:
approval_content_hashmismatch refuses release; a mutation to an unrelated entity does not mismatch (provingas_ofand the tenant-wide totals are excluded — which holds only because the payload carriesrehearsal_projection_hash, and a companion assertion checks the serialized payload contains norehearsal_verdict_hashat any depth); a mutation to an entity inside the decision's own reach cone does mismatch; a mutation to the policy document does mismatch; anapproval_payload_versionorrehearsal_engine_versiondifference is a mismatch by definition and names that reason;request_changesseals 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_secondsand 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
versionreturns null and the caller surfaces 409; a release and a refuse racing on the same decision produce exactly one terminal state;release_allagainst an already-committed terminal disposition is a no-op that records the skip. - Owner routing: an
OWNED_BYsubject resolves to a named owner; an unowned subject recordsresolution: "unresolved", still notifies the operator address, and still queues. - Adapter isolation:
test/gate-adapter/isolation.test.tsresolves every relative import at any nesting depth and fails if one escapessrc/gate-adapter/— the authoritative gate, with the ESLint blocks as the in-editor early warning. Copied wholesale fromtest/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_ADVANCEDon 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-Idmismatch 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 sotest/scripts/seed-reset-collections.test.tsstays green. The adapter'sgate_inboxandgate_dispatchare 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 .../approvalsbefore 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_kindbefore the widening still verifies withverifyDecisionRecordIntegrityafter it, and adeployment_gaterow 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:
- Fixtures are captured, never authored. The
deployment_protection_rulefixtures are raw deliveries recorded in P0 and committed verbatim. A hand-written payload is not admissible. - 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.
- 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-spikeis private in an Enterprise org (§0), with agate-testenvironment, a trivial workflow, and no production credentials anywhere. Aworkflow_dispatchfires 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 .../approvalsreports 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 onif (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 /
opis 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 bydocker-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_rulesubscription. - 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/eventsresolves the tenant only fromgate_installationskeyed 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 →
requireTenanton/api/v1(src/api/app.ts:252-253) — and readreq.tenantId, never a query parameter, matching the rule stated atsrc/api/routes/rehearsal.ts:170-173. - The admin binding route is mounted pre-tenant-middleware behind
requireSuperAdminAllowingAgent, the same guard used atsrc/api/routes/admin/promotion-counterparts.ts:131. - A per-tenant
gate_enabledfeature flag is added toTENANT_FEATURE_FLAGS(src/domain/tenants/feature-flags.ts:24-27) and is the server-side boundary, exactly asrehearsal_enabledis ("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:
- 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. - Immutable user IDs. The approver set is compared on immutable identifiers, never on an email address or a display name.
- Primary and fallback approver sets, server-side, before the first T2 release. Stored on
gate_policiesper(tenant, repository_id, environment); the fallback set is inert until the hold exceedsfallback_after_seconds. - Self-approval prevention where the policy requires it, compared against the run's
triggering_actor_idin the same identifier space. This is a new primitive: today there is zeropermissionMiddlewareon the rehearsal, verdict and mitigation routes (src/api/routes/rehearsal.ts,verdict.ts,mitigation-actions.ts— no matches), and membership roles areowner | admin | member. What remains recorded rather than attested isconfirmed_byon 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.
- 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 (
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 isunknownand 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:
| Bound | Mechanism |
|---|---|
| Scope of installation | Install on the pilot repositories only, never org-wide. Enable the rule only on the specific environments in scope. |
| Scope per action | Per-decision tokens narrowed by repository_ids + permissions. |
| No lateral movement into the platform | The 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 exfiltration | The adapter never receives environment secrets; GitHub does not send them. |
| Detection | Every 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. |
| Containment | Uninstall 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: falseon 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_targetmust 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
StringEqualsonsub == repo:ORG/REPO:environment:<ExactName>, neverStringLikewithrepo:ORG/REPO:*, which defeats the gate entirely. Our own deploy path already pinsrepo: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.
| # | Gate | Evidence required |
|---|---|---|
| 1 | Reliability substrate green (P1.5) | The four kill-tests, the race tests and the lease-reclaim test, all passing in CI |
| 2 | Approver authorization enforced (P2.7) | Integration tests against the real route: M2M rejected, non-approver rejected, fallback windowed, self-approval blocked |
| 3 | Determinism green (P2.5) | The permutation test passing as a merge gate |
| 4 | Artifact binding proven (P5, P6 leg d) | One real deployment where the running image's digest equals approval_payload.artifact.digest |
| 5 | Outage drills run | Adapter 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 |
| 6 | Admin bypass deselected and re-read via the API | The API response, quoted |
| 7 | Product decisions frozen (D0) | ADR-039's disposition matrix filled; the held operation named; the approver sets populated |
| 8 | Production only: keyed-MAC / signature on decision records | The 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.
| Tier | Name | Behaviour |
|---|---|---|
| T1 | Advisory | Rule enabled. The run is held — briefly — and the adapter posts approved immediately alongside a status report carrying the verdict. |
| T2 | Hold-with-human | The pilot tier. Hold; release only on an authorised human's confirmation (§7.2). |
| T3 | Automatic | Auto-release on a clean verdict. Not in the pilot. |
| Tier | On approve | On constrain / reject / unknown | Exit criterion to the next tier |
|---|---|---|---|
| T1 Advisory | The 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-human | Hold; 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 Automatic | — | — | Not 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:
| Failure | Behaviour | Configurable? |
|---|---|---|
| Platform evaluation errors or times out, adapter healthy | MAY fail open after an explicit bounded timeout, with a loud audit event and a status report stating that no verdict was applied | Yes, 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 unavailable | Necessarily fails closed. The deployment stays held until recovery or a customer admin bypass | No. 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 layer | Who | Effect | Latency |
|---|---|---|---|
| 1. Policy kill switch | Tenant admin, in-product | kill_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 flag | Super-admin | gate_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 removal | Repo/org admin | Uninstall 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.
| # | Issue | Title | Repo | Track | One-line scope | Depends on | Supersedes / extends | File today? |
|---|---|---|---|---|---|---|---|---|
| S1 | #1885 | Spike: pin deployment_protection_rule payload, re-run and concurrency semantics | sv0-platform | 1 | SCOPE 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-weeks | — | new | YES — start today, after editing the body |
| S2 | #1886 | Gate response adapter: isolated, separately permissioned GitHub App service | sv0-platform | 1 | SCOPE 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 store | S1 (parse) | new | YES — start today, after editing the body |
| S3 | #1887 | Gate API, installation binding, and the gate decision record | sv0-platform | 1 | SCOPE 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-weeks | — | new; 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 |
| S4 | not filed | Gate evaluation worker + deterministic delta mapper with thresholds as data | sv0-platform | 1 | gate_evaluate + gate_deadline job types; policy-driven mode and thresholds; no threshold literal in code | S3 | extends #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 slice | YES — start today |
| S5 | #1888 | Honesty stamping: absolute per-source last_synced_at, rehearsal engine version, materializer flag set | sv0-platform | 1 | Unchanged by round 2. Three emitted fields on the rehearsal verdict (T1-15, BUILD not shipped); needed whether or not anything is persisted | — | new | YES — start today |
| S6 | not filed | Gate approval surface, three dispositions, and the sealed-decision list | sv0-platform (ui + api) | 1 | SCOPE 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 set | S3, S20 | extends #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 UX | after S3 |
| S17 | #1889 | Approval-owner resolution, notification, and queue placement | sv0-platform | 1 | Unchanged 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 platform | S3 | new — no existing issue covers routing or notification; #1611/#1588 are the internal-lifecycle seam, not a delivery mechanism, and #1852 is the disposition | YES — start today (contract half; the queue lands with S6) |
| S7 | not filed | Release path + status reports | sv0-platform | 1 | SCOPE 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 strings | S1, S2, S3, S4, S18 | new | after S4 |
| S8 | not filed | Verification of result: approvals reconcile, rule-presence poll, projected-vs-realized, artifact digest | sv0-platform | 1 | SCOPE 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 pilot | S7 | extends #1568 — Sergey explicitly said not to create a duplicate verification mechanism | after S7 |
| S9 | not filed | Nightly live gate probe | sv0-platform | 1 | Fire a real gated deployment nightly against deployed dev; fail loudly on any silent leg | S7 | extends #1630 (rehearsal dev probe → nightly deployed smoke test) | after S7 |
| S10 | not filed | Bridge one-sidedness → held/Unknown | sv0-platform | 1 | Feed analyzeBridgeCoverage into the rehearsal coverage factors so under-computed reach cannot present as a clean release | S4 | new; the diagnostic exists (src/ingestion/stitching/bridge-coverage.ts:17-22) but does not feed the verdict | after S4 |
| S11 | #1890 | Fix github_actions vs github external-system mismatch | sv0-platform | 1 | Unchanged by round 2. One-line allowlist fix + a matcher test | — | new (bug) | YES — start today |
| S12 | not filed | ADR-034 … ADR-040 + nav entries | sv0-documentation | 1 | SCOPE 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 contested | S1 | new; references docs#343 / PR #372 (read-IN / write-OUT / write-BACK taxonomy) as the ruling the adapter sits under, and does not re-derive it | YES — start today |
| S18 | NEW — file today | Gate reliability substrate: durable inbox, versioned CAS, dispatch leases, outbox, reconciliation-first retries, sweeper | sv0-platform | 1 | The 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-56 | S2 (adapter half), S3 (platform half) | new — nothing in the prior-art sweep covers crash or concurrency safety for this lifecycle | YES — file today, and ADR-040 is merge-blocking before it |
| S19 | NEW — file today | Determinism repair + ApprovalPayload + approval_content_hash | sv0-platform | 1 | Mirrored { 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] | S3 | new — the ordering half touches shipped rehearsal code and can start today, independent of everything else | YES — file today |
| S20 | NEW — file today | Approver authorization: human-only confirmation, immutable user IDs, primary + fallback approver sets, self-approval prevention | sv0-platform | 1 | Promoted 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 only | supersedes S14, which had this as a post-S13 Track 2 item | YES — file today (mechanism; contents follow D0) |
| S21 | #1907 | Demo promotion repository and two-phase pipeline — the thing the gate actually gates | new repo + sv0-demo-labs | 1 | The 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 2 | new — nothing in the prior-art sweep covers it, and the demo doc carried it as a checklist line rather than work | YES — file today, and it is on the demo critical path |
| S13 | not filed | Track 2: selected-use-case last mile | sv0-platform | 2 | Rehearsal mode, thresholds, envelope binding, approval-owner identity, partner runbook | Sergey's D1 | new | after the working session |
| — | — | — | Superseded by S20 and promoted to Track 1. Do not file it as a Track 2 item | — | — | — | ||
| S15 | not filed | Connector: OIDC sub decomposition (AWS) | sv0-connectors | 2 | Split repo:org/repo:environment:x into structured properties; no new permission | — | new | if the use case needs environment-specific matching |
| S16 | not filed | Connector: Entra federated identity credentials | sv0-connectors | 2 | The Azure analogue of the AWS OIDC edge; Application.Read.All already consented | — | new | second 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
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | A 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 on | Certain — it is an observed property of the callback URL, not a chance event | High (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. |
| R2 | Adapter down → deployment silently held up to 30 days (GitHub does not auto-redeliver) | Medium | High | The 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. |
| R3 | Verdict computed on a graph of unknown absolute age | High — no automated connector refresh exists | High | T1-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". |
| R4 | Partial connector refresh → bridge unformed → under-computed reach presented as clean | Medium | High | S10 wires one_sided to held/Unknown. Until then the coverage factors disclose it and the pilot runbook names the full refresh set per tenant. |
| R5 | Counterpart fingerprint change → 422 on exactly the deployments worth examining | Medium | Medium | Map staleness is already a coverage factor; the gate surfaces "re-confirm the map" as an actionable state rather than an error. |
| R6 | Admin bypass or rule deletion defeats the gate | Medium | High | Bypass deselected and verified by API read-back; rule-presence poll (P6 leg (c)) with an alert. |
| R7 | Public-preview API changes under us | Low–Medium | High | Recorded 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 environments | High for any deployed demo | Medium | Attach #1762 as a blocking dependency; dogfood on dev where the path exists; do not schedule a deployed-env partner demo before it clears. |
| R9 | Rehearsal latency / tenant size — the engine materializes the tenant and rejects above SYNC_MAX = 10_000 | Medium | Medium | Evaluation is a worker job, not a request; over-size yields unknown (held), never a release. Measure on enterprise-nimbus early (§10.2 U3). |
| R10 | confirmed_by remains fenced and someone populates it to save time | Low | High | The 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. |
| R13 | The pilot's approval is recorded, not attested — decision_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 it | Say 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. |
| R14 | Webhook 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 why | Medium (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. |
| R15 | Verdict recomputation is not byte-deterministic, so recompute-and-compare produces false 409s and blocks legitimate releases | High until S19 lands — the tie-break is absent by construction in both adapters | High (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). |
| R16 | The owner is recorded but anyone can confirm, so the demo's differentiator is a claim the code does not support | Was certain in the first draft | High (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. |
| R17 | The 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_tag | High — it is how our own pipelines already work | High (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. |
| R18 | Two components both commit a terminal decision — release vs refuse, or an owner release racing release_all — because the unique natural key deduplicates creation only | Medium | High (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. |
| R11 | Threshold values leak into code and quietly pre-empt the use-case selection | Medium | High | The "two policy documents, two outcomes, zero code change" test (§6.1) is a merge gate on S4. |
| R12 | Demo 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 trail | Medium | Medium | Check 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_attemptincrements, 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_attemptstays 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.
| # | Unknown | Why it is the highest risk | Cheapest test |
|---|---|---|---|
| U3 | Measured end-to-end latency, and whether the target tenant fits the rehearsal admission bound | Sergey'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 verdict | Two 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 claim | Correction | Where |
|---|---|---|
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 mechanism | It 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 field | That 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 state | It 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 it | It 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 today | It 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 needed | False. DecisionRecordContent is closed and the sealer takes only a VerdictResult | §5.1, §2.1a |
| The pilot fails open when SecurityV0 is down | Impossible as a general rule. Split by which component failed; the adapter-down case necessarily fails closed | §8.2 |
| T0 Observe is a tier | It has no trigger — GitHub sends the event only when the rule is enabled. Deleted | §8.1 |
| T1 Advisory never holds | It holds briefly. A rule that never answers holds the run to GitHub's 30-day timeout | §8.1 |
| Approver enforcement is Track 2 | It is a T2 prerequisite (S20) | §7.2 |
| Track 1 is 7.3 lane-weeks, critical path 4.8 | An 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.