docs: specify the agent broker contract

A standalone specification of the keyless-agent broker protocol from
#6467, written for someone implementing a host or a client who will
never open the Rust crate: system model, six invariants, the nine
operations with their wire shapes, the result and error model, the HTTP
binding, and conformance checklists for both sides. Draft status until
approved; the implementation in #6742 is held until then.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: ss-core-02 <cca2c695c61d409321106cc399009c2cc674d72cc8900b2999a68fe54577a411@buzz.block.builderlab.xyz>
This commit is contained in:
ss-core-02
2026-08-25 10:05:49 -07:00
parent 113a33b7e4
commit ec402895ca

613
docs/agent-broker.md Normal file
View File

@@ -0,0 +1,613 @@
# 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 is the interface half of [#6467][6467], which asks that an agent's
identity be separable from its signing key so the key can stay 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 is #6467's
"identity separable from signing" requirement made 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 five 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`. `H` hashes the bytes it receives and compares that digest to the
one recorded under the same `requestId`: 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.
**(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. 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.
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 members, and no others:
```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.
- `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 other than message content and cursors are trimmed
of surrounding whitespace before being frozen into the request. Message
content publishes exactly as written. A cursor is opaque and nothing may alter
it. Length limits on names, prompts, and similar fields count **characters**;
limits on content, cursors, request ids, and storage slugs 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, 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": ["<pubkey>", "…"] }
```
`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 may be
omitted when empty.
**`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 an emoji or a `:shortcode:`, non-empty, at most 66 characters.
This is the one **best-effort** operation in version 1: a host may answer it
`unsupported` and the agent carries on unharmed (§Results). #6467 asks that
non-essential signed housekeeping 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 #6467 lists it among the operations to route
through the interface rather than compute locally.
```json
{ "slug": "mem/slice-c" }
```
`slug` is `core` or a `mem/…` path in the NIP-AE grammar, at most 255 bytes.
The outcome is addressing material only:
```json
{ "authorPubkey": "…", "kind": 30078, "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`: the sorted wire names of the members `H`
actually changed.
**`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": "…",
"status": "succeeded", "action": "message.reply",
"outcome": { "eventId": "…", "kind": 9, "createdAt": 1787675471 } }
```
```json
{ "type": "broker_result", "protocolVersion": 1, "requestId": "…",
"status": "failed",
"error": { "code": "unauthorized", "message": "agents.create is not permitted for this session" } }
```
```json
{ "type": "broker_result", "protocolVersion": 1, "requestId": "…",
"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 <opaque session credential>
Content-Type: application/json
<the frozen request body, verbatim>
```
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 five
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 `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, introduced in [block/buzz#6742][6742] (with its test
file split mechanically in [#6759][6759]). 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 stack that follows it adds a host and
then wires the harness to choose between holding a key locally and delegating
to a broker; those are separate pull requests 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.** #6467 asks whether an agent configured for a broker
should 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 in the stack; 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.
[6467]: https://github.com/block/buzz/issues/6467
[6742]: https://github.com/block/buzz/pull/6742
[6759]: https://github.com/block/buzz/pull/6759