From 7aab0772aed9931ce7912eeda23b1f8482d6d116 Mon Sep 17 00:00:00 2001 From: grumbach Date: Tue, 29 Sep 2026 16:15:37 +0900 Subject: [PATCH 1/5] feat(pointer): make a final state final, so ownership can be handed over 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. --- CHANGELOG.md | 28 ++++ src/lib.rs | 4 +- src/pointer.rs | 350 ++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 334 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ab7ba..19fe1cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed + +- **A pointer state at `counter == u64::MAX` is final.** `PointerState::replaces` + gains a rule ahead of the other two: nothing replaces a final state, not even + another final state with smaller target bytes. Below the final counter the + order is unchanged. This is what makes ownership transferable by redirection: + the owner signs one last state pointing at a pointer the new owner holds the + key to, every reader of the address is redirected there, and the former owner + has no move left. Previously a former owner could grind a smaller target at + the same counter and take the address back. + + The cost is that two *different* final states are unordered, so a node keeps + whichever it took first. That fork can only be made by the owner, only by + racing two final states to different nodes, and only while no node holds a + final state: once one does, no node that holds it will take another. Clients + decide between the two sides by how many of the close group hold each (see + `ADR-0018` in `ant-node`). + + Nodes and clients must agree on this rule; a node on the previous rule still + lets a smaller-target final state displace the first one. + ### Added +- `FINAL_COUNTER`, `Pointer::finalize`, `Pointer::transfer_to` and + `Pointer::transferred_to` / `PointerState::transferred_to`: sign and recognise + the final state that hands a pointer's address over to another pointer. + `finalize` refuses to sign past a record that is already final, since a second + final state is the one way to fork a pointer. `PointerState::is_terminal` + mirrors `Pointer::is_terminal`. + - **Pointers** (`pointer`): a paid, mutable, owner-signed reference. One 5,303-byte record with the ML-DSA-65 owner key inlined, addressed at `BLAKE3::derive_key("autonomi.pointer.address.v1", owner)` — so a pointer is diff --git a/src/lib.rs b/src/lib.rs index cf774bc..3f8c377 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,8 +81,8 @@ pub use payment::{ }; pub use 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, }; // ============================================================================= diff --git a/src/pointer.rs b/src/pointer.rs index 67db8e9..4ce5543 100644 --- a/src/pointer.rs +++ b/src/pointer.rs @@ -6,17 +6,37 @@ //! content hash: the address is fixed for the pointer's life while the bytes //! under it change with every update. //! -//! Ownership is fixed at creation. There is no transfer, no lineage, no -//! certificates and no key rotation: a former owner keeps its key forever, so -//! transferable ownership cannot be made fork-proof by a local rule. An earlier -//! draft of this module implemented transfer through a genesis object and a -//! chain of transfer certificates; it was reviewed and withdrawn. See -//! `ADR-0016` in `ant-node`. +//! The owner *key* is fixed at creation. There is no lineage, no certificates +//! and no key rotation: a former owner keeps its key forever, so ownership +//! cannot be re-keyed by a local rule. An earlier draft of this module +//! implemented transfer through a genesis object and a chain of transfer +//! certificates; it was reviewed and withdrawn. See `ADR-0016` in `ant-node`. //! //! 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 — no //! fetch, no cache, no second close group, nothing that can be missing. //! +//! # Finality, and transfer by final redirection +//! +//! A record at `counter == u64::MAX` is **final**: no record replaces it, not +//! even another one at `u64::MAX`. That is what makes ownership transferable +//! without touching the record: the owner signs one last state, at the final +//! counter, pointing at a pointer the new owner holds the key to +//! ([`Pointer::transfer_to`]). Every reader of the old address is redirected to +//! the new owner's pointer, the address never changes, and the former owner +//! has no move left — a larger counter does not exist, and an equal one does +//! not replace. +//! +//! Two different final states can still exist, because only the owner decides +//! what it signs: sign two and send them to different nodes at once, and each +//! node keeps whichever reached it first. That race is a fork, and nothing +//! local can settle it. What the rule does settle is everything after the +//! race: once a final state is held, no node holding it will ever take +//! another, so a fork can no longer be *made* once the network has a final +//! state — only found, if it was made in the race. Finding it, and reading the +//! state most of the close group holds, is the client's job. See `ADR-0018` in +//! `ant-node`. +//! //! # Encoding //! //! Encoded by hand: canonical, fixed-width, big-endian, no length prefixes and @@ -82,6 +102,12 @@ pub const DATA_TYPE_POINTER: u32 = 1; /// different state and is paid for separately. pub const POINTER_FORMAT_VERSION: u8 = 1; +/// The final counter. A record signed at it is never replaced. +/// +/// See [`PointerState::replaces`] for why, and [`Pointer::transfer_to`] for +/// what it is for. +pub const FINAL_COUNTER: u64 = u64::MAX; + /// Length of a raw ML-DSA-65 public key. pub const ML_DSA_65_PUBLIC_KEY_LEN: usize = 1952; @@ -155,7 +181,8 @@ pub enum PointerError { SignatureInvalid, /// Signing failed. SigningFailed(String), - /// The counter is at its maximum and cannot be advanced. + /// The counter is at [`FINAL_COUNTER`]: the pointer is final and no + /// further state can replace it. CounterExhausted, } @@ -178,8 +205,8 @@ impl std::fmt::Display for PointerError { Self::SigningFailed(reason) => write!(f, "pointer signing failed: {reason}"), Self::CounterExhausted => write!( f, - "pointer counter is at u64::MAX and is terminal; migrate to a fresh \ - pointer with an earlier update" + "pointer is final: its counter is at u64::MAX, and no later state \ + can replace it" ), } } @@ -430,15 +457,34 @@ impl PointerState { } } + /// Whether this state is final: signed at [`FINAL_COUNTER`], and so + /// replaced by nothing. + #[must_use] + pub const fn is_terminal(&self) -> bool { + self.counter == FINAL_COUNTER + } + + /// The pointer this state hands the address over to, if it is a transfer: + /// a final state whose target is another pointer. + /// + /// A final state targeting anything else freezes the pointer rather than + /// handing it on, and a pointer target below the final counter is a + /// forwarding the owner can still take back. Neither is a transfer. + #[must_use] + pub const fn transferred_to(&self) -> Option { + transfer_target(self.counter, self.target) + } + /// Whether `self` replaces `other` under the merge rule. /// /// ```text + /// 0. a final state (counter == u64::MAX) is replaced by nothing /// 1. larger counter /// 2. smaller target bytes /// ``` /// - /// A total order on the states of **one** pointer address. Records of - /// different owners are not comparable and neither replaces the other. + /// Records of different owners are not comparable and neither replaces the + /// other. /// /// Equal state never replaces, whatever the signature bytes: ML-DSA signing /// is randomized, so one authenticated state has unboundedly many valid @@ -447,15 +493,40 @@ impl PointerState { /// win — unbounded storage, replication and audit work for a single /// payment. /// - /// At `counter == u64::MAX` the order still holds, and that has a - /// consequence worth stating plainly: the pointer is **not frozen**. The - /// counter can no longer advance, but equal counters are still resolved by - /// target bytes, and *smaller* bytes win. So a terminal pointer can still - /// be moved — but only ever toward smaller target bytes, and never back. - /// See [`Pointer::next_counter`] for what that means for migration. + /// Rule 0 is what makes a pointer's last state final, and so what makes + /// ownership transferable by redirection ([`Pointer::transfer_to`]). Under + /// rules 1 and 2 alone a state at the final counter could still be + /// displaced by another at the same counter with smaller target bytes, so + /// a former owner could grind a target and take the address back after + /// handing it on. + /// + /// The cost is that the order is total only below the final counter. Two + /// different final states are incomparable: neither replaces the other, so + /// whichever a node took first is the one it keeps. That is the only fork + /// this rule allows, and only the owner can make it — by signing two final + /// states and racing them to different nodes. Once a node holds a final + /// state no arrival can change it, so a second final state can no longer + /// displace an established one anywhere; it can only land on a node that + /// held none. A reader tells the two sides apart by how many of the close + /// group hold each. + /// + /// Every other pair of distinct states is ordered, and the order is a + /// strict partial order everywhere: never both ways round, and transitive. #[must_use] pub fn replaces(&self, other: &Self) -> bool { - self.address == other.address && self.rank() > other.rank() + self.address == other.address && !other.is_terminal() && self.rank() > other.rank() + } +} + +/// The pointer a state at `counter` with `target` transfers to, if any. +/// +/// One definition for [`PointerState::transferred_to`] and +/// [`Pointer::transferred_to`], so a record and its parsed state cannot +/// disagree about whether it is a transfer. +const fn transfer_target(counter: u64, target: PointerTarget) -> Option { + match target.kind() { + Some(PointerTargetKind::Pointer) if counter == FINAL_COUNTER => Some(target.address), + _ => None, } } @@ -606,6 +677,63 @@ impl Pointer { Self::sign(secret_key, &self.owner, self.next_counter()?, target) } + /// Sign this pointer's final state, pointing at `target`. + /// + /// Signed at [`FINAL_COUNTER`], so once a node holds it nothing replaces + /// it — not a later update, which cannot exist, and not another final + /// state, which does not replace. Use [`Self::transfer_to`] to hand the + /// pointer to someone else; a final state pointing at a chunk freezes the + /// pointer on that chunk for good. + /// + /// Final means final for the signer too. Signing a second final state is + /// the one way to fork a pointer: each node keeps whichever of the two it + /// saw first. So this refuses to sign past a record that is already final, + /// and a caller must read the network's state before finalizing rather + /// than after. + /// + /// # Errors + /// + /// Returns [`PointerError::CounterExhausted`] if this record is already + /// final, otherwise as [`Self::sign`]. + pub fn finalize( + &self, + secret_key: &MlDsaSecretKey, + target: PointerTarget, + ) -> Result { + if self.is_terminal() { + return Err(PointerError::CounterExhausted); + } + Self::sign(secret_key, &self.owner, FINAL_COUNTER, target) + } + + /// Sign the final state that hands this pointer's address over to the + /// pointer at `recipient`. + /// + /// The owner key cannot change, but what the address resolves to can be + /// handed over for good: the final state points at a pointer the new owner + /// holds the key to, so every reader of this address is redirected there + /// and only the new owner can move it on. The address readers use does not + /// change. + /// + /// `recipient` should be a pointer that exists, and one whose owner uses + /// it only for this address: a pointer's address derives from its owner + /// key, so every address handed to the same recipient pointer resolves to + /// the same place. + /// + /// # Errors + /// + /// As [`Self::finalize`]. + pub fn transfer_to( + &self, + secret_key: &MlDsaSecretKey, + recipient: XorName, + ) -> Result { + self.finalize( + secret_key, + PointerTarget::new(PointerTargetKind::Pointer, recipient), + ) + } + /// Parse and fully validate a record. /// /// Checks, cheapest first: length, format version, structure, then the @@ -731,31 +859,32 @@ impl Pointer { /// /// # Errors /// - /// Returns [`PointerError::CounterExhausted`] at `u64::MAX`. + /// Returns [`PointerError::CounterExhausted`] at [`FINAL_COUNTER`]. /// - /// A pointer that must outlive its counter has to be migrated by an - /// **earlier** update — one that retargets it at a fresh pointer while a - /// successor counter still exists. A migration written *at* `u64::MAX` is - /// not final: the counter is spent, so the only thing that still decides - /// the merge is the target, and any record with smaller target bytes - /// displaces it. A strictly larger counter is the one move nothing can - /// answer, so migration must spend a counter it still has. + /// A record at the final counter is replaced by nothing, so there is no + /// update to sign past it. Migrating a pointer to a fresh one is exactly + /// that final update: see [`Self::transfer_to`]. pub fn next_counter(&self) -> Result { self.counter .checked_add(1) .ok_or(PointerError::CounterExhausted) } - /// Whether this pointer's counter is spent. + /// Whether this record is final: signed at [`FINAL_COUNTER`]. /// - /// `true` once the counter reaches `u64::MAX`. Note this does not mean the - /// stored value can no longer change: a record at the same counter with - /// smaller target bytes still wins. It means the *counter* can no longer - /// answer such a record, which is why clients migrate before reaching this - /// rather than on it. + /// A node that holds a final record keeps it: neither a lower counter nor + /// another final record replaces it. See [`PointerState::replaces`]. #[must_use] pub const fn is_terminal(&self) -> bool { - self.counter == u64::MAX + self.counter == FINAL_COUNTER + } + + /// The pointer this record hands its address over to, if it is a transfer. + /// + /// As [`PointerState::transferred_to`]. + #[must_use] + pub const fn transferred_to(&self) -> Option { + transfer_target(self.counter, self.target) } /// Whether `self` replaces `other` under the merge rule. @@ -905,9 +1034,8 @@ mod tests { #[test] fn nothing_out_ranks_a_terminal_counter() { - // The migration rule: at u64::MAX no later record can win, so a pointer - // that must outlive its counter has to be retargeted *before* the - // terminal update, not on it. + // No lower counter replaces a final state, and a final state replaces + // every lower one. let terminal = signed(1, u64::MAX, 5); for counter in [0u64, 1, 42, u64::MAX - 1] { let earlier = signed(1, counter, 0); @@ -920,14 +1048,139 @@ mod tests { } #[test] - fn a_terminal_counter_still_resolves_equal_counter_conflicts() { - // The order does not degenerate at the maximum: two records at - // u64::MAX with different targets still resolve deterministically, so - // replicas cannot split there. - let low_target = signed(1, u64::MAX, 1); - let high_target = signed(1, u64::MAX, 2); - assert!(low_target.replaces(&high_target), "smaller target wins"); - assert!(!high_target.replaces(&low_target)); + fn a_final_state_is_replaced_by_nothing_not_even_a_smaller_target() { + // Below the final counter, equal counters resolve by target bytes. At + // it they do not: a former owner who handed the address on could + // otherwise grind a smaller target and take it back. So two final + // states are incomparable, and a node keeps whichever it took first. + let first = signed(1, FINAL_COUNTER, 9); + let smaller_target = signed(1, FINAL_COUNTER, 1); + assert!( + !smaller_target.replaces(&first), + "a smaller target no longer wins" + ); + assert!(!first.replaces(&smaller_target)); + + // Below the final counter nothing changed. + let low = signed(1, 7, 1); + let high = signed(1, 7, 9); + assert!( + low.replaces(&high), + "smaller target still wins below the end" + ); + } + + #[test] + fn a_state_at_the_final_counter_is_final_whatever_it_targets() { + let (pk, sk) = keypair(40); + let chunk = PointerTarget::new(PointerTargetKind::Chunk, [4; XORNAME_LEN]); + let pointer = PointerTarget::new(PointerTargetKind::Pointer, [5; XORNAME_LEN]); + let unknown = PointerTarget::from_raw_tag(200, [6; XORNAME_LEN]); + for target in [chunk, pointer, unknown] { + let held = Pointer::sign(&sk, &pk, FINAL_COUNTER, target).expect("sign"); + assert!(held.is_terminal()); + assert!(held.state().is_terminal()); + for other in [chunk, pointer, unknown] { + for counter in [0, 1, FINAL_COUNTER - 1, FINAL_COUNTER] { + let arrival = Pointer::sign(&sk, &pk, counter, other).expect("sign"); + assert!( + !arrival.replaces(&held), + "counter {counter} must not replace a final state" + ); + } + } + } + } + + #[test] + fn a_transfer_is_a_final_state_that_targets_the_recipient_pointer() { + let (pk, sk) = keypair(41); + let recipient = [0x42; XORNAME_LEN]; + let current = Pointer::create( + &sk, + &pk, + PointerTarget::new(PointerTargetKind::Chunk, [1; XORNAME_LEN]), + ) + .expect("create"); + assert_eq!(current.transferred_to(), None); + + let transfer = current.transfer_to(&sk, recipient).expect("transfer"); + assert_eq!(transfer.counter(), FINAL_COUNTER); + assert_eq!(transfer.address(), current.address(), "the address stays"); + assert_eq!( + transfer.target(), + PointerTarget::new(PointerTargetKind::Pointer, recipient) + ); + assert_eq!(transfer.transferred_to(), Some(recipient)); + assert_eq!(transfer.state().transferred_to(), Some(recipient)); + assert!(transfer.replaces(¤t)); + + // The former owner has no move left: no later counter exists, and a + // second final state, however its target sorts, does not replace it. + assert!(matches!( + transfer.update(&sk, current.target()), + Err(PointerError::CounterExhausted) + )); + assert!( + matches!( + transfer.transfer_to(&sk, [0; XORNAME_LEN]), + Err(PointerError::CounterExhausted) + ), + "finalizing twice is refused rather than signed into a fork" + ); + let grinded = Pointer::sign( + &sk, + &pk, + FINAL_COUNTER, + PointerTarget::new(PointerTargetKind::Pointer, [0; XORNAME_LEN]), + ) + .expect("sign"); + assert!(!grinded.replaces(&transfer)); + } + + #[test] + fn only_a_final_pointer_target_is_a_transfer() { + let (pk, sk) = keypair(43); + let forward = PointerTarget::new(PointerTargetKind::Pointer, [7; XORNAME_LEN]); + let chunk = PointerTarget::new(PointerTargetKind::Chunk, [7; XORNAME_LEN]); + + // A pointer target below the final counter is a forwarding the owner + // can still take back, not a transfer. + let forwarding = Pointer::sign(&sk, &pk, FINAL_COUNTER - 1, forward).expect("sign"); + assert_eq!(forwarding.transferred_to(), None); + + // A final chunk target freezes the pointer; nobody receives it. + let frozen = Pointer::sign(&sk, &pk, FINAL_COUNTER, chunk).expect("sign"); + assert_eq!(frozen.transferred_to(), None); + + let handed_on = Pointer::sign(&sk, &pk, FINAL_COUNTER, forward).expect("sign"); + assert_eq!(handed_on.transferred_to(), Some([7; XORNAME_LEN])); + } + + #[test] + fn with_final_states_a_fold_keeps_the_first_final_it_meets() { + // A node folds `replaces` over arrivals in arrival order. Below the + // final counter the order is total, so the fold ends in one place + // whatever the order; two final states are the one pair it does not + // order, so the fold keeps whichever came first. That is the + // first-come rule, and the only place arrival order matters. + let fold = |arrivals: &[&Pointer]| -> XorName { + let mut held: Option<&Pointer> = None; + for arrival in arrivals { + if held.is_none_or(|h| arrival.replaces(h)) { + held = Some(arrival); + } + } + held.expect("something arrived").state_id() + }; + let early = signed(44, 3, 1); + let final_a = signed(44, FINAL_COUNTER, 9); + let final_b = signed(44, FINAL_COUNTER, 1); + + assert_eq!(fold(&[&early, &final_a, &final_b]), final_a.state_id()); + assert_eq!(fold(&[&final_b, &early, &final_a]), final_b.state_id()); + assert_eq!(fold(&[&final_a, &final_b, &early]), final_a.state_id()); + assert_eq!(fold(&[&early, &final_b]), final_b.state_id()); } #[test] @@ -1081,11 +1334,15 @@ mod tests { // whatever arrives. That fold gives the same answer whatever the order // exactly when the rule is a strict total order on one address: never // both ways round, always one way for distinct states, and transitive. + // It is, for every pair but two different final states — the pair the + // first-come rule leaves unordered on purpose. let records = [ signed(1, 0, 9), signed(1, 2, 1), signed(1, 5, 1), signed(1, 5, 2), + signed(1, FINAL_COUNTER, 3), + signed(1, FINAL_COUNTER, 4), ]; for a in &records { assert!(!a.replaces(a), "an equal state never replaces"); @@ -1094,9 +1351,10 @@ mod tests { !(a.replaces(b) && b.replaces(a)), "two records cannot each replace the other" ); + let both_final = a.is_terminal() && b.is_terminal(); assert!( - a.replaces(b) || b.replaces(a) || a.state_id() == b.state_id(), - "distinct states are always ordered" + a.replaces(b) || b.replaces(a) || a.state_id() == b.state_id() || both_final, + "distinct states are always ordered unless both are final" ); for c in &records { assert!( From 6c6e88f43ab91e6d6c14c47890442794816cb0db Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 11:30:31 +0900 Subject: [PATCH 2/5] docs(pointer): say what final redirection does not stop 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. --- CHANGELOG.md | 24 +++++++++------- src/pointer.rs | 76 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 67 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19fe1cf..8c7667c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,19 +14,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/). another final state with smaller target bytes. Below the final counter the order is unchanged. This is what makes ownership transferable by redirection: the owner signs one last state pointing at a pointer the new owner holds the - key to, every reader of the address is redirected there, and the former owner - has no move left. Previously a former owner could grind a smaller target at - the same counter and take the address back. + key to, every reader of the address is redirected there, and no node that + holds that state gives it up. Previously a former owner could grind a smaller + target at the same counter and take the address back from every node. The cost is that two *different* final states are unordered, so a node keeps - whichever it took first. That fork can only be made by the owner, only by - racing two final states to different nodes, and only while no node holds a - final state: once one does, no node that holds it will take another. Clients - decide between the two sides by how many of the close group hold each (see + whichever it took first. That fork can only be made by the owner, by signing + a second final state and getting it to nodes the first has not reached, at + once or later. It cannot take the address back from a node that holds the + first. Clients decide between the two sides by how many of the close group + hold each, and nodes look for a rival before taking a final state (see `ADR-0018` in `ant-node`). Nodes and clients must agree on this rule; a node on the previous rule still - lets a smaller-target final state displace the first one. + lets a smaller-target final state displace the first one. Nothing on the + wire tells the two rules apart: the pointer format version is still 1. ### Added @@ -34,8 +36,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `Pointer::transferred_to` / `PointerState::transferred_to`: sign and recognise the final state that hands a pointer's address over to another pointer. `finalize` refuses to sign past a record that is already final, since a second - final state is the one way to fork a pointer. `PointerState::is_terminal` - mirrors `Pointer::is_terminal`. + final state is the one way to fork a pointer. That guards a caller against a + mistake, not the network against the owner, who can still finalize an + earlier record again. `PointerState::is_terminal` mirrors + `Pointer::is_terminal`. - **Pointers** (`pointer`): a paid, mutable, owner-signed reference. One 5,303-byte record with the ML-DSA-65 owner key inlined, addressed at diff --git a/src/pointer.rs b/src/pointer.rs index 4ce5543..30d0d46 100644 --- a/src/pointer.rs +++ b/src/pointer.rs @@ -23,19 +23,21 @@ //! without touching the record: the owner signs one last state, at the final //! counter, pointing at a pointer the new owner holds the key to //! ([`Pointer::transfer_to`]). Every reader of the old address is redirected to -//! the new owner's pointer, the address never changes, and the former owner -//! has no move left — a larger counter does not exist, and an equal one does -//! not replace. +//! the new owner's pointer, the address never changes, and no node that holds +//! the final state gives it up — a larger counter does not exist, and an equal +//! one does not replace. //! //! Two different final states can still exist, because only the owner decides -//! what it signs: sign two and send them to different nodes at once, and each -//! node keeps whichever reached it first. That race is a fork, and nothing -//! local can settle it. What the rule does settle is everything after the -//! race: once a final state is held, no node holding it will ever take -//! another, so a fork can no longer be *made* once the network has a final -//! state — only found, if it was made in the race. Finding it, and reading the -//! state most of the close group holds, is the client's job. See `ADR-0018` in -//! `ant-node`. +//! what it signs, and the rule only stops a node from changing its mind. A +//! node that holds no final state takes whichever one reaches it first. So the +//! former owner can fork the pointer by signing a second final state and +//! getting it to nodes the first has not reached: racing both at once, or +//! sending the second later. Nothing local can settle that. What the rule does +//! settle is that no node ever gives up a final state it holds, so a fork +//! cannot take a final state back from the nodes that hold it; it can only +//! split the nodes that held none. Finding a fork, and reading the state most of the +//! close group holds, is the client's job; not taking a rival of a final state +//! the close group already holds is the node's. See `ADR-0018` in `ant-node`. //! //! # Encoding //! @@ -503,12 +505,12 @@ impl PointerState { /// The cost is that the order is total only below the final counter. Two /// different final states are incomparable: neither replaces the other, so /// whichever a node took first is the one it keeps. That is the only fork - /// this rule allows, and only the owner can make it — by signing two final - /// states and racing them to different nodes. Once a node holds a final - /// state no arrival can change it, so a second final state can no longer - /// displace an established one anywhere; it can only land on a node that - /// held none. A reader tells the two sides apart by how many of the close - /// group hold each. + /// this rule allows, and only the owner can make it — by signing a second + /// final state and getting it to nodes the first has not reached, at once + /// or later. Once a node holds a final state no arrival can change it, so a + /// second final state can never displace an established one; it can only + /// land on a node that held no final state. A reader tells the two sides + /// apart by how many of the close group hold each. /// /// Every other pair of distinct states is ordered, and the order is a /// strict partial order everywhere: never both ways round, and transitive. @@ -685,11 +687,13 @@ impl Pointer { /// pointer to someone else; a final state pointing at a chunk freezes the /// pointer on that chunk for good. /// - /// Final means final for the signer too. Signing a second final state is - /// the one way to fork a pointer: each node keeps whichever of the two it - /// saw first. So this refuses to sign past a record that is already final, - /// and a caller must read the network's state before finalizing rather - /// than after. + /// Signing a second final state is the one way to fork a pointer: each + /// node keeps whichever of the two it saw first. So this refuses to sign + /// past a record that is already final, and a caller must read the + /// network's state before finalizing rather than after. That refusal guards + /// a caller against a mistake, not the network against the owner: an + /// earlier record can be finalized again, and [`Self::sign`] signs any + /// counter. /// /// # Errors /// @@ -1115,7 +1119,7 @@ mod tests { assert_eq!(transfer.state().transferred_to(), Some(recipient)); assert!(transfer.replaces(¤t)); - // The former owner has no move left: no later counter exists, and a + // Nothing replaces the final state: no later counter exists, and a // second final state, however its target sorts, does not replace it. assert!(matches!( transfer.update(&sk, current.target()), @@ -1138,6 +1142,32 @@ mod tests { assert!(!grinded.replaces(&transfer)); } + #[test] + fn an_earlier_record_can_be_finalized_twice_and_neither_replaces_the_other() { + // The refusal to finalize a final record is a guard for the caller + // that holds it. The owner still holds the earlier record and its key, + // so it can sign a second, different final state. The merge rule does + // not order the two: a node keeps whichever it took first. + let (pk, sk) = keypair(44); + let current = Pointer::create( + &sk, + &pk, + PointerTarget::new(PointerTargetKind::Chunk, [1; XORNAME_LEN]), + ) + .expect("create"); + let to_a = current.transfer_to(&sk, [0xA; XORNAME_LEN]).expect("first"); + let to_b = current + .transfer_to(&sk, [0xB; XORNAME_LEN]) + .expect("second"); + assert_ne!(to_a.state().state_id, to_b.state().state_id); + assert!(to_a.replaces(¤t) && to_b.replaces(¤t)); + assert!( + !to_a.replaces(&to_b), + "neither final state replaces the other" + ); + assert!(!to_b.replaces(&to_a)); + } + #[test] fn only_a_final_pointer_target_is_a_transfer() { let (pk, sk) = keypair(43); From 9ad7fce38aa2b79ea6c532985930a3f347ab529b Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 12:14:32 +0900 Subject: [PATCH 3/5] docs(pointer): say a final state is permanent where it is held, not everywhere 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. --- CHANGELOG.md | 11 +++++++---- src/pointer.rs | 25 ++++++++++++++++--------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7667c..6f2dac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,10 +54,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). A quote is paid against `state_id = BLAKE3::derive_key("autonomi.pointer.state.v1", body)`, not the address: the address is stable for the pointer's life, so paying - against it would make every update after the first free. The merge rule is a - larger counter first, then smaller target bytes. The counter orders states - rather than metering them: any paid state that beats the held one is taken, - so a counter may skip and a fork at one counter is healed by any later one. + against it would make every update after the first free. Below + `FINAL_COUNTER` the merge rule is a larger counter first, then smaller target + bytes. The counter orders states rather than metering them: any paid state + that beats the held one is taken, so a counter may skip and a fork at one + counter below the final one is healed by any later one. A state at + `FINAL_COUNTER` is the exception (see Changed above): nothing replaces it, + and two different ones are never healed. - `ChunkMessageBody::{PointerPutRequest, PointerPutResponse, PointerGetRequest, PointerGetResponse}`, appended after every existing variant so the diff --git a/src/pointer.rs b/src/pointer.rs index 30d0d46..c2787f3 100644 --- a/src/pointer.rs +++ b/src/pointer.rs @@ -504,8 +504,11 @@ impl PointerState { /// /// The cost is that the order is total only below the final counter. Two /// different final states are incomparable: neither replaces the other, so - /// whichever a node took first is the one it keeps. That is the only fork - /// this rule allows, and only the owner can make it — by signing a second + /// whichever a node took first is the one it keeps. That is the only + /// conflict this rule leaves unordered, and so the only fork no later + /// state heals; a conflict at a lower counter is ordered by target bytes + /// and replaced by any later counter. Only the owner can make it — by + /// signing a second /// final state and getting it to nodes the first has not reached, at once /// or later. Once a node holds a final state no arrival can change it, so a /// second final state can never displace an established one; it can only @@ -685,7 +688,7 @@ impl Pointer { /// it — not a later update, which cannot exist, and not another final /// state, which does not replace. Use [`Self::transfer_to`] to hand the /// pointer to someone else; a final state pointing at a chunk freezes the - /// pointer on that chunk for good. + /// pointer on that chunk at every node that holds it. /// /// Signing a second final state is the one way to fork a pointer: each /// node keeps whichever of the two it saw first. So this refuses to sign @@ -714,10 +717,13 @@ impl Pointer { /// pointer at `recipient`. /// /// The owner key cannot change, but what the address resolves to can be - /// handed over for good: the final state points at a pointer the new owner - /// holds the key to, so every reader of this address is redirected there - /// and only the new owner can move it on. The address readers use does not - /// change. + /// handed over: the final state points at a pointer the new owner holds + /// the key to, so a reader of this address is redirected there by every + /// node that holds this state, and from there only the new owner moves it + /// on. It is not exclusive: the former owner can still finalize an earlier + /// record towards someone else, and a node that holds neither takes + /// whichever reaches it first (see [`Self::finalize`]). The address readers + /// use does not change. /// /// `recipient` should be a pointer that exists, and one whose owner uses /// it only for this address: a pointer's address derives from its owner @@ -1179,7 +1185,8 @@ mod tests { let forwarding = Pointer::sign(&sk, &pk, FINAL_COUNTER - 1, forward).expect("sign"); assert_eq!(forwarding.transferred_to(), None); - // A final chunk target freezes the pointer; nobody receives it. + // A final chunk target freezes the pointer at the nodes that hold it; + // nobody receives it. let frozen = Pointer::sign(&sk, &pk, FINAL_COUNTER, chunk).expect("sign"); assert_eq!(frozen.transferred_to(), None); @@ -1359,7 +1366,7 @@ mod tests { } #[test] - fn the_rule_is_a_strict_order_so_no_fold_depends_on_arrival_order() { + fn the_rule_is_a_strict_order_so_only_final_states_depend_on_arrival_order() { // Nodes and clients both pick a winner by folding `replaces` over // whatever arrives. That fold gives the same answer whatever the order // exactly when the rule is a strict total order on one address: never From 7218b60533fad8e3ffe357a89f163a38b6dc100d Mon Sep 17 00:00:00 2001 From: grumbach Date: Wed, 30 Sep 2026 13:24:21 +0900 Subject: [PATCH 4/5] docs(pointer): a transfer redirects readers where it is held, and forks 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. --- CHANGELOG.md | 6 +++--- src/pointer.rs | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2dac2..b9e0a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). another final state with smaller target bytes. Below the final counter the order is unchanged. This is what makes ownership transferable by redirection: the owner signs one last state pointing at a pointer the new owner holds the - key to, every reader of the address is redirected there, and no node that - holds that state gives it up. Previously a former owner could grind a smaller + key to, a reader of the address is redirected there by every node that holds + that state, and no node that holds it gives it up. Previously a former owner could grind a smaller target at the same counter and take the address back from every node. The cost is that two *different* final states are unordered, so a node keeps @@ -36,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `Pointer::transferred_to` / `PointerState::transferred_to`: sign and recognise the final state that hands a pointer's address over to another pointer. `finalize` refuses to sign past a record that is already final, since a second - final state is the one way to fork a pointer. That guards a caller against a + final state is the one way to fork a pointer for good. That guards a caller against a mistake, not the network against the owner, who can still finalize an earlier record again. `PointerState::is_terminal` mirrors `Pointer::is_terminal`. diff --git a/src/pointer.rs b/src/pointer.rs index c2787f3..cabf819 100644 --- a/src/pointer.rs +++ b/src/pointer.rs @@ -22,10 +22,10 @@ //! even another one at `u64::MAX`. That is what makes ownership transferable //! without touching the record: the owner signs one last state, at the final //! counter, pointing at a pointer the new owner holds the key to -//! ([`Pointer::transfer_to`]). Every reader of the old address is redirected to -//! the new owner's pointer, the address never changes, and no node that holds -//! the final state gives it up — a larger counter does not exist, and an equal -//! one does not replace. +//! ([`Pointer::transfer_to`]). A reader of the old address is redirected to the +//! new owner's pointer by every node that holds that final state, the address +//! never changes, and no node that holds the final state gives it up — a +//! larger counter does not exist, and an equal one does not replace. //! //! Two different final states can still exist, because only the owner decides //! what it signs, and the rule only stops a node from changing its mind. A @@ -690,8 +690,9 @@ impl Pointer { /// pointer to someone else; a final state pointing at a chunk freezes the /// pointer on that chunk at every node that holds it. /// - /// Signing a second final state is the one way to fork a pointer: each - /// node keeps whichever of the two it saw first. So this refuses to sign + /// Signing a second final state is the one way to fork a pointer for + /// good: each node keeps whichever of the two it saw first, and no later + /// state heals it, where a fork at a lower counter is. So this refuses to sign /// past a record that is already final, and a caller must read the /// network's state before finalizing rather than after. That refusal guards /// a caller against a mistake, not the network against the owner: an From ab459e52b3db19c0db15ed33a97fe6282dfd4ae0 Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 2 Oct 2026 11:31:27 +0900 Subject: [PATCH 5/5] docs(pointer): name the published rule a final state replaces ant-protocol 3.1.0 has shipped pointers with the earlier merge rule, and ant-node 0.21.0 runs it. The entry called that rule "previously", as if no release carried it. It now names 3.1.0, and says what a network running both rules shows: the same records can settle differently on different nodes. --- CHANGELOG.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e0a3b..bb31433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). order is unchanged. This is what makes ownership transferable by redirection: the owner signs one last state pointing at a pointer the new owner holds the key to, a reader of the address is redirected there by every node that holds - that state, and no node that holds it gives it up. Previously a former owner could grind a smaller + that state, and no node that holds it gives it up. Under the rule published + in 3.1.0, which `ant-node` 0.21.0 runs, a former owner could grind a smaller target at the same counter and take the address back from every node. The cost is that two *different* final states are unordered, so a node keeps @@ -26,9 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). hold each, and nodes look for a rival before taking a final state (see `ADR-0018` in `ant-node`). - Nodes and clients must agree on this rule; a node on the previous rule still - lets a smaller-target final state displace the first one. Nothing on the - wire tells the two rules apart: the pointer format version is still 1. + Nodes and clients must agree on this rule; a node on the 3.1.0 rule still + lets a smaller-target final state displace the first one, so while a network + runs both, the same records can settle differently on different nodes. + Nothing on the wire tells the two rules apart: the pointer format version is + still 1. ### Added