ops2 events

The ops2 to i3 stream · dispatched, AWB assigned, delivered, RTO, re-fulfilled, cancelled after pick · with the payload, the idempotency key, the claim leg, the resulting ledger rows, the suppression lane that proves the no-double-deduct rule is firing, and an honest column for whether ops2 can actually emit the topic yet

All six topics on this page read "exists in ops2: no". ops2's outbound registry iwms/webhooks.py::ALL_EVENT_TYPES is six strings and every one of them is a warehouse event: carton.created, carton.moved, carton.qc_outcome, carton.qc_queued, transfer.delivered, rts.opened. There is no dispatch, delivery, RTO, refulfil or cancellation topic. Six new event types plus the outbox PR must land in ops2 before a single row below is real. Bridge settings →
9 reverse parcels have had no carrier scan for more than 7 days across 4 couriers. Two crossed their SLA and were promoted to LOST_IN_TRANSIT overnight. A parcel that is neither delivered nor returned is the one state ops2's own model has no column for. Aging board →
Live · pauses on hover
3 events selected · replay re-runs the classifier with the current ruleset · ledger writes are idempotent at the database, and every reverse movement additionally claims a (anchor_key, leg) pair approval required above 10 events

Topic health

Six reverse and fulfilment topics registered here, plus the six warehouse topics ops2 already emits, which module 04 owns. A dashed tile means i3 subscribes and the sibling has no such event type.

healthy stale idle, no expectation not emitting at source
ops2.dispatch.scanned
never0
ops2.awb.assigned
never0
ops2.delivered
never0
ops2.rto
never0
ops2.refulfilled
never0
ops2.cancelled_after_pick
never0
carton.created
1m ago147
carton.moved
2m ago62
carton.qc_outcome
18m ago22
carton.qc_queued
3h ago9
transfer.delivered
41m ago50
rts.opened
> 24h0

Migration state per topic

the "exists in ops2" column is the one v2 has no equivalent of
TopicFamilyExists in ops2ops2 PR neededPosts ledgerClaim legMigration stateReplay24h
ops2.dispatch.scanneddispatchnoemit registry + outboxyesreplacement_outnot_startedrefused0
ops2.awb.assignedawbnoemit registrynononenot_startedrefused0
ops2.delivereddeliverynoemit registry + poller hookyescustody onlynot_startedrefused0
ops2.rtortonoemit registry + poller hookyescustody onlynot_startedrefused0
ops2.refulfilledrefulfilnoemit registry + api_refulfillanchor onlylinks, never postsnot_startedrefused0
ops2.cancelled_after_pickcancelnoemit registryyesreturn_innot_startedrefused0
carton.createdcartonyesnonenomodule 04dual_writerefused147
carton.movedcartonyesnonenomodule 04dual_writerefused62
transfer.deliveredtransferyesnoneyesmodule 07shadowrefused50
rts.openedrtsyesnoneyesrts_outshadowrefused0
CHECK ck_oet_exists refuses to store a topic past shadow while exists_in_ops2 is false, so the registry itself cannot claim readiness a sibling has not delivered. This is deliberately stricter than the CSD registry because ops2's emit() silently returns 0 for an unregistered event type: a typo in a topic name there does not raise, it simply never fans out.

Events per minute · last 60

live
18:14 · 3 events18:16 · 5 events18:18 · 8 events18:20 · 4 events18:22 · 10 events18:24 · 12 events18:26 · 9 events18:28 · 6 events18:30 · 7 events18:32 · 13 events18:34 · 15 events18:36 · 11 events18:38 · 9 events18:40 · 7 events18:42 · 10 events18:44 · 14 events18:46 · 18 events18:48 · 12 events18:50 · 10 events18:52 · 13 events18:54 · 14 events18:56 · 9 events18:58 · 11 events19:00 · 7 events19:02 · 10 events19:04 · 12 events19:06 · 11 events19:08 · 9 events19:10 · 16 events19:12 · 13 eventspeak 18/min
18:1418:4419:12
p50108ms
p95240ms
p99410ms

Reverse parcel outcomes · 30 days

what happens after an RTO
Received and disposed · 412 · 54% In transit, inside SLA · 183 · 24% Carrier confirmed back, not yet inspected · 107 · 14% Lost in transit · 61 · 8% 763 parcels
  • Received and disposed54%
  • In transit, in SLA24%
  • Back, not inspected14%
  • Lost in transit8%

Event stream

GET /api/ops2/events · SSE /api/ops2/events/stream · 847 today, page 1 of 34
TimeTopicAWBCourierOrderAnchorLegIdempotency keyLedgerRule · confStatusms
20:45:12dispatch.scanned 4548827193DHL #MIN10482shopify:8842104:11 sale_out ops2:dispatch:4548827193:l11 1dispatch_ordinary_sale · 0.99 processed182
20:31:08dispatch.scanned 4548827250DHL #MIN10118csd:4688:86402 suppressed ops2:dispatch:4548827250:l04 0replacement_suppressed · 1.00 no double deduct64
20:18:41awb.assigned 77201884Aramex #MIN10451csd:4803:87994 registry ops2:awb:77201884 0reverse_awb_register · 0.99 processed98
19:55:03delivered 44104882PorterEx #MIN10371shopify:8841902:07 custody ops2:delivered:44104882 1delivered_close_transit · 0.99 processed142
19:32:48rto 77198042Aramex #MIN10402ops2:77198042:1841 custody ops2:rto:77198042 1rto_to_in_transit_return · 0.98 processed204
19:14:00delivered 4548825114DHL #MIN10240csd:4714:87004 custody only ops2:delivered:4548825114 0reverse_delivered_no_credit · 1.00 awaiting receipt88
19:02:19refulfilled 4548827311DHL #MIN10271csd:4739:87218 anchor link ops2:refulfil:MIN10271:4548827311 0refulfil_links_anchor · 0.97 linked126
18:48:55dispatch.scanned 4548827311DHL #MIN10271csd:4739:87218 suppressed ops2:dispatch:4548827311:l02 0exchange_out_already_held · 1.00 no double deduct58
18:32:11cancelled_after_pick 90218841Swftbox #MIN10329shopify:8841440:03 return_in ops2:cancel:90218841:l03 1cancel_restore_stock · 0.99 processed168
18:20:02refulfilled MN-70394663Transcorp #MIN10188unresolved unclaimed ops2:refulfil:MIN10188:MN-70394663 0unmatched · reason=courier_change drift audit420
18:02:44dispatch.scanned 4548827104DHL #MIN10466shopify:8842066:22 sale_out ops2:dispatch:4548827104:l22 3dispatch_per_line · 0.99 processed312
17:48:12rto 77190118Aramex #MIN10287ops2:77190118:1902 custody ops2:rto:77190118 1rto_to_in_transit_return · 0.98 19d no receipt94
17:30:55dispatch.scanned 4548827193DHL #MIN10482shopify:8842104:11 suppressed ops2:dispatch:4548827193:l11 0idempotency_duplicate · 1.00 deduped22
17:14:28awb.assigned 44103882PorterEx #MIN10344csd:4776:87511 registry ops2:awb:44103882 0forward_awb_register · 0.99 processed72
16:58:04refulfilled MN-31916010Transcorp #MIN10142unresolved unclaimed ops2:refulfil:MIN10142:MN-31916010 0unmatched · reason=address_change drift audit388
16:40:21cancelled_after_pick no AWBself #MIN10094shopify:8840994:01 return_in ops2:cancel:MIN10094:l01 1cancel_restore_stock · 0.99 processed104
16:22:47delivered SD029458473SHIPA #MIN10218shopify:8841218:05 custody ops2:delivered:SD029458473 1delivered_close_transit · 0.99 processed118
12 dispatch scans today claimed nothing because the leg was already held. Under ops2's own _decide every one of those would have deducted a second time unless somebody had typed the right Shopify tag onto the order. That is the mechanism behind INV1 scenario 32, 111 orders flagged for potential double deduction, and this count is the running proof the replacement is working. A day with zero suppressions while replacements were dispatched is investigated as a bridge failure, not celebrated.

Suppression lane · deductions i3 refused

Every row here is a stock movement ops2 asked for and i3 declined because the (anchor_key, leg) pair was already claimed. The winning claim and the system that won it are named, so a suppression can always be audited back to a real posting.

TimeAnchorLegAsked byAWBSKUQtyWinning claimWon byWon atPayload digestWould have cost
20:31:08csd:4688:86402replacement_outops24548827250MIN-21591clm-88402acsd14:02:11sha256:9f4c…22a1-1 @ HQ_319
18:48:55csd:4739:87218exchange_outops24548827311MSR31-131clm-87218bcsd11:44:02sha256:1a88…7e40-1 @ HQ_319
17:30:55shopify:8842104:11sale_outops24548827193MIN-21591clm-88214cops220:45:12sha256:9f4c…22a1-1 @ HQ_319
16:12:40csd:4776:87511replacement_outshopifyn/aLBA011clm-87511acsd09:18:55sha256:44b1…0c92-1 @ HQ_319
15:44:02csd:4702:86544exchange_outshopifyn/aFIS141clm-86544acsd08:02:14sha256:77e2…4411-1 @ HQ_319
14:58:18ops2:77198042:1841return_inops277198042FYN011clm-84102acsd13:22:40sha256:2c19…8f03+1 @ HQ_319
14:02:11csd:4770:87004replacement_outops277201990CDW031clm-87004acsd10:11:02sha256:6d40…19bb-1 @ HQ_325
13:22:40csd:4744:87104exchange_outops24548827188MSR31-081clm-87104acsd09:40:18sha256:b0f1…5522-1 @ HQ_319
12:18:04csd:4791:87702return_inrfidgate readTNS011clm-87702acsd11:02:33sha256:cc90…7a18+1 @ MCC
11:44:02csd:4798:87881refund_creditshopifyn/aJAE171clm-87881acsd10:50:12sha256:e442…0071+1 @ HQ_319
10:50:12csd:4809:88102exchange_outshopifyn/aMST-CRM-44-BLK1clm-88102acsd08:44:20sha256:31aa…6d80-1 @ HQ_319
09:40:18csd:4671:86188return_inops277177204LP_MSR31-071clm-86188acsd08:12:00sha256:a771…3f22+1 @ BAS
Nine of the twelve digests match the winning claim's digest exactly, meaning the two systems described the same movement and the suppression is unambiguous. Three differ, which is worth a look: a genuinely different second event under the same anchor is either a partial return of a second unit or a mis-anchored payload, and the payload digest is what tells the two apart without opening either body.

Why a carrier "delivered back" never credits stock

A reverse ops2.delivered sets return_parcels.carrier_confirmed_back_at and nothing else. The credit waits for physically_received_at, which only a human dock scan or an RFID gate read can set, and a CHECK enforces that a parcel with units_received > 0 must carry that timestamp.

The reason is a live class in ops2. A provisional system_auto / pending_physical_scan placeholder gets promoted by the silent-RTO sweep to a terminal delivery_status='ReturnedToSender' with delivered_at=NULL and a synthetic source='warehouse' event. The poller then excludes terminal rows, so the carrier's real Delivered scan never lands. Roughly 197 production rows say returned about parcels that reached the customer, and 5 of 7 sampled AWBs were live-verified as carrier-Delivered.

If i3 credited on a carrier string, every one of those 197 would become a phantom unit in sellable.

Refulfil: four reasons link, three do not

ops2's refulfil modal offers seven reasons. Four of them mean the new AWB is a leg of an existing reverse case and must claim against its anchor rather than deduct fresh:
exchange    → links to csd:<case>:<line>
replacement → links
repair      → links
missing_item → links
address_change → new AWB, same units, no claim
payment_change → no stock effect at all
courier_change → no stock effect at all
The three that do not link post nothing: the units already left on the original AWB and are still in IN_TRANSIT_CUSTOMER. Today ops2's api_refulfill has no inventory step of any kind, forward or reverse, so all seven reasons are inventory-silent and the four that should link are exactly where the 111 double-deductions come from.

The two unmatched rows in the stream are the two non-linking reasons arriving without a resolvable anchor, which is correct behaviour surfaced as a queue rather than a silent skip.

The outbox gap and its ordering constraint

ops2's emit() iterates _matching_subscribers and writes a DLQ row per match. With no registered subscriber it writes nothing and returns 0. An unknown event type also returns 0, with a comment saying "we do not raise so a typo does not nuke the user's write".

Two consequences for this bridge:

1. Events fired while i3 is not yet subscribed are gone, with no backfill path. The v2 mockup's banner promised "queued events will backfill on resume"; that promise cannot be kept against the current implementation. Module 04's task T-04.21 is the outbox PR that fixes it, and it touches the ops2 poller, which deploys in lockstep with web.

2. The subscription must be registered before the first topic is switched on, not after. Task T-08.22 exists purely to enforce that ordering, because the failure is silent on both sides: ops2 logs nothing and i3 simply sees no traffic.

What ops2 asks i3 instead of guessing

Today the dispatch scan decides its own exclusion in iwms/bridges/ops2_dispatch.py::_decide, from a free-text order tag plus a prior-dispatch lookup, once per parcel rather than once per line.
// today
if "replacement" in tag_set and _has_prior_dispatch(...):
    return True, "replacement re-send", "tag"
if "exchange" in tag_set:
    return False  # always deducts, never paired

// i3
GET /api/reverse/claims/<anchor_key>
→ { leg: "exchange_out", granted: true,
    claimed_by: "csd", at: "11:44:02" }
A tag becomes evidence with confidence 0.60, beaten by an operator link at 1.00, a variant swap at 0.85 and a line-gap at 0.90, and beaten outright by a CSD claim. It is no longer a decision.

Event stream

ops2 inbox is drained

Every event received has been classified and written. No parcel is stale beyond its carrier SLA and no leg is awaiting a claim decision.
Shipments →

No events match these filters

Courier Transcorp with topic ops2.cancelled_after_pick returns nothing: Transcorp handles no store-cancelled orders.
Claim service unreachable, dispatch classification halted. 14 dispatch scans are queued unclassified rather than posted unclaimed. Posting them would risk exactly the double deduction this bridge exists to prevent, so the queue is the safe failure mode. Events are stored and will classify on resume.
Service health →Claim inspector →

Replay requires admin

You can read the stream, the suppression lane and every payload. Replaying an event needs role >= admin; pausing the ops2 bridge needs role >= ops_manager plus the bridge_control feature flag.
ops2 bridge is paused. ops2 web and poller deploy in lockstep, so the bridge is paused for the duration of an ops2 release. Once the outbox from T-04.21 is live, events fired during the pause replay from ack_cursor. Until then the pause window is a data gap and is kept under two minutes.
GATE 2 cannot start on the ops2 bridge. All six reverse topics read exists_in_ops2 = false, so there is no traffic to reconcile and the 7-day clock has not begun. The blocking work is an ops2 PR adding six event types to ALL_EVENT_TYPES plus the outbox from task T-04.21, which touches the poller and needs an ops2 owner sign-off.

Connected to

Return queue → Repairs → CSD events → All sibling events → IWMS events → Shipments → Transfers → Return to supplier → Shopify events → Classifier rules → Classifier stats → Drift → Conflicts → Webhook outbox → Locations → Audit log → Ops health → Settings →