Skip to content

feat(pointer): make a final state final, so ownership can be handed over - #40

Open
grumbach wants to merge 4 commits into
mainfrom
feat/pointer-ownership-transfer
Open

grumbach wants to merge 4 commits into
mainfrom
feat/pointer-ownership-transfer

Conversation

@grumbach

@grumbach grumbach commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Linear issue

Closes V2-1354

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.

This changes the pointer merge rule that nodes and clients both apply. The change is one comparison, at the final counter.

What

A pointer's owner key cannot change. The address can still be handed over for good: the owner signs one last state at counter == u64::MAX, pointing at a pointer the new owner holds the key to. Every reader of the address is then redirected to the new owner's pointer, and the address stays the same.

Under ADR-0016's rule that does not stick. A state at u64::MAX is still displaced by another state at the same counter with smaller target bytes, so a former owner can grind a target in about two tries and take the address back. This PR adds one rule ahead of the other two:

0. a final state (counter == u64::MAX) is replaced by nothing
1. larger counter
2. smaller target bytes

Below the final counter nothing changes. At the final counter, two different final states are unordered, so a node keeps whichever it took first. Only the owner can create that fork: it still holds the earlier record and the key, so it can sign a second final state, and any node the first has not reached will take it, at once or later. Once a node holds a final state, no arrival moves it off. The node PR also has a node look at its close group before taking a final state, and the client PR reads the side a majority holds and reports forks. replaces stays a strict partial order: it is never true both ways round, it is transitive, and it is total except between two final states.

Also adds FINAL_COUNTER, Pointer::finalize (refuses to sign past a record that is already final, since a second final state is how a fork is made; a guard against a caller's mistake, not against the owner), Pointer::transfer_to, and transferred_to on both the record and PointerState. PointerError::CounterExhausted now says the pointer is final, instead of advising a migration before the last counter.

Companion PRs: WithAutonomi/ant-node#239 (ADR-0018, and the node's look before a final state) and WithAutonomi/ant-client#210 (transfer, finality check, majority reads). Both pin this branch's head by rev.

Compatibility

  • Wire: none. No field, message or encoding changes; a final state is an ordinary record at counter u64::MAX.
  • Storage: none.
  • API: additive: FINAL_COUNTER, Pointer::finalize, Pointer::transfer_to, Pointer::transferred_to, PointerState::is_terminal, PointerState::transferred_to. One behaviour change: PointerState::replaces / Pointer::replaces return false when the held state is final. Nodes and clients must agree on it. A node still on the old rule lets a smaller-target final state displace the first, and nothing on the wire tells the two rules apart (the format version is still 1); see ADR-0018's mixed-fleet note.

Semver impact

  • breaking
  • feature
  • fix

Pointers are not in a published release yet (3.0.0 has none), so the rule change lands on an unreleased surface.

Test evidence

  • cargo test --lib: 109 passed, 22 of them in pointer. New tests:
    • A final state is replaced by no counter and no other final state, whatever its target, for chunk, pointer and unknown target kinds.
    • Below the final counter the order is unchanged.
    • The strict-order property test now includes final states, and is total except between two of them.
    • A fold keeps the first final state it meets.
    • transfer_to signs at the final counter to the recipient and refuses to sign past a final record.
    • Only a final pointer target counts as a transfer.
    • An earlier record finalized twice gives two final states, and neither replaces the other: the limit the fork rule leaves.
  • cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --all -- --check, RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps and cargo build --no-default-features: all clean.
  • Adversarial coverage is in feat(pointer): keep the first final state, and look before taking one ant-node#239: property tests over every delivery order, the request handler, repair, and a live multi-node network. feat(pointer): hand a pointer over for good, and read forks by majority ant-client#210 covers majority reads and fork detection, including end to end against a local testnet with real settlement.

New dependency

None.

ADR

https://github.com/WithAutonomi/ant-node/blob/feat/pointer-ownership-transfer/docs/adr/ADR-0018-pointer-transfer-by-final-redirection.md: ADR-0018, added by WithAutonomi/ant-node#239. It amends ADR-0016's merge rule at the final counter.

Mitigation / rollback

Revert the rule-0 line in PointerState::replaces. Nodes and clients pin this crate by rev, so a rollback is a re-pin, and no stored data changes shape either way.

A pointer's owner key cannot change, but what its address resolves to can
be handed over for good: the owner signs one last state, at counter
u64::MAX, pointing at a pointer the new owner holds the key to. Readers of
the address are redirected there, and the address never changes.

That only works if the last state stays last. Under the previous order a
state at u64::MAX was still displaced by another at the same counter with
smaller target bytes, so a former owner could grind a target and take the
address back. replaces() now has a rule ahead of the other two: nothing
replaces a final state, not even another final one. Below the final counter
the order is unchanged.

The price is that two different final states are unordered, so each node
keeps whichever it took first. Only the owner can make that fork, only by
racing two final states to different nodes, and only while no node holds a
final state yet: once one does, no node that holds it takes another. Which
side a reader believes is decided by how many of the close group hold each,
on the client.

Adds FINAL_COUNTER, Pointer::finalize, Pointer::transfer_to and
transferred_to on both the record and its parsed state. finalize refuses to
sign past a record that is already final, since that is how a fork is made.
The docs and changelog said the former owner had no move left once a
node held a final state, and that a fork could only be made before any
node held one. Neither holds. The owner keeps the earlier record and the
key, so it can sign a second final state at any time, and a node that
holds no final state takes whichever one reaches it first. What the rule
guarantees is narrower: no node gives up a final state it holds.

The docs now say that, say that the refusal to finalize a final record
guards a caller rather than the network, and note that nothing on the
wire tells the old merge rule from the new one. A test pins the limit:
an earlier record finalized twice gives two final states, and neither
replaces the other.
…verywhere

A frozen pointer was said to be frozen for good, and a transfer to leave
only the new owner able to move the pointer on. Both hold only at the
nodes that hold that final state: the former owner can still finalize an
earlier record elsewhere. Two different final states were also called
the only fork the rule allows, while conflicts at a lower counter are
forks too; they are the only unordered conflict, and the only fork no
later state heals. The changelog's merge-rule summary now says it holds
below the final counter, and one test is renamed to say what it checks.
…ks for good only at the end

Two claims were still absolute. A transfer redirects a reader through
the nodes that hold that final state, not every reader: after the owner
finalizes an earlier record again, a reader asking the other side goes
elsewhere. And a second final state is not the only way to fork a
pointer, since two states at one lower counter split nodes too; it is
the only fork no later state heals.

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

APPROVE — reviewed c6ef10f.

No blocking findings. Final states are deliberately incomparable: ordinary counters retain deterministic merging, whereas a stored final state is not replaced by another final state. The API explicitly rejects updates/transfers from an already-final record, without claiming an owner cannot sign competing histories. Wire format and paid state identity remain consistent with the companion node/client changes.

Verification: cargo test --locked --lib passed: 109 tests, zero failures. All GitHub checks were successful at the last check.

Watch-out: accepting/decoding the unchanged wire format does not make old nodes enforce the new final-state semantics. Deployment must be coordinated across protocol, node and client; this approval does not authorise a live rollout.

Review scope: the three companion pointer-transfer PRs were read together. Independent GLM-5.2 review found no blockers on the earlier reviewed heads; I checked subsequent deltas directly and reran the relevant tests. Its cautions about mixed-version deployment and the lack of global ownership consensus are valid, documented limitations rather than demonstrated regressions. Codex CLI could not review because its credentials were revoked; it is not counted as a completed review. Additional source-review seats have not returned, so this is not a claim of full-panel consensus.

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