Skip to main content

GitHub Deployment Gate — demo script

Read this aloud while driving the demo. Beats are numbered, each carries what is on screen, what SecurityV0 is doing underneath, the spoken line, and elapsed time.


Status of this document — read before using it

  • The build is committed. Ivan (CTO), 2026-07-24: the GitHub Deployment Gate is a committed build, not a proposal. The mechanism beats in this script — run held → context supplied → verdict returned → named human decides → the run resumes or fails closed → result verified — are being built.
  • Two product decisions in this script are deliberately NOT answered, and the script is written so it does not need them. (1) Whether an approver may ever accept an exception on a held verdict — §8.1. (2) Whether the gate covers the creation of production authority or only its activation — §1. Both go to the working session with the use-case selection. If you find yourself answering either one from the stage, you are inventing product policy in front of a customer.
  • The use-case framing is a recommendation, not a decision. Sergey reserved the selection of the workload, held operation, production authority, envelope, approval owner, SecurityV0 delta, verification and design partner for a focused working session. This script is written against the recommended candidate — agent promotion to production — from research/usecase-recommendation.md §3.
  • That is fine, and it is why we can rehearse now. The mechanism beats (1, 2, 3, 7, 8, 9) do not change with the selection. Only the content of beats 4–6 and of the two endings changes. See §13.
  • The pitch is AI-agent approval — "Approve. Prove. Rehearse." Determinism, evidence packs and the read-only connector model are build properties that make the answers trustworthy. They are never the headline, and they are never the moat. Do not open with them.
  • The word "control" stays off this demo until enforcement and verification are proven in the bounded scenario. We hold and release; a named human decides. Self-check before you present: grep -n -i control this file. The only permitted hits are this rule, the never-claim list in §11, the code identifier promotion-control-subtraction, and Beat 6's "a safety control that exists in test" — which is the customer's control, not ours. Anything else is a line to rewrite.
  • This gate runs in parallel with the Trajectory direction (#1883), it does not replace it. All three #1883 items are being built; GitHub is simply first, because it is the most straightforward to understand and the most demonstrable. If asked, say so plainly: authority is the foundation, trajectory is the strategic expansion, and this is the first bounded enforcement proof on the authority side.

0. What the demo proves, in one sentence

An AI agent is being promoted to production. GitHub holds the deployment before it can touch a single production credential; SecurityV0 says, by name, what that agent will be able to reach in production that it could never reach in test; the agent's own business owner decides — release it, send it back, or refuse it; and we check afterwards that the estate landed the way we said it would, or, on a refusal, that it did not change at all.

Say "the deployment", not "the access". What the gated job holds is the activation of the production agent, not the creation of its authority — the authority-bearing production role is created by the estate's own Terraform, ungated, long before any promotion runs. See §1.


1. The cast, and where the demo runs

People

WhoRole in the storyReal or persona
Dan Okafor, ML platform engineerShips the change. Starts the promotion run by hand — the workflow has no push or pull-request trigger. Never approves his own deployment.Persona — pick any name, keep it consistent. (If you want an in-lab name instead, marcus.webb@enterprisenimbus.example.com exists on the data-platform side of the same stack.)
Priya Raman, product owner for the media-insights agentThe approval owner. She owns the agent and its business purpose. She has no GitHub repo access and could not be a GitHub required reviewer even if we wanted her to be.Grounded — Owner = "priya.raman@enterprisenimbus.example.com" is the tag on both the UAT and production twins of this agent (sv0-demo-labs/labs/enterprise-nimbus/account-en-workloads-uat/main.tf:70-74, whose comment says "Same accountable owners as the prod twins so the promotion story reads as one workload across two environments")
You, drivingNarrator. Never touch the approve button — Priya's screen does that, even if it is you on a second window.
(optional) a security lead in the audienceThe buyer. Aim beats 4–6 at this person.Prospect

The workload being promoted

en-default-media-insights — a Bedrock agent whose stated job is to "analyze viewing metrics and content-performance data and produce media-insight summaries for the analytics team" (verbatim from the lab's agent instruction). It exists twice:

  • the tested copy, in the AWS account en-workloads-uat (059567099184), deliberately narrow — it may invoke one foundation model and read one reports bucket, with no cross-account statements at all;
  • the production copy, in the AWS account en-workloads, which additionally carries an inline policy reading a customer-PII bucket in a third account, en-data.

That asymmetry is real Terraform on sv0-demo-labs origin/main (account-en-workloads/promotion-demo.tf, media-reports.tf on both sides). It is not staged for the demo; it is the lab.

Naming note: the lab templates agent and bucket names as en-{lab_id}-…. With the default lab_id that is en-default-media-insights, en-default-pii-customers, en-default-media-reports / -media-reports-uat. Read the actual names off the screen on demo day; do not read them off this page.

Systems and identifiers

ThingValueNote
SecurityV0 tenantenterprise-nimbusA demo_real tenant — real connector data, no seed script touches it
Connector refresh set for that tenantaws + entra-servicenow — both, every timeRun one and skip the other and the cross-system bridge silently fails to form. No error is raised (src/ingestion/stitching/bridge-coverage.ts:5-11)
Repoa private repo in the SecurityV0 org holding the promotion pipelineThe org is on the enterprise plan and all product repos are private — verified 2026-07-24 via authenticated gh. Custom protection rules in private repos require GitHub Enterprise; Team is not sufficient (availability reusable)
GitHub environmentsproduction-infra (ungated) and production (gated by our App)Two-phase promotion — see below
Platform environmentprimary: dev.securityv0.com; proven fallback: local Docker stack + a tunnel for the webhookSee §2 for the blocker on the primary
AWS accounts in shoten-workloads-uat, en-workloads, en-dataThree accounts. The whole point is that the interesting reach crosses an account boundary

The pipeline shape — and why it is two phases

The GitHub webhook fires before the job runs. So if the gated job were the apply that creates the production agent, we would be examining the production copy as it existed before the change, and on a first-ever promotion there would be no production copy at all. The fix is pipeline shape:

  1. Phase 1 — production-infra, ungated. The job named publish an immutable version. It publishes an immutable numbered version of the agent through a throwaway publisher alias and emits that number as a job output. Production traffic does not move — but do not say "nothing serves it". The publisher alias is a real alias, and for as long as the hold lasts the version under review is invokable through it by anyone holding bedrock:InvokeAgent on that alias. Concede that if asked.
  2. Phase 1.5 — refresh the picture. The job named refresh the estate picture (manual — see #1913). Its trigger and completion contract are undecided, so today it is an honest placeholder that performs no refresh and says so. Read its name off the screen and do not narrate it as if it refreshes anything.
  3. Phase 2 — production, gated. The job named repoint production to the approved version. It repoints the production alias to exactly the version phase 1 published. This is the job we hold.

The invokable publisher alias has a fix available, and a cost — record it as an option, not a plan. AWS supports pausing an alias: UpdateAgentAlias with aliasInvocationState = REJECT_INVOCATIONS, readable back through GetAgentAlias, and AWS states you need no IAM policy modifications to pause an agent. So the publisher alias could be created paused and unpaused only on release. The cost is that the pinned Terraform provider does not expose it — measured 2026-08-05, a probe resource declaring alias_invocation_state against provider 5.100.0 fails terraform validate with An argument named "alias_invocation_state" is not expected here. So this route exists as a direct AWS API call, not as Terraform configuration, and taking it means putting a non-Terraform step into a pipeline whose whole argument is that Terraform is the thing being gated. Nobody has chosen it. Do not present it as done or as decided.

The workflow is workflow_dispatch only — no push trigger, no pull-request trigger. That is a deliberate decision recorded in the workflow's own comments, not an oversight. A promotion is started by hand, so any narration that opens on a merged pull request is describing a pipeline that does not exist.

CORRECTED 2026-07-26 — this used to read "publishes the alias / cuts traffic over", and that was not buildable. The lab had no alias and no version: prepare_agent = true re-prepares the DRAFT working copy on every apply, so aws_bedrockagent_agent.agent_version is the constant string DRAFT. There was no immutable id for phase 1 to emit, and an alias created with routing_configuration omitted snapshots whatever DRAFT exists at apply time — after the approver released the hold. An out-of-band edit during a 30-day hold would have gone undetected, which is the demo's central claim failing silently.

Status of the two vendor behaviours this section rests on: documented, not yet executed in this lab. AWS's Deploy an agent page states that during alias creation Amazon Bedrock creates a version of the agent automatically, numbers versions in order starting from 1, and that versions are immutable; on the update path, omitting routingConfiguration makes Bedrock create a new version and associate it with the alias. Separately, HashiCorp's AWS provider documentation gives the import ID for a Bedrock agent alias as the alias ID and the agent ID separated by a comma — alias first, e.g. "66IVY0GUTF,GGRRAED6JP" — which is exactly the order our code already writes. Neither of these is a guess any more. Neither has been run here, and documented is not observed. The import format specifically stays a low risk for a different reason worth keeping: a wrong import ID fails loudly at plan time rather than corrupting anything.

Why the corrected shape is a stronger claim, and worth saying on stage: a published Bedrock version is immutable, and phase 2 takes that version number as a required input with no default and no data source that could re-resolve it. So "phase 2 repoints production to the version phase 1 published, and nothing else can reach production through this pipeline" is not a policy promise — phase 2 is structurally incapable of shipping anything else. Read it back off the live alias on stage if anyone asks. That is a claim about pipeline integrity, and it holds today.

Do not extend it into a claim about the approval. "Version 7 is byte-for-byte what you approved" asserts something further: that SecurityV0 put that specific version in front of the approver and sealed the decision against it. That needs the published version number to reach us, and today nothing carries it — it exists only as a job output flowing from phase 1 to phase 2. The intended fix is the one measured on 2026-08-05 and written up under "Where the channel is missing" below: a token holding only actions: read can already list a run's artifacts, so the pipeline uploads the promotion record as a workflow artifact and we read it. That is a workflow change, not an additional App permission — which is why it is the intended fix rather than a wish. Until it lands, say "this pipeline can only promote the version it published" and stop there.

Ownership — describe what was built, not the tidy version. The estate stack owns the agent. The production alias is deliberately adopted by both states: the estate declares it and ignores its routing, the pipeline imports the same alias and ignores its name, description and tags. Both states track the same remote object; ignore_changes divides which planned updates each one makes, it does not transfer ownership. Do not say "two Terraform states cannot own one resource" — this design has two states pointed at one resource on purpose, split by attribute rather than by object.

Why that is safe here, which is the interesting part. The gated deployment role denies alias create and delete outright and is scoped to one exact alias ARN. So a plan that came out destroy-shaped or replace-shaped does not quietly take production's alias away — it fails loudly with a permission error. The ungated phase still cannot write the switch the gate guards. What is genuinely untested is teardown and recreation across the two states, and that is the honest caveat to carry.

Replacing the gated Terraform resource with a direct alias-update call would remove the split altogether. That remains available as a simplification and is an open call — it is not decided, and nothing here should be read as recommending it.

This is a named precondition on any design partner, not an implementation detail. Our own prior research recorded the same requirement (sv0-documentation/docs/architecture/research/2026-02-27-pre-deployment-assurance-research.md, §3.6 / Appendix A). If a partner's promotion is single-phase, this use case is structurally silent on their first promotion — say so before they find out.

What this shape means we are actually gating — carry it honestly. Neither pipeline phase creates production authority. Phase 1 publishes an immutable numbered version through a throwaway publisher alias; it creates no identity and no grant, and both pipeline deployment roles deny every IAM write. The agent's authority-bearing production role is created by the estate Terraform in sv0-demo-labs (the enterprise-nimbus workloads account), ungated, long before any promotion runs. Phase 2 — the job we hold — activates that already-existing authority by repointing the production alias. So the accurate claim is "we gate the cutover", not "we gate the creation of production access". The role is assumable before the gate ever fires, and an actor who can already assume it does not need our gate.

This is one of two product decisions parked for the working session (the other is §8.1). The three options on the table, none of them chosen here: (a) narrow the claim to cutover gate and say so in every room; (b) move the authority-bearing role out of the estate stack and into the promotion pipeline, created disabled / non-assumable, so activation and authority land together at the gate; (c) gate the authority-bearing apply itself via a declared-state rehearsal — the pipeline declares what it is about to change and a human confirms that declaration against the diff (the extra beat already described for the authority-adding deployment candidate in §13). Present the options; do not decide them on stage.

What the gated job is being held to deploy

The webhook tells us a commit. The commit is not what ships. Our own pipelines prove it: deploy-azure-staging.yml:70 re-resolves main HEAD after the run that triggered it, and deploy-prod.yml:6-8 takes an operator-supplied image_tag as a required input — in both cases the thing that lands can differ from the commit GitHub reported. So the hold is designed to bind the artifact identity, not the commit: the immutable id of the object the gated job will deploy, plus workflow + run + attempt + environment. Designed, and not yet wired — say it that way until it is.

For a container deploy that id is the image digest. In this lab there is no container — the object being activated is the prepared Bedrock agent version the alias will point at (sv0-demo-labs/modules/tf-bedrock-agent/main.tf:83 creates the agent; per the ownership note above the estate stack owns the agent and declares the production alias, the pipeline adopts that same alias for its routing only, and versions are published through a throwaway publisher alias of the pipeline's own — §2). Resolving that id in phase 1, passing it to phase 2, and refusing to release unless the gated job deploys exactly it, is a BUILD row (§3) and a partner-side precondition. This is artifact binding, not image scanning — we never look inside the artifact; scanning is an explicit non-goal.

Where the channel is missing, and the cheapest way to close it. Today the published version number exists only as a GitHub Actions job output flowing from phase 1 to phase 2. Nothing carries it to us: the webhook has no artifact field — its deployment object identifies no artifact — and the pipeline calls SecurityV0 not at all. The measured resolution path is that a token holding only actions: read can list a run's artifacts (measured 2026-08-05; the same token is refused by the deployments and contents endpoints), so the piece that is missing is the pipeline uploading the promotion record as a workflow artifact, not an additional App permission. Downloading artifact content is not measured — the spike runs upload nothing, so the listing came back empty.


2. Setup — a checklist someone can execute

T-2 days — the estate

  • cd repos/sv0-demo-labs && git pull — the local working copy has been observed 3 commits behind origin/main and missing the media-reports.tf files that make the "wider in production" disclosure fire.
  • Clear sv0-demo-labs#89 first. The en-security org CloudTrail is tainted in state; an untargeted make up-full destroys and recreates it and takes the exercised cross-account evidence with it. Do not run make up-full until that issue is resolved or the apply is targeted.
  • Bring the enterprise-nimbus full tier up (lab_tier=full). The Bedrock agents and the org trail exist only at full tier; the documented operating model is up-before, down-after (labs/enterprise-nimbus/COSTS.md, ~$10–12/mo full, target <$5 per demo cycle).
  • Run labs/enterprise-nimbus/scripts/exercise-data-refresh.sh so the cross-account crossing carries genuine CloudTrail evidence rather than configuration alone.
  • Confirm both agents exist: the UAT agent in en-workloads-uat (var.create_uat_agent defaults true) and the production counterpart in en-workloads.

T-2 days — the repo and the gate

The repository is build work, not setup — it is issue #1907 (plan S21 / T1-27), and it is on the demo critical path. This section used to open with a single unchecked line asking for a private repo with the two-phase workflow, as though it were a switch to flip on the day. It is not: it is a repository, two GitHub environments, a workflow that applies terraform across two AWS accounts, an OIDC trust relationship, and artifact identity threaded from phase 1 into phase 2. The AWS destination already exists in sv0-demo-labs; the pipeline that promotes into it does not. Verify #1907 is closed before relying on any checkbox below — the rest of this list configures a repository that has to exist first. This is also not the spike repository from #1885/#1900, which is throwaway and whose experiments deliberately reject and cancel deployments.

  • #1907 complete: private repo in the SecurityV0 org with the two-phase workflow (§1), its two environments, and the OIDC trust in place.
  • Install the SecurityV0 Deployment Gate GitHub App on that repo. App configuration: Actions read-only, Deployments read and write, subscribed to Deployment protection rule, webhook URL + webhook secret set.
  • Enable the custom rule on the production environment (Settings → Environments → check the rule → Save). Verify with GET /repos/{owner}/{repo}/environments/production/deployment_protection_rules.
  • Deselect "Allow administrators to bypass configured protection rules" and verify it. It is ON by default, and an admin can otherwise click "Start all waiting jobs" and force every held deployment through. If this box is checked, the demo is a lie.
  • The production AWS role's trust policy uses StringEquals on token.actions.githubusercontent.com:sub == repo:SecurityV0/<repo>:environment:production and on aud. Never StringLike with repo:org/repo:* — GitHub's own docs call that out as the pattern that "allows any branch, pull request merge branch, or environment to assume the role", i.e. it defeats the gate entirely.
  • No production credential exists as a repository or organization secret. Environment secrets only, on the gated environment.
  • deployment: false is not used on the gated environment; pull_request_target is excluded from it; runners are GitHub-hosted, not self-hosted.
  • Phase 1 emits the artifact identity and phase 2 deploys exactly it. Phase 1 resolves the immutable id of the object being activated (§1) and publishes it as an output; phase 2 consumes that output rather than re-resolving anything from main; the hold record stores it alongside workflow, run, attempt and environment. If the pipeline re-resolves, the thing we held is not the thing that ships and the demo's audit claim is false.

T-1 day — the platform

  • Tenant enterprise-nimbus has rehearsal_enabled and verdict_enabled on (PATCH /api/v1/admin/tenants/:slug/features).
  • The pairing between the tested copy and the production copy is written and confirmed — the tenant's promotion counterpart map, including the agent pair and the two media-reports buckets. This is super-admin-only, and on a deployed environment that write path is blocked by #1762. Decide the route (operator API-key kind, an operator with a super-admin session, or run the demo on the local fallback stack) before demo day. No seed script authors this map, and scripts/clean-tenant.ts wipes it on any tenant reset.
  • Refresh both connectors for the tenant: sv0-aws scan --all --submit and entra-servicenow --all --submit. Skipping one leaves the graph internally inconsistent with no error.
  • Know which dispositions will be on Priya's screen before you walk in. They depend on the computed verdict and on a policy decision that is not yet made (§8.1). If the "accept exception" question is still open — it is, as of this document — then on a non-Approve verdict the only terminal action available is refuse, and Ending B is the refusal ending. Do not rehearse a release you cannot perform.
  • Approver enforcement: confirm whether the server-side restriction on who may confirm has landed (§3). Until it has, do not perform a real release in front of an audience and do not say the owner is the only person who could have released it — say what is actually true (Beat 7B).
  • TENANT=enterprise-nimbus npx tsx scripts/check-tenant-bridge-coverage.tsread the table, do not trust the exit code. The diagnostic is explicitly not a hard gate: a one-sided row whose missing side is a wildcard it cannot tie to a connector is reported and still exits 0 (src/ingestion/stitching/bridge-coverage.ts, absentSideSeveritycorroborate). Only a one-sided row whose counterpart connector is present in the tenant exits non-zero.

T-60 minutes

  • Run the whole thing end to end once, on the same day, on the same estate. Record the measured wall-clock latency from merge → hold visible → verdict posted → released. GitHub publishes no latency SLO for this event; this number is the only one we will ever have.
  • Open the Promotion view in the product and read the numbers off the screen. Write them on a card. Never quote a number the live view will not reproduce — that rule is already in our shipped demo runbook and it exists because of exactly this failure.
  • Confirm the pairing is still valid. If anyone renamed, retagged or re-sourced the production agent since the map was confirmed, the rehearsal returns 422, not a soft warning — the fingerprint is over display name, entity type and source system.
  • Have the fallback recording open in a second tab (§12).

T-5 minutes

  • Pre-warm every page you will show. Log in. Dismiss anything modal.
  • Phase 1 has run, and the estate has been re-read out of band — the pipeline's refresh job is a placeholder that performs no refresh (#1913), so this is a manual connector run (standard variant; see §14 for the variant where the pipeline refreshes live).
  • Silence notifications. The status report from GitHub is going to arrive on screen and it should be the only thing that moves.

3. Screens this demo depends on — shipped vs. being built

Say this to no one; know it yourself. If you are rehearsing before the build lands, the build rows are the ones you must fake or skip, and you must not narrate them as if they exist.

Every beat in this script traces to a row here. The right-hand column names which beat depends on it, so you can check the script against the build before you book the room.

Reading the ids: T1-nn is a Track 1 work item in the implementation plan. It is not the T1 Advisory enforcement tier. The tier vocabulary is T1 Advisory / T2 Hold-with-human / T3 Automatic; this demo is T2, and no tier in it is "observe only" — a rule that is enabled always holds the run, if only for as long as the adapter takes to answer.

Screen / capabilityStatusBeats
GitHub Actions run, job "Waiting for approval", deployment panelGitHub, shipped1, 2, 8, 8A
Gate receiver: signature check, ACK inside GitHub's 10 s window, hold record keyed on repo + environment + run + attemptBUILD — Track 12, 8A
Artifact binding — resolve the immutable id of the object the gated job will deploy (image digest where there is one; here the prepared agent version) and store it on the hold with workflow + run + attempt + environment; refuse to release unless the job deploys exactly itBUILD — Track 1, and a partner-side pipeline precondition (§1). Not image scanning. No channel carries the id to us today: it lives only as a job output between the two pipeline jobs, the webhook has no artifact field, and the pipeline never calls us. The gate can already list a run's artifacts on the permission it holds (measured 2026-08-05), so the build is the pipeline publishing the record, not a wider App permission2, 8B, 10A, 9
The promotion comparison itself — tested-copy reach vs production reach, cancellation, appeared list, action widening, coverage caveatsShipped (PR #1767, merged; verified in-product on real AWS data)3, 4, 5, 6, 9A
Downloadable Markdown evidence artifact of that comparisonShipped (src/rehearsal/report.ts, renderDeploymentReportMd at :747renderPromotionReportMd at :1110)fallback ladder, §12
GitHub deployment status report carrying our summary while the job is still heldGitHub API documented (10 posts max, 1024 chars, Markdown); our poster is BUILD3, 9A
A link that lands a reviewer on this specific promotion comparisonBUILD — the Promotion tab has no URL parameter today3, 4
Routing the hold to the agent's owner — resolve the owner from the ownership graph (OWNED_BY, src/ingestion/types.ts:22computeOwnershipStatus, src/ingestion/authority-path-materializer.ts:396), notify them, queue the holdBUILD — Track 1. Resolve, notify, queue; it is not a notification platform3, 4, 7A/7B
A "held deployments" queue in the productBUILD — proposed as a sibling route under the existing Deployment Rehearsal entry4, 7A/7B
Absolute per-source data age (wall-clock "last read 41 minutes ago") on the coverage panelBUILD (T1-15) — today's staleness check is relative to the snapshot (computeStaleSources, src/rehearsal/service.ts:276-310, anchored on asOf, never wall clock), so a uniformly old picture raises nothing6, §11 Q2
A human Approve / Request changes / Reject action recorded against the decisionBUILD (#1852). The approver is recorded on the gate decision; the field that would carry "who confirmed this" into the sealed summary row is deliberately fenced off (the sealer always writes null) until it has a tamper-evidence mechanism of its own7A, 7B, 10A
Enforcement of who may confirm — primary and fallback approver sets resolved to immutable user ids and checked server-side; the confirmation route rejects machine-to-machine and delegated-agent identities; self-approval blocked where policy says soBUILD — a named prerequisite before any real T2 release, not a nicety. Recording the owner is routing; this row is authorization7B, 10A
"Accept exception" — a release on a Constrain / Reject / Unknown verdictNOT BUILT, AND NOT DECIDED. Open product decision, §8.1. Today those verdicts have no release affordance at all7B-Q
"Request changes" as a third disposition — in the gate decision's state machine and in the record contract: keeps the hold, posts a comment on the run, calls neither approve nor rejectBUILD — Track 17A
Approval-content hash stamped at compute, re-checked when the named human confirms — a terminal decision is only accepted against the same decision content that was rendered to the approver: policy and thresholds, freshness class, the named delta, coverage, subject and artifact binding, approver set, outcome. Three hashes with separated jobs: rehearsal_verdict_hash (over the complete verdict) is evidence and drift-labelling only and is never carried in the payload; rehearsal_projection_hash (the verdict with as_of, totals.entities_materialized and totals.baseline_paths removed) is the one the payload carries; and approval_content_hash is the compared object. No field inside the compared hash may be derived from an excluded field — so that an unrelated connector scan cannot refuse a release for a reason that has nothing to do with the decision so that an unrelated connector scan cannot refuse a release for a reason that has nothing to do with the decisionBUILD — Track 1, on the hash mechanism already shipped at src/services/verdict-engine.ts:363-3647B, 10A, §11 re-run Q
Sealed summary row for the terminal decision (release or refuse) in the existing decision_records logThe log is shipped (#1780) — but it is insert-only and pointer-linked with an unkeyed integrity checksum, not a cryptographic hash chain. In the code's own words: it detects corruption, transplant and re-parenting, but "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:22-37). Treat that keyed MAC / signature as a named prerequisite before one of these rows backs a real customer production approval. The gate's one-row-per-terminal-decision seal is BUILD — and not free: DecisionRecordContent is a closed union (src/domain/decision-records/types.ts:229) and sealDecisionRecord accepts only a VerdictResult (src/services/verdict-engine.ts:1281), so a gate record needs the versioned discriminated record kind decided in ADR-036 Decision 7 (gate_decision_v1, 0.6 lane-weeks [assumed]) first7B, 8B, 10A, 9
"Every deployment we gated" list — each with its verdict, its evidence, and its approver as recorded on the gate decisionBUILD — Track 1, and cheap: the storage query already exists (queryDecisionRecords, src/storage/storage-adapter.ts:893) with no callers today. It lists sealed decisions; it does not re-render a past reach map, and the sealed row itself carries no approver (confirmed_by is always null, src/services/verdict-engine.ts:1278)9
Release / refuse callback to GitHub — symmetric lifecycle: a refusal is dispatched, confirmed and recorded exactly as carefully as a releaseBUILD — separately permissioned response adapter8, 8B, 10A
Polling the environment's protection-rule list to detect the gate being removedBUILD — Track 19, §11 Q4
Verification read-back and projected-vs-realized checkBUILD — extends #1568, does not duplicate it9

4. The flow


5. At a glance

 0:00      0:40      1:10      2:20      4:00      5:30      7:30      9:00     10:30
| | | | | | | | |
run job verdict THE second honesty decision lands in verified
starts HELD posted MOMENT finding panel made GitHub read-back
no creds to GitHub named read out resume + projected
bucket or fail vs realized
BeatElapsedWho is actingWhat the audience is looking at
10:00–0:40DanThe promotion workflow is dispatched by hand; the publish job runs; the gated job goes to Waiting
20:40–1:10GitHub → usThe held job. It cannot mint a production token
31:10–2:20SecurityV0A status report appears under the held deployment, with a headline and a link
42:20–4:00PriyaThe named destination in a different AWS account — the moment
54:00–5:30PriyaThe destination that looks unchanged but is wider in production
65:30–7:00PriyaThe coverage panel — what this comparison cannot see
77:00–9:00Priya / DanEnding A (send it back, fix, re-run, release on a clean verdict) or Ending B (refuse it, with a reason, and put the exception question to the room)
89:00–10:00GitHubThe decision lands: the job resumes and the cutover executes (A), or the run fails closed and never gets a credential (B)
910:00–11:30SecurityV0Read the decision back out of GitHub; compare projected vs realized (A) or confirm nothing changed (B); show the list of everything we gated

This strip is the Ending B timeline. Ending A adds a fix-and-re-run cycle, so its beats 9A/10A land at 9:00–10:30 and verification shifts to 10:30–12:00 (§9).

Every elapsed time above is a target, and targets are assumed, not measured. GitHub documents exactly two timing numbers in this entire mechanism — a 10-second response expectation on us, and a 30-day hold ceiling — and publishes no latency SLO. Replace this column with the number you measure in the T-60 rehearsal, and label it measured when you do.


6. The narrative — Act 1 and Act 2

Beat 1 — the change ships · 0:00–0:40

On screen. GitHub, the Actions tab. Dan runs the promotion workflow by hand — it is workflow_dispatch only, with no push and no pull-request trigger, and that is deliberate. The first job, publish an immutable version, is already green. The job we hold, repoint production to the approved version, turns yellow and reads Waiting, with the environment production named next to it. (Between them sits refresh the estate picture (manual — see #1913), which is a placeholder and performs no refresh; if the audience reads the name off the screen, say so rather than skate past it.)

Underneath. Nothing from us yet. GitHub has reached a job that references an environment with our custom protection rule enabled, and is about to send us the event.

Say. "This is an ordinary deployment. An engineer kicked off the promotion that moves an AI agent from the tested version into production. Everything you're about to see happens without him doing anything differently."


Beat 2 — the deployment is held · 0:40–1:10

On screen. Still the waiting job. Point at the word Waiting. If you want a second screen, the environment settings showing the SecurityV0 rule enabled and admin bypass switched off.

Underneath. GitHub sent us the deployment_protection_rule event. Our receiver validated the signature, acknowledged inside GitHub's 10-second window, pulled the run handle out of the callback URL — the only place a run identifier appears anywhere in that delivery — and wrote a hold record keyed on repository + environment + run + attempt. Then it queued the real work. It deliberately did not compute anything inside that request — GitHub counts a delivery failed if we take longer than ten seconds to answer.

The artifact binding is designed and not yet wired, and the missing hop has a name. The design is that the same hold record also carries the artifact identity — the immutable id of the object this job will activate — alongside the workflow, run, attempt and environment, with the commit sitting on the record as evidence rather than as the key. Today nothing carries that identity to us. The published version number travels only as a GitHub Actions job output between the two pipeline jobs; the webhook we receive carries no artifact field — its deployment object identifies no artifact — and the pipeline makes no call to SecurityV0 at all. That is the missing hop: no channel takes the published version number from the pipeline to us.

The resolution path, and what has and has not been measured. On 2026-08-05 an installation token holding only actions: read was measured returning 200 from the run's artifacts listing and from the run object, and 403 from the deployments and contents endpoints — so the scope really is that narrow. The gate can therefore read a run's artifact list with a permission it already holds, which means the missing piece is the pipeline publishing the promotion record as a workflow artifact, not a new App permission. One caveat, and it is the difference between measured and assumed: downloading artifact content has not been measured. The spike runs upload no artifacts, so the listing endpoint returned an empty list.

Say. "The deployment is now held, and this is the part that matters: while it's held, that job has no production credentials at all. The production secrets live on this environment, and the cloud role it would use will only trust a token that says it came from this environment after every gate passed. It isn't waiting politely — it cannot mint a production token."

And say this next once the binding is wired, because it is the difference between an audit record and a story. "Notice what we've pinned. GitHub told us a commit. A commit is not what ships — pipelines re-resolve branches, and plenty of them take a build tag as an input, so the thing that lands can be a different object from the one the commit implies. Ours do both, and we wrote this pipeline. So what we hold this job to is the exact immutable artifact it is about to activate, not the commit that suggested it. If it tries to deploy anything else, the release we granted doesn't cover it."

Two honesty checks on this beat. (1) Artifact binding is a BUILD row (§3) and a pipeline precondition (§1) — and today no channel carries the published version number from the pipeline to us at all. Until it lands, say "the commit" and do not mime the digest. (2) "It cannot do anything" is too strong and a sharp buyer will catch it. The production role already exists — the estate Terraform created it, not the pipeline — and what the held job lacks is a token to assume it. Say "it cannot mint a production token", and if pushed, concede the shape honestly: we gate the cutover, and whether the gate should ever cover the creation of authority is one of the working-session decisions (§1).


Beat 3 — SecurityV0 answers · 1:10–2:20

On screen. Under the held deployment in GitHub, a status report from SecurityV0 appears. Read the headline out loud:

SecurityV0 — held for owner approval. After this cutover, the production agent reaches 1 destination the tested agent never reached: en-default-pii-customers (customer data, account en-data). One further destination is reachable in both, with wider permissions in production. Owner: priya.raman@… · [Open in SecurityV0]

Underneath. We took one snapshot of the customer's identity estate, built two reach sets from it — what the tested copy of the agent can get to, and what the production copy can get to — and subtracted one from the other. Cancellation runs in a fixed order: same destination cancels automatically; then destinations that a human has explicitly confirmed as a matched pair between the two environments cancel. Whatever survives is what the production copy can reach and the tested copy could not. We wrote that verdict onto the hold, stamped with a hash of exactly what was computed, resolved the agent's owner from the ownership graph, put the hold in her queue, and posted the summary back to GitHub without making a decision — the same endpoint takes a status report with no verdict attached, and that is what we used. Nothing is written to the decision-record log yet: the summary row is sealed when the decision is terminal — released or refused. (That log is insert-only and pointer-linked with an unkeyed integrity checksum — §3. Do not call it a hash chain.)

Say. "SecurityV0 has just compared the agent you tested against the agent you're about to run, and posted the answer straight into GitHub, where the deployment is. Nobody has approved or rejected anything yet. Let's look at what it found."


Beat 4 — the moment · 2:20–4:00

On screen. Priya's browser. The held deployment in SecurityV0. Three numbers side by side, then one name:

   Tested agent reaches           1 destination
Production agent reaches 2 destinations
Appears in production 1 → en-default-pii-customers
customer data · account en-data
reached via: prod agent role
→ cross-account assume role
→ bucket

Hover or click the path so the three account boundaries are visible.

Underneath. That destination survived both cancellations. It is reachable from the production agent's execution identity through a cross-account trust relationship that lives in a different AWS account from the one being deployed to — and is not in the repository being deployed at all. We also checked whether any other identity already had a role-based path to it, so we can say whether this deployment is the only way in.

Say. "The agent you tested could reach one thing. The production copy can reach two. The extra one is a customer-data bucket in a different AWS account — and the way it gets there isn't in the code that's being deployed. It's a trust relationship in a third account. There is no diff anywhere in this pipeline that shows you that."

Then stop talking. Let it sit. This is the beat the demo exists for (§10).


Beat 5 — the finding that a subtraction would have hidden · 4:00–5:30

On screen. The second disclosure, below the first:

   Reachable in both environments, but WIDER in production
en-default-media-reports production permits: Get, List, Put
tested permits: Get, List

Underneath. This destination cancelled — both copies of the agent reach it, so a naive comparison drops it and reports nothing. We check the actions separately and name the widening anyway, precisely because cancellation would otherwise hide it.

Say. "This one is more subtle, and it's my favourite. Both versions of the agent can reach this reports bucket, so a straight comparison would say 'no change' and move on. But in production it can write where in test it could only read. That difference is invisible in the diff and invisible in a naive comparison. We name it."


Beat 6 — what this cannot see · 5:30–7:00

On screen. The coverage panel. The first line in it is not a number, it is a caveat. This is the shipped string, verbatim — the screen says UAT, so read it as it is written (src/rehearsal/coverage.ts:471-478):

This comparison assumes the promotion reproduces the UAT configuration faithfully. Subtracting the two environments catches added access but cannot see a guardrail present in UAT and absent in production — do not treat this verdict as a clean gate.

Below it: the snapshot time; the age of each source of data (for example aws_iam last read 41 minutes ago, entra_id last read 3 hours ago); how many pairs are confirmed and when they were last checked.

Ledger check. The absolute, wall-clock per-source age is BUILD (T1-15) — see §3. Today the panel's staleness check is relative to the snapshot, not the clock. If you are rehearsing before T1-15 lands, do not narrate the "41 minutes ago" line; say "when each source was last read" and show what the panel actually renders.

Underneath. Those first two caveats are not conditional and not a layout choice — the engine emits them on every promotion verdict it produces, and forces promotion-control-subtraction (the code's own name for the caveat above) to render ahead of even the snapshot clock. It cannot be configured off, because a promotion verdict without it reads as a clean CI gate, which is exactly the false confidence we refuse to sell.

Say. "And here's what it can't see. It can tell you what production gained. It can't tell you about a safety control that exists in test and quietly isn't there in production. That caveat prints on every single one of these, automatically. That's why a person still makes this call and not a robot — and it's why, when this thing does tell you something, you can believe it."

Do not skip this beat to save time. It is the beat that makes beat 4 credible to a security buyer. A prospect who has been sold three "AI security" tools this quarter is waiting to catch you overclaiming; hand them the limitation before they go looking for it.


7. Ending A — fix it in the infrastructure code

Use this ending when the audience is engineering-heavy, or when you want to show the loop closing without a judgement call.

Beat 7A — the owner sends it back · 7:00–7:40

On screen. Priya clicks Request changes, types one line — "The agent doesn't need customer PII; this looks like a copy-paste from the data-refresh role" — and submits. The deployment stays held in GitHub. A comment appears on the run.

Underneath. "Request changes" is a third disposition in its own right, in the gate decision's state machine and in the record contract: it keeps the hold in place, posts a comment on the run, and calls neither approve nor reject. Nothing goes to GitHub's decision endpoint, so nothing is released and nothing is refused — and because the decision is not terminal, nothing is sealed into the decision-record log yet. The hold simply stays a hold.

Say. "She isn't guessing. She owns this agent and she knows what it's for. It doesn't need customer records, so it goes back."

Beat 8A — the engineer fixes the cause, not the symptom · 7:40–9:00

On screen. Dan's editor: account-en-workloads/promotion-demo.tf, deleting the inline prod-data-escalation policy. Commit, push, then dispatch the promotion workflow again by hand — it has no push trigger. Publish an immutable version publishes a fresh version, the refresh job runs and performs nothing, and repoint production to the approved version goes to Waiting again.

Underneath. Same event, new run id, new hold record. The comparison has to be made against the world as it is after the fix, not against a cached picture — so the estate must be re-read between the two runs. The pipeline's refresh job is a placeholder that performs no refresh (#1913), so on demo day that re-read is a manual connector run. Do it, and confirm it landed, before you let the second hold compute.

Say. "He didn't file a ticket and he didn't add an exception. He fixed the policy, in the same Terraform that created the problem, and pushed."

Beat 9A — nothing appears · 9:00–9:40

On screen. The new status report in GitHub:

SecurityV0. No destination appears in production that the tested agent could not already reach. One destination is wider in production (en-default-media-reports: Put). Owner approval required.

And in SecurityV0, the appeared list is empty.

Say. "Same agent, same pipeline, one policy removed — and now there's nothing in production that wasn't in the version they tested."

Check this before demo day, do not assume it. Ending A only reaches a release if the residual finding — the media-reports bucket that is still wider in production — computes to Approve under the threshold table in force for this tenant. Whether an action widening on a shared destination is Approve or Constrain is a policy decision, not a fact about the engine, and it is part of the use-case scoping (§13). Read the verdict off the screen at T-60. If it comes back Constrain, Ending A ends the same way Ending B does — held, with no release affordance — and you should know that before you are standing in front of someone.

Beat 10A — the honest bit about "green by itself" · 9:40–10:30

On screen. Priya clicks Approve and release. One click, no reason required, because there is nothing to explain. Back in GitHub the job flips from Waiting to running, mints the token, and the cutover runs. The traffic alias moves to the new agent version.

Underneath. The verdict here is Approve, which is the one verdict the pilot gives a release affordance (§8.1). Before the release was dispatched we recomputed the decision and compared its approval-content hash to the one stamped on what Priya was shown — same policy, same freshness class, same delta, same subject, same artifact identity, same approver set. A difference in any of those is a new human confirmation, not a silent pass. Then the response adapter — separately deployed, separately credentialed, holding no handle to your identity graph and none of the credentials that read your cloud — its own store holds only the webhook inbox and the dispatch records — posted approved to GitHub, which returned 204. Only now does the job get the environment secrets and a cloud token whose subject says environment:production, and it is held to deploying the exact artifact identity we bound at Beat 2. This terminal decision is the point at which exactly one summary row is sealed into the decision-record log.

Say. "In the pilot a named person still clicks release even when the answer is clean — because we haven't yet proved this end to end in a customer's environment, and until we have, we don't let a machine open the door. Releasing automatically on a clean result is a setting, and it's the next step, not this one. And notice the order: the job only gets its production credentials after the decision."

Same enforcement guardrail as Beat 7B applies here, and here it bites harder — this beat performs a real release. Until the "Enforcement of who may confirm" row (§3) has landed, do not claim that only Priya could have clicked this, and do not run a live release in front of a customer audience.

Guardrail — do not say "the gate goes green by itself." In the pilot, Approve releases only after a named human confirms; anything short of Approve stays held. The honest claim is that the reason to hold disappeared, not that the gate released itself. If the audience pushes, the answer is: "that's a configuration we turn on after we've proven enforcement and verification in a real environment, and we are deliberately not there yet."


8. Ending B — the owner disposes of it in SecurityV0

Use this ending for a security or governance audience. It is the stronger ending for a CISO because it produces the artifact they will be asked for later: a named person, a named piece of access, a reason, a terminal decision, and a deployment that demonstrably did not happen.

Read this before you run Ending B. The verdict on this run is not Approve — a customer-data destination appeared that the tested copy never reached. Under the pilot rule, Approve may release after a named human confirms, and Constrain / Reject / Unknown stay held: there is no release affordance on them at all. So the terminal action available to Priya here is refuse, and that is what this ending shows. Whether an "accept it anyway" path should ever exist is an open product decision (§8.1) — put it to the room as a question, do not demo it as a capability. An earlier draft of this script had her release this run with a reason; that was the script asserting a governance policy nobody has decided, and it is exactly the kind of thing a prospect's security lead will hold you to.

Beat 7B — the owner decides · 7:00–8:15

On screen. Priya opens the appeared destination and reads the path across the three accounts. The actions available to her on this verdict are Request changes and Refuse — there is no release button on this screen, and that absence is worth pointing at. She clicks Refuse, against the named destination rather than against "the deployment", and types a reason: "This agent summarises viewing metrics. It has no business reading per-customer records, and nothing in the change request said it would."

Pick your ending deliberately. Ending A is the truer story for this lab — the agent's stated job is analytics on viewing metrics, so a customer-PII grant reads as an escalation that should be removed, and that is exactly how the lab was built. Ending A also ends in a release, a cutover and a projected-vs- realized check, so it is the better ending if the audience wants to see the loop close. Ending B is the better story for a governance audience, because a refused deployment with a reason on it is the artifact they get asked for at audit — and because it lets you raise §8.1 honestly.

Underneath. We record her identity, her reason, the named destination, and the exact computation she was looking at, and we bind them to the gate decision. Before the refusal is dispatched we recompute and compare — a terminal decision is only accepted against the same decision content that was put in front of her.

Say. "This is the part GitHub can't do. The person deciding is the person who owns this agent — not the repo admin, not the engineer who wrote the change. She has no GitHub access at all and she couldn't be a required reviewer even if you wanted her to be. And she isn't refusing 'a deployment'. She's naming the specific piece of access she is not willing to grant, with a reason, and that stays on the record."

Guardrail — what "the owner decides" does and does not mean today. Check the ledger row "Enforcement of who may confirm" (§3) before you say anything stronger than routing.

  • If that row has not landed: what is built is resolve the owner from the ownership graph, notify her, queue the hold to her. That is routing, not authorization. Say: "the hold is routed to her and she is the decision maker of record; enforcing server-side that only she or her named fallback can confirm is a prerequisite we are building before this releases a real production deployment." Do not say "only she could have done this", because today someone else with the right scope could.
  • If it has landed: then, and only then, say it as authorization — the approver set is resolved to immutable user ids and checked server-side, machine identities are rejected on the confirmation route, and self-approval is blocked where the policy requires it.

Guardrail — do not oversell the record. Do not say her decision is cryptographically signed or tamper-proof. The decision-record log is insert-only and pointer-linked with an unkeyed integrity checksum: it catches corruption, transplanting a record between tenants, and re-parenting, and it does not stop an actor with write access who recomputes the checksum. The field that would carry "who confirmed this" into the sealed row is deliberately fenced off and always written null. If asked directly: "the log is insert-only and integrity-checked on read; a keyed signature and predecessor-hash chaining are a named prerequisite before one of these rows backs a production approval in your environment, and they are on the build." The approver's identity you are showing on screen comes from the gate decision, not from the sealed row.

Beat 7B-Q — the open question, asked out loud · optional, 30 seconds

Only if the audience is senior enough to enjoy it — and it usually is. On screen: stay on the refusal.

Say. "Now, the obvious question, and I'd rather ask it than have you ask me. What if she looks at this and says 'yes, that's intended, we bought the analytics licence last quarter'? Right now there is no button for that, deliberately: a held verdict is held. The alternative is an explicit accept exception action — different from a release, needing stronger authorisation, a written reason, a scope limited to that one destination and that one run, and an expiry. We haven't decided which of those we ship, and I'm not going to invent an answer in front of you. It's the next conversation we're having internally."

That is a genuinely good look. It is also true — see §8.1.

Beat 8B — the refusal lands and the run fails closed · 8:15–9:15

On screen. Back to GitHub. The job flips from Waiting to failed, with the rejection and Priya's comment on the deployment. No environment secret was ever issued. The traffic alias still points at the old agent version.

Underneath. Our response adapter — a separately deployed, separately credentialed process that holds no handle to your identity graph and none of the credentials that read your cloud — minted a token scoped to one repository and one permission, and posted the decision to GitHub. GitHub returned 204. This terminal decision is the point at which exactly one summary row is sealed into the decision-record log.

Say. "Refused. And notice the order: the job never got its production credentials at all, because GitHub only releases them once every protection rule has passed. That's not our behaviour, that's how GitHub environments work — we just make sure somebody looked at the right thing before that happens."

8.1 The exception question — two candidate policies, and the choice is Sergey's

This is not decided, and this document does not decide it. It goes to the working session alongside the use-case selection and the phase-1 question from §1. Presenters: know both options, advocate neither.

Candidate A — no exception path. Constrain / Reject / Unknown have no release affordance, full stop. The only routes to a release are to change the deployment (Ending A) or to change the policy document that produced the verdict — which is itself a versioned, recorded, reviewable change, not a click on a held run. Simple to explain, simple to enforce, and it never lets a hold be talked away. Its cost: a legitimate intended grant has no in-product path, so real teams will route around the gate.

Candidate B — an explicit "accept exception" action, distinct from release. Not a plain release with a comment on it. It would carry: stronger authorization than a normal release (a role above the workload owner, or two-party), a mandatory written reason, a scope — this destination, this run and attempt, not "the deployment" — and an expiry, after which the same delta is held again rather than silently inherited. It is recorded as its own disposition so nobody can later read it as a clean approval. Its cost: it is a new authorization model and a new lifecycle state, and it is the mechanism by which governance products quietly become rubber stamps.

Computed verdict × human disposition. The matrix is the decidable artifact, and it lives in ADR-039, published with every cell deliberately empty. This script references it by name and neither reproduces it nor fills it in — the working session decides those cells, and a demo that showed them filled would settle the decision by publication.

Until those cells are filled, this script demonstrates only the columns that are decided.


9. Both endings converge — "and we verify the result" · Ending B 10:00–11:30 · Ending A 10:30–12:00

Do not end on the decision — released or refused. Sergey's acceptance sentence ends with verification, and the prospect's real question is "how do you know it worked?"

On screen. Three things, quickly:

  1. GitHub's own record. GET /repos/{owner}/{repo}/actions/runs/{run_id}/approvals — state, comment, user, timestamps. Show it as raw JSON if the audience is technical. It says approved in Ending A and rejected in Ending B; either way it is GitHub's record of the decision, not ours.
  2. Did the estate do what we said it would?
    • Ending A (released). After the next connector refresh, the same comparison re-run: the production agent's actual reach, against what we projected. Match → the decision stands. A destination we did not project → the decision reopens. Also check the obvious thing: the object that actually landed is the artifact identity that was bound to the hold, not something re-resolved along the way.
    • Ending B (refused). The check is the mirror image and it is the one auditors actually ask about: the cutover did not happen, the alias still points at the previous agent version, and the production agent has not gained the destination we refused. A refusal that quietly got applied anyway is the failure mode worth hunting for.
  3. The list. Scroll back one screen: every deployment we gated this month, each row a terminal decision with its verdict, the evidence behind it, and the named person who decided it. Then stop. Be exact about what this is: a list of decisions and their evidence, not a time machine for the reach map. If someone asks to see last Tuesday's picture redrawn, say we do not keep the picture, we keep the decision and what it was based on. (The approver name on that list is read from the gate decision; the sealed summary row deliberately carries none — §3.)

Say. "Three things, and none of them is us marking our own homework. The first is GitHub's own record of the decision — anyone with read access to the repo can pull it, it isn't our log. The second is that we go back and look at the estate afterwards and check it turned out the way we said: if it was released, that nothing showed up we didn't predict; if it was refused, that the thing we refused didn't happen anyway. Either way, if reality disagrees with the decision, the decision reopens. And the third is this list — every deployment we held this month, what we said about each one, and who decided it. That's the thing you get asked for in an audit, and it's already written by the time anyone asks."

Also worth saying once, and only once: "And the gate itself is a thing we check. Any repo admin can delete a protection rule. We poll for that, because a gate you can't tell has been removed isn't a gate."


10. The moment that lands — and how to protect it

The moment is Beat 4, and specifically the instant the audience reads a real bucket name in a different AWS account on a screen that never had a Terraform diff open. Beat 5 is the aftershock that proves it wasn't luck.

Why it lands: everything up to that point is plumbing a technical buyer has seen before — a webhook, a held job, a check. Beat 4 is the first thing on screen that GitHub structurally cannot produce, because GitHub has no identity graph. It knows an environment name and a token subject string. It does not know what that principal can assume, or what that role can reach, or that the reach crosses an account boundary.

What must not be broken at that moment

Must holdWhyHow you assure it
The page renders in under two secondsA spinner at the punchline kills itPre-warm the page at T-5. Have the comparison already computed for the run you are demoing
The numbers on screen equal the numbers on your cardContradicting yourself here loses the room permanentlyRead them off the live screen at T-60 and write them down. If they differ on the day, read the screen, not the card
The pairing between test and production copies is fingerprint-validA rename, retag or re-source since the map was confirmed returns a hard 422, not a soft warningRe-verify at T-60. Do not refresh or rebuild the tenant between the T-60 check and the demo
The destination name is legible and the account boundary is visible"A bucket" is a shrug; "customer PII in a different account" is a reactionZoom the browser. Do not demo this on a shared screen at 1440p unzoomed
Nothing modal, no notification, no second windowDo Not Disturb on, one browser profile, tabs pre-opened

What must not be ambiguous

  • Do not say "we blocked the deployment." GitHub held it. We supplied the context. A named human decided. That sentence order is the whole positioning and it is also literally what happened — and it holds just as exactly in Ending B: the owner refused it, we relayed that refusal, and GitHub failed the run. Our software did not veto anything.
  • Do not let the absent release button read as a product gap. On a held verdict there is no release affordance by design in the pilot. Whether there should ever be an exception path is an open decision (§8.1), and saying so is a better answer than inventing one.
  • Do not let "appeared" sound like "granted just now." It means: reachable by the production copy, not reachable by the tested copy. Say it in those words.
  • Do not front-run beat 6. Deliver the finding cleanly first; the limitations land better as volunteered honesty than as a hedge attached to the claim.

11. Honesty guardrails

Never claim, in any room

  1. "We control / block / enforce." We hold and release; GitHub does the holding; a named human decides. The word "control" is off the table until enforcement and verification are proven in this bounded scenario.
  2. "This is a clean gate." The engine itself refuses to say that, on every verdict, unconditionally. So do not.
  3. "It releases automatically when it's clean." Not in the pilot.
  4. "Your data is current." Say "as we last observed it," and show the per-source ages. The staleness check inside the product measures each source against the snapshot, not against the clock — a uniformly 60-day-old picture raises no warning at all. That is exactly why the gate output is being given wall-clock read times per source (BUILD, T1-15 — see §3; do not narrate it as present until it lands).
  5. "We ingest GitHub." We do not. There is no GitHub connector. The webhook tells us the repo, environment, commit and run; the authority picture comes from the cloud connectors; the link between a deployment and an agent is a mapping an operator sets up.
  6. "The approval is cryptographically signed by the approver." Not yet — see the guardrail in Beat 7B. Nor is the record tamper-proof, and never say hash-chained: the decision-record log is insert-only and pointer-linked with an unkeyed integrity checksum. It catches corruption, transplant and re-parenting; it does not defeat an actor with write access who recomputes the checksum. A keyed MAC or signature plus predecessor-hash chaining is a named prerequisite before one of these rows backs a production approval in a customer's environment.
  7. "This works for every deployment you have." It works for agent pairs someone has mapped. Outside the mapped set it holds the deployment and says it cannot answer — which is safe, and is an adoption cost to name, not hide.
  8. "You can reopen last Tuesday's rehearsal and see that picture again." Be precise about what comes back, because the honest version is still a good answer. What is kept, and what you can show, is the sealed verdict and the evidence behind it — the destinations we named, the numbers, the caveats, who approved it and why — and a list of them: every deployment we gated this month, each with its verdict, its evidence and its approver. What is not kept is the map itself. The reach picture is recomputed from the estate as it stands now, so last Tuesday's cone cannot be re-drawn. Say "the decision and its evidence", never "the graph".
  9. Never lead with determinism, evidence packs or read-only. They are why the answers are trustworthy. They are not why anyone buys.
  10. "The owner is the only person who could have released it." Not until the "Enforcement of who may confirm" row (§3) lands. Before that, the owner is routed to and recorded, which is not the same sentence. Say what is true (Beat 7B) — it is still a good answer.
  11. "You can approve an exception." There is no such action, and whether there should be is an open decision (§8.1). On a held verdict, the honest answer is: today it stays held, and here are the two candidate policies. Do not improvise a third one on stage.
  12. "We gate the creation of production access." We gate the cutover. The authority-bearing production role is created by the estate's own Terraform, ungated and long before the pipeline runs; neither pipeline phase creates it (§1). Narrowing that claim, or moving where the role is created so it stops being true, is on the working-session agenda.
  13. "We fail open if we're down." Wrong twice over. Only one outage shape can fail open — our evaluation failing while the adapter stays healthy — and even there it is not what we ship: the pilot default is hold, and re-approval by a person. Release-after-a-bounded-wait is a per-environment choice a customer makes knowingly. If the adapter, its key, the network or GitHub's API is what is down, nothing can post an approval and the run necessarily stays held. There is no design in which a vendor who cannot reach GitHub releases a deployment. Say the split; see the answer below.

The questions a sharp prospect will ask, and the true answers

"Doesn't GitHub already do this? We have required reviewers on prod."

Concede it at full strength first — it is a genuinely strong baseline and pretending otherwise will cost you the room. "You can already require a named security approver on the production environment, forbid self-approval, restrict which branches can deploy, disallow admin bypass, hold the job for up to thirty days, keep every production credential out of the job until that approval lands, and pull a complete approve/reject audit trail out of the API. All of that is free and you should be using it."

Then the delta: "Every bit of that is about who pressed the button and which branch it came from. None of it tells the approver what authority the deployment confers. GitHub knows an environment name and a token subject string. It doesn't know what role that subject can assume, what that role reaches, or that the reach crosses into a different account through a trust relationship that isn't in the repo. Your required reviewer sees a Terraform diff and an environment name. Ours sees the sentence you just watched: this agent will be able to read that customer bucket, and the version you validated never could."

Concede the sharpest counter before they make it, because in this lab they can: "And before you say it — in this example the grant itself is in the Terraform diff, and a good reviewer might catch it. Two things they can't catch. What that grant unlocks lives in a third account, in a trust relationship that appears in no diff in this pipeline. And the comparison isn't against the reviewer's memory of what the agent is supposed to have — it's against the version that was actually validated."

Secondary, real, but never the headline: "And that reviewer list is at most six people who all need repository access. The person who actually owns this agent has no GitHub account. We route the hold to her." (That routing is Track 1 build — §3. If you are rehearsing before it lands, say "the hold goes to her queue" and open the queue by hand; do not mime a notification that does not exist yet. And keep the verb honest: route and decide are claims we can make today; "only she can release it" is an authorization claim that waits on the enforcement row — Beat 7B.)

"What if your data is stale?"

"Then the answer is wrong, so we do three things. We show you when every source was last read, in wall clock, on the gate output itself. If it's older than the threshold you set, the deployment stays held rather than getting a clean answer. And in this pipeline the picture is re-read as part of the deployment itself, seconds before the hold." If pressed on the product internals, be precise: the built-in staleness check measures each source against the snapshot the comparison was computed on, not against the clock, which is why we are stamping the absolute read time separately (BUILD, T1-15). Do not pretend that is a nuance nobody would notice.

"What happens when you're down?"

"Down" is three different situations and they do not have the same answer. Running them together is how vendors end up lying, and this is a question a good architect will come back to. Say all three.

"First case: our evaluation fails — we can't compute a verdict, but the piece that talks to GitHub is healthy. That is the one case where releasing anyway is even possible, and it's a per-environment setting. What we ship is hold: the run stays held, loudly, and it takes a person to re-approve it. A gate that releases when it can't evaluate isn't a gate, and it fails quietly in exactly the direction someone who wants the release would choose. If you'd rather your deploys keep moving than have a young vendor sitting in the middle of your production path — a fair thing to want, and we've written down why — you can switch that environment to release-after-a-bounded-wait, and then the run carries a note saying plainly that no verdict was applied. You choose it; you don't get it by default.

Second case, and this is the honest one people don't tell you: if the piece that talks to GitHub is itself down — the adapter, its signing key, the network between us, or GitHub's own API — then nothing can post an approval, so the run stays held until we recover or one of your admins bypasses it. There is no design in which a vendor who cannot reach GitHub releases your deployment. Anyone who promises you blanket fail-open has not thought about which component failed.

Third case: we answered fine and the person hasn't decided yet. Then it stays held until they decide. Nothing releases it on a timer."

If they push on the vendor-uptime objection — and they should, it is the strongest thing they can say — do not buy your way out of it by offering fail-open. Answer it the way the decision record does: the hold is bounded by a deadline that escalates to whoever they nominate rather than sitting in silence, and the way out of a held deployment is a break-glass path their own admins hold. "The exit is your admin, not our uptime." And if they still want the release, the setting is there and it is theirs to turn on.

There is a fourth shape hiding inside the first, and if they are sharp they will find it: the platform is unreachable from the adapter, so the adapter is healthy but does not even know what your failure policy for that environment is. "You can buy that one back — a signed per-environment failure policy cached in the adapter, plus a durable queue so it acts on recovery — and it costs real engineering. But even then there's a consequence we won't hide: while the platform is down, the adapter cannot write the audit record at the moment it acts. It reconciles afterwards. If your policy requires that every release has a synchronous audit record, then for you that case has to fail closed too." The second case — the adapter itself, its key, or the path to GitHub — cannot be bought back at all. Do not imply otherwise.

Presenter's note. The blanket sentence "if we're down, we fail open" used to be in this script and in ADR-037. It was wrong, and it was wrong in the direction that gets a vendor removed from a shortlist after an architecture review. Do not reintroduce it.

If pressed on the missed-delivery case: "GitHub doesn't retry a failed webhook delivery, so a hold could sit there with nobody wondering why. We run a poller that finds deliveries we missed and asks GitHub to resend them, and we set our own decision deadline, much shorter than thirty days. That deadline reports and escalates — it never decides. The only thing that ever times a hold out is GitHub's own thirty-day ceiling, after which the job fails. That's GitHub's behaviour, not ours; GitHub documents nothing in between."

"Can a repo admin just delete your rule?"

"Yes. Removing it needs the same permission as adding it. We poll the environment's rule list and raise it when the gate disappears. That's the honest shape of it — it's a hold that a repo admin can remove, and our job is to make removal visible, not to pretend it's impossible."

"How much does this slow us down?"

"Only on the environments you choose to gate, and only for the workloads you've mapped." On latency: "GitHub publishes no delivery guarantee for this event, so anyone who quotes you a number from a datasheet is making it up. Here's what we measured on our own pipeline: [your T-60 number]. We'd measure it in yours before you switch it on for anything that matters."

"What if the approver is asleep, on holiday, or ignores it?"

"Then the deployment doesn't go. That's the point, and it's also the thing most likely to hurt in practice — so it's a question we want to answer with you rather than at you. There's a deadline and there's escalation, but be clear about what the deadline does: it reports and it escalates to whoever you nominate. It does not approve and it does not reject. Nobody's deployment is released by a clock, and nobody's is refused by one either. Where the deadline sits and who it escalates to is part of the pilot design." Do not invent an SLA.

"Does the job have production credentials while it's waiting?"

"No, and that's GitHub's mechanism, not ours — which is why it's trustworthy. Environment secrets are only released once every protection rule has passed, and the cloud role is configured to trust only a token that says it came from this environment. Both together. If you configure the trust policy with a wildcard instead — which GitHub's own documentation flags as the mistake — none of this holds, and that's one of the things we check on setup."

"Can we automate the approval?"

"Not in the pilot. A named person confirms every release. The comparison itself is deterministic, so automating it later is a policy decision, not a technical leap — but we want enforcement and verification proven with a human in the loop first."

"What about a re-run — does someone get to reuse yesterday's approval?"

"GitHub doesn't document what happens on a re-run, so we don't guess. We're pinning it empirically before this goes near a customer pipeline, and until we've pinned it we treat every attempt as needing its own decision. And a decision only releases against the same content it was made on — same policy, same delta, same freshness, same artifact. If any of that moved, it needs a fresh confirmation rather than inheriting the old one." If they ask whether an unrelated data refresh therefore blocks releases: "No — deliberately. What we compare is the decision that was put in front of the approver, not a tenant-wide snapshot counter, precisely so an unrelated scan somewhere else in your estate can't refuse a release for a reason that has nothing to do with this deployment."

"Does this work on Azure? On our stack?"

"What you just watched runs on AWS and that's what's proven. The comparison engine itself has no cloud in it — it works on the identity graph — and we already ingest Azure AI agents and their role assignments, so an Azure pair is very plausible. But 'plausible' and 'proven' are different words and I'm not going to swap them." One specific gap worth volunteering if the prospect is Microsoft-centric: the Azure equivalent of the GitHub→cloud federation link is not yet read, though the permission for it is already granted.

"Do you write to our cloud?"

"No. Everything that reads your systems is read-only, by design. The only thing we write anywhere is the approve-or-refuse answer back to GitHub, and that runs as a completely separate identity from anything that reads your environment."


12. Failure drills

Rehearse these. The failure you have practised is a two-sentence recovery; the one you have not is the whole demo.

The gate does not fire — the job runs straight through, or never goes to Waiting

Do, in order (30 seconds, out loud is fine — "let me check the obvious thing"):

  1. Is the job actually referencing the gated environment? Wrong environment name is the number-one cause.
  2. Is the rule still enabled? GET /repos/{owner}/{repo}/environments/production/deployment_protection_rules.
  3. Did someone set deployment: false on the environment in the workflow file? That configuration makes custom rules fail the job with an error and hands it full environment-secret access — it must never exist on a gated environment.
  4. Is the receiver reachable from the internet right now?
  5. Was this environment switched off the shipped setting? A run that ran straight through because our evaluation failed can only happen where somebody chose release-after-a-bounded-wait for that environment. That is not what we ship — the shipped behaviour is hold, and re-approval by a person.

What you may say out loud rather than hide — but only if the off-default failure setting is actually the answer. "This environment is set to release after a bounded wait if we can't evaluate, and the run carries a note saying no verdict was applied. That isn't the setting we ship — what we ship holds and asks a person — but it is one you can choose per environment, and this lab did." Then move on; do not turn it into a five-minute architecture talk. On the shipped setting an evaluation failure does not produce a run that sails through, so if the environment is on the default, stop reaching for that explanation and work the list.

Do not say "we fail open" as a general statement, because it is false in both directions: it is not the shipped default in the one case where it is even possible, and in the other case it is not available at all — if the adapter, its key, the network or GitHub's API is what's unavailable, nothing can post an approval and the run does not sail through; see the next drill. A run that ran straight past a gated environment while we were unreachable means the rule was not enabled, not that we let it through.

If it does not recover in 60 seconds: stop debugging in front of the audience. Say "the plumbing between GitHub and us isn't cooperating today — let me show you the part that matters, which is what the approver sees," and drive beats 4, 5, 6 live in the product from the Promotion view. The comparison is the value; the hold is the mechanism. You can do the entire security story without GitHub in the room.

The job goes to Waiting and nothing from us ever arrives

This is the opposite failure to the one above, and it is the more likely one. The run is held, correctly, and no status report appears — because our receiver never got the event, or got it and lost it, or the response adapter cannot reach GitHub.

Do, in order: check the App's recent deliveries in GitHub (redeliver a failed one from there — GitHub does not redeliver a delivery it already considers successful); check the receiver is reachable; check the adapter's outbound path to GitHub.

What you may say if it does not come back: "Right now the run is held and it stays held — which is the safe direction, and it is also the honest limit of this design. If the piece of us that talks to GitHub can't talk to GitHub, nobody can post an approval, so your deployment waits for us or for one of your admins to bypass. That's the trade you're making, and I'd rather you hear it from me now than discover it at 2am." Then go to the fallback ladder — do not stand there refreshing.

The verdict comes back Unknown, or the comparison refuses to run

Do not talk it away. Read it out. The product names its own gaps, and "we hold when we can't answer" is a better story than a lucky green.

"It's saying it can't answer — and that means the deployment stays held. That's the correct behaviour and it's the one I'd want on my own pipeline. Here's the specific gap it's naming."

Most likely causes, in order, and what they look like:

SymptomCauseRecovery
422, "counterpart changed"The production agent was renamed, retagged or re-sourced since the pairing was confirmedCannot be fixed live on a deployed environment (super-admin write, #1762). Switch to the recording
422, "no counterpart"This agent is not in the mapSay it plainly: "we only speak about agents someone has paired; this one hasn't been." Pick the mapped agent
404, "map not found"Tenant was reset — the map is wiped by every reset and no seed writes itRecording
413, tenant too largeOver the synchronous limitNot recoverable live
Coverage panel shows a one-sided cross-system bridgeOnly one of the two connectors was refreshedSay the honest thing: under-computed reach means we hold, not release

The run times out or the job errors while held

The 30-day ceiling is GitHub's, not ours, and it will not fire in a demo — and it is the only clock that ever ends a hold. Our own decision deadline reports and escalates; it never releases and never refuses, so nothing you are about to show can be decided by a timer. What you may hit is a re-run after a transient failure. GitHub still documents nothing about that case, but we have watched it: the re-run re-holds — a fresh delivery arrives, the run attempt increments, and the deployment waits again for a decision. Say that, then re-run and carry on.

The numbers on screen disagree with the script

Read the screen. Say the number that is there. Never argue with your own product in front of a prospect; a demo that quotes a number the live view will not reproduce is worse than no demo.

The lab is down, the estate never came up, or #89 bit you

Fallback ladder, in order:

  1. The recording of a successful end-to-end run (always have this open in a tab).
  2. The downloadable Markdown evidence artifact from a previous successful comparison — it is a real product output, it renders anywhere, and it carries the same disclosures.
  3. Whiteboard the flow from §4 and book a follow-up with a live run. This is a better outcome than improvising on a broken estate.

13. What changes if a different use case is chosen

The build is committed; the use case is not. Ivan has committed the substrate — the App, the hold, the verdict, the human decision, the callback, the verification. What Sergey selects at the working session is which deployment we point it at first. All four candidates below are live options and it is his call; the first row is our recommendation, the other three are the alternatives with what they would cost us.

The mechanism beats are identical across all four. Beats 1, 2, 3, 7, 8 and 9 — a run held before it holds credentials, context supplied while it waits, a named human deciding, the run resuming or failing closed, and the outcome verified — do not change with the selection, and no candidate in this table forces a different substrate. Only the narrative changes. That is precisely why we can build now and rehearse this demo before the selection lands.

Beats 4, 5, 6 and both endings are the use-case-dependent last mile. Here is exactly what gets rewritten for each alternative — the Ending A column is the fix-and-release path; Ending B's refused destination is whatever beat 4 named, so it tracks that column:

If Sergey selects…Beat 4 (the moment) becomesBeat 5 becomesBeat 6Ending ASetup changes
Agent promotion (our recommendation — this script)Named destination reachable by the production copy and not the tested copyThe destination that cancels but is wider in productionUnchangedRemove the escalating policy, re-runAs written
Authority-adding deployment (alternative — Sergey's call, not selected)"This change makes the agent the only path to your customer data" — plus the deflation: "7 of the 9 resources it appears to add are already reachable; the real delta is 2"Optional — the deflation is the second findingDifferent caveat set (elevation, conditional access, deny rules)Narrow the policy attachmentOne extra beat before beat 3: the pipeline emits a declaration of what it is changing, and a human confirms it against the diff before the engine runs. That confirmation is the determinism boundary and must be shown, not glossed
Pipeline's own deploy identity (alternative — we recommend against it as the demo; reason in the last column — Sergey's call)"Your production deploy role trusts any branch of this repository"Reach of that identitySame shapePin the trust condition to the environmentDifferent estate — our own org, not the lab. Note: this content should ship as a standing finding regardless of the selection; as a gate it returns the same answer every deploy, which is a weak demo
Regulated data reach across SaaS (alternative — we recommend against it as the demo; reason in the last column — Sergey's call)"After this merge, anyone who can file a ticket reaches restricted customer data in two hops"The classification of what was exposedMust additionally hold when the cross-system link did not formRemove the grantDifferent lab entirely (nimbus-cloud), different refresh set, and a cross-system link that fails silently — the highest-risk demo of the four

One line to have ready if someone asks why we are demoing before the decision: "The mechanism is decided and being built. Which deployment we point it at first is a conversation we're having with our own leadership this week, and the honest reason is that we want the first one to prove customer value, not just prove the plumbing."


14. Variant — let the pipeline refresh the picture live

The main script assumes the estate was re-read during setup, so the demo is fast and has few moving parts. Use this variant with a technical audience, or when someone has already asked the staleness question and you want to answer it with the screen instead of words.

What changes: phase 1.5 does something. Today it is a placeholder that performs no refresh; in this variant it runs as a visible job in the run — a read-only scan against the AWS accounts, submitted to the platform — and the gate waits for that data to settle before computing. Making it do that is build work (#1913), not a switch, so do not choose this variant unless you have watched it work.

What it requires of the partner: their pipeline has to be willing to run the connector as a job inside the deployment. That is a partner-side prerequisite, the same phase-1.5 precondition named in §1, and it belongs in the pilot's partner runbook — not something you can promise in the room.

What it costs: roughly three to four extra minutes between beats 2 and 3 (assumed; measure it), of which about a minute is a deliberate settling window before cross-system links are rebuilt. Fill it by walking the audience through the pipeline file on screen.

What it buys: the strongest possible answer to "what if your data is stale?" — "it was read ninety seconds ago, by this job, and here's the timestamp on the verdict."

What to say while it runs: "This step is the deployment asking us to go and look at the estate again before we answer. It reads only — it can't change anything in your accounts — and the answer you're about to see is computed against what it just found, not against something we cached last week."


15. Every number in this script, and what kind of number it is

Say the label out loud if you are asked. Do not upgrade an assumption into a measurement.

NumberValueLabel
Tested agent reach / production reach / appears in production1 / 2 / 1 → en-default-pii-customersMeasured — produced in-product on real AWS connector data for this tenant (PR #1767)
One destination cancelled with wider production permissions1 (…media-reports)Measured — same run
Our response time to GitHubunder 10 secondsDocumented — GitHub terminates and counts the delivery failed beyond that
How long GitHub will holdup to 30 days, then the job failsDocumented — GitHub's own terminal timeout, and the only clock that ever ends a hold
Our own decision deadlineshorter than 30 days, set per tenantAssumed until the pilot sets it — and it only reports and escalates; it never approves or rejects
Status reports we may post per deployment10, at 1024 characters each, MarkdownDocumented
Maximum GitHub required reviewers6, one approval sufficesDocumented
End-to-end latency, merge → the decision landing in GitHubwhatever you measured at T-60Measured once you have run it — assumed until then. GitHub publishes no latency SLO for this event
Every elapsed time in §5Target / assumed
Verdicts carrying a release affordance1 of 4 (Approve only)Pilot policy, decided. Constrain / Reject / Unknown stay held
Behaviour when our evaluation fails and the adapter is healthyHold; a person re-approvesPilot policy, decided. Release-after-a-bounded-wait is available per environment, but only if the customer chooses it
Verdicts on which an approver may accept an exceptionNot decided. Open product question, §8.1 — do not answer it in the room
Behaviour on a re-run, and on two runs held at onceNot documented by GitHub. Do not answer. Say we are pinning it empirically
Bedrock creating a version automatically on alias creation, numbered from 1, immutableDocumented by AWS — and not yet executed in this lab. Documented is not observed; say both halves
Terraform import ID for the agent alias<aliasId>,<agentId>Documented by HashiCorp — matches what our code writes. Not yet executed here; a wrong ID fails at plan time, which is why the risk is low
Pausing the publisher alias during the holdDocumented by AWS (REJECT_INVOCATIONS, no IAM change) but measured absent from the pinned Terraform provider. An option with a cost, not a plan (§1)

Appendix — the five sentences to have word-perfect

  1. "The deployment is held, and while it's held that job has no production credentials at all."
  2. "The agent you tested could reach one thing. The production copy can reach two — and the extra one is a customer-data bucket in a different account."
  3. "Both versions reach this bucket, so a straight comparison says nothing changed. In production it can write where in test it could only read."
  4. "Here's what it can't see, and it prints that on every single verdict, automatically."
  5. "GitHub holds the deployment. We supply the authority context. A named human decides. And then we check the estate landed the way we said it would."