Skip to content

broker: trusted-operation broker foundation with agents.* as its first capability - #6543

Open
baxen wants to merge 5 commits into
mainfrom
ss-dev-00/broker-foundation
Open

broker: trusted-operation broker foundation with agents.* as its first capability#6543
baxen wants to merge 5 commits into
mainfrom
ss-dev-00/broker-foundation

Conversation

@baxen

@baxen baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

What this is

The start of a general Buzz trusted-operation broker: a way for a requester who cannot hold the owner's credentials to ask the owner's host to perform a small set of named business operations. Agent CRUD (agents.create, agents.update, agents.delete) is its first capability, not its purpose.

This replaces the earlier CRUD-specific agent_crud_request protocol. That approach made agent management a one-off Desktop integration; every future trusted operation would have re-litigated authentication, idempotency, and result delivery. Here the broker core owns those once and capabilities plug into it.

Desktop is host #1, behind an extractable host/handler boundary — a hosted authority can replace it without the wire protocol changing.

Scope

In: the wire envelope (crates/buzz-sdk/src/broker/), the host pipeline, durable idempotency, the authorization seam, the agents.* capability family, transport ingress, and result delivery.

Deliberately out (PR 2): the broker client and buzz agents list|get|create|update|delete, structured CLI errors, and tool/skill/prompt guidance. This PR contains no end-user CLI or prompt wiring — nothing here is reachable by a user typing a command yet, which is what keeps it reviewable as a security boundary rather than as a feature.

What the broker will never do

Only business capabilities. Never signing on a requester's behalf, never publishing arbitrary events, never credential access, never arbitrary tool or command execution. The capability enum is closed, and BrokerErrorCode::UnknownCapability is the answer to anything outside it.

The design decisions worth reviewing

A request cannot name its own authority. Owner, requester, and relay scope are absent from the envelope and derived from the verified transport frame. A payload with an owner field is a payload that can pick its own owner.

No authorization field yet. There is no grant format and no verifier, so shipping a security-looking field that enforces nothing would be worse than shipping none. It arrives with its verifier.

Authorize before claiming. A refusal leaves no durable record, so an unauthorized requester cannot poison a requestId for the legitimate one.

Idempotency is host-side, over the exact received bytes. No client-computed digest and no canonical encoding to agree on. Same (owner, requester, capability, requestId) + same digest replays the recorded outcome; same key + different digest is refused as a conflict rather than being answered with someone else's result. The digest is compared before state for that reason.

Three outcomes, not two. Succeeded { outcome } | Failed { error } | Indeterminate { error } as a discriminated union, so "succeeded with an error" is unrepresentable. Failed promises no side effects; Indeterminate promises nothing. An interrupted (executing) row becomes indeterminate and is never auto-retried — an ephemeral timeout is not evidence that nothing ran, and treating it as such is how you create two agents.

The credential boundary is one stack frame. create_managed_agent returns the minted nsec; the outcome type has no field that could carry it, so it cannot reach the pipeline, the execution log, or the wire.

Requester authentication reads the authoritative roster, never caller input: the requester must be one of this owner's managed agents. The owner's own key is explicitly not a valid requester. An unreadable roster fails closed.

agents.* scope policy lives with the capability family, not in broker core, so future capabilities do not inherit it.

Known limitation

Desktop-offline execution is unsupported. Requests arrive as kind-24200 frames, which the relay routes to connected subscribers without storing. No queue holds a request for an offline host; the requester's wait expires. This is a deliberate limit of the transport, documented in broker/mod.rs, not an oversight — a durable-delivery capability would need its own transport.

Verification

At 1938c1dbb:

  • 2916 desktop Tauri Rust tests pass (74 broker, 18 ingress)
  • 5311 desktop TS tests pass
  • workspace clippy clean, fmt-check + desktop-tauri-fmt-check clean, typecheck clean, file-size caps pass
  • all pre-push gates green

One caveat, stated plainly: cargo test --workspace has two failures in buzz-pair-relay's integration tests (test_120s_timeout, test_cancellation_immediate). These are a pre-existing timing flake, not a regression here — they reproduce on clean origin/main at 074561233, they vary run to run at a fixed tree (1, 1, 1, 0, 2 failures across five identical runs), and buzz-pair-relay has no dependency on buzz-core or buzz-sdk and is untouched by this branch.

Review focus

Where the trust boundary sits. Specifically: that nothing above ingress::verify_frame can be told who is asking, that a verification failure produces no signed output at all (a signed refusal aimed at an unverified sender would make every host a signing oracle), and that the digest-before-state ordering in store::claim really does make a conflicting retry unable to receive another request's result.

ss-dev-00 and others added 5 commits August 21, 2026 19:55
Adds the request/result envelope for the Buzz trusted-operation broker,
with agent CRUD as its first capability. This is protocol and validation
only -- no host, no dispatch, no execution.

The envelope is deliberately minimal:

  { type, protocolVersion, requestId, capabilityVersion, capability, args }

It carries no owner, requester, or relay identity. Those are derived on
the host from the verified frame, because a request body that could name
its own owner would let any signer act on another owner's agents. There
is no `context` and no `authorization` field yet: a field that looks
security-bearing while enforcing nothing is worse than its absence. One
gets added, as a discriminated object, when a real grant format and
verifier exist.

There is no client-computed digest. Idempotency is decided host-side, so
only the host needs the hash -- shipping a second canonicalizer in a
second language would freeze two implementations that must agree
byte-for-byte forever. Retrying means resending identical bytes.

Results are a three-variant discriminated union so invalid combinations
are unrepresentable rather than merely discouraged:

  Succeeded { outcome } | Failed { error } | Indeterminate { error }

Indeterminate is distinct from Failed on purpose. Failed promises no side
effects took hold; Indeterminate promises nothing and demands
reconciliation.

Capabilities stay three separate names (agents.create/update/delete)
rather than one agents.manage action union, so a host can permit one
without permitting the others. Only business operations are addressable:
signing, publishing, credential access, and tool execution are not
capabilities and cannot be named.

channelId appears only on create, where attachment genuinely needs it.
Update and delete identify their target by the agent itself.

Every args type is deny_unknown_fields, so a smuggled api key or nsec
fails to deserialize instead of reaching a mutation, and outcome types
can structurally hold only public identifiers.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
The trusted path lives in one Rust function:

  verified frame -> authorize -> validate -> claim -> dispatch -> complete

It is a single function rather than coordinated steps because every stage
boundary is somewhere a caller could otherwise substitute its own answer
for "who is asking" or "has this already run", and a crash between two
coordinated steps is a side effect nobody recorded.

Identity arrives as VerifiedRequest, whose construction asserts that
owner, requester, and relay scope came from the verified transport.
Nothing in the request body can influence them, so a signer cannot name
someone else as owner. A payload that tries fails to deserialize.

The digest is computed here, over the exact decrypted bytes, after a size
bound. No canonical encoding has to be agreed on and no second
implementation can drift from this one.

The execution log is keyed by
(relay_scope, owner, requester, capability, request_id) and inserts
`executing` before the first side effect. Terminal rows replay; a
digest mismatch is a conflict, checked before state so a conflicting
retry can never be answered with the first request's result; an existing
`executing` row becomes `indeterminate` and is never blindly re-executed.
Claim runs in an IMMEDIATE transaction -- a test races eight threads to
show exactly one winner.

This guarantees at-most-once plus indeterminate. Not exactly-once, and
not durable completion: the log can prove execution started, never how
far it got. Reconciling partial effects belongs to the capability that
knows its own phases.

Authorization is split from authentication. The broker proves who signed;
whether that signer may manage agents is an agents.* scope rule, so a
future capability cannot inherit it. The rule is that the requester must
be one of this owner's managed agents, read from the authoritative Rust
roster. It fails closed when the roster cannot be read, and refuses the
owner key itself as a requester.

Per the approved design, there is no channel restriction on the target of
an update or delete: an owner's agent is theirs regardless of channel,
and requiring co-membership would look tighter than it is while adding no
real constraint. Channel appears only on create, where attachment needs
it.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
The three capabilities dispatch to an AgentService trait rather than to
the Tauri commands directly. Those commands need an AppHandle and a
State, so depending on them here would make every handler untestable
without booting an app and would entangle the capability layer with the
desktop runtime. Behind the seam, handlers are pure and the tests below
drive the real pipeline against a fake.

The seams are async because the mutations they will call are: minting a
key, writing the agent store, and publishing to the relay all await.
That forces ExecutionLog to be a trait instead of a borrowed
rusqlite::Connection, which is !Sync and must not be held across an
.await. The production adapter confines each connection to one blocking
task; the test double serializes behind a Mutex. Cross-call exclusion is
SQLite's either way, since claim() does its read and insert inside a
single IMMEDIATE transaction.

ServiceError distinguishes Failed from Unknown. Failed promises no side
effects persisted; Unknown promises nothing and becomes indeterminate. A
service that mutated and then lost track must say Unknown, because
reporting a clean failure would invite a retry of something that may
already have happened.

Nothing in AgentService returns secret material: a create reports the new
agent's pubkey, never its nsec. A test serializes a create response and
asserts the absence of nsec, privateKey, private_key, and secret, so the
guarantee is checked rather than asserted in a comment.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Implements `AgentService` and `AgentRoster` against the same commands the UI
calls — `create_managed_agent`, `update_managed_agent`,
`delete_managed_agent` — rather than reimplementing the mutations. Two agent
creation routines would drift, and the broker's would be the one nobody looks
at.

The credential boundary is narrowed here: `create_managed_agent` returns the
minted `private_key_nsec`, and this is the one stack frame that sees it. The
outcome type has no field that could carry it, so the secret cannot reach the
pipeline, the execution log, or the wire.

Request translation is pure and tested directly, because the policy it encodes
is worth pinning:

- A brokered create neither spawns a process nor sets `start_on_app_launch`. A
  remote requester asking for an agent to exist has not asked for a local
  process to run on a schedule nobody at the keyboard chose.
- It links no definition or team — that would pin someone else's config
  snapshot onto the new agent, a decision the request never made.
- An update sends `None` for every field the request did not name. The broker
  has no "clear to default" verb, so it never emits the explicit null that
  would wipe stored config.
- A requested runtime is resolved through the catalog before it can become a
  stored harness pin, and an unknown id fails here instead of surfacing later
  as an unrelated spawn error.
- A delete leaves `force_remote_delete` unset, so the deployed-remote guard
  still holds: orphaning provisioned infrastructure stays a human decision.

Channel attachment is its own failable step after the agent exists, so it maps
to `Unknown` rather than `Failed` — telling a requester nothing happened while
an agent sits in the roster would be the worse lie.

Name targets resolve case-insensitively but must be unambiguous. Two agents
can legitimately share a name, and guessing would mutate or delete an agent
the requester never identified.

The roster refuses to answer for an owner this host does not hold keys for,
rather than authorizing against the wrong agent set.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Closes the loop from a signed relay event to a signed result frame. The
pipeline was already testable in isolation; this attaches the transport and
the host's real parts, so nothing above the ingress layer learns what Tauri
or the relay look like.

`ingress::verify_frame` derives every trusted field from the frame rather
than from anything a caller could assert. Owner, requester, and relay scope
are transport-derived, which is why the wire envelope has no field for them:
a payload that could name its own owner would be a payload that could pick
its own authority. Verification refuses duplicate routing tags — a frame with
two `agent` tags has two readings, and the one this host picks would not have
to match the one the relay routed on. Freshness is re-checked here rather
than trusted from the relay, because bounding replay of an old signed frame
is not a hop this host controls.

A frame that fails verification gets no answer at all. Emitting a signed
refusal at an unverified sender would make any host a signing oracle aimed at
a target of the sender's choosing. Frames that are simply not broker traffic —
the overwhelming majority, since ordinary telemetry arrives on this same
subscription — are ignored cheaply.

`host.rs` keeps the command a transport hand-off rather than an authority
hand-off: it takes a raw signed event and nothing else, so the renderer cannot
say who is asking or which owner to act as. The return value is deliberately
thin, since the requester's real answer travels in a signed frame; reporting
an outcome through IPC would make the reply path look like a return value it
is not. Results publish over the relay WebSocket because kind-24200 is
rejected on the HTTP bridge, and as the owner's own identity with no NIP-OA
auth tag — an auth tag is how a managed agent proves owner backing, and the
owner is not one.

On the TypeScript side the renderer forwards the *signed event*, never the
decrypted plaintext, so the host verifies and decrypts independently. The
routing check is shallow on purpose: it decides only whether a payload claims
to be a broker request, not whether it is valid, so an ill-formed request
still reaches the host and earns a structured refusal instead of silence. A
broker request also returns before the session journal, because a payload with
no seq, timestamp, or kind must not be filed as a transcript entry.
Forwarding is fault-isolated: a broker host fault is not an observer transport
fault, and must not be reported as one or tear down telemetry for every agent
over a single failed mutation.

`VerifiedRequest` gets a hand-written `Debug` that redacts the payload to a
byte count. Decrypted request content includes system prompts, and a derived
`Debug` would put them into any error or test failure that formats a request.

Verified at this tree: 2916 desktop Tauri Rust tests (74 broker, 18 ingress),
5311 desktop TS tests, workspace clippy and both fmt gates clean. The two
`buzz-pair-relay` integration failures are a pre-existing timing flake,
reproduced on clean origin/main at 0745612 and independent of these crates.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen requested a review from a team as a code owner August 22, 2026 03:00

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1938c1dbbd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +68 to +70
.await
.map_err(|error| format!("failed to publish broker result: {error}"))?;
Ok(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject negative relay acknowledgements

When the relay responds with a valid NIP-01 OK carrying accepted: false—for example because the owner-agent binding is stale or the frame is rejected by relay policy—buzz_ws_client_pkg::publish_event still returns Ok(OkResponse), and this code discards accepted and reports successful delivery. The mutation has already executed, but the requester receives no result and resultDelivered is incorrectly true; retries merely replay the result through the same rejected path. Inspect OkResponse::accepted and convert a negative acknowledgement into a delivery error.

Useful? React with 👍 / 👎.

Comment on lines +299 to +305
let response = crate::commands::update_managed_agent(
request,
self.app.clone(),
self.app.state::<AppState>(),
)
.await
.map_err(ServiceError::Failed)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark post-commit update errors indeterminate

When update_managed_agent returns an error after committing part of an update, this conversion records a terminal Failed result even though that status promises no persistent side effects. A concrete case is changing respondTo on a running local agent: agent_models_update.rs saves and publishes the new policy, then returns Err if restarting the runtime fails, leaving the policy changed. The broker will persist and replay a clean failure, misleading the requester and preventing reconciliation; post-commit command errors must map to ServiceError::Unknown or otherwise expose whether the mutation committed.

Useful? React with 👍 / 👎.

if (isBrokerRequestPayload(parsed)) {
// Isolated: a broker host fault must not be reported as — or tear down —
// the observer telemetry subscription. See forwardBrokerFrameIsolated.
await forwardBrokerFrameIsolated(event);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind forwarded frames to their source community

If a community switch begins after the generation check but while this awaited IPC call is starting, resetting eventProcessingQueue does not cancel the already-running handler. The Rust command then re-reads the newly active relay URL, so a frame received from community A can be authorized against the global managed-agent roster and executed/logged/published in community B; agents.create can even create the local record before its A channel ID fails attachment on B. Pass the subscription's captured relay scope to the host and fail closed if it no longer matches the active workspace, or recheck cancellation before execution.

AGENTS.md reference: AGENTS.md:L537-L540

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant