Skip to content

sequencer, rawdb, ethapi: audit the store for the window a node did not watch - #2388

Merged
pratikspatil024 merged 10 commits into
cffls/sequence-publisherfrom
ppatil/preconf-store-audit
Sep 17, 2026
Merged

pratikspatil024 merged 10 commits into
cffls/sequence-publisherfrom
ppatil/preconf-store-audit

Conversation

@pratikspatil024

@pratikspatil024 pratikspatil024 commented Sep 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Picks up the component deferred from #2373 (review) — "back track the sequence store if the node is just started" — so that a node which was down for an hour can identify the invalid preconfirmations from while it was down.

bor_getInvalidPreconfBlocks reported that window as clean regardless of what the store held during it. Invalidation records come from one place, the in-memory PendingStore reconciled against canonical blocks, and a node that was down holds no entries for the heights it missed — so nothing was compared and nothing recorded. Silence was indistinguishable from "every preconfirmation in that window held". Nothing recorded where the consumer last watched, either: resumeRequest cold-starts at the local canonical head, and the store's caught-up-to-tip Live frame was received and discarded rather than ending a catch-up phase.

An audit pass (eth/sequencer/audit.go) walks from the watermark to the head and compares the store's final sealed header at each height against the canonical hash:

  • A mismatch is recorded as unobserved_mismatch — the store sealed something that never became canonical, and this node served nothing from it. The existing reasons (canonical_mismatch, reorged, skipped, session_lost) all mean a preconfirmation reached callers and was then invalidated, which is a stronger claim, so this gets its own reason rather than sharing theirs. The write is conditional on absence: a height the live path already judged keeps that record, since a backfill must not overwrite an observation.
  • A height the store never held, or left open, records nothing — nothing was promised there — but still clears the watermark, or the same range is re-walked forever.
  • The comparison needs no execution and no historic state, which is what makes it viable on a pruned node. It runs on its own connection off the session loop, so closing a gap never delays a reconnect and the node keeps serving preconfirmations at the tip while the walk runs.

Triggers: consumer start, every session loss, and reaching the store tip. Session loss earns a pass because a stream that drops while the node stays up leaves the same class of hole as a restart — which also makes this cover gaps from store outages, not just node restarts.

Position (core/rawdb): PreconfAuditedThrough carries the watermark across restarts, and is the only mark. It may only step one height at a time from the live path — a jump would carry it over the catch-up backlog the session dropped without comparing, so a gap requests a pass instead. A node with no watermark seeds at the current head and audits nothing; auditing backwards from an arbitrary point on first enable would produce records with no operational meaning.

bor_getInvalidPreconfBlocks carries the audit's coverage of the range alongside the heights, so no second call is needed to interpret an empty result:

// bor_getInvalidPreconfBlocks("0x5", "0x60")
{ "invalid": ["0x9", "0x6"], "pendingFrom": "0x4e" }

pendingFrom is max(from, auditedThrough + 1): heights from there to the range end are pending rather than clean, null once the whole range is audited, and equal to from on a node that has not audited at all. Every other height in range was compared and matched. The range is capped at 1024 heights and a wider one is an error, which is what makes truncation unnecessary — one record per height means bounding the request bounds the response, so a caller can no longer receive a silently short answer it cannot tell from a complete one. An unreadable watermark is an RPC error, never a null pendingFrom, because null is the claim the range was compared.

Bounded by the store, not by a block count. The walk runs from auditedThrough + 1 to the head, and the store's retention is the only ceiling. A Range request with after unset resolves to the oldest entry the store still serves; the first BlockOpen at or after it is the oldest height a pass can compare, and the walk starts there rather than paying one NOT_FOUND per aged-out height. The skipped range is logged and counted (sequencer/audit/retentionskipped), as is any NOT_FOUND mid-walk (sequencer/audit/unheld).

Retention can age out the middle of a block, so the oldest retained entry is not always an open. A seal reached before any open closes a block whose earlier entries are gone — a partial record list is not comparable against a canonical block — so the floor is the height above it; records carry no height at all and the scan pages on. Failing to resolve a floor is not a correctness boundary: the walk then discovers the same heights unheld one at a time, which is the pre-existing behaviour.

Revised after review

Jerry's review asked for three things; all three are in. The PR is smaller as a result — a knob, a database key, an RPC method and their tests are gone.

  1. Coverage folded into bor_getInvalidPreconfBlocks (above). bor_getPreconfAuditStatus, the PreconfAuditStatus type, the silent 1024-record truncation and rawdb.InvalidPreconfQueryLimit are removed.
  2. The depth bound is gone. sequencer.audit-window and its config plumbing, defaultAuditWindow, auditDepth, skippedTo, recordSkippedWindow, SetAuditWindow, the PreconfUnauditedThrough key with its accessors, leadingUnheld, and the docs entries all go with it. The retention floor resolved from Range replaces them.
  3. The gateway's served depth is a sequence-store matter and nothing here waits on it.

Two notes on the consequences, neither of which changes what was asked for:

  • A clean answer is no longer proof of comparison. With unauditedThrough gone, the watermark advances over the skipped range, so an empty invalid with pendingFrom: null covering it reads the same as a compared-and-clean range. The two counters above are the only record, so the alert belongs on them rather than on the RPC. Deliberate per the review; flagging it because it is the one guarantee this revision narrows. Widening the gateway's served depth shrinks the window where it can happen but does not close it.
  • rawdb.ReadInvalidPreconfs(db, limit) — the non-range accessor, called only by tests — now honours the caller's limit with no ceiling of its own, since the constant that clamped it is gone. Say the word if you would rather it kept one.

Validated on a kurtosis devnet

Run on kurtosis-pos @ vbhattaccmu/sequence-store-benchmark with the store
live (redpanda / ingress / gateway / auditor), two publishing validators, and
two RPC consumers on the same chain — one on this branch, one on
cffls/sequence-publisher as a control. Full write-up in the workspace report;
the parts that matter here:

A consumer restarted after downtime audits exactly the window it missed.
Stopped at watermark 272 under sustained load (60 tx per batch, continuous),
restarted after ~160 blocks:

from=278 through=383  walked=106 compared=106 mismatched=0 uncomparable=0
from=384 through=445  walked=62  compared=62
from=446 through=451  walked=6   compared=6

Three passes — startup, session-end, Live — with compared == walked
throughout, so the store held a generation at every audited height and all of
them were compared against canonical. The watermark was not re-seeded; the only
watermark seeded line is from first boot. In an earlier run the audit started,
the stream went live 9 ms later, and the audit finished after — it runs off the
session loop and does not delay the reconnect.

A store outage freezes the watermark rather than advancing it. With the
gateway stopped, the head advanced from 1295 to 1361 while the watermark held
at 1299, then the gap closed on reconnect: from=1300 through=1395 walked=96 compared=96. Without the watching gate the mark would have tracked
the head and declared 62 uncompared heights clean — this is the invariant the
change exists for.

Preconfirmations are unaffected: 50 concurrent eth_sendRawTransactionSync
→ 50/50 succeeded, 50/50 preconfirmed, 50 tx in one block, p50 117 ms, in line
with the figures Vikram posted on 2026-09-05.

Mismatch detection was not exercised. No store-vs-canonical divergence
arose: the store auditor logged zero supersede events for the whole run, so the
condition the verdict detects never occurred — the absence of the trigger, not
a failure of detection. It is unit-covered, including
TestAuditPassOverGRPCRecordsMismatches, which detects one over a real
ConsumerService server. Forcing it needs deliberate producer contention and
belongs in the e2e rather than a hand-driven devnet.

These runs predate the reshape above. The behaviour they pin — watermark
advance, the watching gate, restart coverage, preconf latency — is untouched
by it; the depth-bound and unauditedThrough observations from those runs are
not reproduced here because neither exists any more. The retention-floor path
that replaces them is unit-covered only, and the honest position is that it has
not yet run against a real store with an aged-out floor: a devnet's store never
reaches its retention limit inside a test run. What a devnet did show is the
condition the floor now resolves — heights 1..127 hold nothing because the
publisher is gated off before Rio at 128 — and on two independent chains the
old mark landed on exactly 127, so the floor has a real case to resolve the
moment it runs there.

Three defects the devnet found, now fixed

  1. The audit ran every two seconds. Pre-Rio deterministic() fails, so
    run retries every consumerRetryDelay, and requestAudit() sat at the top
    of that loop — a gRPC client built, the store walked, the client torn down,
    every 2s indefinitely for any node sitting pre-Rio or with a persistently
    failing stream. The trigger moved into runSession, which asks for a pass
    only after a session that actually ran. Measured: 66 pre-Rio retries now
    produce 0 passes.
    Covered by TestIneligibleSessionDoesNotQueueAnAudit,
    verified to fail against the old placement.
  2. walked hid compared. The first post-Rio pass read walked=129 compared=2 — publishing starts at Rio activation, so 127 heights were
    NotFound. The old single counter made that look like a clean audit of 129
    heights, and I misread it that way myself. Both are now reported.
  3. An invisible log at Debug while bor runs at INFO (moot now, see below).

Dropped from this PR: the backlog-open change

An earlier revision also dropped store opens more than 64 blocks below the
canonical head, to avoid the parent state unavailable lookup on a pruned
node. It is removed. It never fired — it needs p2p import to run 64+ blocks
ahead of the store stream, and across a 330-block downtime the stream kept
pace. The warning storm that motivated it did not reproduce on either build.

An earlier A/B suggested a 20x reduction in skipped opens, but that control was
built from feat/pbc-rpc-endpoints before #2373's later parent-resolution
work — the exact code being counted — and against the PR's real base the
difference disappears. That left a behaviour change in the preconf path, while
coverage is being measured, with nothing exercising it. consumer_session.go
is byte-identical to base as a result, so this PR no longer touches the hot
path at all.

Worth reproducing separately if anyone hits the storm again.

Two further defects found reviewing the feature end to end, now fixed

Both were found by re-reading the paths against the questions the tests did not
ask, after the devnet run.

The audit could overwrite a live invalidation record. invalidPreconfKey is
one key per height and the write was a bare Put, so a pass that judged a
height the live path had already recorded replaced it. The reasons correlate —
a height whose served preconfirmation missed canonical is exactly where the
store's final seal probably missed it too — so this was reachable whenever a
session dropped between the live record and the watermark advance past it. The
damage ran in the worst direction: unobserved_mismatch asserts nothing was
served from this height
, so the ledger would have told an operator there was
no user-visible impact at a height where a preconfirmation was served and
then invalidated. The audit now writes only where a height carries no record
(rawdb.WriteInvalidPreconfIfAbsent) and counts what it left alone.

A range the store held nothing for read as clean. NOT_FOUND cannot
distinguish the producer never published here from retention aged this
height out
, and the pass advanced the watermark across either. So a node down
longer than retention — or restored from an older snapshot — walked its range,
compared nothing, recorded nothing, and reported auditedThrough across the
whole of it: silence again indistinguishable from "everything held", which is
the exact bug this PR exists to remove, at the retention boundary instead of
the downtime one.

The first fix for this raised a second unauditedThrough mark. The review
replaced it with the retention floor read from Range, which is a better
answer to the same problem: the floor separates aged out from never
published
exactly, where a leading run of NOT_FOUND could only ever be
conservative and call both unknown. What it does not do is persist the fact —
hence the alerting note above.

One case of the same shape remains unrecorded. A height the store held
but that could not be decided — no canonical hash, or a seal that does not
decode or sits at the wrong height — advances the watermark without a record.
It is counted in the pass summary (uncomparable) and in
sequencer/audit/unknown, and logged. All three causes are a store or data
fault rather than a normal state, so it should not fire in practice.

Executed tests

  • All pre-existing tests green in eth/sequencer, core/rawdb, internal/ethapi, internal/cli/server, eth/ethconfig. -race clean on eth/sequencer and core/rawdb. go vet, gofmt and golangci-lint (v2.11.4, repo config) clean; gofumpt clean on every file this PR touches.
  • 52 new test functions plus 28 table cases. The audit's mismatch / clean / NotFound / unsealed / cancel / transport-failure / write-failure / no-rewind / checkpoint paths, an auditHeight verdict table and a recordVerdict table, the summary-counter assertions, the watermark accessor including a truncated stored value, the contiguity and watching gates, and the Live frame.
  • The retention floor has its own set: floorFromEntries over open-first / records-then-open / seal-first / records-only / empty / undecodable-seal pages, storeFloor's paging and its four fall-back cases (no reader, failing read, empty page, no boundary inside the page budget), skipToStoreFloor across a floor below / at / inside / above the range, and a pass asserting it starts at the floor and never probes a height below it. TestAuditPassStartsAtTheStoreFloorOverGRPC then pins the request the store really receives — after unset, which is what resolves to the earliest retained entry — because the unit tests inject the reader and so cannot catch a malformed RangeRequest.
  • The new RPC shape is covered by a pendingFrom table over six watermark positions (absent, below, at the start, inside, at the end, above), the wire-format assertion against the shape in the review, the range cap at exactly 1024 and 1025 heights, the widest expressible range (where a count-based cap would overflow to zero and wave it through), and an unreadable watermark returning an error with a nil result.
  • The pass is also exercised end to end over gRPC: TestAuditPassOverGRPCRecordsMismatches stands up a real ConsumerService server, points the consumer at it, and asserts the dial, the per-height GetBlock reads, the recorded mismatch and the watermark write. Unreachable-endpoint and nothing-to-audit cases are covered too, the latter asserting the pass opens no connection at all — a trigger fires on every session retry and must not each cost a dial.
  • Regression tests were confirmed to fail without their fix, so none passes vacuously: TestWatermarkAdvancesOnlyWhileWatchingTheTip, TestIneligibleSessionDoesNotQueueAnAudit, TestAuditDoesNotSeedTheWatermarkOnAReadFailure, TestAuditLeavesALiveRecordInPlace and the rawdb three-state tests each fail against the code they guard against.
  • Diffguard passes with CI's flags: tier-1 logic 86.7% (gate 80), tier-2 semantic 81.8% (gate 60), complexity, sizes, dependency structure and dead code clean. Neither the headline score nor the tiers are stable run to run: --mutation-sample-rate 20 samples a different fifth of the pool each time, and the three runs behind this revision sampled 19, 25 and 30 tier-1 mutants respectively, so their scores are not comparable with each other. The same-tree experiment on the previous revision is the one that measures the variance — three runs of identical code scored 78.7% / 68.1% / 78.7% overall with tier-1 at 88.9% / 93.8% / 95.2%. The gate is the tiers, and the survivor list is drawn fresh every run, so it has to be read per run rather than trusted as a fixed set.
  • All eight survivors in the final run are accounted for, and two rounds of them were resolved rather than accepted:
    • Four are log statements or metric increments (report, auditUnknownCount, the stopped-early warning, the unheld warning's guard) — tier-3, ungated.
    • Three are equivalent mutants: persist's current >= number relaxed to > still falls through to a write of the same value; fetchOldestVia's error return zeroed leaves storeFloor reading an empty page, which returns the same "no floor"; and WriteInvalidPreconfIfAbsent's error-path bool is ignored by its only caller, which tests err first.
    • One is a false positive: accessors_preconf.go:59 (if present { return false, nil } → true) is reported as surviving, but applying that mutation by hand fails TestWriteInvalidPreconfIfAbsent deterministically (second write replaced an existing record), and so does deleting the branch. Worth knowing the survivor list overstates in both directions — an earlier revision of this PR had the same thing happen at audit.go:305.
    • Two survivors from the previous run were removed by rewriting rather than by adding a test: floorFromEntries now type-switches on the entry kind the way entryHeight already does, so there is no nil-seal guard whose removal falls through to the same decode error, and storeFloor's empty-page exit is pinned by a call count — without it, removing the branch changed nothing observable but cost three wasted round trips.
  • docs/cli/{server.md,default_config.toml} regenerated with make docs; the flag's entries are gone.

The automated scenario now exists. The restart-and-audit path above was
driven by hand, and as a standing regression it belongs with Jerry's e2e work
rather than a parallel harness —
pos-workflows#47 and
#52 are both merged to
main, so the sequencer leg gates PRs there. #52 carries the consumer-side
half: preconf receipt shape, canonicalisation, the invalid-preconf range
contract, and chaos episodes against the store.

That leg already knows about this PR's response shape. Its range-contract
test detects which shape it got and checks each against its own contract, so
the {invalid, pendingFrom} branch — including the 1024-height cap and the
rule that a missing pendingFrom fails where a null one passes — starts
asserting by itself once this merges and the workflow's bor ref carries it. No
change needed there.

The mismatch case is still not covered anywhere: forcing a store-vs-canonical
divergence needs deliberate producer contention, which is why neither the
devnet runs above nor the e2e leg has triggered one.

⚠️ Incidental, unrelated to this change: make docs also added rpc.txsync.maxconcurrent to docs/cli/server.md. #2385 regenerated those docs, but they were dropped when #2373 merged into cffls/sequence-publisher, so the flag exists in code with no doc entry. Two lines of generated output; the docs should match the flag set, so I've left the repair in.

Rollout notes

Not consensus-affecting, no coordinated upgrade. Confined to the sequence-store consumer, which is off by default and requires explicit configuration.

No new operator knobs. One new rawdb key, PreconfAuditedThrough, a single uint64 — no migration, no resync. Four new metrics under sequencer/audit/.

bor_getInvalidPreconfBlocks changes shape, so any existing caller needs updating: the result is an object rather than an array, the per-record reason is no longer on the wire (it stays in the database for logs), and a range wider than 1024 heights is now an error instead of a truncated list. The method has not shipped outside this branch as far as I can tell — worth confirming against anything you have pointed at it.

Retention. The audit can only see what the store retained; below the floor GetBlock answers NOT_FOUND. The floor is now resolved up front and the skipped heights are counted, so exceeding retention is visible in metrics — but it is not visible in the RPC, which reports those heights as audited. Alert on sequencer/audit/retentionskipped and sequencer/audit/unheld. With the planned 7-day retention any realistic downtime sits inside the window anyway, and the gateway's served depth is the nearer limit until the sequence-store follow-up lands.

A read error stalls rather than skips. Any store read that is not NOT_FOUND ends the pass, which persists progress and resumes from the same height on the next trigger. A deterministic per-height error therefore freezes auditedThrough at that height and re-warns on every session event. That is the safe direction — the watermark never advances over heights nobody compared, so nothing reads as clean — and it is visible rather than silent, so I have left it alone instead of adding a skip policy that could hide heights. The realistic trigger, a generation over the call's receive limit, needs 33 MB at one height against a ~2 MB gas-bound ceiling, so it is remote.

No repair path. The watermark is monotonic, so heights the audit passed over are not revisited: there is no operator hook to re-audit a range, even where the store still holds the data. That is a deliberate consequence of never rewinding the mark. Worth an admin method later if operators actually hit it — I have not added API surface on speculation.

Disk growth. #2373 established that InvalidPreconf-* is retained without pruning by design, with the gradual growth called out in its rollout notes. This PR does not change that, but it does add a second writer to that keyspace, so the growth rate is no longer bounded by "preconfirmations this node actually served" — a node that restarts repeatedly, or one auditing a range where the store superseded heavily, writes more records than before. Worth a look at whether the existing decision still holds at that rate; I have not assumed it needs changing.

Open questions

The review settled most of the original list: the response shape (2), the depth bound (3), a seal-only read (5, deferred behind the gateway's served depth), per-height "not compared" records (6, answered as log plus metric), and the retention floor (7, answered as Range with after unset). Nothing was raised against the record semantics (1) or seeding at the head on first enable (4), so both stand as built.

Still open:

  1. Reorg policy. A verdict is only as good as the canonical chain when it was reached, and the watermark never rewinds, so a reorg below it replaces heights nothing revisits. Re-auditing would mean rewinding on every reorg and re-walking. I have left it documented rather than changed, on the grounds that Bor reorgs are shallow and milestones make deep ones rare — tell me if that is the wrong call for your use of the ledger.
  2. Where the alert lives. Per the note above, the uncompared-range signal is now metric-only. If you would rather it stayed queryable, the cheapest version is a single auditedFrom alongside pendingFrom — the oldest height the watermark can vouch for — which is one more uint64 and no new method.

One semantic worth stating explicitly: GetBlock resolves to the latest generation at a height, so a preconfirmation that came from an earlier, superseded generation is invisible to this pass. For downtime auditing that's the right question — nothing was served, so what matters is whether the store's final answer matched the chain — but the ledger shouldn't be read as stronger than that.

…ot watch

bor_getInvalidPreconfBlocks reported a node's downtime as clean no matter what
the store held during it. Invalidation records come from one place - the
in-memory PendingStore reconciled against canonical blocks - and a node that
was down holds no entries for the heights it missed, so nothing is compared
and nothing is recorded. Silence was indistinguishable from every
preconfirmation in the window having held.

Nothing recorded where the consumer last watched either. On a cold start
resumeRequest anchors at the local canonical head, so the window between the
last watched height and the current head was never requested, and the store's
caught-up-to-tip frame was received and discarded rather than ending a
catch-up phase.

An audit pass now walks the unaudited window and compares the store's final
sealed header at each height against the canonical hash, recording a mismatch
as unobserved_mismatch: the store sealed something that never became
canonical, and this node served nothing from it. The other reasons all mean a
preconfirmation reached callers and was then invalidated, which is a stronger
claim, so it gets its own reason rather than sharing theirs. The comparison
needs no execution and no historic state, which is what makes it viable on a
pruned node, and it runs on its own connection off the session loop so closing
a gap never delays a reconnect.

It is triggered on consumer start, on every session loss, and when a stream
reaches the tip. Session loss gets a pass because a stream that drops while
the node stays up leaves the same kind of hole as a restart.

PreconfAuditedThrough carries the position across restarts, and it may only
step one height at a time from the live path: a jump would carry it over the
catch-up backlog the session dropped without comparing, so a gap asks for a
pass instead. PreconfUnauditedThrough records a window the depth bound
skipped, so an empty invalidation range there reads as unknown rather than
clean. A node with no watermark seeds at the current head and audits nothing -
auditing backwards from an arbitrary point on first enable would produce
records with no operational meaning.

GetBlock returns a height's whole generation, transaction records included, so
a wide walk pays for payloads the seal comparison never reads. That is why
sequencer.audit-window bounds one pass, defaulting to roughly an hour of
blocks.

The canonical-head reconciliation and the backlog helpers move into their own
files to keep consumer.go and consumer_session.go inside the repository size
checks, matching how exec.go was kept separate on this branch.

Separately, an open more than backlogOpenDepth below the canonical head is now
dropped without the parent-state lookup that fails on a pruned node, and
without skip's reset of the speculative tip - nothing was ever published for a
height the chain already holds, so there is nothing to invalidate. A producer
rebuilding the tip after a rotation lands within a block or two of the head
and still executes; its generation can yet win the height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.56322% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.11%. Comparing base (7e6f183) to head (928e994).

Files with missing lines Patch % Lines
eth/sequencer/audit.go 94.92% 10 Missing and 4 partials ⚠️
eth/sequencer/consumer.go 90.69% 6 Missing and 2 partials ⚠️
core/rawdb/accessors_preconf.go 82.85% 4 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                     Coverage Diff                      @@
##           cffls/sequence-publisher    #2388      +/-   ##
============================================================
+ Coverage                     57.03%   57.11%   +0.07%     
============================================================
  Files                           952      953       +1     
  Lines                        174451   174862     +411     
============================================================
+ Hits                          99497    99871     +374     
- Misses                        69283    69315      +32     
- Partials                       5671     5676       +5     
Files with missing lines Coverage Δ
core/rawdb/schema.go 37.28% <ø> (ø)
eth/backend.go 54.92% <100.00%> (+0.31%) ⬆️
eth/sequencer/consumer_prepare.go 79.03% <100.00%> (ø)
internal/ethapi/bor_api.go 65.47% <100.00%> (+0.97%) ⬆️
core/rawdb/accessors_preconf.go 85.39% <82.85%> (-2.75%) ⬇️
eth/sequencer/consumer.go 86.47% <90.69%> (+2.81%) ⬆️
eth/sequencer/audit.go 94.92% <94.92%> (ø)

... and 31 files with indirect coverage changes

Files with missing lines Coverage Δ
core/rawdb/schema.go 37.28% <ø> (ø)
eth/backend.go 54.92% <100.00%> (+0.31%) ⬆️
eth/sequencer/consumer_prepare.go 79.03% <100.00%> (ø)
internal/ethapi/bor_api.go 65.47% <100.00%> (+0.97%) ⬆️
core/rawdb/accessors_preconf.go 85.39% <82.85%> (-2.75%) ⬇️
eth/sequencer/consumer.go 86.47% <90.69%> (+2.81%) ⬆️
eth/sequencer/audit.go 94.92% <94.92%> (ø)

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…d code

The audit pass was only reachable through its injected fetch hook, so
runAuditPass itself - the dial, the per-height GetBlock, the guarded watermark
write - was almost entirely untested. It now runs against a real
ConsumerService server on a local listener, with the unreachable-endpoint and
nothing-to-audit cases alongside it; the latter asserts no connection is
opened at all, since a trigger fires on every session retry and must not each
cost a dial.

Filling the remaining branches turned up what the mutation run had been
pointing at: nothing asserted the pass's own counters, the window depth
boundary, the checkpoint write, recordVerdict's four verdicts, or what happens
when the database refuses a write. The canonical-head handler and the live
marker were covered only through their helpers, so removing either call site
went unnoticed.

behindHead is split out of behindCanonicalHead so the depth boundary is
testable without a chain deep enough to reach it.

Reverts the canonical_head.go extraction from the previous commit. Moving
evictLoop and the canonical-head reconciliation into a new file made every one
of those pre-existing lines count as added, which pulled code this change does
not touch into both the patch-coverage and the mutation denominators. The
backlog helpers stay in backlog.go - that file holds only new code. consumer.go
returns to 544 lines, inside the 800-line check CI applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pratikspatil024
pratikspatil024 marked this pull request as ready for review September 7, 2026 06:43

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@pratikspatil024
pratikspatil024 requested a lite review from Copilot September 7, 2026 06:46
@pratikspatil024

Copy link
Copy Markdown
Member Author

codegenie review

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

🧞 Codegenie Review

Two verified findings, both low severity, in the new startup audit-pass code. One is a real correctness gap in the rawdb watermark accessor: readPreconfHeight collapses DB read errors and malformed values into the same (0, false) "never audited" sentinel, which makes windowToAudit seed the watermark at the current canonical head and report an uncompared window as clean — the exact false-clean answer this PR exists to remove. The other is a test-quality gap: TestAuditPassSkipsTheDialWithNothingToAudit asserts only that the watermark is untouched, which stays true whether or not the pre-dial early return exists, so the no-dial-per-session-retry contract it is named for is not actually covered.

Coverage: 28 of 33 hunks reviewed (14 deep, 9 normal, 5 light); 5 hunks skipped by configured rules (docs/cli/default_config.toml, docs/cli/server.md). No failed hunks, no degraded planning, verification complete.

Open questions not resolved during review (worth confirming before merge): whether a canonical head at watermark+1 can advance the mark past a height whose store entries have not yet streamed in; whether SetAuditWindow(0) from eth/backend.go keeps the package default depth or zeroes the startup audit; whether auditor.run persists partial progress at the last verified height on fetch error/cancellation given auditCheckpointInterval is 256; whether SetAuditWindow is always called before Start so the unsynchronized c.auditWindow write cannot race auditLoop; and whether a backlog open dropped while s.env != nil leaves an unresolved PendingStore entry since dropBacklogOpen never calls invalidatePendingFrom.

Coverage

Reviewed 28/33 hunks.
Incomplete work: skipped 5.
Coverage levels: deep 14, normal 9, light 5, skip 5.

  • docs/cli/default_config.toml: configured skip rule
  • docs/cli/server.md: configured skip rule

⚠️ Findings

⚪ Low: "Skips the dial" test cannot fail if the pre-dial window check is removed

File: eth/sequencer/audit_pass_grpc_test.go:124 ↗
Confidence: high

TestAuditPassSkipsTheDialWithNothingToAudit cannot fail on the behavior it is named for. Its only assertion is that the watermark is untouched:

// Nothing to audit means no connection is opened at all: a trigger fires on
// every session retry, and those must not each cost a dial.
func TestAuditPassSkipsTheDialWithNothingToAudit(t *testing.T) {
	...
	consumer.endpoint = "127.0.0.1:1"
	head := h.chain.CurrentBlock().Number.Uint64()
	if err := rawdb.WritePreconfAuditedThrough(h.chain.DB(), head); err != nil { ... }
	consumer.runAuditPass(t.Context())
	if got, _ := rawdb.ReadPreconfAuditedThrough(h.chain.DB()); got != head {
 t.Fatalf("watermark = %d, want it untouched at %d", got, head)
	}
}

The windowToAudit guard exists in two places. runAuditPass checks it before dialing:

func (c *Consumer) runAuditPass(ctx context.Context) {
	audit := &auditor{...}
	// Resolve the window before dialing: the common case is nothing to audit,
	// and a trigger fired on every session retry must not cost a connection.
	from, through, _, ok := audit.windowToAudit()
	if !ok { return }
	conn, err := grpc.NewClient(...)

and auditor.run repeats it:

func (a *auditor) run(ctx context.Context) (auditSummary, error) {
	from, through, skippedTo, ok := a.windowToAudit()
	if !ok {
 return auditSummary{}, nil
	}

Impact: with the watermark seeded at head, deleting the pre-dial early return — the exact optimization the comment describes — would make the pass dial 127.0.0.1:1, perform zero fetches, never call persist, and still leave the watermark at head. The test passes either way, so a per-session-retry dial regression would go undetected by the test named for it while giving reviewers false confidence that the no-dial contract is covered.

Suggested fix: observe the dial rather than a proxy. Start a listener (or reuse startAuditStore with a wrapping net.Listener) whose Accept increments a counter, point consumer.endpoint at it, and assert the counter stays 0 after runAuditPass; keep the watermark assertion as a secondary check.

Suggested test: wrap the test listener so each Accept sends on a buffered channel; assert len(accepts) == 0 after runAuditPass with watermark == head, and assert it is > 0 in the mismatch test to prove the counter observes real dials.

⚪ Low: readPreconfHeight maps DB read errors and malformed values onto the "never audited" sentinel, seeding the watermark at head

File: core/rawdb/accessors_preconf.go:137 ↗
Confidence: medium

readPreconfHeight returns the same (0, false) result for a genuinely missing key, a non-not-found Get error, and a stored value whose length is not 8:

func readPreconfHeight(db ethdb.KeyValueReader, key []byte) (uint64, bool) {
	value, err := db.Get(key)
	if err != nil || len(value) != 8 {
 return 0, false
	}
	return binary.BigEndian.Uint64(value), true
}

The caller treats that sentinel strictly as "this node has never audited" and seeds the watermark at the current canonical head (eth/sequencer/audit.go:81-108):

watermark, stored := rawdb.ReadPreconfAuditedThrough(a.db)
if !stored {
	a.persist(through)
	log.Info("Sequence store audit watermark seeded", "height", through)
	return 0, 0, 0, false
}

Because this returns ok=false, run() exits before recordSkippedWindow and no unauditedThrough mark is written. Both write paths are monotonic and never lower the mark:

func (a *auditor) persist(number uint64) {
	if a.advance != nil { a.advance(number); return }
	if current, ok := rawdb.ReadPreconfAuditedThrough(a.db); ok && current >= number { return }
	if err := rawdb.WritePreconfAuditedThrough(a.db, number); err != nil { log.Warn(...) }
}

Impact: on a transient read failure or a corrupted value, the heights between the real watermark and head are marked audited without ever being compared, and the mark cannot be lowered afterwards. bor_getPreconfAuditStatus then reports auditedThrough=head, unauditedThrough=null for a window that was never audited (internal/ethapi/bor_api.go:174-184) — the false-clean answer this PR exists to prevent. Reachability is narrow: writePreconfHeight always writes 8 bytes, and a backend failing Get will usually also fail the subsequent Put, leaving the watermark unchanged with only a logged warning, so the sticky outcome requires a read-only failure.

The doc comment on ReadPreconfAuditedThrough states that "a node that has never audited has no watermark, which is not the same as having audited through block zero"; no intent signal says read errors or malformed values should map onto that same absence state. Please confirm the intended contract for the error case.

Suggested fix: give readPreconfHeight a third state — return (0, false, nil) only for ethdb not-found, and propagate other Get errors and unexpected value lengths as an error. windowToAudit should then abort the pass (or record unauditedThrough=head) rather than calling persist(through).

Suggested test: a rawdb test with a KeyValueReader stub whose Get returns a non-not-found error, plus one storing a 4-byte value, asserting both are distinguished from a missing key; and a sequencer test asserting windowToAudit does not seed the watermark at head when ReadPreconfAuditedThrough fails. Current tests in eth/sequencer/audit_pass_test.go only exercise valid 8-byte watermarks or an empty DB.

🙋 Needs Human Attention

  • behindHead computes number+backlogOpenDepth, which wraps for absurd numbers near MaxUint64 from a faulty/malicious store entry, making the open silently dropped instead of skipped/invalidated. Are open block numbers validated upstream against the local head before applyOpen?

    • Files: eth/sequencer/backlog.go, eth/sequencer/consumer_session.go
    • Symbols: applyOpen, behindHead, validateOpenExecutionContext
    • Reason: Packet reviewer could not resolve this question from the reviewed context.
  • Is any iteration over a key prefix in the DB affected by the new plain keys "PreconfAuditedThrough"/"PreconfUnauditedThrough" (e.g., a prefix scan that would now pick them up)?

    • Files: core/rawdb/accessors_preconf.go, core/rawdb/schema.go
    • Symbols: ReadInvalidPreconfs, ReadInvalidPreconfsInRange, invalidPreconfPrefix
    • Reason: Packet reviewer could not resolve this question from the reviewed context.

Stats

  • 🤖 Model: anthropic claude-opus-5 high
  • 🧞 Codegenie: v0.5.6 (a662388fde)
  • Elapsed time: 5m 33s
  • Git: 0xPolygon/bor from cffls/sequence-publisher to ppatil/preconf-store-audit (cc65e04371)
  • Posting: 2 inline
  • Review completeness: complete.
  • Usage: model calls 99, tokens 2411597, cost $8.9487.
  • Effective caps: tokens 8000000.
  • Local context pressure: 4 tool-budget rejections, 23 degraded tool results, 5 degraded hunks.

— View Workflow Job

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a few concrete correctness/API semantics issues to address (including a uint64 overflow risk in the new backlog predicate and JSON presence semantics for the new audit-status RPC).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adds an “audit pass” to the Sequence Store consumer so a node can retrospectively detect and record invalid preconfirmations for chain heights it did not observe while offline (or while the stream was replaying history), and exposes audit watermarks via a new Bor RPC method.

Changes:

  • Introduces a bounded, non-executing audit walker (eth/sequencer/audit.go) that compares the store’s final sealed header per height against canonical hashes and records unobserved_mismatch invalidations plus persisted audit/unaudited watermarks.
  • Extends the sequencer consumer to (a) track when it is truly “watching the tip” and (b) drop deeply-backlogged opens without hitting pruned-state paths.
  • Adds rawdb accessors + schema keys for PreconfAuditedThrough / PreconfUnauditedThrough, wires a new CLI flag (sequencer.audit-window), and adds bor_getPreconfAuditStatus.
File summaries
File Description
internal/ethapi/bor_api.go Adds PreconfAuditStatus and GetPreconfAuditStatus RPC surface.
internal/ethapi/bor_api_test.go Tests new audit-status RPC serialization and watermark reads.
internal/cli/server/sequencer_flags_test.go Tests config→ethconfig wiring for sequencer.audit-window.
internal/cli/server/flags.go Adds sequencer.audit-window CLI flag.
internal/cli/server/config.go Adds SequencerConfig.AuditWindow and gating helper sequencerAuditWindow().
eth/sequencer/consumer.go Adds “watching tip” gating + audit triggers/loop and watermark advancement logic.
eth/sequencer/consumer_session.go Drops backlog opens when sufficiently behind canonical head.
eth/sequencer/consumer_prepare.go Propagates “live” marker frame so the consumer can start “watching”.
eth/sequencer/backlog.go New depth predicate + backlog-open drop behavior.
eth/sequencer/audit.go New audit implementation: windowing, gRPC reads, verdicts, watermark persistence.
eth/sequencer/audit_watermark_test.go Unit tests for watermark stepping rules, “live” frame, backlog predicate, audit loop.
eth/sequencer/audit_pass_test.go Unit tests for audit window selection, mismatch/unknown/no-seal handling, checkpointing, write failures.
eth/sequencer/audit_pass_grpc_test.go End-to-end gRPC audit pass tests (dial-skipping, unreachable store, mismatch recording).
eth/ethconfig/config.go Adds SequencerAuditWindow to runtime config.
eth/backend.go Wires SequencerAuditWindow into consumer via SetAuditWindow.
docs/cli/server.md Regenerated CLI docs (includes sequencer.audit-window and rpc.txsync.maxconcurrent).
docs/cli/default_config.toml Regenerated default config (adds sequencer.audit-window and txsync.maxconcurrent).
core/rawdb/schema.go Adds rawdb keys for audit/unaudited watermarks.
core/rawdb/accessors_preconf.go Adds read/write helpers for PreconfAuditedThrough and PreconfUnauditedThrough.
core/rawdb/accessors_preconf_test.go Tests watermark semantics and truncated-value handling.
Review details
  • Files reviewed: 20/20 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread eth/sequencer/backlog.go Outdated
Comment thread internal/ethapi/bor_api.go Outdated
Comment thread internal/cli/server/flags.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧞 Codegenie Review

Two verified findings, both low severity, in the new startup audit-pass code. One is a real correctness gap in the rawdb watermark accessor: readPreconfHeight collapses DB read errors and malformed values into the same (0, false) "never audited" sentinel, which makes windowToAudit seed the watermark at the current canonical head and report an uncompared window as clean — the exact false-clean answer this PR exists to remove. The other is a test-quality gap: TestAuditPassSkipsTheDialWithNothingToAudit asserts only that the watermark is untouched, which stays true whether or not the pre-dial early return exists, so the no-dial-per-session-retry contract it is named for is not actually covered.

Coverage: 28 of 33 hunks reviewed (14 deep, 9 normal, 5 light); 5 hunks skipped by configured rules (docs/cli/default_config.toml, docs/cli/server.md). No failed hunks, no degraded planning, verification complete.

Open questions not resolved during review (worth confirming before merge): whether a canonical head at watermark+1 can advance the mark past a height whose store entries have not yet streamed in; whether SetAuditWindow(0) from eth/backend.go keeps the package default depth or zeroes the startup audit; whether auditor.run persists partial progress at the last verified height on fetch error/cancellation given auditCheckpointInterval is 256; whether SetAuditWindow is always called before Start so the unsynchronized c.auditWindow write cannot race auditLoop; and whether a backlog open dropped while s.env != nil leaves an unresolved PendingStore entry since dropBacklogOpen never calls invalidatePendingFrom.

Reviewed 28/33 hunks.
Incomplete work: skipped 5.

Coverage disclosure:

  • docs/cli/default_config.toml: configured skip rule
  • docs/cli/server.md: configured skip rule

🙋 Needs human attention:

  • behindHead computes number+backlogOpenDepth, which wraps for absurd numbers near MaxUint64 from a faulty/malicious store entry, making the open silently dropped instead of skipped/invalidated. Are open block numbers validated upstream against the local head before applyOpen?
  • Is any iteration over a key prefix in the DB affected by the new plain keys "PreconfAuditedThrough"/"PreconfUnauditedThrough" (e.g., a prefix scan that would now pick them up)?

— codegenie v0.5.6 (a662388fde) · View Workflow Job

Comment thread eth/sequencer/audit_pass_grpc_test.go Outdated
Comment thread core/rawdb/accessors_preconf.go Outdated
…sent one

readPreconfHeight returned the same (0, false) for a missing key, a failed
read, and a value that was not eight bytes. windowToAudit treats that sentinel
as "this node has never audited" and seeds the watermark at the current head,
so a read-only failure marked every height in the unaudited window as compared
- and because both write paths are monotonic, the mark could never be walked
back. bor_getPreconfAuditStatus then reported auditedThrough=head with no gap:
the false-clean answer this feature exists to prevent.

The read now probes with Has, then Get, then the length, and returns an error
for anything that is not a clean present-or-absent answer. Absence still seeds
the watermark; a failure aborts the pass, and persist and advanceAudited hold
rather than write over a value they could not compare against. The RPC returns
the error instead of an absent mark. WritePreconfUnauditedThrough treats an
unreadable current mark as absent and writes anyway, since recording a known
gap beats leaving the window unrecorded.

behindHead compares by subtraction. The height arrives from the store, so
number+backlogOpenDepth could wrap for a value near the top of the range and
read as backlog.

TestAuditPassSkipsTheDialWithNothingToAudit could not fail on the behaviour it
was named for: with the watermark at head, deleting the pre-dial short-circuit
leaves run to recheck the window and return, so the watermark stays put either
way. Counting accepted connections does not fix it either - grpc.NewClient is
lazy, so no connection is attempted when run returns first. The short-circuit
saves a client and a per-retry warning on a bad endpoint, not a connection; the
comment now says so and says there is no test for it, and the test asserts what
is true: no reads and no connections when there is nothing to audit.

The same trap caught the first version of the read-failure test, which asserted
that no window resolved - true whether the read failed or reported absence. It
now asserts that nothing was written, and was confirmed to fail against the old
code by finding the watermark seeded at the head.

The audit-window flag's help text says that zero uses the built-in window;
docs regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pratikspatil024 and others added 3 commits September 7, 2026 21:05
…backlog change

Three findings from a kurtosis devnet with the store live, none of which the
unit tests could see.

The audit ran every two seconds. Pre-Rio, deterministic() fails, so run()
retries every consumerRetryDelay, and requestAudit() sat at the top of that
loop - so every retry built a gRPC client, walked the store, and tore the
client down, indefinitely, for any node sitting pre-Rio or with a persistently
failing stream. The trigger moves into runSession, which asks for a pass only
after a session that actually ran. Measured on the devnet: 66 pre-Rio session
retries now produce 0 audit passes, against roughly one per retry before.

The summary could not distinguish a height the store held nothing for from one
it compared. "walked=129 mismatched=0" read as a clean audit of 129 heights
when the first post-Rio pass had actually compared 2 - publishing starts at Rio
activation, so the rest were NotFound. walked and compared are now separate,
and the log carries both.

The backlog-open drop is removed. It never fired: it needs p2p import to run
64+ blocks ahead of the store stream, and across a 330-block downtime the
stream kept pace, so behindCanonicalHead never returned true. The pruned-state
warning storm that motivated it did not reproduce on either build. An earlier
comparison suggested a 20x reduction in skipped opens, but that control was
built from feat/pbc-rpc-endpoints before #2373's later parent-resolution work -
the code being counted - and against the PR's real base the difference
disappears. That leaves a behaviour change in the preconf path, while
preconfirmation coverage is being measured, with nothing exercising it.
consumer_session.go is back to base as a result.

Devnet evidence for what remains: a consumer stopped at watermark 272 under
sustained load and restarted walked heights 278-451 in three passes with
compared == walked throughout and no mismatches; with the gateway stopped, the
head advanced to 1361 while the watermark held at 1299, and the gap closed on
reconnect. Preconfirmations are unaffected - 50/50 preconfirmed, p50 117ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing backlog.go took its exhaustive depth-predicate table with it, which
had been killing a large share of the tier-1 mutants, so the quality gate fell
to 77.3% on a pool that no longer contained them. Two of the survivors were
real gaps rather than an artifact of the smaller pool.

Nothing asserted that a returned session stops the consumer watching the tip.
If it did not, the next canonical head would advance the audit watermark across
a window nobody compared - the invariant a gateway outage exercised on the
devnet, where the head ran from 1295 to 1361 while the mark held at 1299. The
reset moves from run() into runSession() as a defer, which is where it belongs
and makes it reachable from a test.

Nothing asserted that Start wires the audit loop, so a restart would never
close its window. The seeding pass reaches no further than the local chain, so
the test drives Start against an unreachable store.

Both tests were checked against the code they guard. The remaining survivors
are log statements, the receive-buffer arithmetic in the client options, and
one equivalent mutant: relaxing `number <= watermark` to `<` in
markCanonicalHeadAudited falls through to advanceAudited, which is monotonic
and writes nothing at that height, so no test can separate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dict

Three findings from re-reading the audit against questions its tests were not
asking. The devnet campaign had already passed and the quality gate was green,
so none of these were visible from either side.

The audit could overwrite an invalidation the live path had already recorded.
invalidPreconfKey is one key per height and the write was a bare Put, so a pass
judging a height the live path had judged replaced it. The reasons correlate: a
height whose served preconfirmation missed canonical is where the store's final
seal probably missed it too, so this was reachable whenever a session dropped
between the live record and the watermark advancing past that height. It ran in
the worst direction - unobserved_mismatch asserts nothing was served from the
height, so the ledger would have reported no user-visible impact where a
preconfirmation had in fact been served to callers and then invalidated. The
audit now writes only where a height carries no record, and counts what it left
alone. The check and the write are not atomic; the accessor documents that
rather than implying it away.

A window the store held nothing for read as clean. NOT_FOUND cannot tell "the
producer never published here" from "retention aged this height out", and the
pass advanced the watermark across either - so a node down longer than
retention walked its window, compared nothing, recorded nothing, and reported
auditedThrough across the whole range. That is the same silence-as-clean this
work exists to remove, arriving at the retention boundary instead of the
downtime one. A run of NOT_FOUND at the oldest end of a walked window now
raises PreconfUnauditedThrough, which already means not-compared; when the whole
window is unheld the two marks meet. The rule stays narrow on purpose - a hole
in the middle is the store having been down for those heights, not a floor - and
a test pins that narrowness so it cannot widen by accident.

getPreconfAuditStatus had no read-failure coverage, and an unreadable mark
returned a nil status with no error, which serializes as null and reads as
never-audited from the one method built to avoid a clean-looking answer. Now
covered per mark, because corrupting both lets the first read short-circuit and
leaves the second branch unexercised.

One case of the same shape is left as it is. A height the store held but that
could not be decided still advances the watermark unrecorded, and it cannot use
these marks: unauditedThrough is a prefix, so raising it for one scattered
height would declare the entire history below it uncompared. Recording it needs
per-height state, which is a storage and API decision rather than a fix, so it
stays counted and logged alongside the reorg and repair-path limits - all three
documented for review rather than changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cffls cffls left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the watermark approach is the right shape. A few changes before this lands, plus one follow-up that belongs in sequence-store rather than here.

1. Return the audit state from bor_getInvalidPreconfBlocks itself

Callers should not need a second call to interpret an empty result. Since auditedThrough is a prefix mark, the not-yet-audited part of any query range is always its tail, so one extra field is enough:

// bor_getInvalidPreconfBlocks("0x5", "0x60")
{
  "invalid": ["0x9", "0x6"],
  "pendingFrom": "0x4e"
}
  • invalid: heights in range with an invalidation record, newest first, as hexutil.Uint64. The reason field is not needed on the wire; callers only need the heights. Keep it in the DB for logs.
  • pendingFrom: the first height in range the audit has not reached, i.e. max(from, auditedThrough + 1); heights from there to to are pending and callers query again later for them. null when the whole range is audited. Equal to from when the node has not audited yet.
  • Every other height in range is clean.
  • Cap the range at 1024 heights (to - from + 1 <= 1024) and return an error above that, like from > to does today. With one record per height this makes the silent 1024-record truncation in ReadInvalidPreconfsInRange and InvalidPreconfQueryLimit unnecessary.
  • A failed watermark read returns an RPC error, not a result with pendingFrom null.

With this in place bor_getPreconfAuditStatus and PreconfAuditStatus can go; the range call covers what they report.

2. Audit depth: walk from the watermark to head, bounded by the store, not by a block count

The audit should start at auditedThrough + 1 and continue to head. The store's retention is the ceiling; a separate block window on our side is a second bound operators would have to keep aligned with it, so defaultAuditWindow, auditDepth, skippedTo, recordSkippedWindow, SetAuditWindow, the --sequencer.audit-window flag and its config plumbing, TestSequencerAuditWindow, and the docs/cli changes can be dropped. The unauditedThrough mark, leadingUnheld, and their accessors go with them; auditedThrough is the only mark the API needs.

Two details for the walk:

  • Do not probe heights the store no longer holds one GetBlock at a time. A Range request with after unset resolves to the oldest entry the store still serves, and that entry's BlockOpen.block_number is where the walk starts. If it is above auditedThrough + 1, log a warning with the skipped range and count it in a metric so operators can alert on it, then continue from there.
  • A NOT_FOUND mid-walk means the store aged the height out while the walk was running or nothing was published there; treat it the same way, log and count, and advance.

Progress checkpointing every 256 heights stays as is.

3. Follow-up in sequence-store

The gateway serves GetBlock and Range from an in-memory window bounded by --window-bytes (256 MiB by default), which is shorter than the log's retention. The gateway should serve as far back as the log retains so the audit can cover a full outage. That is a sequence-store change and will be tracked there; nothing in this PR waits on it. Once the served depth grows, a seal-only read in the proto would keep the walk cheap, since GetBlock currently returns full transaction payloads.

Fold the audit's coverage of a range into bor_getInvalidPreconfBlocks as
pendingFrom, so a caller needs no second call to tell an unaudited range
from a clean one, and cap the range at 1024 heights instead of
truncating the response: one record per height means bounding the
request bounds the answer. bor_getPreconfAuditStatus goes with it.

Replace the audit's block-count depth bound with the store's retention
floor, read from a Range with after unset. A second bound on this side
is one operators have to keep aligned with the store's, so the
sequencer.audit-window flag and the PreconfUnauditedThrough mark go too.
Heights below the floor, and any NOT_FOUND mid-walk, are logged and
counted under sequencer/audit/ rather than marked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pratikspatil024

Copy link
Copy Markdown
Member Author

All three are in, and the PR is smaller for it — the knob, the PreconfUnauditedThrough key, bor_getPreconfAuditStatus and their tests are all gone.

bor_getInvalidPreconfBlocks answers exactly the shape you wrote ({"invalid":["0x9","0x6"],"pendingFrom":"0x4e"}, pinned as a wire-format assertion), capped at 1024 heights, reason off the wire and still in the DB for logs, unreadable watermark an RPC error rather than a null. The walk runs watermark→head with the floor resolved from Range with after unset; the skipped range is logged and counted in sequencer/audit/retentionskipped, a mid-walk NOT_FOUND in sequencer/audit/unheld. Checkpointing unchanged.

One thing I'd like your explicit yes on. With unauditedThrough gone the watermark advances over the skipped range, so an empty invalid with pendingFrom: null covering those heights now reads identically to compared-and-clean — the counters are the only record that nothing was checked. I'm fine with that and the rollout note says "alert on the metrics", but it is the one guarantee this revision narrows, so I'd rather you confirmed it than found it later. If you'd sooner keep it queryable, the cheap version is an auditedFrom beside pendingFrom — the oldest height the watermark can vouch for — one more uint64, no new method.

Two smaller notes:

  • Entry is a oneof, so the earliest retained entry isn't always a BlockOpen — retention can age out mid-block. The floor takes the first open in the page; a seal reached before any open closes a block that is only partly retained, so the floor is the height above it; records carry no height, so the scan pages on (4 pages of 1024 entries, then it gives up and lets the walk discover the heights unheld one at a time, which is the old behaviour).
  • rawdb.ReadInvalidPreconfs(db, limit) — the non-range accessor, called only by tests now — lost its ceiling along with InvalidPreconfQueryLimit. Say the word if you'd rather it kept one.

Still open from the original list: reorg policy (8). Everything else your review settled.

Brings in #2400 (cap coalesced published records at the store message
limit), #2394, #2393 and the producer-only publisher-endpoint check.

No conflicts: the base's config.go change sits in the same
SequencerConfig this branch removed a field from, and the two edits do
not overlap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pratikspatil024 added a commit to 0xPolygon/pos-workflows that referenced this pull request Sep 14, 2026
Review on 0xPolygon/bor#2388 folded the audit's coverage into
bor_getInvalidPreconfBlocks as a pendingFrom field and removed
bor_getPreconfAuditStatus along with the unauditedThrough mark, so the
method this suite probed no longer exists and the bare array of
{number, reason} becomes an object of {invalid, pendingFrom}.

Detect which shape came back and check each against its own contract:
numbers and reasons for the array, hex heights plus a present
pendingFrom and the 1024-height request cap for the object. The bor this
leg builds still returns the array, so both have to be accepted for the
object branch to go live on its own when the ref moves.

A missing pendingFrom fails while a null one passes: null is the
legitimate "fully audited", and telling the two apart is the whole
reason the field exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cffls

cffls commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Checked the head; it matches what you describe. Answers in order:

  1. Metric-only, explicit yes. No auditedFrom. A gap wider than what the store serves is an operational condition, not an API state: the alerts on sequencer/audit/retentionskipped and sequencer/audit/unheld are the record, and the sequence-store follow-up (serve as far back as the log retains) is what closes the gap.

  2. Floor resolution. The gateway only ever starts its window at a BlockOpen: on cold start it skips entries until the first open, and eviction cuts at generation boundaries. So Range with after unset returns an open as its first entry. The seal case and the paging guard against something the gateway does not produce; keeping them is fine since they fall back to the walk, but a single page with "first entry must be an open, otherwise fall back" would be enough. I'll get that invariant into the proto comment so it is a contract rather than an implementation detail.

  3. ReadInvalidPreconfs. The caller supplies limit, so it does not need a ceiling of its own. Leave it.

  4. Reorg policy. Rather than rewinding the mark, bound the walk at the finalized head (milestone) instead of the current head. Verdicts at or below finality cannot be reorged, so nothing below the watermark ever needs revisiting, and heights between finality and head show up as pendingFrom, which is the right answer for them anyway. The finality lag is a handful of blocks, so coverage is unaffected. For the canonical-head path that also advances the mark, I take it that only covers heights the live path reconciled, where a reorg already produces a reorged record; confirm that and I think the open question is closed.

Review asked for the walk to stop at the finalized head rather than the
chain head. A verdict is only as good as the canonical chain it was
reached against, and the mark never rewinds, so a height judged before a
reorg replaced it would keep a verdict about a block that no longer
exists. At or below a milestone that cannot happen, and heights above it
report as pendingFrom, which is what they are.

The canonical-head path needs the same ceiling. The review took it to be
safe already, on the grounds that a reorg there produces a reorged
record — but reconcileCanonicalLocked removes the pending entry once the
height is reconciled, whether it matched or was invalidated, so a reorg
arriving after that writes no record anywhere and the mark has already
passed the height. Both paths now stop at finality.

A node with no milestone source falls back to the head: bounding at a
finality it cannot see would freeze the watermark forever. That is not
the same as a source reporting nothing final yet, which judges nothing,
so the two are distinguished rather than collapsed into one absent value.

WhitelistedMilestone grows a nil guard. The consumer holds it and calls
it from the audit loop, which Start launches during construction, so it
cannot assume the handler is up; without the guard the wiring tests
nil-dereference it, which showed up as one flaky package failure before
it showed up as a test.

Also per review: the floor read is one page and one rule, since the
gateway's served window always begins at an open, and ReadInvalidPreconfs
keeps its caller-supplied limit with no ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pratikspatil024

Copy link
Copy Markdown
Member Author

All four in, pushed as 6c6c41bd2. One of them does not confirm the way you expected, so taking 4 first.

4. Reorg policy — the walk now stops at finality, and so does the canonical-head path. The bound is exactly as you describe and it is a better answer than rewinding: nothing below the mark can be replaced, so monotonicity costs nothing.

But I cannot confirm the second half. reconcileCanonicalLocked removes the pending entry once the height is reconciled — whether it matched the chain or was invalidated (pending_reconcile.go, the s.removeLocked(key) at the end of the loop body, unconditional). So reorged only covers a reorg that arrives while the entry is still pending. A reorg of a height the live path has already reconciled finds no entry, writes no record, and markCanonicalHeadAudited has already stepped the mark past it — which is precisely the reorg-below-the-mark case the bound is meant to close.

So the ceiling has to apply to both paths or the invariant does not hold, and it now does. The live path steps to watermark+1 only while that height is at or below the milestone, keeping the existing contiguity and watching gates; the mark then trails finality by a few blocks and closes the distance one height per canonical head, which is the cadence blocks arrive at anyway. If you would rather leave the live path alone and accept that narrow exposure, say so and I will drop that half — it is the one piece here that goes beyond what you asked for.

1. Metric-only — taken, no auditedFrom. The rollout notes point at the two counters.

2. Floor — simplified to what you describe: one page, first entry must be an open, anything else resolves nothing and falls back to the walk. The seal case and the paging loop are gone. It reads the invariant off your gateway rather than rediscovering it, so the proto comment is what makes it a contract — no rush, nothing here breaks without it, it just becomes documented rather than assumed.

3. ReadInvalidPreconfs — left with the caller's limit and no ceiling.

One thing worth flagging from wiring this up: WhitelistedMilestone needed a nil guard. The consumer holds it and calls it from the audit loop, which Start launches during construction, so it cannot assume the handler is up — attachSequencer runs after newHandler in production, but the wiring tests build a bare Ethereum and nil-dereferenced it. That surfaced as one flaky package failure before it surfaced as a test; there is a deterministic one on it now.

pratikspatil024 added a commit to 0xPolygon/pos-workflows that referenced this pull request Sep 16, 2026
* test(sequencer): add preconf rpc and store chaos e2e suites

The publisher suite covers whether validators write to the store. These cover
what a client sees and whether the store can hurt the chain, both ported down
from the manual campaign in #tmp_seqstore-testing-and-qa to a size CI affords.

Both scripts source the existing utils rather than editing them, so the
functional suite is untouched and the three run as separate workflow steps
against one enclave, the way the sibling kurtosis leg already sequences its
suites.

rpc suite: a preconfirmed receipt is marked preconfirmation:true and carries a
null blockHash, which is the client's only signal before canonicalisation;
every sampled transaction then has to reach the canonical chain with a real
hash and a success status, the 452k-receipt re-fetch scaled to 120. Plus
eth_sendRawTransactionSync being registered at all - it was missing from a
deployed private RPC, which returns -32601 and breaks any client built on the
sync path - the multicall3 pending read looped 25 times, since intermittent
failure there becomes random estimateGas failures for geth-based clients, and
bor_getInvalidPreconfBlocks answering an array of numbered, reasoned records
and rejecting a reversed range.

Hashes come from the pending block, not the load generator's output: the
pending view is the consumer's own speculative state, so anything in it should
be servable as a preconfirmation, and it keeps the suite off polycli's log
format.

chaos suite: five episodes through one harness - broker stop, ingress latency,
gateway pause, a seeded random fault, and an oversized-record burst. Each
holds the fault, asserts the chain kept building, repairs, and requires
publishing to resume on its own; a store fault that permanently de-registered
a publisher would leave a healthy-looking chain with every preconfirmation
silently gone. A 1Hz head sampler runs across the whole session and the
closing assertion is that no node ever reported one hash for a height and
later a different one, which is the property a store fault must not be able
to break.

Throughput under fault is printed, never asserted. The campaign measured
25-50% cost from store outage or latency, against a design brief that says the
store cannot affect block production. A threshold either fails today or
freezes whichever number is currently true into CI, so the numbers are output
for a human.

bor_getPreconfAuditStatus skips on -32601 rather than failing, the same shape
the suite already uses for an absent polycli, so it lands now and goes live
once the audit ships.

Two notes on what the tooling actually supports: polycli has no --sync-txs on
the pinned release (or on main), so the sync path gets a registration check
rather than a latency measurement; and --calldata needs contract-call mode
with a deployed address, so the oversized-record burst uses store mode with
--store-data-size.

Timeout goes to 75 minutes for the three suites, and the diagnostics dump now
triggers on any of them failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(sequencer): fix four assertions that passed without testing anything

Ran both suites against a local kurtosis devnet with the store built from
source. Four of the assertions were not measuring what they claimed, and two
of them I had already reported as passing.

The preconfirmation check was measuring its own latency. It read one pending
block of 143 transactions, then walked it with a request per hash; by the
tenth, the rest had canonicalised, so preconfirmation was absent and they
scored as misses - 6% coverage on a node that was preconfirming correctly.
Probed directly, 8 of 8 pending transactions were preconfirmed at 0ms with a
null blockHash. A transaction that reached the canonical chain before the
harness looked says nothing either way, so those are now excluded rather than
counted against coverage, and each receipt is classified: preconfirmed,
already-canonical, unmarked, or never-served. A speculative receipt with no
preconfirmation flag is now a hard failure, which is the case that would let a
client treat unconfirmed state as final. Coverage went from a spurious 6% to
86 of 86 transactions caught while still speculative.

The oversized-record burst sent nothing at all. polycli's store mode writes
the payload into contract storage at roughly 20k gas per word, so a 32KB
transaction wants ~24M gas and fails the node's tx fee cap - 400 submissions,
400 rejections, tps 0. The episode passed anyway, and the entry count it
reported as evidence came from the background transfer load. It now sends
calldata instead, where zero bytes cost 4 gas each, addressed to an
unallocated account so no deployment step is needed.

Nothing checked that the burst reached the chain, which is what let that hide.
assert_burst_landed counts transactions addressed to the sink across the
burst's block range and fails when the window is empty.

count_txs_to_sink called rpc_call, which is local to sequencer_rpc_test.sh and
not in scope in the utils. Every call failed, the count came back zero, and
the new guard reported a burst that had demonstrably landed as missing. It
uses rpc_post. Shell has no import graph, so neither bash -n nor shellcheck
can see a cross-file function reference; only running it does.

The burst sizes are now measured rather than guessed. Against an ingress
without the fix, on max.message.bytes=1048576: 32KB x 400 mined puts ~1.3MB in
a block and never fences, while 120KB x 240 fences eight times with
MESSAGE_TOO_LARGE. The same 120KB burst against an ingress with the fix stays
clean, so the episode detects the defect and clears the fix with bor and the
burst held constant. 120KB also sits just under the txpool's 128KB ceiling.
The default was 32KB, which provably never reached the path. Lowering it
disarms the episode silently, because those transactions do land and
assert_burst_landed cannot tell they were too small to coalesce past 1MB - the
comment says so.

The burst now checks publishers per node instead of a summed entry count,
which hides one dead publisher. That is not hypothetical: where the store
rejects an oversized entry as MALFORMED rather than self-fencing, a producer
without the bor-side size cap disables its own publishing and does not
re-enable it, so the chain keeps building while that node silently stops
preconfirming. A later episode's recovery check is what surfaced it; the burst
should catch its own damage.

Burst concurrency is configurable - each sender holds its own payload, and the
default killed the run on a memory-constrained host.

Also verified live: bor_getPreconfAuditStatus reported auditedThrough 0xf8
with unauditedThrough 0x7f, and 127 is exactly the last pre-Rio block, so the
unheld-window path behaves as intended against a real store. 120 of 120
sampled transactions canonicalised with no mismatches. The multicall3 pending
read succeeded 25 of 25, so that intermittent failure did not reproduce here
and is not claimed fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(sequencer): track the reshaped invalid-preconf response

Review on 0xPolygon/bor#2388 folded the audit's coverage into
bor_getInvalidPreconfBlocks as a pendingFrom field and removed
bor_getPreconfAuditStatus along with the unauditedThrough mark, so the
method this suite probed no longer exists and the bare array of
{number, reason} becomes an object of {invalid, pendingFrom}.

Detect which shape came back and check each against its own contract:
numbers and reasons for the array, hex heights plus a present
pendingFrom and the 1024-height request cap for the object. The bor this
leg builds still returns the array, so both have to be accepted for the
object branch to go live on its own when the ref moves.

A missing pendingFrom fails while a null one passes: null is the
legitimate "fully audited", and telling the two apart is the whole
reason the field exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(sequencer): trigger the e2e leg now that #47 has landed

Empty commit. The leg's path filter only fires for main, so this PR's
suites have never run in CI; #47 merging is what makes them eligible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(sequencer): size the burst payload for Linux argv

The burst passed 120KB of calldata as one argv entry: 245762 hex
characters. Linux caps a single argument at MAX_ARG_STRLEN (32 pages,
131072 bytes) where macOS allows far more, so the episode ran on a
laptop and could not run in CI, which failed it with "Argument list too
long".

60KB encodes to 122882 characters, inside the limit, and a check on the
encoded length now fails with a reason if anyone raises it again rather
than letting polycli discover it a CI run later. Generating the payload
with printf's width instead of seq drops a 245760-element argument list
on the way there too.

The smaller payload should still reach the coalescing cap: a record is
capped by bytes before the 64-transaction count, so 18 pending 60KB
transactions fill 1MB where 33 were needed at 32KB. That is reasoning
rather than a measurement — with bor's own cap in place the assertion
passes whether or not the cap engaged — and the note on the constant
says so.

assert_burst_landed did its job here: it caught that nothing reached the
chain and failed instead of asserting over an empty window.

Also corrects the episode's comment, which still described the store
mode this replaced with contract-call calldata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up #2406; the two changes append to the same metrics var block.

# Conflicts:
#	eth/sequencer/metrics.go
@pratikspatil024
pratikspatil024 merged commit 44ec7db into cffls/sequence-publisher Sep 17, 2026
19 checks passed
@pratikspatil024
pratikspatil024 deleted the ppatil/preconf-store-audit branch September 17, 2026 15:18
kamuikatsurgi added a commit that referenced this pull request Sep 21, 2026
…n-block loss window

bor_getInvalidPreconfBlocks must never omit a block whose preconfirmation this
node served and then broke. The store audit (#2388) closes the gap for a node's
downtime by comparing the store's sealed generation at each height against
canonical, so it can only judge heights the store still holds a seal for. One
window escapes that: the producer dies after serving preconfirmations for an
open block but before sealing it, and its successor rebuilds the height without
the store. The store holds no seal - or only an unsealed open - so the audit has
nothing to compare, advances past the height, and the broken preconfirmation is
never recorded. That is the severe case: an invalid preconf absent from the
ledger.

Record the node's own promise durably at the moment it serves. As receipts are
about to reach callers, persist a per-height commitment to chaindb: the number
of transactions preconfirmed there and a keccak fold over their hashes in served
order. It survives the crash that loses the store generation because it lives in
the node's own database, in a separate keyspace off the block path. The fold is
node-local and compared only against itself, serve time versus audit time, so it
folds the already-cached tx hashes rather than the on-wire commitment scheme.

The audit falls back to the commitment whenever the store cannot confirm a
height - it holds nothing, or holds a generation it never sealed. It folds the
canonical block's leading transactions the same way and compares: a divergence
is a preconfirmation the chain did not keep, recorded as served_mismatch - a
distinct reason from unobserved_mismatch, because the node did serve this one.
Commitments the audit confirms, and heights the store seals canonical, are
cleared; so are the commitments in a retention-skipped range, reconciled before
the watermark passes them so the skip neither hides a broken promise nor leaks
keys.

The commitment holds only the latest generation per height; a superseded
generation's promise is left to the live path, as before. The fallback is a
read-only monitor - it records a marker and does not touch fork choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cffls pushed a commit that referenced this pull request Sep 21, 2026
…n-block loss window (#2427)

* eth/sequencer, rawdb: record served preconfirmations to close the open-block loss window

bor_getInvalidPreconfBlocks must never omit a block whose preconfirmation this
node served and then broke. The store audit (#2388) closes the gap for a node's
downtime by comparing the store's sealed generation at each height against
canonical, so it can only judge heights the store still holds a seal for. One
window escapes that: the producer dies after serving preconfirmations for an
open block but before sealing it, and its successor rebuilds the height without
the store. The store holds no seal - or only an unsealed open - so the audit has
nothing to compare, advances past the height, and the broken preconfirmation is
never recorded. That is the severe case: an invalid preconf absent from the
ledger.

Record the node's own promise durably at the moment it serves. As receipts are
about to reach callers, persist a per-height commitment to chaindb: the number
of transactions preconfirmed there and a keccak fold over their hashes in served
order. It survives the crash that loses the store generation because it lives in
the node's own database, in a separate keyspace off the block path. The fold is
node-local and compared only against itself, serve time versus audit time, so it
folds the already-cached tx hashes rather than the on-wire commitment scheme.

The audit falls back to the commitment whenever the store cannot confirm a
height - it holds nothing, or holds a generation it never sealed. It folds the
canonical block's leading transactions the same way and compares: a divergence
is a preconfirmation the chain did not keep, recorded as served_mismatch - a
distinct reason from unobserved_mismatch, because the node did serve this one.
Commitments the audit confirms, and heights the store seals canonical, are
cleared; so are the commitments in a retention-skipped range, reconciled before
the watermark passes them so the skip neither hides a broken promise nor leaks
keys.

The commitment holds only the latest generation per height; a superseded
generation's promise is left to the live path, as before. The fallback is a
read-only monitor - it records a marker and does not touch fork choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* eth/sequencer, rawdb: fold execution context into the served commitment; don't record unjudgeable heights

Addresses review on #2427.

- Seed the served fold with the block's execution context (contextSeed:
  ParentHash, Number, Time, GasLimit, BaseFee, Difficulty — the fields the live
  path's sameExecutionContext checks). A producer handover rebuilds the height
  with a different context (succession-based Difficulty, often Time or parent),
  so a preconfirmation served against the old context is now caught even when
  its transactions are unchanged. Both sides seed identically: the serve path
  from s.env.header on the first batch, the audit from the canonical block header.

- A canonical block not available locally (pruned body, or a snap-sync gap) is
  no longer read as a broken promise. judgeServedAgainstCanonical returns a
  three-way verdict; an unjudgeable height is counted uncomparable and its
  commitment left in place, so the audit cannot write a spurious served_mismatch
  — the scenario-1 failure mode.

- Measure the serve-path write: sequencer/preconf/servedpersist timer.

Tests: served_mismatch on a different context with identical txs; an unjudgeable
height leaves the commitment and records nothing; the live-path commitment clear
in markCanonicalHeadAudited; persistServed round-trip; ReadPreconfServed read
failures. Existing served tests now seed commitments with the context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* eth/sequencer: simplify audit dedup and doc/consistency cleanups

/simplify + /security-review follow-ups on #2427 (no behavior change):

- Extract recordMismatch: the WriteInvalidPreconfIfAbsent + alreadyJudged
  bookkeeping was copy-pasted between recordVerdict and judgeServed.
- Extract package-level clearServedPreconf: the DeletePreconfServed + log was
  duplicated between the audit's clearServed and the consumer's live head path.
- Drop contextSeed's hand-kept capacity arithmetic (cold path, once per block).
- Fix the stale auditSummary.compared doc comment (compared now also counts
  served-commitment comparisons) and document why judgeServedAgainstCanonical
  omits receipts (execution is deterministic given parent + ordered tx prefix +
  context).
- Log on the auditUnknown read-error path too, matching reconcileServed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants