Fulfillments Topic Mapping Review — 2026-08-12
Task: P1 item C5 (docs/roadmap/P1_BUILD_PLAN.md:61-63) — "verify per-topic envelope
fields for fulfillments/* against the canonical concepts (likely small; measured generic
path already accepts the topic)."
Measured on: commit bd8a581b, branch claude/signal-shopify-integrate-3mq63m.
Method: code reading with file:line evidence, PLUS in-process execution of the real
POST /webhooks/shopify route (FastAPI TestClient, HMAC-signed) with a realistic
fulfillments/create / fulfillments/update payload shaped per Shopify's documented
fulfillments webhook example, and direct exercise of
contracts/mizoki_contracts/envelope.py::CanonicalEventEnvelope.wrap in both directions.
Baseline: the existing gateway suite tests/connectors/test_shopify_gateway.py runs
24 passed on this tree before any change.
Claim labels (TRUTH.md discipline): everything below is implemented-level evidence
(code + local execution). The route is deployed per the 2026-08-11 record
(.claude/memory/active/marketing-commerce-connectors.md:65-75); nothing here is
live-verified — SHOPIFY_WEBHOOK_SECRET is unset in production, so every real delivery
401s and zero fulfillments traffic has ever crossed this gate.
Round-2 addendum (2026-08-12, coordinator-approved): the proposed fixes for G1/G2/G4/G5 were implemented in this working tree, G6's seeded tests were added, and G3's docs half was corrected; G3's fulfillments sync resource was deliberately not built this round per coordinator scope. Everything was re-measured with the same signed-delivery probe and the suites re-run (gateway 24 → 34 passed; remediation 3 passed; service-local 20 passed). Each gap row below states its fix and the re-measured result. Status of every fix: fixed-in-tree, pending land/deploy — claim labels stay
implemented; nothing is live-verified. §1 preserves the PRE-fix measured trace (that is what the review measured), with per-line annotations where the tree now differs.
1. Measured trace of a fulfillments/create | fulfillments/update delivery
Route: services/service-marketing-connectors/main.py:450-537.
- HMAC verify (
main.py:459-461,341-348): fail-closed; empty secret ⇒ 401. Topic-generic. - Replay cache (
main.py:469-471): webhook-id TTL dedupe; duplicate ⇒ ACK, not reprocessed. - GDPR check (
main.py:474-485):fulfillments/*is NOT inGDPR_TOPICS(webhook_hardening.py:266) — proceeds. - Inventory routing (
main.py:488-489): NOT inINVENTORY_TOPICS(webhook_hardening.py:267) — proceeds on the person-side canonical path. - Tenant (
main.py:491-493): header orDEFAULT_TENANT_ID, else 422. - Consent gate (
main.py:498-512):is_person_topic("fulfillments/create")= True (measured) — fulfillments are not on the market-safe allowlist (webhook_hardening.py:277-287), so the fail-closed default (290-294) applies. Correct classification: the payload carriesemail+ a ship-to address block. -subject_key_from_payload=""(measured): fulfillment payloads carry nocustomer.id/customer_id(webhook_hardening.py:257-263), and an empty subject key denies (webhook_hardening.py:159-160). Consequence (measured, section D of the harness): the gate can NEVER pass for fulfillments — a granted registry row still redacts. Fail-closed = the safe direction; recorded as designed behavior, not a gap. - Redaction removed exactly 4 paths (pre-fix measurement):$.email,$.destination.first_name,$.destination.phone,$.destination.last_name— and leftdestination.name("Steve Shipper"),destination.address1("123 Shipping Street"), city/zip/lat/long in the forwarded payload. See GAP-1. (Fixed in tree: re-probe removals are$.email+$.destinationsubtree; name/street/zip/geo leak checks all False.) - CanonicalRecord (
main.py:514-532), measured values: -record_type=shopify_webhook_fulfillments_create/..._update(distinct per topic,main.py:518) -source_id= webhook id, fallbackadmin_graphql_api_id→id→ sha256(raw) (main.py:514-516) -occurred_at= payloadupdated_at(2026-08-11T10:05:00-04:00, offset preserved; chainupdated_at→created_at→processed_at→now,main.py:351-359) -entity_ids=['gid://shopify/Fulfillment/…', '4444444444444']— the fulfillment's own two ids only;order_idwas NOT included (pre-fixmain.py:522). See GAP-2. (Fixed in tree: re-probe shows[..., '4444444444444', '820982911946154508']on both create and update.) -confidence1.0,verification_statusverified,materialitymedium(highreserved fororders/*,main.py:524— defensible, noted only) -provenance= transport/topic/shop_domain/webhook_id/consent_redacted_paths (main.py:525-531); no API version pre-fix (see GAP-5). (Fixed in tree:api_version= the delivery'sX-Shopify-API-Versionheader, explicitnullwhen absent — re-measured both ways.) -payloadretains all market-level provider-native evidence (measured survivors):status,shipment_status,tracking_company,tracking_number(s),tracking_url(s),line_items(id/variant_id/product_id/sku/quantity/price/…),order_id,location_id,origin_address,receipt,name, timestamps — rule 5 (.claude/memory/active/marketing-commerce-connectors.md:35) satisfied for the market-level field set. - Forward to canonical ingestion (
main.py:172-203): sendsobserved_at= now-at-send (main.py:183); does NOT sendavailable_to_model_ator abackfillmarker (measured via source inspection — correct for deliveries, which are not retrospective; the SYNC path now carriesprovenance.backfill=true, G4 fixed in tree).CanonicalEventEnvelope.wrapthen suppliessource_payload_hash+ deterministicevent_id(envelope.py:110-119),available_to_model_at= ingest-time now (envelope.py:123),schema_version,audit_id, and ingestion addsingested_by/tenant_asserted(services/service-canonical-ingestion/main.py:158-159). All Article 2.1 field groups (OPERATING_SYSTEM.md:41-44) are present on the wire. - Bitemporal invariant (
OPERATING_SYSTEM.md:45): measured both directions —available_to_model_at >= observed_at= True on the default path, andavailable < observedraises (envelope.py:87-94). PASS structurally.
Bulk sync path: fulfillments are not pulled at all — ShopifySyncRequest.resource
is Literal["orders", "products", "customers"] (main.py:145), SHOPIFY_QUERIES has no
fulfillments query (main.py:238-276); the only fulfillment signal in backfill is the
coarse displayFulfillmentStatus string on orders (main.py:245). See GAP-3.
Adjacent ingress paths (scope check): the intent extender maps checkouts topics only
(services/intent-shopify-extender/main.py:85-87) and returns ignored for unmapped
topics (:225-228); the legacy ekis TS receiver has no fulfillment handling. The gateway
is the sole fulfillments ingress.
2. PASS / GAP table
| # | Finding | Evidence (file:line) | Severity | Minimal proposed fix |
|---|---|---|---|---|
| P1 | Topic accepted generically; distinct record_type per topic (shopify_webhook_fulfillments_create/_update); HTTP 200 measured |
main.py:518; harness §C/§E |
PASS | — |
| P2 | Fail-closed person-topic scope correctly routes fulfillments/* through the consent gate |
webhook_hardening.py:277-294; measured is_person_topic=True |
PASS | — |
| P3 | occurred_at from provider updated_at/created_at, timezone-aware, offset preserved |
main.py:351-359,520; measured -04:00 |
PASS | — |
| P4 | source_id fallback chain sound (webhook id → gid → numeric id → sha256(raw)) |
main.py:514-516 |
PASS | — |
| P5 | Provider-native market-level evidence retained: status, shipment_status, tracking company/numbers/urls, line items, order_id, location, timestamps all in payload |
harness §C survivors; rule 5 marketing-commerce-connectors.md:35 |
PASS | — |
| P6 | Envelope integrity fields complete downstream: source_payload_hash, deterministic event_id, schema_version, audit_id, provenance enrichment |
envelope.py:110-126; service-canonical-ingestion/main.py:147-163 |
PASS | — |
| P7 | Bitemporal invariant holds: observed_at=send time, available_to_model_at defaults to ingest time, never precedes observed (negative case raises) |
main.py:183; envelope.py:92-93,123; harness §F both directions |
PASS | — |
| P8 | Replay dedupe + deterministic event-id idempotency; tenant 422; HMAC fail-closed (topic-generic, applies to fulfillments identically) | main.py:459-471,491-493; webhook_hardening.py:104-136 |
PASS | — |
| G1 | Consent redaction misses the fulfillment destination container: full name (destination.name) + street address + geo coordinates pass the FAIL-CLOSED gate. Measured with consent DENIED: "Steve Shipper", "123 Shipping Street", lat/long present in the forwarded payload (first/last name, phone, email correctly dropped). Orders' equivalent containers (billing_address/shipping_address/default_address) are subtree-dropped; destination is the uncovered provider-native synonym. The market_signal twin set lacks it too (src/shared/virtuoso_models/market_signal.py:173-178) — coverage gap, not drift. |
webhook_hardening.py:212-219 (set), 228-254 (walker); harness §B/§C |
HIGH | Add "destination" to _PERSON_KEY_JOINED (webhook_hardening.py:216-218) so the ship-to subtree drops exactly like shipping_address. Ship with seeded tests both directions (rule 01): destination absent post-redaction on fulfillments/create; top-level name ("#1001.1"), tracking_*, line_items, origin_address (merchant ship-from) must survive. Serving behavior — gateway redeploy on land. FIXED IN TREE (re-measured): destination + sibling addresses (customer address book, same leak class) added to _PERSON_KEY_JOINED; re-probe removals = $.email + $.destination, "Steve Shipper"/"123 Shipping Street"/zip/geo checks all False, market fields + origin_address intact; drop accounting intact (counter +2, consent_redacted=2). Tests both directions in TestFulfillmentsMappingReview. Pending land/deploy. |
| G2 | Order linkage absent from entity_ids: [fulfillment gid, fulfillment id] only; order_id survives solely inside payload. The envelope Identity axis (OPERATING_SYSTEM.md:42) and the rule-6 spine (…Customer → Order → Product) need the join at envelope level; the sync path already enriches orders' entity_ids with customer/line-item/product/variant ids (main.py:284-295) while the webhook path does not. |
main.py:522; harness §C measured entity_ids |
MEDIUM | One line at main.py:522: entity_ids=[str(v) for v in [payload.get("admin_graphql_api_id"), payload.get("id"), payload.get("order_id")] if v] — also benefits refunds/create (README-recommended topic, same field). Serving behavior — gateway redeploy. FIXED IN TREE (re-measured): entity_ids = ['gid://shopify/Fulfillment/4444444444444', '4444444444444', '820982911946154508'] on create and update; orders delivery pinned unchanged by test (test_orders_entity_ids_unchanged). Pending land/deploy. |
| G3 | No fulfillments backfill/reconciliation path exists: sync resources exclude fulfillments; a missed webhook delivery is unrecoverable (only coarse displayFulfillmentStatus on orders). Active-memory rule 3 lists fulfillments among first-class "reconciliation/backfill" resources; README.md:20 presents "Admin GraphQL pull" as covering "fulfillment … context" — built-but-unwired presented as present-tense (rule 01 Claims). |
main.py:145,238-276,245; marketing-commerce-connectors.md:33; README.md:20,94 |
MEDIUM | Minimal now (docs): reword the README provider row to webhook-only for fulfillments. Build follow-up (register item): add a fulfillments sub-selection to the orders GraphQL query (fulfillments are nested under orders in Admin GraphQL) or a dedicated sync resource, emitting record_type=shopify_fulfillment. Docs half is docs-only; build half is gateway serving behavior when it lands. DOCS HALF FIXED IN TREE: README provider row now states GraphQL pull = orders/products/customers only, fulfillments webhook-only, plus a backfill-section note naming this gap. Build half deliberately NOT built this round (coordinator scope) — the sync-resource gap REMAINS OPEN. |
| G4 | provenance.backfill=true never set on retrospective loads (OS 2.1.4): sync-path provenance is {transport, api_version} only. For fulfillments the rule is currently vacuous (no backfill path exists — G3); for the three sync resources the marker is absent. LOW because the load-bearing half of the rule holds structurally: available_to_model_at defaults to ingest time, never business time (envelope.py:122-123), verified in harness §F. |
main.py:304; harness §G (backfill absent both paths) |
LOW | Add "backfill": True to _shopify_record provenance (main.py:304) so sync pulls are lineage-marked per OS 2.1.4. Serving behavior — gateway redeploy (metadata-only change). FIXED IN TREE (re-measured): sync-path _shopify_record provenance now carries backfill: True (probe §G True; test_sync_records_carry_backfill_true); webhook records carry NO backfill key (probe §G False; test_webhook_records_never_carry_backfill). Pending land/deploy. |
| G5 | Webhook provenance lacks API version — rule 4 (marketing-commerce-connectors.md:34) lists "API version" among preserved evidence; Shopify sends X-Shopify-API-Version on every delivery; the route reads five headers, not that one. Sync path DOES record it (main.py:304). |
main.py:451-457,525-531 |
LOW | Add x_shopify_api_version: str = Header("") to the route signature and "api_version": x_shopify_api_version to provenance (main.py:525-531). Serving behavior — gateway redeploy. FIXED IN TREE (re-measured): provenance api_version = header value when sent ("2026-07" test), explicit null when absent (probe: "api_version": null) — never guessed from the configured Admin version. Pending land/deploy. |
| G6 | Zero gateway tests cover any fulfillments/* topic — suite topics are orders/create, inventory_levels/update, the GDPR trio, carts/subscription_contracts/disputes/tender_transactions/some_future, products/collections/locations; no test exercises a destination-shaped payload, which is exactly where G1 hides. Suite is otherwise healthy: 24 passed on this tree. |
tests/connectors/test_shopify_gateway.py:102,119,143,163,209,221,227-228,250-253,268-269; tests/remediation/test_marketing_connectors.py (zero matches) |
MEDIUM | Add fulfillments/create + fulfillments/update cases with a realistic destination-bearing payload asserting: 200-accepted, record_type, occurred_at from updated_at, order linkage in entity_ids (post-G2), and — both directions — person fields (name/address/email/phone) absent while tracking/status/line_items survive. Tests only — no serving change. FIXED IN TREE: 10 seeded tests added as tests/connectors/test_shopify_gateway.py::TestFulfillmentsMappingReview (G1 catch+legal+walker+customers-sibling, G2 both directions, G4 both directions, G5 both directions); gateway suite 24 → 34 passed. |
Measured behavior recorded, not a gap (safe direction): consent can never PASS for
fulfillments because the subject key is underivable from the payload (no customer id) —
a granted registry row still redacts (harness §D). Fail-closed per design
(webhook_hardening.py:159-160); person fields on this topic are structurally
unrecoverable. Changing that (e.g., resolving the subject via order_id lookup) is an
owner/design decision, not a defect fix, and is intentionally NOT proposed here.
3. Verdict
GAPS — 6 findings, fixes proposed, gateway landing required
The §C5 claim splits under measurement: "the generic path already accepts the topic" is TRUE (P1–P8: envelope axes, hashing, idempotency, bitemporal invariant, and market-level evidence retention are all sound for fulfillments); "likely small" is FALSE in one place — the fail-closed consent gate leaked the ship-to full name + street address on every fulfillment delivery (G1, HIGH — pre-fix measurement; closed in this tree, see remediation status below), because the redaction key-set covered the orders-payload address containers but not the fulfillments-payload one.
Fix routing for the coordinator: - Serving behavior (gateway paths → production redeploy on land): G1 (one set entry), G2 (one line), G4 (one provenance key), G5 (one header + one provenance key). - Docs only: G3's README correction. - Tests only: G6 (required alongside G1/G2 per rule 01 seeded-both-directions). - Build register (later): G3's fulfillments backfill sub-selection.
Remediation status (round 2, 2026-08-12, coordinator-approved):
G1, G2, G4, G5 implemented in the working tree, G6's 10 seeded tests added, G3's docs
half corrected; G3's sync build deliberately not built this round and remains open.
Re-measured with the same signed-delivery probe: G1 closed (consent-denied fulfillment
carries no ship-to name/street/zip/geo anywhere in the forwarded payload), G2/G4/G5
verified in both directions. Suites on the fixed tree: gateway 34 passed, remediation
3 passed, service-local 20 passed. Everything is fixed-in-tree, pending
land/deploy — the G1/G2/G4/G5 files are gateway serving code, so landing fires a
production redeploy; claim labels stay implemented, nothing live-verified.
4. Honest limits — what this review could NOT verify
- Nothing is live-verified.
SHOPIFY_WEBHOOK_SECRETis unset in production, so all real deliveries 401 and the gate has processed zero live traffic (marketing-commerce-connectors.md:71-75);CONNECTOR_CONSENT_REGISTRY_ENABLEDunset ⇒ registry deny (webhook_hardening.py:153-155,164-166). Every conclusion is code reading plus local in-process execution on commitbd8a581b. Claim label ceiling:implemented(routedeployedper prior record); nolive-verifiedclaims are made or licensed. - Payload shape is Shopify's documented example, not a captured live delivery. A real
fulfillments/createmay carry additional fields (API-version-dependent). The redaction walker is key-driven, so an unlisted person-bearing key outside both token sets would also pass; the G1 fix closes the measured hole but does not prove redaction complete over all possible payload shapes. - Downstream KG projection untraced. What the KG mapper does with
record_type=shopify_webhook_fulfillments_*after canonical ingestion (node/edge shapes) is outside C5 scope and was not measured. - Test environment: repo's installed dependencies plus pip-installed
pytest, not a fresh venv (rule 01 caveat disclosed); the 24-test baseline is deterministic and passed. - Round-2 fixes are in the working tree only — uncommitted, unlanded, undeployed. G1/G2/G4/G5 touch gateway serving code and fire a production redeploy when the coordinator lands them. Until then the deployed revision still has the G1 leak (moot in practice: the unset webhook secret 401s every delivery).
Round-2 judgment calls (sibling containers / over- vs under-redaction)
customercontainer (e.g. inlined in orders and any fulfillment variant that carries it): already covered —customersits in both_PERSON_KEY_TOKENSand_PERSON_KEY_JOINED; verified by the existing orders test and the token walk. No change needed.origin_address: deliberately KEPT (not redacted). It is the merchant's ship-from location — org data, the same class as the market-safeshop/updatereasoning; Shopify'sorigin_addressshape carries no name/phone person fields. Redacting it would discard legitimate merchant/market data, which the coordinator explicitly warned against. Stated here per rule 01 (excluded, with reason).addressesADDED alongsidedestination. The customer address book oncustomers/*topics (README-recommended subscriptions) measured the SAME leak class pre-fix: list items'name+address1/city/zip survived the token walk. Bounded change: the key appears in person-bearing payloads only, and market-safe topics skip the gate entirely. Seeded test:test_customer_addresses_container_is_dropped.market_signaltwin sets NOT changed (src/shared/virtuoso_models/market_signal.py:173-178). Different boundary — there the sets powerfind_person_identifier, a DETECTOR for market-only lanes where an address container already means a mis-routed payload — and it was outside the approved scope. Divergence flagged for review per that module's own "drift is caught in review" docstring; a follow-up may adddestination/addressesthere for symmetry.- NOT swept:
payment_details-class fields on orders/checkout payloads (masked card metadata such ascredit_card_number: "•••• 4242", bin, AVS codes). Outside C5 fulfillments scope; flagged rather than silently expanded. Worth its own bounded review. - Metric semantics note:
connector_consent_drops_totalcounts REMOVED PATHS. A container subtree drop counts once, so the same fulfillment payload now counts 2 ($.email+$.destination) where pre-fix it counted 4 leaf paths while leaking the rest of the block. Fewer counted paths with zero leakage is the correct trade; dashboards keying on absolute drop counts should know the unit changed for container-bearing payloads.
Measurement harness: session scratchpad measure_fulfillments.py (drives the real
route via TestClient with signed deliveries; consent denied AND granted; envelope wrap
positive + negative). Run pre-fix (leak measured) and re-run post-fix (leak closed). Not
committed — its assertions now live durably in
tests/connectors/test_shopify_gateway.py::TestFulfillmentsMappingReview, and it remains
reproducible from §1 plus the suite's fixture pattern
(tests/connectors/test_shopify_gateway.py:78-96).