feat: Adjust S2S ingest with single install-source authority#5
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>
🤖 Code review — Claude CodeVerdict: Request changes — two correctness bugs defeat the PR's core "single install-source authority" goal in the recommended configuration, plus a secret-hygiene issue on a new public endpoint.
SummaryAdds a public S2S ingest endpoint ( Findings
Test coverageGood vitest coverage of the new pure logic ( 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>
✅ Changes applied (fixup pushed)Both High findings and the Medium/Low secret-hygiene issues addressed:
Commits
|
Summary
Wires Adjust into the growth pipeline (ConversionEvent) via real-time S2S callbacks, with the three prerequisites from
docs/growth/06-mmp-ingest.mdclosed first — without them, running GA4 + Adjust on the same org double-counts installs (halving CAC), splits channels, and zeroes retention/LTV.Canon decisions implemented (06 §2, now marked decided)
PlatformAuthcount installs/channel attribution fromsource='adjust'only; GA4 is demoted to funnel-deep events (first_chat, scene_generated, …) that Adjust doesn't report. Anti-zeroing guard: if the authority source has 0 installs in the window while the other has signal (e.g. org has legacy Report creds but never wired the callback),resolveInstallAuthorityfalls back with a logged warning instead of reporting a false funnel zero.app_user_idforwarded as an Adjust partner parameter; fall back to a namespacedadjust:${adid}that intentionally does not join across sources.Changes
src/lib/growth/adjust-ingest.ts— puremapAdjustCallback:install→ canonical install;eventvia an event-token map (unmapped → drop); reattribution/session dropped.GET|POST /api/ingest/adjust?org=— Adjust callbacks are URL templates (no HMAC, often GET), so auth is a constant-time static secret (verifyBearer, same storage as/api/ingest/events) accepted viaAuthorizationor?token=; the token is stripped before anything is persisted toraw. Unmapped events are acked 200 so Adjust doesn't retry-storm. Idempotent via the existing unique key.channels.ts—ADJUST_NETWORK_MAP+resolveAdjustChannel(ASA deterministic; Meta/TikTok install networks conservatively →*_ios/SKAN unless the campaign name says web; unmapped → organic/inferred).resolveChanneluntouched.cohorts.ts—RawEvent.source+installAuthorityfilter applied to acquisition events only.growth-synccron — resolves the authority per org and reports it (+ any fallback warning) in the run summary.Test plan
npx vitest run— 298/298 (23 new: dual-source install not double-counted under authority — and the reproduction showing it does double without one; authority never filters funnel-deep events; anti-zeroing fallback; mapper drop branches; channel map; userKey both modes)npx tsc --noEmitclean;npm run lint0 errors (35 pre-existing warnings)🤖 Generated with Claude Code