diff --git a/workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md b/workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md new file mode 100644 index 0000000..9ee1a86 --- /dev/null +++ b/workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md @@ -0,0 +1,287 @@ +--- +id: STATE-WP-0093 +type: workplan +title: "Per-recipient broadcast receipts and standing notices" +domain: infotech +repo: state-hub +status: proposed +owner: claude +topic_slug: infotech +flavor: implementation +created: "2026-09-21" +updated: "2026-09-21" +related: + - STATE-WP-0091 +origin: founder-direction +origin_ref: the-custodian/docs/agent-environment-orientation.md +--- + +# Per-recipient broadcast receipts and standing notices + +Founder direction 2026-09-21: fleet-wide guidance should be **pulled** by +agents through the inbox they already check at session start, not pushed +as edits into ~120 repositories. `to_agent: "broadcast"` is that channel, +but it is broken by global read state. This plan fixes it. Planning was +approved; **implementation waits on the founder's review of this plan**. + +## Problem (verified in code 2026-09-21) + +- `api/routers/messages.py` `list_messages` (l.62-67) already ORs + `to_agent == "broadcast"` into every agent's inbox query. +- Read state is one column, `AgentMessage.read_at` + (`api/models/agent_message.py`). `unread_only=true` filters on it + (l.72-73). `PATCH /messages/{id}/read` (l.100-110) sets it globally, + `PATCH /{id}/archive` sets it too, and `POST /{id}/reply` (l.137-138) + marks the original read. So the **first** agent that reads a broadcast + hides it from all others. +- Live evidence: all 5 broadcasts ever sent are read, none archived — + including gate-house 2026-08-29 "ACCEPTED: security layer model v0.7 — + and start here", invisible to every agent that oriented since. + +Findings that refine the custodian's reading: + +1. **The MCP message tools are not in this repo.** `get_messages`, + `mark_message_read` and `reply_to_message` for the main MCP server are + registered in `hub-core` (`/home/worsch/hub-core/hub_core/mcp/server.py` + l.129-160). Only the Codex MCP server (`mcp_server/codex_server.py` + l.55-64) lives here. MCP parity is therefore a cross-repo change. +2. **A mark-read request carries no context.** It is a separate request + from the inbox listing; the reader cannot be inferred from a + `to_agent` query. An unattributed mark-read must therefore not touch + broadcast visibility at all (see D2), and "seen" is best recorded at + **delivery** (D3), which needs no protocol change. +3. **The system itself broadcasts.** `api/routers/capability_requests.py` + (l.100-102) routes unmatched capability requests to `broadcast`. Those + become `news` broadcasts under this design (D4); first-reader-hides + was never their intended semantics either. +4. `reply_to_message` already carries `from_agent`, so the reply path + has a reader identity for free. + +## Design + +**D1 — Receipts table.** New `message_receipts` +(`message_id` FK → `agent_messages.id` ON DELETE CASCADE, +`agent` String(100) canonical slug, `delivered_at`, `read_at`, +`acknowledged_at`, all timestamptz nullable; PK `(message_id, agent)`). +Receipts are written **only for broadcasts**. The reader slug goes +through `canonicalize_repository_slug`, so renamed repos keep their +receipts. Direct messages keep today's semantics exactly: global +`read_at`, no receipt rows. + +**D2 — Mark-read with and without a reader.** `PATCH /messages/{id}/read` +gains an optional `reader` (query param; also accepted in the JSON body). +- Direct message: unchanged, with or without `reader`. +- Broadcast + `reader`: upsert receipt `read_at` for that reader; + global `read_at` untouched. +- Broadcast without `reader` (every existing curl, both MCP servers): + **no-op on visibility**, response `200` with the message body plus + `Deprecation: true` and `Warning: 299 - "broadcast mark-read needs + ?reader="` headers, and a legacy-meter hit so the callers can + be counted and retired. It never hides the broadcast from anyone. +- `PATCH /{id}/archive` on a broadcast stays global (sender or founder + withdrawal) but no longer stamps `read_at`. +- `POST /{id}/reply` on a broadcast writes a receipt (`read_at`) for + `body.from_agent` instead of the global `read_at`; direct replies + unchanged. + +**D3 — Delivery receipts (zero protocol change).** When +`GET /messages/?to_agent=X&unread_only=true` returns a broadcast, the +API upserts a receipt with `delivered_at` for X in the same request. +That exact call shape is the orientation step in every repo's protocol, +so "who has seen it" becomes visible without any instruction change. +Listing without `to_agent` (dashboard) or without `unread_only` writes +nothing. A write inside a GET is a deliberate trade-off; it is +idempotent (upsert, first timestamp wins) and scoped to broadcasts. + +**D4 — Kinds, expiry, supersede.** New columns on `agent_messages`: +`kind` String(20) NOT NULL default `'message'` +(`message` | `news` | `standing`), `expires_at` timestamptz null, +`supersedes_id` UUID FK self null. `kind` is only meaningful for +broadcasts; sending a broadcast without `kind` defaults to `news`. +- `news`: in X's unread inbox until X has **any** receipt (delivered, + read, or acknowledged). Agents see it once at orientation — which is + what a broadcast should do — and it drops off without a mark-read. + Default `expires_at` = created + 30 days if the sender gives none. +- `standing`: in X's unread inbox until X has `acknowledged_at`, or the + notice expires, or it is superseded. Delivery and plain read do not + clear it. Acknowledgement: `POST /messages/{id}/ack` with + `{"agent": ""}` (also accepted as `PATCH /{id}/read?reader=X&ack=true`). +- `supersedes_id`: creating a notice that supersedes an older one sets + the older one's `archived_at`; archived and expired notices never + appear in inboxes. Chains are allowed; only the newest stays live. +- Expired broadcasts are filtered at query time (`expires_at > now()`); + no sweeper job. + +**D5 — Visibility.** `GET /messages/notices` returns live standing +notices with, per notice, counts and lists of agents that have +acknowledged / only been delivered / never been reached, measured +against the set of active registered repositories (the same set +fix-consistency uses). `/state/summary` gains `standing_notices: +[{id, subject, expires_at, acked, pending}]` (counts only). The +dashboard inbox page (`dashboard/src/inbox.md`, +`dashboard/src/data/messages.json.py`) gets a "Standing notices" panel +with the not-yet-acknowledged repo list. + +**D6 — Existing 5 broadcasts.** All 5 have global `read_at` set and +would re-surface to every agent once visibility ignores `read_at` for +broadcasts. **Default: the migration archives them** (`archived_at = +now()`, `kind = 'news'`), nothing re-surfaces, archive is reversible. +The gate-house v0.7 "start here" note is gate-house's content, so the +hub does not re-author it; instead T08 asks gate-house (or the founder) +whether to re-publish it as a `standing` notice. Alternatives the +founder may pick at T01: re-surface the three gate-house ones as `news` +(one-time appearance per agent), or leave all five read with no archive. + +**D7 — Protocol change.** Receipts and `news`: **none**. `standing` +acknowledgement needs one instruction line, changed once: +- `scripts/project_rules/session-protocol.template` l.34 and + `scripts/project_rules/agents-codex.template` l.62: the mark-read curl + becomes `.../messages//read?reader={REPO_SLUG}` with a one-line + note "standing notices: `POST /messages//ack` after acting". +- Carried once by `scripts/update_agent_instruction_files.py` at the + next routine propagation (it renders these templates); no recurring + push. Until an agent's copy is updated, standing notices still reach + it at every orientation (delivery is logged) — it just cannot clear + them, which surfaces it on the notices view. That is the intended + "who is out of date" signal, not a failure. + +## Confirm design with the founder + +```task +id: STATE-WP-0093-T01 +status: todo +priority: high +``` + +Founder reviews D1-D7, in particular the unattributed mark-read no-op +(D2), write-on-delivery GET (D3), and the default for the 5 existing +broadcasts (D6). Record the choices as decisions in this file. + +Done when D2, D3 and D6 each have a recorded founder choice and this +workplan moves to `ready`. + +## Schema and migration + +```task +id: STATE-WP-0093-T02 +status: todo +priority: high +``` + +Model `MessageReceipt` in `api/models/`; add `kind`, `expires_at`, +`supersedes_id` to `AgentMessage`. One Alembic revision on head +`c6f7a8b9d0e1`: create `message_receipts`, add the three columns +(`kind` server default `'message'`, broadcasts backfilled to `'news'`), +index `(agent)` on receipts and `(to_agent, kind, archived_at)` on +messages, and apply the D6 choice to the 5 existing broadcasts. +Downgrade drops the table and columns (archive stamps stay). + +Done when `alembic upgrade head` and `downgrade -1` both run clean on a +copy of a production dump, and the 5 broadcasts end in the D6 state. + +## API: receipts, notices, ack + +```task +id: STATE-WP-0093-T03 +status: todo +priority: high +``` + +Implement D2-D4 in `api/routers/messages.py` and +`api/schemas/agent_message.py` (`MessageCreate` gains `kind`, +`expires_at`, `supersedes_id`; `MessageRead` exposes them; per-reader +`read_at`/`acknowledged_at` are returned when the list is scoped by +`to_agent`). Add `POST /messages/{id}/ack`. Default `news` expiry. +Capability-request broadcasts send `kind='news'`. + +Done when direct-message behavior is byte-for-byte unchanged in the +existing test suite and T06's new tests pass. + +## MCP parity (state-hub and hub-core) + +```task +id: STATE-WP-0093-T04 +status: todo +priority: medium +``` + +`mcp_server/codex_server.py`: `mark_message_read(message_id, reader=None)`, +new `acknowledge_notice(message_id, agent)`, `send_message` accepts +`kind`/`expires_at`/`supersedes_id` if exposed. The main MCP tools live +in `hub-core` (`hub_core/mcp/server.py`): same signature changes there +via a hub-core workplan/handoff message (cross-repo; do not edit +hub-core from this repo), then bump the pinned hub-core. Update +`mcp_server/TOOLS.md`. + +Done when both MCP servers can mark a broadcast read per reader and +acknowledge a standing notice, with tests in +`tests/test_codex_mcp_server.py` and hub-core's suite. + +## Visibility: notices view, summary, dashboard + +```task +id: STATE-WP-0093-T05 +status: todo +priority: medium +``` + +Implement D5: `GET /messages/notices`, `standing_notices` in +`/state/summary`, dashboard panel. + +Done when a live standing notice shows acked / delivered-only / +unreached repo lists on the endpoint and the dashboard. + +## Tests + +```task +id: STATE-WP-0093-T06 +status: todo +priority: high +``` + +API tests: broadcast read by A stays unread for B; unattributed +mark-read of a broadcast is a no-op with deprecation headers; direct +message mark-read, archive and reply unchanged; reply to a broadcast +writes a receipt for the replier only; delivery receipt written only for +`to_agent`+`unread_only` listing; `news` disappears after delivery, +`standing` persists until ack; expiry hides; supersede archives the +predecessor; renamed-repo reader resolves to the canonical receipt; +migration state of pre-existing broadcasts. + +Done when the new tests pass and the full suite is green. + +## Release through the normal promotion path + +```task +id: STATE-WP-0093-T07 +status: wait +priority: high +``` + +**Waits on the founder's go-ahead.** Promote per +`deploy/railiance/apps/charts/state-hub/PROMOTE.md`: headroom preflight +(`make railiance-state-hub-headroom`), image build, `helm upgrade +--atomic`; migration runs as part of the release. Restart MCP servers +after the hub-core bump. + +Done when the release is live, `alembic current` shows the new head in +the cluster, and a smoke test sends a test broadcast, reads it as two +agents, and archives it. + +## Publish the first standing notice and propagate the one-line change + +```task +id: STATE-WP-0093-T08 +status: wait +priority: medium +``` + +After T07: publish `kind: standing` from `the-custodian`, subject "Read +the-custodian/docs/agent-environment-orientation.md (revision +2026-09-21)", `expires_at` ~60 days. Apply the D7 template line change +and let the next routine `update_agent_instruction_files.py` run carry +it once. Ask gate-house whether its v0.7 "start here" note should be +re-published as a standing notice (D6). + +Done when the notice is live, visible on `/messages/notices`, the +templates carry the reader/ack line, and gate-house has answered.