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
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 |
|
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: 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_dumptaken insidedatabases/state-hub-db-1(the pod's local postgres user; no credential handled) supplied the schema,alembic_versionandagent_messages(998 rows). They were restored into a scratch database.alembic upgrade headappliedb5e6f7a8c9d0 → c6f7a8b9d0e1 → d7e8f9a0b1c2: 5 of 5 broadcasts archived, direct messages untouched (2 archived before and after), 998 total,message_receiptscreated, kindsmessageandnews.downgrade -1thenupgrade headwere clean. The dumps and the scratch database were deleted. - The release carried exactly two app commits since
main-0747b4d:ddc3338(STATE-WP-0092 flavor, migrationc6f7a8b9d0e1, unreleased since 2026-09-14) andef541f5(this workplan). - Gates: the full suite passed, 894 of 894 (
uv run pytest, localcustodian_test). CIbuild-and-pushforef541f5succeeded. Headroom preflightok, 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-ef541f5gave revision 65, deployed. API and MCP both runmain-ef541f5, 1/1. The migrate hook completed. - Verified live:
/state/healthok with schemad7e8f9a0b1c2;GET /messages/notices200 (empty);standing_noticesin/state/summary; the 5 old broadcasts archived and out of inboxes; direct-message mark-read unchanged. - Still open: T04 hub-core (handoff
69fc387cplus 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, kindstanding, expires 2027-03-20. It points every coding agent tothe-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 /ackas the-custodian clears it for the-custodian only, and gate-house still sees it;GET /messages/noticesreported 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.templateandagents-codex.templatenow mark read with?reader={REPO_SLUG}and documentPOST /messages/<id>/ack. - Fleet propagation NOT run, deliberately.
update_agent_instruction_files.pyregenerates 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.