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

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