state-hub/docs/inbox-change-feed-contract.md
tegwick 4ffc723673
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 38s
Inbox change feed v1 (STATE-WP-0098-T05); close out open waits
- migration c4d5e6f7a8b9: inbox revision sequence, per-transaction revision
  trigger with advisory lock, message tombstones
- GET /messages/feed/snapshot and /messages/feed?after= per the accepted
  contract; archived_at is now first-write-wins
- STATE-WP-0098 blocked: T02/T04 qualified waits on owners and HUB-WP-0011-T01
- STATE-WP-0094-T05: correct the cited CCR, wait on railiance-platform
- legacy-meter review evidence 2026-10-14

Not deployed; the migration deploy is a separate confirmed step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 356991@bnt-lap001
Assistant-Session: 5d99ea69-ed55-4a1e-8d97-f4f312db247f
2026-10-14 02:48:06 +02:00

20 KiB
Raw Blame History

State Hub inbox change-feed contract

Status: implemented (not deployed) (2026-10-14, STATE-WP-0098-T05; migration c4d5e6f7a8b9). Accepted by the founder 2026-10-13: D1-D4 as recommended. Deliverable of STATE-WP-0098-T03. Deploy is a separate confirmed step. Implementation details and deviations are in §8. It is the State Hub input for HUB-WP-0011-T01 (the first row of its "Dependency review — 2026-10-04" table). Date: 2026-10-13.

Scope: how hub-core keeps a derived, read-only copy of the State Hub inbox fresh. Hub-core would replace its one-shot snapshot (import_snapshot in hub_core/runtime/inbox_projection.py) with this feed. This document does not switch any reader or writer. Reader switches remain under STATE-WP-0079-T04 and HUB-WP-0011-T03.

Sources read: api/models/agent_message.py, api/routers/messages.py, api/services/message_receipts.py, migrations f3a4b5c6d7e8 and d7e8f9a0b1c2, docs/evidence/statehub-inbox-pilot-20260905.{md,json}, STATE-WP-0079 T08/T09, HUB-WP-0011, and hub-core inbox_projection.py and schemas/agent_message.py.

1. Identity, transport and auth

  • Contract id: statehub.inbox-feed. Version: v1. Every response carries "contract": "statehub.inbox-feed.v1". Additive fields do not bump the version. A changed meaning, a removed field or a changed cursor encoding bumps it to v2, and State Hub then serves both versions for the parity window.
  • Endpoints (read-only, GET only):
    • GET /messages/feed/snapshot returns a consistent full image and the cursor that goes with it.
    • GET /messages/feed?after=<cursor>&limit=<n> returns changes after the cursor.
    • Both send Cache-Control: no-store. Any other method returns 405.
  • Direction: pull. Hub-core's refresh runner calls State Hub over the in-cluster ClusterIP. Today the State Hub API has no application-level auth; access is bounded by the network (ClusterIP, the workstation SSH forward). The feed adds no new credentials. It inherits that boundary.
  • Admission (unverified): the pilot evidence proves the opposite direction. NetworkPolicy admits State Hub API pods to hub-core, and transport_from_state_hub_without_token returned 401. It does not show whether hub-core pods can reach the State Hub API. If they cannot, the rapp-core-hub packaging owner adds a scoped ingress/egress rule for the refresh runner only. That is a packaging change, not a credential, and it belongs to the second T01 row of HUB-WP-0011.
  • Message bodies: the feed includes body, because the inbox cannot be served without it. Evidence and receipts must keep excluding bodies, as the pilot did.

2. Consistent snapshot and monotonic cursor

What the schema has today

agent_messages has the columns id, from_agent, to_agent, subject, body, thread_id, read_at, archived_at, kind, expires_at, supersedes_id and created_at. message_receipts has (message_id, agent), delivered_at, read_at and acknowledged_at.

Neither table has updated_at, a sequence, a revision column or a tombstone table. No existing column can serve as a cursor:

  • Timestamps are application-clock values. They come from datetime.now() and utcnow(), not from commit time. Concurrent transactions can commit them out of order, and they collide on ties.
  • Some changes leave no timestamp. A receipt upsert can change one field among several. An archive call on a row that is already archived overwrites archived_at (see archive_message). A delete leaves nothing behind.

Required migration (described here, not written)

One Alembic revision, roughly 80 lines:

  1. Revision sequence. Create the sequence inbox_revision_seq (bigint).
  2. Revision columns. Add revision bigint NOT NULL to agent_messages and to message_receipts. Backfill both with one value from the sequence, so that one snapshot equals one revision. Index revision on both tables.
  3. Tombstone table. Add message_tombstones with the columns message_id uuid PK, revision bigint NOT NULL, deleted_at timestamptz NOT NULL and reason text.
  4. Write trigger. A BEFORE INSERT OR UPDATE trigger on both tables. It
    • skips the update when NEW IS NOT DISTINCT FROM OLD, so that no-op COALESCE upserts and repeated reads do not bump the revision;
    • takes pg_advisory_xact_lock(<inbox-feed constant>);
    • assigns one revision per transaction. On the first row it calls nextval, caches the value with set_config('statehub.inbox_rev', …, true), and reuses it for every later row in the same transaction.
  5. Delete trigger. A BEFORE DELETE trigger on agent_messages. It writes a tombstone row under the same transaction revision.

Why the advisory lock is needed. A sequence value is drawn at write time, not at commit time. Without the lock, transaction A can draw revision 41, transaction B can draw 42 and commit first, and a reader at cursor 42 would never see 41. With the lock, inbox writes are serialized, so commit order equals revision order. Any visible maximum revision is then a safe watermark.

Inbox write volume is low: sends, read marks, receipts, archives. The lock cost is negligible. Unverified: there is no load measurement. If the lock is rejected, the alternative is an xid8 column plus a pg_snapshot_xmin(pg_current_snapshot()) watermark. That avoids the lock but is more complex to serve.

The cursor is an opaque string that encodes v1:<revision>. Consumers must not do arithmetic on it. They may compare the decoded revisions only for the out-of-order checks in §5.

Snapshot

GET /messages/feed/snapshot runs in one REPEATABLE READ READ ONLY transaction and returns:

contract, source_instance (from /state/health instance_label),
source_time (DB now() of the snapshot txn), cursor (max visible revision),
messages[] (all rows, archived included), receipts[], tombstones[] (within horizon),
count, content_hash (sha256 over the normalized rows, as in the pilot)

It is returned as a single response. The pilot captured 20 rows. Unverified: the current production row count. If the response grows past a few MB, the snapshot is paged inside one exported snapshot (pg_export_snapshot). That is a v1 addition, not a new version.

Hub-core could poll the snapshot endpoint and diff it, with content_hash + source_time as the revision. It gives freshness but no ordering: hashes cannot be compared for order, and source_time is only as monotonic as the DB clock. It also cannot distinguish a delete from a filtering bug. Acceptable only as a stop-gap behind the existing snapshot-pilot label.

3. Change records and event types

The wire carries state, not deltas. Each change is the full post-image of one entity at its revision:

{revision, entity: "message"|"receipt"|"tombstone", key, op: "upsert"|"delete", state}

Hub-core works out the event type by comparing the post-image with its current row. This keeps replay idempotent (§6). A page never splits a revision: the server extends the page past limit until the last revision is complete.

Only the transitions below can occur in the current State Hub model.

Event Source mechanics (today) Appears on the feed as
created POST /messages/, POST /{id}/reply message upsert with no prior row; thread_id, supersedes_id, kind and expires_at set at creation
read (direct) PATCH /{id}/read without a reader; a reply marks the original as read; archiving a direct message stamps read_at if it is null message upsert where read_at goes from null to a value. There is one global read_at and no reader identity
receipt (broadcast, per reader) PATCH /{id}/read?reader=, POST /{id}/ack, a reply to a broadcast, and GET /messages/?unread_only=true (stamps delivered_at) receipt upsert keyed (message_id, agent). The fields are first-write-wins (COALESCE)
archived PATCH /{id}/archive, and supersession of a broadcast message upsert where archived_at is set. Since T05, re-archiving keeps the first archived_at and produces no new revision
superseded sending a broadcast with supersedes_id archives its predecessor in the same transaction one revision that holds the new message and the predecessor's archive upsert
reply / thread link thread_id points at the root; a reply inherits the root carried in created. There is no separate link event
deleted no API route deletes messages. Only a manual DB delete can tombstone record. The delete cascades: receipts are dropped, and thread_id/supersedes_id on other rows become NULL. Those referencing rows arrive as upserts in the same revision

State that does not exist, so no event is emitted: unarchive (no route does it), message edits, per-reader read state for direct messages, who archived a message, and per-reader archive.

Expiry is not an event. expires_at is a stored value, and visibility (not_expired) is evaluated against the clock at read time. Hub-core must evaluate it the same way.

Message identity aliases are resolved by State Hub reads (MessageIdentityAlias), but no State Hub code writes them. Unverified: whether that table is populated in the State Hub DB. Aliases are not in v1.

Gaps against hub-core's pilot

  • Nothing the pilot carries is missing from State Hub. Its MessageRead (id, from_agent, to_agent, subject, body, thread_id, read_at, archived_at, created_at) is a strict subset.
  • The reverse gap is hub-core's to close. Hub-core's model lacks kind, expires_at, supersedes_id and receipts. Without receipts, its unread_only filter on read_at gives wrong answers for broadcasts: since STATE-WP-0093, broadcast read state lives in message_receipts.
  • Delivery stamps stop for switched readers. A State Hub GET ?unread_only=true writes delivered_at. A reader moved to hub-core would stop producing those stamps unless hub-core writes them back, and that would make hub-core a second writer. See founder decision D3.
  • Recipient slugs are stored as written. to_agent may hold a historical repository slug, and State Hub canonicalizes it when reading (_reader_values). The feed emits stored values. Resolving aliases is HUB-WP-0011-T02 scope.

4. Deletion versus retention

Settled by the STATE-WP-0079 T08/T09 record

  • State Hub is the only message writer throughout the read cutover. Sends, read marks, acks, archives and replies all stay on State Hub. A switched reader that changes state calls the State Hub routes.
  • The hub-core copy is derived and read-only. It is never authoritative, and it never mutates source history.
  • Rollback is State Hub /messages/.
  • The snapshot pilot must not be presented as a fresh inbox.
  • Writer cutover and the B2/B3 route migration are out of scope here.

Proposed by this contract (to be accepted with the contract)

  • Archive is not deletion. Archived rows stay in the feed and in the snapshot. Hub-core keeps them so that thread closure holds, as the pilot already requires.
  • A State Hub delete always produces a tombstone (the trigger in §2), even if the delete is a manual DB operation.
  • On a tombstone, hub-core deletes its derived row and keeps only (message_id, revision, deleted_at). It must not keep the body.
  • Hub-core never emits its own tombstones.

Founder decisions (resolved 2026-10-13)

The T08/T09 record did not settle these. The founder accepted all four recommendations on 2026-10-13: 90-day tombstones (D1), no purge (D2), delivery-stamp loss accepted (D3), migration approved (D4).

  • D1 — Tombstone retention horizon. Recommend: keep tombstones for 90 days, and at least twice the maximum consumer lag. A cursor older than the horizon gets 410, and the consumer re-snapshots.
  • D2 — Purging during the retirement window. Should State Hub purge any messages before it retires, such as expired news (the default TTL is 30 days) or old archived rows? Recommend: no purge. Use archive only until State Hub retires. Retention is then decided once, by the receiving owner.
  • D3 — Delivery stamps. Delivery stamps (delivered_at) are lost for a reader that moves to hub-core. Recommend: accept the loss, and pick the first switched reader (STATE-WP-0098-T04) from readers that do not rely on broadcast delivery tracking. Do not add a write-back path.
  • D4 — Approve the migration. Approve the §2 migration (revision columns, trigger, tombstone table) as a retirement-enabling change to a frozen service. Recommend: approve. It is bounded and additive, and it is the only way to give a true cursor.

5. Recovery, ordering and freshness

Feed response.

contract, source_instance, source_time (DB now() at serve time),
high_water (cursor of the max committed revision), next_cursor, has_more,
compacted_before (oldest cursor still servable), changes[]

Compaction is implicit. Rows hold only their latest revision, so intermediate states are never replayable. A consumer that resumes from cursor c gets the latest post-image of every entity changed after c. Converging on the final state is guaranteed; seeing every step is not. Tombstones are the only history kept, and they are bounded by D1.

Recovery.

Situation Behaviour
after is older than compacted_before 410 Gone with {"resnapshot_required": true}
Cursor from another source_instance or another contract version 409. The consumer re-snapshots
Consumer restarts It resumes from its persisted cursor. Replaying pages it already applied is a no-op (§6)
Re-snapshot Applies the snapshot as a full image under the §6 rules. Rows absent from the snapshot and not tombstoned are a consistency error: refuse and alert. They are not silently deleted

Out-of-order refusal (consumer side).

  • Reject a page whose changes[0].revision is at or below the stored cursor, unless it is an exact replay.
  • Reject a page whose high_water is lower than one already seen from the same source_instance. That indicates a source restore or rollback. The consumer stops serving as fresh and re-snapshots.

Freshness.

  • State Hub provides source_time, high_water and source_instance on every response, including empty ones. An empty page is a heartbeat: "nothing changed as of source_time".
  • Lag = consumer clock − last successful source_time.
  • Maximum lag, stale-source behaviour and the read-mode header are hub-core's and rapp-core-hub's to set. This contract only supplies the signals.

6. Idempotency without an unconditional overwrite

Hub-core's importer refuses any receiver that differs from the bundle. The contract replaces "refuse any difference" with "refuse any difference that a higher source revision does not justify". Hub-core stores source_revision on each derived row and applies these rules per entity key:

Incoming revision r vs stored s Outcome
No stored row Insert
r < s Ignore (stale replay). Log it
r == s, identical normalized hash No-op
r == s, different content Refuse. Integrity error; stop and re-snapshot
r > s Replace with the post-image, after the field checks below

Field checks (for r > s). These catch source bugs instead of copying them:

  • Immutable fields must not change: id, from_agent, to_agent, subject, body, kind, created_at, expires_at.
  • thread_id and supersedes_id may change only from a value to NULL, and only in a revision that tombstones the referenced message.
  • read_at and the receipt fields may change only from null to a value.
  • archived_at may change only from null to a value (first-write-wins since T05). It never returns to null.

Anything else is refused, and hub-core re-snapshots.

The whole page is applied in one destination transaction, in revision order, and the cursor is updated in the same transaction. Thread roots arrive in the same or an earlier revision, because a reply cannot exist before its root, so the pilot's thread-closure check still holds.

Small State Hub fix (done in T05): archive_message is first-write-wins for archived_at, so the re-archive rule above is "null to value only".

7. State Hub implementation sketch (implemented 2026-10-14, see §8)

File Change Size
migrations/versions/<new>_inbox_feed_revision.py §2 sequence, columns, backfill, triggers, tombstone table ~80 lines
api/models/agent_message.py revision on both models; MessageTombstone model ~20
api/schemas/inbox_feed.py snapshot/page/change envelopes; cursor encode and decode ~70
api/routers/message_feed.py the two GET routes; repeatable-read transaction; page extension to a revision boundary; 409/410 handling ~130
api/main.py include the router before messages.router ~2
api/routers/messages.py first-write-wins archived_at ~3
tests/test_message_feed.py snapshot/feed parity, per-transaction revisions, no bump on no-op upsert, tombstone on delete, 410 past horizon, page boundary not split, replay no-op ~250

The total is about 550 lines, roughly half of them tests. Expect one or two sessions, plus a deploy through the normal State Hub migration path.

The trigger needs PostgreSQL. Unverified: whether the test suite runs on SQLite. If it does, the trigger tests need the Postgres test fixture.

The hub-core side (source_revision column, page applier, cursor store, staleness) is not sketched here. It belongs to HUB-WP-0011-T01.

8. Implementation notes (2026-10-14)

Implemented as STATE-WP-0098-T05. Files: migration migrations/versions/c4d5e6f7a8b9_inbox_feed_revision.py, api/models/agent_message.py, api/schemas/inbox_feed.py, api/routers/message_feed.py, api/main.py, api/routers/messages.py, tests/test_message_feed.py. Where this section and §1-§7 differ, this section describes what the code does.

  • Revision cache. inbox_feed_revision() caches "<txid>:<revision>" in the transaction-local GUC statehub.inbox_rev and reuses it only when the txid matches. A savepoint that rolls back after drawing a revision can make a transaction use two revisions; both stay ordered under the advisory lock (key 5348490098).
  • Keys and state. key is the message UUID for message and tombstone, and "<message_id>:<agent>" for receipt. state is the full row and includes revision. Snapshot rows have the same shape as state. Within a revision the order is messages, receipts, tombstones.
  • Foreign cursor (409). The cursor stays v1:<revision> and cannot carry the instance. The server answers 409 (resnapshot_required: true) when the cursor has another version prefix (for example v2:), when the optional query parameter source_instance differs from this instance's instance_label, or when the cursor is ahead of high_water (another instance or a restored source). A malformed cursor gets 400.
  • Horizon (410). compacted_before is v1:<R>, where R is the highest revision of any tombstone older than 90 days (v1:0 if there is none). Those tombstones are no longer served, and after < R gets 410 with resnapshot_required: true. The horizon is enforced logically. Nothing purges tombstones physically (D2). The snapshot also carries compacted_before (additive).
  • Paging. limit defaults to 500, maximum 5000. A page may exceed it to finish a revision. An empty page returns next_cursor equal to after.
  • content_hash is sha256 over canonical JSON (sort_keys, compact separators) of {messages, receipts, tombstones}. Rows are sorted by key and timestamps are ISO strings. It is deterministic but not byte-identical to the pilot's hash.
  • Delete reason. The tombstone reason comes from SET LOCAL statehub.delete_reason = '...' if the deleting session sets it, and is NULL otherwise.
  • Not covered. A direct DELETE on message_receipts writes no tombstone, and no API route does this. Receipts disappear only through the message delete cascade, which the message tombstone implies.
  • Tests and create_all. The test suite builds its schema with Base.metadata.create_all. The models attach the same trigger DDL via after_create. The migration keeps its own frozen copy, and a test asserts the two are identical. It also runs the migration's downgrade and upgrade on the test database in a rolled-back transaction.