From 7f4f13a2b55f62229cb5ccc554403df76787e247 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Mon, 14 Sep 2026 13:41:45 +0200 Subject: [PATCH] perf(engine): hold a grant to one parent resolve and one read per moved node The convergence pass resolved the parent scope root a second time, after converge_grant_subtree had already resolved it for the resume probe. The pass now takes the proved pair from its caller, so sweep_pass keeps the resolve and converge_subtree does not repeat it. The resumed interior walk read every already-moved node twice: resolve_child paid a fanout resolve, a head fetch and a decode, then dropped them and moved_interior_node repeated all three. One seam, resolve_moving_child, now reads the record once and classifies it against both scope ids, each arm opening under the derivation of the scope the record claims. The promoted root's override seed is recovered once for the whole move instead of once per node. MovedScopeSeed holds it behind an Rc keyed on the root's own identity, so the memo is the terminal owner and the bytes zeroize when the slot is replaced. --- crates/contract/tests/contract.rs | 16 +- crates/engine/src/facade.rs | 5 +- crates/engine/src/grants/create.rs | 172 ++++++++++++++------ crates/engine/src/grants/invite_mint.rs | 14 +- crates/engine/src/grants/mod.rs | 8 +- crates/engine/src/net/cut.rs | 5 +- crates/engine/src/net/rotation.rs | 198 +++++++++++++++++++----- crates/engine/src/rotation/sweep.rs | 39 +++-- 8 files changed, 338 insertions(+), 119 deletions(-) diff --git a/crates/contract/tests/contract.rs b/crates/contract/tests/contract.rs index 06d58fd9c3..03609ac35f 100644 --- a/crates/contract/tests/contract.rs +++ b/crates/contract/tests/contract.rs @@ -33,8 +33,8 @@ use cipherbox_engine::api::{ use cipherbox_engine::content::{ContentProfile, DAG_ROOT_CODEC, assemble}; use cipherbox_engine::grants::{ GrantRecipient, GrantResumeResolver, GranteeScopePlan, InteriorRecord, InteriorResealer, - OwnerGrantKeys, ParentScopePlan, PromotedScopeRoot, ScopePointerVoucher, ScopeRootPromoter, - SharePointer, create_grant, import_contact, post_share_pointer, + MovingChild, OwnerGrantKeys, ParentScopePlan, PromotedScopeRoot, ScopePointerVoucher, + ScopeRootPromoter, SharePointer, create_grant, import_contact, post_share_pointer, }; use cipherbox_engine::mailbox::poll_verified; use cipherbox_engine::net::REGISTRY_BATCH_MAX; @@ -1387,12 +1387,16 @@ impl GrantResumeResolver for LocalNet { Ok(None) } - async fn moved_interior_node( + async fn resolve_moving_child( &self, + source: &ChildScopeRef, _root: &ResealedScopeRoot, - _node: &NodeRef, - ) -> Result, SweepResolveFailure> { - Ok(None) + node: &NodeRef, + ) -> Result { + match self.resolve_child(source, node).await? { + SweptChild::Interior(swept) => Ok(MovingChild::Pending(swept)), + SweptChild::ScopeRoot(_) => Ok(MovingChild::ScopeRoot), + } } async fn holds_a_scope_root_floor(&self, _node: &NodeRef) -> Result { diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index a9fe385aec..d4aa4137ee 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -83,7 +83,7 @@ use crate::net::cut::OwnerCutNet; use crate::net::record_publish::RecordPublishError; use crate::net::retire::{OrphanHeads, ReclaimStall, retire}; use crate::net::rotation::scope_name; -use crate::net::rotation::{GatedRoots, RotationAncestry, SweptScopeState}; +use crate::net::rotation::{GatedRoots, MovedScopeSeed, RotationAncestry, SweptScopeState}; use crate::net::{ Adopter, ChildAdopter, ChildResolveError, DescendantScopeRoot, EolRenewResult, FolderRefresh, FolderRefreshReport, GraftedLeg, HeldKey, HeldMaterial, HeldRecord, HeldRecords, @@ -2815,6 +2815,7 @@ where payload_version: POINTER_PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), }; // The cut about to run mints a fresh seed at a fresh epoch, so the walked // material for this scope is superseded the moment it lands. Standing the @@ -5713,6 +5714,7 @@ where { payload_version: POINTER_PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), }; // The wave is idempotent and every later write advances it, // so a pass that does not converge is left to the next one. @@ -7205,6 +7207,7 @@ where { payload_version: POINTER_PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), } } diff --git a/crates/engine/src/grants/create.rs b/crates/engine/src/grants/create.rs index b11dd038b7..62c4b1c893 100644 --- a/crates/engine/src/grants/create.rs +++ b/crates/engine/src/grants/create.rs @@ -59,7 +59,7 @@ use crate::rotation::sweep::{body_children, canonicalize_frontier, resolve_scope use crate::rotation::{ AscentAuthority, CascadeResealResolver, CommittedSet, NodeRef, ResealError, ResealSeeds, ResealedScopeRoot, ResolveFailure, RotationPublishError, ScopeRootIdentity, ScopeRootPublisher, - SweepError, SweepPublisher, SweepResolveFailure, SweepResolver, SweptChild, WriteHistory, + SweepError, SweepPublisher, SweepResolveFailure, SweepResolver, SweptNode, WriteHistory, converge_subtree, derive_write_name, reseal_scope_root, }; use crate::seams::{Mailbox, SeamError}; @@ -487,17 +487,21 @@ pub trait GrantResumeResolver { node: &NodeRef, ) -> Result, ResolveFailure>; - /// Read `node` as a record a stalled move already published into `root`, or - /// `None` when the record at its name claims another scope. + /// Read `node` once and classify it against both scopes the move admits: + /// `source`, the scope the folder is leaving, and `root`, the scope it is + /// moving into. /// - /// Authenticity comes from `root`'s own derivation — the override seed out - /// of its section's owner blob, at the epoch the record claims — so `None` - /// never widens what a walk admits. - async fn moved_interior_node( + /// Each arm authenticates the record under the derivation of the scope it + /// claims — `source`'s gated read seed, or the override seed out of + /// `root`'s own section's owner blob — so admitting either scope for the + /// duration of the move widens nothing. A record claiming neither is a + /// fail-closed [`SweepResolveFailure::Rejected`]. + async fn resolve_moving_child( &self, + source: &ChildScopeRef, root: &ResealedScopeRoot, node: &NodeRef, - ) -> Result, SweepResolveFailure>; + ) -> Result; /// Whether this device holds a read-epoch floor at `node`'s own scope id. /// @@ -509,6 +513,19 @@ pub trait GrantResumeResolver { async fn holds_a_scope_root_floor(&self, node: &NodeRef) -> Result; } +/// Which of the two scopes a grant's interior move admits the one record at a +/// node's name claims ([`GrantResumeResolver::resolve_moving_child`]). +pub enum MovingChild { + /// Still in the scope the folder is leaving, so the move owes it the + /// re-seal. + Pending(SweptNode), + /// Already in the grantee scope, published by a stalled attempt. The walk + /// carries on through its children and publishes nothing. + Moved(ReadBody), + /// A scope root, which the move never walks through. + ScopeRoot, +} + /// The scope root a stalled grant already published over the granted folder, as /// the gate authenticated it. pub struct PromotedScopeRoot { @@ -835,7 +852,9 @@ where { return Err(CreateGrantError::TargetAlreadyNamesAScope); } - let swept = converge_subtree(resolver, publisher, &parent_ref, &folder) + // The pass runs on the scope this command already proved current, so the + // parent name is resolved once here and not again inside the pass. + let swept = converge_subtree(resolver, publisher, &parent_ref, parent_scope, &folder) .await .map_err(CreateGrantError::Converge)?; // A node the pass could not read is as unproven as one whose convergence @@ -1314,10 +1333,11 @@ pub(crate) fn commits_write_grant( /// Re-seal every interior node under the granted folder into `root`, the scope /// published over that folder. /// -/// The walk descends from `frontier` — `root`'s own body — reading each node in -/// `source`, the scope it is leaving, and falling through to `root` for a node a -/// stalled attempt already moved there. Admitting either scope id for the -/// duration of the move is what makes the leg re-drivable ([`GrantResumeResolver`]). +/// The walk descends from `frontier` — `root`'s own body — reading each node +/// once under whichever of `source`, the scope it is leaving, and `root`, the +/// scope it is moving into, the record claims. Admitting either scope id for the +/// duration of the move is what makes the leg re-drivable +/// ([`GrantResumeResolver::resolve_moving_child`]). /// /// [`InteriorBounds::stop_at`] is skipped, because a scope root is re-keyed as /// one and its own interior stays in the scope it already belongs to. A node @@ -1358,8 +1378,8 @@ where node_id: child.node_id, }); } - match resolver.resolve_child(source, child).await { - Ok(SweptChild::Interior(node)) => { + match resolver.resolve_moving_child(source, root, child).await { + Ok(MovingChild::Pending(node)) => { // Release-active (security rule 8). The read admits any // record at or below the scope's epoch, so a record that // regressed since the pass would travel into the grantee's @@ -1389,26 +1409,12 @@ where error, })?; } - Ok(SweptChild::ScopeRoot(_)) => { + Ok(MovingChild::ScopeRoot) => { return Err(CreateGrantError::InteriorNotConverged { node_id: child.node_id, }); } - // The scope the folder is leaving does not authenticate the - // record, so either this move already published it into `root` - // or nothing here opens it. - Err(SweepResolveFailure::Rejected) => { - let moved = resolver - .moved_interior_node(root, child) - .await - .map_err(|reason| CreateGrantError::InteriorResolve { - node_id: child.node_id, - reason, - })? - .ok_or(CreateGrantError::InteriorResolve { - node_id: child.node_id, - reason: SweepResolveFailure::Rejected, - })?; + Ok(MovingChild::Moved(moved)) => { next.extend(body_children(&moved)); } Err(reason) => { @@ -1427,6 +1433,7 @@ where #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeMap; /// Records what the mint vouched for, and can be told to refuse — the /// pointer plane's half of the mint, without a network. @@ -1601,6 +1608,12 @@ mod tests { /// cascade re-seal resolve. A refusal that must land before the /// convergence gate leaves this at zero. resolve_calls: Rc>, + /// Scope-root resolves alone, so a test can hold one command to one + /// resolve of the parent name. + scope_resolves: Rc>, + /// Read-seam entries per node id, so a test can hold the interior move + /// to one read of each node it walks. + node_reads: Rc>>, fail_after: Option<(usize, RotationPublishError)>, /// Interior nodes **inside** the granted folder: id → published epoch. interior: Rc>>, @@ -1644,8 +1657,8 @@ mod tests { /// The direct-child-scope index that root reparented. promoted_boundaries: Vec, /// Interior nodes already re-sealed into the granted scope. The scope - /// the folder left no longer authenticates them, so `resolve_child` - /// refuses them and `moved_interior_node` answers instead. + /// the folder left no longer authenticates them, so the move's read + /// answers them out of the granted scope instead. moved: Rc>>, /// One node whose re-seal publish stalls, so a test can strand the tail /// of a subtree and then let it through. @@ -1661,6 +1674,8 @@ mod tests { publish_result, publish_calls: Rc::new(RefCell::new(0)), resolve_calls: Rc::new(RefCell::new(0)), + scope_resolves: Rc::new(RefCell::new(0)), + node_reads: Rc::new(RefCell::new(BTreeMap::new())), fail_after: None, interior: Rc::new(RefCell::new(Vec::new())), outside: Rc::new(RefCell::new(Vec::new())), @@ -1829,6 +1844,19 @@ mod tests { fn resolve_calls(&self) -> usize { *self.resolve_calls.borrow() } + + fn scope_resolves(&self) -> usize { + *self.scope_resolves.borrow() + } + + fn count_node_read(&self, node_id: [u8; 16]) { + self.count_resolve(); + *self.node_reads.borrow_mut().entry(node_id).or_default() += 1; + } + + fn node_reads(&self, node_id: [u8; 16]) -> usize { + self.node_reads.borrow().get(&node_id).copied().unwrap_or(0) + } } impl SweepResolver for FakeNet { @@ -1837,6 +1865,7 @@ mod tests { scope: &ChildScopeRef, ) -> Result { self.count_resolve(); + *self.scope_resolves.borrow_mut() += 1; if scope.scope_id != PARENT_SCOPE { return Err(SweepResolveFailure::Rejected); } @@ -1868,7 +1897,7 @@ mod tests { _scope: &ChildScopeRef, child: &NodeRef, ) -> Result { - self.count_resolve(); + self.count_node_read(child.node_id); if self.unresolvable == Some(child.node_id) { return Err(SweepResolveFailure::Unavailable); } @@ -2063,26 +2092,31 @@ mod tests { Ok(self.floored.borrow().contains(&node.node_id)) } - async fn moved_interior_node( + async fn resolve_moving_child( &self, + source: &ChildScopeRef, _root: &ResealedScopeRoot, node: &NodeRef, - ) -> Result, SweepResolveFailure> { - if !self.moved.borrow().contains(&node.node_id) { - return Ok(None); + ) -> Result { + if self.moved.borrow().contains(&node.node_id) { + self.count_node_read(node.node_id); + return Ok(MovingChild::Moved(ReadBody::Folder { + created_at: 0, + modified_at: 0, + children: child_refs( + self.nested + .borrow() + .iter() + .filter(|(parent, _, _)| *parent == node.node_id) + .map(|(_, node_id, _)| *node_id), + ), + unknown: PreservedFields::new(), + })); + } + match self.resolve_child(source, node).await? { + SweptChild::Interior(swept) => Ok(MovingChild::Pending(swept)), + SweptChild::ScopeRoot(_) => Ok(MovingChild::ScopeRoot), } - Ok(Some(ReadBody::Folder { - created_at: 0, - modified_at: 0, - children: child_refs( - self.nested - .borrow() - .iter() - .filter(|(parent, _, _)| *parent == node.node_id) - .map(|(_, node_id, _)| *node_id), - ), - unknown: PreservedFields::new(), - })) } } @@ -2891,6 +2925,44 @@ mod tests { ); } + #[test] + fn a_grant_resolves_the_parent_scope_root_once() { + // The convergence pass runs on the scope the resume probe already proved + // current, so the parent name costs one resolve for the whole command. + let net = FakeNet::new(Ok(())).with_interior(INTERIOR_NODE, PARENT_EPOCH); + let (outcome, _published, _hub) = run(9, &[], net.clone(), &[]); + outcome.expect("the grant completes"); + assert_eq!(net.scope_resolves(), 1); + } + + #[test] + fn a_resumed_walk_reads_each_already_moved_node_once() { + // A re-drive meets the first attempt's nodes in the scope it published + // them into. Classifying that one read against both scopes is what keeps + // the re-drive from paying the subtree's reads twice. + let net = FakeNet::new(Ok(())) + .with_interior(INTERIOR_NODE, PARENT_EPOCH) + .with_interior(SECOND_NODE, PARENT_EPOCH) + .stalling_reseal_at(SECOND_NODE); + stall_grant(&net); + let moved_reads = net.node_reads(INTERIOR_NODE); + let scope_resolves = net.scope_resolves(); + + let (resumed, _published, _hub) = run(8, &[], net.clone(), &[]); + resumed.expect("the re-drive finishes the owed move"); + + assert_eq!( + net.node_reads(INTERIOR_NODE) - moved_reads, + 1, + "the node the first attempt moved is read once on the re-drive", + ); + assert_eq!( + net.scope_resolves() - scope_resolves, + 1, + "the re-drive resolves the parent name once", + ); + } + #[test] fn a_promoted_root_that_commits_no_row_for_this_recipient_is_not_resumed() { // Resuming grafts no second recipient onto a scope: their blob is not in diff --git a/crates/engine/src/grants/invite_mint.rs b/crates/engine/src/grants/invite_mint.rs index ffbe39cd29..232af34e6d 100644 --- a/crates/engine/src/grants/invite_mint.rs +++ b/crates/engine/src/grants/invite_mint.rs @@ -221,7 +221,7 @@ where #[cfg(test)] mod tests { use super::super::create::{ - GrantResumeResolver, InteriorRecord, InteriorResealer, PromotedScopeRoot, + GrantResumeResolver, InteriorRecord, InteriorResealer, MovingChild, PromotedScopeRoot, }; use super::*; use crate::rotation::published_override_seed; @@ -471,12 +471,16 @@ mod tests { Ok(false) } - async fn moved_interior_node( + async fn resolve_moving_child( &self, + source: &ChildScopeRef, _root: &ResealedScopeRoot, - _node: &NodeRef, - ) -> Result, SweepResolveFailure> { - Ok(None) + node: &NodeRef, + ) -> Result { + match self.resolve_child(source, node).await? { + SweptChild::Interior(swept) => Ok(MovingChild::Pending(swept)), + SweptChild::ScopeRoot(_) => Ok(MovingChild::ScopeRoot), + } } } diff --git a/crates/engine/src/grants/mod.rs b/crates/engine/src/grants/mod.rs index 33eb067d6d..59521d4803 100644 --- a/crates/engine/src/grants/mod.rs +++ b/crates/engine/src/grants/mod.rs @@ -44,10 +44,10 @@ pub use contact_store::{ pub(crate) use create::commits_write_grant; pub use create::{ ConvergedSubtree, CreateGrantError, CreateGrantOutcome, GrantRecipient, GrantResumeResolver, - GrantSubtree, GranteeScopePlan, InteriorRecord, InteriorResealer, MintNet, OwnerGrantKeys, - ParentScopePlan, PromotedScopeRoot, PromotedSubtree, ScopePointerVoucher, ScopeRootPromoter, - converge_grant_subtree, create_grant, mint_grantee_scope, post_share_pointer, - resume_grantee_scope, + GrantSubtree, GranteeScopePlan, InteriorRecord, InteriorResealer, MintNet, MovingChild, + OwnerGrantKeys, ParentScopePlan, PromotedScopeRoot, PromotedSubtree, ScopePointerVoucher, + ScopeRootPromoter, converge_grant_subtree, create_grant, mint_grantee_scope, + post_share_pointer, resume_grantee_scope, }; pub use invite::{ CLAIM_ID_LEN, ClaimOutcome, CommittedLink, CommittedScope, ConvertedClaim, diff --git a/crates/engine/src/net/cut.rs b/crates/engine/src/net/cut.rs index 1cdb80f9f6..95dde4fb30 100644 --- a/crates/engine/src/net/cut.rs +++ b/crates/engine/src/net/cut.rs @@ -24,8 +24,8 @@ use crate::facade::{Event, NodeId}; use crate::gate::floor; use crate::net::liveness::HeldRecords; use crate::net::rotation::{ - GatedRoots, GatedWaveRoot, OwnerRotationKeys, OwnerRotationNet, PointerConsultArm, - RotationAncestry, SweptScopeState, WaveSubtree, WriteWaveNet, + GatedRoots, GatedWaveRoot, MovedScopeSeed, OwnerRotationKeys, OwnerRotationNet, + PointerConsultArm, RotationAncestry, SweptScopeState, WaveSubtree, WriteWaveNet, }; use crate::profile::SyncTimingProfile; use crate::rotation::{ @@ -161,6 +161,7 @@ where payload_version: self.payload_version, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), } } } diff --git a/crates/engine/src/net/rotation.rs b/crates/engine/src/net/rotation.rs index 39dafcced0..024c380d76 100644 --- a/crates/engine/src/net/rotation.rs +++ b/crates/engine/src/net/rotation.rs @@ -71,9 +71,9 @@ use crate::gate::{ use crate::grants::child_index::canonicalize; use crate::grants::create::ScopePointerVoucher; use crate::grants::{ - GrantResumeResolver, InteriorRecord, InteriorResealer, PromotedScopeRoot, ScopeRootPromoter, - UNATTESTED_IDENTITY_PK, enforce_committed_ledger, mint_grant_row, recipient_self_location, - row_is_owner_attested, self_locate_signed, + GrantResumeResolver, InteriorRecord, InteriorResealer, MovingChild, PromotedScopeRoot, + ScopeRootPromoter, UNATTESTED_IDENTITY_PK, enforce_committed_ledger, mint_grant_row, + recipient_self_location, row_is_owner_attested, self_locate_signed, }; use crate::net::fanout::{FanoutRecord, fanout_get_classified, fanout_get_verify}; use crate::net::resolve::Adopter; @@ -200,6 +200,9 @@ pub struct OwnerRotationNet<'a, T, H: Http, C: CredentialStore, F, Sch, E, S> { /// The scope the sweep is walking, held from the scope-root read that /// proved it (see [`SweptScopeState`]). One sweep pass per net. pub swept: SweptScopeState, + /// The override seed of the scope a grant's interior move is publishing + /// into (see [`MovedScopeSeed`]). One move per net. + pub moved_seed: MovedScopeSeed, } /// The one scope root this pass gated and has not yet republished. @@ -232,6 +235,62 @@ impl GatedRoots { } } +/// The override seed of the scope a grant's interior move publishes into, +/// recovered from that root's own owner blob and held for the length of the +/// move. +/// +/// Every node of one move seals under the same seed, and recovering it is an +/// X25519 operation plus an owner-blob open, so without this the move pays both +/// once per node. One slot keyed on the root's own identity, as [`GatedRoots`] +/// is keyed on a name: a second root evicts the first rather than aliasing onto +/// it. Held behind an [`Rc`] so the seed has one live copy that zeroizes when +/// the slot is replaced or dropped. +#[derive(Default)] +pub struct MovedScopeSeed { + inner: RefCell>, +} + +/// One recovered override seed under the root identity it belongs to: a record +/// naming any other root re-recovers rather than reading under this seed. +struct HeldMovedSeed { + key: MovedScopeKey, + seed: Rc>, +} + +/// The root identity a recovered override seed is held under. +#[derive(PartialEq, Eq)] +struct MovedScopeKey { + scope_id: [u8; 16], + ipns_name: Vec, + read_epoch: u64, +} + +impl MovedScopeSeed { + fn recover( + &self, + enc_secret: &X25519Secret, + record: &ResealedScopeRoot, + ) -> Result>, RotationPublishError> { + let key = MovedScopeKey { + scope_id: record.scope_id, + ipns_name: record.ipns_name.clone(), + read_epoch: record.read_epoch, + }; + let mut held = self.inner.borrow_mut(); + if let Some(parked) = held.as_ref() + && parked.key == key + { + return Ok(Rc::clone(&parked.seed)); + } + let seed = Rc::new(new_override_seed(enc_secret, record)?); + *held = Some(HeldMovedSeed { + key, + seed: Rc::clone(&seed), + }); + Ok(seed) + } +} + /// What republishing a scope root needs from the record it replaces: the body /// carried forward, the envelope fields a republish preserves byte-stable /// (#27 D10), and the write seed the record's name signs under. @@ -1952,7 +2011,8 @@ where record: &ResealedScopeRoot, ) -> Result, RotationPublishError> { let name = scope_name(&record.ipns_name).map_err(publish_verdict)?; - let override_seed = new_override_seed(self.keys.enc_secret, record)?; + // The interior move this promotion heads seals under the same seed. + let override_seed = self.moved_seed.recover(self.keys.enc_secret, record)?; let source = self .swept_scope(parent) .map_err(|_| RotationPublishError::Rejected)?; @@ -2584,7 +2644,7 @@ where name: &IpnsName, sequence: u64, envelope: Envelope, - ) -> Result { + ) -> Result { if envelope.v != ENVELOPE_V { return Err(SweepResolveFailure::VersionSkew); } @@ -2604,13 +2664,49 @@ where &envelope, ) .await?; - Ok(SweptChild::Interior(SweptNode { + Ok(SweptNode { current_read_epoch: envelope.epoch, sequence, read_body, carried_unknown: envelope.unknown, carried_epoch_tag_unknown: envelope.epoch_tag_unknown, - })) + }) + } + + /// Open a node a stalled move already published into `root`. + /// + /// Authenticity comes from `root`'s own derivation: the override seed out of + /// its section's owner blob, at the epoch the record claims. + async fn moved_interior_node( + &self, + root: &ResealedScopeRoot, + node: &NodeRef, + name: &IpnsName, + sequence: u64, + envelope: &Envelope, + ) -> Result { + if envelope.v != ENVELOPE_V { + return Err(SweepResolveFailure::VersionSkew); + } + if envelope.id != node.node_id { + return Err(SweepResolveFailure::Rejected); + } + let override_seed = self + .moved_seed + .recover(self.keys.enc_secret, root) + .map_err(|_| SweepResolveFailure::Rejected)?; + self.open_interior_record( + &InteriorReadScope { + scope_id: root.scope_id, + read_epoch: root.read_epoch, + read_scope_seed: &override_seed, + history_links: &root.section.history_links, + }, + name, + sequence, + envelope, + ) + .await } /// The interior read rule itself, over whichever scope's derivation the @@ -2808,6 +2904,7 @@ where } self.interior_node(&source, child, &name, sequence, envelope) .await + .map(SweptChild::Interior) } } @@ -3027,7 +3124,7 @@ where } // Recovered from the owner blob the minted section wraps, so the seam // carries no seed ([`new_override_seed`]). - let override_seed = new_override_seed(self.keys.enc_secret, root)?; + let override_seed = self.moved_seed.recover(self.keys.enc_secret, root)?; let read_key = read_key_for(&override_seed, &node.node_id); self.publish_interior_head( &name, @@ -3140,11 +3237,13 @@ where .is_some()) } - async fn moved_interior_node( + async fn resolve_moving_child( &self, + source: &ChildScopeRef, root: &ResealedScopeRoot, node: &NodeRef, - ) -> Result, SweepResolveFailure> { + ) -> Result { + let source = self.swept_scope(source)?; let name = scope_name(&node.ipns_name).map_err(SweepResolveFailure::from)?; let Some((_, record_bytes)) = fanout_get_verify(self.transport, &name).await else { return Err(SweepResolveFailure::Unavailable); @@ -3154,34 +3253,29 @@ where .await .map_err(read_verdict)?; let envelope = decode_envelope(&block).map_err(|_| SweepResolveFailure::Rejected)?; - if envelope.v != ENVELOPE_V { - return Err(SweepResolveFailure::VersionSkew); - } - if envelope.id != node.node_id || has_grant_section(&envelope) { - return Err(SweepResolveFailure::Rejected); + if has_grant_section(&envelope) { + self.gated_child_scope_root( + &source, + node, + &name, + &record_bytes, + LocalHead { + cid: root_block_cid(&block), + block, + }, + ) + .await?; + return Ok(MovingChild::ScopeRoot); } - // The move still owes this node: the caller re-seals it out of the scope - // it is leaving. - if envelope.scope != root.scope_id { - return Ok(None); + if envelope.scope == root.scope_id { + return self + .moved_interior_node(root, node, &name, sequence, &envelope) + .await + .map(MovingChild::Moved); } - // The seed the move sealed under is the one this root's own owner blob - // yields, never a value the walk carries. - let override_seed = new_override_seed(self.keys.enc_secret, root) - .map_err(|_| SweepResolveFailure::Rejected)?; - self.open_interior_record( - &InteriorReadScope { - scope_id: root.scope_id, - read_epoch: root.read_epoch, - read_scope_seed: &override_seed, - history_links: &root.section.history_links, - }, - &name, - sequence, - &envelope, - ) - .await - .map(Some) + self.interior_node(&source, node, &name, sequence, envelope) + .await + .map(MovingChild::Pending) } } @@ -4643,6 +4737,7 @@ pub(crate) async fn enrol_owned_scope_pointers( payload_version: pass.payload_version, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), }; let Ok(root) = net.resolve_vault_root(&vault_root).await else { return; @@ -5122,6 +5217,7 @@ mod tests { payload_version: PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), } } } @@ -6433,6 +6529,36 @@ mod tests { } } + #[test] + fn one_override_seed_recovery_serves_a_whole_interior_move() { + // The seed is an X25519 operation plus an owner-blob open, and a move + // seals every node of the subtree under the same one. + let root = vault_root(SCOPE, Vec::new()); + let first_root = cut(&root, SCOPE, OWNER_ROOT_EPOCH + 1); + let enc_secret = owner_enc(); + let memo = MovedScopeSeed::default(); + + let first = memo + .recover(&enc_secret, &first_root) + .expect("the owner blob yields the seed"); + let again = memo + .recover(&enc_secret, &first_root) + .expect("the owner blob yields the seed"); + assert!( + Rc::ptr_eq(&first, &again), + "the same root is recovered once for the whole move", + ); + + let second_root = cut(&root, SCOPE, OWNER_ROOT_EPOCH + 2); + let evicted = memo + .recover(&enc_secret, &second_root) + .expect("the owner blob yields the seed"); + assert!( + !Rc::ptr_eq(&first, &evicted), + "another root recovers its own seed rather than aliasing onto the held one", + ); + } + /// `SCOPE`'s root staged at the fixture epoch on a plain harness, plus the /// cut `rotate_scope` would hand the publisher for it. fn staged_cut() -> ( diff --git a/crates/engine/src/rotation/sweep.rs b/crates/engine/src/rotation/sweep.rs index ffdcc9b543..dd839d0d43 100644 --- a/crates/engine/src/rotation/sweep.rs +++ b/crates/engine/src/rotation/sweep.rs @@ -513,7 +513,13 @@ where R: SweepResolver, P: SweepPublisher, { - walk_and_converge(resolver, publisher, scope, None).await + let (scope_ref, swept) = resolve_scope_current(resolver, scope) + .await + .map_err(|reason| SweepError::Scope { + scope_id: scope.scope_id, + reason, + })?; + walk_and_converge(resolver, publisher, &scope_ref, swept, None).await } /// Converge just the subtree rooted at `node` inside `scope` — grant creation's @@ -522,38 +528,39 @@ where /// /// `node` itself is measured against the scope's epoch too: the granted folder /// is an interior node until the mint publishes its new scope root over it. +/// +/// `scope` and `swept` are what [`resolve_scope_current`] proved, and the caller +/// passes them in: grant creation must resolve the parent itself, because its +/// resume probe reads the scope source only that resolve parks, and one command +/// owes the parent name one resolve. pub async fn converge_subtree( resolver: &R, publisher: &P, scope: &ChildScopeRef, + swept: SweptScope, node: &NodeRef, ) -> Result where R: SweepResolver, P: SweepPublisher, { - walk_and_converge(resolver, publisher, scope, Some(node)).await + walk_and_converge(resolver, publisher, scope, swept, Some(node)).await } -/// The one pass both entry points run: gate the scope root, walk from `from` -/// (or from the root's own body), self-heal the index, re-seal what lags. +/// The one pass both entry points run over a scope root already proved current: +/// walk from `from` (or from the root's own body), self-heal the index, re-seal +/// what lags. async fn walk_and_converge( resolver: &R, publisher: &P, - scope: &ChildScopeRef, + scope_ref: &ChildScopeRef, + swept: SweptScope, from: Option<&NodeRef>, ) -> Result where R: SweepResolver, P: SweepPublisher, { - let (scope_ref, swept) = resolve_scope_current(resolver, scope) - .await - .map_err(|reason| SweepError::Scope { - scope_id: scope.scope_id, - reason, - })?; - let boundaries: BTreeSet<[u8; 16]> = swept .direct_child_scope_index .iter() @@ -593,7 +600,7 @@ where outcome.skipped_scope_roots.push(child.node_id); continue; } - let (resolved, found) = match resolve_child_current(resolver, &scope_ref, child).await { + let (resolved, found) = match resolve_child_current(resolver, scope_ref, child).await { Ok(pair) => pair, Err(reason) if reason.isolates_the_node() => { outcome.unreachable.push((child.node_id, reason)); @@ -635,7 +642,7 @@ where index = repair_observed(&index, scope_root.clone()); } if index != swept.direct_child_scope_index { - match publisher.repair_child_scope_index(&scope_ref, &index).await { + match publisher.repair_child_scope_index(scope_ref, &index).await { Ok(()) => outcome .flagged_indexes .extend(omitted.iter().map(|root| root.scope_id)), @@ -660,7 +667,7 @@ where carried_unknown: &swept_node.carried_unknown, carried_epoch_tag_unknown: &swept_node.carried_epoch_tag_unknown, }; - match publisher.publish_node(&scope_ref, &lagging_node).await { + match publisher.publish_node(scope_ref, &lagging_node).await { Ok(()) => outcome.converged.push(node.node_id), // The one spec-mandated non-abort per-node path. The winner may be a // non-advancing ordinary write, so the node is not proven converged; @@ -1278,6 +1285,7 @@ mod tests { &net, &net, &scope_ref(0x00), + block_on(net.resolve_scope(&scope_ref(0x00))).expect("the scope resolves"), &node_ref(0x01), )) .expect("the subtree converges"); @@ -1294,6 +1302,7 @@ mod tests { &net, &net, &scope_ref(0x00), + block_on(net.resolve_scope(&scope_ref(0x00))).expect("the scope resolves"), &node_ref(0x01), )) .expect("converges");