Skip to content

Commit 06904ff

Browse files
dzhelezovclaude
andcommitted
harness(rg3): RG4-K7 — kill-during-rollback-apply covered against the mock (INV-10)
K7 (SIGKILL mid rollback-apply) was previously deferred because its never-committed scenario forked BELOW the finalized anchor — an intrinsically invalid reorg the product must (and does) fatal on as an unknown-parent gap. That was a scenario-design bug, not a mock or product limit: the product has a real accepted rollback-apply path (portal-realtime.ts:1105-1117, exercised today by K5-409) and mock-portal.mjs already carries the handleRollbackApply handler. This authors a correct scenario whose fork point sits strictly ABOVE the anchor, so the product enters reconcile -> {kind:'reorg'}, rolls back the losing suffix, and re-applies the winning branch; a SIGKILL mid-apply resumes byte-identical. No product code changes — harness only (mock + scenario + orchestrate + prose). - scenarios/k7-rollback.json: window 101..106 on 'main' (finalized anchor <=102), a childDiscovery+awaitRedelivery handshake at 101 (deterministic non-vacuous vault+deposit rows, mirroring k5-canonical — no factory-redelivery race), then a rollbackApply forking at block 104 (parent 103:main, in-window above the anchor) extended to block 112 so the app completes. killAt block 105 lands the SIGKILL mid re-apply, after the rollback event and the first apply. The baseline digest oracle uses the same file via baseline_scenario_for_class fall-through (the killAt gate is released, not a path divergence — no separate canonical file needed, unlike K5's 409 negotiation). - mock-portal.mjs handleRollbackApply: cursor discipline (204 a mis-cursored request, parity with handleStatus409) and a reorgApplied counter incremented where the cross-branch fork block is actually served, exposed in handler stats so a scenario that silently degraded to a plain append cannot self-report a vacuous pass. - orchestrate.sh: strengthen k7RollbackNonVacuousOk to mirror k6CutoverOrganicOk — assert killsWithLiveStream===killCount, the empirical kill-block set is exactly {105}, and reorgApplied>0 per run. Candor tightened to defer ONLY live-protocol fork-choice fidelity to RG4/RG5; the crash-timing half is now mock-covered. - VALIDATION.md 5.11 / class table: K7 reclassified from deferred to mock-covered (crash-timing), mapped to existing INV-10 (no new invariant); live-protocol fork fidelity remains RG4/RG5. - mock-portal.test.mjs: cursorMatchesStep routes a rollbackApply match only on the natural post-window resume cursor. Empirical proof (CHAOS_CLASSES=K7 CHAOS_KILLS_PER_CLASS=1, graft built fresh from this tree): scenario picked up (not the deferral branch); baseline reorged at block 103 and re-applied the rollback branch cleanly (no "Cannot reconcile safely"); kill landed at block 105 mid-apply; resume byte-identical (store+app digest identical, vault=1 deposit=1 non-vacuous, zero duplicate finalized rows); k7RollbackNonVacuousOk=true with killBlocks=[105], killsWithLiveStream=1, reorgApplied=1. mock-portal.test.mjs 10/10; biome clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cf1c6fd commit 06904ff

5 files changed

Lines changed: 172 additions & 10 deletions

File tree

VALIDATION.md

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1722,8 +1722,14 @@ cutover-handler entry (a 409 variant + a wrong-fork variant) · K6 at the histor
17221722
Per-class mock fidelity is recorded candidly in the harness and reproduced here — HIGH for K1/K2/K4
17231723
(direct 200/reorg/defer flows), MEDIUM for K3/K5 (redelivery / fork-handler reachability), and
17241724
**MEDIUM for K6** after Phase B (an organic mock cutover at the client's natural chunk-boundary
1725-
request). K7 is **not included** in RG3 Phase B: the pre-flight hit the product unknown-parent fatal
1726-
before a kill, so kill-during-rollback remains RG4/RG5.
1725+
request). K7 (kill-during-rollback-apply) is added against the mock: the pre-flight fatal was a
1726+
scenario-design bug (a fork point *below* the finalized anchor, which correctly fatals as an unknown-
1727+
parent/deep-reorg gap), not a mock or product limit. With the fork point corrected to sit strictly
1728+
*above* the anchor, the same product code path (`reconcile → {kind:'reorg'}` then rollback-apply,
1729+
`portal-realtime.ts:1105`) that K5-409 already exercises drives an accepted rollback, and the SIGKILL
1730+
lands mid-apply — so the **crash-timing** half of K7 is now mock-covered. What remains RG4/RG5 is only
1731+
the **live-protocol fork-choice fidelity** (a real Portal 409/reorg with competing finalized sources),
1732+
not kill-during-rollback as a class.
17271733

17281734
**Result (Phase A, from the operator's evidence archive — verbatim).**
17291735

@@ -1749,7 +1755,7 @@ All three RG3 numeric thresholds are met: **238 ≥ 200** kills across **7 ≥ 6
17491755
| Duplicate FINALIZED rows | **0** |
17501756
| K6 organic cutover | **10 / 10** (every kill on block 106, the chunk boundary) |
17511757
| K5 wrong-fork consumed in-window | **deferred** — default clean run consumed **0 / 1**; a stretched post-injection window makes the product correctly raise the wrong-fork finalize fatal |
1752-
| K7 kill-during-rollback | **deferred**pre-flight baseline fataled on unknown-parent/deep-reorg before any kill |
1758+
| K7 kill-during-rollback-apply | **mock-covered (crash-timing)**fork point corrected to sit ABOVE the finalized anchor (≤102), so `reconcile → {kind:'reorg'}` rolls back 104..106:main and re-applies the rollback branch; the SIGKILL lands mid-apply at block 105 and resumes byte-identical. `k7RollbackNonVacuousOk` self-certs kill-block=={105}, live /stream before every kill, and the mock served the cross-branch fork (`reorgApplied>0`). **Live-protocol fork fidelity remains RG4/RG5.** |
17531759

17541760
The full K1–K6 moderate rerun was attempted with
17551761
`CHAOS_KILLS_PER_CLASS=10 CHAOS_MIN_PER_CLASS=10 CHAOS_TOTAL_KILLS=70`, but it stopped before Phase-B
@@ -1793,10 +1799,24 @@ hidden:
17931799
`fromBlock=106` with the canonical parent hash). The cutover is **organic within the mock's
17941800
deterministic capability** — it is still not a live Portal cutover with competing finalized sources;
17951801
that remains RG4/RG5.
1796-
- **K7 kill-during-rollback is deferred.** The pre-flight scenario attempted to stream block 104 on a
1797-
rollback branch with parent block 103 while the finalized anchor was 105. The client treated that as
1798-
the existing unknown-parent/deep-reorg fatal (`Cannot reconcile safely`) before any kill could be
1799-
taken. The invariant was not weakened; K7 remains RG4/RG5.
1802+
- **K7 kill-during-rollback-apply — crash-timing is mock-covered; live fork fidelity remains RG4/RG5.**
1803+
The pre-flight scenario that fataled attempted to stream block 104 on a rollback branch with parent
1804+
block 103 while the finalized anchor was 105 — a fork point *below* finality, which correctly fatals
1805+
(`Cannot reconcile safely`; a fork below the finalized floor has no safe rollback). That was a
1806+
**scenario-design bug**, not a mock or product limit: the mock already carries the
1807+
`handleRollbackApply` handler and the product carries a real accepted rollback-apply path
1808+
(`portal-realtime.ts:1105-1117`), exercised today by K5-409. K7 now moves the fork point strictly
1809+
*above* the anchor (window 101..106 on `main`, anchor ≤102, reorg forking at block 104), so
1810+
`reconcile` returns `{kind:'reorg'}` (not `gap`), the losing 104..106:main suffix is rolled back and
1811+
the `rollback` branch re-applied. The SIGKILL fires at block 105 — after the rollback event and the
1812+
first apply, mid re-apply — and the resume is byte-identical. `k7RollbackNonVacuousOk` self-certifies
1813+
non-vacuity exactly as `k6CutoverOrganicOk` does: the empirical kill-block set is `{105}`, every kill
1814+
followed ≥1 live `/stream` request, and the mock-side `reorgApplied` counter (>0 per run) proves the
1815+
cross-branch fork was actually served rather than degrading to a plain append. This is crash-timing
1816+
coverage of **INV-10**'s rollback arm — no new invariant. What genuinely **remains RG4/RG5** is only
1817+
the *live-protocol fork-choice fidelity*: a real Portal 409/reorg with competing finalized sources
1818+
and real parentHash chains. The mock proves the rollback-apply *code path* is crash-timing safe; it
1819+
does not prove the Portal emits such branches — that is RG4/RG5.
18001820
- **The harness `invariant()` fast-path is a fatal-string tripwire, not the primary death detector.**
18011821
It grep-matches all eight real fatal throws in `portal/portal-realtime.ts` (the gap fatal, the
18021822
below-floor 409 fatal, the 409 no-progress cap, the deterministic-4xx fatal, the delivery-progress

harness/chaos/realtime/mock-portal.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ function createRuntime(initialScenario, options = {}) {
501501
wrongForkFinalizes: 0,
502502
wrongForkFinalizeConsumed: 0,
503503
wrongForkFinalizeRejected: 0,
504+
reorgApplied: 0,
504505
requestLog: [],
505506
};
506507
const phaseLog = options.phaseLog;
@@ -573,6 +574,7 @@ function createRuntime(initialScenario, options = {}) {
573574
stats.wrongForkFinalizes = 0;
574575
stats.wrongForkFinalizeConsumed = 0;
575576
stats.wrongForkFinalizeRejected = 0;
577+
stats.reorgApplied = 0;
576578
stats.requestLog = [];
577579
pendingWrongForkHead = undefined;
578580
skipWrongForkRejection = false;
@@ -893,13 +895,33 @@ function createRuntime(initialScenario, options = {}) {
893895
};
894896

895897
const handleRollbackApply = async (step, body, res) => {
898+
// Cursor discipline (parity with handleStatus409): only serve the reorg branch on the client's
899+
// natural resume cursor. `stream()` already 204s a mis-cursored request via isCursorStep, but a
900+
// defensive re-check here makes a stray request 204 rather than mis-deliver the rollback branch.
901+
if (step.match !== undefined && cursorMatchesStep(step, body) === false) {
902+
stats.r204 += 1;
903+
res.writeHead(204);
904+
res.end();
905+
906+
return;
907+
}
908+
896909
const reorgBlock = Number(step.reorgBlock ?? streamCursor - 1);
897910
const count = Math.max(0, Number(step.count ?? 1));
898911
const branchId = step.branch ?? step.branchId ?? 'rollback';
899912
const parentBranchId = step.parentBranch ?? 'main';
900913
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
901914
for (let i = 0; i < count; i++) {
902915
const number = reorgBlock + i;
916+
// Count the reorg branch actually being served: the fork block (i === 0) carries a CROSS-branch
917+
// parentHash (parent on `parentBranchId`, self on `branchId`), which is what drives the product's
918+
// reconcile -> {kind:'reorg'} rollback-apply. A scenario that silently degraded to a plain
919+
// tip-append (fork point == branch) has parentBranchId === branchId and never increments this,
920+
// so a vacuous "append masquerading as rollback" cannot self-report a K7 pass.
921+
if (i === 0 && parentBranchId !== branchId) {
922+
stats.reorgApplied += 1;
923+
}
924+
903925
const phase = gatePhaseForBlock(step, number);
904926
if (phase !== undefined) {
905927
const open = await gate(

harness/chaos/realtime/mock-portal.test.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,40 @@ test('cursorMatchesStep: routes redelivery and 409 steps by request cursor', ()
227227
false,
228228
);
229229
});
230+
231+
test('cursorMatchesStep: rollbackApply fires only on the natural post-window resume cursor', () => {
232+
// K7: after streaming 101..106 on `main`, the client's natural next request is
233+
// fromBlock=107 with parentBlockHash = hashBlock(106, 'main'). The rollbackApply step's
234+
// `match` must fire there — and NOT on the earlier in-window cursors — so the reorg branch
235+
// is served on the resume request rather than mis-delivered mid-stream.
236+
const step = {
237+
type: 'rollbackApply',
238+
reorgBlock: 104,
239+
count: 3,
240+
branch: 'rollback',
241+
parentBranch: 'main',
242+
match: { fromBlock: 107, parentBlock: 106, parentBranch: 'main' },
243+
};
244+
245+
assert.equal(
246+
cursorMatchesStep(step, {
247+
fromBlock: 107,
248+
parentBlockHash: hashBlock(106, 'main'),
249+
}),
250+
true,
251+
);
252+
assert.equal(
253+
cursorMatchesStep(step, {
254+
fromBlock: 106,
255+
parentBlockHash: hashBlock(105, 'main'),
256+
}),
257+
false,
258+
);
259+
assert.equal(
260+
cursorMatchesStep(step, {
261+
fromBlock: 107,
262+
parentBlockHash: hashBlock(106, 'rollback'),
263+
}),
264+
false,
265+
);
266+
});

harness/chaos/realtime/orchestrate.sh

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ const candor = {
511511
K4: 'idle / 204 retry path',
512512
K5: "synthetic fork via stale-parentBlockHash 409 (client consumes the canonical replacement chain and resumes byte-identically) PLUS a wrong-fork-finalize handler-REACHABILITY probe. handlerStats.wrongForkFinalizes counts mock-side handler entries (reachability) ONLY — the counter increments on handler entry, before the gate, so on a killed run the splice may not even execute; the injected wrong finalized head is NOT consumed by the client here (finalize poll cadence ~4s > the post-injection scenario window ~1.8s), so this is not clean-resume wrong-fork evidence. The client-side wrong-fork-finalize FATAL guard (hash-mismatch at finality) is proven deterministically by portal-realtime.test.ts ('a finalize whose canonical hash mismatches the local block is FATAL', finalizePollMs:0). Real L1-reorg wire fidelity NOT exercised.",
513513
K6: "kill lands at the backfill→live chunk boundary (cutoverGate fires on the client's natural fromBlock=106 request, not a fixed block), so resume re-drives an organic cutover within the mock's deterministic capability. Probe-retry wire fidelity (the 3-attempt loop in clampFinalizedToPortalHead) NOT exercised — mock's /finalized-head succeeds first try; probe-retry fidelity rests on portal-realtime-wire.test.ts.",
514-
K7: 'K7: kill lands mid rollback-apply (client sees parent mismatch at block 104 and must roll back to 103 before applying the rollback branch). Mock-driven: the rollback is scripted, not a live L1 reorg. Live reorg-during-rollback fidelity is RG4/RG5.',
514+
K7: "kill lands mid rollback-apply: after 101..106 on main, the client resumes at fromBlock=107/parent=106:main, the mock serves a reorg branch forking at block 104 (104:rollback carries parent 103:main, in-window above the <=102 finalized anchor), so reconcile -> {kind:'reorg'} rolls back 104..106:main and re-applies the rollback branch 104..112 (extended to PONDER_END so the app completes); the SIGKILL fires at block 105, AFTER the rollback event and the first apply, mid re-apply. The fork point is strictly ABOVE the finalized anchor (<=102) so this is an ACCEPTED reorg, never the below-floor gap fatal. reorgApplied counts the mock actually serving the cross-branch fork block, so a scenario that degraded to a plain append cannot self-report a K7 pass. Mock-driven crash-timing coverage of INV-10's rollback arm (same product code path from portal-realtime.ts:1105 as K5-409's accepted reorg). What is NOT proven here: live-protocol fork-choice fidelity — a real Portal 409/reorg with competing finalized sources and real parentHash chains — remains RG4/RG5.",
515515
};
516516
const lines = fs.existsSync(process.env.RECORDS)
517517
? fs.readFileSync(process.env.RECORDS, 'utf8').trim().split(/\n+/).filter(Boolean)
@@ -534,6 +534,7 @@ for (const klass of classes) {
534534
acc.wrongForkFinalizes += Number(s.wrongForkFinalizes ?? 0);
535535
acc.wrongForkFinalizeConsumed += Number(s.wrongForkFinalizeConsumed ?? 0);
536536
acc.wrongForkFinalizeRejected += Number(s.wrongForkFinalizeRejected ?? 0);
537+
acc.reorgApplied += Number(s.reorgApplied ?? 0);
537538
return acc;
538539
}, {
539540
r204: 0,
@@ -544,6 +545,7 @@ for (const klass of classes) {
544545
wrongForkFinalizes: 0,
545546
wrongForkFinalizeConsumed: 0,
546547
wrongForkFinalizeRejected: 0,
548+
reorgApplied: 0,
547549
});
548550
// Distinct empirical kill blocks (from each run's kill.phase.log) — proves K2's varied mid-stream
549551
// coverage rather than one repeated fixed block. `killBlocksRecorded` keeps every row's non-null
@@ -556,6 +558,11 @@ for (const klass of classes) {
556558
const killsWithLiveStream = rows.filter((r) => Number(r.killStats?.stream ?? 0) > 0).length;
557559
const wrongForkRows = ok.filter((r) => r.variant === 'wrongfork');
558560
const wrongForkConsumedRuns = wrongForkRows.filter((r) => Number(r.stats?.wrongForkFinalizeConsumed ?? 0) > 0).length;
561+
// K7 non-vacuity: count ok runs whose FINAL mock stats recorded the reorg branch actually served
562+
// (reorgApplied > 0) on the resumed run. A scenario that silently degraded to a plain append (fork
563+
// point drifting to the tip) never increments the mock-side counter, so a vacuous K7 pass cannot
564+
// self-report reorgAppliedRuns === killCount.
565+
const reorgAppliedRuns = ok.filter((r) => Number(r.stats?.reorgApplied ?? 0) > 0).length;
559566
perClass[klass] = {
560567
scenarios: [...new Set(rows.map((r) => r.scenario).filter(Boolean))],
561568
variantLabels: [...new Set(rows.map((r) => r.variant).filter(Boolean))].join(', '),
@@ -565,6 +572,7 @@ for (const klass of classes) {
565572
killsWithLiveStream,
566573
wrongForkRunCount: wrongForkRows.length,
567574
wrongForkConsumedRuns,
575+
reorgAppliedRuns,
568576
cleanResumeCount: ok.length,
569577
digestMatchCount: digestMatch.length,
570578
duplicateFinalizedRows: dupCount,
@@ -615,12 +623,23 @@ const acceptance = {
615623
&& perClass.K6.killsWithLiveStream === perClass.K6.killCount),
616624
k2KillSpreadOk: !hasClass('K2') || perClass.K2.killBlocks.length >= 3,
617625
// Left `undefined` (not `true`) when K7 is absent so the guards below skip it entirely — a class
618-
// that never ran must not self-report a vacuous "non-vacuous: true" pass in RESULT.md.
626+
// that never ran must not self-report a vacuous "non-vacuous: true" pass in RESULT.md. When present,
627+
// mirror k6CutoverOrganicOk's non-vacuity self-cert so a scenario that silently degraded to a plain
628+
// append (fork point drifting to the tip, so the kill never lands mid-rollback-apply) cannot report
629+
// a byte-identical pass: every K7 kill must have seen a live /stream request before the kill
630+
// (killsWithLiveStream), the empirical kill-block set must be exactly {105} (the mid-apply block),
631+
// and every ok run's FINAL mock stats must record the reorg branch actually served
632+
// (reorgAppliedRuns === killCount) — the mock-side reachability floor for the load-bearing splice.
619633
k7RollbackNonVacuousOk: !hasClass('K7')
620634
? undefined
621635
: (perClass.K7.killCount > 0
622636
&& perClass.K7.cleanResumeCount === perClass.K7.killCount
623-
&& perClass.K7.digestMatchCount === perClass.K7.killCount),
637+
&& perClass.K7.digestMatchCount === perClass.K7.killCount
638+
&& perClass.K7.killsWithLiveStream === perClass.K7.killCount
639+
&& perClass.K7.killBlocksRecordedCount === perClass.K7.killCount
640+
&& perClass.K7.killBlocks.length === 1
641+
&& perClass.K7.killBlocks[0] === 105
642+
&& perClass.K7.reorgAppliedRuns === perClass.K7.killCount),
624643
};
625644
acceptance.accepted = acceptance.totalKillsOk
626645
&& acceptance.perClassKillsOk
@@ -679,6 +698,7 @@ for (const klass of classes) {
679698
md.push(`- kill blocks (distinct, from kill.phase.log): ${c.killBlocks.length > 0 ? c.killBlocks.join(', ') : 'n/a'}`);
680699
md.push(`- kills after >=1 live /stream request: ${c.killsWithLiveStream} / ${c.killCount}`);
681700
if (klass === 'K5') md.push(`- wrong-fork consumed runs: ${c.wrongForkConsumedRuns} / ${c.wrongForkRunCount}`);
701+
if (klass === 'K7') md.push(`- reorg-applied runs (mock served the rollback branch): ${c.reorgAppliedRuns} / ${c.killCount}`);
682702
md.push(`- handler stats: ${JSON.stringify(c.handlerStats)}`);
683703
md.push(`- fidelity: ${c.fidelity} — ${c.candor}`);
684704
if (c.failures.length > 0) md.push(`- failures: ${JSON.stringify(c.failures)}`);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"chainId": 1,
3+
"genesis": {
4+
"number": 100,
5+
"timestamp": 1700001200
6+
},
7+
"finalizedHeadSeq": [
8+
{ "number": 100 },
9+
{ "number": 100 },
10+
{ "number": 102 },
11+
{ "number": 102 }
12+
],
13+
"steps": [
14+
{
15+
"type": "childDiscovery",
16+
"block": 101,
17+
"child": "0x1111111111111111111111111111111111111111",
18+
"transactionHash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
19+
"logIndex": 0
20+
},
21+
{
22+
"type": "awaitRedelivery",
23+
"block": 101,
24+
"logs": [
25+
{
26+
"block": 101,
27+
"event": "ProxyCreated",
28+
"proxy": "0x1111111111111111111111111111111111111111",
29+
"transactionHash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
30+
"transactionIndex": 0,
31+
"logIndex": 0
32+
},
33+
{
34+
"block": 101,
35+
"event": "Deposit",
36+
"vault": "0x1111111111111111111111111111111111111111",
37+
"sender": "0x2222222222222222222222222222222222222222",
38+
"owner": "0x3333333333333333333333333333333333333333",
39+
"assets": "123456789",
40+
"shares": "987654321",
41+
"transactionHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
42+
"transactionIndex": 0,
43+
"logIndex": 1
44+
}
45+
]
46+
},
47+
{
48+
"type": "blocks",
49+
"count": 5,
50+
"branch": "main",
51+
"turnDelayMs": 10
52+
},
53+
{
54+
"type": "rollbackApply",
55+
"reorgBlock": 104,
56+
"count": 9,
57+
"branch": "rollback",
58+
"parentBranch": "main",
59+
"match": { "fromBlock": 107, "parentBlock": 106, "parentBranch": "main" },
60+
"killAt": { "block": 105, "phase": "K7-rollback" }
61+
}
62+
]
63+
}

0 commit comments

Comments
 (0)