Conversation
dirvine
left a comment
There was a problem hiding this comment.
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.
bee7859 to
3a36d2c
Compare
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:
Transientinstead of proved, which puts it in the auditor's timeout lane with no trust penalty, and no live session ever gives its roots up;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
Compatibility
PointerStore::supersededstill returns the newest replaced record, andsuperseded_allreturns every one kept, newest first.handle_subtree_slice_challenge_with_pointerskeeps 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 newhandle_subtree_slice_challenge_with_pointer_bindings.PointerStore::drop_expired_supersededis new.PointerBindingsandpointer_bindingsare new.Semver impact
Test evidence
mainat56b5770(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 pedanticassert_is_emptylint 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 head3a36d2c, locally on macOS with Rust 1.99.0:cargo test --lib --features test-utils1,241 passed;cargo test --lib --no-default-features1,196 passed;pointer_convergence15; thee2epointer and subtree-audit tests, 18, passed over QUIC;poc_commitment_audit_attacks19 andpoc_audit_handler_live16;cargo clippy --all-targets --all-features -- -D warnings,cargo fmt --check,cargo docwith-D warningsandscripts/adr-governance.pyagainstmainall 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.mainat4f78154:several_updates_between_the_rounds_do_not_fail_an_honest_holder(three paid updates between the rounds, judged by the auditor's ownverify_slice_response) returnedFail(DigestMismatch). It passes with this change, and asserts round 2 serves exactly the record round 1 read.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.Transientwhile the pointer is held andKeyAbsentonce it is not; a missing root isTransient, 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.bee7859:cargo test --lib --features test-utils1,241 passed;e2e111 passed, 3 ignored as onmain;migration_reclaims_disk2 (run alone: it measures free space on the whole filesystem, and failed once when another build was writing to the same disk),migration_crash_safety5,migration_shared_volume5,storage_scale2,webrtc_direct_devnet3,poc_commitment_audit_attacks19,poc_audit_handler_live16,poc_bootstrap_stall3,poc_shutdown_lmdb_drain1, andpointer_convergence15, all passing.cargo clippy --all-targets --all-features -- -D warnings,cargo fmt --check,cargo docwith--deny=warnings, andscripts/adr-governance.pypass.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.