feat: campaign-name canon — agency/bid/goal parsing + funnel↔delivery join#7
feat: campaign-name canon — agency/bid/goal parsing + funnel↔delivery join#7mtmtian wants to merge 18 commits into
Conversation
WHY: docs/growth/00 §7.3 disciplines existed only as pure functions in
src/lib/growth/{pilot-gates,budget-guard}.ts — zero act-time enforcement.
A $5K pilot could overspend and SKAN-immature campaigns could be
auto-adjusted; executeApprovedDecision also skipped all guardrails.
WHAT: register pilot_budget_cap (fail-closed, activates via pilotStartDate
config), skan_maturity (fail-closed, <72h reject / learning-phase warn),
tier_cac_ceiling (fail-open, increase-only) in the evaluator registry +
schemas; re-evaluate guardrails at execution time inside
executeApprovedDecision (all five caller routes inherit), with rollback
exemption via existing DecisionStep.rollbackOf. Tests for boundaries,
fail-closed paths, and the bypass fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: perceive() attached GrowthSnapshot to the snapshot but plan.v1.md had no growth placeholder — funnel/gate/budget signals were computed then dropped before reaching the LLM. WHAT: add GROWTH_JSON block to plan.v1.md (after the cache-control marker) with explicit rules — gate=kill means reduce/stop only, budget non-ok forbids any spend-increasing action; render it in plan.ts; regression test pins the placeholder position relative to the cache split. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: wiring an MMP into ConversionEvent alongside GA4 double-counts installs (source is part of the idempotency key, cohort aggregation is source-blind), splits channels (network names all fall through to organic), and zeroes retention/LTV (adid vs pseudo_id namespaces). WHAT: docs/growth/06-mmp-ingest.md — the three holes, two canon decisions to make first, and an executable S2S-callback integration checklist; pointer comments on the legacy Report-only clients. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: wiring Adjust into ConversionEvent next to GA4 double-counts installs (source is part of the idempotency key; cohort aggregation was source-blind), splits channels (MMP network names fell through to organic), and breaks retention/LTV joins (adid vs pseudo_id namespaces). Decisions (docs/growth/06 §2): Adjust is the install/channel authority, GA4 supplies funnel-deep events only; userKey prefers a forwarded RC app_user_id, falling back to a non-joinable adjust: prefix. WHAT: mapAdjustCallback pure mapper + GET|POST /api/ingest/adjust (static-secret verifyBearer, token never persisted to raw, unmapped events acked not 5xx'd); ADJUST_NETWORK_MAP + resolveAdjustChannel; RawEvent.source + installAuthority filter on acquisition events only; resolveInstallAuthority with anti-zeroing fallback (legacy-creds-only orgs fall back to ga4 with a logged warning). 23 new tests incl. the dual-source no-double-count case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reakdown WHY: the BI view needs registration-cohort metrics (signup anchor), an OS dimension, an agency dimension, and D0/D7 revenue windows — none existed. The previous install-authority filter also silently dropped backend signups once Adjust was wired (anchor model only held for non-MMP orgs). WHAT: cohort anchor = first signup (never authority-filtered), install fallback; channel/os attribution prefers the user's eligible install (MMP) over signup UTM. ConversionEvent/CohortSnapshot/Report gain os, Report/PlatformAccount gain agency, CohortSnapshot gains signups/revenueD0/revenueD7 (one additive migration). SOURCES gains first-class 'backend'. growth-sync injects spendByCohort (cac no longer always null; unmappable platform spend reported as unallocatedSpend). New GET /api/growth/summary (os×paid/organic, mature-window-gated retention, ROI/ARPU/ARPPU) and GET /api/reports/breakdown (date>os>platform>agency; funnel columns pending the attribution bridge). Fixes the D7 dilution bug in overview (immature cohorts no longer counted in the denominator). Canon recorded in docs/growth/06 §6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tables WHAT: reusable DateRangePicker (presets + native inputs), generic DataTable (totals row, overflow scroll), FieldConfigBar (fixed base columns + checkbox/draggable optional columns persisted to localStorage), FilterBar multi-selects. Dashboard home becomes: date range + OS/source/platform/agency filters, OS×source summary table (retention gated to mature windows, revenue columns incl. D0/D7 windows), date>os>platform>agency delivery table (funnel columns render — until the attribution bridge lands), with the legacy stat cards and trend chart kept below. No new dependencies; totals row refuses to average rates without denominators. i18n en+zh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… join WHY: agency, bid strategy, and conversion goal live in the campaign name by team convention (agency-date-bid-os-region-channel-idx-product-goal- audience-...); OS/channel stay authoritative on Adjust's own fields. Parsing the name gives both sides of the pipeline a shared (date, os, platform, agency) key — closing the funnel↔delivery gap that kept breakdown funnel columns pending. WHAT: parseCampaignName pure parser (positional, fault-tolerant, raw segments preserved, never throws); ConversionEvent gains agency/bidStrategy/conversionGoal, CohortSnapshot gains agency (in the unique key; additive migration). adjust-ingest parses campaign_name with os_name/network_name still winning over name-derived hints. Cohort group key becomes date|channel|os|agency. Report campaign-level rows stamp agency from the name (PlatformAccount.agency demoted to fallback). /api/reports/breakdown joins CohortSnapshot on the shared key and fills signups/costPerSignup/D1/D7/D0-D7 ROI for joined rows; funnelJoin is a response-level full|partial|none signal and the UI pending hint shows only when not full. Shared window/metric helpers extracted into kpi-canon (summary + breakdown use the same math). Canon recorded in docs/growth/06 §7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… not partner WHY: partner parameters are forwarded to ad networks; callback parameters are what come back on raw-data callbacks. Configuring the wrong type means app_user_id never arrives and every install silently downgrades to the non-joinable adjust:adid userKey, breaking media→MMP→backend matching. WHAT: fix the term in docs/growth/06 (§1/§2/§3/§7) and adjust-ingest.ts comments; note the AppsFlyer analogue (CUID/customer_user_id) and the SDK setters (v5 addGlobalCallbackParameter / v4 addSessionCallbackParameter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: 04-status.md is the build-status handoff doc but stopped at the 2026-07-04 upstream snapshot (PR oratis#1–oratis#3); anyone reading it would miss the guardrail wiring, Adjust ingest, BI dashboard, and campaign-name canon now in review. WHAT: append the PR oratis#4–oratis#7 table with scopes and ops prerequisites; normalize the two pre-existing table separators to the padded style the linter enforces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🤖 Code review — Claude CodeVerdict: Request changes — two real silent-failure correctness bugs in the PR's core deliverable (the funnel↔delivery join), not edge-case nits.
SummaryAdds a positional "campaign-name canon" parser and threads parsed Findings
No Blocker / multi-tenant issues: both new queries filter by Test coverageNot adequate for the highest-risk code — the join/grouping in Strengths
Independent automated review. Findings are advisory — use judgment. |
…success WHY (review oratis#4): sync writes both an account-level row and per-campaign rows for the same spend, so the unfiltered sum double-counted and tripped the $5K cap at ~half real spend. The execution-time guardrail re-check was also discarded on the success path, silently dropping warn-level signals from the audit trail. WHAT: pilot_budget_cap filters level:'account' (the one-per-platform- per-day contract row); success-path decisionStep.update persists guardrailReport. Regression tests for both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it secret WHY (review oratis#5): install authority keyed off the legacy Report-API credential, so a recommended S2S-only org kept GA4 authority and its Adjust installs were silently excluded; mapAdjustCallback was always called with an empty event-token map, dropping every non-install event; the route shared the events-route HMAC secret while transmitting it in cleartext query strings. WHAT: resolveInstallAuthority prefers adjust when live source='adjust' installs exist OR the credential is configured; the route reads a distinct PlatformAuth(platform='ingest_adjust') slot (env fallback INGEST_ADJUST_SECRET) whose extra JSON supplies eventTokenMap; the ?token strip now happens AFTER the POST form merge so a form field named token can't smuggle the secret into raw. Docs + .env.example updated; S2S-only regression test added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: the previous commit typed the parsed map as Record<string,string>, which doesn't satisfy AdjustEventTokenMap (values must be canonical EventNames) — tsc error; and an unvalidated config value could have produced rows with non-canonical event names. WHAT: filter extra.eventTokenMap entries through the EVENTS closed set at parse time; typo'd entries degrade to token-unmapped (dropped), never a bad row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… window WHY (review oratis#6): isMatureForRetentionWindow marked a cohort mature at the first instant of day N, but retention counts events across the whole of day N (dayDiff === N) — cohorts exactly N days old entered the D1/D7 rate before that day's events could arrive, reintroducing the dilution this gate exists to fix; the test asserted the buggy boundary. Separately /api/reports/breakdown scanned every org Report row when start/end were omitted. WHAT: matureAt = cohortStart + (days + 1) days; boundary tests now assert immaturity throughout day N and maturity from day N+1. breakdown defaults to a rolling 60-day window when start is omitted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # docs/growth/06-mmp-ingest.md
…atis#7) WHY (review oratis#7): aggregateBreakdownRows divided all-days spend by joined-days-only signups, silently inflating costPerSignup exactly in the funnelJoin:'partial' state this PR introduced; agency values from resolveReportAgency kept their original case while parseCampaignName lowercases, so mixed-case agencies ('GroupM') could never exact-match the funnel join — a permanent silent miss, not a pending state. WHAT: accumulate joinedSpend alongside signups and divide those twins; lowercase agency in resolveReportAgency and the backend ingest path; mixed joined/unjoined regression test (the exact gap the review named); cohort-side breakdown query now shares the report query's effective window; aggregateCohortWindow fixtures updated to the corrected maturity boundary (merged from oratis#6) plus an in-progress-day case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atis#7) WHY (review oratis#7): aggregateBreakdownRows divided all-days spend by joined-days-only signups, silently inflating costPerSignup exactly in the funnelJoin:'partial' state this PR introduced; agency values from resolveReportAgency kept their original case while parseCampaignName lowercases, so mixed-case agencies ('GroupM') could never exact-match the funnel join — a permanent silent miss, not a pending state. WHAT: accumulate joinedSpend alongside signups and divide those twins; lowercase agency in resolveReportAgency and the backend ingest path; mixed joined/unjoined regression test (the exact gap the review named); cohort-side breakdown query now shares the report query's effective window; aggregateCohortWindow fixtures updated to the corrected maturity boundary (merged from oratis#6) plus an in-progress-day case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0c6ae05 to
7e2b79c
Compare
Keep business/reference material (e.g. the Google Ads API design PDF) out of the repo tree — local-only per the data-handling rules. Prevents a repeat of the accidental commit into this branch's history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Changes applied (fixup pushed)Both High findings and the Medium normalization gap addressed:
Re the [Low] Commit |
Summary
Implements the team's campaign-naming convention as the attribution canon and uses it to close the funnel↔delivery gap left pending in #6.
Canon (docs/growth/06 §7)
--delimited):agency-date-bid-os-regions-channel-idx-product-goal-audience-custom..., e.g.inhouse-20260512-mai-Android-US/T1/JP-Google-01-Luddi-install-female-Davis-xx.Changes
parseCampaignName— pure positional parser: tolerant of short/long names, regions split on/, os/date enum-validated with raw segments preserved (osRaw/dateRaw), free-text fields lowercased not whitelisted (word lists are customer-defined), never throws. 22 tests.ConversionEvent+=agency/bidStrategy/conversionGoal;CohortSnapshot+=agency(into the unique key). Additive migration.date|channel|os|agency(agency attribution follows the same install-first precedence as channel/os).agencyfrom the row's campaign name;PlatformAccount.agencydemoted to fallback (account-level rows and legacy MMP report pulls keep using it)./api/reports/breakdownjoins CohortSnapshot on(date, os, channelToPlatform(channel), agency)and fills signups / cost-per-signup / D1 / D7 / D0–D7 ROI for joined rows. Joined-to-zero and never-joined are distinguishable (nullvs real 0);funnelJoin: full|partial|noneis response-level and the UI "pending" hint shows only when not full. Window/metric math shared with/api/growth/summaryvia kpi-canon helpers — one formula, two consumers.Known limits (documented in §7)
agency: nulland won't join — their funnel columns stay—by design (no guessing).osyet (pre-existing), which lowers join hit-rate on those platforms until that's wired.Test plan
npx vitest run— 384/384 across 30 files (33 new: parser edge cases, channelToPlatform, shared window helpers, breakdown aggregation with joined/unjoined rows)npx tsc --noEmitclean ·npm run lint0 errors (35 pre-existing warnings) ·npm run buildpasses ·prisma validatepasses (additive-only migration)🤖 Generated with Claude Code