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 codedpackages/db/scripts/backfill-xrefs-from-history.ts. That doc’s mechanism (fill the existing Typesensecross_referencesP1 bucket frommanual_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:
- 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.- Persist/autoReprice path auto-ships with no review surface — the confirm/reject UI only fires in
auto-validate, notpersist-auto-priced-matches. Gate history auto-apply there.- 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 withfindItemPattern’s name tier to cover them. Eng-review decisions #19 (findItemPattern-as-new-bucket) is revised: keep the bootstrap doc’scross_referencesmechanism; usefindItemPatternonly 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_referencesIS 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.tsfilterscustomer_part_number IS NOT NULL, but the extractor never populates that field (codes go toitem_ids[]; only VMS sets it). Author already pivoted tobackfill-xrefs-from-golden.tskeyed onitem_ids+item_name. Adopt-from-golden, not-from-history.- Stale-ID defense is RUNTIME-ONLY: the dagster
cross_referencesasset has noreconcilestep (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 across_referencesreconcile 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 sandbox4f234677.- Net deltas this autoplan contributes to the bootstrap doc: (#1) build the live capture hook —
confirmItemwrites nothing today; (#2) gate the persist/autoReprice path (no review surface there); (#3) key the go-forward source onitem_ids+item_name(=-from-golden/findItemPattern tiers), notcustomer_part_number; (#4) addcross_referencesreconcile on reindex.
0. The thesis in one paragraph
Section titled “0. The thesis in one paragraph”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. Problem statement
Section titled “1. Problem statement”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-waymulti_search: customer xref, universal xref, fuzzy product, UPC-filter, name+description. Phase A ofdocs/designs/typesense-matching-architecture.mdis built.- BUT the customer-keyed
cross_referencescollection 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.sqlis stillSELECT * FROM iceberg_source('cross_references')with no UNION to the Postgres table. There is noxref-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) renderitem.itemName(raw OCR) everywhere. The ERP match card (~652) shows onlyerpItemId(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 intopersist-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 asource. The price is written toextracted_order_items.unit_priceon 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)”- Typesense
cross_references(primary, customer-scoped) — populated only from P21 Iceberg + stale universal rows; never from confirmations. 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.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 insidefindProductMatchesBatch’s app-code ranking (slot it relative to xref/UPC — see §3.1a), sourced fromfindItemPattern(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 existinggetProductsByErpIdshop — 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 + legacycustomer_idbackfill (F6), part-codes-only. - (c) Typesense
cross_referencesUNION 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 fromconfirmation_count, NOT the hardcodedexact/1.0 the currentfindPartNumberMappingMatchreturns (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
erpItemIdin 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).
savePartNumberMappingfires only fromassignErpMatch(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 toconfirmItemand the keyboard-confirm path that records an operator confirmation. If using thefindItemPatternsource, the “table” is the accepted-order history itself — capture = ensuring confirmed rows are queryable with asource/confirmed marker; spec the exact write. - Only human confirmations count. Auto-validated rows may surface as
suggestedbut never auto-promote on their own count (no laundering). - Demotion that can’t inherit trust (ENG F9): override flows through the fire-and-forget
assignErpMatchupsert which doesmatchCount + 1on(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 >= 2AND target ∈ current catalog (§3.0/F8). First confirmation →suggested(Review V2 confirm/reject, §4.5). - Persist-path gating (ENG F11 — the riskiest cell): when
historyAwareMatchingANDautoRepriceOnExtractionare both ON, a history match flows throughpersist-auto-priced-matches.tsand is writtenisAutoValidated:truewith no operator review (Track-2’s confirm UI only fires in theauto-validatepath). Gate history auto-apply in the persist path tosuggested/needs-review until reviewed, or block it when autoReprice is on. - Schema:
confirmation_count/last_seen_atdo not exist onmanual_cross_references; thefindItemPatternpath 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) andassignErpMatch(carryresult.name, stop discarding). - Center
h2switches to ERP product name when matched. Extraction text is NOT muted — render a persistentMatched from «raw extraction»line at--om-surface-ink-2(the diff the operator’s eye runs to catch a mismatch). For low-trustmatchType(fuzzy/trigram/fulltext) render that line with more weight, not less. (Design F-D2) - Long names: clamp h2 to 2 lines +
titleattr. (F-D8) - NULL-name fallback: historical rows have
erpItemIdbut NULLmatched_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 existingextractedUnitPrice. 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-3italic / “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
Ooverride 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, sometimesDiscountAppliedBadge). 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
MatchTypeBadgefrom 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
suggestedstate 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 (reuseisAutopath), 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.
5. Phasing
Section titled “5. Phasing”- 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
partNumberMappingsloop (§3.1 recommended path): raise precedence, key onitem_name/item_description, addoperator_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_referencespath 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.tswith CK history fixtures: a customer with N prior confirmed orders → assert history xref wins over fuzzy. xref-learnerunit tests: idempotent upsert, confirmation_count increment, source precedence.- dbt UNION test: Postgres row visible in
stg_cross_referenceswithout 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.
7. Out of scope
Section titled “7. Out of scope”- Redesigning the three-pane layout. Mobile/responsive. Replacing Typesense. New ERP connectors. (Phase C embeddings are in scope but last and gated.)
8. Open premises (for the gate)
Section titled “8. Open premises (for the gate)”- 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) → erpItemIdis the single highest-precedence signal, above fuzzy text. (Validated by the existingorder-item-patterndesign 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) → erpItemIdstability 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.
Appendix A: Original UX brief
Section titled “Appendix A: Original UX brief”(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)”9.1 Error & Rescue Registry
Section titled “9.1 Error & Rescue Registry”| 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) |
9.3 NOT in scope (deferred)
Section titled “9.3 NOT in scope (deferred)”- 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.
9.4 CEO completion summary
Section titled “9.4 CEO completion summary”- 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. ENG-phase outputs (mandatory)
Section titled “10. ENG-phase outputs (mandatory)”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).
10.2 Test diagram + plan
Section titled “10.2 Test diagram + plan”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.
10.3 Failure modes — eng additions
Section titled “10.3 Failure modes — eng additions”| 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 |
10.4 Eng completion summary
Section titled “10.4 Eng completion summary”- Track 2: sound, ship-first correct, low risk (additive migration + display). Ready to detail.
- Track 1: mechanism corrected (history bucket from
findItemPattern, notpartNumberMappings). 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.
Decision Audit Trail
Section titled “Decision Audit Trail”| # | 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 |