Customer-Keyed Cross-Reference Bootstrap from History
Status: Design v2 β re-reviewed 2026-06-15 against current tree; corrections applied below
Owner: David Boone
Trigger: During Phase A diagnosis we discovered that CK has 0 customer-keyed xrefs across 77 customers despite 1,055+ validated POs in history. The Phase A matching unlock works for any customer but every customer still starts cold. This design wires the historical bootstrap.
Supersedes: sections of docs/designs/typesense-matching-architecture.md (Phase B). Phase A still as-shipped in PR #932.
Repo paths in this doc are relative to repo root.
0. CORRECTIONS APPLIED (2026-06-15 re-review β read first)
Section titled β0. CORRECTIONS APPLIED (2026-06-15 re-review β read first)βA fresh eng pass against the current tree found this doc had drifted. Four corrections, all code-verified:
- Use
backfill-xrefs-from-golden.ts, NOT-from-history.ts. The-from-historyscript filterscustomer_part_number IS NOT NULL, but the extractor never populates that field for CK (codes land initem_ids[]; only the VMS profile sets it). It is inert for CK β the author already pivoted (backfill-xrefs-from-golden.ts:5-7header). The go-forward source keys onitem_ids+ normalizeditem_name, mirroringfindItemPatternβs tiers. Β§7 A.5 below is updated.- Add a
cross_referencesreconcile to the dagster asset (Β§3/Β§8 #5). Todayapps/dagster/erp_pipeline/assets/typesense.pyreconciles the products collection (reconcile_documents) but the cross_references asset is pure upsert β stale xrefs (e.g. SKUs killed by the two-series collapse) linger forever and are only masked at query time by the matcher self-gate. Mirrorreconcile_documentsforcross_referencesso dead rows get swept at the data layer.- Confirmation capture is missing (Β§7 Phase B step 3).
confirmItem(the Confirm button + J/Enter,orderActions.ts:313) writes nothing today β only the βK manual-modal path (assignErpMatchβsavePartNumberMapping) captures. So βreview-v2 confirm feeds the loopβ is a real new write, not a hook on an existing one.- Gate the persist/autoReprice path.
persist-auto-priced-matches.tsauto-applies matches withisAutoValidated:trueand no review surface (the review-v2 confirm/reject UI only fires in theauto-validatepath). History auto-apply there must be gated tosuggested/needs-review.- Stale refs: this doc + both scripts referenced the retired CK Demo connection
093e4161; current sandbox is4f234677. Updated throughout this doc (the scripts still need the same refresh at build time).Confirmed sound (no change): the
cross_referencesP1 bucket is customer+connection-scoped and self-gates stale IDs (resolves the xrefβs target product; drops the hit if SKU absent/delete_flag; + Jaccard guard) βtypesense-search-service.ts:1821-1885. Companion UX work (matched-name + ERP-price clarity, ship-first) lives inhistory-aware-matching-and-match-clarity.mdΒ§4.
1. Problem
Section titled β1. ProblemβAuto-validation in apps/webapp/src/services/search/typesense-search-service.ts filters cross_references by customer_id:=[β¦]. For Cleveland Kitchen connection 4f234677:
- 347 existing xrefs, all
customer_id = __UNIVERSAL__ - 0 customer-specific xrefs for any of CKβs 77 customers
- 1,055 validated POs sit in Postgres
extracted_order_itemsandpackages/eval/test-data/golden-dataset/cleveland-kitchen-golden-dataset.jsonβ none mined into xrefs
Phase A (PR #932) fixes βmatcher never had a chanceβ via UPC variants + name+description sub-search + Jaccard guard. It is customer-agnostic and helps every customer immediately. But: every customer still has a cold matching surface. Each unique Giant Eagle / Dot Foods / Misfits catalog code has to fight through fuzzy heuristics on every PO, even when the right answer is sitting in our own validated history.
Effect on the kickoff: the Phase A code will likely lift Giant Eagle from 3/19 β ~12/19 via name bucket. Customer-keyed xrefs would push that to ~17/19 by short-circuiting the fuzzy search with operator-confirmed history.
2. Source survey
Section titled β2. Source surveyβ| Source | What it preserves | Customer-keyed code xrefs? | Volume (CK) |
|---|---|---|---|
Postgres extracted_order_items | clerkOrganizationId, customerPartNumber (customerβs original catalog code), itemName, itemDescription, erpItemId, isValidated, isAutoValidated, joined to extracted_orders.erp_customer_id | β Yes β direct | ~4,770 validated lines, growing |
cleveland-kitchen-golden-dataset.json | customer_name, _wherefourMatch.customerId, order_items.item_ids (post-validation: CK SKU + UPC), item_name. Customerβs original code stripped during validation snapshot | β No β only UPCβSKU and nameβSKU triples | 1,055 POs across 77 customers |
Live xref-learner (Phase A goes forward) | Review-v2 operator confirms β write xref in real time | β Yes β going forward only | 0 today, ~N/day after ship |
Source A is primary. Source B is a precision audit overlay only (if A says βGiant Eagle 2725 β SKU Xβ but Bβs UPC chain disagrees, quarantine). They are NOT merged into the same upsert.
3. Architecture
Section titled β3. Architectureβ βββββββββββββββββββββββββββββββββββ β Postgres `extracted_order_items`β β (4,770+ validated lines) β ββββββββββββββββββ¬ββββββββββββββββββ β β JOIN extracted_orders.erp_customer_id β ββββββββββββββββΌβββββββββββββββ β xref-learner.ts (module) β β upsert(row, source, conf) β ββββββββ¬βββββββββββββββ¬ββββββββ β β ββββββββββββββΌβββββ ββββββΌβββββββββββββββ β backfill-xrefs- β β review-v2 confirm β β from-history.ts β β + auto-validate β β (one-shot) β β (real-time) β ββββββββββ¬βββββββββ βββββββββββ¬ββββββββββ β β βββββββββββββ¬ββββββββββββ β ββββββββββββββββΌβββββββββββββββ β Postgres β β `manual_cross_references` β β single source of truth ββββββββ¬βββββββββββββββ¬ββββββββ β β direct push β β Phase B+: dbt UNION βΌ βΌ βββββββββββββββββββββ βββββββββββββββββββ β Typesense β β Iceberg β dbt β β cross_references β β (analytics) β β (matching path) β β β βββββββββββββββββββββ βββββββββββββββββββKey design tenets (all from independent eng review):
- Postgres is authoritative. Typesense is a derived index. The βwriter of truth was dbtβ mistake that got us into the current hole is not repeated.
- One code path, two drivers. Backfill script and the live
xref-learnerinvoke the same upsert function. Different drivers = same write contract. - Source A only for actual writes. Source B is contradiction-detection only.
4. Schema additions to manual_cross_references
Section titled β4. Schema additions to manual_cross_referencesβDrizzle schema lives at packages/db/src/schema/manual-cross-references.ts. Add these fields (Phase B prereq):
source: text('source').notNull(), // 'operator_selection' | 'auto_validated' | 'order_history' | 'spreadsheet_upc'confidence: decimal('confidence', { precision: 4, scale: 3 }).notNull().default('1.0'),confirmation_count: integer('confirmation_count').notNull().default(1),last_seen_at: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),quarantined: boolean('quarantined').notNull().default(false),quarantine_reason: text('quarantine_reason'),Composite uniqueness on (connection_id, customer_id, item_id, their_item_id). Upsert increments confirmation_count, bumps last_seen_at, updates source priority (operator_selection > auto_validated > order_history > spreadsheet_upc).
5. Confidence model (recency-decay + compound gate)
Section titled β5. Confidence model (recency-decay + compound gate)βAuto-fire (matcher accepts as exact xref) when:
quarantined = falseANDconfidence >= 0.7(after decay) AND- (
source = operator_selectionORconfirmation_count >= 2ORsource = order_historyANDlast_seen_atwithin 365 days)
Suggest only (surfaces in review-v2 as βwe think this might be itβ) when:
confidence in [0.3, 0.7)ORsource = auto_validatedandconfirmation_count = 1ORlast_seen_atolder than 365 days
Quarantine (excluded from auto-fire, flagged in review-v2):
- Same
(customer_id, their_item_id)resolves to two differentitem_ids in history β catalog change or operator error. Hard-stop, surface for human resolution.
Decay: linear confidence = clamp(1.0 - 0.5 * age_days / 730, 0.3, 1.0). 90 days β 0.94. 365 days β 0.75. 730 days β 0.5. Older still rules out auto-fire by the >= 0.7 gate. Re-confirmation in review-v2 resets last_seen_at and snaps confidence back up.
6. Per-customer cohort eval (required ship gate)
Section titled β6. Per-customer cohort eval (required ship gate)βAggregate eval can hide cohort regressions. packages/eval/src/runners/auto-validation-runner.ts already exists and calls the production validator directly. Extensions:
- Emit per-
erp_customer_idscores (already grouped by entry; just aggregate by_wherefourMatch.customerId). - Ship gate: top 10 customers by line volume must each be β₯ 80% gross price accuracy AND no cohort regresses > 2 pts vs Phase A baseline (taken as the eval baseline AFTER PR #932 merges).
- Counterfactual eval: run with
manual_cross_referencesrows hidden vs visible. The delta is the workβs value. If any cohort gets <5 pt lift, the operational risk on that cohort is not worth the bootstrap. - Precision@auto-fire: of rows we auto-fired, what % were correct (matched the golden truth). Separate from recall.
7. Migration plan (revised vs old design)
Section titled β7. Migration plan (revised vs old design)βPhase A.5 β TODAY (hours, ship for kickoff)
Section titled βPhase A.5 β TODAY (hours, ship for kickoff)βSingle, narrow goal: get CKβs most-active customers a bootstrap before kickoff. Defer the formal xref-learner to Phase B; today we ship a thin backfill script that writes directly to Postgres + Typesense (mirroring import-upc-mapping.pyβs already-proven pattern).
- Use
packages/db/scripts/backfill-xrefs-from-golden.ts(NOT-from-history.tsβ see Correction #1; the history scriptβscustomer_part_number IS NOT NULLfilter is inert for CK).--dry-run(default),--connection-id,--customer-id,--min-confirmations- Source the customer code from
item_ids[]+ normalizeditem_name(the-from-goldenkeys), NOTcustomer_part_number. This mirrorsfindItemPatternβs tiered identity (erp_item_id β item_ids β item_name) and covers description-only lines. - Builds
(connection_id, customer_id, their_code/normalized_name β erp_item_id)rows withsource='order_history'andlast_seen_at = max(extracted_order.order_date, extracted_order_items.updated_at) - Hard-quarantines
(customer_id, their_item_id)resolving to >1 distinctitem_id(present today viaactive=false) - Upsert to
manual_cross_references(existing schema; new fields are Phase B) - Direct push to Typesense
cross_referencescollection (same shape asimport-upc-mapping.py:418). The matcher self-gate drops any pushed xref whoseerp_item_idis no longer a live product, so stale-collapse rows canβt mis-match β but add the dagster reconcile (Correction #2) to sweep them.
- Spot-check: confirm top-3 customersβ xref counts and visually scan a sample of 20 rows
- Smoke-test: re-open Giant Eagle PO in review-v2, auto-validate, expect β₯ 15/19
- NOT-shipping in A.5: cohort eval gate, schema additions, confidence decay, xref-learner module, quarantine UI
Phase B β this week (1 week, the real version)
Section titled βPhase B β this week (1 week, the real version)β- Schema additions to
manual_cross_references(source, confidence, confirmation_count, last_seen_at, quarantined, quarantine_reason) β Drizzle migration apps/webapp/src/services/matching/xref-learner.tsβ the shared upsert module- Hook
xref-learnerinto review-v2 confirm + auto-validate success. NOTE (Correction #3):confirmItem(Confirm button + J/Enter,orderActions.ts:313) writes nothing today β onlyassignErpMatch(βK modal) captures. This is a NET-NEW write on the confirm path, not a hook onto an existing one. Also gate the persist/autoReprice path (persist-auto-priced-matches.ts) β it auto-applies with no review surface (Correction #4): history auto-apply there βsuggested/needs-review until reviewed. - Refactor
backfill-xrefs-from-golden.ts(the adopted script, Correction #1) to callxref-learnerinstead of inline SQL - Add Postgres β Iceberg bronze asset for
manual_cross_references; add UNION inraw_cross_references.sql(analytics parity only β the matching path does NOT need it; direct Typesense push covers matching) - Add a
cross_referencesreconcile toapps/dagster/erp_pipeline/assets/typesense.py(Correction #2) β mirror the products assetβsreconcile_documentsso xrefs whoseerp_item_iddied (SKU collapse) get swept, not just runtime-masked by the matcher self-gate. - Cohort eval extension to
auto-validation-runner.ts+ counterfactual mode + precision@auto-fire - Run cohort eval, must clear ship gate
- Roll out to all 77 CK customers + every other connection with
is_validated=truehistory (system-wide, not CK-specific) - Review-v2 surfaces quarantined xrefs for resolution
Phase C β week+ (was already Phase C, unchanged)
Section titled βPhase C β week+ (was already Phase C, unchanged)βVector / hybrid name_embedding search + upc_variants: string[] schema field + Typesense Synonyms API + curation overrides.
8. Failure modes
Section titled β8. Failure modesβ| # | Failure | Test? | Error handling? | User-visible? |
|---|---|---|---|---|
| 1 | Backfill writes wrong xrefs from past operator errors | A.5: dry-run + manual spot-check. B: precision@auto-fire eval | Quarantine on (customer, code) β multi-item conflict | Yes β surfaced as needs-resolution in review-v2 |
| 2 | Race during backfill β auto-validate firing in parallel produces conflicting upserts | A.5: run backfill during quiet hour. B: single-writer lock per (connection_id) | Postgres unique constraint + upsert resolves | No (Postgres serializes) |
| 3 | Typesense lags Postgres mid-backfill β matcher sees half-populated xrefs | A.5: batched upsert, run during quiet hour. B: explicit sync barrier between Postgres write and Typesense push | None today | Yes β degraded matching during backfill window |
| 4 | Operator confirms in review-v2, next item in same PO doesnβt benefit (no cache invalidation) | B: session-scoped xref overlay in review-v2 store | None today | Yes β confusing UX |
| 5 | Catalog change / SKU collapse (CK two-series) β old xref points at a dead erp_item_id | A.5: matcher self-gate drops it at query time. B: decay + quarantine + cross_references reconcile (Correction #2) | Runtime self-gate today; data-layer reconcile in B | No β self-gate prevents wrong match; reconcile removes the dead row |
| 6 | Backfill re-run produces duplicates | A.5: ON CONFLICT DO UPDATE upsert. Tested via dry-run twice | Idempotent upsert | No |
Critical gaps for A.5 (acknowledged, deferred to B): #3, #4, #5 partial. Ship with documented limitations + a kill switch (Typesense collection alias swap can revert).
9. Test plan
Section titled β9. Test planβPhase A.5 (today)
Section titled βPhase A.5 (today)β- Backfill script
--dry-runagainst staging connection4f234677, output saved to file, spot-check 20 rows manually - Re-run with
--dry-run --customer-id=415230(Giant Eagle Bedford) and confirm at least 5 xrefs would be written (we have at least 5 validated Giant Eagle line items in CK history if any) - Live run with
--connection-id=4f234677(CK only, not all connections) - Smoke: Giant Eagle PO #406206 re-validate, expect named auto-matched count > Phase A baseline
Phase B (this week)
Section titled βPhase B (this week)β- Unit tests for
xref-learner(upsert + decay + quarantine + conflict resolution) - Cohort eval baseline β with-bootstrap delta per customer
- Counterfactual eval: empty vs populated
manual_cross_references - Precision@auto-fire: 95% threshold (an auto-fired xref must be right 95% of the time on the golden set)
- Replay test: 100 fake review-v2 confirms in parallel, verify Postgres upserts are correct and Typesense ends up consistent
- Migration test: schema additions backward-compatible, dbt graph rebuilds cleanly with UNION
10. Whatβs NOT in scope
Section titled β10. Whatβs NOT in scopeβ- Source B (golden dataset JSON) as a writer. Used only for contradiction detection.
- Vector embeddings / hybrid search. Phase C.
- Multi-org cross-pollination. xrefs are connection-scoped; no signal sharing across orgs.
- Spreadsheet-driven xref uploads via UI. Could be a Phase C if a customer requests it; today the
import-upc-mapping.pypattern is sufficient. - Automatic xref invalidation when a product is deleted in WhereFour. Phase C.
11. What already exists (donβt rebuild)
Section titled β11. What already exists (donβt rebuild)βscripts/import-upc-mapping.py:418β direct-to-Typesensecross_referencesimport. Reuse the pattern in Phase A.5, donβt write from scratch.packages/eval/src/runners/auto-validation-runner.tsβ already callsfindProductMatchesBatch+findFuzzyMatchdirectly (no HTTP). Extend, donβt replace.packages/eval/src/scorers/auto-validation-scorer.tsβ already produces aggregate + per-line scores. Add cohort grouping by_wherefourMatch.customerId.extracted_order_itemsPostgres +extracted_orders.erp_customer_idjoin β direct query, no new ingestion needed.manual_cross_referencesDrizzle schema β Phase B adds columns, A.5 uses as-is.
12. Open questions for the user (final gate decisions)
Section titled β12. Open questions for the user (final gate decisions)β- Phase A.5 today + Phase B this week vs commit to the full Phase B today (1-week scope)
- Source A only vs Source A + Source B fusion
- Same code path (xref-learner) for backfill + live vs separate one-shot script + Phase B learner later
- Cohort eval as hard ship gate for Phase B vs soft signal only
13. Cross-phase themes (autoplan)
Section titled β13. Cross-phase themes (autoplan)β- CEO + Eng both flagged: scope risk if βend to endβ means 1 week of work; recommend two-step landing (A.5 today, B this week) so kickoff isnβt blocked.
- Independent eng voice flagged: Postgres-as-truth and one-code-path are non-negotiable; the design doc already aligns. No cross-phase disagreement.
- Mismatch with original (this morningβs) design doc: that doc framed Phase B as βthis week, optional, behind feature flag.β The autoplan output upgrades it to βthis week, required for sustained value, no feature flag β direct rollout per-connection.β