state-hub/workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md
tegwick ef541f58cf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 53s
STATE-WP-0093: per-recipient broadcast receipts and standing notices (T01-T06).
Founder approved T01 on 2026-09-22 (D2, D3, D6 as recommended).
- T02: message_receipts table; kind/expires_at/supersedes_id on
  agent_messages; migration d7e8f9a0b1c2 archives existing broadcasts,
  leaves direct messages untouched, reversible.
- T03: reader-aware mark-read (unattributed broadcast mark-read is a
  metered, deprecated no-op), delivery receipts on the scoped unread inbox,
  POST /messages/{id}/ack, news/standing kinds, expiry, supersede, broadcast
  archive no longer stamps read_at, reply writes the replier's receipt.
- T04 (state-hub part): Codex MCP reader param and acknowledge_notice;
  hub-core part handed off (message 69fc387c).
- T05: GET /messages/notices, standing_notices in /state/summary,
  dashboard standing-notices panel.
- T06: 17 new tests; full suite green.

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-22 00:26:33 +02:00

16 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 active claude infotech implementation 2026-09-21 2026-09-22
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: done
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.

Founder decisions (Bernd Worsch, 2026-09-22) — all accepted as recommended:

  1. D3 ACCEPTED: GET /messages/?to_agent=X&unread_only=true records an idempotent delivered_at receipt for X on each broadcast it returns (a deliberate write inside the GET; no protocol change for delivery tracking).
  2. D6 ACCEPTED: the migration ARCHIVES the 5 existing broadcasts (reversible; nothing re-surfaces). T08 asks gate-house whether to republish its v0.7 "start here" note as a standing notice.
  3. D2/D7 ACCEPTED: unattributed mark-read of a broadcast is a visibility no-op with deprecation headers; one line in scripts/project_rules/session-protocol.template and agents-codex.template (inbox curl gains ?reader={REPO_SLUG} plus an ack note), carried ONCE by update_agent_instruction_files.py. The fleet propagation run itself is T08, not part of the implementation session.

T07 (release) still waits on its own founder go-ahead.

Schema and migration

id: STATE-WP-0093-T02
status: progress
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.

2026-09-22 (progress): MessageReceipt model and Alembic revision d7e8f9a0b1c2 (on c6f7a8b9d0e1) implemented: message_receipts, kind/expires_at/supersedes_id, both indexes, broadcasts backfilled to news and archived (D6); direct messages untouched. Verified on a scratch local database built from the full migration chain (upgrade head, downgrade -1, upgrade head all clean; a read broadcast ended news + archived, a direct message unchanged) and by an isolated-schema up/down test. Remaining: the rehearsal on a copy of a production dump — not run from the implementation session (no production access); do it in the T07 preflight.

API: receipts, notices, ack

id: STATE-WP-0093-T03
status: done
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.

2026-09-22 (done): D2-D4 in api/routers/messages.py, api/services/message_receipts.py, api/schemas/agent_message.py (local subclasses of the hub-core schemas). PATCH /read?reader=&ack= (also JSON body), unattributed broadcast mark-read = no-op + Deprecation/Warning headers + legacy-meter key rest_api:PATCH /messages/{id}/read broadcast-without-reader, broadcast reply writes a receipt for the replier, POST /messages/{id}/ack, news default expiry 30d, supersede archives the predecessor, expiry filtered at query time, capability-request broadcasts are news. Direct-message paths unchanged; existing suite green.

MCP parity (state-hub and hub-core)

id: STATE-WP-0093-T04
status: progress
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.

2026-09-22 (progress — split): state-hub part done: mcp_server/codex_server.py mark_message_read(message_id, reader=None) and new acknowledge_notice(message_id, agent), tests in tests/test_codex_mcp_server.py, mcp_server/TOOLS.md updated (the Codex server has no send_message). hub-core part handed off via State Hub message 69fc387c-5bd3-47bc-9a3f-d3d83b6dc213 (addendum f8eecebf-cf76-44f9-832e-20401b1b37ec: absorb the schema fields so state-hub can return to a pure re-export). Remaining: hub-core change + release, then bump the pinned hub-core here.

Visibility: notices view, summary, dashboard

id: STATE-WP-0093-T05
status: progress
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.

2026-09-22 (progress): GET /messages/notices (acknowledged / delivered_only / unreached against active managed_repos), standing_notices in /state/summary (computed per request, outside the revision cache), dashboard inbox "Standing notices" panel + separate "Broadcasts" section (no global mark-read for broadcasts), loader dashboard/src/data/notices.json.py. Endpoint and summary covered by tests; dashboard build passes. Remaining: check the panel against a live notice after T07.

Tests

id: STATE-WP-0093-T06
status: done
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.

2026-09-22 (done): tests/test_message_receipts.py (17 tests: all listed cases, incl. reader in JSON body, ack via mark-read flag, capability broadcast as news, isolated-schema migration up/down). Full suite: 894 passed (tests/test_hub_core_imports.py message-schema check relaxed from identity to subclass, see T04).

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.