Skip to content

fix(pointer): serve the record round 1 bound, however many updates follow - #238

Open
grumbach wants to merge 9 commits into
WithAutonomi:mainfrom
grumbach:fix/pointer-audit-retention
Open

grumbach wants to merge 9 commits into
WithAutonomi:mainfrom
grumbach:fix/pointer-audit-retention

Conversation

@grumbach

@grumbach grumbach commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Two paid updates to a pointer between the two rounds of a storage audit made an honest holder fail round 2 with DigestMismatch, a confirmed failure that feeds the trust penalty. ADR-0016 accepted that case. This PR closes it, and records the change in a new ADR-0019 rather than editing ADR-0016.

Round 1 reports, for each pointer leaf, a nonced root over the record the node holds. Round 2 has to serve the bytes that reproduce it. The node kept only the record the last update replaced, so after two updates neither the record held nor the one kept was the one round 1 read.

Now:

  • the single-use round-1 session keeps the root round 1 reported for each pointer it proved, capped across all live sessions at 65,536 roots (4 MiB of payload). A round 1 whose roots would not fit is answered Transient instead of proved, which puts it in the auditor's timeout lane with no trust penalty, and no live session ever gives its roots up;
  • the pointer store keeps every record an update replaces, not only the last one, for ten minutes instead of five, which outlasts a round 1 over the largest subtree an auditor waits for plus the session its round 2 must arrive within, with the default configuration. It keeps the replaced record before the new one becomes visible, evicts to the unchanged 2,048-record cap only once an update has succeeded, and drops records past the ten minutes at each prune pass even when no update comes;
  • round 2 serves the one record, held or replaced, whose root matches;
  • when it cannot, because the record aged out or was evicted, round 2 is rejected as Transient. That is the auditor's timeout lane: no trust penalty, but the auditor forgets the holder's standing as a proven holder for the whole pinned commitment until it passes again. A node that no longer holds the pointer is reported absent, a confirmed failure as before, whatever replaced records it still keeps in memory.

The auditor, the wire format and the subtree-audit protocol id are unchanged.

Linear issue

Closes V2-1363

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Compatibility

  • Wire: none. A round-2 pointer item now carries exactly one record, which every auditor accepts; the cap stays two.
  • Storage: none. Replaced records and round-1 roots live in memory only, as before.
  • API: additive. PointerStore::superseded still returns the newest replaced record, and superseded_all returns every one kept, newest first. handle_subtree_slice_challenge_with_pointers keeps its signature and serves pointers as before, the record held and the newest one replaced, except that a pointer no longer held is now absent; the engine uses the new handle_subtree_slice_challenge_with_pointer_bindings. PointerStore::drop_expired_superseded is new. PointerBindings and pointer_bindings are new.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • Rebased on 2026-10-02 onto main at 56b5770 (0.21.0, with main's Rust 1.99 lint updates), with no conflicts: the eight reviewed commits are unchanged. One commit is added. Rust 1.99's new pedantic assert_is_empty lint flagged one assertion in a test this PR adds, which failed CI's clippy job on that main, so the assertion now prints the value on failure, as main did for its own tests. At head 3a36d2c, locally on macOS with Rust 1.99.0: cargo test --lib --features test-utils 1,241 passed; cargo test --lib --no-default-features 1,196 passed; pointer_convergence 15; the e2e pointer and subtree-audit tests, 18, passed over QUIC; poc_commitment_audit_attacks 19 and poc_audit_handler_live 16; cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --check, cargo doc with -D warnings and scripts/adr-governance.py against main all clean. The full CI matrix runs on this head. feat(pointer): keep the first final state, and look before taking one #239 merges this head, and its combined run is listed there.
  • Reproduced first on main at 4f78154: several_updates_between_the_rounds_do_not_fail_an_honest_holder (three paid updates between the rounds, judged by the auditor's own verify_slice_response) returned Fail(DigestMismatch). It passes with this change, and asserts round 2 serves exactly the record round 1 read.
  • Over the wire: round_two_serves_the_record_round_one_read_across_several_updates (e2e) sends round 1 and round 2 by hand over QUIC to a live node's engine, with three updates to every opened pointer in between. It passes with this change and fails when the engine stops handing round 1's roots to the session, which I checked by making that one-line mutation and running it.
  • New unit tests: a bound record no longer held is Transient while the pointer is held and KeyAbsent once it is not; a missing root is Transient, updated or not; round 1 binds exactly its pointer leaves; a session over the roots budget is refused and no opened session loses its roots or its place, even with the session cap full; the store evicts the globally oldest replaced record, and a record kept for an update that then fails costs no other record; retention outlasts the slowest audit, computed from the default config and the largest legal subtree; the earlier entry point still serves an unchanged pointer, and across one update the record held then the one it replaced, and passes the auditor; a pointer lost after an update is absent through either entry point, however many replaced records remain; expired replaced records go without another update. Each of these new tests fails when its fix is reverted, which I checked.
  • Before the rebase, every test step CI runs, locally on macOS at bee7859: cargo test --lib --features test-utils 1,241 passed; e2e 111 passed, 3 ignored as on main; migration_reclaims_disk 2 (run alone: it measures free space on the whole filesystem, and failed once when another build was writing to the same disk), migration_crash_safety 5, migration_shared_volume 5, storage_scale 2, webrtc_direct_devnet 3, poc_commitment_audit_attacks 19, poc_audit_handler_live 16, poc_bootstrap_stall 3, poc_shutdown_lmdb_drain 1, and pointer_convergence 15, all passing.
  • cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --check, cargo doc with --deny=warnings, and scripts/adr-governance.py pass.
  • Dev testnet: not run. That gate is the release manager's call.

New dependency

none

ADR

https://github.com/grumbach/ant-node/blob/fix/pointer-audit-retention/docs/adr/ADR-0019-pointer-audits-serve-the-record-round-one-bound.md

Mitigation / rollback

Revert. Nothing is persisted, so a node running the previous code simply serves pointers as ADR-0016 describes again.

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review: approve

Reviewed exact head bee7859ada404fe152e57e3d7975b21286a20a94.

No introduced blocking defect found in the parent review, independent code-review seat, or independent OpenRouter GLM-5.2 seat. Checked round-1 bindings, multiple replacements, lost-pointer versus unavailable-root classification, expiry, failed-rename rollback, and memory/session budgets against ADR-0019.

Evidence: locally executed cargo test --locked --lib pointer --features test-utils: 85 passed, 0 failed. The first cold-build attempt timed out; the completed rerun is the result quoted here. Independently inspected Linux CI logs: 111 e2e passed (3 ignored), 19 audit attack PoCs passed, 16 live audit-handler tests passed. All reported CI checks are green.

Panel qualification: GLM returned APPROVE-WITH-CONCERNS for style/maintenance observations, not correctness blockers; these do not warrant a change request. Memory pressure can deliberately turn an audit into Transient rather than penalising an honest holder, as the ADR documents.

Integration caveat: #238 and #240 merge cleanly at Git level, but I have not run the combined tree or a deployed testnet. Re-run combined audit/repair coverage before rollout. This code review is not production-rollout or ADR-acceptance sign-off.

…llow

A storage audit binds, in round 1, a nonced root over each pointer record
a node holds, and round 2 must serve the bytes that reproduce it. The node
kept only the record the last update replaced, so two paid updates to a
pointer between the rounds made an honest holder fail round 2 with
DigestMismatch, a confirmed failure that feeds the trust penalty. An
owner who is also one of the holder's auditors knows exactly when its
round 1 has been answered, so this was a cheap way to penalise a chosen
honest neighbour.

The round-1 session now keeps the root reported for each pointer leaf,
the pointer store keeps every record an update replaces rather than only
the last one, and round 2 serves the one record, held or replaced, whose
root matches. Replaced records are kept for ten minutes, longer than a
round 1 over the largest subtree an auditor waits for plus the session its
round 2 must arrive within. Roots are capped at 65,536 across all live
sessions, the oldest sessions giving theirs up first.

When a node cannot serve the record round 1 read, because it aged out,
was evicted, or the session kept no root and the pointer has been updated
since, round 2 is rejected as Transient instead of guessing: no trust
penalty, only the credit of that audit. A node that holds nothing at all
for the pointer is still reported absent, as before.

The auditor, the wire format and the subtree-audit protocol id are
unchanged. ADR-0017 records the change and amends one point of ADR-0016,
which is left as written.
Review of the first version found three ways a node could still lose the
record an audit was owed.

- A flood of pointer-heavy round-1 sessions made older sessions give up
  their roots to make room, so an honest holder's round 2 went transient.
  A round 1 whose roots do not fit now withholds its proof, exactly as a
  round 1 refused for capacity already does, and no live session gives
  its roots up.
- Keeping a replaced record could evict another one before an update that
  then failed. Eviction now waits for the rename to succeed, and a failed
  rename takes back the record it kept.
- A round 2 could read the new record before the one it replaced was kept.
  The replaced record is now kept before the rename, under the same lock.

ADR-0017 now states what a transient round 2 costs (the auditor forgets
the holder's standing for the whole pinned commitment, with no trust
penalty) and that the ten-minute retention is sized for the default
configuration.
ADR-0017 is now also claimed by an open PR that renumbered its own ADR,
and ADR-0018 by another. 0019 is the next number neither main nor any
open ADR PR uses. The ADR's text is unchanged; every reference to it in
comments and tests follows, and the ADR index lists it.
… old APIs

A round 1 whose pointer bindings would not fit the budget withheld its
proof silently. Silence reads as a peer that did not answer, which costs
the honest holder trust at the transport, so it now answers Transient,
the auditor's timeout lane, with no trust penalty.

ant-node 0.21.0-rc.1 already carries the pointer store and the slice
handler, so this change keeps their signatures: PointerStore::superseded
again returns the record last replaced, beside a new superseded_all, and
handle_subtree_slice_challenge_with_pointers keeps its arguments, beside a
new handle_subtree_slice_challenge_with_pointer_bindings that the engine
uses. The additions are the only API change.
A round 1 refused over the pointer bindings budget, and a pointer record
round 1 bound that is no longer kept, are answered Transient. The
protocol docs still said Transient only follows failed read retries and
that any rejection of a recent pinned commitment is a confirmed failure.
…ters as before

handle_subtree_slice_challenge_with_pointers was kept for callers of its
earlier signature, but it passed empty round-1 bindings, so every pointer
it served came back as a transient failure, even one that never changed.
It now serves a committed pointer as it did before ADR-0019: the record
held and the newest one an update replaced. The engine keeps using the
entry point that takes round 1's roots. A test calls the earlier entry
point with an unchanged pointer and requires the auditor to pass it.
…, and those expire unaided

A node that no longer held a pointer but still kept, in memory, a record
an update had replaced was not reported absent: it answered Transient,
or passed outright when the kept record was the one round 1 read.
Replaced records prove what round 1 read, not that the pointer is still
held, so a node without the pointer now reports it absent, a confirmed
failure, as before ADR-0019.

Replaced records past their ten minutes were dropped only when another
update came, so after a burst of updates up to 11 MB could stay resident
indefinitely. Each prune pass now drops them.

The earlier entry point's test now also covers one update between the
rounds: it serves the record held and the one it replaced, newest first,
and passes.
The earlier slice-challenge entry point served the record an update
replaced when the pointer itself was gone, so a node that had lost a
pointer after one update could still pass an audit through it. It now
reports a pointer it no longer holds as absent, as the engine's entry
point does, and still serves the record held and the one it replaced
otherwise. A test covers it through that entry point.
Rust 1.99's clippy adds the pedantic assert_is_empty lint, which main now
answers for its own tests: a bare assert! on is_empty() prints nothing
useful when it fails. This test, new on this branch, had the last such
assertion, so CI's clippy job failed once the branch sat on that main. Bind
the value and print it, as main does; the condition is unchanged.
@grumbach
grumbach force-pushed the fix/pointer-audit-retention branch from bee7859 to 3a36d2c Compare October 2, 2026 04:21
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.

2 participants