Skip to content

History-Aware Item Matching + Match-Identity Clarity

Owner: David Boone · Status: PLAN (autoplan review in progress) · Priority: High (blocks CK UAT confidence) Synthesized: 2026-06-15 from two inputs — (1) “full solution to item matching using customers’ past ordering history”, (2) the pasted Review V2 — Match Identity & Price Clarity UX brief (Appendix A).


⚠️ SUPERSESSION (2026-06-15, after final-gate interrogation)

Section titled “⚠️ SUPERSESSION (2026-06-15, after final-gate interrogation)”

Track 1 (the matching engine) is superseded by the existing, already-autoplan-reviewed docs/designs/xref-bootstrap-from-history.md + its coded packages/db/scripts/backfill-xrefs-from-history.ts. That doc’s mechanism (fill the existing Typesense cross_references P1 bucket from manual_cross_references; recency-decay + quarantine-on-conflict confidence) is better and further along than the Track 1 synthesized here. Do not build Track 1 from this doc. This doc’s lasting contribution is Track 2 (match-clarity UX) + three augmentations the bootstrap doc under-specifies:

  1. Confirmation capture is missing — confirmItem (Confirm/J/Enter) writes nothing today; only the ⌘K modal does. The “hook into review-v2 confirm” is a real new write, not a one-liner.
  2. Persist/autoReprice path auto-ships with no review surface — the confirm/reject UI only fires in auto-validate, not persist-auto-priced-matches. Gate history auto-apply there.
  3. Backfill source is too narrow — it filters customer_part_number IS NOT NULL, but that field isn’t populated by the extractor, so description-only lines are skipped. Broaden the backfill source with findItemPattern’s name tier to cover them. Eng-review decisions #19 (findItemPattern-as-new-bucket) is revised: keep the bootstrap doc’s cross_references mechanism; use findItemPattern only to widen the backfill source.

Re-review of the bootstrap doc vs current tree (2026-06-15) — what’s actually true

Section titled “Re-review of the bootstrap doc vs current tree (2026-06-15) — what’s actually true”
  • Mechanism SOUND: Typesense cross_references IS the customer+connection-scoped P1 bucket, and it self-gates stale IDs (fetches the xref’s target product; drops the hit if SKU absent/deleted; + Jaccard guard) — typesense-search-service.ts:1821-1885. This is the real stale-ID defense.
  • The doc’s specified backfill is INERT for CK: backfill-xrefs-from-history.ts filters customer_part_number IS NOT NULL, but the extractor never populates that field (codes go to item_ids[]; only VMS sets it). Author already pivoted to backfill-xrefs-from-golden.ts keyed on item_ids + item_name. Adopt -from-golden, not -from-history.
  • Stale-ID defense is RUNTIME-ONLY: the dagster cross_references asset has no reconcile step (the products asset does), no decay built, gold model doesn’t join live products. Dead xrefs linger, masked only at query time. New delta #4: add a cross_references reconcile to the dagster asset (mirror products’ reconcile_documents) so SKU-collapse dead rows get swept, not just runtime-hidden.
  • STALE refs: doc + scripts reference retired connection 093e4161 — update to current sandbox 4f234677.
  • Net deltas this autoplan contributes to the bootstrap doc: (#1) build the live capture hook — confirmItem writes nothing today; (#2) gate the persist/autoReprice path (no review surface there); (#3) key the go-forward source on item_ids+item_name (= -from-golden/findItemPattern tiers), not customer_part_number; (#4) add cross_references reconcile on reindex.

Typesense gives us deterministic, catalog-driven matching. It cannot capture the single strongest real-world signal: a given customer reorders the same items, and the operator already told us the right ERP product for those items on prior POs. Today that signal is visible to operators (the Pattern card reads order history) but is thrown away by the matcher — it never closes the loop. This plan does two complementary things: (Track 1) turn confirmed history into a high-precedence matching signal (a learning loop), and (Track 2) make the match visible and trustworthy in Review V2 so operators stop distrusting correct matches. The two tracks reinforce each other: every confirmation an operator makes in the clearer UI becomes training data for the matcher.


1.1 The matcher ignores the customer’s own history (Track 1)

Section titled “1.1 The matcher ignores the customer’s own history (Track 1)”
  • findProductMatchesBatch (apps/webapp/src/services/search/typesense-search-service.ts) runs a 5-way multi_search: customer xref, universal xref, fuzzy product, UPC-filter, name+description. Phase A of docs/designs/typesense-matching-architecture.md is built.
  • BUT the customer-keyed cross_references collection is never populated from confirmed matches. manual_cross_references (Postgres) exists but the webapp never writes to it; apps/dagster/dbt/models/bronze/raw_cross_references.sql is still SELECT * FROM iceberg_source('cross_references') with no UNION to the Postgres table. There is no xref-learner.
  • findItemPattern (apps/webapp/src/services/order-item-pattern.ts) already resolves, per customer, the prior accepted (erpItemId, price, qty, PO, date) for a line. It is called only by the review-v2 Pattern card (/api/extracted-orders/[id]/items/[itemId]/pattern). It never informs matching.
  • Net: a CK customer who has ordered “CK PICKLED RED ONION 16 OZ” 12 times, each confirmed by an operator, gets matched from scratch by fuzzy text every time. We re-derive what we already know.

1.2 Match identity is invisible in Review V2 (Track 2)

Section titled “1.2 Match identity is invisible in Review V2 (Track 2)”
  • Rail (ReviewIssuesRail.tsx:531) and center heading (ReviewCenterPane.tsx:~485) render item.itemName (raw OCR) everywhere. The ERP match card (~652) shows only erpItemId (e.g. “774488”) — never the product name.
  • The matched product name is available at match time (BatchMatchResult.product.name, typesense-search-service.ts:38) and even flows into persist-auto-priced-matches.ts:343, but is not persisted as a column and not shown. Confirmed gap.

1.3 Document price vs. ERP price is ambiguous (Track 2)

Section titled “1.3 Document price vs. ERP price is ambiguous (Track 2)”
  • resolveAndReviewPrices (apps/webapp/src/services/pricing/auto-price-resolve.ts) computes an ERP gold-layer / tier price and a source. The price is written to extracted_order_items.unit_price on reprice, but the distinct ERP-resolved price and its source are discarded. LineOverrideEditor (25% deviation threshold) compares only document price vs. operator-typed value — no ERP baseline.

1.4 Pattern card has no empty/error affordance (Track 2)

Section titled “1.4 Pattern card has no empty/error affordance (Track 2)”
  • The card has loading/loaded/null/error states, but null AND error both render nothing (ReviewCenterPane.tsx:~1033-1052). On a 10s timeout the card silently vanishes; operators can’t tell “no history” from “lookup failed.” (The brief’s “stuck loading” is slightly off — the real defect is silent-null, no retry/empty affordance.)

2. What already exists (reuse map — corrected after CEO review)

Section titled “2. What already exists (reuse map — corrected after CEO review)”

CEO-review correction (2026-06-15): a confirmed-match learning loop already exists and is live — the original plan wrongly called it a “stub.” There are THREE mapping systems; the matcher consults two of them in sequence.

Capability Status Location
5-way multi_search matcher (primary) Built typesense-search-service.ts:1517-2187
Postgres-native fuzzy fallback after Typesense miss Built auto-validate.ts:417 → findFuzzyMatch
partNumberMappings learning loop — LIVE & CLOSED Built written by savePartNumberMapping (erpApi.ts:185) from assignErpMatch (orderActions.ts:301); read + reinforced (matchCount++) by findFuzzyMatch (fuzzy-matching.ts:345-384); schema erp-mappings.ts
itemName/itemDescription passed into matcher Built auto-validate.ts:303
Customer-keyed cross_references Typesense collection Built, unpopulated from confirmations findProductMatchesBatch:1641; typesense.py
manual_cross_references Postgres table Built, NO writers, NO confirmation_count/last_seen_at (only source,confidence,sourceDetail,theirItemId) manual-cross-references.ts:48-72
Order-history lookup per customer+item, tiered identity Built, display-only order-item-pattern.ts (findItemPattern)
ERP gold-layer / tier price resolution with source Built, source discarded auto-price-resolve.ts
Matched product name returned from hit Built, not persisted BatchMatchResult.product.name
Vector/hybrid name_embedding search Partial — vector fallback only, not in batch path typesense-search-service.ts:592-618
pricingConfig.autoRepriceOnExtraction per-connection flag Built erpConnections.extraConfig

2.1 The three mapping systems (precedence today)

Section titled “2.1 The three mapping systems (precedence today)”
  1. Typesense cross_references (primary, customer-scoped) — populated only from P21 Iceberg + stale universal rows; never from confirmations.
  2. partNumberMappings (Postgres) — the live loop, but it fires only as a fallback after Typesense misses, and only on part-code lines (inputPartNumbers), never description-only lines. Low precedence, narrow trigger.
  3. manual_cross_references (Postgres) — dormant; the design doc’s intended customer-xref source, never wired.

The real gap (reframed): confirmed history IS captured and IS consulted — but at the wrong precedence (last-resort fallback) and for the wrong subset (part-codes only). Track 1 is no longer “build a loop”; it’s “promote the loop that exists to a high-precedence signal and broaden its trigger to description lines, with confidence + invalidation.”


3. Track 1 — History-aware matching engine (reframed after CEO review)

Section titled “3. Track 1 — History-aware matching engine (reframed after CEO review)”

3.0 Prerequisite — identity hygiene FIRST (CEO finding F8/F4)

Section titled “3.0 Prerequisite — identity hygiene FIRST (CEO finding F8/F4)”

A history loop built on bad identifiers learns bad mappings faster. For the lead customer (CK) the matching pain traces to stale catalog IDs (the two-series SKU collapse left ~62/76 numeric inventory IDs stale; PO 1204843 already bound stale 261915) and corrupted WhereFour UPCs (UPC enrichment overlay half-built in PR #1527). Do not auto-apply any learned mapping whose target erpItemId is not a member of the current catalog index. Catalog reindex / SKU-collapse MUST invalidate (deactivate) affected learned mappings. This is the foundation the loop sits on.

3.1 Mechanism — add a history bucket to the batch matcher, sourced from findItemPattern (corrected after ENG review)

Section titled “3.1 Mechanism — add a history bucket to the batch matcher, sourced from findItemPattern (corrected after ENG review)”

Eng review (F1/F2) found the original “promote the existing loop” framing mechanically false: the primary matcher findProductMatchesBatch (used by BOTH auto-validate.ts:304 and the autoReprice path persist-auto-priced-matches.ts:322) never queries Postgres. The partNumberMappings loop is only reachable as a post-miss fallback in auto-validate.ts:418 and is invisible to the persist path. And partNumberMappings is the wrong source — connection-scoped, part-codes-only. The right source already exists.

Recommended mechanism (3-way taste call → gate):

  • (b, RECOMMENDED) History bucket from findItemPattern. Add a new high-precedence bucket inside findProductMatchesBatch’s app-code ranking (slot it relative to xref/UPC — see §3.1a), sourced from findItemPattern (order-item-pattern.ts) which is already customer-scoped, name-tiered (erp_item_id → item_ids → item_name), org-double-scoped, and accepted-gated. Resolve its result to a Typesense product via the existing getProductsByErpIds hop — which IS the catalog-membership/stale-ID gate for free (F8: it returns null when the ERP id is absent from the index). Wire the bucket into BOTH callers. This reuses customer-scope + name-tier + accepted-gating that exist and are tested, so it dissolves the cross-customer leak (F10) and the constraint-migration churn (F6).
  • (a) Promote partNumberMappings — rejected: connection-only (cross-customer leak F10), needs a unique-constraint change + legacy customer_id backfill (F6), part-codes-only.
  • (c) Typesense cross_references UNION path — Iceberg/dbt cost + no existing writers (further from “reuse” than the plan implied, F12).

3.1a Precedence + guards (the bucket is not a blank check)

Section titled “3.1a Precedence + guards (the bucket is not a blank check)”
  • Precedence: history bucket sits below corrected cross_references/UPC-exact (so an operator’s explicit xref fix always wins) and above fuzzy-product/name. This prevents the “2am Friday” regression where a stale name-keyed history row out-ranks a just-corrected xref.
  • Jaccard guard: apply the same name-overlap guard the xref/UPC buckets use (:1700,:1768) to the history bucket — the plan must NOT omit it.
  • Graded confidence: the bucket emits a distinct matchType: 'history' and confidence derived from confirmation_count, NOT the hardcoded exact/1.0 the current findPartNumberMappingMatch returns (F3).
  • Name-specificity floor (F7): reject name-keyed auto-apply for low-specificity normalized names (token-count floor; or reject names that map to >1 distinct erpItemId in history) so “ALLOWANCE”/“FREIGHT” don’t bind to one wrong product.

3.2 Confidence gating + capture + demotion (CEO F3 + ENG F5/F9/F11)

Section titled “3.2 Confidence gating + capture + demotion (CEO F3 + ENG F5/F9/F11)”
  • Confirmation CAPTURE is missing today (ENG F5 — critical). savePartNumberMapping fires only from assignErpMatch (manual modal pick). confirmItem (Confirm button + J/Enter, orderActions.ts:313) writes nothing. So “every confirmation is training data” is false now. Add a capture step to confirmItem and the keyboard-confirm path that records an operator confirmation. If using the findItemPattern source, the “table” is the accepted-order history itself — capture = ensuring confirmed rows are queryable with a source/confirmed marker; spec the exact write.
  • Only human confirmations count. Auto-validated rows may surface as suggested but never auto-promote on their own count (no laundering).
  • Demotion that can’t inherit trust (ENG F9): override flows through the fire-and-forget assignErpMatch upsert which does matchCount + 1 on (connectionId, inputPartNumber) — so changing the target silently inherits the old count onto the new product. Reset confirmation count on target change, and await the demotion (not fire-and-forget).
  • Auto-apply only when: operator_confirmations >= 2 AND target ∈ current catalog (§3.0/F8). First confirmation → suggested (Review V2 confirm/reject, §4.5).
  • Persist-path gating (ENG F11 — the riskiest cell): when historyAwareMatching AND autoRepriceOnExtraction are both ON, a history match flows through persist-auto-priced-matches.ts and is written isAutoValidated:true with no operator review (Track-2’s confirm UI only fires in the auto-validate path). Gate history auto-apply in the persist path to suggested/needs-review until reviewed, or block it when autoReprice is on.
  • Schema: confirmation_count/last_seen_at do not exist on manual_cross_references; the findItemPattern path needs no new table but does need a confirmed-marker. Spec per chosen mechanism.

3.3 (DEMOTED) Hybrid embeddings — separate research spike, not this plan (CEO finding F7)

Section titled “3.3 (DEMOTED) Hybrid embeddings — separate research spike, not this plan (CEO finding F7)”

A prior naïve-MiniLM attempt regressed CK product search; only vector-fallback+rerank helped. Repeating it in the batch path is the most speculative, least UAT-relevant track. Cut from this plan; track as a standalone spike with its own eval gate. (Surfaced at the final gate.)

3.4 Per-connection flag — inventory the sprawl (CEO finding F6)

Section titled “3.4 Per-connection flag — inventory the sprawl (CEO finding F6)”

Gate behind matchingConfig.historyAwareMatching on erpConnections.extraConfig, default off, CK sandbox 4f234677 first. But this joins pricingConfig.autoRepriceOnExtraction, CK_UPC_OVERRIDE_CONNECTION_IDS, discount-config, two-series-collapse — a real combinatorial matrix. Plan must inventory existing per-connection flags and state the interaction test burden (history-match × UPC-override × collapse).


4. Track 2 — Match identity & price clarity UX (revised after design review)

Section titled “4. Track 2 — Match identity & price clarity UX (revised after design review)”

4.1 Persist + surface the matched product name

Section titled “4.1 Persist + surface the matched product name”
  • Migration 0078: matched_product_name TEXT. Write in persist activity (persist-auto-priced-matches.ts:343) and assignErpMatch (carry result.name, stop discarding).
  • Center h2 switches to ERP product name when matched. Extraction text is NOT muted — render a persistent Matched from «raw extraction» line at --om-surface-ink-2 (the diff the operator’s eye runs to catch a mismatch). For low-trust matchType (fuzzy/trigram/fulltext) render that line with more weight, not less. (Design F-D2)
  • Long names: clamp h2 to 2 lines + title attr. (F-D8)
  • NULL-name fallback: historical rows have erpItemId but NULL matched_product_name (0078 backfills nothing). Specify: h2 keeps extraction text + ERP card shows “ERP name unavailable” — never silently pass extraction off as the ERP name. Backfill historical matched names where resolvable. (F-D3)
  • Custom/discount lines (isCustomLineItem → DiscountMatchPanel) have no ERP name by design: carve them out of the matched-name and red-unmatched treatments entirely. (F-D4)

4.2 Persist + surface the ERP-resolved price (highest trust/safety risk — F-D1, F-D5)

Section titled “4.2 Persist + surface the ERP-resolved price (highest trust/safety risk — F-D1, F-D5)”
  • Migration 0078: erp_price_source TEXT; anchor document price on existing extractedUnitPrice. Store resolved ERP price + source + UOM.
  • ERP card shows “ERP price: $4.97 / case (gold tier)” — UOM mandatory (case-vs-each is the CK GTIN-14 pain). The 25% deviation check must compare like-for-like UOM or suppress when UOMs differ, else false-positives on every case/each line. (F-D5)
  • Suggested ≠ confirmed (CRITICAL): when document price is missing and we show ERP price as a suggestion, it must be visually unmistakable from a real value (dashed underline / ink-3 italic / “suggested” chip) AND block silent Enter — confirming a line whose price is an unconfirmed suggestion requires an explicit accept, never advance-through. (F-D1)
  • Surface the deviation warning + doc-vs-ERP side-by-side inline in the ERP card during the default flow, not gated behind the O override editor. (F-D1)
  • No-ERP-price case → “ERP price: not in catalog”, not blank.

4.3 Pattern card empty/error states (good in brief; spec the divergence)

Section titled “4.3 Pattern card empty/error states (good in brief; spec the divergence)”
  • loaded + null → “No prior orders for this item”. error/timeout → “History unavailable — retry”, not silent null.
  • Divergence callout: compare price and qty independently, skip non-positive prices (credits/discounts trip naive ratios), render an amber line inside the pattern card AND flip the rail dot amber so it’s visible without focusing the line. Threshold ≥2× stated explicitly. (F-D9)

4.4 Sidebar — CONSOLIDATE signals, don’t add a 4th (F-D7, TASTE→gate)

Section titled “4.4 Sidebar — CONSOLIDATE signals, don’t add a 4th (F-D7, TASTE→gate)”
  • The rail row already carries ~6 signals (dot, name, idx, ×qty, status word, MatchTypeBadge, sometimes DiscountAppliedBadge). Adding green/amber/red on top = noise.
  • Recommended: the left dot carries the single weight (green=no attention, amber=needs review incl. price-missing/divergence, red=blocking); keep status word; remove MatchTypeBadge from the rail (redundant with dot+word; match basis belongs in the center ERP card per §4.1). Ensure focused-row orange left-border doesn’t collide with a red dot. (Rail-density consolidation surfaced at gate — it removes an existing element.)

4.5 Suggested → confirm/reject surface (NEW — reuse existing pattern, F-D6, F-D11)

Section titled “4.5 Suggested → confirm/reject surface (NEW — reuse existing pattern, F-D6, F-D11)”
  • §3.2’s suggested state needs a surface. Reuse the existing inline Reject/Confirm banner auto-matched lines already render (ReviewCenterPane.tsx:522-527) — same shell, new amber-info variant, copy that names the source (“Suggested from this customer’s history — confirm to learn it”). Enter=confirm (reuse isAuto path), X=reject; no modal, no new keys. Reject must visibly demote (§3.2).
  • Support (read-only) mode: suggested banner renders informationally (source + confidence), no confirm/reject affordance, no key binding. Confirm new states keep the J→Enter→J focus-advance rhythm (explicit non-regression). (F-D11)

4.6 Token vocabulary (TASTE→gate, F-D10)

Section titled “4.6 Token vocabulary (TASTE→gate, F-D10)”
  • review-v2 uses a parallel hardcoded palette (--om-paper, #1F5F33, etc.), not the DESIGN.md workspace tokens. Recommended: follow the existing review-v2 token set for local consistency (avoid a third color vocabulary in one pane); migrate the whole pane in a separate cleanup. New green/amber/red weights get named tokens, not new hexes.

  • Phase A (days): Track-2 UX + data wiring (migration 0078, persist name+price+source, rail/heading/card surfacing, pattern empty/error states). Ships value immediately, no pipeline risk. Also lights up the operator-confirmation capture (4.1/assignErpMatch) that feeds Track 1.
  • Phase B (days, not the original “week”): Track-1 = promote + broaden the existing partNumberMappings loop (§3.1 recommended path): raise precedence, key on item_name/item_description, add operator_confirmations/last_seen_at + demotion + catalog-membership gate (§3.0/§3.2). Per-connection flag, CK sandbox first. Backfill from operator-confirmed rows only (dry-run → reviewed → live). The Iceberg/manual_cross_references path is the alternative if the gate picks it.
  • Phase C — REMOVED from this plan. Hybrid embeddings → separate research spike with its own eval gate (prior MiniLM regression).
  • Pre-Phase-B gate: run the P2 stability query on CK history; if IDs churn faster than §3.0 invalidation can catch, stop and fix hygiene first.

6. Test / eval plan (outline — full plan to be written by eng phase)

Section titled “6. Test / eval plan (outline — full plan to be written by eng phase)”
  • Extend packages/eval/scripts/run-auto-validation-eval.ts with CK history fixtures: a customer with N prior confirmed orders → assert history xref wins over fuzzy.
  • xref-learner unit tests: idempotent upsert, confirmation_count increment, source precedence.
  • dbt UNION test: Postgres row visible in stg_cross_references without overwriting Iceberg rows.
  • Regression: universal-UPC fixture, vendor-conflict fixture (customer-scoped wins).
  • UX: pattern card null/error/divergence states; matched-name rendering for matched/unmatched/discount-line items; price side-by-side + deviation.

  • Redesigning the three-pane layout. Mobile/responsive. Replacing Typesense. New ERP connectors. (Phase C embeddings are in scope but last and gated.)

  • P1. The user wants the full two-track solution (engine + UX), not just the pasted UX brief — the brief explicitly defers “new matching strategies,” but the user’s framing (“full solution… match with higher fidelity based on past ordering history”) overrides that deferral.
  • P2. “Customers rarely change what they order” is true enough that a confirmed (customer, item) → erpItemId is the single highest-precedence signal, above fuzzy text. (Validated by the existing order-item-pattern design rationale.)
  • P3. Precision beats recall here: a wrong auto-match that looks confident is worse than no match. Hence confidence gating (>=2) and surfacing suggestions for confirm/reject.
  • P4 (corrected). Ship Track 2 first because it is cheap, reversible, and measurement-generating — pure display/persistence, zero precision/pipeline risk, and it surfaces match-name correctness rates that let us validate P2 empirically before betting on the engine. (The original “turns on confirmation-capture” reason was wrong — capture is already live via savePartNumberMapping.) Gate Track 1 on what Track 2’s data reveals about CK match-name correctness.
  • P2 (needs measurement, not assertion). “Customers rarely change what they order” is checkable today: query CK’s confirmed history for (customer, item-desc) → erpItemId stability over time. Given the SKU collapse, IDs demonstrably churn — so P2 is conditionally false unless §3.0 invalidation holds. Run the stability query before Phase B.

(Preserved verbatim from the user’s input — “Review V2 — Match Identity & Price Clarity UX Design Pass”. Sections 1–9: match identity invisible in primary reading positions; doc-price vs ERP-price disambiguation; pattern card stuck loading; design tokens from DESIGN.md; keyboard-first/support-mode interaction notes; out-of-scope; deliverables; engineering dependencies — erpProductName persistence, ERP resolved price, pattern timeout handling; reference screenshots for HEB Houston PO #287756 line 03.)


9. CEO-phase registries (mandatory outputs)

Section titled “9. CEO-phase registries (mandatory outputs)”
Error Trigger Caught where Operator sees Tested?
StaleMappingError Learned mapping targets erpItemId not in current catalog §3.0 membership gate at apply time Mapping skipped, falls to fuzzy; no silent wrong-bind new test
MappingPoisonGuard Auto-validated row would increment trust counter §3.2 source check n/a (suppressed) new test
Pattern fetch timeout / 4xx/5xx /items/[id]/pattern slow or errors Pattern card state machine “History unavailable — retry” (not silent null) new test
Price resolve miss resolveAndReviewPrices returns no gold price persist activity “ERP price: not in catalog” not blank extend test
Migration 0078 partial apply column add fails mid-deploy drizzle migrate n/a; additive IF NOT EXISTS, rollback-safe migration test

9.2 Failure Modes Registry (with critical-gap flags)

Section titled “9.2 Failure Modes Registry (with critical-gap flags)”
Failure mode Severity Defended by Gap?
Self-poisoning loop (auto-matches counted as confirmations) CRITICAL §3.2 operator-only counter + demotion CLOSED by plan revision
Stale-ID bind post-SKU-collapse CRITICAL §3.0 catalog-membership gate + invalidation CLOSED by plan revision
Duplicate learning systems disagree HIGH §3.1 promote-in-place (single loop) CLOSED if reuse path chosen at gate
Per-connection flag interaction bugs MEDIUM §3.4 flag inventory + interaction tests OPEN — needs test matrix
dbt UNION / Iceberg fragility MEDIUM reuse path avoids Iceberg entirely CLOSED if reuse path chosen
Embedding maintenance / regression MEDIUM §3.3 removed to spike CLOSED (descoped)
  • Hybrid embeddings (→ separate spike). Cross-customer pattern transfer. Active-learning prioritization. Three-pane layout redesign. Mobile. Replacing Typesense. New ERP connectors. → write to TODOS.md.
  • Right problem: Track 2 yes; Track 1 reframed from “build a loop” to “promote the live loop + add safety.” Net scope is smaller and safer than the original plan.
  • Biggest risks (both now defended): self-poisoning, stale-ID bind.
  • Open taste calls for gate: (a) Track-1 mechanism — promote-in-place vs Typesense-cross_references; (b) embeddings descope confirmation.
  • Dream-state delta: builds the self-improving loop + trust surface; defers cross-customer transfer and embeddings.

10.1 Architecture (corrected) — where the history bucket slots

Section titled “10.1 Architecture (corrected) — where the history bucket slots”
auto-validate.ts:304 persist-auto-priced-matches.ts:322
│ │
▼ ▼
┌──────────────────────── findProductMatchesBatch ───────────────────────┐
│ multi_search ranking (app-code, typesense-search-service.ts:1671) │
│ P1 cross_references (customer xref) ← operator's explicit fix wins │
│ P1.5 ★ NEW history bucket ★ (from findItemPattern → getProductsByErpIds│
│ resolve = catalog-membership gate F8; Jaccard guard; graded conf) │
│ P2 UPC-filter exact │
│ P2.5 idstrip exact-id │
│ P3 fuzzy product (name/desc) │
└──────────────────────────────────────────────────────────────────────────┘
│ │
(auto-validate ONLY) unmatched → findFuzzyMatch → partNumberMappings (legacy fallback, unchanged)
│
CAPTURE LOOP: confirmItem (Confirm/J/Enter) ──[NEW write, F5]──┐
assignErpMatch (manual modal) ──savePartNumberMapping (await, reset-on-retarget F9)─┘
▼
accepted-order history (findItemPattern source) + confirmed marker
│
read back as P1.5 bucket next PO ◀─────┘ (operator_confirmations>=2 → auto; ==1 → suggested)

Key correction: the bucket lives inside the batch matcher and is wired into both callers — not a precedence tweak on the post-miss findFuzzyMatch fallback (which the persist path never calls).

Full test diagram and eval suite list written to: ~/.gstack/projects/ERP-Unlocked-ordermatic/claude-ecstatic-curie-5b1f9f-test-plan-20260615-074952.md Critical gaps flagged: confirmation-capture test (path writes nothing today), precedence-regression test, persist-path history-gating test, history-bucket Jaccard guard test.

Failure mode Severity Defended by Gap?
History bucket unreachable from primary matcher CRITICAL §3.1 add bucket to findProductMatchesBatch + both callers CLOSED by revision
Confirmation capture absent (confirmItem writes nothing) CRITICAL §3.2 add capture to confirm paths OPEN — must build
Cross-customer mis-bind (connection-scoped name key) HIGH §3.1 use findItemPattern (customer-scoped) CLOSED if path (b)
Persist/autoReprice auto-ships history w/o review HIGH §3.2 persist-path gating to needs-review OPEN — must gate
Demotion inherits trust via upsert matchCount+1 HIGH §3.2 reset-on-retarget + await OPEN — must build
Low-specificity name binds wrong product HIGH §3.1a name-specificity floor OPEN — must build
Graded confidence has no home (returns 1.0/exact) MEDIUM §3.1a matchType:'history' + conf from count OPEN — must build
  • Track 2: sound, ship-first correct, low risk (additive migration + display). Ready to detail.
  • Track 1: mechanism corrected (history bucket from findItemPattern, not partNumberMappings). Net scope is larger than the original “days” framing — it’s a new matcher bucket + two-caller wiring + capture path + persist gating. Honest re-estimate: ~1.5-2 wks human / CC phased over days.
  • Reuse wins: catalog-membership gate (F8), rowVersion CAS (F4), name normalization (F7), customer-scoping (F2) all already exist.

# Phase Decision Classification Principle Rationale Rejected
1 CEO Scope = full two-track, UX(Track2) first then engine(Track1) Premise gate (user-confirmed) — User confirmed all 4 premises at the gate; not auto-decided Engine-first; UX-brief-only
2 CEO Reframe Track 1: promote existing partNumberMappings loop, not build parallel Iceberg one TASTE (→gate) P4 DRY, P5 explicit A live closed loop already exists (orderActions.ts:301/fuzzy-matching.ts:345); building a parallel one duplicates it Parallel manual_cross_references+Iceberg loop
3 CEO Add confirmation_count/last_seen_at/their_upc_variants columns (don’t exist) Mechanical P1 Verified absent in manual-cross-references.ts; §3.2 depends on them Assume built
4 CEO Self-poison guard: only operator_selection counts; demotion-on-override; never auto-promote auto-matches Mechanical (critical) P3 Backfill from isValidated includes auto-validated → laundering Count any validated row
5 CEO Stale-ID guard: invalidate learned mappings on catalog reindex; gate apply on catalog membership Mechanical (critical) P1 CK SKU collapse → 62/76 stale IDs; loop would re-encode WhereFour bind bug No invalidation
6 CEO Correct P4 justification to de-risk/measurement Mechanical P6 Confirmation capture already live; original reason false Keep false reason
7 CEO Demote Phase C embeddings to separate spike TASTE (→gate) P3, P6 Prior MiniLM regressed CK search; not UAT-blocking Keep in plan
8 CEO Identity hygiene = prerequisite foundation (§3.0), not alternative Mechanical P2 Loop on bad IDs learns bad mappings faster Treat as either/or
9 Design Suggested price visually distinct + block silent Enter + inline deviation (§4.2) Mechanical (critical) P1 trust “Suggestion” rendered as confirmed value → wrong-price ship Same KV row as real value
10 Design Keep extraction as “Matched from” line at ink-2, weight up for fuzzy (§4.1) Mechanical P1 Muting it buries the mismatch evidence Mute to ink-3
11 Design NULL matched_product_name fallback + historical backfill (§4.1) Mechanical P1 Historical rows have erpItemId but no name Silent fallback to extraction
12 Design Carve out custom/discount lines from name + red treatments (§4.1) Mechanical P1 No ERP name by design Treat as unmatched
13 Design ERP price row carries UOM; deviation compares like-UOM (§4.2) Mechanical P1 case-vs-each false positives (CK GTIN-14) Single price, naive ratio
14 Design Suggested→confirm reuses existing inline banner, Enter/X, no modal (§4.5) Mechanical P5 Pattern already exists at :522-527 New modal
15 Design Rail: dot carries weight, remove MatchTypeBadge from rail TASTE (→gate) P5 subtraction Row already has ~6 signals; 4th = noise Add weight on top
16 Design h2 long-name clamp + title; divergence skips non-positive, dual-surface Mechanical P1 wrap/overflow + credit-line ratio trips unbounded
17 Design Support read-only: suggested banner info-only, focus non-regression (§4.5) Mechanical P1 read-only audit mode must not learn unspecified
18 Design Token vocabulary: follow existing review-v2 palette, defer pane migration TASTE (→gate) P5 avoid a 3rd color vocabulary in one pane mix DESIGN.md tokens now
19 Eng Track-1 mechanism = history bucket in findProductMatchesBatch from findItemPattern TASTE (→gate) P4 DRY, P5 Loop unreachable from primary matcher; findItemPattern already customer-scoped/name-tiered promote partNumberMappings; cross_references UNION
20 Eng Add confirmation capture to confirmItem + keyboard confirm Mechanical (critical) P1 confirmItem writes nothing today → thesis false leave capture to manual modal only
21 Eng Gate history auto-apply to needs-review in persist/autoReprice path Mechanical (critical) P1, P3 riskiest cell auto-ships w/o review surface rely on auto-validate UI only
22 Eng Reset confirmation count on retarget; await demotion (not fire-and-forget) Mechanical P3 upsert matchCount+1 inherits trust onto new target keep fire-and-forget
23 Eng Name-specificity floor + Jaccard guard on history bucket Mechanical P1 “ALLOWANCE”/“FREIGHT” bind wrong product name trigger ungated
24 Eng matchType:‘history’ + confidence from confirmation_count (not 1.0/exact) Mechanical P3 hardcoded 1.0 makes gating invisible downstream reuse exact/1.0
25 Eng Cite existing catalog-membership gate (getProductsByErpIds null) + rowVersion CAS Mechanical P4 already built; not net-new work treat as new
26 Eng Honest re-estimate: Track 1 ~1.5-2 wks, not “days” Mechanical P6 new bucket + 2-caller wiring + capture + gating keep “days”
27 Gate Track 1 SUPERSEDED by existing xref-bootstrap-from-history.md (+coded backfill) Reframe (user-surfaced via Devin) P4 DRY A better, autoplan-reviewed design + Phase A.5 code already exist re-synthesize Track 1 here
28 Gate REVISE #19: keep bootstrap doc’s cross_references mechanism; use findItemPattern only to widen backfill source Mechanical P4 cross_references is the existing P1 bucket (no new bucket); findItemPattern fixes the customer_part_number coverage gap findItemPattern as the bucket
29 Gate Carry forward 3 deltas as augmentations to bootstrap doc: capture (F5), persist auto-ship (F11), name-tier backfill source Mechanical P1 bootstrap doc under-specifies these drop them
30 Re-review Adopt -from-golden backfill, NOT -from-history (inert: customer_part_number never extractor-populated) Mechanical (critical) P4 author’s own pivot note in -from-golden.ts:5-7; field dead for CK run -from-history
31 Re-review Delta #4: add cross_references reconcile to dagster asset (sweep stale xrefs on reindex) Mechanical P1 only runtime self-gate today; dead rows linger rely on self-gate alone
32 Re-review Refresh stale refs 093e4161→4f234677; cross_references P1 self-gate confirmed sound Mechanical P6 doc predates two-series collapse + Demo retirement leave stale