ADR-0034: Canonical products as read-model in Phase 1-a (deviation from ADR-0003)
Status: accepted Date: 2026-04-21 Deciders: Platform, Catalog, Ingestion Supersedes: none Superseded-by: none Relates-to: ADR-0003 (event-sourced canonical products), ADR-0024, ADR-0031
Context
ADR-0003 mandates event-sourcing for the canonical catalog: every state
change lands as an immutable event, canonical state is a projection.
Phase 1-a ingestion needs a working canonical row per supplier SKU to
stamp supplier_offers.canonical_id and issue viewable_id at
observation time.
Implementing the full event-sourced path (events → projection → matching → merge) before real matching data exists would block the ingestion loop for several phases. Iter-1 requirement is narrower: “one canonical per supplier offer” — no cross-supplier matching, no merge, no state-change history.
Decision
In Phase 1-a, canonical_products is a CRUD read-model:
CanonicalProvisioner.EnsureForOffer(supplier, supplier_sku, name, now)derivescanonical_id = UUIDv5(supplier|supplier_sku)and inserts a row withstatus=active,manufacturer_id=NULL,viewable_idfromViewableIDIssuer.Next.- No event stream is persisted for canonical state changes in iter-1.
- The
canonical_aliasestable exists but is never written in Phase 1-a — it is reserved for Phase 2 merges. - Existing
canonical_productsadmin CRUD endpoints (create / update / assign characteristic) stay live and behave as CRUD.
Expiration trigger
This deviation expires when real matching lands (Phase 2 —
expected milestone phase2-matching-core). At that point:
- Event-sourced canonical state gets layered on top of the existing table (adds events table + projector; existing rows become the initial projection).
CanonicalProvisioner.EnsureForOfferis replaced by a matcher that emitscanonical.created/canonical.mergedevents.canonical_aliasesstarts receiving writes on merge.
No schema rewrite is anticipated — ADR-0003 event-sourcing is additive over the current row shape.
Consequences
Accepted costs
- Canonical state changes in Phase 1-a leave no audit trail beyond
canonical_products.updated_at. Operators can still reconstruct mutations fromsupplier_offers.last_seen_at+ ingestion logs, but canonical-level “who changed what when” is not queryable. - Manual CRUD on canonical rows via admin endpoints will race with ingestion-side Upserts. Mitigation: admin operators warned via runbook to pause supplier-sync during bulk manual edits; race window accepted for iter-1.
Benefits
- Ingestion loop unblocked — Phase 1-a can ship without waiting for matching.
viewable_idissuance integrates cleanly into the existing canonical repo without a separate event stream.- Phase 2 matching has real canonical + observation data to train against before the event layer goes live.
References
- ADR-0003 event-sourced canonical products
- ADR-0024 supplier connector contract
- ADR-0031 microkernel sub-modules per BC
- spec
docs/superpowers/specs/2026-04-20-ingestion-phase1-a-design.md§2.5 - plan
docs/plans/2026-04-20-11-45-phase1a-ingestion-core.md(CanonicalProvisioner task)