Shopify Merchant Onboarding Pack v1.0 (W6)

Status: v1.0 (2026-08-27) · Shopify Closeout + Merchant Expansion master prompt v1.0, workstream W6 · Canon: docs/product/SIGNAL_SHOPIFY_MASTER_v4.md (build spec) and docs/product/SIGNAL_OVERVIEW_v5.md (top-level). Claim discipline: [Validated] / [Illustrative] / [Roadmap] per .claude/rules/03-canonical-architecture.md. Every step below is a test, not a promise — see docs/product/SHOPIFY_INSTALL_ACCEPTANCE_v1.md for the install-flow acceptance bar this pack extends.

This is a sequencing map for a merchant walking Shopify → MIZOKI Signal, and an honest inventory of what each step is backed by. It does not itself change any flag or serve any new merchant traffic.

1. Connect the store — OAuth install [Validated]

Governed by services/service-marketing-connectors/shopify_install.py + the /shopify/install / /shopify/callback routes (main.py). Flag SHOPIFY_OAUTH_ENABLED, off by default; owner-armed per docs/runbooks/SHOPIFY_CONNECT_RUNBOOK.md.

One merchant, one tenant (lane plan 2026-09-30, D5/D9; merged 2026-10-01, deployed with that merge): an install started from the merchant's authenticated onboarding session (POST /api/v1/shopify/install/start) lands in the tenant that holds their declarations and cost worksheet; the direct install URL lands in the shop's existing registry tenant, else a default mint. A shop is never re-homed by an install or a credential save, and a Connectors-page token save never overwrites an OAuth install's custody. The console's Connect Shopify button that calls the start route landed with #1285 (merged 2026-10-01, deployed by deploy-ui run #557 — the console entry below; its review round-1 fix,

1291, was closed unmerged 2026-10-01 and is not on main); readiness over the registry row + first delivery landed with #1286

(deployed, connectors run #84); the registered-webhooks stamp landed with

1304 and its round-1 fix #1312 (deployed, connectors runs #88 and #89,

2026-10-02).

W6 polish (this pack): every install/callback failure now carries an additive, merchant-readable merchant_message + retryable field alongside the existing engineering reason/detail — shopify_install.merchant_error_copy(), wired into every OAuth error response in main.py. The engineering fields keep their shape. Since D12 (#1316, 2026-10-02) the reason vocabulary has two tiers, and a consumer of the PUBLIC routes' bodies must read the generic one: while the OAuth path is off or unconfigured, the public GET /shopify/install and /shopify/callback answer ONE posture-free reason, shopify_oauth_unavailable (503), so that an unauthenticated caller cannot tell a disabled flag from missing credentials; the precise reasons shopify_oauth_disabled and shopify_oauth_unconfigured are answered only on the authenticated POST /api/v1/shopify/install/start and named on /readyz. Covered reasons: shopify_oauth_unavailable (public routes), shopify_oauth_disabled, shopify_oauth_unconfigured (authenticated start route), shop_malformed, state_unknown / state_replayed / state_expired / state_shop_mismatch, code_missing, token_exchange_failed, webhook_registration_failed, pixel_activation_failed, and the D5 tenant-ladder refusals shop_bound_to_other_tenant / shop_registry_unavailable. The webhook and pixel failures are explicitly retryable=true — re-running /shopify/install is always safe (the install is idempotent; see shopify_oauth_callback's docstring).

Test: services/service-marketing-connectors/test_shopify_install.py::MerchantErrorCopyTests (14 route reasons covered, plus an unknown-reason fallback that never leaks the raw reason string; the public reason's copy is pinned to name no posture).

**Console entry — the Connect Shopify button (lane plan 2026-09-30, D5/D9;

1285, merged and deployed 2026-10-01 by deploy-ui run #557, with the gateway

release carrying POST /api/v1/shopify/install/start deployed the same day by

1284 / connectors run #85; the button renders whenever the gateway's catalog

reports the OAuth path available, as production did that day; the round-1 fix for the unavailable case, #1291, was closed unmerged and is not on main): the /onboarding Connectors step's Shopify card offers Connect Shopify** when the gateway's catalog row says the OAuth path is available (connection_modes.oauth_install.available). The button posts the typed store to the console BFF route POST /api/bff/connectors/shopify/install/start (requireRole, admin — the /onboarding floor; tenant ONLY from the verified session, a body naming another tenant is 403), which calls the gateway's authenticated start route for that tenant and answers the store's authorize_url; the card opens it only after re-checking it is the typed store's own https://<store>/admin/oauth/authorize, so the console can never be an open redirect even if the gateway answer were wrong. When the OAuth path is off or unconfigured the card says so in words and keeps the custom-app token fields (the D1 direct-install posture is unchanged); a catalog without connection_modes renders the pre-D5 card. Gateway refusals keep their status and arrive as the gateway's merchant_message (409 shop bound to another tenant, 503 off / unconfigured / registry unreadable, 422 shop malformed); a gateway that does not serve the route yet answers 404, named as such. Tests: miz-oki-command-center-ui/components/connectors/marketing-connectors.oauth.test.tsx, app/api/bff/connectors/shopify/install/start/route.test.ts, lib/bff/shopify-install-start.test.ts, lib/connectors/shopify-oauth.test.ts.

2. Supply cost truth — the COGS worksheet [Validated]

The interchange shape and its validation are canon (docs/onboarding/COGS_WORKSHEET.md); the parser is services/measurement-rails/cogs_import.py (P1 build plan item C1).

W6 addition (this pack): services/measurement-rails/cogs_worksheet.py productizes C1 for a merchant, not just an engineer:

Nothing here computes or guesses a cost; every number it reports is one cogs_import.py already validated or refused. Tests: services/measurement-rails/test_cogs_worksheet.py.

3. Reconciliation and identity coverage [Validated]

services/measurement-rails/reconciliation.py (P1 build plan item C2) is the L1 promotion instrument: 14 trailing clean days of Shopify-vs-tracked agreement gate every value feed (§4).

W6 addition (this pack): a Stage-1 identity-coverage readout on the same harness. reconcile(..., identity=[IdentityCoverage(date, total_orders, resolved_orders), ...]) is an optional, additive parameter — omit it and identity_coverage_pct / satisfies_stage1_identity stay None on the result and null in artifact()'s identity_coverage block, never a guessed number. When supplied, only rows inside the reconciliation window count, and the readout is:

"identity_coverage": {
  "coverage_pct": 84.3,
  "stage1_floor_pct": 80.0,
  "satisfies_stage1_identity": true
}

STAGE1_IDENTITY_COVERAGE_FLOOR = 0.80 is a v1 engineering default (design target — TRUTH.md label), the same status as the existing revenue-tolerance ceilings in this module; an owner/covenant decision may revise it later. This readout is informational on the reconciliation artifact only — it does not (yet) gate ncm_value_feed's L1 attestation gate, which remains governed solely by trailing_clean_days. Tests: services/measurement-rails/test_reconciliation.py::IdentityCoverageTests.

4. Value feeds — margin, never raw revenue

Every value feed below is triple-gated (flag off by default + dry_run default + injected-transport-only) and carries E[NCM] margin, never a raw order total, in its value field — ncm_value_feed.value_fragment is the only source of that number, and it refuses until the §3 L1 reconciliation attestation clears 14 clean days.

Rail Status Flag Notes
Meta Conversions API built, pre-benchmark [Illustrative] MEASUREMENT_RAIL_META_CAPI (off) services/measurement-rails/meta_capi.py + ncm_feed_wiring.py
Google Enhanced Conversions built, pre-benchmark [Illustrative] MEASUREMENT_RAIL_GOOGLE_EC (off) services/measurement-rails/google_enhanced_conversions.py + ncm_feed_wiring.py
Klaviyo email/SMS value feed (W5) built, pre-benchmark [Roadmap] KLAVIYO_FEED (off) services/measurement-rails/klaviyo_feed.py + ncm_feed_wiring.py; skeleton — no tenant credential wiring yet

The Klaviyo rail deliberately differs from the ad-platform rails in one way: Klaviyo identifies a profile by its own plaintext identifier (email/phone/external_id), not a hashed match key — it is the merchant's own first-party ESP already holding that customer's PII, not a third-party ad-matching surface, so hashing would break Klaviyo's own profile-attach contract without adding a privacy benefit. health() reports not_configured until KLAVIYO_PRIVATE_API_KEY is set — no merchant-facing surface may claim this feed is LIVE before GATE 2 and a real credential exist. This is distinct from the existing Klaviyo pull connector (klaviyo_connector.py, BUILD_DEBT KLV-1, aggregate campaign reporting only) — that connector reads FROM Klaviyo; this feed writes value events TO it.

Status detail: docs/connectors/SHOPIFY_CONNECTOR_STATUS.md, docs/product/FEATURE_COVERAGE_MATRIX_v1.md row 4.8.

5. Gaps this pack does not close

← All docsView source on GitHub →