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`
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.