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

@ -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,
)