state-hub/workplans/STATE-WP-0093-broadcast-receipts-and-standing-notices.md
tegwick b62e8bf7a8
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 39s
STATE-WP-0093-T08: reader-aware mark-read and notice ack in the instruction templates; first standing notice live.
No fleet regeneration: standing notices are already visible to agents on
old instructions, and the notice carries its own ack command.

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:46:00 +02:00

19 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: done
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: done
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: done
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: done
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.

Release record, 2026-09-22 (founder go-ahead; custodian session)

  • Migration trial on a production copy (closes T02). A read-only pg_dump taken inside databases/state-hub-db-1 (the pod's local postgres user; no credential handled) supplied the schema, alembic_version and agent_messages (998 rows). They were restored into a scratch database. alembic upgrade head applied b5e6f7a8c9d0 → c6f7a8b9d0e1 → d7e8f9a0b1c2: 5 of 5 broadcasts archived, direct messages untouched (2 archived before and after), 998 total, message_receipts created, kinds message and news. downgrade -1 then upgrade head were clean. The dumps and the scratch database were deleted.
  • The release carried exactly two app commits since main-0747b4d: ddc3338 (STATE-WP-0092 flavor, migration c6f7a8b9d0e1, unreleased since 2026-09-14) and ef541f5 (this workplan).
  • Gates: the full suite passed, 894 of 894 (uv run pytest, local custodian_test). CI build-and-push for ef541f5 succeeded. Headroom preflight ok, 585m CPU free. The render diff showed only the image changing on the API and MCP Deployments.
  • Release (T07): helm upgrade --atomic --set image.tag=main-ef541f5 gave revision 65, deployed. API and MCP both run main-ef541f5, 1/1. The migrate hook completed.
  • Verified live: /state/health ok with schema d7e8f9a0b1c2; GET /messages/notices 200 (empty); standing_notices in /state/summary; the 5 old broadcasts archived and out of inboxes; direct-message mark-read unchanged.
  • Still open: T04 hub-core (handoff 69fc387c plus addendum). T05 check against a live notice, which comes with T08. T08: the first standing notice, the template line, and the gate-house question.

T08 record, 2026-09-22 (founder go-ahead; custodian session)

  • First standing notice published: 4ff51ffa-2b95-49da-a5a9-2c1965d5fcbe, from the-custodian to broadcast, kind standing, expires 2027-03-20. It points every coding agent to the-custodian/docs/agent-environment-orientation.md (revision 2026-09-21), and its body carries the ack command.
  • Live end-to-end check (closes T05):
    • the notice appears in the-custodian's and gate-house's unread inbox;
    • POST /ack as the-custodian clears it for the-custodian only, and gate-house still sees it;
    • GET /messages/notices reported 1 acknowledged, 1 delivered-only and 135 unreached. The delivered-only entry was gate-house, recorded by the custodian's own test query.
  • Template line: session-protocol.template and agents-codex.template now mark read with ?reader={REPO_SLUG} and document POST /messages/<id>/ack.
  • Fleet propagation NOT run, deliberately. update_agent_instruction_files.py regenerates each repository's whole CLAUDE.md, rules and AGENTS.md without committing. Across the fleet it would overwrite local customisations and leave ~120 dirty working trees. It is also unnecessary: agents on the old instructions already see standing notices at every inbox check, and the notice body tells them how to acknowledge. The template change reaches each repository whenever it is next regenerated for its own reasons.
  • The gate-house question (republish the archived v0.7 "start here" note as a standing notice?) was sent to gate-house.