From 4091ec974ff94faf4e65ae9628646fc6aa60dfde Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 23 Sep 2026 14:33:19 -0400 Subject: [PATCH] docs(plan): propose where a subagent's account is created The subagents epic now answers issue #133 with a design for review. It recommends DIDBOT_SUBAGENT_OPERATOR, agent by default, so a session creates its subagents with its own token and its locks, deletion and a Revoke reach them; a report may override it per context. The owner has five questions to answer, the default first. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I66dbfa06ccdade39e2124fd9473e876ec3d7ac1b --- plan/subagents.md | 394 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 376 insertions(+), 18 deletions(-) diff --git a/plan/subagents.md b/plan/subagents.md index 5fa7e7d9..163d780b 100644 --- a/plan/subagents.md +++ b/plan/subagents.md @@ -2,13 +2,13 @@ id: subagents title: A subagent is a context, so it gets an account status: open -crates: [didbot-pds, didbot-agentd] +crates: [didbot-pds, didbot-agentd, didbot-operator] dependsOn: [ownership, node] exitCriterion: > Two subagents of one session write records signed by two different accounts, each account's registration names the account it was created beneath, and a - deployment that wants one account per session instead sets that in - configuration rather than in code. + deployment sets in configuration rather than in code whether that is the + session or the host, and whether a session's subagents share its account. --- # subagents @@ -33,9 +33,10 @@ a role its parent plays. It gets an account. The last point holds whatever harness is underneath: a subagent runs inside its parent's process and filesystem, so the boundary is reported rather than enforced. A session id is the same kind of claim from the same bookkeeping as -a subagent id. What the server checked is the host's key: read a subagent's -account as "a host the human admitted created this", never as "this agent is -who it says it is". +a subagent id. What the server checked is the creator's credential and the +operator's allowance for it: the host's key, or the session's own token when +the session creates. Read a subagent's account as "an account the operator's +allowance covers created this", never as "this agent is who it says it is". One account reused across every subagent of a type would be cheaper and would denote nothing: two invocations share no context, so they are not one @@ -43,17 +44,372 @@ entity. ## Which account a subagent is created beneath -- [ ] **Beneath the agent, or beneath the host.** Today the daemon creates - every context beneath the host, with the host's key, so a subagent's - lineage ends at the host and its parent's account says nothing about - it. The other tree puts the subagent beneath the agent: the daemon - takes a session as the parent agent and creates as it, the agent's - repository gains `bot.did.operator/`, and the lineage ends - at the agent; the human's allowance has to reach `descendants` for - that create to be permitted. Which is the default, why, a - `didbot-agentd` key that picks one, and a way for a harness that knows - better to say so per context are all open. Tracked as issue - `3mvo4zski2f2n`. +The daemon creates every context with the host's key, so a subagent's +lineage ends at the host and its session's account says nothing about it. +The other tree creates a subagent as its session, beneath the session. +[Issue #133](https://tangled.org/did:plc:swttlkbjvcoln67iievud7b3/issues/133) +asks which, and how a deployment and a harness choose. + +The hook tells the daemon which context is acting. didbot-claude sends the +harness's `agent_id` as the report's `context`, and leaves it out for the +main conversation. The daemon keys a context by `{session, context}` +(`crates/didbot-agentd/src/context.rs`), so a subagent's session is the +context with the same `session` and no `context`. Below, `{S, A}` is +subagent `A` of session `S`, and `{S}` is the session. The hook names the +acting agent and not the one that spawned it, so a subagent started by +another subagent has `{S}` as its parent too. + +### Options + +1. **Do nothing.** Every context is created beneath the host, as today. + - Costs nothing, breaks nothing, stores nothing new. + - A lock on a session reaches none of its subagents, and its next + subagent is still created. `crates/didbot-pds/src/estop.rs` names the + gap: "a halted agent that can still provision takes a fresh account + under the same host". + - Deleting a session leaves its subagents standing, and nothing at the + server names the session they came from: an account's name is a digest + of both halves of its context. + - Every context on the machine spends the host's `creates_per_hour`, 100 + by default, so one session's fan-out can leave another session's next + context refused `CreatesTooFast`. +2. **A key, `host` by default.** A deployment, or a harness for one context, + chooses `agent`. + - Costs the daemon change below. Breaks nothing. + - Stores nothing new: one environment variable and one optional field on + the wire. +3. **The same key, `agent` by default.** + - Costs what option 2 costs, and more. A laptop host is admitted with + `--creates-beneath agent` beside `--creates agent`; the pool in + [docs/ownership.md](../docs/ownership.md) already carries it. + The scenario suite's `Daemon::agent` reports a subagent of a fresh + session, so its laptop's allowance and its lineage assertions change. + `didbot operate --check` refuses a chain still hosted here after three + edges (`MAX_EDGES`), and a subagent beneath a session beneath a host + beneath a pool is such a chain. + - Breaks every host whose record lacks the `descendants` allowance: each + subagent is refused `NotAllowedToCreate` until the record changes or the + deployment sets `host`. No command changes a standing account's + `creates`: a second `didbot operate` for it is refused + `AccountAlreadyExists`, and the record that stood is put back. + - Stores nothing new. + +### Recommendation + +Option 3, `agent` by default. + +- **The tree records who spawned what.** Lineage tracking is in + [the mandate](README.md#the-capabilities). Only `agent` puts the session + in a subagent's lineage, and a record naming the subagent in the + session's repository. +- **The operator's controls reach what a session spawned.** A lock on a + session hangs on each subagent beneath it and refuses the next one. A + delete of the session's subtree takes its subagents. A policy binding that + includes the session's descendants covers them. +- **A Revoke holds.** Its release ends every agent token, so a session from + before it gets no new subagents. Under `host` the host's key gives them + fresh accounts. +- **A fan-out is charged to its session.** The session's own + `creates_per_hour` bounds it, and the host's counts sessions only. The + deployment's `[capacity] max_accounts` counts the same accounts either way. + +The case for `host`: + +- **It needs nothing from the operator.** `agent` needs a `descendants` + allowance, and that lets every account beneath the host create, each + against its own `[tree]` bounds. A fan-out from one token is then bounded + by `max_depth` and `[capacity] max_accounts`, not by the host's hourly + rate. Under `host` an agent's token creates nothing. +- **A subagent depends on the host's account alone.** Under `agent` it also + needs the session's account provisioned, active and unlocked, and a + session token that still verifies: a year by default, ended by a Revoke's + release, and gone if the daemon loses the session's binding. +- **The tree is one edge shallower.** `didbot operate --check` holds at three + hosted edges, and a session can deactivate itself or be erased without its + subagents going first. + +On a laptop the session's token sits in `contexts.json` beside the host's +key, so a process that reads one reads the other. The wider allowance matters +for a token that leaves the machine without the key. + +### What `agent` does + +For a report from `{S, A}`, which has no account yet: + +1. The daemon takes `{S, A}`'s turn, as today. +2. If `{S}` has no account, the daemon creates one first, under `{S}`'s own + turn and with the host's JWT, as a report from `{S}` would. `{S}` is told + its identity on its own next report. +3. It calls `bot.did.createAccount` with `{name, kind: "agent"}`, and + `{S}`'s account token as `Authorization: Bearer`. The name is built as + today, from both halves of `{S, A}`. +4. The server checks what it checks for every hosted creator + ([docs/ownership.md](../docs/ownership.md)): + - the operator's record for the root above `{S}` still stands; + - `{S}` and every account above it are active and unlocked; + - the `[tree]` bounds hold, counted against `{S}`; + - an allowance covers the chain `[session, host, …]`: `descendants`, with + no kinds or with `agent`, on the host's record or one above it. +5. It writes: + - `bot.did.operator/` into `{S}`'s repository, as `{S}`: + `subject` and `createdAt`, one commit on the firehose; + - `bot.did.registration/self` into the subagent's repository: `operator` + the human, `lineage` `[host, session]` (or `[pool, host, session]` + beneath a pool), `kind` `agent`; + - what every account gets: `app.bsky.actor.profile/self`, a DID document + naming the human as `operator`, and a ledger opened by `Provisioned`, + whose `parent` is `{S}`; + - nothing into the host's repository. +6. The daemon keeps the DID and the token in `{S, A}`'s binding, follows its + sign-ins and answers with its identity, as today. + +The daemon already holds `{S}`'s token: `createAccount` answered it once as +`accountToken`, and `contexts.json` keeps it. `tree::proof::prove` reads a +token this server issued as `Proof::Session`, and the account it names is +the creator (`crates/didbot-serve/src/tree/proof.rs`). No second sign-in is +needed, and none would work. `bot.did.createSession` takes a key-signed JWT +or an ID token, and `{S}`'s account holds neither. A `getServiceAuth` JWT is +signed with the `#atproto` key, which `prove_by_key` does not read. No +credential type is added. + +The server half is built and tested: +`a_descendants_allowance_lets_the_children_create_and_not_the_root` +(`crates/didbot-serve/tests/tree.rs`) creates an agent beneath an agent and +checks its lineage, and `a_session_creates_beneath_the_account_it_names` +(`crates/didbot-serve/src/tests/provisioning.rs`) creates with a session's +token. + +The protocol does not decide this. The reference PDS's +`com.atproto.server.createAccount` takes no creator or parent +(bluesky-social/atproto at `ff99ec0a`, vendored in `vendor/atproto-lexicons`), +so the tree is `bot.did.*`'s own. The spec governs one side effect: a lock +that removes reads changes an account's hosting status, and each account the +cascade reaches gets its own `#account` event +([account spec, Hosting Status](https://atproto.com/specs/account#hosting-status)). + +### When the allowance does not reach descendants + +The refusal is the subagent's trouble, and nothing is created beneath the +host instead. + +- The server re-reads the operator's records, waiting up to eight seconds + (`ALLOWANCE_REREAD_WAIT`), and answers `NotAllowedToCreate` naming `{S}`. +- The daemon answers the report with that name and sentence as `trouble`, + and logs a warning naming both ways out: `--creates-beneath agent` on the + host's record, or `DIDBOT_SUBAGENT_OPERATOR=host`. +- didbot-claude writes trouble to its hook error log and puts nothing in + front of the model. The subagent runs with no account. +- The subagent stays owed a name, and its next call asks again: the retry a + host with no allowance gets today. + +A fallback on this one refusal would escape no lock and no bound, because +the server checks those before the allowance. It is refused anyway: + +- where a subagent lands would depend on when the operator's poll last ran, + and the key would stop saying where accounts are; +- the same fallback on `AccountLocked`, `CreatorNotActive`, + `AncestorNotStanding`, `TreeTooDeep`, `TooManyChildren`, `CreatesTooFast`, + `InvalidToken` or `ExpiredToken` takes a subagent out from under what + refused it, and one match arm would be all that separates the two. + +### The key + +`DIDBOT_SUBAGENT_OPERATOR`: the issue's `subagent.operator`, spelled the way +the daemon's settings are, since "the environment is the whole +configuration" (`crates/didbot-agentd/src/bin/didbot-agentd.rs`). It is read +once, at start. + +- `agent`, the default: a subagent is created as its session. +- `host`: a subagent is created with the host's key, beneath the host. +- Anything else refuses the start, naming the key and both values, as + `DIDBOT_CONTEXT_TTL_DAYS` refuses a number outside its range. + +It does not touch a session's own account, which the host always creates. + +### The per-context override + +A report may carry `operator`, `"agent"` or `"host"`. + +- It is read on the report that creates a subagent's account, and wins over + the key. +- It is ignored on a session's report, and on every report once the account + exists: an account is never moved. +- Any other value is trouble naming both, and nothing is created. + +The wire needs the field and a new version. `Report` drops fields it does +not know, so a version-2 daemon would create beneath the host and say +nothing. Version 3 adds `operator`. A daemon reads a version-2 report as +before, so an adapter that never sends the field need not move. One that +sends it writes version 3 on that report, and a version-2 daemon refuses it +by name (`serve::too_new`). `ask-report-full.json` in +`crates/didbot-agentd/fixtures/protocol/` gains the field. + +Both values are honoured, whatever the key says. `host` on a daemon set to +`agent` takes a subagent out from under its session. That is no wider than +the hook already is: it names the session, and a report naming a new session +already gets an account beneath the host. [node](node.md)'s rule that nothing +from a hook may widen anything is why the owner is asked below. + +didbot-claude need not send it: nothing in Claude Code's payload changes the +answer. + +### What a session's freeze, lock, deletion or e-stop does to its subagents + +| What happens to the session | `host` | `agent` | +|---|---|---| +| A lock: the operator's (`didbot account lock`), its own (`bot.did.freezeAccount`), or a policy's quarantine | Its subagents keep writing, and new ones are created beneath the host. | Each live subagent carries the same lock from `parent` until the session's comes off (`Provisioner::cascade`). A new subagent is refused `AccountLocked`. | +| It deactivates itself (`bot.did.deactivateAccount`) | Allowed. | Refused `AccountHasLiveChildren` while a subagent beneath it is live. | +| The operator erases it (`didbot account erase`) | Allowed; its subagents are untouched. | Refused `AccountHasLiveChildren` while a subagent beneath it is live. | +| The operator deletes it (`didbot account delete`) | It goes alone. Its subagents stay beneath the host, and nothing names their session. | Refused `LiveChildren` without `--recursive`. With it, each subagent goes first, then the session. | +| A lock on the host, or the operator's record for the root lapsing | Reaches every account on the host. | The same, through the session. | +| An e-stop Pause, or a Revoke while it stands | Every create is refused `Halted`. | The same. | +| A Revoke released | Every agent token ends. The host's key still creates the session's new subagents. | The session's token has ended, so its new subagents are refused `InvalidToken`. A session started after the release is unaffected. | +| Its token expires (`[credential] agent_token_ttl_days`, a year by default) | Nothing changes for its subagents. | Its new subagents are refused `ExpiredToken`. | +| What the ledger says | A subagent's `Provisioned.parent` is the host. | A subagent's `Provisioned.parent` is the session, and a cascaded lock is a `Locked` entry from `parent` in its ledger. | + +### State + +The recommended option adds no stored fact, field or lifecycle. It changes +which account some existing facts name. + +Settings, which nothing stores: + +| Setting | Type | Where | +|---|---|---| +| `DIDBOT_SUBAGENT_OPERATOR` | `agent` or `host`; `agent` when unset | the daemon's environment: `~/.config/didbot/agentd.env` under the unit | +| `Report::operator` | `agent` or `host`, optional | one report, protocol version 3 | + +Stored facts: + +| Fact | Type | Where it lives | A subagent under `agent` | Under `host` | +|---|---|---|---|---| +| The subagent's binding | `session`, `context`, `kind`, `did`, `token`, `told`, `done`, `last_reported` | `contexts.json` (`context::Binding`) | as today | as today | +| The session's `token` | a secret | the session's binding in `contexts.json` | presented to create the subagent | not read | +| `parent` | DID | the subagent's account row (`HostedAccount::parent`) | the session | the host | +| `bot.did.operator/` | `subject`, `createdAt` | the parent's repository | the session's | the host's | +| `lineage` | DIDs, root first | `bot.did.registration/self` in the subagent's repository | `[host, session]` | `[host]` | +| `Provisioned.parent` | DID | the subagent's ledger | the session | the host | +| Live children; creates in the last hour | counts | derived from the account store; the provisioner's memory | the session's | the host's | +| The host's `creates` | allowances | `bot.did.operator/` in the operator's repository | `subject` and `descendants`, each `[agent]` | `subject [agent]` | + +A subagent context in the daemon. The choice is the report's `operator` when +it has one, and the key when it does not. + +| From | Trigger | When | Then | To | +|---|---|---|---|---| +| none | any report from `{S, A}` | | a binding with no `did` | unnamed | +| unnamed | `began` or `acted` | `operator` is neither value | trouble naming both; no create | unnamed | +| unnamed | `began` or `acted` | the choice is `host` | `createAccount` with the host's JWT | named, or unnamed with the refusal | +| unnamed | `began` or `acted` | the choice is `agent`; `{S}` is named and holds a token | `createAccount` with `{S}`'s token | named beneath `{S}`, or unnamed with the refusal | +| unnamed | `began` or `acted` | the choice is `agent`; `{S}` has no account | `{S}` is created first under its own turn, with the host's JWT; then the row above | named beneath `{S}`, or unnamed with the refusal | +| unnamed | `began` or `acted` | the choice is `agent`; `{S}` is named and holds no token | trouble naming `{S}`'s DID; no create | unnamed | +| named | any report | | told once per asker, as today; `operator` ignored | named | +| any | `ended` | | as today | done | +| any | quiet past `DIDBOT_CONTEXT_TTL_DAYS` | | as today | dropped | + +A session context moves as it does today, and ignores `operator`. A +subagent's account gains no transitions: the table above says what its +session's do to it. + +### Build + +- [ ] **The key.** `DIDBOT_SUBAGENT_OPERATOR` read and checked at start in + `crates/didbot-agentd/src/bin/didbot-agentd.rs`, and held by `Daemon`. +- [ ] **The create.** `provision_context` presents `{S}`'s token for a + subagent under `agent`, creates `{S}` first when it has no account, + and logs both ways out on `NotAllowedToCreate`. +- [ ] **The override.** `Report::operator` and protocol version 3, with the + fixtures blessed. +- [ ] **The walk.** `MAX_EDGES` in + `crates/didbot-operator/src/operate/walk.rs` goes from three to four. +- [ ] **The words.** A host is admitted with + `--creates agent --creates-beneath agent` in `docs/agentd.md`, the + laptop record and scenario table in `docs/ownership.md`, + `docs/first-hour.md` and `docs/cli.md`. `docs/agentd.md`'s + provisioning paragraph says which credential creates a subagent, and + it, `docs/cli.md` and `didbot-agentd --help` list + `DIDBOT_SUBAGENT_OPERATOR`. [node](node.md)'s exit criterion says a + session's account is created beneath the host. +- [ ] **The scenarios.** The laptop in `crates/didbot/tests/scenarios/` is + admitted with `--creates-beneath agent`, and each lineage it asserts + follows the tree. +- [ ] **The order.** Everything above lands with `host` as the default. + `agent` becomes the default in its own breaking change, once the + owner has said how a standing host gains its `descendants` allowance. + +### Tests + +The daemon, against the `Counting` registrar in +`crates/didbot-agentd/src/serve.rs`, which keeps the credential it was shown: + +- [ ] `a_subagent_is_created_with_its_sessions_token`: under `agent`, the + subagent's create presents `{S}`'s token and `{S}`'s presents a host + JWT. +- [ ] `a_subagent_before_its_session_creates_the_session_first`: one report + from `{S, A}` makes two creates, `{S}` first. `{S}`'s next report is + told its identity, and both accounts are followed for sign-ins. +- [ ] `two_subagents_of_a_session_with_no_account_create_it_once`: two + reports at once make three creates. +- [ ] `a_refusal_as_the_session_is_the_subagents_trouble`: for + `NotAllowedToCreate` and for `AccountLocked`, one create and no host + JWT; the answer carries the server's words; the next report asks again + as `{S}`. +- [ ] `under_host_a_subagent_is_created_with_the_hosts_key`. +- [ ] `a_report_saying_where_wins_over_the_key`: `host` under `agent`, and + `agent` under `host`. +- [ ] `a_sessions_report_saying_agent_is_created_beneath_the_host`. +- [ ] `a_report_naming_neither_value_is_refused_by_name`. + +The wire and the binary: + +- [ ] `operator` round-trips in a version-3 report, and + `ask-report-full.json` carries it + (`crates/didbot-agentd/tests/fixtures.rs`). +- [ ] `didbot-agentd` refuses to start on `DIDBOT_SUBAGENT_OPERATOR=pool`, + naming the key and both values, and `--help` lists it + (`crates/didbot-agentd/tests/cli.rs`). + +End to end, with the real binaries in `crates/didbot/tests/scenarios/`: + +- [ ] `a_laptops_subagent_is_created_beneath_its_session`: its lineage is + `[host, session]`; the session's repository holds + `bot.did.operator/` and the host's does not; + `didbot operate --check` walks subagent, session, host, human. +- [ ] `a_host_without_descendants_refuses_subagents_by_name`: admitted with + `--creates agent` alone, the subagent is refused `NotAllowedToCreate`, + the daemon's log names both ways out, and nothing is created beneath + the host. +- [ ] `locking_a_session_stops_its_subagents`: after `didbot account lock` + on the session, its subagent carries `Frozen` from `parent` and a new + subagent is refused `AccountLocked`; `unlock` lifts both. +- [ ] `deleting_a_session_takes_its_subagents`: `didbot account delete` on + the session is refused `LiveChildren`; with `--recursive` both go, the + subagent first. +- [ ] `a_released_revoke_ends_what_a_session_spawns`: the session's next + subagent is refused `InvalidToken`, and a new session's subagent is + created. +- [ ] `a_pools_subagent_checks_in_four_edges`: subagent, session, host, + pool, human, which `--check` refuses as `TooDeep` until `MAX_EDGES` is + four. +- [ ] `a_fan_out_is_charged_to_its_session`: under `creates_per_hour = 2`, + a session, its two subagents and a second session are all created in + one hour, where `host` refuses the second subagent `CreatesTooFast`. + +### Questions for the owner + +- [ ] **The default.** `agent`, as recommended, or `host`. +- [ ] **A standing host's allowance.** `didbot operate` rewrites `creates` on + a record whose account stands; or operators rewrite it by hand, and + set `host` until they do. +- [ ] **`host` from a hook when the key says `agent`.** Honour it, as + recommended, since a hook already names the session; or refuse it, + under [node](node.md)'s rule that nothing from a hook widens anything. +- [ ] **The names.** The issue's `operator`, which is the record's name; or + `parent`, which `createAccount`'s answer, the account row and the + ledger use, and which never names the human. +- [ ] **What bounds a fan-out beneath `descendants`.** `[tree] max_depth` + and `[capacity] max_accounts`, as today; or a scope that reaches one + level, beside `subject` and `descendants`, which is a lexicon change. ## The identifier is not reliable, and disk is @@ -82,7 +438,9 @@ under one name. Subagent use is bursty and concentrated where the work is: two days on one machine were 16 sessions and 167 subagent invocations, 161 of them from one project. A fan-out of parallel subagents is the same burst -[node](node.md)'s `[tree]` bounds cap, moved to the busiest moment. +[node](node.md)'s `[tree]` bounds cap, moved to the busiest moment, and +charged to whichever account creates it: the session under `agent`, the host +under `host`. - [ ] **Granularity is configuration, with per-context the default.** [account-types](account-types.md) already has the kind supply defaults -- 2.51.2