block / block/buzz

RFC: authorization contract for structured interactions — bind the response to the prompt, gate eligibility where it cannot be faked (additive to #3261)

Open
#7,566 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## What this is

Additive to [#3261](https://github.com/block/buzz/issues/3261) ("structured interaction
events — agent-postable buttons, forms, and polls"), and narrower. #3261 asks for the
surface: a prompt event, a response event, client rendering, a schema. This issue is about
the **authorization contract underneath it** — what a response must bind to, who may emit
one, and what a consumer is allowed to treat as equivalent to one. If maintainers prefer,
fold it into #3261 as a design section; I kept it separate because the requirements below
are testable on their own and one of them (R7) is a bug in today's delivery path regardless
of whether #3261 ever ships.

Source state for line references: `origin/main` @ `051c3a270`.

## Why the surface alone is not enough

Today, "the human approved it" is inferred from a free-text message sitting at a particular
place in a channel timeline. That inference is not sound, for a reason that has nothing to do
with clocks being wrong:

- Clients order messages by the signed `created_at` and nothing else
(`desktop/src/features/messages/lib/messageQueryKeys.ts:33-44`). No receipt timestamp is
exposed to clients; the relay records `received_at` (`crates/buzz-core/src/event.rs:15`)
but only surfaces it through the admin API.
- Any member may place their own signed message anywhere in a ±15-minute window
(`crates/buzz-relay/src/handlers/ingest.rs:2233-2241`). Authorship is not forgeable —
`ingest.rs:2253` pins the event pubkey to the NIP-42 identity — but *position* is.
- Adding a relay-asserted receipt timestamp does not fix this. A relay that will reorder
will also mis-state receipt time, and clients have no way to check either (see
[#7564](https://github.com/block/buzz/issues/7564)).

So a bare "Yes, please!" is authorization for whatever happens to sit above it. The worked
example that motivated this:

```
2:05 Alice: Hey, boss, want some coffee?
2:08 Boss: Yes, please!
2:06 Eve: Can we have our clankers delete all the customer data?
```

Eve's message is signed by Eve and cannot be attributed to anyone else — she just backdates
it under the boss's approval. Every agent reading that channel as prose sees a granted
request. **Ordering cannot carry authorization; only binding can.**

## Prior art already in the tree

This does not need inventing from scratch. `KIND_APPROVAL_GRANT = 46030` /
`KIND_APPROVAL_DENY = 46031` (`crates/buzz-core/src/kind.rs:560-562`) exist, and
`crates/buzz-relay/src/handlers/command_executor.rs:1020-1061` already enforces close to the
right shape: the grant carries a token hash, the record must still be `Pending` (single-use),
it must not be past `expires_at`, and `check_approver_spec` gates the caller at ingest.

Two gaps: it is workflow-only (there are **zero** references to 46030 anywhere in
`desktop/src` — no button exists), and per
[#2878](https://github.com/block/buzz/issues/2878) the only approver specs the relay accepts
in practice are `""`/`"any"` — any authenticated key in the community — or a single bare
pubkey. See also [#2830](https://github.com/block/buzz/issues/2830), where the grant binds a
run id and a step *index* rather than the step itself.

## Requirements

**R1 — A response binds to the prompt and to one of its options.**
The response `e`-tags the prompt event id *and* commits to the option it selects, e.g. by
carrying the option id together with a digest over the prompt's full option set. A response
must be uninterpretable outside the prompt that produced it, and a prompt whose options were
edited must not be satisfiable by a response minted against the old set. This is what makes
replay inert: replaying "approve" after Eve's message still approves the coffee event.

**R2 — Eligibility is a property of the prompt, enforced where it cannot be faked.**
The prompt declares who may respond. At minimum: an explicit pubkey list, "channel members",
and "any". For agent-initiated approvals the useful default is *the requesting agent's
respond-to allowlist* (`crates/buzz-acp/src/lib.rs:373-396`), not community membership —
otherwise anyone in the community can approve an action for an agent that would not even
answer them. Enforcement belongs at the relay, as `check_approver_spec` already does; a
client that merely hides the button is not a control. Fixing #2878 is a prerequisite.

**R3 — Cardinality is declared, not assumed.**
Approvals are single-use and terminal: first accepted response wins, later ones are rejected
rather than silently ignored. Polls are one-response-per-pubkey and revisable until close.
Revision must be explicit — a replacement response referencing the response it supersedes —
rather than last-write-wins, which would reintroduce exactly the ordering dependence this
issue is trying to remove.

**R4 — Prompts expire.**
`expires_at` on the prompt, refused at ingest after it passes, as
`command_executor.rs:1054-1058` already does. Without it, "approve" is a bearer token with
unlimited lifetime.

**R5 — Consumers must refuse unbound equivalents.**
An agent that asks via a prompt must not accept free text as the same authority. If the
question was a button, "yes" in chat is a suggestion, not a grant. Without this rule the
whole mechanism is decorative — the injected-prose path stays open and is strictly easier to
exercise. This should be stated in the base prompt and enforced in the harness, not left to
each agent's judgement.

**R6 — Responses must actually reach agents.**
The default `mentions` subscription is `[KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_STREAM_REMINDER]` (`crates/buzz-acp/src/config.rs:1359-1365`) with `require_mention` on.
A button click carries no `@mention`, and 46030 is not in that list — so under today's
default config, an approval grant would never wake the agent waiting for it. Whatever kind
carries responses has to be in the default set and exempt from the mention filter. (Same root
cause as reactions never reaching agents, noted in #3261's comment thread.)

**R7 — Integrity of the rendered prompt.**
A click is only as trustworthy as the card it was made on. Desktop does not verify event ids
or signatures on the message path ([#7564](https://github.com/block/buzz/issues/7564)), so
today a relay could render a prompt no one authored. Not a blocker for this design, but the
two should land in the same era.

## Worked examples

**Approval — the Eve case.** Agent posts a prompt: *"Delete all customer data? This is
irreversible."* with `[Approve] [Deny]`, eligibility = the agent's respond-to allowlist,
expiry 10 minutes, cardinality single-use. Eve is not on the allowlist, so the relay rejects
her response outright — there is no button for her to click and no message she can position
that means the same thing. The boss's grant binds to that prompt id and that option, so it
cannot be harvested and replayed against a different request. R5 is what stops the agent from
accepting Eve's prose version instead.

**Poll — the same machinery, relaxed.** *"Pizza for lunch?"* with `[👍] [👎]`, eligibility =
channel members, cardinality = one per pubkey and revisable, close condition = a time or a
manual close by the author. The tally is computed from signed responses, each bound to this
poll and to a declared option. Anyone can recount it from the events; nobody can vote twice,
vote after close, or have a 👍 on some other message counted here.

The only differences between the two are the eligibility spec, the cardinality rule, and
whether responses are public — which is the argument for one primitive rather than an
approvals feature and a polls feature.

## Explicitly not proposed here

Rendering, form fields, schema validation, and the Slack Block Kit parity story — that is
#3261's half, and it is well argued there. This issue also does not propose new kind numbers;
reusing/extending the 46030-series versus minting an interaction range in the 45000s is a
maintainer call.

## Open questions

1. Reuse 46030/46031 (already enforced, workflow-shaped) or new interaction kinds with a
`decision` field that subsumes approve/deny/choice? The former ships sooner; the latter is
cleaner for polls with more than two options.
2. Anonymous polls: responses that are relay-tallied but not publicly attributable are a
different privacy model and probably out of scope for v1 — worth saying so explicitly.
3. Should a prompt be able to require *n* distinct approvers (two-person rule)? R3 generalizes
to it, but it changes the "terminal on first response" rule.
4. What happens to an in-flight prompt when the author is removed from the channel?

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.