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

379 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
### 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_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.