From c05382276ca307d4844e09ebcbbf4a9decdbc812 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:35:34 +0800 Subject: [PATCH 1/7] feat: wire pilot guardrails into agent runtime, close approval bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/agent/act.test.ts | 198 ++++++++++++++++++++++ src/lib/agent/act.ts | 60 ++++++- src/lib/agent/guardrail-schemas.ts | 50 ++++++ src/lib/agent/guardrails.test.ts | 255 ++++++++++++++++++++++++++++- src/lib/agent/guardrails.ts | 214 ++++++++++++++++++++++++ 5 files changed, 774 insertions(+), 3 deletions(-) create mode 100644 src/lib/agent/act.test.ts diff --git a/src/lib/agent/act.test.ts b/src/lib/agent/act.test.ts new file mode 100644 index 0000000..fc6b10a --- /dev/null +++ b/src/lib/agent/act.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// vi.mock is hoisted — define stubs inside the factory so they're +// initialised before the module under test imports them. +vi.mock('@/lib/prisma', () => ({ + prisma: { + decision: { findFirst: vi.fn(), update: vi.fn(), create: vi.fn() }, + decisionStep: { update: vi.fn(), create: vi.fn(), createMany: vi.fn() }, + pendingApproval: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})) + +vi.mock('./tools', () => ({ + getTool: vi.fn(), +})) + +vi.mock('./guardrails', () => ({ + evaluateGuardrails: vi.fn(), + isBlocked: vi.fn((results: Array<{ pass: boolean }>) => results.some((r) => !r.pass)), +})) + +vi.mock('@/lib/webhooks', () => ({ + fireWebhook: vi.fn(() => Promise.resolve()), +})) + +vi.mock('./notify', () => ({ + notifyApprovers: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@/lib/audit', () => ({ + logAudit: vi.fn(() => Promise.resolve()), +})) + +import { executeApprovedDecision } from './act' +import { prisma } from '@/lib/prisma' +import { getTool } from './tools' +import { evaluateGuardrails } from './guardrails' +import { logAudit } from '@/lib/audit' + +const mockedPrisma = prisma as unknown as { + decision: { findFirst: ReturnType; update: ReturnType } + decisionStep: { update: ReturnType } +} +const mockedGetTool = getTool as unknown as ReturnType +const mockedEvaluateGuardrails = evaluateGuardrails as unknown as ReturnType +const mockedLogAudit = logAudit as unknown as ReturnType + +function makeTool(execute = vi.fn(async () => ({ ok: true, output: {} }))) { + return { + name: 'adjust_daily_budget', + description: '', + inputSchema: {}, + reversible: true, + riskLevel: 'medium' as const, + validate: (i: unknown) => i, + execute, + } +} + +function makeStep(overrides: Record = {}) { + return { + id: 'step1', + decisionId: 'd1', + stepIndex: 0, + toolName: 'adjust_daily_budget', + toolInput: JSON.stringify({ campaignId: 'c1', newDailyBudget: 100 }), + toolOutput: null, + status: 'pending', + guardrailReport: null, + platformResponse: null, + platformLinkId: null, + reversible: true, + rollbackOf: null, + executedAt: null, + createdAt: new Date(), + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockedPrisma.decision.update.mockResolvedValue({}) + mockedPrisma.decisionStep.update.mockResolvedValue({}) +}) + +describe('executeApprovedDecision — fresh guardrail re-check', () => { + it('blocks a step when the fresh re-check finds a violation (non-rollback)', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep() + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([ + { pass: false, rule: 'pilot_budget_cap', reason: 'over cap' }, + ]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).not.toHaveBeenCalled() + expect(mockedPrisma.decisionStep.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'step1' }, + data: expect.objectContaining({ status: 'blocked' }), + }) + ) + expect(mockedLogAudit).toHaveBeenCalled() + expect(result.status).toBe('failed') + }) + + it('proceeds normally when the fresh re-check passes', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep() + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([{ pass: true, rule: 'pilot_budget_cap' }]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).toHaveBeenCalled() + expect(result.status).toBe('executed') + }) +}) + +describe('executeApprovedDecision — rollback exemption', () => { + // Rollback detection signal: DecisionStep.rollbackOf is set on every + // inverse step created by both decisions/[id]/rollback and + // decisions/bulk-rollback routes. + it('executes despite pilot_budget_cap / tier_cac_ceiling violations when rolling back', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep({ rollbackOf: 'original-step-1' }) + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([ + { pass: false, rule: 'pilot_budget_cap', reason: 'over cap' }, + { pass: false, rule: 'tier_cac_ceiling', reason: 'cac too high' }, + ]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).toHaveBeenCalled() + expect(result.status).toBe('executed') + }) + + it('executes despite a skan_maturity violation alone when rolling back (warn-only)', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep({ rollbackOf: 'original-step-1' }) + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([ + { pass: false, rule: 'skan_maturity', reason: 'campaign too young' }, + ]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).toHaveBeenCalled() + expect(result.status).toBe('executed') + }) + + it('still blocks a rollback step on an unrelated violated rule', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep({ rollbackOf: 'original-step-1' }) + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([ + { pass: false, rule: 'high_risk_requires_approval', reason: 'high risk' }, + ]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).not.toHaveBeenCalled() + expect(result.status).toBe('failed') + }) +}) diff --git a/src/lib/agent/act.ts b/src/lib/agent/act.ts index f47f865..331d600 100644 --- a/src/lib/agent/act.ts +++ b/src/lib/agent/act.ts @@ -1,8 +1,9 @@ import { prisma } from '@/lib/prisma' import { fireWebhook } from '@/lib/webhooks' import { getTool } from './tools' -import { evaluateGuardrails, isBlocked } from './guardrails' +import { evaluateGuardrails, isBlocked, type GuardrailEvalResult } from './guardrails' import { notifyApprovers } from './notify' +import { logAudit } from '@/lib/audit' import type { AgentMode, PlanResult, @@ -150,6 +151,14 @@ export async function executeApprovedDecision( }) if (!decision) throw new Error(`Decision ${decisionId} not found`) + // Rollback detection: both rollback routes (decisions/[id]/rollback and + // decisions/bulk-rollback) stamp every inverse DecisionStep's `rollbackOf` + // with the source step id it reverses (see prisma schema DecisionStep. + // rollbackOf) and record `{ rollbackOf: original.id }` in the rollback + // Decision's perceiveContext. `rollbackOf` on the steps we already loaded + // is the simplest reliable per-Decision signal — no extra query needed. + const isRollback = decision.steps.some((s) => s.rollbackOf != null) + await prisma.decision.update({ where: { id: decisionId }, data: { status: 'executing', executedAt: new Date() }, @@ -200,6 +209,55 @@ export async function executeApprovedDecision( continue } + // Re-check guardrails immediately before execution — the shared choke + // point for every path that reaches executeApprovedDecision (approvals, + // approvals/bulk, rollback, bulk-rollback, slack/interactive). State can + // have changed since processOne's original evaluation (e.g. approval sat + // pending for hours), so we can't trust the stored guardrailReport alone. + const freshResults = await evaluateGuardrails({ + orgId, + step: { tool: step.toolName, input: JSON.parse(step.toolInput) }, + tool, + }) + + // Rollback exemption: rolling back restores a previous (already + // guardrail-passed-once) state, so blocking a rollback on the pilot + // budget cap or CAC ceiling would strand the system in a bad state with + // no way back — those two are excluded from the blocking check entirely + // during rollback. skan_maturity's concern about touching an + // immature-data campaign still deserves a visible warning even during + // rollback, so its result is kept in the report but never allowed to + // block. + const blockingResults: GuardrailEvalResult[] = isRollback + ? freshResults.filter((r) => r.rule !== 'pilot_budget_cap' && r.rule !== 'tier_cac_ceiling' && r.rule !== 'skan_maturity') + : freshResults + + if (isBlocked(blockingResults)) { + const failed = freshResults.filter((r) => !r.pass) + await prisma.decisionStep.update({ + where: { id: step.id }, + data: { + status: 'blocked', + toolOutput: JSON.stringify({ blocked: true, guardrails: freshResults }), + guardrailReport: JSON.stringify(freshResults), + }, + }) + anyFailed = true + await logAudit({ + orgId, + action: 'advisor.apply', + targetType: 'decision_step', + targetId: step.id, + metadata: { + result: 'blocked_at_execution', + decisionId, + rule: failed.map((r) => r.rule), + reasons: failed.map((r) => r.reason), + }, + }) + continue + } + const ctx: ToolContext = { orgId, decisionId, stepIndex: step.stepIndex, mode } let result: ToolResult try { diff --git a/src/lib/agent/guardrail-schemas.ts b/src/lib/agent/guardrail-schemas.ts index 462c5c4..b303a34 100644 --- a/src/lib/agent/guardrail-schemas.ts +++ b/src/lib/agent/guardrail-schemas.ts @@ -247,6 +247,56 @@ export const GUARDRAIL_SCHEMAS: GuardrailSchema[] = [ }, ], }, + { + rule: 'pilot_budget_cap', + label: { en: 'Growth pilot spend cap', zh: '增长试点花费上限' }, + description: { + en: 'Inert until pilotStartDate is set. Once set, blocks spend-increasing steps once cumulative org spend since that date reaches the auto-pause threshold (95% of capTotal).', + zh: '默认不生效,需先设置 pilotStartDate。设置后,一旦该日期以来的组织累计花费达到自动暂停阈值(capTotal 的 95%),即拦截所有会增加花费的操作。', + }, + fields: [ + { + name: 'pilotStartDate', + label: { en: 'Pilot start date', zh: '试点开始日期' }, + hint: { en: 'ISO date, e.g. 2026-01-01. Leave unset to keep this rule inert.', zh: 'ISO 日期,如 2026-01-01。不填则此规则不生效。' }, + type: { kind: 'string' }, + default: '', + }, + { + name: 'capTotal', + label: { en: 'Pilot cap total (USD)', zh: '试点总预算上限(USD)' }, + hint: { en: 'default 5000', zh: '默认 5000' }, + type: { kind: 'number', min: 1 }, + default: 5000, + }, + ], + }, + { + rule: 'skan_maturity', + label: { en: 'Require SKAN data maturity', zh: 'SKAN 数据成熟度要求' }, + description: { + en: 'For Meta/TikTok app_install campaigns (SKAN-attributed iOS), reject automated adjustments within 72h of launch, and reject learning-phase (≤7d) spend runaways beyond 2× daily cap.', + zh: '针对 Meta/TikTok app_install 广告系列(SKAN 归因 iOS),启动 72 小时内拒绝任何自动化调整;学习期(≤7 天)内花费超过日预算 2 倍时也拒绝。', + }, + fields: [], + }, + { + rule: 'tier_cac_ceiling', + label: { en: 'Cap bid/budget increases by tier CAC ceiling', zh: '按分层 CAC 上限限制加价/加预算' }, + description: { + en: 'Reject bid/budget increases on a channel whose most recent CohortSnapshot CAC exceeds firstMonthNet × 5. No CohortSnapshot data → not blocked.', + zh: '当渠道最近一次 CohortSnapshot 的 CAC 超过 firstMonthNet × 5 时,拒绝该渠道的加价/加预算操作。无 CohortSnapshot 数据时不拦截。', + }, + fields: [ + { + name: 'firstMonthNet', + label: { en: 'First-month net revenue (USD)', zh: '首月净收入(USD)' }, + hint: { en: 'default 8.5', zh: '默认 8.5' }, + type: { kind: 'number', min: 0 }, + default: 8.5, + }, + ], + }, ] export function getSchema(rule: string): GuardrailSchema | undefined { diff --git a/src/lib/agent/guardrails.test.ts b/src/lib/agent/guardrails.test.ts index 78a6d56..cbc462d 100644 --- a/src/lib/agent/guardrails.test.ts +++ b/src/lib/agent/guardrails.test.ts @@ -9,8 +9,10 @@ vi.mock('@/lib/prisma', () => ({ decisionStep: { findFirst: vi.fn(), count: vi.fn() }, platformLink: { findFirst: vi.fn(), findMany: vi.fn() }, campaignSnapshot: { findFirst: vi.fn() }, - report: { findMany: vi.fn() }, + report: { findMany: vi.fn(), aggregate: vi.fn() }, guardrail: { findMany: vi.fn() }, + cohortSnapshot: { findFirst: vi.fn() }, + budget: { findFirst: vi.fn() }, }, })) @@ -25,8 +27,10 @@ const mockedPrisma = prisma as unknown as { decisionStep: { findFirst: ReturnType; count: ReturnType } platformLink: { findFirst: ReturnType; findMany: ReturnType } campaignSnapshot: { findFirst: ReturnType } - report: { findMany: ReturnType } + report: { findMany: ReturnType; aggregate: ReturnType } guardrail: { findMany: ReturnType } + cohortSnapshot: { findFirst: ReturnType } + budget: { findFirst: ReturnType } } const tool = (name: string, riskLevel: 'low' | 'medium' | 'high' = 'low'): ToolDefinition => ({ @@ -51,7 +55,10 @@ beforeEach(() => { mockedPrisma.platformLink.findMany.mockResolvedValue([]) mockedPrisma.campaignSnapshot.findFirst.mockResolvedValue(null) mockedPrisma.report.findMany.mockResolvedValue([]) + mockedPrisma.report.aggregate.mockResolvedValue({ _sum: { spend: 0 } }) mockedPrisma.guardrail.findMany.mockResolvedValue([]) + mockedPrisma.cohortSnapshot.findFirst.mockResolvedValue(null) + mockedPrisma.budget.findFirst.mockResolvedValue(null) }) describe('evaluateGuardrails — built-in defaults', () => { @@ -185,3 +192,247 @@ describe('isBlocked', () => { expect(isBlocked([])).toBe(false) }) }) + +describe('pilot_budget_cap', () => { + it('not applicable to a non-spend-increasing tool', async () => { + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'pause_campaign', input: { campaignId: 'c1' } }, + tool: tool('pause_campaign', 'low'), + }) + expect(results.find((r) => r.rule === 'pilot_budget_cap')?.pass).toBe(true) + }) + + it('inert when no pilotStartDate configured (no DB call)', async () => { + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + expect(results.find((r) => r.rule === 'pilot_budget_cap')?.pass).toBe(true) + expect(mockedPrisma.report.findMany).not.toHaveBeenCalled() + }) + + it('pass true, no reason, when spend is below warn threshold', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01', capTotal: 5000 }) }, + ]) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 1000 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + const r = results.find((r) => r.rule === 'pilot_budget_cap') + expect(r?.pass).toBe(true) + expect(r?.reason).toBeUndefined() + }) + + it('pass true with a warn reason at 80% of cap', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01', capTotal: 5000 }) }, + ]) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 4000 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + // Two evaluations run: the always-on builtin (config {}, inert — pass) + // and the org-configured one (config with pilotStartDate). Check the + // org-configured occurrence specifically (the one carrying a reason). + const orgResult = results.filter((r) => r.rule === 'pilot_budget_cap').find((r) => r.reason) + expect(orgResult?.pass).toBe(true) + expect(orgResult?.reason).toBeDefined() + }) + + it('blocks at/above 95% of cap', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01', capTotal: 5000 }) }, + ]) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 4750 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + expect(results.some((r) => r.rule === 'pilot_budget_cap' && !r.pass)).toBe(true) + }) + + it('fail-closed: blocks if the DB read throws', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01' }) }, + ]) + mockedPrisma.report.findMany.mockRejectedValueOnce(new Error('db down')) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + const r = results.filter((r) => r.rule === 'pilot_budget_cap').find((r) => !r.pass) + expect(r?.pass).toBe(false) + expect(r?.reason).toContain('fail-closed') + }) +}) + +describe('skan_maturity', () => { + it('not applicable to an irrelevant tool', async () => { + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'pause_campaign', input: { campaignId: 'c1' } }, + tool: tool('pause_campaign', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(true) + }) + + it('passes non-SKAN campaigns (e.g. google platform)', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'google', + objective: 'app_install', + startDate: new Date(), + managedByAgent: true, + }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(true) + }) + + it('fail-closed rejects SKAN campaign with unknown startDate', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'meta', + objective: 'app_install', + startDate: null, + managedByAgent: true, + }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(false) + }) + + it('rejects SKAN campaign younger than 72h', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'meta', + objective: 'app_install', + startDate: new Date(Date.now() - 10 * 60 * 60 * 1000), // 10h old + managedByAgent: true, + }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(false) + }) + + it('warns (does not reject) in learning window (day 3) under 2x daily cap', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'meta', + objective: 'app_install', + startDate: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // 3 days old + managedByAgent: true, + }) + mockedPrisma.budget.findFirst.mockResolvedValueOnce({ amount: 100 }) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 50 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + const r = results.find((r) => r.rule === 'skan_maturity') + expect(r?.pass).toBe(true) + }) + + it('rejects learning-window spend over 2x daily cap', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'meta', + objective: 'app_install', + startDate: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), + managedByAgent: true, + }) + mockedPrisma.budget.findFirst.mockResolvedValueOnce({ amount: 100 }) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 250 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(false) + }) + + it('passes SKAN campaigns older than 7 days without further restriction', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'tiktok', + objective: 'app_install', + startDate: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), + managedByAgent: true, + }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(true) + }) +}) + +describe('tier_cac_ceiling', () => { + it('passes a non-increase input (bid decrease)', async () => { + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 3, previousBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'tier_cac_ceiling')?.pass).toBe(true) + }) + + it('passes an increase with no derivable channel', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ platform: 'google', objective: 'web_conversion', managedByAgent: true }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'tier_cac_ceiling')?.pass).toBe(true) + }) + + it('passes with a warn reason when no CohortSnapshot found for a derivable channel', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ platform: 'meta', objective: 'app_install', managedByAgent: true }) + mockedPrisma.cohortSnapshot.findFirst.mockResolvedValueOnce(null) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + const r = results.find((r) => r.rule === 'tier_cac_ceiling') + expect(r?.pass).toBe(true) + expect(r?.reason).toBeDefined() + }) + + it('passes when CohortSnapshot.cac is within ceiling', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ platform: 'meta', objective: 'app_install', managedByAgent: true }) + mockedPrisma.cohortSnapshot.findFirst.mockResolvedValueOnce({ cac: 20 }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'tier_cac_ceiling')?.pass).toBe(true) + }) + + it('rejects when CohortSnapshot.cac exceeds the ceiling (firstMonthNet default 8.5 × 5 = 42.5)', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ platform: 'meta', objective: 'app_install', managedByAgent: true }) + mockedPrisma.cohortSnapshot.findFirst.mockResolvedValueOnce({ cac: 50 }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'tier_cac_ceiling')?.pass).toBe(false) + }) +}) diff --git a/src/lib/agent/guardrails.ts b/src/lib/agent/guardrails.ts index 1678477..1aaed79 100644 --- a/src/lib/agent/guardrails.ts +++ b/src/lib/agent/guardrails.ts @@ -7,6 +7,14 @@ */ import { prisma } from '@/lib/prisma' import type { ProposedDecisionStep, ToolDefinition } from './types' +import { + evaluatePilotBudget, + tierCacCeiling, + withinCacCeiling, + LEARNING_PHASE_DAYS, + LEARNING_PHASE_HARD_MULTIPLE, +} from '@/lib/growth/budget-guard' +import { FIRST_MONTH_NET_DEFAULT } from '@/lib/growth/pilot-gates' export type GuardrailEvalResult = { pass: boolean @@ -321,6 +329,209 @@ const evaluators: Record = { } return { pass: true, rule: 'requires_approval_above_spend' } }, + + /** + * Growth pilot ($5K cap) — hard org-wide spend ceiling for any step that + * increases spend. Inert unless an org explicitly sets `pilotStartDate` in + * this rule's config (there is no default pilotStartDate) — this keeps + * existing non-pilot customers unaffected. See src/lib/growth/budget-guard.ts. + */ + pilot_budget_cap: async (ctx, config) => { + if (!isSpendIncreaseTool(ctx.step)) return { pass: true, rule: 'pilot_budget_cap' } + const cfg = (config || {}) as { pilotStartDate?: string; capTotal?: number } + if (!cfg.pilotStartDate) return { pass: true, rule: 'pilot_budget_cap' } + const since = new Date(cfg.pilotStartDate) + const reports = await prisma.report.findMany({ + where: { orgId: ctx.orgId, date: { gte: since } }, + select: { spend: true }, + }) + const cumulativeSpend = reports.reduce((s, r) => s + r.spend, 0) + const result = evaluatePilotBudget({ cumulativeSpend, capTotal: cfg.capTotal }) + if (result.action === 'auto_pause') { + return { + pass: false, + rule: 'pilot_budget_cap', + reason: `pilot spend ${(result.pct * 100).toFixed(0)}% of cap: ${result.reasons.join('; ')}`, + config, + } + } + if (result.action === 'warn') { + return { + pass: true, + rule: 'pilot_budget_cap', + reason: `pilot spend ${(result.pct * 100).toFixed(0)}% of cap: ${result.reasons.join('; ')}`, + } + } + return { pass: true, rule: 'pilot_budget_cap' } + }, + + /** + * SKAN-attributed iOS install channels (meta/tiktok app_install) have + * delayed, low-trust attribution for the first 72h, and are only + * "learning phase" quality through day 7. Reject automated adjustments on + * a too-young campaign; warn (don't block) during the learning window + * unless spend is running away. + */ + skan_maturity: async (ctx, _config) => { + const skanTools = new Set([ + 'adjust_bid', + 'adjust_daily_budget', + 'adjust_targeting_geo', + 'adjust_targeting_demo', + 'enable_smart_bidding', + ]) + if (!skanTools.has(ctx.step.tool)) return { pass: true, rule: 'skan_maturity' } + const cid = (ctx.step.input as Record).campaignId + if (typeof cid !== 'string') return { pass: true, rule: 'skan_maturity' } + const campaign = await prisma.campaign.findFirst({ + where: { id: cid, orgId: ctx.orgId }, + select: { platform: true, objective: true, startDate: true }, + }) + if (!campaign) return { pass: true, rule: 'skan_maturity' } + const channel = deriveSkanChannel(campaign.platform, campaign.objective) + if (!channel) return { pass: true, rule: 'skan_maturity' } + + if (!campaign.startDate) { + return { + pass: false, + rule: 'skan_maturity', + reason: 'campaign startDate unknown — cannot verify SKAN maturity', + } + } + const ageHours = (Date.now() - campaign.startDate.getTime()) / 3_600_000 + const ageDays = ageHours / 24 + + if (ageHours < 72) { + return { + pass: false, + rule: 'skan_maturity', + reason: `campaign age ${ageHours.toFixed(1)}h < 72h — SKAN data untrusted`, + } + } + + if (ageDays <= LEARNING_PHASE_DAYS) { + const budget = await prisma.budget.findFirst({ + where: { campaignId: cid, type: 'daily' }, + orderBy: { createdAt: 'desc' }, + }) + if (!budget) { + return { + pass: false, + rule: 'skan_maturity', + reason: 'no daily budget on record to verify learning-phase spend', + } + } + const startOfToday = new Date() + startOfToday.setUTCHours(0, 0, 0, 0) + const todayReports = await prisma.report.findMany({ + where: { campaignId: cid, date: { gte: startOfToday } }, + select: { spend: true }, + }) + const spendToday = todayReports.reduce((s, r) => s + r.spend, 0) + const multiple = budget.amount > 0 ? spendToday / budget.amount : 0 + if (multiple > LEARNING_PHASE_HARD_MULTIPLE) { + return { + pass: false, + rule: 'skan_maturity', + reason: `learning-phase spend ${multiple.toFixed(1)}x daily cap > ${LEARNING_PHASE_HARD_MULTIPLE}x`, + } + } + return { + pass: true, + rule: 'skan_maturity', + reason: `campaign in learning phase (day ${ageDays.toFixed(1)}) — proceed with caution`, + } + } + + return { pass: true, rule: 'skan_maturity' } + }, + + /** + * Reject bid/budget increases whose channel's most recent CohortSnapshot + * CAC exceeds the tier ceiling (firstMonthNet × SCALE_PAYBACK_MULTIPLE). + * Not fail-closed: missing CohortSnapshot data is a "don't know", not a + * violation, so it never blocks. + */ + tier_cac_ceiling: async (ctx, config) => { + if (ctx.step.tool !== 'adjust_bid' && ctx.step.tool !== 'adjust_daily_budget') + return { pass: true, rule: 'tier_cac_ceiling' } + if (!isSpendIncreaseTool(ctx.step)) return { pass: true, rule: 'tier_cac_ceiling' } + const cfg = (config || {}) as { firstMonthNet?: number } + const cid = (ctx.step.input as Record).campaignId + if (typeof cid !== 'string') return { pass: true, rule: 'tier_cac_ceiling' } + const campaign = await prisma.campaign.findFirst({ + where: { id: cid, orgId: ctx.orgId }, + select: { platform: true, objective: true }, + }) + if (!campaign) return { pass: true, rule: 'tier_cac_ceiling' } + const channel = deriveSkanChannel(campaign.platform, campaign.objective) + if (!channel) return { pass: true, rule: 'tier_cac_ceiling' } + + const snapshot = await prisma.cohortSnapshot.findFirst({ + where: { orgId: ctx.orgId, channel }, + orderBy: { cohortDate: 'desc' }, + }) + if (!snapshot || snapshot.cac == null) { + return { + pass: true, + rule: 'tier_cac_ceiling', + reason: `no CohortSnapshot data for channel ${channel} — cannot verify CAC ceiling`, + } + } + const firstMonthNet = cfg.firstMonthNet ?? FIRST_MONTH_NET_DEFAULT + const ceiling = tierCacCeiling(firstMonthNet) + if (!withinCacCeiling(snapshot.cac, ceiling)) { + return { + pass: false, + rule: 'tier_cac_ceiling', + reason: `CAC $${snapshot.cac.toFixed(2)} > tier ceiling $${ceiling.toFixed(2)} (channel ${channel})`, + config, + } + } + return { pass: true, rule: 'tier_cac_ceiling' } + }, +} + +/** + * True for tools that increase spend, used by pilot_budget_cap and + * tier_cac_ceiling. Conservative: if a "previous" value isn't present to + * compare against, treat it as an increase. + */ +function isSpendIncreaseTool(step: ProposedDecisionStep): boolean { + const input = step.input as Record + switch (step.tool) { + case 'adjust_daily_budget': { + const next = Number(input.newDailyBudget) + const prev = Number(input.previousDailyBudget) + if (!Number.isFinite(next)) return false + if (!Number.isFinite(prev)) return true + return next > prev + } + case 'adjust_bid': { + const next = Number(input.newBidUsd) + const prev = Number(input.previousBidUsd) + if (!Number.isFinite(next)) return false + if (!Number.isFinite(prev)) return true + return next > prev + } + case 'resume_campaign': + case 'enable_smart_bidding': + case 'clone_campaign': + return true + default: + return false + } +} + +/** + * Map Campaign(platform, objective) to the growth channel taxonomy's two + * SKAN-attributed iOS channels. Returns null for anything else — those + * campaigns aren't SKAN and this rule doesn't apply. + */ +function deriveSkanChannel(platform: string, objective: string | null): 'paid_meta_ios' | 'paid_tiktok_ios' | null { + if (platform === 'meta' && objective === 'app_install') return 'paid_meta_ios' + if (platform === 'tiktok' && objective === 'app_install') return 'paid_tiktok_ios' + return null } type BuiltinDef = { rule: string; config: unknown; failClosed?: boolean } @@ -337,6 +548,9 @@ const BUILTIN_DEFAULTS: BuiltinDef[] = [ { rule: 'cooldown', config: { hours: 4 } }, { rule: 'pause_only_with_conversions', config: { minSpendThreshold: 50, minImpressionsForSignal: 2000 } }, { rule: 'max_per_day', config: { max: 20 } }, + { rule: 'pilot_budget_cap', config: {}, failClosed: true }, + { rule: 'skan_maturity', config: {}, failClosed: true }, + { rule: 'tier_cac_ceiling', config: {}, failClosed: false }, ] const FAIL_CLOSED_RULES = new Set( From 862fc02e9d2f4739c553580f0ddae5f4b7e6729f Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:35:54 +0800 Subject: [PATCH 2/7] feat: render growth snapshot into plan prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/agent/plan.ts | 4 ++++ src/lib/agent/prompts/loader.test.ts | 16 ++++++++++++++++ src/lib/agent/prompts/plan.v1.md | 20 ++++++++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/lib/agent/plan.ts b/src/lib/agent/plan.ts index c176279..bfe1ec2 100644 --- a/src/lib/agent/plan.ts +++ b/src/lib/agent/plan.ts @@ -165,6 +165,7 @@ export async function plan( TOOL_CATALOG_JSON: JSON.stringify(toolCatalogForPrompt(), null, 2), RECENT_DECISIONS_JSON: '', GUARDRAIL_HINTS: '', + GROWTH_JSON: '', CAMPAIGNS_JSON: '', }) const volatileBody = renderPrompt(split.volatileMarker, { @@ -174,6 +175,9 @@ export async function plan( snapshot.guardrailHints.length === 0 ? '(none configured — only built-in defaults apply)' : snapshot.guardrailHints.join('\n'), + GROWTH_JSON: snapshot.growth + ? JSON.stringify(snapshot.growth, null, 2) + : '(none — no growth/cohort data for this org)', CAMPAIGNS_JSON: JSON.stringify(snapshot.campaigns, null, 2), }) diff --git a/src/lib/agent/prompts/loader.test.ts b/src/lib/agent/prompts/loader.test.ts index ceb9e9b..d6c03f9 100644 --- a/src/lib/agent/prompts/loader.test.ts +++ b/src/lib/agent/prompts/loader.test.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import { describe, it, expect } from 'vitest' import { orgBucket, renderPrompt } from './loader' @@ -38,6 +39,21 @@ describe('orgBucket', () => { }) }) +describe('plan.v1.md template shape', () => { + // plan.ts splits the template on this marker for prompt caching: everything + // before it is the stable (cached) half and gets empty vars. Volatile data + // slots must therefore sit AFTER the marker or they silently render empty. + it('keeps volatile placeholders after the cache-split marker', async () => { + const template = await readFile(new URL('./plan.v1.md', import.meta.url), 'utf-8') + const splitIdx = template.indexOf('## Recent decisions') + expect(splitIdx).toBeGreaterThan(0) + for (const slot of ['{{RECENT_DECISIONS_JSON}}', '{{GUARDRAIL_HINTS}}', '{{GROWTH_JSON}}', '{{CAMPAIGNS_JSON}}']) { + expect(template.indexOf(slot)).toBeGreaterThan(splitIdx) + } + expect(template.indexOf('{{TOOL_CATALOG_JSON}}')).toBeLessThan(splitIdx) + }) +}) + describe('renderPrompt', () => { it('substitutes {{KEY}} placeholders', () => { expect(renderPrompt('Hello {{NAME}}!', { NAME: 'world' })).toBe('Hello world!') diff --git a/src/lib/agent/prompts/plan.v1.md b/src/lib/agent/prompts/plan.v1.md index e5ab023..9bcae09 100644 --- a/src/lib/agent/prompts/plan.v1.md +++ b/src/lib/agent/prompts/plan.v1.md @@ -1,9 +1,11 @@ You are Adex Agent, an autonomous ad-operations assistant. ## Your job + Look at recent campaign performance and decide whether to take any actions. You may propose 0–5 decisions per cycle. **Quality over quantity.** A single well-justified decision beats five guesses. ## Hard rules + - You may ONLY call tools from the catalog below. Inventing tool names is a critical failure. - You MUST return strict JSON matching the schema at the bottom — no prose, no markdown, no comments. - For every step, include both the tool name and a short `reason` explaining *why this campaign, why this tool, why now*. @@ -13,6 +15,7 @@ Look at recent campaign performance and decide whether to take any actions. You - Never reference campaigns or ad-groups not present in the perceive context — IDs are validated server-side. ## How to think (very brief) + 1. **Skim** the campaign list. What's burning money with no return? What's outperforming? 2. **Pick at most 1–3 issues** that justify acting RIGHT NOW. Most cycles will have nothing to do. 3. **Pick the smallest reversible step** that addresses each issue. @@ -20,27 +23,40 @@ Look at recent campaign performance and decide whether to take any actions. You 5. **Use `noop`** when nothing rises to the level of action. ## Severity + - `info` — informational only, no action recommended (`noop`) - `opportunity` — favorable trend, scale up - `warning` — degrading metric, intervene before it gets worse - `alert` — active waste / outage; stop the bleeding ## Tool catalog + {{TOOL_CATALOG_JSON}} ## Recent decisions (for short-term memory; do not repeat) + {{RECENT_DECISIONS_JSON}} ## Active guardrails (advisory hints — server enforces hard limits) + {{GUARDRAIL_HINTS}} +## Growth funnel & pilot status (per-channel gates; `(none)` for non-growth orgs) + +{{GROWTH_JSON}} + +- `channels[].gate` is pilot discipline: `kill`/`halve` → only downward steps (pause, budget/bid cuts) for that channel; `scale` → scaling still requires a human decision, propose it via `propose_paid_gate_change`, never raise budgets/bids directly. +- If `budget.action` is not `ok`, do not propose any step that increases spend on any paid channel. + ## Campaigns (last 7d + last 24h) + {{CAMPAIGNS_JSON}} ## Output schema + Return JSON of this exact shape: -``` +```json { "decisions": [ { @@ -56,6 +72,6 @@ Return JSON of this exact shape: If nothing to do: -``` +```json { "decisions": [{ "rationale": "Healthy", "severity": "info", "steps": [{ "tool": "noop", "input": {}, "reason": "All campaigns within targets" }] }] } ``` From 14241bf91224ffa72b4a9b29e96764175e320acf Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:35:54 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs(platform):=20mmp=20ingest=20guide=20?= =?UTF-8?q?=E2=80=94=20three=20holes=20before=20adjust/appsflyer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/growth/06-mmp-ingest.md | 52 ++++++++++++++++++++++++++++++++++ src/lib/platforms/adjust.ts | 7 +++++ src/lib/platforms/appsflyer.ts | 5 ++++ 3 files changed, 64 insertions(+) create mode 100644 docs/growth/06-mmp-ingest.md diff --git a/docs/growth/06-mmp-ingest.md b/docs/growth/06-mmp-ingest.md new file mode 100644 index 0000000..2567340 --- /dev/null +++ b/docs/growth/06-mmp-ingest.md @@ -0,0 +1,52 @@ +# MMP(Adjust / AppsFlyer)接入新数据链路指南 + +> 版本 v1 · 2026-07-07 · 结论先行:**MMP 走 S2S callback → ConversionEvent;接之前必须先堵三个洞,否则 GA4 + MMP 双开会让 install 翻倍、CAC 腰斩,且现有测试测不出来。** + +## 0. 现状:MMP 只喂旧链路 + +`src/lib/platforms/adjust.ts` / `appsflyer.ts` 目前只做一件事:`reports/sync` 拉 app 级日聚合(Adjust 维度仅 `day,app`,无渠道拆分)→ 写 `Report(level=account)` → `/dashboard` 展示。**这条旧链路保留不动**——它是"MMP 原生总量视图",与新链路(ConversionEvent → CohortSnapshot → `/growth` + agent perceive)物理隔离(读写零交集),二者数字对不上是口径差异,不是 bug,不要试图对齐。 + +真正的冲突全在新链路内部:让 MMP 往 ConversionEvent 写数据时,它会和 GA4 在同一张 CohortSnapshot 里打架。 + +## 1. 三个必堵的洞(接入前置,缺一不可) + +| # | 洞 | 成因 | 后果 | 堵法 | +| --- | --- | --- | --- | --- | +| 1 | **双源 install 重复计数** | 幂等键 `@@unique([orgId, source, eventName, userKey, occurredAt])` 把 `source` 算进唯一约束(GA4/Adjust 各落一行),而 `cohorts.ts` 聚合完全不感知 source、只按 userKey 分组累加 | installs 翻倍 → CAC 腰斩 → gate 判定(放量/CAC 上限)资损级误判 | `RawEvent` 加 `source` 字段;cohort 聚合前按 org 配置的 install 权威源单选(见 §2 决策 A) | +| 2 | **channel 归一化失配** | `channels.ts:resolveChannel` 只认 `adex_*` UTM 和 kol/seo 等裸码;MMP 的 network 名("Apple Search Ads"、"Meta Installs")全部兜底成 `organic` | 同一渠道在 CohortSnapshot 裂成两行(如 ASA 裂成 `organic` + `paid_asa`),看板和 gate 各算各的 | 新增 `ADJUST_NETWORK_MAP` 显式映射表 + `resolveAdjustChannel()`;**不要扩 `resolveChannel`**(它服务自有 UTM 主路径) | +| 3 | **userKey 跨源断裂** | 留存 D1/D7 = "同一 userKey 在 +1/+7 有 GA4 行为事件";RC 收入也靠 userKey join。MMP 的 adid/idfa 与 GA4 pseudo_id、RC app_user_id 是不同命名空间 | MMP 渠道的 cohort **留存和 LTV 假性归零**;同人被当两个 user 重复计 install | 首选:callback 透传 RC `app_user_id` 统一 userKey(需客户侧配置,Adjust partner parameter 支持);降级:userKey 加 `adjust:` 前缀、承认跨源不 join(见 §2 决策 B) | + +## 2. 两个先拍板的口径决策(代码之前) + +- **决策 A · install 权威源**:接了 MMP 的 org,install 与渠道归因以谁为准?推荐:**MMP 为 install 权威,GA4 只供 first_chat / scene_generated 等 MMP 不报的漏斗深层事件**。定了写进 `kpi-canon.ts`(如 `installAuthority` 配置)并在本文件记录。 +- **决策 B · userKey 统一方式**:能否让客户在 MMP callback 里透传 RC app_user_id?能 → 全链路统一;不能 → namespace 前缀方案,此时留存/LTV 仍以 GA4/RC 体系计算,MMP 只贡献 install 计数与渠道校正(注意留存分子分母必须同 userKey 体系,否则错配)。 + +## 3. 接入步骤(Adjust,S2S callback 方案) + +**不推荐**用 Report Service API 拉聚合造合成事件——聚合行没有 userKey,进不了 cohort。 + +1. **堵洞(代码前置)** + - `src/lib/growth/cohorts.ts`:`RawEvent` 加 `source`,聚合按决策 A 单源过滤 install 类事件;`growth-sync` cron 的查询同步加 `source` select + - `src/lib/growth/channels.ts`:加 `ADJUST_NETWORK_MAP: Record` + `resolveAdjustChannel(networkName, campaignName)`(映射表按客户实际 network 命名维护) + - 新建 `src/lib/growth/adjust-ingest.ts`:`mapAdjustCallback(payload) → ConversionEventInput` 纯函数,照 `ga4.ts:mapGa4Event` 的写法;Adjust `activity_kind`(install/event/reattribution)+ 自定义 event token → canonical `EventName`(`events.ts` 闭集白名单,透传会被 drop) + - 测试必须含**双源用例**:同一 userKey 的 GA4+Adjust install 经聚合后不翻倍(现有 cohorts.test.ts 全是单源,这正是洞 1 测不出来的原因) +2. **鉴权路由**:Adjust callback 是 URL 模板,算不了 per-request HMAC,两选一: + - 新建 `POST /api/ingest/adjust?org=`,静态密钥 + `verifyBearer`(完整模仿 `src/app/api/ingest/revenuecat/route.ts`,密钥存 `PlatformAuth(platform='ingest')`;Adjust 侧密钥放 callback URL 查询参数,路由内 `verifyBearer` 常时比较) + - 或客户后端收 callback 后签 HMAC 转发到现有 `/api/ingest/events`(复用全部现有鉴权,代价是多一跳客户侧工程) +3. **Adjust 后台配置**:Raw Data Exports → Real-time callbacks,install + 关键 event 各配一条,URL 带 placeholder:`{activity_kind}`、`{network_name}`、`{campaign_name}`、`{adid}`、`{idfa}`、`{created_at}`、`{country}`,及透传的 partner parameter(决策 B 的 app_user_id) +4. **验证清单**:同一 callback 重放两次只落一行(幂等键);ASA/Meta 的 network 名映射进正确 channel(不落 organic);GA4+Adjust 双开的 org 在 `/growth` 的 installs 与单开 MMP 一致;`/dashboard` 的 Report.adjust 总量不受影响 + +## 4. AppsFlyer 附注 + +同构接入:Push API(S2S 实时回传)对应 Adjust callback,`pid/c` 字段对应 `network_name/campaign_name`(需 `APPSFLYER_NETWORK_MAP`),设备标识 `appsflyer_id` 同样是独立命名空间——三个洞和两个决策**一字不差地适用**。旧链路 `getInstallReport`(`groupings=date,pid`)同样保留不动。 + +## 5. 涉及文件速查 + +| 动作 | 文件 | +| --- | --- | +| 新建 mapper | `src/lib/growth/adjust-ingest.ts`(+测试) | +| 新建路由(方案一) | `src/app/api/ingest/adjust/route.ts` | +| 改:channel 映射 | `src/lib/growth/channels.ts` | +| 改:单源聚合 | `src/lib/growth/cohorts.ts`、`src/app/api/cron/growth-sync/route.ts` | +| 改:口径记录 | `src/lib/growth/kpi-canon.ts`、本文件 §2 | +| 不动 | `src/lib/platforms/adjust.ts` / `appsflyer.ts`、`reports/sync`、`/api/ingest/events` 鉴权、`ingest-parse.ts`(`source='adjust'` 已注册) | diff --git a/src/lib/platforms/adjust.ts b/src/lib/platforms/adjust.ts index f66707b..0f7dfb3 100644 --- a/src/lib/platforms/adjust.ts +++ b/src/lib/platforms/adjust.ts @@ -1,3 +1,10 @@ +/** + * Legacy Report-only path: app-level daily aggregates (no channel dimension) + * into `Report` for /dashboard. Do NOT feed this into ConversionEvent — for + * cohort-grade ingestion (S2S callbacks, channel mapping, install-source + * authority) see docs/growth/06-mmp-ingest.md first; wiring Adjust into the + * growth pipeline without those prerequisites double-counts installs. + */ export interface AdjustConfig { apiToken: string appToken: string diff --git a/src/lib/platforms/appsflyer.ts b/src/lib/platforms/appsflyer.ts index f1fd420..7842167 100644 --- a/src/lib/platforms/appsflyer.ts +++ b/src/lib/platforms/appsflyer.ts @@ -1,3 +1,8 @@ +/** + * Legacy Report-only path: date×pid aggregates into `Report` for /dashboard. + * Do NOT feed this into ConversionEvent — for cohort-grade ingestion the same + * prerequisites as Adjust apply (docs/growth/06-mmp-ingest.md §4). + */ export interface AppsFlyerConfig { apiToken: string appId: string From 71400619096260d60bb3d18b0acaab86376912cf Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:28:32 +0800 Subject: [PATCH 4/7] feat: adjust s2s ingest with single install-source authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/growth/06-mmp-ingest.md | 40 ++++++------ src/app/api/cron/growth-sync/route.ts | 37 +++++++++-- src/app/api/ingest/adjust/route.ts | 93 +++++++++++++++++++++++++++ src/lib/growth/adjust-ingest.test.ts | 82 +++++++++++++++++++++++ src/lib/growth/adjust-ingest.ts | 80 +++++++++++++++++++++++ src/lib/growth/channels.test.ts | 42 +++++++++++- src/lib/growth/channels.ts | 62 ++++++++++++++++++ src/lib/growth/cohorts.test.ts | 55 +++++++++++++++- src/lib/growth/cohorts.ts | 18 +++++- src/lib/growth/kpi-canon.test.ts | 30 +++++++++ src/lib/growth/kpi-canon.ts | 58 +++++++++++++++++ 11 files changed, 568 insertions(+), 29 deletions(-) create mode 100644 src/app/api/ingest/adjust/route.ts create mode 100644 src/lib/growth/adjust-ingest.test.ts create mode 100644 src/lib/growth/adjust-ingest.ts diff --git a/docs/growth/06-mmp-ingest.md b/docs/growth/06-mmp-ingest.md index 2567340..b0b37b4 100644 --- a/docs/growth/06-mmp-ingest.md +++ b/docs/growth/06-mmp-ingest.md @@ -16,37 +16,37 @@ | 2 | **channel 归一化失配** | `channels.ts:resolveChannel` 只认 `adex_*` UTM 和 kol/seo 等裸码;MMP 的 network 名("Apple Search Ads"、"Meta Installs")全部兜底成 `organic` | 同一渠道在 CohortSnapshot 裂成两行(如 ASA 裂成 `organic` + `paid_asa`),看板和 gate 各算各的 | 新增 `ADJUST_NETWORK_MAP` 显式映射表 + `resolveAdjustChannel()`;**不要扩 `resolveChannel`**(它服务自有 UTM 主路径) | | 3 | **userKey 跨源断裂** | 留存 D1/D7 = "同一 userKey 在 +1/+7 有 GA4 行为事件";RC 收入也靠 userKey join。MMP 的 adid/idfa 与 GA4 pseudo_id、RC app_user_id 是不同命名空间 | MMP 渠道的 cohort **留存和 LTV 假性归零**;同人被当两个 user 重复计 install | 首选:callback 透传 RC `app_user_id` 统一 userKey(需客户侧配置,Adjust partner parameter 支持);降级:userKey 加 `adjust:` 前缀、承认跨源不 join(见 §2 决策 B) | -## 2. 两个先拍板的口径决策(代码之前) +## 2. 两个口径决策(已拍板) -- **决策 A · install 权威源**:接了 MMP 的 org,install 与渠道归因以谁为准?推荐:**MMP 为 install 权威,GA4 只供 first_chat / scene_generated 等 MMP 不报的漏斗深层事件**。定了写进 `kpi-canon.ts`(如 `installAuthority` 配置)并在本文件记录。 -- **决策 B · userKey 统一方式**:能否让客户在 MMP callback 里透传 RC app_user_id?能 → 全链路统一;不能 → namespace 前缀方案,此时留存/LTV 仍以 GA4/RC 体系计算,MMP 只贡献 install 计数与渠道校正(注意留存分子分母必须同 userKey 体系,否则错配)。 +- **决策 A · install 权威源**(已拍板):接了 Adjust 的 org,install 与渠道归因以 `source='adjust'` 为权威;GA4 只供 first_chat / scene_generated 等 Adjust 不报的漏斗深层事件。实现:`kpi-canon.ts:resolveInstallAuthority({ hasAdjustAuth, adjustInstallCount, ga4InstallCount })`——org 存在 `PlatformAuth(platform='adjust')` → `'adjust'`,否则 `'ga4'`。**防归零保险**:若权威源在计算窗口内 install 数为 0 而另一源 > 0(例如 org 只配了 legacy Report 凭证、没接 `/api/ingest/adjust` callback),本次计算回退另一源,并在返回值 `fallback`/`warning` 字段标注,`growth-sync` cron 会把 warning 打进日志。`cohorts.ts:buildCohortSnapshots` 的 `opts.installAuthority` 据此只过滤 ACQUISITION 类事件(install/signup)的来源;漏斗深层/收入事件不受影响。 +- **决策 B · userKey 统一方式**(已拍板):userKey 首选 Adjust callback 透传的 RC `app_user_id`(partner parameter,需客户侧在 Adjust 后台配置);取不到时降级为 `adjust:${adid}` 前缀,明确承认这条 userKey 跨源不可 join——留存/LTV 仍以 GA4/RC 体系计算,Adjust 只贡献 install 计数与渠道校正。实现见 `adjust-ingest.ts:mapAdjustCallback`。 ## 3. 接入步骤(Adjust,S2S callback 方案) **不推荐**用 Report Service API 拉聚合造合成事件——聚合行没有 userKey,进不了 cohort。 -1. **堵洞(代码前置)** - - `src/lib/growth/cohorts.ts`:`RawEvent` 加 `source`,聚合按决策 A 单源过滤 install 类事件;`growth-sync` cron 的查询同步加 `source` select - - `src/lib/growth/channels.ts`:加 `ADJUST_NETWORK_MAP: Record` + `resolveAdjustChannel(networkName, campaignName)`(映射表按客户实际 network 命名维护) - - 新建 `src/lib/growth/adjust-ingest.ts`:`mapAdjustCallback(payload) → ConversionEventInput` 纯函数,照 `ga4.ts:mapGa4Event` 的写法;Adjust `activity_kind`(install/event/reattribution)+ 自定义 event token → canonical `EventName`(`events.ts` 闭集白名单,透传会被 drop) - - 测试必须含**双源用例**:同一 userKey 的 GA4+Adjust install 经聚合后不翻倍(现有 cohorts.test.ts 全是单源,这正是洞 1 测不出来的原因) -2. **鉴权路由**:Adjust callback 是 URL 模板,算不了 per-request HMAC,两选一: - - 新建 `POST /api/ingest/adjust?org=`,静态密钥 + `verifyBearer`(完整模仿 `src/app/api/ingest/revenuecat/route.ts`,密钥存 `PlatformAuth(platform='ingest')`;Adjust 侧密钥放 callback URL 查询参数,路由内 `verifyBearer` 常时比较) - - 或客户后端收 callback 后签 HMAC 转发到现有 `/api/ingest/events`(复用全部现有鉴权,代价是多一跳客户侧工程) +1. **堵洞(代码前置)**——已完成 + - `src/lib/growth/cohorts.ts`:`RawEvent` 加 `source`;`buildCohortSnapshots(events, { installAuthority })` 聚合前按决策 A 单源过滤 ACQUISITION 类事件(install/signup),漏斗深层/收入事件不过滤;`growth-sync` cron 查询同步加 `source` select + - `src/lib/growth/channels.ts`:新增 `ADJUST_NETWORK_MAP: Record` + `resolveAdjustChannel(networkName, campaignName)`。ASA 确定性映射;Meta/TikTok 的 install network 名(`Facebook Installs` / `TikTok Installs` 等)保守映射到 `*_ios`(SKAN,低置信度)——Adjust 的 network_name 分不清 web 与 app-install,`campaign_name` 含 "web" 时才改判 `*_web`;未映射网络名 → `organic`/`inferred`。未改动 `resolveChannel` 一行 + - `src/lib/growth/adjust-ingest.ts`(新建 + 测试):`mapAdjustCallback(params, eventTokenMap?) → ConversionEventInput | null`,照 `ga4.ts:mapGa4Event` 的写法;`activity_kind='install'` → `install`;`='event'` 时查 `eventTokenMap`(event token → canonical `EventName`,映射不到 → drop);`reattribution`/`session` 等 → drop(返回 null,不抛错) + - 测试含**双源用例**(`cohorts.test.ts`):同一真实安装的 GA4 + Adjust 两行(不同 userKey namespace)不设权威时 installs=2(复现洞 1);设 `installAuthority: 'adjust'` 后 installs=1 且渠道以 Adjust 行为准;另有用例确认 installAuthority 不过滤漏斗深层事件 +2. **鉴权路由**——已完成,选定方案一 + - `GET|POST /api/ingest/adjust?org=`(`src/app/api/ingest/adjust/route.ts`),静态密钥 + `verifyBearer`(完整仿 `src/app/api/ingest/revenuecat/route.ts`,密钥存 `PlatformAuth(orgId, platform='ingest').apiKey`,env `INGEST_WEBHOOK_SECRET` 兜底);Adjust 侧密钥支持 `Authorization: Bearer` 或 `?token=` query(callback 是 URL 模板,设不了自定义 header);单条映射失败跳过(200 ok, ignored),不 5xx;写入沿用 `createMany` + `skipDuplicates` 幂等 3. **Adjust 后台配置**:Raw Data Exports → Real-time callbacks,install + 关键 event 各配一条,URL 带 placeholder:`{activity_kind}`、`{network_name}`、`{campaign_name}`、`{adid}`、`{idfa}`、`{created_at}`、`{country}`,及透传的 partner parameter(决策 B 的 app_user_id) 4. **验证清单**:同一 callback 重放两次只落一行(幂等键);ASA/Meta 的 network 名映射进正确 channel(不落 organic);GA4+Adjust 双开的 org 在 `/growth` 的 installs 与单开 MMP 一致;`/dashboard` 的 Report.adjust 总量不受影响 ## 4. AppsFlyer 附注 -同构接入:Push API(S2S 实时回传)对应 Adjust callback,`pid/c` 字段对应 `network_name/campaign_name`(需 `APPSFLYER_NETWORK_MAP`),设备标识 `appsflyer_id` 同样是独立命名空间——三个洞和两个决策**一字不差地适用**。旧链路 `getInstallReport`(`groupings=date,pid`)同样保留不动。 +同构接入:Push API(S2S 实时回传)对应 Adjust callback,`pid/c` 字段对应 `network_name/campaign_name`(需 `APPSFLYER_NETWORK_MAP`),设备标识 `appsflyer_id` 同样是独立命名空间——三个洞和两个决策**一字不差地适用**。旧链路 `getInstallReport`(`groupings=date,pid`)同样保留不动。AppsFlyer 接入尚未实现,本节仍是规划。 ## 5. 涉及文件速查 -| 动作 | 文件 | -| --- | --- | -| 新建 mapper | `src/lib/growth/adjust-ingest.ts`(+测试) | -| 新建路由(方案一) | `src/app/api/ingest/adjust/route.ts` | -| 改:channel 映射 | `src/lib/growth/channels.ts` | -| 改:单源聚合 | `src/lib/growth/cohorts.ts`、`src/app/api/cron/growth-sync/route.ts` | -| 改:口径记录 | `src/lib/growth/kpi-canon.ts`、本文件 §2 | -| 不动 | `src/lib/platforms/adjust.ts` / `appsflyer.ts`、`reports/sync`、`/api/ingest/events` 鉴权、`ingest-parse.ts`(`source='adjust'` 已注册) | +| 动作 | 文件 | 状态 | +| --- | --- | --- | +| 新建 mapper | `src/lib/growth/adjust-ingest.ts`(+ `adjust-ingest.test.ts`) | 已完成 | +| 新建路由 | `src/app/api/ingest/adjust/route.ts`(GET+POST) | 已完成 | +| 改:channel 映射 | `src/lib/growth/channels.ts`(`ADJUST_NETWORK_MAP` / `resolveAdjustChannel`,+ 测试) | 已完成 | +| 改:单源聚合 | `src/lib/growth/cohorts.ts`、`src/app/api/cron/growth-sync/route.ts`(+ 测试) | 已完成 | +| 改:口径记录 | `src/lib/growth/kpi-canon.ts`(`resolveInstallAuthority`,+ 测试)、本文件 §2 | 已完成 | +| 不动 | `src/lib/platforms/adjust.ts` / `appsflyer.ts`、`reports/sync`、`/api/ingest/events` 鉴权、`ingest-parse.ts`(`source='adjust'` 已注册) | 按计划不动 | +| 未实现 | AppsFlyer S2S 接入(§4 仍是规划,非本次范围) | 待办 | diff --git a/src/app/api/cron/growth-sync/route.ts b/src/app/api/cron/growth-sync/route.ts index deb7176..4edcf7e 100644 --- a/src/app/api/cron/growth-sync/route.ts +++ b/src/app/api/cron/growth-sync/route.ts @@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/cron-auth' import { prisma } from '@/lib/prisma' import { buildCohortSnapshots, type RawEvent } from '@/lib/growth/cohorts' -import { EVENTS, type EventName } from '@/lib/growth/events' +import { EVENTS, SOURCES, type EventName } from '@/lib/growth/events' +import { resolveInstallAuthority } from '@/lib/growth/kpi-canon' /** * POST /api/cron/growth-sync @@ -28,12 +29,12 @@ export async function POST(req: NextRequest) { const cutoff = new Date(Date.now() - WINDOW_DAYS * 86_400_000) const orgs = await prisma.organization.findMany({ select: { id: true } }) - const summary: Array<{ orgId: string; cohorts: number; events: number }> = [] + const summary: Array<{ orgId: string; cohorts: number; events: number; installAuthority?: string; installAuthorityWarning?: string }> = [] for (const org of orgs) { const rows = await prisma.conversionEvent.findMany({ where: { orgId: org.id, occurredAt: { gte: cutoff } }, - select: { eventName: true, occurredAt: true, userKey: true, channel: true, revenue: true }, + select: { eventName: true, occurredAt: true, userKey: true, channel: true, revenue: true, source: true }, }) const events: RawEvent[] = rows @@ -44,9 +45,29 @@ export async function POST(req: NextRequest) { userKey: r.userKey, channel: r.channel, revenue: r.revenue, + source: r.source, })) - const cohorts = buildCohortSnapshots(events) + // Install authority (decision A, docs/growth/06-mmp-ingest.md §2): if the + // org has Adjust wired, Adjust's install events are authoritative and the + // GA4 install rows for the same window are excluded from cohort placement + // (see cohorts.ts). The anti-zeroing guard in resolveInstallAuthority falls + // back to GA4 if Adjust is configured but reported 0 installs this window. + const hasAdjustAuth = await prisma.platformAuth.findUnique({ + where: { orgId_platform: { orgId: org.id, platform: 'adjust' } }, + }).then((a) => !!a?.isActive).catch(() => false) + const adjustInstallCount = events.filter((e) => e.eventName === EVENTS.INSTALL && e.source === SOURCES.ADJUST).length + const ga4InstallCount = events.filter((e) => e.eventName === EVENTS.INSTALL && e.source === SOURCES.GA4).length + const installAuthorityResult = resolveInstallAuthority({ + hasAdjustAuth, + adjustInstallCount, + ga4InstallCount, + }) + if (installAuthorityResult.warning) { + console.warn(`[growth-sync] org=${org.id} ${installAuthorityResult.warning}`) + } + + const cohorts = buildCohortSnapshots(events, { installAuthority: installAuthorityResult.authority }) // Idempotent recompute: clear the window, then insert fresh. await prisma.cohortSnapshot.deleteMany({ @@ -72,7 +93,13 @@ export async function POST(req: NextRequest) { }) } - summary.push({ orgId: org.id, cohorts: cohorts.length, events: events.length }) + summary.push({ + orgId: org.id, + cohorts: cohorts.length, + events: events.length, + installAuthority: installAuthorityResult.authority, + ...(installAuthorityResult.warning ? { installAuthorityWarning: installAuthorityResult.warning } : {}), + }) } return NextResponse.json({ ok: true, window: `${WINDOW_DAYS}d`, orgs: summary }) diff --git a/src/app/api/ingest/adjust/route.ts b/src/app/api/ingest/adjust/route.ts new file mode 100644 index 0000000..d00d67a --- /dev/null +++ b/src/app/api/ingest/adjust/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyBearer, readBearer } from '@/lib/growth/ingest-auth' +import { mapAdjustCallback, type AdjustCallbackParams } from '@/lib/growth/adjust-ingest' + +/** + * GET|POST /api/ingest/adjust?org=&token= + * + * Receives an Adjust real-time S2S callback (docs/growth/06-mmp-ingest.md §3) + * and writes a normalized ConversionEvent. Adjust's callback is a URL template + * it expands itself — it can't sign a per-request HMAC and typically fires as + * a GET, so auth is a static secret compared constant-time, same as + * /api/ingest/revenuecat, except the secret may arrive via `?token=` (Adjust + * can't set custom headers) in addition to `Authorization: Bearer`. + * + * Auth: PlatformAuth(orgId, platform='ingest').apiKey, falling back to the + * INGEST_WEBHOOK_SECRET env var (mirrors /api/ingest/events). + * + * A single callback maps to at most one event; unmapped activity_kind / + * event_token combinations are dropped (200 ok, not written) so Adjust never + * sees a 4xx/5xx and retries. Idempotent via the ConversionEvent unique key. + */ +export async function GET(req: NextRequest) { + return handle(req) +} + +export async function POST(req: NextRequest) { + return handle(req) +} + +async function handle(req: NextRequest) { + const url = new URL(req.url) + const orgId = url.searchParams.get('org') + if (!orgId) { + return NextResponse.json({ error: 'missing ?org' }, { status: 400 }) + } + + let expected: string | undefined = process.env.INGEST_WEBHOOK_SECRET + try { + const auth = await prisma.platformAuth.findUnique({ + where: { orgId_platform: { orgId, platform: 'ingest' } }, + }) + if (auth?.apiKey) expected = auth.apiKey + } catch { + // fall through to env fallback + } + + const provided = readBearer(req.headers.get('authorization')) ?? url.searchParams.get('token') + if (!verifyBearer(provided, expected)) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + const params: AdjustCallbackParams = Object.fromEntries(url.searchParams.entries()) + // Never persist the auth secret into ConversionEvent.raw. + delete params.token + if (req.method === 'POST') { + try { + const form = await req.formData() + for (const [k, v] of form.entries()) { + if (typeof v === 'string') params[k] = v + } + } catch { + // no form body — GET-style query params only, that's fine + } + } + + const mapped = mapAdjustCallback(params) + if (!mapped) { + // Not funnel-relevant (reattribution/session, unmapped event token). Ack + // so Adjust doesn't retry. + return NextResponse.json({ ok: true, ignored: true }) + } + + const result = await prisma.conversionEvent.createMany({ + data: [ + { + orgId, + source: mapped.source, + eventName: mapped.eventName, + occurredAt: mapped.occurredAt, + userKey: mapped.userKey ?? null, + utmCampaign: mapped.utmCampaign ?? null, + channel: mapped.channel ?? null, + country: mapped.country ?? null, + revenue: mapped.revenue ?? 0, + raw: JSON.stringify(mapped.raw ?? params), + }, + ], + skipDuplicates: true, + }) + + return NextResponse.json({ ok: true, written: result.count }) +} diff --git a/src/lib/growth/adjust-ingest.test.ts b/src/lib/growth/adjust-ingest.test.ts new file mode 100644 index 0000000..7476ae1 --- /dev/null +++ b/src/lib/growth/adjust-ingest.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { mapAdjustCallback } from './adjust-ingest' +import { EVENTS, SOURCES } from './events' +import { CHANNELS } from './channels' + +const createdAt = '1751500800' // 2025-07-03T00:00:00Z + +describe('mapAdjustCallback', () => { + it('maps an install activity_kind to install', () => { + const r = mapAdjustCallback({ + activity_kind: 'install', + network_name: 'Apple Search Ads', + adid: 'abc123', + created_at: createdAt, + country: 'US', + }) + expect(r).toMatchObject({ + source: SOURCES.ADJUST, + eventName: EVENTS.INSTALL, + userKey: 'adjust:abc123', + channel: CHANNELS.PAID_ASA, + country: 'US', + }) + expect(r?.occurredAt.getTime()).toBe(Number(createdAt) * 1000) + }) + + it('prefers the transmitted RC app_user_id over the adid namespace fallback (decision B)', () => { + const r = mapAdjustCallback({ + activity_kind: 'install', + adid: 'abc123', + app_user_id: 'rc-user-1', + created_at: createdAt, + }) + expect(r?.userKey).toBe('rc-user-1') + }) + + it('maps a mapped event_token via the caller-supplied map', () => { + const r = mapAdjustCallback( + { activity_kind: 'event', event_token: 'abc1', created_at: createdAt, adid: 'x1' }, + { abc1: EVENTS.FIRST_CHAT }, + ) + expect(r?.eventName).toBe(EVENTS.FIRST_CHAT) + }) + + it('drops an event activity_kind with an unmapped token', () => { + const r = mapAdjustCallback( + { activity_kind: 'event', event_token: 'unknown', created_at: createdAt, adid: 'x1' }, + { abc1: EVENTS.FIRST_CHAT }, + ) + expect(r).toBeNull() + }) + + it('drops event without an event_token map at all', () => { + expect(mapAdjustCallback({ activity_kind: 'event', event_token: 'abc1', created_at: createdAt })).toBeNull() + }) + + it('drops reattribution and session activity_kinds', () => { + expect(mapAdjustCallback({ activity_kind: 'reattribution', created_at: createdAt })).toBeNull() + expect(mapAdjustCallback({ activity_kind: 'session', created_at: createdAt })).toBeNull() + }) + + it('drops when created_at is missing or unparseable', () => { + expect(mapAdjustCallback({ activity_kind: 'install', adid: 'x1' })).toBeNull() + expect(mapAdjustCallback({ activity_kind: 'install', adid: 'x1', created_at: 'not-a-number' })).toBeNull() + }) + + it('falls back to null userKey when neither app_user_id nor adid is present', () => { + const r = mapAdjustCallback({ activity_kind: 'install', created_at: createdAt }) + expect(r?.userKey).toBeNull() + }) + + it('resolves channel via resolveAdjustChannel, defaulting unmapped networks to organic', () => { + const r = mapAdjustCallback({ activity_kind: 'install', created_at: createdAt, network_name: 'Some Ad Network' }) + expect(r?.channel).toBe(CHANNELS.ORGANIC) + }) + + it('preserves raw params', () => { + const params = { activity_kind: 'install', created_at: createdAt, adid: 'x1' } + const r = mapAdjustCallback(params) + expect(r?.raw).toBe(params) + }) +}) diff --git a/src/lib/growth/adjust-ingest.ts b/src/lib/growth/adjust-ingest.ts new file mode 100644 index 0000000..2db765b --- /dev/null +++ b/src/lib/growth/adjust-ingest.ts @@ -0,0 +1,80 @@ +/** + * Adjust S2S callback → ConversionEvent mapping (pure). + * + * Adjust's real-time callback is a URL template Adjust expands per-event and + * GETs/POSTs to us — see docs/growth/06-mmp-ingest.md §3. Only `install` and + * mapped `event` activity kinds are funnel-relevant; everything else + * (`reattribution`, `session`, unmapped event tokens) is dropped, not thrown, + * so a misconfigured callback never 500s the route. + * + * userKey (decision B, 06-mmp-ingest.md §2): prefer the RC `app_user_id` + * transmitted as an Adjust partner parameter (customer must configure this in + * their Adjust dashboard so it's forwarded on the callback). If absent, we + * fall back to `adjust:${adid}` — a namespaced id that intentionally does NOT + * join with GA4 pseudo ids or RC app_user_ids. Cohort-level consequences of + * that downgrade are documented in cohorts.ts / kpi-canon.ts. + * + * Ref: docs/growth/06-mmp-ingest.md §1 hole 3, §2 decision B, §3 + */ + +import { EVENTS, SOURCES, type ConversionEventInput, type EventName } from './events' +import { resolveAdjustChannel } from './channels' + +/** Adjust `event_token` → our canonical EventName. Unmapped tokens are dropped. */ +export type AdjustEventTokenMap = Record + +export interface AdjustCallbackParams { + activity_kind?: string + event_token?: string + network_name?: string + campaign_name?: string + adid?: string + /** unix seconds, as Adjust sends it. */ + created_at?: string + country?: string + /** RC app_user_id transmitted as a partner parameter (decision B). */ + app_user_id?: string + [key: string]: string | undefined +} + +/** + * Map one Adjust S2S callback request to a normalized ConversionEventInput, or + * null if it isn't funnel-relevant (unmapped activity_kind / event_token, + * missing timestamp). + */ +export function mapAdjustCallback( + params: AdjustCallbackParams, + eventTokenMap: AdjustEventTokenMap = {}, +): ConversionEventInput | null { + const activityKind = params.activity_kind?.trim().toLowerCase() + + let eventName: EventName | undefined + if (activityKind === 'install') { + eventName = EVENTS.INSTALL + } else if (activityKind === 'event') { + const token = params.event_token + eventName = token ? eventTokenMap[token] : undefined + } + // 'reattribution', 'session', and anything else are not in our funnel + // vocabulary — drop, don't guess. + if (!eventName) return null + + const createdAtSec = Number(params.created_at) + if (!Number.isFinite(createdAtSec) || createdAtSec <= 0) return null + + const userKey = params.app_user_id?.trim() || (params.adid ? `adjust:${params.adid}` : null) + + const { channel } = resolveAdjustChannel(params.network_name, params.campaign_name) + + return { + source: SOURCES.ADJUST, + eventName, + occurredAt: new Date(createdAtSec * 1000), + userKey, + utmCampaign: params.campaign_name ?? null, + channel, + country: params.country ?? null, + revenue: 0, + raw: params, + } +} diff --git a/src/lib/growth/channels.test.ts b/src/lib/growth/channels.test.ts index e756ee7..515430a 100644 --- a/src/lib/growth/channels.test.ts +++ b/src/lib/growth/channels.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { CHANNELS, isChannel, isPaidChannel, isSkanChannel, resolveChannel } from './channels' +import { CHANNELS, isChannel, isPaidChannel, isSkanChannel, resolveChannel, resolveAdjustChannel } from './channels' describe('channel classification', () => { it('paid channels are flagged, earned are not', () => { @@ -59,3 +59,43 @@ describe('resolveChannel — UTM convention adex_{arm}', () => { expect(resolveChannel({ utmSource: 'mystery' })).toEqual({ channel: CHANNELS.ORGANIC, confidence: 'inferred' }) }) }) + +describe('resolveAdjustChannel — MMP network_name mapping', () => { + it('maps Apple Search Ads deterministically', () => { + expect(resolveAdjustChannel('Apple Search Ads')).toEqual({ + channel: CHANNELS.PAID_ASA, + confidence: 'deterministic', + }) + }) + it('maps Meta/TikTok install networks to the iOS SKAN channel (conservative app-install default)', () => { + expect(resolveAdjustChannel('Facebook Installs')).toEqual({ + channel: CHANNELS.PAID_META_IOS, + confidence: 'skan', + }) + expect(resolveAdjustChannel('Instagram Installs').channel).toBe(CHANNELS.PAID_META_IOS) + expect(resolveAdjustChannel('TikTok Installs')).toEqual({ + channel: CHANNELS.PAID_TIKTOK_IOS, + confidence: 'skan', + }) + }) + it('is case-insensitive on network name', () => { + expect(resolveAdjustChannel('APPLE SEARCH ADS').channel).toBe(CHANNELS.PAID_ASA) + }) + it('prefers a web-funnel hint in campaign_name over the app-install default', () => { + expect(resolveAdjustChannel('Facebook Installs', 'Q3 Web Retargeting')).toEqual({ + channel: CHANNELS.PAID_META_WEB, + confidence: 'inferred', + }) + expect(resolveAdjustChannel('TikTok Installs', 'web-lander-us').channel).toBe(CHANNELS.PAID_TIKTOK_WEB) + }) + it('unmapped network names fall back to organic/inferred, never a paid channel', () => { + expect(resolveAdjustChannel('Some Unknown Network')).toEqual({ + channel: CHANNELS.ORGANIC, + confidence: 'inferred', + }) + expect(resolveAdjustChannel(null)).toEqual({ channel: CHANNELS.ORGANIC, confidence: 'inferred' }) + }) + it('explicit organic network is deterministic', () => { + expect(resolveAdjustChannel('organic')).toEqual({ channel: CHANNELS.ORGANIC, confidence: 'deterministic' }) + }) +}) diff --git a/src/lib/growth/channels.ts b/src/lib/growth/channels.ts index 5acaef6..d108303 100644 --- a/src/lib/growth/channels.ts +++ b/src/lib/growth/channels.ts @@ -101,3 +101,65 @@ export function resolveChannel(input: { return { channel: CHANNELS.ORGANIC, confidence: src ? 'inferred' : 'deterministic' } } + +// ── Adjust (MMP) network → channel mapping ────────────────────────────── +// Adjust callbacks report `network_name` (and `campaign_name`), not our +// adex_{arm} UTM convention — resolveChannel() doesn't recognize these names +// and would bucket them all into organic (the "channel norm失配" hole, +// docs/growth/06-mmp-ingest.md §1 hole 2). This is a separate, explicit table +// so resolveChannel's own UTM-first contract stays untouched. +// +// Adjust's `network_name` doesn't distinguish web vs iOS-app-install for +// Meta/TikTok — both surface as e.g. "Facebook Installs" / "Instagram +// Installs". We conservatively map these to the *_ios (SKAN) channel, the +// lower-trust/lower-confidence bucket, since Adjust is an app-install MMP and +// most orgs wiring it up are attributing native app installs, not the web +// funnel. If `campaignName` carries a recognizable hint (e.g. contains "web"), +// we prefer that signal over the network-name default. This is a documented +// approximation — revisit if a customer's actual Adjust setup differs. +export const ADJUST_NETWORK_MAP: Record = { + 'apple search ads': CHANNELS.PAID_ASA, + 'facebook installs': CHANNELS.PAID_META_IOS, + 'facebook ads': CHANNELS.PAID_META_IOS, + 'instagram installs': CHANNELS.PAID_META_IOS, + 'meta installs': CHANNELS.PAID_META_IOS, + 'tiktok installs': CHANNELS.PAID_TIKTOK_IOS, + 'tiktok for business': CHANNELS.PAID_TIKTOK_IOS, + 'google ads': CHANNELS.PAID_GOOGLE_UAC, + 'google installs': CHANNELS.PAID_GOOGLE_UAC, + organic: CHANNELS.ORGANIC, +} + +/** + * Resolve an Adjust `network_name` (+ optional `campaign_name`) to a canonical + * channel. Case-insensitive on network name. Unmapped networks fall back to + * organic with `inferred` confidence (never throws, never silently mis-buckets + * as a paid channel). + */ +export function resolveAdjustChannel( + networkName?: string | null, + campaignName?: string | null, +): { channel: Channel; confidence: Confidence } { + const network = (networkName ?? '').trim().toLowerCase() + const campaign = (campaignName ?? '').trim().toLowerCase() + + const mapped = ADJUST_NETWORK_MAP[network] + if (!mapped) { + return { channel: CHANNELS.ORGANIC, confidence: 'inferred' } + } + + // Meta/TikTok network names are app-install-only in our table (mapped to the + // *_ios SKAN channel); if the campaign name explicitly signals a web-funnel + // campaign, prefer that over the app-install default. + if (mapped === CHANNELS.PAID_META_IOS && campaign.includes('web')) { + return { channel: CHANNELS.PAID_META_WEB, confidence: 'inferred' } + } + if (mapped === CHANNELS.PAID_TIKTOK_IOS && campaign.includes('web')) { + return { channel: CHANNELS.PAID_TIKTOK_WEB, confidence: 'inferred' } + } + + if (mapped === CHANNELS.PAID_ASA) return { channel: mapped, confidence: 'deterministic' } + if (isSkanChannel(mapped)) return { channel: mapped, confidence: 'skan' } + if (mapped === CHANNELS.ORGANIC) return { channel: mapped, confidence: 'deterministic' } + return { channel: mapped, confidence: 'inferred' } +} diff --git a/src/lib/growth/cohorts.test.ts b/src/lib/growth/cohorts.test.ts index 7ab8f8a..8d1e3b2 100644 --- a/src/lib/growth/cohorts.test.ts +++ b/src/lib/growth/cohorts.test.ts @@ -6,7 +6,7 @@ import { CHANNELS } from './channels' const D = (iso: string) => new Date(iso) function ev(p: Partial & Pick): RawEvent { - return { userKey: 'u1', channel: null, revenue: 0, ...p } + return { userKey: 'u1', channel: null, revenue: 0, source: 'ga4', ...p } } describe('dayKey', () => { @@ -84,4 +84,57 @@ describe('buildCohortSnapshots', () => { expect(rows[0].cac).toBeCloseTo(10) // $20 / 2 installs expect(buildCohortSnapshots(events)[0].cac).toBeNull() }) + + it('single install-source authority: same real install reported by GA4 + Adjust under different userKey namespaces does not double-count (decision A)', () => { + const events: RawEvent[] = [ + // Same physical device/user, but GA4 and Adjust can't be joined (decision B) — + // they land as two distinct userKeys. + ev({ + userKey: 'ga4-pseudo-1', + eventName: EVENTS.INSTALL, + occurredAt: D('2026-07-01T08:00:00Z'), + channel: CHANNELS.ORGANIC, + source: 'ga4', + }), + ev({ + userKey: 'adjust:abc123', + eventName: EVENTS.INSTALL, + occurredAt: D('2026-07-01T08:05:00Z'), + channel: CHANNELS.PAID_ASA, + source: 'adjust', + }), + ] + // Without an authority, both userKeys place a cohort row each — the hole. + expect(buildCohortSnapshots(events).reduce((s, r) => s + r.installs, 0)).toBe(2) + + // With Adjust as install authority, the GA4 install is excluded from + // acquisition placement (that userKey has no other acquisition event, so + // it drops out entirely) — only the Adjust-attributed install counts. + const rows = buildCohortSnapshots(events, { installAuthority: 'adjust' }) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ installs: 1, channel: CHANNELS.PAID_ASA }) + }) + + it('installAuthority never filters funnel-deep/revenue events, only acquisition events', () => { + const rows = buildCohortSnapshots( + [ + ev({ + userKey: 'adjust:abc123', + eventName: EVENTS.INSTALL, + occurredAt: D('2026-07-01T08:00:00Z'), + channel: CHANNELS.PAID_ASA, + source: 'adjust', + }), + // Adjust doesn't report first_chat — it arrives from GA4, must still count. + ev({ + userKey: 'adjust:abc123', + eventName: EVENTS.FIRST_CHAT, + occurredAt: D('2026-07-01T09:00:00Z'), + source: 'ga4', + }), + ], + { installAuthority: 'adjust' }, + ) + expect(rows[0]).toMatchObject({ installs: 1, activated: 1 }) + }) }) diff --git a/src/lib/growth/cohorts.ts b/src/lib/growth/cohorts.ts index bc4adb0..798ecb7 100644 --- a/src/lib/growth/cohorts.ts +++ b/src/lib/growth/cohorts.ts @@ -14,6 +14,15 @@ * on cohortDay+N. Documented as a proxy — a dedicated GA4 retention signal can * refine it later without changing this contract. * + * Single install-source authority (decision A, docs/growth/06-mmp-ingest.md §2): + * when an org has both GA4 and Adjust wired, `opts.installAuthority` restricts + * which ACQUISITION-class event source counts toward a user's cohort placement + * — otherwise the same real install lands twice (once per source, under two + * different userKey namespaces) and installs double-count. Funnel-deep + * (first_chat/scene_generated) and revenue events are never filtered by + * source — Adjust doesn't report those at all, so filtering them would zero + * out activation/retention for MMP-attributed cohorts. + * * Ref: docs/growth/00-cuddler-first-redesign.md §4.1 · kpi-canon.realizedLtv */ @@ -27,6 +36,8 @@ export interface RawEvent { userKey: string | null channel: string | null revenue: number + /** ConversionEvent.source (ga4 | revenuecat | deeplink | adjust). */ + source: string } export interface CohortRow { @@ -61,10 +72,13 @@ function dayDiff(fromKey: string, to: Date): number { /** * Build cohort rows from a user's full event set. `spendByCohort` optionally * supplies media spend keyed by `${cohortDate}|${channel}` to compute CAC. + * `installAuthority`, if set, restricts which source's ACQUISITION-class + * events (install/signup) count toward cohort placement — see the + * single-install-source-authority note above the module doc comment. */ export function buildCohortSnapshots( events: RawEvent[], - opts: { spendByCohort?: Map } = {}, + opts: { spendByCohort?: Map; installAuthority?: string } = {}, ): CohortRow[] { // 1. Group events by user. const byUser = new Map() @@ -82,7 +96,7 @@ export function buildCohortSnapshots( for (const userEvents of byUser.values()) { const acquisition = userEvents - .filter((e) => ACQUISITION.has(e.eventName)) + .filter((e) => ACQUISITION.has(e.eventName) && (!opts.installAuthority || e.source === opts.installAuthority)) .sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime())[0] if (!acquisition) continue // no install/signup → can't place in a cohort diff --git a/src/lib/growth/kpi-canon.test.ts b/src/lib/growth/kpi-canon.test.ts index 053524a..dbaec05 100644 --- a/src/lib/growth/kpi-canon.test.ts +++ b/src/lib/growth/kpi-canon.test.ts @@ -13,6 +13,7 @@ import { realizedLtv, projectSubscriberLtv, kFactor, + resolveInstallAuthority, } from './kpi-canon' describe('rate metrics', () => { @@ -92,3 +93,32 @@ describe('kFactor carries a confidence flag', () => { expect(kFactor(30, 300, true).confidence).toBe('measured') }) }) + +describe('resolveInstallAuthority — decision A + anti-zeroing guard', () => { + it('no Adjust auth → GA4 is authority', () => { + expect( + resolveInstallAuthority({ hasAdjustAuth: false, adjustInstallCount: 0, ga4InstallCount: 50 }), + ).toEqual({ authority: 'ga4', fallback: false }) + }) + it('Adjust auth configured and reporting installs → Adjust is authority', () => { + expect( + resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 40, ga4InstallCount: 50 }), + ).toEqual({ authority: 'adjust', fallback: false }) + }) + it('anti-zeroing: Adjust configured but 0 installs in window while GA4 has signal → falls back to GA4 with a warning', () => { + const r = resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 0, ga4InstallCount: 30 }) + expect(r.authority).toBe('ga4') + expect(r.fallback).toBe(true) + expect(r.warning).toMatch(/adjust/i) + }) + it('does not fall back when both sources are legitimately 0', () => { + expect( + resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 0, ga4InstallCount: 0 }), + ).toEqual({ authority: 'adjust', fallback: false }) + }) + it('does not fall back when the authority source has installs, even if lower than the other', () => { + expect( + resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 5, ga4InstallCount: 100 }), + ).toEqual({ authority: 'adjust', fallback: false }) + }) +}) diff --git a/src/lib/growth/kpi-canon.ts b/src/lib/growth/kpi-canon.ts index e1dd538..c3022fc 100644 --- a/src/lib/growth/kpi-canon.ts +++ b/src/lib/growth/kpi-canon.ts @@ -179,3 +179,61 @@ export function kFactor( confidence: attributionReady ? 'measured' : 'estimated', } } + +// ───────────────────────────────────────────────────────────────────────── +// Install authority — decision A (docs/growth/06-mmp-ingest.md §2) +// ───────────────────────────────────────────────────────────────────────── + +export type InstallAuthority = 'adjust' | 'ga4' + +export interface InstallAuthorityResult { + authority: InstallAuthority + /** True when we deviated from the configured authority (anti-zeroing guard). */ + fallback: boolean + /** Present only when `fallback` is true — surface it in cron logs / responses. */ + warning?: string +} + +/** + * Resolve which source is authoritative for INSTALL-class events in a + * ConversionEvent aggregation window. + * + * Rule (decision A): if the org has a configured Adjust `PlatformAuth`, Adjust + * is the install/channel authority and GA4 is demoted to funnel-deep events + * only (first_chat, scene_generated, ...) that Adjust doesn't report. + * Orgs with no Adjust integration keep GA4 as the (only) install source. + * This function is pure — callers do the `PlatformAuth` lookup and the + * per-source install counts (e.g. the growth-sync cron) and pass in + * primitives. + * + * Anti-zeroing guard: an org can have an Adjust `PlatformAuth` row for the + * legacy Report pull (`src/lib/platforms/adjust.ts`, `reports/sync`) without + * ever wiring the S2S callback route (`/api/ingest/adjust`) that actually + * populates ConversionEvent. If that happens, naively trusting "Adjust + * configured → Adjust authoritative" reports installs=0 for the whole window + * while GA4 still has real signal — a false funnel zero-out, not a real drop. + * When the resolved authority's window install count is 0 but the other + * source's count is > 0, we fall back to the other source and set + * `fallback: true` with a `warning` string so callers can log/surface it + * instead of silently reporting installs=0. + */ +export function resolveInstallAuthority(params: { + hasAdjustAuth: boolean + adjustInstallCount: number + ga4InstallCount: number +}): InstallAuthorityResult { + const { hasAdjustAuth, adjustInstallCount, ga4InstallCount } = params + const preferred: InstallAuthority = hasAdjustAuth ? 'adjust' : 'ga4' + const other: InstallAuthority = preferred === 'adjust' ? 'ga4' : 'adjust' + const preferredCount = preferred === 'adjust' ? adjustInstallCount : ga4InstallCount + const otherCount = other === 'adjust' ? adjustInstallCount : ga4InstallCount + + if (preferredCount === 0 && otherCount > 0) { + return { + authority: other, + fallback: true, + warning: `install authority '${preferred}' had 0 installs in the window while '${other}' had ${otherCount} — falling back to '${other}' to avoid a false zero (check whether /api/ingest/adjust is actually wired for this org)`, + } + } + return { authority: preferred, fallback: false } +} From f14b6389438ac71e03957f628581a5d9522ab744 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:16:45 +0800 Subject: [PATCH 5/7] fix: pilot cap sums account-level rows only; persist fresh report on success WHY (review #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 --- src/lib/agent/act.test.ts | 25 +++++++++++++++++++++++++ src/lib/agent/act.ts | 4 ++++ src/lib/agent/guardrails.test.ts | 17 +++++++++++++++++ src/lib/agent/guardrails.ts | 6 +++++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/lib/agent/act.test.ts b/src/lib/agent/act.test.ts index fc6b10a..17dd6d0 100644 --- a/src/lib/agent/act.test.ts +++ b/src/lib/agent/act.test.ts @@ -129,6 +129,31 @@ describe('executeApprovedDecision — fresh guardrail re-check', () => { expect(execute).toHaveBeenCalled() expect(result.status).toBe('executed') }) + + it('persists the fresh guardrail report on the success path (warn signals survive)', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep() + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + const fresh = [{ pass: true, rule: 'pilot_budget_cap', reason: 'pilot spend 82% of cap: warn' }] + mockedEvaluateGuardrails.mockResolvedValue(fresh) + + await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(mockedPrisma.decisionStep.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'executed', + guardrailReport: JSON.stringify(fresh), + }), + }) + ) + }) }) describe('executeApprovedDecision — rollback exemption', () => { diff --git a/src/lib/agent/act.ts b/src/lib/agent/act.ts index 331d600..e214635 100644 --- a/src/lib/agent/act.ts +++ b/src/lib/agent/act.ts @@ -273,6 +273,10 @@ export async function executeApprovedDecision( platformResponse: result.ok && result.platformResponse ? JSON.stringify(result.platformResponse) : null, platformLinkId: result.ok && result.platformLinkId ? result.platformLinkId : null, executedAt: new Date(), + // Persist the execution-time re-check on the success path too — warn- + // level signals (learning phase, 80%-of-cap) would otherwise be lost, + // leaving only the stale proposal-time report on the row. + guardrailReport: JSON.stringify(freshResults), }, }) if (!result.ok) anyFailed = true diff --git a/src/lib/agent/guardrails.test.ts b/src/lib/agent/guardrails.test.ts index cbc462d..e93a667 100644 --- a/src/lib/agent/guardrails.test.ts +++ b/src/lib/agent/guardrails.test.ts @@ -259,6 +259,23 @@ describe('pilot_budget_cap', () => { expect(results.some((r) => r.rule === 'pilot_budget_cap' && !r.pass)).toBe(true) }) + it('sums account-level rows only — campaign rows duplicate the same spend', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01', capTotal: 5000 }) }, + ]) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 1000 }]) + await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + expect(mockedPrisma.report.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ level: 'account' }), + }) + ) + }) + it('fail-closed: blocks if the DB read throws', async () => { mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01' }) }, diff --git a/src/lib/agent/guardrails.ts b/src/lib/agent/guardrails.ts index 1aaed79..6fc4506 100644 --- a/src/lib/agent/guardrails.ts +++ b/src/lib/agent/guardrails.ts @@ -341,8 +341,12 @@ const evaluators: Record = { const cfg = (config || {}) as { pilotStartDate?: string; capTotal?: number } if (!cfg.pilotStartDate) return { pass: true, rule: 'pilot_budget_cap' } const since = new Date(cfg.pilotStartDate) + // level:'account' — sync writes BOTH an account-level row (the contract, + // one per platform per day) AND best-effort per-campaign rows for the + // same spend (src/lib/sync/report-writer.ts). Summing all levels double- + // counts and trips the cap at ~half real spend. const reports = await prisma.report.findMany({ - where: { orgId: ctx.orgId, date: { gte: since } }, + where: { orgId: ctx.orgId, level: 'account', date: { gte: since } }, select: { spend: true }, }) const cumulativeSpend = reports.reduce((s, r) => s + r.spend, 0) From 23dc9a82f5fab3820a244344522207e2742c5d9a Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:21:06 +0800 Subject: [PATCH 6/7] fix: adjust authority from live s2s events; wire event-token map; split secret WHY (review #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 --- .env.example | 8 +++++++ docs/growth/06-mmp-ingest.md | 2 +- src/app/api/ingest/adjust/route.ts | 35 ++++++++++++++++++++++++------ src/lib/growth/kpi-canon.test.ts | 7 ++++++ src/lib/growth/kpi-canon.ts | 15 ++++++++----- 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index c72cb96..49a2e6a 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,14 @@ MAIL_FROM="Adex " # openssl rand -hex 32 CRON_SECRET="" +# Growth ingest secrets (org-level PlatformAuth rows override these fallbacks). +# INGEST_WEBHOOK_SECRET — HMAC signing key for POST /api/ingest/events. +# INGEST_ADJUST_SECRET — static token for GET|POST /api/ingest/adjust. MUST be +# distinct from INGEST_WEBHOOK_SECRET: the Adjust token travels in cleartext +# query strings (lands in access logs) and must not unlock HMAC forgery. +INGEST_WEBHOOK_SECRET="" +INGEST_ADJUST_SECRET="" + # Demo seed config (used by `npm run db:seed`). # - SEED_EMAIL: defaults to demo@adexads.com if unset. # - SEED_PASSWORD: if unset, the seed script generates a cryptographically diff --git a/docs/growth/06-mmp-ingest.md b/docs/growth/06-mmp-ingest.md index b0b37b4..64559ce 100644 --- a/docs/growth/06-mmp-ingest.md +++ b/docs/growth/06-mmp-ingest.md @@ -31,7 +31,7 @@ - `src/lib/growth/adjust-ingest.ts`(新建 + 测试):`mapAdjustCallback(params, eventTokenMap?) → ConversionEventInput | null`,照 `ga4.ts:mapGa4Event` 的写法;`activity_kind='install'` → `install`;`='event'` 时查 `eventTokenMap`(event token → canonical `EventName`,映射不到 → drop);`reattribution`/`session` 等 → drop(返回 null,不抛错) - 测试含**双源用例**(`cohorts.test.ts`):同一真实安装的 GA4 + Adjust 两行(不同 userKey namespace)不设权威时 installs=2(复现洞 1);设 `installAuthority: 'adjust'` 后 installs=1 且渠道以 Adjust 行为准;另有用例确认 installAuthority 不过滤漏斗深层事件 2. **鉴权路由**——已完成,选定方案一 - - `GET|POST /api/ingest/adjust?org=`(`src/app/api/ingest/adjust/route.ts`),静态密钥 + `verifyBearer`(完整仿 `src/app/api/ingest/revenuecat/route.ts`,密钥存 `PlatformAuth(orgId, platform='ingest').apiKey`,env `INGEST_WEBHOOK_SECRET` 兜底);Adjust 侧密钥支持 `Authorization: Bearer` 或 `?token=` query(callback 是 URL 模板,设不了自定义 header);单条映射失败跳过(200 ok, ignored),不 5xx;写入沿用 `createMany` + `skipDuplicates` 幂等 + - `GET|POST /api/ingest/adjust?org=`(`src/app/api/ingest/adjust/route.ts`),静态密钥 + `verifyBearer`(仿 `src/app/api/ingest/revenuecat/route.ts`)。**密钥槽独立**:存 `PlatformAuth(orgId, platform='ingest_adjust').apiKey`,env `INGEST_ADJUST_SECRET` 兜底——不与 `/api/ingest/events` 的 HMAC 密钥共用,因为 Adjust token 走 `?token=` 明文 query 会进访问日志,泄漏不能连带 HMAC 伪造。同一行的 `extra` JSON 配置 **event token 映射**:`{"eventTokenMap":{"":"trial_start"}}`——不配置时非 install 事件会被安全丢弃(200 ignored),install 不受影响;Adjust 侧密钥支持 `Authorization: Bearer` 或 `?token=` query(callback 是 URL 模板,设不了自定义 header);单条映射失败跳过(200 ok, ignored),不 5xx;写入沿用 `createMany` + `skipDuplicates` 幂等 3. **Adjust 后台配置**:Raw Data Exports → Real-time callbacks,install + 关键 event 各配一条,URL 带 placeholder:`{activity_kind}`、`{network_name}`、`{campaign_name}`、`{adid}`、`{idfa}`、`{created_at}`、`{country}`,及透传的 partner parameter(决策 B 的 app_user_id) 4. **验证清单**:同一 callback 重放两次只落一行(幂等键);ASA/Meta 的 network 名映射进正确 channel(不落 organic);GA4+Adjust 双开的 org 在 `/growth` 的 installs 与单开 MMP 一致;`/dashboard` 的 Report.adjust 总量不受影响 diff --git a/src/app/api/ingest/adjust/route.ts b/src/app/api/ingest/adjust/route.ts index d00d67a..2384257 100644 --- a/src/app/api/ingest/adjust/route.ts +++ b/src/app/api/ingest/adjust/route.ts @@ -13,8 +13,11 @@ import { mapAdjustCallback, type AdjustCallbackParams } from '@/lib/growth/adjus * /api/ingest/revenuecat, except the secret may arrive via `?token=` (Adjust * can't set custom headers) in addition to `Authorization: Bearer`. * - * Auth: PlatformAuth(orgId, platform='ingest').apiKey, falling back to the - * INGEST_WEBHOOK_SECRET env var (mirrors /api/ingest/events). + * Auth: PlatformAuth(orgId, platform='ingest_adjust').apiKey, falling back to + * the INGEST_ADJUST_SECRET env var — a slot distinct from the events-route + * HMAC secret, because this key travels in cleartext query strings. The same + * row's `extra` JSON supplies the event-token map (custom Adjust event tokens + * → canonical event names). * * A single callback maps to at most one event; unmapped activity_kind / * event_token combinations are dropped (200 ok, not written) so Adjust never @@ -35,12 +38,29 @@ async function handle(req: NextRequest) { return NextResponse.json({ error: 'missing ?org' }, { status: 400 }) } - let expected: string | undefined = process.env.INGEST_WEBHOOK_SECRET + // Distinct secret slot (platform='ingest_adjust', env INGEST_ADJUST_SECRET). + // Deliberately NOT the '/api/ingest/events' HMAC secret: this route has to + // transmit its key in cleartext via `?token=` (Adjust can't set headers), + // which lands in LB/access logs — a leak here must not let anyone forge + // HMAC signatures for the events route. The same row's `extra` JSON also + // carries the org's Adjust event-token map: {"eventTokenMap":{"abc123":"trial_start"}}. + let expected: string | undefined = process.env.INGEST_ADJUST_SECRET + let eventTokenMap: Record | undefined try { const auth = await prisma.platformAuth.findUnique({ - where: { orgId_platform: { orgId, platform: 'ingest' } }, + where: { orgId_platform: { orgId, platform: 'ingest_adjust' } }, }) if (auth?.apiKey) expected = auth.apiKey + if (auth?.extra) { + try { + const extra = JSON.parse(auth.extra) as { eventTokenMap?: Record } + if (extra.eventTokenMap && typeof extra.eventTokenMap === 'object') { + eventTokenMap = extra.eventTokenMap + } + } catch { + // malformed extra JSON — proceed without a map (installs still ingest) + } + } } catch { // fall through to env fallback } @@ -51,8 +71,6 @@ async function handle(req: NextRequest) { } const params: AdjustCallbackParams = Object.fromEntries(url.searchParams.entries()) - // Never persist the auth secret into ConversionEvent.raw. - delete params.token if (req.method === 'POST') { try { const form = await req.formData() @@ -63,8 +81,11 @@ async function handle(req: NextRequest) { // no form body — GET-style query params only, that's fine } } + // Never persist the auth secret into ConversionEvent.raw — strip AFTER the + // form merge, or a form field named `token` would smuggle it back in. + delete params.token - const mapped = mapAdjustCallback(params) + const mapped = mapAdjustCallback(params, eventTokenMap) if (!mapped) { // Not funnel-relevant (reattribution/session, unmapped event token). Ack // so Adjust doesn't retry. diff --git a/src/lib/growth/kpi-canon.test.ts b/src/lib/growth/kpi-canon.test.ts index dbaec05..7bb7103 100644 --- a/src/lib/growth/kpi-canon.test.ts +++ b/src/lib/growth/kpi-canon.test.ts @@ -100,6 +100,13 @@ describe('resolveInstallAuthority — decision A + anti-zeroing guard', () => { resolveInstallAuthority({ hasAdjustAuth: false, adjustInstallCount: 0, ga4InstallCount: 50 }), ).toEqual({ authority: 'ga4', fallback: false }) }) + it('S2S-only org (no legacy credential, live adjust events) → Adjust is authority', () => { + // The recommended setup wires only the callback route — the legacy + // Report-API credential must not be a precondition for authority. + expect( + resolveInstallAuthority({ hasAdjustAuth: false, adjustInstallCount: 40, ga4InstallCount: 50 }), + ).toEqual({ authority: 'adjust', fallback: false }) + }) it('Adjust auth configured and reporting installs → Adjust is authority', () => { expect( resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 40, ga4InstallCount: 50 }), diff --git a/src/lib/growth/kpi-canon.ts b/src/lib/growth/kpi-canon.ts index c3022fc..875a6de 100644 --- a/src/lib/growth/kpi-canon.ts +++ b/src/lib/growth/kpi-canon.ts @@ -198,10 +198,15 @@ export interface InstallAuthorityResult { * Resolve which source is authoritative for INSTALL-class events in a * ConversionEvent aggregation window. * - * Rule (decision A): if the org has a configured Adjust `PlatformAuth`, Adjust - * is the install/channel authority and GA4 is demoted to funnel-deep events - * only (first_chat, scene_generated, ...) that Adjust doesn't report. - * Orgs with no Adjust integration keep GA4 as the (only) install source. + * Rule (decision A): an org is "on Adjust" when it has live `source='adjust'` + * install events in the window (the S2S pipeline signal) OR a configured + * Adjust `PlatformAuth` (the legacy Report-API credential, treated as a hint + * only). Either makes Adjust the install/channel authority, demoting GA4 to + * funnel-deep events (first_chat, scene_generated, ...) that Adjust doesn't + * report. The recommended setup is S2S-only with NO legacy credential — so + * the credential must never be a precondition, otherwise those orgs' channel- + * attributed installs are silently excluded whenever GA4 has any installs. + * Orgs with neither signal keep GA4 as the (only) install source. * This function is pure — callers do the `PlatformAuth` lookup and the * per-source install counts (e.g. the growth-sync cron) and pass in * primitives. @@ -223,7 +228,7 @@ export function resolveInstallAuthority(params: { ga4InstallCount: number }): InstallAuthorityResult { const { hasAdjustAuth, adjustInstallCount, ga4InstallCount } = params - const preferred: InstallAuthority = hasAdjustAuth ? 'adjust' : 'ga4' + const preferred: InstallAuthority = hasAdjustAuth || adjustInstallCount > 0 ? 'adjust' : 'ga4' const other: InstallAuthority = preferred === 'adjust' ? 'ga4' : 'adjust' const preferredCount = preferred === 'adjust' ? adjustInstallCount : ga4InstallCount const otherCount = other === 'adjust' ? adjustInstallCount : ga4InstallCount From 7f05dee59e23be71b16be61c564d91f122c611a8 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mx-320@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:22:08 +0800 Subject: [PATCH 7/7] fix: validate event-token map values against the canonical event set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY: the previous commit typed the parsed map as Record, 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 --- src/app/api/ingest/adjust/route.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/app/api/ingest/adjust/route.ts b/src/app/api/ingest/adjust/route.ts index 2384257..21bbd76 100644 --- a/src/app/api/ingest/adjust/route.ts +++ b/src/app/api/ingest/adjust/route.ts @@ -1,7 +1,10 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { verifyBearer, readBearer } from '@/lib/growth/ingest-auth' -import { mapAdjustCallback, type AdjustCallbackParams } from '@/lib/growth/adjust-ingest' +import { mapAdjustCallback, type AdjustCallbackParams, type AdjustEventTokenMap } from '@/lib/growth/adjust-ingest' +import { EVENTS, type EventName } from '@/lib/growth/events' + +const EVENT_NAME_SET = new Set(Object.values(EVENTS)) /** * GET|POST /api/ingest/adjust?org=&token= @@ -45,7 +48,7 @@ async function handle(req: NextRequest) { // HMAC signatures for the events route. The same row's `extra` JSON also // carries the org's Adjust event-token map: {"eventTokenMap":{"abc123":"trial_start"}}. let expected: string | undefined = process.env.INGEST_ADJUST_SECRET - let eventTokenMap: Record | undefined + let eventTokenMap: AdjustEventTokenMap | undefined try { const auth = await prisma.platformAuth.findUnique({ where: { orgId_platform: { orgId, platform: 'ingest_adjust' } }, @@ -53,9 +56,17 @@ async function handle(req: NextRequest) { if (auth?.apiKey) expected = auth.apiKey if (auth?.extra) { try { - const extra = JSON.parse(auth.extra) as { eventTokenMap?: Record } + const extra = JSON.parse(auth.extra) as { eventTokenMap?: Record } if (extra.eventTokenMap && typeof extra.eventTokenMap === 'object') { - eventTokenMap = extra.eventTokenMap + // Only keep entries whose value is a canonical EventName — a typo'd + // config entry degrades to "token unmapped → dropped", never a bad row. + const validated: AdjustEventTokenMap = {} + for (const [token, name] of Object.entries(extra.eventTokenMap)) { + if (typeof name === 'string' && EVENT_NAME_SET.has(name)) { + validated[token] = name as EventName + } + } + eventTokenMap = validated } } catch { // malformed extra JSON — proceed without a map (installs still ingest)