Skip to content

feat: campaign-name canon — agency/bid/goal parsing + funnel↔delivery join#7

Open
mtmtian wants to merge 18 commits into
oratis:mainfrom
mtmtian:feat/campaign-canon
Open

feat: campaign-name canon — agency/bid/goal parsing + funnel↔delivery join#7
mtmtian wants to merge 18 commits into
oratis:mainfrom
mtmtian:feat/campaign-canon

Conversation

@mtmtian

@mtmtian mtmtian commented Jul 7, 2026

Copy link
Copy Markdown

Stacked on #6 (→ #5#4) — merge in order. This PR is the last 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)

  • Pipeline: activation = Adjust · signup/retention/payment = backend · agency/bid-strategy/conversion-goal = parsed from campaign name · OS/channel = Adjust's own fields (name-derived values are fallback only). Front/back linking stays on the registered-user id forwarded to Adjust (existing app_user_id partner-param mechanism).
  • Naming convention (positional, --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.
  • Cohort group key: date|channel|os|agency (agency attribution follows the same install-first precedence as channel/os).
  • Report campaign-level rows stamp agency from the row's campaign name; PlatformAccount.agency demoted to fallback (account-level rows and legacy MMP report pulls keep using it).
  • Funnel↔delivery join: /api/reports/breakdown joins 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 (null vs real 0); funnelJoin: full|partial|none is response-level and the UI "pending" hint shows only when not full. Window/metric math shared with /api/growth/summary via kpi-canon helpers — one formula, two consumers.

Known limits (documented in §7)

  • Campaigns not following the naming convention parse to agency: null and won't join — their funnel columns stay by design (no guessing).
  • Adapter-written Report rows don't populate os yet (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 --noEmit clean · npm run lint 0 errors (35 pre-existing warnings) · npm run build passes · prisma validate passes (additive-only migration)

🤖 Generated with Claude Code

mtmtian and others added 9 commits July 7, 2026 16:35
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#1oratis#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#4oratis#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>
@oratis

oratis commented Jul 8, 2026

Copy link
Copy Markdown
Owner

🤖 Code review — Claude Code

Verdict: Request changes — two real silent-failure correctness bugs in the PR's core deliverable (the funnel↔delivery join), not edge-case nits.

Scope note: Top of the stacked series #4#5#6#7 (all target main), so this PR's diff-vs-main contains #4/#5/#6. This review covers only the incremental delta #7 adds over #6. Merge the stack in order (or retarget bases) — merging #7 now would pull in #4/#5/#6 implicitly, including the open issues flagged on those PRs.

Summary

Adds a positional "campaign-name canon" parser and threads parsed agency/bidStrategy/conversionGoal through the Adjust ingest path, Report writes, and a new CohortSnapshot.agency column. Then implements the funnel↔delivery join in /api/reports/breakdown (Report ⋈ CohortSnapshot on date/os/platform/agency via a channelToPlatform bridge), replacing the hardcoded funnelJoin: 'pending' stub.

Findings

  • [High] src/lib/dashboard-bi.ts:316,332-333costPerSignup divides all-dates spend by joined-only signups. aggregateBreakdownRows sums spend over every row, but only sums signups/sets hasSignups when r.signups !== null. So costPerSignup = spend(all rows) / signups(joined rows only) whenever a group mixes joined and unjoined days — exactly the funnelJoin: 'partial' state this PR introduces — yielding a silently inflated (wrong, not null) KPI. The same function correctly nulls d1Rate/d7Rate/d0Roi/d7Roi in aggregate mode for this precise "not re-derivable across mixed join state" reason; costPerSignup was missed. → Accumulate a separate joinedSpend (only add r.spend when r.signups !== null) and divide by that, mirroring the rate-column discipline.

  • [High] src/lib/sync/report-writer.ts:56-60,145 vs src/lib/growth/campaign-name.ts:99agency join-key normalization mismatch → permanent silent miss. parseCampaignName().agency is always .toLowerCase()'d (and that's what lands in ConversionEvent.agency/CohortSnapshot.agency), but resolveReportAgency() — the fallback used by writeCampaignReports for any campaign name that doesn't parse under the canon (i.e. most non-Cuddler campaigns) — returns PlatformAccount.agency verbatim. The join (reports/breakdown/route.ts:73,110,128) is an exact string match with no normalization at the join site. Any org with a mixed-case agency on file (e.g. "GroupM") gets its non-canon Report rows permanently un-joinable to CohortSnapshot data — not a transient "pending", a permanent silent miss. → Lowercase in resolveReportAgency (or canonicalize both sides at the join boundary).

  • [Medium] src/lib/growth/ingest-parse.ts:68agency: str(r.agency) accepts a backend-supplied agency verbatim, no .trim().toLowerCase(), unlike every other producer of this dimension. Currently unused by any live caller, but it's the same normalization gap waiting for the next caller. → Normalize now while it's one line.

  • [Low] src/lib/growth/cohorts.ts (keyOf) and reports/breakdown/route.ts:73,110,128 — group/join keys are naive |-delimited template strings over fields that can contain arbitrary chars (a campaign segment could legally contain a literal |), risking key collision. Pre-existing pattern extended to agency here; low real-world likelihood, noting for awareness.

  • [Low] Test-coverage gap on the riskiest code. The join logic itself (cohortsByKey construction, per-group lookup, funnelJoin full/partial/none derivation) has zero coverage — only pure helpers (aggregateCohortWindow, computeFunnelMetrics, channelToPlatform) are unit-tested. Telling: the new aggregateBreakdownRows tests cover "all joined" and "zero joined" but not the mixed case — precisely the gap that let the first finding through. No e2e added.

No Blocker / multi-tenant issues: both new queries filter by org.id from requireAuthWithOrg(); no raw SQL. Migration 20260707130000_campaign_name_canon matches the schema diff (adds the three ConversionEvent cols + CohortSnapshot.agency, widens the unique index) and is staged. parseCampaignName is pure/never-throws with thorough edge-case tests (empty/whitespace/non-string/no-dash/consecutive-dash all covered); single anchored regex, no backtracking risk. CohortSnapshot rows are summed (not fanned out) across the dropped appId dim — deliberate and correct. _client.tsx validates Array.isArray(d.rows) and coerces funnelJoin to a known enum before use.

Test coverage

Not adequate for the highest-risk code — the join/grouping in /api/reports/breakdown has no direct unit or e2e test; the closest aggregation suite stops short of the mixed joined/unjoined case (the actual bug).

Strengths

  • Extensive doc comments (docs/growth/06-mmp-ingest.md §7, in-code priority chains) make intent easy to verify against implementation.
  • Consistent "never guess, null when unknown" discipline throughout the parsing/fallback chains.
  • Sound "recompute ratios from sums, never average pre-derived per-day ratios" instinct — the costPerSignup bug is a narrow miss of a principle applied correctly everywhere else in the same function.

Independent automated review. Findings are advisory — use judgment.

mtmtian and others added 7 commits July 9, 2026 12:16
…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
mtmtian added a commit to mtmtian/adex that referenced this pull request Jul 9, 2026
…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>
@mtmtian mtmtian force-pushed the feat/campaign-canon branch from 0c6ae05 to 7e2b79c Compare July 9, 2026 06:27
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>
@mtmtian

mtmtian commented Jul 9, 2026

Copy link
Copy Markdown
Author

✅ Changes applied (fixup pushed)

Both High findings and the Medium normalization gap addressed:

  • [High] costPerSignup divides all-dates spend by joined-only signupsaggregateBreakdownRows now accumulates a separate joinedSpend (only adding r.spend when r.signups !== null) and divides by that, mirroring the rate-column discipline already applied to d1Rate/d7Rate/d0Roi/d7Roi. In the funnelJoin: 'partial' state, cost-per-signup is now joinedSpend/joinedSignups instead of a silently inflated all-spend/joined-signups. Added the mixed joined/unjoined regression test — the exact case the prior 'all joined'/'zero joined' tests skipped.
  • [High] agency case-normalization mismatchresolveReportAgency now .trim().toLowerCase()s the PlatformAccount.agency value, matching parseCampaignName's normalization. Mixed-case agencies ('GroupM') on non-canon Report rows can no longer permanently miss the exact-match funnel join.
  • [Medium] backend ingest agency unnormalizedingest-parse.ts now normalizes agency the same way (.trim().toLowerCase()), closing the gap before the next caller hits it.

Re the [Low] |-delimited key collision and the join-logic e2e gap — noted; the key pattern is pre-existing and low-likelihood, and the shared window helpers are unit-covered. The mixed-case aggregation test now covers the specific bug the review flagged; broader route-level e2e is a follow-up.

Commit 7e2b79c (plus 091ed37 chore: gitignore local design PDFs — unrelated repo hygiene). Re-verified: 389/389 vitest, tsc clean, lint clean, build passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants