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:
generate_worksheet_csv(variants)— a starter CSV pre-filled with the merchant's own variant/SKU ids (from their catalog, never invented) and every cost column blank. It deliberately carries nostore_defaultsrows: a blank store-default value is a hard reject incogs_import.py(unlike a variant's blank cost, which is legally "uncosted" — blank beats guessed either way, but the two sections have different blank semantics).STORE_DEFAULTS_GUIDANCEnames the two required default keys (platform_fee_pct,default_pick_pack) and their guidance text for the UI/runbook to show next to the download.humanize_rejects(coverage)— turns everyRowRejectreason codecogs_import.pycan emit into a sentence a merchant can act on (e.g. "the cost isn't a valid non-negative number — leave it BLANK if you don't know it yet, never a guess"). An unrecognized future reason code still produces a message rather than going silent.coverage_summary(coverage)— a readout: variants loaded, costed vs. uncosted counts and percentage, the uncosted variant ids, and the humanized issue list — everything a merchant needs to see without reading aCoverageReportdataclass.
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
- Operator remainder (Shopify app credentials, webhook secret, pixel
ingest secret, reconciliation runner's real BigQuery/Admin-API
credentials) is unchanged by this pack — see
CLAUDE.mdregister item 6 anddocs/reports/SHOPIFY_CLOSEOUT_VERIFY_2026-08-27.mdItem 4. - No tenant-scoped credential wiring exists for the Klaviyo value feed; it is a skeleton per §4.
- The identity-coverage meter is a readout only — it is not (yet) an
activation gate for any value feed. Wiring it into
ncm_value_feed's gate, if desired, is a separate, deliberate decision outside this pack's scope.