ADR 029 — Catalog-first data object model
- Status: Accepted (2026-07-19) — first slice implemented (contracts +
monolith projection + first MCP consumer; see
Implementation status). Roadmap: infra program
§11.5 Workstream 5B ("Extract the catalog aggregate, E7/S2") and
alphaswarm_internal/TODO.md§23.1. - Authors: Platform team
- Related: ADR 019 (one component
registry / URN index), infra program §11.5 5A (strangler artifacts,
/metadata/catalogCANDIDATE route), TODO §23.2 (engine-noun internalization), TODO §23.3 (one physical-registration status)
Context
Catalog concerns are triple-homed:
- the monolith relational catalog —
DatasetCatalog/DatasetVersionORM (70 importing files) +MetadataCatalogService+ the/metadata/catalog/*,/datasets/*routes anddata.catalog.*MCP tools; - the satellite in-memory
alphaswarm_data.DataCatalog(DatasetDescriptor/InstrumentDescriptorprovider index) — isolated, zero shared shapes with the monolith; - the physical catalogs (Polaris / Gravitino / Iceberg-REST / Trino), leaking
engine nouns through customer-visible surfaces
(
iceberg_identifierfields,/data/polaris/catalogs, …).
There was no dependency-light, typed object model that UI / CLI / MCP / agents / satellites could share — every consumer either imported the ORM directly or defined its own dataset shape, and "is this dataset registered?" had per-engine answers instead of one.
Decision
Populate the data half of alphaswarm_catalog (alphaswarm_catalog.data)
as contracts ONLY, mirroring the dependency posture of alphaswarm_core:
- Typed frozen wire contracts —
Dataset,DatasetVersion,DataSource(aliasFeed),Instrument(field-compatible with the satellite'sInstrumentDescriptor, canonical open identifiers included),FeatureSet,Corpus,GraphEntityRef,ArtifactRef,ColumnDoc. Allfrozen=True+extra="forbid"; additive evolution only. - One URN scheme, second kind namespace — data objects share the PCG's
urn:alphaswarm:<kind>:<environment>:<local-id>format (sameURN_PREFIX, never forked) with their own closed kind set (DATA_URN_KINDS:dataset,dataset-version,feed,instrument,feature-set,corpus,graph-entity,artifact— disjoint fromPCG_URN_KINDS, enforced by test). Both namespaces register into the ADR-019 component registry (register_data_catalog()). - Never engine nouns — the object surface carries no
iceberg/polaris/gravitinofields. Physical placement surfaces only through the singlePhysicalRegistrationaspect (§23.3): oneregistered|pending|drifted|orphanedstate + a per-engine evidence map, maintained by one reconciler on the implementation side. - Pinned enums —
MedallionLayer(bronze/silver/gold, migration 0027),CurationState(uncurated/certified/deprecated) andSupportStatus(supported/experimental/retired) (migration 0108),RecordType(bar/quote/trade/reference/fundamental/macro — satellite vocabulary). Values are contract-locked to the shipped immutable migrations. DatasetRefresolution semantics — exactly one addressing form per ref (urn→dataset_id→ provider-qualifiedname), fail-closed on empty/mixed refs and on name ambiguity. The resolver implementation stays with the monolith service; the contract pins only the semantics.- Ownership stays put — the monolith keeps catalog persistence +
service implementation and projects rows onto the contract
(
alphaswarm/services/catalog_contract.py, guarded import so the monolith boots without the sibling); the satelliteDataCatalogbecomes a client/projection of the same shapes; physical catalogs remain implementation detail behind the aspect. No persistence or governance moves into the contract package.
Consequences
Positive
- UI/CLI/MCP/satellites can import one catalog contract; the "every consumer forks the dataset shape" class of drift ends at the contract boundary.
- Engine-noun exposure becomes a projection concern: the §23.2 route
internalization and the eventual
/metadata/catalogstrangler flip (5A CANDIDATE → extracted) now have a stable target shape. - One registration verdict (§23.3) with per-engine evidence replaces per-engine status fields.
Negative / risks
- Dual shapes exist during the strangler window (legacy ORM-shaped payloads + additive contract fields); consumers must not mix them. Mitigated by additive-only enrichment (existing fields byte-identical) and projection tests pinning both.
- The
PhysicalRegistrationderivation is heuristic until the one reconciler lands; states beyondregistered|pendingrequire it.
Implementation status
- 2026-07-19 (first slice, §11.5 5B):
alphaswarm_catalog.datalanded with contract tests (enum pinning, URN round-trip + disjointness, frozen/forbid,DatasetReffail-closed, ADR-019 idempotent registration); monolith projectionalphaswarm/services/catalog_contract.py(dataset_from_row/dataset_payload_from_row/dataset_urn_for_row/physical_registration_from_row, typedCatalogContractUnavailableErrorwhen the package is absent); first consumerdata.catalog.browseMCP tool emits additiveurn+physical_registrationfields. - 2026-07-19 (second slice):
/metadata/catalog/*route payloads carry the contract —MetadataDataset+MetadataDatasetResponsegained additiveurn+physical_registrationfields populated by the guarded_contract_enrichmentinMetadataCatalogService._row_to_dataset(null when the package is absent). Satellite projection landed:alphaswarm_data/catalog/contract_projection.py(optional-convergence pattern;dataset_to_contract/instrument_to_contract/catalog_to_contract_datasets; provider ids with:map to~URN local-ids with the verbatim id inmetadata["provider_dataset_id"];catalogextra on the package). - 2026-07-19 (third slice): the ONE physical-registration reconciler landed —
migration 0139 (
physical_registration_json+_checked_atondataset_catalogs),alphaswarm/data/catalog/registration_reconciler.py(pure verdicts:registered|pending|drifted, per-engine evidence, fail-honest on listing outages, orphan sweep in the task summary), beat taskcatalog-registration-reconcile(ALPHASWARM_CATALOG_REGISTRATION_RECONCILE_*knobs), and reads prefer the persisted verdict. §23.2 shadow phase: the 11 engine-noun read routes carry shadow logging +Deprecation/Linkheaders; operator mirrors live at/data/_diagnostics/*behindadmin:iceberg+ step-up, schema-excluded. Consumer cutover extended:data.catalog.discover_datasets+data.catalog.get_schemaMCP tools and the Vite client types (catalog.ts:PhysicalRegistration,MetadataDataset.urn). - 2026-07-19 (fourth slice):
/datasets/tablespayloads carry the contract (TableSummary.urn+.physical_registrationviaCatalogRowSnapshotin-session enrichment; Iceberg-only entries stay null). The per-enginepolaris_registration_status/gravitino_registration_statusresponse fields are RETIRED (zero consumers verified across monolith/client/ui) —MarketDataMaterializeResponsenow carriesurn+ the singlephysical_registrationaspect instead (§23.3 acceptance: per-engine statuses are evidence, not API surface). MCP re-keying started:data.catalog.get_schema+data.iceberg.run_queryaccept the catalog URN (_resolve_dataset_ref: URN → row-id lookup under the same tenancy filter, physical identifier read off the row; malformed URNs and unregistered rows fail closed; legacy identifier path byte-identical). - Open: legacy engine-noun alias deletion after the shadow window; folding
the read-time orphan merges (
_iceberg_only_rows,DiscoveryService) into the reconciler (auto-registering orphan rows needs a tenancy decision — orphan tables carry no workspace context and NULL-workspace rows are visible to all tenants); CLI cutover (blocked on the CLI's ownGET /datasets/target, which does not exist on the monolith — a CLI defect, not a payload gap anymore); remaining engine-noun MCP tool re-keying (data.iceberg.read_slice/snapshot_history/time_travel_read, hudi/questdb/streaming writes); stranglerextracted_ref+ flip once a serving extraction target exists.