Shopify Multi-Merchant OAuth Install — Design Note (P1 §C4)
Status: Design note v1.0 (2026-08-12) · P1 item C4, docs/roadmap/P1_BUILD_PLAN.md §C4 —
"Design note first; this is the largest P1 piece and the only one that changes an auth
boundary." Everything in this
document is [design target] / [IN BUILD — not started] unless a line cites shipped code by
file:line, in which case the label on that line governs. The platform ceiling is built,
pre-benchmark (TRUTH.md); nothing here is a performance or security claim beyond what the
cited code enforces today.
Lane canon: docs/product/SIGNAL_OVERVIEW_v5.md → docs/product/SIGNAL_SHOPIFY_MASTER_v4.md
(build spec; §2.1 ingestion, §3.5 compliance, §3.6 open owner decisions).
Governing constraints: docs/roadmap/P1_BUILD_PLAN.md §A (measured inventory), §D (standing
constraints: L0/L1 only, consent fail-closed, deliberate gateway landings);
docs/roadmap/SIGNAL_SHOPIFY_PHASE_BINDING.md (P1 ceiling);
.claude/memory/active/marketing-commerce-connectors.md ("Non-negotiable architecture",
"Production gates"); docs/architecture/FLEET_INTEGRITY.md (tenant boundary); AGENTS.md 6.6
(every persisted/contract change is a migration, never a bare rename).
External facts (scope names, token semantics, compliance-webhook rules) were verified against
shopify.dev on 2026-08-12; each such fact is marked (doc-verified).
1. Measured current posture — the migration baseline
Measured against the tree on 2026-08-12 (rule 01-verification-discipline: the inventory is
measured, not inherited). This section is the real posture the migration starts from; it
matches P1_BUILD_PLAN.md §A with three refinements noted in §1.1.
| Surface | Measured state |
|---|---|
| Webhook ingress | POST /webhooks/shopify (services/service-marketing-connectors/main.py:450-537), public, HMAC-first. _verify_shopify_hmac (main.py:341-348) computes base64(HMAC-SHA256(raw_body, secret)) against X-Shopify-Hmac-Sha256 — exactly Shopify's webhook signature shape (doc-verified) — keyed by the single env SHOPIFY_WEBHOOK_SECRET (main.py:57). Unset secret ⇒ every delivery 401 (main.py:341-342, 460-461) — the §A "blocked on operator secrets" state; zero live traffic has crossed the hardened gate. |
| Webhook tenant | X-Mizoki-Tenant-Id header, else DEFAULT_TENANT_ID env (main.py:491-493). Shopify sends no such header, so in production the route is single-tenant-by-env. README.md:97 says "deploy one connector revision per tenant" for multi-tenant — this note supersedes that guidance (§5). |
Hardening (PR #654, merge 59782a39) |
Replay cache post-HMAC (main.py:469-471, webhook_hardening.py:104-136); GDPR topics ACK+audit, never ingest (main.py:474-485, GDPR_TOPICS at webhook_hardening.py:266); inventory topics → Cell 37, 503 cell37_unconfigured when unset (main.py:487-489, 427-431); consent gate FAIL-CLOSED over unified.consent_registry_latest — registry disabled or erroring ⇒ redact (main.py:498-512; webhook_hardening.py:140-202, closed default at :164-166, 181-183); person-topic scope is an explicit market-safe allowlist, unknown topics fail closed (webhook_hardening.py:277-294). deployed, not live-verified. |
| Bulk backfill | POST /api/v1/shopify/sync (main.py:400-419) → _pull_shopify (main.py:308-338) reads module-level env SHOPIFY_SHOP_DOMAIN / SHOPIFY_ADMIN_ACCESS_TOKEN (main.py:55-56); 503 when unset (main.py:231-235). One shop per deployment: the request's tenant_id selects where records land, not which store is pulled. |
| Caller auth | verify_caller on every non-webhook route (contracts/mizoki_contracts/auth.py:85-120): Google-signed OIDC, audience + allowlist, fail-closed 503 on misconfiguration in a deployed environment (auth.py:72-82) — [enforced today]. |
| Tenant enforcement | Gap. resolve_tenant + the TENANT-001 registry (caller-SA → tenants; Firestore tenant_registry, contracts/mizoki_contracts/auth.py:123-157, contracts/mizoki_contracts/tenancy.py) exist in the shared contracts but are never invoked anywhere in this service (grep-verified 2026-08-12). tenant_id flows from request body / header / env default unchecked. The connectors-memory production gate "tenant registry enforcement" is therefore not yet met on this surface. |
| Per-tenant credential store | Already exists, unwired to the data plane. connector_credentials.py (SecretManagerCredentialStore, :243-371) stores one JSON secret per (tenant, provider) as mizoki-connector-{tenant}-{provider} (:48, :289-293), labeled, versioned, masked-hints-only, never echoed; Shopify field spec = shop_domain + admin_access_token (:63-76). The sync/webhook paths do not read it. |
| Web Pixel path | services/intent-shopify-extender — superseded 2026-08-12 (single-ingress collapse). It no longer receives Shopify webhooks and no longer holds the webhook secret: the gateway's /webhooks/shopify is the one boundary, and the extender is a downstream consumer at /v1/downstream/shopify (Cloud Run IAM). Consent is now gated twice — the gateway's registry lookup redacts person fields before ingestion, then the extender's marketing-opt-in check runs on what survives. /readyz is fail-closed on CELL33_URL, not on the webhook secret. |
| OAuth install flow | Does not exist anywhere in the tree (grep-verified: the only OAuth code under services/ekis/ is Google Ads OAuth; no /admin/oauth reference exists in services/ or src/). §C4's "genuinely new surface" claim is measured-true for the flow itself. |
| Deploy path | Any push to main touching services/service-marketing-connectors/** (README included) fires deploy-service-marketing-connectors.yml → production build-deploy-verify (workflow lines 4-8). Every build step below that touches this path is flagged [GATEWAY DEPLOY — deliberate] per P1_BUILD_PLAN.md §D. |
1.1 Refinements to the §A / §C4 inventory (measured, not contradictions of §A's claims)
- §C4 lists "per-shop token custody in Secret Manager" as part of the genuinely new
surface. Measured: the custody mechanism already exists (
connector_credentials.py:243-371, namingmizoki-connector-{tenant}-{provider}). What is genuinely new is (a) the OAuth flow that populates it, (b) the data-plane routes reading it, (c) shop→tenant resolution. The build is smaller than the §C4 sentence implies; this design extends the existing store rather than inventing a second one (memory rule: no parallel stores). - §A row 2 says "Single-tenant credential posture (Secret Manager)". Precisely: the sync path
reads environment variables (
main.py:55-56) thatREADME.md:50-52directs operators to mount from Secret Manager. Secret Manager custody is deployment posture, not code behavior; the tenant-scoped store in the same service is not consulted. §A's summary is fair; the distinction matters for the migration plan (§5.3). - Tenant registry enforcement is absent from the gateway routes (table above). §A does not claim it; the production-gates list requires it; this design is where it lands.
2. Install and authorization flow [design target — IN BUILD, not started]
One MIZOKI Signal app (a single client_id/client_secret pair, platform-owned) installs
into many merchant shops. Per-shop access tokens are merchant-authorized — "sanctioned
ad-platform APIs with merchant tokens only" (SIGNAL_SHOPIFY_MASTER_v4.md §3.5) applies to the
commerce side identically: MIZOKI holds tokens granted by the merchant, never merchant
passwords, never platform-owned store credentials.
2.1 Grant type
- Base flow: OAuth 2.0 authorization-code grant (doc-verified: supported for both standalone and admin-embedded apps). Decision 1 is now DECIDED (2026-08-12, owner-delegated — §9): direct/unlisted distribution for P1–P3, App Store listing at P4 — this grant is therefore the production install path for P1–P3, not a both-ways hedge.
- Embedded variant, adopt at P4: Shopify's recommended path for apps rendered in the admin is Shopify managed installation + token exchange (doc-verified). Decision 1 schedules the App Store/embedded shift at P4: the install step then becomes Shopify-managed (scopes declared in the app config TOML) and token acquisition becomes session-token exchange; the callback route below then handles only the non-managed remainder. Designed as a swap-in, not a second flow.
- Not chosen: client-credentials grant (own-org apps only (doc-verified); Signal is multi-merchant).
2.2 Flow, step by step
GET /shopify/install?shop={shop}.myshopify.com— entry. Theshopparameter is validated against a strict^[a-z0-9][a-z0-9-]*\.myshopify\.com$shape before any redirect (an open redirect here is an OAuth phishing primitive). Unknown-but-valid shops are allowed — that is what installing means; the tenant row is created only at callback completion (§5.1). The console's Connect Shopify button (lane plan 2026-09-30, D5; #1285, merged 2026-10-01) is a second entry to the same step 2: the console BFF routePOST /api/bff/connectors/shopify/install/start(admin role, tenant from the verified session only) calls the gateway's authenticatedPOST /api/v1/shopify/install/start, receives the authorize URL, and opens it only after re-checking it is the typed store's ownhttps://{shop}/admin/oauth/authorize— the shape pin on the gateway and the host pin on the console are two independent checks on purpose.- 302 to
https://{shop}/admin/oauth/authorizewithclient_id, the §3 scope string,redirect_uri(allowlisted in the app config), andstate. statenonce (CSRF): cryptographically random (≥128 bits), single-use, TTL ≤ 15 min, stored server-side keyed by nonce with the requesting shop recorded — and, for an install started from the authenticated onboarding session (POST /api/v1/shopify/install/start), the tenant it is bound to (shipped 2026-10-01, lane plan D5). Callback must present a known, unexpired, unburnt nonce whose recorded shop equals the callbackshop; compare constant-time; burn on first presentation (replay of a used state ⇒ 403).- Callback verification, in order (
GET /shopify/callback): a.hmacquery parameter: hexHMAC-SHA256over the sorted query string minushmac, keyed by the app client secret (doc-verified). Invalid ⇒ 401. This is a different encoding and message than the webhook HMAC (hex-over-query vs base64-over-raw-body); the design keeps two named verifiers and never conflates them. b.shopre-validated against the strict shape and the nonce's recorded shop. c.statematched and burnt (step 3). d. Only then: exchangecodeatPOST https://{shop}/admin/oauth/access_tokenwithclient_id+client_secret. - Persist, then activate: token payload written to Secret Manager (§4) first, tenant
registry row created/activated (§5) second, mandatory + operational webhook subscriptions
registered via Admin API third (their counts stamped on the registry row — built 2026-10-01,
lane plan item 9b PR-3b), Web Pixel activated (§3,
write_pixels) last. A row without a stored token must never exist (fail-closed ordering; a crash between steps leaves a re-runnable install, not a half-tenant).
2.3 Token mode: offline (expiring), and why
- This app uses offline access tokens. Webhook processing and scheduled backfill are service-to-service background work with no user in the loop — Shopify's stated use case for offline mode (doc-verified).
- Expiring offline tokens are mandatory for us: as of December 2025 Shopify issues expiring
offline tokens with 90-day refresh tokens, and public apps created on or after
2026-04-01 must use them (doc-verified). A new Signal app created now is in that class.
Consequences designed-in: the stored unit is
{access_token, refresh_token, expires_at}(§4), and refresh is serialized per shop — Shopify keeps one refreshable offline token per app×store and invalidates the older refresh token immediately on rotation (doc-verified), so two concurrent refreshes lose a refresh token and strand the shop until re-auth. - Online tokens are not used in P1. They expire with the user session (≤24 h), carry per-user permissions, and exist for interactive admin UIs (doc-verified). P1's merchant surface is the L0 report; if an embedded per-user UI ships later, online/session-token auth is added for that UI only — never as a data-plane credential.
- Embedded-app session tokens (App Bridge JWTs, verified against the app secret with
aud=client_id,exp,dest=shop) authenticate UI requests in the embedded variant. They authenticate a browser to our backend; they are not store-data credentials and are never stored. - Re-authorization triggers (doc-verified list): no token for the shop, token minted before a client-secret rotation, or a scope-set change. The install route detects all three and restarts the grant.
3. Scopes — minimal set, each justified by a named stream
Requested at install (the full set; a scope with no named stream below is a defect):
| Scope | Named stream that requires it | Verification |
|---|---|---|
read_orders |
orders/create, orders/updated, orders/paid, orders/cancelled and refunds/create webhooks (doc-verified: refunds/create requires read_orders) + orders bulk backfill (main.py:400-419). Streams: NCM revenue side (Rᵢ, discounts, refunds → E[RLᵢ] true-up, master §2.5), Shopify-vs-tracked reconciliation (P1 exit; reconciliation.py). |
(doc-verified) |
read_fulfillments |
fulfillments/create, fulfillments/update webhooks + backfill. Stream: fulfillment status/timing for reverse-logistics true-up (E[RLᵢ]) and the §C5 fulfillments-topic envelope review. |
(doc-verified) |
read_inventory |
inventory_levels/update, inventory_levels/connect webhooks (INVENTORY_TOPICS, webhook_hardening.py:267). Stream: Cell 37 MarketSignal ingest (market-level, person-free; main.py:422-447); working-capital pacing is [Roadmap] and adds no scope. |
(doc-verified) |
read_products |
Products/variants bulk backfill (main.py:253-263). Stream: SKU→COGS join keys for bundle-decomposed cost (docs/onboarding/COGS_WORKSHEET.md, cogs_import.py), catalog concepts in the canonical envelope. |
(doc-verified: inventory_items/* topics also accept read_products) |
read_customers |
Customers bulk backfill (main.py:264-275). Stream: identity spine for the Stage-1 ≥80% identity/attribution-coverage precondition (SIGNAL_SHOPIFY_PHASE_BINDING.md), joined on customer.id only — the shipped query selects no email/phone. |
shipped query, main.py:264-275 |
write_pixels + read_customer_events |
Web Pixel Extension activation and customer-event subscription (doc-verified: both scopes required to invoke web-pixel mutations). Stream: micro-signals → Cell 33 through the consent gate (services/intent-shopify-extender). |
(doc-verified) |
Protected-customer-data posture: Level 1, not Level 2. Shopify tiers protected customer data:
Level 1 = customer data excluding name, address, phone, email fields; Level 2 = including them,
with field-level grants and data-protection reviews (doc-verified). P1 requests Level 1
protected-customer-data access only. Shopify then redacts/withholds the Level-2 fields at
source, which composes with — and does not replace — our own fail-closed consent gate
(webhook_hardening.py:140-202): Shopify's level approval is their control; the consent gate is
ours; both stay.
- Known consequence, handled: the shipped customers backfill query selects
defaultAddress { countryCodeV2 provinceCode city zip }(main.py:271) — address fields. Under a Level-1 grant these return redacted/absent. Migration step B3 (§8) makes the mapper treat absent address subfields as normal, and dropscity/zipfrom the query outright.
Explicitly rejected as broader than the named streams:
| Rejected | Reason |
|---|---|
read_all_orders |
The default 60-day order window (doc-verified; the scope needs separate Partner-Dashboard permission) covers the 14-clean-day reconciliation attestation with 4× margin. Historical seasonality baselines are P2+ and must bring their own justification then. |
write_orders, write_products, every write_* except write_pixels |
P1 is L0/L1: no mutation authority on the store, no spend authority anywhere (P1_BUILD_PLAN.md §D; phase binding). write_pixels is the sole write, required to install our own pixel (doc-verified). |
| Level-2 protected fields (name/address/email/phone) | P1 identity keys are shopify_customer:{id} (webhook_hardening.py:257-263). HMAC-tokenized email/phone internal keys and CAPI EMQ hashed params (master §2.4) become a separately-reviewed Level-2 request tied to the value-feed stream when it turns on (P2+, after the L1 attestation) — not before. |
read_checkouts, read_draft_orders |
Session/checkout micro-signals come from the Web Pixel, not the checkouts API. |
read_discounts, read_price_rules |
Pᵢ promo pro-ration reads order-level discount_applications already present in the orders payload. |
read_locations |
inventory_levels payloads carry location_id; Cell 37 keys on ids, not location names. |
Scope-set changes re-trigger merchant authorization (§2.3), so additions are visible, auditable events per shop — never silent grants.
4. Per-shop token custody [design target — IN BUILD, not started]
Extend the existing store; do not build a second one. Secret id stays
mizoki-connector-{tenant}-shopify (connector_credentials.py:48, 289-293), labeled as today
(:307-311). The payload becomes schema-versioned:
{"schema": 2, "shop_domain": "…", "access_token": "…", "refresh_token": "…",
"token_expires_at": "…", "scopes_granted": ["read_orders", "…"],
"api_version": "2026-07", "install_id": "…", "installed_at": "…"}
- Migration, not rename (AGENTS.md 6.6): schema 1 (
shop_domain+admin_access_token, the Connectors-page custom-app path,connector_credentials.py:63-76) stays readable forever; the loader dispatches onschema. The manual Connectors-page path remains for direct/custom-app merchants — custom apps are exempt from the expiring-token mandate (doc-verified) — which now serves decision 1's ruling (direct for P1–P3; the custom-app path stays alive for enterprise/T3 merchants after the P4 App Store shift). - Least privilege: the runtime SA gets
secretmanager.versions.access+versions.add+secrets.createscoped to themizoki-connector-*secrets, not project-wide admin. The current 403 hint text suggestsroles/secretmanager.adminas one option (connector_credentials.py:362-367); this design narrows the documented ask, and the operator grant is [operator] work (AGENTS.md 7.6). - Rotation: expiring offline tokens self-rotate via refresh (≤90-day refresh-token lifetime).
Refresh is single-flight per shop (per-shop lease in the registry row, or Secret Manager
etag-guarded
addVersion) because concurrent refresh invalidates the sibling refresh token (doc-verified) and strands the shop until merchant re-auth. A failed refresh marks the rowtoken_staleand alerts; the shop's ingest degrades loudly (503 on pulls, webhook HMAC still verifies — webhook signing uses the app secret, not the shop token). App client-secret rotation is an [operator] runbook item with a dual-verify window on the webhook boundary (old + new secret accepted for the overlap, then old removed). - Revocation on
app/uninstalled(webhook topic subscribed at install): 1. registry row →status=uninstalled,uninstalled_atstamped (deactivation, not deletion — §5.1); 2. scheduled pulls for the shop disabled; 3. Secret Manager: prior versions destroyed (the token is already dead at Shopify on uninstall; destroying versions removes the residue); 4. stale-delivery dedup: in-flight deliveries for an uninstalled shop are ACKed (200) and dropped, audited + counted (connector_uninstalled_drops_total), never ingested and never 5xx'd — a 5xx would make Shopify retry a corpse. This is the one deliberate exception to "misconfiguration ⇒ 503" (§7) and carries its reason inline; 5.shop/redactarrives ~48 h later (doc-verified) → tenant-scoped erasure (§6); 6. re-install mints a newinstall_id; webhook dedup keys namespace by(install_id, webhook_id)so pre-uninstall ids cannot collide into the new install.
No credential ever appears in code, config files, images, or logs — Secret Manager only
(README.md:56; memory "Production gates": Secret Manager only, least privilege).
5. Tenant registry [design target — IN BUILD, not started]
5.1 Two registries, two keys — deliberately
TENANT-001 (contracts/mizoki_contracts/tenancy.py) maps caller service-account → tenants
and governs authenticated internal callers. A public webhook has no OIDC caller, so it can never
be tenant-resolved by TENANT-001. The new surface maps shop_domain → tenant and is a
different relation, not a parallel copy of the same one:
- Firestore collection
shopify_shop_registry(name distinct fromtenant_registryon purpose), one row per shop:
{"shop_domain": "…myshopify.com", "tenant_id": "…", "status": "installed|uninstalled|suspended",
"install_id": "…", "scopes_granted": [ "…" ], "secret_ref": "mizoki-connector-{tenant}-shopify",
"api_version": "2026-07", "country": "…", "token_state": "…",
"installed_at": "…", "uninstalled_at": null, "redacted_at": null,
"pixel_activation": {"status": "…", "reason": "…", "pixel_id": "…", "at": "…"},
"first_delivery_at": null, "first_delivery_topic": null, "first_delivery_transport": null,
"first_delivery_webhook_id": null}
The first_delivery_* fields are stamped ONCE per shop by the webhook doors after canonical
ingestion accepts the first non-GDPR, non-inventory delivery (shopify_connection.py, lane plan
2026-09-30 item 9b PR-3); the health read projects them, with the row itself, into the shopify
row's connection block (connector_health.connection_readiness) — a delivery verdict beside the
sync verdict state. The webhook-registration count on the row (webhooks_registered /
webhooks_requested) is stamped by the OAuth callback at step 5.3 (built 2026-10-01, lane plan item
9b PR-3b): the full count after a successful Admin-API registration, the partial count before a
webhook_registration_failed 502; the health read reports it as recorded: true with both counts.
The stamp is a field-only transactional update bound to the install that produced it (#1304 review
round 1, 2026-10-02): a first-delivery stamp or an uninstall that lands while registration runs is
never replaced, and a callback whose install a newer one has superseded stamps nothing (superseded,
counted). A re-install rebuilds the row, so a stale count never survives it; a row installed by the
Connectors-page custom-app save (which registers no webhooks) reads recorded: false, honestly.
The console's Connectors card renders this block beside the sync-health row (lane plan item 13,
2026-10-02; miz-oki-command-center-ui/components/connectors/connector-health.tsx): the readiness
verdict, the store record, the webhook counts and the first delivery, each named absence as its own
line in the service's words — nothing inferred from credential presence, tenant_binding never
shown, and onboarding_complete untouched. A health row without the block renders as before.
- Created at OAuth-callback completion, after the token is stored (§2.2 step 5).
tenant_idassignment — SHIPPED 2026-10-01 (lane plan 2026-09-30, defect D5) as one fail-closed ladder,shopify_install.resolve_install_tenant, shared by the callback and the Connectors-page save: (1) an existing row for the shop, any status, names the tenant — a nonce or a save bound to a different tenant is refused (shop_bound_to_other_tenant; re-homing is an operator action with its own audit row, and no route performs it yet); (2) no row and a nonce bound by the authenticated start route → that onboarding tenant; (3) no row and the public entry's unbound nonce → the deterministic mintshopify-{prefix}(the direct-install posture). The binding (onboarding/registry/custom_app/default_mint) is recorded on the row astenant_binding. The invariant that a row never exists without a tenant and a stored token is unchanged. - Deactivated (never deleted) on
app/uninstalled;redacted_atstamped when theshop/redacterasure completes. Deactivated rows are the dedup/audit spine for stale deliveries and re-installs.
5.2 Webhook boundary becomes tenant-resolving
Target order in POST /webhooks/shopify (replacing main.py:491-493):
- HMAC verify (unchanged position — first).
- Replay dedup (unchanged).
- GDPR topics (unchanged ACK-never-ingest; §6 adds the cascade behind the ACK).
- Tenant resolution:
X-Shopify-Shop-Domain→shopify_shop_registry: -status=installed→ resolved tenant; proceed to the existing consent gate + ingest. -status=uninstalled→ ACK-and-drop (§4). - no row → 503shop_unregistered, retryable and loud: an HMAC-valid delivery for an unknown shop means registry lag or corruption, and Shopify's retry window buys repair time. -DEFAULT_TENANT_IDremains the migration shim: consulted only when the registry collection is empty (pre-multi-merchant deployments keep working). Its retirement is operator-timed after ≥1 registry row serves live traffic (AGENTS.md 6.6: dual-accept, timed removal, zero-importer check). - Consent gate and canonical ingest exactly as shipped (
main.py:498-537).
Honest limit, stated as posture not claim: Shopify's webhook HMAC covers the raw body only;
X-Shopify-Shop-Domain is outside the signature. Forging it requires possession of a
validly-signed body (only Shopify and we hold the signing secret; TLS covers transit), leaving
replay-with-forged-header as the residual. Mitigations: dedup keys include the shop domain
((install_id, webhook_id)), provenance records the header verbatim for audit
(main.py:525-531 already does), and compliance topics never trust the header — their
payloads carry shop_domain inside the signed body (doc-verified) and §6 resolves from the
body. This paragraph describes designed mitigations, not properties the current code enforces.
5.3 Sync route becomes tenant-resolving
POST /api/v1/shopify/sync (main.py:400-419) migrates in two moves:
- Enforce TENANT-001 where it already belongs: call
resolve_tenant(caller, req.tenant_id)(contracts/mizoki_contracts/auth.py:123-157) so a mapped caller cannot act for a tenant it does not hold (403), closing the §1 gap. Unmapped callers keep the audited-passthrough migration semantics untilMIZOKI_TENANT_STRICT=1— the registry's own designed flip. - Per-tenant credentials:
_pull_shopifytakes credentials resolvedtenant → SecretManagerCredentialStore.load(tenant, "shopify")(schema 1 or 2, §4) instead of module-level env (main.py:55-56). The env pair remains the last-resort fallback during migration and is retired operator-timed (6.6 again). Expired schema-2 tokens trigger the single-flight refresh (§4) before the pull.
Tenant isolation downstream is unchanged by this note: canonical ingestion stamps and enforces
tenancy on every record (main.py:176-193 forwards tenant_id; FLEET_INTEGRITY.md (b) and
P1_BUILD_PLAN.md §D govern cross-tenant separation; merchant cost data and NCM outputs never
cross the tenant boundary).
6. Mandatory compliance webhooks [design target on the cascade; ACK behavior is enforced today]
customers/data_request, customers/redact, shop/redact are subscribed as mandatory
compliance webhooks, delivered to the same /webhooks/shopify endpoint, HMAC-verified like every
other delivery.
- The boundary behavior does not change. Shipped today [enforced —
main.py:474-485;GDPR_TOPICSatwebhook_hardening.py:266]: ACK (200-series, Shopify's requirement (doc-verified)) + audit record (shop_domain + webhook_id only — no payload PII in the audit body,main.py:477-484; keep it that way), never ingest. Compliance payloads carry raw email/phone (doc-verified sample payloads); they are processed for the compliance action only and never enter the canonical stream. - New: a durable compliance job behind the ACK (queue with retry + operator visibility; Shopify allows 30 days to complete (doc-verified), tracked per job):
customers/redact→ the erasure cascade. Resolve tenant from the signed body'sshop_domain(§5.2 honest-limit note); a shop the registry does not know (and noDEFAULT_TENANT_ID) still gets its job, OPEN astenant_unresolvedand re-resolved from the registry on every run — never acknowledged into nothing (lane plan 2026-09-30 item 9c, D7). Subject key =shopify_customer:{id}— the same derivation the consent gate uses (webhook_hardening.py:257-263). Then, in order:- revoke the subject's row in
unified.consent_registryfirst — the fail-closed gate (webhook_hardening.py:140-202) then redacts any delivery for that subject that arrives mid-cascade; the gate stays fail-closed throughout, unchanged; - invoke the existing cascade: Cell 33
DELETE /v1/identity/{identity_id}(src/cells/cell33/ingest_cell/main.py:278) — cascading, idempotent, and fail-closed: an unconfirmable leg returns 503erasure_incomplete(main.py:304) [enforced today] — once per identity FORM the subject was stored under (shopify_identity.cascade_identities, 9c/D7): the extender'sshp_{id}first (what Cell 33's rows carry; the old singleshopify_customer:{id}call matched nothing and its zero-count 200 read as confirmed), thenshopify_customer:{id}; every call must answer 200, and the per-identity cell receipts with the summed row count ride the job, so a zero-row erasure is visible. Anonymousshpanon_{hash}identities derive from checkout tokens a redact payload never carries and are NOT reachable from this cascade (a named limitation, not a claim); cells 34/35/36 hold the peer subject-rights surfaces (src/cells/cell3?/tests/ test_subject_rights.py,test_dsar_readiness.py); - connector-landed canonical records for the subject: erase/redact via the canonical store's
subject path (
POST /api/v1/subjects/erase;_canonical_store_erasure, wired 2026-08-20 behindCANONICAL_ERASER_URL— unset in production, so the leg failscanonical_eraser_unconfiguredand the job stays open); identifiers go in BOTH the numeric and thegid://form (shopify_identity.canonical_identifiers, 9c/D7) because backfilled records carry only gids; an incomplete leg keeps the job open and alerting, never silently done.
- revoke the subject's row in
customers/data_request→ subject access. Cell 33GET /v1/identity/{identity_id}/export(main.py:255) plus connector/canonical holdings, compiled for delivery to the store owner (Shopify's contract is app→merchant, not app→customer (doc-verified)). Delivery is out-of-band ⇒ ships with an operator runbook [IN BUILD]. Built 2026-10-01 (#1298; connectors00080-m5n): the gateway compiles Cell 33's export for both identity forms plus its own holdings into a stored export (shopify_compliance_exports), readable only at the caller-verified, tenant-boundGET /api/v1/shopify/compliance/exports/{job_id}; the canonical store has no subject-access route, so the job's leg staysexport_incompletewith that gap named until one exists. Delivery to the store owner stays out-of-band.shop/redact→ tenant-scoped erasure. Arrives ~48 h after uninstall (doc-verified): erase person-level data for that shop's tenant across the stores, stampredacted_aton the registry row, confirm secret versions destroyed (§4). Built 2026-10-01 (#1298): the gateway tombstones the tenant's compiled exports, then thetenant_scoped_erasureleg FAILS with the two missing downstream routes NAMED — Cell 33 and the canonical service erase per identity / per subject only, neither has a tenant-wide route — so the job stays open andredacted_atis never stamped while the tenant's data remains downstream (a named limitation, not a claim). Non-personal governance records (audit rows, the immutable action ledger, tokenized aggregates) are retained as the proof surface — consistent withFLEET_INTEGRITY.md(b) and constitution II.6 as applied inFLEET_INTEGRITY.md(c): redaction webhooks and the erasure cascade apply identically in every region.
7. Failure posture — every misconfiguration fails closed
| Condition | Behavior | Status |
|---|---|---|
Auth misconfig in a deployed env (SELF_URL/ALLOWED_CALLER_SA unset, MIZOKI_AUTH_DISABLED present) |
503 on every protected route | [enforced — auth.py:72-97] |
| Webhook signing secret unset | 401 every delivery | [enforced — main.py:341-348, 460-461] |
OAuth client_id/client_secret unset |
/shopify/install + /shopify/callback ⇒ 503 shopify_oauth_unavailable — one posture-free reason on the public routes (D12); the unset or placeholder-shaped value is named on /readyz's refusal and on the authenticated /api/v1/shopify/install/start — never a dev fallback |
[enforced — main.py _require_oauth_config; tests/connectors/test_public_surface_disclosure.py] |
Callback hmac invalid / state unknown, expired, or replayed / shop malformed |
401 / 403 / 422; nonce burnt on first use | [design target] |
| Registry has no row for an HMAC-valid shop | 503 shop_unregistered (retryable, loud) |
[design target] |
| Delivery for an uninstalled shop | 200 ACK-drop + audit + metric — the one deliberate non-5xx, reason in §4 | [design target] |
| Consent registry disabled, unreachable, or erroring | deny ⇒ redact person fields | [enforced — webhook_hardening.py:153-183] |
CELL37_URL unset for inventory topics |
503 cell37_unconfigured |
[enforced — main.py:427-431] |
CANONICAL_INGESTION_URL unset |
503; /readyz fail-closed |
[enforced — main.py:162-165, 373-385] |
| Token refresh fails / refresh token lost | row token_stale, pulls 503, alert; webhook verify unaffected (app-secret-keyed) |
[design target] |
| Erasure cascade leg unconfirmable | 503 erasure_incomplete; job stays open |
[enforced at Cell 33 — cell33/ingest_cell/main.py:304; job wrapper design target] |
No credential in code or config files — Secret Manager only (§4). Dev constants never
deploy: the only auth bypass is inert wherever K_SERVICE is set (auth.py:33-35, 76-78)
[enforced today]; the new install flow adds a readiness probe that reports
shopify_oauth_configured and fails /readyz on unset or placeholder-shaped values — the
measured 6c failure mode (a placeholder Klaviyo key silently no-oping,
.claude/memory/active/current-priorities.md item 6) must be structurally impossible here, and
the new routes are additionally gated by SHOPIFY_OAUTH_ENABLED whose OFF default is a
test-asserted source literal (the FEEDS_DEFAULT_ENABLED precedent, P1_BUILD_PLAN.md §B).
8. Build plan — ordered, all [IN BUILD — not started]
Steps touching services/service-marketing-connectors/** are [GATEWAY DEPLOY — deliberate]
(§1 deploy-path row). Every step ships tests in both directions per rule 01: the violation the
gate must catch and the legal case it must pass.
| # | Step | Deploy path | Tests it ships with (both directions) |
|---|---|---|---|
| B0 | shopify_shop_registry collection + schema-2 custody in a new gateway-local module (shopify_install.py); no contracts change |
none until mounted | row lifecycle: activate-requires-stored-token refused when token missing / valid install activates; schema-1 payloads still load; schema-2 round-trips |
| B1 | OAuth routes /shopify/install + /shopify/callback, flag-gated SHOPIFY_OAUTH_ENABLED (default OFF, source-literal test) |
[GATEWAY DEPLOY] | happy path mints+stores+activates in the §2.2 order; forged callback hmac 401; replayed/expired state 403; malformed shop 422; unset client creds 503; flag OFF ⇒ routes 503; flag default asserted at the source literal |
| B2 | Webhook tenant resolution (§5.2) with DEFAULT_TENANT_ID migration shim |
[GATEWAY DEPLOY] | installed shop resolves; unknown signed shop 503; uninstalled shop ACK-drop with metric; empty-registry fallback keeps today's single-tenant behavior byte-identical (regression suite over PR #654 paths rerun); GDPR/consent/replay ordering unchanged |
| B3 | Sync per-tenant credentials + resolve_tenant enforcement (§5.3); customers query drops city/zip, tolerates redacted address fields |
[GATEWAY DEPLOY] | mapped caller × wrong tenant 403; unmapped caller audited-passthrough until strict flip; env fallback still green; expired token triggers exactly one refresh under concurrency (fake clock, single-flight); Level-1-redacted fields don't fail the mapper |
| B4 | app/uninstalled + compliance jobs (§4, §6): deactivation, version destroy, stale-drop; customers/redact → consent revoke → Cell 33 cascade; shop/redact tenant-scoped job; customers/data_request export job |
[GATEWAY DEPLOY] | uninstall deactivates + destroys + drops stale (and 200s them); redact enqueues with consent revoked before cascade; cascade 503 keeps the job open; ACK-never-ingest behavior provably unchanged (existing GDPR tests rerun green); re-install new install_id doesn't inherit old dedup keys |
| B5 | Web Pixel activation at install completion (write_pixels), wiring the extender's per-shop config |
extender lane (separate deploy) | pixel activates only for status=installed rows; consent settings honored; no activation without §2.2 step-5 completion |
| B6 | Doc corrections riding the same deliberate landings (rule 01: fix the doc in the run that fixed the tree): README.md:97 multi-tenant guidance, README.md:50-52 env table gains the OAuth/registry entries. README is inside the deploy-trigger path — it ships with B2, not as a docs-only ride-along |
with B2 | claims-lint clean; README states the registry path and labels the env pair as migration shim |
| OP | [operator — AGENTS.md 7.6, not agent-executable] Create the Shopify app with direct/unlisted distribution (decision 1 ruling — no App Store listing before P4); client_id/client_secret into Secret Manager; redirect-URI allowlist; Level-1 protected-data request in the Partner Dashboard (required for protected data regardless of listing status); register-item-6 secrets (SHOPIFY_WEBHOOK_SECRET semantics become "app client secret" at B2 — coordinate the value swap with the landing); SA IAM narrowing (§4) |
— | — |
Sequencing: B0→B1→B2→B3→B4 strictly ordered; B5 after B2; B6 with B2; OP items gate live verification, not the build. Nothing turns on by default: the flag is OFF, the registry is empty, and today's single-tenant behavior is the tested regression baseline at every step.
9. What this design does NOT decide
- Owner decision 1 (App Store vs direct, per tier) — DECIDED 2026-08-12 (owner-delegated in-session, "Make decisions on 1 and 2"): direct distribution for P1–P3 — one platform-owned unlisted public Signal app, installed by direct install link through the §2.2 authorization-code grant (the landed implementation); the §3 Level-1 protected-data request is filed regardless of listing status. App Store listing lands at P4, exactly where the owner-ratified phase binding already schedules it (B4→P4 "App Store" row): at that point the §2.1 embedded/token-exchange swap-in activates, plus the review implications — Shopify app review incl. Built for Shopify criteria (master §1.4), protected-customer-data review of the Level-1 request (and any later Level-2 ask), mandatory-webhook verification at review, listing-level privacy disclosures. The expiring-token mandate (§2.3) is already built to the strict case, so the P4 shift needs no token-custody rework. Per tier after P4: App Store is the self-serve (T1/T2) acquisition surface; direct install and the §4 custom-app custody path stay supported for enterprise/T3.
- Owner decision 2 (merchant-owned vs managed ad accounts) — DECIDED 2026-08-12
(owner-delegated, same instruction): merchant-owned ad accounts with merchant-granted
credentials — managed accounts are NOT adopted. This selects the shipped posture: master
§3.5 "merchant tokens only", credentials granted on the Connectors page into per-tenant
Secret Manager custody (
mizoki-connector-{tenant}-{provider}), merchant-revocable at will. It preserves the L0/L1 no-spend-authority gate (spend authority arrives only via DEL promotion, and then on the merchant's own account) and FLEET_INTEGRITY tenant isolation (no MIZOKI-operated accounts commingling merchant spend). Any future managed-account offering is a NEW owner decision with its own design, gated behind L3+ autonomy — not pre-authorized by this ruling. Enforced in code since D11 (lane plan 2026-09-30; 2026-10-02): the direct-pull adapters' credential view (direct_connectors.ProviderCredentials) answers a credential or account identifier from the tenant's vault entry only — never from the process environment — andPOST /api/v1/direct-connectors/{provider}/syncrefuses a tenant without a grant (merchant_credentials_not_configured) before any provider call; the action-runner's adapters already resolved per tenant throughSecretManagerCredentialResolverwith no env fallback. Pinned in both directions bytests/connectors/test_direct_connectors_governed.py. - Owner decisions 14 (EU residency) / 5 (pooled-prior consent) — untouched; the registry row
records the shop's country so that
FLEET_INTEGRITY.md(c) can gate EU onboarding when decision 14 lands. No EU merchant is onboarded before that determination. - Tenant provisioning UX (who assigns
tenant_idat install) — named [IN BUILD] in §5.1; the invariant (no row without tenant+token) is decided, the flow that chooses the tenant is onboarding-lane work.
10. Claim discipline
Everything above is design target / [IN BUILD — not started] except lines labeled
[enforced — file:line], which describe code shipped on main today. Current proof levels stay
exactly as recorded: the hardened webhook boundary is deployed with zero live traffic
(operator secrets, register item 6); nothing Shopify-side is live-verified; no reconciliation
attestation exists for any merchant. The platform ceiling remains built, pre-benchmark — this
note adds no performance, security, or compliance claims, only mechanisms with named tests and
the measured baseline they migrate from.