From 6c34f481f3fae46c424dbd4d47ccb3b6709e773a Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 30 Sep 2026 00:45:29 +0200 Subject: [PATCH 1/4] fix(engine): journal a root-only debt when a dropped version's staged root is gone A drop whose staged root is gone, fails its own CID, or does not decode now journals a retire-ledger entry of the new origin dropped root, keyed by the root CID the op record names and priced at its size. The settle fetches the root, reads the owing node as unconfirmed and retires the whole version under the node's record. A root no source serves stays owed as a TargetUnexpandable stall. The preserved-set trim now journals this debt for an entry whose root stopped opening, where it dropped the entry in silence before. Implements ADR 0059 D1 and its blueprint rewords. --- blueprint/engine.md | 8 +- crates/engine/src/net/retire.rs | 139 +++++++++++++++++++++-- crates/engine/src/seams/retire_ledger.rs | 8 +- crates/engine/src/sync/staging.rs | 90 ++++++++++++--- crates/engine/tests/write_plane.rs | 40 ++++++- 5 files changed, 252 insertions(+), 33 deletions(-) diff --git a/blueprint/engine.md b/blueprint/engine.md index 03b00377e5..ce3bc759fd 100644 --- a/blueprint/engine.md +++ b/blueprint/engine.md @@ -151,7 +151,9 @@ bytes (FSM1/cipher-box-next#28 D2). version — a discard, a refused preserved entry, or the preserved-set trim — journals every row the version charged to the retire ledger first, and the settle retires them once the name holds a record above the sequence its PUT - was acknowledged at (ADR 0054). A publish that fails **before the record reaches + was acknowledged at (ADR 0054). A drop whose staged root is gone, fails its + own CID, or does not decode journals the same debt from the root CID the op + record names (ADR 0059). A publish that fails **before the record reaches the transport** — register-first, the floor read, the head-CID echo, or an upload whose ack never came back — is the mirror case: its head block may already be pinned under its own charged row, no record can name it, and the @@ -1480,7 +1482,9 @@ contract-test suite owned by the testing-strategy blueprint (FSM1/cipher-box-nex version that falls outside the rule loses that reference, and what it owes the registry is journaled to the retire ledger before the shortened history publishes. A version a dead letter drops journals its whole target set, root - and leaves, so the settle needs no gateway read (ADR 0054). A write-rotation name wave registers every version's root and + and leaves, so the settle needs no gateway read (ADR 0054). A version whose + staged root does not read journals its root alone, and the settle fetches the + root (ADR 0059). A write-rotation name wave registers every version's root and leaves at the node's new name before the record moves (ADR 0047). A version whose root the name wave cannot fetch carries its root alone. Its leaves lose their reference edges when the old name retires, and stay pinned only because diff --git a/crates/engine/src/net/retire.rs b/crates/engine/src/net/retire.rs index b5a7625efd..de1f63ea50 100644 --- a/crates/engine/src/net/retire.rs +++ b/crates/engine/src/net/retire.rs @@ -24,7 +24,7 @@ use super::REGISTRY_BATCH_MAX; use crate::api::{ApiClient, ApiError}; use crate::content::{ ContentPlane, ContentProfile, Expansion, Gateway, RetireTarget, expand_retire_targets, - read_block, + expand_staged_root, read_block, }; use crate::net::publish::PublishError; use crate::net::record_publish::RecordPublishError; @@ -508,8 +508,8 @@ impl RetireLedger for StagingRetireLedger<'_, St> { /// - unversioned, read only: `node(16) | owedBytes | manifestBytes | cid`, a /// [`DebtOrigin::Prune`] debt; /// - versioned: `ENTRY_V2 | origin | node(16) | owedBytes | manifestBytes | -/// cid`, and for [`DebtOrigin::DroppedVersion`] one `cid | pinnedBytes` per -/// target after it, the root last. +/// cid`; a [`DebtOrigin::DroppedVersion`] entry adds one `cid | pinnedBytes` +/// per target after it, the root last. /// /// Figures are big-endian `u64`. `cid` is the binary CID the entry is keyed /// by, which binds the value to its key. @@ -523,6 +523,7 @@ fn encode_entry(entry: &OwedRetire, cid: &[u8]) -> SeamResult> let (origin, targets) = match &entry.origin { DebtOrigin::Prune => (ORIGIN_PRUNE, None), DebtOrigin::DroppedVersion(targets) => (ORIGIN_DROPPED_VERSION, Some(targets.as_slice())), + DebtOrigin::DroppedRoot => (ORIGIN_DROPPED_ROOT, None), }; let pairs = targets.map_or(0, <[RetireTarget]>::len); let mut stored = Zeroizing::new(Vec::with_capacity( @@ -556,6 +557,7 @@ fn encode_entry(entry: &OwedRetire, cid: &[u8]) -> SeamResult> const ENTRY_V2: u8 = 2; const ORIGIN_PRUNE: u8 = 0; const ORIGIN_DROPPED_VERSION: u8 = 1; +const ORIGIN_DROPPED_ROOT: u8 = 2; /// Whether a dropped version's target set is one the settle may send: it is not /// empty, it ends at the entry's own root, and its figures sum to the total. @@ -589,6 +591,7 @@ fn decode_entry(stored: &[u8], cid: &[u8]) -> Option { let manifest_bytes = u64::from_be_bytes(*manifest); let origin = match origin_tag { ORIGIN_PRUNE if tail.is_empty() => DebtOrigin::Prune, + ORIGIN_DROPPED_ROOT if tail.is_empty() => DebtOrigin::DroppedRoot, ORIGIN_DROPPED_VERSION => { let targets = decode_targets(tail, cid.len())?; let root = is_wellformed_content_cid(cid).then(|| encode_content_cid_str(cid))?; @@ -730,7 +733,9 @@ where }; let owing = match (retired, &entry.origin) { (true, _) => OwingRecord::Retired, - (false, DebtOrigin::DroppedVersion(_)) => OwingRecord::Unconfirmed, + (false, DebtOrigin::DroppedVersion(_) | DebtOrigin::DroppedRoot) => { + OwingRecord::Unconfirmed + } (false, DebtOrigin::Prune) => OwingRecord::Published, }; let node = match live_of.entry((entry.node, owing)) { @@ -894,13 +899,20 @@ async fn expand_owed(entry: &OwedRetire, source: &RootSource<'_, H>) -> ) .await .ok()?; - expand_retire_targets( - &entry.target, - &root_block, - source.profile, - entry.manifest_bytes, - ) - .ok() + match entry.origin { + // The op record this device wrote names the root, and the fetch + // verifies the block against it, so no quoted total bounds it. + DebtOrigin::DroppedRoot => { + expand_staged_root(&entry.target, &root_block, source.profile).ok() + } + _ => expand_retire_targets( + &entry.target, + &root_block, + source.profile, + entry.manifest_bytes, + ) + .ok(), + } } /// How one owed entry's registry call ended. @@ -1767,6 +1779,111 @@ mod tests { assert_eq!(owed_entries(&store, OWNER), vec![entry]); } + /// The bytes the previous release wrote for a dropped version, framed by + /// hand so a change to the encoder cannot move them. + #[test] + fn a_dropped_version_the_previous_release_wrote_still_reads() { + let (entry, _) = dropped_version(&[11u8; 100]); + let DebtOrigin::DroppedVersion(targets) = &entry.origin else { + unreachable!("a dropped version carries its targets"); + }; + let (_, cid) = encoded(&entry); + let mut stored = vec![2u8, 1u8]; + stored.extend_from_slice(&entry.node); + stored.extend_from_slice(&entry.owed_bytes.to_be_bytes()); + stored.extend_from_slice(&entry.manifest_bytes.to_be_bytes()); + stored.extend_from_slice(&cid); + for target in targets { + stored.extend_from_slice(&decode_content_cid_str(&target.cid).unwrap()); + stored.extend_from_slice(&target.pinned_bytes.to_be_bytes()); + } + assert_eq!( + decode_entry(&stored, &cid), + Some(OwedRetire { + target: String::new(), + ..entry + }) + ); + } + + /// The debt a dead letter journals for a version whose staged root did not + /// read, priced at the op record's size. + fn dropped_root(plaintext: &[u8]) -> (OwedRetire, Vec, Vec) { + let (entry, root_block, leaf_cids) = owed_version(plaintext); + let entry = OwedRetire { + origin: DebtOrigin::DroppedRoot, + ..OwedRetire::whole(entry.node, entry.target, plaintext.len() as u64) + }; + (entry, root_block, leaf_cids) + } + + #[test] + fn a_dropped_root_round_trips_and_a_tail_reads_as_nothing() { + let (entry, ..) = dropped_root(&[12u8; 100]); + let store = InMemoryStagingStore::default(); + owe(&store, OWNER, &entry); + assert_eq!(owed_entries(&store, OWNER), vec![entry.clone()]); + + let (stored, cid) = encoded(&entry); + assert_eq!(stored[1], ORIGIN_DROPPED_ROOT); + let tailed = [&stored[..], &cid[..], &[0u8; 8]].concat(); + assert_eq!(decode_entry(&tailed, &cid), None); + } + + /// The settle fetches a dropped root, reads its node as unconfirmed, and + /// retires the whole version under the node's record, off the fetched + /// manifest rather than the op record's size. + #[test] + fn a_dropped_root_settles_off_its_fetched_root_under_the_nodes_record() { + let (entry, root_block, leaf_cids) = dropped_root(&(0..100u8).collect::>()); + let store = InMemoryStagingStore::default(); + owe(&store, OWNER, &entry); + let http = ledger_http(&entry, Some(root_block), Some(1)); + let asked = RefCell::new(Vec::new()); + + let pass = drain_with_live(&store, OWNER, &http, async |_, owing| { + asked.borrow_mut().push(owing); + Some(owning(BTreeSet::new())) + }); + + assert_eq!(asked.into_inner(), vec![OwingRecord::Unconfirmed]); + assert_eq!( + retire_entries(&http), + vec![ + (Some(OWNER_NAME.to_owned()), leaf_cids), + (Some(OWNER_NAME.to_owned()), vec![entry.target.clone()]) + ] + ); + assert_eq!(pass.still_owed, 0); + assert!(owed_entries(&store, OWNER).is_empty()); + } + + /// A dropped root no source serves stays owed, as a stall the host can + /// see, at the figure the drop quoted. + #[test] + fn a_dropped_root_no_source_serves_stalls_at_its_quoted_figure() { + let (entry, ..) = dropped_root(&[13u8; 100]); + let store = InMemoryStagingStore::default(); + owe(&store, OWNER, &entry); + let http = ledger_http(&entry, None, Some(1)); + + let pass = drain_with_live(&store, OWNER, &http, async |_, _| { + Some(owning(BTreeSet::new())) + }); + + assert_eq!(pass.still_owed, entry.owed_bytes); + assert_eq!( + pass.stalls, + vec![ReclaimStall { + node: NODE, + target: entry.target.clone(), + reason: ReclaimStallReason::TargetUnexpandable, + }] + ); + assert_eq!(owed_entries(&store, OWNER), vec![entry]); + assert!(retire_batches(&http).is_empty()); + } + /// An acknowledged sequence keeps its highest value, reads only for the /// name it was held at, and goes when forgotten. #[test] diff --git a/crates/engine/src/seams/retire_ledger.rs b/crates/engine/src/seams/retire_ledger.rs index bca17a314d..7d852f2830 100644 --- a/crates/engine/src/seams/retire_ledger.rs +++ b/crates/engine/src/seams/retire_ledger.rs @@ -22,7 +22,8 @@ pub enum OwingRecord { /// permanently unsettleable against a never-discard ledger. Retired, /// The node's record may carry a version a dead letter dropped - /// ([`DebtOrigin::DroppedVersion`]), or may never have published. + /// ([`DebtOrigin::DroppedVersion`], [`DebtOrigin::DroppedRoot`]), or may + /// never have published. /// /// A record at or below the node's acknowledged sequence, or one the /// endpoints serve tied with other bytes, stands the entry down: a PUT of @@ -47,6 +48,11 @@ pub enum DebtOrigin { /// the root last, each with its pinned bytes. The owing node reads as /// [`OwingRecord::Unconfirmed`]. DroppedVersion(Vec), + /// A dead letter dropped a staged version whose root did not give a target + /// set, so only the root CID the op record names is journaled, and the + /// settle fetches the root. The owing node reads as + /// [`OwingRecord::Unconfirmed`] (ADR 0059 D1). + DroppedRoot, } /// One owed retirement: a doomed version's **root** `contentCid` and the pinned diff --git a/crates/engine/src/sync/staging.rs b/crates/engine/src/sync/staging.rs index 4d64024d39..45bc43ece1 100644 --- a/crates/engine/src/sync/staging.rs +++ b/crates/engine/src/sync/staging.rs @@ -358,29 +358,40 @@ impl<'a, S: StagingStore> DroppedVersionDebts<'a, S> { /// [`Self::drop_version`] over a root block the caller already read. The /// journal is best-effort: a failure leaves the rows charged, which is a /// leak, never a loss, and is reported ([`Event::RegistryDebtUnjournaled`]). - /// A root that is gone names nothing to journal. + /// A root that does not give a target set journals its CID alone, priced at + /// the op record's size, and the settle fetches it (ADR 0059 D1). async fn drop_staged(&self, op: &Op, root: &[u8], block: Option<&[u8]>) { let manifest = block .filter(|block| verify_cid(root, block).is_ok()) .and_then(|block| decode_root(block).ok()); - if let (Some(block), true) = (block, manifest.is_some()) { - let target = encode_content_cid_str(root); - let journaled = match expand_staged_root(&target, block, self.profile) { - Ok(expansion) => { - let debt = OwedRetire { + let target = encode_content_cid_str(root); + let debt = + match (block, &manifest) { + (Some(block), Some(_)) => expand_staged_root(&target, block, self.profile) + .ok() + .map(|expansion| OwedRetire { origin: DebtOrigin::DroppedVersion(expansion.targets), ..OwedRetire::whole(op.target.0, target, expansion.pinned_bytes) - }; - StagingRetireLedger::new(self.store, self.seal) - .owe(&self.reader.owner_tag(), &[debt]) - .await - .is_ok() + }), + _ => { + let size = op + .staged_content() + .map_or(0, |content| content.plaintext_size); + Some(OwedRetire { + origin: DebtOrigin::DroppedRoot, + ..OwedRetire::whole(op.target.0, target, size) + }) } - Err(_) => false, }; - if !journaled { - let _ = self.events.unbounded_send(Event::RegistryDebtUnjournaled); - } + let journaled = match debt { + Some(debt) => StagingRetireLedger::new(self.store, self.seal) + .owe(&self.reader.owner_tag(), &[debt]) + .await + .is_ok(), + None => false, + }; + if !journaled { + let _ = self.events.unbounded_send(Event::RegistryDebtUnjournaled); } let leaves = manifest .map(|manifest| manifest.leaf_cids) @@ -1002,6 +1013,7 @@ async fn reconcile_preserved_dead_letters( let before = kept.len(); let mut slots = Vec::with_capacity(before); let mut sized = Vec::with_capacity(before); + let mut gone = Vec::new(); for entry in kept { let Some(op) = debts.opens(&entry.record) else { slots.push(Slot::Foreign(entry)); @@ -1015,7 +1027,10 @@ async fn reconcile_preserved_dead_letters( let bytes = version_bytes(&manifest, block.len(), &staged); (Some(block), bytes) } - Ok(OpenedVersion::Gone) => continue, + Ok(OpenedVersion::Gone) => { + gone.push((op, root)); + continue; + } // A root this build cannot decode, or a store that cannot answer, // decides nothing: keep the entry, unsized, and let the next pass // judge it. @@ -1060,6 +1075,9 @@ async fn reconcile_preserved_dead_letters( .drop_staged(&parked.op, &parked.root, parked.block.as_deref()) .await; } + for (op, root) in gone { + debts.drop_staged(&op, &root, None).await; + } } /// Staging keys held by the store that nothing references — orphan residue from @@ -2050,6 +2068,35 @@ mod tests { }); } + /// A trimmed version whose staged root is gone journals the root alone, at + /// the op record's size (ADR 0059 D1). + #[test] + fn a_trimmed_version_whose_root_is_gone_owes_its_root_alone() { + let store = InMemoryStagingStore::default(); + block_on(async { + let (blocks, root_block, staged) = framed(b"forty bytes of content ------------------"); + put_blocks(&store, &blocks, &root_block, &staged).await; + let (root_cid, size) = (staged.root_cid.clone(), staged.plaintext_size); + let record = encode_op_record(seal(1), &content_op(1, staged)).unwrap(); + park(&store, &record).await; + store.remove_staged_bytes(&root_cid).await.unwrap(); + let expired = PreservedBounds { + ttl: Duration::ZERO, + ..bounds(ROOMY) + }; + let events = reconcile(&store, expired).await; + + assert!(events.is_empty()); + assert_eq!( + owed_by_owner(&store).await, + vec![OwedRetire { + origin: DebtOrigin::DroppedRoot, + ..OwedRetire::whole(id(1).0, encode_content_cid_str(&root_cid), size) + }] + ); + }); + } + /// A trimmed version whose debt did not reach the ledger is reported, not /// dropped in silence. #[test] @@ -2142,9 +2189,16 @@ mod tests { ); assert_eq!( sweep(&store, &[]).await.unwrap().len(), - blocks.len() + 1, - "its blocks are referenced by nothing and become collectable" + blocks.len(), + "the drop releases the root, and its leaves become collectable" ); + assert!(matches!( + owed_by_owner(&store).await[..], + [OwedRetire { + origin: DebtOrigin::DroppedRoot, + .. + }] + )); }); } diff --git a/crates/engine/tests/write_plane.rs b/crates/engine/tests/write_plane.rs index 675f041601..e2ba70dc12 100644 --- a/crates/engine/tests/write_plane.rs +++ b/crates/engine/tests/write_plane.rs @@ -15,7 +15,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use cipherbox_core::codec::Value; use cipherbox_core::content::{ - CONTENT_CID_CODEC, compute_cid, encode_content_cid_str, is_wellformed_content_cid, + CONTENT_CID_CODEC, compute_cid, decode_content_cid_str, encode_content_cid_str, + is_wellformed_content_cid, }; use cipherbox_core::ipns::{IpnsName, IpnsRecord}; use cipherbox_core::kdf; @@ -9339,6 +9340,43 @@ fn a_refused_version_that_kept_its_rows_still_retires_them() { ); } +/// A parked version whose staged root the store has lost gives no local +/// manifest, so its discard journals the root alone. The settle fetches the +/// root the upload left on the gateway and retires the whole version under the +/// node's record (ADR 0059 D1). +#[test] +fn a_discarded_version_whose_staged_root_is_gone_still_retires_its_rows() { + let world = FakeWorld::new(); + let blocks = Blocks::default(); + let RefusedFile { + alice, + mut engine, + mut tasks, + target, + version, + } = refused_new_file(&world, &blocks, false); + let (parked, _) = tick_until_dead_lettered(&world, &engine, &mut tasks); + let root_cid = decode_content_cid_str(version.last().expect("a root")).expect("a CID"); + block_on(alice.staging_store.remove_staged_bytes(&root_cid)).expect("the root goes"); + + block_on(engine.command(Command::DiscardDeadLetter { + op_id: parked[0].op_id, + })) + .expect("the discard lands"); + tick(&world, &engine, &mut tasks); + + let named: BTreeSet = retire_entries(&alice) + .into_iter() + .filter(|(name, _)| name.as_deref() == Some(write_name(target).as_str())) + .flat_map(|(_, targets)| targets) + .collect(); + assert!( + version.iter().all(|cid| named.contains(cid)), + "every block the version charged is retired under the node's record: {named:?}" + ); + assert_eq!(engine.pending_reclaim_bytes(), 0, "the debt settles"); +} + /// A name this device once adopted a record at is never read as empty on the /// endpoints' word alone: the debt waits rather than unpin what a record the /// endpoints have lost may still name. From ccea5054505f5738870211a52d99dc154f6ed8e1 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 30 Sep 2026 01:02:46 +0200 Subject: [PATCH 2/4] fix(engine): journal a root-only debt for a staged root that does not expand, and record ADR 0059 in the ADRs --- crates/engine/src/net/retire.rs | 4 +- crates/engine/src/sync/staging.rs | 55 ++++++++----------- ...publish-retires-exactly-what-it-charged.md | 3 +- ...settles-above-the-acknowledged-sequence.md | 3 +- ...t-does-not-read-journals-its-root-alone.md | 2 +- 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/net/retire.rs b/crates/engine/src/net/retire.rs index de1f63ea50..26e48b0376 100644 --- a/crates/engine/src/net/retire.rs +++ b/crates/engine/src/net/retire.rs @@ -874,13 +874,13 @@ pub enum ReclaimStallReason { TargetStillLive, /// The doomed root itself could not be expanded — no source served the block, /// or the manifest is not this version's — so what the retire would name is - /// unknown. The figure falls back to the ceiling the prune quoted. + /// unknown. The figure falls back to the figure the entry quoted. TargetUnexpandable, } /// One owed entry's whole expansion: the target set it carries, or else its /// own fetched root block. `None` leaves the entry owed for the figure the -/// prune quoted: a root no source served, or a manifest that is not this +/// entry quoted: a root no source served, or a manifest that is not this /// version's. async fn expand_owed(entry: &OwedRetire, source: &RootSource<'_, H>) -> Option { if let DebtOrigin::DroppedVersion(targets) = &entry.origin { diff --git a/crates/engine/src/sync/staging.rs b/crates/engine/src/sync/staging.rs index 45bc43ece1..c74ed46aa6 100644 --- a/crates/engine/src/sync/staging.rs +++ b/crates/engine/src/sync/staging.rs @@ -365,31 +365,28 @@ impl<'a, S: StagingStore> DroppedVersionDebts<'a, S> { .filter(|block| verify_cid(root, block).is_ok()) .and_then(|block| decode_root(block).ok()); let target = encode_content_cid_str(root); - let debt = - match (block, &manifest) { - (Some(block), Some(_)) => expand_staged_root(&target, block, self.profile) - .ok() - .map(|expansion| OwedRetire { - origin: DebtOrigin::DroppedVersion(expansion.targets), - ..OwedRetire::whole(op.target.0, target, expansion.pinned_bytes) - }), - _ => { - let size = op - .staged_content() - .map_or(0, |content| content.plaintext_size); - Some(OwedRetire { - origin: DebtOrigin::DroppedRoot, - ..OwedRetire::whole(op.target.0, target, size) - }) + let expansion = block + .filter(|_| manifest.is_some()) + .and_then(|block| expand_staged_root(&target, block, self.profile).ok()); + let debt = match expansion { + Some(expansion) => OwedRetire { + origin: DebtOrigin::DroppedVersion(expansion.targets), + ..OwedRetire::whole(op.target.0, target, expansion.pinned_bytes) + }, + None => { + let size = op + .staged_content() + .map_or(0, |content| content.plaintext_size); + OwedRetire { + origin: DebtOrigin::DroppedRoot, + ..OwedRetire::whole(op.target.0, target, size) } - }; - let journaled = match debt { - Some(debt) => StagingRetireLedger::new(self.store, self.seal) - .owe(&self.reader.owner_tag(), &[debt]) - .await - .is_ok(), - None => false, + } }; + let journaled = StagingRetireLedger::new(self.store, self.seal) + .owe(&self.reader.owner_tag(), &[debt]) + .await + .is_ok(); if !journaled { let _ = self.events.unbounded_send(Event::RegistryDebtUnjournaled); } @@ -2068,10 +2065,10 @@ mod tests { }); } - /// A trimmed version whose staged root is gone journals the root alone, at - /// the op record's size (ADR 0059 D1). + /// A preserved version whose staged root is gone journals the root alone, + /// at the op record's size (ADR 0059 D1). #[test] - fn a_trimmed_version_whose_root_is_gone_owes_its_root_alone() { + fn a_preserved_version_whose_root_is_gone_owes_its_root_alone() { let store = InMemoryStagingStore::default(); block_on(async { let (blocks, root_block, staged) = framed(b"forty bytes of content ------------------"); @@ -2080,11 +2077,7 @@ mod tests { let record = encode_op_record(seal(1), &content_op(1, staged)).unwrap(); park(&store, &record).await; store.remove_staged_bytes(&root_cid).await.unwrap(); - let expired = PreservedBounds { - ttl: Duration::ZERO, - ..bounds(ROOMY) - }; - let events = reconcile(&store, expired).await; + let events = reconcile(&store, bounds(ROOMY)).await; assert!(events.is_empty()); assert_eq!( diff --git a/decisions/0047-a-failed-or-abandoned-publish-retires-exactly-what-it-charged.md b/decisions/0047-a-failed-or-abandoned-publish-retires-exactly-what-it-charged.md index bf1be89dca..1c6eb5ec17 100644 --- a/decisions/0047-a-failed-or-abandoned-publish-retires-exactly-what-it-charged.md +++ b/decisions/0047-a-failed-or-abandoned-publish-retires-exactly-what-it-charged.md @@ -136,7 +136,8 @@ decide whether the carve-outs join this ADR as a Dn and the "Retirement" bullet. **E4 — An acknowledged PUT that never becomes live leaks everything it charged.** D2 keeps the name, the head and every content row of such an op, and no later pass learns that the record never landed. The attempt budget per op bounds the cost. Narrowed by ADR 0054 on 2026-09-27 to a version -whose staged root is already gone (ADR 0054 E1). +whose staged root is already gone (ADR 0054 E1). Amended by ADR 0059 on 2026-09-29: narrowed +to a version whose root no source serves. **E5 — A version whose root the name wave cannot fetch carries its root alone.** Its leaves lose their reference edges when the old name retires. They stay pinned only because the registry diff --git a/decisions/0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md b/decisions/0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md index e9bf621433..56f1f1e68a 100644 --- a/decisions/0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md +++ b/decisions/0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md @@ -90,4 +90,5 @@ and no mark exists for the node; any other read waits. **E1 — Should a version's rows be journaled when they are charged?** `Preservation::ContentGone` has no manifest to read, so its rows stay charged. FSM1/cipher-box#2065 names the charge-time journal, at one ledger write per version upload, as the candidate. The -owner decides whether it lands. +owner decides whether it lands. Amended by ADR 0059 on 2026-09-29: closed, the charge-time journal is +not adopted. diff --git a/decisions/0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md b/decisions/0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md index b3fac915e8..b812caf8fb 100644 --- a/decisions/0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md +++ b/decisions/0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md @@ -11,7 +11,7 @@ it charged), [ADR 0020](./0020-the-durable-op-queue-reads-the-previous-release.md) (durable staging state reads the previous release), the `blueprint/engine.md` "Resolve/publish pipeline" section ("Retirement" bullet) and "Content plane" section ("Referenced equals kept" bullet) -- **Implemented by:** not landed +- **Implemented by:** FSM1/cipher-box#2105 - **Amends:** ADR 0054 D1 ## Context From ad655c73afd7212b63ad8e088df7a8008af1c214 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 30 Sep 2026 01:15:38 +0200 Subject: [PATCH 3/4] refactor(engine): drop a redundant expansion gate and name the prune arm of expand_owed expand_staged_root checks the root CID and decodes the root itself, so the manifest gate before it adds no condition. The settle match names each origin, so a new origin does not fall into the quoted-total arm silently. --- crates/engine/src/net/retire.rs | 2 +- crates/engine/src/sync/staging.rs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/net/retire.rs b/crates/engine/src/net/retire.rs index 26e48b0376..833ffc9fda 100644 --- a/crates/engine/src/net/retire.rs +++ b/crates/engine/src/net/retire.rs @@ -905,7 +905,7 @@ async fn expand_owed(entry: &OwedRetire, source: &RootSource<'_, H>) -> DebtOrigin::DroppedRoot => { expand_staged_root(&entry.target, &root_block, source.profile).ok() } - _ => expand_retire_targets( + DebtOrigin::Prune | DebtOrigin::DroppedVersion(_) => expand_retire_targets( &entry.target, &root_block, source.profile, diff --git a/crates/engine/src/sync/staging.rs b/crates/engine/src/sync/staging.rs index c74ed46aa6..c2ee48ebc1 100644 --- a/crates/engine/src/sync/staging.rs +++ b/crates/engine/src/sync/staging.rs @@ -365,9 +365,8 @@ impl<'a, S: StagingStore> DroppedVersionDebts<'a, S> { .filter(|block| verify_cid(root, block).is_ok()) .and_then(|block| decode_root(block).ok()); let target = encode_content_cid_str(root); - let expansion = block - .filter(|_| manifest.is_some()) - .and_then(|block| expand_staged_root(&target, block, self.profile).ok()); + let expansion = + block.and_then(|block| expand_staged_root(&target, block, self.profile).ok()); let debt = match expansion { Some(expansion) => OwedRetire { origin: DebtOrigin::DroppedVersion(expansion.targets), From 6788a4d83ffab4dba71efc7194af51b87c735326 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 30 Sep 2026 01:29:49 +0200 Subject: [PATCH 4/4] fix(engine): cite ADR 0059 D1 in its amendments and test a root that does not expand --- crates/engine/src/sync/staging.rs | 68 ++++++++++++++++++- ...publish-retires-exactly-what-it-charged.md | 5 +- ...settles-above-the-acknowledged-sequence.md | 2 +- ...t-does-not-read-journals-its-root-alone.md | 5 +- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/sync/staging.rs b/crates/engine/src/sync/staging.rs index c2ee48ebc1..284e0937a9 100644 --- a/crates/engine/src/sync/staging.rs +++ b/crates/engine/src/sync/staging.rs @@ -1169,7 +1169,7 @@ mod tests { use crate::sync::op::{NewNode, StagedContent}; use crate::sync::record::{RecordClass, RecordReader}; use crate::testkit::fakes::InMemoryStagingStore; - use crate::testkit::{SeededEntropy, block_on, frame_version}; + use crate::testkit::{SeededEntropy, block_on, frame_version, frame_version_with}; use cipherbox_core::content::{compute_cid, decode_content_cid_str}; use cipherbox_core::suite::aead::KEY_LEN; use cipherbox_core::suite::x25519::X25519Secret; @@ -2089,6 +2089,72 @@ mod tests { }); } + /// The same debt when the trim's age bound is what drops the entry. + #[test] + fn an_expired_preserved_version_whose_root_is_gone_owes_its_root_alone() { + let store = InMemoryStagingStore::default(); + block_on(async { + let (blocks, root_block, staged) = framed(b"forty bytes of content ------------------"); + put_blocks(&store, &blocks, &root_block, &staged).await; + let (root_cid, size) = (staged.root_cid.clone(), staged.plaintext_size); + let record = encode_op_record(seal(1), &content_op(1, staged)).unwrap(); + park(&store, &record).await; + store.remove_staged_bytes(&root_cid).await.unwrap(); + let expired = PreservedBounds { + ttl: Duration::ZERO, + ..bounds(ROOMY) + }; + let events = reconcile(&store, expired).await; + + assert!(events.is_empty()); + assert_eq!( + owed_by_owner(&store).await, + vec![OwedRetire { + origin: DebtOrigin::DroppedRoot, + ..OwedRetire::whole(id(1).0, encode_content_cid_str(&root_cid), size) + }] + ); + }); + } + + /// A root that verifies and decodes but does not expand under this build's + /// profile gives no target set, so the drop journals the root alone. + #[test] + fn a_trimmed_version_whose_root_does_not_expand_owes_its_root_alone() { + let store = InMemoryStagingStore::default(); + block_on(async { + let plaintext = b"forty bytes of content ------------------"; + let (blocks, root_block, content) = + frame_version_with(plaintext, [9; KEY_LEN], 1, ContentProfile::PRODUCTION); + let staged = StagedContent { + root_cid: content.content_cid().to_vec(), + plaintext_size: content.size(), + sealed_content_key: b"sealed-key-blob".to_vec(), + scope: NodeId([0; 16]), + epoch: 1, + }; + put_blocks(&store, &blocks, &root_block, &staged).await; + let (root_cid, size) = (staged.root_cid.clone(), staged.plaintext_size); + let record = encode_op_record(seal(1), &content_op(1, staged)).unwrap(); + park(&store, &record).await; + let expired = PreservedBounds { + ttl: Duration::ZERO, + ..bounds(ROOMY) + }; + let events = reconcile(&store, expired).await; + + assert!(kept_records(&store).await.is_empty()); + assert!(events.is_empty(), "the debt journals: {events:?}"); + assert_eq!( + owed_by_owner(&store).await, + vec![OwedRetire { + origin: DebtOrigin::DroppedRoot, + ..OwedRetire::whole(id(1).0, encode_content_cid_str(&root_cid), size) + }] + ); + }); + } + /// A trimmed version whose debt did not reach the ledger is reported, not /// dropped in silence. #[test] diff --git a/decisions/0047-a-failed-or-abandoned-publish-retires-exactly-what-it-charged.md b/decisions/0047-a-failed-or-abandoned-publish-retires-exactly-what-it-charged.md index 1c6eb5ec17..6fce1e59f8 100644 --- a/decisions/0047-a-failed-or-abandoned-publish-retires-exactly-what-it-charged.md +++ b/decisions/0047-a-failed-or-abandoned-publish-retires-exactly-what-it-charged.md @@ -136,8 +136,9 @@ decide whether the carve-outs join this ADR as a Dn and the "Retirement" bullet. **E4 — An acknowledged PUT that never becomes live leaks everything it charged.** D2 keeps the name, the head and every content row of such an op, and no later pass learns that the record never landed. The attempt budget per op bounds the cost. Narrowed by ADR 0054 on 2026-09-27 to a version -whose staged root is already gone (ADR 0054 E1). Amended by ADR 0059 on 2026-09-29: narrowed -to a version whose root no source serves. +whose staged root is already gone (ADR 0054 E1). Amended by ADR 0059 D1 on 2026-09-29: narrowed +to a version whose root no source serves, or does not expand under this build's profile, which +stalls permanently with its figure in the pending-reclaim figure. **E5 — A version whose root the name wave cannot fetch carries its root alone.** Its leaves lose their reference edges when the old name retires. They stay pinned only because the registry diff --git a/decisions/0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md b/decisions/0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md index 56f1f1e68a..db6f31d467 100644 --- a/decisions/0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md +++ b/decisions/0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md @@ -90,5 +90,5 @@ and no mark exists for the node; any other read waits. **E1 — Should a version's rows be journaled when they are charged?** `Preservation::ContentGone` has no manifest to read, so its rows stay charged. FSM1/cipher-box#2065 names the charge-time journal, at one ledger write per version upload, as the candidate. The -owner decides whether it lands. Amended by ADR 0059 on 2026-09-29: closed, the charge-time journal is +owner decides whether it lands. Amended by ADR 0059 D1 on 2026-09-29: closed, the charge-time journal is not adopted. diff --git a/decisions/0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md b/decisions/0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md index b812caf8fb..6f5e12b8bd 100644 --- a/decisions/0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md +++ b/decisions/0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md @@ -12,7 +12,7 @@ staging state reads the previous release), the `blueprint/engine.md` "Resolve/publish pipeline" section ("Retirement" bullet) and "Content plane" section ("Referenced equals kept" bullet) - **Implemented by:** FSM1/cipher-box#2105 -- **Amends:** ADR 0054 D1 +- **Amends:** ADR 0054 D1 and E1, ADR 0047 E4 ## Context @@ -34,7 +34,8 @@ entry with the origin "dropped root", which carries no target set. The owed figu that the op record carries. The settle fetches the root over the gateway ladder and expands it, as for a prune debt. It reads the owing node under `OwingRecord::Unconfirmed`, as for a dropped version. A root that no source serves keeps the entry, as a `TargetUnexpandable` stall with its -figure in the pending-reclaim figure. Encode refuses a dropped-root entry with a target tail, and +figure in the pending-reclaim figure. So does a root that does not expand under this build's +profile, which stalls permanently with its figure in the pending-reclaim figure. Encode refuses a dropped-root entry with a target tail, and decode reads such bytes as unwritten (AGENTS.md rule 8). The upload path writes nothing new. This release reads every entry the previous release wrote with no change. The previous release