- 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
20 KiB
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 tov2, and State Hub then serves both versions for the parity window. - Endpoints (read-only, GET only):
GET /messages/feed/snapshotreturns 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_tokenreturned 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()andutcnow(), 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(seearchive_message). A delete leaves nothing behind.
Required migration (described here, not written)
One Alembic revision, roughly 80 lines:
- Revision sequence. Create the sequence
inbox_revision_seq(bigint). - Revision columns. Add
revision bigint NOT NULLtoagent_messagesand tomessage_receipts. Backfill both with one value from the sequence, so that one snapshot equals one revision. Indexrevisionon both tables. - Tombstone table. Add
message_tombstoneswith the columnsmessage_id uuid PK,revision bigint NOT NULL,deleted_at timestamptz NOT NULLandreason text. - Write trigger. A
BEFORE INSERT OR UPDATEtrigger 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 withset_config('statehub.inbox_rev', …, true), and reuses it for every later row in the same transaction.
- skips the update when
- Delete trigger. A
BEFORE DELETEtrigger onagent_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.
No-migration interim (not recommended)
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_idand receipts. Without receipts, itsunread_onlyfilter onread_atgives wrong answers for broadcasts: since STATE-WP-0093, broadcast read state lives inmessage_receipts. - Delivery stamps stop for switched readers. A State Hub
GET ?unread_only=truewritesdelivered_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_agentmay 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].revisionis at or below the stored cursor, unless it is an exact replay. - Reject a page whose
high_wateris lower than one already seen from the samesource_instance. That indicates a source restore or rollback. The consumer stops serving as fresh and re-snapshots.
Freshness.
- State Hub provides
source_time,high_waterandsource_instanceon every response, including empty ones. An empty page is a heartbeat: "nothing changed as ofsource_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_idandsupersedes_idmay change only from a value to NULL, and only in a revision that tombstones the referenced message.read_atand the receipt fields may change only from null to a value.archived_atmay 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 GUCstatehub.inbox_revand 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 (key5348490098). - Keys and state.
keyis the message UUID formessageandtombstone, and"<message_id>:<agent>"forreceipt.stateis the full row and includesrevision. Snapshot rows have the same shape asstate. 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 examplev2:), when the optional query parametersource_instancediffers from this instance'sinstance_label, or when the cursor is ahead ofhigh_water(another instance or a restored source). A malformed cursor gets 400. - Horizon (410).
compacted_beforeisv1:<R>, whereRis the highest revision of any tombstone older than 90 days (v1:0if there is none). Those tombstones are no longer served, andafter < Rgets 410 withresnapshot_required: true. The horizon is enforced logically. Nothing purges tombstones physically (D2). The snapshot also carriescompacted_before(additive). - Paging.
limitdefaults to 500, maximum 5000. A page may exceed it to finish a revision. An empty page returnsnext_cursorequal toafter. - 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
reasoncomes fromSET LOCAL statehub.delete_reason = '...'if the deleting session sets it, and is NULL otherwise. - Not covered. A direct
DELETEonmessage_receiptswrites 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 viaafter_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.