diff --git a/crates/didbot-pds/src/provision.rs b/crates/didbot-pds/src/provision.rs index a9621a7e..e998b8ab 100644 --- a/crates/didbot-pds/src/provision.rs +++ b/crates/didbot-pds/src/provision.rs @@ -708,6 +708,10 @@ pub struct RegistryStats { /// How many reservations are waiting on a vouch, against the cap. #[serde(default)] pub reservations: ReservationDepth, + /// The digest of the operator policy revision being enforced, once one + /// has been read -- see [`PolicyGate::enforced_revision`]. + #[serde(default)] + pub policy_revision: Option, } /// How many repositories are held built at once. @@ -6762,6 +6766,7 @@ where counts }), reservations: self.reservation_depth(OffsetDateTime::now_utc()), + policy_revision: self.policy_gate.enforced_revision(), } } diff --git a/crates/didbot-policy-source/src/lib.rs b/crates/didbot-policy-source/src/lib.rs index 7f2e5c71..6711b215 100644 --- a/crates/didbot-policy-source/src/lib.rs +++ b/crates/didbot-policy-source/src/lib.rs @@ -73,6 +73,6 @@ pub use builtin::{builtin_policies, BuiltinPolicy}; pub use compile::{CompileReport, Language, PolicyWarning, ScopedDenial}; pub use last_good::LastGoodPolicies; pub use merge::{build, LastGoodFallback, LoadReport}; -pub use poll::{OperatorPoll, OperatorView, PollError, PollEvent}; +pub use poll::{list_records, OperatorPoll, OperatorView, PollError, PollEvent}; pub use record::{parse_batch, parse_record, ParsedPolicy, PredicateRef, RecordOutcome}; pub use startup::{load as load_startup, StartupError, StartupLoad}; diff --git a/crates/didbot-policy-source/src/poll.rs b/crates/didbot-policy-source/src/poll.rs index b06729b4..5983dead 100644 --- a/crates/didbot-policy-source/src/poll.rs +++ b/crates/didbot-policy-source/src/poll.rs @@ -467,19 +467,21 @@ pub(crate) async fn resolve_pds_endpoint( }) } -/// Page through `operator_did`'s `bot.did.policy` collection at `endpoint` -/// to completion, returning every record found keyed by at-uri. +/// Page through `operator_did`'s `collection` at `endpoint` to completion, +/// returning every record found keyed by at-uri. /// /// `Err` on the first failure of any kind -- an unreachable endpoint, a /// malformed page -- deliberately with no partial result: [`poll_once`] /// relies on this all-or-nothing contract to keep [`OperatorView`] untouched -/// on failure. Split out from [`fetch_all`] so [`poll_once`] can reuse the -/// same resolved `endpoint` for [`crate::blob`]'s predicate fetch without -/// resolving the operator's did:web document twice. -async fn list_collection( +/// on failure, and a caller reading two collections as one revision relies +/// on it to apply neither when either fails. Takes an already resolved +/// `endpoint` so one resolution of the operator's document serves every +/// listing and [`crate::blob`]'s predicate fetch alike. +pub async fn list_records( client: &reqwest::Client, endpoint: &str, operator_did: &str, + collection: &str, ) -> Result, PollError> { let list_url = format!("{endpoint}/xrpc/com.atproto.repo.listRecords"); let mut out = HashMap::new(); @@ -487,7 +489,7 @@ async fn list_collection( loop { let mut query = vec![ ("repo".to_string(), operator_did.to_string()), - ("collection".to_string(), COLLECTION.to_string()), + ("collection".to_string(), collection.to_string()), ("limit".to_string(), PAGE_SIZE.to_string()), ]; if let Some(cursor) = &cursor { @@ -550,7 +552,7 @@ pub async fn fetch_all( operator_did: &str, ) -> Result, PollError> { let endpoint = resolve_pds_endpoint(client, operator_did).await?; - list_collection(client, &endpoint, operator_did).await + list_records(client, &endpoint, operator_did, COLLECTION).await } /// Fetch the operator's collection and, only on success, apply it to @@ -569,7 +571,7 @@ pub async fn poll_once( blob_cache: &BlobCache, ) -> Result, PollError> { let endpoint = resolve_pds_endpoint(client, operator_did).await?; - let listing = list_collection(client, &endpoint, operator_did).await?; + let listing = list_records(client, &endpoint, operator_did, COLLECTION).await?; Ok(view .apply( listing, diff --git a/crates/didbot-serve/src/bin/didbot-pds.rs b/crates/didbot-serve/src/bin/didbot-pds.rs index 5bc9b473..3caf36c0 100644 --- a/crates/didbot-serve/src/bin/didbot-pds.rs +++ b/crates/didbot-serve/src/bin/didbot-pds.rs @@ -52,6 +52,7 @@ use didbot_pds::{ ServerLifecycle, StaleSweep, Trust, DEFAULT_GRACE_WINDOW, DEFAULT_HOLD, }; use didbot_serve::ownership_poll; +use didbot_serve::policy_poll::PolicyPoll; #[cfg(feature = "route53")] use didbot_serve::serve_tls; use didbot_serve::DEFAULT_REPOS_CAPACITY; @@ -1634,6 +1635,13 @@ async fn run(mut args: Args) -> Result<(), String> { "polling for the operator's public bot.did.operator claim; a server whose \ operator has not written one yet is ordinary and fully operational" ); + // The operator's policy records ride the same tick, read + // from the same repository: see `didbot_serve::policy_poll`. + let policies = Arc::new(PolicyPoll::new( + args.owner.clone(), + registry.service_did(), + enforcement.gate.clone(), + )); Some(OwnershipPoll::spawn( ownership, auth.estop.clone(), @@ -1642,6 +1650,7 @@ async fn run(mut args: Args) -> Result<(), String> { didbot_http::client(), didbot_serve::PollSchedule::default(), nudge.clone(), + Some(policies), )) } None => { @@ -1801,20 +1810,16 @@ where /// The gate every write this server admits is judged by, and the log both /// it and `POST /oauth/par` write their refusals to. /// -/// Built from `didbot-policy-source`'s first source alone -- the policies -/// compiled into this binary. The other two sources `plan/policy.md` -/// describes are passed empty here: source 2 wants a startup file this -/// binary has no flag for, and source 3 wants the operator's own policy -/// records, whose lexicon `plan/policy.md` leaves deliberately unsettled. -/// Both are `didbot_pds::policy_tree::TreePolicyGate::swap` calls away once -/// those exist, which is the reason this is a real gate over an -/// almost-empty tree rather than `didbot_pds::policy::NoPolicyGate`: a -/// built-in denial that ships in the binary has to be judged by *something* -/// on the write path, and until this call that something was nowhere. +/// Built at startup from `didbot-policy-source`'s first source alone -- the +/// policies compiled into this binary -- with source 2 passed empty, since +/// this binary takes no startup file. Source 3, the operator's own +/// `bot.did.policy` and `bot.did.policyBinding` records, reaches it later: +/// `didbot_serve::policy_poll::PolicyPoll` swaps each set it builds into +/// this same gate on the ownership poll's tick. /// /// A deny-only tree with no policies in it admits everything, so with an /// empty [`didbot_policy_source::builtin_policies`] this is exactly the -/// behaviour a gateless server had. +/// behaviour a gateless server had until the first set lands. /// /// [`Enforcement::open`] is the only caller: the pair has to be built once /// and shared, or the two things this server judges -- a write and a grant @@ -1978,7 +1983,7 @@ fn policy_gate(evaluation_log: Arc) -> Arc { } info!( builtins = builtin_count, - "policy gate installed; no startup file and no operator records are wired to this build" + "policy gate installed; the operator's records arrive with the ownership poll" ); Arc::new(TreePolicyGate::new(tree).with_evaluation_log(evaluation_log)) } diff --git a/crates/didbot-serve/src/lib.rs b/crates/didbot-serve/src/lib.rs index 05e931e1..e4a28e3c 100644 --- a/crates/didbot-serve/src/lib.rs +++ b/crates/didbot-serve/src/lib.rs @@ -46,7 +46,7 @@ //! | `GET /xrpc/com.atproto.repo.getRecord` | one record by collection and key | //! | `GET /xrpc/com.atproto.repo.listRecords` | read a collection back, newest first | //! | `GET /xrpc/com.atproto.repo.describeRepo` | the account, its document and its collections | -//! | `GET /xrpc/bot.did.stats` | accounts, records, bytes per collection, blobs by lifecycle state | +//! | `GET /xrpc/bot.did.stats` | accounts, records, bytes per collection, blobs by lifecycle state, the policy revision enforced | //! | `POST /xrpc/com.atproto.server.createSession` | a legacy session, from an app password | //! | `POST /xrpc/com.atproto.server.refreshSession` | rotate a session's tokens | //! | `POST /xrpc/com.atproto.server.deleteSession` | end a session | @@ -79,6 +79,7 @@ mod health; pub mod oauth; mod onboarding; pub mod ownership_poll; +pub mod policy_poll; #[cfg(test)] mod race; pub mod rate_limit; @@ -107,6 +108,7 @@ pub use oauth::scope::{ Transition, }; pub use ownership_poll::{OwnershipPoll, PollNudge, PollSchedule, MIN_POLL_INTERVAL}; +pub use policy_poll::{PolicyPoll, PolicyPollError}; pub use routes::{ app, app_with_auth, app_with_events, app_with_health, app_with_repos, app_with_streams, AuthState, XrpcBody, ATPROTO_METHODS, BOT_DID_METHODS, MAX_REQUEST_BODY, @@ -124,9 +126,10 @@ pub use wire::{ record_uri, refuse_skipped_validation, AgentSummary, ApplyWritesRequest, BlobsView, CreateRecordRequest, CreateRecordResponse, CreateSessionRequest, DeleteRecordRequest, DescribeRepoQuery, DescribeRepoResponse, DescribeServerResponse, DidRequest, FirehoseQuery, - GetRecordQuery, GetRecordResponse, GetSessionResponse, ListRecordsQuery, ProvisionAgentRequest, - PutRecordRequest, RecordView, RepoWrite, SessionResponse, SetPinnedRequest, StatsResponse, - Swap, TallyView, WireRegistration, WriteResult, MAX_LIST_LIMIT, + GetRecordQuery, GetRecordResponse, GetSessionResponse, ListRecordsQuery, PolicyView, + ProvisionAgentRequest, PutRecordRequest, RecordView, RepoWrite, SessionResponse, + SetPinnedRequest, StatsResponse, Swap, TallyView, WireRegistration, WriteResult, + MAX_LIST_LIMIT, }; pub use zone_read::{ZoneReadRetry, ZoneRecords, ZoneUnderRead, DEFAULT_ZONE_READ_INTERVAL}; diff --git a/crates/didbot-serve/src/ownership_poll.rs b/crates/didbot-serve/src/ownership_poll.rs index 874a75ca..265c2f62 100644 --- a/crates/didbot-serve/src/ownership_poll.rs +++ b/crates/didbot-serve/src/ownership_poll.rs @@ -58,6 +58,8 @@ use tokio::task::JoinHandle; use tokio::time::MissedTickBehavior; use tracing::{debug, info, warn}; +use crate::policy_poll::PolicyPoll; + /// The collection this poll reads. `didbot-pds` holds no schema for a /// collection defined by someone else's repository convention here, so this /// is a plain string rather than a `didbot_lexicon::nsid` constant — the @@ -445,6 +447,7 @@ impl OwnershipPoll { /// every further nudge inside that window coalesces into the same single /// poll. So an unbounded burst of nudges costs this server one extra /// outbound request, not one per nudge — see [`PollNudge`]. + #[allow(clippy::too_many_arguments)] pub fn spawn( ownership: Arc, estop: Arc, @@ -453,6 +456,7 @@ impl OwnershipPoll { client: reqwest::Client, schedule: PollSchedule, nudge: Arc, + policies: Option>, ) -> Self { let stop = Arc::new(Notify::new()); let notified = stop.clone(); @@ -516,6 +520,7 @@ impl OwnershipPoll { &client, OffsetDateTime::now_utc(), &mut reread_from, + policies.as_deref(), ), ) .await @@ -550,9 +555,9 @@ impl OwnershipPoll { /// closure, and public, so a caller — an integration test walking the /// bootstrap, in particular — can drive exactly one tick against a real /// operator repository without spawning a task or waiting on a timer. The -/// spawned poll calls nothing else. Every call here re-reads standing -/// vouches from the first host; the spawned poll is what carries on -/// between ticks. +/// spawned poll runs this and, on the same resolved endpoint, the +/// [`PolicyPoll`] it was given. Every call here re-reads standing vouches +/// from the first host; the spawned poll is what carries on between ticks. pub async fn poll_once( ownership: &Ownership, estop: &Estop, @@ -562,12 +567,16 @@ pub async fn poll_once( now: OffsetDateTime, ) -> OwnershipTransition { tick( - ownership, estop, lifecycle, registry, client, now, &mut None, + ownership, estop, lifecycle, registry, client, now, &mut None, None, ) .await } -/// [`poll_once`] with `reread_from` as [`refresh_host_vouches`]'s cursor. +/// [`poll_once`] with `reread_from` as [`refresh_host_vouches`]'s cursor, +/// followed by `policies`' own tick against the endpoint the claim was +/// read from. A tick that could not resolve the operator reads no policies +/// either, and the gate keeps the set it had. +#[allow(clippy::too_many_arguments)] async fn tick( ownership: &Ownership, estop: &Estop, @@ -576,6 +585,7 @@ async fn tick( client: &reqwest::Client, now: OffsetDateTime, reread_from: &mut Option, + policies: Option<&PolicyPoll>, ) -> OwnershipTransition { let endpoint = resolve_endpoint(client, ownership.operator_did()).await; let fetched = match &endpoint { @@ -688,6 +698,15 @@ async fn tick( ) .await; } + if let (Some(policies), Ok(endpoint)) = (policies, &endpoint) { + if let Err(error) = policies.poll_once(client, endpoint, registry).await { + warn!( + operator = ownership.operator_did(), + %error, + "operator policy poll applied nothing; the set last built keeps serving" + ); + } + } transition } @@ -1319,6 +1338,7 @@ mod tests { floor: StdDuration::from_millis(120), }, nudge.clone(), + None, ); // Let the immediate first tick land. @@ -1379,6 +1399,7 @@ mod tests { floor: StdDuration::from_millis(50), }, nudge.clone(), + None, ); tokio::time::sleep(StdDuration::from_millis(60)).await; diff --git a/crates/didbot-serve/src/policy_poll.rs b/crates/didbot-serve/src/policy_poll.rs new file mode 100644 index 00000000..0b7be6f9 --- /dev/null +++ b/crates/didbot-serve/src/policy_poll.rs @@ -0,0 +1,186 @@ +//! The policy poll: the operator's `bot.did.policy` and +//! `bot.did.policyBinding` records, read on the ownership poll's tick and +//! built into one policy set. +//! +//! One tick lists both collections to their last page from the endpoint +//! the ownership poll resolved, builds the whole set from them with +//! [`didbot_pds::policy_bindings::resolve`], and swaps the tree into the +//! gate every write and grant is judged by. A listing that fails at any +//! page builds nothing, and a build any record spoils installs nothing: +//! either way the gate keeps the set it last built, so an operator's +//! repository being unreachable or mid-edit never widens what an account +//! may do. The set's digest is what `bot.did.stats` publishes as +//! `policy.enforced`, and the build last refused is kept for an operator +//! to read. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use didbot_pds::policy_records::{BINDING_COLLECTION, POLICY_COLLECTION}; +use didbot_pds::policy_tree::TreePolicyGate; +use didbot_pds::{ + ApplyEvent, AtUri, Observed, PolicyRevision, PolicySet, Registry, RejectedBuild, ServerIdentity, +}; +use didbot_policy_source::{list_records, PollError}; +use tracing::{debug, info, warn}; + +/// Why a tick installed nothing. The gate keeps the set it had. +#[derive(Debug, thiserror::Error)] +pub enum PolicyPollError { + /// One collection could not be listed to completion. + #[error("listing {collection}: {source}")] + Listing { + /// The collection whose listing failed. + collection: &'static str, + /// What went wrong. + #[source] + source: PollError, + }, + /// The records were read whole and the build refused them. + #[error("{0}")] + Rejected(Arc), +} + +/// The operator's policy records as this deployment reads and enforces +/// them, across ticks. +pub struct PolicyPoll { + operator_did: String, + this_pds: String, + gate: Arc, + set: PolicySet, +} + +impl PolicyPoll { + /// A poll of `operator_did`'s repository on behalf of the server whose + /// service DID is `this_pds`, swapping each set it builds into `gate`. + pub fn new( + operator_did: impl Into, + this_pds: impl Into, + gate: Arc, + ) -> Self { + Self { + operator_did: operator_did.into(), + this_pds: this_pds.into(), + gate, + set: PolicySet::new(), + } + } + + /// The revision in force. + pub fn current(&self) -> Arc { + self.set.current() + } + + /// The build most recently refused, if the newest observation was. + pub fn rejected(&self) -> Option> { + self.set.rejected() + } + + /// The digest the gate enforces, once a set has been built. + pub fn enforced(&self) -> Option { + self.gate.enforced() + } + + /// One tick against the operator's repository at `endpoint`: list both + /// collections, build the set whole, and swap it in. `registry` answers + /// the admission tree a binding's subjects are checked against. + pub async fn poll_once( + &self, + client: &reqwest::Client, + endpoint: &str, + registry: &dyn Registry, + ) -> Result, PolicyPollError> { + let mut listed = Vec::with_capacity(2); + for collection in [POLICY_COLLECTION, BINDING_COLLECTION] { + let listing = list_records(client, endpoint, &self.operator_did, collection) + .await + .map_err(|source| PolicyPollError::Listing { collection, source })?; + listed.push(by_record_key(listing)); + } + let bindings = listed.pop().unwrap_or_default(); + let policies = listed.pop().unwrap_or_default(); + let observed = Observed { + author: self.operator_did.clone(), + policies, + bindings, + }; + + let chain_of = |did: &str| registry.boundary(did).ok(); + let built = didbot_pds::policy_bindings::resolve( + &observed, + ServerIdentity { + service_did: &self.this_pds, + operator_did: &self.operator_did, + }, + &chain_of, + didbot_policy_source::builtin_policies(), + &HashMap::new(), + ); + let bound = match built { + Ok(bound) => bound, + Err(rejected) => { + let rejected = self.set.reject(rejected); + for failure in &rejected.failures { + warn!( + collection = failure.collection, + key = %failure.key, + reason = %failure.reason, + "a policy record was refused; the set last built keeps serving" + ); + } + return Err(PolicyPollError::Rejected(rejected)); + } + }; + + let digest = bound.revision.digest.clone(); + let (revision, events) = self.set.install(bound.revision); + for event in &events { + match event { + ApplyEvent::Added { collection, key } + | ApplyEvent::Updated { collection, key } + | ApplyEvent::Removed { collection, key } => { + debug!(collection, %key, ?event, "operator policy poll observed a change"); + } + } + } + for key in &bound.report.unbound { + debug!(%key, "a policy no binding names reaches nobody"); + } + for (binding, subject) in &bound.report.foreign_subjects { + debug!(%binding, %subject, "a binding names a subject that is not an account here; ignored"); + } + for warning in &bound.load.warnings { + warn!( + ?warning, + "a policy compiled with something the operator should see" + ); + } + info!( + %digest, + policies = revision.policies.len(), + bindings = revision.bindings.len(), + bound = bound.bindings.len(), + "installed the operator's policy set" + ); + self.gate.swap_bound(bound.tree, bound.bindings, digest); + Ok(revision) + } +} + +/// A listing keyed by at-uri, re-keyed by record key: the key a binding +/// names a policy by, and the one a refusal names a record by. +fn by_record_key( + listing: HashMap, +) -> BTreeMap { + listing + .into_iter() + .map(|(uri, value)| { + let key = AtUri::parse(&uri) + .ok() + .and_then(AtUri::rkey) + .unwrap_or_else(|| uri.rsplit('/').next().unwrap_or(&uri)) + .to_owned(); + (key, value) + }) + .collect() +} diff --git a/crates/didbot-serve/src/tests.rs b/crates/didbot-serve/src/tests.rs index 15df0fc2..99b25278 100644 --- a/crates/didbot-serve/src/tests.rs +++ b/crates/didbot-serve/src/tests.rs @@ -994,6 +994,7 @@ impl Registry for FakeRegistry { pending, capacity: self.reservation_capacity, }, + policy_revision: None, } } @@ -3907,7 +3908,8 @@ async fn an_empty_deployment_reports_zeroes() { "reservations": { "pending": 0, "capacity": didbot_pds::DEFAULT_PENDING_RESERVATIONS, - } }) + }, + "policy": {} }) ); } diff --git a/crates/didbot-serve/src/wire.rs b/crates/didbot-serve/src/wire.rs index 27e2cde5..acd322d9 100644 --- a/crates/didbot-serve/src/wire.rs +++ b/crates/didbot-serve/src/wire.rs @@ -981,6 +981,21 @@ pub struct StatsResponse { /// published here rather than sitting as a constant nobody can watch. #[serde(default)] pub reservations: ReservationsView, + /// What operator policy this deployment enforces. + #[serde(default)] + pub policy: PolicyView, +} + +/// The `policy` member of [`StatsResponse`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyView { + /// The digest of the operator's policy and binding records this + /// deployment enforces, over every record's key and body. An operator + /// computes the same digest over their own repository and compares; + /// absent until a revision has been read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforced: Option, } /// The `reservations` member of [`StatsResponse`]. @@ -1100,6 +1115,9 @@ impl StatsResponse { }, live_children: stats.live_children.clone(), reservations: stats.reservations.into(), + policy: PolicyView { + enforced: stats.policy_revision.clone(), + }, } } } diff --git a/docs/ownership-verification.md b/docs/ownership-verification.md index 23432777..1ab9224c 100644 --- a/docs/ownership-verification.md +++ b/docs/ownership-verification.md @@ -21,8 +21,13 @@ applies this repository's policies to its subjects, to every account admitted beneath them, or both, minus the excluded accounts and everything beneath those. The policies a binding names must currently be in the same repository as the binding; a reference into another repository is not -supported yet. A server that cannot read its policies keeps the last set it -read rather than widening. +supported yet. A server builds the whole set from one reading of both +collections, and a set any record spoils is refused whole, so a server that +cannot read or build its policies keeps the last set it built rather than +widening. It publishes a digest of the records it enforces as +`policy.enforced` on `bot.did.stats`, so whether it is obeying the owner is +a string comparison against the same digest computed over the owner's +repository. Neither party can write the other's. The agent cannot write its own registration — `bot.did.registration` is server-authored and every write diff --git a/plan/policy-store.md b/plan/policy-store.md index e788cf71..e03c5919 100644 --- a/plan/policy-store.md +++ b/plan/policy-store.md @@ -26,26 +26,27 @@ OAuth client against a server we do not run — and that client is a static site with no backend, not this server. Keeping the write off this host is the point: a compromised server can then break policy but cannot change it. -- [ ] **A policy lexicon**, saying what is permitted rather than encoding an +- [ ] **This server never holds a write scope on the owner's account.** Its only + grant there is `atproto`, which authenticates and nothing more, for + signing the owner in to [ops-dashboard](ops-dashboard.md). Client metadata + is public, so that claim is checkable from outside. + +## Done + +The code paths are named in [policy](policy.md)'s Done list. + +- [x] **A policy lexicon**, saying what is permitted rather than encoding an internal enum: scopes, apps, collections. -- [ ] **Read it along the chain the vouch already establishes**. -- [ ] **Cache it, and decide what an unreadable policy does.** The owner's +- [x] **Read it along the chain the vouch already establishes**. +- [x] **Cache it, and decide what an unreadable policy does.** The owner's server being down must not widen what an agent may do, or silently brick a running swarm. -- [ ] **Poll, on a stated interval.** There is no per-repository subscription +- [x] **Poll, on a stated interval.** There is no per-repository subscription to a foreign server: watching one repository would mean consuming a whole server's firehose or a relay's. The interval is the propagation delay and should be written down rather than discovered. -- [ ] **Apply a version whole.** A policy is a set of records, and applying half +- [x] **Apply a version whole.** A policy is a set of records, and applying half of one is a window in which the rules disagree with each other. -- [ ] **Publish the revision being enforced.** Then the first check on whether +- [x] **Publish the revision being enforced.** Then the first check on whether this server is obeying the owner is a string comparison rather than an audit of its behaviour. -- [ ] **This server never holds a write scope on the owner's account.** Its only - grant there is `atproto`, which authenticates and nothing more, for - signing the owner in to [ops-dashboard](ops-dashboard.md). Client metadata - is public, so that claim is checkable from outside. - -## Done - -Nothing closed yet. diff --git a/plan/policy.md b/plan/policy.md index e464f0aa..1fe2deff 100644 --- a/plan/policy.md +++ b/plan/policy.md @@ -422,6 +422,12 @@ a write. ## Done +- Source 3 is read and enforced. `didbot_serve::policy_poll::PolicyPoll` + lists both collections on the ownership poll's tick, builds the set + whole through `didbot_pds::policy_bindings::resolve`, keeps the build + last refused for an operator to read (`PolicySet::rejected`), and swaps + the tree into the gate; `bot.did.stats` publishes the set's digest as + `policy.enforced`. - Bindings resolved within each account's boundary (`crates/didbot-pds/src/policy_bindings.rs`). `resolve` builds the whole set from one observation of both collections or refuses it, naming every