state-hub/workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md
tegwick 4d8390bec5
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 8s
STATE-WP-0093: hub IDs written back; tasks wait on founder review (C-23).
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
2026-09-21 23:49:07 +02:00

13 KiB

id type title domain repo status owner topic_slug flavor created updated related origin origin_ref state_hub_workstream_id
STATE-WP-0093 workplan Per-recipient broadcast receipts and standing notices infotech state-hub proposed claude infotech implementation 2026-09-21 2026-09-21
STATE-WP-0091
founder-direction the-custodian/docs/agent-environment-orientation.md f885387e-5335-5aea-ae83-9c72c177d4c3

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

id: STATE-WP-0093-T01
status: wait
priority: high
state_hub_task_id: "94eec945-b728-50cb-aefb-60eca5aeccd9"

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

id: STATE-WP-0093-T02
status: wait
priority: high
state_hub_task_id: "6680d408-ebce-5b2c-8a97-84f0bf011754"

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

id: STATE-WP-0093-T03
status: wait
priority: high
state_hub_task_id: "33010aa6-326d-5f52-9ff3-32b09c9e88c4"

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)

id: STATE-WP-0093-T04
status: wait
priority: medium
state_hub_task_id: "43529a93-6e49-51c8-a358-726f7e737fab"

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

id: STATE-WP-0093-T05
status: wait
priority: medium
state_hub_task_id: "fdb44fd9-ab44-5e26-86b3-a4f0f8a4e701"

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

id: STATE-WP-0093-T06
status: wait
priority: high
state_hub_task_id: "d68aa1fa-0f09-5d64-a4fc-cbc24180f2c3"

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

id: STATE-WP-0093-T07
status: wait
priority: high
state_hub_task_id: "e9978994-8b6a-5f98-8056-6ee3970342d1"

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

id: STATE-WP-0093-T08
status: wait
priority: medium
state_hub_task_id: "7866d802-eaed-5a7d-be74-eda3185f55d0"

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.