Skip to content

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

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

grumbach wants to merge 2 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-0017 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 withholds its proof, exactly as a round 1 refused for capacity does, 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, and evicts to the unchanged 2,048-record cap only once an update has succeeded;
  • 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 holds nothing at all for the pointer is still reported absent.

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

Linear issue

Closes V2-1277

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: PointerStore::superseded returns every replaced record kept, newest first, as Bytes, instead of the last one; handle_subtree_slice_challenge_with_pointers takes the round-1 pointer bindings; PointerBindings and pointer_bindings are new. None of these exists in a released ant-node, since pointers have not shipped.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • 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.
  • Every test step CI runs, locally on macOS at this head: cargo test --lib --features test-utils 1,236 passed; e2e 111 passed, 3 ignored as on main; migration_reclaims_disk 2, 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-0017-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.

…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.
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.

1 participant