Files
buzz/docs/agent-broker.md
ss-core-02 c087a3edbb docs(agent-broker): take seven review points
- Key the idempotency record on (authenticated principal, requestId),
  never requestId alone (I3, C2)
- Rule empty arrays a second spelling of absence: optional arrays are
  omitted when empty, a present [] is malformed (I6)
- State the shared publish outcome (eventId/kind/createdAt) once at
  message.post
- Define the no-answer treatment for a request whose requestId is
  itself unusable
- Name the unit behind 'characters' (Unicode scalar values)
- Correct the storage.address example kind to the agent-memory kind
- Say out loud that read completeness on the wake path is trusted, not
  verifiable: silent mention withholding is indistinguishable from quiet

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-26 09:18:25 -07:00

33 KiB
Raw Blame History

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:

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.

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.

{
  "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.

{ "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:

{ "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.

{ "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 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:

{ "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.

{ "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).

{ "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.

{ "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:

{ "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.

{ "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:

{ "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.

{ "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:

{ "type": "broker_result", "protocolVersion": 1, "requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10",
  "status": "succeeded", "action": "message.reply",
  "outcome": { "eventId": "5ff391bf2c6d4e8a9b7c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a",
               "kind": 9, "createdAt": 1787675471 } }
{ "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" } }
{ "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

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 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.