Skip to content

13 β€” Xref-first matching: ship-to-scoped history cross-references + cold-start-to-review

This reframes the CK precision program around the lever the data says is strongest: learn a customer-scoped descriptionβ†’product cross-reference from confirmed order history, auto-submit only what an identity-grade match confirms, and send everything else to a human whose confirmation writes the next cross-reference. It is the Genfit mechanism applied to CK’s signal (a repeated description rather than a repeated part number).

It supersedes doc 12’s emphasis on a calibrated risk score: if fuzzy matches never auto-submit, there is nothing to calibrate. Companion: doc 09 (ship-to resolution), doc 11 (price-pack selector, now a review-assist), docs/designs/xref-bootstrap-from-history.md (the existing bootstrap doc this extends). All claims anchored to code verified 2026-06-17.

Four facts, all measured:

  1. Within a customer, the pack is deterministic. Grouping CK answer lines by (customer, product-family): 97.8% of groups map to exactly one SKU; 93.5% of all order lines sit in single-SKU (customer, description) groups. The pack ambiguity that wrecks matching is global, not within-customer β€” β€œBozzuto’s + CLASSIC CARAWAY” is the 6/16 case nearly every time.
  2. The volume is repeat business. 72.8% of (customer, description) groups recur across β‰₯2 POs; 96.3% of lines are in repeat groups. An xref learned once covers almost all future volume.
  3. Identity-grade binds are safe. Cross-reference / UPC / exact-id binds are 2.6% wrong; fuzzy binds are 32% wrong and all report a useless flat 0.92 confidence (docs 11/12).
  4. Ship-to identifies the customer reliably. The ship-to resolver is 93.5% top-1 (+29pp over name-only’s 64.3%), already wired into the matcher, gated off (doc 09).

Therefore: turn confirmed history into customer-scoped description→SKU cross-references, scope them by the ship-to-resolved customer, auto-submit a line only when it gets an identity-grade bind (xref / UPC / exact), and route every other line to review. Cold start is fine — no history means human review (and that review creates the history). The fuzzy matcher + price-pack selector stop being an auto-submit path and become review-assist: they rank suggestions for the human, whose pick writes the xref.

This hits the Ξ± ≀ 2% target by construction (only identity-grade binds auto-submit), and grows coverage through the flywheel rather than by relaxing the target.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
extracted PO line ───► β”‚ customer = SHIP-TO resolver (93.5%) β”‚ doc 09, activate flag
β”‚ (name-match only if ship-to abstains) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό customer_id scopes the xref lookup
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ MATCH (priority order, first identity-grade hit wins) β”‚
β”‚ P1 cross-reference ── customer-scoped descriptionβ†’SKU (history-learned) β”‚
β”‚ P2 UPC exact / P2.5 exact-id β”‚
β”‚ ────────────────────────── identity-grade line above; auto-submit-eligible ──│
β”‚ P3 fuzzy + price-pack ── REVIEW-ASSIST only (ranks suggestions, never binds β”‚
β”‚ P4 name for auto-submit) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
identity-grade bind ───────────────── no identity-grade bind
β–Ό β–Ό
AUTO-SUBMIT (deterministic gate) HUMAN REVIEW (cold start / fuzzy-only)
β”‚ CSR confirms a product
β–Ό
WRITE customer-scoped xref ──► (next identical order auto-submits)
β”‚
β–Ό Dagster materialize β†’ Typesense cross_references

3a. Ship-to as the primary customer key (activate; mostly built)

Section titled β€œ3a. Ship-to as the primary customer key (activate; mostly built)”

The resolver (resolve_customer_by_shipto, apps/temporal-worker/activities/typesense.py:124; algorithm in packages/pdf-shared/pdf_shared/matching/shipto_resolver.py) already runs first in pdf_extraction.py (name-match only on abstain), and its customer_id already flows into batch_match_items β†’ findProductMatchesBatch β†’ the xref customer filter (customer_id:=[customerId, __UNIVERSAL__]) and is persisted to extracted_orders.erp_customer_id.

Work: turn on the two gates for CK β€” Flagsmith matching_shipto_resolver_v1 + extraConfig.matchingConfig.customerResolution.enabled (the CK org guard is already coded). Then, per doc 09 sites 2–3, pre-rank the review-v2 customer picker and the LLM validator’s candidate set by the resolver score so the operator path is ship-to-scoped too. Critical rule: when ship-to abstains, treat the line as cold-start β†’ review; do not guess the customer, because a wrong customer silently mis-scopes every xref to __UNIVERSAL__.

3b. Configurable P1 gate (matcher-side; the description-alias enabler)

Section titled β€œ3b. Configurable P1 gate (matcher-side; the description-alias enabler)”

Today the P1 accept predicate (typesense-search-service.ts ~2042–2063) requires the line’s extracted ids to exactly contain the alias (their_item_id or an ocr_variant); the search queries q = primaryId (an id). A description alias lives in the line’s name, so it never fires. Make the gate per-connection configurable via the same matchingConfig plane:

matchingConfig.xref = {
queryBy: ['id'] (default, today's behavior) | ['id','name'], // CK: also query by item_name
acceptFields: ['their_item_id','ocr_variants'] (default) | [...defaults,'their_item_desc'], // CK
}

Defaults reproduce today’s gate exactly β†’ byte-identical off-path, other connections untouched. For CK, query the cross_references collection by the normalized item_name and accept a their_item_desc match.

Non-negotiable guardrail: the accept predicate stays an EXACT normalized-equality match (normalize(line.item_name) === normalize(xref.their_item_desc)), never a Typesense fuzzy/relevance match. If configurability slips into accepting high-search_text hits, P1 stops being identity-grade and we have reintroduced the 32%-wrong fuzzy problem inside the safe bucket. The matcher and the Dagster bootstrap (3c) MUST share one normalize() function.

Simpler alternative considered and rejected for the long term: store the normalized description in their_item_id and leave the gate alone (no matcher change, but it overloads the part-number field and is harder to reason about per-connection). The configurable gate is the right abstraction for a platform that keys CK on description and Genfit on part#.

3c. Dagster bootstrap: confirmed history β†’ cross-references

Section titled β€œ3c. Dagster bootstrap: confirmed history β†’ cross-references”

The cross_references Typesense collection is built by the Dagster asset typesense_cross_references (apps/dagster/erp_pipeline/assets/typesense.py:1090) from gold xref_products (dbt) ← silver stg_cross_references ← bronze raw_cross_references ← Iceberg. The authoritative source for non-ERP xrefs is the Postgres table manual_cross_references (packages/db/src/schema/manual-cross-references.ts); its documented path is manual_cross_references β†’ dbt UNION β†’ xref_products β†’ Typesense, but that UNION is not built yet (the existing backfill-xrefs-from-golden.ts pushes straight to Typesense as a stopgap).

Bootstrap source (extracted_order_items + extracted_orders + pdf_documents): rows where is_validated = true and erp_item_id is set, giving (connection_id, erp_customer_id, normalize(item_name) β†’ erp_item_id). ~4,770 validated CK lines exist; the golden bootstrap yields ~610 customer-keyed pairs.

Rows written to manual_cross_references: their_item_desc = the normalized description (and their_item_id = the same normalized description, so it fires whether the matcher uses the configurable-gate or the store-in-id path), customer_id = erp_customer_id, item_id = erp_item_id, source = 'order_history', confidence = 0.95 (human-validated) / 0.80 (auto-only), active = true. Quarantine conflicts: a (customer, description) that resolved to β‰₯2 SKUs (the ~6.5% ambiguous tail) is written active = false / not at all β€” those stay review (price-pack assists), they must not auto-bind.

Dagster work:

  • B1. Postgresβ†’Iceberg bronze asset for manual_cross_references (active = true).
  • B2. dbt/models/bronze/raw_manual_cross_references.sql + UNION ALL into stg_cross_references.sql (the existing dedup window keys on connection_id, customer_id, item_id, their_item_id ordered by last_synced_at β€” history rows must carry a real last_synced_at). Gold + the Typesense asset pick it up unchanged.
  • B3. Add reconcile_documents to typesense_cross_references (it is upsert-only today β€” typesense.py:1200), mirroring the products asset (typesense.py:932), so stale ids from the two-series collapse are swept instead of lingering (Correction #2 in the bootstrap doc).
  • A one-time generator (extend backfill-xrefs-from-golden.ts) seeds manual_cross_references from confirmed history, then the Dagster path is the durable channel.

3d. The flywheel: capture every CSR confirmation as an xref

Section titled β€œ3d. The flywheel: capture every CSR confirmation as an xref”

Today the CSR Confirm gesture (confirmItem, orderActions.ts:331) persists is_validated = true + erp_item_id via PUT /api/extracted-orders/[id] (index.ts:448–494) but writes nothing to manual_cross_references. Hook it server-side in that PUT handler: when a line flips to validated with an erp_item_id, emit an idempotent manual_cross_references upsert (connection_id, ship-to-resolved customer_id, normalize(item_name) β†’ erp_item_id, source = 'operator_selection') on the unique key (connection_id, customer_id, item_id, their_item_id). Server-side (not the client) so it is transactional and can’t be skipped. The next materialize indexes it; the next identical order auto-submits. (Note: the ⌘K manual-pick path assignErpMatch writes part_number_mappings, a connection-scoped table with no customer dimension β€” do not conflate; the customer-scoped xref must come from this hook.)

The deterministic gate (Phase 0a, PR #1744) already blocks any order with an unmatched line. Extend the policy: auto-submit only when every line is identity-grade (P1 xref / P2 UPC / P2.5 exact). A line whose only bind is fuzzy (P3/P4) is treated as not auto-submit-eligible β†’ routed to review with the fuzzy result shown as a suggestion (ranked by price-pack selector + name overlap), not a committed bind. This is the confidence/bucket gate the doc-12 sweep already measures; here it is set to β€œidentity-grade only.” Cold start = review by definition.

  • The Phase-2 calibrated risk score / conformal layer (doc 12) is demoted from the critical path, NOT deleted. Fuzzy binds never auto-submit, so there is no flat-0.92 confidence to make honest for the auto-submit decision. The risk score becomes the review-assist ranker (order the suggestions the human sees) and a future option if flywheel coverage stalls. We do not remove it; we stop depending on it for precision. (Per Codex: replacing calibrated scoring outright is premature β€” keep it as the fallback/ranker.)
  • The Phase-1 abstention heuristics (PR #1745) are not needed for Ξ± β€” xref-or-review subsumes them. They remain gated-off as documented knobs.

Same discipline as the rest of the program (gated, default-off, byte-identical off-path, measured on the 207-order corpus with the gate-simulation + confidence-gate sweep from PR #1744/#1745).

  1. Simulate the xref lever first (before building the pipeline). Bootstrap description→SKU xrefs from a train split of the corpus, apply to a held-out split (split by sender domain to avoid leakage), and measure α + auto-submit-rate when only identity-grade binds auto-submit. This is the ceiling check; the within-customer-stability numbers predict a large lift, but measure it.
  2. Tax it by ship-to error. Re-run with the ship-to resolver in the loop (not oracle customer) so the measured Ξ±/coverage reflects the 93.5% customer accuracy, not a perfect one. Report both (oracle ceiling and ship-to-realistic).
  3. Metrics: wrong-among-auto-submitted (Ξ±, target ≀2%), auto-submit-rate (coverage β€” expected to start low and grow with the flywheel), review-load. Plus the flywheel growth curve: simulate β€œorders 1..N in time order; how does auto-submit-rate climb as confirmations accumulate.”
  4. Ship gates per piece: (a) ship-to ON shows the measured DC accuracy and no cross-tenant leakage; (b) the configurable gate is byte-identical for non-CK connections; (c) the bootstrap’s conflict-quarantine keeps Ξ± at identity-grade; (d) the flywheel hook is idempotent and transactional.

Codex’s correction: do not activate ship-to first β€” on abstain it still guesses (falls to name-match), so activation without the policy change is unsafe. Build the foundations (provenance, gate, invariants) before turning anything on.

  • P-0. Matcher provenance + identity-grade gate. Expose WHICH bucket bound each line (matchType ∈ {xref, upc, idstrip, fuzzy, name} β€” today they collapse to typesense, typesense-search-service.ts:244). Then the auto-submit gate (Phase 0a, #1744) can require every line be identity-grade. Without this, β€œxref-or-review” is unenforceable in production.
  • P-A. Desc-key uniqueness invariant. Enforce one active xref per (connection, customer, normalized-desc) β€” the DB unique key is on their_item_id, not the description, so two SKUs for one description can both stay active and the matcher picks by hit order. Add a uniqueness/quarantine constraint + decide the normalization (and whether item_description joins item_name in the key). This is what makes a description xref identity-grade.
  • P-B. Configurable P1 gate (matcher) β€” and budget for real plumbing: a dedicated normalized-desc query value + acceptance semantics, not just two config keys. Then simulate the xref lever (Β§5.1–5.2) β€” resolver-in-loop, held-out by domain β€” to confirm the lift before pipeline work.
  • P-C. Ship-to as primary key, fail-closed. Activate the resolver AND change the abstain path: ship-to abstain β†’ review, not name-match guess (pdf_extraction.py:568 currently guesses). Do not treat the resolver’s confidence=1.0 as calibrated.
  • P-D. Dagster bootstrap (B1 Postgresβ†’Iceberg, B2 dbt UNION, B3 composite-key reconcile β€” NOT the default erp_product_id key) + the one-time history seed (conflict-quarantined).
  • P-E. Flywheel β€” the server-side confirmation hook, with the poisoning guards in Β§3d.
  • P-F. Flip the policy to xref-or-review; fuzzy/price-pack become review-assist.
  • Ship-to is the new ceiling. ~6.5% wrong-customer β†’ mis-scoped xref. Mitigate: on ship-to abstain, go to review (don’t guess); monitor cross-tenant binds.
  • Cold-start coverage. Auto-submit-rate starts low and grows via the flywheel; acceptable under the $0.10/line economics, but set expectations β€” early weeks are review-heavy.
  • Label noise. Bootstrap inherits any fulfilled-orderβ‰ PO substitution noise; the 97.8% single-SKU stability suggests it’s clean, and confidence-weighting + conflict-quarantine contain it.
  • Staleness. cross_references is upsert-only today; B3 (reconcile) is required or collapsed/retired SKUs linger as wrong binds.
  • Normalization drift. The matcher’s normalize(item_name) and the bootstrap’s must be the same function, or stored xrefs won’t match queries. Put it in shared code.
  • Open: exact normalization rules (case, punctuation, stopwords, brand prefixes like β€œCK”); whether to also key on quantity for the rare within-customer multi-pack lines; the confidence floor for auto-only (non-human-validated) bootstrap rows.
  • D21: CK matching is xref-first β€” auto-submit only identity-grade binds (xref/UPC/ exact); fuzzy + price-pack are review-assist, not an auto-submit path. Supersedes doc-12 Phase 2 as the critical path.
  • D22: Customer identity for matching is keyed off the ship-to resolver (primary); name-match is the fallback only when ship-to abstains; ship-to abstain β†’ review.
  • D23: The P1 xref gate is per-connection configurable (matchingConfig.xref.queryBy / acceptFields) but the accept predicate stays exact normalized-equality to preserve identity-grade reliability.
  • D24: Confirmed history (bootstrap) and CSR confirmations (flywheel) write customer-scoped manual_cross_references keyed on the normalized description; the durable channel is Dagster (manual_cross_references β†’ dbt UNION β†’ Typesense), with reconcile added to sweep stale ids.
  • Ship-to resolver: apps/temporal-worker/activities/typesense.py:124; packages/pdf-shared/pdf_shared/matching/shipto_resolver.py; doc 09.
  • Matcher P1 gate: apps/webapp/src/services/search/typesense-search-service.ts (~1833 query, ~2042 accept).
  • Xref Typesense asset: apps/dagster/erp_pipeline/assets/typesense.py:1090 (+ schema ~538); dbt stg_cross_references.sql, xref_products.sql.
  • Xref source table: packages/db/src/schema/manual-cross-references.ts.
  • Bootstrap source: extracted_order_items / extracted_orders / pdf_documents (packages/db/src/schema/pdf-documents.ts:435); generator packages/db/scripts/backfill-xrefs-from-golden.ts.
  • Flywheel hook: apps/webapp/src/pages/api/extracted-orders/[id]/index.ts:448; apps/webapp/src/actions/orderActions.ts:331.
  • Measurement: apps/webapp/scripts/run-ck-match-eval.ts, packages/eval/src/scorers/ck-match-scorer.ts (gate-sim + confidence-gate sweep).

9. Codex review (2026-06-17) β€” findings & resolutions

Section titled β€œ9. Codex review (2026-06-17) β€” findings & resolutions”

Independent Codex review against the live code. Verdict: xref-first is the right primary pivot, but implementable only with the changes below; do not delete the calibrated-risk path outright. Each finding and how the plan now reflects it:

#Finding (code-verified)Resolution in this plan
1BatchMatchResult.matchType is only typesense|typesense_xref β€” UPC/idstrip/fuzzy all collapse to typesense (typesense-search-service.ts:244/2205/2233/2487); the gate can’t tell identity from fuzzy.New P-0: expose real bucket provenance (matchType ∈ {xref,upc,idstrip,fuzzy,name}) before the identity-grade gate can be enforced in prod.
2DB uniqueness is on their_item_id, not the description (manual-cross-references.ts:94; dbt dedupe stg_cross_references.sql:12) β†’ one description can map to β‰₯2 active SKUs, matcher picks by hit order. Plan keyed on item_name but the fuzzy path uses item_name+item_description.New P-A: hard invariant β€” one active xref per (connection, customer, normalized-desc); quarantine conflicts in the table/sync, not just at bootstrap; decide whether item_description joins the key.
3Ship-to is NOT fail-closed: abstain falls back to name-match (pdf_extraction.py:568); resolver returns confidence=1.0 regardless of score (typesense.py:288).P-C makes abstain β†’ review a code change; do not treat resolver confidence as calibrated. Re-sequenced AFTER the gate/invariant, not first.
4No manual_cross_references bronze/UNION exists (raw_cross_references.sql:23 reads only Iceberg); reconcile resource defaults to id_field='erp_product_id' (typesense_resource.py:360) but xrefs use a composite doc id (typesense.py:1188).P-D: B1/B2 are real work; B3 reconcile must use the composite doc-id key, not the products default; shape Postgres-origin rows so stg dedupe stays stable.
5Flywheel danger: PUT trusts client erpCustomerId/erpItemId/isValidated (index.ts:403/447); a bad confirm writes a priority-1 xref that wins before every bucket β€” worse than a bad fuzzy hit, and deterministic.Β§3d guards (now required): write only on explicit human validation / manual SKU selection (not auto-validated); re-derive customer server-side (don’t trust the client field blindly); enforce one-active-desc-key; stamp verifiedBy/verifiedAt/sourceDetail; first-class deactivation/rollback.
6Configurable gate underestimated: batch xref uses q=primaryId + id-oriented query_by (:1840); MatchGateOptions has no xref config (:37).P-B budgets real plumbing β€” a dedicated normalized-desc query value + acceptance semantics + the gate config β€” not just queryBy/acceptFields.

Net change from review: added P-0 (provenance) and P-A (desc-key invariant) as foundations; re-sequenced ship-to behind them with a fail-closed abstainβ†’review code change; hardened the flywheel against xref poisoning; corrected the Dagster reconcile key; and softened β€œobviates the risk score” to β€œdemotes to review-assist ranker.” The thesis (the within-customer stability + ship-to reliability + identity-grade safety) stands; the execution bar is higher than the first draft implied.