diff --git a/docs/agent-broker.md b/docs/agent-broker.md new file mode 100644 index 00000000000..d4a3d008520 --- /dev/null +++ b/docs/agent-broker.md @@ -0,0 +1,643 @@ +# The Agent Broker: A Specification + +`draft` + +## Abstract + +This document specifies the protocol by which a Buzz agent that holds **no +secret key** participates in Buzz anyway: reading its channels, answering a +mention, reacting, maintaining its profile, addressing its encrypted memory, +and creating the agents it needs. It does so by asking a **broker host** — a +process that does hold the key — to act on its behalf, one named operation at +a time. + +The broker exists so that an agent's identity can be separated from its +signing key: the key stays under separate custody while the agent runs +somewhere ephemeral, shared, or less trusted. The +protocol is Buzz-defined and lives in Buzz's shared client library, but it is +**not a NIP**: the relay never sees it. A broker host does ordinary relay work +as an ordinary client, and nothing about the relay protocol changes. + +Two design decisions shape everything below. First, the broker exposes a +**closed set of named operations** rather than a "sign these bytes" primitive, +so that a host can reason about *what* is being asked, not only *who* is +asking. Second, the request body **never names its own authority** — no +requester, owner, or scope field exists — so the host's authentication of the +session is the only thing that decides who is acting. + +We state six invariants — **no secret crosses the wire**, **authority comes +from the session, never the body**, **retry means identical bytes**, **reads +return signed events**, **a verdict is only ever read against the request it +answers**, and **absence has one spelling** — and argue each from the rules. + +## Scope and Non-Goals + +This document specifies the **contract between an agent and a broker host**: +the request and response envelopes, the nine operations and their argument and +outcome shapes, the result and error model, and the HTTP binding. It +deliberately does **not** specify: + +- **The host.** How a host authenticates a credential, decides authorization, + stores idempotency records, executes an operation against the relay, or + caps the depth of agent-creates-agent chains. The host is a separate + deliverable with its own specification; this document states what the host + must *accept* and *return*. +- **Signing, encryption, and relay authentication.** These are mechanisms + inside whoever holds the key. An agent says "post this message"; the host + signs. There is no operation for "sign", "publish", "encrypt", or + "authenticate to the relay", and the names are reserved against ever + appearing (§Operations). +- **Authorization grants.** There is no authorization field in any payload. + One will be added, as a discriminated object, when a real grant format and + verifier exist — not before, so that no field looks security-bearing while + enforcing nothing. +- **Secret-key custody.** How a host holds keys, and whether an agent refuses + to start when a stale local key is still present in its environment, are + decisions for the host and the harness respectively. +- **Transport confidentiality.** The credential is the whole of an agent's + authority. A host serving this protocol over anything other than a loopback + socket or TLS is publishing that authority. The requirement is stated; the + mechanism is the deployment's. + +## System Model + +Three principals: + +- **Agent** `A` — a harness process running a Buzz agent. Holds its own + **public key** and an opaque **session credential** handed to it at startup. + Holds no secret key and may have **no route to the relay at all**. Everything + it wants to do, reading included, it does by asking `H`. +- **Host** `H` — the broker. Holds the secret keys of the agents it serves + and a connection to the relay. Authenticates each request by its + credential, decides whether the requester may do what it asks, performs the + operation as an ordinary relay client, and returns a verdict. +- **Relay** `R` — the Buzz relay. Sees only `H`, behaving as any client would. + `R` does not know `A` exists and does not know `H` is a broker. + +The shape of every interaction is one round trip: + +```text +A ──── request (one operation, one idempotency key, bearer credential) ───▶ H +H: authenticate → authorize → validate → execute against R → verdict +A ◀─── response (succeeded + outcome | failed + error | indeterminate + error) ── H +``` + +Two axioms govern the whole design: + +- **(B1) Identity is public-key-only.** The only identity anywhere in this + protocol is a 64-character lowercase hex public key. No type, field, or + message carries a secret key in either direction. This makes "identity + separable from signing" structural rather than procedural. +- **(B2) The body never asserts authority.** No request names who is asking, + who owns what, or which scope applies. `H` derives all of that from the + authenticated session. A body that could name its own subject would let any + caller act as anyone. + +From B2 follow three things a reader might otherwise expect and not find: a +mentions-only read cannot ask about a *different* identity's mentions; the +profile operation has no subject field, because only the requester's own +profile is addressable; and agent creation has no owner field, because the +owner is whoever `H` authenticated. + +## Invariants + +Each invariant names the property, the rules that produce it, and what remains +honestly outside the claim. + +**(I1) No secret crosses the wire.** No payload in either direction has a +field that can carry secret key material. Every request and response type is +**strict**: an unknown member anywhere — at the top of the envelope, inside an +argument object, inside an outcome, inside each returned event — is a parse +failure, not a field that is quietly ignored. Agent creation returns the new +agent's public key, name, and channel and nothing else; the minted key stays +on `H`. The exact member set of every one of the eighteen argument and +outcome shapes is pinned by test, so a field cannot be added to any of them +without a reviewer touching the table that lists them. + +*Limits.* A string field can physically hold secret text, so keeping secrets +out of message content and error messages is host policy the contract cannot +enforce. And nothing prevents a host from *holding* keys — that is its job. +The invariant is that it cannot *hand them over* through this interface. + +**(I2) Authority comes from the session, never the body.** Axiom B2, restated +as a property of the wire: the request envelope has exactly six members +(§Request) and none of them is a requester, owner, scope, relay, or +credential. The credential travels in a transport header, opaque to the +contract. Ownership of a created agent is implicitly the requester, which is +what lets agents own agents while the chain of ownership still terminates at +a human. + +*Limits.* Binding a credential to a specific agent and conversation — so that +a leaked token cannot act as a different agent or outside the conversation it +was issued for — is `H`'s responsibility. The contract documents that +expectation and deliberately cannot enforce it, because enforcing it would +require the very scope field B2 forbids. + +**(I3) Retry means identical bytes.** A request is validated, normalized, and +serialized **exactly once**; the transport only ever sees that frozen form, +and the first attempt and every retry send the same bytes under the same +`requestId`. The durable idempotency record is keyed on the **authenticated +principal and the `requestId` together**, never on the `requestId` alone — +otherwise one caller reusing another's id would be handed the other's recorded +verdict, a cross-principal read that no authorization check would ever see. +`H` hashes the bytes it receives and compares that digest to the +one recorded under the same key: same key and same digest replays the +recorded outcome without re-running anything; same key and a different digest +is rejected as a `request_id_conflict`. Because there is no path by which a +client can re-render a request between attempts, a retry cannot drift and be +mistaken for a conflicting request. There is no client-computed digest field: +idempotency is decided host-side, and a caller-supplied digest would be a +claim `H` has to recompute anyway. + +**(I4) Reads return signed events.** A read returns the relay's own signed +Nostr events, verbatim — id, pubkey, created_at, kind, tags, content, and +signature. `A` can verify authorship and content locally, with no relay and no +key, and therefore trusts `H` only for **completeness** (that nothing was +withheld) and for **authorization**. Reply ancestry and mentions are derived +from the signed tags rather than carried as sibling fields, so nothing in a +read result can disagree with the signature. Each event object is held to the +canonical seven members and nothing else, so I1 reaches inside events too. + +*Limits.* Verification is available to `A`, not forced on it; whether to pay +for it and what to do when it fails is the caller's policy. And the trust in +completeness lands hardest on the wake path: a keyless agent polls its +mentions instead of holding a subscription, cursors are opaque and +host-issued, and pages end when `nextCursor` is absent — so a host that +silently withholds mentions from one sender is indistinguishable, to the +agent, from a quiet channel, forever. This document places that squarely on +the trust already extended to `H`: the host holds the key and could as easily +refuse to act; selective silence on reads is the same betrayal by a different +door, detectable only from outside the pair. + +**(I5) A verdict is only read against the request it answers.** The only +response a caller can obtain is one that has been checked against the request +that produced it: the `requestId` correlates; a succeeded outcome is for the +same operation that was asked; identifiers the outcome echoes (channel, +pubkey, event id) match those the request supplied, compared as parsed +identities so that spelling variants never cause a false mismatch; a read +returns no more events than the request's effective limit; and the status does +not contradict its own error code (§Results). A response that fails any of +these is **not a host verdict** — it is treated as "no answer", from which +nothing can be concluded about side effects, rather than as a failure the +caller might act on. + +*Limits.* An update or delete that targeted an agent by *name* asked for an +identity the caller cannot verify in the reply. That is inherent: `H` resolves +the name, and a rename may be the very thing the call performed. + +**(I6) Absence has one spelling.** Every optional member means "absent" by +being **omitted**. `null` is never a legal value anywhere in this protocol, at +any depth, in either direction; a member present with the value `null` makes +the whole payload malformed. The same rule covers empty collections: an +optional array member is omitted when it has no elements, and a member present +as `[]` is malformed — otherwise absence would have exactly the second +spelling this invariant exists to forbid. A key may also appear at most once in any object. +The reason is that this contract decides meaning from absence — which members +a status admits, whether a read has a default page size — so absence cannot +have two spellings without one of them slipping past a check. This rule costs +the most for implementations in languages whose serializers emit `null` for an +unset field by default; §Conformance states the consequence plainly. + +## Protocol + +### Identities and spellings + +Two identity forms appear throughout, and both admit several legal spellings +in the wild. The protocol picks one and normalizes to it at every door, so a +value is canonical whether it was constructed or parsed: + +- A **channel id** is a UUID. Canonical form is lowercase and hyphenated. A + sender may use uppercase, the unhyphenated 32-character form, braces, or a + `urn:uuid:` prefix and will be read as having sent the canonical form. +- A **public key**, **event id**, or **d-tag** is 64 hex characters. Canonical + form is lowercase. A public key must additionally be a valid secp256k1 + x-only point — 64 hex characters that name no point on the curve are + malformed, not merely useless. + +Without this, a caller's spelling of a channel and `H`'s canonical echo of the +same channel would fail I5's correlation check for a correct answer. The +correlation check *also* compares parsed identities, so neither guard is +load-bearing alone. + +### Request + +A request is a JSON object with exactly these six members, and no others. +Throughout this document `…` in an example marks a placeholder for a real +value; the three full envelope examples in §Results use values that parse. + +```json +{ + "type": "broker_request", + "protocolVersion": 1, + "requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10", + "actionVersion": 1, + "action": "message.reply", + "args": { + "channelId": "5df7dfa8-e919-43df-8efd-f1dcb8af7071", + "replyToEventId": "cacf5f811cc8ef3f4af3f92cc222f92a86cdf6a26728a144c8e63b74ab6db359", + "content": "On it.", + "mentions": ["d31d54af61c73c47053822d496ce341eac337252ee1aa7c41c9751fa3060d121"] + } +} +``` + +- `type` must be the literal `broker_request`. +- `protocolVersion` is the version of this envelope. It is **required**; there + is no "absent means 1" rule, because the protocol is unshipped and an + unknown or missing version is rejected outright. This document is version 1. +- `requestId` is the caller-chosen idempotency key, unique per logical + operation: 1–128 bytes of printable ASCII with no spaces. It becomes part of + a durable idempotency record and appears in audit logs, which is why its + character set is constrained. A request whose `requestId` is itself + unusable — missing, `null`, repeated, or malformed — is the one envelope + error a host cannot answer with a correlated verdict; whatever it returns + is uncorrelated by construction, and the caller treats it as "no answer" + (I5), never as a verdict. +- `actionVersion` is the version of the operation's argument shape the caller + wrote against. All nine operations are at version 1 in this document. +- `action` is one of the nine wire names in §Operations. +- `args` is the argument object for that operation. Its shape is fixed per + operation and strict. + +### Operations + +There are nine operations in version 1. Operations are the unit of policy: a +host can serve `channel.read` while refusing `agents.create`, and any later +policy or information-flow layer has a per-operation surface to attach to. The +cost of a closed set is that adding an operation is a change to this document +— which is the point; it makes a new capability a reviewable change rather +than a new use of an existing blank cheque. + +Throughout, string fields are trimmed of surrounding whitespace before being +frozen into the request, with three exceptions: message content publishes +exactly as written; a cursor is opaque and nothing may alter it; and a +`requestId` is never trimmed because it may not contain whitespace at all. Length limits on names, prompts, and similar fields count **characters** +(Unicode scalar values — code points, not grapheme clusters and not UTF-16 +units); limits on content, cursors, and request ids count **bytes**. + +**`channel.read`** — the one read operation. It covers a whole channel, a +single thread, or the requester's mention feed, because those differ only by +filter, and a name per scope would split one permission — *may this agent see +this channel* — across three policy decisions. + +```json +{ "channelId": "…", "rootEventId": "…", "mentionsOnly": true, "cursor": "…", "limit": 50 } +``` + +`channelId` is required. `rootEventId`, when present, narrows the read to one +thread. `mentionsOnly` defaults to false and, when true, narrows to messages +mentioning the requester — the wake path for a keyless agent, which polls this +instead of holding a subscription. `cursor` is the opaque position returned by +a previous read; absent on a first read, which starts at the host's default +window. `limit` is 1–500; absent means the host's default page of 100, **not** +unbounded, so there is always a number the response can be held to. The +outcome is a page: + +```json +{ "messages": [ { "id": "…", "pubkey": "…", "created_at": 1787675388, "kind": 9, "tags": [], "content": "…", "sig": "…" } ], + "nextCursor": "…" } +``` + +`messages` are signed events in the host's declared order (I4). `nextCursor` +is present when there is more to read and omitted when there is not; a caller +learns to stop from its absence, never by comparing a page length against a +limit it may not have set. A cursor is 1–256 bytes of printable ASCII with no +spaces, issued by `H`, validated for shape only, and **never parsed, compared, or +synthesized** by `A`. This is deliberate: a timestamp cursor cannot page +safely when more events than `limit` share one second, and it would commit +every future host to one ordering strategy. `H` owns ordering and cursor +stability, including whether a cursor survives its own restart. + +**`message.post`** — a top-level channel message. + +```json +{ "channelId": "…", "content": "…", "mentions": ["", "…"] } +``` + +`content` is required, non-empty, and at most 64 KiB. `mentions` is optional, +at most 50 public keys, and is what produces notification tags; it is +omitted when empty (I6). The outcome — shared by all four publishing operations +(`message.post`, `message.reply`, `reaction.add`, `profile.set`) — is the +published event's `eventId`, `kind`, and `createdAt`, all three host-minted: + +```json +{ "eventId": "…", "kind": 9, "createdAt": 1787675471 } +``` + +**`message.reply`** — a reply to an existing message. As `message.post`, plus +a required `replyToEventId`. Which event becomes the thread root is `H`'s +job, derived from the parent's own tags. + +**`reaction.add`** — a reaction to an existing message. + +```json +{ "channelId": "…", "targetEventId": "…", "reaction": "👍" } +``` + +`reaction` is intended to be an emoji or a `:shortcode:`; the contract checks +only that it is non-empty and at most 66 characters, and what counts as a +reaction beyond that is `H`'s to decide. This is the one **best-effort** operation in version 1: a host may answer it +`unsupported` and the agent carries on unharmed (§Results). Non-essential +signed housekeeping must be skippable so an agent can run where it is +unavailable; reactions are that housekeeping. + +**`profile.set`** — the requester's own profile metadata. No subject field +(B2). + +```json +{ "displayName": "…", "about": "…", "picture": "https://…" } +``` + +Each member is optional; at least one must be present, and absent members are +left as they are — the host does not clear them. Limits: 120 characters for +the name, 2,000 for the blurb, 300 for the picture URL. + +**`storage.address`** — derive the relay address of one encrypted-memory +record. Deriving the address needs the secret this protocol exists to keep +away from `A`, which is why it is routed through the interface rather than +computed locally. + +```json +{ "slug": "mem/slice-c" } +``` + +`slug` is `core` or a `mem/…` path in the NIP-AE grammar, at most 255 +characters (the grammar is ASCII-only, so bytes and characters coincide). +The outcome is addressing material only: + +```json +{ "authorPubkey": "…", "kind": 30174, "dTag": "…" } +``` + +The d-tag is a keyed hash of the slug, so it identifies the record without +revealing the slug or the key that derived it. + +**`agents.create`** — mint a managed agent owned by the requester. + +```json +{ "channelId": "…", "displayName": "…", "systemPrompt": "…", + "runtime": "…", "provider": "…", "model": "…", "respondTo": "owner-only" } +``` + +`channelId`, `displayName` (≤ 120 characters), and `systemPrompt` (≤ 20,000 +characters) are required. `runtime`, `provider`, and `model` are optional +short scalars (≤ 300 characters) the host interprets; a host refuses a runtime +it cannot resolve. `respondTo` is `owner-only` or `anyone`; absent means the +host's owner-only default. There is no allow-list mode, because it would need +a pubkey list this shape does not carry, and a mode without its list would +mint an agent nobody can talk to. **There is no owner field** (B2, I2). The +outcome: + +```json +{ "agentPubkey": "…", "displayName": "…", "channelId": "…" } +``` + +— public identity only. The key that was just minted is not here and has no +place to be (I1). + +**`agents.update`** — patch a managed agent the requester owns. + +```json +{ "target": { "pubkey": "…" }, "displayName": "…", "systemPrompt": "…", + "runtime": "…", "provider": "…", "model": "…", "respondTo": "anyone" } +``` + +`target` names the agent by **exactly one** of `pubkey` or `name`, so a host +never has to decide which of two selectors wins. At least one other member +must be present. The outcome reports the agent's pubkey, its name after the +update, and `updatedFields`, in which `H` lists the wire names of the members +it actually changed, sorted. The client holds `H` to that only by convention — +it checks the name's shape and nothing about the list. + +**`agents.delete`** — remove a managed agent the requester owns. Arguments +are the `target` selector alone; the outcome is the removed agent's pubkey and +name. + +**Reserved against.** `sign`, `publish`, `encrypt`, `decrypt`, and relay +authentication are **not operations and will not become operations**. A +signing primitive would make the broker an oracle that can tell *who* is +asking but not *what for*, and the only policies it could express would be +"all" and "none". Naming intent — `message.post` — gives `H` something to +reason about; naming a mechanism — `publish(event)` — does not. A test pins +that none of those names resolve. + +**Deferred, not refused.** `presence.set` and `typing.set` are not in version +1. They are housekeeping a host may decline anyway, and adding them later is +purely additive. Streaming reads are also deferred: reads are +request/response, and waking on a mention is a polled mentions-only read. + +### Results and errors + +A response is a JSON object with one of three shapes, discriminated by +`status`: + +```json +{ "type": "broker_result", "protocolVersion": 1, "requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10", + "status": "succeeded", "action": "message.reply", + "outcome": { "eventId": "5ff391bf2c6d4e8a9b7c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a", + "kind": 9, "createdAt": 1787675471 } } +``` + +```json +{ "type": "broker_result", "protocolVersion": 1, "requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10", + "status": "failed", + "error": { "code": "unauthorized", "message": "agents.create is not permitted for this session" } } +``` + +```json +{ "type": "broker_result", "protocolVersion": 1, "requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10", + "status": "indeterminate", + "error": { "code": "outcome_unknown", "message": "relay connection dropped after publish was attempted" } } +``` + +A `succeeded` response carries `action` and `outcome` and **must not** carry +`error`. A `failed` or `indeterminate` response carries `error` and **must +not** carry `action` or `outcome`. These are parse failures, not warnings: +"succeeded with an error" and "failed with an outcome" are unrepresentable, +and the strict reader applies I6 here too, so `"outcome": null` beside a +failure does not parse as a plain failure and skip the check. + +An optional `replayed: true` marks a response that replays a previously +recorded outcome under I3. It is delivery metadata, not part of the stored +verdict, and a replayed response is byte-identical in its result to the +original. It is omitted when false. + +The three statuses make three different promises. **`succeeded`** means the +operation completed and the outcome describes what happened. **`failed`** +promises that **no side effects took hold** — the caller may treat the +operation as never having happened. **`indeterminate`** promises **nothing** +and demands reconciliation: the caller must retry the identical bytes (which +`H` will deduplicate) or read state to find out. + +Because a status and an error code are two statements about the same fact — +whether side effects landed — they cannot be paired freely. The complete +table: + +| Code | Meaning | with `failed` | with `indeterminate` | +|---|---|---|---| +| `invalid_request` | envelope or arguments failed validation | yes | no | +| `unsupported_protocol_version` | `protocolVersion` not supported by this host | yes | no | +| `unknown_action` | action name unknown to this host | yes | no | +| `unsupported_action_version` | `actionVersion` not supported for this action | yes | no | +| `unsupported` | host knows the action but does not offer it | yes | no | +| `unauthenticated` | credential missing, malformed, or rejected | yes | no | +| `unauthorized` | authenticated, but not permitted this action | yes | no | +| `request_id_conflict` | `requestId` reused with different bytes | yes | no | +| `action_failed` | the action ran and reported a domain failure | yes | no | +| `outcome_unknown` | host cannot tell whether side effects occurred | no | yes | +| `internal` | unexpected host fault | yes | yes | + +Every code but two names a fate `H` *knows*, so those are `failed`-only. +`outcome_unknown` is the code for not knowing, so it is `indeterminate`-only. +`internal` is the one code legitimately either: a fault before dispatch is a +known no-op, and the same fault mid-execution genuinely is not. A response +pairing a code with a status the table forbids is malformed (I5). + +Two codes deserve a sentence each. **`unauthenticated` is a host verdict, +delivered as `failed`**, never as a transport error — it carries the promise +every `failed` carries, that the action did not run. Modelling it as a +transport error would throw that knowledge away and tell the caller to +reconcile something that provably never happened. **`unsupported` is a normal +answer for a best-effort operation** (`reaction.add` in version 1) and the +agent carries on; for any other operation it means the agent cannot do its job +on this host. + +Error messages are for operators and must never carry secrets — no keys, no +credentials, no decrypted payloads. + +### HTTP binding + +```http +POST /v1/action +Authorization: Bearer +Content-Type: application/json + + +``` + +One endpoint. The body is the request exactly as frozen under I3. The +credential is a bearer token `A` received at startup and can only replay; it +is not a key, not a signature, and not derived from anything `A` holds. It is +opaque to this contract. + +**Every verdict `H` reached comes back as a well-formed envelope with HTTP +200** — including `failed`, and including a rejected credential. The verdict +lives in `status`; a second copy of it in the status line could only ever +disagree with the first. A client must nonetheless **attempt to parse an +envelope regardless of HTTP status**, because a host or an intermediary may +map dispositions onto conventional statuses for observability or for +middleware that cannot read bodies. If a valid envelope is present, it is the +answer and the status line is decoration. + +Only when **no envelope can be parsed** does the status matter, and then only +as detail for an operator. A proxy's 401, a 404 for a missing route, a 502, +a connection that dropped mid-request, or a body that claimed to be an +envelope and was not — all of these are **transport failures**: the request's +fate is unknown, nothing can be concluded about side effects, and the only +safe next step is to retry the identical bytes or reconcile by reading state. +An intermediary's 401 does not prove `H` never ran the action. Host verdicts +never appear as transport failures, and transport failures are never promoted +to verdicts. + +## Conformance + +### [C1] Client conformance — any agent-side implementation + +- Sends only the nine operations named in §Operations; never attempts a + reserved name. +- Validates and normalizes a request, serializes it once, and sends those + bytes on the first attempt and every retry under the same `requestId` (I3). +- Never places a requester, owner, scope, relay, or credential in a body + (I2). The credential travels only in the `Authorization` header. +- Treats a cursor as opaque: round-trips it verbatim, never parses, compares, + or synthesizes one. +- Attempts to parse an envelope from every HTTP response regardless of + status; treats a parseable envelope as the answer and an unparseable one as + a transport failure, never as a verdict. +- Reads a response only after checking it against the request: matching + `requestId`, matching operation on success, matching echoed identities + compared as parsed values, page size within the effective limit, and a + status/code pairing the table permits (I5). A response failing any check + is "no answer". +- Treats `failed` as "nothing happened", `indeterminate` as "reconcile", and + `unsupported` on a best-effort operation as a normal answer. +- Omits every absent optional member; never emits `null`; never repeats a + key (I6). +- May verify each returned event's id and signature locally (I4); decides + its own policy when verification fails. + +### [C2] Host conformance — any broker host + +- Accepts only the request envelope in §Request with exactly its six + members; rejects an unknown member anywhere, a `null` anywhere, a repeated + key, an unknown `protocolVersion`, an unknown action, or an unsupported + `actionVersion` — each as `failed` with the matching code. +- Derives requester, owner, and scope from the authenticated session and + nothing else (I2). Binds each credential to the agent and conversation it + was issued for. +- Records, per authenticated principal and `requestId`, a digest of the + received bytes and the verdict + reached; replays the recorded verdict with `replayed: true` on a matching + digest and answers `request_id_conflict` on a differing one (I3). +- Returns read results as the relay's signed events with exactly the + canonical seven members each; never a projection, never an extra member + (I1, I4). Honors `limit` and the default page of 100 when it is absent; + omits `nextCursor` when there is nothing further. +- Returns public identity only from `agents.create`; never places key + material, a credential, or a decrypted payload in any outcome or error + message (I1). +- Pairs status and code only as the table in §Results permits. Never answers + a rejected credential with anything but `failed` + `unauthenticated`. +- Returns every verdict as an envelope with HTTP 200; may additionally map + statuses for intermediaries but never relies on the status line to carry + the verdict. +- Omits every absent optional member; never emits `null` (I6). Implementers + in Go must set `omitempty` on optional members; in Python must drop + `None`-valued keys before serializing; in any language must not assign + optional keys unconditionally. +- Serves the endpoint only over loopback or TLS. + +## Relationship to the Implementation + +The reference implementation of this contract is the broker module in Buzz's +shared client library. It is a **contract only** — the +request and response shapes, the validators that enforce the rules above, and +a client interface whose only implementation is a test double. It contains no +host, no transport, and no signing. The work that follows it adds a host and +then wires the harness to choose between holding a key locally and delegating +to a broker; those land as separate changes and, where they change the wire, +amend this document in the same change. + +Where this document and the implementation disagree, the implementation is +wrong and this document is to be amended if the disagreement was intentional. + +## Open Decisions + +- **Best-effort set.** Version 1 marks only `reaction.add` best-effort. + Whether `profile.set` should join it — an agent can function with a stale + profile — is a policy call that affects which hosts an agent can run + against. Current position: no; a profile is identity-adjacent and a host + that refuses it should say so loudly. +- **Stale local key.** Should an agent configured for a broker tolerate, warn + about, or refuse a secret key still present in its environment? This is + harness behavior, not wire, and is left to the harness change that follows; + the contract is silent on purpose. +- **Grant format.** When an authorization field is added (§Non-Goals), it + will be a discriminated object with a verifier on `H`. Its shape is not + designed here. +- **Depth of ownership chains.** Agents may own agents (I2). Bounding the + depth is `H`'s policy; whether the contract should expose a code that + distinguishes "depth exceeded" from a generic `unauthorized` is open. + +## Summary + +A keyless agent holds a public key and a bearer credential and asks a broker +host to perform one of nine named operations on its behalf. The body of a +request never says who is asking; the host's authentication of the session +decides that. Requests are frozen to bytes once and retried verbatim, so +idempotency is a property of the bytes rather than a promise. Reads return +signed events the agent can verify itself. A response is only ever read +against the request it answers, and a response that fails that check is no +answer at all. Nothing optional is ever `null`. No secret, in any direction, +has a field to travel in. The relay never learns any of this happened. +