Shared-catalog replication contract (catalog-replication/v1)

Status: v1, 2026-08-02 (SPRINT_20260722_027 SCR-1/SCR-2, X14). Runtime: src/__Libraries/StellaOps.Catalog.Replication. Producers: Platform (actor_identity, 2026-08-02) and Authority (tenants, SPRINT_20260722_016, 2026-08-03).

1. Why

Every service that resolves tenants or actor identities historically read shared.tenants / shared.actor_identity across a database boundary (X14 in the ownership register). Under ADR-039 those reads die: each consumer converges a LOCAL replica in its OWN database from producer events plus a bootstrap pull, and reads never leave the host again — which is also what preserves the offline posture (a producer outage stops replication from advancing; it never stops a resolver from answering).

2. Roles

3. Wire shape

GET {producer}/catalog-changes/{catalog}?afterSeq=N&limit=M     scope: catalog:replicate

{ "catalog": "actor_identity",
  "streamEpoch": 1754127339123,        // null = producer has never emitted (NOT epoch 0)
  "headSeq": 42,                        // lag = headSeq - checkpoint
  "changes": [ {
      "eventId": "…", "streamEpoch": …, "seq": …,
      "key": "{tenantId}:{actorRef}",
      "version": 3,                     // monotonic per key (row_version, never a timestamp)
      "payload": { …the versioned row… },
      "deleted": false,
      "occurredAt": "2026-08-02T…"
  } ] }

Rules (all enforced by the runtime, all covered by CatalogReplicationTests/CatalogWireRoundTripTests):

  1. Versioned application. A change applies only when version is newer than the stored row’s — duplicates and out-of-order deliveries are no-ops by construction. Versions come from a per-row counter bumped by the producer’s write path; timestamps are not versions (same-tick writes would tie, and a tie is a lost update on a replica).
  2. Deletes are tombstones. A replica never removes the row — removal could be resurrected by an out-of-order older event.
  3. Epoch discipline. streamEpoch changes when the producer is rebuilt/restored; the consumer discards its checkpoint and re-syncs. The epoch comparison runs BEFORE the empty-page return (an empty batch is not evidence that the checkpoint is still valid).
  4. Payloads carry the full versioned row, minimized. Catalogs are small reference data; shipping the row lets a replica converge without calling back.

4. Event types (DC-06)

TypeStream/catalogProducerPayload (minimized)
catalog.changed v1actor_identityPlatform (PostgresActorIdentityStore)tenant_id, actor_ref, display_name, email, external_subject, region_tag, erasednever IP addresses or user agents (telemetry does not leave the producer’s database)
catalog.changed v1tenantsAuthority (TenantRepository + TenantCatalog, SPRINT_20260722_016)tenant_id (slug, = the change key), id (authority UUID — consumers rebuild the slug→UUID mapping from it), name, display_name, status (active/suspended) — never settings/metadata/audit stamps (tenant configuration does not leave Authority’s database)

Tenants semantics. authority.tenants.row_version (migration 023) is the monotonic version; a BEFORE UPDATE trigger bumps it for every writer, so no write path can forget. Suspension is a versioned status update (the tenant still exists; resolvers must see suspended), while a hard delete is a tombstone (the tenant is gone; replicas stop resolving it). Tenants created by migrations/seeds predate the producer and have no catalog history — the startup backfill (TenantCatalogBackfill, ordered after the authority + eventing migrations) emits them once, tombstones catalog keys whose row vanished behind the producer’s back, and is silent at steady state. The feed rides Authority itself: GET {authority}/catalog-changes/tenants under the same catalog:replicate machine scope.

Erasure semantics (GDPR, actor-identity). An erase is a versioned update, never a tombstone: the payload ships the already-nulled row with erased: true, so every replica converges to the erased state with zero PII on the wire. A tombstone would be wrong — a tombstoned replica row projects as unknown actor, and erased and unknown are DIFFERENT shapes in the three-shape identity projection. The SCR-4 offboarding cascade builds on this: erasure propagates to replicas through the ordinary replication path, no side channel.

5. Tenant lifecycle events (SCR-4, v1 producer half)

Typed offboarding events ride the SAME wire shape on their own stream, tenant_lifecycle, served by Authority’s feed under the same catalog:replicate scope. Consumers treat it as an ORDERED EVENT FEED (inbox dedup on (stream, epoch, seq)), never as versioned-row upserts.

Event (payload event field)Emitted whenPayload
tenant.suspendedthe active→suspended TRANSITION commits (a resume or rename emits nothing; the resolver-facing status lives on the tenants catalog)event, id (authority UUID), tenant_id (slug)
tenant.deleteda tenant row is hard-deleted (same transaction as the tenants tombstone)same

Consumer contract on tenant.deleted: dispose the tenant’s data per your own P13 retention classes, with two hard overrides — evidence legal holds (evidence_holds) block disposal of held bundles, and ledger-class stores (Findings Merkle partitions) archive-then-detach rather than delete. Per DC-06, ignore unknown event types. The consumer-side handler surface + dry-run default land with the SCR-4 library half; per-service handlers ride each service’s program (or record an explicit not-applicable).

The evidence override, as actually implemented (011 EVD-11, 2026-08-18). EvidenceTenantLifecycleHandler (src/Evidence/StellaOps.Evidence.WebService/ TenantLifecycle/) is the named legal-holds adopter, and two measured facts refine the one-line rule above rather than contradicting it:

A hold with a NULL bundle_id is unattributable and WIDENS the withholding rather than being ignored.

6. Consumer checklist (per SCR-3 flag flip)

  1. Register AddCatalogReplication() (homes catalog_replica in your own DB) and AddEventingReliability() (inbox/checkpoints).
  2. Wire HttpCatalogChangeFeed on a named client carrying your service credentials with the catalog:replicate grant.
  3. Drain on your own schedule; read through CatalogReplicaReader, not CatalogReplicaStore directly — see §7 — and never through shared.* again.
  4. The producer’s table stays the source of truth; your replica is rebuildable (drop + re-drain is always safe).

7. Reading a replica: three states, not two (SCR-3)

A replica read has three distinguishable outcomes, and collapsing the last two is the defect this section exists to prevent:

OutcomeMeaningConsumer must
Founda live row existsuse it
Absentthe replica answered: the key is unknown or tombstonedrefuse (a real verdict)
SourceUnavailablethe replica could not answerfail closed and say so

CatalogReplicaReader.LookupAsync returns that tri-state. SourceUnavailable covers a catalog_replica schema that has not converged, an unreadable/ unreachable database, and — the one a row count cannot see — a replica that has never drained. The witness for that last case is the drain’s durable checkpoint or its owner-confirmed empty observation, not a row count. A null stream epoch says only that the producer has not emitted; it does not prove that a producer’s domain table is empty before backfill. Without either durable witness, absence remains SourceUnavailable.

For a never-emitted stream, the producer may include emptyCatalogConfirmed:true only after its explicitly registered owner probe confirms an empty domain catalog. Platform opts in environment_state through its own IEnvironmentStateStore; other catalogs do not acquire this behavior automatically. The HTTP reader requires a matching catalog, explicit null streamEpoch and headSeq, an explicit empty changes array and the boolean confirmation. Missing/wrong-type/duplicate fields, contradictory confirmation, non-200 responses and failed authentication never establish an observation.

The consumer stores the confirmed observation in its own catalog_replica.empty_observations table (forward migration 002). It creates no event, epoch zero, seal document or Eventing checkpoint. The observation survives producer loss. A first real event removes it in the same local transaction as the row application and real checkpoint. Existing rows/checkpoints are preserved if a later response claims a never-emitted producer. Empty observations and event writes share a short local transaction advisory lock; no lock is held over HTTP. The consumer rechecks the checkpoint after acquiring that lock and discards a response whose starting checkpoint changed while the HTTP request was in flight. The reader obtains its row/checkpoint/empty-observation answer from one local repeatable-read snapshot, so a first-event race cannot combine an old missing row with a new checkpoint to invent absence.

empty_observations is derived-rebuildable: one row per observed empty catalog, removed on the first event or rebuilt by authenticated observation after an intentional replica reset. It is not a TTL cache, and it must not expire simply because the producer is offline. The migration adds no fixture rows or new runtime DDL writer; each existing AddCatalogReplication host owns its schema migration.

Reading by payload field (the reverse direction)

CatalogReplicaReader.LookupByPayloadFieldAsync answers the mirror question — which live row carries this value in its payload? — with the same three states, the same single repeatable-read snapshot and the same never-drained witness as LookupAsync. The tenants catalog needs it because the replica is keyed by the slug while the canonical UUID lives in the payload, so a consumer holding a UUID has no key to look up.

Two rules are specific to a reverse read:

The tenants-shaped accessor over it is IStellaOpsTenantKeyResolver (ReplicaStellaOpsTenantKeyResolver), registered by the same AddStellaOpsTenantResolverReplicaOnly call as the forward seam, so a flipped host acquires both directions with no extra configuration key. It is a separate interface on purpose: widening IStellaOpsTenantResolver would make every implementation owe a reverse it has no data for.

CatalogReplicaLookup.EnsureAnswered() converts SourceUnavailable into CatalogReplicaUnavailableException, whose message names the flag, the catalog:replicate grant and the drain. Callers whose return type cannot carry a third state — IStellaOpsTenantResolver returns Guid?must throw rather than return the empty value, because a consumer reads that empty value as an answer: a null tenant surfaces as tenant_invalid_format (HTTP 400, blaming the caller) or “does not resolve to a known tenant” (HTTP 500 naming the wrong cause). That exact conflation cost the SPRINT_20260722_007 POL-F6 cutover two rollbacks: a gate evaluation decided correctly, was attested, and then failed its decision-history write with a message about the tenant identifier while the real fault was a missing Platform-owned relation.

Bootstrap window. Until the first drain commits a checkpoint or a confirmed empty observation, a missing-row read is SourceUnavailable — correctly, since the replica genuinely cannot answer. The tenants seam narrows that window by draining once, bounded, before host startup completes (Catalog:Replication:Tenants:BootstrapTimeout, default 30 s), ordered after the startup-migration hosts by registration order. It is deliberately non-fatal: a host whose replica has already converged must start even with a dead producer (the SCR-1 kill-the-producer posture).