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/agent/act.test.ts b/src/lib/agent/act.test.ts new file mode 100644 index 0000000..17dd6d0 --- /dev/null +++ b/src/lib/agent/act.test.ts @@ -0,0 +1,223 @@ +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') + }) + + 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', () => { + // 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..e214635 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 { @@ -215,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/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..e93a667 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,264 @@ 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('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' }) }, + ]) + 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..6fc4506 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,213 @@ 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) + // 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, level: 'account', 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 +552,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( 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" }] }] } ``` 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