From 743efcf01fa082f92b508bef4db844a5a50bdd96 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 22 Sep 2026 08:45:33 -0400 Subject: [PATCH] feat(pds)!: type the ledger's permanent fields and refuse an unreadable commit Admitted.depth becomes u32, Provisioned.backend becomes an enum over the four labels already on disk, and Provisioned.assurance goes: it was one constant written into every entry. A commit entry naming something that is not a content identifier now stops the boot instead of coming back at an earlier head behind a warning. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I341e3593527b6a189116ded73ae5e06e32f77d7c --- crates/didbot-pds/src/durable.rs | 11 +++ crates/didbot-pds/src/history.rs | 85 +++++++++++++++---- crates/didbot-pds/src/ledger.rs | 47 ++++++++-- crates/didbot-pds/src/provision/mod.rs | 18 ++-- crates/didbot-pds/src/provision/registry.rs | 3 +- .../src/provision/server_account.rs | 3 +- crates/didbot-pds/src/registration.rs | 3 +- crates/didbot-pds/src/wal/mod.rs | 19 +++++ crates/didbot-pds/tests/durability.rs | 37 +++++++- crates/didbot-pds/tests/ledger.rs | 4 +- crates/didbot-serve/src/tests/mod.rs | 3 +- 11 files changed, 189 insertions(+), 44 deletions(-) diff --git a/crates/didbot-pds/src/durable.rs b/crates/didbot-pds/src/durable.rs index 65f24466..2bc3c4bb 100644 --- a/crates/didbot-pds/src/durable.rs +++ b/crates/didbot-pds/src/durable.rs @@ -273,6 +273,17 @@ impl Durable { entry, ); } + // After the replay, because a latch is only complete once the whole + // log has been read, and before anything is served or written. The + // history is the one store whose entries carry a string another crate + // has to parse, and a string it cannot is damage rather than a fact. + if let Some(unreadable) = commits.unreadable() { + return Err(WalError::UnreadableCommit { + path: dir.to_path_buf(), + did: unreadable.did, + cid: unreadable.cid, + }); + } // After the replay and before anything is served, for the same // reason the blob sweep below runs there: a body is an orphan exactly // when the journal does not name it, and the journal is only fully diff --git a/crates/didbot-pds/src/history.rs b/crates/didbot-pds/src/history.rs index c31a010b..c4aed088 100644 --- a/crates/didbot-pds/src/history.rs +++ b/crates/didbot-pds/src/history.rs @@ -216,6 +216,23 @@ struct Kept { #[derive(Debug, Default)] pub struct MemoryCommitStore { repos: Mutex>, + /// The first commit entry this store could not read, if one arrived. + /// + /// Replay is total — a fact was accepted before it was written, so there + /// is nothing left for [`Journaled::apply`](crate::journal::Journaled::apply) + /// to refuse — and a CID that will not parse is not a fact, it is damage. + /// So it is latched here and the boot reads it once the log has been + /// replayed, which is where a refusal can still be one. + unreadable: Mutex>, +} + +/// A commit entry naming something that is not a content identifier. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnreadableCommit { + /// The repository the entry was about. + pub did: String, + /// The string that would not parse. + pub cid: String, } impl MemoryCommitStore { @@ -249,6 +266,24 @@ impl MemoryCommitStore { kept.head = head; } + /// The first commit entry replay could not read, if one arrived. + #[must_use] + pub fn unreadable(&self) -> Option { + self.unreadable + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } + + /// Latches the first unreadable commit entry, keeping the first. + fn refuse(&self, did: &str, cid: &str) { + let mut held = self.unreadable.lock().unwrap_or_else(|p| p.into_inner()); + held.get_or_insert_with(|| UnreadableCommit { + did: did.to_owned(), + cid: cid.to_owned(), + }); + } + /// Every repository's head, for a compaction rewriting the log. /// /// The heads only. A trail is what a compaction is entitled to drop; see @@ -380,30 +415,46 @@ impl crate::journal::Journaled for MemoryCommitStore { // Only what `owns` claims reaches here. return; }; - // A CID that will not parse is a log this binary cannot read the - // history out of, and the history is bookkeeping: the entry is - // dropped with a line and the repository comes back with an earlier - // head, or with none, which is a state it is allowed to be in. - // Dropping a *record* for the same reason would be losing an - // account's data, which is why nothing else does it. - let Ok(commit) = Cid::parse(&commit) else { - tracing::warn!(did, rev, "a commit entry names an unreadable commit"); + // A string here that is not a content identifier is damage, and the + // log has one answer for damage: it is latched and the boot refuses, + // rather than the repository quietly coming back at an earlier head. + let read = |raw: &str| match Cid::parse(raw) { + Ok(cid) => Some(cid), + Err(_) => { + self.refuse(&did, raw); + None + } + }; + let Some(commit) = read(&commit) else { return; }; - let created = created - .iter() - .filter_map(|cid| Cid::parse(cid).ok()) - .collect(); - let prev = prev.as_deref().and_then(|cid| Cid::parse(cid).ok()); + let mut blocks = BTreeSet::new(); + for raw in &created { + let Some(cid) = read(raw) else { + return; + }; + blocks.insert(cid); + } + let created = blocks; + let prev = match prev.as_deref() { + Some(raw) => match read(raw) { + Some(cid) => Some(cid), + None => return, + }, + None => None, + }; // A head restored without a tree root keeps the commit's own CID in // its place. It is the wrong CID for a `prevData` and the right // shape, and the frame that would carry it is the first one after a // restart, where the replay buffer is empty and every consumer is // being told `OutdatedCursor` regardless. - let data = data - .as_deref() - .and_then(|cid| Cid::parse(cid).ok()) - .unwrap_or_else(|| commit.clone()); + let data = match data.as_deref() { + Some(raw) => match read(raw) { + Some(cid) => cid, + None => return, + }, + None => commit.clone(), + }; self.restore( &did, Head { diff --git a/crates/didbot-pds/src/ledger.rs b/crates/didbot-pds/src/ledger.rs index dbdd6d01..b4de9627 100644 --- a/crates/didbot-pds/src/ledger.rs +++ b/crates/didbot-pds/src/ledger.rs @@ -68,12 +68,8 @@ pub enum LedgerEvent { Provisioned { /// The label the DID was minted from. account_id: String, - /// Who created the account: `server`, `account` or `operator`, the - /// label of the [`crate::Creator`] the request carried. - backend: String, - /// [`crate::CREATOR_ASSURANCE`]: the caller authenticated the - /// creator before the request reached the registry. - assurance: String, + /// Who created the account. + backend: Backend, /// The account that spawned this one, if this deployment hosts it. /// /// A fact the server checked rather than one it was told: an @@ -170,7 +166,7 @@ pub enum LedgerEvent { /// The root the chain reached. root: String, /// How many edges the chain had. - depth: usize, + depth: u32, }, /// The account was deleted. /// @@ -211,6 +207,40 @@ impl LedgerEvent { } } +/// Who asked for an account, as a [`LedgerEvent::Provisioned`] entry records +/// it. +/// +/// A closed set rather than free text, because this is written into a +/// permanent ledger and a fifth value should be a code change. The first +/// three are [`Creator::label`](crate::Creator::label)'s. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Backend { + /// The deployment itself, acting for its operator. + Server, + /// A hosted account, creating a child. + Account, + /// The operator, from their own repository. + Operator, + /// The deployment's own account, which it creates for itself on the + /// first boot that has an operator to name. + SelfProvisioned, +} + +impl Backend { + /// The label a ledger entry carries, matched rather than derived from + /// serde so the strings a log reader greps for are visible in this file. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Server => "server", + Self::Account => "account", + Self::Operator => "operator", + Self::SelfProvisioned => "self-provisioned", + } + } +} + /// How one parent link was verified, as an [`LedgerEvent::Admitted`] entry /// records it. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -596,8 +626,7 @@ mod tests { fn provisioned() -> LedgerEvent { LedgerEvent::Provisioned { account_id: "quartz-vole".to_owned(), - backend: "server".to_owned(), - assurance: crate::CREATOR_ASSURANCE.to_owned(), + backend: Backend::Server, parent: None, } } diff --git a/crates/didbot-pds/src/provision/mod.rs b/crates/didbot-pds/src/provision/mod.rs index c625fc64..d747a18b 100644 --- a/crates/didbot-pds/src/provision/mod.rs +++ b/crates/didbot-pds/src/provision/mod.rs @@ -113,12 +113,17 @@ pub enum Creator { } impl Creator { - /// The label a ledger entry and a log line carry. + /// The label a log line carries. pub fn label(&self) -> &'static str { + self.backend().label() + } + + /// What a ledger entry records this creator as. + pub fn backend(&self) -> crate::ledger::Backend { match self { - Self::Server => "server", - Self::Account(_) => "account", - Self::Operator { .. } => "operator", + Self::Server => crate::ledger::Backend::Server, + Self::Account(_) => crate::ledger::Backend::Account, + Self::Operator { .. } => crate::ledger::Backend::Operator, } } } @@ -198,8 +203,9 @@ pub struct Provisioned { pub operator: String, } -/// What the ledger's `assurance` column says for every account: its creator -/// was authenticated by the caller before the request reached here. +/// What a [`LifecycleEvent::Provisioned`](crate::LifecycleEvent) carries as +/// its assurance: the creator was authenticated by the caller before the +/// request reached here. pub const CREATOR_ASSURANCE: &str = "creator"; /// Bounds on the tree this deployment enforces on its own, whatever policy diff --git a/crates/didbot-pds/src/provision/registry.rs b/crates/didbot-pds/src/provision/registry.rs index 964f858d..9ba98762 100644 --- a/crates/didbot-pds/src/provision/registry.rs +++ b/crates/didbot-pds/src/provision/registry.rs @@ -242,8 +242,7 @@ where did.as_str(), LedgerEvent::Provisioned { account_id: account_id.clone(), - backend: request.creator.label().to_owned(), - assurance: CREATOR_ASSURANCE.to_owned(), + backend: request.creator.backend(), parent: Some(operator.clone()), }, ) { diff --git a/crates/didbot-pds/src/provision/server_account.rs b/crates/didbot-pds/src/provision/server_account.rs index d3d3d849..12f3c985 100644 --- a/crates/didbot-pds/src/provision/server_account.rs +++ b/crates/didbot-pds/src/provision/server_account.rs @@ -90,8 +90,7 @@ where did.as_str(), LedgerEvent::Provisioned { account_id: "server".to_owned(), - backend: "self-provisioned".to_owned(), - assurance: CREATOR_ASSURANCE.to_owned(), + backend: crate::ledger::Backend::SelfProvisioned, parent: Some(self.operator.clone()), }, ); diff --git a/crates/didbot-pds/src/registration.rs b/crates/didbot-pds/src/registration.rs index 861f564f..3d17b0e0 100644 --- a/crates/didbot-pds/src/registration.rs +++ b/crates/didbot-pds/src/registration.rs @@ -96,8 +96,7 @@ mod tests { fn provisioned() -> LedgerEvent { LedgerEvent::Provisioned { account_id: "kestrel".to_owned(), - backend: "server".to_owned(), - assurance: "creator".to_owned(), + backend: crate::ledger::Backend::Server, parent: Some(OPERATOR.to_owned()), } } diff --git a/crates/didbot-pds/src/wal/mod.rs b/crates/didbot-pds/src/wal/mod.rs index 4385e114..b041c887 100644 --- a/crates/didbot-pds/src/wal/mod.rs +++ b/crates/didbot-pds/src/wal/mod.rs @@ -835,6 +835,25 @@ pub enum WalError { #[source] source: serde_json::Error, }, + /// A commit entry names something that is not a content identifier. + /// + /// A sibling of [`WalError::Corrupt`]: those bytes will not parse as an + /// entry, these parse and carry a string no reader can resolve. Both mean + /// the log holds something this binary cannot make state out of, and both + /// get the same answer — refuse, rather than come up at an earlier head + /// and look healthy. + #[error( + "write-ahead log at {path}: the commit history for {did} names {cid}, which is not a \ + content identifier. Run the build that wrote it; do not edit the log by hand" + )] + UnreadableCommit { + /// The data directory. + path: PathBuf, + /// The repository the entry was about. + did: String, + /// The string that would not parse. + cid: String, + }, /// The entry is larger than a frame this log could ever read back. /// /// Refused rather than written. See the module's "Running out of room": diff --git a/crates/didbot-pds/tests/durability.rs b/crates/didbot-pds/tests/durability.rs index 4aa0aefe..57d989fb 100644 --- a/crates/didbot-pds/tests/durability.rs +++ b/crates/didbot-pds/tests/durability.rs @@ -2805,7 +2805,7 @@ const WRITTEN_ENTRIES: &[(&str, &str)] = &[ "ledgerAppended", r#"{"op":"ledgerAppended","did":"did:web:a.agents.localhost", "entry":{"seq":1,"at":"2026-01-01T00:00:00Z","event":"provisioned", - "accountId":"shearwater","backend":"loopback","assurance":"self-asserted"}}"#, + "accountId":"shearwater","backend":"server"}}"#, ), ("nameClaimed", r#"{"op":"nameClaimed","name":"quernstone"}"#), ( @@ -2855,6 +2855,41 @@ const WRITTEN_ENTRIES: &[(&str, &str)] = &[ ), ]; +/// A commit entry naming something that is not a content identifier stops +/// the boot. +/// +/// The log has one answer for damage, and this is the one path that used to +/// have another: the entry was dropped with a line and the repository came +/// back at an earlier head, which is a deployment that looks healthy and is +/// behind the network's idea of where it is. +#[test] +fn a_commit_entry_naming_an_unreadable_cid_refuses_the_boot() { + let dir = scratch("bad-commit"); + std::fs::create_dir_all(&dir).expect("mkdir"); + let entry = br#"{"op":"repoCommitted","did":"did:web:a.agents.localhost","rev":"3lb", + "commit":"not-a-cid","data":null,"prev":null,"created":[]}"#; + std::fs::write(dir.join(didbot_pds::wal::segment_name(0)), frame(entry)).expect("write"); + stamp(&dir); + + let err = Durable::open(&dir, Duration::days(30)).expect_err("damage refuses"); + assert!( + matches!( + &err, + didbot_pds::wal::WalError::UnreadableCommit { did, cid, .. } + if did == "did:web:a.agents.localhost" && cid == "not-a-cid" + ), + "{err}" + ); + let rendered = err.to_string(); + assert!(rendered.contains("not-a-cid"), "{rendered}"); + assert!( + rendered.contains("did:web:a.agents.localhost"), + "{rendered}" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + /// Every entry reads back as the variant that wrote it. /// /// `Entry` is a tagged enum over serde, which is forgiving in exactly the diff --git a/crates/didbot-pds/tests/ledger.rs b/crates/didbot-pds/tests/ledger.rs index 70f275e8..ca0e2769 100644 --- a/crates/didbot-pds/tests/ledger.rs +++ b/crates/didbot-pds/tests/ledger.rs @@ -115,12 +115,10 @@ fn provisioning_opens_a_ledger_naming_the_backend_that_admitted_it() { LedgerEvent::Provisioned { account_id, backend, - assurance, .. } => { assert_eq!(account_id, "kestrel"); - assert_eq!(backend, "server"); - assert_eq!(assurance, didbot_pds::CREATOR_ASSURANCE); + assert_eq!(*backend, didbot_pds::ledger::Backend::Server); } other => panic!("the first entry must be a provisioning: {other:?}"), } diff --git a/crates/didbot-serve/src/tests/mod.rs b/crates/didbot-serve/src/tests/mod.rs index 1f114933..6db26175 100644 --- a/crates/didbot-serve/src/tests/mod.rs +++ b/crates/didbot-serve/src/tests/mod.rs @@ -1000,8 +1000,7 @@ impl Registry for FakeRegistry { at: account.created_at, event: LedgerEvent::Provisioned { account_id: account.account_id.clone(), - backend: "server".to_owned(), - assurance: didbot_pds::CREATOR_ASSURANCE.to_owned(), + backend: didbot_pds::ledger::Backend::Server, parent: None, }, }], -- 2.51.2