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.

  1. HMAC verify (main.py:459-461, 341-348): fail-closed; empty secret ⇒ 401. Topic-generic.
  2. Replay cache (main.py:469-471): webhook-id TTL dedupe; duplicate ⇒ ACK, not reprocessed.
  3. GDPR check (main.py:474-485): fulfillments/* is NOT in GDPR_TOPICS (webhook_hardening.py:266) — proceeds.
  4. Inventory routing (main.py:488-489): NOT in INVENTORY_TOPICS (webhook_hardening.py:267) — proceeds on the person-side canonical path.
  5. Tenant (main.py:491-493): header or DEFAULT_TENANT_ID, else 422.
  6. 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 carries email + a ship-to address block. - subject_key_from_payload = "" (measured): fulfillment payloads carry no customer.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 left destination.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 + $.destination subtree; name/street/zip/geo leak checks all False.)
  7. CanonicalRecord (main.py:514-532), measured values: - record_type = shopify_webhook_fulfillments_create / ..._update (distinct per topic, main.py:518) - source_id = webhook id, fallback admin_graphql_api_id → id → sha256(raw) (main.py:514-516) - occurred_at = payload updated_at (2026-08-11T10:05:00-04:00, offset preserved; chain updated_at→created_at→processed_at→now, main.py:351-359) - entity_ids = ['gid://shopify/Fulfillment/…', '4444444444444'] — the fulfillment's own two ids only; order_id was NOT included (pre-fix main.py:522). See GAP-2. (Fixed in tree: re-probe shows [..., '4444444444444', '820982911946154508'] on both create and update.) - confidence 1.0, verification_status verified, materiality medium (high reserved for orders/*, 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's X-Shopify-API-Version header, explicit null when absent — re-measured both ways.) - payload retains 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.
  8. Forward to canonical ingestion (main.py:172-203): sends observed_at = now-at-send (main.py:183); does NOT send available_to_model_at or a backfill marker (measured via source inspection — correct for deliveries, which are not retrospective; the SYNC path now carries provenance.backfill=true, G4 fixed in tree). CanonicalEventEnvelope.wrap then supplies source_payload_hash + deterministic event_id (envelope.py:110-119), available_to_model_at = ingest-time now (envelope.py:123), schema_version, audit_id, and ingestion adds ingested_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.
  9. Bitemporal invariant (OPERATING_SYSTEM.md:45): measured both directions — available_to_model_at >= observed_at = True on the default path, and available < observed raises (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

  1. Nothing is live-verified. SHOPIFY_WEBHOOK_SECRET is 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_ENABLED unset ⇒ registry deny (webhook_hardening.py:153-155,164-166). Every conclusion is code reading plus local in-process execution on commit bd8a581b. Claim label ceiling: implemented (route deployed per prior record); no live-verified claims are made or licensed.
  2. Payload shape is Shopify's documented example, not a captured live delivery. A real fulfillments/create may 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.
  3. 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.
  4. 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.
  5. 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)

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).

← All docsView source on GitHub →