# Activity + Posts lane — state file (2026-08-11)

## ⭐ 2026-08-11 (second session) — FB opener FIXED, DM texts now EDITABLE

Prod API at **`77f7ad3`**; backend and web both deployed and verified.

**1 · The Facebook DM opener was our bug, not Meta's** (`664e5e7`). D3 and
blocker 12 below are closed. Backend oracle: fresh pids 581660/581673, restart
0 → 1, `/health` `{"status":"ok","db":true,"redis":true,"worker":true}`, and the
VPS `packages/shared/dist/facebook/facebook-client.js` carries
`"/me/messages", { recipient: { comment_id` (the string `private_replies` survives
only in the warning comment).

**⭐⭐ PROVEN END-TO-END THROUGH THE LIVE PIPELINE, 2026-08-11.** The operator
authorised the replay of the three buying-intent comments the closed gate had
dropped. Each was re-run from its own stored webhook envelope through the real
`processEnvelope` path — `bot` / `pipeline`, not a human retry:

| commenter | their comment | DM opener | public reply |
|---|---|---|---|
| Niranjala Silva Weththasinghe | *"I'm interested about this more details plz"* | **ok** `m_vEeTDs46z8eTHgPAU7h2…` | posted, the model's own words |
| Sristy Akter | *"Details"* | **ok** `m_MZQPb1fyotjvi6C81SkJ…` | posted, the model's own words |
| Ian Joseeph | *"Details plz"* | **failed** `(#10900) Activity already replied to` | **NOTHING posted** — draft held |

**2 of 3 landed. Lifetime facebook `dm_opener` ok: 0 → 3.** The fourth comment
(*"Nice 👌👌👌👌"*, no buying intent) was deliberately skipped and has **0 rows** —
the operator's *"don't spam anyone"* applied to it.

**The new no-fallback guard is proven in production by the one failure.** Ian
Joseeph's row stayed `dry_run`, no `reply` action exists, and two review items
were raised (`dm_opener` to retry, `approve` holding the model's draft). Nothing
was said publicly. Under the old contract he would have received the contact-us
fallback.

⚠ **`#10900` = A HUMAN ALREADY ANSWERED, from the Meta Page inbox.** Settled
2026-08-11 on the operator's own hypothesis, then proved by timing. Nine
commenters were privately replied to inside a **7-minute window
(13:12:13 → 13:19:05)** despite having commented across **six days** — lags of
12.4h, 1.3d, 3.5d, 3.7d, 5.7d, 5.9d and 6.5d. That is a person clearing a
backlog; an automation answers seconds after each comment and would be spread
across those six days. The follow-ups at 13:25/13:26 (*"Hi Mr. Adnan. Greetings
of the Day!"*) confirm a human kept the conversations going.

⚠ **Two earlier attributions in this file were WRONG and are retracted**:
ManyChat (circumstantial — its comment keywords `YARDS · Ibiza · district · SG ·
TORONTO` match none of these comments) and "an unidentified automation". Note
`app_id` is **not a readable field** on a Messenger message node, so no probe can
name a sender — the timing signature is the only tool that works here.

**The real defect this exposes: the pipeline cannot see a human's private
reply.** Meta allows ONE private reply per comment, so every comment the team
answers by hand becomes a permanent `#10900` for the bot — it will retry and fail
forever, and the row shows a failure that is really "already handled, by us".
Nothing reconciles the Page inbox with our own records. **`#10900` specifically
should be read as *already answered*, NOT as a send failure** — that is the one
outcome where the fail-closed default is wrong, and it needs its own state.

⚠ **Replaying a stored delivery needs TWO steps, not one.** Setting
`webhook_events.processed = false` is not enough: `enqueue` uses
`jobId: webhook-<id>` (`webhook-ingest.service.ts:150-159`) and BullMQ silently
drops an `add` whose jobId already exists, so the completed job must be REMOVED
from Redis first. A re-POST to `/webhooks/instagram` alone looks like a success
(`200 {"received":true}`) and does nothing at all.

**Earlier the same day — blocker 7 first closed by a manual retry:** Retried the queued `dm_opener` for
`Dyal Singh Rattol` (commented *"Pl give your ph.no and address"*, 2026-08-09
21:48) through the audited review route: `POST /api/agent/review/:id/decide` →
**200**, and `moderation_actions` records `facebook · dm_opener · ok` with Meta's
message id `m_fP0kzYXcF1d9qCnLEho5RdX43ISmvpn-…`. **Lifetime tally 0/16 → 1 ok.**
A second retry (`Anoop Thomas Chundamannil`) returned `(#10900) Activity already
replied to` and sent NOTHING — a business-rule rejection from the private-reply
handler, which is itself proof the edge resolves. That comment had already been
answered BY HAND from the Page inbox — see the `#10900` block above.

⚠ **7 `dm_opener` items remain `waiting` and MUST NOT be swept.** One is
`Kp Vijayan Kp`, *"Who are you man I don't know you"* — DMing that person is
exactly the spam the operator forbade (2026-08-11). Per row, by eye, or not at all.

**And the bigger find: the `facebook:comments` gate being off dropped four
comments SILENTLY** — no row, no turn, no log, no counter. Ingestion was never
broken (an earlier claim to that effect is retracted). All three leads have since
been answered. Full evidence: `HANDOFF.md`, top block.

**2 · The DM opener and its public fallback are editable in Behaviour**
(`d35055e`). A "What it says" section on the Comment replies tab. Web oracle: the
SERVED bundle greps **1** for each of `sit-agent-says`, `What it says`,
`commentDmOpener`, `commentDmFallback`, `agent-field-` and
`it is the standard wording` — `commentDmOpener` was **ZERO** in the bundle, which
is the very measurement blocker 4 was written from. `/agent/behaviour` 200.
Looked at in a real browser at 1440 and 375 (read mode and edit mode);
`scrollWidth === clientWidth`, zero overflowing elements.

Suites: **shared 1507 · api 2400 · web 1177** (1170 + 7 new), typecheck and lint
clean, `vite build` green.

⚠ **Blocker 4 is only PARTLY closed.** Two of the five texts now have a surface;
**three do not** — the comment seed policy, the output contract and the safety
rail. Those are prompt text rather than settings rows, and the recommendation had
two of the three read-only. That half still needs the operator's ruling.

⚠ **A trap this session paid for twice.** `vite build` is the ONLY oracle for a
shared VALUE crossing into the browser: every shared subpath the web imports needs
a **source alias in `apps/web/vite.config.ts`**, or rollup loses the named exports
while vitest resolves them happily. A whole wrong root cause (a "chained
`exports.A = exports.B = … = void 0`" theory, and a workaround written into
`comment-dm-opener.ts` for it) was built before the existing pattern was spotted
in the config's own comments. The workaround was reverted; the alias is the fix.

> **The lane's own state file.** Every line below is true as of the title date;
> it outranks the shared `HANDOFF.md` for this lane. Direction:
> `plans/rethink/sweep-activity-posts-kickoff.md`. Opener:
> `plans/kickoff-prompts/activity-posts.md`.
> `design/tiles/sweep-activity-posts/ANNOTATIONS.md` is round-3 vintage and
> carries a superseded-on-three-points banner — read the banner, not the counts.

## Status in one line

**Deployed and live: all six sheet-fidelity gaps and both 2026-08-06 priority
items (`main` at `c5d0753`, verified in the served bundle).**

**Also deployed 2026-08-07 (`main` at `fa73a39`): two of the four items he raised
that day, plus four defects an independent review found — one of which deleted a
customer's comment at Meta on ONE CLICK with nothing asked.** Suites on the
deployed tree: api 2400 · shared 1507 · web 1170.

**2026-08-11 — a VERIFICATION session; no code changed, nothing deployed.** The
operator brought seven live-use complaints and asked for them to be checked rather
than believed. Five held, two did not. It produced **four unowned defects (D1–D4
below, none fixed)** and **two new forks (10, 11)**. Only docs changed —
`9f22f5f` + `b07b71f` on `work/activity-posts`; `main` and prod are untouched.

⚠ **The eleven open forks are now ONE file:
`plans/rethink/activity-posts-decisions.html`.** It replaces
`activity-posts-unauthored.html`, `activity-posts-unauthored-2.html` and
`activity-dm-and-redraft.html`, **which contradicted each other on four points** —
most sharply on whether the comment seed policy is editable (one said yes, the
other listed it read-only) and on one option that would silently delete another
page's screen. The three sources are LEFT IN PLACE pending his word on retiring
them; do not treat them as current.

## The deploy, and what actually proved it (2026-08-06)

`pm2` fresh pid 3827862, uptime seconds, restart count 0 → 1. But the oracle was
**not** `pm2` and **not** `/health` (it answers `ok` from old code):

- **The api**: `POST /api/moderation/draft` answers **401**, and
  `POST /api/moderation/nope` answers **404**. The new route exists and is
  guarded. That probe ALSO closes the DI risk `CLAUDE.md` warns about — the seat
  could not boot a real injector in vitest, and `ReviewItemsService` gained a 7th
  constructor arg behind a new `ReviewModule → SettingsModule` edge. A wrong edge
  crash-loops on boot; a 401 proves the graph resolved.
- **The web**: grep of the SERVED bundle (`/var/www/channels-web/assets/`) —
  `cm-panel__body`, `cm-activity__markglyph`, `Kind of activity` and
  `moderation/draft` all PRESENT; the retired `Load older` and
  `cm-activity__markrow` both **0 hits**.
- Shared dist rebuilt first (`commentDmFallbackText` present in
  `packages/shared/dist/agent/comment-dm-opener.js`), then api, then restart as
  `sopserver1`.

⚠ **Pre-existing, NOT from this deploy:** `channels-api` logs an hourly
`No whatsapp connection is configured` from `QualityService.refreshFromMeta`
(seen at 05:00/06:00/07:00 on the OLD pid). Unrelated to this work; nobody owns
it yet.

## What shipped (2026-08-06)

- **P1 · the public reply no longer promises an unsent DM — on BOTH routes.**
  `sendDmOpener` returns whether the opener LANDED, and a live-mode failure
  withholds the model's "check your DMs" text for a seeded contact-us fallback.
  ⚠ **The first fix covered only the confident-live branch.** A review caught
  that `weOpenTheDm` is a function of the GATE (`comment-pipeline.ts:717-719`),
  so the CTA is written on every path, while the withhold sat below two early
  returns — a LOW-CONFIDENCE comment (`:841-866`) and a SHADOW reply gate both
  file an `approve` review item carrying the CTA and never reach `sendDmOpener`.
  Approving it later posted the promise verbatim. Both routes converge on one
  `approve` item, so the guard now lives at that convergence:
  `review-items.service.ts` opens the DM first and withholds the draft for the
  fallback when it does not land. A `send` (wording a HUMAN typed) still posts
  unchanged — we do not rewrite a person's words — with a loud warning.
  The fallback is a setting (`commentDmFallback`), so pointing it at the website
  and later at WhatsApp is an operator edit, not a deploy.
  ⚠ **It has NO UI yet** — see the open item below.
- **P2 · `reply_ai` is reachable.** `POST /api/moderation/draft` re-runs the
  policy and files the draft HELD for approval; `draft` is now its own
  `HumanAction` on Activity AND Posts. Both screens had mapped `reply_ai` and
  `reply_manual` onto one `reply` act, so the registry's agent-draft path had an
  entry and no button.
- **The four sheet gaps**: the panel body renders `KINDS[kind].sections`
  (`ActivitySections.tsx`, new); the filter menu is the sheet's five labelled
  groups with an in-menu footer and a Kind facet filtered in the DATABASE; a real
  Previous/Next pager with an honest `total` (NULL where no honest count exists);
  the right/wrong hover pair, on the outcome's own line.
- **Also ruled and built 2026-08-06**: the panel sticks-then-releases, and both
  group headings fold with their counts.

### ⚠ Open, and needed before this is finished

- ~~**The DM fallback is invisible in Behaviour.**~~ **BUILT AND LIVE 2026-08-11
  (`d35055e`).** Both it and the DM opener are now edited in Behaviour →
  Comment replies → "What it says", on the tab's own Edit/Save. A blanked field
  DELETES the settings row so the seed ships again; only changed fields are
  patched, so the seed is never stamped into the store as though chosen.
  ⚠ The prompt-text half of the same question (seed policy, output contract,
  safety rail) is still unbuilt and still needs a ruling — blocker 4.
- **`expectedStatus` on the draft endpoint is OPTIONAL, deliberately.** The web
  client posts `{ igCommentId }` only, so requiring it would 400 every existing
  call. The always-on guard is the status allow-list (`dry_run` only) plus a
  claim-by-update on the comment row; the optimistic check applies when a caller
  does send it. Making it required is a coordinated web change.

## Open from the 2026-08-06 review — read before touching these files

An independent review of the branch found nine items. The two web ones and the
critical are fixed or in flight; these are the ones deliberately left, each
verified as real:

- **The Kind facet and the Kind chip can disagree.** `turnToRow` relabels a
  comment turn as kind `moderation` when its still-waiting review is a
  `moderate`, but `caseKeyFilterFor` inverts `turnKind()` only, which knows
  nothing about that overlay. So **Kind = Comment** can list rows whose chip
  reads "Moderation", and **Kind = Moderation** returns an empty feed half for
  rows that are plainly visible unfiltered. Mostly masked on page one (those rows
  dedup into Needs), and it breaks `agent-feed.service.ts:118`'s own stated
  standard — "the filter can never return a row displaying a different word".
- **`caseKeys` + `caseKeyNotIn` clobber each other** (`turn-trace-admin.service.ts`
  ~:228). `caseKeyNotIn` overwrites `where["caseKey"]` unconditionally. NOT
  triggerable today — neither is exposed at the HTTP boundary and
  `caseKeyFilterFor` returns exactly one — so it is a landmine for the next
  caller, not a live defect.
- **A facet change shows the previous filter's rows for one fetch.**
  `placeholderData: (prev) => prev` holds across a FACET change, not just a page
  change, and nothing reads `isPlaceholderData`. No wrong action can result —
  every act carries its own row id and `expectedStatus` — but the table briefly
  lies about what it is showing.
- **`DecisionLabelService.label()` upserts on `(turnId, labeledByUserId)`**, so
  the redraft correction REPLACES that operator's existing verdict *and their
  typed note* on that turn. That is the spine's defined "a re-label overwrites
  rather than piles up" and a redraft is the newer signal, but the note loss is
  real and nothing warns about it.
- **A correction's `note` is write-only.** Every reader selects
  `{turnId, verdict}` — feed, console-read, replay. `DecisionLabelService.forTurns()`
  is the only method returning `note` and has ZERO callers: dead code. The
  verdict half genuinely lands (Activity's mark, and `replay.service.ts:159`
  builds its corpus from it), so this is one grain on the "kept, counted, never
  read" pile, not a new pile.

## 2026-08-07 — four operator items and four review findings, all deployed

He raised four. Two were **already ruled by the approved sheet**, so they were
built; two are ruled by nothing, so they are drawn. An independent review of the
branch then found four more defects, three of which PREDATE this branch.

⚠ **The review's worst finding was not in the new code at all.** `Posts.tsx` read
the registry's `destructive` flag only to pick a CSS class and fired the act
straight through, so **one click permanently deleted a customer's comment at
Meta** — no undo, nothing asked. Activity always had the confirm tray; Posts had
none. Fixed, keyed off `destructive` rather than the id.

Also found and fixed: Posts hardcoded the registry KIND, offering a hidden
comment two buttons the server can only refuse; the Needs half computed a
DIFFERENT outcome word than the Feed half for the same comment (a failed send
read "held" in the queue and "failed" everywhere else, and the queue's copy is
the one you see); and a trace-less Facebook review row was labelled Instagram.
A `dm` failed-send could compose `dm`+`held`, an outcome the registry forbids,
which left the row with NO acts and no stated reason — confirmed reachable.

And a test-harness defect: `Posts.test.tsx` never reset mock call history, so
every `not.toHaveBeenCalled()` in it was reading earlier tests' calls.

**BUILT and DEPLOYED (`fa73a39`):**
- **Overruling now reads the registry in the registry's own words.** `Posts.tsx`
  rendered `overridesFor(...)` straight through — the path's own label plus a
  `becomes` badge — while Activity funnelled the same list through
  `ACT_FOR_OVERRIDE` → `ACTION_LABEL`, collapsing distinct paths onto one generic
  verb ("Replace our reply" and "Write a reply yourself" both became "Reply") and
  dropping `becomes`. Two screens, one comment, two vocabularies. His words: *"I
  cannot order overwrite in activities like how I do in posts."*
- **`UnbuiltHere`** — a path the registry OFFERS that the screen cannot perform
  now says so. `ImpossibleHere` covers `never` (impossible at the platform);
  silence conflated the two, so an unbuilt path read as a forbidden one. That is
  how `reply_ai` sat unreachable behind a registry entry. `delete_reply` is the
  live case. ⚠ It excludes `mark_wrong`, which IS built as the row's mark pair.
- **The Source control's second destination.** Ruled 2026-08-04 and stated in
  the ANNOTATIONS banner: our record on a plain click, the platform on
  shift-click AND as its own visible link. Only our half had ever been built.
  The link is composed SERVER-side (`externalSource`, both halves of the Activity
  feed) because the alternative is six raw ids plus per-channel URL shapes in
  the browser.
  ⚠ **CORRECTED 2026-08-11 — this line used to claim "every row" and that was
  wrong. It shipped on ACTIVITY ONLY.** `externalSource` appears in
  `agent-feed.service.ts:857,948` and `Activity.tsx` and **nowhere in `Posts.tsx`
  or `posts.service.ts`**, while `posts.html` promises the gesture in as many
  words. See D4. ⚠ **And the shift-click gesture itself is now contested** — it is
  a documented Chrome shortcut and it currently means two different things
  depending on the row (blocker 11).

⚠ **`crm` rows never produce a platform link and that needs him.** The LeadRat
lead id is derivable, but no LeadRat WEB address exists anywhere in this repo or
the ledger — the only verified host is the API (`connect.leadrat.com`). The
mockup's `app.leadrat.com/leads/DEMO` is placeholder text. Inventing it would
ship a button that 404s, so the row states the absence instead. One line to fix
once he supplies the portal URL.

**DRAWN, needs a ruling — `plans/rethink/activity-dm-and-redraft.html`:**
- **(A) The DM opener is in Activity and indistinguishable.** It is written as a
  comment turn with outcome `replied` (`comment-pipeline.ts:1574`), same
  `caseKey` as the comment, so `turnKind()` answers *comment* for both. A live
  send therefore produces TWO identical Comment/sent rows for one comment, and
  neither reveals a private message. Recommended: fold it into the comment (one
  row, a "+ DM" marker, its own panel section).
- **(B) Asking the agent to redo a reply with an instruction does not exist.**
  Every rewrite path makes the operator type it: `replace_reply` is manual, and
  `reply_ai` takes no instruction and only appears on a skipped comment. Two
  forks, the sharp one being whether the wrong reply comes down immediately or
  stays public until the new one is approved.

## 🔧 UNOWNED DEFECTS — found 2026-08-11, verified, NOT fixed, need NO ruling

Nobody is working on these. Each was proven against the running code and the
production database on 2026-08-08; none was touched, because that session's order
was to verify, not to build. **These are code fixes, not forks.**

| # | Defect | Evidence | Fix shape |
|---|---|---|---|
| D1 | **Moderation falls open SILENTLY in live mode.** Empty model output on `max_tokens` → the fail-safe forces `leave`, no alert, no review item. The board records this as fixed at source 2026-07-20 (cap 1024, `moderation-policy.ts:167`) and states *"no detector exists if it recurs."* **It recurred.** | 2 live rows: facebook `empty_output` 2026-08-07 02:58, `empty_output:max_tokens` 2026-08-08 00:59. One is the comment the operator asked about — it was never judged. | The fail-safe must PAGE, not go quiet. Detector + escalation, mirroring the existing failsafe trace path. |
| D2 | **The review route's delete never writes the row status.** `review-items.service.ts:443-454` calls Graph, records the act `ok`, returns. Its siblings in the same method both write it (reply → `posted` `:437-440`, restore → `dry_run` `:465-468`); the console path writes it too (`moderation-action.service.ts:126-129`). So the row reaches `deleted` only if Meta's remove-webhook arrives. | **3 prod rows still read `hidden`** after a successful delete — IG `17868747270593504` (deleted 08-08 10:52, `updated_at` frozen at the 08:44 hide), `18057342800782223`, plus one more. All 3 had been hidden by the bot first; the 6 that landed correctly had not. | Write the status like the siblings do, then correct the 3 stale rows. |
| ~~D3~~ | ~~**The Facebook DM opener has NEVER once succeeded.**~~ **ROOT-CAUSED AND FIXED 2026-08-11 — deployed (`664e5e7`).** Final count before the fix: **16 failures out of 16** (2026-08-05 → 08-10); Instagram **18/18 ok on the SAME Page token**. The cause was ours, not Meta's: `sendPrivateReply` called `POST /{comment_id}/private_replies`, which Meta's reference says **"was removed after Graph API v3.2"**, on a prod running **v25.0**. | The read-only probe that settled it, with the live token: `GET /me` → **200** (Page `Silver Oak Properties - UAE`, `109939868479359`) and `GET /{comment_id}` → **200** with the comment body. Object exists, token can read it → only *"does not support this operation"* remains. | **DONE.** Now `POST /me/messages` with `{recipient:{comment_id},message:{text}}` — the shape IG already used. ⚠ **Not proven to be the only blocker**: the oracle is one real FB comment recording `dm_opener` → `ok`. |
| D4 | **The Source platform link is built on Activity ONLY, not on Posts.** `externalSource` is composed in `agent-feed.service.ts:857,948` and read in `Activity.tsx`; it appears NOWHERE in `Posts.tsx` or `posts.service.ts`. | `posts.html` promises *"shift-click to open it on the platform"* and this state file claimed on 2026-08-07 that it shipped on both. **The sheet, this file and the screen disagreed.** Surfaced per Precedence, not reconciled. | Compose it in the Posts payload and render it, as Activity does. |

## 🔒 BLOCKED — every item, with the ONE thing that unblocks it (2026-08-11)

Nothing below can move without the named unblocker. Nothing below is in flight.
**All eleven forks are now in ONE file: `plans/rethink/activity-posts-decisions.html`**,
which also states the four conflicts between the three pages it replaces. The
numbers below are that file's decision numbers.

| # | Blocked item | Unblocker — who and what |
|---|---|---|
| 1 | **A `crm` row has no platform link** and cannot get one. The lead id is derivable; no LeadRat WEB address exists in this repo or the ledger. Only the API host is verified (`connect.leadrat.com`, `leadrat.service.ts:163`). The mockup's `app.leadrat.com/leads/DEMO` is placeholder text, and inventing it ships a 404. | **OPERATOR: the LeadRat portal URL for a single lead.** Then one line in `activity-external-source.ts`, like the other five kinds. |
| 2 | **The DM opener is indistinguishable in Activity** — one comment yields two identical Comment/sent rows. Decision 4. | **OPERATOR: pick a fork.** Recommended: fold it into the comment (one row, a "+ DM" marker, its own panel section). ⚠ On Facebook the marker would never appear — see D3. |
| 3 | **Asking the agent to redo a reply with an instruction** does not exist. Decision 3. | **OPERATOR: pick two forks.** The sharp one is whether the wrong reply comes down immediately or stays public until the new one is approved. ⚠ **Rule decision 2 first** — this item's stated value depends on a correction's note being readable, which it is not. |
| 4 | **PARTLY BUILT 2026-08-11 (`d35055e`) — 2 of the 5 texts now have a surface.** The operator ordered the build, so the section was invented on the recommendation: **"What it says" on the Comment replies tab, holding the DM opener and the DM fallback, both editable**, each showing what a customer would actually receive and saying so when nobody has written it. `grep commentDmOpener` in the SERVED bundle is now **1**, was ZERO. ⚠ **Still invisible: the comment seed policy, the output contract and the safety rail** — prompt text, not settings rows. Decision 1. | **OPERATOR: one ruling left — the remaining three.** Recommended: same section, seed editable, contract and rail read-only. ⚠ The two old pages CONTRADICTED each other on whether the seed is editable; that conflict is still unresolved and is exactly what this half turns on. |
| 5 | The four remaining drawn items — `· conf 0.42` on a skipped row (decision 6), two registry copies with nothing keeping them equal (9), the Kind filter vs the Kind chip (5), a correction's write-only note (2). | **OPERATOR: pick a fork on each.** ⚠ Decision 2's option 3 would DELETE the corrections screen decision 2's own first option draws — stated in the combined file. |
| ~~6~~ | ~~A button in the DM opener — blocked on the ManyChat intent-field character limit.~~ **CLOSED 2026-08-11.** The operator supplied the screenshot: his live intent is `person wants to buy/rent/invest/inquire in property` — **exactly 51 chars, 34 runs**, so 51 demonstrably works and every drafted replacement is shorter (the pick is 42). The limit was only needed to go LONGER. He also confirmed the trigger is identical on Messenger. | — |
| 7 | **The first real comment since the DM leg went live.** ⚠ **Partly answered 2026-08-11:** the path HAS executed — Instagram 8 sends, all ok. **Facebook has never once worked** (see D3). | **OPERATOR: a real comment on a live FB post** once D3's cause is settled, or confirmation he wants it forced. |
| 8 | **The sheet amendment for sortable columns.** He ruled Time only; `activity.html:667-668` still marks Time, Channel, Kind AND Outcome sortable. Three of the four cannot be served honestly (`outcomeLabel` is composed across three tables, Kind is derived). | **OPERATOR: confirm the sheet may be edited** (it is the approved artifact). Recommended: yes, amend to Time only. |
| 9 | **`send` posts the operator's own wording unchanged even when the DM failed.** A model draft gets the fallback; wording a human typed does not, with a loud warn log. My judgement call, not his ruling. | **OPERATOR: confirm or reverse.** Two-line change either way. |
| 10 | **How many DM openers may ONE person receive?** NEW, found 2026-08-11: `sendDmOpener` (`comment-pipeline.ts:1536`) has **no de-duplication of any kind** — not per person, per post or per day. One IG account commented on one post 6 times and got **2 openers**; 8 people have commented 2–6 times on a single post and each comment was treated as a stranger. Meta permits one private reply per COMMENT, so the limit must be ours. Decision 10. | **OPERATOR: pick a cap.** Recommended: one opener per person per post. |
| 11 | **Shift-click is a documented Chrome shortcut** (*"Open a link in a new window"* — verified against Chrome's own help) and we took it. Worse, it means **two things**: `Activity.tsx:2127` returns early when a row has no platform link, so Chrome opens OUR record in a new window instead; the mockup (`activity.html:909-914`) preventDefaults unconditionally and opens nothing. Three behaviours, two artifacts, one modifier. Affects every `crm` row, every IG `dm`/`flow` row, every `web` row and every deleted comment (**50 prod rows** in that last group alone). Decision 11. | **OPERATOR: pick a fork.** Recommended: keep the shortcut but make it consistent. He asked for the shortcut twice (2026-08-04, 08-07), so dropping it is his reversal to make, not mine. |
| ~~12~~ | ~~**The Facebook DM opener cannot be fixed from this repo.**~~ **CLOSED 2026-08-11 — it was fixable from this repo, and it is fixed and deployed (`664e5e7`).** The claim that the failure was Meta-side, and that "read-only probing is exhausted", were both wrong; the Messenger-product candidate is dead as the cause. See D3. ⚠ Blocker 7 stays open: one real FB comment is still the only oracle. | — |

**Handed to another stream and CLOSED — nothing owed:** the API work on both
2026-08-06 items, the absolute-source derivation, the outcome-computation fix and
the guessed-channel fix all went to an executor seat, were verified by me against
the code and the full suites, and are merged and deployed. No seat has open work.

## ⭐ Waiting on the operator — the five unplanned items are now DRAWN

**`plans/rethink/activity-posts-unauthored-2.html`** (2026-08-06). The remainder
of the lane, at the size each would ship, with the fork on each. Every fact on it
was checked against the code that day, not carried from a state file. Ruling it
is what turns it into a work order. The five:

**A** everything the agent says is invisible in Behaviour (his own question of
2026-08-06 — the opener, the new fallback and the comment rules are all settings
rows with no screen) · **B** a skipped comment shows `· conf 0.42` instead of a
reason (`Activity.tsx:1768`) · **C** two copies of the registry with nothing
keeping them equal · **D** the Kind filter can disagree with the Kind column ·
**E** a correction's note is written and read nowhere, and a redraft can
overwrite one he typed.

D and E came from the 2026-08-06 review and were deliberately left unfixed so
they could be ruled rather than assumed; their detail is in the section below.

## Waiting on the operator — do NOT build these until he rules

- **`plans/rethink/activity-posts-unauthored.html`** (drawn 2026-08-05). Four
  items with no plan on disk, each with a fork: server-side `commonOverrides()`
  enforcement · the unstored IG DM thread id · the corrections collection behind
  the head's link · the comment prompt being invisible in Behaviour.
  ⚠ **The fourth one grew on 2026-08-06.** The DM opener text and the new DM
  fallback text are BOTH settings with no surface in Behaviour, and
  `grep commentDmOpener apps/web` returns zero. Whatever section answers this
  fork has to hold all three.
- **Where a correction is read.** `reply_ai` now records one (ruled 2026-08-06),
  which makes "corrections are kept, counted, and never read" a bigger item than
  it was, not a smaller one.
- **A button in the DM opener**, so a tap fires a ManyChat client interaction.
  Blocked on his screenshot of ManyChat's intent-field character limit. His
  current intent string is 51 chars; drafted shorter alternatives are in the
  2026-08-05 session, the pick being `Wants to buy, rent or invest in a property`.
- **The first real comment since the DM leg went live.** That path had NEVER
  executed on prod: 0 sends, 0 attempts, 0 failures, ever. A failure lands as a
  `dm_opener` row in Needs action plus a `failsafe` trace, never a silent miss.
- **The sheet amendment for sortable columns.** He ruled **Time only** and to
  amend the sheet; the sheet still marks Channel, Kind and Outcome sortable, and
  three of those cannot be served honestly (`outcomeLabel` is composed across
  three tables, Kind is derived).

## Shipped and live (deployed 2026-08-05, verified in the SERVED bundle)

**⚠ Health is NOT a deploy oracle** — it answers `ok` from the old code just as
happily. The oracle is `pm2 list` showing fresh uptime and an incremented restart
count, plus a grep of the rebuilt `dist/`. And restart **as `sopserver1`, never
as root**: root's `pm2 list` is empty, so a root restart exits 0 and restarts
nothing.

**Activity** — the list gets the width, not the panel (Details went 53px → 229px,
27 of 54 overflowing cells → 0); one list with two group headings replacing the
Feed/Needs switch, merging two queries so trace-less review rows survive and a
both-halves row renders once; Time sorts on the server with the keyset flipped;
the screen READS `activity-registry.ts`, which nothing imported before, and
stopped offering acts on comments Meta has permanently deleted; a min-width floor
(Details rendered at width ZERO on a phone) and a 24-hour clock.

**Posts** — one comment, one outcome word: the local pill table ("replied", "in
review", "no action") is deleted and `computeOutcome` runs server-side for post
comments, because the registry keys override paths on the outcome and the two
screens would otherwise offer different buttons for one comment. A comment is
decidable from the post, every button from `overridesFor`, each carrying the
registry's `becomes`, with `never` stated as a fact.

**The comment → DM leg is LIVE on both channels** (operator: *"live on both …
but make it live ok"*).

**Facebook posts** — already built before this session, and ingesting: **206
organic FB posts and 6 FB ad posts on prod**. The earlier report classifying it
as unbuilt was wrong; it was read off the plan file without checking for code.

## Landmines

- **Flipping the `comment_to_dm` gate does NOT change the CTA on its own.** The
  prompt is the seed AND the operator's Situations headings, combined. His live
  text said "DM us" **17 times** against the seed's "check your DMs", and the
  more specific text wins. Rewritten on prod → 0 DM-CTA, 28 "check your DMs".
  Backups: `~/kb-backups/agentSituations-20260805-precta.json` (10,698 b,
  validated) and `~/kb-backups/channelGates-20260805.json` (428 b, validated).
- **The public reply must not promise what the DM does not carry.** The opener
  is a greeting, not a brochure; a test pins seven over-promising phrases out.
- **Two of our own docs disagree on whether a private reply can carry a button.**
  `comment-dm-opener.ts:30-38` says the text-only claim was WITHDRAWN as an
  unsourced inference; `facebook-client.ts:167` still states text-only by Meta's
  schema. Unresolved. Certain either way: **our clients send text only**.
- **The FB private reply is documented "built DARK"** for want of
  `pages_messaging` (`facebook-client.ts:141`); the gate is live anyway.
- **MERGE, never rebase, never force-push.** This branch carries published merge
  commits; a rebase replays ~25 and conflicts through the doc files. `main` moved
  FOUR times during one session — merge it in and re-run the suites before any
  deploy.
- **The MOCKUP registry and the ported one must not drift, and NOTHING ENFORCES
  IT.** The mockup copy is disposable now both screens build from the port.
- **A scoped `vitest run <file>` is NOT green.** The full run caught a hardcoded
  `#fff` (the design-system conformance guard) a scoped run never executes.
- **A narrow `--window-size` LIES on Windows** (clamps to ~485px and crops). Use
  CDP device emulation for a true 375.
- **The screenshot bridge dies after ~6 captures** — the CAPTURE dies, not the
  page. Measure by query rather than retrying the shot.
- **Verify a screen against its sheet by LOOKING, not by measuring.** Four of the
  six fidelity gaps existed while every suite was green and every width measured
  clean, because the panel was never held up against the sheet at all.

## Oracles

```
cd packages/shared && npx vitest run --pool=forks --poolOptions.forks.maxForks=2
cd apps/web        && npx vitest run --pool=forks --poolOptions.forks.maxForks=2
cd apps/api        && npx vitest run --pool=forks --poolOptions.forks.maxForks=2
corepack pnpm --filter @channels/web typecheck && corepack pnpm --filter @channels/web build
```

Last full green on the merged tree: **shared 1459 · api 2280 · web 1131**,
typechecks and `vite build` clean. `vite build` is the real oracle for a runtime
value crossing the shared barrel — vitest passes where rollup fails.

Serving a sheet, looking at a screen, and reading prod's database are in memory
(`screenshot-recipe`, `stub-the-api-to-look-at-a-screen`, `query-prod-db`).
