P3.5.1 — Equivocation Slasher — Verification
Step 5 of the 5-step chain. Captures gate evidence and the AC-coverage matrix.
§1. Gates
All three gates pass against the worktree at the Step 4 commit (after
the implementation commit feat(p3-5-1-equivocation)).
| Gate | Command | Result |
|---|---|---|
| Build | npm run build |
PASS — zero TS errors; postbuild migrations copy |
| Lint | npm run lint |
PASS — zero ESLint errors |
| Test | npm test |
PASS — 2848 / 2848 (final stable run) |
§2. Test baseline reconciliation
| Snapshot | Test count | Source |
|---|---|---|
| Prompt-cited baseline | 2744 | Pre-P3.1.2 (#237) |
Base SHA 498e6ea5 actual |
2820 | Confirmed by running on a tree with equivocation.{ts,test.ts} removed |
| After P3.5.1 lands | 2848 | This slice (+28) |
Delta: +28 new tests, all in
src/__tests__/domains/consensus/equivocation.test.ts. Net delta
against the prompt-cited 2744 baseline is +104, of which +76 belong to
already-merged R89 Wave 2 slices (#234, #235, #236, #237) and +28 to
this slice.
§3. Flake observation (carryover)
The first npm test run hit a SQLite test-setup race in
src/__tests__/domains/reputation/{tools,witnesses}.test.ts (“no such
table: reputations”). Re-running the suite was clean — the third
consecutive npm test produced 2848/2848 passing. This is a
pre-existing flake, NOT a regression from this slice:
- Confirmed by checking out
origin/main’ssrc/(with my changes removed) and re-running — same race surfaces under parallel test execution. - The slice does NOT touch
src/domains/reputation/{tools,witnesses}.tsor any DB / migration code. - The flake is in the test setup’s table-creation ordering under
--runInBand-less parallelism.
Recommended to PM: track the reputation-tools / witnesses migration race as an out-of-scope follow-up; out of P3.5.1 scope.
§4. Acceptance corpus coverage
All 28 AC IDs from docs/contracts/p3-5-1-equivocation-contract.md §8
map 1:1 to a passing it() / test() block.
| AC# | Describe / Test name | Result |
|---|---|---|
| AC#1 | verifyEquivocationProof / happy path returns {valid: true} | PASS |
| AC#2 | verifyEquivocationProof / tampered signed_vote_a → sig_a_invalid | PASS |
| AC#3 | verifyEquivocationProof / tampered signed_vote_b → sig_b_invalid | PASS |
| AC#4 | verifyEquivocationProof / wrong pubkey for sig_a (short-circuits) | PASS |
| AC#5 | verifyEquivocationProof / same tuple in both votes → same_tuple | PASS |
| AC#6 | verifyEquivocationProof / different round_id → different_round_or_level | PASS |
| AC#7 | verifyEquivocationProof / short-circuit order — sig_a wins over same_tuple | PASS |
| AC#8 | verifyEquivocationProof / malformed signature does not throw | PASS |
| AC#9 | evidenceHashHex / lowercase 64-char hex | PASS |
| AC#10 | evidenceHashHex / stable across calls | PASS |
| AC#11 | applyEquivocationSlash / happy path — score 10000 → 2000 | PASS |
| AC#12 | applyEquivocationSlash / Set guard — duplicate on second call | PASS |
| AC#13 | applyEquivocationSlash / invalid proof → invalid_proof | PASS |
| AC#14 | applyEquivocationSlash / λ double-jeopardy → lambda_double_jeopardy | PASS |
| AC#15 | applyEquivocationSlash / history_event.reason has band:critical| prefix | PASS |
| AC#16 | applyEquivocationSlash / event_id === evidence_hash_hex | PASS |
| AC#17 | applyEquivocationSlash / domain === arbitration | PASS |
| AC#18 | applyEquivocationSlash / ban_until_epoch = current_epoch + 100 | PASS |
| AC#19 | applyEquivocationSlash / row.node_id mismatch throws | PASS |
| AC#20 | applyEquivocationSlash / row.domain mismatch throws | PASS |
| AC#21 | single-arbiter (n=1) self-equivocation slashable | PASS |
| AC#22 | EQUIVOCATION_BAND === critical | PASS |
| AC#23 | EQUIVOCATION_DOMAIN === arbitration | PASS |
| AC#24 | EQUIVOCATION_SLASH_BPS === DAMAGE_CRITICAL === 8000n | PASS |
| AC#25 | integration with detectDoubleVote / pair → buildProof → verify → slash | PASS |
| AC#26 | integration / re-slash via Set → duplicate | PASS |
| AC#27 | source body — forbidden-token corpus scan | PASS |
| AC#28 | buildEquivocationProof re-exported and callable | PASS |
§5. Invariant evidence
| Invariant | Source-of-truth |
|---|---|
| I1 — Pure module | AC#27 forbidden-token scanner over JSDoc-stripped body |
| I2 — verify path never throws | AC#8 malformed-sig test wraps in expect(…).not.toThrow |
| I3 — No double-slash via Set | AC#12, AC#26 |
| I4 — λ delegation honors band/domain mapping | AC#15, AC#16, AC#17 |
| I5 — Constant 8000bps amount | AC#11 (score 10000 → 2000 = -8000), AC#24 |
| I6 — Single-arbiter clause | AC#21 |
| I7 — No λ surface edits | src/domains/reputation/penalties.ts untouched (git diff confirms) |
| I8 — Verify short-circuit order | AC#7 |
§6. Prompt forbiddens check
| Forbidden | Status |
|---|---|
--no-verify |
Not used; commits ran pre-commit hooks normally |
--amend |
Not used; 5 distinct commits |
--force-push |
Not used; branch publishes via standard push |
| Main edits | None — all work in .worktrees/claude/p3-5-1-equivocation |
| Math.* / Date.* / Math.random | AC#27 corpus scan asserts absence |
| Floats | All arithmetic is bigint; ReputationRow.score crosses to number at λ’s stable boundary |
| Modifying λ penalty surface | apply_penalty imported as-is; no override |
| Double-slash | Set guard at top of applyEquivocationSlash; AC#12 + AC#26 |
§7. Out of scope (deferred — for downstream slices)
- π suspension flow (Phase 6).
- View-change trigger on
equivocation_observed(P3.1.3). - Reputation history persistence (
insertHistoryEvent— caller-owned per λ P2.1.1 invariant). - Multi-arbiter slashing concurrency control (DB row locking).
- Network propagation of equivocation proofs (P3.3.x gossip layer).
§8. Step 5 conclusion
P3.5.1 ships clean. 28/28 acceptance criteria green. Build + lint + test gates all pass at the final commit. Pre-existing reputation-suite flake observed and confirmed unrelated to this slice. Ready for PR review.