From 976202a79cc7f5aef1a714e2bb7d71dbdba101f2 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sun, 20 Sep 2026 22:10:35 -0400 Subject: [PATCH] docs(plan): rewrite the ownership epic around the allowance model Creation is not a policy, credentials are the account's own records, and did.write and label leave the register. Co-Authored-By: Claude Fable 5.1 Change-Id: I6c6e6ed1e69ea4fc5f3ad81a2493ff270b9cfca6 --- plan/account-lifecycle.md | 20 ++-- plan/account-types.md | 14 ++- plan/adversarial.md | 18 ++-- plan/agent-accounts.md | 8 +- plan/analytics-dashboard.md | 19 ++-- plan/app-allowlist.md | 12 +-- plan/attestation.md | 12 +-- plan/auth-types.md | 10 +- plan/capacity.md | 2 +- plan/cedar-engine.md | 4 + plan/cli.md | 51 ++++++--- plan/cred-delivery.md | 2 +- plan/credentials.md | 23 ++-- plan/dedupe-audit.md | 9 +- plan/did-minting.md | 10 +- plan/e-stop.md | 4 +- plan/handshake.md | 11 +- plan/index.md | 4 +- plan/local-dev.md | 2 +- plan/node.md | 15 ++- plan/oauth.md | 22 ++-- plan/onboarding-policies.md | 2 +- plan/onboarding.md | 4 +- plan/ownership.md | 207 +++++++++++++++++------------------- plan/pds-xrpc.md | 2 +- plan/periodic-backups.md | 2 +- plan/policy.md | 14 ++- plan/provenance.md | 4 +- plan/subagents.md | 11 +- plan/tombstone-serving.md | 6 +- plan/web-launch.md | 2 +- plan/zone-scale.md | 2 +- 32 files changed, 277 insertions(+), 251 deletions(-) diff --git a/plan/account-lifecycle.md b/plan/account-lifecycle.md index df4f01d4..1b49fe6a 100644 --- a/plan/account-lifecycle.md +++ b/plan/account-lifecycle.md @@ -87,10 +87,10 @@ was told to stop serving. | Lock | Hung by | Removes | Removed by | |---|---|---|---| -| `Frozen` | an operator, or the account (`freezeAgent`) | writes | the party that hung it (`unfreezeAgent` for the account's own) | +| `Frozen` | an operator, or the account (`freezeAccount`) | writes | the party that hung it (`unfreezeAccount` for the account's own) | | `Quarantined` | a policy outcome | writes | a recorded operator override | | `Suspended` | an operator | reads and writes | that operator | -| `Deactivated` | the account itself (`deactivateAgent`) | reads and writes | the account (`activateAgent`) | +| `Deactivated` | the account itself (`deactivateAccount`) | reads and writes | the account (`activateAccount`) | Any party may hang any lock, and two parties hanging one kind are two tags. @@ -111,7 +111,7 @@ refused outright; stopping its service is a separate act (`Deactivated`), so no `-ing` state is ever left stuck. An operator sets it and an operator clears it, and the ledger records both. -A pin is not a hold. `setAgentPinned` sets `Retention::Forever` and unpinning +A pin is not a hold. `setAccountPinned` sets `Retention::Forever` and unpinning restores the kind's retention — a value on the retention axis, which is the row's lifetime: the sweep passes a pinned account by and a hard delete is refused, while the account's own erasure goes through it. @@ -138,7 +138,7 @@ describe a host, not an account. ## We host only names we serve -`AgentDid::parse` refuses anything but a hostname-level `did:web`, and +`AccountDid::parse` refuses anything but a hostname-level `did:web`, and `HostedDid::host` refuses one outside every zone the deployment serves. `AccountStore` is keyed by `HostedDid`, so "we host only names we serve" is a compile error to violate. @@ -168,11 +168,11 @@ it hosts*, which stays true. ## Decisions for the owner -- [ ] **May a caller name its own agent.** If not, `provisionAgent` earns its +- [ ] **May a caller name its own agent.** If not, `createAccount` earns its existence and `createAccount` refuses `handle` too. If so, squatting and enumeration enter a zone whose record sets are finite. - [ ] **`PreventHandleReuse`.** Handle reuse after erasure is - `Reservation::FormerAgent`, set on every erasure and lifted when the + `Reservation::FormerAccount`, set on every erasure and lifted when the row goes. That is the name to use if it ever becomes a per-account choice. @@ -186,9 +186,9 @@ it hosts*, which stays true. the same value would be two mechanisms for one decision. - [x] **An operator clears `PreventDataDeletion`**, and the ledger records which one set it and which one cleared it. -- [x] **`softDeleteAgent` became the `Deactivated` lock**, reversible, as - `bot.did.deactivateAgent` and `bot.did.activateAgent`. Erasure is - `bot.did.deleteAgent`. +- [x] **`softDeleteAccount` became the `Deactivated` lock**, reversible, as + `bot.did.deactivateAccount` and `bot.did.activateAccount`. Erasure is + `bot.did.deleteAccount`. - [x] `HostedDid`, and `AccountStore` keyed by it. - [x] The lock and hold entries, declared once; the stamp moved once. - [x] Locks replace `AccountState::Frozen`; `policy()` folds the hasp over @@ -204,7 +204,7 @@ it hosts*, which stays true. [`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 + parent it was admitted under (`HostedAccount::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. diff --git a/plan/account-types.md b/plan/account-types.md index 02fab5be..4ef2df57 100644 --- a/plan/account-types.md +++ b/plan/account-types.md @@ -41,7 +41,7 @@ account carries the values, rather than the kind deciding behaviour directly. that wants a variant. A kind that names a row of defaults does not. Two of the three landed: [`AccountKind`](../crates/didbot-pds/src/kind.rs) names the four members of the registration record's `actor` union and - supplies a row of defaults, and `AgentAccount` carries a + supplies a row of defaults, and `HostedAccount` carries a `nameProvenance` and a `retention` of its own. **Renewal** is not a field, deliberately: nothing renews, so it would be a stored value nothing ever writes a second time — an unenforced control. It lands with @@ -78,6 +78,8 @@ account carries the values, rather than the kind deciding behaviour directly. DID, a hostname and a DNS write on something nothing will ever resolve twice. - [ ] **`generation`, and a provisioning check that consults the ledger.** + The registration is written once and carries no such field; a + generation would be the ledger's to count and the document's to show. Provisioning refuses a duplicate by asking `store.get`, which knows live accounts only, so a deleted label re-mints into a fresh account at the same DID with a new key and an empty repository. The ledger is the right @@ -113,7 +115,7 @@ anything is the honest source, rather than a copy on the profile) is sound and is kept by [provenance](provenance.md); the tick is not. - [x] **`pinned` is derived.** It was a boolean on - [`AgentAccount`](../crates/didbot-pds/src/account.rs) standing in for a + [`HostedAccount`](../crates/didbot-pds/src/account.rs) standing in for a retention policy, and once retention is a value a pin is one of its settings — two mechanisms for one decision, and the one that lost would still be the one some caller was reading. `pinned` is a method over @@ -207,7 +209,9 @@ is kept by [provenance](provenance.md); the tick is not. chosen for on the two kinds with real requirements. - [x] **The `bot.did.registration` lexicon, with the kind as an open union.** One record for every kind of account, which is what keeps - `revision`, `handles` and `keys` from being written twice. The per-kind + one fact from being written twice. The record has since been cut to + `did`, `operator`, `lineage`, `kind` and `provisionedAt`; see + [ownership](ownership.md). The per-kind fields sit in a union member rather than behind a `kind` string, because a lexicon cannot make one field's presence depend on another's value and a union member can declare its own `required` — so an agent without a @@ -236,8 +240,8 @@ is kept by [provenance](provenance.md); the tick is not. *admitted on*, which stays true forever, and the harness as the one that *asked for* the account. Anything genuinely current is either stamped on the records a turn writes or is a question for the server, and anything - the server must attest across an account's life is a history array, which - is what `handles` and `keys` already are. + the server must attest across an account's life is a history array. + The record carries none today: it is written once and never rewritten. - [x] **Three kinds drawn from what the credentials actually say**, rather than from how the accounts feel: an agent, a continuous integration job, and a host. Writing the fields out is what separated the last two — a job is diff --git a/plan/adversarial.md b/plan/adversarial.md index 4d779613..17156658 100644 --- a/plan/adversarial.md +++ b/plan/adversarial.md @@ -2,7 +2,7 @@ id: adversarial title: Integration tests at the seams, not inside the components that already pass status: open -crates: [didbot-serve, didbot-pds, didbot-identity, didbot-policy, didbot-policy-source, didbot-dns, didbot-tls] +crates: [didbot-serve, didbot-pds, didbot-identity, didbot-policy, didbot-policy-records, didbot-dns, didbot-tls] dependsOn: [] exitCriterion: > Adversarial integration tests exist for zone containment and DPoP binding, @@ -101,7 +101,7 @@ Where the remaining risk is, in rough order: `plan/policy.md` and `docs/write-pipeline.md`'s whole design rests on "adding a policy can never widen what is permitted." `crates/didbot-policy`, -`crates/didbot-policy-regex`, `crates/didbot-policy-source` and the gate plus +`crates/didbot-policy-regex`, `crates/didbot-policy-records` and the gate plus per-repository queue in `crates/didbot-pds` landed in one day, from four agents working in parallel, and had never been attacked before this pass. @@ -149,7 +149,7 @@ concurrently — only sequentially, the way `crates/didbot-pds/tests/naming.rs` and `provisioning.rs` already did before this pass. What held and what did not, from actually racing it with real threads rather than reasoning about it: -- **Held: two requests minting the same `agent_id`.** `AccountStore::insert` +- **Held: two requests minting the same `account_id`.** `AccountStore::insert` is a single check-and-insert under one mutex, so exactly one of two concurrent `provision()` calls for the same DID gets past it. The loser's insert is refused with the row untouched, and everything it unwinds — the @@ -159,7 +159,7 @@ not, from actually racing it with real threads rather than reasoning about it: - **Held: naming through `Naming::issue`.** `NameRegistry::claim` checks and claims a name under one mutex, so two concurrent provisions cannot both walk away believing they hold the same generated or hinted name. -- **Fixed: two requests minting *different* `agent_id`s for the *same* +- **Fixed: two requests minting *different* `account_id`s for the *same* caller-asserted handle, with no `Naming` configured.** `check_requested_handle` scans the account store and the actual claim (`AccountStore::insert`) happens several steps later — a mint, a keypair, a @@ -180,12 +180,12 @@ not, from actually racing it with real threads rather than reasoning about it: (`git stash` the fix) fails the test 5/5 runs; restoring it passes 3/3. - **Held: the e-stop and lifecycle gates.** `docs/write-pipeline.md`'s ordering — e-stop before lifecycle before anything is parsed — is exactly - what `crates/didbot-serve/src/routes.rs`'s `provision_agent` does, and + what `crates/didbot-serve/src/routes.rs`'s `create_account` does, and because both checks run before `registry.provision()` is ever called, "a refusal leaves nothing behind" is trivial rather than tested: nothing was started. Already covered end to end in `crates/didbot-serve/src/tests.rs` (Revoke, Pause, and Pause-thrown-by-a-lapsed-operator-claim each refuse - `provisionAgent` with `Halted`, and Pause is shown leaving an + `createAccount` with `Halted`, and Pause is shown leaving an already-issued token alone). Left open, in rough order of how much a deployment should care: @@ -319,8 +319,8 @@ doc comment, and each was run. actually be a socket. The function's own doc already said it should refuse to guess; the code did the opposite. - **The scope ceiling, as an invariant rather than a table.** Eight tests - in `crates/didbot-scope/src/lib.rs`'s `ceiling_boundary` - module, over every pairing of a corpus built out of *adjacent* atoms: + in `crates/didbot-scope/tests/ceiling_boundary.rs`, over every + pairing of a corpus built out of *adjacent* atoms: nothing granted is outside the ceiling, nothing granted is outside the request, re-applying a ceiling changes nothing, reversing a ceiling grants the same thing, an empty ceiling admits nothing, disjoint atoms @@ -379,7 +379,7 @@ doc comment, and each was run. `a_400_that_is_not_record_not_found_is_unreachable_not_an_absent_claim` in the same file. - **A freeze names who froze it.** A freeze is a `Lock` tag on the account - row (`AgentAccount::locks`, `crates/didbot-pds/src/lockout.rs`), and each + row (`HostedAccount::locks`, `crates/didbot-pds/src/lockout.rs`), and each tag carries the `Party` that hung it: operator, policy, the account itself, or a parent. The tag outlives the write that tripped it. A policy outcome hangs `Lock::Quarantined` under `Party::Policy` and records a diff --git a/plan/agent-accounts.md b/plan/agent-accounts.md index e3584e0f..89a2139e 100644 --- a/plan/agent-accounts.md +++ b/plan/agent-accounts.md @@ -59,7 +59,7 @@ Two hostnames per account: the DID's, and the handle's, which needs What this epic contributes to the decision is a constraint, not an opinion: the landing states are not symmetric. `active` and `frozen` are reversible; `soft-deleted` burns the name permanently - (`Reservation::FormerAgent`) and only hard delete frees it, which is + (`Reservation::FormerAccount`) and only hard delete frees it, which is administrator-only and has no route until [oauth](oauth.md) gives it a caller it can check. So a retention policy that lands a session end on soft-delete is choosing to spend a name, and one that lands on frozen is @@ -171,10 +171,10 @@ Two hostnames per account: the DID's, and the handle's, which needs the one operation that frees a burned name. The route returns when [oauth](oauth.md)'s operator sign-in gives it a caller it can check. `Registry::delete`/`freeze`/`unfreeze`/`soft_delete` and - `setAgentPinned` are `Credential::AgentSelf` — an agent acting on + `setAccountPinned` are `Credential::AccountSelf` — an agent acting on itself, and nothing else. - [x] **Names are burned permanently by soft delete, freed only by hard - delete.** `Reservation::FormerAgent` extends the existing reservation + delete.** `Reservation::FormerAccount` extends the existing reservation mechanism (`Reservation::ZoneApex`/`Operational`) rather than adding a second one; `NameRegistry::retire` moves a live or held name straight to a permanent reservation, and only `NameRegistry::release` — called @@ -183,7 +183,7 @@ Two hostnames per account: the DID's, and the handle's, which needs means fetchable, not writable, so `frozen` maps to the same wire shape as `active` and there is no `#account` status for it — the open union does not rescue this, because a status is only meaningful when - `active` is false. `bot.did.listAgents`'s `AgentSummary.state` is + `active` is false. `bot.did.listAccounts`'s `AccountSummary.state` is where a frozen account is actually visible, using the same `AccountState` the firehose event maps from so the two surfaces cannot drift. See `docs/conformance.md`. diff --git a/plan/analytics-dashboard.md b/plan/analytics-dashboard.md index bf446557..3b468f7b 100644 --- a/plan/analytics-dashboard.md +++ b/plan/analytics-dashboard.md @@ -30,11 +30,6 @@ network. default. Operator-only means the sign-in [ops-dashboard](ops-dashboard.md) uses — atproto OAuth against the operator's own server, `atproto` scope and nothing else — and no second credential. -- [ ] **Every route under it is a read.** No control lives here, so a public - setting widens what can be seen and never what can be done. A test - walks the router and refuses a non-`GET` under the page's prefix. -- [ ] **The agents, and the tree between them.** Every account this server - holds, its kind, its state, and the parent it was provisioned under. - [ ] **What each wrote, by collection**, from `didbot_pds::write_log`, and **which apps each signed in to**, from `OAuthGrantStore::live` — the same rows the operations page reads, drawn for a reader who may not be @@ -46,4 +41,16 @@ network. - [x] **The homepage half of this surface**: an unauthenticated `/dashboard/api/about`, reusing `bot.did.stats`'s own numbers, so a person opening a running deployment's address with no credential still - gets a real answer about what it is and what it holds. + gets a real answer about what it is and what it holds. It reports the + deployment's own lifecycle line beside them. +- [x] **Every route under it is a read.** The page sits under `/analytics`, + apart from the operations dashboard's prefix. No control lives here, so + a public setting widens what can be seen and never what can be done. A + test walks the router and refuses a non-`GET` under the page's prefix. +- [x] **The agents, and the tree between them.** `GET /analytics` draws every + account this server holds — its handle, DID, kind, label, operator, the + proof its document publishes, when it was created and the state of the + edge above it — nested under the account that created it. A chain a + stranger could not walk is drawn as paused rather than silently listed. + `bot.did.listAccounts` carries the same values, so the page and the wire + cannot disagree. diff --git a/plan/app-allowlist.md b/plan/app-allowlist.md index d05d9a9c..6d202c11 100644 --- a/plan/app-allowlist.md +++ b/plan/app-allowlist.md @@ -74,20 +74,20 @@ likely to be mistaken for something the protocol does. client's origin and content key instead. Asserted on the one function that decides what leaves this server, `DecisionRecord::to_json`, and over the wire in - `crates/didbot-serve/tests/oauth_agent_flow.rs`. + `crates/didbot-serve/tests/oauth_account_flow.rs`. - [x] **The fetch reaches the public internet and nothing else.** `client_id` is a URL an unauthenticated caller chose, so `resolve_client` resolves its host and refuses it unless every address it answers to is outside - `REFUSED_BLOCKS` — loopback, the private ranges, link-local (which is - where `169.254.169.254` hands out an EC2 instance's IAM credentials), - carrier-grade NAT, multicast and the reserved and documentation - blocks. A deployment whose zone is under `.localhost` reaches its own + `didbot_http::REFUSED_BLOCKS` — loopback, the private ranges, + link-local (which is where `169.254.169.254` hands out an EC2 + instance's IAM credentials), carrier-grade NAT, multicast and the + reserved and documentation blocks. A deployment whose zone is under `.localhost` reaches its own machine, through the same `LoopbackClients` gate that already decided whether a loopback `client_id` is fetched at all. Every way the fetch can fail then reads as one sentence naming none of them (`ParError::ClientMetadata`), so neither the address nor a redirect target comes back to whoever asked. Tests: - `the_deployments_own_network_is_not_fetchable`, + `this_deployments_own_network_is_not_public`, `a_client_id_on_this_network_is_refused_before_the_fetch`, `a_localhost_zone_still_fetches_from_its_own_machine` and `every_way_a_client_metadata_fetch_can_fail_reads_the_same`. diff --git a/plan/attestation.md b/plan/attestation.md index d2e929f1..782a1f85 100644 --- a/plan/attestation.md +++ b/plan/attestation.md @@ -2,7 +2,7 @@ id: attestation title: Where an agent was provisioned from is a claim somebody else signed status: open -crates: [didbot-attest] +crates: [] dependsOn: [] exitCriterion: > An agent provisioned on a cloud instance carries an attestation whose @@ -37,11 +37,11 @@ otherwise. Where it would live, if it is closed: - - **The seam belongs in this crate; the store does not.** - `didbot-attest` verifies and holds no storage, and a file here would - give every backend one. What it should gain is what a backend spends - a claim against, with `SeenNonces` as the implementation a deployment - gets when it wants nothing durable. + - **The seam belongs with the verifier; the store does not.** A + verifier that holds storage gives every backend one. What it should + gain is what a backend spends a claim against, with an in-memory + nonce set as the implementation a deployment gets when it wants + nothing durable. - **The store belongs to the server, behind that seam.** The verifier is the only party a replay can be refused at, and `didbot_pds::Provisioner` already holds both the backend and the diff --git a/plan/auth-types.md b/plan/auth-types.md index 42a34c8e..a0dc2cae 100644 --- a/plan/auth-types.md +++ b/plan/auth-types.md @@ -101,10 +101,10 @@ and no operator minting an app password for it, so an app-password session was the wrong vehicle — building it would have meant a machine holding a *human* credential shape for a caller that is never human. What it got was a bearer token, minted once at provisioning, bound to one DID, with an expiry and no -rotation: `didbot_pds::credential` — `Credential::AgentToken` on the HTTP +rotation: `didbot_pds::credential` — `Credential::AccountToken` on the HTTP side. `Provisioner::provision` issues one automatically, durable in the -write-ahead log (`pds.layout` bumped to 3), returned once as `agentToken` in -`provisionAgent`'s response body. A client carries it the same way it already +write-ahead log (`pds.layout` bumped to 3), returned once as `accountToken` in +`createAccount`'s response body. A client carries it the same way it already carries the DID, and the conformance suite proves the whole path: client and server agreeing on a credential none of them minted by hand. That is what exists, not what should: the session taken by attestation above is what @@ -149,7 +149,7 @@ credential authenticates as. That is what makes a pending authorization readable, approvable and declinable only by the agent it was addressed to: there is no account parameter for the credential to disagree with. -`bot.did.hardDeleteAgent`, which must not be self-service, consequently has +`bot.did.hardDeleteAccount`, which must not be self-service, consequently has no route at all — see [agent-accounts](agent-accounts.md). There is no netizen argument for letting a stranger delete an account, so this stays hard-locked regardless of the disclosure decision below. @@ -191,7 +191,7 @@ shape comes up again — not re-derived per route: - **Closing a route has to be visible, or the mechanism defeats itself.** The threat this default resists is an operator who cannot be audited, and the configuration tier that closes a route is a tier an on-box attacker - also reaches — closing `listAgents` is exactly what that attacker would do + also reaches — closing `listAccounts` is exactly what that attacker would do to hide what a stranger needed to see. This cannot be prevented from the server alone, but it is made *legible*: a closed route answers `DisclosureDisabled`, never a 404 and never an empty list, so an outsider diff --git a/plan/capacity.md b/plan/capacity.md index 6ae82550..a238df4c 100644 --- a/plan/capacity.md +++ b/plan/capacity.md @@ -136,7 +136,7 @@ used to shrink it. - [x] **A configured cap on accounts, with a sane default.** `--max-accounts` or `[capacity] max_accounts` sets it, and `DEFAULT_MAX_ACCOUNTS` is - 1000, a cap an operator raises deliberately. `provision_agent` refuses with + 1000, a cap an operator raises deliberately. `create_account` refuses with `503 AccountCapReached`, naming the cap, before it parses the request or writes anything, so a caller never sees a `ChangeResourceRecordSets` failure after the keys and the first hostname exist. The cap is one diff --git a/plan/cedar-engine.md b/plan/cedar-engine.md index 0991a5c0..897bdb49 100644 --- a/plan/cedar-engine.md +++ b/plan/cedar-engine.md @@ -49,6 +49,10 @@ same entity the two OAuth actions take as their resource. So the account's own credential has no client, and a statement tests `context has client` before reading it. +The principal of every action is an `Account` carrying `kind`, `handle` and +`depth` (edges between it and the operator) when the gate knows them; a +statement guards each with `has`. + `now` and every timestamp are Cedar `datetime` values, so a document can say `context.now.durationSince(context.existing.createdAt) > duration("1d")`. A create over a tombstone arrives with `existing` populated and `operation` diff --git a/plan/cli.md b/plan/cli.md index c1328a18..5550fe55 100644 --- a/plan/cli.md +++ b/plan/cli.md @@ -21,14 +21,15 @@ each versioned and installed on its own: |---|---|---| | `didbot-pds` | the server, in the image | its own flags; no verb reaches it | | `didbot` | operator laptop, agent host | dispatcher only: `--list`, `--version`, `help ` | -| `didbot-operator` | operator laptop | `didbot operate []`, `didbot operate --check `, `didbot login`, `didbot estop`, `didbot announce` | -| `didbot-oauth` | agent host | `didbot oauth pending`, `didbot oauth show`, `didbot oauth approve`, `didbot oauth decline` | +| `didbot-operator` | operator laptop | `didbot operate [] [--server ] [--kind K] [--oidc =...] [--fingerprint SHA256:...] [--creates [K...]] [--creates-beneath [K...]]`, `didbot operate --check `, `didbot login`, `didbot estop`, `didbot announce` | +| `didbot-oauth` | agent host | `didbot oauth pending`, `didbot oauth show`, `didbot oauth approve`, `didbot oauth decline`, each with `--token-file ` or `--token-stdin` | +| `didbot-register` | the machine that will be the account | `didbot register [--under ] [--token-file \| --token-stdin] [--server ] [--timeout ]`, the server from `--server` or `DIDBOT_PDS` | | `didbot-agentd` | agent host, a service | none; a daemon the unit starts | Three install sets follow from the table. An operator installs `didbot` -and `didbot-operator`. An agent host installs `didbot`, `didbot-oauth` and -`didbot-agentd`. A developer builds the workspace and reaches everything -through `scripts/`. +and `didbot-operator`. An agent host installs `didbot` and `didbot-agentd`, +which ships `didbot-oauth` and `didbot-register` beside the daemon. A +developer builds the workspace and reaches everything through `scripts/`. This epic is about the entry point, not what the verbs do. Each verb has its own epic; this one says how a person reaches it. @@ -61,8 +62,10 @@ as. First-party verbs use it so they look like one tool. **First-party verb names are known to the dispatcher even when their code is absent.** `didbot` carries a table, `didbot_dispatch::verbs::FIRST_PARTY`: `operate`, `login`, `estop` and `announce` run `didbot-operator `; -`oauth` runs `didbot-oauth` with what followed. A first-party verb whose -binary is not on `PATH` answers "install `didbot-operator`" and exits 1. +`oauth` runs `didbot-oauth` and `register` runs `didbot-register`, each with +what followed. Every row names the crate that ships its binary, so a +first-party verb whose binary is not on `PATH` answers "install +`didbot-operator`" or "install `didbot-agentd`" and exits 1. Any other word is external dispatch: `didbot foo` runs `didbot-foo` from `PATH` with argv passed through, and when there is none answers "unknown command `foo`; `didbot --list` shows what is installed" and exits 2. The @@ -73,10 +76,17 @@ table is the mechanism; there are no links between binaries. `didbot-operator` holds two sign-ins that never meet. `operate` is the claim: it authenticates to the operator's *own* atproto account through `jacquard-oauth` and writes `bot.did.operator` into their own repository, -keeping that session in `didbot-authstore`'s file. `login` starts the -deployment's own sign-in and keeps the session the deployment minted, which -`estop` and `announce` present. One is checked by the operator's PDS, the -other by the deployment; neither verb can use the other's. +keeping that session in `didbot-authstore`'s file. For a name beneath a +server the same session has the operator's PDS mint a service-auth token, +and that token — never the session — is what the server sees. `login` +starts the deployment's own sign-in and keeps the session the deployment +minted, which `estop` and `announce` present. One is checked by the +operator's PDS, the other by the deployment; neither verb can use the +other's, and only the first admits anything. + +`didbot register` and the daemon hold a third thing that is not a +credential at all: a key that never leaves the machine. The server sees +its public half, and tokens it signs. - [ ] **The `didbot-claude` plugin spells the approval `didbot oauth approve `.** Another repository; its sessions are told the @@ -93,10 +103,21 @@ other by the deployment; neither verb can use the other's. each held by `crates/didbot-dispatch/tests/dispatch.rs` against stand-in binaries on a `PATH` of the test's making. - [x] **`didbot operate`.** `didbot-operator`'s `operate` verb, with - `--check` and the reserved-host form under `--server`. - `didbot_operator::operate` holds the checks, the record, the scope - and the write as modules; the session it keeps is - `didbot_authstore::FileAuthStore`'s file. + `--check` for a server and for an account, and the account form + beneath a server: `didbot_operator::operate::under` reads which + server from the operator's own records, `::account` mints the + service-auth token, writes the record and creates, taking the record + back if the create is refused; `::walk` is the check. The session it + keeps is `didbot_authstore::FileAuthStore`'s file. +- [x] **`didbot register`.** `didbot-agentd`'s `didbot-register` binary + over `didbot_agentd::register`: a parked key admitted from the + operator's side, or an OIDC token as the parent, ending in a session + under `didbot_cli::tokens`, which is behind that crate's `tokens` + feature so the dispatcher cannot reach it. +- [x] **`didbot oauth --token-file` and `--token-stdin`.** + `didbot_agentd::direct::Direct::from_token_flag`: a session from + `bot.did.createSession`, then the command as that account. A token is + never an argument. - [x] **The operator binary links no server code.** `didbot-operator` depends on `didbot-scope` — the scope grammar, which `didbot-serve` re-exports as `oauth::scope` — and on the crates the policy page diff --git a/plan/cred-delivery.md b/plan/cred-delivery.md index 24c78f3d..52532e1c 100644 --- a/plan/cred-delivery.md +++ b/plan/cred-delivery.md @@ -123,7 +123,7 @@ The honest list, because the boundary is worth more than the mechanism. ## Done - [x] **The daemon holds the account credential, in memory and nowhere else.** - `provisionAgent` hands back an agent token once and keeps no copy, and + `createAccount` hands back an agent token once and keeps no copy, and the daemon used to drop it. It keeps it now, on the context it belongs to (`didbot_agentd::context::Context::token`), and presents it as that account on the four sign-in decision routes. It is a diff --git a/plan/credentials.md b/plan/credentials.md index 2d0a7746..765387a1 100644 --- a/plan/credentials.md +++ b/plan/credentials.md @@ -2,7 +2,7 @@ id: credentials title: A session gets a credential without a wrapper process status: open -crates: [didbot-attest] +crates: [] dependsOn: [oauth, attestation] exitCriterion: > An ordinary session, configured only through settings.json, receives a @@ -51,7 +51,7 @@ write, and nothing assumes co-location. hook to deliver anything. Half of this is now answered: there is nothing left to stamp, because `didbot-oauth` names a decision rather than an account, and it runs with no daemon and no hook at all against - `DIDBOT_PDS` and `DIDBOT_AGENT_TOKEN` — so a runner holding a credential + `DIDBOT_PDS` and `DIDBOT_ACCOUNT_TOKEN` — so a runner holding a credential can spend one. How it comes to hold one is the half still open. See [account-types](account-types.md). @@ -75,7 +75,7 @@ section decides what replaces it before any of it is built. a code. `AttestationClaim` names the host and nothing it is for, so it has to gain the account and the token endpoint. Otherwise one claim can be spent against another account. - - `Credential::AgentSelf` accepting a DPoP-bound token, and only one + - `Credential::AccountSelf` accepting a DPoP-bound token, and only one this grant issued. An app an agent signed in to must not be able to approve further sign-ins as that agent. - `didbot-oauth` direct mode signing that claim, which puts a node key @@ -96,20 +96,17 @@ section decides what replaces it before any of it is built. DPoP private key, the last nonce from each of the authorization server and the resource server, and the endpoints it refreshes against. That is `jacquard_oauth::session::ClientSessionData`, which `didbot operate` - already keeps. `didbot_agentd::sessions::SessionStore` keeps many at - once, one `0600` file each in a `0700` directory, written through a - synced temporary and a rename. One stored copy can serve both the - context store and the poller: `SessionStore::update` holds a lock per - session across a refresh, so a rotated refresh token has one place to - land, and a refresh refused for good removes the session. - `SessionStore::end` revokes a session where it was issued before the - file goes, because the tokens in it outlive the file. + already keeps. One stored copy serves both the context store and the + poller, which means a refresh holds a lock per session across the round + trip, so a rotated refresh token has one place to land. Ending a + session revokes it where it was issued before the copy goes, because + the tokens in it outlive the copy. - [x] **At rest, file permissions alone.** The node key sits in the same directory under the same permissions, so a key derived from it protects nothing from whoever can read either file. The OS keyring needs a desktop Secret Service, which a headless agent host does not - run. A session file or directory found wider than its owner stops the - store from opening, naming the path. + run. A session found wider than its owner stops the daemon, naming the + path. Adjacent, and worth knowing about here: a harness client carries a write credential (`didbot_pds::credential`, minted at diff --git a/plan/dedupe-audit.md b/plan/dedupe-audit.md index 12dd60fd..855de300 100644 --- a/plan/dedupe-audit.md +++ b/plan/dedupe-audit.md @@ -2,7 +2,7 @@ id: dedupe-audit title: A workspace-wide sweep for drifted duplicate logic, dead code, and stragglers from removed backends status: open -crates: [didbot-serve, didbot-pds, didbot-attest] +crates: [didbot-serve, didbot-pds] dependsOn: [] exitCriterion: > Every finding below carrying a decision for a human (the `normalize_pds_url` @@ -51,9 +51,6 @@ operator or a maintainer, fixed in this branch: `Booting` state's shape. This was live, user-facing operational guidance that would have sent a real deployer down a dead end. -`didbot-attest`'s node-credential backend is current, not a straggler: -provisioning checks a host's attestation against it. - ## Not investigated deeply `#[allow(dead_code)]` sites (`didbot-serve/src/dashboard.rs`, @@ -75,8 +72,8 @@ lines. what was found and that the operator shared secret is gone. - [x] **`normalize_pds_url`** left the workspace with the hook crates, in `53814041`, so there is nothing to export. -- [x] **`crates/didbot-attest/src/lib.rs`**'s module doc says which backend - provisioning consults. +- [x] **`crates/didbot-attest`** was reachable from no crate, no binary and + no test, and is deleted. - [x] **`crates/didbot-serve/src/onboarding.rs`**'s `Provisioning` step list no longer tells an operator to write `attestation-secret` and `operator-secret` SSM parameters that nothing reads; it now reflects diff --git a/plan/did-minting.md b/plan/did-minting.md index 5a6ff927..8620e6a9 100644 --- a/plan/did-minting.md +++ b/plan/did-minting.md @@ -14,9 +14,9 @@ exitCriterion: > A `did:web` DID is a hostname, and this server takes that hostname from whoever asked for the account. -[`ProvisionRequest::agent_id`](../crates/didbot-pds/src/provision.rs) is +[`ProvisionRequest::account_id`](../crates/didbot-pds/src/provision.rs) is documented as "the DNS label the DID will be minted from", and -`Provisioner::provision` passes it straight to `AgentDid::mint`. The server +`Provisioner::provision` passes it straight to `AccountDid::mint`. The server checks two things about it: that it is a legal DNS label ([`validate_label`](../crates/didbot-identity/src/did.rs) — lowercase ASCII, digits, hyphens, no hyphen at either end) and that the minted host sits at or @@ -29,7 +29,7 @@ changed afterwards. ## What follows from it **A harness may not have an identifier to give.** A top-level session has no -`agent_id` of its own, and a client papers over it by deriving a label out of +`account_id` of its own, and a client papers over it by deriving a label out of whatever session bookkeeping it holds, because the harness does not promise the format. That is a good workaround living in the wrong place — it is one client's convention, not a rule the server enforces, and a second client is @@ -68,7 +68,7 @@ have an account", never to name it. - [ ] **Split the identifier from the correlation key.** `ProvisionRequest` grows a `correlation: Option` that is opaque to identity, and - loses `agent_id`. Nothing about the key reaches the DID, so it needs no + loses `account_id`. Nothing about the key reaches the DID, so it needs no label rules and can be as long, as structured or as absent as a harness requires. This is a breaking change to the provisioning surface and to every caller of it, so it lands as a `!` commit. @@ -81,7 +81,7 @@ have an account", never to name it. for it over a counter — but a counter is durable and auditable, and this is a decision to make explicitly rather than inherit. Which zone it is minted *from* is [name-pools](name-pools.md)'s question. -- [ ] **Keep provisioning idempotent.** Today `agent_id` doubles as the +- [ ] **Keep provisioning idempotent.** Today `account_id` doubles as the idempotency key: re-provisioning the same session hits the duplicate refusal. Once the server mints, the store needs a durable correlation-key-to-DID index to answer the same question, and a request diff --git a/plan/e-stop.md b/plan/e-stop.md index 0b168b8a..dfc56641 100644 --- a/plan/e-stop.md +++ b/plan/e-stop.md @@ -33,7 +33,7 @@ run, and which stops strictly more. person to reach for scoping needs them. One fact decides most of it. A halt narrower than "everything" must leave - some provisioning permitted, and `bot.did.provisionAgent` authenticates a + some provisioning permitted, and `bot.did.createAccount` authenticates a host and nothing narrower: the daemon on a host signs for every context on it, and the profile the request carries is the caller's word about itself. A halted agent that can still provision takes a fresh account @@ -83,7 +83,7 @@ run, and which stops strictly more. finished arriving still refuses one. - [x] **A latch both gates read**, at issuance and at the write. `didbot_pds::Estop::check_issue` gates `POST /oauth/token` and - `bot.did.provisionAgent`; `Estop::check_use` gates every repository + `bot.did.createAccount`; `Estop::check_use` gates every repository write route and `uploadBlob`, checked fresh on every request rather than cached — see the immediacy test. - [x] **Say what it is refusing.** `Estop::status` reports tokens, operations diff --git a/plan/handshake.md b/plan/handshake.md index 568de38e..cc59f5c7 100644 --- a/plan/handshake.md +++ b/plan/handshake.md @@ -2,7 +2,7 @@ id: handshake title: A server and its operator establish each other, with no shared secret status: open -crates: [didbot-pds, didbot-serve, didbot-lexicon, didbot-attest, didbot-operator] +crates: [didbot-pds, didbot-serve, didbot-lexicon, didbot-operator] dependsOn: [ownership, policy-store, alerts] exitCriterion: > An operator points a local command at a freshly booted PDS that vouches for @@ -160,10 +160,9 @@ operator relationship. A laptop generates a keypair, the operator writes its public half into their own repository, and the PDS reads the node allowlist from the same poll that reads the operator vouch and the policy record. -`NodeCredentialBackend` in `crates/didbot-attest/src/node_credential.rs` -already takes `(node_id, VerifyingKey)` pairs and verifies against the public -half alone — it needs no new backend to do this, only a source for the list -that is not a file an operator edits by hand on the server's own box. That +A node allowlist is `(node_id, VerifyingKey)` pairs, verified against the +public half alone. What it needs is a source that is not a file an operator +edits by hand on the server's own box. That would put the node allowlist in the tier [node](node.md) already wants it in: one an on-box attacker cannot reach, because writing it requires the operator's own key rather than anything reachable from the server's host. @@ -277,7 +276,7 @@ neither the operator nor a channel to them. the operational one and the admin socket's `PAUSE`/`REVOKE` are refused there. - [x] **The pause a lapse causes.** `didbot_pds::Estop::throw_self(Mode::Pause)` - and the existing `bot.did.provisionAgent` estop check refuse + and the existing `bot.did.createAccount` estop check refuse provisioning exactly as an operator's own pause would — no new account state, no second gate — and `Estop::Cause::OperatorMissing` keeps the two distinguishable at the source. diff --git a/plan/index.md b/plan/index.md index 59b2ae86..a79aa9f7 100644 --- a/plan/index.md +++ b/plan/index.md @@ -51,12 +51,12 @@ proves, versus what this index merely reports because a server said so, is Seven of them — the vouch-chain discovery walk, the firehose subscription and its sweep backstop, the work clustering, lineage learned from records, the query service's reader endpoints, `/health`, the three firehose filter -modes, and `View::agent_by_handle` — described code in `didbot-index` and +modes, and `View::account_by_handle` — described code in `didbot-index` and `didbot-query`. Both crates were removed from this repository in `build!: remove didbot-index, didbot-query and the canvas`; the reading side now lives at vibescrobble.com. Nothing in this workspace walks a vouch chain, subscribes to another server's firehose, or clusters work, and -`grep` for `SimHash`, `FilterMode` or `View::agent_by_handle` finds +`grep` for `SimHash`, `FilterMode` or `View::account_by_handle` finds nothing. They are unticked here rather than left standing, because a tick against code that is not in the tree is what sent three agents to reimplement work that was never here. diff --git a/plan/local-dev.md b/plan/local-dev.md index c67fe96f..b0cbfd01 100644 --- a/plan/local-dev.md +++ b/plan/local-dev.md @@ -167,7 +167,7 @@ than left standing. The surviving scripts are described accurately below. - [x] A swarm that drives the stack with simulated agents and records. - [x] The swarm signs agents in over OAuth (PAR, decision record, approve, token exchange) rather than only using the raw credential - `provisionAgent` hands back, so `crates/didbot-swarm/tests/decision_bounds.rs` + `createAccount` hands back, so `crates/didbot-swarm/tests/decision_bounds.rs` can drive the decision store's per-account and total bounds under concurrent load. - [x] The swarm's sign-in presents real RFC 9449 proofs over a key it diff --git a/plan/node.md b/plan/node.md index c9016b7f..96954a8a 100644 --- a/plan/node.md +++ b/plan/node.md @@ -2,7 +2,7 @@ id: node title: A host proves what it is once, and issues credentials to the sessions on it status: open -crates: [didbot-agentd, didbot-attest] +crates: [didbot-agentd] dependsOn: [attestation] exitCriterion: > A machine holding a node credential issues a session credential to a hook that @@ -118,7 +118,7 @@ load leaves another. life. The file carries the key, the DID, the token, the harness's word for the kind, and the askers already told. It is read back at start-up beside the sessions, and one file that does not read costs one context. -- [ ] **Name a context after both halves of its key.** `agent_id` takes the +- [ ] **Name a context after both halves of its key.** `account_id` takes the subagent id alone when there is one (`crates/didbot-agentd/src/serve.rs`), on the stated assumption that a subagent id is unique on the machine, while the store is keyed by @@ -177,13 +177,10 @@ load leaves another. the server treats its signing keys. `crates/didbot-agentd/src/node.rs` mints one secp256k1 key on first start, in a `0700` directory as a `0600` file, under a lock held for the daemon's life, and hands out only - the public half and signatures. The server's - [`NodeCredentialBackend`](../crates/didbot-attest/src/node_credential.rs) - keeps only the public half, registered once the host stands. -- [x] **Say what it is not.** `crates/didbot-attest/src/node_credential.rs` - and `node.rs`'s module doc both say the key is a file: anything that can - read the disk can copy it, and the copy is indistinguishable from the - original. + the public half and signatures. +- [x] **Say what it is not.** `node.rs`'s module doc says the key is a file: + anything that can read the disk can copy it, and the copy is + indistinguishable from the original. - [x] **The socket carries e-stop's hardening, all four parts.** `crates/didbot-agentd/src/socket.rs`: a subdirectory this process owns under `XDG_RUNTIME_DIR` or the temporary directory; `0700` set on it on diff --git a/plan/oauth.md b/plan/oauth.md index 8ccc1d9a..a19d1ae1 100644 --- a/plan/oauth.md +++ b/plan/oauth.md @@ -102,7 +102,7 @@ both gone. said `allow`. What the gate and the ceiling *are* asked again, at the token endpoint and at every write, is whether to issue and what a token may carry — a refusal there refuses the token and leaves the record - alone. `oauth_agent_flow.rs`'s + alone. `oauth_account_flow.rs`'s `a_policy_loaded_after_par_leaves_the_record_and_refuses_the_token` loads a denial between the push and the answer: the record's verdict is unchanged, `GET /oauth/authorize` still renders it, the approval still @@ -136,12 +136,12 @@ both gone. account-keyed bound would otherwise answer with — see the next item for why *that* is load-bearing, not incidental. Proved end to end, with no browser and no network, by `crates/didbot-serve/tests/ - oauth_agent_flow.rs`, which drives the real router through + oauth_account_flow.rs`, which drives the real router through `tower::ServiceExt::oneshot`. - [x] **Confirm after the page, through an identity-aware call.** `bot.did.approveAuthorization` takes the decision's one-time token under - `Credential::AgentSelf`, so the acting account is the one the presented + `Credential::AccountSelf`, so the acting account is the one the presented agent token authenticates as and never a value in the body. Two checks: the reference is live and unused, and the account named in the request is the account approving — the second enforced by the credential. There @@ -151,7 +151,7 @@ both gone. no unauthenticated route that takes an acting DID as a field in a body. Each check has its own unit test in `oauth::consent`, and - `oauth_agent_flow.rs` drives both to a refusal over the real router — + `oauth_account_flow.rs` drives both to a refusal over the real router — replay and an approval by the wrong account — confirming no code is issued on either. @@ -181,7 +181,7 @@ both gone. what keeps it from reopening the same hole). `201` vs `400` was the whole oracle: push one request per candidate handle or DID and read account existence back from the status, reachable without ever calling - `bot.did.listAgents` and so unaffected by narrowing that route's + `bot.did.listAccounts` and so unaffected by narrowing that route's `Credential::Disclosure`. The fix is `oauth::par:: push_for_unresolved_hint`: a `login_hint` naming nobody now gets the identical `201`, `request_uri` and `expires_in` a real account's push @@ -259,10 +259,10 @@ both gone. abandoned entry outlived forever. The honest signal an operator or an admitted agent needs is - unaffected: `bot.did.listAgents` still answers when disclosure is + unaffected: `bot.did.listAccounts` still answers when disclosure is public, and a request naming a real account under its own bound is still recorded exactly as before, narrowing included, whatever the - gate answers. `oauth_agent_flow.rs`'s + gate answers. `oauth_account_flow.rs`'s `eight_spellings_of_a_real_account_and_a_ninth_unresolvable_hint_all_answer_201`, `the_nth_plus_one_push_for_a_real_account_lands_and_displaces_the_oldest`, `a_stranger_filling_an_accounts_pending_bound_cannot_lock_it_out`, @@ -299,7 +299,7 @@ both gone. bounded per account and in total, because PAR is unauthenticated — `[oauth] pending_per_account` and `pending_total`, beside `scope_ceiling`, which is where an operator writes the ceiling down. - Four routes, all `Credential::AgentSelf`, all naming no account: + Four routes, all `Credential::AccountSelf`, all naming no account: - `GET bot.did.listPendingAuthorizations?cursor&wait` long-polls this account's live decisions, holding the request open up to 30 seconds @@ -319,7 +319,7 @@ both gone. The client's own `client_name`, `client_uri` and `logo_uri` are kept with the record and never serialized — `app-allowlist`'s "record the client's own copy; do not show it". Driven end to end over the real - router in `crates/didbot-serve/tests/oauth_agent_flow.rs`. + router in `crates/didbot-serve/tests/oauth_account_flow.rs`. - [x] **The daemon is the user-agent, and that is a request the model influenced.** Every fetch it makes on a request the model touched is bounded: the deprecated page path accepts only the authorize endpoint @@ -389,7 +389,7 @@ both gone. token, refresh rotation, client metadata fetching and validation. `crates/didbot-serve/src/oauth/{par,authorize,token,client_metadata}.rs`. Both grants are driven through the real router in - `crates/didbot-serve/tests/oauth_agent_flow.rs`, which is where the + `crates/didbot-serve/tests/oauth_account_flow.rs`, which is where the rotation's wire behaviour lives: a client that spends a refresh token twice loses the family, and one that presents the right token under the wrong key is refused without losing it. @@ -442,7 +442,7 @@ both gone. lifetime has to account for rather than as an omission to fix. - [x] **Rate-limit the authorize path**, which does work for unauthenticated callers. A fixed-window limiter keyed on the caller's address - (`crate::rate_limit`, shared with `provisionAgent`'s limiter — one + (`crate::rate_limit`, shared with `createAccount`'s limiter — one module, not a second copy under `oauth`), a self-contained primitive rather than a new dependency for one call site. Keyed on the real TCP peer address by diff --git a/plan/onboarding-policies.md b/plan/onboarding-policies.md index ce3da6b2..979e2aa4 100644 --- a/plan/onboarding-policies.md +++ b/plan/onboarding-policies.md @@ -2,7 +2,7 @@ id: onboarding-policies title: A new operator is shown policies worth having, not an empty ruleset status: open -crates: [didbot-policy-source, didbot-serve, didbot-operator] +crates: [didbot-policy-records, didbot-serve, didbot-operator] dependsOn: [policy, onboarding] exitCriterion: > An operator claiming a fresh deployment is offered a small set of sample diff --git a/plan/onboarding.md b/plan/onboarding.md index 8333086b..e51a6caa 100644 --- a/plan/onboarding.md +++ b/plan/onboarding.md @@ -2,7 +2,7 @@ id: onboarding title: An operator establishes a server before the server can establish anyone else status: open -crates: [didbot-attest, didbot-pds, didbot-serve, didbot-agentd] +crates: [didbot-pds, didbot-serve, didbot-agentd] dependsOn: [ownership, deploy] exitCriterion: > An operator takes a freshly applied deployment — instance up, no accounts, @@ -25,7 +25,7 @@ cover, and the half that has to happen first. ## The bootstrap paradox -**A provisioning request carries a host's claim.** `bot.did.provisionAgent` +**A provisioning request carries a host's claim.** `bot.did.createAccount` mints only under a claim signed with the node key of a host the operator has vouched for — see [handshake](handshake.md). The key is generated on the host and never leaves it, so there is no shared secret to distribute or get diff --git a/plan/ownership.md b/plan/ownership.md index d0ebc29c..7f447d07 100644 --- a/plan/ownership.md +++ b/plan/ownership.md @@ -5,8 +5,9 @@ status: open crates: [didbot-claim-check, didbot-pds, didbot-serve] dependsOn: [pds-writes] exitCriterion: > - A verifier that trusts neither side confirms an agent and its operator claim - each other, and a revocation breaks the claim in a way the verifier sees. + A stranger reaches the human from any account in three reads and can check + it, and a hosted account creates beneath itself only under an allowance the + human wrote in their own repository. --- # ownership @@ -14,119 +15,109 @@ exitCriterion: > Accountability is what a stranger uses: can this agent be traced to a responsible human, and can that human withdraw it. -Bidirectional means both statements exist and are checked against each other. -Without the agent's side, an operator's record is a claim anybody may make -about any account, and the account cannot disagree; without the operator's side, a -server is vouching for itself. Both records exist now. The reads that check -them are shared code in `didbot-claim-check`, run from the operator's own -command and from the policy page; the server's own poll reads the operator's -record at the same key. +Every account is a `did:web` in a tree under one human whose repository is +on another PDS. Two records make each edge. `bot.did.operator/`, +in the repository above, says the writer operates the account and what may +be created beneath it (`creates`). `bot.did.registration/self`, in the +account's own repository, is written once by the server and names the +human as `operator` and every hosted account between as `lineage`. The DID +document carries the same `operator`. What logs in as an account is its own +`bot.did.credential` records, one per key or OpenID Connect identity, +written by the server. +[docs/ownership.md](../docs/ownership.md) is the model as a reader meets +it; [docs/operator-verification.md](../docs/operator-verification.md) is +the top edge and its timings. -A third statement — the server's own word about itself and its agents — is -still load-bearing for a reader. What each of the three statements is worth, -and what a verifier does when they disagree, is [vouch](vouch.md); this epic -builds the second statement and stops at "checked against each other" for -those two. +Creation is an allowlist read only from the human's own repository, never +from a hosted account's, and it is not part of the deny-only policy tree. +A hosted account creates where a `creates` entry covers it — `subject` on +its own record, `descendants` on an ancestor's — and nowhere else. The +human creating from their own repository needs none: the record naming the +new account is the authorization. -- [ ] **A check that trusts neither server, for somebody who is neither - party.** Not an endpoint, because a check this server serves is one it - can pass by asserting it. It resolves the agent's, the server's and - the operator's DID documents itself, reads `bot.did.registration` out of - the agent's repository and `bot.did.operator` out of the *operator's*, and - confirms only when the two name each other, the claim binds the key - the server's own document publishes now, and the claim has not - expired. When one party serves both repositories their agreement is - not evidence, so that is a refusal. The record-key rule, the document - reading and the `describeServer` cross-check it needs are - `didbot-claim-check`'s already; `didbot_pds::OperatorClaim::from_json` - is the one reading of the record, shared with the server's own poll. -- [ ] **Delegation bounds, or an explicit no.** A bare vouch proves who - operates, not delegation. -- [ ] **Three statements, none written into somebody else's repository.** The - operator vouches for the server, from [policy-dashboard](policy-dashboard.md) - with their own key. The server answers its controlling DID on an - unauthenticated status endpoint. Each agent carries its operator and its - server in its origin record. A verifier refuses a pair where only one side - says so. +- [ ] **Whether the human's record carries the key itself, which would + retire the park.** `didbot operate` confirms a parked key by + fingerprint and names it as `parkedKey`, and the server writes the + parked key as the account's first `bot.did.credential`. A record + carrying the multibase key would let the server write the credential + from the record alone. +- [ ] **Key rotation as replace or overlap, and whether a rotation ends + live sessions.** +- [ ] **Delegation bounds, or an explicit no.** An allowance says what may + be created; nothing yet says what a hosted account may say about the + accounts beneath it. +- [ ] **The server's own word about itself.** The server answers its + controlling DID on an unauthenticated status endpoint, so a reader + can compare it with the human's record for the server. ## Done -- [x] **Revocation, and how it propagates.** Two readers, two bounds, both in - [docs/operator-verification.md](../docs/operator-verification.md). - This server polls every `DEFAULT_POLL_INTERVAL` (five minutes) and - pauses once `DEFAULT_GRACE_WINDOW` (six hours) has elapsed with no - readable claim, so it acts within the window plus one interval — six - hours and five minutes, held to that by - `a_revocation_is_acted_on_within_the_grace_window_plus_one_interval` - in `didbot-serve`'s `operator_poll`, which drives ticks across both - constants rather than restating either. A third party is not polling: - each read of the record starts from nothing, so a deletion is seen on - the next read, and the second path — the record's own `expiresAt`, the - only expiry field the lexicon has — on the first read past it. DNS is - not the mechanism and cannot be. What revocation cannot do is reach a - verdict already read: a verdict is a statement about the instant it - was made and nothing later. +- [x] **The three records' shapes.** `lexicons/bot/did/operator.json`: + `subject`, `creates` (`{scope, kinds?}`) and `createdAt`, keyed by + the subject's hostname. `lexicons/bot/did/registration.json`: `did`, + `operator`, `lineage?`, `kind`, `createdAt`, keyed `self`, written + once by `registration::compose`. `lexicons/bot/did/credential.json`: + `did`, `credential` (a union of `#publicKey {publicKeyMultibase}` and + `#oidcIdentity {issuer, claims}`) and `createdAt`, keyed by a TID, + one per credential, written by `Provisioner::land_initial_records`. + All three are `records::SERVER_AUTHORED` and refused on every write + path from the account. `didbot_claim_check::OperatorClaim` is the one + reader and writer of the operator record, shared by the server's + poll, `didbot operate` and the policy page. -- [x] **Enumeration is derived, and is a lower bound.** `bot.did.listAgents` - is one server's account of its own contents, so a reader treats it as - a proposal: each DID in it is checked against the operator's own record - before it is counted, and a fabricated entry has no record behind it. - Four things make the count undercount — an omitted entry, a deployment - that closed the listing, an operator running other servers nobody thought - to ask, and a listing being a moment — and none lets a server inflate - it. `AgentSummary`'s own doc in `didbot-serve` and - [docs/operator-verification.md](../docs/operator-verification.md) - say so where a reader meets the listing. +- [x] **The proof path reads the credential records.** `Registry::credentials` + answers an account's `bot.did.credential` records from the record + store. `tree::proof::prove_by_key` verifies a hosted account's JWT + against the keys they hold, and `tree::oidc` matches an ID token + against the identities they name; the one document a proof is checked + against is the human's own, for their service-auth token. -- [x] **The agent-side operator claim**, written by the server at provisioning, - never by the agent about itself. `bot.did.registration`'s `owner` field - (`lexicons/bot/did/registration.json`) is the DID of the party - accountable for the account, required for every account this server - admits and absent only on the server's own `#service` record before an - operator claim has been found. `Provisioner::provision` composes it - through `registration::compose`, which takes `owner` as a required - argument, so no agent repository can exist without one. The account - cannot then disagree: `bot.did.registration` is in - `didbot_pds::records::SERVER_AUTHORED`, and `guard_server_authored` sits - on every write path — `writing_the_registration_record_is_a_403_with_its_own_name` - and `no_route_can_destroy_the_registration_record` in - `crates/didbot-serve/src/tests.rs` prove it over HTTP. This is the - server's statement, not proof; checking it against the other direction - is the verifier item below. +- [x] **The allowance gate.** `didbot_pds::allowance::Allowances::permits` + answers whether the creator, or any ancestor with scope + `descendants`, holds an allowance for the kind; an empty index + refuses every hosted creator. `Provisioner::require_allowance` runs it + after the tree's own bounds and before a name is taken, and a refusal + is `NotAllowedToCreate`. The human's own creates skip it. -- [x] **A command that writes a vouch.** `didbot operate` — see - [handshake](handshake.md)'s Done list, which built it. It writes - `bot.did.operator` into the *operator's own* repository through - `com.atproto.repo.putRecord`, keyed at the PDS's hostname, under the - narrow scope `atproto repo:bot.did.operator?action=create,update`. - That is the only vouch a command here can write: the "three statements" - item below rules out per-agent records in the operator's repository, so - operator-to-server is the whole of it and an agent's own operator rides in - its registration record instead. - `a_clean_run_writes_the_servers_resolved_key` and the three refusal - tests beside it in `crates/didbot-operator/src/operate/orchestrate.rs` show - nothing is written until every check passes. +- [x] **The poll refreshes allowances.** `operator_poll::refresh_allowances` + lists the human's `bot.did.operator` collection on the same tick that + reads the record for the server itself, indexes every allowance by + subject, and installs the index; a listing that fails keeps the last + one in force. - Reading one back is *not* built, and this item used to say it was. The - vouch-chain walk that claim rested on lived in `didbot-index`, which - has been removed; see this section's correction note. +- [x] **The document names the human.** `didbot_identity::OPERATOR_PROPERTY` + is a top-level `operator` on every hosted account's DID document, the + same DID the registration names, so one GET reaches the human. -**Correction, not new work:** this section previously ticked three items — -the `bot.did.vouch` lexicon, a vouch-chain discovery walk, and its -label-by-label containment check — that were never actually built. All -three described `didbot-index`'s own reading of a `bot.did.vouch` design; -that crate has since been removed, and no schema for `bot.did.vouch` was -ever checked in. Its NSID constant is gone too: `crates/didbot-lexicon/src/nsid.rs` -deleted it rather than keeping it beside `OPERATOR` as a retired name, on the -reasoning that a name nothing reads only invites using the wrong one — so -there is no `didbot_lexicon::nsid::VOUCH`, and an earlier revision of this -paragraph saying there was is the same kind of drift this section exists to -correct. -[handshake](handshake.md) built a fresh record instead, `bot.did.operator`, -designed from scratch rather than as a rename of the one described here — -see that epic's own Done list for its fields, and for -`crates/didbot-pds/src/provision.rs`'s zone-escape check, the one piece of -"containment" this codebase currently still enforces, in a different place -than this line originally claimed. Nothing in this repository today walks a -voucher/repository/claim/server chain the way the original three items -described. +- [x] **A constant-time check.** The registration, or the document, names + the human; the human's repository is read twice, for the record + naming the lineage's first entry (or the account itself) and for the + record naming the server. The count does not grow with depth. `didbot + operate --check` remains the thorough walk, edge by edge, refusing a + chain still hosted here after three edges. + +- [x] **Revocation, and how it propagates.** Two readers, two bounds, both + in [docs/operator-verification.md](../docs/operator-verification.md). + The server polls every `DEFAULT_POLL_INTERVAL` and pauses once + `DEFAULT_GRACE_WINDOW` has elapsed with no readable claim, held to + that by `a_revocation_is_acted_on_within_the_grace_window_plus_one_interval` + in `didbot-serve`'s `operator_poll`. The same poll re-reads the + record for every root the human admitted and quarantines a lapsed + root and everything beneath it. A third party is not polling: each + read starts from nothing, so a deletion is seen on the next read. A + verdict already read is about the instant it was made. + +- [x] **Enumeration is derived, and is a lower bound.** `bot.did.listAccounts` + is one server's account of its own contents, so each DID in it is + checked against the human's own record before it is counted. An + omitted entry, a closed listing, a human running other servers and a + listing being a moment all make it undercount; none lets a server + inflate it. + +- [x] **A command that writes the human's record.** `didbot operate` writes + `bot.did.operator` into the human's own repository through + `com.atproto.repo.putRecord` under the scope + `atproto repo:bot.did.operator?action=create,update`, with + `--creates` / `--creates-beneath` under `creates`, then creates with a + service-auth token, the parked key or the OIDC identity in the body. + Nothing is written until every check passes. diff --git a/plan/pds-xrpc.md b/plan/pds-xrpc.md index de2b735b..d347f101 100644 --- a/plan/pds-xrpc.md +++ b/plan/pds-xrpc.md @@ -39,7 +39,7 @@ checked today. - [x] **The stream and the routes agree, checked from outside the process.** `crates/didbot/tests/relay_view.rs` drives this server the way an outside implementation would: a WebSocket on `subscribeRepos` with no - credential, and `bot.did.provisionAgent`, + credential, and `bot.did.createAccount`, `com.atproto.repo.{createRecord,getRecord,listRecords}`, `com.atproto.sync.getLatestCommit`, `com.atproto.server.describeServer`, `/.well-known/atproto-did` and diff --git a/plan/periodic-backups.md b/plan/periodic-backups.md index f9a911dd..99d353ff 100644 --- a/plan/periodic-backups.md +++ b/plan/periodic-backups.md @@ -63,7 +63,7 @@ twenty-four hours, and a copy that survives the account the vault is in. turns out to need a still filesystem, and is the owner's call. - [ ] **Signing keys and credentials stay out of it, and say so.** Decision to make, stated rather than implied: `Entry::AccountInserted` carries a - whole `SigningKey` and `Entry::AgentTokenIssued` carries a token digest, + whole `SigningKey` and `Entry::AccountTokenIssued` carries a token digest, and the proposal is that the process-taken artefact carries neither. The consequence is the reason it needs deciding out loud — that artefact restores records, blobs, name holds and ledger history, and the diff --git a/plan/policy.md b/plan/policy.md index 86ddf3d9..ce1023dc 100644 --- a/plan/policy.md +++ b/plan/policy.md @@ -265,7 +265,7 @@ of an evaluator failure. A policy that compiled at revision N and fails to compile at N+1 keeps enforcing N — dropping it would widen what an agent may do, and denying -everything it covers would turn a typo into an outage. `didbot-policy-source`'s +everything it covers would turn a typo into an outage. `didbot-policy-records`'s `LastGoodPolicies` holds the last successfully-compiled record per operator-sourced id, across every `merge::build` call; a revision that fails to compile is looked up there and, if found, recompiled and enforced in the @@ -381,6 +381,14 @@ network resolution on a cache miss. - [ ] **Show where each policy came from** and, for polled ones, how far behind the last successful pull is. +## Creation is not a policy + +Whether an account may create beneath itself is an allowlist, not a denial. +It is read from the `creates` entries on the operator's own +`bot.did.operator` records and nowhere else, and no allowance means no +creation. It is not evaluated here and no policy action names it; +[docs/ownership.md](../docs/ownership.md) is where it lives. + ## Not this epic Two neighbouring epics use "policy" in a different sense and are not governed @@ -396,7 +404,7 @@ A rule that applies to "agents of this type" is keyed on something, and the strength of the whole rule is the strength of that key. - [ ] **Say what `agent_type` is worth, where a rule uses it.** - `AgentAccount::agent_type` is the harness's own word for what it is — + `HostedAccount::agent_type` is the harness's own word for what it is — supplied in the provisioning request, not a checked fact, one tier below an attested node in `docs/trust-model.md`'s terms. The running agent cannot change it and the model never sets it, but whoever may provision @@ -550,7 +558,7 @@ a write. - The `compile` step on `Evaluator` (three outcomes, a `CompiledId` the evaluator owns, a parallel policy-lifecycle channel) and - last-good-revision (`crates/didbot-policy`, `crates/didbot-policy-source`, + last-good-revision (`crates/didbot-policy`, `crates/didbot-policy-records`, `crates/didbot-policy-regex`, `crates/didbot-pds/src/policy_tree.rs`). - [x] **A freeze names why.** `AccountState::Frozen` needs a reason, because an diff --git a/plan/provenance.md b/plan/provenance.md index aa53b7cf..32e1a599 100644 --- a/plan/provenance.md +++ b/plan/provenance.md @@ -99,7 +99,9 @@ model. Two are carried. The third is not. DID, so a consumer can say which operator served it; `admittedBy` and `assurance` as two fields, so judging an account does not require knowing what each backend does; the revision; and `handles`, every handle the - account has been issued, oldest first. A permanent record may not carry a + account has been issued, oldest first. Of these the record now carries + `did`, `provisionedAt`, `kind`, the human as `operator` and the chain + between as `lineage`; see [ownership](ownership.md). A permanent record may not carry a changeable field unless it carries that field's history, and an `initialHandle` would have been true forever while answering less. The node identifier is deliberately absent: it names a machine rather than an diff --git a/plan/subagents.md b/plan/subagents.md index f7f20cb5..f8565dca 100644 --- a/plan/subagents.md +++ b/plan/subagents.md @@ -43,20 +43,19 @@ filesystem, so the boundary is reported rather than enforced. ## Reported, not enforced — and that was always true -`agent_id` arrives on the harness's own report of what it spawned. Nothing +`account_id` arrives on the harness's own report of what it spawned. Nothing proves the subagent process is isolated from its parent's memory or its siblings'. What is worth keeping is that `session_id` is exactly the same kind of claim from exactly the same bookkeeping. Sessions were never better attested than subagents, so admitting subagents lowers nothing. What both rest on is the -node the [handshake](handshake.md) admitted, and `didbot-attest` says as much: -read a provenance as "a machine we admitted asked for this", never as "this -agent is who it says it is". +node the [handshake](handshake.md) admitted: read a provenance as "a machine +we admitted asked for this", never as "this agent is who it says it is". ## The identifier is not reliable, and disk is -The harness's `agent_id` has been observed arriving wrong: one subagent's +The harness's `account_id` has been observed arriving wrong: one subagent's consecutive tool calls under two different ids, and a single stray id collecting calls from more than one real subagent. A stray id would mint an account for a context that never existed, or worse, sign several agents' work @@ -67,7 +66,7 @@ under one name. file, so which agent made a given call is recoverable from disk with certainty — including for two byte-identical concurrent commands, where nothing derived from the command itself can tell them apart. Measured: - the hook's `agent_id` agreed with the transcript on every call observed, + the hook's `account_id` agreed with the transcript on every call observed, and the row landed within a tenth of a second of the hook firing. - [ ] **Decide what happens when they disagree.** Disk is authoritative, so the write is either attributed to the agent disk names or refused. What diff --git a/plan/tombstone-serving.md b/plan/tombstone-serving.md index acf75bba..8b64a17a 100644 --- a/plan/tombstone-serving.md +++ b/plan/tombstone-serving.md @@ -63,8 +63,8 @@ rewriting a path rather than by running a query planner. restating them.** The candidate set is the [`DidDocument`](../crates/didbot-identity/src/document.rs), the CAR from `export_car`, the account's - [`AgentLedger`](../crates/didbot-pds/src/ledger.rs) as - `bot.did.getAgentLedger` answers it, the `bot.did.registration` record + [`AccountLedger`](../crates/didbot-pds/src/ledger.rs) as + `bot.did.getAccountLedger` answers it, the `bot.did.registration` record the account was born with, every record the [`RecordStore`](../crates/didbot-pds/src/records.rs) holds for the DID, and every blob its `BlobIndex` references. Each of those is a decision @@ -130,7 +130,7 @@ rewriting a path rather than by running a query planner. reverse trivially — an export is a copy, and the write-ahead log it was taken from is still whatever `plan/periodic-backups.md` kept. The identity does not: a soft delete burns the label through - [`Reservation::FormerAgent`](../crates/didbot-pds/src/names.rs), which + [`Reservation::FormerAccount`](../crates/didbot-pds/src/names.rs), which only an administrator's hard delete ever frees, and the account's signing key is gone with its repository. Proposal: restoring a frozen account produces a *new generation* at the same DID rather than a diff --git a/plan/web-launch.md b/plan/web-launch.md index bbb24958..5648ad1f 100644 --- a/plan/web-launch.md +++ b/plan/web-launch.md @@ -121,7 +121,7 @@ its own record. answer no origin but their own; widening that set is the human decision the item named. - [x] **Serve `com.atproto.sync.getRepoStatus`.** `active` and `status` - are read through `AgentAccount::sync_status`, the mapping + are read through `HostedAccount::sync_status`, the mapping `subscribeRepos`'s `#account` is announced from, with `rev` when the repository is fetchable and `RepoNotFound` where the stream is silent. The conformance harness checks the body against the vendored document diff --git a/plan/zone-scale.md b/plan/zone-scale.md index 5e78698c..27b2e430 100644 --- a/plan/zone-scale.md +++ b/plan/zone-scale.md @@ -77,7 +77,7 @@ the fix is cheap and knowing when to apply it is not free. Every generated label goes through `didbot_name::check`, which no longer hand-maintains the DNS-label character rules: it calls `didbot_identity::validate_label` — now `pub`, and the same function - `AgentDid::mint` checks an agent id against before it becomes part of a + `AccountDid::mint` checks an agent id against before it becomes part of a `did:web` identifier — so there is one legality rule for both places a label has to be legal, not two that could disagree. A UUID's canonical form is emitted with its hyphens in exactly the positions that rule -- 2.51.2