feat: wire pilot guardrails into agent runtime + close approval bypass#4
feat: wire pilot guardrails into agent runtime + close approval bypass#4mtmtian wants to merge 4 commits into
Conversation
WHY: docs/growth/00 §7.3 disciplines existed only as pure functions in
src/lib/growth/{pilot-gates,budget-guard}.ts — zero act-time enforcement.
A $5K pilot could overspend and SKAN-immature campaigns could be
auto-adjusted; executeApprovedDecision also skipped all guardrails.
WHAT: register pilot_budget_cap (fail-closed, activates via pilotStartDate
config), skan_maturity (fail-closed, <72h reject / learning-phase warn),
tier_cac_ceiling (fail-open, increase-only) in the evaluator registry +
schemas; re-evaluate guardrails at execution time inside
executeApprovedDecision (all five caller routes inherit), with rollback
exemption via existing DecisionStep.rollbackOf. Tests for boundaries,
fail-closed paths, and the bypass fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: perceive() attached GrowthSnapshot to the snapshot but plan.v1.md had no growth placeholder — funnel/gate/budget signals were computed then dropped before reaching the LLM. WHAT: add GROWTH_JSON block to plan.v1.md (after the cache-control marker) with explicit rules — gate=kill means reduce/stop only, budget non-ok forbids any spend-increasing action; render it in plan.ts; regression test pins the placeholder position relative to the cache split. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: wiring an MMP into ConversionEvent alongside GA4 double-counts installs (source is part of the idempotency key, cohort aggregation is source-blind), splits channels (network names all fall through to organic), and zeroes retention/LTV (adid vs pseudo_id namespaces). WHAT: docs/growth/06-mmp-ingest.md — the three holes, two canon decisions to make first, and an executable S2S-callback integration checklist; pointer comments on the legacy Report-only clients. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY: 04-status.md is the build-status handoff doc but stopped at the 2026-07-04 upstream snapshot (PR #1–oratis#3); anyone reading it would miss the guardrail wiring, Adjust ingest, BI dashboard, and campaign-name canon now in review. WHAT: append the PR oratis#4–oratis#7 table with scopes and ops prerequisites; normalize the two pre-existing table separators to the padded style the linter enforces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🤖 Code review — Claude CodeVerdict: Request changes — the core approval-bypass fix is correctly implemented, but a High double-counting bug breaks the arithmetic of this PR's headline guardrail.
SummaryCloses a real approval-bypass: Findings
Core fix verified sound: traced every caller of Test coverageAdequate for the core logic —
Strengths
Independent automated review. Findings are advisory — use judgment. |
…success WHY (review oratis#4): sync writes both an account-level row and per-campaign rows for the same spend, so the unfiltered sum double-counted and tripped the $5K cap at ~half real spend. The execution-time guardrail re-check was also discarded on the success path, silently dropping warn-level signals from the audit trail. WHAT: pilot_budget_cap filters level:'account' (the one-per-platform- per-day contract row); success-path decisionStep.update persists guardrailReport. Regression tests for both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Changes applied (fixup pushed)Both review findings addressed:
Commit
|
WHY: product decision — the whole pipeline lands on adex, not local tooling. Control plane stays in the Next.js service; minute-scale audio/video batch work moves to a Cloud Run Jobs worker whose image packages the already-verified creative-pipeline scripts. Adds per-stage design table, PR roadmap (#1-oratis#4), cost + model-tier policy.
* feat(db): add CompetitorCreative + RemixJob models Adds the competitor-intel data model per docs/growth/07 §4 (+ 09 §3's segmentPlan addition): CompetitorCreative caches AppGrowing ad metadata and AI analysis, idempotent on (orgId, source, externalId); RemixJob is a queryable ledger for turning competitor creatives into our-own-IP assets (not wired to an engine yet — Phase 2). Purely additive, both relate to Organization like the rest of the schema. Migration generated via `prisma migrate diff --from-schema <base> --to-schema prisma/schema.prisma --script` (no local Postgres available in this worktree — see .claude/rules/schema.md). * feat(api): add competitor-intel ingest + listing routes POST /api/ingest/competitor?org=<orgId> — clones the ingest/scenes HMAC + idempotent-upsert pattern for competitor ad creatives pushed by the local creative-pipeline/AppGrowing bridge. Accepts a single item or an array; mediaUrl already on our GCS bucket is linked directly, an external mediaUrl is fetched once and uploaded to GCS. Per-item created/updated/failed result. Connector parsing/mapping logic lives in src/lib/platforms/appgrowing.ts so the ingest source is isolated from the route, per docs/growth/07 §5. GET /api/competitors — session-scoped listing (requireAuthWithOrg, never trusts a client orgId), filterable by appName/level, orderable by adDays/impressions. Ref: docs/growth/09-pipeline-adex-integration.md §3 * fix(platform): seedance2 video-url + duration handling Real-world doubao-seedance-2-0-260128 task responses return the generated video URL at content[].video_url.url, not output.video_url — add resolveVideoUrl() that prefers content, falls back to output for older/other model shapes. Also round the requested duration to whole seconds before submitting to Ark; a fractional duration gets a 400 InvalidParameter. Found via first real Seedance2 calls in the local creative-pipeline tooling (commit e06e215/1224f1c). Ref: docs/growth/09-pipeline-adex-integration.md §3 * test(e2e): cover competitor-intel ingest happy path + auth Register→login→GET /api/orgs→POST /api/ingest/competitor round trip: HMAC-signed ingest creates then idempotently updates a CompetitorCreative row, a bad signature 401s, GET /api/competitors requires a session and reflects the upsert. playwright.config.ts's webServer.env gains INVITE_CODES_DISABLED + INGEST_WEBHOOK_SECRET so the spec can self-provision a user/org and sign requests without a PlatformAuth row. Verified the code path against a build with no DATABASE_URL (fails at the expected `DATABASE_URL is not set` boundary, confirming registration/ingest wiring is correct) — no live Postgres was available in this worktree to run the full assertions end-to-end. * docs(growth): competitor-intel remix + pipeline integration specs Adds the design docs this PR implements: 07 (competitor-intel + remix proposal, incl. the CompetitorCreative/RemixJob model proposal), 08 (creative pipeline stages), 09 (this PR's scope — pipeline↔adex integration, PR #1 boundaries). * docs(growth): revise 09 to adex-native architecture (v2) WHY: product decision — the whole pipeline lands on adex, not local tooling. Control plane stays in the Next.js service; minute-scale audio/video batch work moves to a Cloud Run Jobs worker whose image packages the already-verified creative-pipeline scripts. Adds per-stage design table, PR roadmap (#1-oratis#4), cost + model-tier policy. * docs(growth): record storage-write decision — media enters via ingest API only * fix(platform): correct seedance2 response parsing to real API shape WHY: verified against live task cgt-20260710153030-s5t5x — real doubao-seedance-2-0-260128 returns content as an OBJECT {video_url:string} (not an array) and duration at TOP LEVEL. Prior resolveVideoUrl threw TypeError (content.find is not a function), leaving Assets stuck 'generating' — the PR's core fix did not work against the target model. WHAT: resolveVideoUrl handles object/array/legacy shapes (array form skips echoed reference_video inputs); resolveDuration reads top-level→output→ usage; generate route rounds duration before the Int? persist (was a 500 after a paid Ark job started). * feat(db): add CompetitorCreative.level column + (orgId, level) index WHY: code review — the competitors list filtered level via a rawMeta JSON path that nothing writes (dead filter), while its sibling segmentPlan got a real column. level is the primary routing axis (09 §3), so it warrants a first-class indexed column, not an untyped blob key. * fix(api): harden competitor ingest + listing per code review WHY/WHAT (xhigh review findings): - SSRF: ingest fetched caller-supplied mediaUrl with no validation → new storage.uploadFromUrl blocks non-http(s) + private/link-local hosts. - OOM/timeout: whole video buffered, unbounded batch → 100MB cap + 50-item batch cap (413). - competitors list masked all errors as empty 200 → returns 500 on failure; level filter now hits the real column. - parseHumanNumber fixes impressions/adDays silently dropping '710.4K' / '1,942' (the ranking signals); mergeRawMeta no longer corrupts array payloads; asset type prefers content-type over extension; isOwnGcsUrl + gcsPublicPrefix consolidated into storage.ts (was duplicated). * test(app): make competitor-ingest e2e robust — shared registration WHY: register is rate-limited 5/hour/IP; registering per-test flaked across retries/reruns. Register once in beforeAll and share the authed context; the anon test uses the default unauthenticated request fixture (config baseURL, no hardcoded localhost). --------- Co-authored-by: Martin <60476606+mx-320@users.noreply.github.com>
Summary
Wires the four P21 pilot disciplines (docs/growth/00 §7.3) into the agent's actual runtime — they previously existed only as pure functions in
src/lib/growth/{pilot-gates,budget-guard}.tswith zero act-time enforcement — and closes the guardrail bypass inexecuteApprovedDecision.1. Three new guardrail evaluators (
guardrails.ts+ schemas)pilot_budget_cap(fail-closed) — inert until an org setspilotStartDatein the rule config, so existing orgs are unaffected. SumsReport.spendsince pilot start; ≥95% of cap blocks spend-increasing tools, ≥80% warns.skan_maturity(fail-closed) — meta/tiktokapp_installcampaigns: <72h hard-reject automated adjustments; learning phase (≤7d) warns only, unless daily spend >200% of budget. MissingstartDate/budget data rejects.tier_cac_ceiling(fail-open to avoid cold-start lockout) — blocks bid/budget increases when the channel's latestCohortSnapshot.cacexceedstierCacCeiling(firstMonthNet); decreases always pass.2. Approval-path bypass closed (
act.ts)executeApprovedDecisionnow re-runsevaluateGuardrailsagainst fresh state immediately before eachtool.execute— approvals can sit pending for hours, so the stored report can't be trusted. All five callers (approvals, bulk approvals, rollback, bulk rollback, Slack interactive) inherit from this single choke point. Rollbacks (detected via existingDecisionStep.rollbackOf) are exempt from the budget cap and CAC ceiling so a bad state can't get stranded; SKAN warnings surface but don't block.3. Growth snapshot now reaches the plan LLM
perceive()computed funnel/gate/budget signals butplan.v1.mdhad no growth placeholder — the data was dropped before prompting. Added aGROWTH_JSONblock (after the prompt-cache marker, with a regression test pinning that position) plus explicit rules:gate=kill→ reduce/stop only; budget non-ok → no spend-increasing actions.4. Docs: MMP ingestion prerequisites
docs/growth/06-mmp-ingest.md— why plugging Adjust/AppsFlyer intoConversionEventnext to GA4 double-counts installs (idempotency key includessource; cohort aggregation is source-blind), splits channels, and zeroes retention/LTV; plus an executable S2S-callback integration checklist. Pointer comments on the legacy Report-only clients.Test plan
npx vitest run— 275/275 across 27 files (new: evaluator boundaries at 95%/72h/7d/ceiling, fail-closed paths, decrease pass-through;act.test.tscovers block-at-execution, rollback exemptions, and a control case where rollback still blocks on unrelated rules)npx tsc --noEmitclean;npm run lint0 errors (35 pre-existing warnings)🤖 Generated with Claude Code