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.
1. The thesis
Section titled β1. The thesisβFour facts, all measured:
- 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.
- 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.
- 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).
- 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.
2. Architecture
Section titled β2. Architectureβ βββββββββββββββββββββββββββββββββββββββββββββββ 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_references3. The pieces (each grounded in existing infra)
Section titled β3. The pieces (each grounded in existing infra)β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_idand 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 ALLintostg_cross_references.sql(the existing dedup window keys onconnection_id, customer_id, item_id, their_item_idordered bylast_synced_atβ history rows must carry a reallast_synced_at). Gold + the Typesense asset pick it up unchanged. - B3. Add
reconcile_documentstotypesense_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) seedsmanual_cross_referencesfrom 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.)
3e. Auto-submit policy: xref-or-review
Section titled β3e. Auto-submit policy: xref-or-reviewβ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.
4. What this demotes (not deletes)
Section titled β4. What this demotes (not deletes)β- 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.
5. Validation & measurement
Section titled β5. Validation & measurementβ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).
- 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.
- 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).
- 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.β
- 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.
6. Phasing (re-sequenced after Codex review)
Section titled β6. Phasing (re-sequenced after Codex review)β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 totypesense,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 whetheritem_descriptionjoinsitem_namein 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:568currently guesses). Do not treat the resolverβsconfidence=1.0as calibrated. - P-D. Dagster bootstrap (B1 PostgresβIceberg, B2 dbt UNION, B3 composite-key reconcile β
NOT the default
erp_product_idkey) + 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.
7. Risks & open questions
Section titled β7. Risks & open questionsβ- 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_referencesis 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
quantityfor the rare within-customer multi-pack lines; the confidence floor for auto-only (non-human-validated) bootstrap rows.
8. Decision-log additions (proposed)
Section titled β8. Decision-log additions (proposed)β- 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_referenceskeyed on the normalized description; the durable channel is Dagster (manual_cross_referencesβ dbt UNION β Typesense), withreconcileadded to sweep stale ids.
Appendix β key files / assets
Section titled βAppendix β key files / assetsβ- 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); dbtstg_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); generatorpackages/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 |
|---|---|---|
| 1 | BatchMatchResult.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. |
| 2 | DB 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. |
| 3 | Ship-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. |
| 4 | No 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. |
| 5 | Flywheel 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. |
| 6 | Configurable 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.