# Flows canvas — lane state file

> **The flows lane's own state.** The shared one is `HANDOFF.md`; this outranks
> it for this lane. Every line below is true as of **2026-08-06**. Rewritten in
> place, closed items deleted. Several sessions share this repo — read
> `PARALLEL-SESSIONS.md` first.
>
> Branch `work/flows-canvas` · worktree `..\channels-manager-flows`

## Start here — your first three moves

1. `git fetch origin && git merge origin/main`. **MERGE, do not rebase** —
   rebasing replays ~70 commits and cannot be pushed without a force-push,
   forbidden on this shared `.git`. **`main` moves several times a day**: it
   moved three times during one session on 2026-08-05, once bringing a real
   conflict. Re-check it right before you push, never once at the start.
2. **Nothing is buildable until a fork is answered.** The six unplanned items
   are drawn in `plans/rethink/flows-canvas-unauthored.html`; "What is left" is
   the table of questions they put to the operator.
3. The 25 frames in `design/research/operator-review-20260803/` are REFERENCE,
   not a work list. Read one when you touch the surface it binds.

## Where the scope stands

**Prod runs `main` at `6e266e3`, deployed and verified 2026-08-06.** `main` has
moved past it since — but only docs and one drawing
(`git diff 6e266e3..origin/main -- apps packages scripts` is EMPTY), so prod's
CODE is current. Re-check that diff before assuming a later tip needs a deploy.

The oracle was NOT `pm2 list` this time and NOT `/health` — both lied. `pm2`
reported 12h uptime and restart count 0 for a process that had just been
restarted (a daemon-level artifact), and health answers `ok` from old code
regardless. **What proved the deploy was asking the running API whether the new
route exists:** `POST /api/agent/flows/media` answers **401**, not 404 — the
route is in the running Nest graph and its guard refuses an unauthenticated
call. Use that shape of check; a bookkeeping column is not evidence.

Also verified: health `{"status":"ok","db":true,"redis":true,"worker":true}`,
`/api/nope` 404, site 200. Served bundle carries `Upload`, `MB max`,
`cm-insp__drop`, `Pills are sent on Instagram`, `Checks which channel this run
came in on`, `This stops working`, `cm-fc__blue`, `Buttons on this message`.

### Shipped — seven items, each gated one at a time

Every one ran: failing test → implement → full per-package suites → typecheck →
lint → build → a screenshot on the RUNNING screen → an independent review → fix
what the review found → commit → push.

1. **Publish names what it stops before it writes** (`79860fe`). The boarded
   data-loss path is CLOSED.
2. **One primary blue on the canvas** (`daccc5a`). Zero elements inside
   `.cm-fc` resolve an indigo token.
3. **`+ Add Button` inside the text block** (`d04ee53`), frames 06 and 18.
4. **A live flow POSTS the public reply its trigger picked** (`0fea72e`, merged
   with the other lane's at `6a4f868` — see the ruling below).
5. **Branch a flow on the channel its run came in on** (`774af95`).
6. **Quick replies are actually sent** (`e6d38ef`). `buildQuickReplies` had zero
   callers; the pills ride the text send now, but ONLY on Instagram, because a
   pill whose tap cannot reach the engine is worse than no pill.
7. **A media block takes a FILE** (`2ae377e`). Frames 06/09/10's upload tiles.

## ⚠ OPEN — the one thing that needs the operator

**Two lanes built the public-reply send on the same day and gated it
differently.** Theirs landed first (`0f720db`, "Stage F"); mine was `0fea72e`.
The merge requires **both** gates rather than picking a winner:

- **theirs** — the channel reply gate. Right: a claim is reachable with that
  gate OFF, and an operator who closed public replies must not get one because
  a flow happened to be armed.
- **mine** — the FLOW's own state. Not a preference: the engine's law is that a
  `shadow` flow "executes nothing externally visible"
  (`flow-engine.service.ts:1363`), and a public reply under a member of the
  public's comment is exactly that.

**Loosening it to one gate is an operator ruling, not a merge.** Ledger it in
DECISIONS and delete this block when he rules.

## What is left

**Nothing to build until the operator rules.** The six items that had no plan
are now DRAWN: **`plans/rethink/flows-canvas-unauthored.html`** (2026-08-06).
Open it in a browser — no server needed. Each carries the fact that makes it
necessary, its states, and a fork with a recommendation.

| # | item | the question it puts to you |
|---|---|---|
| A | The AI node | the brief settles the FORM, not what Generate does. This repo already ships `ai_handover` — is the AI node that node with a panel, or a thing that writes steps onto the canvas? |
| B | Making a match mode | all six in one dropdown with a cost note on `is_about`, or `is_about` as its own earlier choice the way ManyChat does it? |
| C | The three wizard forks | confirm as built, or flip headings / include-exclude? |
| D | Ref URL, QR, Ads | Ref + QR need nothing from Meta we lack and share one implementation; Ads brings Ads Manager. Build the two, or all three? |
| E | One human, two contacts | a Person row above `Contact`, joined only on a captured phone/email or by hand — or leave it and warn on screen? |
| F | Flow delivery analytics | Sent + Delivered are one nullable `wamid` away; Opened + Clicked are not obtainable. Drop them from the promise? |

Build begins when a fork is answered. Nothing in that file is built.

## Named limits in what shipped — not bugs, not hidden

- **Nothing collects uploaded media.** An upload is permanent: dropping the
  block, discarding the draft or deleting the flow all leave the file on the
  volume that serves the site. The sweep must know which urls any flow version
  still references, which is its own job. Named in
  `flow-media.controller.ts`'s header.
- **`X-Content-Type-Options: nosniff` is NOT set on `/media/`** (measured on
  prod). Low risk — the allowlist admits no SVG and no HTML, and the stored
  extension always comes from the sniffed bytes — but it is the cheap
  defence-in-depth and it is absent. An nginx change, so not made unannounced.
- **Quick-reply pills send on Instagram only.** `quickRepliesCanRoute`. Facebook's
  DM receive arm is deliberately send-free (`fb-dm.no-send.test.ts`), so a
  Messenger tap has no path to the engine. WhatsApp has no `quick_replies` at
  all; its tappables are `buildFlowInteractive`'s buttons.
- **`quickReplySettings` still does nothing.** The pills send; no follow-up
  timer fires and no re-ask happens. The panel says exactly that on screen.
- **Story replies build 3 of 4 captured controls.** `autoLike` ("React with ❤️")
  is in the schema (`types.ts:531`), absent from the wizard, and unactionable —
  there is no reaction call on the IG client. Building the control without the
  capability would recreate the "stored and not acted on" trap.
- **`FLOW_CONDITION_CHANNELS` offers `web` and nothing can stamp a session with
  it.** Greyed WITH the reason; the schema still accepts it, so the day the
  website can start a flow nothing has to migrate.
- **Four match modes can be seen and removed, never created** — and
  `cm-fc__kwother` has still never met real data.

## ⚠ Not this lane's, but red on `main`

`apps/web/src/routes/agent/Activity.tsx:429` — `'needsCount' is assigned a value
but never used` is an ESLint **error**, so `pnpm --filter @channels/web lint`
fails for everyone. It arrived from the activity lane and that file is actively
being worked, so this lane reported it rather than editing it
(`PARALLEL-SESSIONS.md` § 5). Every package this lane owns lints clean.

## ⚠ Narrow-window protections — read before you touch anything

- **`AppShell.tsx` is SHARED with the screens lane.** Its `immersive` route test
  must keep matching `/^\/automation\/flows\/[^/]+$/`.
- **`postIds` stores `externalId`, the PLATFORM media id, never our row id.**
  Pinned by a test; do not "tidy" it to `p.id`.
- **`flow-canvas-tokens.css` is the ONE file allowed raw colour**, pinned to an
  EXACT count (60) in `conformance.test.ts`. Scoped `.cm-fc, .cm-fc__blue` —
  the second selector exists because the publish dialog portals to
  `document.body`. `flow-canvas-screen.css` must stay at ZERO.
- **`scripts/publish-web.sh`'s `--exclude=/media/` is LOAD-BEARING.** Without it
  `rsync --delete` wipes every uploaded file on the next web publish and every
  live flow carrying a picture breaks at send time. Proved on prod with a
  sentinel file that survived a real publish.
- **`vitest` does not typecheck.** A `tsc`-only error passed 1444 green tests
  and failed the build. Always run the package build.
- **Never run bare `pnpm test`**; **kill dev servers as you go**; `pnpm` is not
  on PATH (use `corepack pnpm`, per-package).
- **`pm2 restart` runs as `sopserver1`, never root.**
- **`CsvBroadcast.test.tsx` is flaky under full-suite load**, not broken. Do not
  "fix" it.

## Environment facts that cost time to rediscover

- **To LOOK at a screen with real data**: `corepack pnpm --filter @channels/web
  dev`, then `chrome-devtools` `navigate_page` with an `initScript` that sets
  `whatapp_token` and replaces `window.fetch` with a canned matcher. **Stub
  `/api/auth/me` too** or the route guard redirects to `/login`. A reload LOSES
  the initScript — re-navigate. This caught four defects no test saw: a dialog
  that diffed local state the server had not seen, an 8px tab-strip overflow, a
  canvas card printing `instagram` where an operator reads a product name, and
  a media tile missing its glyph.
- **Debug Chrome**: launch it yourself headless on `:9222` with
  `--user-data-dir=%LOCALAPPDATA%\Temp\claude-chrome-debug`.
- **`.env` was copied into this worktree from the hub** for `prisma generate`.
  Gitignored; delete it if the worktree is discarded.
- **Prod `.env` gained `FLOW_MEDIA_DIR` and `FLOW_MEDIA_BASE_URL`** on
  2026-08-06 (backup written beside it). Without them the upload route refuses
  with an honest message rather than returning a url that resolves nowhere.
