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
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 |
|
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.pylist_messages(l.62-67) already ORsto_agent == "broadcast"into every agent's inbox query.- Read state is one column,
AgentMessage.read_at(api/models/agent_message.py).unread_only=truefilters on it (l.72-73).PATCH /messages/{id}/read(l.100-110) sets it globally,PATCH /{id}/archivesets it too, andPOST /{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:
- The MCP message tools are not in this repo.
get_messages,mark_message_readandreply_to_messagefor the main MCP server are registered inhub-core(/home/worsch/hub-core/hub_core/mcp/server.pyl.129-160). Only the Codex MCP server (mcp_server/codex_server.pyl.55-64) lives here. MCP parity is therefore a cross-repo change. - A mark-read request carries no context. It is a separate request
from the inbox listing; the reader cannot be inferred from a
to_agentquery. 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. - The system itself broadcasts.
api/routers/capability_requests.py(l.100-102) routes unmatched capability requests tobroadcast. Those becomenewsbroadcasts under this design (D4); first-reader-hides was never their intended semantics either. reply_to_messagealready carriesfrom_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 receiptread_atfor that reader; globalread_atuntouched. - Broadcast without
reader(every existing curl, both MCP servers): no-op on visibility, response200with the message body plusDeprecation: trueandWarning: 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}/archiveon a broadcast stays global (sender or founder withdrawal) but no longer stampsread_at.POST /{id}/replyon a broadcast writes a receipt (read_at) forbody.from_agentinstead of the globalread_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. Defaultexpires_at= created + 30 days if the sender gives none.standing: in X's unread inbox until X hasacknowledged_at, or the notice expires, or it is superseded. Delivery and plain read do not clear it. Acknowledgement:POST /messages/{id}/ackwith{"agent": "<slug>"}(also accepted asPATCH /{id}/read?reader=X&ack=true).supersedes_id: creating a notice that supersedes an older one sets the older one'sarchived_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.templatel.34 andscripts/project_rules/agents-codex.templatel.62: the mark-read curl becomes.../messages/<id>/read?reader={REPO_SLUG}with a one-line note "standing notices:POST /messages/<id>/ackafter acting".- Carried once by
scripts/update_agent_instruction_files.pyat 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:
- D3 ACCEPTED:
GET /messages/?to_agent=X&unread_only=truerecords an idempotentdelivered_atreceipt for X on each broadcast it returns (a deliberate write inside the GET; no protocol change for delivery tracking). - 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.
- 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.templateandagents-codex.template(inbox curl gains?reader={REPO_SLUG}plus an ack note), carried ONCE byupdate_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.