STATE-WP-0093: per-recipient broadcast receipts and standing notices (T01-T06).
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

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
This commit is contained in:
tegwick 2026-09-22 00:26:33 +02:00
parent 740068fcf1
commit ef541f58cf
18 changed files with 1256 additions and 52 deletions

View file

@ -9,6 +9,7 @@ def test_codex_server_has_only_repository_coordination_tools() -> None:
"get_domain_summary",
"get_messages",
"mark_message_read",
"acknowledge_notice",
"add_progress_event",
"record_decision",
"update_task_status",
@ -17,3 +18,18 @@ def test_codex_server_has_only_repository_coordination_tools() -> None:
def test_codex_server_uses_distinct_server_identity() -> None:
assert codex_server.mcp.name == "dev-hub-codex"
def test_mark_message_read_passes_reader(monkeypatch) -> None:
calls = []
monkeypatch.setattr(
codex_server, "_request", lambda method, path, body=None: calls.append((method, path, body)) or {}
)
codex_server.mark_message_read("abc", reader="state-hub")
codex_server.mark_message_read("abc")
codex_server.acknowledge_notice("abc", "state-hub")
assert calls == [
("PATCH", "/messages/abc/read?reader=state-hub", {}),
("PATCH", "/messages/abc/read", {}),
("POST", "/messages/abc/ack", {"agent": "state-hub"}),
]

View file

@ -37,8 +37,12 @@ from hub_core.schemas.tpsc import (
)
def test_state_hub_reexports_core_message_schema() -> None:
assert MessageCreate is CoreMessageCreate
def test_state_hub_message_schema_extends_core() -> None:
# STATE-WP-0093 extends the hub-core shape locally (kind, expires_at,
# supersedes_id) until hub-core absorbs the fields; then this returns to
# an identity re-export.
assert issubclass(MessageCreate, CoreMessageCreate)
assert set(CoreMessageCreate.model_fields) <= set(MessageCreate.model_fields)
def test_state_hub_reexports_core_doi_schema() -> None:

View file

@ -0,0 +1,392 @@
"""STATE-WP-0093: per-recipient broadcast receipts and standing notices."""
from __future__ import annotations
import importlib
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import inspect, select, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from api.models.agent_message import AgentMessage, MessageReceipt
from api.models.legacy_meter import LegacyInterface
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositorySlug
from api.routers.messages import UNATTRIBUTED_BROADCAST_READ_KEY
from tests.conftest import create_test_domain, create_test_repo
async def _send(client, **payload):
body = {"from_agent": "the-custodian", "subject": "s", "body": "b", **payload}
body.setdefault("to_agent", "broadcast")
resp = await client.post("/messages/", json=body)
assert resp.status_code == 201, resp.text
return resp.json()
async def _inbox(client, agent, unread_only=True):
resp = await client.get(
"/messages/", params={"to_agent": agent, "unread_only": str(unread_only).lower()}
)
assert resp.status_code == 200, resp.text
return resp.json()
def _ids(messages):
return {m["id"] for m in messages}
async def _receipts(test_engine, message_id):
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
result = await session.execute(
select(MessageReceipt).where(MessageReceipt.message_id == uuid.UUID(message_id))
)
return {r.agent: r for r in result.scalars().all()}
@pytest.mark.asyncio
async def test_broadcast_defaults_to_news_with_expiry(client):
msg = await _send(client)
assert msg["kind"] == "news"
expires = datetime.fromisoformat(msg["expires_at"])
assert timedelta(days=29) < expires - datetime.now(timezone.utc) <= timedelta(days=30)
@pytest.mark.asyncio
async def test_direct_message_rejects_broadcast_fields(client):
resp = await client.post(
"/messages/",
json={"from_agent": "a", "to_agent": "b", "subject": "s", "body": "b", "kind": "standing"},
)
assert resp.status_code == 422
direct = await _send(client, to_agent="b")
assert direct["kind"] == "message"
assert direct["expires_at"] is None
@pytest.mark.asyncio
async def test_broadcast_read_by_a_stays_unread_for_b(client, test_engine):
msg = await _send(client, kind="standing")
resp = await client.patch(f"/messages/{msg['id']}/read", params={"reader": "agent-a"})
assert resp.status_code == 200
body = resp.json()
assert body["reader"] == "agent-a"
assert body["read_at"] is not None
assert msg["id"] in _ids(await _inbox(client, "agent-b"))
receipts = await _receipts(test_engine, msg["id"])
assert set(receipts) == {"agent-a", "agent-b"} # b: delivery receipt only
assert receipts["agent-b"].read_at is None
# Global read_at never set for a broadcast.
listing = (await client.get("/messages/")).json()
assert next(m for m in listing if m["id"] == msg["id"])["read_at"] is None
@pytest.mark.asyncio
async def test_mark_read_reader_in_json_body(client, test_engine):
msg = await _send(client, kind="standing")
resp = await client.patch(f"/messages/{msg['id']}/read", json={"reader": "agent-a"})
assert resp.status_code == 200
assert (await _receipts(test_engine, msg["id"]))["agent-a"].read_at is not None
@pytest.mark.asyncio
async def test_unattributed_broadcast_mark_read_is_noop_with_deprecation(client, test_engine):
msg = await _send(client, kind="standing")
resp = await client.patch(f"/messages/{msg['id']}/read", json={})
assert resp.status_code == 200
assert resp.headers["Deprecation"] == "true"
assert "reader" in resp.headers["Warning"]
assert resp.json()["read_at"] is None
assert await _receipts(test_engine, msg["id"]) == {}
assert msg["id"] in _ids(await _inbox(client, "agent-a"))
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
meter = (
await session.execute(
select(LegacyInterface).where(
LegacyInterface.interface_key == UNATTRIBUTED_BROADCAST_READ_KEY
)
)
).scalar_one_or_none()
assert meter is not None
@pytest.mark.asyncio
async def test_direct_message_read_archive_reply_unchanged(client, test_engine):
direct = await _send(client, to_agent="agent-b")
assert direct["id"] in _ids(await _inbox(client, "agent-b"))
resp = await client.patch(f"/messages/{direct['id']}/read", json={})
assert resp.status_code == 200
assert "Deprecation" not in resp.headers
assert resp.json()["read_at"] is not None
assert direct["id"] not in _ids(await _inbox(client, "agent-b"))
assert await _receipts(test_engine, direct["id"]) == {}
second = await _send(client, to_agent="agent-b")
reply = await client.post(
f"/messages/{second['id']}/reply", json={"from_agent": "agent-b", "body": "ok"}
)
assert reply.status_code == 201
assert reply.json()["to_agent"] == "the-custodian"
assert second["id"] not in _ids(await _inbox(client, "agent-b"))
assert await _receipts(test_engine, second["id"]) == {}
third = await _send(client, to_agent="agent-b")
archived = (await client.patch(f"/messages/{third['id']}/archive")).json()
assert archived["archived_at"] is not None
assert archived["read_at"] == archived["archived_at"]
@pytest.mark.asyncio
async def test_reply_to_broadcast_writes_receipt_for_replier_only(client, test_engine):
msg = await _send(client, kind="standing")
reply = await client.post(
f"/messages/{msg['id']}/reply", json={"from_agent": "agent-a", "body": "noted"}
)
assert reply.status_code == 201
receipts = await _receipts(test_engine, msg["id"])
assert set(receipts) == {"agent-a"}
assert receipts["agent-a"].read_at is not None
listing = (await client.get("/messages/")).json()
assert next(m for m in listing if m["id"] == msg["id"])["read_at"] is None
assert msg["id"] in _ids(await _inbox(client, "agent-b"))
@pytest.mark.asyncio
async def test_archive_broadcast_does_not_set_read_at(client):
msg = await _send(client)
archived = (await client.patch(f"/messages/{msg['id']}/archive")).json()
assert archived["archived_at"] is not None
assert archived["read_at"] is None
assert msg["id"] not in _ids(await _inbox(client, "agent-a"))
@pytest.mark.asyncio
async def test_delivery_receipt_only_for_scoped_unread_listing(client, test_engine):
msg = await _send(client, kind="standing")
await client.get("/messages/")
await client.get("/messages/", params={"unread_only": "true"})
await _inbox(client, "agent-a", unread_only=False)
assert await _receipts(test_engine, msg["id"]) == {}
first = await _inbox(client, "agent-a")
delivered = (await _receipts(test_engine, msg["id"]))["agent-a"].delivered_at
assert delivered is not None
assert first[0]["delivered_at"] is not None
await _inbox(client, "agent-a")
receipts = await _receipts(test_engine, msg["id"])
assert len(receipts) == 1
assert receipts["agent-a"].delivered_at == delivered # first timestamp wins
@pytest.mark.asyncio
async def test_news_disappears_after_delivery_standing_persists_until_ack(client):
news = await _send(client, kind="news")
standing = await _send(client, kind="standing")
first = _ids(await _inbox(client, "agent-a"))
assert {news["id"], standing["id"]} <= first
second = _ids(await _inbox(client, "agent-a"))
assert news["id"] not in second
assert standing["id"] in second
# Plain read does not clear a standing notice.
await client.patch(f"/messages/{standing['id']}/read", params={"reader": "agent-a"})
assert standing["id"] in _ids(await _inbox(client, "agent-a"))
ack = await client.post(f"/messages/{standing['id']}/ack", json={"agent": "agent-a"})
assert ack.status_code == 200
assert ack.json()["acknowledged_at"] is not None
assert standing["id"] not in _ids(await _inbox(client, "agent-a"))
assert standing["id"] in _ids(await _inbox(client, "agent-b"))
@pytest.mark.asyncio
async def test_ack_via_mark_read_flag_and_ack_rejects_direct(client):
standing = await _send(client, kind="standing")
await client.patch(
f"/messages/{standing['id']}/read", params={"reader": "agent-a", "ack": "true"}
)
assert standing["id"] not in _ids(await _inbox(client, "agent-a"))
direct = await _send(client, to_agent="agent-a")
resp = await client.post(f"/messages/{direct['id']}/ack", json={"agent": "agent-a"})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_expired_broadcast_hidden(client):
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()
expired = await _send(client, kind="standing", expires_at=past)
assert expired["id"] not in _ids(await _inbox(client, "agent-a"))
assert expired["id"] not in _ids(await _inbox(client, "agent-a", unread_only=False))
assert (await client.get("/messages/notices")).json() == []
@pytest.mark.asyncio
async def test_supersede_archives_predecessor(client):
old = await _send(client, kind="standing", subject="v1")
new = await _send(client, kind="standing", subject="v2", supersedes_id=old["id"])
assert new["supersedes_id"] == old["id"]
inbox = _ids(await _inbox(client, "agent-a"))
assert new["id"] in inbox and old["id"] not in inbox
notices = (await client.get("/messages/notices")).json()
assert [n["id"] for n in notices] == [new["id"]]
missing = await client.post(
"/messages/",
json={
"from_agent": "x", "to_agent": "broadcast", "subject": "s", "body": "b",
"kind": "standing", "supersedes_id": str(uuid.uuid4()),
},
)
assert missing.status_code == 404
@pytest.mark.asyncio
async def test_notices_view_and_summary_counts(client):
domain = await create_test_domain(client)
for slug in ("repo-a", "repo-b", "repo-c"):
await create_test_repo(client, domain_slug=domain["slug"], slug=slug)
notice = await _send(client, kind="standing", subject="Read the orientation")
await _send(client, kind="news", subject="not a notice")
await client.post(f"/messages/{notice['id']}/ack", json={"agent": "repo-a"})
await _inbox(client, "repo-b")
notices = (await client.get("/messages/notices")).json()
assert len(notices) == 1
status = notices[0]
assert status["acknowledged"] == ["repo-a"]
assert status["delivered_only"] == ["repo-b"]
assert status["unreached"] == ["repo-c"]
assert (status["acked_count"], status["delivered_only_count"], status["unreached_count"]) == (1, 1, 1)
summary = (await client.get("/state/summary")).json()
assert summary["standing_notices"] == [
{
"id": notice["id"],
"subject": "Read the orientation",
"expires_at": None,
"acked": 1,
"pending": 2,
}
]
@pytest.mark.asyncio
async def test_renamed_repo_reader_resolves_to_canonical_receipt(client, test_engine):
domain = await create_test_domain(client)
repo = await create_test_repo(client, domain_slug=domain["slug"], slug="flex-auth")
notice = await _send(client, kind="standing")
await client.post(f"/messages/{notice['id']}/ack", json={"agent": "flex-auth"})
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
managed = await session.get(ManagedRepo, uuid.UUID(repo["id"]))
old = (
await session.execute(select(RepositorySlug).where(RepositorySlug.slug == "flex-auth"))
).scalar_one()
old.kind = "alias"
old.protected = True
managed.slug = "access-engine"
managed.name = "Access Engine"
session.add(
RepositorySlug(repo_id=managed.id, slug="access-engine", kind="canonical", protected=True)
)
await session.commit()
# The pre-rename ack still counts for the new name, and new writes via the
# old name land on the canonical slug.
assert notice["id"] not in _ids(await _inbox(client, "access-engine"))
second = await _send(client, kind="standing", subject="second")
await client.patch(f"/messages/{second['id']}/read", params={"reader": "flex-auth"})
assert set(await _receipts(test_engine, second["id"])) == {"access-engine"}
@pytest.mark.asyncio
async def test_capability_request_broadcast_is_news(client, test_engine):
await create_test_domain(client)
resp = await client.post(
"/capability-requests/",
json={
"title": "Need a thing",
"requesting_agent": "agent-a",
"requesting_domain": "infotech",
"capability_type": "service",
"description": "unmatched",
},
)
assert resp.status_code == 201, resp.text
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
msgs = (
await session.execute(select(AgentMessage).where(AgentMessage.to_agent == "broadcast"))
).scalars().all()
assert msgs and all(m.kind == "news" and m.expires_at is not None for m in msgs)
@pytest.mark.asyncio
async def test_migration_archives_broadcasts_and_is_reversible(test_engine):
migration = importlib.import_module(
"migrations.versions.d7e8f9a0b1c2_broadcast_receipts_and_notices"
)
from alembic.migration import MigrationContext
from alembic.operations import Operations
schema = f"receipts_migration_{uuid.uuid4().hex}"
direct_id, read_bc, unread_bc, archived_bc = (uuid.uuid4() for _ in range(4))
old_archive = datetime(2026, 1, 1, tzinfo=timezone.utc)
async with test_engine.connect() as connection:
def run(sync_connection):
quoted = sync_connection.dialect.identifier_preparer.quote(schema)
sync_connection.exec_driver_sql(f"CREATE SCHEMA {quoted}")
sync_connection.exec_driver_sql(f"SET LOCAL search_path TO {quoted}")
sync_connection.exec_driver_sql(
"CREATE TABLE agent_messages ("
"id uuid PRIMARY KEY, from_agent varchar(100) NOT NULL, "
"to_agent varchar(100) NOT NULL, subject varchar(500) NOT NULL, "
"body text NOT NULL, thread_id uuid REFERENCES agent_messages(id), "
"read_at timestamptz, archived_at timestamptz, "
"created_at timestamptz NOT NULL DEFAULT now())"
)
sync_connection.execute(
text(
"INSERT INTO agent_messages (id, from_agent, to_agent, subject, body, read_at, archived_at) VALUES "
"(:d, 'a', 'b', 's', 'b', NULL, NULL), "
"(:r, 'gate-house', 'broadcast', 's', 'b', now(), NULL), "
"(:u, 'system', 'broadcast', 's', 'b', NULL, NULL), "
"(:x, 'system', 'broadcast', 's', 'b', now(), :old)"
),
{"d": direct_id, "r": read_bc, "u": unread_bc, "x": archived_bc, "old": old_archive},
)
original_op = migration.op
migration.op = Operations(MigrationContext.configure(sync_connection))
try:
migration.upgrade()
assert "message_receipts" in inspect(sync_connection).get_table_names(schema=schema)
rows = {
r.id: r
for r in sync_connection.execute(
text("SELECT id, kind, read_at, archived_at FROM agent_messages")
)
}
assert rows[direct_id].kind == "message"
assert rows[direct_id].archived_at is None
assert rows[direct_id].read_at is None
for bc in (read_bc, unread_bc, archived_bc):
assert rows[bc].kind == "news"
assert rows[bc].archived_at is not None
assert rows[archived_bc].archived_at == old_archive
assert rows[unread_bc].read_at is None # archive does not stamp read_at
migration.downgrade()
columns = {
c["name"] for c in inspect(sync_connection).get_columns("agent_messages", schema=schema)
}
assert not {"kind", "expires_at", "supersedes_id"} & columns
assert "message_receipts" not in inspect(sync_connection).get_table_names(schema=schema)
count = sync_connection.execute(text("SELECT count(*) FROM agent_messages")).scalar()
assert count == 4
finally:
migration.op = original_op
await connection.run_sync(run)
await connection.rollback()