diff --git a/crates/didbot-pds/src/provision.rs b/crates/didbot-pds/src/provision.rs index 584e5f4b..1b5328fe 100644 --- a/crates/didbot-pds/src/provision.rs +++ b/crates/didbot-pds/src/provision.rs @@ -438,6 +438,24 @@ pub enum ProvisionError { /// [`crate::admission`]. #[error("admission refused: {0}")] Admission(#[from] AdmissionError), + /// The principal still has live children, so it cannot stop serving or + /// be erased. + /// + /// Refused rather than cascaded: a principal leaves the tree from the + /// leaves up, and a child that is not [`AccountState::Decommissioned`] + /// holds a reference on its parent. The count and one child are named + /// so the caller can act on the children first. + #[error("account {did} has {count} live child account(s), such as {sample}; {operation} is refused until they are decommissioned")] + LiveChildren { + /// The principal. + did: String, + /// How many children hold a reference. + count: usize, + /// One of them. + sample: String, + /// What was attempted, for the message. + operation: &'static str, + }, } impl From for ProvisionError { @@ -482,6 +500,14 @@ pub struct RegistryStats { /// What their blobs come to. #[serde(default)] pub blobs: BlobStats, + /// How many live accounts hold a reference on a parent, by the child's + /// kind label. + /// + /// The reference counts [`ProvisionError::LiveChildren`] refuses on, + /// summed across the tree: a deployment can see how much hangs beneath + /// its principals before it tries to retire one. + #[serde(default)] + pub live_children: BTreeMap, } /// How many repositories are held built at once. @@ -3601,6 +3627,39 @@ where }) } + /// Refuses `operation` while any child of `account` is live. + /// + /// The reference-count rule, checked where a principal would stop + /// serving its children — [`Lock::Deactivated`] — or start erasing + /// itself. See [`ProvisionError::LiveChildren`]. + fn require_no_live_children( + &self, + account: &AgentAccount, + operation: &'static str, + ) -> Result<(), ProvisionError> { + let live: Vec = self + .store + .children(&account.did) + .into_iter() + .filter(|child| child.state.is_live()) + .collect(); + let Some(sample) = live.first() else { + return Ok(()); + }; + tracing::info!( + count = live.len(), + sample = %sample.did, + operation, + "refused: the account has live children" + ); + Err(ProvisionError::LiveChildren { + did: account.did.as_str().to_owned(), + count: live.len(), + sample: sample.did.as_str().to_owned(), + operation, + }) + } + /// Refuses the account's own erasure while any lock is hung on it. /// /// The one place the self-service rule lives: an operator's erasure and @@ -3698,7 +3757,10 @@ where self.require_unlocked(&account)?; } let account = match account.state { - AccountState::Active => self.transition(did, AccountState::Decommissioning)?, + AccountState::Active => { + self.require_no_live_children(&account, "erasure")?; + self.transition(did, AccountState::Decommissioning)? + } AccountState::Decommissioning => account, from => { tracing::info!(?from, "erasure refused: nothing to erase from this state"); @@ -3905,6 +3967,9 @@ where fn hang_lock(&self, did: &str, lock: Lock, actor: Actor<'_>) -> Result<(), ProvisionError> { let account = self.lookup(did)?; self.refuse_server(&account.did, "locking")?; + if lock == Lock::Deactivated { + self.require_no_live_children(&account, "deactivation")?; + } let tag = Tag::new(lock, actor.party()); if account.locks.holds(tag) { tracing::info!(%tag, "lock refused: already hung"); @@ -5837,6 +5902,15 @@ where accounts: self.accounts().len(), records: self.records.stats(), blobs: self.blobs.stats(), + live_children: self + .store + .list() + .into_iter() + .filter(|account| account.parent.is_some() && account.state.is_live()) + .fold(BTreeMap::new(), |mut counts, account| { + *counts.entry(account.kind.label().to_owned()).or_insert(0) += 1; + counts + }), } } diff --git a/crates/didbot-pds/tests/admission_tree.rs b/crates/didbot-pds/tests/admission_tree.rs index c152fe04..a4ad1f93 100644 --- a/crates/didbot-pds/tests/admission_tree.rs +++ b/crates/didbot-pds/tests/admission_tree.rs @@ -952,3 +952,115 @@ fn the_boundary_is_the_chain_from_the_account_to_the_root() { Err(ProvisionError::UnknownAccount { .. }) )); } + +// --------------------------------------------------------------------------- +// Reference counts +// --------------------------------------------------------------------------- + +/// A principal with live children is neither deactivated nor erased: the +/// refusal names how many and one of them, a child that is decommissioned +/// releases its reference, and the operation goes through once the last +/// child is gone. +#[test] +fn a_principal_with_live_children_is_refused_until_the_last_child_is_gone() { + let (pds, store) = mem(Trust::new()); + let server = pds.service_did(); + let host = admitted(&pds, &*store, "host", &server); + let first = admitted(&pds, &*store, "first", host.as_str()); + let second = admitted(&pds, &*store, "second", host.as_str()); + let live_children = |pds: &MemoryPds| pds.stats().live_children; + assert_eq!( + live_children(&pds), + [("agent".to_owned(), 3)].into_iter().collect(), + "the host and its two agents each hold a reference" + ); + + let refused = pds + .lock(host.as_str(), Lock::Deactivated, Actor::Account) + .expect_err("two live children"); + assert!( + matches!(&refused, ProvisionError::LiveChildren { did, count: 2, sample, operation: "deactivation" } + if did == host.as_str() && (sample == first.as_str() || sample == second.as_str())), + "{refused:?}" + ); + let refused = pds + .delete(host.as_str()) + .expect_err("erasure is refused too"); + assert!( + matches!( + &refused, + ProvisionError::LiveChildren { + count: 2, + operation: "erasure", + .. + } + ), + "{refused:?}" + ); + let refused = pds + .hard_delete(host.as_str(), host.as_str(), "operator-erin") + .expect_err("an operator's erasure respects the children too"); + assert!( + matches!(&refused, ProvisionError::LiveChildren { count: 2, .. }), + "{refused:?}" + ); + assert_eq!( + pds.account(host.as_str()).expect("known").state, + AccountState::Active, + "nothing moved" + ); + assert!(!holds( + &pds, + &host, + Tag::new(Lock::Deactivated, Party::Account) + )); + + // One child decommissioned: one reference left, and it is named. + pds.delete(first.as_str()) + .expect("the first agent erases itself"); + assert_eq!( + pds.account(first.as_str()).expect("known").state, + AccountState::Decommissioned + ); + let refused = pds + .lock(host.as_str(), Lock::Deactivated, Actor::Account) + .expect_err("one live child"); + assert!( + matches!(&refused, ProvisionError::LiveChildren { count: 1, sample, .. } + if sample == second.as_str()), + "{refused:?}" + ); + assert_eq!( + live_children(&pds), + [("agent".to_owned(), 2)].into_iter().collect(), + "a decommissioned child no longer counts" + ); + + // Deactivating the last child does not release it: it is still data. + pds.lock(second.as_str(), Lock::Deactivated, Actor::Account) + .expect("the second agent deactivates itself"); + assert!(matches!( + pds.lock(host.as_str(), Lock::Deactivated, Actor::Account), + Err(ProvisionError::LiveChildren { count: 1, .. }) + )); + pds.unlock(second.as_str(), Lock::Deactivated, Actor::Account) + .expect("and comes back"); + + // The last child gone, the host may stop serving and then go. + pds.delete(second.as_str()) + .expect("the second agent erases itself"); + pds.lock(host.as_str(), Lock::Deactivated, Actor::Account) + .expect("no live children remain"); + pds.unlock(host.as_str(), Lock::Deactivated, Actor::Account) + .expect("reactivated"); + pds.delete(host.as_str()) + .expect("erased from the leaves up"); + assert_eq!( + pds.account(host.as_str()).expect("known").state, + AccountState::Decommissioned + ); + assert!( + live_children(&pds).is_empty(), + "nothing live hangs beneath anything" + ); +} diff --git a/crates/didbot-serve/src/error.rs b/crates/didbot-serve/src/error.rs index 6ef11cd7..3c6920b8 100644 --- a/crates/didbot-serve/src/error.rs +++ b/crates/didbot-serve/src/error.rs @@ -476,6 +476,9 @@ impl From<&ProvisionError> for ApiError { // The edge that failed is in the message; the name says only // that the chain did not reach a root. ProvisionError::Admission(..) => (StatusCode::FORBIDDEN, "AdmissionRefused"), + // The message carries the count and a child; the caller retires + // the children and asks again. + ProvisionError::LiveChildren { .. } => (StatusCode::CONFLICT, "AccountHasLiveChildren"), }; Self::new(status, name, err.to_string()) } diff --git a/crates/didbot-serve/src/tests.rs b/crates/didbot-serve/src/tests.rs index 1669b51c..2483720b 100644 --- a/crates/didbot-serve/src/tests.rs +++ b/crates/didbot-serve/src/tests.rs @@ -872,6 +872,7 @@ impl Registry for FakeRegistry { discarded_at_boot: self.boot.discarded, ..self.blobs.stats() }, + live_children: Default::default(), } } diff --git a/crates/didbot-serve/src/wire.rs b/crates/didbot-serve/src/wire.rs index 7d5799ca..53c0a674 100644 --- a/crates/didbot-serve/src/wire.rs +++ b/crates/didbot-serve/src/wire.rs @@ -868,6 +868,11 @@ pub struct StatsResponse { /// two units would be a number nothing could act on. An operator /// watching a run wants to know which of the two is growing. pub blobs: BlobsView, + /// How many live accounts hold a reference on a parent, by the child's + /// kind: what a principal cannot be deactivated or erased over. Absent + /// while nothing hangs beneath anything. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub live_children: BTreeMap, } /// How many blobs, and what they occupy. @@ -967,6 +972,7 @@ impl StatsResponse { missing_at_boot: stats.blobs.missing_at_boot.into(), discarded_at_boot: stats.blobs.discarded_at_boot.into(), }, + live_children: stats.live_children.clone(), } } } diff --git a/plan/account-lifecycle.md b/plan/account-lifecycle.md index 0958fe4a..df4f01d4 100644 --- a/plan/account-lifecycle.md +++ b/plan/account-lifecycle.md @@ -203,3 +203,11 @@ it hosts*, which stays true. answer changes; the lifecycle on `didbot-fsm`, with [`docs/account-lifecycle.md`](../docs/account-lifecycle.md) generated from the types and checked by a test. +- [x] **A lock is confined to a subtree.** Every account carries the + parent it was admitted under (`AgentAccount::parent`, verified by + `Registry::admit`'s chain walk), a tag hung on a principal is hung on + everything live beneath it as `Party::Parent`, and a parent's vouch + lapsing quarantines its subtree at the next confinement poll. +- [x] **A principal with live children stays.** `Deactivated` and erasure + are refused with the count and one child while anything beneath it + is short of `Decommissioned`; `bot.did.stats` publishes the counts.