@@ -2,8 +2,18 @@ import { existsSync, readFileSync } from 'node:fs'
22import { resolve } from 'node:path'
33import * as p from '@clack/prompts'
44import { type AgentEnvironment , detectAgents } from '../init/detect-agents.js'
5- import { parsePlanSummary , renderPlanSummary } from '../init/lib/parse-plan.js'
5+ import {
6+ type PlanStep ,
7+ type PlanSummary ,
8+ effectiveStep ,
9+ parsePlanSummary ,
10+ renderPlanSummary ,
11+ } from '../init/lib/parse-plan.js'
612import { readContextFile } from '../init/lib/read-context.js'
13+ import {
14+ classifyPhase ,
15+ detectColumnStates ,
16+ } from '../init/lib/rollout-state.js'
717import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js'
818import {
919 CONTEXT_REL_PATH ,
@@ -46,6 +56,71 @@ async function confirmContinueWithoutPlan(): Promise<void> {
4656 }
4757}
4858
59+ /**
60+ * Verify the plan-summary's targeted columns have crossed the deploy gate
61+ * (a `dual_writing` event recorded in `cs_migrations`). Used only for
62+ * `cutover`-step plans. Returns the list of columns that are still in the
63+ * rollout step (no `dual_writing` event yet) — empty when the plan is
64+ * safe to proceed.
65+ *
66+ * On any DB error, returns `null` to signal "could not verify". The caller
67+ * still proceeds — refusing to run when the DB is just temporarily
68+ * unreachable would be too brittle, and the encrypt commands themselves
69+ * also gate on the same event before doing anything destructive.
70+ */
71+ async function verifyCutoverPreconditions (
72+ cwd : string ,
73+ summary : PlanSummary ,
74+ ) : Promise < { table : string ; column : string } [ ] | null > {
75+ const migrate = summary . columns . filter ( ( c ) => c . path === 'migrate' )
76+ if ( migrate . length === 0 ) return [ ]
77+
78+ let databaseUrl : string
79+ try {
80+ const { loadStashConfig } = await import ( '../../config/index.js' )
81+ const config = await loadStashConfig ( )
82+ databaseUrl = config . databaseUrl
83+ } catch {
84+ return null
85+ }
86+
87+ const states = await detectColumnStates ( databaseUrl , migrate )
88+ return states
89+ . filter ( ( s ) => classifyPhase ( s . phase ) !== 'cutover' )
90+ . map ( ( s ) => ( { table : s . table , column : s . column } ) )
91+ }
92+
93+ /**
94+ * Print the deploy-gate banner shown at the end of a successful
95+ * rollout-step impl run. The banner is the explicit handover from the
96+ * CLI back to the user — the encrypted twin column and dual-write code
97+ * are now in the repo, but they only become safe once that code is
98+ * running in production. The CLI deliberately does not chain into
99+ * anything destructive here; the next destructive step (`stash encrypt
100+ * backfill`) requires the user to come back and run `stash plan` after
101+ * deploy.
102+ */
103+ function printDeployGateBanner ( cli : string ) : void {
104+ p . note (
105+ [
106+ 'Encryption rollout is in your repo.' ,
107+ '' ,
108+ 'Encrypted values are not yet flowing through your application because the' ,
109+ 'dual-write code is not deployed. Until your production environment is running' ,
110+ 'this code, backfilling historical rows would corrupt new writes (any row' ,
111+ 'inserted during the backfill window would land in plaintext only and create' ,
112+ 'silent migration drift).' ,
113+ '' ,
114+ 'Next:' ,
115+ ` 1. Deploy this branch to production.` ,
116+ ` 2. Run \`${ cli } status\` to verify dual-writes are live.` ,
117+ ` 3. Run \`${ cli } plan\` again — the CLI will detect dual-writes and draft` ,
118+ ` the encryption cutover (backfill → switch reads → drop plaintext).` ,
119+ ] . join ( '\n' ) ,
120+ '⛔ Deploy gate' ,
121+ )
122+ }
123+
49124/**
50125 * `stash impl` — execute an encryption plan.
51126 *
@@ -54,13 +129,23 @@ async function confirmContinueWithoutPlan(): Promise<void> {
54129 *
55130 * - **Plan exists** (TTY): parse the structured summary block, render
56131 * a confirmation panel, ask the user to proceed. Default-yes.
132+ * For `cutover`-step plans, verify `dual_writing` events are
133+ * recorded for every migrate column before launching — refuse if
134+ * not, and point the user at re-running `stash plan` after deploy.
57135 * - **Plan exists** (non-TTY): proceed without confirmation.
58136 * - **No plan, `--continue-without-plan`**: confirm once, then implement.
59137 * - **No plan, TTY**: present a `p.select` — draft a plan first
60138 * (delegates to `planCommand`) or continue without one (confirms
61139 * once, then implements).
62140 * - **No plan, non-TTY**: error out with a clear next-action; CI must
63141 * pass `--continue-without-plan` or run `stash plan` first.
142+ *
143+ * After successful handoff, the outro depends on plan step:
144+ * - `rollout` — deploy-gate banner; explicit "do not run encrypt
145+ * backfill yet" message.
146+ * - `cutover` — confirmation that the rollout is fully complete.
147+ * - `complete` — same as cutover (escape hatch covers everything).
148+ * - no plan / no summary — generic "verify state" pointer.
64149 */
65150export async function implCommand ( flags : Record < string , boolean > ) {
66151 const cwd = process . cwd ( )
@@ -82,12 +167,38 @@ export async function implCommand(flags: Record<string, boolean>) {
82167 const continueWithoutPlan = flags [ 'continue-without-plan' ] === true
83168 const isTTY = process . stdout . isTTY
84169
170+ let planStep : PlanStep | undefined
171+
85172 try {
86173 if ( planExists ) {
174+ const summary = parsePlanSummary ( readFileSync ( planPath , 'utf-8' ) )
175+ planStep = summary ? effectiveStep ( summary ) : undefined
176+
177+ // Deploy-gate enforcement for cutover-step plans. Block before any
178+ // confirm prompt or handoff so the user never gets the chance to
179+ // press Enter through a misshapen flow. The check itself is
180+ // defensive: a DB outage doesn't fail the run (returns null), the
181+ // encrypt commands have their own gate.
182+ if ( summary && planStep === 'cutover' ) {
183+ const missing = await verifyCutoverPreconditions ( cwd , summary )
184+ if ( missing && missing . length > 0 ) {
185+ p . log . error (
186+ 'This plan is a cutover-step plan, but `cs_migrations` has no `dual_writing` event for the following column(s):' ,
187+ )
188+ for ( const col of missing ) {
189+ p . log . error ( ` · ${ col . table } .${ col . column } ` )
190+ }
191+ p . log . info (
192+ `Backfill, cutover, and drop are unsafe until the dual-write code is live in production. Deploy your encryption-rollout PR first, then re-run \`${ cli } status\` to verify and \`${ cli } plan\` to redraft.` ,
193+ )
194+ p . outro ( 'Cutover blocked.' )
195+ process . exit ( 1 )
196+ }
197+ }
198+
87199 // Plan-summary checkpoint: the last save point before launching the
88200 // (potentially hour-long) implementation phase.
89201 if ( isTTY ) {
90- const summary = parsePlanSummary ( readFileSync ( planPath , 'utf-8' ) )
91202 if ( summary ) {
92203 p . note ( renderPlanSummary ( summary ) , 'Plan summary' )
93204 } else {
@@ -154,9 +265,14 @@ export async function implCommand(flags: Record<string, boolean>) {
154265
155266 await howToProceedStep . run ( state )
156267
157- p . outro (
158- `Implementation handoff complete. Run \`${ cli } db status\` to verify state.` ,
159- )
268+ if ( planStep === 'rollout' ) {
269+ printDeployGateBanner ( cli )
270+ p . outro ( 'Encryption rollout handed off — deploy when ready.' )
271+ } else {
272+ p . outro (
273+ `Implementation handoff complete. Run \`${ cli } status\` to verify state.` ,
274+ )
275+ }
160276 } catch ( err ) {
161277 if ( err instanceof CancelledError ) {
162278 p . cancel ( 'Cancelled.' )
0 commit comments