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:
Read the diagram as four rules.
-
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). -
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_ruledelivery, 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 writeon 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_inboxandgate_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 forsrc/mcpateslint.config.js:55-110, withtest/mcp/isolation.test.tsas the authoritative gate); and it mints a per-decision installation token scoped byrepository_idsand{"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. -
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,assertSafeMethodthrows). A read-side gate tool is cheap; an approve tool structurally cannot live there. This is a hard boundary, not packaging. -
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 ongate_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 asks | Which connector supplies it | Node/edge shape |
|---|---|---|
| Who can assume what in AWS | aws | identity, role, permission (statement-level), HAS_ROLE, GRANTS |
| Who can act in Azure/Entra | azure-foundry, azure-sentinel-soc, entra-servicenow | managed identities, ARM role assignments, app role assignments |
| What actually ran | aws CloudTrail extractor; Foundry/Sentinel execution_evidence | execution_evidence, EVIDENCES |
| What a repo can become | only the AWS trust-policy parser's github_oidc connection → AUTHENTICATES_AS → IAM role (transformer.py:2839-2872) | connection node, sourceSystem: "github_actions" |
| Cross-system reach | no 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 onconnector_kindanywhere in the driver — scheduling an Entra scope would spawnsv0-aws. - The connector runtime is not in the platform image.
Dockerfileisnode:22-alpine→npm ci→npm 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:54 — documented, 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 needs | Today | Gap |
|---|---|---|
| A tenant graph of known absolute age at decision time | Age is "whenever a human last ran a CLI" — assumed days to weeks, inferred from the absence of any automated trigger in either repo | No automated refresh for any connector except AWS, and not even AWS in the shipped image |
| Per-source freshness the verdict can assert | computeStaleSources (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 worse | Correlation 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 trigger | 202-and-enqueue only, AWS-only driver, no runtime in the image | Any 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.)
| Quantity | Value | Label |
|---|---|---|
| Our ACK budget on the webhook | 10 s (Best practices for using webhooks) | documented |
| GitHub's hold ceiling | 30 days, then the job fails (Configure custom protection rules) | documented |
| GitHub latency SLO for the event | none published | not documented — must be measured |
| Stitch debounce | 60 s (stitch-debounce.ts:54) | documented (repo constant) |
| Relative staleness threshold | 7 days before asOf (coverage.ts:40) | documented (repo constant) |
| CloudTrail observation window | 30-day default, 90-day cap (cloudtrail_extractor.py:89-95,148) | documented (repo constant) |
| Absolute age of a real tenant graph | days to weeks | assumed — inferred from the absence of any automated trigger; no deployed Mongo was queried |
| Duration of one full refresh set | unknown | must 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 invisible — asOf 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_of | per-source last_synced_at | |
|---|---|---|
| What it is | A 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 it | Any connector scan touching any entity, including entities with no relationship whatsoever to the held deployment | Only a sync of that specific source |
| What it can support | Labelling the snapshot the verdict was computed against; drift narration after the fact | A 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 decision | No, 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:
- False Approve — a role gained authority after our last scan; the cone we rehearse is smaller than the real one.
- False Reject/Constrain — an over-broad grant was already remediated; we hold a stale finding.
- 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
| Option | What it is | Cost | Kills which failure |
|---|---|---|---|
| A — accept staleness, labelled, with an absolute freshness rule | Stamp 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 report | Small; no connector work; one new deterministic rule + one emission change | Makes 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 decide | Receive → enqueue the tenant's full refresh set → wait for ingest + 60 s stitch → compute | Large 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 none | Would 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 runs | The 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 environment | Same Path-B prerequisites for the refresh itself, but the latency sits outside the hold window and the failure is visible before the gate fires | Fixes 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.
- 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_hashwhen the named human confirms. Absolute per-sourcelast_synced_atis 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_ofcannot 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 toUnknownand holds, instead of silently producing a confident wrong answer. - 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. - 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.
| Rule | Trigger | Gate outcome |
|---|---|---|
| F1 — absolute source age | For any source_system contributing to the reach cone, now − max(last_synced_at) > tenant.max_source_age | Unknown, held. Name the source and its age in whole days |
| F2 — refresh-set completeness | Any 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 timestamp | Unknown, held |
| F3 — one-sided bridge | analyzeBridgeCoverage returns one_sided / no_partner for a default-enabled cross-system rule whose two sides are both in the declared refresh set | Unknown, held. This wiring does not exist today — bridge-coverage output does not feed src/rehearsal/coverage.ts |
| F4 — empty cone is never evidence of no access | Zero grants or zero reach paths | Already the platform's rule (src/services/verdict-engine.ts:643-650, "the historical fail-open"). Carry it into the gate unchanged |
| F5 — counterpart fingerprint mismatch | computeCounterpartFingerprint(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 asymmetry | Any inferred/asserted-grade input in the Approve path | Already 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
Unknownand holds. It never producesApprove, and it never produces a confident wrong answer in silence.Unknownis 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:
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 question | Source | Connector needed? |
|---|---|---|
| Which repo / org | repository.id (immutable), repository.full_name | No |
| Which environment | environment (string, name not id) | No |
| Which commit / ref | sha, ref — "always populated from the check suite" | No |
| Which PR | pull_requests[] | No |
| Who triggered it | sender | No |
| Which installation (to mint the token) | installation.id | No |
| Which run to call back | only embedded in deployment_callback_url | No — 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 identity | one extra GET /repos/{owner}/{repo}/actions/runs/{run_id} | No |
| Which artifact will actually be deployed | not 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 confers | the identity graph | Yes — 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_systemscontains"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-70re-resolves currentmainHEAD withgh api /repos/{repo}/commits/mainafter the triggering run, with the comment "two rapid pushes can finish CI out of order" — so the deployed commit is whatevermainis at resolve time, not the commit that fired the event..github/workflows/deploy-prod.yml:6,55takes an operator-suppliedimage_taginput 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.
| Tier | Name | Behaviour |
|---|---|---|
| T1 | Advisory | Rule 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" |
| T2 | Hold-with-human | The pilot tier. Hold; release only on a named, server-side-authorized human's confirmation (§5.5) |
| T3 | Automatic | Auto-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 failed | What actually happens | What we may promise |
|---|---|---|
| Platform evaluation errors or is unreachable — adapter, credential, network and GitHub API all healthy | The 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 applied | This is the only fail-open we can offer, and it is bounded and per-environment |
| Adapter down · private key unavailable · network partition · GitHub API unavailable | Nothing 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 job | Say 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:
| Failure | T1 Advisory | T2 Hold-with-human (the pilot tier) |
|---|---|---|
No binding for (installation.id, repository.id) → no tenant | Post a status report naming the gap and release. Alert the operator | Post 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" comment | Unknown — held |
| Ambiguous binding (more than one subject) | Status report naming every candidate; never pick one | Unknown — held |
| Subject resolves but the entity is missing / changed / unconfirmed | The 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 release | Unknown — held. An approval that cannot name the artifact is not an approval of a deployment |
| Tenant too large for synchronous rehearsal | 413 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 ages | Unknown — held |
| Platform evaluation errors, adapter healthy | Release 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 unavailable | Nothing can be posted; the run stays held until recovery | Identical: 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) andgate_policies(the threshold policy table) in platform Mongo; buildgate_inboxandgate_dispatch(the reliability substrate, §6.2) in the adapter's own separately credentialed store; and seal exactly one summary row intodecision_recordsat the terminal decision. Define an explicitApprovalPayload(§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 aversionfield (§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). TheApprovalPayloadcarries 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 indexestenant_workload_contentandtenant_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 409CHAIN_ADVANCED. Putting the lifecycle ingate_decisionsand touchingdecision_recordsonce, 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.
DecisionRecordContentis a closed interface with a fixed signal set (src/domain/decision-records/types.ts:229), andsealDecisionRecordaccepts only an existingVerdictResult(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 reusingcomputeDecisionIntegrityHash(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: arecord_kinddiscriminator on the sealed content with agate_decision_v1variant 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 thatqueryDecisionRecordsdoes not understand. It is a migration-visible schema-version bump that touches the shippedverdictpath — a schema change with a migration story, not a free reuse. Priced at 0.6 lane-weeks [assumed], matching the plan; the shippedbreak_setcontent 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_hashover the rehearsal bytes verbatim, which is evidence only and never a release gate on its own;rehearsal_projection_hashover the same verdict with the volatile tenant-wide fields projected out, which is the only rehearsal hash the payload carries; andapproval_content_hashover the complete immutableApprovalPayloadrendered to the approver, which is the gate. At confirmation the full decision is recomputed andapproval_content_hashcompared; 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(...))atsrc/services/verdict-engine.ts:363-364, andcomputeContextFingerprintat: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_bystays fenced, and the record is not tamper-evident against a write-capable actor.DecisionRecordDoc.confirmed_byis 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_recordsis 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 includeprevious_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 thegate_decisionsdocument, 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
queryDecisionRecordsexists 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.
- Canonical bytes, once. The sealed verdict content is
canonicalSerialize(verdict)(src/rehearsal/engine.ts:1555) verbatim — never a re-derived summary.captured_atis already deliberately excluded from the verdict so the bytes are stable. - Bind, do not embed twice. The
gate_decisionsdocument references the sealeddecision_recordsrow 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. - 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 atsrc/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. - Stamp absolute freshness. Wall-clock
last_synced_atper contributingsource_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. - Stamp the immutable artifact digest (§4.2), together with workflow identity,
run_id,run_attemptand environment. The approved object must name the thing that will run, not only the commit GitHub reported. - 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 withas_of,totals.entities_materializedandtotals.baseline_pathsremoved — this is the one the payload carries.approval_content_hash=sha256over the canonicalApprovalPayload, excludingas_of,totals.entities_materialized,totals.baseline_pathsand any other tenant-scoped counter that moves on unrelated ingest.as_ofis 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 insideapproval_content_hashmay 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. - Recompute-and-compare
approval_content_hashat 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. - 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.
- Three dispositions, not two.
gate_decisionsstate 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 fromrefuseand 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. - Every transition is a compare-and-swap on a
versionfield, 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. - Give the artifact a URL a check-run can link to. Today the Promotion tab has no URL
parameter at all:
agentModeisuseState(ui/src/pages/DeploymentRehearsalPage.tsx:124) andsearchParams.getappears once, forrole(:173). A check-run cannot deep-link a reviewer to a specific rehearsal. Small, and blocking for the demo. - 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.
| Field | Contents | In the compared hash? |
|---|---|---|
payload_version | Schema version of this contract | Yes |
subject | Subject entity id + display name, resolved via gate_installations (repository.id, environment) | Yes |
enforcement_point | installation.id, repository.id, environment, tier | Yes |
artifact | Immutable image digest, workflow identity, run_id, run_attempt (§4.2) | Yes |
commit | sha, ref — evidence of source, not of artifact | Yes |
outcome | The gate outcome: Approve / Constrain / Reject / Unknown | Yes |
authority_delta | The named delta the outcome is about — paths, destinations, actions | Yes |
policy | Policy document id + version + the threshold values actually applied | Yes |
mapper_version | Version of the verdict→gate-outcome mapper | Yes |
engine_identity | Verdict ENGINE_VERSION, rehearsal_engine_version, materializer flag set | Yes |
freshness_class | The quantized band derived from per-source absolute last_synced_at, plus the declared refresh set, which members were present, and which freshness rules fired | Yes — 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) |
coverage | Coverage factors, control-subtraction caveat first | Yes |
evidence_set | Ids of the evidence the decision cites | Yes |
approval_owner | Immutable user ids of the primary and fallback approver sets (§5.5) | Yes |
rehearsal_projection_hash | sha256(canonicalSerialize(...)) over the verdict with as_of, totals.entities_materialized and totals.baseline_paths removed — see the seam note below | Yes — 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_hash | sha256(canonicalSerialize(verdict)) over the complete verdict, unmodified | No — 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_of | deriveAsOf 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_paths | Tenant-wide counters (src/rehearsal/types.ts:364-365) | No — same reason |
captured_at, timings, notification metadata | Wall-clock envelope | No |
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:
| Field | Computed over | In approval_content_hash? |
|---|---|---|
rehearsal_verdict_hash | the complete verdict, unmodified | No — evidence and drift labelling only |
rehearsal_projection_hash | the verdict with as_of, totals.entities_materialized and totals.baseline_paths removed | Yes — 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_hashmay 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:362and the sibling loads), and both adapters gate slicing onif (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.
queryCorrelationssorts.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_idtie-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:
- 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. - 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. - 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:
- 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. - Resolve immutable user IDs — never email, never display name. Both are mutable and both are re-assignable.
- 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. - Self-approval is prevented where the policy requires it — the deployment's triggering
sendermay 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.tsalready 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:
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 document —
verification: "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.
| # | Component | Repo / area | Change | New or extended | Track |
|---|---|---|---|---|---|
| 1 | Empirical spike repo (callback URL, re-run, concurrency) | throwaway private Enterprise repo | pin 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 unobserved | New | 1 — do first |
| 2 | The one gate App (identity, webhook secret, Actions:read + Deployments:read+write) | new | registration + config. It owns the rule on the environment, so it is the only App GitHub permits to review it | New | 1 |
| 3 | Gate ingress / webhook receiver — in the adapter, not the platform | the 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 graph | New | 1 |
| 4 | Redelivery poller | platform src/workers | fetch deliveries, find non-OK, redeliver | New | 1 |
| 5 | Response 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-back | New | 1 |
| 6 | gate_installations collection + super-admin route | platform storage + src/api/routes/admin | App installation + per-environment binding; pattern-match promotion-counterparts.ts:111 | New | 1 (shape) / 2 (what the subject is) |
| 7 | gate_decisions collection + versioned CAS state machine | platform 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-46 | New | 1 |
| 8 | Terminal seal into decision_records — requires a versioned discriminated record kind | src/domain/decision-records/types.ts:229, src/services/verdict-engine.ts:1281-1284 | seal 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 untouched | Extended | 1 |
| 9 | Keyed MAC / signature + predecessor-hash chaining (the confirmed_by integrity mechanism) | src/domain/decision-records, src/evidence/integrity.ts:25-36 | sealed 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" | New | 1 — 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 |
| 10 | ApprovalPayload + three hashes + recompute-and-compare at confirm | src/rehearsal/service.ts, engine.ts, gate confirm path | assemble 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 confirmation | Extended | 1 |
| 11 | Absolute freshness rule (F1/F2) | new deterministic rule | wall-clock, unlike computeStaleSources (src/rehearsal/service.ts:276-310, which is relative to asOf) | New | 1 |
| 12 | Bridge-coverage → verdict wiring (F3) | src/ingestion/stitching/bridge-coverage.ts → src/rehearsal/coverage.ts | one-sided bridge becomes a held Unknown. Reuse the existing signal — analyzeBridgeCoverage is already consumed by the stitch worker (src/workers/handlers/stitch-ingestion.ts:437), so this is a wiring change, not a second analysis | New | 1 |
| 13 | gate_evaluate worker job type | src/domain/workers/types.ts:17-25 (8 today) | 9th type; precedent is the verdict precompute at src/workers/handlers/evaluate-findings.ts:47-90 | Extended | 1 |
| 14 | Verdict → gate-outcome mapper + gate_policies threshold policy table | platform 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 here | New | 1 (shape) — thresholds are DATA, not code / 2 (values, and the matrix) |
| 15 | Enforcement tiers T1–T3 + kill switch | platform config | per-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 decision | New | 1 |
| 16 | Gate queue UI + deep-linkable rehearsal params | ui/src — sibling route under rehearsal/ in the Governance group (ui/src/components/Layout.tsx:146) | the launcher, candidates hook and reach panels already live there | New + extended | 1 |
| 17 | Approval-owner resolution + notification + queue | src/ingestion/authority-path-materializer.ts:396 (computeOwnershipStatus), OWNED_BY (src/ingestion/types.ts:22), plus one notification path and the gate queue | Resolve, 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 build | New | 1 |
| 18 | Decision list surface (Ivan's "see all previous") | ui/src + one route | every 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 graph | New | 1 |
| 19 | Verification-of-result leg | extend #1568, do not duplicate | read 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 (pending → verified | drifted), never a lifecycle state — it must not overwrite released / refused (§6.2) | Extended | 1 |
| 20 | AWS connector: decompose the OIDC sub claim | sv0-connectors/.../trust_policy_parser.py | repo:org/repo:environment:prod → structured properties | Extended | 2 — Track 1 only if the selected use case needs environment-specific matching |
| 21 | github_actions vs github classification mismatch | src/domain/system-classification/types.ts:59 | bug fix | Extended | 1 |
| 22 | Entra federatedIdentityCredentials extractor | sv0-connectors/shared/sv0_azure | existing consented Application.Read.All; no new permission | Extended | 2 — second vertical; permission already consented |
| 23 | Connector connector_kind dispatch + runtime packaging | src/workers/connector-driver.ts:172, Dockerfile | prerequisite for any second scheduled connector | Extended | 1 — required only if freshness option C is automated; named dependency, not first slice |
| 24 | Read-side MCP gate tool | src/mcp — frozen six-tool contract change | optional; read-only by construction | Extended | 1 (optional) |
| 25 | Which rehearsal mode the gate calls | one-line branch on the shipped route src/api/routes/rehearsal.ts:340 | promotion_agent_id vs existing_agent_id+role_id | Extended | 2 |
| 26 | Envelope definition; Constrain vs Reject thresholds | policy table values | — | 2 | |
| 27 | Which owner is the approval owner (the rule and the identity) | ownership graph vs partner org | the delivery mechanism is item 17 and is Track 1; who it resolves to is policy and is Sergey's | — | 2 |
| 28 | Partner onboarding runbook, demo estate, demo narrative | docs + sv0-demo-labs | A needs a counterpart map; C needs a declared-delta artifact | New | 2 |
| 29 | Design partner name, roadmap placement | — | Sergey's | — | 2 |
| 30 | Approver authorization enforcement (§5.5) | src/api gate confirm route, tenant/identity layer | human-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 field | New | 1 — a T2 PREREQUISITE. No T2 release before it ships |
| 31 | Determinism 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.ts | add { 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 10 | Extended | 1 — before recompute-and-compare is load-bearing |
| 32 | gate_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 it | New | 1 — before T2 |
| 33 | gate_dispatch — outbox with atomic claims and leases (§6.2) | the adapter's own separately credentialed store + the adapter's dispatch path | durable 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 states | New | 1 — before T2 |
| 34 | Immutable artifact-digest resolution + binding (§4.2) | gate evaluate path + registry read | resolve 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-goal | New | 1 (mechanism) / 2 (how a given partner pipeline surfaces the digest) |
| 35 | Cached, signed per-environment failure policy (§4.3) | response adapter | the 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 synchronously | New | 1 — 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) takespromotion_agent_idfor candidate A (agent promotion) andexisting_agent_id+role_idfor candidate C (authority-adding deployment). - One UI page already renders both —
ui/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 sameApprovalPayload(§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
- 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.
- 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.
| Case | Effect | Response |
|---|---|---|
| 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 on | The 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 ingestion | Already 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 all | Ship 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 field | Why 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 version | Which 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 first | What 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 changes | Three 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 state | Whether 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 decision | Who was permitted to act, distinct from who was notified |
| Read-back evidence from the provider | Proof 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 open | What the delivery showed | Consequence for the design |
|---|---|---|
Exact format of deployment_callback_url | Pinned: 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 URL | The 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 attempts | Reassurance 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.
| # | Question | Cheapest test | Blocks |
|---|---|---|---|
| 3 | Two runs waiting on the same environment concurrently — is a superseded wait cancelled, and are we told? NOT DOCUMENTED | Same spike repo: trigger two runs against one gated environment | Whether we can hold more than one verdict per environment, and how we reconcile |
| 4 | Does a custom rule (not just required reviewers) gate environment-secret release? Our claim combines two documented sentences | Spike 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 read | The credential-isolation claim in section 1 |
| 5 | Absolute wall-clock age of a real tenant's data | One read-only script against a deployed env: per source_system, max(last_synced_at). Note the environment map: dev.securityv0.com ≠ dev-azure ≠ staging ≠ app — independent Mongo each | Whether F1's threshold is realistic or would hold every deployment |
| 6 | How long a full refresh set takes for a real tenant | Time one full refresh of enterprise-nimbus (aws + entra-servicenow) and one of contoso (three connectors). Measured, once | Whether freshness option C is workable and what our decision deadline should be |
| 7 | End-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 numbers | Sergey's latency gate |
| 8 | Does 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 + rehearsal | Whether a Microsoft-only partner is reachable for candidate A |
| 9 | Does the counterpart fingerprint survive a routine connector refresh? | Refresh enterprise-nimbus and re-run the same promotion rehearsal; observe whether F5 (422 COUNTERPART_CHANGED) fires | How often the gate will hold for a non-security reason |
| 10 | Is a partner tenant under the synchronous rehearsal ceiling (SYNC_MAX = 10_000, src/rehearsal/service.ts:55-56)? | countEntities on the candidate tenant during qualification | Whether the gate can compute synchronously at all for that partner |
| 11 | Is 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 environments | Any claim that the gate holds |
| 12 | Retention window for webhook deliveries available for redelivery — NOT DOCUMENTED | Observe the deliveries list in the spike repo over a week | Sizes the redelivery poller's recovery window |
| 13 | Has the design partner customized the OIDC subject claim (include_claim_keys)? | Ask during qualification; read one token's sub | If customized, the documented sub shapes do not apply and any trust policy we specify would be wrong |
| 14 | How 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 end | Component 34; without it a T2 approval cannot name what will run |
| 15 | Is 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, once | Component 31; and whether recompute-and-compare can be switched on at all |
| 16 | How long is T1's "brief" hold in practice? GitHub publishes no latency SLO, so this is our evaluation time plus delivery | Dogfood measurement alongside question 7; report a distribution, not a single number | Whether "Advisory" is honestly sellable as low-impact |
| 17 | Where 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 way | Component 30 can be built now; its source of truth is Track 2 |
Appendix — status ledger for claims used above
| Claim | Status |
|---|---|
| Promotion rehearsal (UAT→prod), coverage seams, downloadable artifact | shipped (src/rehearsal/engine.ts:1266+, verified in-product on real AWS data) |
| Sealed decision records, insert-only and pointer-linked, with an unkeyed integrity checksum | shipped (#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_records | does not exist — today only the revocation break_set shape is sealed (src/services/verdict-engine.ts:1251-1257) |
gate_decisions / gate_installations / gate_policies collections | do not exist — new in this slice, all three in platform Mongo (§5.1) |
gate_inbox / gate_dispatch | do 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 it | does 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 time | does 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 recomputation | does 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 record | does 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 content | does 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 leases | do 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 anywhere | does not exist — new in this slice, and it is built in the adapter, not in platform src/api (§1, component 3) |
| Immutable artifact-digest binding | does 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 queue | does 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 disposition | does 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_by | spec-only, fenced, zero populate paths |
| Rehearsal persistence of any kind | does not exist — grep -rn "rehearsal" src/storage/ returns zero matches; the API is three GETs |
| Any GitHub connector, GitHub API call, or hold/release mechanism | does not exist in either repo |
github_repo as an entity type | does not exist — it is a tenant issue-tracker deeplink string (src/domain/tenants/types.ts:123) |
github_app identity subtype | spec-only — exists in the taxonomy (src/domain/graph/identity-subtypes.ts:4), nothing emits it |
Entra federatedIdentityCredentials ingestion | does not exist — 0 hits both repos; permission already consented |
| Automated connector refresh | does 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 routes | does 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" tier | does 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) |