Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
624b125
fix(pointer): serve the record round 1 bound, however many updates fo…
grumbach Sep 29, 2026
11babb9
fix(pointer): withhold round 1 rather than evict pointer bindings
grumbach Sep 29, 2026
f59a1b2
docs(adr): renumber the pointer audit ADR to 0019
grumbach Sep 30, 2026
d9badec
fix(pointer): answer a round 1 over the bindings budget, and keep the…
grumbach Sep 30, 2026
d6e7efd
docs(audit): name the pointer causes of a transient rejection
grumbach Sep 30, 2026
664923f
fix(audit): keep the earlier slice-challenge entry point serving poin…
grumbach Sep 30, 2026
7fa53b8
fix(audit): a lost pointer is absent whatever replaced records remain…
grumbach Sep 30, 2026
b79067f
fix(audit): the earlier entry point reports a lost pointer absent too
grumbach Sep 30, 2026
2c9b79e
fix(pointer): harden pointer replication against thin views and floods
grumbach Sep 29, 2026
971673a
fix(pointer): charge one invalid record once in a possession check
grumbach Sep 30, 2026
1524523
fix(pointer): judge a possession check on what a peer owes when it an…
grumbach Sep 30, 2026
651b10c
fix(pointer): push hints only for admitted syncs, once per peer per i…
grumbach Sep 30, 2026
f59bec5
fix(pointer): answer only fresh syncs from routed peers with hints, a…
grumbach Sep 30, 2026
7132e3c
fix(pointer): give up a request that cannot be sent in time, charge n…
grumbach Sep 30, 2026
5f5b457
fix(pointer): let shutdown win when it and a permit are ready together
grumbach Sep 30, 2026
804a5f9
feat(pointer): keep the first final state, and look before taking one
grumbach Sep 29, 2026
e582b24
test(pointer): import what the finality tests use, rather than naming…
grumbach Sep 29, 2026
1009114
docs(adr): name ADR-0018's pull requests
grumbach Sep 29, 2026
e2e00af
docs(pointer): make the finality check's budget public, as its docs l…
grumbach Sep 29, 2026
7d74631
docs(adr): leave ADR-0016 as written, and say in ADR-0018 what it cha…
grumbach Sep 30, 2026
ad3a953
fix(pointer): make the look before a final state hard to stall, cheap…
grumbach Sep 30, 2026
092493d
chore(deps): pin ant-protocol at the head of its companion change
grumbach Sep 30, 2026
282cf14
fix(pointer): never let a second proof replace the first, and share o…
grumbach Sep 30, 2026
b13e9db
fix(pointer): drop a clear look when its write fails, and spread look…
grumbach Sep 30, 2026
2707292
fix(pointer): a stale fresh commit keeps no clear look, and a tie bet…
grumbach Sep 30, 2026
7764c23
fix(pointer): adopt a final state only as the larger side counting si…
grumbach Sep 30, 2026
0917c5f
fix(pointer): count repair votes by the whole state, and a summary fo…
grumbach Sep 30, 2026
98b5bfb
fix(pointer): name the remembered final state when refusing a rival, …
grumbach Sep 30, 2026
e98bddd
fix(pointer): a finality proof must be exactly the state the peer cla…
grumbach Sep 30, 2026
3a36d2c
test(pointer): give the evicted-address assertion a failure message
grumbach Oct 2, 2026
98a19a6
merge: bring in #238's round 1 bound for pointer audits
grumbach Oct 2, 2026
a1f0799
merge: bring in #240's pointer replication hardening
grumbach Oct 2, 2026
99eee09
chore(deps): pin ant-protocol at the head of its companion change
grumbach Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ webrtc-direct = [
"saorsa-transport/webrtc-direct",
]

[patch.crates-io]
# The published 3.1.0 carries pointers (ADR-0016) but not the final state this
# change relies on (ADR-0018), so this is the only entry that has to leave the
# release baseline. A rev, not a branch, so the pin is immutable. Drop it once a
# release includes the companion change. Versions are bumped at release.
ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", rev = "ab459e52b3db19c0db15ed33a97fe6282dfd4ae0" }

[profile.release]
lto = true
codegen-units = 1
Expand Down
319 changes: 319 additions & 0 deletions docs/adr/ADR-0018-pointer-transfer-by-final-redirection.md

Large diffs are not rendered by default.

165 changes: 165 additions & 0 deletions docs/adr/ADR-0019-pointer-audits-serve-the-record-round-one-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# ADR-0019: Pointer audits serve the record round 1 bound

- **Status:** Proposed
- **Date:** 2026-09-29
- **Decision owners:** Anselme (@grumbach)
- **Reviewers:** <pending>
- **Supersedes:** none. It amends one point of ADR-0016, "Updates between the
rounds", and leaves the rest of ADR-0016 as written.
- **Superseded by:** none
- **Related:** ADR-0002 (audit), ADR-0009 (audit families), ADR-0016 (pointers)

## Context

A storage audit is two rounds (ADR-0002, ADR-0009). Round 1 reports, for every
leaf of the audited subtree, a root over the bytes the node holds, keyed by the
audit's fresh nonce. Round 2 then opens a few of those leaves, and the node must
serve bytes that reproduce the root round 1 reported.

For a pointer (ADR-0016) those bytes are the whole signed record, and the owner
may replace the record at any time with a paid update. ADR-0016 covers one
update between the rounds: the store keeps the record an update replaced for
five minutes, and round 2 serves it beside the new one. It accepts the case of
two updates:

> Two updates to one pointer inside the same audit would fail an honest holder,
> and would take the owner two paid updates within seconds of each other.

Two things make that worth closing rather than accepting.

- **The failure is charged to the wrong party.** The auditor reports
`DigestMismatch`, a confirmed failure, and the holder takes the trust penalty
for its owner's activity. Nothing the holder did was wrong.
- **It can be aimed.** An owner who is also a close-group auditor of the holder
knows exactly when its round 1 has been answered, and two paid updates cost
two writes, about 0.013 ANT each on the 990-node pointer testnet. ADR-0016
already notes that an owner can grind a node id beside its own pointer. That
turns an accepted edge case into a cheap way to penalise a chosen honest
neighbour.

The cause is narrow. Round 2 serves the record held now and the one last
replaced, because the node has not kept what round 1 bound and so cannot tell
which record it owes. After two updates neither of those is the one round 1
read.

## Decision Drivers

- An honest holder must not take a confirmed failure because its owner updated
the pointer, however often.
- No wire change and no change to what the auditor accepts. Pointers have not
shipped in any release yet, but the smaller change is still the better one.
- Memory stays bounded against an auditor that opens many round-1 sessions, and
running out of it must never turn into a confirmed failure either.

## Considered Options

1. **Keep ADR-0016 as written.** Cheapest, and it leaves the failure above.
2. **Serve every record held since round 1.** It needs a larger per-item cap
than `MAX_POINTER_RECORDS_PER_ITEM`, so the auditor's check changes, and any
cap is still one more paid update away from failing.
3. **Remember what round 1 bound, and serve that record.** Round 1 already
reports a nonced root per pointer leaf. The node keeps those roots in the
round-1 session it already holds, and round 2 serves the one record,
current or replaced, whose root matches.

## Decision

We will take option 3.

- **The session keeps round 1's roots.** When a round-1 proof is about to be
sent, the single-use session it opens keeps the nonced root reported for each
pointer leaf, keyed by address: 64 bytes of key and root a pointer, and
nothing for chunks. A session whose proof then fails to send keeps them until
it expires, as it keeps its place today.
- **The store keeps every replaced record, for longer.** Every record an update
replaces is kept in memory, not only the last one, for ten minutes rather
than five. A round 1 can read a pointer at its start and take as long as the
auditor waits for the largest subtree (1,024 leaves), about seven minutes
with the default configuration, before its session even opens, and round 2
then has the session's two minutes. A test ties the ten minutes to those two
figures for the default configuration; an auditor configured to wait longer
than that can outlast the record. The overall cap stays 2,048 records, about
11 MB, oldest first wherever it is, and records past the ten minutes are
dropped at each prune pass even when no update comes to drop them.
- **Round 2 serves the record that matches.** Among the record held now and the
replaced records kept for that address, it serves the one whose nonced root,
under the audit's own nonce, is the root round 1 reported. That is a single
record, so the auditor's check and the item cap are unchanged.
- **When it cannot, it says so.** If the root matches nothing kept, because the
record aged out or was evicted, round 2 is rejected as `Transient`, as for a
local read error. That is the auditor's timeout lane: no trust penalty, but
the auditor forgets the holder's standing as a proven holder of every key
under the pinned commitment, until the holder passes again. A node that no
longer holds the pointer reports it absent, which is a confirmed failure, as
before, whatever replaced records it still keeps: those prove what round 1
read, not that the pointer is still held.
- **The roots are bounded, by admission.** Every live session together keeps
at most `MAX_SESSION_POINTER_BINDINGS` (65,536) roots, 4 MiB of payload
before the maps' own overhead. A round 1 whose roots would not fit withholds
its proof and answers `Transient` instead, which puts it in the auditor's
timeout lane with no trust penalty; staying silent would read as a peer that
did not answer. Roots are never stripped from a live session to make room:
its round 2 is owed them. A whole session can still be evicted when the
session count reaches `MAX_SUBTREE_SESSIONS`, as before this change, and its
round 2 then goes to the timeout lane. Without a root for a pointer, round 2
would reject it as `Transient` rather than guess, since the replaced records
kept are capped and an empty history proves nothing, but a session this node
opened always holds a root for every pointer it proved.

## Consequences

### Positive

- Updates between the rounds no longer fail an honest holder, however many.
What remains are local limits, and none is a confirmed failure: the bound
record evicted by more than 2,048 paid updates across the node's pointers
inside ten minutes is reported as `Transient`, and so is a round 1 over the
roots budget.
- Round 2 serves one pointer record where it could serve two, so it is smaller.
- The auditor, the wire format and the subtree-audit protocol id are unchanged.

### Negative / Trade-offs

- Round-1 sessions carry state they did not before, bounded by the budget
above. Auditors that open pointer-heavy sessions faster than they complete
can fill it, and later round 1s are then answered `Transient` until it
drains. That costs the holder those audits' credit, not trust, much as the
round-1 concurrency and work budgets already can.
- A responder that returns `Transient` is not proved wrong. That was already
so, since any responder can report a local read error, so this gives a
dishonest node no answer it did not have.
- One address can hold many replaced records inside the window, and round 2
hashes each candidate it checks for that address. The global cap bounds that
work to about 11 MB of keyed BLAKE3 per opened pointer, and filling it takes
that many paid updates.
- Replaced records are kept twice as long, so under a high update rate the
2,048-record cap is reached sooner. The memory bound itself is unchanged.

### Neutral / Operational

- A restart drops every session, as before, so a round 2 that follows one goes
to the graced timeout lane, as it already did.

## Validation

- `several_updates_between_the_rounds_do_not_fail_an_honest_holder`: three
updates between the rounds fail with `DigestMismatch` before this change and
pass after it, and round 2 serves exactly the record round 1 read.
- `round_two_serves_the_record_round_one_read_across_several_updates` (e2e):
both rounds sent over QUIC to a live node, with three updates between them.
It fails if the node stops handing round 1's roots to its session.
- `a_bound_record_no_longer_held_is_unavailable_not_failed`,
`without_the_bound_root_a_pointer_is_unavailable_not_failed`,
`subtree_session_carries_pointer_bindings_within_the_budget`,
`past_the_cap_the_oldest_replaced_record_anywhere_goes_first` and
`a_replaced_record_outlives_the_slowest_audit` cover the limits above.
- In production, a pointer holder's `DigestMismatch` rate should not rise with
the update rate of the pointers it holds. A rise in `Transient` round-2
rejections naming a pointer means the retention or session budget is being
reached.

## Notes for AI-assisted work

AI tools may help draft this ADR, but **must not mark it Accepted without human
review**. Accepted ADRs are immutable: create a new superseding ADR rather than
editing an Accepted ADR.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ See [`TOOLING.md`](./TOOLING.md) for `adrs`, `adr-kit`, and AI harness setup.
- [ADR-0013: Settlement version and pre-payment compatibility](./ADR-0013-settlement-version-and-pre-payment-compatibility.md)
- [ADR-0015: Direct browser clients over WebRTC Direct](./ADR-0015-direct-browser-clients-over-webrtc-direct.md)
- [ADR-0016: Pointers — paid mutable references with an immutable owner](./ADR-0016-pointers-immutable-owner.md)
- [ADR-0018: Pointer ownership transfer by final redirection](./ADR-0018-pointer-transfer-by-final-redirection.md)
- [ADR-0019: Pointer audits serve the record round 1 bound](./ADR-0019-pointer-audits-serve-the-record-round-one-bound.md)
7 changes: 3 additions & 4 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,10 @@ impl NodeBuilder {
};

// ADR-0016: pointers replicate through the same engine. The PUT handler
// hands each newly stored paid state to it on this channel.
// hands each newly stored paid state to it, and asks it before taking
// a final state (ADR-0018).
if let Some(service) = protocol.pointer_service() {
let (writes, fresh_writes) = tokio::sync::mpsc::unbounded_channel();
service.attach_fresh_writes(writes);
engine.with_pointers(service.store().clone(), fresh_writes);
engine.with_pointer_service(service);
}

// ADR-0004: wire the engine's commitment state as the quote generator's
Expand Down
22 changes: 15 additions & 7 deletions src/pointer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@
//! Implements `docs/adr/ADR-0016-pointers-immutable-owner.md`.
//!
//! A pointer is a mutable, owner-signed reference stored at an address derived
//! from the owner's public key. Ownership is fixed at creation: there is no
//! transfer, no lineage, no certificates and no key rotation. That choice is
//! what lets the design be this small — the owner key is inlined in the
//! record, so validating a pointer needs nothing but the pointer.
//! from the owner's public key. The owner key is fixed at creation: there is no
//! lineage, no certificates and no key rotation. That choice is what lets the
//! design be this small — the owner key is inlined in the record, so
//! validating a pointer needs nothing but the pointer.
//!
//! What the address resolves to can still be handed over for good
//! (`docs/adr/ADR-0018-pointer-transfer-by-final-redirection.md`): a state at
//! the final counter is replaced by nothing, so once a node holds one pointing
//! at the new owner's pointer, the former owner cannot take it back there. The
//! store gets that from the merge rule; the service adds one look at the close
//! group before taking a final state, so a node that had not heard of the
//! first refuses a second one when a peer proves the first in time.
//!
//! # What lives here
//!
Expand Down Expand Up @@ -53,8 +61,8 @@ pub mod store;

pub use ant_protocol::pointer::{
pointer_address, state_id_for_body, ParsedPointer, Pointer, PointerError, PointerState,
PointerTarget, PointerTargetKind, DATA_TYPE_POINTER, POINTER_BODY_LEN, POINTER_FORMAT_VERSION,
POINTER_WIRE_LEN, TARGET_WIRE_LEN,
PointerTarget, PointerTargetKind, DATA_TYPE_POINTER, FINAL_COUNTER, POINTER_BODY_LEN,
POINTER_FORMAT_VERSION, POINTER_WIRE_LEN, TARGET_WIRE_LEN,
};
pub use service::PointerService;
pub use service::{FinalStateWitness, FinalityCheck, PointerService};
pub use store::{PointerStore, PutOutcome};
Loading
Loading