Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
9f3f5e7
feat(ledger): Final check one-click sign-off + tee-overlap dedupe + r…
ar2rsawseen Sep 21, 2026
df7d4a0
feat(ledger): one-call set-boundary + cluster-truth dashboard tiles
ar2rsawseen Sep 21, 2026
d29620b
feat(ledger): startup guard — refuse to run unbounded against a live …
ar2rsawseen Sep 21, 2026
26b6d77
docs: keep deployment references generic
ar2rsawseen Sep 21, 2026
ba960a4
fix(ledger): address review — dedupe native-counterpart evidence, str…
ar2rsawseen Sep 21, 2026
712cb2d
fix(ledger): harden guard evidence, strict dedupe gates, corroborated…
ar2rsawseen Sep 21, 2026
91a55ab
docs(runbook): clone-source migration variant — frozen-copy semantics…
ar2rsawseen Sep 21, 2026
85c6aca
docs: make the runbook and README self-contained for on-premise opera…
ar2rsawseen Sep 21, 2026
17844e4
docs(runbook): scenario chooser first; clone-source framed as an opti…
ar2rsawseen Sep 21, 2026
29dbff8
fix(ledger): distinct-id drift coverage, fail-closed DLQ/bound reads,…
ar2rsawseen Sep 21, 2026
d8e0fc1
feat(ledger): tiered Final check — quick (ledger verify + samples) by…
ar2rsawseen Sep 21, 2026
93845d5
fix(ledger): scope dedupe id-matching end to end, slack-aware execute…
ar2rsawseen Sep 21, 2026
efe8a82
fix(ledger): claim-fenced bound apply, exact duplicate verdicts, over…
ar2rsawseen Sep 21, 2026
26cf33e
fix(ledger): epoch-ms validation on bound application, NaN-proof samp…
ar2rsawseen Sep 21, 2026
6c61685
fix(ledger): page id query-params at 2,000 — ClickHouse form-field limit
ar2rsawseen Sep 21, 2026
b489a8d
docs(runbook): what quick vs deep can and cannot see — deep is the de…
ar2rsawseen Sep 21, 2026
1e304c9
fix(ledger): verify in both tiers with cutover awareness, sweep-row v…
ar2rsawseen Sep 21, 2026
db3d8d9
fix(ledger): fail-closed cutover validation, bound rollback restores …
ar2rsawseen Sep 21, 2026
5b145a1
fix(ledger): anchor-abutting auto-apply, no bound raises on pruned gr…
ar2rsawseen Sep 21, 2026
e6c2cd8
fix(ledger): gap purity + cutover-aware duplicate boundary + id pagin…
ar2rsawseen Sep 21, 2026
eb32b15
fix(ledger): CAS bound store, complete rollback receipts, fail-closed…
ar2rsawseen Sep 21, 2026
ffe8eb5
docs(runbook): naive table totals are orientation only on a live targ…
ar2rsawseen Sep 21, 2026
8c5eda5
fix(ledger): bound-aware prune rollback, persisted empty-target verdi…
ar2rsawseen Sep 21, 2026
3f980b3
docs(runbook): boundary question is answered once, before first write…
ar2rsawseen Sep 21, 2026
d3ee233
fix(ledger): fingerprint-bracketed final check, indeterminate rollbac…
ar2rsawseen Sep 21, 2026
9f8446a
fix(ledger): strict sweep-subtracted verification, fail-closed winner…
ar2rsawseen Sep 21, 2026
761d4df
fix(ledger): CAS'd rollback, ledger-reconciled audit coverage, fail-c…
ar2rsawseen Sep 21, 2026
7bb1300
test(ledger): model the vanished-collection scenario correctly (one o…
ar2rsawseen Sep 21, 2026
1fadb85
fix(ledger): never restore under an assumed bound; quick verdicts nev…
ar2rsawseen Sep 21, 2026
ffbc30f
fix(ledger): per-write duplicate detection in restore, fingerprinted …
ar2rsawseen Sep 21, 2026
f8c3fea
fix(ledger): absent-first DLQ discount in id coverage; unscoped windo…
ar2rsawseen Sep 21, 2026
6a28409
fix(ledger): auto-ack only on truly EMPTY targets, full drift-window …
ar2rsawseen Sep 21, 2026
15efbf6
fix(ledger): map-vs-apply self-prune, absent-first sweep discount, pr…
ar2rsawseen Sep 21, 2026
a9696e5
fix(ledger): post-claim bound fence, final-check/dedupe mutual exclusion
ar2rsawseen Sep 21, 2026
799ea14
fix(ledger): authoritative-only auto-ack, receipt-scoped clamping
ar2rsawseen Sep 21, 2026
e88ce1b
fix(ledger): immutable env bound in the post-claim fence; pre-delete …
ar2rsawseen Sep 21, 2026
124550e
fix(ledger): per-page delete fence, durable clamp gate, accepted expe…
ar2rsawseen Sep 21, 2026
63c5a81
fix(ledger): fence stride equals the staging DELETE page — one fence …
ar2rsawseen Sep 21, 2026
ac97407
fix(ledger): receipt-before-write prune, synchronous maintenance lock
ar2rsawseen Sep 21, 2026
15bda2a
fix(ledger): remove the automatic empty-target verdict — the boundary…
ar2rsawseen Sep 21, 2026
c662d63
fix(ledger): absent-intersected window discounts, tokenized bound own…
ar2rsawseen Sep 21, 2026
32b5bd9
fix(ledger): token-scoped fence casualties restored on bound rollback
ar2rsawseen Sep 21, 2026
d011053
fix(ledger): lost-ack compensations fail closed
ar2rsawseen Sep 21, 2026
7090f42
fix(ledger): apply-in-progress marker — claims release while the grid…
ar2rsawseen Sep 21, 2026
422a726
Serialize bound applies via CAS marker; anchor dedupe execute to the …
ar2rsawseen Sep 21, 2026
77cd12b
Durable prune journal for bound applies; exclude null-cd sweep rows f…
ar2rsawseen Sep 21, 2026
4673cfe
Retry prune-journal recovery at claim time and completion; page huge …
ar2rsawseen Sep 21, 2026
0b9826d
Drop rolled-back adopted bounds at map passes; replayed DLQ rows bump…
ar2rsawseen Sep 21, 2026
a0f87c4
Defer receipt-less prunes while an apply is in flight; replay account…
ar2rsawseen Sep 22, 2026
6bd0fff
Dedupe matches and deletes exact (_id, cd) pairs; reconcile replay in…
ar2rsawseen Sep 22, 2026
0428c52
Frozen-source bracket for unbounded deep checks; recover journal page…
ar2rsawseen Sep 22, 2026
0cd9051
Round 38: row-exact sweep evidence, pair-exact null-cd audits, marker…
ar2rsawseen Sep 22, 2026
1478287
Refuse dedupe execute on the dry-run service; a lost maintenance leas…
ar2rsawseen Sep 22, 2026
737af53
Ownership-fence prune writes; frozen-source bracket uses exact count …
ar2rsawseen Sep 22, 2026
23fda18
Dedupe deletes only the observed live subset; bracket compares both d…
ar2rsawseen Sep 22, 2026
167cf7a
Bracket the audited prefix on bounded deep checks; reduce the source …
ar2rsawseen Sep 22, 2026
0cb43fb
Primary reads for decision-authorizing probes; synchronous lease reva…
ar2rsawseen Sep 22, 2026
db27346
Fence generation makes prune mutations atomic with ownership
ar2rsawseen Sep 22, 2026
ffc888d
Target-stability bracket on both check tiers; identity hash in the so…
ar2rsawseen Sep 22, 2026
7d9b424
Scope replay already-live presence checks per source collection
ar2rsawseen Sep 22, 2026
f50a488
Replay inserts are idempotent by reconciliation
ar2rsawseen Sep 22, 2026
377e21d
Pair-exact replay reconciliation; sweep pairs join the target bracket
ar2rsawseen Sep 22, 2026
b19d39b
Replay: resolution failures never re-trigger target inserts
ar2rsawseen Sep 22, 2026
0bf1895
Duplicate groups are scoped to the source identity
ar2rsawseen Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ both stacks, in the same partition), checks match `(_id, cd)` pairs — the
retry copy's cd can never equal the migrated copy's. Preflight verifies the
boundary is trustworthy (source frozen, clocks sane) before anything runs.

This README covers what you need BEFORE the dashboard exists (installing,
env vars, starting the service, automation reference). Everything after —
running, monitoring, troubleshooting, verifying — lives in the dashboard,
with `docs/RUNBOOK.md` as the cross-system procedure (cutover choreography,
Kafka retention, incident tables) for operators.

## Architecture
This README covers what you need BEFORE the dashboard exists: installing
and starting the service. `.env.example` is the commented configuration
reference (the two required variables and every optional one). Everything
after — running, monitoring, troubleshooting, verifying — lives in the
dashboard's **Migration Guide** and **Help & Recovery** tabs, with
`docs/RUNBOOK.md` as the standalone operations manual (terms, cutover
scenarios, incident table, sign-off procedure, curl reference) — start
there if you are planning a migration from scratch.
252 changes: 219 additions & 33 deletions docs/RUNBOOK.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function envToRawConfig(env: NodeJS.ProcessEnv) {
cdUpperBoundMs: env.LEDGER_CD_UPPER_BOUND,
captureTransformErrors: env.LEDGER_CAPTURE_TRANSFORM_ERRORS,
startPaused: env.LEDGER_START_PAUSED,
unboundedOk: env.LEDGER_UNBOUNDED_OK,
dryRun: env.DRY_RUN,
dryRunSamplePct: env.DRY_RUN_SAMPLE_PCT,
},
Expand Down
2 changes: 2 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export const configSchema = z.object({
// click starts the whole fleet, pods that join later start
// immediately, and a pod that restarts after Start stays started.
startPaused: booleanFromEnv.default(false),
/** Explicit no-mirror declaration: skips the unbounded-with-live-target startup guard. */
unboundedOk: booleanFromEnv.default(false),
// Dry run: sampled rehearsal against a Null-engine clone.
dryRun: booleanFromEnv.default(false),
dryRunSamplePct: numberFromEnv.default(2).pipe(z.number().min(0.1).max(5)),
Expand Down
228 changes: 206 additions & 22 deletions src/http/ledger-viz-route.ts

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions src/runtime/boundary-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,63 @@ export interface BoundaryProgress {
report: BoundaryReport | null;
}

/**
* One-call boundary setting: decide whether a detection is safe to apply
* unattended. An exact ingestion-pause GAP is; an ANCHOR carries quantified
* ambiguity and needs a human (or an explicit acceptAnchor).
*/
export function decideAutoApply(
report: BoundaryReport | null | undefined,
acceptAnchor: boolean,
): { apply: boolean; boundMs?: number; reason?: string } {
const d = report?.detection;
if (!d || d.status !== 'ok' || !d.suggestedBoundMs) {
return { apply: false, reason: `no boundary detected${d?.reason ? ` — ${d.reason}` : d?.status ? ` (${d.status})` : ''}` };
}
if (d.method !== 'gap' && !acceptAnchor) {
return {
apply: false,
reason: `detected an ANCHOR, not an exact gap — ${d.ambiguousMongoDocs ?? '?'} old-side docs sit inside the ambiguity band. Review GET /api/boundary, then re-call with {"acceptAnchor": true} to take it, or pass an explicit {"boundMs": ...}.`,
};
}
// A quiet minute only proves a seam when there was traffic to go quiet
// FROM: on low-volume installs every other minute is silent, and the
// first lull would be taken as the flip. Require corroborating volume on
// both flanks before applying a gap unattended.
if (d.method === 'gap' && !acceptAnchor) {
const gap = d.gap;
const mins = d.minutes ?? [];
const FLANK_MS = 10 * 60_000;
const MIN_FLANK_DOCS = 25;
const before = gap ? mins.filter((m) => m.minuteMs >= gap.fromMs - FLANK_MS && m.minuteMs < gap.fromMs).reduce((a, m) => a + m.mongo, 0) : 0;
const after = gap ? mins.filter((m) => m.minuteMs >= gap.toMs && m.minuteMs < gap.toMs + FLANK_MS).reduce((a, m) => a + m.ch, 0) : 0;
Comment thread
ar2rsawseen marked this conversation as resolved.
if (!gap || before < MIN_FLANK_DOCS || after < MIN_FLANK_DOCS) {
return {
apply: false,
reason: `a gap was found but traffic around it is too sparse to trust a quiet minute as the seam (${before} old-side docs in the 10 min before, ${after} new-side docs in the 10 min after — need ${MIN_FLANK_DOCS} each). Review GET /api/boundary, then re-call with {"acceptAnchor": true} or pass an explicit {"boundMs": ...}.`,
};
}
// The real seam ends where the new side BEGINS: a trusted gap must
// contain or directly abut the ClickHouse anchor. A lull minutes before
// the true tee start can otherwise pass the flank check and exclude
// every old-side doc between the false gap and the anchor.
const anchor = d.anchorMs;
// the 2-min allowance is for ingest latency, not for RESUMED old-side
// traffic: any Mongo docs between the gap end and the anchor would land
// beyond the bound and never migrate
const resumedBetween = mins
.filter((m) => m.minuteMs >= gap.toMs && typeof anchor === 'number' && m.minuteMs < anchor)
.reduce((a, m) => a + m.mongo, 0);
if (typeof anchor !== 'number' || anchor < gap.fromMs || anchor > gap.toMs + 2 * 60_000 || resumedBetween > 0) {
return {
apply: false,
reason: `the gap (${new Date(gap.fromMs).toISOString()}–${new Date(gap.toMs).toISOString()}) does not cleanly abut the first new-side data (anchor ${typeof anchor === 'number' ? new Date(anchor).toISOString() : 'unknown'}${resumedBetween > 0 ? `; ${resumedBetween} old-side docs resumed in between` : ''}) — likely a lull BEFORE the real tee start; applying it would exclude the old-side docs in between. Review GET /api/boundary, then re-call with {"acceptAnchor": true} or pass an explicit {"boundMs": ...}.`,
};
}
}
return { apply: true, boundMs: d.suggestedBoundMs };
Comment thread
ar2rsawseen marked this conversation as resolved.
}

export interface BoundaryReport {
detection: {
status: 'ok' | 'refused' | 'no_data';
Expand Down
Loading
Loading