Project One

Internal: The Scheduled Tick

The external-scheduler entry point (Supabase pg_cron / GitHub Action / any scheduler). One call runs one platform tick, strictly ordered: pipeline pass → per-instance funnel metrics → pending static renders → due nurture sends → the two-phase video pass.

Endpoint Auth Rate limit Body
POST /api/cron Authorization: Bearer <CRON_SECRET> none ignored

POST /api/cron

Auth

Byte-exact Bearer ${CRON_SECRET}, constant-time compare. Three tiers:

  • CRON_SECRET unset → 401 for everyone (the endpoint cannot be authorized).
  • Secret valid, Supabase unconfigured → 500 plain-text service client unavailable. There is no demo tier here — past the auth check the secret is set, so a missing service client is a misconfiguration (SUPABASE_SECRET_KEY absent or misspelled), not a keyless run. It fails loud on purpose (#17): a silent 200 would make every tick a no-op forever with no signal, halting metric-sync and nurture platform-wide.
  • Fully wired → live tick.

⚠️ A CRON_SECRET under 16 chars or matching placeholder patterns (changeme, …) makes requireSecret throw outside the try/catch — you'll see a 500, not a 401. Fix the env var; don't retry.

Responses

Status Body
200 (live) { "ok": true, "instances": n, "pipelineActions": [...], "pipelineErrors": n, "funnelErrors": n, "nurtureSent": n }
401 plain-text unauthorized (not JSON)
500 plain-text service client unavailable — authorized, but SUPABASE_SECRET_KEY is missing/misspelled. Fix the env var; retrying will not help
500 plain-text cron job error — the tick itself threw

pipelineActions values: advanced:<stage>, generated:<stage>, revised:positioning|angles, requested:meta_launch, awaiting:positioning_confirm|angle_choice|page_publish|meta_launch, waiting:intake|creative_approval|llm, noop:live.

The revised:* pair is emitted when a rejected gate sends a stage back for another pass (src/lib/pipeline/worker.ts — the PipelineAction union is the authority). A consumer switching exhaustively on this vocabulary would previously have hit an unhandled value.

What a tick does (live)

  1. Pipeline pass — every active/provisioning instance, up to 10 decision steps, at most one LLM generation per instance per pass; may open approvals and post jargon-guarded Conductor lines. Generation gated on ANTHROPIC_API_KEY and the daily token budget (spent budget → waiting:llm, pipeline still advances on evidence).
  2. Funnel metrics — idempotent daily metric_records upsert per instance.
  3. Static renders — pending kind='static' creatives (storage_path IS NULL), bounded per pass.
  4. Nurture — due enrollment steps (batched queries; suppressed/consent-blocked sends count in nurtureSent).
  5. Video pass (last) — two-phase: collect finished provider jobs (bounded polls), then submit new drafts (CAS-claimed, budget-capped). No GEMINI_API_KEY → all video rows skipped, nothing charged.

Notes for operators

  • A 200 does not mean everything succeeded — per-instance failures are swallowed by design; inspect pipelineErrors / funnelErrors. Static-render and video results are console-logged only.
  • Idempotent by design: safe to re-run forever. Approvals only open when none of that type is pending; renders key on null storage_path; metrics upsert; video submits are claim-guarded.
  • maxDuration = 300 — the function declares a 5-minute ceiling; schedule accordingly.
  • Deploy note: call it on the canonical origin — a .netlify.app alias 301s and strips the Authorization header (guarded against in the bundled Netlify function).

Source: app/api/cron/route.ts, src/lib/jobs/scheduled.ts, src/lib/pipeline/worker.ts, src/lib/render/render-creatives.ts, src/lib/video/render-videos.ts, src/lib/nurture/runner.ts