From b25ababa3776c64863e4ea7eaf2ae2b708122c12 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 2 Sep 2026 16:21:49 -0400 Subject: [PATCH] feat(didbot-pds): supply attributes, deployment facts, now, and drive observe/lifecycle WriteSubject grows the three inputs a stateless predicate may reference beyond diff and identity: SubjectAttributes (kind, handle, harness, agent_type -- all four kind-dependent and populated from the account, never the request, with absence left ordinary rather than faked), and DeploymentAttributes (this PDS's own DID and hostname). `now` is pinned once per write from the write path rather than left for an evaluator to read its own clock, for the same reason evaluators get no store handle: two policies judging one write must see the same instant. WriteQueue::submit now calls PolicyGate::observe unconditionally after judge, on every outcome -- the observation channel does not short-circuit even though admit does. Freeze calls PolicyGate::froze; Provisioner's freeze, unfreeze and delete_as now drive froze/unfroze/deleted so a stateful evaluator's per-agent state cannot outlive or outlast what actually happened to the account. Change-Id: I7a52115d617410394c547721a21bda8a41d1dbc3 --- crates/didbot-pds/src/policy.rs | 114 ++++++++++++++++++++++++++++ crates/didbot-pds/src/provision.rs | 39 ++++++++++ crates/didbot-pds/src/writequeue.rs | 14 +++- 3 files changed, 166 insertions(+), 1 deletion(-) diff --git a/crates/didbot-pds/src/policy.rs b/crates/didbot-pds/src/policy.rs index 32f0e151..c74eec8d 100644 --- a/crates/didbot-pds/src/policy.rs +++ b/crates/didbot-pds/src/policy.rs @@ -48,6 +48,9 @@ //! this crate's job to keep from happening. use serde_json::Value; +use time::OffsetDateTime; + +use crate::kind::AccountKind; /// Which version of a policy set judged a write. /// @@ -238,13 +241,48 @@ fn diff_into<'a>(path: &str, before: Option<&'a Value>, after: Option<&'a Value> /// `didbot-policy` is expected to define the canonical attribute set a gate /// may depend on; this is what the write path can supply today, and it grows /// as that set does. +/// +/// `bot.did.registration`'s `actor` union names four kinds of account — +/// [`AccountKind::Agent`], [`AccountKind::Pipeline`], [`AccountKind::Host`], +/// [`AccountKind::Service`] — and only `Agent` has a harness or a claimed +/// agent type. `kind` is always populated, truthfully, so a gate (or the +/// tree above it) can route a host's write past a policy that only makes +/// sense for an agent without an evaluator running at all. The rest are +/// absent exactly when the account has nothing there: a host has no +/// harness, and nothing here synthesizes a placeholder to keep the shape +/// uniform — an evaluator asking a host for its harness is expected to see +/// `None`, not a fabricated value. #[derive(Debug, Clone, Copy, Default)] pub struct SubjectAttributes<'a> { + /// Which of the four account kinds this is. + pub kind: AccountKind, /// The account's own stored handle, if it has one. Read off /// [`AgentAccount::handle`](crate::account::AgentAccount::handle), which /// this deployment set at provisioning or a later rename — never off /// anything in the write being judged. pub handle: Option<&'a str>, + /// The harness that provisioned an [`AccountKind::Agent`], if the + /// caller named one at the time. Read off + /// [`AgentAccount::harness`](crate::account::AgentAccount::harness). + pub harness: Option<&'a str>, + /// The harness's own name for the agent's type, if the caller named + /// one. Read off + /// [`AgentAccount::agent_type`](crate::account::AgentAccount::agent_type). + pub agent_type: Option<&'a str>, +} + +/// Static facts about this deployment, the same on every write it judges. +/// +/// Cheap to supply — nothing here changes between one write and the next — +/// so the write path builds this once per call rather than caching it: the +/// PDS's own DID and hostname are already held by +/// [`Provisioner`](crate::provision::Provisioner) for other reasons. +#[derive(Debug, Clone, Copy)] +pub struct DeploymentAttributes<'a> { + /// This server's own service DID. + pub pds_did: &'a str, + /// This server's own hostname. + pub pds_hostname: &'a str, } /// One write, as a [`PolicyGate`] judges it: `(agent, client_id, collection, @@ -260,6 +298,15 @@ pub struct SubjectAttributes<'a> { /// write, and a subject shape without the field could never express that /// once client association exists. Wiring a real value in is follow-up work, /// not invented here. +/// +/// A stateless predicate may reference exactly three things: the subject +/// (its identity and [`SubjectAttributes`]), the incoming write (`diff`'s +/// `after` side), and the current version if one exists (`diff`'s `before` +/// side). Nothing else is on this type, on purpose — no store handle, no +/// registry, no way to reach another record. An evaluator that needs more +/// than these three inputs is a *stateful* evaluator, which owns its own +/// state and is told about writes through +/// [`PolicyGate::observe`], not by being handed a way to go fetch more. #[derive(Debug, Clone, Copy)] pub struct WriteSubject<'a> { /// The DID whose repository is being written to. @@ -277,6 +324,21 @@ pub struct WriteSubject<'a> { /// Facts about the agent this deployment can vouch for. See /// [`SubjectAttributes`]. pub attributes: SubjectAttributes<'a>, + /// Facts about this deployment, the same on every write. See + /// [`DeploymentAttributes`]. + pub deployment: DeploymentAttributes<'a>, + /// The instant this write is judged at. + /// + /// An input, never an ambient read: an evaluator must not call its own + /// clock, for the same reason it gets no store handle — two policies + /// judging one write must see the same instant, or a decision would be + /// explicable only by accident. Pinned once, at the same moment the + /// judging [`PolicyVersion`] is pinned, and — like the version — meant + /// to be recorded alongside the decision rather than re-derived later: + /// `plan/policy.md`'s "decisions are not reproducible" is a second + /// reason a time-dependent rule's instant has to be written down, not + /// only its version. + pub now: OffsetDateTime, } /// A policy gate's verdict on one write. @@ -324,6 +386,53 @@ pub trait PolicyGate: Send + Sync { /// agree even if a policy set reloads concurrently with this write; see /// [`crate::writequeue::WriteQueue::submit`]. fn version(&self) -> PolicyVersion; + + /// Every judged attempt and its outcome, on this gate's declared + /// observation surface. + /// + /// Unlike `judge`, this never short-circuits: [`WriteQueue`] calls it for + /// every write that reached `judge` at all, including one `judge` itself + /// refused, because a metapolicy watching for repeated denials has to see + /// a denial even when it is the one that produced it. Never called for a + /// write that never reached a gate — refused by a full queue, or made by + /// an administrative bypass; see [`crate::provision::Provisioner`]'s + /// enumerated list. + /// + /// Default is a no-op, so a gate with nothing stateful to observe does + /// not have to implement this. + /// + /// [`WriteQueue`]: crate::writequeue::WriteQueue + fn observe(&self, subject: &WriteSubject<'_>, outcome: &Outcome) { + let _ = (subject, outcome); + } + + /// This agent's writes were frozen, whether by this gate's own judgment + /// or by an operator's explicit [`Registry::freeze`](crate::provision::Registry::freeze). + /// + /// Default is a no-op. + fn froze(&self, agent: &str, reason: &str) { + let _ = (agent, reason); + } + + /// An operator lifted a freeze on this agent. + /// + /// `plan/policy.md`'s "An evaluator can never unfreeze. Only an operator, + /// explicitly. But the evaluator must be told, or the state that + /// triggered the freeze survives the unfreeze and slams it shut again." + /// Default is a no-op, which is only correct for a gate with no state + /// that a freeze could have set. + fn unfroze(&self, agent: &str) { + let _ = agent; + } + + /// This agent's account is gone, and any per-agent state a stateful + /// evaluator built for it should go with it — not be reconstructed from + /// the log, and not linger for an identifier nothing will ever reuse. + /// + /// Default is a no-op. + fn deleted(&self, agent: &str) { + let _ = agent; + } } /// The gate a deployment starts with, and the one every test not exercising @@ -358,6 +467,11 @@ mod tests { action: WriteAction::Create, diff, attributes: SubjectAttributes::default(), + deployment: DeploymentAttributes { + pds_did: "did:web:pds.example", + pds_hostname: "pds.example", + }, + now: OffsetDateTime::UNIX_EPOCH, } } diff --git a/crates/didbot-pds/src/provision.rs b/crates/didbot-pds/src/provision.rs index 6e570c48..3e0f4047 100644 --- a/crates/didbot-pds/src/provision.rs +++ b/crates/didbot-pds/src/provision.rs @@ -2881,6 +2881,7 @@ where } else { WriteAction::Create }; + let pds_did = self.zone.service_did(); let subject = WriteSubject { agent: account.did.as_str(), client_id: None, @@ -2888,8 +2889,16 @@ where action, diff: &changes, attributes: SubjectAttributes { + kind: account.kind, handle: account.handle.as_deref(), + harness: account.harness.as_deref(), + agent_type: account.agent_type.as_deref(), }, + deployment: crate::policy::DeploymentAttributes { + pds_did: &pds_did, + pds_hostname: self.zone.host(), + }, + now: OffsetDateTime::now_utc(), }; let admitted = self .write_queue @@ -3033,6 +3042,14 @@ where // hash-match and verify were this skipped. self.credentials.revoke(removed.did.as_str()); + // And any per-agent state a stateful evaluator built for this + // identity — nothing here will ever write under this DID again, so + // state kept for it is dead weight at best and, held past deletion, + // a bookkeeping error waiting for a future account this DID is + // reused by, which this deployment's own DID minting is designed + // never to do but a gate should not have to assume. + self.policy_gate.deleted(removed.did.as_str()); + // Released after the store, not before: a name freed for an account // the store then refused to remove would be issued to a second agent // while the first is still answering to it. @@ -3845,6 +3862,12 @@ where fn freeze(&self, did: &str) -> Result<(), ProvisionError> { let account = self.transition(did, AccountState::Frozen)?; self.refresh_identity(&account.did); + // Told regardless of whether the write queue's own line for this + // repository is already frozen (a policy may have tripped it first): + // a stateful evaluator's own bookkeeping needs to agree this account + // is frozen either way, and telling it twice costs nothing a + // once-only guard would be worth adding for. + self.policy_gate.froze(account.did.as_str(), "frozen by an operator"); tracing::info!("froze account: repository stays readable, writes refused"); Ok(()) } @@ -3853,6 +3876,13 @@ where fn unfreeze(&self, did: &str) -> Result<(), ProvisionError> { let account = self.transition(did, AccountState::Active)?; self.refresh_identity(&account.did); + // Both halves of lifting a freeze: the write queue's own line, which + // is what actually stops a write from reaching a gate again (see + // `crate::writequeue`), and the gate's own state, which + // `plan/policy.md` requires be told or "the state that triggered the + // freeze survives the unfreeze and slams it shut again". + self.write_queue.unfreeze(account.did.as_str()); + self.policy_gate.unfroze(account.did.as_str()); tracing::info!("unfroze account"); Ok(()) } @@ -4222,6 +4252,7 @@ where // routing it through the queue would only cost a turn for // nothing. let changes = policy_diff(Some(old), None); + let pds_did = self.zone.service_did(); let subject = WriteSubject { agent: account.did.as_str(), client_id: None, @@ -4229,8 +4260,16 @@ where action: WriteAction::Delete, diff: &changes, attributes: SubjectAttributes { + kind: account.kind, handle: account.handle.as_deref(), + harness: account.harness.as_deref(), + agent_type: account.agent_type.as_deref(), + }, + deployment: crate::policy::DeploymentAttributes { + pds_did: &pds_did, + pds_hostname: self.zone.host(), }, + now: OffsetDateTime::now_utc(), }; let admitted = self .write_queue diff --git a/crates/didbot-pds/src/writequeue.rs b/crates/didbot-pds/src/writequeue.rs index b94265cc..e247ce88 100644 --- a/crates/didbot-pds/src/writequeue.rs +++ b/crates/didbot-pds/src/writequeue.rs @@ -296,6 +296,11 @@ impl WriteQueue { let line = self.line(subject.agent); let ticket = line.enter(subject.agent, self.capacity)?; let outcome = gate.judge(subject); + // Never short-circuits, unlike everything below it: a metapolicy + // watching for repeated denials must see this attempt even though + // `judge` already decided it, so `observe` runs for every outcome, + // including the two that stop `admit` from ever running. + gate.observe(subject, &outcome); match outcome { Outcome::Allow => { let version = gate.version(); @@ -313,6 +318,7 @@ impl WriteQueue { // must see it, or it would be judged as if nothing had // happened. line.freeze(reason.clone()); + gate.froze(subject.agent, &reason); line.leave(ticket.ticket); Err(QueueError::Frozen { reason: reason.to_string(), @@ -360,10 +366,11 @@ impl Drop for Ticket { #[cfg(test)] mod tests { use super::*; - use crate::policy::{PolicyVersion, SubjectAttributes, WriteAction}; + use crate::policy::{DeploymentAttributes, PolicyVersion, SubjectAttributes, WriteAction}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Barrier; use std::time::Duration; + use time::OffsetDateTime; fn subject(agent: &str) -> WriteSubject<'_> { WriteSubject { @@ -373,6 +380,11 @@ mod tests { action: WriteAction::Create, diff: &[], attributes: SubjectAttributes::default(), + deployment: DeploymentAttributes { + pds_did: "did:web:pds.example", + pds_hostname: "pds.example", + }, + now: OffsetDateTime::UNIX_EPOCH, } } -- 2.51.2