Skip to main content

GitHub Deployment Gate — architecture and data flow


title: "GitHub Deployment Gate — architecture and data flow" description: "Trust boundaries, data scanning, freshness, the deployment→authority join, persistence, and the Track 1 / Track 2 split for sv0-platform#1884." tags:

  • deployment-gate
  • architecture
  • connectors
  • rehearsal

Status. The build is committed (Ivan, CTO, 2026-07-24). The use case is not selected — Sergey reserved that for a focused working session. This document is therefore written as a plan of record for the substrate, and is deliberately valid for all four use-case candidates — the two on the recommended shortlist and the two recommended against. Where a decision belongs to the session, it is marked Track 2 and left open.

What this document is for. Ivan's ask: "first we need to do architectural updates for the connectors to see exactly how the data flow will be and how data scanning will be happening." Sections 2 and 3 are the answer; everything else is the frame they need to be read in.

Language rules observed throughout. This is a hold-and-release mechanism with a named human in the loop. It is not described as "control" — that word is reserved until enforcement and verification are both proven in the bounded scenario (Sergey, 2026-07-24). The gate runs in parallel with the Trajectory direction and does not replace or supersede it: Authority remains the foundation, Trajectory remains the strategic expansion. Determinism, evidence packs and the read-only connector model are build properties, not the pitch.

Evidence conventions. Repository claims cite path:line in /Users/mini2/dev/securityv0/repos/sv0-platform unless another repo is named; all were opened at the cited line on 2026-07-24. Provider claims cite docs.github.com. Every number is labelled measured, documented, or assumed. Status labels: shipped / spec-only / does not exist.

Revision 2 (2026-07-24), after an external adversarial review. Twelve findings, several critical, three verified against the code. What changed here, so a reader of revision 1 is not misled: the audit record is not hash-chained and is not tamper-evident against a write-capable actor (§5.1); the compared object is now an explicit ApprovalPayload rather than the rehearsal verdict (§5.3); recompute-and-compare has a determinism prerequisite the code does not yet satisfy (§5.4); the failure posture is split by which component failed and there is no general fail-open (§4.3); the reliability substrate — durable inbox, CAS'd lifecycle, dispatch outbox with leases — is now visible (§6.2); the approval is bound to the immutable artifact digest, not only the commit (§4.2); approver authorization is a component and a T2 prerequisite (§5.5); and the T0 Observe tier is deleted because GitHub sends no event that could feed it (§4.3).

Revision 3 (2026-07-24), settling cross-document inconsistencies. Five changes, each of them a decision rather than a rewording, so a reader of revision 2 is not misled. The adapter receives the webhook and owns the durable inbox — the receiver and gate_inbox move inside the write-isolated boundary, and the storage vocabulary is now four platform collections (gate_decisions, gate_installations, gate_policies, decision_records) plus two in the adapter's own separately credentialed store (gate_inbox, gate_dispatch); the adapter's isolation claim is stated as no handle to the platform's tenant graph, retiring "no MongoDB handle", which forbade that inbox (§1, §5.1, §6.3). The payload carries rehearsal_projection_hash, not the whole-verdict hash — a nested hash over the unprojected verdict would have re-imported the volatility the compared object excludes, so the general rule is now stated: no field inside approval_content_hash may be derived from an excluded field (§5.3, §5.6). One state machine — §6.2 renders ADR-036 Decision 1 exactly, computing replaces evaluating, and verification is a field, not a state. The sealed-record shape is decided: a versioned discriminated record_kind (gate_decision_v1), ADR-036 Decision 7, 0.6 lane-weeks [assumed] (§5.1, component 8). The verdict × human-disposition matrix lives in ADR-039 with empty cells; this document points at it and settles it neither by reproduction nor by omission (§4.3).

No ML, anywhere in this design. Every join in section 4 is exact-match on a curated or provider-supplied identifier. Where a match is ambiguous the architecture requires Unknown and a hold — never a best guess, never a similarity score.


1. Decision points and trust boundaries

Three planes, one write-capable component.

The same three planes, drawn to scale with the write-capable process isolated:

GitHub Deployment Gate trust boundaries — the read-only connector plane, the SecurityV0 platform, and the single write-capable response adapter that holds the only source-system write scope.

Read the diagram as four rules.

  1. Every arrow into the read-only plane is a read. The connector model is a hard constraint: connectors never write to a source system. The one GitHub-shaped thing that already exists in the graph today is a by-product of reading an AWS trust policy, not a GitHub call (sv0-connectors/integrations/aws/src/sv0_aws/core/transformer.py:2848-2856).

  2. There is exactly one gate App, and exactly one process inside it holds a source-system write scope. One App is not a preference, it is the only design GitHub permits: "GitHub Apps can only review their own custom deployment protection rules" (REST: Review custom deployment protection rules for a workflow run) — documented. The rule is owned by whichever App is enabled on the environment, that App is the one that receives the deployment_protection_rule delivery, and no other App can POST the review. A second, write-only App identity could not release the hold, so it is not on the table.

    The write scope itself is unavoidable: GitHub's write endpoint requires Deployments: read and write on the App (Permissions required for GitHub Apps), and there is no narrower scope than "deployments: write" — the same scope also permits creating and deleting deployments. That is an accepted-risk item in the adapter's threat model, not something to gloss.

    Least privilege is therefore achieved by process and credential isolation, not by a second App identity. Four mechanisms, all of them ours rather than GitHub's: the write path runs as a separately deployed, separately credentialed adapter process; it holds no handle to the platform's tenant graph (it has its own separately credentialed store for gate_inbox and gate_dispatch, and no reach into entities, findings or evidence packs — the older "no MongoDB handle" phrasing is retired because it forbade the durable inbox this design requires); the import boundary is lint-enforced (the pattern already shipped for src/mcp at eslint.config.js:55-110, with test/mcp/isolation.test.ts as the authoritative gate); and it mints a per-decision installation token scoped by repository_ids and {"deployments": "write"} exactly as GitHub's own example shows (Creating custom deployment protection rules). On top of those: an allowlist of one operation, idempotency, read-back verification, and a kill switch. The only identity separation that survives contact with GitHub's API is this gate App versus any future read-only GitHub connector App — which is a real and worthwhile separation, and a different claim from splitting the gate itself in two.

  3. The MCP surface cannot become the write path even by accident. The tool registry is a frozen six-tool contract (src/mcp/server.ts:33-46) and the only network handle a tool gets is GET-only by construction (src/mcp/api-client.ts:3-5, SAFE_METHOD, assertSafeMethod throws). A read-side gate tool is cheap; an approve tool structurally cannot live there. This is a hard boundary, not packaging.

  4. The reliability substrate is four named components, not an implementation detail. gate_inbox (a durable webhook inbox in the adapter's own store — the receiver returns 2XX only after the write, because GitHub redelivers only failed deliveries), the versioned compare-and-swap lifecycle on gate_decisions (platform Mongo), gate_dispatch (an outbox with atomic claims and lease expiry in the adapter's own store, because multiple replicas may poll the same authorized decision), and the approver-authorization component that sits between the notification and the human (platform). §6.2 specifies them. They are drawn here because leaving them implicit is what produced the acknowledged-then-lost, double-commit and duplicate-dispatch defects the round-1 review found.

Who receives the webhook, decided once: the adapter does. The receiver sits inside the write-isolated boundary, with gate_inbox and gate_dispatch, for three reasons: the adapter holds the GitHub App credential and must validate the delivery HMAC with it; it is the only component that can answer its own rule, so the credential cannot be split from the rule it serves; and it already has a separately credentialed durable store, which is what makes "2XX only after a durable write" implementable without the platform being up. The storage vocabulary follows from that split, and it is the vocabulary every artifact uses: platform Mongo holds gate_decisions, gate_installations, gate_policies and decision_records; the adapter's own separately credentialed store holds gate_inbox and gate_dispatch (§5.1).

Where the hold actually happens. GitHub holds the job. We never hold anything — we answer, or we stay silent and GitHub keeps holding. This is worth stating precisely because it determines the failure posture: "When a custom deployment protection rule is triggered it will wait for up to 30 days for a webhook event response before it times out and the workflow job fails" (Configure custom protection rules) — documented. Fail-closed, but on a 30-day horizon, which is far too long to be our only safety net. Our own decision deadline is our behaviour and must always be labelled as ours.

The consequence that governs §4.3's failure posture. Because GitHub holds the job and only the response adapter can post approved, the ability to fail open is itself a dependency on the adapter being alive. If the adapter, its private key, the network or the GitHub API is unavailable, nothing can release the hold — the run waits until GitHub's timeout fails it. A blanket "we fail open when SecurityV0 is down" promise is therefore not implementable and is not made anywhere in this document. The posture is split by which component failed; see §4.3.

The credential-isolation claim, and what it rests on. Two documented mechanisms, used together: environment-scoped secrets are released only once the job proceeds — "Once a job is approved (and any other deployment protection rules have passed), the job will proceed. At this point, the job can access any secrets stored in the environment" (Reviewing deployments) — and an OIDC trust policy conditioned with StringEquals on …:sub == repo:ORG/REPO:environment:<ExactName> (Configuring OpenID Connect in AWS). Honest caveat: GitHub states the secret-release sentence verbatim for required reviewers, and separately says the job proceeds once "any other deployment protection rules have passed". Our claim combines two sentences; it is not one crisp documented sentence. Say it that way.


2. Data scanning — what is scanned, by whom, on what trigger

2.1 What lands, and how

A connector reads a source system, transforms to a NormalizedGraph (src/ingestion/types.ts:224-239), and POSTs it to the platform. Nodes are one of twelve types and edges one of twenty-two (src/ingestion/types.ts:1-13, :17-42). Everything downstream — execution paths, authority chains, cross-system reach — is materialized platform-side, not emitted by connectors.

Authority question the gate asksWhich connector supplies itNode/edge shape
Who can assume what in AWSawsidentity, role, permission (statement-level), HAS_ROLE, GRANTS
Who can act in Azure/Entraazure-foundry, azure-sentinel-soc, entra-servicenowmanaged identities, ARM role assignments, app role assignments
What actually ranaws CloudTrail extractor; Foundry/Sentinel execution_evidenceexecution_evidence, EVIDENCES
What a repo can becomeonly the AWS trust-policy parser's github_oidc connection → AUTHENTICATES_AS → IAM role (transformer.py:2839-2872)connection node, sourceSystem: "github_actions"
Cross-system reachno connector — 8 correlation rules platform-side (src/ingestion/stitching/rules/registry.ts)correlations, stitched paths

Deletion safety is a connector contract, and it is the sharpest edge in ingestion. evidenceCompleteness.sources is the sole deletion-safety gate; scanScope.errors.permissionDenied is explicitly observability-only and does not gate deletion (src/ingestion/types.ts:204-221). A connector that fails a cell silently but reports available will cause real entities to be diffed away — and a gate verdict computed on a graph that just lost entities will under-state reach. This is why the gate's freshness contract (section 3) is expressed per-source, not as a single tenant-level "is it fresh" boolean.

2.2 How scans are actually initiated today — the honest answer

There are two trigger paths in code. Only one has ever moved real data, and it is a human on a laptop.

Path A — manual CLI (--submit). Every connector's documented operating mode (sv0-connectors/AGENTS.md). This is how every tenant in every environment got its data.

There is no scheduled refresh. The only cron in sv0-connectors/.github/workflows/ is servicenow-keepalive.yml:4-6, which curls a dev instance so it does not hibernate — it ingests nothing. The connector's own CI scan workflow passes only --json/--md, never --submit (entra-servicenow-scan.yml:87-88,100-101). CI never refreshes tenant data.

Path B — the platform scheduler + subprocess driver. Shipped, wired, enabled by default (src/workers/scheduler.ts, src/index.ts:203-215), with a manual API trigger at POST /api/v1/scan-runs that returns 202 and enqueues (src/api/routes/scan-runs.ts:166). It is blocked twice over:

  • The driver is AWS-only. const DEFAULT_BINARY = "sv0-aws"; (src/workers/connector-driver.ts:172). There is no dispatch on connector_kind anywhere in the driver — scheduling an Entra scope would spawn sv0-aws.
  • The connector runtime is not in the platform image. Dockerfile is node:22-alpinenpm cinpm run build. No Python, no connector binary. The deploy docs state the prerequisite rather than satisfying it (deploy/fixtures/README.md:118-120).

The documented intended AWS cadence is 6 h (deploy/fixtures/enterprise-nimbus-aws.json:20,26,32,38) — documented intent, not observed behaviour, because it requires the binary on the API host's PATH.

There is no synchronous "scan now". POST /api/v1/scan-runs is 202-and-enqueue, then a subprocess of unbounded duration, then ingestion, then a 60 s stitch debounce (src/services/stitching/stitch-debounce.ts:54documented, repo constant), then evaluation. No endpoint blocks until the graph reflects a fresh scan.

2.3 What the gate requires — and the named gap

The gate needsTodayGap
A tenant graph of known absolute age at decision timeAge is "whenever a human last ran a CLI" — assumed days to weeks, inferred from the absence of any automated trigger in either repoNo automated refresh for any connector except AWS, and not even AWS in the shipped image
Per-source freshness the verdict can assertcomputeStaleSources (src/rehearsal/service.ts:276-310) measures staleness relative to asOf = max(entity.updated_at) (deriveAsOf, src/rehearsal/service.ts:245-252, threshold INGEST_STALENESS_THRESHOLD_DAYS = 7 at src/rehearsal/coverage.ts:40)A uniformly 60-day-old graph produces zero freshness warnings. The detector measures drift between connectors, not absolute age
A refresh that does not make the graph worseCorrelation bridges fire only when both partner connectors co-ingest; running one connector of a multi-connector tenant leaves reach silently missing (src/ingestion/stitching/bridge-coverage.ts:5-11)A partial refresh is more dangerous than no refresh, and the diagnostic that detects it is explicitly "a DIAGNOSTIC, not a hard gate" (bridge-coverage.ts:17-22) and does not feed the rehearsal coverage factors
A scan the gate can trigger202-and-enqueue only, AWS-only driver, no runtime in the imageAny on-demand refresh path requires fixing connector_kind dispatch and packaging connector runtimes — prerequisites for any second scheduled connector, not GitHub-specific

Named gap, in one sentence: the platform can compute a verdict at gate time, but it cannot currently tell you how old the estate it computed on is, and it cannot refresh that estate on demand. Section 3 is the design response.


3. Freshness — how old is our data when GitHub asks?

This is the section the document exists for.

3.1 The two clocks

(Dates are illustrative of shape, not measurements. The only labelled quantities are below.)

QuantityValueLabel
Our ACK budget on the webhook10 s (Best practices for using webhooks)documented
GitHub's hold ceiling30 days, then the job fails (Configure custom protection rules)documented
GitHub latency SLO for the eventnone publishednot documented — must be measured
Stitch debounce60 s (stitch-debounce.ts:54)documented (repo constant)
Relative staleness threshold7 days before asOf (coverage.ts:40)documented (repo constant)
CloudTrail observation window30-day default, 90-day cap (cloudtrail_extractor.py:89-95,148)documented (repo constant)
Absolute age of a real tenant graphdays to weeksassumed — inferred from the absence of any automated trigger; no deployed Mongo was queried
Duration of one full refresh setunknownmust be measured before any on-demand option is committed

The asymmetry that decides the design. GitHub's clock moves in minutes. Our clock moves when a person runs a CLI. The gap between them is unbounded and currently invisibleasOf is max(entity.updated_at), never wall-clock, so the platform is structurally unable to report absolute age today.

Two different timestamps, and only one of them can sit inside a compared hash. This distinction decides §5's design, so it is stated here, once, in full.

as_ofper-source last_synced_at
What it isA tenant-wide maximum: deriveAsOf takes max(updated_at) over loadAllTenantEntities — every entity in the tenant (src/rehearsal/service.ts:245-252, load at :361)An absolute wall-clock stamp per contributing source_system, already carried on each entity (src/domain/entities/types.ts:296)
What moves itAny connector scan touching any entity, including entities with no relationship whatsoever to the held deploymentOnly a sync of that specific source
What it can supportLabelling the snapshot the verdict was computed against; drift narration after the factA freshness class that is comparable across time: "AWS was 3 days old then, AWS is 3 days old now"
Inside the compared hash?No. It is stored as evidence on the hold record and used for drift labelling. Including it would refuse a release because an unrelated Jira scan ran — a mismatch with no relationship to the decisionNo, not the raw stamp — CORRECTED 2026-07-25. It moves on every sync of that source, including one that makes the data fresher, so comparing it would refuse valid releases for a reason unconnected to the deployment. It is sealed in the transcript and rendered to the approver, never compared. What the compared object carries is the quantized freshness band derived from it, because a band is one of the things the human approves (ADR-036 Decision 2, ADR-038 Decision 3)

totals.entities_materialized and totals.baseline_paths (src/rehearsal/types.ts:364-365) have exactly the same tenant-wide-counter property as as_of and are excluded on the same grounds. Note that all three sit inside the rehearsal verdict structure (as_of at src/rehearsal/types.ts:342), so the raw whole-verdict hash (rehearsal_verdict_hash) inherits their volatility — which is exactly why that hash is evidence about the rehearsal and never a release gate on its own, and why the payload carries a separately named rehearsal_projection_hash instead. §5.3 specifies the projection.

3.2 How stale data produces a wrong verdict

Three concrete mechanisms, all silent:

  1. False Approve — a role gained authority after our last scan; the cone we rehearse is smaller than the real one.
  2. False Reject/Constrain — an over-broad grant was already remediated; we hold a stale finding.
  3. Silent under-reach — the deployment's authority lives in a system whose partner connector was not run, so the correlation bridge never formed and the cone simply omits it, with no error (bridge-coverage.ts:5-11).

Only the first is a safety failure. That asymmetry gives the rule.

3.3 The options

OptionWhat it isCostKills which failure
A — accept staleness, labelled, with an absolute freshness ruleStamp wall-clock last_synced_at per contributing source_system on every gate verdict (the datum already exists on EntityDoc.last_synced_at, src/domain/entities/types.ts:296); add a new absolute deterministic freshness rule; degrade to Unknown (held) when it trips; render the ages on the check-run status reportSmall; no connector work; one new deterministic rule + one emission changeMakes 1 and 2 visible and converts 1 into a hold. Does not fix 3 on its own
B — trigger an on-demand scan on webhook receipt, poll, then decideReceive → enqueue the tenant's full refresh set → wait for ingest + 60 s stitch → computeLarge and blocked: needs connector_kind dispatch in the driver (connector-driver.ts:172), connector runtimes packaged into the platform image (Dockerfile), credentials for every connector on the API host, and a refresh of the whole declared set — a partial refresh is worse than noneWould fix 1–3, but scan duration is unmeasured and a partial or failed refresh degrades the graph inside the hold window
C — pre-warm: refresh between pipeline phases, before the gated job runsThe partner's pipeline is two-phase: phase 1 applies infrastructure, a connector refresh runs, phase 2 (the cutover) is the job that enters the gated environmentSame Path-B prerequisites for the refresh itself, but the latency sits outside the hold window and the failure is visible before the gate firesFixes 1–3 with no time pressure, and the graph reflects the post-phase-1 estate

3.4 Recommendation

Ship A now as substrate. Adopt C as the target operating model and as a named partner precondition. Do not make B the pilot's primary path.

Three reasons.

  1. A is required no matter which option wins. Even under C, a verdict must state the absolute age of every source it relied on, because C can fail quietly (a refresh that ran but returned partial evidence completeness). The stamp is the honesty mechanism; the refresh is an optimisation of it. A is also what makes section 5's safety mechanism mean anything, and it is the reason the compared object carries a freshness band rather than a timestamp. The gate compares an approval_content_hash when the named human confirms. Absolute per-source last_synced_at is the input that makes a freshness class computable at all — but the class is what is compared, not the stamp behind it. A raw stamp moves on every sync of that source, including one that makes the data fresher, so comparing it would refuse a valid release for a reason unconnected to the deployment; the quantized band moves only when a source crosses a threshold, so "the approver blessed a decision computed on AWS-3-days-old" holds while a change to AWS-11-days-old correctly demands a new confirmation. The raw stamps are sealed in the transcript and rendered to the approver, and never compared (CORRECTED 2026-07-25; ADR-036 Decision 2). as_of cannot do the job either: it is a tenant-wide max that moves on unrelated ingest, so comparing it would refuse releases for reasons with no relationship to the decision — and comparing no freshness term at all would leave a matching hash proving only that nothing in a possibly two-month-old graph moved, reassuring and empty. Option A is therefore not a nice-to-have next to §5: without the stamps there is no band, and without the band there is no freshness term the compared object can legitimately carry. With it, staleness degrades the verdict to Unknown and holds, instead of silently producing a confident wrong answer.
  2. C is free architecture, not new code. The use-case work already requires a two-phase promotion pipeline for an independent reason: the webhook fires before the job, so if the gated job is the apply that creates the production twin, the promotion engine examines the pre-deploy production identity and returns 422 (src/rehearsal/promotion.ts:86-95). Our own prior research reached the same conclusion in February (sv0-documentation/docs/architecture/research/2026-02-27-pre-deployment-assurance-research.md §3.6 / Appendix A). The same two-phase shape that makes the verdict semantically correct also gives us a refresh window. Take both from one precondition.
  3. B's failure mode is the one we least want. A refresh triggered inside the hold window that only partially completes leaves the graph internally inconsistent rather than merely old — the silent-missing-reach case — and we would be computing a verdict on it under time pressure.

3.5 The freshness contract — the deterministic rules

Each of these is a hold, not a warning. All are exact comparisons on stored timestamps and stored booleans; none involve scoring.

RuleTriggerGate outcome
F1 — absolute source ageFor any source_system contributing to the reach cone, now − max(last_synced_at) > tenant.max_source_ageUnknown, held. Name the source and its age in whole days
F2 — refresh-set completenessAny connector in the tenant's declared refresh set (per sv0-documentation/docs/runbooks/connector-tenant-mapping.md) has never synced, or synced before the deployment's phase-1 timestampUnknown, held
F3 — one-sided bridgeanalyzeBridgeCoverage returns one_sided / no_partner for a default-enabled cross-system rule whose two sides are both in the declared refresh setUnknown, held. This wiring does not exist todaybridge-coverage output does not feed src/rehearsal/coverage.ts
F4 — empty cone is never evidence of no accessZero grants or zero reach pathsAlready the platform's rule (src/services/verdict-engine.ts:643-650, "the historical fail-open"). Carry it into the gate unchanged
F5 — counterpart fingerprint mismatchcomputeCounterpartFingerprint(target) !== agentPair.target_fingerprint → 422 (src/rehearsal/promotion.ts:86-95,129-131)Unknown, held — never a silent pass, and never an auto-reconfirm
F6 — grade asymmetryAny inferred/asserted-grade input in the Approve pathAlready enforced by the branded ApproveGate type whose sole constructor is gateForApprove (verdict-engine.ts:540-547, :809). Inferred inputs can drive Constrain/Reject/Unknown, never Approve

The single governing rule, stated for the plan:

Stale or incomplete data degrades the verdict to Unknown and holds. It never produces Approve, and it never produces a confident wrong answer in silence. Unknown is already first-class product vocabulary, not a new concept.

What must be rendered with the verdict, in this order. The two unconditional promotion coverage invariants come first: promotion-control-subtraction and promotion-correlation-asymmetry are pushed on every promotion verdict regardless of inputs, and control-subtraction is forced to render ahead of even the snapshot clock (src/rehearsal/coverage.ts:458-493, render order at :59-64). The code's own justification is the gate's justification: "a promotion verdict without the control-subtraction caveat reads as a clean CI gate — exactly the false confidence the issue forbids." This is the documented ground for keeping a human in the loop for the pilot, and it holds.

What the comparison actually computes, and the blind spot it always declares:

Promotion-mode rehearsal comparison — the tested copy&#39;s reach set subtracted from the production copy&#39;s, the three resulting counts, and the control-subtraction blind spot the engine declares on every promotion verdict.

GitHub gives us a place to put it: a status report on the same endpoint with state omitted, up to 10 posts, 1024 characters each, Markdown (Creating custom deployment protection rules) — documented. That budget is tight enough that the durable artifact must live on our side and the status report must be a headline plus a deep link.


4. What the gate needs that no connector provides — and the join

4.1 The split: webhook vs connector

The webhook answers what is being deployed, where, and by whom for free. It answers nothing about authority.

Gate questionSourceConnector needed?
Which repo / orgrepository.id (immutable), repository.full_nameNo
Which environmentenvironment (string, name not id)No
Which commit / refsha, ref — "always populated from the check suite"No
Which PRpull_requests[]No
Who triggered itsenderNo
Which installation (to mint the token)installation.idNo
Which run to call backonly embedded in deployment_callback_urlNo — GitHub documents no format, but ours is now pinned against a captured delivery (§9.1): 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
run_attempt, workflow identityone extra GET /repos/{owner}/{repo}/actions/runs/{run_id}No
Which artifact will actually be deployednot in the payload at all — the immutable image digest must be resolved before the hold and bound into the approval (§4.2)No — but it is a required pre-hold step, not a free field
What authority this deployment confersthe identity graphYes — this is the entire SecurityV0 contribution

Source: Webhook events and payloads → deployment_protection_rule. Two traps that shape the data model: there is no run_id and no workflow_run object in the payload, and deployment is nullable — a naive parser will null-deref. The event is available to GitHub Apps only; it cannot be delivered to a repository or organization webhook.

4.2 The join

There are three candidate join paths, and only one of them is trustworthy today.

(i) The curated binding — the one the pilot uses. (repository.id, environment) → subject entity is an operator-authored map, not an inference. Precedent and shape: promotion_counterpart_maps (src/storage/mongo/collections.ts:91) with a super-admin PUT /api/v1/admin/promotion-counterparts (src/api/routes/admin/promotion-counterparts.ts:111), mounted pre-tenant-middleware. gate_installations is the same pattern with a different key. This is deliberate: the binding must be curated because a wrong binding silently gates the wrong subject, and no similarity match is permissible.

(ii) The OIDC edge — real, but repo-granular, not environment-granular. The AWS connector already emits a connection node keyed github_oidc:<sha256(repo_pattern)[:16]> with an AUTHENTICATES_AS edge to the IAM role, storing the raw sub condition value verbatim — e.g. repo:myorg/myrepo:* (transformer.py:2839-2872, parser at core/trust_policy_parser.py:202-220). It gives this repo pattern can assume this role. It does not decompose the pattern into repo / ref / environment, so it cannot answer does the production environment specifically reach this role. Fixing that is a connector-side change with no new permission, and it is worth doing regardless of the use case.

Two related defects to fix in the same slice:

  • String mismatch. DEFAULT_SYSTEM_CLASSIFICATION.external_systems contains "github" (src/domain/system-classification/types.ts:59) while the connector stamps "github_actions", and the matcher compares the prefix before the first : — so the GitHub-OIDC node never matches the external-system rule.
  • The platform's stitching extractor deliberately excludes GitHub OIDC subjects as non-Entra (src/ingestion/stitching/extractors/aws-role-oidc-trust-subject.ts:53-55). No correlation rule bridges GitHub to anything. That is correct today and must be a conscious decision, not a discovery, if we ever want a GitHub-side bridge.

(iii) The Azure half — does not exist, and is the cheapest high-leverage read we hold permission for. Entra federatedIdentityCredentials (the Azure analogue of the AWS OIDC trust) is not ingested: zero hits across both repos, and the shared Entra client exposes no such call (sv0-connectors/shared/sv0_azure/sv0_azure/entra.py). Application.Read.All is already requested and consented in both Azure connectors. Adding it is an extractor change inside an existing connector with an existing scope — not a new connector and not a new permission ask.

sha is evidence, not a join key — and it is not the artifact either. Nothing in our graph is keyed on a commit, and any design that tries to derive authority from the commit is an IaC-diff capability — a different build (#1782 — WS4, intent from config), explicitly not in this slice.

The stronger point, and the one that changes the data model: the commit GitHub reports to the rule can differ from the container that is actually deployed. Our own workflows prove it, so this is not a hypothetical:

  • .github/workflows/deploy-azure-staging.yml:67-70 re-resolves current main HEAD with gh api /repos/{repo}/commits/main after the triggering run, with the comment "two rapid pushes can finish CI out of order" — so the deployed commit is whatever main is at resolve time, not the commit that fired the event.
  • .github/workflows/deploy-prod.yml:6,55 takes an operator-supplied image_tag input and deploys that. A tag is mutable; it names whatever the registry currently points it at.

Ruling for this design: resolve the immutable image digest before the hold, and bind it. The digest, plus workflow identity, run id, run attempt and environment, are fields of the ApprovalPayload (§5.3), and the gated job must deploy exactly that digest — checked on the read-back leg (component 19). Approving sha alone approves a claim about source, not about the thing that will run.

This is artifact binding, not image scanning. We assert an identity equality between what was approved and what is deployed. We do not inspect image contents, look for vulnerabilities, or make any statement about the artifact's quality — image scanning is an explicit non-goal of this build and nothing in the digest field implies otherwise.

4.3 Enforcement tiers, and what happens when the join fails

One tier vocabulary, used everywhere in this document and in every sibling artifact. There are no "monitor / enforce / off" modes and no "advisory / soft / hard" numbering; those names are retired.

TierNameBehaviour
T1AdvisoryRule enabled. The job is held for as long as it takes us to evaluate; the adapter then posts approved immediately alongside its status report. A brief hold, not "never holds"
T2Hold-with-humanThe pilot tier. Hold; release only on a named, server-side-authorized human's confirmation (§5.5)
T3AutomaticAuto-release on a clean verdict without a human. Not in the pilot

There is no T0 "Observe" tier, and any artifact that still lists one is wrong. GitHub sends deployment_protection_rule only once the rule is enabled on the environment (Creating custom deployment protection rules) — documented. A tier defined as "the rule is not enabled, compute anyway" has no event to compute from, so it was never implementable. If a shadow mode is wanted later it needs an explicit workflow step or API call as its trigger; that is a separate item to file, not a capability to imply here.

One consequence worth stating because it is easy to get backwards: a custom deployment protection rule that never answers holds the job until GitHub's 30-day timeout. Every enabled tier therefore holds. T1's honesty obligation is to say how long — the brief hold is our evaluation latency, which is open question 7 and is not documented and must be measured. "Advisory" describes the decision, never the absence of a hold.

Which computed verdict permits which human disposition is not settled in this document, and not settled by omission either. The computed-verdict × allowed-human-disposition matrix lives in ADR-039, published with every cell deliberately empty and labelled as the working-session decision. This document references it by name and does not reproduce it. Two consequences follow and both are stated rather than implied: nothing here should be read as deciding that Constrain / Reject / Unknown can never be released — they stay held pending that matrix, not because this document has ruled on them; and if any exception cell is opened, it becomes an explicit accept exception disposition on the gate_decisions lifecycle with stronger authorization, a recorded reason, a scope and an expiry — never a plain release. Both shapes are already accommodated by the lifecycle in §6.2 and the payload in §5.3; what is open is which cells are permitted.

Failure posture is split by which component failed, not by "is SecurityV0 up". There is no general fail-open, and this document makes no unconditional "we fail open" statement anywhere. The reason is structural and was stated in §1: the only component that can post approved is the response adapter, and it gets its authorization by polling the platform. Fail-open is itself a capability that requires a healthy adapter.

Which component failedWhat actually happensWhat we may promise
Platform evaluation errors or is unreachable — adapter, credential, network and GitHub API all healthyThe adapter has a live token and a live GitHub connection, and can post. It MAY fail open after an explicit bounded timeout (a configured per-environment value, recorded on the affected run), posting approved with a status report stating plainly that no verdict was appliedThis is the only fail-open we can offer, and it is bounded and per-environment
Adapter down · private key unavailable · network partition · GitHub API unavailableNothing can post approved. The run necessarily fails closed — it waits until recovery, until a customer admin bypasses the rule, or until GitHub's 30-day timeout fails the jobSay exactly this. Do not promise otherwise. There is no configuration that changes it

The cost of the first row, stated rather than hidden. Bounded platform-outage fail-open is not free: it requires a cached, signed, per-environment failure policy the adapter can evaluate with the platform down, plus the durable inbox (§6.2) so the delivery is not lost while the platform is unavailable — and the inbox only does that job because it lives in the adapter's own store, not in platform Mongo (§1). And even with both, the adapter cannot synchronously write the audit record while the audit platform is down — the record is written on recovery from the adapter's own durable log, which means there is a window in which a release has happened and the platform does not yet know. That is a real consequence of choosing fail-open, and it belongs in the customer conversation.

(b) The named human has not decided is a different question and must never share a rule with the above. 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 own 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 — label it that way everywhere it appears.

Join failures are deterministic and depend only on the tier:

FailureT1 AdvisoryT2 Hold-with-human (the pilot tier)
No binding for (installation.id, repository.id)no tenantPost a status report naming the gap and release. Alert the operatorPost a status report naming the gap; do not decide; alert the operator; the run stays held. Our deadline escalates the notification — it never decides
Binding resolves a tenant but no subject for (repository.id, environment)Status report + release with an explicit "not evaluated" commentUnknown — held
Ambiguous binding (more than one subject)Status report naming every candidate; never pick oneUnknown — held
Subject resolves but the entity is missing / changed / unconfirmedThe shipped promotion degrade taxonomy applies verbatim: 404 AGENT_NOT_FOUND / 422 NO_COUNTERPART / COUNTERPART_NOT_CONFIRMED / COUNTERPART_CHANGED (src/rehearsal/promotion.ts:31-95)Unknown — held, with the specific error surfaced
The immutable artifact digest cannot be resolved (§4.2)Status report saying the approval is commit-scoped only, then releaseUnknown — held. An approval that cannot name the artifact is not an approval of a deployment
Tenant too large for synchronous rehearsal413 TENANT_TOO_LARGE above SYNC_MAX = 10_000 entities (src/rehearsal/service.ts:55-56)Unknown — held. A real ceiling to check against a partner tenant before committing
The graph fails the freshness contract (F1–F3)Status report with per-source agesUnknown — held
Platform evaluation errors, adapter healthyRelease with "no verdict applied"Bounded, per-environment, configured fail-open or stay held — whichever the environment's failure policy says. The configured value is recorded on every affected run
Adapter / key / network / GitHub API unavailableNothing can be posted; the run stays held until recoveryIdentical: necessarily held. No tier and no configuration can change this

One repository must never map to two tenants. gate_installations is the gate's only tenant resolver — a webhook arrives with an installation and a repository, never with X-Tenant-Id. It lives in platform Mongo and is read platform-side, by the gate_evaluate worker: the adapter receives and durably inboxes the delivery but resolves no tenant, which is what keeps the "no handle to the platform's tenant graph" claim true. The map enforces the platform's tenant-isolation invariant, and a duplicate binding must be rejected at write time, not resolved at read time.

The kill switch. Its default is hold_all. release_all is not a flag anyone can drift into: it requires an explicit, human-initiated, per-tenant action, the acting human is recorded on every drained decision, and each drained run carries a status report saying no verdict was applied. The kill switch loses to an already-committed human terminal decision — a release_all racing an owner's refuse must not overwrite it. This is not a policy preference to enforce in the handler; it is a consequence of the compare-and-swap lifecycle in §6.2, where every transition names its allowed prior states and a terminal disposition is one-way. Two independent switches then exist, both required: ours (the tier plus the kill switch) and the customer's (removing the rule from the environment, which any repo admin can do via DELETE …/deployment_protection_rules/{id}). Because the second is out of our hands, detecting removal is our job — poll GET /repos/{owner}/{repo}/environments/{env}/deployment_protection_rules (REST: Deployment protection rules). Equally: admin bypass is ON by default — an administrator can force all waiting jobs through (Reviewing deployments) — so any claim that the gate holds requires "Allow administrators to bypass configured protection rules" to be deselected and verified in the pilot repo.


5. Persistence — what the gate stores, and what it deliberately does not

Ivan asked whether a rehearsal should become a persistent entity. It is answered in two halves, and only one of them is built now. The correctness half — the real defect — is solved without a rehearsal store. The visibility half — what Ivan actually asked to see — is served by the sealed gate decision plus a list surface.

5.1 The ruling

Build gate_decisions (the versioned hold lifecycle), gate_installations (the binding) and gate_policies (the threshold policy table) in platform Mongo; build gate_inbox and gate_dispatch (the reliability substrate, §6.2) in the adapter's own separately credentialed store; and seal exactly one summary row into decision_records at the terminal decision. Define an explicit ApprovalPayload (§5.3) as the object the human approves; hash that, and recompute-and-compare that when the named human confirms. Do not build a rehearsal artifact store, a rehearsal collection, a compare view, or an overlay entity collection in this slice.

Six collections, six jobs, one set of names used in every artifact — and which store each lives in is part of the name, not an implementation detail (§1: the adapter receives, so the inbox and the outbox are its, not the platform's).

Platform Mongo — four:

  • gate_decisions — new; owns the hold lifecycle. Hot path, one document per deployment hold, concurrent across repositories. Every state transition lives here, and every transition is a compare-and-swap on a version field (§6.2).
  • gate_installations — new; App installation and per-environment binding.
  • gate_policies — new; the verdict-threshold policy table and its lifecycle (component 14 — thresholds are data, not code). The ApprovalPayload carries the policy row id, its version and the threshold values actually applied, so a policy change demands a fresh confirmation (§5.3).
  • decision_records — the existing shipped sealed record collection (#1780). At the terminal decision (release / refuse) the gate seals exactly one summary row into it. Insert-only and pointer-linked — see the honesty note below on what that does and does not prove.

The adapter's own separately credentialed store — two:

  • gate_inbox — new; the durable webhook inbox. The receiver — which is adapter-side — returns 2XX only after this write.
  • gate_dispatch — new; the outbox for GitHub callbacks, with atomic claims and lease expiry.

The adapter still holds no handle to the platform's tenant graph: owning these two collections is not a reach into entities, findings, evidence packs or any platform collection. The older phrasing "the adapter holds no MongoDB handle" is retired — it forbade the durable inbox these rulings require. The names deployment_holds, gate_bindings and GateHoldDoc are retired too. Any artifact still using them is wrong.

Why this split, precisely:

  • It removes record-chain contention from the per-deployment hot path. Decision records link per (tenant_id, workload_id) with unique indexes tenant_workload_content and tenant_workload_previous (src/storage/mongo/schema.ts:216-221) — one root, one successor per workload. A gate fires per deployment attempt, so two concurrent deploys of one workload would collide as 409 CHAIN_ADVANCED. Putting the lifecycle in gate_decisions and touching decision_records once, at the end, makes the collision structurally impossible rather than something to retry around.
  • The sealer must be widened, and that is real work this plan prices. The earlier claim that "no union widening is needed" was wrong and is withdrawn. DecisionRecordContent is a closed interface with a fixed signal set (src/domain/decision-records/types.ts:229), and sealDecisionRecord accepts only an existing VerdictResult (src/services/verdict-engine.ts:1281-1284) — a gate record cannot be passed to it today. Two shapes were viable: (i) a versioned discriminated record kind with gate-specific sealed content, or (ii) a generic sealed-envelope primitive reusing computeDecisionIntegrityHash (src/evidence/integrity.ts:42-59) without going through the verdict sealer. The shape is decided, not deferred: (i) — this is ADR-036 Decision 7, and it is an engineering decision rather than a product one, so it does not go to the working session. Concretely: a record_kind discriminator on the sealed content with a gate_decision_v1 variant carrying gate-specific content, plus a sealer entry point that accepts it. A discriminated kind keeps one audit surface and one query path and lets the verifier reject an unknown kind loudly, where (ii) would create a second class of record that queryDecisionRecords does not understand. It is a migration-visible schema-version bump that touches the shipped verdict path — a schema change with a migration story, not a free reuse. Priced at 0.6 lane-weeks [assumed], matching the plan; the shipped break_set content shape is untouched. Component 8 in §6.3 carries it, and it is not costless.
  • Retention is not the safety mechanism — recompute-and-compare is, over the right object. The defect is genuine: GitHub's hold can last up to 30 days, so a human confirms minutes-to-hours after the status report posts, and under recompute-live they could bless a materially different computation with nothing recording the substitution. The fix is not a rehearsal store. It is separating the hashes by job (§5.3): rehearsal_verdict_hash over the rehearsal bytes verbatim, which is evidence only and never a release gate on its own; rehearsal_projection_hash over the same verdict with the volatile tenant-wide fields projected out, which is the only rehearsal hash the payload carries; and approval_content_hash over the complete immutable ApprovalPayload rendered to the approver, which is the gate. At confirmation the full decision is recomputed and approval_content_hash compared; any difference in policy, freshness class, mapper version, subject, evidence set, owner, outcome or rehearsal requires a new human confirmation — never a silent pass. The primitive already ships (sha256(canonicalSerialize(...)) at src/services/verdict-engine.ts:363-364, and computeContextFingerprint at :504-520, which already drives 200-replay / 409-CHAIN_ADVANCED); what is new is what it is computed over.
  • Recompute-and-compare has a prerequisite the code does not yet satisfy. See §5.4: verdict recomputation is not byte-deterministic today, so the comparison would produce false mismatches before it produced true ones. The ordering fix ships before the comparison is load-bearing.
  • confirmed_by stays fenced, and the record is not tamper-evident against a write-capable actor. DecisionRecordDoc.confirmed_by is spec-only, always null, 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). A gate approval is exactly a confirmed production approval. So, plainly: decision_records is insert-only and pointer-linked, protected by an unkeyed integrity checksum — it is not hash-chained and not cryptographically authenticated. The code says so in its own words: the seal "is an UNKEYED checksum, not a cryptographic authenticator… A write-capable actor who recomputes hashes after editing defeats it; a keyed MAC or signature plus predecessor-hash chaining is the documented future step before decision records back a confirmed production approval" (src/evidence/integrity.ts:25-36). The preimage does include previous_record_id, so re-parenting and cross-tenant transplant are verify-loud — but that is pointer linkage, not predecessor-hash chaining. The keyed-MAC/signature work is therefore a named prerequisite before T2 holds back a production approval, not a nice-to-have filed for later (component 9, §6.3). For the pilot the human confirmation is recorded on the gate_decisions document, which claims no tamper-evidence at all. Budget the mechanism, not the field.
  • Ivan's "visible when a user logs in" is the visibility half, and it is served now, cheaply. The surface is "every deployment we gated, with its verdict, its evidence and who approved it" — a list over the sealed gate decisions, not a rehearsal index. #1780 already ships the immutable record, and queryDecisionRecords exists on the interface and the adapter with zero non-test callers (src/storage/storage-adapter.ts:893, src/storage/mongo/adapters/decision-record-adapter.ts:87; grep confirms only tests). A browsable history of held/released deployments is one route and one page.

Say the honest thing, in these words, wherever this surface is described: what you can reopen is the sealed verdict and its evidence, not a live graph. No artifact may imply that a past reach cone can be re-rendered.

Not built now, explicitly: the overlay entity collection, a re-renderable pinned reach cone, rehearsal-to-rehearsal comparison, and temporal graph replay. Revisit at the pilot's first genuinely disputed hold, or when the selected use case demands a reopenable graph — whichever comes first.

Why the not-built list is not merely deferred scope. Retrofitting the past is impossible with today's substrate: correlations, tenant_correlation_settings, system_classifications and promotion_counterpart_maps are all unversioned current-state, EntityQuery has no temporal field (src/storage/storage-adapter.ts:99-127), and F5's fingerprint gate will 422 on exactly the deployments most worth re-examining. A rehearsal store built on top of that would look like a reopenable graph and would not be one — which is precisely the claim the honesty line above forbids. Sealing the approved payload into the record collection we already write introduces no new subsystem and promises only what it can deliver — though it is not free: it needs the discriminated sealed-record kind priced as component 8.

Also recorded, because it changes what we must build if we ever go further: the Reports UI cannot render a stale artifact honestly. ReportsPage.tsx and ReportDetailPage.tsx contain zero occurrences of stale / freshness / as_of / generated_as_of (verified by grep) — they render created_at only. Any future browsable rehearsal surface must ship absolute-age labelling with it, or it manufactures false currency.

5.2 The minimum shape designed now

Irreversible items first. Items 1–10 cannot be retrofitted onto records already written; items 11–12 are hours and should ship in the same slice.

  1. Canonical bytes, once. The sealed verdict content is canonicalSerialize(verdict) (src/rehearsal/engine.ts:1555) verbatim — never a re-derived summary. captured_at is already deliberately excluded from the verdict so the bytes are stable.
  2. Bind, do not embed twice. The gate_decisions document references the sealed decision_records row by id; that row holds the bytes; the bytes are hashed once, in the record's preimage. Exactly one row is sealed, at the terminal decision — not one per state transition.
  3. Stamp the algorithm. rehearsal_engine_version (new const) and the materializer flag set (path_collapse_enabled, scope_terminals_enabled, chain_contract_enabled, already computed per-run at src/rehearsal/service.ts:676-680). A verdict that does not name the algorithm that produced it is under-specified — this is right independent of any comparison feature.
  4. Stamp absolute freshness. Wall-clock last_synced_at per contributing source_system (src/domain/entities/types.ts:296), plus the declared refresh set and which members were present. This is F1/F2's evidence and it is needed whether or not anything else persists.
  5. Stamp the immutable artifact digest (§4.2), together with workflow identity, run_id, run_attempt and environment. The approved object must name the thing that will run, not only the commit GitHub reported.
  6. Three hashes, three jobs — and only one rehearsal hash is in the payload (§5.3). rehearsal_verdict_hash = sha256(canonicalSerialize(verdict)) over the verdict verbatim — evidence and drift labelling only, never a release gate, and never carried in the payload. rehearsal_projection_hash = the same computation over the verdict with as_of, totals.entities_materialized and totals.baseline_paths removed — this is the one the payload carries. approval_content_hash = sha256 over the canonical ApprovalPayload, excluding as_of, totals.entities_materialized, totals.baseline_paths and any other tenant-scoped counter that moves on unrelated ingest. as_of is stored on the hold record as evidence and for drift labelling; it is never part of the compared object. The general rule, so this 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 is computed over the same projection the payload uses.
  7. Recompute-and-compare approval_content_hash at confirm. At the moment the named human confirms, the full decision — not just the rehearsal — is recomputed and the payload hash compared. On any difference in policy, freshness class, mapper version, subject, evidence set, owner, outcome or rehearsal, the release is refused and a new human confirmation is required; the current cone is re-presented. Never a warning, never a silent pass.
  8. Determinism is a prerequisite, not an assumption (§5.4). The ordering fix and its permutation test land before item 7 is load-bearing, or the comparison generates false mismatches.
  9. Three dispositions, not two. gate_decisions state transitions and the sealed record contract both carry release · refuse · request changes. "Request changes" keeps the hold in place and posts a comment on the run; it calls neither approve nor reject on GitHub. It is a distinct outcome from refuse and must not be folded into it. Which computed verdict may reach which of these dispositions is ADR-039's matrix, published with empty cells and open (§4.3) — an accept exception disposition would be a fourth, added only if that matrix opens an exception cell.
  10. Every transition is a compare-and-swap on a version field, with an explicit allowed-prior-state set and a one-way terminal-disposition lock (§6.2). Dispositions are not idempotent by accident; they are idempotent by construction.
  11. Give the artifact a URL a check-run can link to. Today the Promotion tab has no URL parameter at all: agentMode is useState (ui/src/pages/DeploymentRehearsalPage.tsx:124) and searchParams.get appears once, for role (:173). A check-run cannot deep-link a reviewer to a specific rehearsal. Small, and blocking for the demo.
  12. Reset-list hygiene. Every new platform collection — gate_decisions, gate_installations, gate_policies — joins the tenant reset lists so the regression guard (test/scripts/seed-reset-collections.test.ts) stays green. The adapter's own store (gate_inbox, gate_dispatch) is outside that guard's reach by construction, so it needs its own reset path in the adapter — a separate item, not a line in the platform's list.

5.3 ApprovalPayload — the object the human approves and the hash covers

The payload is the contract; the hash is only its fingerprint. The round-1 review's finding was that the earlier design hashed canonicalSerialize(rehearsalVerdict) — which is simultaneously too narrow (the human approves an outcome derived from policy, thresholds, the named authority delta, freshness, coverage, subject binding, artifact identity and approval owner, none of which are in the rehearsal bytes; the same rehearsal can yield a different outcome under a different policy document) and too broad (as_of and the tenant-wide totals are inside the rehearsal verdict and move on unrelated ingest, so the comparison would refuse releases for reasons unrelated to the decision).

ApprovalPayload is immutable once rendered to the approver. It is what the approval surface displays, what the status report summarises, and what approval_content_hash covers.

FieldContentsIn the compared hash?
payload_versionSchema version of this contractYes
subjectSubject entity id + display name, resolved via gate_installations (repository.id, environment)Yes
enforcement_pointinstallation.id, repository.id, environment, tierYes
artifactImmutable image digest, workflow identity, run_id, run_attempt (§4.2)Yes
commitsha, ref — evidence of source, not of artifactYes
outcomeThe gate outcome: Approve / Constrain / Reject / UnknownYes
authority_deltaThe named delta the outcome is about — paths, destinations, actionsYes
policyPolicy document id + version + the threshold values actually appliedYes
mapper_versionVersion of the verdict→gate-outcome mapperYes
engine_identityVerdict ENGINE_VERSION, rehearsal_engine_version, materializer flag setYes
freshness_classThe quantized band derived from per-source absolute last_synced_at, plus the declared refresh set, which members were present, and which freshness rules firedYes — the band, not the stamps. The band is comparable across time (§3.1). The raw per-source last_synced_at values sit outside the compared hash: they move on every sync, including one that makes the data fresher, so they are sealed in the transcript and rendered to the approver but never compared (ADR-036 Decision 2)
coverageCoverage factors, control-subtraction caveat firstYes
evidence_setIds of the evidence the decision citesYes
approval_ownerImmutable user ids of the primary and fallback approver sets (§5.5)Yes
rehearsal_projection_hashsha256(canonicalSerialize(...)) over the verdict with as_of, totals.entities_materialized and totals.baseline_paths removed — see the seam note belowYes — the hash, not the bytes. This is the only rehearsal hash the payload carries. A substantive rehearsal change therefore does require re-confirmation
rehearsal_verdict_hashsha256(canonicalSerialize(verdict)) over the complete verdict, unmodifiedNo — evidence and drift labelling only, stored alongside the payload, not in it. It moves on unrelated ingest, because as_of and the totals live inside those bytes
as_ofderiveAsOf tenant-wide max (src/rehearsal/service.ts:245-252)No — stored on the hold record as evidence and for drift labelling
totals.entities_materialized, totals.baseline_pathsTenant-wide counters (src/rehearsal/types.ts:364-365)No — same reason
captured_at, timings, notification metadataWall-clock envelopeNo

The one seam this creates, named rather than hidden — and the general rule that closes it. as_of and the totals live inside the rehearsal verdict bytes, so rehearsal_verdict_hash moves whenever an unrelated connector scan moves them. Carrying that hash in the payload would re-import the volatility through the nest and the comparison could never pass. Therefore the two rehearsal hashes are separately named, and only one of them is ever in the payload:

FieldComputed overIn approval_content_hash?
rehearsal_verdict_hashthe complete verdict, unmodifiedNo — evidence and drift labelling only
rehearsal_projection_hashthe verdict with as_of, totals.entities_materialized and totals.baseline_paths removedYes — this is the one the payload carries

Both are stored; only the projection is compared. The projection is an explicit, tested function — not an implicit "we forgot to include it" — and the removed-field list above is its specification.

The general rule, stated 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. A future field that summarises the verdict, the graph or the tenant is admissible only once it is computed over the projection.

5.4 Determinism — the prerequisite recompute-and-compare rests on

Recomputation is not byte-deterministic today. Stating this plainly matters: without the fix, recompute-and-compare produces false mismatches, and a gate that refuses releases for phantom reasons gets switched off.

canonicalSerialize sorts object keys recursively but deliberately preserves array order — its own comment says arrays "preserve the engine's deterministic ordering (already sorted above)" (src/rehearsal/engine.ts:1551-1557). That contract holds only where every array actually was totally ordered upstream.

The real exposure is tie ORDER, not subset selection. It is worth separating these because the round-1 review named the wrong mechanism:

  • Not a truncation problem on this path. All four rehearsal call sites pass { limit: 0 } (src/rehearsal/service.ts:362 and the sibling loads), and both adapters gate slicing on if (limit > 0) (src/storage/mongo/adapters/correlation-adapter.ts:60-62, src/rehearsal/in-memory-adapter.ts:146-147) — so no cutoff selects a different subset here.
  • Yes a tie-order problem. queryCorrelations sorts .sort({ last_confirmed_at: -1 }) with no secondary tie-break (correlation-adapter.ts:60), and MongoDB does not guarantee a stable sort, so two identical queries can return equal-timestamp correlations in different orders. The in-memory adapter mirrors this on purpose — its comment reads "No _id tie-break is added — the Mongo adapter has none, and adding one would diverge from real behavior" (in-memory-adapter.ts:133-139).
  • Any other paginated or limited read that feeds the verdict carries the subset risk as well; the audit is per-read, not once.

The fix, in three parts:

  1. Add { last_confirmed_at: -1, _id: 1 } to both adapters, and keep them mirrored — the in-memory adapter's value is that it behaves like Mongo, so both change together or neither does.
  2. Give every array serialized into the verdict a total comparator ending in a guaranteed-unique last key. sortReachConeByRisk (src/rehearsal/engine.ts:867-888) is the pattern to copy: it captures the build index before sorting and uses it as "guaranteed-unique stable last key" in its own words.
  3. Test it as a permutation test: shuffle the loaded correlation array, recompute, assert byte-identical output. Not a 101-tie cutoff test — that exercises a truncation this path does not hit and would pass while the real defect survives.

5.5 Who may approve — authorization, not routing

This is a component, not a field. The demo's differentiator is that the workload owner approves without GitHub access; recording who approved while letting anyone approve would make that claim hollow. §6.3 component 17 resolves and notifies the owner; component 30 enforces who may act, and it is a T2 prerequisite — not a Track 2 nicety.

Four requirements, all server-side, all before the first T2 release:

  1. The human confirmation route rejects all M2M and delegated-agent identities. This must be enforced in the route handler, because machine principals skip membership middleware entirely — "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" (src/api/middleware/auth-middleware.ts:374-386). A route that assumes middleware established a human is wrong by construction.
  2. Resolve immutable user IDs — never email, never display name. Both are mutable and both are re-assignable.
  3. Primary and fallback approver sets are enforced server-side, not merely rendered. The sets are part of the ApprovalPayload (§5.3), so a change to them requires a fresh confirmation.
  4. Self-approval is prevented where the policy requires it — the deployment's triggering sender may not be the confirming approver under that policy.

What the pilot may still say, and what it may not. With respect to confirmed_by, the approval is recorded, not attested (§5.1 — unkeyed checksum, no keyed MAC yet). That honest limitation is about proving after the fact who approved. It is not a licence to leave who may approve unenforced: authorization is enforced server-side and ships with T2.

5.6 The hash binding, and the isolation guarantee

The diagram below is the mechanism, not a storage overlay: it shows which object is hashed, which fields are deliberately kept out of it, where each hash lives, and what the comparison at confirm-time actually compares.

What this buys, in one line: a human cannot bless a decision that has since changed in any way that matters, and cannot be forced to re-approve because an unrelated Jira scan moved a tenant-wide counter. What it does not buy, stated in the next line: the resulting record detects corruption, transplant and re-parenting, but a write-capable actor who recomputes the checksum defeats it — see §5.1. The substitution is recorded; it is not attested.

The rule, stated as a hard constraint for the architecture doc:

Persist the result, never the inputs-as-entities. Nothing representing a hypothetical or a proposed change may exist in any collection an entity query can reach. This is why the overlay entity collection is on the not-built list and stays there. If a sealed verdict must ever be drawn, the drawing is built client-side from the sealed bytes — exactly as ui/src/lib/proposed-overlay.ts already does — and it is a picture of what was recorded, not a re-rendered live cone.

Why this is not paranoia. Today isolation is structural: the rehearsal engine runs against src/rehearsal/in-memory-adapter.ts, a Map behind a Proxy that throws RehearsalAdapterUnsupported for every storage method it does not implement (:5-23, :168-181) — there is no Mongo handle in the module, and writesObserved is telemetry proving the run stayed dry (src/rehearsal/service.ts:799-818). Nothing has to remember to filter. If a hypothetical ever became storage-resident, the chain that follows is all shipped machinery: src/evaluator/index.ts:52 reads entities unfiltered (limit: 0) → a real FindingDoc is written → the build_evidence_pack worker seals a SHA256 evidence pack about an agent that does not exist → src/services/posture-service.ts:103-105 counts it in the tenant's headline posture → and because an Approve record can never coexist with any active finding of any type (verdict-engine.ts:88-92, :896), a hypothetical could flip a real production workload's verdict from Approve to Unknown, silently.

Isolation guarantee for the gate, in one line: the gate writes only to the decision plane; the observed-state plane is written only by ingestion; no gate artifact is reachable from queryEntities / countEntities / getEntitiesByIds / getSubgraph, and therefore no gate artifact can move a posture number, create a finding, or enter an evidence pack.


6. The full loop

6.1 Sequence

The life of one promotion end to end, with every box labelled BUILT, SPIKED or DESIGNED so the diagram cannot be read as a claim that the loop runs today:

Life of one gated promotion — the publish job, the held cutover, the webhook receiver, the rehearsal comparison, the human confirmation and the dispatch back to GitHub, each box marked BUILT, SPIKED or DESIGNED.

Two seams on that path are deliberately absent from the diagram — the between-phase refresh, and how the published artifact identity would reach us. Both are unresolved; see the diagram catalogue for why, and §9.2 for the open questions themselves.

Two properties of this sequence are non-negotiable and both come from documented GitHub behaviour: the rehearsal cannot run inside the webhook request (10-second ACK budget), and a redelivery poller is a required component, not an optional one"GitHub does not automatically redeliver failed webhook deliveries" (Handling failed webhook deliveries). Without it, a receiver that is down leaves a customer's deployment silently held for up to 30 days.

6.2 The reliability substrate — three visible components

(Numbered 6.2 because it must be read before the component map; the map is §6.3.)

The round-1 review found three defects with one root cause: the lifecycle had no concurrency or crash safety, and the substrate that would provide it was invisible in the design. It is drawn in §1, it appears in the §6.1 sequence, and it is specified here.

(1) gate_inbox — durable inbox. The ACK boundary is a correctness boundary. GitHub's redelivery affordance covers failed deliveries only. If the receiver returns 2XX and then dies before the platform durably stores the event, GitHub considers the delivery successful and the deployment is held with nobody computing anything — the customer sees a silent hold to the 30-day timeout. Rule: return 2XX only after a durable inbox write, or after confirmed durable platform acceptance. Add a sweeper for inbox rows in received with no evaluation job. The test is a crash test on both sides of the ACK/enqueue boundary — kill the process after the write and before the ACK, and after the ACK and before the enqueue.

(2) Versioned compare-and-swap on gate_decisions. The unique natural key dedupes creation only. It does nothing for transitions: a release and a refuse arriving together, or an owner's release racing release_all, can both read held and both commit. Rule: add a version field; every transition is an atomic findOneAndUpdate carrying the expected version and the allowed prior states; a terminal disposition sets a one-way lock; and the kill switch must LOSE to an already-committed human terminal decision. The precedent to follow ships today — the optimistic-locked update on src/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-46, whose contract is "Returns null when version does not match — caller should surface 409 Conflict and re-read".

(3) gate_dispatch — outbox with atomic claims and leases. Callback delivery is not crash-safe without it. Multiple replicas may poll the same authorized decision with no claim, GitHub may accept the callback before the adapter dies without recording it, and — the sharp one — a timeout or a 4xx does not prove the deployment is still held. Rule: reconciliation-first retries — read GitHub's approval state before every retry, never after. Durable outbox rows, atomic claim, lease expiry so a dead replica's work is reclaimed rather than duplicated.

The lifecycle, with the authorized/dispatching/delivered split made explicit. Refusal gets the same symmetric lifecycle as release — a refusal that silently fails to deliver is exactly as bad as a release that does.

One state machine, and this is it. The union below renders ADR-036 Decision 1 exactly — no more and no less. The state is computing, never evaluating; that older name is retired everywhere.

Verification is not a lifecycle state. It is a separate field on the same documentverification: "pending" | "verified" | "drifted" — set by the verification leg (component 19) after released / refused. Modelling it as a state was the three-way disagreement between our artifacts, and it was also unsafe: a verification outcome would have overwritten a terminal disposition, so a drifted deployment would no longer read as released. As a field, both facts survive: what was decided and whether the world matched it.

delivery_failed and ambiguous are first-class terminal states, not error logs. ambiguous specifically means we could not read the provider's state, and it must alert rather than resolve itself in either direction. overdue is a holding state, not a decision — our deadline escalates the notification and never decides (§4.3) — and timed_out_by_github records GitHub's behaviour, not ours. unbound is reachable from any state and means no binding resolves the delivery.

6.3 Component impact map

Track 1 = use-case-independent substrate, starts now. Track 2 = use-case-dependent last mile, scoped after the working session.

#ComponentRepo / areaChangeNew or extendedTrack
1Empirical spike repo (callback URL, re-run, concurrency)throwaway private Enterprise repopin three undocumented behaviours before the data model freezes. Two are pinned by the captured delivery — the callback-URL format, and that a re-run re-holds with an incremented attempt but a byte-identical callback URL (§9.1). Concurrent waits on one environment are still unobservedNew1 — do first
2The one gate App (identity, webhook secret, Actions:read + Deployments:read+write)newregistration + config. It owns the rule on the environment, so it is the only App GitHub permits to review itNew1
3Gate ingress / webhook receiver — in the adapter, not the platformthe response-adapter process (§1: it holds the App credential, must validate the HMAC with it, and is the only component that can answer its own rule)HMAC validation, durable gate_inbox write BEFORE the 2XX into the adapter's own store, <10 s ACK, then a durable handoff that creates the gate_decisions row idempotently on its natural key and enqueues gate_evaluate. Tenant resolution stays platform-side — the receiver reads no binding and no tenant graphNew1
4Redelivery pollerplatform src/workersfetch deliveries, find non-OK, redeliverNew1
5Response adapter (deployments: write)separately deployed process, own credential — same App, isolated process; also hosts the receiver (component 3) and its own store (components 32/33)no handle to the platform's tenant graph (it owns gate_inbox and gate_dispatch in its own separately credentialed store, and reaches no platform collection — the retired phrasing was "no MongoDB handle"); lint-enforced import boundary; mint a per-decision install token scoped by repository_ids + {"deployments":"write"}; one allowlisted operation; idempotency; read-backNew1
6gate_installations collection + super-admin routeplatform storage + src/api/routes/adminApp installation + per-environment binding; pattern-match promotion-counterparts.ts:111New1 (shape) / 2 (what the subject is)
7gate_decisions collection + versioned CAS state machineplatform storage (platform Mongo)per-deployment natural key; owns the hold lifecycle; version + expected-prior-state on every transition; one-way terminal lock (kill switch loses to a committed human decision); three dispositions — release · refuse · request changes. The state union is ADR-036 Decision 1, rendered exactly in §6.2: received · computing (not evaluating) · held · changes_requested · release_authorized · refusal_authorized · dispatching · released · refused · delivery_failed · ambiguous · overdue · timed_out_by_github · superseded · unbound. verification: "pending" | "verified" | "drifted" is a separate FIELD on the same document, never a state — a verification outcome must not overwrite a terminal disposition. References the sealed row by id. Precedent: cluster-resolution-record-adapter.ts:40-46New1
8Terminal seal into decision_recordsrequires a versioned discriminated record kindsrc/domain/decision-records/types.ts:229, src/services/verdict-engine.ts:1281-1284seal exactly one summary row at the terminal decision (ApprovalPayload + all three hashes + graph anchor + confirmation). The earlier "no widening needed" claim was wrong and is withdrawn: DecisionRecordContent is a closed interface and sealDecisionRecord accepts only a VerdictResult, so a gate record cannot be sealed today. Decided shape (ADR-036 Decision 7, shape (i)): a versioned discriminated record kind — a record_kind discriminator on the sealed content with a gate_decision_v1 variant and a sealer entry point that accepts it, so the verifier can reject an unknown kind loudly (the alternative — a generic sealed envelope over computeDecisionIntegrityHash — would create records queryDecisionRecords does not understand). Migration-visible schema-version bump touching the shipped verdict path; priced at 0.6 lane-weeks [assumed], not free. The existing break_set shape is untouchedExtended1
9Keyed MAC / signature + predecessor-hash chaining (the confirmed_by integrity mechanism)src/domain/decision-records, src/evidence/integrity.ts:25-36sealed confirmation event / counter-signed envelope; predecessor's hash in the preimage, not only its _id. Today's seal is an unkeyed checksum the code itself names as insufficient "before decision records back a confirmed production approval"New1 — a NAMED PREREQUISITE before T2 holds back a production approval. Until it ships the pilot's approval is recorded, not attested, and that limitation must be stated to the customer rather than inherited silently
10ApprovalPayload + three hashes + recompute-and-compare at confirmsrc/rehearsal/service.ts, engine.ts, gate confirm pathassemble the ApprovalPayload (§5.3) — policy id+version+thresholds, mapper version, engine identity, materializer flags, absolute per-source last_synced_at (BUILD — not shipped today), artifact digest, approver sets. rehearsal_verdict_hash (whole verdict) = evidence and drift labelling only, never in the payload; rehearsal_projection_hash (verdict minus as_of / totals.entities_materialized / totals.baseline_paths) = the one the payload carries — the projection is an explicit tested function; approval_content_hash = the gate, excluding those same fields and the raw per-source last_synced_at stamps — only the quantized freshness band derived from them is compared (ADR-036 Decision 2). Invariant to test: no field inside approval_content_hash may be derived from an excluded field. Recompute the full decision at confirm; any difference demands a new human confirmationExtended1
11Absolute freshness rule (F1/F2)new deterministic rulewall-clock, unlike computeStaleSources (src/rehearsal/service.ts:276-310, which is relative to asOf)New1
12Bridge-coverage → verdict wiring (F3)src/ingestion/stitching/bridge-coverage.tssrc/rehearsal/coverage.tsone-sided bridge becomes a held Unknown. Reuse the existing signalanalyzeBridgeCoverage is already consumed by the stitch worker (src/workers/handlers/stitch-ingestion.ts:437), so this is a wiring change, not a second analysisNew1
13gate_evaluate worker job typesrc/domain/workers/types.ts:17-25 (8 today)9th type; precedent is the verdict precompute at src/workers/handlers/evaluate-findings.ts:47-90Extended1
14Verdict → gate-outcome mapper + gate_policies threshold policy tableplatform services + platform storage (gate_policies)named authority delta → Approve/Constrain/Reject/Unknown (ADR-039); the row id, version and applied thresholds travel in the ApprovalPayload. Which verdict permits which human disposition is ADR-039's matrix — published with empty cells, not settled hereNew1 (shape) — thresholds are DATA, not code / 2 (values, and the matrix)
15Enforcement tiers T1–T3 + kill switchplatform configper-tenant tier (T1 Advisory = rule enabled, brief hold, immediate approve / T2 Hold-with-human = the pilot / T3 Automatic, not in the pilot). There is no T0 — GitHub sends no event until the rule is enabled (§4.3). Kill switch defaults to hold_all; release_all is human-initiated per tenant, records the actor on every drained decision, and loses to an already-committed human terminal decisionNew1
16Gate queue UI + deep-linkable rehearsal paramsui/src — sibling route under rehearsal/ in the Governance group (ui/src/components/Layout.tsx:146)the launcher, candidates hook and reach panels already live thereNew + extended1
17Approval-owner resolution + notification + queuesrc/ingestion/authority-path-materializer.ts:396 (computeOwnershipStatus), OWNED_BY (src/ingestion/types.ts:22), plus one notification path and the gate queueResolve, notify, queue — routing only. This is the headline answer to "what do you add beyond GitHub's native rule", so it must be real rather than promised. Not a notification platform. Routing is not authorization — enforcement is component 30 and is a separate buildNew1
18Decision list surface (Ivan's "see all previous")ui/src + one routeevery deployment we gated, with its verdict, its evidence and who approved it. queryDecisionRecords already exists with zero non-test callers. Reopens the sealed verdict and its evidence — not a live graphNew1
19Verification-of-result legextend #1568, do not duplicateread back …/approvals; verify the deployed artifact digest equals the approved one (§4.2); re-ingest and compare projected vs realized; poll that the rule still exists. Writes the verification FIELD (pendingverified | drifted), never a lifecycle state — it must not overwrite released / refused (§6.2)Extended1
20AWS connector: decompose the OIDC sub claimsv0-connectors/.../trust_policy_parser.pyrepo:org/repo:environment:prod → structured propertiesExtended2 — Track 1 only if the selected use case needs environment-specific matching
21github_actions vs github classification mismatchsrc/domain/system-classification/types.ts:59bug fixExtended1
22Entra federatedIdentityCredentials extractorsv0-connectors/shared/sv0_azureexisting consented Application.Read.All; no new permissionExtended2 — second vertical; permission already consented
23Connector connector_kind dispatch + runtime packagingsrc/workers/connector-driver.ts:172, Dockerfileprerequisite for any second scheduled connectorExtended1 — required only if freshness option C is automated; named dependency, not first slice
24Read-side MCP gate toolsrc/mcp — frozen six-tool contract changeoptional; read-only by constructionExtended1 (optional)
25Which rehearsal mode the gate callsone-line branch on the shipped route src/api/routes/rehearsal.ts:340promotion_agent_id vs existing_agent_id+role_idExtended2
26Envelope definition; Constrain vs Reject thresholdspolicy table values2
27Which owner is the approval owner (the rule and the identity)ownership graph vs partner orgthe delivery mechanism is item 17 and is Track 1; who it resolves to is policy and is Sergey's2
28Partner onboarding runbook, demo estate, demo narrativedocs + sv0-demo-labsA needs a counterpart map; C needs a declared-delta artifactNew2
29Design partner name, roadmap placementSergey's2
30Approver authorization enforcement (§5.5)src/api gate confirm route, tenant/identity layerhuman-only route (rejects M2M and delegated-agent identities — machine principals skip membership middleware, src/api/middleware/auth-middleware.ts:374-386); immutable user ids, never email or display name; primary + fallback approver sets enforced server-side; self-approval prevented where policy requires. A component, not a fieldNew1 — a T2 PREREQUISITE. No T2 release before it ships
31Determinism fix: total ordering + permutation test (§5.4)src/storage/mongo/adapters/correlation-adapter.ts:60, src/rehearsal/in-memory-adapter.ts:133-139, src/rehearsal/engine.tsadd { last_confirmed_at: -1, _id: 1 } to both adapters, kept mirrored; give every array serialized into the verdict a total comparator ending in a guaranteed-unique key (sortReachConeByRisk, engine.ts:867-888, is the pattern); permutation test — shuffle the loaded array, recompute, assert byte-identical. Prerequisite for component 10Extended1 — before recompute-and-compare is load-bearing
32gate_inbox — durable inbox + sweeper (§6.2)the adapter's own separately credentialed store + the adapter's receiver (component 3)2XX only after the durable write; sweeper for received rows with no evaluation job; crash tests on both sides of the ACK/enqueue boundary. Needs its own reset path — the platform's reset-list guard cannot reach itNew1 — before T2
33gate_dispatch — outbox with atomic claims and leases (§6.2)the adapter's own separately credentialed store + the adapter's dispatch pathdurable outbox rows, atomic claim, lease expiry, reconciliation-first retries (read GitHub's approval state before every retry), symmetric lifecycle for refusal, delivery_failed / ambiguous as terminal statesNew1 — before T2
34Immutable artifact-digest resolution + binding (§4.2)gate evaluate path + registry readresolve the digest before the hold; bind digest + workflow + run + attempt + environment into the ApprovalPayload; require the gated job to deploy exactly that digest. Artifact binding, not image scanning — scanning stays an explicit non-goalNew1 (mechanism) / 2 (how a given partner pipeline surfaces the digest)
35Cached, signed per-environment failure policy (§4.3)response adapterthe only way bounded platform-outage fail-open is implementable; the adapter must evaluate it with the platform down. Consequence to state: the audit record is written on recovery, not synchronouslyNew1 — only if a partner requires platform-outage fail-open

7. Track 1 / Track 2 — and whether any candidate breaks the architecture

7.1 The claim

No use-case candidate forces a different architecture — not the two on the recommended shortlist, and not the two we recommend against. Engineering starts now at zero rework risk, and the selection is parallelisable rather than urgent.

The evidence is structural, not a resemblance argument:

  • One API route already serves both shortlisted candidates. GET /api/v1/rehearsal/deployment (src/api/routes/rehearsal.ts:340) takes promotion_agent_id for candidate A (agent promotion) and existing_agent_id+role_id for candidate C (authority-adding deployment).
  • One UI page already renders bothui/src/pages/DeploymentRehearsalPage.tsx, with a Promotion tab and an existing-agent tab.
  • One terminal seal serves both — both produce a RehearsalDeploymentVerdict (src/rehearsal/types.ts:381) and both are wrapped in the same ApprovalPayload (§5.3), so the single new sealed-record kind (component 8) serves either candidate. The sealer widening is real work, but it is use-case-independent work: it is paid once, on Track 1, whichever candidate is selected.
  • The two recommended-against candidates need no divergent substrate either, which is why the claim is stated over all four and not over a shortlist. Cross-SaaS regulated-data reach makes an existing Track 1 item load-bearing rather than adding one; "standing deploy authority" needs no gate substrate at all. Both are covered in §7.3.

7.2 The two disciplines that keep it true

  1. The verdict-threshold policy table must be data, not code. Hardcoding one candidate's thresholds is the one substrate decision that can quietly pre-empt the selection.
  2. A declared-delta artifact must be an optional payload enrichment, never a required field. Candidate C's pipeline declares what it is about to change; candidate A declares nothing. Making the declaration mandatory would pre-empt A.

7.3 What would force a different architecture

Three named cases. Only one is live. Note on framing: we ran kill tests against the four candidates and they produced a recommendation, not a decision. Two candidates are on the recommended shortlist (A and C); two are recommended against, with reasons — and that is Sergey's call at the working session, not ours.

CaseEffectResponse
A single-phase partner pipeline (the live risk)If infrastructure and cutover land in one gated apply, the promotion engine examines the pre-deploy production twin and the first promotion returns 422 (src/rehearsal/promotion.ts:86-95) — silent exactly when the stakes are highest. It also removes the refresh window that freshness option C depends onThe fork is pipeline shape, not our code. Two-phase promotion becomes a named partner precondition. If a partner refuses, the gate must evaluate a declared delta instead — i.e. candidate C's shape — which the optional-enrichment discipline already accommodates
Cross-SaaS regulated-data reach (recommended against for the first slice, with reasons — Sergey's call)Would make the bridge-coverage→Unknown wiring (item 12) load-bearing rather than defensive, and would add customer-classification ingestionAlready on Track 1. No architectural change; a stronger dependency on an existing item — so picking it costs no rework
"Standing deploy authority" (recommended against as the pilot use case, with reasons — Sergey's call)Its content — "your production deploy role trusts any branch of this repo" — is a deterministic finding, computable from the already-ingested sub string, and needs no gate substrate at allShip it as a finding regardless of the selection, together with the github_actions/github fix (item 21)

7.4 What the selection does gate

Partner outreach. The qualification filter differs between candidates, so we should not start qualifying partners before the use case lands. One filter item is non-negotiable for every candidate: GitHub Enterprise is a hard prerequisite"Custom deployment protection rules are available in public repositories for all plans. For access to custom deployment protection rules in private or internal repositories, you must use GitHub Enterprise" (Configure custom protection rules) — documented. GitHub Team is not sufficient. And a product risk to state plainly: the mechanism is "currently in public preview and subject to change" — our enforcement point has no deprecation contract.


8. The seam for the other two #1883 items

All three items in #1883 will be built. GitHub is first because it is the most straightforward to understand and the most demonstrable. The other two are later consumers of the same decision contract, not later users of a framework we build now.

What is shared: the record shape and the audit fields. Every consumer stamps the same contract.

Audit fieldWhy every consumer needs it
Subject (entity id + display name)What the decision is about
Graph anchor: absolute per-source last_synced_at + declared refresh set — the quantized freshness band derived from those stamps is what sits in the compared hash; the raw stamps and as_of are stored as evidence and for drift labelling, never compared (§3.1)The estate the decision was computed on, at known age, without refusing releases when a routine sync moves a raw stamp or an unrelated scan moves a tenant-wide max
Engine identity: verdict ENGINE_VERSION + rehearsal_engine_version + materializer flags; policy id + version + applied thresholds; mapper versionWhich algorithm and which policy produced it — the same rehearsal yields a different outcome under a different policy document
Verdict + ordered rationale clauses (reject → constrain → unknown_gap → approve)Why, in a form a human can dispute
Coverage factors, control-subtraction firstWhat the computation structurally cannot see
Canonical bytes + three hashes: rehearsal_verdict_hash (whole verdict — evidence and drift labelling, never in the payload), rehearsal_projection_hash (the projected variant the payload carries) and approval_content_hash over the ApprovalPayload (the gate)Corruption / transplant / re-parenting detection — unkeyed, so not tamper-evidence against a write-capable actor (§5.1) — and the input to recompute-and-compare at confirm time. No field inside the compared hash is derived from an excluded field
Disposition: release · refuse · request changesThree outcomes, not two — "request changes" holds and comments. Which computed verdict reaches which disposition is ADR-039's open matrix
verification: pending / verified / drifted — a field, never a lifecycle stateWhether the world matched the decision, without overwriting what was decided (§6.2)
Human confirmation (recorded now; confirmed_by once its integrity mechanism ships)Who released it
Provider event key (repo+env+run+attempt / workflow+run / job execution id)Which real-world action this decision authorized
Immutable artifact identity (image digest / deployed package identity)The commit a provider reports is not the artifact it runs (§4.2). Binding, not scanning
Approver sets (immutable user ids) + the authorization decisionWho was permitted to act, distinct from who was notified
Read-back evidence from the providerProof the decision landed, independent of our own logs

What is not shared, deliberately. No generic adapter framework, no provider-abstraction layer, no shared event bus. Each response adapter is hand-written against one provider's documented hold-or-stop operation, with its own credential, its own allowlist, and its own read-back. The seam is the record contract plus one narrow interface — given a decision and a provider handle, perform one allowlisted operation and read back its effect. Generalize only when the Logic Apps path becomes a real second consumer.

Direction of travel, stated so it cannot be misread. The GitHub gate is a bounded hold-and-release proof at a deployment boundary. The Logic Apps work proceeds in shadow mode; containment comes only after latency, attribution and pending-run behaviour are proven. Container-workload portability is a contract definition now and one bounded fixture later. The gate does not replace the Trajectory direction — Authority is the foundation, Trajectory is the strategic expansion, and the two run in parallel.


9. Open questions, each with the cheapest test that resolves it

9.1 Answered — by a captured delivery, not by GitHub's documentation

Two questions this section used to carry were resolved on 2026-07-28 by a real deployment_protection_rule delivery captured from the spike repo, committed as a fixture in sv0-platform, and recorded on the dispatch-attempt issue (sv0-platform#1920). They are answered for us. GitHub has still published nothing about either behaviour, so treat both as pinned observations a provider change could invalidate — the committed fixture is the regression that would catch it.

Was openWhat the delivery showedConsequence for the design
Exact format of deployment_callback_urlPinned: https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule. There is no run identifier and no workflow-run object anywhere else in the payload — the run id is embedded only inside that URLThe gate_decisions natural key and the decision call can be built against a known string. Parsing that URL is load-bearing, so it gets its own test against the fixture
Does a re-run re-fire the rule, or reuse the prior approval?It re-holds: a fresh delivery arrives and run_attempt increments, so a prior approval is not silently reused. But the callback URL is byte-identical across attemptsReassurance and a hazard in one observation. Because the URL cannot distinguish attempts, a decision dispatched against a superseded attempt is indistinguishable from one dispatched against the current one — the stale-dispatch hazard. run_attempt therefore stays in the natural key, and the dispatcher re-reads the run's current attempt immediately before it posts

9.2 Still open

Item 3 changes the data model, and so does item 14; both must be settled before the vertical slice is scoped. Numbering is unchanged from earlier revisions — 1 and 2 are answered in §9.1 and the gap is deliberate, so existing references to a question number still resolve.

#QuestionCheapest testBlocks
3Two runs waiting on the same environment concurrently — is a superseded wait cancelled, and are we told? NOT DOCUMENTEDSame spike repo: trigger two runs against one gated environmentWhether we can hold more than one verdict per environment, and how we reconcile
4Does a custom rule (not just required reviewers) gate environment-secret release? Our claim combines two documented sentencesSpike repo: environment with only the custom rule and one environment secret consumed in step 1; assert the job never starts while held, then approve and observe the secret readThe credential-isolation claim in section 1
5Absolute wall-clock age of a real tenant's dataOne read-only script against a deployed env: per source_system, max(last_synced_at). Note the environment map: dev.securityv0.comdev-azurestagingapp — independent Mongo eachWhether F1's threshold is realistic or would hold every deployment
6How long a full refresh set takes for a real tenantTime one full refresh of enterprise-nimbus (aws + entra-servicenow) and one of contoso (three connectors). Measured, onceWhether freshness option C is workable and what our decision deadline should be
7End-to-end gate latency (commit → hold visible → verdict posted → release)Dogfood on sv0-platform's own environments, which already exist. GitHub publishes no SLO, so this can only be answered with measured numbersSergey's latency gate
8Does promotion rehearsal work on an Azure Foundry agent pair? Only AWS is verified; the engine has no cloud branching (src/rehearsal/promotion.ts, no aws/azure conditionals)Author one counterpart pair on a Foundry-sourced tenant and run the shipped GET /api/v1/rehearsal/promotion-candidates + rehearsalWhether a Microsoft-only partner is reachable for candidate A
9Does the counterpart fingerprint survive a routine connector refresh?Refresh enterprise-nimbus and re-run the same promotion rehearsal; observe whether F5 (422 COUNTERPART_CHANGED) firesHow often the gate will hold for a non-security reason
10Is a partner tenant under the synchronous rehearsal ceiling (SYNC_MAX = 10_000, src/rehearsal/service.ts:55-56)?countEntities on the candidate tenant during qualificationWhether the gate can compute synchronously at all for that partner
11Is admin bypass disabled on the pilot environment, and is the rule still installed?GET the environment settings and GET …/deployment_protection_rules on a schedule — this is item 19's third leg. Read the second endpoint, not the first, for the rule. The environment object's protection_rules array never lists App-installed custom rules; those appear only on the separate deployment-protection-rules endpoint, so an environment that looks unguarded in the first response may be fully gated. Measured 2026-08-05 on the promotion repo: one enabled custom rule from the spike App on production, and admin bypass already false on both environmentsAny claim that the gate holds
12Retention window for webhook deliveries available for redelivery — NOT DOCUMENTEDObserve the deliveries list in the spike repo over a weekSizes the redelivery poller's recovery window
13Has the design partner customized the OIDC subject claim (include_claim_keys)?Ask during qualification; read one token's subIf customized, the documented sub shapes do not apply and any trust policy we specify would be wrong
14How does a given pipeline surface the immutable artifact digest before the gated job runs? Our own workflows show both a re-resolved main HEAD (deploy-azure-staging.yml:67-70) and an operator-supplied mutable tag (deploy-prod.yml:6,55)Partly narrowed already, and still open. Two options are eliminated: the webhook's deployment object carries nothing that identifies an artifact, so (a) yields nothing, and the pipeline makes no call to us. A third is now measured — a token holding only actions: read reads a run's artifact listing and the run object (200) while the deployments and contents endpoints refuse it (403), measured 2026-08-05 — so the gate could read a promotion record the pipeline uploads as a workflow artifact with the permission it already holds. What remains untested is downloading artifact content: the spike runs upload no artifacts, so the listing came back empty. Next test: have the pipeline upload one record and read it back end to endComponent 34; without it a T2 approval cannot name what will run
15Is verdict recomputation byte-identical under permuted input order today?The permutation test of §5.4, run BEFORE the fix, to size the defect: shuffle the loaded correlation array, recompute, diff the bytes. Measured, onceComponent 31; and whether recompute-and-compare can be switched on at all
16How long is T1's "brief" hold in practice? GitHub publishes no latency SLO, so this is our evaluation time plus deliveryDogfood measurement alongside question 7; report a distribution, not a single numberWhether "Advisory" is honestly sellable as low-impact
17Where do the primary and fallback approver sets come from — the ownership graph, an operator-authored map, or the partner's IdP?Decide with the use case; the enforcement mechanism (component 30) is identical either wayComponent 30 can be built now; its source of truth is Track 2

Appendix — status ledger for claims used above

ClaimStatus
Promotion rehearsal (UAT→prod), coverage seams, downloadable artifactshipped (src/rehearsal/engine.ts:1266+, verified in-product on real AWS data)
Sealed decision records, insert-only and pointer-linked, with an unkeyed integrity checksumshipped (#1780; src/domain/decision-records/, decision_records at src/storage/mongo/collections.ts:93). Not hash-chained, not cryptographically authenticated — src/evidence/integrity.ts:25-36 states this in the code's own words
Any gate write into decision_recordsdoes not exist — today only the revocation break_set shape is sealed (src/services/verdict-engine.ts:1251-1257)
gate_decisions / gate_installations / gate_policies collectionsdo not exist — new in this slice, all three in platform Mongo (§5.1)
gate_inbox / gate_dispatchdo not exist — new in this slice, in the adapter's own separately credentialed store, not platform Mongo (§1, §5.1)
ApprovalPayload as an explicit contract, and approval_content_hash over itdoes not exist — new in this slice (§5.3). Today only canonicalSerialize(rehearsalVerdict) is hashed, which is simultaneously too narrow (no policy, thresholds, subject binding, artifact or owner) and too broad (as_of at src/rehearsal/types.ts:342 and the tenant-wide totals at :364-365 move on unrelated ingest)
Recompute-and-compare at confirm timedoes not exist — the hash primitive is shipped (src/services/verdict-engine.ts:363-364); the comparison, and the object it compares, are new
Byte-deterministic verdict recomputationdoes not exist as a guarantee. canonicalSerialize preserves array order by contract (src/rehearsal/engine.ts:1551-1557); queryCorrelations sorts on last_confirmed_at with no tie-break (src/storage/mongo/adapters/correlation-adapter.ts:60) and the in-memory adapter mirrors that deliberately (src/rehearsal/in-memory-adapter.ts:133-139). Prerequisite for recompute-and-compare — component 31
Widening the sealer to accept a gate recorddoes not exist — BUILD, component 8. DecisionRecordContent is a closed interface (src/domain/decision-records/types.ts:229) and sealDecisionRecord takes only a VerdictResult (src/services/verdict-engine.ts:1281-1284). The earlier "no widening needed" claim was wrong. Shape decided: a versioned discriminated record_kind with a gate_decision_v1 variant (ADR-036 Decision 7), 0.6 lane-weeks [assumed]
record_kind as a discriminator on sealed contentdoes not exist — BUILD, component 8. Nothing in src/domain/decision-records/types.ts:229 discriminates content kinds today
rehearsal_projection_hash (the verdict hashed with as_of and the tenant-wide totals removed)does not exist — BUILD, component 10. Today's single hash is over the whole verdict, so it carries the volatile fields (src/rehearsal/types.ts:342, :364-365) and cannot be the compared object
Durable webhook inbox, versioned CAS transitions, dispatch outbox with leasesdo not exist — BUILD, components 32/33 (in the adapter's own store) and the version field on component 7 (platform Mongo). Optimistic-locking precedent ships at src/storage/mongo/adapters/cluster-resolution-record-adapter.ts:40-46
A gate webhook receiver anywheredoes not exist — new in this slice, and it is built in the adapter, not in platform src/api (§1, component 3)
Immutable artifact-digest bindingdoes not exist — BUILD, component 34. Nothing in the platform records a deployed image digest today
Absolute per-source data age (wall-clock last_synced_at on the verdict and the coverage panel)does not exist — BUILD, item 10/11. Today's check is relative to asOf = max(entity.updated_at) (src/rehearsal/service.ts:276-310, deriveAsOf at :245-252)
Approval-owner resolution, notification, and hold queuedoes not exist — BUILD, item 17. computeOwnershipStatus (src/ingestion/authority-path-materializer.ts:396) and OWNED_BY (src/ingestion/types.ts:22) exist; nothing routes a hold to an owner
"Request changes" as a third dispositiondoes not exist — new in the gate_decisions state machine and the record contract
Decision-record hash chaining (predecessor's hash in the preimage)does not exist — ID-only linkage (src/evidence/integrity.ts:42-59)
confirmed_byspec-only, fenced, zero populate paths
Rehearsal persistence of any kinddoes not existgrep -rn "rehearsal" src/storage/ returns zero matches; the API is three GETs
Any GitHub connector, GitHub API call, or hold/release mechanismdoes not exist in either repo
github_repo as an entity typedoes not exist — it is a tenant issue-tracker deeplink string (src/domain/tenants/types.ts:123)
github_app identity subtypespec-only — exists in the taxonomy (src/domain/graph/identity-subtypes.ts:4), nothing emits it
Entra federatedIdentityCredentials ingestiondoes not exist — 0 hits both repos; permission already consented
Automated connector refreshdoes not exist for any connector; scheduler path is AWS-only and the runtime is absent from the image
Role-based "who may approve" primitive on tenant routesdoes not exist — BUILD, component 30, and a T2 prerequisite. No permissionMiddleware on the rehearsal, verdict or mitigation routes; machine principals skip membership middleware entirely (src/api/middleware/auth-middleware.ts:374-386), so a confirm route must enforce human-only itself
A T0 "Observe" tierdoes not exist and cannot — GitHub sends deployment_protection_rule only once the rule is enabled on the environment, so there is no event to compute from. Deleted from the tier vocabulary (§4.3)