ADR-NY-001 — Net Yield backend scaffold

Status: accepted (scaffold) · Date: 2026-08-07 · claim_label: built, pre-benchmark Deploy state: deployed via human dispatch (amended 2026-08-19: first green dispatch run 31221912948 on 2026-08-07, rev net-yield-00005-cgc; registry row deployed-dispatch). Deploys ONLY via human dispatch: there is no push-triggered workflow for services/net-yield/**, so bot merges deploy nothing; the paths to a serving revision are the dispatch-only .github/workflows/deploy-net-yield.yml (added 2026-08-07 on owner instruction; DDL + build/deploy + fail-closed smoke) and the operator RUNBOOK (docs/net-yield/RUNBOOK.md), which stays authoritative for IAM grants, extender feed, scheduler, and writeback enablement.

Context

The Signal v2.1 program (master checklist / drop/claude-code-prompt-signal-net-yield-build-v2.md, Phase 4) requires the "Net contribution, not top line" roadmap card to be backed by real code — order economics from real cost inputs, nightly net contribution per order and cohort, a serving API, and value-writeback stubs — while the customer-facing label stays "Preview · in development" until a pilot writes verified numbers (positioning doc claim ledger).

Decisions

  1. New bounded service services/net-yield/, not an in-place extension of a deployed service. The Deploy Router redeploys any service whose deploy-*.yml push-paths match a merged diff. Touching service-marketing-connectors/** or src/shared/virtuoso_models/** (Boss + coding-MOA images) would redeploy live services on merge — violating this build's "nothing live" rule. A new directory matches no workflow; the spec's "new service or extension of the Financial cell" option made this the honest choice. (The positioning doc's "extension of Cell 35 + Financial cell" reads as capability lineage; runtime-wise the fleet has no standalone financial cell to extend without a live redeploy.)
  2. The live Shopify intake is intent-shopify-extender; it gains a default-off forward. That service is the platform's real Shopify webhook receiver (HMAC-authed, fail-closed) and has no push-path deploy workflow (intent-family deploys are dispatch-only), so an additive change is deploy-inert. With NET_YIELD_INGEST_URL unset (the default), behavior is byte-for-byte unchanged; when the operator sets it, order/refund payloads are ALSO copied to net-yield over OIDC, fire-and-forget — a forward failure can never fail the webhook or perturb the Cell 33 path. service-marketing-connectors remains the strategic gateway; when its Shopify order sync goes live, it should feed the same /v1/ingest/shopify contract and the extender forward retires (migration note below).
  3. One envelope, no schema fork. Order economics ride in CanonicalEventEnvelope.payload["order_economics"] (the constitution's shape registry, imported and validated at ingest) — no second envelope, no change to the shared JourneyEvent schema (whose src/shared/** home would redeploy Boss/MOA on merge). Follow-up, to batch with the next shared- package change: add shopify to the JourneyEvent event_source enum and a typed OrderEconomics block to mizoki_contracts (a migration per GOVERNANCE Art. 5.4 — additive optional field, dual-readable).
  4. Dataset reality: mizoki_unified_data, not unified. The spec named unified.order_economics; the platform's revenue dataset is mizoki_unified_data (see unified_revenue.sql, unified-revenue-ingestion). Reality wins: mizoki_unified_data.order_economics, .net_contribution, .net_contribution_cohort, partitioned/clustered per the existing conventions.
  5. /health, not /healthz. The spec asked for /healthz; on run.app the GFE reserves /healthz, so the fleet serves /health (OPERATING_SYSTEM 2.3.3). The service follows the fleet.
  6. No invented economics — structural, not aspirational. Costs come only from config/net_yield_costs.yaml (strict schema, unknown keys rejected). A missing cost is NAMED in missing_costs, flips economics_complete=false, keeps net_contribution NULL, and excludes the row from cohort aggregates (incomplete_excluded makes the exclusion visible). Writeback builders refuse incomplete rows outright.
  7. Expected returns are earned, not assumed. Per-SKU return rates enter the formula only after a SKU shows ≥ return_cycle_days of history (tenant-configured; 365-day OFF-window when unconfigured). Before that, contribution is actual-only and labeled return_basis=actual_only. The formula is documented in compute.py and mirrored in the nightly SQL.
  8. Cohorts, v1: tenant|channel|YYYY-MM, channel from landing-site UTM → source_name → unattributed. Deliberately coarse; the pilot defines the next granularity (e.g. joins to attribution edges). Never guesses a channel not present in the payload.
  9. PII minimization + consent stance. Economics rows carry the deterministic shp_<id> / hashed anonymous key only — never email, phone, name, or address. Order economics is first-party transactional bookkeeping (same class as unified_revenue), not behavioral intent modeling, so it is not gated on marketing opt-in; the intent path's consent gate is untouched. Probabilistic keys stay out of causal math (platform §8.4). Erasure cascades reach these tables via the RUNBOOK's GDPR section.
  10. Writeback is hard-flagged OFF and L1 recommend-only. NET_YIELD_WRITEBACK=false is baked into the image env and cloudbuild; /health reports it; a unit test pins the default; send_* raises WritebackDisabled while off. No actuator is registered with the action-runner, so even an enabled flag cannot fire autonomously (two-key rule) — enablement conditions live in the RUNBOOK §8 and flip the positioning claim ledger with owner approval.

Consequences

docs/net-yield/RUNBOOK.md (operator commands) · # MIZ OKI 3.5/docs/marketing/mizoki-shopify-net-yield-positioning.md (claim ledger — the Preview label flips only after a verified pilot) · # MIZ OKI 3.5/docs/marketing/signal-story-bank.md v1.1 (Story 7) · # MIZ OKI 3.5/scripts/content_qa.py (net-yield preview framing enforced in CI).

← All docsView source on GitHub →