@@ -419,26 +419,36 @@ as one file per stage (`000004_rollback_stage_a_discard_shadow.sql`, `…_stage_
419419` …_stage_c_promote_original.sql ` , and the shared ` 000004_rollback_reverse_replay.sql ` ) and driven by
420420[ ` scripts/rollback.sh ` ] ( scripts/rollback.sh ) , so no one authors it under pressure.
421421
422- ** No data loss by construction.** The stages are mutually exclusive, so each lives in its ** own file** — no single file
423- mixes the ` TRUNCATE ` (stage A only) with the ` EXCHANGE ` /` DROP ` of the others, and running any file does exactly one
424- stage. No statement drops a data-bearing table: swaps are atomic ` EXCHANGE ` /` RENAME ` , and the only ` DROP ` targets the
425- ` Distributed ` wrapper, which stores no data (it is a routing definition over ` traces_local ` ). Before running, ` rollback.sh `
426- ** asserts the live ` traces ` topology matches the requested stage and aborts otherwise** — so a wrong-stage run (the only
427- way a ` TRUNCATE ` /` DROP ` could hit the wrong table) makes no change. Every stage lands in the same ** canonical state** :
428- ` traces ` = the original data (live), ` traces_local_v2 ` = the successor data (parked backup). No leftover ` *_new ` names.
429- The parked backup is dropped only later, by ` finalize.sh ` , after the soak.
422+ ** No data-bearing table is dropped by construction.** The stages are mutually exclusive, so each lives in its ** own
423+ file** — no single file mixes the ` TRUNCATE ` (stage A only) with the ` EXCHANGE ` /` DROP ` of the others, and running any
424+ file does exactly one stage. No statement drops a data-bearing table: swaps are atomic ` EXCHANGE ` /` RENAME ` , and the only
425+ ` DROP ` targets the ` Distributed ` wrapper, which stores no data (it is a routing definition over ` traces_local ` ). Before
426+ running, ` rollback.sh ` ** asserts the live ` traces ` topology matches the requested stage and aborts otherwise** — so a
427+ wrong-stage run (the only way a ` TRUNCATE ` /` DROP ` could hit the wrong table) makes no change. Every stage lands in the
428+ same ** canonical state** : ` traces ` = the original data (live), ` traces_local_v2 ` = the successor data (parked backup).
429+ No leftover ` *_new ` names. The parked backup is dropped only later, by ` finalize.sh ` , after the soak.
430+
431+ > ** Stages B/C make post-cutover writes non-live — an accepted, acknowledged trade-off.** Promoting the frozen
432+ > ` traces_pre_cutover_backup ` means traces the successor accepted ** after** ` cutover_start ` stop being served by the live
433+ > table (the reverse-replay carries post-cutover * deletes* forward, but not * writes* ). They are ** not destroyed** : the
434+ > successor is parked as ` traces_local_v2 ` and retained until ` finalize.sh ` , so recover them from there during the soak
435+ > if the rollback is later judged unnecessary. This is inherent to promoting a point-in-time backup and is * not* auto-repaired
436+ > — merging the successor's post-cutover writes back would re-import the very data the rollback exists to discard. Because
437+ > it is irreversible in the moment, stages B/C require ` --accept-post-cutover-write-loss ` , and ` rollback.sh ` prints the
438+ > recovery pointer before the promote.
430439
431440Pick the stage by how far the cutover got (` cutover_start ` is the value ` exchange_and_wrap.sh ` printed):
432441
433442- ** Stage A — before EXCHANGE:** ` ./scripts/rollback.sh --database opik --stage A ` . Discards the disposable shadow
434443 ` traces_local_v2 ` ; the live ` traces ` was never touched. (Guarded: aborts unless ` traces ` is still the original schema.)
435- - ** Stage B — after EXCHANGE, before wrap:** ` ./scripts/rollback.sh --database opik --stage B --cutover-start '<ts>' ` .
436- ` EXCHANGE ` ` traces_pre_cutover_backup ` back to live ` traces ` , rename the now-parked successor back to
437- ` traces_local_v2 ` , then the reverse replay. (Guarded: aborts if ` traces ` is ` Distributed ` — use C.)
438- - ** Stage C — after wrap:** ` ./scripts/rollback.sh --database opik --stage C --cutover-start '<ts>' ` . Drops the
439- ` Distributed ` wrapper, then one atomic ` RENAME ` promotes the original (` traces_pre_cutover_backup ` ) back to ` traces `
440- and parks the successor under ` traces_local_v2 ` , then the reverse replay. (Guarded: aborts unless ` traces ` is
441- ` Distributed ` .)
444+ - ** Stage B — after EXCHANGE, before wrap:** `./scripts/rollback.sh --database opik --stage B --cutover-start '<ts >'
445+ --confirm-retention-paused --accept-post-cutover-write-loss` . ` EXCHANGE` ` traces_pre_cutover_backup` back to live
446+ ` traces ` , rename the now-parked successor back to ` traces_local_v2 ` , then the reverse replay. (Guarded: aborts if
447+ ` traces ` is ` Distributed ` — use C.)
448+ - ** Stage C — after wrap:** `./scripts/rollback.sh --database opik --stage C --cutover-start '<ts >'
449+ --confirm-retention-paused --accept-post-cutover-write-loss` . Drops the ` Distributed` wrapper, then one atomic
450+ ` RENAME ` promotes the original (` traces_pre_cutover_backup ` ) back to ` traces ` and parks the successor under
451+ ` traces_local_v2 ` , then the reverse replay. (Guarded: aborts unless ` traces ` is ` Distributed ` .)
442452
443453After a stage B or C rollback, ` traces ` is the Nullable original again — ** revert ` traceColumnsNonNullable ` to ` false `
444454AND roll-restart every backend instance** . The flag is read from a ** startup snapshot** of ` OpikConfiguration ` (bound via
0 commit comments