Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
83388ec
fix(pointer): serve the record round 1 bound, however many updates fo…
grumbach Sep 29, 2026
6f93d7b
fix(pointer): withhold round 1 rather than evict pointer bindings
grumbach Sep 29, 2026
f749421
docs(adr): renumber the pointer audit ADR to 0019
grumbach Sep 30, 2026
e997494
fix(pointer): answer a round 1 over the bindings budget, and keep the…
grumbach Sep 30, 2026
aa77bf1
docs(audit): name the pointer causes of a transient rejection
grumbach Sep 30, 2026
b2387e0
fix(audit): keep the earlier slice-challenge entry point serving poin…
grumbach Sep 30, 2026
874ea68
fix(audit): a lost pointer is absent whatever replaced records remain…
grumbach Sep 30, 2026
1df03a0
fix(audit): the earlier entry point reports a lost pointer absent too
grumbach Sep 30, 2026
0caa731
test(pointer): give the evicted-address assertion a failure message
grumbach Oct 2, 2026
ba43f12
feat(pointer): keep the first final state, and look before taking one
grumbach Sep 29, 2026
bd11eea
test(pointer): import what the finality tests use, rather than naming…
grumbach Sep 29, 2026
16456c4
docs(adr): name ADR-0018's pull requests
grumbach Sep 29, 2026
86d6788
docs(pointer): make the finality check's budget public, as its docs l…
grumbach Sep 29, 2026
f083d9b
docs(adr): leave ADR-0016 as written, and say in ADR-0018 what it cha…
grumbach Sep 30, 2026
acdd68c
fix(pointer): make the look before a final state hard to stall, cheap…
grumbach Sep 30, 2026
f225e2b
chore(deps): pin ant-protocol at the head of its companion change
grumbach Sep 30, 2026
bdd1d3e
fix(pointer): never let a second proof replace the first, and share o…
grumbach Sep 30, 2026
5a63a81
fix(pointer): drop a clear look when its write fails, and spread look…
grumbach Sep 30, 2026
5241751
fix(pointer): a stale fresh commit keeps no clear look, and a tie bet…
grumbach Sep 30, 2026
8283212
fix(pointer): adopt a final state only as the larger side counting si…
grumbach Sep 30, 2026
ad66142
fix(pointer): count repair votes by the whole state, and a summary fo…
grumbach Sep 30, 2026
7027a6d
fix(pointer): name the remembered final state when refusing a rival, …
grumbach Sep 30, 2026
7067e0b
fix(pointer): a finality proof must be exactly the state the peer cla…
grumbach Sep 30, 2026
2f039ff
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.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ webrtc-direct = [
# streams without copying. Drop both once releases include them.
saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core", rev = "c0022b871961c7f373f2ccde815385816962de98" }
saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport", rev = "1e6eb1a3bc5b8b7f51c6773b11a152ae50d87320" }
# The published 3.1.0 carries pointers (ADR-0016) but not the final state this
# change relies on (ADR-0018). 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
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 @@ -37,3 +37,5 @@ See [`TOOLING.md`](./TOOLING.md) for `adrs`, `adr-kit`, and AI harness setup.
- [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-0017: Bounded fresh-replication offers and copy-free message sends](./ADR-0017-bounded-fresh-offers-and-copy-free-sends.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