Skip to content

feat: Adjust S2S ingest with single install-source authority#5

Open
mtmtian wants to merge 8 commits into
oratis:mainfrom
mtmtian:feat/adjust-ingest
Open

feat: Adjust S2S ingest with single install-source authority#5
mtmtian wants to merge 8 commits into
oratis:mainfrom
mtmtian:feat/adjust-ingest

Conversation

@mtmtian

@mtmtian mtmtian commented Jul 7, 2026

Copy link
Copy Markdown

Stacked on #4 — merge that first; this branch includes its commits. The Adjust-specific diff is the last commit (7140061).

Summary

Wires Adjust into the growth pipeline (ConversionEvent) via real-time S2S callbacks, with the three prerequisites from docs/growth/06-mmp-ingest.md closed 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)

  • A · Install authority: orgs with an Adjust PlatformAuth count installs/channel attribution from source='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), resolveInstallAuthority falls back with a logged warning instead of reporting a false funnel zero.
  • B · userKey: prefer the RC app_user_id forwarded as an Adjust partner parameter; fall back to a namespaced adjust:${adid} that intentionally does not join across sources.

Changes

  • src/lib/growth/adjust-ingest.ts — pure mapAdjustCallback: install → canonical install; event via 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 via Authorization or ?token=; the token is stripped before anything is persisted to raw. Unmapped events are acked 200 so Adjust doesn't retry-storm. Idempotent via the existing unique key.
  • channels.tsADJUST_NETWORK_MAP + resolveAdjustChannel (ASA deterministic; Meta/TikTok install networks conservatively → *_ios/SKAN unless the campaign name says web; unmapped → organic/inferred). resolveChannel untouched.
  • cohorts.tsRawEvent.source + installAuthority filter applied to acquisition events only.
  • growth-sync cron — 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 --noEmit clean; npm run lint 0 errors (35 pre-existing warnings)
  • Note: no e2e added for the new route — its logic lives in pure, unit-tested functions, matching how the existing ingest routes shipped (see 06 §3 for the manual verification checklist incl. callback replay idempotency)

🤖 Generated with Claude Code

mtmtian and others added 4 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>
@oratis

oratis commented Jul 8, 2026

Copy link
Copy Markdown
Owner

🤖 Code review — Claude Code

Verdict: 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.

Scope note: #4#5#6#7 are a stacked series all targeting main. This review covers only the incremental delta #5 adds over #4. Schema is unchanged here (no migration needed — ConversionEvent.source already exists from #4). Please merge the stack in order.

Summary

Adds a public S2S ingest endpoint (/api/ingest/adjust) mapping Adjust real-time callbacks into ConversionEvent rows, plus the "single install-source authority" logic (resolveInstallAuthority, ADJUST_NETWORK_MAP/resolveAdjustChannel, and an installAuthority filter in buildCohortSnapshots) meant to stop one real install from being double-counted when both GA4 and Adjust report it.

Findings

  • [High] src/app/api/cron/growth-sync/route.ts:56-58install-authority signal reads the wrong credential. hasAdjustAuth is derived from PlatformAuth(platform='adjust') (the legacy Report-API credential), not from whether the org has live S2S data (authenticated via platform='ingest' in the new route). An org that follows the doc's own recommendation — wire the S2S callback only, skip the legacy Report credential (docs/growth/06-mmp-ingest.md: "不推荐用 Report Service API") — gets hasAdjustAuth=false, so GA4 stays the default authority. Since resolveInstallAuthority only overrides when the preferred source's count is exactly 0, Adjust's more accurate channel-attributed installs are silently excluded whenever GA4 has any installs — the exact bug this PR exists to fix. → Derive the signal from actual source='adjust' event presence (adjustInstallCount > 0), not the legacy credential row.

  • [High] src/app/api/ingest/adjust/route.ts:67non-install events are dead on arrival. mapAdjustCallback(params) is called with no second arg, so eventTokenMap always defaults to {} (adjust-ingest.ts:47). Every activity_kind='event' callback maps to eventName=undefined and is dropped — confirmed by the PR's own test adjust-ingest.test.ts:53-55. No config path anywhere supplies a real map, yet docs/growth/06-mmp-ingest.md §3 tells customers to configure key-event callbacks and marks the mapper "已完成". → Wire a real event-token map (e.g. from PlatformAuth.extra JSON) before calling mapAdjustCallback, or scope the docs to install-only until it lands.

  • [Medium] src/app/api/ingest/adjust/route.ts:38-46secret hygiene. Auth reuses the same secret that /api/ingest/events uses purely as an HMAC signing key (never sent over the wire). This route transmits that identical key in cleartext via ?token= (unavoidable — Adjust can't set headers), which lands in Cloud Run / LB access logs. A leaked Adjust token then also lets an attacker forge valid X-Adex-Signature HMACs for /api/ingest/events. → Use a distinct secret/platform slot for the Adjust route.

  • [Low] src/app/api/ingest/adjust/route.ts:53-65delete params.token (L55) runs before the POST form-data merge (L56-65); if a customer's Adjust partner param is named token, the merge re-adds it and it persists into ConversionEvent.raw (L86). → Strip the token after the merge / immediately before building raw.

  • [Low] No test exercises the route itself (401 path, GET vs POST, org scoping, DB write/idempotency) — only the pure functions are covered. A minimal e2e ("no token → 401", "valid token → 200 + row + retry skipped") would close a real gap on a public ingest surface.

  • [Low] growth-sync/route.ts:56-58 adds another sequential per-org platformAuth.findUnique inside the existing per-org loop (compounds a pre-existing N+1). Fine at current scale; batch if org count grows.

Test coverage

Good vitest coverage of the new pure logic (mapAdjustCallback, resolveAdjustChannel, resolveInstallAuthority, buildCohortSnapshots, incl. a double-source dedup case mirroring the goal). Gap: the HTTP route has zero coverage, and the hasAdjustAuth bug slipped through because no test exercises the cron against an S2S-only org (no legacy PlatformAuth(platform='adjust') row).

Strengths

  • Idempotency correctly reuses the ConversionEvent unique-key pattern; retries deduped via skipDuplicates.
  • Unmapped/irrelevant activity kinds fail closed to a 200 ack (respects Adjust retry semantics) rather than 500ing or guessing.
  • docs/growth/06-mmp-ingest.md is thorough and kept in sync with the code.

Independent automated review. Findings are advisory — use judgment.

mtmtian and others added 4 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>
@mtmtian

mtmtian commented Jul 9, 2026

Copy link
Copy Markdown
Author

✅ Changes applied (fixup pushed)

Both High findings and the Medium/Low secret-hygiene issues addressed:

  • [High] authority reads wrong credentialresolveInstallAuthority now prefers Adjust when there are live source='adjust' install events in the window OR the legacy credential exists (hasAdjustAuth || adjustInstallCount > 0). The recommended S2S-only org (callback wired, no legacy Report credential) now correctly gets Adjust authority instead of silently keeping GA4. Added a regression test for the S2S-only case (hasAdjustAuth: false, adjustInstallCount: 40authority: 'adjust') — the exact gap that had no coverage.
  • [High] non-install events dead on arrival — the route now loads a real eventTokenMap from PlatformAuth(platform='ingest_adjust').extra JSON ({"eventTokenMap":{"<token>":"trial_start"}}) and passes it into mapAdjustCallback. Map values are validated against the canonical EVENTS set at parse time, so a typo'd config entry degrades to token-unmapped (dropped), never a bad row.
  • [Medium] secret hygiene — the Adjust route now uses a distinct secret slot: PlatformAuth(platform='ingest_adjust') / env INGEST_ADJUST_SECRET, separate from the events-route HMAC key. A leaked Adjust token (which travels in cleartext ?token=) can no longer be used to forge X-Adex-Signature HMACs for /api/ingest/events. .env.example documents the split.
  • [Low] token strip orderingdelete params.token now runs after the POST form-data merge, so a customer partner param literally named token can't be re-added into ConversionEvent.raw.

Commits 23dc9a8, 7f05dee. Re-verified: 389/389 vitest, tsc clean, build passes.

The route-level e2e gap (401 path, GET/POST, idempotency) is noted; the pure mapper/authority logic is unit-covered and I'd rather add the HTTP e2e in the repo's Playwright suite as a focused follow-up than expand this PR.

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