User flows
How an actor moves through the product: step, screen, endpoint.
9 journeys · 5 complete. A flow is only done when every step is — press play to walk one.
UF-001Sign up → organization created → dashboard
doneThe bootstrap path. Creates the organization and its owner in one transaction, so there is never an org without an owner or a user without a tenant.
Submit the signup form
/signupPOST /api/v1/auth/signup
API creates Organization + owner User in one transaction
First audit row written
Tokens stored, session established
Land on the command centre
/dashboardGET /api/v1/auth/me
Claude: The signup UI is not built yet — the API path is, and is covered by tests.
UF-002Read the build board
doneWhat is built, what is next, how it is architected, what is tested, and the docs — without leaving the app or opening the repo.
Open the landing page
/
Follow through to the command centre
/dashboard
Overview shows progress and what's coming up, computed across sections
/dashboard
Open the Features tab, expand an item for its remarks and updates
/dashboard
Open the Docs tab and read any doc in place
/dashboard
Claude: Server-rendered from data/dashboard/*.json and docs/*.md — no client fetch, no waterfall.
UF-003Plan a quarter for a project
plannedThe core planning loop: brief in, plan out, calendar populated.
Create a project
/projects/newPOST /api/v1/projects
Write the campaign brief — audience, offer, budget, success metric
/projects/{id}/briefPOST /api/v1/projects/{id}/brief
Generate a plan (channel mix, budget split, cadence)
/projects/{id}/planPOST /api/v1/projects/{id}/plan/generate
Edit the plan — the AI output is a draft, not a decision
/projects/{id}/planPATCH /api/v1/projects/{id}/plan
Accept — plan expands into calendar slots across the three tracks
/projects/{id}/schedulerPOST /api/v1/projects/{id}/plan/accept
Claude: Step 4 is deliberate: a generated plan that lands straight on the calendar removes the judgement the plan needs.
UF-004Agency drafts → lead approves → publishes
plannedThe approval gate in its normal shape. An outside contributor produces work; an internal owner takes responsibility for it before it ships.
Agency member logs in, sees only assigned projects
/projectsGET /api/v1/projects
Drafts a multi-channel post with per-channel overrides
/composerPOST /api/v1/posts
Submits for approval
/composerPOST /api/v1/posts/{id}/submit
Lead is notified
Lead reviews and approves (or rejects with a comment)
/approvalsPOST /api/v1/posts/{id}/approve
Post is scheduled into a calendar slot
/calendarPOST /api/v1/posts/{id}/schedule
Worker publishes at the slot time; failures retry with backoff
Claude: Step 1 is the security-critical one: org scope is not enough for an agency user, the project assignment is checked too.
UF-005Agent proposes → human approves → it is applied
doneThe agentic loop, and the gate that makes it safe. An agent writes its whole proposal while the run is `awaiting_approval` — nothing has touched content, a budget or a platform at that point, so a rejected run costs model tokens and nothing else. There is deliberately no `running → applied` edge in the state machine.
Press Run now on an agent
/projects/:id/agentsPOST /api/v1/agents/runs
Plan + project visibility checked; run written as `queued` and published to RabbitMQ
Worker picks it up (`running`), the agent reads the project, and writes its proposal (`awaiting_approval`)
Proposal appears beside the team it came from
/projects/:id/agents/proposalsGET /api/v1/agents/runs
Someone with `agents:approve` reads what it found and decides
/projects/:id/agents/proposalsPOST /api/v1/agents/runs/:id/approve
`apply.ts` runs — the Drafter creates posts, the others record or unlock the next step
/projects/:id/content
Claude: No auto-apply mode. adwords-adsense planned autopilot as the default; that is where the money risk lives.
Claude: Marked done. The flow shipped; this row still described the planned shape, including a /approvals screen that now means something else — post sign-off, not agent proposals. Full walkthrough with diagrams in docs/AGENT_FLOW.md.
UF-006Answer 'what is running and what is it returning?'
plannedThe success metric from the brief: under 60 seconds, no spreadsheet.
Open the KPI surface
/kpisGET /api/v1/analytics/kpis
Every channel, organic and paid, with spend, return and freshness
/kpis
Compare against vertical benchmarks
/kpisGET /api/v1/ads/benchmarks
Drill into a project or a channel
/kpis/{scope}
Claude: Step 2 carries an as-of timestamp per source. A stale number that looks live is worse than no number.
UF-007Connect a channel
plannedOAuth into a platform and keep the connection alive without anyone watching it.
Pick a platform
/settings/channels
Redirect to the platform's consent screen
GET /api/v1/channels/{platform}/oauth/start
Callback exchanges the code; tokens encrypted at rest
GET /api/v1/channels/{platform}/oauth/callback
Profile fetched, account listed as connected
/settings/channels
Hourly job refreshes tokens expiring within 24h; a 6-hourly health check flags a break before a publish fails
Claude: Step 5 matters more than it looks — a silently expired token surfaces as a mysterious publish failure days later.
UF-008Ship a release: merge → release → nonprod → prod
doneHow code reaches customers. Merging deploys nothing: every merge builds signed api + web images already stamped with the next version; a release names one of those builds vX.Y.Z (nothing is rebuilt); the promoter puts it on nonprod; a person on PROD_DEPLOYERS deploys the same images to prod through gates. The next patch number is set automatically after each release (D-066), so only a minor or major release needs a version PR. Runbook: deploy/README.md → Shipping a release.
Merge a PR with its NEXT/ note (impact: patch, minor or major). CI builds and signs api + web images stamped <nextStableVersion>-rc.N
GitHub → Pull request.github/workflows/container-candidate.yml
Minor or major release only: a one-line PR sets nextStableVersion first (e.g. 0.12.0), and the release is cut after it merges. A patch needs nothing: the number is already set
container-candidate.json (e.g. PR #431)
Cut the release from the newest main candidate: version = nextStableVersion, candidate-manifest = <artifact id>@<digest>. It writes CHANGELOG/vX.Y.Z.md from NEXT/, the tag and the GitHub Release
Actions → container release.github/workflows/container-release.yml
Automatic: next version moves nextStableVersion one patch on (0.11.1 → 0.11.2) and pushes it to main with the release App
Actions → next version.github/workflows/next-version.yml
Automatic: the promoter deploys the release to nonprod; any new migration runs there first
Actions → promote → deploy nonprodpromote.yml → deploy-nonprod.yml
Check the change on nonprod; it reports the release. The promoter's summary says ready for prod after a 24 h soak, but deploy prod only requires that nonprod ran it
https://nonprod.omnichan.studioGET /api/v1/version
Deploy prod: signatures, ran-on-nonprod, downgrade and migration gates (tick migrations_acknowledged when the release adds one), database backup to R2, migrate, roll with a health check
Actions → deploy prod (release = vX.Y.Z).github/workflows/deploy-prod.yml
Confirm prod serves the release. Roll back = deploy the previous release with allow_downgrade: images only, never the database
https://studio.lightningleap.storeGET /api/v1/version
Claude: First run end to end: v0.10.0 reached nonprod and then prod through these steps (deploy prod run 37619410085, every gate green).
Claude: Commands. Candidate: run=$(gh run list --workflow container-candidate.yml --branch main --event push --status success --limit 1 --json databaseId --jq '.[0].databaseId'); gh api repos/verJSON/marketing-studio/actions/runs/$run/artifacts --jq '.artifacts[] | select(.name | startswith("container-candidate-")) | "\(.id)@\(.digest)"'. Release: gh workflow run container-release.yml --ref main -f version=X.Y.Z -f candidate-manifest=<id>@<digest>. Prod: gh workflow run deploy-prod.yml --ref main -f release=vX.Y.Z (add -f migrations_acknowledged=true when it adds a migration).
Claude: v0.11.1 went through every step: release run 37775655720; next version set 0.11.2 on its first run (b0a37a0f); nonprod 37775951946; prod 37776609135 with the post_origin migration acknowledged.
UF-009Ship a database migration: expand now, contract later
doneHow a schema change reaches prod safely. Every deploy runs pending migrations automatically, before the new api and worker start; nonprod runs them first; prod refuses until a person acknowledges them, and backs the database up before migrating. There are no down-migrations: a rollback swaps images, never the database, so every migration must be safe for the previous release to run against. Add (table, defaulted or nullable column, index) now; rename or drop in a later release.
Change the schema and generate the migration; add any new table to TABLES in the test setup so each test starts clean
apps/api/prisma/schema.prisma, apps/api/src/test/setup.tsnpm run db:migrate (prisma migrate dev)
Keep it additive. A rename or drop waits for a later release, once no running code reads the old shape
PR review
CI applies every migration to a fresh test database and checks each file is pure SQL
ci / build-testapps/api/src/test/migrations-clean.test.ts
Nonprod: a one-shot migrate container runs prisma migrate deploy before api and worker start; a failure stops the roll and the old version keeps serving
Actions → deploy nonproddocker-compose.prod.yml → migrate, deploy/roll-vm.sh
Prod: deploy prod stops at the migration gate and lists the new migrations; run it again with migrations_acknowledged=true once they are read
Actions → deploy proddeploy-prod.yml → Downgrade and migration gates
Backup to R2, then migrate, then roll with a health check. A failed migration aborts the roll and the previous release keeps serving (D-047)
Actions → deploy prod → backup, deploydeploy/db-backup.sh, deploy/roll-vm.sh
The promoter never deploys a release that adds a migration to prod by itself: a person always does
Actions → promote → run summaryscripts/promote-plan.mjs
Claude: v0.11.1 shipped 20261007120000_post_origin this way: an enum and a defaulted column, nothing back-filled. Next: #428 adds the seo_research_results table (FK to projects, on delete cascade) in v0.12.0.