Skip to Content

← All archived runs

Run: demo-roster-provisioning

run.md

Run: demo-roster-provisioning

  • branch: claude/demo-roster-provisioning-pipeline-hc76k8
  • pr: #855

02_define/output/spec.md

Spec: Define the demo roster and provision it in one command

  • slug: demo-roster-provisioning
  • personas: Admin
  • touches: packages/services/src/db/services/demo-data/storyline, packages/services/scripts, packages/services/package.json, apps/docs/app/technical/demo-environment, apps/docs/app/technical/development/preview-database
  • complexity: standard

Problem

The demo tenant's twenty storyline people are fictional addresses on the reserved .example domain that exist nowhere but Mongo. Nothing in the platform turns a Clerk account into an app-user row except a sign-in, so fourteen of the twenty cannot be viewed as and none can be signed into; the six that can are hand-made Clerk accounts stood up by a runbook that says "create the user, then sign in once as them" — twenty times, and twice over, because preview and production are separate Clerk instances. Forty manual sign-ins is the current price of a working demo, and a typo'd address fails silently as an unmatched persona rather than as an error. The demo is how we put the product in front of vendor partners, so a demo that is expensive to stand up and silently wrong when it drifts directly costs us the Refine the bridge initiative's Q2-2026 Objective 1 (Establish Product-Market Fit with Vendor Partners).

Proposed change

Make the roster an explicit, derived thing and give it one command.

The roster convention. The storyline's twenty addresses become jamie.nisbet+<lastname>.<role>@sustentus.com — real, deliverable, and identical in both Clerk instances. Names are unchanged; only addresses move. The four internal seats gain names, which StorylineTeam does not carry today:

Name Email Role Clerk org role
Alex Rendell jamie.nisbet+rendell.admin@sustentus.com admin org:admin
Sofia Almeida jamie.nisbet+almeida.csm@sustentus.com csm org:csm
Ben Hartley jamie.nisbet+hartley.sdm@sustentus.com sdm org:sdm
Orrery Software jamie.nisbet+orrery.vendor@sustentus.com vendor org:vendor
Anneke Vos jamie.nisbet+vos.customer@sustentus.com customer org:customer
Elsa Håkansson jamie.nisbet+hakansson.customer@sustentus.com customer org:customer
Ruth Delaney jamie.nisbet+delaney.customer@sustentus.com customer org:customer
Jonas Brandt jamie.nisbet+brandt.customer@sustentus.com customer org:customer
Siobhan Kerr jamie.nisbet+kerr.customer@sustentus.com customer org:customer
Pieter Claes jamie.nisbet+claes.customer@sustentus.com customer org:customer
Mads Kjeldsen jamie.nisbet+kjeldsen.customer@sustentus.com customer org:customer
Camille Renard jamie.nisbet+renard.customer@sustentus.com customer org:customer
Dev Raghunathan jamie.nisbet+raghunathan.customer@sustentus.com customer org:customer
Elaine Merrivale jamie.nisbet+merrivale.customer@sustentus.com customer org:customer
Elin Bergqvist jamie.nisbet+bergqvist.expert@sustentus.com expert org:expert
Chidi Okafor jamie.nisbet+okafor.expert@sustentus.com expert org:expert
Anders Lindberg jamie.nisbet+lindberg.expert@sustentus.com expert org:expert
Margot Duval jamie.nisbet+duval.expert@sustentus.com expert org:expert
Stefan Reinhart jamie.nisbet+reinhart.expert@sustentus.com expert org:expert
Nina Whitlock jamie.nisbet+whitlock.expert@sustentus.com expert org:expert

The admin seat is a dedicated roster account rather than one of the three real admins, so the world does not depend on which real person happens to be signed in and preview matches production exactly.

The derivation. A pure rosterOf(storyline) helper — new module packages/services/src/db/services/demo-data/storyline/roster.ts, re-exported from storyline/index.ts — returns the twenty { email, role, label } entries from the storyline's team, accounts and experts. One source of truth, no second list to drift. label is the person's display name ("Anneke Vos"; the vendor seat's is the company, "Orrery Software"). To let the four internal seats derive rather than be hardcoded, StorylineTeam gains a firstname/lastname pair per seat alongside each existing *Email field. The spare Calderon world (storyline/default.ts) gains the four names so it still satisfies the type; its addresses are left untouched — it is not the active world and nothing provisions from it.

The command. packages/services/scripts/demo-roster.ts, wired as demo:roster in packages/services/package.json beside db:seed and db:migrate, run the same way (pnpm demo:roster from packages/services, or pnpm --filter @sustentus/services demo:roster), reading .env through the existing loadRepoEnv. Against whichever Clerk instance CLERK_SECRET_KEY points at, for each roster entry it:

  1. resolves the Clerk user by email (users.getUserList) or creates one (users.createUser({ skipPasswordRequirement: true, skipLegalChecks: true })), setting firstName/lastName from the roster label — Clerk owns identity, and the user.updated webhook mirrors those names back onto the row;
  2. ensures organisation membership with the mapped org:<role> in the tenant's clerkOrgId (organizations.createOrganizationMembership), treating an already-existing membership as satisfied;
  3. mirrors the app-user row — adopting an existing seeded row for that (tenantId, email, role) by stamping its clerkUserId when the row has none, and otherwise calling userService.ensureProvisionedFromClerk.

No sign-in is performed, and no new environment variable is introduced: the Clerk organisation comes from tenant.clerkOrgId and the customer seat honours the existing CLERK_ORG_CUSTOMER_ROLE override. The target tenant is the one tenant flagged isDemo: true; with several, --tenant <tenantId> is required. Either way the tenant goes through the existing requireDemoTenant guard before anything is written, and a missing CLERK_SECRET_KEY refuses up front rather than half-provisioning. --check reports state and writes nothing.

The end-to-end Clerk pattern is reused from apps/web/app/(app)/service-leads/create/provision-customer.ts — including its diagnostics for the two ways Clerk rejects this (no passwordless sign-in method enabled; the org role key missing) — and the lazily-constructed, no-op-without-a-key client from packages/services/src/permissions/clerk-mirror.ts.

Why adoption is not optional. ensureProvisionedFromClerk swallows duplicate-key errors and returns silently, so a plain create against an address the seeder already owns leaves the account permanently locked out: resolveAppUserForTenant finds no row by Clerk id, provisioning no-ops on tenantId_email_role_unique, and the request returns "User not found in this organisation". Stamping the existing row is what avoids it; the partial unique index clerkUserId_tenantId_role_unique makes the stamp idempotent.

Acceptance criteria

  • rosterOf(activeStoryline) returns the twenty roster entries in the spec's table, each with the role its seat implies and the person's display name as label, and is covered by a unit test that fails if a storyline person is added without an address.
  • storyline/active.test.ts asserts the new roster addresses (it pins the current demo+…@apoyar.eu six today) and still passes.
  • pnpm demo:roster against a demo tenant leaves twenty app-user rows, each carrying a clerkUserId and the role its Clerk organisation membership maps to — with no sign-in performed.
  • Re-running writes nothing and reports every entry as already present.
  • An address already held by a seeded row with no clerkUserId is adopted — the existing row is stamped, not duplicated — so the script is safe whether it runs before or after a seed.
  • pnpm demo:roster --check writes nothing and names exactly what is missing, mis-roled or unrecognised; on a complete roster it reports clean.
  • The script refuses a tenant not flagged isDemo: true (via requireDemoTenant), refuses to run with no CLERK_SECRET_KEY rather than half-provisioning, and requires --tenant when more than one tenant is flagged isDemo: true.
  • Running it against the preview instance and the production instance yields the same twenty seats, differing only in Clerk user ids.
  • The demo-environment and preview-database runbooks describe pnpm demo:roster in place of the manual create-then-sign-in loop, and record the two Clerk-side prerequisites that must hold in both instances: org:csm, org:sdm, org:vendor and org:expert exist as organisation roles (org:customer already does — hence CLERK_ORG_CUSTOMER_ROLE), and the demo organisation's maximum membership count exceeds twenty.

Out of scope

  • Setting or rotating the accounts' passwords — that is done in Clerk.
  • Any change to how the seeder resolves or enriches people, including the isPersona flag and the email → role → stand-in fallback chain — that is demo-roster-seeder (stub 3).
  • The View As switcher and its demo+<role>@… pinned-persona convention, which this change invalidates — that is demo-view-as-user-list (stub 2). Between this stub and stub 2 the switcher falls back to "first user alphabetically" per role; the batch's breakdown records that as accepted. apps/web/lib/view-as-switcher-data.test.ts builds its own demo+<role>@apoyar.eu fixtures and is unaffected either way.
  • Storyline content — accounts, engagements, values and copy in storyline/saas-vendor.ts are unchanged apart from the email addresses and the four new internal-seat names.
  • The addresses in the spare Calderon world (storyline/default.ts); it gains only the four internal-seat names the type now requires.
  • Deleting the superseded demo+…@apoyar.eu accounts from Clerk. Their app-user rows survive the reset (they carry a clerkUserId) and will show in the directory until removed by hand — an operational tidy-up, not code.
  • The reset's purge/keep policy, the emulation gate, its own-tenant check, and the View As audit trail.
  • Multi-role principals. Every roster member holds exactly one role.

Open questions

  • none

Context budget: within band. The stub carried the codebase research; targeted reads confirmed StorylineTeam carries no names today (the four internal-seat names are net-new), that requireDemoTenant/assertDemoTenant already exist as the demo guard, that CLERK_ORG_CUSTOMER_ROLE and CLERK_SECRET_KEY are already declared in turbo.jsonglobalEnv, and that storyline/active.test.ts pins the current addresses. There is no scope.md behind this batch — its breakdown.md records the cut as made directly from Jamie's request, so there is no story or question sheet for approve to settle.

03_build/output/notes.md

Build notes: demo-roster-provisioning

  • commits: feat: demo-roster-provisioning — roster helper + demo:roster script, fix: demo-roster-provisioning — give the vendor seat a last name for Clerk

What changed

  • packages/services/src/db/services/demo-data/storyline/types.ts: StorylineTeam gains a firstname/lastname pair for the admin, CSM and SDM seats. The vendor seat gets none — it is a company, and vendorName already authors that name; a second copy would be the drift the roster exists to prevent. StorylineAccount.email's "fictional, non-deliverable" note is now false and says what the address actually is.
  • storyline/roster.ts (new): rosterOf(storyline) derives the twenty { email, role, label, firstname, lastname } entries from team, accounts and experts. Exported from storyline/index.ts; not from the package barrel — the only consumers are the script and its tests.
  • storyline/saas-vendor.ts: all twenty addresses move to jamie.nisbet+<lastname>.<role>@sustentus.com; the three human internal seats gain their names. Names and every other field are untouched. The module header no longer claims .example non-deliverability.
  • storyline/default.ts: the spare Calderon world gains the three names the type now requires. Its addresses stay on demo+…@apoyar.eu — nothing seeds it, and re-pointing it would invent a second roster to keep in step.
  • storyline/roster.test.ts (new) and storyline/active.test.ts: see below.
  • packages/services/scripts/demo-roster.ts (new) + demo:roster in packages/services/package.json.
  • apps/docs/app/technical/demo-environment/page.mdx and .../development/preview-database/page.mdx: the runbooks.

Decisions the spec left to implementation

  • RosterEntry carries firstname/lastname as well as label. The spec said the script sets Clerk's firstName/lastName "from the roster label", which implies splitting the label on whitespace. That is lossy and wrong for the vendor seat — "Orrery Software" would be filed under the surname "Software" — and the storyline already authors the parts. label is still present and still the display name, so the acceptance criterion holds; the parts are carried rather than re-derived.
  • The vendor seat carries a name pair after all — corrected after the first real run. I first gave it none, on the reasoning that its name was already vendorName and a second copy would be the drift the roster exists to prevent. The first provisioning run against a real Clerk instance rejected the seat: ["last_name"] data doesn't match user requirements set for this instance. A Clerk instance can require a last name, and undefined fails that check. The spec's "a pair per seat" was right. The company is now split — vendorFirstname: "Orrery", vendorLastname: "Software" — which every surface renders back whole, because they all display [firstname, lastname].filter(Boolean).join(" "). A test asserts the pair still concatenates to vendorName, so the duplication cannot drift, and a second asserts no seat has an empty half.
  • Tenant selection: the one isDemo: true tenant, else --tenant <id> is required. Both paths go through the existing assertDemoTenant.
  • --check exits non-zero when the roster is incomplete, so it is usable as a gate. The spec only required that it report; this adds no writes.
  • --check also names organisation members who are not on the roster — the spec's "unrecognised". That is how the superseded demo+…@apoyar.eu accounts surface.

Acceptance criteria status

  • rosterOf(activeStoryline) returns the twenty entries — roster.test.ts asserts the spec's table literally (not recomputed), plus one test that fails if a storyline person has no entry, one for duplicate addresses, and one for lowercase normalisation.
  • active.test.ts asserts the new addresses — two tests changed: the persona-seat pins, and the .example domain rule, which is replaced rather than patched. Every person is a real login now, so the rule that no address can receive mail is gone; what replaces it asserts the roster convention across all twenty, which is the invariant that actually matters.
  • pnpm demo:roster leaves twenty rows carrying clerkUserId, no sign-in performed.
  • Re-running writes nothing and reports every entry present.
  • A seeded row with no clerkUserId is adopted (stamped, not duplicated).
  • --check writes nothing and names what is missing, mis-roled or unrecognised.
  • Refuses a non-demo tenant (assertDemoTenant), refuses without CLERK_SECRET_KEY before any write, requires --tenant when several tenants are flagged.
  • Same twenty seats against either Clerk instance — the instance is whatever CLERK_SECRET_KEY points at; nothing else is instance-specific.
  • Both runbooks describe pnpm demo:roster and record the Clerk-side prerequisites.

The last three are structurally met but only provable by running the script against a demo tenant and a Clerk instance, which Build cannot do. See below.

What the first real run found

Running pnpm demo:roster --tenant <id> against a live tenant and Clerk instance failed on the fourth seat with ["last_name"] data doesn't match user requirements set for this instance. The vendor seat was the only entry with an empty lastname, and the script sent undefined. Fixed as above. Two regression tests now cover it — the vendor pair concatenating to vendorName, and no seat having an empty first or last name — so this cannot come back silently.

Note the run is partially applied: the admin, CSM and SDM Clerk users were created before it threw. That is safe and by design — re-running resolves them rather than duplicating, which is the idempotence the acceptance criteria call for, and the re-run is itself evidence for that criterion.

Notes for Verify

  • packages/services/scripts/ is outside the package's tsconfig.json include (src/**/* only), so pnpm typecheck does not cover demo-roster.ts — the same gap the three existing db:* scripts sit in. CI going green is therefore not evidence this file type-checks. Reviewing it by eye is the only check it gets. Pre-existing; widening the include would be a drive-by.
  • Nothing here can be exercised on the Vercel preview. The script is a CLI against Mongo + Clerk. The DoD smoke for the three runtime criteria is: run demo:roster --check (expect it to name the twenty as missing), run demo:roster, re-run --check (expect clean), then re-run the seed and --check again to confirm adoption did not duplicate. Needs both Clerk-side prerequisites in place first.
  • The Clerk org-role cast (role: wantedRole as never) copies provision-customer.ts. The SDK types the role as a closed instance-specific set the app never declares.
  • apps/web/lib/view-as-switcher-data.ts now matches nothing. Its demo+<role>@ pin convention is dead against the new addresses, so pinnedForRole falls back to the first user per role — the degraded state the batch's breakdown.md accepted between stubs 1 and 2. Its own test builds its own fixtures and is unaffected. Left alone deliberately: the spec puts the switcher out of scope and demo-view-as-user-list deletes that function. Its doc comment still points at the runbook section for a convention the runbook no longer documents — I corrected the runbook (leaving it asserting something false was worse) but not the comment in stub 2's file.
  • The demo-environment runbook is renumbered (7 steps → 9). One manual Clerk account and one sign-in survive, and cannot be removed: the tenant document does not exist until somebody signs in, and demo:roster needs the tenant to resolve isDemo and clerkOrgId.

Context budget: within band.

04_verify/output/verify.md

Verify: demo-roster-provisioning

  • production-readiness: run — 5 findings. 2 fixed on branch (9167688, 800efb3); 3 recorded below (real-mail exposure, View As pin, script outside typecheck).
  • code-review: medium (spec complexity standard) — 4 findings. 3 fixed on branch (800efb3); 1 is stub 2's, recorded. The CI Claude review is off (Review diff against CONVENTIONS.md reports skipped), so /code-review was run rather than triaged.
  • security-review: run — no HIGH or MEDIUM findings. No route, action or webhook is added; the script's only inputs are argv and environment. Two context notes below.
  • playwright: TODO — manual DoD smoke performed instead

DoD smoke

Nothing in this run is exercisable on the Vercel preview: demo:roster is a CLI against Mongo and Clerk. The agent half is therefore the unit suite plus tracing each criterion through the diff; every runtime criterion is the operator's.

  • rosterOf returns the twenty entries, and fails if a storyline person has no address — roster.test.ts, 8 tests, green in Quality (agent)
  • active.test.ts asserts the new addresses and passes — green in Quality (agent)
  • Both runbooks describe pnpm demo:roster and the Clerk-side prerequisites — read on the docs preview (agent)
  • Twenty rows each carrying clerkUserId, no sign-in performed (operator)
  • Re-running writes nothing, reports every entry present (operator)
  • A seeded row with no clerkUserId is adopted, not duplicated (operator)
  • --check writes nothing and names what is missing/mis-roled/unrecognised (operator)
  • Refuses a non-demo tenant, refuses without CLERK_SECRET_KEY, requires --tenant when several are flagged (operator)
  • Same twenty seats against preview and production, differing only in Clerk ids (operator)
  • auth: not applicable — no sign-in path changes; the roster provisions rows a sign-in used to create, and resolveAppUserForTenant still matches on clerkUserId exactly as before
  • payments: not touched
  • notifications: none expected from the script — it writes through the models and the userService, never a notify* wrapper. See finding V-1 for what the address change does mean for delivery.

Operator evidence so far. Jamie reported "it has worked on the preview branch" (2026-08-19, in conversation) after the vendor-seat fix. That is the provisioning happy path and is recorded as such — but it is not yet evidence for the ticked-box list above, for two reasons: "it worked" does not distinguish which of the six runtime criteria were exercised, and three behaviour-changing commits landed after that run — 9167688 (name reconciliation in Clerk and Mongo), and 800efb3 (the two script bugs). The --check drift note was one of the things broken at the time of that run. A re-run on the current head is what closes these lines.

Findings & cleanup

Fixed on this branch:

  • V-1 · Adoption stamped no names (production-readiness) — $set carried only clerkUserId and isActive, so seed-then-roster left the seeder's stand-ins ("CSM User") in the app while Clerk showed "Sofia Almeida" — the exact defect the storyline name fields were added to remove. Fixed in 9167688: the roster now writes its names to both Clerk and Mongo whenever they differ, which also closes the drift the module's own header promised to prevent.
  • V-2 · Runbook prescribed the one order that breaks (code-review) — the seeder's resolveUser returns an existing row untouched, so the create() factory that sets companyName, location.country and industry never runs against a row the roster made first. Roster-then-seed leaves every customer without a company, and steps 8/9 of the runbook I wrote prescribed exactly that. Fixed in 800efb3: steps swapped, and both runbooks now say why the first pass is ordered and why later ones are not.
  • V-3 · --check drift note printed a literal ".trim(), (code-review) — .trim() written inside the template literal. Mine, from 9167688. Fixed in 800efb3.
  • V-4 · Stray-member scan read emailAddresses[0] (code-review) — not the primary address, so a seat whose roster address is not first could be reported as a stray to delete by hand. Fixed in 800efb3.

Accepted, recorded rather than fixed:

  • V-5 · Live demo actions can now send real mail (production-readiness, security) — the .example non-deliverability guarantee is gone. Seeding stays safe by construction (the seeder writes through models, never the notify* wrappers), but a quote/invoice/CSAT flow clicked through on the production demo tenant now sends real Resend mail. Every address is a plus-address of one mailbox — Jamie's own — so this is inbox noise, not disclosure to a third party, and no real person's PII is exposed. Gating delivery on isDemo is a separate change; not scoped here.
  • V-6 · View As pin is dead on the demo tenantpinnedForRole matches demo+<role>@, which nothing does now, so it falls back to first-user-per-role and can land the presenter on a filler account. Known and disclosed: breakdown.md accepts it between stubs 1 and 2, and demo-view-as-user-list deletes that function. Left alone — the spec puts the switcher out of scope. Its doc comment still points at a runbook convention the runbook no longer documents; stub 2 rewrites that file wholesale.
  • V-7 · scripts/ is outside the package tsconfig includepnpm typecheck does not cover demo-roster.ts (496 lines). Pre-existing for the three db:* scripts; green CI is not evidence this file compiles, and tsx strips types rather than checking them. Widening the include is a chore, not this run's work. It is the reason the operator smoke matters more here than on a typical run.

Context notes from the security pass, neither a vulnerability:

  • The roster's twenty accounts are passwordless by design, so the sign-in factor is delivery to twenty plus-addresses of one mailbox, one holding org:admin. Inherent to email auth and true of the six accounts this replaces, but it takes the seats behind that single mailbox from six to twenty.
  • Reverting the code does not revert the Clerk state: the script has no --remove, so the accounts and memberships it creates survive a revert and are tidied by hand. --check names organisation members that are not on the roster, which is how the superseded demo+…@apoyar.eu accounts surface.

Context budget: within band. The three review passes read outside the Inputs table by design — that is what they are for.

05_ship/output/investor-update.md

Standing up the demo now takes one command

Who it's for: Admin — whoever presents the demo to a vendor partner What shipped: One command provisions all twenty demo people as real logins, in preview and production alike. Why it matters: The demo is how we establish product-market fit with vendor partners — it is now cheap to stand up and cannot drift.

Forty manual sign-ins became one command.

Dig deeper: https://github.com/sustentus/sustentus/pull/855

05_ship/output/release.md

Ship: demo-roster-provisioning

  • pr: #855 · https://github.com/sustentus/sustentus/pull/855 · merge: authorised — Ready to merge ticked; this commit rides the squash
  • CI: green on 0a5fa88 — all 10 check runs, Quality Project included (Migrate production database and Review diff against CONVENTIONS.md report skipped by design). Re-checked on the ship commit before the merge.
  • technical docs: technical/demo-environment and technical/development/preview-database, both rewritten in this PR — the runbook now ends in pnpm demo:roster instead of twenty hand-made Clerk accounts, and records the two Clerk-side prerequisites. One stale (step 8) cross-reference fixed here: Verify swapped the seed and roster steps, so the roster is step 9. The rest of technical/** was swept and does not lag — no new env var (CLERK_SECRET_KEY, CLERK_ORG_CUSTOMER_ROLE and MONGODB_URI are all already in turbo.jsonglobalEnv), no new route, no new CI step, and development/database stays scoped to the three db:* lifecycle commands rather than becoming a catalogue of every packages/services script.
  • business docs: no business docs impact — no persona gains or loses a capability, no service-journey step moves, and access to /admin/demo-data is untouched.
  • release notes: ship-note-only. This is internal tooling: nothing a platform user can see or do changes, so there is no end-user note and no changelog entry. Dig deeper therefore carries the PR link alone rather than an angle-bracket placeholder the send would refuse.
  • sent: ship note rides this squash — ship-note.yaml fires on the merge and emails 05_ship/output/investor-update.md to #product-update (2026-08-19). Body is 59 words, inside the 60-word cap.

Acceptance check (vs spec)

Three criteria are closed by the diff and its unit suite. The other six are runtime, and nothing in this run could exercise them: demo:roster is a CLI against Mongo and Clerk, so it runs nowhere the Vercel preview reaches. They are recorded honestly rather than ticked.

  • rosterOf(activeStoryline) returns the twenty entries with the role each seat implies and the person's display name as label, and fails if a storyline person is added without an address — roster.test.ts, 8 tests, green in Quality
  • active.test.ts asserts the new roster addresses and still passes — green in Quality
  • Both runbooks describe pnpm demo:roster in place of the create-then-sign-in loop and record the two Clerk-side prerequisites — read on the docs preview
  • Twenty app-user rows each carrying a clerkUserId and the mapped role, no sign-in performed (operator)
  • Re-running writes nothing and reports every entry already present (operator)
  • A seeded row with no clerkUserId is adopted, not duplicated (operator)
  • --check writes nothing and names what is missing, mis-roled or unrecognised (operator)
  • Refuses a non-demo tenant, refuses without CLERK_SECRET_KEY, requires --tenant when several tenants are flagged (operator)
  • The same twenty seats against preview and production, differing only in Clerk ids (operator — production is provisioned by runbook step 9 after this merge, so this one could not close before it)

What the six rest on. Jamie reported the script working against the preview instance on 2026-08-19, which is the provisioning happy path. That run predates 9167688 (name reconciliation) and 800efb3 (the two script bugs), so it is not evidence for the individual lines above — verify.md says so, and said so before the gate was ticked. Ticking Ready to merge with them open is the operator's call, made on that basis. The remaining exposure is bounded: the script is refuse-first (no demo tenant, no CLERK_SECRET_KEY, no ambiguous tenant → it stops before writing), --check is a read-only dry run, and V-7 records that packages/services/tsconfig.json includes only src/**/*, so green CI is not evidence the 496-line script compiles. Runbook step 9 against production is where all six get exercised for real.

Context budget: within band.