ID-61 bid→procurement rename — Execution Strategy
⚠️ EXECUTED / HISTORICAL (bannered S462). All Units A–F are ledger-done — but Unit F’s VISIBLE-COPY half ({61.12} §1(a) + §(g)) did NOT land despite closure (S462 grep-verified; correcting journal on {61.12}). The workspace-umbrella framing is superseded by DR-038. Outstanding copy sweep is owned by ID-145.
ID-61 bid → procurement/form Rename — Execution Strategy
Section titled “ID-61 bid → procurement/form Rename — Execution Strategy”Author: Senior Staff (strategy only — no code edits in this session)
Repo state: worktree of canonical at origin/main (da009507)
Inputs: ID-61.3 decision register (87 identifiers: 61→procurement, 7→form, 5 keep, 18 defer); ID-71 OQ-4 lockstep precedent; ID-64 DB-table rename (live on prod); the ratified two-axis rule (ID-71 {71.12}).
0. The governing principle
Section titled “0. The governing principle”ID-71 already proved the correct shape for this codebase: a coordinated client-contract break lands in ONE PR per contract surface, forced into lockstep by a drift-guard test, aligned with the DDL cutover wave. We reuse that pattern, but ID-61 is broader than ID-71’s MCP-only surface — it spans app UI, TanStack cache, REST response shapes, a generated MCP bundle, DB RPC columns + api-views, and persisted CV/CHECK literals. These do not share a blast radius, so they do not share a PR. The strategy is therefore a sequence of independently-gated, internally-atomic PRs, ordered by data-coupling risk, NOT one mega-break.
The decision rule for “what must be atomic”: a value and every consumer that pattern-matches that exact value must change in the same commit. Pure-rename identifiers (type names, local vars, hook exports) are graph-tracked and can ride together safely; string-literal values (cache keys, runtime discriminants, DB columns, wire fields, persisted CV values) define the atomic boundaries.
1. Sequencing & PR shape
Section titled “1. Sequencing & PR shape”Verdict: a sequence of gated PRs, NOT one atomic break. Rationale: ID-71’s single-PR break worked because its whole surface was one contract (the MCP tool surface) guarded by one drift-guard (mcp-fixture-sync.test.ts). ID-61’s surfaces have disjoint guards (typecheck, schema-parity, supabase-types-parity, api-view-coverage, e2e-smoke, jscpd). Forcing them into one PR maximises merge-conflict surface and couples a low-risk pure-rename (61 identifiers, typecheck-guarded) to a high-risk DB migration (prod DDL). Splitting lets each PR close on its own forcing-function gate.
The atomic units (each = one PR, must not be split mid-unit)
Section titled “The atomic units (each = one PR, must not be split mid-unit)”| Unit | Atomic contents (all-or-nothing in one commit) | Closing gate |
|---|---|---|
| A — Pure-rename, no value coupling | 61 procurement-axis + 7 form-axis pure identifiers (types, interfaces, hook exports, local vars, props, JSDoc). Graph-tracked via gitnexus_rename; verified by ast-dataflow rename-sweep. | tsc --noEmit (typecheck job) + bun run test |
| B — TanStack cache keys | queryKeys.bids→queryKeys.procurement namespace + the 6 'bids' string tuples (query-keys.ts:328-337) + ~24 accessor sites + the 4 use-draft-stream.test.ts lockstep assertions (1247/1250/1321/1324). | bun run test (the 4 assertions ARE the forcing function — they fail loudly on desync) |
C — bid_deadline runtime discriminant + generated bundle | union literal (attention.ts:35, reorient.ts:33) + 3 producers (attention.ts:278, reorient.ts:298/311) + icon-map key (unified-attention-section.tsx:85) + zod enum (reorient/route.ts:14) + reorient-me SOURCE (mcp-apps/reorient-me/src/{types.ts,app.ts}) + bun run build:mcp-apps regen of app-bundles.ts + snapshot assertions in __tests__/lib/attention.test.ts. | bun run test + mcp-build job (bundle freshness) |
D — REST response shape (bids: + failed_bid_ids) | producer (procurement/route.ts) + Zod schema + ALL 4 in-repo consumers of bids: (app/procurement/page.tsx, formatters/apps.ts, form-dashboard source, contract test) + failed_bid_ids→failed_procurement_ids (producer + tests only — zero consumers). | bun run test + mcp-build (form-dashboard regen) |
E — DB co-migration (unique_bids + DB residue) | new migration: rename unique_bids→unique_procurements in public + api get_aggregate_win_rate_stats RETURNS TABLE + CTE aliases; bid_response_auto_version()→form_response_auto_version() (fn+trigger+grants); get_form_summary param→workspace_id; bid_outcome alias→outcome. Then generate-api-views.ts regen + supabase gen types regen of database.types.ts + TS readers (win-rate/route.ts, content-performance-section.tsx) + tests. | db push (foreground) + supabase-types-parity + api-view-coverage --check + schema-parity |
| F — UI copy + DOM-id/e2e selectors (owner put copy in scope) | visible “Bids”/“bid” copy strings (page h1, “Manage bid submissions…”, “No bids match…”, “Active Bids”, etc.) + data-testid="bid-card-${id}" producer (procurement-list-card.tsx:87) + DOM ids (procurement/[id]/page.tsx tabpanel ids) + e2e selector file e2e/helpers/selectors.ts bid: block + e2e copy assertions (bid-pipeline.spec.ts, dashboard.spec.ts, workspaces.spec.ts). | e2e-smoke (staging) — copy + selector lockstep |
Dependency order
Section titled “Dependency order”A (pure rename) ──┐ ├──► independent, can land first / parallelB (cache keys) ───┤C (discriminant) ─┤D (REST shape) ───┘ E (DB migration) ── independent (DB-led), but land AFTER A so renamed TS readers exist to consume regen'd typesF (UI copy + e2e) ── land LAST (after A, since it touches the same files A renames; and after the app is otherwise stable so e2e-smoke is a clean gate)A, B, C, D have no inter-dependency (disjoint files mostly; A is the only one that overlaps F). E is DB-led and orthogonal. F is last because (i) it shares files with A (app/procurement/page.tsx) so sequencing avoids conflicts, and (ii) e2e-smoke is the slowest, staging-bound gate — run it once on the final shape.
ID-71 precedent applied: ID-71’s “six lanes in one PR forced by mcp-fixture-sync.test.ts” maps to our Unit-by-Unit forcing functions. We inherit its discipline (regen generated artefacts from source, never hand-edit; one cutover wave per contract) but split by guard boundary rather than cramming all into one PR, because our guards are disjoint where ID-71’s were unified.
2. Per-coupled-item handling (the 6 + DB residue + persisted CV/CHECK)
Section titled “2. Per-coupled-item handling (the 6 + DB residue + persisted CV/CHECK)”Item 1 — unique_bids → unique_procurements (DB RPC column)
Section titled “Item 1 — unique_bids → unique_procurements (DB RPC column)”CORRECT HANDLING: DB co-migration + regen, in Unit E. NOT a TS-only rename.
It is a RETURNS TABLE column on get_aggregate_win_rate_stats (public and api schema; squash baseline 20260617130000 L456/2018 + CTE aliases L2057/2079; and the generated id115 api-views migration L1539). TS reads it positionally as .unique_bids (win-rate/route.ts:90/104/113). Renaming the TS field alone = runtime undefined. Atomic unit: (a) CREATE OR REPLACE both RPCs with the renamed column + CTE aliases in a new migration; (b) re-run generate-api-views.ts to regenerate the api-views migration (guarded by generate-api-views.ts --check + check-api-view-coverage.ts); (c) supabase gen types --schema public,api to regenerate database.types.ts (guarded by supabase-types-parity); (d) rename the TS readers + the unique_bids TS-only sites (content-performance-section.tsx) + tests. schema-parity CI compares public/extensions/auth/storage (NOT api), so the public RPC change must reach prod+staging together; the api layer is generator-guarded. This is a CREATE OR REPLACE (return shape positional) — safe, no DROP needed for the public/api functions (the generator uses DROP+CREATE for the api wrapper, which is fine).
Item 2 — bid_deadline → procurement_deadline (runtime discriminant + generated bundle)
Section titled “Item 2 — bid_deadline → procurement_deadline (runtime discriminant + generated bundle)”CORRECT HANDLING: atomic lockstep across union/producers/zod/consumer/SOURCE+regen, in Unit C.
This is the highest-coupling pure-string item. The value flows: union literal (attention.ts:35, reorient.ts:33) → produced (type:'bid_deadline' at attention.ts:278, reorient.ts:298/311) → validated (zod enum reorient/route.ts:14) → consumed as r.type==="bid_deadline" discriminant in two places: the React icon-map (unified-attention-section.tsx:85) AND the generated app-bundles.ts (L343, L349). The generated bundle’s source is mcp-apps/reorient-me/src/app.ts:279/306 + types.ts:22 — edit the SOURCE, then bun run build:mcp-apps; never hand-edit the minified bundle. Renaming the union literal alone silently breaks: icon falls back to Info, and the bundle’s ==="bid_deadline" branches never fire (the “Help me draft” / “Show bid detail” buttons vanish). All of it changes in one commit; snapshot assertions in __tests__/lib/attention.test.ts (the attention-bid-${id} id prefix) update in lockstep. Note: the attention-bid-${id} synthetic id prefix (attention.ts:277) is register-mapped to attention-procurement-${id} — fold into Unit C with the snapshot update.
Item 3 — failed_bid_ids / failedBidIds → failed_procurement_ids (public API field)
Section titled “Item 3 — failed_bid_ids / failedBidIds → failed_procurement_ids (public API field)”CORRECT HANDLING: CLEAN BREAK, rename producer + tests atomically (Unit D). No deprecation, no dual-emit.
Investigation verdict: zero consumers, in-repo or external. The field is emitted by GET /api/procurement (procurement/route.ts:51 schema, 170/177-178 emit) only when the stats-RPC fallback fails (H13 “absent when empty”). No UI, hook, MCP tool, plugin, contract-test, or OpenAPI schema reads it — only the producer route and __tests__/api/procurement.test.ts. The MCP layer queries Supabase directly, not this REST endpoint. A dual-emit/deprecation window would be pure ceremony for a field nobody reads. Rename producer + Zod + the 4 test assertions in one commit; tests fail loudly on a miss.
Item 4 — source_bid_id / source_bid_name → source_procurement_* (write-only JSONB)
Section titled “Item 4 — source_bid_id / source_bid_name → source_procurement_* (write-only JSONB)”CORRECT HANDLING: LEAVE-WITH-RATIONALE (do not rename this session); if renamed later, write-side-only — NO backfill.
These are written into content_items.metadata (JSONB) at outcome/integrate/route.ts:247-248, alongside source_question_id/integrated_at. Zero readers across TS/Python/SQL. Because nothing reads them, renaming the write keys creates harmless historical key-drift (old rows source_bid_id, new rows source_procurement_id) with no functional consequence — but also delivers zero value since no consumer benefits. Recommendation: leave as-is with a one-line code comment documenting the legacy key (cheapest correct option). If a future feature reads these keys, that feature’s ticket owns the rename + (only if it must query historical rows) a jsonb_set backfill. Do not spend a migration on an unread audit tag now.
Item 5 — queryKeys.bids + 'bids' tuples + 4 draft-stream assertions (TanStack cache)
Section titled “Item 5 — queryKeys.bids + 'bids' tuples + 4 draft-stream assertions (TanStack cache)”CORRECT HANDLING: atomic in-memory rename, Unit B. The 4 test assertions ARE the forcing function.
Pure in-memory cache keys — no persistence, no bookmarkable contract, no DB. But the value 'bids' is pattern-matched: query-keys.ts:329-337 defines the tuples, ~24 accessor sites build keys, and use-draft-stream.test.ts:1247/1250/1321/1324 assert queryKey: ['bids','questions','bid-1'] by literal. A half-rename desyncs the writer’s invalidation key from the reader’s cache key → silent stale UI (invalidate fires on ['procurement',…], cache still keyed on ['bids',…]). Use gitnexus_rename for the queryKeys.bids symbol (graph-tracked), then ast-dataflow string-literal-uses to sweep the 'bids' literal tuples, then update the 4 assertions — all one commit. The assertions deliberately encode the contract; keep them as literal-string assertions (do not soften to expect.anything()), they are the desync alarm.
Item 6 — Persisted CV values + DB CHECK literals (the DEFER list)
Section titled “Item 6 — Persisted CV values + DB CHECK literals (the DEFER list)”CORRECT HANDLING: stay DEFERRED this session; each is a value-coupled own-ticket. Per-item disposition below. These carry data/URL/persisted-state coupling that a rename would silently break without a migration — exactly the class ID-61.3 correctly deferred. Decisions for the future tickets:
| Literal | Persistence surface | Future correct handling |
|---|---|---|
bid_writing (primary_focus persona) | Supabase Auth user_metadata.primary_focus (written profile-section.tsx:133) | Legacy-value-with-display-label (label already “Procurement writing”). Renaming the value needs an Auth-metadata backfill across all users — high cost, low value. Keep value, display label is already correct. Only backfill if a hard product need arises. |
bid_management (feature toggle) | Verifier-overturned → rename-procurement (file-based static toggle, NOT persisted) | Goes into Unit A as procurement_management — it is NOT a persisted CV. (Register §1 row.) |
bid_discovered (lifecycle) | content_items.lifecycle_type (varchar, no CHECK in current DDL) + compared in TS (freshness.ts) | Legacy-value-with-label or backfill. Plain varchar → no DB constraint break, but TS value-compares against it. Own ticket: backfill UPDATE … SET lifecycle_type='procurement_discovered' + rename TS literals atomically, OR keep value + label. Backfill preferred (small table, value-coupled reads). |
prior_bid_response (extractor_kind) | q_a_extractions.extractor_kind CHECK constraint (squash L949) — strict enum, zero TS refs | Backfill migration mandatory if ever renamed (CHECK rejects mismatched rows). Lowest priority — no TS coupling, purely a DB-cosmetic. Likely “leave-with-rationale” indefinitely. |
bid_detail/LAYER_BID_DETAIL, bid_library | layer_vocabulary CV (seed.sql) + ingestion_source; TS constant mirror (layer-inference.ts) | Backfill migration (CV seed row rename + content_items data backfill + TS constant) — own ticket; label already “Procurement Detail”. Dual-read during migration window. |
from_bid (URL param) + fromBidId/fromBidWorkspace/fromBid | Bookmarkable URL query key | Own ticket with redirect shim. Renaming breaks live bookmarks; needs a back-compat read (searchParams.get('from_procurement') ?? searchParams.get('from_bid')) for a deprecation window. e2e bid-session.spec.ts asserts the param. |
--bid-* design tokens + bg/text/border-bid-* | CSS @theme inline + Tailwind utility classes | Owned by bl-349. Rename requires CSS def + @theme RHS + grep-sweep of utility classnames in TSX + the app-bundles.ts token (regen). Self-contained ticket. |
kb-bid-view (localStorage) | Browser localStorage key (use-view-mode.ts) | Own ticket with migration read (localStorage.getItem('kb-procurement-view') ?? getItem('kb-bid-view')) or accept a one-time silent view-preference reset. Low stakes. |
legacy workspaces.type==='bid' alias | DB still emits the wire alias (args.type==='bid'?'procurement') | Own ticket gated on a DB data migration retiring the alias. Until the DB stops emitting 'bid', the TS comparison is load-bearing — KEEP. |
DB residue (Unit E, with unique_bids)
Section titled “DB residue (Unit E, with unique_bids)”bid_response_auto_version()→form_response_auto_version(): CREATE new fn + repoint triggerform_response_set_version+ mirror GRANT/REVOKE + DROP old. Sole version-setter onform_responses; zero TS callers (trigger fns are internal). Safe.get_form_summary(bid_workspace_id)→workspace_idparam:CREATE OR REPLACEwith renamed param + body (6 sites) + grants. No TS named-arg callers (zerorpc('get_form_summary', {bid_workspace_id})in TS) → PostgREST positional calls unaffected. Safe.bid_outcomeinternal alias →outcome: cosmetic CTE alias, NOT in RETURNS TABLE. Body-only edit. Safe.
3. Tooling & verification discipline
Section titled “3. Tooling & verification discipline”Per-edit-class tool selection:
- Graph-tracked symbols (type/interface/hook/fn/var names — Unit A, the
queryKeys.bidssymbol):gitnexus_rename(understands the call graph; never find-and-replace). Rungitnexus_impact({target, direction:'upstream'})before each high-blast-radius symbol (ActiveBidSummary~26 importers,invalidateBidDatacross-hook contract) and report HIGH/CRITICAL before proceeding. - String-literal values (cache-key tuples,
bid_deadline,unique_bidsTS sites, CV literals):gitnexus_renamedoes NOT cover these →ast-dataflow string-literal-usesto enumerate exact sites, then targeted edits. Follow with the ast-dataflow rename-sweep skill (Q1 string-literal sites, Q2 import-path sweep, Q3 new-symbol references) to produce a categorised VERDICT that the rename is complete. - SQL + Python: ast-dataflow is TS-only →
grepsweep ofsupabase/migrations/*.sqlandscripts/**/*.pyfor every value being renamed (already done forunique_bids,bid_response,bid_deadline). - Generated artefacts (
app-bundles.ts, api-views migration,database.types.ts,plugin-bundle.ts): regenerate from source, never hand-edit.bun run build:mcp-apps(bundle),generate-api-views.ts(api migration),supabase gen types(types).app-bundles.tsandplugin-bundle.tsare sandbox read-denied — confirms they are generated, not source. gitnexus_detect_changes({scope:'compare', base_ref:'main'})before each commit to confirm the diff touches only expected symbols/flows.
Lockstep test updates (the forcing functions):
- Unit B: the 4
use-draft-stream.test.tsliteral-key assertions. - Unit C:
__tests__/lib/attention.test.tsid-prefix snapshots. - Unit D:
__tests__/api/procurement.test.tsfailed_bid_idsassertions + thebids:contract test (mcp-app-contracts.test.ts). - Unit E:
__tests__/api/analytics-win-rate.test.ts+content-performance-section.test.tsxunique_bids→unique_procurements. - Unit F: e2e copy assertions +
e2e/helpers/selectors.tsbid:block.
Migration discipline (Unit E — from supabase/CLAUDE.md): supabase migration new + foreground supabase db push (interactive, hangs background shells); verify supabase/.temp/project-ref before push (main repo can silently target prod); all new fns SET search_path = public, extensions; explicit REVOKE … FROM anon on new fns; regen types --schema public,api (deterministic order). Never use MCP apply_migration/execute_sql for DDL.
CI gates that close each unit: quality-test (typecheck tsc -p tsconfig.ci.json + bun run test) for A/B/C/D; mcp-build for bundle freshness (C/D); supabase-types-parity + type-drift-parity + api-view-coverage + schema-parity for E; e2e-smoke (staging) for F; dupe-check (jscpd) runs over all. Draft PRs skip CI — keep PRs non-draft once the unit is internally complete.
4. Risk register (silent-failure modes + guard per phase)
Section titled “4. Risk register (silent-failure modes + guard per phase)”| Risk | Failure mode | Guarded by |
|---|---|---|
| Cache-key desync (Unit B) | Invalidate writes ['procurement',…]; reads still keyed ['bids',…] → stale UI, no error | The 4 literal-key use-draft-stream.test.ts assertions fail on any half-rename. Keep them literal. |
| Runtime discriminant mismatch (Unit C) | Union renamed but emitted value or ==="bid_deadline" consumer not → icon falls to Info, bundle buttons vanish, no throw | bun run test (icon-map + snapshot) + zod enum rejects bad value at API boundary + mcp-build bundle freshness. Edit reorient-me SOURCE + regen. |
| Generated-artifact staleness (C/D/E) | Hand-edit minified bundle / forget regen → source says procurement, shipped bundle says bid | mcp-build job rebuilds + diffs app-bundles.ts; generate-api-views.ts --check + supabase-types-parity diff generated vs committed. Never hand-edit. |
| API contract break (Unit D) | Rename bids: wire field, miss a consumer → data.bids undefined | Zero external consumers (verified); 4 in-repo consumers + contract test all in one commit; mcp-app-contracts.test.ts asserts shape. |
| DB column runtime break (Unit E) | Rename TS .unique_bids without SQL, or SQL without type regen → positional read returns undefined / type mismatch | supabase-types-parity (committed types must match live schema) + api-view-coverage + foreground db push to prod+staging together. schema-parity catches public-schema drift between envs. |
| Historical-data key drift (Item 4, deferred CVs) | Rename persisted value/JSONB key without backfill → old rows orphaned from new reads | DEFERRED — each own ticket carries the backfill (or leave-with-rationale where no reader). NOT touched this session. |
URL/bookmark break (from_bid, deferred) | Rename param → live bookmarks 404 the filter context | DEFERRED — own ticket with back-compat ?? read shim + e2e update. |
| e2e copy/selector drift (Unit F) | Rename UI copy/DOM-id, miss the e2e assertion → red smoke on staging | e2e-smoke is the forcing function; selector file + copy assertions land with the DOM-id producer. |
5. Explicit recommendation — concrete phased plan
Section titled “5. Explicit recommendation — concrete phased plan”Phase 1 (THIS session) — Unit A: pure-rename, zero value-coupling.
Scope: the 61 procurement-axis + 7 form-axis pure identifiers (incl. the verifier-overturned bid_management→procurement_management and 'bid' ClaudePrompt.category) via gitnexus_rename + ast-dataflow rename-sweep. Explicitly EXCLUDES every string-value boundary (cache keys, bid_deadline, unique_bids, REST fields, DB, UI copy) and the entire DEFER list.
Gate: tsc --noEmit green + bun run test green + gitnexus_detect_changes shows only expected symbols + ast-dataflow rename-sweep VERDICT = complete.
Why first: largest, lowest-risk slice; typecheck-guarded; unblocks E (renamed readers) and reduces F’s conflict surface.
Phase 2 — Unit B: TanStack cache-key lockstep.
Scope: queryKeys.bids→procurement namespace + 'bids' tuples + ~24 accessors + 4 use-draft-stream.test.ts assertions, one commit.
Gate: bun run test (the 4 assertions).
Phase 3 — Unit C: bid_deadline discriminant + reorient-me source regen.
Scope: union + producers + zod + icon-map + attention-bid- id prefix + reorient-me SOURCE edit + bun run build:mcp-apps + attention snapshot.
Gate: bun run test + mcp-build.
Phase 4 — Unit D: REST response shape.
Scope: bids:→ renamed wire field + 4 in-repo consumers + form-dashboard source regen; failed_bid_ids→failed_procurement_ids (clean break, producer+tests).
Gate: bun run test + mcp-build + mcp-app-contracts.test.ts.
Phase 5 — Unit E: DB co-migration + regen.
Scope: one migration renaming unique_bids (public+api RPC + CTE), bid_response_auto_version, get_form_summary param, bid_outcome alias; then generate-api-views + supabase gen types regen + TS readers + tests.
Gate: foreground db push (prod+staging) + supabase-types-parity + api-view-coverage --check + schema-parity + bun run test.
Phase 6 — Unit F: UI copy + DOM-id/e2e (owner put copy in scope).
Scope: visible “Bids”/“bid” copy → “Procurement”; data-testid="bid-card-${id}" producer + DOM tab ids; e2e/helpers/selectors.ts bid: block; e2e copy assertions.
Gate: e2e-smoke (staging) green.
Deferred to future own-tickets (NOT this migration): the entire ID-61.3 DEFER list — from_bid+derived (redirect-shim ticket), --bid-* tokens (bl-349), bid_writing/bid_library/bid_detail persona+layer CVs (backfill tickets), bid_discovered/prior_bid_response CHECK/lifecycle literals (backfill-or-leave), kb-bid-view localStorage, legacy workspaces.type==='bid' wire-alias (DB-data-migration-gated), source_bid_* JSONB (leave-with-rationale). Each is value-coupled to persisted data/URL/localStorage and must carry its own backfill or back-compat shim.
KEEP untouched (ratified): VALID_BID_STATUSES/TERMINAL_BID_STATUSES (const+values), the bid_response reorient union tag (types/reorient.ts + its emit/validator network incl. reorient-me source — decoupled from the DB), bid as a form_types CV value, the _s224..._bid_draft_all migration-filename JSDoc.
6. Single biggest risk
Section titled “6. Single biggest risk”Unit E (the DB column rename) shipping its SQL and its TS/type-regen out of lockstep across the prod+staging cutover. unique_bids is read positionally as .unique_bids in live runtime code AND lives in both public and api schemas with disjoint guards (schema-parity covers public, api-view-coverage+generator covers api, supabase-types-parity covers the committed types). If the migration reaches prod but database.types.ts/the api-views migration/the TS reader don’t land together — or staging and prod diverge — the win-rate endpoint silently returns unique_procurements: 0 (positional ?? 0 swallows the undefined) with no thrown error. Mitigation: treat Phase 5 as a single atomic PR that includes migration + both regens + TS readers + tests, push DDL foreground to prod AND staging in the same cutover, and rely on all four parity gates before merge. This is the one phase where a partial landing is both silent and prod-facing.