# Bluebut — developer guide

> ## ⛔ NEVER EVER DO THIS — couple logic to the presentation layer instead of the data model
>
> A separation-of-concerns / layering violation: **control flow must depend on the stable data contract (the model/state), never on the rendered output meant for humans (display strings).**
>
> Sub-names, same root:
> - **Programming against the view, not the model** — reading what the page *renders* instead of the state it renders *from*.
> - **Stringly-typed control flow** — branching on free-form text where a typed enum/flag was available.
> - **Depending on an incidental, unstable representation** — display copy carries no stability contract; it can change any release. Building logic on it = relying on a non-guarantee.
>
> This repo's positive form is the **truth ladder**: read the source of truth — cookie/jar → inlined Relay state → persisted GraphQL `doc_id` — and treat the rendered DOM as the last resort. Matching localized `innerText` (e.g. `"Tillåt alla cookies"`) is the bottom rung dressed up as a check. Correct example: detect FB's cookie banner via the server-rendered flag `"shouldShowCookieBanner":true`, not the button's label. Skills: `surface-extract`, `local-browser`.
>
> **Operationally this means: new extraction adapters are WIRE-BASED GraphQL replay, and the legacy DOM scrapers are a migration backlog.** See [⭐ Extraction doctrine](#-extraction-doctrine--wire-based-graphql-replay-is-the-default-dom-scraping-is-deprecated) under Adapters.

Facebook outreach operator dashboard for the Bofrid housing-acquisition funnel. **Groups** are the primary object today, but the spine (jobs, accounts, data planes) is **site- and object-agnostic** — a page, a person, any object on any site rides the same machinery.

Four pages: **Groups** (home, `/` → `/groups`) + **Jobs**, **Accounts**, **Tools**. Opening a group is a full-page **workspace** (`/groups/<id>[/<tab>]`, `pages/groups/GroupWorkspace.tsx`), tabs **Overview** + **People** (`admins`). Posting is **not** a tab — broadcasting is a cross-group **batch** from the Groups-index checkbox panel.

---

## Architecture — the mental model

**One object, one verb, one log.** The operator works on **objects** (groups today); every action is a **job**; every job's trail lands in **HyperDX** + the durable `job` ledger, keyed by `job_id`. None of it is FB- or group-specific.

> ## 🚧 ARCHITECTURE IN MIGRATION — chromebox → cloakbox consumer
>
> Bluebut is being migrated from **owning** a browser fleet (the chromebox + its own
> fingerprint/proxy/session/HAR/tap stack) to being a **CONSUMER of the cloakbox identity
> platform** (gologin/pbox), which owns all of that. Bluebut then owns ONLY its FB domain
> verbs. The target architecture, remove-list, and current status are inlined below.
>
> > ## ⛔ TRANSPORT — consume cloak-cdp over its HTTP API, NOT the MCP
> >
> > The platform is reached over **two surfaces** — `keyvault` (pick accounts) and
> > **`cloak-cdp` (launch + drive)**. For everything bluebut runs unattended (the runner
> > `bin/bluebut-adapter.mjs`, the bridge, `bin/*` scripts, cron), cloak-cdp is consumed
> > over its **plain HTTP API**, NOT the MCP JSON-RPC endpoint. The MCP (`.../mcp`) is for
> > interactive agent sessions; it can be absent/interactively-authed in headless/cron runs
> > (the exact failure the harness warns about) — so **runtime code MUST use the HTTP API**.
> >
> > - **Base:** `https://cloak-cdp.hostbun.cc` · **auth:** `?key=ddash` or header `X-Api-Key: ddash` (env `CLOAK_API_KEY`).
> > - `POST /api/call` body `{"tool":"<name>","args":{…}}` → `{"result":…}` — the one call for every verb (`cdp_load`/`cdp_save`/`cdp_catalog`/`cdp_drive`/`wiretap_*`).
> > - `GET /api/tools` (full input schemas) · `GET /api/launchable` (= `cdp_catalog`; `?service=facebook&runnable=1&summary=1`) · `GET /api/health`.
> > - `GET /api/tools` (full input schemas) · `GET /api/launchable` (= `cdp_catalog`) · `GET /api/health` (liveness). Quick check: `curl -s 'https://cloak-cdp.hostbun.cc/api/health?key=ddash'`.
> > - **Seam:** `adapters/_lib/cloak-client.js` — the ONE client, and it speaks `POST /api/call` (stateless, no handshake). Every verb (`cdpLoad`/`cdpSave`/`cdpCatalog`/`cdpDrive`/`wiretap*`) goes through its `callTool`. **Nothing else calls the platform directly; never re-introduce the MCP JSON-RPC transport.**
>
> **Status (2026-07-09): CUT OVER (code) — the runner is cloakbox-only.** The
> `BLUEBUT_CLOAKBOX` gate is GONE: `bin/bluebut-adapter.mjs` always `cdp_load`s → drives →
> `cdp_save`s; the chromebox (`fb.chrome.blpk.cc`) is retired at the deploy level. Live
> catalog: **run=5 · mint-new=300 · birth=0** — only 5 accounts have a Windows cloakbox
> device; the other 300 need a device minted (per-account human VNC login via `add-account`).
> **Remaining violations to migrate off:** the runner still
> runs its OWN capture stack (`netcapture`/`har`/`gql-tap`/`graphql-store` → own MinIO +
> bofrid.dev) — the platform **wiretap** already captures everything (`bluebut-wiretap`
> consumes it). CAPTURE = platform; RECOGNIZE (`*-wire.js`) = bluebut.
>
> > ## ⛔ NO CLOAKBOX DEVICE → NO JOB (for that account, for ANY verb)
> >
> > Every job runs through `cdp_load(service, slug)`, and `cdp_load` can only load an account
> > that has a **Windows cloakbox device** (`cdp_catalog` verdict **`run`**). An account with
> > only a mac/linux device or none (verdict `mint-new`/`birth`) has **nothing to load** — the
> > job dies at `cdp_load` with *"no seeded device for facebook/<slug> … nothing to load."*
> > This is not scrape-specific: it is true for **read, scrape, post, admin, messenger — every
> > verb**. So "can this account run" is exactly "does it have a cloakbox device", nothing else
> > (not `logged_in`, not membership, not admin-ness).
> >
> > **Live reality: only 5 of 305 facebook accounts are runnable** (`run=5 · mint-new=300`).
> > `joel-skola`, `philip`, `pojtie` are among them; most named + all `df-*` farm accounts are
> > NOT. To make one runnable, MINT a device (`cdp_new` → human VNC login → `cdp_save`) via the
> > **`add-account`** skill.
> >
> > **Enforced in code — never pick a non-runnable actor.** The cloakbox-runnable set is
> > `getCloakboxCatalog()` → `makeDrivable(sessions, cloak)` (`ui/src/lib/util.tsx`); `drivable(slug)`
> > is the hard gate. Every place that chooses WHICH account drives a job filters to `drivable`
> > FIRST and returns **null / [] when none qualifies** — the action is then DISABLED (with the
> > "mint a device" hint), never fired as a doomed job:
> > - `GroupWorkspace.pickActor()` — refresh / scrape / admin-edit actor (drivable is the filter, scoring only orders survivors).
> > - `broadcastActor.candidatesForGroup()` + `makeAutoActor` fallback — the broadcast WHO-posts router (hard-filtered, no non-drivable tail).
> >
> > **The trap this closes:** stale `fb_account_group` edges for **purged Page pointers**
> > (`uthyra-se`, `bofrid`, …) — Pages that were never cloakbox accounts (the pointer-model
> > purge removed the kv rows but left the membership edges). A blind `memberAccts[0]` pick
> > floated one of these first → `cdp_load facebook/uthyra-se` → dead. `drivable` excludes them
> > because they have no device. (Cleaning the ghost edges is DB hygiene, separate from the gate.)

### Execution path — UI → job → bridge → runner → box

```
operator click
 → useJobs().runJob / startJob   (ui/src/state/jobs.tsx)  — UI job: in-memory + toast + HyperDX
   → runCli / runCliLive         (ui/src/api/index.ts)    — threads job_id
     → POST /exec/stream (SSE)    (ui/bridge/opencli-bridge.mjs, VITE_BRIDGE_URL)
       → spawn runner             (bin/bluebut-adapter.mjs, env BLUEBUT_JOB_ID)
         → import adapters/<site>/<cmd>.js
           → cloak-client POST /api/call {tool:cdp_load,args:{slug}} → resolvePageWs
             → cloakserve (Windows seed browser on pbox, platform-gated) → Facebook
             → POST /api/call {tool:cdp_save} at the end
```

The runner is **cloakbox-only** (the `BLUEBUT_CLOAKBOX` gate is gone): every FB job calls the platform's `cdp_load` **over the HTTP API** (`POST /api/call`, key `ddash` — see the transport banner above) which resolves+egress-verifies the proxy, gates the fingerprint, and injects the session, then `cdp_save`s at the end — and SKIPS the retired chromebox-only proxy-gate/hardenSession/egress-guard/ensureFbSession (the platform owns them). `adapters/_lib/cloak-client.js` is the ONE seam; adapters are unchanged.

### Three data planes

> **Under cloakbox:** keyvault stays the account store but the platform owns identity writes (`cdp_save`); the Traffic firehose + HAR planes are RETIRED (platform wiretap captures every seed).

| Plane | Where | Holds | Access |
|---|---|---|---|
| **Accounts** | **keyvault** | FB identity / creds / cookies — *"are our accounts working?"* | R/W |
| **Entity graph + activity** | **bluebut-db** (own Postgres) via **bluebut-rest** (`https://bluebut-rest.blpk.cc`, PostgREST) — *cutover off shared hostbun `db.hostbun.cc` is DONE/LIVE* | groups, memberships, people, chats, `group_post`, the `job` ledger | R/W |
| **Traffic firehose** | **bofrid.dev** (`supabase.bofrid.dev`) | `har_recording` + `traffic_entry` (every captured request/response + WS frames, ~277k rows/day) + `fb_traffic_live` (24h-purge live mirror) | **R/W** (the write exception) |
| **HAR blobs** | **bluebut-minio** (MinIO/S3, private `har` bucket) — *cut over from the hostbun `har` bucket* | the full gzipped lossless recordings; `traffic_entry.recording_id` → `har_key` → bucket | R/W |
| **Legacy history** | **bofrid.dev** (`supabase.bofrid.dev`) | fb-bot's sent DMs + broadcasts (`fb_sent_messages`/`fb_broadcast_posts`), `fb_scraped_posts`, `fb_ws_captures` | **read-only** (except the traffic + scraped-feed writes) |

**bofrid.dev is read-only EXCEPT the deliberate write tables:** the traffic firehose (`har_recording`/`traffic_entry`/`fb_traffic_live`) and the fb-bot scraped-posts feed (`fb_scraped_posts`). Everything else on bofrid.dev (DMs, broadcasts) stays read-only. The heavy traffic index lives on **bofrid.dev, not hostbun**, to keep the shared operational DB lean — `traffic-store.js` `creds()` resolves `BOFRID_SUPA_URL` first.

### Dev principles

1. **One object, one verb, one log.** Object = the thing (group/page/person/account); job = every action; HyperDX + `job` ledger = the trail (keyed by `job_id`).
2. **Every action is a job.** Observability is built in — the job ships steps + screenshot-on-fail + one copy-log button. Debug by checking the job first.
3. **Agnostic by default.** A job links to what it acted on via the generic `(site, target_type, target_id)` triple — never a site-/object-specific column. New object or site = no schema change.
4. **Self-heal over diagnose-then-act.** One smart verb that figures out what's needed beats N manual verbs. `ensureFbSession` heals cold sessions mid-action. Expose manual rungs as escape-hatches, never the default.
5. **Never trust a click — re-read the authoritative end-state.** Mutating adapters re-open the menu / reload `/people/` and only return ok once the state actually flipped (`okWith`/`failWith`). Optimistic UI lies.
6. **One source of truth per plane.** Accounts → keyvault. Live entity state + `job` ledger → bluebut-db (via bluebut-rest; cutover off hostbun `db.hostbun.cc` DONE/LIVE). Traffic firehose (`traffic_entry`/`har_recording`/`fb_traffic_live`) + scraped-feed → bofrid.dev (R/W). Legacy history (DMs/broadcasts) → bofrid.dev (read-only). HAR blobs → bluebut-minio `har` bucket. Writing the wrong plane is a bug.
7. **The accounts are the asset.** Anti-detect rules are enforced in code, not convention. Survival beats throughput.
8. **Adapters are first-class repo code.** Owned in `adapters/`, picked up on next run — no install/symlink. Fork upstream fixes down; don't edit toolbox up.
9. **Fail loud with forensics.** `failWith()` attaches a screenshot + selector strategies tried. Swallow errors only where a comment says it's deliberate.
10. **Precise terminology.** cookie ⊂ storage_state ⊂ session.
11. **Keep the gate green.** `npm run check` (lint + typecheck + build + test) on every push. Errors block.
12. **Run long-running scripts in a subagent, not the main loop.** Any script that occupies the session — the group-creation queue, the login-box flow, a long scrape/batch — launch it via the **Agent tool (background or foreground subagent)** so the **main agent stays free to monitor and inspect the running job** (tail logs, check the `job` ledger / HyperDX, answer questions) instead of being blocked waiting on it. The subagent owns the script's lifecycle; the main agent observes. Don't `Bash`-block the main loop on a script that runs for minutes.

## Running locally

```bash
npm install      # repo root — @jackwener/opencli (lib) + runner
cd ui && npm install
cd .. && npm run bridge   # terminal 1 — local adapter bridge (BRIDGE_PORT, .env → :8790)
cd ui && npm run dev      # terminal 2 — http://localhost:5173
```

> ## ⛔ LOCAL-FIRST — verify locally, DON'T push to test
>
> `git push` deploys to PROD (Coolify auto-deploys `@main`). It is NOT a test button.
> **The bridge + adapters + UI are all local code** — the same files prod runs — so every
> change is observable on your machine in seconds. A push is the LAST step, once the change
> is proven locally, not a way to see whether it works. Don't burn a prod deploy per edit.
>
> **The loop is `.env`-wired end-to-end:** `BRIDGE_PORT=8790` + `VITE_BRIDGE_URL=http://localhost:8790`
> (headroom squats the 8787 default, so we run on 8790), so a locally-running UI drives the
> locally-running bridge — same code path as prod. Test recipe by what you touched:
>
> | Changed | See it without deploying |
> |---|---|
> | **bridge** (`ui/bridge/*`) | restart `npm run bridge` (no HMR; ~1s boot). `curl localhost:8790/health` |
> | **listing text / compose** (`_lib/listing-post-text.js` + `/listings/compose`) | `curl -s localhost:8790/listings/ads?limit=1` → pipe one row into `POST /listings/compose` → read the text. **No FB post, no browser.** |
> | **adapter** (`adapters/**`) | picked up on the NEXT runner spawn — no restart. Run it: `node bin/bluebut-adapter.mjs adapters/facebook/<cmd>.js --account joel-skola …` |
> | **messenger ingest** (daemon→bridge `handleE2eeIngest`) | the whatsmeow daemon is PROD-only and POSTs to the PROD bridge, so a local daemon can't drive it. Test the bridge's persistence by **POSTing a sample `DecryptedMessage` to the LOCAL bridge**: `curl -X POST -H "authorization: Bearer $BRIDGE_INGEST_TOKEN" localhost:8790/messenger/e2ee/ingest -d '{"messages":[{"account":"pojtie","thread_key":"<peer_fbid>","message_id":"local-1","sender_id":"<peer>","timestamp_ms":<now>,"message_type":"image","text":"","is_self":false,"attachments":[{"attachment_type":"image","mimetype":"image/jpeg"}]}]}'` → it writes the SAME bluebut-db (local bridge reads the prod DSN), so check `fb_message`/`fb_message_attachment`/`fb_thread`/`fb_contact`. NO prod deploy, no live send. |
> | **UI** (`ui/src/*`) | Vite HMR — already live on :5173, no restart |
> | **pure logic** (`_lib/*`) | `npm test` (node --test) — a sibling `*.test.mjs` is faster than any endpoint |
>
> Push only a change that (a) is verified locally and (b) is actually meant for prod. Then
> follow the global "verify after deploy" rule. If a change is bridge/adapter/UI-only, the
> local run IS the proof — the deploy just ships it.

### Onboarding new accounts

**Cloakbox is the only onboarding path.** New account = the `add-account` skill — creds+proxy→keyvault, `cdp_new` mint device, `cdp_save` GROUPED before login, HUMAN VNC login, `cdp_save` session. NEVER login.py/Mac. Proxy assignment: `npm run proxy:pick -- --account <a> --geo SE` (proxyless accounts self-provision via `_lib/proxybun.js`). Known: capsolver self-hosted down → hand-solve captcha. (The old Mac-local login path — `local-dev/`, `login-container/`, `npm run local*` — is DELETED.)

Without the bridge, action buttons fail; read-only pages still work. Single root `.env` (Vite reads via `envDir: ".."`; bridge/runner/sniffer/canary/hyperdx all read it):

```
VITE_SUPABASE_URL=https://bluebut-rest.blpk.cc  # entity graph + job ledger (self-owned bluebut-db via PostgREST)
VITE_SUPABASE_KEY=<service_role_jwt>          # UI-ONLY (baked into the browser bundle at build). Server-side (runner/bridge/canaries) reads this JWT from KEYVAULT — `bluebut/config/rest-service-key` — not env.
VITE_BOFRID_SUPA_URL=https://supabase.bofrid.dev
VITE_BOFRID_SUPA_KEY=<service_role_jwt>
VITE_CHROMEBOX_URL=https://fb.chrome.blpk.cc  # box: CDP gateway + /chrome/* + /accounts/*
VITE_BRIDGE_URL=http://localhost:8790         # local opencli bridge (8790: headroom squats the 8787 default)

# self-owned data plane (Coolify; cutover DONE — entity graph on bluebut-rest, HAR on MinIO)
S3_ENDPOINT=http://minio-malct88jd5gp0miiag4jhslm:9000  # bluebut-minio; HAR blobs → `har` bucket
S3_ACCESS_KEY=<minio_root_user>               # also MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
S3_SECRET_KEY=<minio_root_password>
# BLUEBUT_HAR_BUCKET=har                       # default
```

## Deployment & infrastructure — Coolify (hostbun)

Everything runs on the **hostbun Coolify** (`coolify.hostbun.cc`, MCP `coolify-hostbun`), project **`bluebutchromebox`** / env **`production`** (`environment_id=21`), one server: **`funnelsites-bluebut-hexabyte-chrm`** = Hexabyte e3, **`194.104.94.173`**, Traefik proxy on docker network `coolify`. (NOT hetzner — that old `bluebut` app is a stale dupe.) Apps auto-deploy from `devdashco/bluebut@main` on push; one push redeploys UI + bridge together.

> ## ⛔ WORK ON `main` — NO FEATURE BRANCHES
>
> Coolify deploys `bluebut@main`, so `main` IS the deploy branch. **Commit straight to `main`
> and push** — do NOT open a feature branch or a PR for normal work. Keep the gate green
> (`npm run check`) before every push; a green push deploys. (The one exception is a genuinely
> risky, long-lived change the operator explicitly asks to stage off-`main` — otherwise always `main`.)

### Apps (project `bluebutchromebox`)

| App | Domain | Repo / branch | Role |
|---|---|---|---|
| **bluebut** | `https://bluebut.blpk.cc` | bluebut@main | The Vite UI. `VITE_BRIDGE_URL` baked → token-gated bridge. |
| **bluebut-bridge** | `https://bluebut-bridge.blpk.cc` | bluebut@main | opencli bridge: `{site,cmd}` → runner over box CDP. `BRIDGE_TOKEN` gates; `Dockerfile.bridge`. |
| **bluebut-db-mcp** | `https://bluebut-db-mcp.blpk.cc` | bluebut@main | MCP over `bluebut-db` (in the devdash marketplace). |
| **bluebut-e2ee** | — (no domain) | bluebut@main | Go/whatsmeow E2EE daemon — registers an FB E2EE device + decrypts type-15 Messenger bodies. Register-once store = `bluebut-e2ee-db`. |
| **chromebox-sniffer** | — (no domain) | bluebut@main | Always-on traffic firehose daemon → HyperDX + bofrid.dev `fb_traffic_live`. `Dockerfile.sniffer`. |
| **chromebox-fb** | `https://fb.chrome.blpk.cc` · `https://selkies.fb.chrome.blpk.cc:8081` | `devdashco/toolbox@chromebox-fb` | The box: per-account Chromium + CDP gateway (`/cdp/<acct>`) + `/chrome/*` + `/accounts/*` + Selkies. Profiles bind-mounted (survive rebuild). |

### Data planes — the self-owned move (cutover DONE)

bluebut has moved off the shared hostbun Supabase → its **own** private Postgres + MinIO in the Coolify project:

| Resource | Coolify name | Internal DSN / endpoint | Holds |
|---|---|---|---|
| **Entity-graph DB** | `bluebut-db` | `postgres://bluebut:***@p3jif8hgier7tzxnsze7ibik:5432/bluebut` | entity graph + `job` ledger + messenger. Replaces the shared hostbun Supabase. |
| **REST contract** | `bluebut-rest` (PostgREST) | `https://bluebut-rest.blpk.cc` (Traefik strips `/rest/v1`); `PGRST_DB_URI=…authenticator@…/bluebut`, same JWT secret so the existing service-role key validates | the `/rest/v1` surface the UI+adapters speak (Supabase-shaped). |
| **E2EE register store** | `bluebut-e2ee-db` | private PG | the E2EE device register-once Postgres (see `messenger/e2ee/PLAN.md`). |
| **Blob storage** | `bluebut-minio` (MinIO/S3) | internal `http://minio-malct88jd5gp0miiag4jhslm:9000`; env `S3_ENDPOINT`/`S3_ACCESS_KEY`/`S3_SECRET_KEY`, `BLUEBUT_HAR_BUCKET` (default `har`) | private `har` bucket (HAR blobs — account-takeover material) + `fb-media`. Replaces Supabase storage. |

**Migration status: DONE / LIVE.** Entity graph + job ledger served by `bluebut-db` via `bluebut-rest.blpk.cc`; HAR on MinIO; `chromebox_sessions` retired (keyvault sole account store). **bofrid.dev unchanged** — traffic firehose + scraped-feed (R/W) and legacy DMs/broadcasts (read-only) stay there.

Secrets are not in this file — `S3_*`, `BRIDGE_TOKEN`, JWT/service-role keys, DB passwords live in **keyvault** + the Coolify app env (`***` masked). Internal DSN hosts are the Coolify resource UUIDs, reachable only on the `coolify` docker network. Observability → shared `hyperdx-v2` (`otel.hyperdx.hostbun.cc`, `ServiceName='bluebut'`/`'chromebox-traffic'`).

## Tech stack

- React + Vite + TypeScript; TanStack Query.
- **Use the component library — never hand-roll UI primitives.** (1) app primitives in `ui/src/components/Ui.tsx` (`Button`, `Input`, `Select`, `Card`, `Row`, …) — Ui.tsx IS the primitive layer, the only file allowed to render the raw elements; (2) **MUI v9** (themed dark, `ui/src/theme.ts`) for everything else. The lookup table:

  | Need | Use | Never |
  |---|---|---|
  | modal | `<Dialog>` | a `position:fixed; inset:0` backdrop div, raw `<dialog>` |
  | side panel | `<Drawer anchor="right">` | a bespoke slide-over |
  | menu / popover | `<Menu>` / `<Popover>` | an absolutely-positioned div |
  | dropdown | `<TextField select>` + `<MenuItem>` (`slotProps={{ select: { displayEmpty: true } }}` when the empty option is the placeholder) | a raw `<select>` + inline `style` |
  | tabular list | `<AppDataGrid>` (MUI X DataGrid) | a raw `<table>` |
  | clickable list rows | `<List>` / `<ListItemButton selected>` | styled `<button>` cards |
  | error / warning box | `<Alert>` + `<AlertTitle>` | a red-bordered div |
  | loading placeholder | `<Skeleton>` | a gradient div |
  | small status pill | `<Chip size="small">` | a styled `<span>` |
  | text / layout | `<Typography>` / `<Box>` / `<Stack>` on theme colors | `<div style={{ color: "#9ca3af" }}>` |

  (MUI v9 gotchas: paper styling is `slotProps={{ paper: { sx } }}`; system props are gone off `Typography`/`Stack` — put `fontWeight`/`alignItems`/`flexWrap` in `sx`.)

  **Enforced, not merely documented:** `ui/eslint.config.js` `no-restricted-syntax` makes a hand-rolled backdrop, a raw `<dialog>`, a raw `<select>` and a raw `<table>` a CI **error** naming the replacement. `Ui.tsx` is exempt (it's the primitive). The ~11 legacy sites carry a line-scoped `eslint-disable-next-line … -- legacy raw <select>/<table>: migrate to …` — that comment IS the migration backlog; delete it by migrating, never copy it into new code.
- **Color = design tokens, NEVER raw hex.** ONE semantic palette is the source of truth: the CSS `@theme` block in `ui/src/index.css` (`--color-bg`/`-surface`/`-fg`/`-muted`/`-faint`/`-line`/`-primary`/`-info(-fg/-bg)`/`-success(-strong/-fg/-bg)`/`-warning(-fg/-bg)`/`-danger(-strong/-fg/-bg)`/`-violet(-fg/-bg)`/`-hover`) with its JS mirror in `ui/src/tokens.ts` (import `t` where a real color is needed — the MUI `theme.ts`, canvas/SVG-attr colors). Reference `var(--color-<role>)` in every inline `style`/`sx` and Tailwind utility; a hex literal in a component is drift (there were ~950 of them across 4 grays-for-one-role etc — collapsed to the token set). One role → one value, everywhere. Keep `index.css @theme` and `tokens.ts` paired.
- **Frontend/UI polish → the `impeccable` skill.** Any design/redesign/critique/audit of a UI surface — visual hierarchy, spacing, typography, color, motion, accessibility, empty/error states, responsive behavior — invoke `/impeccable` first. It governs how UI work is shaped here; the component-library table above is the primitive floor, `impeccable` is the taste layer on top.
- No routing library — a tiny History-API router (`ui/src/lib/router.ts`). Route `/<page>[/<sub>]`; navigate with `navigate(page, sub?)`. Refresh/deep-link safe.

## Conventions

### Session terminology (use these exact words)

```
cookie  ⊂  storage state (storage_state)  ⊂  session (the account record)
c_user      { cookies[], origins[] }           { storage_state, fb_id, status, proxy, … }
```

- **cookie** — ONE FB `name=value` pair (`c_user`, `xs`, `datr`, `i_user`).
- **storage state** (`storage_state`) — the auth bundle = cookies **+ localStorage origins** (Playwright/CDP shape). The precise name for what we inject/dump/persist. Prefer over "jar".
- **session** — the account **record** = `storage_state` + identity (`fb_id`, `display_name`) + `status` + `proxy` + metadata. Contains a storage state.

`jar` survives only as casual shorthand + stable identifiers (`decodeJar`, `jar-truth.js`) — don't introduce it in new code.

### Quality gate — `npm run check`

CI (`.github/workflows/check.yml`) runs it — plus the ui build — on every push to main + every PR. ERRORS block; WARNINGS tolerated. Coolify auto-deploys main independently, so CI is the TRIPWIRE (revert on a red ✗), not a deploy gate. Run `npm run check` locally before every push.
- **`npm run lint`** — root ESLint (Node: `adapters/`, `bin/`, `ui/bridge/`).
- **`npm run lint:ui`** — UI ESLint (TS-aware). `lint:all` = both.
- **`npm run typecheck`** — `tsc -b` in `ui/`. **`npm test`** — adapter unit tests (`node --test adapters/_lib/*.test.mjs`).
- Underscore prefix (`_unused`) marks an intentionally-unused var/arg/caught-error.

### DB schema — NO ORM, NO migration tool (deliberate)

There is **no Drizzle/Prisma/Kysely and no migration runner** — and there shouldn't be. The DB is reached ONLY over PostgREST REST (`_lib/db.js`, UI `dbGet`), so an ORM's query layer would go unused; and the schema is **self-extending at runtime** (`fb_schema_ingest` RPC + ~77 `ADD COLUMN IF NOT EXISTS`), which a versioned-migration tool would fight. The **three `SCHEMA.sql` files ARE the schema-as-code** — idempotent (`CREATE TABLE IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`), heavily commented, rebuildable; re-applying the whole file converges prod to the file. **Apply them by hand via the `bluebut-db` MCP** (or psql) — that is the "migration" step; there is no `schema:apply` script (running DDL from Node would need `pg`, an unwanted dep).

- **`npm run db:types`** — generate `ui/src/api/db-types.gen.ts` (one `Db<Table>` interface per bluebut-db table) from the LIVE PostgREST swagger, ZERO deps (just `fetch`, reads `VITE_SUPABASE_URL/_KEY` from `.env`). This is a **drift-check reference for the hand-written runtime interfaces** in `api/index.ts` (`GroupPost`, `FeedPost`, …) — NOT imported at runtime (several UI types are bridge/view SHAPES, not raw rows). Regenerate after a schema change and diff to catch a stale interface (the class of bug behind `is_premium` vs `premium_listing_price_ore`).

---

## Jobs — every action is a job (the agnostic spine)

Every operator action runs as a **job** — the only way the UI calls an adapter. The job is the unit of work *and* observability; not tied to groups or FB.

### Taxonomy — four layers (think Skyvern: WorkflowRun → Task → Step → Action)

| Layer | Term | What it is | Identity |
|---|---|---|---|
| Envelope | **Batch** | one operator **intent** fanned across N targets (bulk post/admin/scrape). A single action is a batch of one. | `batch_id` |
| **Unit** | **Job** | exactly ONE adapter run against ONE target (or a self-driven chain). The atomic observable + retryable unit. | **`job_id`** |
| Phase | **Step** | a named phase *inside* a job — `step()` → `job.event()`. Drives the timeline. | — |
| Atom | **Action** | the atomic CDP op a step performs (click/type/nav/GraphQL). Implicit — the `chromebox-traffic` trail. | — |

Source of truth: `adapters/_lib/taxonomy.js` (`LIFECYCLE`/`TERMINAL`, `ActionType`, `OPS` with read/write per adapter, `classify`/`rollup`/`opFor`). UI re-declares the static enums in `ui/src/lib/taxonomy.ts` (no cross-boundary import); `taxonomy.test.mjs` guards drift. New automation → the **`new-automation`** skill.

- The **Job** is the unit — a chain of adapters is still ONE job with more steps.
- A **Batch** owns 1..N jobs and home for cross-target concerns (routing, jitter, cooldown, stop-on-block). Don't model a batch as a fake mega-job.
- A **Step** is a phase, not a second adapter call (that's a second Job in the same Batch).

### The durable `job` ledger — object-agnostic (`adapters/SCHEMA.sql`)

One generic `job` table in the entity-graph DB (bluebut-db; site-agnostic schema at root; per-site tables in `adapters/<site>/SCHEMA.sql`). Durable record behind the bridge's wiped-on-restart in-memory list.

```sql
CREATE TABLE job (
  job_id text PRIMARY KEY,   -- BLUEBUT_JOB_ID (same id threaded to HyperDX)
  batch_id text, site text, cmd text NOT NULL, op text, access text, account text,
  target_type text,          -- 'group' | 'page' | 'person' | 'account' | null
  target_id text, target_url text, target_name text,
  status text NOT NULL,       -- running | ok | failed | partial | blocked | content_unavailable
  ok boolean, aborted_reason text, summary jsonb,
  har_key text,               -- bucket key of this job's full HAR: job/<job_id>.har.gz
  started_at timestamptz, ended_at timestamptz, duration_ms integer,
  created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
);
-- indexes: (target_type,target_id,created_at DESC), (created_at DESC), batch_id, (account,created_at DESC)
```

**The link is the generic `(target_type, target_id)` pair.** Any object's history = same store, no schema change: a group's jobs = `target_type='group' AND target_id=<fb_group_id>`; page/person/account analogous. `site` records which adapter family ran it.

- **Write** — the runner writes one row per run, best-effort: `status=running` on start, terminal state on end.
- **Read** — `Q.jobsForTarget(type, id)` (`ui/src/lib/util.tsx`); `GroupJobs.tsx` uses it. Point any new object-history surface at this query — never a per-object bespoke log.
- **Still group-shaped, fix when extending:** target resolution only maps `--group`/`--page` args → `target_type` (the map is `[['group','group'],['page','page']]`); account/person-scoped commands leave `target_type=null`. The terminal update only enriches from `result.group_*` and falls back to `target_type='group'`. Generalize both before relying on page/person history.

### The model — `ui/src/state/jobs.tsx`

- **`runJob({ label, account, site, cmd, args, summarize })`** — one-shot adapter call; auto status/toast/failure-log.
- **`startJob({ label, account, site, cmd })`** → `{ event, succeed, warn, fail }` — a multi-step flow the page drives (e.g. promote→verify→accept in `util.tsx`).
- **Status: `running | ok | warn | failed`.** `warn` = completed but not verified (invite sent, role unconfirmed) — distinct from `ok` (verified).

### Observability is built in

- Every step + terminal state → HyperDX via `shipLog` (`ui/src/logger.ts`): `[job:step|ok|warn|fail]`, tagged `job_id`, `ServiceName='bluebut'`.
- `job_id` threads UI → bridge → runner (`BLUEBUT_JOB_ID`), so the FB network trail (`ServiceName='chromebox-traffic'`, `source=runner`) shares the id.
- On failure: a `diagnostic` (`adapters/_lib/diagnostics.js` `failWith()`) — screenshot + selector strategies. Rendered inline in the Jobs dock.
- **One `⧉ copy log` button per job** (`jobToText`) → id + steps + diagnostic + ready-to-run HyperDX query.

### Where jobs surface

- **GlobalJobsDock** (bottom-left, app-global, live; expand to watch its tab).
- **Jobs page** (`/jobs`) — history + inline live tab + bridge job list (`fetchBridgeJobs`).
- **Live tab screencast** — bridge relays box Chromium's `Page.startScreencast` over `WS /screencast?account=X` into a canvas (`components/LiveScreen.tsx`). Watching a job = watching the real anti-detect Chrome.

**Rule for any new feature:** run it through a job, ship steps by `job_id`, link via `(target_type, target_id)`, attach a diagnostic on failure, expose copy-log.

---

## Adapters — project-owned, run via the local runner

Everything an adapter needs lives in this repo — no install, no symlink, no global `opencli`:

```
bluebut/
  bin/bluebut-adapter.mjs   # the runner — argv → register → CDP → JSON. Its `finally`
                            #   is the post-job pipeline: stop capture → saveGraphqlCalls
                            #   → mineHar (atlas) → harvestEntities (PASSIVE WIRE) → buildHar
  adapters/
    SCHEMA.sql              # site-agnostic shared schema (the `job` ledger)
    _lib/                   # shared helpers — NO adapter is a "site"; everything cross-
                            #   adapter lives here, ESM, with a sibling <name>.test.mjs:
                            #     netcapture.js   — CDP capture (GraphQL bodies + WS frames)
                            #     fb-schema.js    — the ONE Relay decoder (parseGqlJson,
                            #                       extractInlinedBlobs, mineTypes/mineHar)
                            #     passive-wire.js — ambient harvester: WIRES registry +
                            #                       harvestEntities() (reuses fb-schema's decoder)
                            #     db.js           — the one PostgREST write client (auto-steps)
                            #     sessions.js · group-sync.js · taxonomy.js · diagnostics.js · …
    facebook/<cmd>.js       # one cli({...}) per file (active/pull adapters)
    facebook/SCHEMA.sql     # per-site FB entity tables (groups, people, members, …)
  messenger/                # the Messenger box (site:'messenger') — its OWN subsystem,
                            #   NOT under adapters/ (jobs/, lib/, decode.js, e2ee/)
```

**Where new extraction code goes (by the two axes — see the Extraction doctrine):**
- **Active/pull** read or write of ONE object → a new `adapters/facebook/<cmd>.js` (`cli({...})`), run as a job.
- **Passive/ambient** harvest that should ride EVERY job → a new entry in `WIRES` inside `adapters/_lib/passive-wire.js` (`{ name, typenames, extract }`) — never a new adapter, never a new folder. The runner already calls it.
- **A new Relay-decode primitive** (parsing, id reassembly, blob extraction) → `adapters/_lib/fb-schema.js`, so there stays exactly ONE decoder both the atlas miner and the passive wire share.
- Every `_lib/` module gets a sibling `*.test.mjs` (run by `npm test`: `node --test adapters/_lib/*.test.mjs messenger/lib/*.test.mjs`). New entity tables / column ALTERs → the matching `SCHEMA.sql` (site-agnostic → root; FB entity tables → `adapters/facebook/SCHEMA.sql`).

opencli is a library (`@jackwener/opencli/registry` for `cli({...})` + `Strategy`); the runner does dispatch + CDP lifecycle. **Pinned to `1.7.8`** (1.7.22+/1.8.x enforce `access:'read'|'write'` on every `cli({...})` — a bounded future change).

### ⭐ Extraction doctrine — wire-based GraphQL replay is the DEFAULT; DOM scraping is DEPRECATED

This is the operational form of the truth-ladder rule at the top of this file. **Any adapter that READS data from FB reads it off the same persisted-GraphQL wire the page itself reads** — it does NOT scrape rendered HTML. DOM scraping (`innerText` / `querySelector` / `role="article"` / `aria-*` / matching localized labels) is the **bottom rung**: it breaks on every locale change and every FB UI refresh, fakes timestamps, and can't see hidden data (folders, real ids). New read adapters are wire-based, full stop. **The legacy DOM read-scrapers are a migration backlog, not a template to copy.**

#### Two axes: where the data lives × how we consume it

**Axis A — the SOURCE** (where a fact lives) has three answers. **Axis B — the MODE** (how an adapter gets it) has two.

**Axis A — source (3, ranked by the truth ladder):**

| Source | What it is | A "wire"? | Read it via |
|---|---|---|---|
| **GraphQL** | The persisted-Relay data the page reads — whether **XHR'd** on demand (`doc_id` + `end_cursor`) or **server-inlined** into the document at first paint (`<script type="application/json">` blobs: `visibility`, `viewer_join_state`, `if_viewer_*` gates). Same `__typename`/`id` records, two delivery paths. | **Yes** | replay the `doc_id` (`list-joined-groups`) or parse the inlined blob (surface-extract skill) |
| **Socket** | Realtime push frames — Messenger/LightSpeed, MQTT publish+ack, DataGateway RPCs. Lives on a **separate CDP target** from the page. | **Yes** | browser-level WS tap (`netcapture` `wsApply`) → `_webSocketMessages` → `ws-decode.js` / `messenger/decode.js` |
| **DOM** | Rendered HTML / localized `innerText` / `aria-*`. Carries no `__typename`, no stability contract. | **No** — the **bottom rung**, last resort only | `querySelector` (legacy scrapers — migration backlog) |

A **wire** = a structured source you read by typed field, not by label → **GraphQL and Socket are wires; the DOM is not.** "Wire-based" = read off a wire.

**Axis B — consumption mode (2):**

| Mode | What it means | When |
|---|---|---|
| **Active** (pull) | An adapter **drives a surface** and reads the wire it triggers — replay the `doc_id`, parse the inlined node, open the thread. On-demand, per-object. | You need a specific object's data NOW. **The default for a new adapter.** |
| **Passive** (ambient) | A single **agnostic harvester** decodes whatever wire traffic **already flowed** on *any* job and writes the freshest value home — job-, site-, object-agnostic. Zero extra FB calls. | The fact rides along anyway (a member roster carries `User` avatars; a group response carries our own actor). Run group-sync, and if our profile pic changed, update it on the spot. |

**The passive wire — `adapters/_lib/passive-wire.js` (full playbook: the `passive-wires` skill).** Every job buffers its GraphQL + inlined-Relay bodies in-process. `harvestEntities(network, ctx)` runs in the runner's `finally` (sibling to `mineHar`), walks every Relay record once, merge-upserts what it recognizes. **ALWAYS-ON** — harvest + every capture prerequisite default ON (opt-out `!== '0'`: `BLUEBUT_PASSIVE_WIRE`/`_CAPTURE_GQL_BODIES`/`_CAPTURE_PAGES`/`_WORKER_HTTP`; WS tap always attaches), at BOTH sites (box runner finally + local rolling capture). Recognizers, **three kinds**: (1) `__typename`-routed entries in the `WIRES` registry; (2) `__typename`-less **signature-detected** handlers in `collect()` (Relay strips `__typename` off fragments — `group-about-fragment`, `join-config`, `invite`); (3) self-scoped `freshenOwn*` wires. Routed by `__typename`+numeric `id`+strong signature, never a DOM label.

> **⭐ THE FULL-OBJECT MANDATE — mirror FB's object into ours.** When you wire an entity, harvest **every field the node carries** — the aim is to replicate FB's object in our DB; a field skipped now is an extra FB call (= detection risk) later. Discipline: (1) **dump the whole node first** and read its full key union before writing the extractor; (2) **reassemble across fragments** — match by signature field + numeric id, merge by id (never assume one node = the whole object); (3) **structural fields only** (`privacy_info.icon_name`), a display string only when FB exposes it nowhere else; (4) **grow the schema to fit** — `ALTER … IF NOT EXISTS` + `NOTIFY pgrst` (expected, not scope-creep); (5) **reject phantoms** — require a strong entity-only signal + a test; (6) **prove it live** against bluebut-db. **Every wire change updates the docs in the same commit** (this field list, the `new-automation` skill, a memory entry). The `group` wire is the reference.
- **`person`** → `User` nodes → **`fb_person`** (the ONE User/lead/actor hub, canonical on numeric `fb_id`; INSERT-new-only so a passing node never clobbers a richer scrape). *fb_user + fb_profile were redundant second/third User tables — folded into fb_person + dropped 2026-07-08. See [[reference_fb_identity_model]].*
- **`group`** → `Group` nodes → `fb_group` (the FULL about-object: name, member_count, privacy via `privacy_info.icon_name`, cover, `has_membership_questions`, description via `description_with_entities`, location + `location_fb_id` from `group_locations[]`, `created_time` from `group_history`). FB spreads these across the header `Group` node AND `__typename`-less about-card fragments — a signature detector picks up the fragments, `collect()` reassembles by `fb_group_id`. Active twin = `probeGroupWire` (`_lib/facebook.js`, same inlined `/about` blobs + `group_admin_profiles` roster), called WIRE-FIRST by `probeGroup` (DOM drift-fallback only).
- **`page`** → `Page` nodes → **ENRICH-ONLY** `fb_page` (PATCH name/username matched by `page_fb_id`). A `Page` is its OWN typename, **never folded into the `fb_person` User hub via the passive `person` wire** (would poison it). NB: a Page CAN still legitimately appear IN fb_person as an ACTOR/lead when it POSTS (a company page posting a listing) or is a group member — fb_person is the actor hub, `is_company` distinguishes; the rule is only that the passive `person`/`User` wire must not create Page rows. A bare `Page` node in passing traffic doesn't carry the `asset_id` (`page_key`), so this wire can't INSERT — that's the **`managed-page`** wire's job (below). An account can act AS a page, so a Page's pic also feeds the avatar wire.
- **`managed-page`** → a `ProfileSwitcherEligibleProfile` node (FB inlines `profile_switcher_eligible_profiles` on ~every document) → **INSERT** `fb_page` + the **`fb_account_page`** account↔page link. The ONE node carrying `delegate_page_id` (= `asset_id` = `page_key`), the key a bare `Page` lacks — so unlike the enrich-only `page` wire it creates both rows. Requires `delegate_page_id` (→ a real managed page). Shares `toPageRow` with active `list-pages` (`_lib/pages-wire.js`); additive. **Also denormalizes onto the account's KEYVAULT row** (`managed_pages = [{page_key, asset_id, page_fb_id, name, username}]`, via `freshenManagedPages`) so a downstream LOADER (fb-bot) can load an account → act AS a page without touching bluebut-db. bluebut-db `fb_account_page` = source of truth; `keyvault.managed_pages` = its loader-facing projection.
- **`post`** → group feed `Story` nodes → `fb_post` (merge-upsert on `post_url`) + poster promotion, via `adapters/_lib/post-sync.js`. Wire twin of the deprecated `scrape-group`: reads STRUCTURAL fields off the Story — numeric `post_id` (dedup key), `permalink_url`, epoch `creation_time`, owning `User`, body via a bounded comment-safe walk — off **any** job that loaded a group feed. Scoped to `/groups/`. Poster promotion = `fb_person` upserted on canonical `fb_id` + `fb_person_signal` 'posted' (same `post-sync.js` helper `get-group-feed` calls). Passive wire does NOT feed the bofrid.dev fb-bot pipeline (that's `get-group-feed`'s deliberate write).
- **`comment`** → group-post `Comment` nodes → `fb_comment` (merge-upsert on `comment_id`=`legacy_fbid`) via `post-sync.js` `extractStoryComment`/`persistComment`. Reads STRUCTURAL fields: `legacy_fbid` (dedup), `feedback.id`, `feedback.url` (→ derives `post_url`+`group_fb_id` from the comment itself), `body.text`, epoch `created_time`, `depth`, `author{id,name,url}`, parent `comment_direct_parent.legacy_fbid`, reply count. **Reactions COUNTS-ONLY** (`feedback.reactors.count`+`top_reactions`) — no per-reactor table (paginated query = detection risk). Scoped to `/groups/`. `fb_comment.post_url` FKs `fb_post` → `persistComment` retries `post_url=null` on 23503. Comments ride the permalink document (inlined Shape B) / UFI XHR, NOT the feed wire; **active twin `get-post-comments`** (`_lib/comment-wire.js`, pure inlined read, no DOM click).
- **`message`** (LightSpeed twin — NOT a Relay `WIRES` entry) → Messenger `fb_thread`/`fb_message`/`fb_contact`/`fb_thread_participant`/`fb_message_reaction` via `messenger/lib/passive-messages.js` `harvestMessages`. Threads ride FB's **LightSpeed step-VM payload** (`lightspeed_web_request`), the SAME wire `messenger/sync-inbox`/`sync-thread` read. `collectLsCalls` gathers LS blobs from all three passive sources — `/api/graphql` responses, SSR-inlined initial sync (`pages[].html`), binary `ws/lightspeed` frames (`lsPseudoCallsFromWs`) — then runs the SAME `extractPayloads`→`decodeCaptured`→`persistTables` (ADDITIVE) path. **E2EE caveat:** `thread_type=15` threads carry METADATA + the ENCRYPTED envelope, never a plaintext body — bodies come only from the bluebut-e2ee daemon. Gated `BLUEBUT_PASSIVE_MESSAGES` (default on); wired at all three harvest sites. `is_self` off `readActingIdentity`. Test: `passive-messages.test.mjs`.
- **`join-config`** → a group's JOIN-DIALOG node (`GroupsCometRequestToParticipateOnJoinDialogQuery`'s `group`) → the join contract: `fb_group` flags (`is_rules_agreement_enabled`, `has_email_verification_question`, `join_questionnaire`, `join_email_config`) + `fb_group_join_question` + `fb_group_rule`, via `join-config-wire.js` `extractJoinConfig` + `group-sync.js` `upsertGroupJoinConfig`. Node is **`__typename`-less** → a STRONG signature detector (`isJoinConfigNode`: numeric id + `community_participation_questions[]` + `is_rules_agreement_enabled`) routes it in `collect()`. **Additive-only** (active `get-group-join-config` owns the authoritative replace). NB: FB's legacy `has_membership_questions` ≠ the modern `community_participation_questions` gate — this wire never writes `has_membership_questions` (the `group` wire owns it).
- **`notification`** → EVERY notifications-jewel notif → `fb_notification` (per-account: `notif_type`/`actor_fb_id`/`target_id`/`body`/`seen_state`/`created_time`). Signature-detected off the notif's `tracking` JSON blob (STRUCTURAL `notif_type`, never localized body). The jewel (`CometNotificationsDropdownQuery`) rides ~every job → comments/mentions/tags/friend-accepts self-harvest. The group-invite subset also lands in `fb_group_invite`. Additive; `body` display-only. Scoped to `ctx.account`.
- **`my-membership-edge`** → the VIEWER's own edge off a `Group` node carrying `viewer_join_state` and/or the per-node admin gates → `is_member` to `fb_account_group` (always safe, additive) + our `role` to `fb_group_member` (OUR fb_id, from `readActingIdentity`). **Role is written ONLY when the reliable `if_viewer_is_admin`/`if_viewer_is_not_admin` gate is present** (a group-page visit inlines it) — NEVER a guessed `member`, so a passing `all_joined_groups` node can't downgrade a known admin (the roster DELETE stays banned passively; `get-user-groups` owns the authoritative role split via the `ADMIN_MODERATOR_GROUPS` listType). So visiting any group we admin self-heals our admin edge.
- **own-avatar** → the account's own actor (`User` or acted-as `Page`) → if the scontent asset token changed, `saveAvatarToStorage` + repoint `avatar_url` in keyvault (token cached in `avatar_src`; matched by stored `fb_id`). **Bytes → `fb-media` MinIO bucket** (`_lib/media-store.js`), served via the bridge's public `GET /media/fb-media/<key>` (un-tokened, unlike `/har/blob`). `avatar_url = ${BLUEBUT_MEDIA_BASE}/media/fb-media/avatars/<account>.<ext>`.
- **own-session** → when FB **server-asserted the account is live this job** (`sessionSaveDecision`), re-dump the jar to keyvault — but ONLY under a 6-rung gate so it can **never replace a working session with a broken one**: (1) dumped jar logged-in (`c_user`≠0 + `xs`), (2) complete (`datr` present), (3) FB served an authenticated document (`readActingIdentity` non-zero `USER_ID`), (4) no integrity error (`accessFromNetwork` ∉ {checkpoint,blocked,auth} — a walled session still inlines a `USER_ID`, so the access verdict closes that hole), (5) same account (`actingId===c_user`), (6) `xs` rotated. Prior jar stashed to `storage_state_prev`; stamps `storage_state_saved_at`.
- **Device bundle is NOT passive** (deliberate). The full anti-detect profile — FB device tokens (`datr` = the ~2-year FB device id; `sb`/`dbln`/`fr`) + client fingerprint (UA/Client Hints, screen/DPR, hardwareConcurrency/deviceMemory, tz/locale, WebGL vendor+renderer, canvas/audio hash, platform, webdriver tell) — is captured **ACTIVELY** by `persistDeviceProfile` (`_lib/device-profile.js`) at **login + load-session** only, stored as `device_profile`+`fb_device`. Binding one stable profile per account is what stops the logouts. No passive device wire.
- Writes are **additive merge-upserts (or enrich-only PATCH for pages)** — never the destructive roster DELETE `upsertMembers` does (passive sees one node, not an authoritative set); role edges + account↔page links are deliberately NOT written passively. Everything best-effort; never touches the job result.
- **Backlog (high-value, doc_id already known):** the **notifications jewel** is the free-rider — `CometNotificationsDropdownQuery` (doc_id `27646303031640503`), fired on ~every page, and `get-user-groups` already decodes group-admin invites off it (`notif_type=group_invited_to_group` → `fb_group_invite`). Add an `invite`/`notification` wire to `WIRES` that recognizes those story nodes passively, so admin/group invites self-refresh on *any* job instead of only when `get-user-groups` runs. (Future: a general `fb_notification`.)

**The (active) wire pattern (copy from a reference impl, don't reinvent):**
1. The **cloakbox wiretap** (the **surface-extract** skill — captures every seed's traffic incl. WS) on the target surface → its backing `doc_id` + `variables` + the response path (`…edges[].node` + `page_info`).
2. Author `adapters/facebook/<cmd>.js` with an **in-page replay** (`pageExpr`): read `fb_dtsg`/`lsd`/`uid` LIVE from the page, `fetch('/api/graphql/', …)` with that `doc_id`, page on `end_cursor` until `has_next_page:false`.
3. Extract by **structural field name + stable enum key** (`viewer_join_state`, `tab_groups_list.edges`) — NEVER a DOM label.
4. `doc_id` ROTATES → keep it in an env-overridable const and re-capture (cloakbox wiretap) when the query starts erroring; fail loud (`failWith`) with the doc_id in the message. See the **doc_id lifecycle doctrine** below for the exact discipline.

#### ⭐ doc_id lifecycle — the discipline (enforced by `eslint` + skill `fb-doc-id`)

A `doc_id` is FB's **persisted-query id**: the server stores the query text; the client sends id + `variables` + `fb_dtsg`. How every FB read AND mutation is replayed. We handle it like the mature players — **hardcoded constants, patched by hand, detected-on-failure.**

**1. Per-FB-build, NOT per-account.** Same id for every account on the same FB release. Per-account/session = the tokens + variables (`fb_dtsg`/`lsd`, `uid`=`c_user`, vars), read LIVE off the page each run. Never store `fb_dtsg`.

**2. Pin every doc_id in an ENV-OVERRIDABLE const keyed by FriendlyName** (mirrors mautrix-meta's hand-patched `{DocID, FriendlyName}` map):
```js
const DEMOTE_DOC = process.env.BLUEBUT_DEMOTE_ADMIN_DOC_ID || '27831756479762517'; // GroupsCometMembersRemoveAdminMutation
```
Env override = patch a rotated id without a rebuild. **A bare inline doc_id literal is an eslint error** — named const + env fallback + FriendlyName comment. Two flavors: `doc_id` (default) and `client_doc_id` (client-persisted, e.g. IG avatar); capture tells you which — don't assume.

**3. The PAGE is the truth-source; a const can drift.** doc_ids live in FB's JS bundles → discovery = load the page, capture the id (cloakbox wiretap). Rotation check = capture live, grep the FriendlyName, diff vs the const. **Read the id live off the page instead of pinning when you can** (the Messenger box does — zero hardcoded doc_id).

**4. Rotation is DETECTED, never scheduled.** A stale doc_id can't fake success — FB returns a loud `PersistedQueryNotFound`-class error (HTTP 400, or 200 + `{"errors":[…]}`), never silent wrong data (the whole reason wire > DOM). `failWith()` with the doc_id in the message; `npm run canary:admin` (cron) catches a rotation the day it ships. Re-capture, swap the const/env, done.

**Reference implementations (read before writing a new extractor):**
- `list-joined-groups.js` — joined groups (doc_id `9974006939348139`). The wire replacement for `list-groups`.
- `get-activity-log.js` — paginated stories (doc_id `27054591887516604`, `category_key` swapped — 134 categories one query).
- `set-account-language.js` — a **mutation** replay (doc_id `23915562351374516`) — proof WRITES can be wire.
- `create-group-post.js` + `_lib/create-post-wire.js` — group-post **mutation** (`ComposerStoryCreateMutation`, doc_id `28568493719405999`). Text (own + act-as-Page) fires the persisted mutation directly; the response hands back id+permalink (no feed re-scan). **Act-as-Page is wire**: identity rides the envelope as `av===__user===input.actor_id===<page fb_id>` signed by the manager's unchanged `fb_dtsg` (i_user-flipped session shares it). IMAGE posts still fall to the DOM composer. On success mirrors into `fb_post` (`persistOwnPost`, `authored_by_us`, numeric id + base64 `story_gid`).
- `delete-group-post.js` + `_lib/delete-post-wire.js` — DELETE twin (`useCometFeedStoryDeleteMutation`, doc_id `36213403264942057`). ⚠ targets `input.story_id`=base64 `story_gid`, NOT numeric `post_id` (`resolveStoryIdExpr` reads it off the permalink's inlined Relay). Ownership-gated on `fb_post.authored_by_us`; re-reads to confirm gone; tombstones the row.
- The whole **Messenger box** (`messenger/`) — DMs off the LightSpeed wire.

**External references (prior art, cloned in `meta-reverse-engineered/`):** the "Reverse-Engineering Messenger" case study (intuitiveexplanations.com/tech/messenger — FB web request shape); `raxod502/unzuckify` (our session model: durable jar reused, `fb_dtsg` re-scraped live, login only on auth-fail; doc_id discovery by grepping the served JS for the FriendlyName — what `bin/canary-doc-ids.mjs` does); `mautrix/meta` (the Go client `decode.js` is ported from — hand-patched `{DocID, FriendlyName}` map).

**Migration backlog — DOM read-scrapers → wire replay (each entry: don't reintroduce the DOM scraper):**
- `list-groups`/`list-member-groups`/`list-group-invites` → **DELETED** — replaced by `list-joined-groups` + `get-user-groups`.
- `sync-messages` + `adapters/facebook/send-message.js` → **DELETED** — superseded by `messenger/jobs/sync-inbox`/`send-message` (LightSpeed wire).
- `scrape-group` → **DELETED** — replaced by **`get-group-feed`** (`GroupsCometFeedRegularStoriesPaginationQuery`; MANY groups per warm session via `--groups`; full structural post via `post-sync.js` → `fb_post`+`fb_person`+fb-bot feed). Passive `post` wire = ambient twin.
- `scrape-new-members` → **WIRE** — `ActivitiesListPaginationQuery` (doc_id `27418668794433773`) + `GroupsCometAdminActivityLogItemSeeDetailsDialogQuery` (doc_id `37221570147456563`) via `_lib/member-request-wire.js`. Full member object (fb_id, profile_url, account_created_at, city+city-page-id, education/work/about, membership+rules enums, questionnaire answers incl. email).
- `scrape-profile` → **WIRE** — inlined Relay (Shape B, `_lib/profile-wire.js`), `User` reassembled across fragments → identity + all public about fields/sections + timeline_context, stored in `fb_person.profile_raw`. Privacy-gated.
- `list-friends` → **WIRE** — inlined `viewer.all_friends` (Shape B) off `/friends/list`. Fail-loud if it overflows the inlined page and the pagination doc_id is unset.
- `list-friend-requests` → **WIRE** — incoming = inlined `viewer.friend_requests`; outgoing = `FriendingCometOutgoingRequestsDialogQuery` (doc_id `9355113201284242`).
- `get-group`/`ingest-groups`/`sync-new-groups` (shared `probeGroup`) → **WIRE** — `probeGroupWire` (`_lib/facebook.js`) reads the about-object off inlined `/about` + the `group_admin_profiles` roster on `/members/admins`; wire-first, DOM drift-fallback only. LIST enumeration also WIRE via shared `enumerateJoinedGroups` (`_lib/joined-groups-wire.js`, `GroupsCometAllJoinedGroupsSectionPaginationQuery` doc_id `9974006939348139`, paged to exhaustion). Prune-safety requires `enumeration.complete`.
- `list-pages` → **WIRE** — inlined `actor.profile_switcher_eligible_profiles.nodes[].profile` (Shape B, `_lib/pages-wire.js`): Page fb_id/name/pic/`delegate_page_id`/`is_profile_plus`. GOTCHA: read LIVE DOM `<script data-sjs>` (a bg `fetch()` returns a stripped shell), walk depth ≥60.
- `get-page-insights` — still DOM, NOT a doc_id case: Business Suite streams figures over the **DataGateway WebSocket** (`gateway.facebook.com/ws/streamcontroller`) as a subscription (`useMAIBAAsyncMessageSubscription`, doc_id `27434986989461233`). Wire migration = a DGW-subscription decoder (future).

**Mutations** — wire replay by default; **no DOM fallback** (rotation fails loud, caught by `npm run canary:doc-ids`). Rule #5 binds: never trust the send; re-read the end-state. DONE wire-only (doc_ids): `create-group-post` (`ComposerStoryCreateMutation`), `create-group` (`useGroupsCometCreateMutation` `37309644325300927`), `invite-to-group` (`useGroupsCometInviteLinkCreateWithNotificationBulkMutation` `27043683338577412`, `_lib/group-invite-wire.js`), `accept-friend-requests` (`FriendingCometFriendRequestConfirmMutation` `27351021931180810`, `_lib/friend-wire.js`), `add-friend` (`FriendingCometFriendRequestSendMutation`), `update-group-admins` promote/demote/remove (`GroupsCometMembersInvite/RemoveAdminMutation` + `GroupsCometRemoveMemberMutation` `27148641238078140`), `approve-join-request` (`GroupsCometApprove`/`DeclinePendingMemberMutation` `27132805893014911`/`27119718754378916`), `delete-group-post` (`useCometFeedStoryDeleteMutation` `36213403264942057`, `input.story_id`=base64 story_gid), `set-account-language`. **Input SHAPE is captured off FB's OWN served JS** (the commit-function `variables:{input:{…}}`); doc_id from `_facebookRelayOperation` or a browser-level CDP tap (autoAttach flatten → catches worker-target mutations the page misses). Capturing a lazy mutation module needs its UI state to exist first. Email-based FB jobs (`invite-email-to-group`, `accept-group-invite-email`, `add-account-email`) were **DELETED**.

**Runner in-page-fetch capture — FIXED (`adapters/_lib/gql-tap.js`).** The in-page `page.evaluate` `fetch('/api/graphql')` a replay fires does NOT surface on the CDP `Network.*` socket (a replay job used to show **"0 graphql"**), so the passive wires + schema miner + `fb_graphql_call` got nothing from the jobs richest in FB data. Now the runner mounts an **in-page GraphQL tap** (`buildGqlTapSource` → wraps `fetch`+XHR over the SAME bridge `page.evaluate` uses, clones each `/api/graphql` response before the app reads it, reports via the `__bbGqlTap` CDP binding + a window-array fallback) and `foldGqlTap` merges them into `network.gqlCalls` at stop() — identical shape to a natively-captured XHR. So **every wire-replay job now feeds the passive wires**: proven live — `list-joined-groups` went from `0 graphql` → `folded 13 · 20 req · 13 graphql` → passive harvest `90 group · 90 membership`. Gated `BLUEBUT_INPAGE_GQL_CAPTURE` (default ON). The `source:'inpage_tap'` tag marks folded calls.

### Run an adapter

The bluebut-rest DB key is sourced from **keyvault** (`bluebut/config/rest-service-key`) by `_lib/db.js` — **no env export needed** (keyvault needs no creds: defaults to `keyvault.hostbun.cc` + bearer `ddash`). The runner is **cloakbox-only** — it resolves the account through the cloak-cdp **HTTP API** (`POST /api/call`, key `ddash`), no `OPENCLI_CDP_ENDPOINT`:

```bash
node bin/bluebut-adapter.mjs adapters/facebook/<cmd>.js --account <slug>
#   → cloak-client POST https://cloak-cdp.hostbun.cc/api/call {tool:cdp_load,args:{slug}}
#     (gates + egress-verifies + injects the session) → drive → {tool:cdp_save}.
#     Only accounts with a cloakbox device run (catalog verdict 'run'; the other
#     ~300 need a device minted — add-account skill).
#   Override the platform base/key with CLOAK_API_BASE / CLOAK_API_KEY (default ddash).
```

> ## ⛔ NEVER ssh-tunnel to pbox — the gateway is PUBLIC
>
> `cdp_load` returns a **public** gateway origin (`https://cloakbox.hostbun.cc?fingerprint=<seed>`).
> Both drive paths reach it from **any box with no tunnel**: `resolvePageWs` → the public
> `wss://cloakbox.hostbun.cc/fingerprint/<seed>/…` (opencli `page` API), and `cdp_drive` →
> server-side over the public `POST /api/call`. **Proven live from a laptop:**
> `node bin/bluebut-adapter.mjs adapters/facebook/whoami.js --account joel-skola` → `ok:true`,
> no tunnel. Do NOT `ssh -fNL 39555:127.0.0.1:39555 pbox` and do NOT set `CLOAK_ENDPOINT_BASE`
> to a tunnel — that loopback path is **retired**. `CLOAK_ENDPOINT_BASE` survives only as a
> legacy origin-override (private/on-box gateway); unset (the default) uses the public origin.
> Collapsing to `cdp_drive`-only (kill the direct ws) is an OPTIONAL simplification, not a
> reachability need.

Sanity-check the platform + list runnable accounts without launching anything:

```bash
curl -s 'https://cloak-cdp.hostbun.cc/api/health?key=ddash'
curl -s 'https://cloak-cdp.hostbun.cc/api/launchable?key=ddash&service=facebook&runnable=1&summary=1'
```

- Streamed events → **stderr** (JSON lines); final result → one JSON object on **stdout**.

```bash
fbcli() {  # usage: fbcli <account> <cmd> [args…]  (cloakbox-only; over the public HTTP API)
  local acct="$1" cmd="$2"; shift 2
  node "$PWD/bin/bluebut-adapter.mjs" "$PWD/adapters/facebook/$cmd.js" --account "$acct" "$@"
}
```

### From the dashboard

`runCli`/`runCliStream` POST to the bridge (`VITE_BRIDGE_URL`); it maps `{site, cmd}` → `adapters/<site>/<cmd>.js`, spawns the runner, streams stderr as SSE. Missing file → 404. **Hosted is not view-only:** prod bakes `VITE_BRIDGE_URL` → a token-gated bridge (`bluebut-bridge.blpk.cc`, Coolify, `Dockerfile.bridge`); `BRIDGE_TOKEN` gates it, build bakes `VITE_BRIDGE_TOKEN` so `bridgeAuth()` sends the bearer.

### Source of truth + discovery

- **`adapters/` IS the source.** Vendored originally from `devdashco/toolbox`, owned here now — toolbox is NOT in the runtime path. Edit the file; next run picks it up. Cherry-pick upstream fixes manually.
- Box: `chromebox-fb` at `https://fb.chrome.blpk.cc` (image `devdashco/toolbox` @ `chromebox-fb`). The old shared `chrome.blpk.cc` is NOT ours.
- `GET <box>/chrome/profiles` → accounts, `cdp_url`, spawn status.
- `<box>/selkies/?token=<VNC_PASSWORD>` → **Selkies** remote desktop (UI "VNC" button, `vncUrl()`). Token = `SELKIES_MASTER_TOKEN` = `VNC_PASSWORD` (`ddash`), proxied at `/selkies/` by `proxy.py`. noVNC (`/vnc/`) is a fallback.

### Adapter reference (facebook site)

- **Session/identity:** `load-session` (from keyvault via the account's proxy), `login` (re-auth stale creds), `whoami`. Page act-as = `--as-page` (see the pointer-model block); `load-page-session` is retired. Fresh onboarding = the `add-account` skill (cloakbox), not a box adapter.
- **Messaging:** via the [Messenger box](#messenger-box--readsendmanage-off-the-lightspeed-wire) (`site:'messenger'`) — `sync-inbox` (→ `fb_thread`/`fb_message`, all folders) + `send-message` ⭐ (**BROWSER-FREE** `browser:false` — `SendMessageTask` over the account's PERSISTENT LightSpeed socket via daemon `/ls-send`, type-15 → whatsmeow; no cdp_load, no DOM composer).
- **Groups — discovery/membership:** `list-joined-groups` ⭐ (doc_id `9974006939348139`), `get-user-groups` ⭐ (groups + role + invites), `get-group` (detail+admins; write-back by default `--persist` → `fb_group`+`fb_group_member`+`fb_account_group`), `get-group-join-config` ⭐ (`GroupsCometRequestToParticipateOnJoinDialogQuery` doc_id `27156146040745454`, `_lib/join-config-wire.js` → join questions/rules/flags), `ingest-groups`, `create-group` ⭐ (`useGroupsCometCreateMutation`), `join-group`, `accept-admin-invite`, `approve-join-request` ⭐ (`GroupsCometApprove`/`DeclinePendingMemberMutation`), `invite-to-group` ⭐ (`_lib/group-invite-wire.js`).
- **Groups — editing (admin):** `update-group-admins` ⭐ (`--promote`/`--demote`/`--remove`, self-verifying), `update-group-location`, `update-group-description`, `update-group-cover`.
- **Groups — posting/scraping:** `create-group-post` ⭐ (`ComposerStoryCreateMutation` text, own + act-as-Page `--as-page` → `group_post`+`fb_post`; DOM fallback for IMAGE only), `delete-group-post` ⭐ (`useCometFeedStoryDeleteMutation` doc_id `36213403264942057`, targets base64 `story_gid`, ownership-gated, verifies gone), `get-group-feed` ⭐ (feed replay → `fb_post`+`fb_person`+fb-bot feed; `--group`/`--groups`/`--all` = every joined group, `--max-groups N` stalest-first), `scrape-new-members` ⭐ (`_lib/member-request-wire.js`), `get-post-comments` ⭐ (inlined permalink, `_lib/comment-wire.js` → `fb_comment`, no DOM click), `get-group-insights` ⭐ (`GroupsCometForumInsightsGrowthRootQuery` doc_id `26096051466719514`, admin only).
- **Social graph:** `add-friend` ⭐, `accept-friend-requests` ⭐, `list-friend-requests` ⭐, `list-friends` ⭐, `list-users`.
- **Pages:** `list-pages` ⭐ (`_lib/pages-wire.js` → `fb_page`/`fb_account_page`), `get-page-insights` (DOM — BizKit DGW WS subscription, not a doc_id).

> **Bulletproofing:** every mutating admin adapter follows *never trust a click*. `failWith()` attaches screenshot + context + strategies and reclassifies `status:'blocked'` on an FB block/checkpoint; `okWith()` reports the verified end-state. The UI chain (`promoteAdminChain`/`addAccountAsAdmin`) adds an independent `get-group` confirmation → `verdict` (`verified | pending_unverified | blocked | failed`). Drift canary: `npm run canary:admin` runs the full cycle, ships to HyperDX, exits non-zero on drift.

---

## Semantic model

### Layer 1 — Infrastructure: *are our accounts working?*

FB account record → **keyvault** (canonical). Client = `adapters/_lib/keyvault.js` (MCP `keyvault.hostbun.cc/mcp`, bearer `ddash`); `sessions.js` reads/writes it for `site==='facebook'`.

**One namespace, owner-as-field:** `facebook/accounts/<slug|id>` (`ACCOUNT_PREFIX`) holds ALL FB accounts. Full rows carry `owner=bluebut` (R/W); droidfleet farm blobs carry tag `df-sync`/no owner (droidfleet is their writer, bluebut reads them as a sync source). Tell ours apart by `owner`, NEVER by a key prefix.

**keyvault is the SOLE account store** — `chromebox_sessions` is retired. **kv_list caps at ~100 — use `kvListAll()`/`kvListAccountRows()`.**

> ## ⛔ PAGES ARE POINTERS, NOT ACCOUNT ROWS — the strict pointer model
>
> A Facebook **Page has no login of its own** — FB has no page credentials; you "act as" a Page by overlaying `i_user=<page_fb_id>` on its **managing PROFILE's** authed session. So the invariant:
> - **ONE keyvault account row per real login identity = the manager PROFILE** (`account_type='profile'`, its creds + `storage_state` + proxy + device). **A keyvault row is NEVER `account_type='page'`** — enforced at the write boundary (`saveSession` throws on `account_type:'page'`; test in `sessions.test.mjs`).
> - **Pages live only as POINTERS**: `fb_account_page` (bluebut-db = source of truth) + the manager row's `managed_pages[]` (kv projection the loader reads). Each pointer = `{page_key(=asset_id), page_fb_id, name, switch_method}`, self-healed by the `managed-page` passive wire.
> - **Act as a page = `--account <manager> --as-page <page_fb_id|name>`** (runner `resolvePageActor` reads the pointer off the manager's `managed_pages`; overlays `i_user`; VERIFIES FB flipped). No page account, no jar copy, no separate proxy — the page rides the manager's session/proxy/device.
> - **Retired:** the standalone `facebook/accounts/<page>` rows + `load-page-session`/`loadPageSession` (a page row was a drifting copy of the manager's jar+`i_user` that duplicated identity/proxy/fingerprint and caused the **philip→bofrid.se-page** identity corruption). The 5 legacy page rows were purged 2026-07-07 (backup in the migration scratchpad); `load-page-session` now throws a deprecation → `--as-page`. The passive `freshenOwnIdentity` never infers `account_type` (`ownIdentityPatch`).
> - **UI:** the Accounts grid nests Pages under their manager sourced from `managed_pages` (synthetic view-only rows, `Accounts.tsx` `byManager`/`pageSessions`); a Page is view-only with one action (Act as Page → `--as-page`), never an editable account row.

Bridge/UI: `GET /accounts/registry` (canonical list), `POST /accounts/sync-keyvault`, `POST /keyvault/account` (writeback).

**The UI/registry sources the account LIST + proxy DISPLAY from keyvault** (the `sessions` query → `/accounts/registry`); the box `/accounts` view only AUGMENTS live runtime state (spawn status/pid/idle) — **never gate account data on box reachability** (a truth-ladder violation: read the canonical store, not the presentation surface). All account R/W goes through `keyvault.js`; `chromebox_sessions` REST is dead. `proxy` is keyvault-canonical (only `cdp_port` is box-local).

~90+ accounts (named + ~80 `df-*` farm), all `account_type='profile'` (Pages are pointers). Named profiles: `pojtie`, `joel-skola`, `mrkoll`, `william`, `philip`, `oskar-wik`. Active: `pojtie`, `joel-skola`. `philip` manages 9 pages (incl. `bofrid.se`).

> **`joel-skola` is the TEST account.** Use it for any manual/live end-to-end check (cloakbox `cdp_load` drives, wire probes, passive-wire smoke tests). It's the default cloakbox test account (migrated 2026-07-08, seed 13269) and manages the Uthyra.se page. Don't burn a real outreach account on a test.

### Layer 2 — Entity graph: *what do we know about the FB world?*

| Entity | Table (bluebut-db) | Source |
|---|---|---|
| FB group | `fb_group` | `list-joined-groups`, `get-user-groups`, `get-group` |
| Account ↔ group | `fb_account_group` — `is_member` only | `list-joined-groups`, `get-user-groups` |
| **Group member edge (role)** | `fb_group_member` — `fb_user_id` is an ACTOR (User in `fb_person` OR Page in `fb_page`; no FK) | `get-group` |
| **Person / lead / actor** (the ONE User hub) | `fb_person` (canonical on `fb_id`) | `scrape-new-members`, `get-group-feed`, passive `person` wire |
| Messenger contact (per-viewer) | `fb_contact` | messenger `sync-inbox` |
| Observable event | `fb_person_signal` | scrapers |
| Group post | `fb_post` | `get-group-feed`, passive `post` wire |
| **Classified listing (offer)** | `post_extract_listings` | `classify-posts` (LLM) |
| **Classified request (demand)** | `post_extract_seekers` | `classify-posts` (LLM) |
| Group-post comment | `fb_comment` | `get-post-comments`, passive `comment` wire |
| DM thread/message | `fb_thread` / `fb_message` | messenger box `sync-inbox` (LightSpeed wire) |
| Group post log | `group_post` | broadcast flow |

- **`fb_group_member` is FB's membership edge — the SINGLE source of admin-ness.** One row per `(fb_group_id, fb_user_id)` with `role` (`admin | moderator | member`). No separate admin table, no `is_admin`/`my_role` on `fb_account_group`. "Our admins of group G" is derived: `role='admin'` ∩ our accounts' fb_ids (`ourAdminsOfGroup` in `util.tsx`). Roster roles written via `upsertMembers` in `_lib/group-sync.js`. **`fb_user_id` is a bare numeric ACTOR id (NO FK): a group member/poster is a User (`fb_person`) OR a Page (`fb_page` — a page joined-as-page or posting-as-page; our own pages are members of 100+ group slots). A single-table FK would reject Page members.**
- **`fb_person` is the ONE User/lead/actor hub** — canonical on the numeric **`fb_id`** (`UNIQUE`; the only stable FB identity — vanity/pfbid/profile_url are aliases; see [[reference_fb_identity_model]]). Holds Users AND company/posting Pages (an actor), distinguished by `is_company`/`company_name`. Fields: `fb_id`, `profile_url` (last-seen alias), `email` (admin_scrape/questionnaire — high value), `intent` (`seeker|landlord|unknown`, set by `classify-posts`), `intent_confidence` (`explicit|inferred`), `outreach_status` (`new|contacted|replied|…`, the DM-dedup surface), `first_seen_channel`, `city`. **fb_user + fb_profile (redundant User tables) merged in + dropped 2026-07-08.**
- **`fb_person_signal`** — immutable event log, one row per action (`joined_group`, `posted`, `dm_reply`). Append only.
- **`group_post`** — every group post Bluebut made: `fb_group_id`, `group_url`, `chromebox_account`, `body`, `post_url`, `status` (`pending|posted|failed|skipped`), `skip_reason` (`cooldown|no_session|access_denied|blocked`), `posted_at`.

### Layer 3 — Activity log: *what have we done?*

`fb_sent_messages` (DMs) + `fb_broadcast_posts` (group posts) live on **bofrid.dev** — legacy fb-bot, **read-only** (queried for dedup + cooldown). Never write it.

---

## Facebook anti-automation — the most important operational constraint

FB monitors posting behaviour across accounts/groups; it shapes every architectural decision. The accounts are the asset — temp-block = can't post/scrape/DM; perm-block = gone.

**What FB detects:** velocity (3+ groups/~1hr spikes risk; 2+ hrs rarely triggers), content fingerprinting (identical text = spam hash), timing regularity (use jitter), cross-account coordination (overlapping content/window), IP reputation.

**Rules enforced in code:**
1. **Min 8 min between posts/account**, randomised 8–15 min. Never fixed.
2. **Max ~4 posts/account/hr.** Exceed → route to another account or slow down.
3. **Per-group cooldown ≥ 7 days.** Check `group_post` (bluebut-db, posted) **and** `fb_broadcast_posts` (bofrid.dev, success) before including; show "last posted N days ago" for override.
4. **Text variation.** Never identical text to >3 groups/session — warn + prompt.
5. **Stop on block.** `aborted_reason` ~ "blocked"/"restricted" → stop the batch for that account, surface prominently.
6. **Dedup before DM — "have we messaged this person? block".** PERSON-level, keyed on the canonical numeric `fb_id` (`messenger/lib/dm-dedup.js` `alreadyMessaged` → checks `fb_message is_self` across the peer's threads + the `fb_person` hub `messaged_at`/`outreach_status`; `stampMessaged` on send). Wired into `send-message` before ALL paths (plain/E2EE/DOM) via `--abort-on-prior` (default on). The fb-bot legacy `fb_sent_messages` (bofrid.dev) is folded in as `outreach_status='contacted'` where resolved (the ~10k→fb_id import is a pending backfill).

---

## Two acquisition channels (don't conflate)

- **Channel A — Admin scraper** (`scrape-new-members`, **admin**, **WIRE**): replays the `admin_activities` participation-change list + the per-activity "See details" dialog (`_lib/member-request-wire.js`) → the FULL member-request object: numeric fb_id, real profile_url, `account_created_at`, city (+ city Page id → geo), education, work, about_me, membership + rules-agreement enums, the structured **questionnaire answers** (where **email** + intent live), and the action enum → `fb_person` + `fb_person_signal`. Highest value — email reaches them via the Bofrid platform.
- **Channel B — Feed scraper** (`get-group-feed`, **member**, wire): replays the feed GraphQL for `/groups/<id>` → post content + poster + engagement/media → `fb_post` + lazy `fb_person`.

**Categorization (in-repo, WIRE + LLM — the funnel §3, SHIPPED 2026-07-08):** `classify-posts` (browserless job, `Strategy.PUBLIC`) sends each scraped `fb_post` to `llm.hostbun.cc` (`claude-haiku-4-5`, `_lib/llm.js` + `_lib/judge.js`) → ONE typed struct: `post_type` (rental-listing | listing-seeker | none | unknown) + `is_company`/`company_name` + `confidence` + structured housing fields. By type it writes **`post_extract_listings`** (offers: rent/rooms/sqm/area/company/contact…) or **`post_extract_seekers`** (demand: budget/desired_area/household/move_in…), stamps `fb_post.classified_at`, and refreshes the `fb_person.intent` cache. Replaces the old regex `classifyPost` + the external fb-bot classifier. NOT `fb_judgment`/`housing_extract` (an earlier draft — superseded by the two standard rental tables).

**Signal → Person promotion:** when `get-group-feed`/the passive `post` wire resolves a poster, `promotePersonFromPost` (`post-sync.js`) upserts `fb_person` on the canonical `fb_id` + links the post + appends `fb_person_signal` 'posted'.

## Group post broadcast model

Different from fb-bot's listing broadcaster: content is a campaign message, groups picked manually by the present operator, output → `group_post` (bluebut-db), dedup `(group_url, 7 days)`, **no queue/poller/approval/manager** — the operator IS the approval; direct `runCli` per group with live feedback.

**Account routing** (computed fresh): (1) `fb_account_group is_member=true` (admin via `fb_group_member` role=admin ∩ our fb_ids) → (2) filter to active session (`last_status='logged_in'`) → (3) prefer admin → (4) prefer UI-selected → (5) none active → skip `no_session`.

### ⛔ Three things are called "post" — never conflate them

The **Broadcast page** (`ui/src/pages/Broadcast.tsx`) is the ONE "one content → many groups" surface (the old separate **Listings** page was consolidated INTO it 2026-07-09; `/listings` is now an alias route). Its listing table + the per-listing **detail drawer** (`ui/src/pages/broadcast/ListingDetail.tsx`) sit on top of three confusable objects:

| Term | What it is | Table | Identity / link |
|---|---|---|---|
| **Listing** | the bofrid rental OBJECT (source content) | `bofrid_listings` on **bofrid PROD supabase** (remote; read live via bridge `/listings/ads`) — **NOT in any bluebut SCHEMA** | uuid `id` |
| **Group post** (a "broadcast") | a FB group post WE authored FROM a listing | `group_post` (bluebut-db) | linked to the listing by **`group_post.listing_id` ONLY** |
| **Scraped `fb_post`** | OTHER people's feed posts | `fb_post` (bluebut-db) | `post_url`; `authored_by_us` flag |

**The listing→our-posts link is `group_post.listing_id` — nothing else.** Never a URL/text match, never `fb_post`. Because the listing object lives on a DIFFERENT database, `listing_id` is a bare `text` **cross-DB pointer with no FK possible** (same shape as the geo slug-pointer) — it can dangle; treat it as a soft reference. "Never broadcast" (zero `group_post` rows for a `listing_id`) is a first-class state, not an error.

**Two rollup VIEWs already exist — use them, don't re-derive:**
- **`group_post_stats`** — per-GROUP (`posts_count`, `last_post_at`, `listings_count`, `last_listing_at`). Powers the Groups index columns.
- **`listing_broadcast`** — per-LISTING (`attempts`/`posted`/`failed`/`blocked`/`groups_live`/`verified_posts`/`batches`). The authoritative "how did each listing's broadcast go" rollup — the same thing the detail drawer shows. The drawer reads a per-listing `group_post?listing_id=eq.<id>` query (unbounded); the Broadcast grid's rollup column re-derives it in JS off the page's global 500-row `groupPosts` cache (fine for recent listings; point at the view if a bulk/old-listing view needs it uncapped).

---

## Where to build it — bluebut vs fb-bot vs platform

| System | Repo | DB | Role |
|---|---|---|---|
| **bluebut** | `devdashco/bluebut` | bluebut-db R/W | Active operator dashboard. **Where new code goes.** |
| **fb-bot** | `devdashco/fb-bot` | bofrid.dev (read-only here) | Legacy queue/poller/approval for overnight broadcasts. **Don't extend.** History of record. |
| **bofrid platform** | admin-fb-leads, geo-bofrid, manager, cron-fb-growth | ecosystem + bofrid.dev | Listing intake, geo, growth. bluebut consumes results. |

**Default to bluebut** when a human operator does something with FB (a human is present → no queue/poller/approval). **Not bluebut** for unattended automation (→ fb-bot/cron-fb-growth), listing/intake/geo (→ bofrid platform), or read-only history display (query bofrid.dev directly).

**Recipe — "add X for bofrid":** (1) FB work → adapter in `adapters/<site>/`; (2) state → idempotent `SCHEMA.sql` (site-agnostic → root); (3) UI → workspace tab (per-group) or batch page (cross-group) using `Ui.tsx` + `runCli`; (4) enforce anti-automation before any FB write; (5) the operator is the queue.

---

## Traffic capture — sniffer + HAR

> **Entire subsystem is PLATFORM-OWNED under cloakbox:** the platform's always-on **wiretap** captures every seed into a `wiretap` schema + MinIO + per-session HAR (`wiretap_grep_traffic`/`_search_payloads`/`_export_har`). The sniffer, `netcapture.js`, `har*.js`, `gql-tap.js`, `traffic-store.js` + the capture loop below all move to the **DELETE** list once migrated; bluebut keeps only its `*-wire.js` **recognizers**, run over the `entities` a `cdp_drive gql` returns or the warehouse. CAPTURE = platform, RECOGNIZE = bluebut.

**chromebox-sniffer** (`bin/chromebox-sniffer.mjs`) — always-on daemon, ships ALL box browser activity to HyperDX tagged by account (polls `/chrome/profiles`, browser-level CDP per profile, records request+nav). OTLP → `service.name=chromebox-traffic`. `npm run sniffer`; `bin/Dockerfile.sniffer`.

**Every job is captured by the RUNNER, not the daemon** — per call it drops a `kind:'job'` start/end pair + the full per-request trail (`source=runner`) carrying `account`/`cmd`/`job_id`. The daemon is a best-effort firehose for traffic the runner can't see (manual noVNC).

**Debug from traffic** (MCP `hyperdx_sql`, `connectionId='6a0dd8f074371384ffe07f18'`):
```sql
SELECT Timestamp, SeverityText, LogAttributes['kind'] AS kind, LogAttributes['status'] AS status, Body
FROM otel_logs
WHERE ServiceName='chromebox-traffic' AND LogAttributes['account']='<acct>' AND LogAttributes['job_id']='<id>'
ORDER BY Timestamp;
```
Rule of thumb: session/nav bug → `kind='nav'` (`/login/?next=` = cold/blocked, `/checkpoint/`); API/permission → `SeverityText='error'` + `gql_name`; "reaching FB?" → the `job-start`/`job-end` pair + `ok`/`duration_ms`.

**HAR — full-fidelity archive** (everything: headers, cookies incl. live `c_user`/`xs` UNREDACTED, postData, response bodies up to 16 MB lossless, **WebSocket frames** as `_webSocketMessages` — Messenger/MQTT publish+ack; HAR 1.2 → Chrome DevTools → Network).
- **Two stores, by design:** the gzipped HAR **blobs** live in the **private `har` bucket** (bluebut-minio MinIO/S3, `public:false` — account-takeover material; cut over from the hostbun Supabase bucket, `har-store.js` → `S3_ENDPOINT`); the **tabular index** (`har_recording` + `traffic_entry`, one row per request incl. WS sockets) lives on **bofrid.dev**, NOT hostbun (it's a ~277k-row/day firehose — kept off the shared operational DB). `traffic-store.js` `creds()` → `BOFRID_SUPA_URL`. The link is `traffic_entry.recording_id` → `har_key` → bucket. Read only via the token-gated bridge.
- **WS capture** gate `WS_CAPTURE=1` (cheap, frames only) is independent of the heavy full-body `HAR_CAPTURE`; `HAR_CAPTURE=1` implies it. E2EE: we capture the encrypted publish+ack (proof of send), never plaintext/read-receipts.
- **⚠️ ALWAYS capture WebSocket frames — the page CDP target alone NEVER sees them.** FB's realtime sockets (lightspeed/realtime/streamcontroller/edge-chat/e2ee) each live on a **separate CDP target**; a page-only capture records **ZERO** frames while hundreds fly. Fix (both engines): a **second, browser-level CDP connection** (`/json/version` → `webSocketDebuggerUrl`) with `Target.setAutoAttach({autoAttach,flatten})`, re-armed on each `attachedToTarget`, routing `Network.webSocketFrame*` into the sockets map (lazy-materialize on first frame — long-lived MQTT never fires `webSocketCreated`). Runner: `netcapture.js` `wsApply` + the tap. Under cloakbox the platform **wiretap** owns this browser-level WS capture. If a HAR shows 0 WS frames, the browser-level tap is missing — that's the bug.
- Keys: per job → `job/<job_id>.har.gz` (linked on `job.har_key`); sniffer rolling → `<account>/sniffer/<ISO-ts>.har.gz`. Local copy next to the JSON artifact.
- **Per job (guaranteed):** runner `finally` → `netcapture.stop()` → `buildHar` → `gzipHar` → `uploadHar('job/<id>.har.gz')` → sets `har_key`. Best-effort. Pieces: `adapters/_lib/har.js`, `har-store.js`, `netcapture.js`. HAR blobs use the S3/MinIO creds (`S3_*`); the DB key comes from keyvault.
- **Sniffer always-on (OFF by default):** `HAR_CAPTURE=1` on the sniffer's Coolify app; tuning `HAR_ROLL_MS`/`HAR_ROLL_MAX`/`HAR_BODY_MAX`/`HAR_INFLIGHT`/`BLUEBUT_CAPTURE_BINARY`. Captures live `c_user`/`xs` fleet-wide → deliberate outward decision.
- **Retrieve:** `GET /har/blob?job=<id>` (or `?key=<bucket-key>`), `GET /har/list?account=<acct>` (or `?prefix=job`). `gunzip` → drag into DevTools.
- **Analyze offline (reads `.gz` directly):** point DuckDB at the HAR gzips (`wiretap_export_har` pulls them from the platform) — the UI's DuckDB-WASM panel runs the same SQL. Do the **FB-error scan keyed on the structural error CODE** (368/1675004/1404xxx/1390xxx, never localized text) that catches rate-limit/abuse errors riding inside HTTP 200. For bulk sweeps: `read_json('*.har.gz', maximum_object_size=2e8)` + `UNNEST(log.entries)`, filter `response.status>=400 OR response.content.text LIKE '%"errors"%'`.

---

## Messenger box — read/send DMs through whatsmeow (the `bluebut-e2ee` daemon)

A self-contained subsystem at repo root **`messenger/`** (NOT `adapters/messenger/`) that works FB Messenger off **Facebook's own LightSpeed (LS) sync wire**, not the rendered DOM — the truth ladder applied to Messenger. The legacy `adapters/facebook/sync-messages.js` scrapes localized aria-text (bottom rung: no hidden folders, no real ids, faked timestamps). FB web Messenger has no chat store in the DOM — it runs an in-browser **LightSpeed DB** the server drives with `LS.sp(...)` ops streamed in a `/api/graphql/` response (`lightspeed_web_request.payload`). The box reads that same wire.

> ## ⭐ TARGET ARCHITECTURE (2026-07-09): whatsmeow is the ONE messaging path — LightSpeed is being DELETED
>
> Messaging goes through **whatsmeow ONLY** (the `bluebut-e2ee` daemon = a registered encrypted-messaging device per account). The entire **LightSpeed** subsystem — the browser `sync-inbox`/`sync-thread` jobs, the persistent `ls-session.go` socket, `lightspeed.go`/`ls-ingest.go`, `bin/ls-keeper.mjs`, `bin/poll-messenger-inbox.mjs`, `messenger/lib/decode.js`/`ls-procedures.js`/`passive-messages.js`, the `/ls-connect`/`/ls-send`/`/ls-ingest` endpoints, the `ls-keeper` cron, `MESSENGER_LS_PERSIST` — is being **removed**. Say **"encrypted messages"**, never "E2EE".
>
> **The model:** whatsmeow holds a standing socket per account, **decrypts EVERY inbound message** (text, image, video, audio, file, sticker, location, contact, view-once — `handler.go` never drops a type) and POSTs it to the bridge. A decrypted message is **self-contained** (`DecryptedMessage`: thread_key + sender_id + timestamp_ms + message_id + message_type + text + attachments). The bridge **materializes the inbox from that stream** (`handleE2eeIngest`): UPSERT `fb_message` (+ `fb_message_attachment`), UPSERT `fb_thread` so the conversation appears, UPSERT `fb_contact` with the sender **name resolved from the `fb_person` entity graph** (whatsmeow only gives the numeric id). Send = `send-message` → the daemon's whatsmeow send.
>
> **The accepted losses (why this is a deliberate trade):** no thread-list enumeration of idle conversations (a thread appears when it gets a message), **no history** (whatsmeow is realtime-only), **no folders/unread**, and **no plain type-1 threads** (older/business/page/group DMs that aren't encrypted are unreachable). Contact **names come from `fb_person`**, not a Messenger roster. Keep-alive = the **reconnect-sweep** (CAT minted via the cloakbox once/~24h; the one browser touch) + a register-once device — there is no `ls-keeper` anymore.
>
> **Status:** cutover IN PROGRESS. Stage 1 DONE (`handler.go` captures all types; the bridge builds `fb_thread`/`fb_contact` from the stream). Stages remaining: `send-message` whatsmeow-only; delete the LightSpeed files/endpoints/jobs/cron/env; UI + doc rename. The prose below still describes the old LightSpeed subsystem — it is the migration backlog, not the target.

- **How it runs:** `messenger/jobs/<cmd>.js` declare `cli({ site:'facebook' })` so the runner applies every protection (proxy/freeze gate, `ensureFbSession`, HAR, job ledger). Bridge routes `site:'messenger'` → `messenger/jobs/<cmd>.js`. UI = `ui/src/pages/Messenger.tsx`.
- **No hardcoded doc_id** — `installLsTap` (document-start) observes the page's own LS call, `decode.js` (JS port of mautrix-meta's LightSpeed step-VM) decodes via positional field maps (`lib/ls-procedures.js`), `replayLs` pages deeper. `sync-inbox` fails loud if it captures **zero** LS payloads.
- **Jobs:** `sync-inbox` (all folders incl. hidden Requests/Spam/Archived), `sync-thread` (one thread's full history), **`send-message`** ⭐ (auto-routed by thread type: PLAIN type-1 → `SendMessageTask` label 46 over LightSpeed via the daemon's `messagix` client; E2EE type-15 → the daemon's whatsmeow `SendFBMessage(<peer>@msgr)` — a plain LS task into a type-15 thread is accepted-then-DROPPED by FB, so E2EE MUST go through whatsmeow. `send-message.js` auto-detects type-15 via `fb_thread`; `--transport=e2ee`/`dom` force), `manage-thread` (`mark-read` verified; archive/delete/mute gated pending capture).
- **E2EE runs on hostbun, self-heals tunnel-free.** The `bluebut-e2ee` daemon is a Coolify app **on hostbun** (internal `:8099`, public fqdn `https://bluebut-e2ee.blpk.cc`; opens its own Noise socket to FB via the account proxy). Registers a per-account device (ICDC via a CAT), decrypts inbound type-15 → bridge `/messenger/e2ee/ingest` → `fb_message` (`is_e2ee`), and sends E2EE. CAT expires ~24h, only the live browser mints it. **Durability = the reconnect-sweep:** `bin/e2ee-reconnect-sweep.mjs` → `cat-connect.js` `mintCatAndConnect` mints via `cloak-cdp` `cdp_drive` + POSTs the daemon `/connect` with the stored `wa_device_id` (register-once). Two Coolify cron tasks: `e2ee-reconnect-heal` (`*/15`) + `e2ee-cat-refresh` (`41 */6 --force`). Register-once record = keyvault `bluebut/e2ee-device/<slug>`.
  - **⚠ The CAT mint MUST drive the PUBLIC cloakbox gateway** (`https://cloakbox.hostbun.cc`), NOT a pbox loopback. `cloak-client.js` `DEFAULT_DRIVE_BASE` was `http://127.0.0.1:39555` on a stale "cdp_drive runs on pbox" assumption — every mint got `ECONNREFUSED 127.0.0.1:39555` and E2EE could not self-heal. Fixed to the public gateway (env `CLOAK_DRIVE_BASE` overrides only for a private/on-box gateway). Proven: reconnect-sweep mints + connects philip/pojtie; a full browser-free round-trip (philip→pojtie) lands `is_self=true decrypted_by=self` on the sender AND `is_self=false decrypted_by=daemon` on the recipient, same `message_id`.
- **⭐ WHAT MUST BE UP for realtime send+receive (per account) — and it RUNS IN PROD.** Two daemon-held sockets per account, both on the prod `bluebut-e2ee` daemon: **(1) the persistent LightSpeed socket** — plain (type-1) receive+send, thread list, folders; auth = keyvault jar+proxy, **browser-free**, held up by `bin/ls-keeper.mjs` (`*/5`, `pickAccounts` runnable set). **(2) the whatsmeow/E2EE socket** — type-15 receive(decrypt)+send; auth = a **CAT** (the ONLY browser touch: minted once per <24h per account by driving the account's cloakbox seed), held up by the reconnect-sweep crons above. Both sockets live IN-MEMORY in the daemon, so **every daemon restart (deploy) drops all of them** → the keepers re-establish (LS fast+browser-free; E2EE via a CAT mint each). Keep daemon restarts rare. **Local dev CANNOT hold these sockets** — you consume the PROD daemon over its public URL (`E2EE_DAEMON_URL=https://bluebut-e2ee.blpk.cc`, token `E2EE_DAEMON_TOKEN`) and the public cloak gateway; `send-message`/`sync-inbox` run locally but the standing sockets + the keeper crons must run in prod (Coolify on hostbun) for anything to stay online.
- **⭐ Persistent LightSpeed sessions — REALTIME inbox, NO polling (daemon-held).** `messenger/e2ee/daemon/ls-session.go` holds a **long-lived `messagix` socket per account** (a few MB, NOT a Chrome). This is the go-forward replacement for the browser `sync-inbox` poll (`bin/poll-messenger-inbox.mjs`, now DISABLED).
  - **DOES IT NEED SYNC? NO.** A standing socket receives every new message in **realtime** — FB pushes it down the open socket → decoded → bridge `POST /messenger/ls-ingest` → `fb_thread`/`fb_message`, the instant it arrives. There is **exactly ONE sync per connection**: the *initial mailbox sync* that fires once when a socket (re)connects, to catch up on anything that landed while it was disconnected. After that it is pure push — no poll, no `sync-inbox` job, no cron "refresh". If you're running a sync to see new messages, the socket isn't up; fix the socket, don't poll. (Carries metadata + previews + **non-E2EE bodies**; E2EE type-15 bodies still come only from the whatsmeow side.) Auth = the account's web jar (`c_user`/`xs`/`datr`) + its own proxy — **no cloakbox device needed to READ**.
  - **The daemon is dumb; the keeper HOLDS the socket (it does NOT sync)** (principle 12). `bin/ls-keeper.mjs` = the node keeper: enumerate the **cloakbox-runnable accounts** via the shared `pickAccounts()` (`_lib/cloak-client.js` → `cdpCatalog`, verdict `run` — NOT every logged-in jar; that pulled in dead/checkpointed g2g/df accounts we never use), load each jar+proxy from keyvault, `POST /accounts/<slug>/ls-connect {fbid,proxy,cookies}`. Its ONLY job is to keep the socket OPEN: each run it CHECKS `ls_connected` (the daemon `/status` reports it beside the E2EE `connected` — key on `ls_connected` ONLY, E2EE-up ≠ mailbox-up) and RECONNECTS only the DOWN ones — a healthy socket is left untouched. Any drop → a **Telegram alert** (via `_lib/tg-notify.js`) + non-zero exit. Daemon gate `MESSENGER_LS_PERSIST` (`*` = accept any slug the keeper pushes → the keeper is the sole gate); a real proxy is mandatory (a proxyless raw socket egresses the daemon IP → mass burn).
  - **STATUS (2026-07-09): FIXED + working.** The earlier "flap" was NOT the sockets — they hold fine (`/accounts` showed 5/6 `connected:true`); it was the **keeper killing healthy sockets** because the OLD `/status` reported the E2EE session (down post-restart) not the LS one → 404 → blind reconnect → `start()` stops-then-replaces the live socket. Fix (deployed): `/status` now reports `ls_connected` distinct from the E2EE `connected`, and the keeper keys on it → it leaves healthy sockets alone (proven: keeper run reports `joel-skola/philip/pojtie ✓ up`, 0 reconnects; philip + pojtie ingested ~30 realtime msgs each in a 30-min window). **`s7-amelia-bontin` is flaky** (LS `HTTP 502` on connect, won't hold — account-specific, open). The `ls-keeper` Coolify cron is currently REMOVED (was churning under the old bug); re-add `*/5` now that it no longer churns.
- **Schema (`messenger/SCHEMA.sql`):** per-viewer-scoped — `fb_thread`/`fb_message`/`fb_contact`/`fb_thread_participant`/`fb_message_reaction`/`fb_message_attachment`. `rpc/fb_schema_ingest` self-applies new columns.
- **WS link:** encrypted MQTT publish+ack frames land in the per-job HAR as `_webSocketMessages` (`WS_CAPTURE=1`) — proof-of-send, never plaintext.

A messenger job's decoded wire + `_webSocketMessages` sit in its per-job HAR (`GET /har/blob?job=<id>` → gunzip → DevTools). `0 graphql` in the step log is expected — LS rides one `lightspeed_web_request` payload, not named-query traffic.

---

## UI pages

Four pages — `ui/src/App.tsx`. Nav: **Groups** → Groups; **Automation** → Jobs; **Admin** → Accounts, Tools.

| Page | Purpose |
|---|---|
| **Groups** (home, `/groups`) | Metrics + group grid (sort/filter/search, checkbox **bulk panel**: `MultiGroupPostForm`, `BulkAdminForm`, `MultiGroupAIJobForm`). Row → workspace. |
| **Group workspace** (`/groups/<id>[/<tab>]`) | Tabs `["overview","admins"]`. Overview = cover/info/location editors + our accounts + recent posts + cooldown + Refresh. People = roster + promote/demote (`GroupAdminPromoter`). |
| **Jobs** (`/jobs`) | Live + historical; expand to watch its tab (CDP screencast). |
| **Accounts** | Session health, proxy, credential editing; add account; "Sync from keyvault". |
| **Tools** | One-off CLI — run any adapter. |

A retired lens (Pipeline/Inbox/Outreach/Scraper) comes back as a workspace tab (per-group) or batch page (cross-group), never a top-level one-off.

---

## Geo association for FB groups (planned)

Each FB group covers a geography. Polygons live in the **geo** project (`geo_regions`/`municipalities`/`geo_areas`, served by geo-bofrid `geo.bofrid.eu/api/geo`); `fb_group` is in bluebut-db — cross-DB FKs impossible. **Plan: slug-only** — add `geo_slug` to `fb_group`; `(geo_level, geo_slug)` is the canonical pointer, resolved on demand via geo-bofrid. **Out of scope:** storing `geom`, replicating geo tables, moving `fb_group` into geo.
