Activity + Posts lane · 2026-08-08 · combines three files

Everything waiting on your ruling, in one place

This replaces three separate pages: activity-posts-unauthored.html (2026-08-05), activity-posts-unauthored-2.html (2026-08-06) and activity-dm-and-redraft.html (2026-08-07). Nine items across them became ten decisions, because two of the three pages contradicted each other on the same subject and one page argued for something that is not true yet.

How to use it. Read the four conflicts first, since they change what the rest of the page is asking. Then tick one option per decision. A ticked option is a work order; an untouched decision stays blocked. Nothing on this page is built. Every fact was re-checked against the running code and the production database on 2026-08-08, not carried from a state file.

First — the four conflicts between the three pages

contradiction  1 · The comment rules: editable, or read-only?

Two pages describe the same text and recommend the opposite thing. This is the only place the three files genuinely disagree, and it is why they had to be merged.

unauthored.html §D — 2026-08-05
The comment seed policy becomes an ordinary editable field with a Reset. Only the output contract and the safety rail stay read-only. "Show all three, edit two."
unauthored-2.html §A — 2026-08-06
The comment rules are listed as read-only — shown "so nothing about the agent is hidden, even where it is not yours to edit."

The lane's own state file already noticed they are one item and never resolved it. Merged into decision 1, where you rule editability once.

one option kills the other page  2 · Corrections

unauthored-2.html §E offers option 3: "stop writing notes and keep corrections as bare verdicts." But unauthored.html §C draws a whole Corrections screen whose third column is "Your correction" — it renders exactly those notes. Picking E-3 deletes C's design without saying so. Merged into decision 2.

argues from something untrue  3 · The redo path's justification

activity-dm-and-redraft.html §B sells the redo path on the grounds that your note is "stored as a correction [and] feeds the periodic prompt review, which is the only loop that stops the same mistake recurring." Nothing reads a correction's note today — that is precisely what §E of the other page proves. So the main argument for §B only becomes true once decision 2 lands. Not a contradiction, an ordering: decision 2 before decision 3.

factually wrong  4 · The server-side guard misses a third path

unauthored.html §A proposes one guard in moderation-action.service.ts · act() and argues it covers everything because "actBulk delegates to act." It does — but there is a third act path it never mentions: the review route (review-items.service.ts) calls Graph directly and never passes through act() at all. That is also the path carrying a live defect found today (see below). The guard as drawn would leave it open. Corrected in decision 7.

Second — five things verified on production today that change the pages

What was foundWhy it changes a decision above
The Facebook DM opener has never once worked. 10 attempts, 10 failures, zero successes. Instagram: 8 for 8. Decision 4 draws a "+ DM" marker on the comment row. On Facebook that marker would never appear, and every Facebook reply has been silently posting the contact-us fallback instead of the agent's own text.
Moderation fell open silently, twice, in live mode — it returned nothing on max_tokens and the fail-safe forced leave (FB 08-07 02:58 and 08-08 00:59). Not on any page. It is a pure defect, not a fork — no ruling needed, and it is why one comment you asked about was never actually judged.
The review route's delete never writes the row status. 3 production rows still read hidden after being deleted at Meta. Same file as conflict 4. Also a pure defect. Its sibling branches in the same method both write status; only delete forgets.
The DM opener has no de-duplication of any kind. One person received two openers after commenting on the same post six times. A fork nobody has drawn. Added as decision 10.
The Source control's platform link is built on Activity only, not on PostsexternalSource appears nowhere in Posts.tsx or posts.service.ts. posts.html promises it in as many words. The lane's state file claims it shipped. A defect, not a ruling — but it means the approved sheet and the built screen disagree.

The eleven decisions

1

Where everything the agent says lives, and what you may edit

your order, twice

Merges unauthored §D + unauthored-2 §A, and settles conflict 1.

The fact. Five texts reach real customers or shape every reply, and none has a screen. Behaviour is driven only by the Situations structure plus the assembled prompt, with no free-form field anywhere: the DM opener · the DM fallback · the comment seed policy (~2.4k chars, carrying the two-tier reply logic, the moderation bar and the reply-style examples) · the output contract · the safety rail. grep commentDmOpener apps/web returns zero. Your order of 2026-08-05 was "all the prompt should be organized and shown in behaviour and not hidden."

Two questions, and the second is the one the two pages disagreed on.

Where does it live?

  1. One "What it says" section in Behaviour, holding all five. Everything the agent utters is then in one place, which is what the order asked for.
  2. Beside the channel gates in Settings. Cheaper, but splits "what the agent says" across two screens and leaves Behaviour still incomplete.
  3. Leave them as settings rows. Honest only if you accept that changing the public fallback means asking an engineer.

What is editable?

  1. Opener, fallback and seed policy editable; contract and rail read-only. The seed gets a Reset that returns the shipped text. Read-only is the difference between "hidden" and "not yours to change" — and the contract is what the engine parses, so editing it breaks replies outright.
  2. Opener and fallback editable; seed policy read-only too. Solves "hidden", not "operator-extensible". This is what unauthored-2 assumed.
  3. Fold the seed into your five headings. One surface, no extra card, but it rewrites text you already recategorised by hand and the merge cannot be undone.
Unblocks: blocker 4. Note: the fallback is already a setting, so once it has a field, pointing it at the website and later at WhatsApp is your edit, not a deploy.
2

Corrections: collect them, and let one say why

a link with nothing behind it

Merges unauthored §C + unauthored-2 §E, and settles conflict 2.

The fact. The approved sheet's head carries a line you signed off: "N corrections kept since DATE. Nothing is added to the agent's instructions automatically. Review them and improve the instructions." The link goes nowhere, because nothing gathers corrections. Worse, a correction's note is read nowhere at all — every reader selects the verdict only, and the one method that returns the note has zero callers. So the verdict half works (it draws the row's mark and feeds the replay corpus); the explanation is lost.

One decision with three parts, because they only make sense together.

  1. Build the collection, show the note, warn before overwriting one. The head's link opens a real list; the panel renders the note beside the mark; a redraft that would replace a note you typed asks first. This is the only combination that makes the periodic review actually possible.
  2. Show the note in the panel and warn on overwrite; no collection screen. Fixes the loss and the silence, leaves the sheet's head line unhonoured.
  3. Stop writing notes; keep bare verdicts. Closes the question permanently — and deletes the "Your correction" column from the screen unauthored §C drew, so decision 3 loses its main justification too.

If you take option 1, what does the count mean?

  1. Since a date you move. A stored "reviewed up to" mark, so the number means owed, not ever.
  2. Since forever. One number that only grows and stops meaning anything by October.
  3. Rolling 30 days. No state to keep, but a correction can age out unread.
Unblocks: blocker 5 (part E) and the sheet's own head line. Blocks: decision 3's stated value.
3

Telling the agent what to change, and letting it rewrite

does not exist

activity-dm-and-redraft §B. Rule decision 2 first (conflict 3).

The fact. Every path that rewrites a reply makes you write it. "Replace our reply" opens an empty box. "Draft a reply now" asks the agent but takes no instruction and only appears on a comment it already skipped. There is nothing that says redo this, and here is what was wrong with it.

What happens to the public reply while the agent rewrites?

  1. Delete it first. The wrong reply stops being public immediately; the comment sits unanswered for a few seconds.
  2. Leave it up until the new one is approved. Never a gap, but the reply you objected to stays public until you come back.

Does your note reach this reply only, or the instructions too?

  1. This reply only, kept as a correction for your review. Matches the standing rule that nothing is added to the agent's instructions automatically.
  2. Fold it into the Situations text. Changes the agent's behaviour everywhere, from one annoyed sentence typed about one comment.
Unblocks: blocker 3.
4

The DM we open with is in Activity, and you cannot tell

present, indistinguishable

activity-dm-and-redraft §A. Read with today's Facebook finding.

The fact. The opener is written as a comment turn with the outcome replied and the same case key as the comment, so it renders as kind Comment, outcome sent — identical to the public reply. One comment produces two indistinguishable rows, and neither says a private message was involved. Confirmed on production: eight Instagram comments each show dm_opener/ok → reply/ok.

New today: on Facebook the opener has failed 10 times out of 10, so whichever shape you pick, the Facebook row will show no DM at all until that is fixed — and it needs your Meta-side action, not a code change.
  1. Fold it into the comment: one row, a "+ DM" marker, its own panel section. One comment stays one row, which is the sheet's whole rule, and the DM text becomes readable for the first time.
  2. Give it its own row, marked as a DM. Truthful about the two sends, at the cost of two rows per comment forever.
  3. Leave it as a second Comment row. Only defensible if you never need to know whether the DM went out — which the fallback work says you do.
Unblocks: blocker 2.
5

The Kind filter can disagree with the Kind column

from a review

unauthored-2 §D.

The fact. Filtering by Comment can list rows whose own chip reads Moderation, and filtering by Moderation can return an empty half for rows that are plainly there unfiltered. A comment held by the moderator is relabelled at display time; the filter inverts the original derivation, which knows nothing about the relabelling. It breaks the feed service's own stated standard — "the filter can never return a row displaying a different word." Mostly masked on page one, which is why it would reach you as a puzzle rather than a bug.
  1. Teach the filter about the relabelling. Kind = Moderation finds held comments; Kind = Comment excludes them. Filter and column then agree by construction.
  2. Drop the relabelling. A held comment stays a Comment and its held state rides on the outcome word alone. Simpler everywhere, loses a distinction the sheet drew on purpose.
  3. Leave it. Masked today; surfaces the first time somebody pages or sorts.
Unblocks: blocker 5 (part D). Interacts with decision 4 — both change what the Kind and Outcome columns mean, so rule them together.
6

A skipped comment says why, instead of showing a number

breaks a standing law

unauthored-2 §B.

The fact. The row already carries the reason the agent left the comment alone. The screen renders · conf 0.42 instead — engineering vocabulary on a screen whose whole rule is plain words. It survived three rounds because the row was never held up against the panel, which does show the reason properly.
  1. A plain sentence on the row; the number only in the panel, beside the floor it was judged against, where it means something.
  2. Keep the number, add the floor. "0.42 against a floor of 0.60" is at least complete, and still a number where a sentence belongs.
  3. Drop it from the row entirely. Cleanest list; a held row then gives no clue why it waits.
Unblocks: blocker 5 (part B).
7

The screen is the only thing stopping a forbidden act

a real hole

unauthored §A, corrected per conflict 4.

The fact. The registry says which acts a row allows. Both screens obey it. The server obeys one line of it — a deleted comment is refused. Everything else is enforced by the buttons we chose to draw, which is not enforcement. A crafted request or a stale tab can ask to hide a flow-run row, which declares no hide path at all.

Corrected today: the drawn guard sits in moderation-action.service.ts · act() and the page claims that covers everything because actBulk delegates to it. There is a third path: the review route calls Graph directly and never enters act(). The guard has to go in both, or in something they share.
  1. Refuse, and say why — on both paths. The act returns the registry's own reason, so a stale tab tells the truth instead of failing blankly.
  2. Refuse silently. Cheaper, and indistinguishable from a bug when it fires on a real operator.
  3. Log only, for one week. Learn whether it ever fires before making it fatal — and leave the hole open while you watch.
8

An Instagram DM has no link back to Instagram

honest, and useless

unauthored §B.

The fact. Every kind offers two Sources — our record, and the real thing. WhatsApp derives wa.me/<waId> today and Facebook derives facebook.com/messages/t/<psid>. Instagram needs a thread id we never store, so the row states the absence and you retype a handle into Instagram.
  1. Store it from now on. One nullable column written from the webhook envelope that already carries it. New conversations link; old ones keep the honest fallback. No backfill, no invention.
  2. Store it and backfill. A Graph read per old conversation, and the thread id may no longer resolve. Spend for a tail.
  3. Leave it. The Inbox link is the useful one; the absolute source matters most for comments, which already have it.
9

Two copies of the registry, and nothing keeps them equal

silent when it breaks

unauthored-2 §C.

The fact. The registry decides what a row can be overruled into and what is permanently impossible. It exists twice — the mockup's copy, which both design sheets render, and the ported copy, which both screens and the server read. Nothing compares them. When they drift, the sheet keeps drawing a control the product no longer offers, and the next design round is reviewed against a lie. The lane's state file has carried this warning since 2026-08-05 and it is still true.
  1. Delete the mockup's copy; the sheets import the port. One registry, drift impossible by construction rather than by vigilance. The sheets get slightly harder to open standalone.
  2. A conformance test that fails the build on any difference. Keeps both files, catches drift immediately, costs a test that has to parse a plain script.
  3. Accept the drift and mark the mockup copy historical, so nobody reviews against it again.
Unblocks: blocker 5 (part C).
10

How many DMs may one person get?

new — found on production today

Drawn by nobody. Found 2026-08-08 in the production data.

The fact. The DM opener has no de-duplication of any kind — not per person, not per post, not per day. Every top-level comment that reaches the leg sends one. It has already happened: one Instagram account commented on the same post six times and received two openers. Meta permits one private reply per comment, so six comments are six legal openers — the limit has to be ours. Separately, the pipeline has no memory of a repeat commenter at all: eight people have commented 2–6 times on one post and each comment was treated as a stranger.
  1. One opener per person per post. Kills the observed duplicate, still greets someone who comments on a genuinely different post, and needs no new state beyond a query.
  2. One opener per person per 24 hours, across all posts. Strictest against spam; a genuine second enquiry the next morning still gets a greeting.
  3. One opener per person, ever. Never annoying, and a returning customer months later is met with silence.
  4. Leave it uncapped. Honest only if you are content that a spammer can pull an unlimited number of DMs out of us.
11

Shift-click is a documented browser shortcut, and we took it

new — verified 2026-08-08

Raised by the operator, 2026-08-08. Not drawn anywhere before.

The fact, from Chrome's own documentation. Shift+click on a link is "Open a link in a new window"; Ctrl+click is "Open a link in new background tab"; Ctrl+Shift+click is "Open a link, and jump to it." Both our screen and the sheet acknowledge this in a comment — "Shift is 'open in new window', which almost nobody uses on purpose" — so the 2026-08-04 ruling was made with the collision in view. That justification was never sourced or measured.

The defect is not the collision, it is the inconsistency. The handler reads if (!e.shiftKey || !platform) return;. When a row has a platform link we suppress the browser and open the platform. When it has none we return early and Chrome's native behaviour opens our own record in a new window instead. One gesture, two meanings, with nothing on the row saying which.

And the sheet disagrees with the build on that same case. The mockup suppresses the default unconditionally and then opens nothing when there is no platform link — a dead gesture. Three behaviours across two artifacts for one modifier.

Rows with no platform link are common, not exotic: every crm row, every Instagram dm or flow row, every web row, and every deleted comment. The deleted comments alone are 50 rows on production today.

  1. Keep the shortcut, make it consistent. Suppress the default on every row, and when there is no platform link say why in the panel rather than silently opening our own record in a new window. Keeps the gesture you asked for twice; removes the two-meanings problem.
  2. Drop the shift-click hijack; keep the visible platform link. The second destination has been its own visible link since 2026-08-07, so the shortcut now adds nothing but a collision with a documented browser gesture. Deletes code, removes the collision and the inconsistency at once. This is what I would pick if the gesture were not already ruled.
  3. Leave it. The gesture keeps meaning two different things depending on the row.
Not verified: whether our replacement window.open(url,"_blank","noopener") yields a window or a tab. If a tab, then even where the hijack works, Shift gives a tab where Chrome promised a window.

The two approved sheets — what is actually open on them

activity.html — two things, and only the first is a ruling

1 · The sortable columns contradict your own ruling. The sheet marks Time, Channel, Kind and Outcome all sortable (activity.html:667-668). You ruled Time only. Three of the four cannot be served honestly anyway: the outcome word is composed across three tables and Kind is derived, so sorting them means sorting something the database does not hold.

What you rule: may the approved sheet be edited to say Time only? That is blocker 8, and it exists solely because the sheet is your artifact and I will not quietly rewrite it. Recommended: yes, amend it — the alternative is building three sorts that would have to lie.

2 · The head promises a screen that does not exist. The sheet's head reads "N corrections kept since DATE… Review them and improve the instructions" with a link. There is nothing behind it. That is decision 2, not a separate question — ruling decision 2 is what makes the sheet's own head honest, or tells me to delete the line.

Nothing else. ANNOTATIONS.md beside it is round-3 vintage, its four foot-forks are closed, and its counts (8 outcomes, 26 pairs, 73 paths) are superseded by the live registry (7, 24, 67). Its banner already says so, so it needs no ruling — only eventual retirement.

posts.html — nothing to rule, one thing to know

There is no open fork on this sheet. Everything it drew has shipped, and the one thing it shares with Activity — the registry — is decision 9.

But it carries a promise the build does not keep. The sheet says, in as many words, "Open the post here — shift-click to open it on the platform." That second destination was ruled on 2026-08-04 and reported as shipped. It is built on Activity and not on Posts: externalSource appears in agent-feed.service.ts and Activity.tsx, and nowhere in Posts.tsx or posts.service.ts. So the approved sheet, the lane's state file and the running screen disagree with each other.

That is a defect and it is mine to fix, not yours to rule. It is listed here because you asked what is open on this sheet, and this is the honest answer.

Ruling block — mark it and hand it back

1  What the agent says   → where: 1 / 2 / 3      editable: 1 / 2 / 3
2  Corrections           → 1 / 2 / 3             count window: 1 / 2 / 3
3  Redo a reply          → reply comes down: 1 / 2    note reaches: 1 / 2
4  The DM's row          → 1 / 2 / 3
5  Kind filter vs chip   → 1 / 2 / 3
6  A skipped row's why   → 1 / 2 / 3
7  Server-side guard     → 1 / 2 / 3
8  IG DM thread id       → 1 / 2 / 3
9  One registry          → 1 / 2 / 3
10 DMs per person        → 1 / 2 / 3 / 4
11 Shift-click           → 1 / 2 / 3

activity.html sortable columns  → amend the sheet to Time only?  yes / no
LeadRat portal URL for one lead →