mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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:
613
docs/agent-broker.md
Normal file
613
docs/agent-broker.md
Normal 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
|
||||
Reference in New Issue
Block a user