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) derives canonical_id = UUIDv5(supplier|supplier_sku) and inserts a row with status=active, manufacturer_id=NULL, viewable_id from ViewableIDIssuer.Next.
  • No event stream is persisted for canonical state changes in iter-1.
  • The canonical_aliases table exists but is never written in Phase 1-a — it is reserved for Phase 2 merges.
  • Existing canonical_products admin 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.EnsureForOffer is replaced by a matcher that emits canonical.created / canonical.merged events.
  • canonical_aliases starts 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 from supplier_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_id issuance 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)