Skip to content

Commit a6a86e9

Browse files
dzhelezovclaude
andauthored
harness(rg3): Phase B realtime chaos hardening — K6 organic cutover + K5/K7 candid deferrals (#166)
* feat(realtime): harden RG3 Phase B chaos harness Add the organic K6 cutover gate and self-cert, expose wrong-fork finalized-head counters for K5 candor, and wire optional K7 inclusion behind scenario presence. Update validation with the actual Phase B evidence and deferrals. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * harness(rg3): fold committee nits — K7 vacuous-pass report + K6 null-row cert gap Review committee (glm + codex + reviewer) on #166 converged on two harness self-cert weaknesses. Both empirically fixed and re-verified: - k7RollbackNonVacuousOk was `!hasClass('K7') || (...)` → evaluated to `true` when K7 is absent, so the `!== undefined` guards always fired and RESULT.md printed a vacuous "K7 rollback non-vacuous: true" for a class that never ran. Now `undefined` when K7 is absent → the guards skip it entirely, so an unrun class no longer self-reports a pass. Verified: a K6-only run emits 0 K7 lines and omits the k7 field from the summary JSON. - k6CutoverOrganicOk checked only the distinct non-null killBlocks set, so a kill row whose block was unreadable/null could slip past `length===1 && [0]===106` when another row recorded 106. Now also requires killBlocksRecordedCount === killCount (every kill recorded a block). Re-verified the cert still bites: reverting k6-cutover.json to the pre-PR fixed-block form flips accepted:false / k6CutoverOrganicOk:false, while the organic form stays accepted:true with recorded==kill count==2. node --test harness/chaos/realtime/mock-portal.test.mjs: 9/9. biome check .: exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c5bc20e commit a6a86e9

6 files changed

Lines changed: 328 additions & 51 deletions

File tree

VALIDATION.md

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,12 +1695,12 @@ End-to-end verification grounded the fix at scale. Across all 12 preserved Porta
16951695

16961696
**Upstream observation.** None. Unlike §5.9 (where ponder's RPC cascade attributes an error geth's own callTracer does not), the root-frame gas difference is purely a Parity-vs-geth representation convention with no attribution question — geth `callTracer` and the RPC store agree, and the fork matches them for parity.
16971697

1698-
### 5.11 Realtime chaos — crash-timing safety (RG3 Phase A, mock-driven)
1698+
### 5.11 Realtime chaos — crash-timing safety (RG3 Phase B, mock-driven)
16991699

17001700
The stream-realtime campaign (its plan lives alongside this document under `.agents/plans/`) gates a
17011701
`PORTAL_REALTIME=stream` production label on a six-gate dossier; **RG3** is its realtime chaos gate:
17021702
*≥200 kills across ≥6 timing classes, 100 % clean resumes, byte-identical digests*. This subsection
1703-
records the **RG3 Phase-A** result and — as with every claim in this document — states plainly what it
1703+
records the **RG3 Phase-A** result plus the **Phase-B** delta and — as with every claim in this document — states plainly what it
17041704
does and does **not** prove. It is deliberately scoped and self-contained: the full §5.8 rewrite that
17051705
folds RG3–RG5 into a single reorg/finality dossier is a later (RG6) task, not this one.
17061706

@@ -1721,7 +1721,9 @@ block · K3 during the redelivery await · K4 during a finalize-defer streak ·
17211721
cutover-handler entry (a 409 variant + a wrong-fork variant) · K6 at the historical→realtime cutover.
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
1724-
**MEDIUM–LOW for K6** (a scripted, not organic, cutover).
1724+
**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.
17251727

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

@@ -1737,18 +1739,36 @@ All three RG3 numeric thresholds are met: **238 ≥ 200** kills across **7 ≥ 6
17371739
**100 %** clean resumes, and every resumed cluster **byte-identical** to its unkilled baseline with
17381740
**zero** double-indexed finalized rows.
17391741

1740-
**The K6 cutover fix is proven non-vacuous.** An adversarial review of the harness caught that the
1742+
**Phase B delta (this worktree run).**
1743+
1744+
| Metric | Value |
1745+
|--------|-------|
1746+
| K6 organic-cutover kills | **10** (`CHAOS_CLASSES=K6 CHAOS_KILLS_PER_CLASS=10`) |
1747+
| Clean resumes | **10 / 10** |
1748+
| Store↔app digest match | **10 / 10** byte-identical |
1749+
| Duplicate FINALIZED rows | **0** |
1750+
| K6 organic cutover | **10 / 10** (every kill on block 106, the chunk boundary) |
1751+
| 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 |
1753+
1754+
The full K1–K6 moderate rerun was attempted with
1755+
`CHAOS_KILLS_PER_CLASS=10 CHAOS_MIN_PER_CLASS=10 CHAOS_TOTAL_KILLS=70`, but it stopped before Phase-B
1756+
classes because the current K2 baseline did not reach `K2-midstream`: the app raised the product
1757+
unknown-parent fatal after child discovery at block 101 left block 102 absent from the unfinalized
1758+
window and block 103 arrived. A timing-only K2 probe (`turnDelayMs` 450 → 900) did not change that
1759+
outcome, so Phase B does not claim a new full-suite acceptance number here.
1760+
1761+
**The K6 cutover fix is now proven organic within the mock.** An adversarial review of the harness caught that the
17411762
original K6 "cutover" kill landed on the **first `/finalized-head` startup probe**, before any live
17421763
`/stream` block was served — so it re-exercised startup, not a cutover, and was vacuous. After the fix
1743-
(the `killAt` gate moved into the served-block step), all **34** K6 kills recorded
1744-
`killStats.stream == 1` (min 1, only value 1): each kill lands after block ≥106 has streamed, i.e. on a
1745-
**real** backfill→live cutover, not a startup restart. **K2 non-vacuity** is likewise recorded: the
1746-
kill-at-block varied across **8 distinct interior blocks** {103,104,105,106,107,108,109,110},
1747-
recovered empirically from the kill phase log rather than trusted from the environment. Both properties
1748-
are now **self-certified in the harness**: the `accepted` conjunction includes `k6CutoverNonVacuousOk`
1749-
(every K6 kill after ≥1 live/stream block) and `k2KillSpreadOk` (≥3 distinct interior kill blocks),
1750-
each mutation-verified load-bearing — a regression that makes K6 vacuous again or pins K2 to a single
1751-
block flips `accepted:false` rather than silently passing on a byte-identical resume.
1764+
(the `cutoverGate` cursor step), all **10** Phase-B K6 kills recorded
1765+
`killStats.stream > 0` and the empirical kill block set was exactly `{106}`: the kill lands on the
1766+
client's natural `fromBlock=106` request with the canonical parent hash for block 105, not a fixed
1767+
served-block gate. The new `k6CutoverOrganicOk` self-cert is mutation-verified load-bearing:
1768+
temporarily reverting K6 to a fixed-block `blocks` step with `killAt.block=107` produced a clean
1769+
resume but flipped `k6CutoverOrganicOk:false` and `accepted:false`; restoring `cutoverGate` flipped it
1770+
back to `true`. The existing `k6CutoverNonVacuousOk` and Phase-A `k2KillSpreadOk` self-certs remain in
1771+
the harness.
17521772

17531773
**Mock-fidelity caveats (candor is the product).** These bound the claim precisely and are stated, not
17541774
hidden:
@@ -1758,15 +1778,25 @@ hidden:
17581778
**NOT** live-protocol reorg/finalize semantics. Those are **RG4/RG5's** job (live Portal + a
17591779
multichain stream soak), not RG3's.
17601780
- **K5 exercises mock-side *reachability* of the cutover-handler entries** — a 409 variant and a
1761-
wrong-fork/"losing fork" variant — **not** a real client fork-choice splice. The wrong-fork
1762-
finalized head the mock injects is not consumed by the client within the scenario window (the
1763-
finalize-poll cadence is longer than the post-injection window), so the wrong-fork counter is a
1764-
**handler-reachability** count, not clean-resume wrong-fork evidence. The real client-side
1765-
wrong-fork-finalize FATAL guard is proven deterministically by the `portal-realtime.test.ts` unit
1766-
test *"a finalize whose canonical hash mismatches the local block is FATAL"* (§5.8(B)), not by K5.
1767-
- **K6 is a MEDIUM–LOW-fidelity mock cutover.** It proves the *fix* — kills now land on a stream-phase
1768-
cutover (after a block has actually streamed) rather than vacuously on startup — but the cutover
1769-
semantics are **scripted, not organic**.
1781+
wrong-fork/"losing fork" variant — **not** a real client fork-choice splice. Phase B attempted to
1782+
close the wrong-fork-vacuity caveat, but the real chaos app has no harness-accessible
1783+
`finalizePollMs` knob (the production default remains the injected `finalizePollMs` field in
1784+
`portal-realtime.ts`). With default timing, the clean K5 probe recorded `wrongForkFinalizeConsumed=0`.
1785+
Lengthening the mock's post-injection window made the client consume the wrong finalized head, but
1786+
the product correctly raised the wrong-fork finalize fatal at block 108. Therefore K5 remains
1787+
**handler-reachability** evidence in RG3 Phase B; no `k5WrongForkConsumedOk` self-cert is claimed.
1788+
The real client-side wrong-fork-finalize FATAL guard is proven deterministically by the
1789+
`portal-realtime.test.ts` unit test *"a finalize whose canonical hash mismatches the local block is
1790+
FATAL"* (§5.8(B)), not by K5.
1791+
- **K6 is a MEDIUM-fidelity mock cutover** (raised from MEDIUM–LOW in Phase A). The kill target is no
1792+
longer a fixed block but the client's natural chunk-boundary request (`cutoverGate` fires on
1793+
`fromBlock=106` with the canonical parent hash). The cutover is **organic within the mock's
1794+
deterministic capability** — it is still not a live Portal cutover with competing finalized sources;
1795+
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.
17701800
- **The harness `invariant()` fast-path is a fatal-string tripwire, not the primary death detector.**
17711801
It grep-matches all seven real fatal throws in `portal/portal-realtime.ts` (the gap fatal, the
17721802
below-floor 409 fatal, the 409 no-progress cap, the deterministic-4xx fatal, the unknown-parent

harness/chaos/realtime/mock-portal.mjs

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,15 @@ export function advanceScenarioCursor(cursor, step) {
6464
if (step?.type === 'blocks' || step?.type === 'fork') {
6565
return cursor + Math.max(0, count);
6666
}
67+
if (step?.type === 'rollbackApply') {
68+
return cursor + Math.max(0, count || 1);
69+
}
6770
if (step?.type === 'childDiscovery') return Number(step.block ?? cursor + 1);
6871
if (step?.type === 'gapTrigger') return Number(step.block ?? cursor + 1);
6972
if (step?.type === 'wrongForkFinalize') return cursor;
73+
if (step?.type === 'cutoverGate') {
74+
return cursor;
75+
}
7076
if (step?.type === 'idle204' || step?.type === 'status409') return cursor;
7177
if (step?.type === 'awaitRedelivery') return cursor;
7278

@@ -411,6 +417,8 @@ function isCursorStep(step) {
411417
'status409',
412418
'idle204',
413419
'wrongForkFinalize',
420+
'cutoverGate',
421+
'rollbackApply',
414422
]).has(step.type) || step.match !== undefined
415423
);
416424
}
@@ -468,6 +476,8 @@ function createRuntime(initialScenario, options = {}) {
468476
let finalizedIndex = 0;
469477
let resumeMode = false;
470478
let seq = 0;
479+
let pendingWrongForkHead;
480+
let skipWrongForkRejection = false;
471481
let currentPhase = {
472482
name: 'idle',
473483
phase: 'idle',
@@ -489,6 +499,8 @@ function createRuntime(initialScenario, options = {}) {
489499
redeliveryReopens: 0,
490500
finalizedHeadGates: 0,
491501
wrongForkFinalizes: 0,
502+
wrongForkFinalizeConsumed: 0,
503+
wrongForkFinalizeRejected: 0,
492504
requestLog: [],
493505
};
494506
const phaseLog = options.phaseLog;
@@ -559,7 +571,11 @@ function createRuntime(initialScenario, options = {}) {
559571
stats.redeliveryReopens = 0;
560572
stats.finalizedHeadGates = 0;
561573
stats.wrongForkFinalizes = 0;
574+
stats.wrongForkFinalizeConsumed = 0;
575+
stats.wrongForkFinalizeRejected = 0;
562576
stats.requestLog = [];
577+
pendingWrongForkHead = undefined;
578+
skipWrongForkRejection = false;
563579
};
564580

565581
const load = (override) => {
@@ -588,6 +604,40 @@ function createRuntime(initialScenario, options = {}) {
588604
: (step.turnDelayMs ?? 10),
589605
);
590606

607+
const observeWrongForkConsumed = (head) => {
608+
if (pendingWrongForkHead === undefined) {
609+
return;
610+
}
611+
if (head === undefined) {
612+
return;
613+
}
614+
if (Number(head.number) !== pendingWrongForkHead.number) {
615+
return;
616+
}
617+
if (head.hash !== pendingWrongForkHead.hash) {
618+
return;
619+
}
620+
621+
stats.wrongForkFinalizeConsumed += 1;
622+
pendingWrongForkHead = undefined;
623+
skipWrongForkRejection = false;
624+
};
625+
626+
const observeWrongForkStreamRequest = (body) => {
627+
if (pendingWrongForkHead === undefined) {
628+
return;
629+
}
630+
if (skipWrongForkRejection) {
631+
skipWrongForkRejection = false;
632+
return;
633+
}
634+
635+
if (body?.parentBlockHash !== pendingWrongForkHead.hash) {
636+
stats.wrongForkFinalizeRejected += 1;
637+
pendingWrongForkHead = undefined;
638+
}
639+
};
640+
591641
const finalizedHead = async (res) => {
592642
recordRequest('finalizedHead');
593643
const head =
@@ -608,6 +658,7 @@ function createRuntime(initialScenario, options = {}) {
608658
);
609659
if (open === false) return;
610660
}
661+
observeWrongForkConsumed(head);
611662
if (finalizedIndex < scenario.finalizedHeadSeq.length - 1) {
612663
finalizedIndex += 1;
613664
}
@@ -627,6 +678,7 @@ function createRuntime(initialScenario, options = {}) {
627678
scenario.finalizedHeadSeq[0] ?? {
628679
number: scenario.genesis.number,
629680
};
681+
observeWrongForkConsumed(head);
630682
const to = Math.min(Number(body?.toBlock ?? head.number), head.number);
631683
if (from > to) {
632684
stats.r204 += 1;
@@ -689,6 +741,39 @@ function createRuntime(initialScenario, options = {}) {
689741
stepIndex += 1;
690742
};
691743

744+
const handleCutoverGate = async (step, body, res) => {
745+
const number = Number(step.fromBlock);
746+
const phase = phaseForStep(step);
747+
if (phase !== undefined) {
748+
const open = await gate(
749+
phase,
750+
{
751+
stepIndex,
752+
block: number,
753+
fromBlock: body.fromBlock,
754+
parentBlockHash: body.parentBlockHash,
755+
cutoverBlock: number,
756+
},
757+
res,
758+
);
759+
if (open === false) return;
760+
}
761+
762+
const branchId = step.branch ?? step.branchId ?? 'main';
763+
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
764+
writeNdjson(
765+
res,
766+
blockBatch(number, body, {
767+
header: headerFor(number, branchId),
768+
logs: logsForBlock(step, number),
769+
}),
770+
);
771+
streamCursor = number;
772+
stepIndex += 1;
773+
stats.r200 += 1;
774+
res.end();
775+
};
776+
692777
const handleIdle204 = async (step, body, res) => {
693778
if (phaseForStep(step) !== undefined) {
694779
const open = await gate(
@@ -807,6 +892,50 @@ function createRuntime(initialScenario, options = {}) {
807892
res.end();
808893
};
809894

895+
const handleRollbackApply = async (step, body, res) => {
896+
const reorgBlock = Number(step.reorgBlock ?? streamCursor - 1);
897+
const count = Math.max(0, Number(step.count ?? 1));
898+
const branchId = step.branch ?? step.branchId ?? 'rollback';
899+
const parentBranchId = step.parentBranch ?? 'main';
900+
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
901+
for (let i = 0; i < count; i++) {
902+
const number = reorgBlock + i;
903+
const phase = gatePhaseForBlock(step, number);
904+
if (phase !== undefined) {
905+
const open = await gate(
906+
phase,
907+
{
908+
stepIndex,
909+
block: number,
910+
fromBlock: body.fromBlock,
911+
parentBlockHash: body.parentBlockHash,
912+
reorgBlock,
913+
},
914+
res,
915+
);
916+
if (open === false) return;
917+
}
918+
919+
writeNdjson(
920+
res,
921+
blockBatch(number, body, {
922+
header: headerFor(
923+
number,
924+
branchId,
925+
number === reorgBlock ? parentBranchId : branchId,
926+
),
927+
logs: logsForBlock(step, number),
928+
}),
929+
);
930+
streamCursor = number;
931+
await streamTurnDelay(turnDelayForStep(step));
932+
if (res.destroyed || res.writableEnded) return;
933+
}
934+
stepIndex += 1;
935+
stats.r200 += 1;
936+
res.end();
937+
};
938+
810939
const handleGapTrigger = async (step, body, res) => {
811940
const number = Number(step.block ?? streamCursor + 1);
812941
const header = {
@@ -839,16 +968,21 @@ function createRuntime(initialScenario, options = {}) {
839968
);
840969
if (open === false) return;
841970
}
971+
const hash =
972+
step.hash ?? step.canonicalHash ?? hashBlock(number, 'wrong-fork');
842973
scenario.finalizedHeadSeq.splice(finalizedIndex, 0, {
843974
number,
844-
hash: step.hash ?? step.canonicalHash ?? hashBlock(number, 'wrong-fork'),
975+
hash,
845976
});
977+
pendingWrongForkHead = { number, hash };
978+
skipWrongForkRejection = true;
846979
stepIndex += 1;
847980
await stream(body, res);
848981
};
849982

850983
const stream = async (body, res) => {
851984
recordRequest('stream', body);
985+
observeWrongForkStreamRequest(body);
852986
const step = scenario.steps[stepIndex];
853987
if (step === undefined) {
854988
stats.r204 += 1;
@@ -871,6 +1005,9 @@ function createRuntime(initialScenario, options = {}) {
8711005
case 'status409':
8721006
await handleStatus409(step, body, res);
8731007
return;
1008+
case 'cutoverGate':
1009+
await handleCutoverGate(step, body, res);
1010+
return;
8741011
case 'idle204':
8751012
await handleIdle204(step, body, res);
8761013
return;
@@ -883,6 +1020,9 @@ function createRuntime(initialScenario, options = {}) {
8831020
case 'gapTrigger':
8841021
await handleGapTrigger(step, body, res);
8851022
return;
1023+
case 'rollbackApply':
1024+
await handleRollbackApply(step, body, res);
1025+
return;
8861026
case 'wrongForkFinalize':
8871027
await handleWrongForkFinalize(step, body, res);
8881028
return;

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ test('hashBlock: deterministic 32-byte hex and branch-sensitive', () => {
4040
test('advanceScenarioCursor: advances only block-producing steps', () => {
4141
assert.equal(advanceScenarioCursor(100, { type: 'blocks', count: 3 }), 103);
4242
assert.equal(advanceScenarioCursor(100, { type: 'fork', count: 2 }), 102);
43+
assert.equal(advanceScenarioCursor(100, { type: 'cutoverGate' }), 100);
44+
assert.equal(
45+
advanceScenarioCursor(100, { type: 'rollbackApply', count: 4 }),
46+
104,
47+
);
4348
assert.equal(advanceScenarioCursor(100, { type: 'idle204', count: 5 }), 100);
4449
assert.equal(
4550
advanceScenarioCursor(100, { type: 'childDiscovery', block: 160 }),
@@ -193,4 +198,32 @@ test('cursorMatchesStep: routes redelivery and 409 steps by request cursor', ()
193198
),
194199
true,
195200
);
201+
assert.equal(
202+
cursorMatchesStep(
203+
{
204+
type: 'cutoverGate',
205+
fromBlock: 106,
206+
parentBlockHash: hashBlock(105, 'main'),
207+
},
208+
{
209+
fromBlock: 106,
210+
parentBlockHash: hashBlock(105, 'main'),
211+
},
212+
),
213+
true,
214+
);
215+
assert.equal(
216+
cursorMatchesStep(
217+
{
218+
type: 'cutoverGate',
219+
fromBlock: 106,
220+
parentBlockHash: hashBlock(105, 'main'),
221+
},
222+
{
223+
fromBlock: 108,
224+
parentBlockHash: hashBlock(107, 'main'),
225+
},
226+
),
227+
false,
228+
);
196229
});

0 commit comments

Comments
 (0)