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

@ -23,7 +23,7 @@ from api.models.technical_debt import TechnicalDebt, TDStatus
from api.models.contribution import Contribution, ContributionType, ContributionStatus
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.sbom_entry import SBOMEntry, Ecosystem
from api.models.agent_message import AgentMessage
from api.models.agent_message import AgentMessage, MessageReceipt
from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
@ -76,6 +76,7 @@ __all__ = [
"SBOMSnapshot",
"SBOMEntry", "Ecosystem",
"AgentMessage",
"MessageReceipt",
"CapabilityCatalog",
"CapabilityRequest",
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",

View file

@ -1,15 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, text
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, new_uuid
BROADCAST = "broadcast"
MESSAGE_KINDS = ("message", "news", "standing")
class AgentMessage(Base):
__tablename__ = "agent_messages"
__table_args__ = (
Index("ix_agent_messages_to_kind_archived", "to_agent", "kind", "archived_at"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
@ -30,6 +37,19 @@ class AgentMessage(Base):
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# STATE-WP-0093: kind is only meaningful for broadcasts
# (message | news | standing); direct messages stay "message".
kind: Mapped[str] = mapped_column(
String(20), nullable=False, default="message", server_default="message"
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
supersedes_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agent_messages.id", ondelete="SET NULL"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=text("now()"),
@ -42,3 +62,29 @@ class AgentMessage(Base):
foreign_keys=[thread_id],
lazy="select",
)
class MessageReceipt(Base):
"""Per-reader state for broadcast messages (STATE-WP-0093).
Written only for broadcasts; direct messages keep the global
``AgentMessage.read_at``.
"""
__tablename__ = "message_receipts"
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agent_messages.id", ondelete="CASCADE"),
primary_key=True,
)
agent: Mapped[str] = mapped_column(String(100), primary_key=True, index=True)
delivered_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
acknowledged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

View file

@ -7,7 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
from api.models.agent_message import AgentMessage
from api.models.agent_message import BROADCAST, AgentMessage
from api.services.message_receipts import NEWS_DEFAULT_TTL, utcnow
from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest
from api.models.domain import Domain
@ -372,6 +373,10 @@ def _add_notification(
subject=subject,
body=body,
)
if to_agent == BROADCAST:
# STATE-WP-0093: system broadcasts are news (seen once per reader).
msg.kind = "news"
msg.expires_at = utcnow() + NEWS_DEFAULT_TTL
session.add(msg)

View file

@ -1,12 +1,29 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import or_, select
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.agent_message import AgentMessage
from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
from api.models.agent_message import BROADCAST, MESSAGE_KINDS, AgentMessage
from api.schemas.agent_message import (
MessageAck,
MessageCreate,
MessageMarkRead,
MessageRead,
MessageReply,
NoticeStatus,
)
from api.services.legacy_meter import identity_from_request, record_legacy_usage
from api.services.message_receipts import (
NEWS_DEFAULT_TTL,
broadcast_unread_clause,
is_broadcast,
notice_statuses,
receipts_for,
upsert_receipt,
utcnow,
)
from api.services.repository_aliases import (
canonicalize_repository_slug,
resolve_repository_slug,
@ -16,6 +33,38 @@ from hub_core.models.message_identity_alias import MessageIdentityAlias
router = APIRouter(prefix="/messages", tags=["messages"])
UNATTRIBUTED_BROADCAST_READ_KEY = "rest_api:PATCH /messages/{id}/read broadcast-without-reader"
UNATTRIBUTED_BROADCAST_READ_REPLACEMENT = "PATCH /messages/{id}/read?reader=<agent>"
UNATTRIBUTED_BROADCAST_WARNING = '299 - "broadcast mark-read needs ?reader=<agent>"'
async def _reader_values(session: AsyncSession, reader: str) -> tuple[str, tuple[str, ...]]:
"""Canonical reader slug plus every historical slug it answers to."""
resolution = await resolve_repository_slug(session, reader, required=False)
if resolution is None:
return reader, (reader,)
return resolution.canonical_slug, tuple(resolution.slug_values)
async def _read_view(
session: AsyncSession,
message: AgentMessage,
reader_values: tuple[str, ...] | None,
reader: str | None,
) -> MessageRead:
view = MessageRead.model_validate(message)
if reader_values is None or not is_broadcast(message):
return view
state = (await receipts_for(session, [message.id], reader_values)).get(message.id, {})
return view.model_copy(
update={
"reader": reader,
"delivered_at": state.get("delivered_at"),
"read_at": state.get("read_at"),
"acknowledged_at": state.get("acknowledged_at"),
}
)
async def _get_message(reference: str, session: AsyncSession) -> AgentMessage:
message_id = await resolve_message_reference(
@ -43,6 +92,38 @@ async def send_message(
payload = body.model_dump()
payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent)
payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent)
if payload["to_agent"] == BROADCAST:
kind = body.kind or "news"
if kind == "message":
kind = "news"
if kind not in MESSAGE_KINDS:
raise HTTPException(
status_code=422,
detail=f"kind must be one of {', '.join(MESSAGE_KINDS[1:])} for broadcasts",
)
payload["kind"] = kind
if kind == "news" and body.expires_at is None:
payload["expires_at"] = utcnow() + NEWS_DEFAULT_TTL
if body.supersedes_id is not None:
predecessor = await session.get(AgentMessage, body.supersedes_id)
if predecessor is None or not is_broadcast(predecessor):
raise HTTPException(
status_code=404,
detail=f"Superseded broadcast {body.supersedes_id} not found",
)
if predecessor.archived_at is None:
predecessor.archived_at = utcnow()
else:
if body.kind not in (None, "message"):
raise HTTPException(
status_code=422, detail="kind news/standing is only valid for broadcasts"
)
if body.expires_at is not None or body.supersedes_id is not None:
raise HTTPException(
status_code=422,
detail="expires_at and supersedes_id are only valid for broadcasts",
)
payload["kind"] = "message"
message = AgentMessage(**payload)
session.add(message)
await session.commit()
@ -57,24 +138,74 @@ async def list_messages(
unread_only: bool = False,
limit: int = 50,
session: AsyncSession = Depends(get_session),
) -> list[AgentMessage]:
) -> list[MessageRead]:
now = utcnow()
query = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
reader: str | None = None
reader_values: tuple[str, ...] | None = None
if to_agent:
resolution = await resolve_repository_slug(session, to_agent, required=False)
values = resolution.slug_values if resolution else (to_agent,)
query = query.where(
or_(AgentMessage.to_agent.in_(values), AgentMessage.to_agent == "broadcast")
)
reader, reader_values = await _reader_values(session, to_agent)
direct = AgentMessage.to_agent.in_(reader_values)
if unread_only:
query = query.where(
or_(
and_(direct, AgentMessage.read_at.is_(None)),
broadcast_unread_clause(reader_values, now),
)
)
else:
query = query.where(
or_(
direct,
and_(
AgentMessage.to_agent == BROADCAST,
or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now),
),
)
)
elif unread_only:
query = query.where(AgentMessage.read_at.is_(None))
if from_agent:
resolution = await resolve_repository_slug(session, from_agent, required=False)
values = resolution.slug_values if resolution else (from_agent,)
query = query.where(AgentMessage.from_agent.in_(values))
if unread_only:
query = query.where(AgentMessage.read_at.is_(None))
result = await session.execute(
query.order_by(AgentMessage.created_at.desc()).limit(limit)
)
return list(result.scalars().all())
messages = list(result.scalars().all())
if reader_values is None or reader is None:
return [MessageRead.model_validate(m) for m in messages]
broadcast_ids = [m.id for m in messages if is_broadcast(m)]
if unread_only and broadcast_ids and reader != BROADCAST:
# D3 (founder-approved 2026-09-22): the orientation inbox call records
# an idempotent delivery receipt for each broadcast it returns.
await upsert_receipt(session, broadcast_ids, reader, delivered=True, at=now)
await session.commit()
receipts = await receipts_for(session, broadcast_ids, reader_values)
views = []
for message in messages:
view = MessageRead.model_validate(message)
if is_broadcast(message):
state = receipts.get(message.id, {})
view = view.model_copy(
update={
"reader": reader,
"delivered_at": state.get("delivered_at"),
"read_at": state.get("read_at"),
"acknowledged_at": state.get("acknowledged_at"),
}
)
views.append(view)
return views
@router.get("/notices", response_model=list[NoticeStatus])
async def list_notices(
session: AsyncSession = Depends(get_session),
) -> list[NoticeStatus]:
"""Live standing notices with acknowledged / delivered-only / unreached repos."""
return await notice_statuses(session)
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
@ -100,14 +231,66 @@ async def get_thread(
@router.patch("/{message_id}/read", response_model=MessageRead)
async def mark_read(
message_id: str,
request: Request,
response: Response,
reader: str | None = None,
ack: bool = False,
body: MessageMarkRead | None = Body(default=None),
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
) -> AgentMessage | MessageRead:
message = await _get_message(message_id, session)
if message.read_at is None:
message.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(message)
return message
if not is_broadcast(message):
if message.read_at is None:
message.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(message)
return message
reader = reader or (body.reader if body else None)
ack = ack or bool(body and body.ack)
if not reader:
view = MessageRead.model_validate(message)
# D2: an unattributed mark-read never hides a broadcast from anyone.
response.headers["Deprecation"] = "true"
response.headers["Warning"] = UNATTRIBUTED_BROADCAST_WARNING
response.headers["X-StateHub-Replacement"] = UNATTRIBUTED_BROADCAST_READ_REPLACEMENT
try:
await record_legacy_usage(
session,
interface_key=UNATTRIBUTED_BROADCAST_READ_KEY,
interface_kind="rest_api",
replacement_ref=UNATTRIBUTED_BROADCAST_READ_REPLACEMENT,
owner_component="state-hub.api",
replacement_verified=True,
identity=identity_from_request(request),
)
except Exception:
await session.rollback()
return view
canonical, values = await _reader_values(session, reader)
await upsert_receipt(session, [message.id], canonical, read=True, acknowledged=ack)
await session.commit()
return await _read_view(session, message, values, canonical)
@router.post("/{message_id}/ack", response_model=MessageRead)
async def acknowledge_message(
message_id: str,
body: MessageAck,
session: AsyncSession = Depends(get_session),
) -> MessageRead:
"""Acknowledge a broadcast (clears a standing notice for that agent)."""
message = await _get_message(message_id, session)
if not is_broadcast(message):
raise HTTPException(
status_code=400,
detail="ack applies to broadcasts only; use PATCH /messages/{id}/read",
)
canonical, values = await _reader_values(session, body.agent)
await upsert_receipt(session, [message.id], canonical, acknowledged=True)
await session.commit()
return await _read_view(session, message, values, canonical)
@router.patch("/{message_id}/archive", response_model=MessageRead)
@ -117,7 +300,8 @@ async def archive_message(
) -> AgentMessage:
message = await _get_message(message_id, session)
message.archived_at = datetime.now(timezone.utc)
if message.read_at is None:
# Broadcast archive is a global withdrawal; it no longer stamps read_at.
if message.read_at is None and not is_broadcast(message):
message.read_at = message.archived_at
await session.commit()
await session.refresh(message)
@ -135,10 +319,13 @@ async def reply_to_message(
session: AsyncSession = Depends(get_session),
) -> AgentMessage:
original = await _get_message(message_id, session)
if original.read_at is None:
replier = await canonicalize_repository_slug(session, body.from_agent)
if is_broadcast(original):
await upsert_receipt(session, [original.id], replier, read=True)
elif original.read_at is None:
original.read_at = datetime.now(timezone.utc)
reply = AgentMessage(
from_agent=await canonicalize_repository_slug(session, body.from_agent),
from_agent=replier,
to_agent=await canonicalize_repository_slug(session, original.from_agent),
subject=f"Re: {original.subject}",
body=body.body,

View file

@ -9,6 +9,7 @@ from sqlalchemy.orm import noload, selectinload
from api.config import settings
from api.database import get_session
from api.services.message_receipts import standing_notice_digests
from api.services.schema_state import schema_state
from api.flow_defs import assertion_result_to_dict, load_flow
from api.models.capability_request import CapabilityRequest
@ -136,6 +137,18 @@ def _apply_summary_flavor_view(
)
async def _live_summary_sections(
session: AsyncSession, *, refresh: bool = False
) -> dict[str, object]:
"""Sections computed per request, outside the revision-keyed cache."""
return {
"ops_runs": await get_ops_run_projection(refresh=refresh),
# STATE-WP-0093 D5: receipts are written on inbox reads, which do not
# bump the summary revision, so standing-notice counts are live.
"standing_notices": await standing_notice_digests(session),
}
@router.get("/summary", response_model=StateSummary)
async def get_summary(
request: Request,
@ -155,7 +168,7 @@ async def get_summary(
if cache_status == "hit-revision" and cached is not None:
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return _apply_summary_flavor_view(
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
cached.model_copy(update=await _live_summary_sections(session)),
include_residuals=include_residuals,
flavor=flavor,
)
@ -164,7 +177,7 @@ async def get_summary(
result = await apply_progress_section(session, cached, revision)
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return _apply_summary_flavor_view(
result.model_copy(update={"ops_runs": await get_ops_run_projection()}),
result.model_copy(update=await _live_summary_sections(session)),
include_residuals=include_residuals,
flavor=flavor,
)
@ -173,7 +186,7 @@ async def get_summary(
cache.schedule_refresh(revision)
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
return _apply_summary_flavor_view(
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
cached.model_copy(update=await _live_summary_sections(session)),
include_residuals=include_residuals,
flavor=flavor,
)
@ -182,7 +195,7 @@ async def get_summary(
cache.store(result, revision)
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
return _apply_summary_flavor_view(
result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}),
result.model_copy(update=await _live_summary_sections(session, refresh=force_refresh)),
include_residuals=include_residuals,
flavor=flavor,
)

View file

@ -1,7 +1,78 @@
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
"""Agent message schemas.
The base shapes come from hub-core. STATE-WP-0093 extends them locally with
broadcast kinds, expiry, supersede and per-reader receipt fields; direct
messages keep the hub-core semantics (the new fields stay at their defaults).
"""
import uuid
from datetime import datetime
from hub_core.schemas.agent_message import MessageCreate as _HubMessageCreate
from hub_core.schemas.agent_message import MessageRead as _HubMessageRead
from hub_core.schemas.agent_message import MessageReply
from pydantic import BaseModel
class MessageCreate(_HubMessageCreate):
# Only meaningful for broadcasts: news | standing. A broadcast without a
# kind defaults to ``news``; direct messages must leave it unset or
# ``message``.
kind: str | None = None
expires_at: datetime | None = None
supersedes_id: uuid.UUID | None = None
class MessageRead(_HubMessageRead):
kind: str = "message"
expires_at: datetime | None = None
supersedes_id: uuid.UUID | None = None
# Per-reader receipt state; populated for broadcasts when the request is
# scoped to a reader (``to_agent`` on list, ``reader`` on mark-read/ack).
# For such responses ``read_at`` is the reader's own read time.
reader: str | None = None
delivered_at: datetime | None = None
acknowledged_at: datetime | None = None
class MessageMarkRead(BaseModel):
reader: str | None = None
ack: bool = False
class MessageAck(BaseModel):
agent: str
class NoticeStatus(BaseModel):
id: uuid.UUID
from_agent: str
subject: str
body: str
created_at: datetime
expires_at: datetime | None = None
supersedes_id: uuid.UUID | None = None
acknowledged: list[str] = []
delivered_only: list[str] = []
unreached: list[str] = []
acked_count: int = 0
delivered_only_count: int = 0
unreached_count: int = 0
class StandingNoticeDigest(BaseModel):
id: uuid.UUID
subject: str
expires_at: datetime | None = None
acked: int = 0
pending: int = 0
__all__ = [
"MessageAck",
"MessageCreate",
"MessageMarkRead",
"MessageRead",
"MessageReply",
"NoticeStatus",
"StandingNoticeDigest",
]

View file

@ -10,6 +10,7 @@ from api.schemas.progress_event import ProgressEventRead
from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams
from api.schemas.suggestion import RankedSuggestionDigest
from api.schemas.agent_message import StandingNoticeDigest
from api.schemas.workstream import WorkstreamWithDeps
from api.schemas.ops_run import OpsRunProjection
@ -94,6 +95,7 @@ class StateSummary(BaseModel):
open_capability_requests: int = 0
ranked_suggestions: list[RankedSuggestionDigest] = []
ops_runs: OpsRunProjection | None = None
standing_notices: list[StandingNoticeDigest] = []
class DashboardWorkplanRow(BaseModel):

View file

@ -0,0 +1,212 @@
"""Per-recipient broadcast receipts and standing notices (STATE-WP-0093).
Broadcast visibility is per reader: a ``message_receipts`` row per
(message, agent) records delivery, read and acknowledgement. Direct messages
never get receipts and keep the global ``AgentMessage.read_at``.
- ``news`` broadcasts stay in a reader's unread inbox until that reader has
any receipt (delivery at orientation is enough).
- ``standing`` broadcasts stay until the reader acknowledges, the notice
expires, or it is superseded (which archives it).
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable, Sequence
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, exists, func, not_, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.agent_message import BROADCAST, AgentMessage, MessageReceipt
from api.models.managed_repo import ManagedRepo
from api.schemas.agent_message import NoticeStatus, StandingNoticeDigest
NEWS_DEFAULT_TTL = timedelta(days=30)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def is_broadcast(message: AgentMessage) -> bool:
return message.to_agent == BROADCAST
def not_expired(now: datetime):
return or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now)
def broadcast_unread_clause(reader_values: Sequence[str], now: datetime):
"""SQL clause: broadcast is still unread for a reader (any of its slugs)."""
has_any_receipt = exists().where(
and_(
MessageReceipt.message_id == AgentMessage.id,
MessageReceipt.agent.in_(reader_values),
)
)
has_ack = exists().where(
and_(
MessageReceipt.message_id == AgentMessage.id,
MessageReceipt.agent.in_(reader_values),
MessageReceipt.acknowledged_at.is_not(None),
)
)
return and_(
AgentMessage.to_agent == BROADCAST,
not_expired(now),
or_(
and_(AgentMessage.kind == "standing", not_(has_ack)),
and_(AgentMessage.kind != "standing", not_(has_any_receipt)),
),
)
async def upsert_receipt(
session: AsyncSession,
message_ids: Iterable[uuid.UUID],
agent: str,
*,
delivered: bool = False,
read: bool = False,
acknowledged: bool = False,
at: datetime | None = None,
) -> None:
"""Idempotently stamp receipt fields; the first timestamp wins."""
ids = list(message_ids)
if not ids or not agent or agent == BROADCAST:
return
at = at or utcnow()
rows = [
{
"message_id": mid,
"agent": agent,
"delivered_at": at if delivered else None,
"read_at": at if (read or acknowledged) else None,
"acknowledged_at": at if acknowledged else None,
}
for mid in ids
]
stmt = pg_insert(MessageReceipt).values(rows)
table = MessageReceipt.__table__
stmt = stmt.on_conflict_do_update(
index_elements=[table.c.message_id, table.c.agent],
set_={
col: func.coalesce(table.c[col], stmt.excluded[col])
for col in ("delivered_at", "read_at", "acknowledged_at")
},
)
await session.execute(stmt)
async def receipts_for(
session: AsyncSession,
message_ids: Iterable[uuid.UUID],
reader_values: Sequence[str],
) -> dict[uuid.UUID, dict[str, datetime | None]]:
"""Merged receipt state per message across a reader's historical slugs."""
ids = list(message_ids)
if not ids:
return {}
result = await session.execute(
select(MessageReceipt).where(
MessageReceipt.message_id.in_(ids),
MessageReceipt.agent.in_(reader_values),
)
)
merged: dict[uuid.UUID, dict[str, datetime | None]] = {}
for receipt in result.scalars().all():
state = merged.setdefault(
receipt.message_id,
{"delivered_at": None, "read_at": None, "acknowledged_at": None},
)
for field in state:
value = getattr(receipt, field)
if value is not None and (state[field] is None or value < state[field]):
state[field] = value
return merged
async def _active_repo_slugs(session: AsyncSession) -> list[str]:
result = await session.execute(
select(ManagedRepo.slug).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
)
return list(result.scalars().all())
async def live_standing_notices(
session: AsyncSession, now: datetime | None = None
) -> list[AgentMessage]:
now = now or utcnow()
result = await session.execute(
select(AgentMessage)
.where(
AgentMessage.to_agent == BROADCAST,
AgentMessage.kind == "standing",
AgentMessage.archived_at.is_(None),
not_expired(now),
)
.order_by(AgentMessage.created_at.desc())
)
return list(result.scalars().all())
async def notice_statuses(session: AsyncSession) -> list[NoticeStatus]:
notices = await live_standing_notices(session)
if not notices:
return []
repos = await _active_repo_slugs(session)
result = await session.execute(
select(MessageReceipt).where(
MessageReceipt.message_id.in_([n.id for n in notices])
)
)
by_notice: dict[uuid.UUID, list[MessageReceipt]] = {}
for receipt in result.scalars().all():
by_notice.setdefault(receipt.message_id, []).append(receipt)
statuses: list[NoticeStatus] = []
for notice in notices:
receipts = by_notice.get(notice.id, [])
acked = sorted({r.agent for r in receipts if r.acknowledged_at is not None})
seen = {r.agent for r in receipts}
delivered_only = sorted(seen - set(acked))
unreached = [slug for slug in repos if slug not in seen]
statuses.append(
NoticeStatus(
id=notice.id,
from_agent=notice.from_agent,
subject=notice.subject,
body=notice.body,
created_at=notice.created_at,
expires_at=notice.expires_at,
supersedes_id=notice.supersedes_id,
acknowledged=acked,
delivered_only=delivered_only,
unreached=unreached,
acked_count=len(acked),
delivered_only_count=len(delivered_only),
unreached_count=len(unreached),
)
)
return statuses
async def standing_notice_digests(session: AsyncSession) -> list[StandingNoticeDigest]:
statuses = await notice_statuses(session)
if not statuses:
return []
repos = set(await _active_repo_slugs(session))
digests = []
for status in statuses:
acked_repos = len(repos.intersection(status.acknowledged))
digests.append(
StandingNoticeDigest(
id=status.id,
subject=status.subject,
expires_at=status.expires_at,
acked=acked_repos,
pending=len(repos) - acked_repos,
)
)
return digests

View file

@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Observable data loader: fetches /messages/notices (live standing notices)."""
import json
import os
import urllib.error
import urllib.request
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
try:
with urllib.request.urlopen(f"{API_BASE}/messages/notices", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.URLError as e:
print(json.dumps({"error": str(e), "notices": []}))

View file

@ -68,6 +68,7 @@ Current loaders:
| `decisions.json.py` | `/decisions/` |
| `domains.json.py` | `/domains/` |
| `messages.json.py` | `/messages/` |
| `notices.json.py` | `/messages/notices` |
| `progress.json.py` | `/progress/` |
| `repos.json.py` | `/repos/` |
| `sbom.json.py` | `/sbom/aggregated` |

View file

@ -11,14 +11,16 @@ import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
const inboxState = (async function*() {
let failures = 0;
while (true) {
let messages = [], ok = false;
let messages = [], notices = [], ok = false;
try {
const resp = await apiFetch("/messages/?limit=100");
ok = resp.ok;
if (ok) messages = await resp.json();
const nresp = await apiFetch("/messages/notices");
if (nresp.ok) notices = await nresp.json();
} catch {}
failures = ok ? 0 : failures + 1;
yield {messages, ok, ts: new Date()};
yield {messages, notices, ok, ts: new Date()};
await waitForVisible(pollDelay({ok, failures}));
}
})();
@ -26,12 +28,17 @@ const inboxState = (async function*() {
```js
const messages = inboxState.messages ?? [];
const notices = inboxState.notices ?? [];
const _ok = inboxState.ok ?? false;
const _ts = inboxState.ts;
const unread = messages.filter(m => !m.read_at && !m.archived_at);
const read = messages.filter(m => m.read_at && !m.archived_at);
const archived = messages.filter(m => m.archived_at);
// Broadcast read state is per reader (STATE-WP-0093): the dashboard has no
// reader identity, so live broadcasts get their own section.
const isBc = m => m.to_agent === "broadcast";
const unread = messages.filter(m => !isBc(m) && !m.read_at && !m.archived_at);
const read = messages.filter(m => !isBc(m) && m.read_at && !m.archived_at);
const broadcasts = messages.filter(m => isBc(m) && !m.archived_at);
const archived = messages.filter(m => m.archived_at);
// Group unread by agent for KPI
const agentCounts = {};
@ -55,6 +62,12 @@ const _kpiBox = html`<div class="kpi-infobox">
<div class="kpi-row-value" style="color:${unread.length > 0 ? '#d97706' : 'inherit'}">${unread.length}</div>
</div>
</div>
<div class="kpi-row">
<span class="kpi-row-label">standing notices</span>
<div class="kpi-row-right">
<div class="kpi-row-value" style="font-size:1rem">${notices.length}</div>
</div>
</div>
<div class="kpi-row">
<span class="kpi-row-label">total</span>
<div class="kpi-row-right">
@ -85,6 +98,39 @@ Inter-agent coordination messages. Agents send messages via `send_message()` MCP
---
## Standing notices
Live `standing` broadcasts stay in every agent's inbox until that agent
acknowledges them (`POST /messages/{id}/ack`). Repositories listed as not yet
acknowledged are the ones still out of date.
```js
function repoList(label, slugs, color) {
if (!slugs.length) return "";
return html`<details class="notice-list"><summary style="color:${color}">${label} (${slugs.length})</summary>
<div class="notice-slugs">${slugs.join(", ")}</div></details>`;
}
if (notices.length === 0) {
display(html`<p class="dim">No live standing notices.</p>`);
} else {
display(html`<div class="msg-list">${notices.map(n => html`<div class="msg-card" style="border-left-color:#7c3aed">
<div class="msg-header">
<span class="msg-from">${n.from_agent}</span>
<span class="msg-kind">standing</span>
<span class="msg-time">expires ${n.expires_at ? new Date(n.expires_at).toLocaleDateString() : "never"}</span>
</div>
<div class="msg-subject">${n.subject}</div>
<div class="notice-counts">${n.acked_count} acknowledged · ${n.delivered_only_count} delivered, not acknowledged · ${n.unreached_count} not reached</div>
${repoList("Not yet acknowledged — delivered", n.delivered_only, "#d97706")}
${repoList("Not yet acknowledged — never reached", n.unreached, "#dc2626")}
${repoList("Acknowledged", n.acknowledged, "#059669")}
</div>`)}</div>`);
}
```
---
## Unread
```js
@ -96,7 +142,7 @@ function fmtDate(s) {
function renderMessage(m, showMarkRead = false) {
const isBroadcast = m.to_agent === "broadcast";
const borderColor = !m.read_at ? "#d97706" : "#6b7280";
const borderColor = isBroadcast ? "#7c3aed" : (!m.read_at ? "#d97706" : "#6b7280");
async function onMarkRead() {
await fetch(`${API}/messages/${m.id}/read`, {method: "PATCH"});
@ -111,9 +157,10 @@ function renderMessage(m, showMarkRead = false) {
<span class="msg-from">${m.from_agent}</span>
<span class="msg-arrow"></span>
<span class="msg-to ${isBroadcast ? 'msg-broadcast' : ''}">${m.to_agent}</span>
${isBroadcast ? html`<span class="msg-kind">${m.kind}</span>` : ""}
<span class="msg-time">${fmtDate(m.created_at)}</span>
<div class="msg-actions">
${showMarkRead ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
${showMarkRead && !isBroadcast ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
<button class="msg-btn msg-btn-archive" onclick=${onArchive}>Archive</button>
</div>
</div>
@ -135,6 +182,18 @@ if (unread.length === 0) {
---
## Broadcasts
```js
if (broadcasts.length === 0) {
display(html`<p class="dim">No live broadcasts.</p>`);
} else {
display(html`<div class="msg-list">${broadcasts.map(m => renderMessage(m, false))}</div>`);
}
```
---
## Read
```js
@ -186,4 +245,9 @@ if (archived.length === 0) {
.msg-body { white-space: pre-wrap; margin: 0.4rem 0 0; font-family: var(--mono); font-size: 0.8rem; background: var(--theme-background); padding: 0.5rem; border-radius: 4px; }
.msg-thread { font-size: 0.7rem; color: var(--theme-foreground-muted, #999); margin-top: 0.2rem; }
.dim { color: gray; font-style: italic; }
.msg-kind { font-size: 0.7rem; font-weight: 600; color: #7c3aed; border: 1px solid #c4b5fd; border-radius: 4px; padding: 0 0.3rem; }
.notice-counts { font-size: 0.8rem; color: var(--theme-foreground-muted, #555); margin: 0.2rem 0; }
.notice-list { font-size: 0.8rem; margin-top: 0.2rem; }
.notice-list summary { cursor: pointer; font-weight: 600; }
.notice-slugs { font-family: var(--mono); font-size: 0.75rem; padding: 0.3rem 0; }
</style>

View file

@ -247,8 +247,15 @@ Use `"broadcast"` as `to_agent` to send to all agents.
|------|----------|-------------|
| `get_messages(to_agent?, from_agent?, unread_only?, limit?)` | `to_agent`: your agent name; `unread_only`: True recommended at session start | Check for pending coordination messages. |
| `send_message(from_agent, to_agent, subject, body, thread_id?)` | all except `thread_id` required | Send a coordination message to another agent (or broadcast). |
| `mark_message_read(message_id)` | `message_id`: UUID | Mark a message as read after acting on it. |
| `reply_to_message(message_id, from_agent, body)` | all required | Reply in-thread; marks original as read. |
| `mark_message_read(message_id, reader?)` | `message_id`: UUID; `reader`: your repo slug | Mark a message as read after acting on it. For broadcasts pass `reader` — read state is per reader; without it the call is a deprecated no-op for broadcasts (STATE-WP-0093). |
| `acknowledge_notice(message_id, agent)` | both required | Acknowledge a `standing` broadcast notice after acting on it; clears it from `agent`'s inbox (`POST /messages/{id}/ack`). Codex server now; main server pending hub-core. |
| `reply_to_message(message_id, from_agent, body)` | all required | Reply in-thread; marks original as read (for a broadcast: for the replier only). |
Broadcast kinds (STATE-WP-0093): `news` (default for `to_agent="broadcast"`,
30-day default expiry) appears once per reader — the unread inbox call records
delivery; `standing` stays in each reader's unread inbox until acknowledged,
expired or superseded (`supersedes_id` archives the predecessor). Live notices
and who has acknowledged them: `GET /messages/notices`.
Dashboard: `http://localhost:3000/inbox`

View file

@ -10,6 +10,7 @@ from __future__ import annotations
import json
import os
from typing import Any
from urllib.parse import quote
import httpx
from fastmcp import FastMCP
@ -59,9 +60,22 @@ def get_messages(to_agent: str, unread_only: bool = True) -> str:
@mcp.tool()
def mark_message_read(message_id: str) -> str:
"""Mark one coordination message as read."""
return _json(_request("PATCH", f"/messages/{message_id}/read", {}))
def mark_message_read(message_id: str, reader: str | None = None) -> str:
"""Mark one coordination message as read.
Pass ``reader`` (your repository slug) so a broadcast is marked read for
you only; without it a broadcast stays visible and the call is deprecated.
"""
path = f"/messages/{message_id}/read"
if reader:
path += f"?reader={quote(reader, safe='')}"
return _json(_request("PATCH", path, {}))
@mcp.tool()
def acknowledge_notice(message_id: str, agent: str) -> str:
"""Acknowledge a standing notice after acting on it (clears it for ``agent``)."""
return _json(_request("POST", f"/messages/{message_id}/ack", {"agent": agent}))
@mcp.tool()

View file

@ -0,0 +1,92 @@
"""per-recipient broadcast receipts and standing notices (STATE-WP-0093)
Creates ``message_receipts`` (per-reader delivered/read/acknowledged state for
broadcasts) and adds ``kind``, ``expires_at`` and ``supersedes_id`` to
``agent_messages``. Existing broadcasts are backfilled to ``kind='news'`` and,
per the founder decision on D6 (2026-09-22), archived so that none of them
re-surfaces once broadcast visibility stops depending on the global
``read_at``. Direct messages are not touched.
Downgrade drops the table, index and columns. Archive stamps stay (they are
reversible through the API and harmless under the old global read state).
Revision ID: d7e8f9a0b1c2
Revises: c6f7a8b9d0e1
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "d7e8f9a0b1c2"
down_revision = "c6f7a8b9d0e1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"agent_messages",
sa.Column(
"kind",
sa.String(length=20),
nullable=False,
server_default="message",
),
)
op.add_column(
"agent_messages",
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"agent_messages",
sa.Column(
"supersedes_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey(
"agent_messages.id",
ondelete="SET NULL",
name="fk_agent_messages_supersedes_id",
),
nullable=True,
),
)
op.create_index(
"ix_agent_messages_to_kind_archived",
"agent_messages",
["to_agent", "kind", "archived_at"],
)
op.create_table(
"message_receipts",
sa.Column(
"message_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("agent_messages.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("agent", sa.String(length=100), primary_key=True),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_message_receipts_agent", "message_receipts", ["agent"])
# D6: existing broadcasts become news and are archived (only broadcasts).
op.execute(
"UPDATE agent_messages SET kind = 'news' WHERE to_agent = 'broadcast'"
)
op.execute(
"UPDATE agent_messages SET archived_at = now() "
"WHERE to_agent = 'broadcast' AND archived_at IS NULL"
)
def downgrade() -> None:
op.drop_index("ix_message_receipts_agent", table_name="message_receipts")
op.drop_table("message_receipts")
op.drop_index("ix_agent_messages_to_kind_archived", table_name="agent_messages")
op.drop_constraint(
"fk_agent_messages_supersedes_id", "agent_messages", type_="foreignkey"
)
op.drop_column("agent_messages", "supersedes_id")
op.drop_column("agent_messages", "expires_at")
op.drop_column("agent_messages", "kind")

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()

View file

@ -4,12 +4,12 @@ type: workplan
title: "Per-recipient broadcast receipts and standing notices"
domain: infotech
repo: state-hub
status: proposed
status: active
owner: claude
topic_slug: infotech
flavor: implementation
created: "2026-09-21"
updated: "2026-09-21"
updated: "2026-09-22"
related:
- STATE-WP-0091
origin: founder-direction
@ -150,7 +150,7 @@ acknowledgement needs one instruction line, changed once:
```task
id: STATE-WP-0093-T01
status: wait
status: done
priority: high
state_hub_task_id: "94eec945-b728-50cb-aefb-60eca5aeccd9"
```
@ -162,11 +162,28 @@ 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
```task
id: STATE-WP-0093-T02
status: wait
status: progress
priority: high
state_hub_task_id: "6680d408-ebce-5b2c-8a97-84f0bf011754"
```
@ -182,11 +199,21 @@ 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
```task
id: STATE-WP-0093-T03
status: wait
status: done
priority: high
state_hub_task_id: "33010aa6-326d-5f52-9ff3-32b09c9e88c4"
```
@ -201,11 +228,21 @@ 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)
```task
id: STATE-WP-0093-T04
status: wait
status: progress
priority: medium
state_hub_task_id: "43529a93-6e49-51c8-a358-726f7e737fab"
```
@ -222,11 +259,21 @@ 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
```task
id: STATE-WP-0093-T05
status: wait
status: progress
priority: medium
state_hub_task_id: "fdb44fd9-ab44-5e26-86b3-a4f0f8a4e701"
```
@ -237,11 +284,20 @@ Implement D5: `GET /messages/notices`, `standing_notices` in
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
```task
id: STATE-WP-0093-T06
status: wait
status: done
priority: high
state_hub_task_id: "d68aa1fa-0f09-5d64-a4fc-cbc24180f2c3"
```
@ -257,6 +313,12 @@ 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
```task