Classifier rules

The unified catalog: v2's 39 Shopify scenarios reconciled with INV1's 37-scenario audit into 72 numbered rules · editable sentences, immutable versions, shadow mode with a promotion gate

Every count on this page is derived from the catalog table, never written as a literal. v2 disagreed with itself in three places: rules.html said 39 rules, classifier-stats.html said 42, and the event detail page said "evaluated 38 rules". i3 has 72, of which 47 have a v2 origin, 31 an INV1 origin, 22 both, 19 are i3-new and 1 is removed. Hit stats →

Group A · sales

12C-01 to C-12
C-01straight_sale_onlinesingle line, web v2 1 · INV1 2both todaylive v4
WHENtopic==fulfillments/create ANDorder.source_name!=pos ANDline_items.count==1 ANDorder.tagsexcludesExchange: Consignment: Preorder THENpost ledger−qty @STOCK@fulfillment.location_id
Ledger rows 1 Key shopify:sale:{order_id}:{line_id}:{fulfillment_id} Hits 7d 4,182 v4 by ahmad 24-07 · history
C-03pos_salesource_name = pos v2 3 · INV1 1both todaylive v3
WHENtopic==orders/create ANDorder.source_name==pos FOR EACHline THENpost ledger−qty @STOCK@mapped store ANDlink serialWITHIN5 min
Ledger rows 1 per line + unit hop Key shopify:sale:{order_id}:{line_id}:pos Hits 7d 2,008 v3 by ahmad 19-07 · history
C-11preorder_backorderreplaces v2 scenario 16 i3 newneither todayshadow v1 · 3/7
WHENorder.tagscontainsPreorder ANDinv.sellable==0 THENpost no ledger row ANDrecord obligationAGAINSTinbound PO line
Ruling L17. v2 scenario 16 drew the preorder from IN_PRODUCTION when STOCK = 0. That would make unproduced goods sellable and breaks the rule that IN_PRODUCTION is a real balance that is never sellable. The order still exists, no stock is deducted, and a fulfilment obligation is recorded instead.
Ledger rows 0 Key shopify:backorder:{order_id}:{line_id} Hits 7d 22 v1 by ahmad 23-07 · history
C-11xpreorder_from_productionv2 scenario 16, as written v2 16never builtremoved
WHENorder.tagscontainsPreorder ANDSTOCK==0 THENdraw fromIN_PRODUCTION
Removed by ruling L17 · superseded by C-11 A removed rule that vanishes from the list is indistinguishable from a rule nobody wrote, so it stays.
C-12bundled_sku_expandthe sold SKU is a composite INV1 recipesINV1 todaylive v2
WHENsku.is_composite==true THENexpand recipePINNED ATrecipe_id at sale time FOR EACHcomponent post ledger−qty × ratio@STOCK ANDzero rows for the parent
Rulings L2 and L3. The composite never holds stock, so the parent gets no row. And the recipe_id is pinned on the decision: a later refund replays that exact version. INV1's refund path re-resolves the currently active recipe, so a recipe edit between sale and refund returns components that were never shipped and strands the ones that were.
Ledger rows n components, parent 0 Key {parent_key}:recipe:{recipe_id}:{component_sku_id} Hits 7d 378 v2 by ahmad 21-07 · history

Group C · refunds and returns

7C-16 to C-22
C-16refund_restockrestock_type = return v2 7 · INV1 9both todaylive v3
WHENtopic==refunds/create FOR EACHrefund_line_item WHERErestock_type==return THENpost ledger+qty @QUARANTINE@restock location
Ruling L19. A refund credits QUARANTINE, never STOCK. Present, owned, not sellable. Only a physical scan moves it out. No rule in the refund or ops2 groups has STOCK in its action set, which makes a carrier string structurally incapable of crediting sellable stock.
Ledger rows 1 per refunded line Key shopify:refund:{refund_id}:{line_id}:restock Hits 7d 462 v3 by ahmad 22-07 · history
C-17refund_no_restockrestock_type = no_restock v2 8 · INV1 12both, differentlyshadow v3 · 7/7
WHENrefund_line_item.restock_typeinno_restock THENpost no ledger row ANDopen drift caseWITHrefund_amountasevidence
INV1 credits stock here on the stated grounds that "our system tracks physical stock movements, not Shopify restock flags". The RFID app's own P0 list says the opposite: a no-restock refund means the unit was damaged, kept, or never returned, so the tag must not flip back to active. i3 sides with RFID and posts nothing, because a credit i3 cannot evidence is exactly the kind of phantom stock that made i2 unusable.
Ledger rows 0 Key shopify:refund:{refund_id}:{line_id}:norestock Hits 7d 58 v3 by ahmad 22-07 · history

Group D · exchanges

6C-23 to C-28
C-25size_variant_exchangerefund A plus sale B INV1 17INV1 todaylive v2
WHENcsd.case.intent==exchange THENpost ledger+qty@QUARANTINEFORold_sku ANDpost ledger−qty@STOCKFORnew_sku SHARINGone anchor key
R-14.14. The exchange-out leg and the refund leg share the key csd:{case_id}:{line_id}, so whichever arrives second is suppressed by (source_system, idempotency_key). This is the fix for defect D14: ops2's returns bridge writes the tag replace_pending while the dispatch bridge reads replacement, so the tag is never seen and a replacement re-send deducts a second time.
Ledger rows 2, different SKUs Key csd:{case_id}:{line_id} Hits 7d 72 v2 by ahmad 20-07 · history
C-27ghost_exchangerefund, money, zero line items INV1 18 · 812 ordersdetected, unresolvedlive v1
WHENrefund.transactions.count>0 ANDrefund.refund_line_items.count==0 THENpost no ledger row ANDopen drift caseWITHrefund AED as evidence
Ledger rows 0 by design Key shopify:ghost:{refund_id} Hits 7d 44 v1 by ahmad 16-07 · history

Group E · fulfilment and edits

7C-29 to C-35
C-33cross_location_fulfilmentorder location differs from fulfilment location INV1 23 · 1,205 orderswrong in bothshadow v2 · 7/7
WHENfulfillment.location_id!=order.physicalLocation THENpost ledger−qty @STOCK@fulfillment.location_id NEVERorder.physicalLocation
R-14.7. INV1 attributes the deduction to order.physicalLocation for API performance reasons, and its own docs record the cost: 1,205 orders with wrong store attribution. The RFID app lists the same thing as a P0. Shadow shows 122 decisions where the two locations differ and the fulfilment location is right in every one. Where a fulfilment carries no location the decision is blocked, never defaulted to HQ_319.
Ledger rows 1 Key shopify:sale:{order_id}:{line_id}:{fulfillment_id} Hits 7d 122 v2 by ahmad 23-07 · history
C-31order_edit_add_lineitems added after creation v2 21 · INV1 26neither todaylive v1 · never fired
WHENtopic==orders/updated ANDline_itemsadded sincelast revision THENpost only when a fulfilment covers the added line
Liability. The INV1 audit counted 5,019 edited orders, and this rule has matched zero events in 30 days while orders/updated received 312 today alone. Either the matcher is wrong or C-01 is absorbing the added lines silently. Not healthy silence.
Ledger rows 0 or 1 Key shopify:sale:{order_id}:{line_id}:{fulfillment_id} Hits 7d 0 v1 by ahmad 15-07 · history

Group G · ops2

7C-41 to C-47
C-42ops2_returns_scanfour-bucket returns classification v2 6.1neither todaydisabled · awaiting sibling
WHENsource==ops2 ANDtopic==returns.scanned FOR EACHline THENpost ledger+qty@QUARANTINE
Ruling L18. returns.scanned is not in ops2's ALL_EVENT_TYPES, which is six strings and all inbound-warehouse. The rule is written, registered and disabled, and event_topics.posting_allowed cannot be set true while exists_upstream is false. Zero hits here is correct, not dead.
Ledger rows 1 per line when enabled Key ops2:retscan:{awb}:{line_id} Hits 7d 0 v1 by ahmad 14-07 · history

Group L · terminal

3C-70 to C-72
C-70known_noopshapes with no inventory consequence INV1 7, 33, 36, 37 · v2 19, 33INV1 todaylive v1
WHENshapeindiscount · manual payment · gift wrap · fraud void · order note ORtopicis notthe posting topic for this line THENrecord noop with the reason
This rule exists so a reader can prove a shape is handled. INV1 scenario 7 alone is about 77,000 discounted orders with no inventory impact; without a rule to point at, a later reader concludes the shape is unhandled. It is also the rule that catches the three non-posting order topics under R-14.6.
Ledger rows 0 Key shopify:noop:{topic}:{order_id} Hits 7d 1,506 v1 by ahmad 12-07 · history
C-72unmatchedno rule scored above threshold v2 38i2 todaylive v1
WHENmax(rule.score)<0.800 THENpost no ledger row ANDopen drift caseIN THE SAME TRANSACTION ANDstore all 72 scores
An event is never dropped. ck_cd_unmatched makes an unmatched decision without a drift case impossible to insert, so the two counts on the classifier page cannot diverge.
Ledger rows 0 Key n/a · the drift case carries the identity Hits 7d 31 v1 by ahmad 12-07 · history

Draft editor

C-33 v2, shadow

shadow
Backtest window

A backtest runs against the shadow posting path with writes_ledger = FALSE. It cannot touch ledger_entries under any circumstance.

Deployed v1Draft v2
matched 54,082matched 54,204
-+122 relocated
blocked 0blocked 4
−1,205 mis-attributed-
avg 18msavg 16ms
divergence 0.00%divergence 0.00%

The 4 new blocked decisions are fulfilments with no location_id. v1 silently sent those to HQ_319. Blocking them is the improvement, not a regression.

Promotion gate

R-14.11

2 ready
C-33 cross_location_fulfilment v2
23
24
25
26
27
28
29
Clean streak
7 of 7
Decisions
1,204 · min 30
Worst day
0.0000% · max 0.1%
P1 divergences
0 · max 0
Approver
needed, not the author
C-11 preorder_backorder v1
23
24
25
26
27
28
29

Two thin days: 22 and 19 decisions, both under the 30-sample floor. A day with too few samples is not a clean day, it is an untested day.

C-01 straight_sale_online v5
23
24
25
26
27
28
29

Draft v5 emits a zero-quantity RESERVED row. The ledger rejects quantity_delta = 0 under ruling L14, so this would error after promotion. One P1 divergence, streak reset to zero today.

See the divergence

Version history

C-01 · append only

v5shadowadds a RESERVED pair28-07
v4liveexcludes the Preorder tag24-07
v3retiredmoves the anchor to fulfillment_id19-07
v2retiredadds the source_name != pos clause15-07
v1retiredseeded from the v2 catalog12-07

A version that has been live is immutable. Editing produces a new version, so a ledger row can always be traced to the exact rule text that wrote it. i2's classifier_rules had no shadow_mode, no last_matched_at and no match_count column despite its PRD specifying all three.

Shadow is real here

R-14.10

mode CHECK (mode IN (
  'draft','shadow','live','disabled'))

-- the writer refuses a non-live version
IF v.mode <> 'live' THEN
  RAISE EXCEPTION 'shadow rule …'

i2 offered the same toggle and it did nothing. api_rule_set_state mapped both active and shadow to is_active = TRUE, and the display read an is_shadow column that does not exist in the schema. So a rule flipped to shadow kept writing real ledger rows, and the page rendered it as active forever while reporting "0 rules in shadow" as though that were a fact.

Permission: authoring needs role >= manager plus i3.classifier_author; promotion needs i3.classifier_promote and an approver who is not the author.

Catalog index

Origin says where the rule comes from · Today says what actually exists in the incumbents right now · every posting rule has a non-empty key formula

#RuleGroupOriginTodayLedger rowsIdempotency keyModeHits 7d
C-01straight_sale_onlinesalebothboth1 @ STOCKshopify:sale:{order}:{line}:{fulfil}live v44,182
C-02multi_line_salesalebothboth1 per lineshopify:sale:{order}:{line}:{fulfil}live v22,594
C-03pos_salesalebothboth1 @ storeshopify:sale:{order}:{line}:poslive v32,008
C-05cod_salesaleINV1 4INV11 @ STOCKshopify:sale:{order}:{line}:{fulfil}live v1288
C-10b2b_consignmentsalev2 15neither2, movement pairshopify:consign:{order}:{line}:{leg}live v10
C-11preorder_backordersalei3 newneither0, obligationshopify:backorder:{order}:{line}shadow v122
C-11xpreorder_from_productionsalev2 16never built-removed by L17removed-
C-12bundled_sku_expandsaleINV1INV1n components, parent 0{parent}:recipe:{recipe_id}:{comp}live v2378
C-14cancel_post_fulfilmentcancelv2 11i21 @ STOCK +shopify:cancel:{order}:{line}:postfulfillive v146
C-16refund_restockrefundbothboth1 @ QUARANTINEshopify:refund:{refund}:{line}:restocklive v3462
C-17refund_no_restockrefundbothboth, differently0, drift caseshopify:refund:{refund}:{line}:norestockshadow v358
C-21price_adjustment_onlyrefundINV1 14INV10, monetaryshopify:adjust:{refund}live v10
C-25size_variant_exchangeexchangeINV1 17INV12, one anchorcsd:{case_id}:{line_id}live v272
C-27ghost_exchangeexchangeINV1 18detect only0, drift caseshopify:ghost:{refund_id}live v144
C-33cross_location_fulfilmentfulfilmentINV1 23wrong in both1 @ fulfil locshopify:sale:{order}:{line}:{fulfil}shadow v2122
C-34missing_sku_linefulfilmentINV1 25INV1 skips0, noop reasonshopify:nosku:{order}:{line}live v162
C-37variant_sku_changecatalogv2 37partial0, alias onlyshopify:skurename:{variant}:{new_sku}live v286
C-40shopify_inventory_manual_adjustcatalogv2 34i2 logs0, never autoshopify:invlevel:{item}:{loc}:{ts}live v16
C-41ops2_dispatch_scanops2v2 6.1neither0 or 1, reconcilesops2:dispatch:{awb}:{order}:{sku}live v21,214
C-42ops2_returns_scanops2v2 6.1neither1 @ QUARANTINEops2:retscan:{awb}:{line_id}disabled0
C-44carrier_status_changeops2INV1 30, 31neither1 @ QUARANTINEops2:carrier:{awb}:{status}:{at}disabled0
C-51csd_reservation_placecsdv2 6.2wrong · D162 per line, paircsd:res:{res_code}:{line}:{seq}live v2168
C-54grn_receipt_linegrn_iwmsv2 6.3INV1 broken · D101 @ RNIgrn:{grn_number}:{line_id}live v164
C-57iwms_picking_replenishgrn_iwmsv2 6.5broken · D12, movement pairiwms:replen:{carton}:{task_id}live v131
C-61rfid_gate_readrfidv2 6.4rfid1, authoritativerfid:GATE:{epc}:{occurred_at}live v1296
C-64rfid_write_offrfidv2 6.4rfid1 @ WRITTEN_OFFrfid:WRITE_OFF:{epc}:{approval_ref}live v10
C-70known_noopterminalbothINV10shopify:noop:{topic}:{order_id}live v11,506
C-71blockedterminali3 newneither0, retryablen/a · retry queue carries itlive v118
C-72unmatchedterminalv2 38i20, drift casen/a · the drift case carries itlive v131
72 rules · 29 shownSix rows say the incumbent is wrong or broken rather than merely absent. Those are D1, D10, D14, D16 and the 1,205 cross-location orders, and each one is a live production defect rather than a missing feature.
Loading

Rule cards keep their heights so a sentence does not reflow as the hit counts land.

Empty and zero-filter
No rules match.

The catalog seed ships all 72, so a truly empty list means the seed did not run. Zero-filter shows the chips: Shadow RFID together hide all 72.

Error
Draft failed to compile.

The classifier keeps running the last live version. A broken draft never displaces a working rule, and it never silently becomes live.

Permission

A viewer sees every sentence, every key formula and every count, and no editable token. Promotion is gated on a feature flag plus a second human.

Maintenance

During the nightly verify the page is read-only. Rule authoring writes to the audit chain, so it waits for the walk to finish rather than appending mid-verification.

Gate-blocked

Thirteen rules cannot be enabled because their topic is not emittable upstream. They render with awaiting sibling and the ruling reference, not as disabled or dead.

The rules this page enforces

R-14.5, R-14.6, R-14.39

One physical event, one key, one posting topic. The key is anchored on the event, not on the delivery, the webhook id, or the topic that reported it:

shopify:sale:{order_id}:{line_id}:{fulfillment_id}

i2 anchored on the topic prefix. on_order_create used oc:{webhook_id[:16]} and on_order_update used ou:{order_id}, and orders/create, orders/paid, orders/updated and orders/fulfilled all route into those two functions. The keys genuinely differ, so (source_system, idempotency_key) cannot catch it, and the sale posts twice on essentially every order.

R-14.6 closes it from the other side: for any (order_id, line_id) exactly one topic is the posting topic, decided by source_name. Every other topic on that order resolves to noop. Two topics can never both attempt a posting for the same line.

Hit stats A traced event
Keyboard J K moveEnter expand Shift Enter edit sentenceN new rule B backtestP request promotion H version history⌘S save draft⌘K palette
Connected to Classifier stats Event inbox Event detail Sibling events Alert rules Drift Audit log Outbox SKU detail Aliases Returns GRN RFID Settings