Plan per-recipient broadcast receipts and standing notices (STATE-WP-0093).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 63291@bnt-lap001 Assistant-Session: 8bd77868-ca68-4f49-bb1e-d539ecc0d703
This commit is contained in:
parent
6b97d01861
commit
1caf17370b
1 changed files with 287 additions and 0 deletions
|
|
@ -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=<agent>"` 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": "<slug>"}` (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/<id>/read?reader={REPO_SLUG}` with a one-line
|
||||||
|
note "standing notices: `POST /messages/<id>/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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue