Graph asset-registry event feed contract (010x023 seam)
Status: source SHIPPED (2026-08-26) — the F-R0824-GRA-1 successor to Findings Ledger’s direct cross-schema SELECT FROM graph.asset_registry_events, which breaks the moment graph leaves stellaops_platform (GRA-9) and which blocked the consolidated findings-web from booting against stellaops_findings at the 2026-08-26 FND-9 canary. Live activation (deployed graph-api build, armed findings-web, one event advancing end to end) is window work owned by SPRINT_20260722_023 GRA-9 / SPRINT_20260722_010 FND-9. Owner: Graph (producer). Consumer: Findings Ledger (consolidated host only; the predecessor findings-ledger-web keeps the same-database read until FND-10).
Source of truth for the shapes: src/Graph/__Libraries/StellaOps.Graph.Contracts/AssetRegistry/AssetRegistryEventFeedContract.cs — a closed leaf project on the StellaOps.Scanner.Contracts pattern (zero ProjectReferences, zero PackageReferences; pinned by GraphConsolidationConformanceTests.GraphContracts_RemainsAClosedLeafSeam).
Identity
| Field | Value |
|---|---|
| Route (native graph-api) | GET /graph/asset-registry/events |
| Query | afterRecordedAt (ISO-8601), afterEventId, limit (≤ 5000, default 500), maxBytes (≤ 8 MiB, default 2 MiB), streamEpoch |
| Authorization | graph:asset-registry:read-all ONLY (policy Graph.AssetRegistryProjectionRead); ordinary graph:read/graph:query is refused 403 |
| Transport identity | gateway-shaped signed identity envelope (X-StellaOps-Identity-Envelope + signature, shared Router:IdentityEnvelopeSigningKey) — graph-api authenticates envelopes, not bearer tokens |
| Errors | 400 asset_registry_feed_invalid_request, 409 asset_registry_stream_epoch_mismatch (carries the live epoch), 503 asset_registry_feed_unavailable |
Semantics
- Ordering and cursor are byte-faithful to the retired direct read: the global cross-tenant log ordered by
(recorded_at, event_id)ascending, cursor exclusive — so the Findings durable checkpoint (findings.asset_registry_projection_offsets:last_graph_recorded_at,last_graph_event_id) keeps its meaning across the seam cutover, and catch-up from a zero checkpoint replays the retained history. - Tenancy is the Q-23 service-global-projection shape: one global stream, one cursor; every event carries its
tenantIdand the consumer partitions by it. The dedicated machine scope is catalogued (S052_graph_asset_registry_projection_scope.sql) and granted to NO descriptor client (StandardPluginOptionsTestspins the universal forbid): the consuming hop mints its own envelope (subjectstellaops-findings-graph-projection) — the VexLens lookup precedent, because nothing on graph-api validates a bearer token. - Stream epoch = the ordinal-first retained event’s
event_id, computed in the same REPEATABLE READ snapshot as the page. The log is append-only and hash-chained, so while the origin row is unchanged no consumed cursor can have skipped anything; any truncation or re-baseline moves the origin, the epoch changes, and a caller that pinned the old epoch gets the typed 409 instead of a silently different history. An empty log hasstreamEpoch = null— a valid zero-event state, not a failure. - Byte-bounded pages (the Q-22 companion-B precedent): pages trim to an ordered gapless prefix by cumulative
payloadJsonbytes and always serve at least one event, so an oversized event can never stall catch-up.headRecordedAt/headEventIdreport the true head in the same snapshot, so a trimmed consumer knows it has not caught up. - Consumer honesty (D-FND-GRA-1, preserved across the transport swap): refused identity, absent route, epoch divergence, out-of-order pages, malformed bodies and a missing local seam relation (
findings.asset_registry_feed_state, Findings migration 008) are each the typed runbook-linked refusal that fails the host closed; ordinary transport transients at runtime keep the worker’s bounded retry. Operator procedure: findings-graph-asset-registry-source-unavailable. - Inherited visibility caveat, stated honestly:
recorded_atdefaults toNOW()at INSERT time, so a long-running producer transaction can commit a row whose cursor position a consumer already passed. This is exactly the semantics of the retired direct read (same predicate, same ordering), not a regression introduced by the feed; the asset-registry pipeline’s writes are short single-row inserts, and tightening this belongs to the producer if the write path ever batches.
Verification
Producer: AssetRegistryEventFeedReaderTests (ordering/cursor/epoch/byte-bound on disposable PostgreSQL), AssetRegistryEventFeedEndpointTests (401/403/200/409/400/503 incl. the ordinary-graph:read-is-refused boundary). Consumer: GraphAssetRegistryFeedSeamTests (wire pages through the shared contract types into the Findings sink: tenant partitioning, checkpoint advance, epoch adoption, ordered-gap/epoch/identity refusals, missing-seam-relation refusal). Composition: AssetRegistrySeamCompositionTests (predecessor keeps the legacy source; seam default-off registers nothing; armed selects the feed) and FindingsWebGraphlessProductionBootTests (the production findings-web composition boots and serves against a fresh database with no graph schema — the 2026-08-26 canary’s exact failure mode, red/green-proven by flipping the host back to the legacy path).
