Skip to content

lazurio: WhatsApp intake, model-free WhatsApp group triggers for a support bot (DEV-6691) - #80

Merged
immakermatty merged 1 commit into
mainfrom
agent/DEV-6691-whatsapp-intake
Nov 1, 2026
Merged

immakermatty merged 1 commit into
mainfrom
agent/DEV-6691-whatsapp-intake

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 30, 2026 •

Copy link
Copy Markdown

Why

A provider's Environment should answer its clients in the WhatsApp groups it shares with them, without anyone opening the app and without a model reading WhatsApp all day. The Organization Admin decided the shape on 2026-10-30 (plan DEV-6691):

  • The support bot is an ordinary bot in the Environment's existing MausBot server. There is no second server and no confinement flag.
  • In the pilot groups it replies on its own when tagged or replied to, from the first day.
  • A model-free cross-client outbound guard is added on top of the commercial and rate guards.

Every bot runs with the Environment's full access (LAZURIO.md, principles 2 and 3), so the support bot can technically reach a shell, gh and the wacli store. What keeps one client's conversation out of another's therefore has to be plain code in front of the bot. That code controls two things: what the bot is given (one group per run, and nothing from the map that would lead it to a client's systems) and where its words may go (that group, or the operator). This PR is that code, built on the pattern of the GitHub intake. It is component C5 / step A3 of the plan.

This push: the rebase onto main

The review of 04953e92 blocked only on CI, which does not run while the PR conflicts with main: 25e5805ff, the GitHub intake's verdict change, added its row at the end of the release runbook's overlay table, as this PR does. This push, 04953e92 → bc36b67f, rebases the one overlay commit onto main at 25e5805ff and keeps both rows, main's first, in branch order.

  • The code is unchanged. git range-diff cdbe94931..04953e92 25e5805ff..bc36b67f differs only in context lines: main's row, now right above this PR's, and the GitHub intake's line in server/index.ts that 25e5805ff changed, which merged without a conflict. This PR's files under server/ and scripts/, tests included, are byte for byte the same at both heads, but for that line, and 04953e92 → bc36b67f changes exactly 25e5805ff's lines.
  • Checks. The full check passed on this head and CI runs on it (Verification); the mutation check did not run again.

The last code change: the blocking review finding on 1cbf1ccb

The review of 1cbf1ccb found that an edit-time read failing by turns could replay a STOP after a START: the catch-up read an earlier edit's time, wacli stored an edit to STOP, and the next reads failed, gave the STOP's time and failed again. The intake applied its fail-closed STOP but remembered no time, as the last read gave none, so after the operator's START the STOP's own entry, read with its real time once the store recovered, paused again. That push, 1cbf1ccb → 04953e92, keeps the newest time any read gave; its regression tests fail on 1cbf1ccb.

  • What is remembered. readEdit also returns the newest edit time any read gave, the read before the first text included. When the reads tie the text to no time (it moved, or the store could not tell it), the change still counts as a new edit and newEdit remembers that time instead of none, also when only the first read gave one; when no read gave one, the time remembered stays.
  • Why that time is safe. wacli only moves edited_ts forward, so if the store later gives that time, nothing was edited since that read and the change judged covered that edit: its text was read after that time, or it counted as a STOP for all groups. The STOP applied, read again with that time, is a rewrite and never undoes a later START; a new edit after it has a later time, and a STOP in it pauses.
  • When the stored data cannot tell, pausing wins. An edit whose time no read gave, stored after the last read that gave one, may be the edit judged or a new one, so a STOP in it pauses once more, even after a START. The operator learns this once: the notices for a store that cannot tell and for a moving time now end with Až čas zase půjde přečíst, může takový STOP pozastavit odesílání ještě jednou, i po zprávě START, pokud z úložiště nepůjde poznat, že už platil. The moving-time notice and the status's last error also name a time that could not be read.
  • Smaller points. server/whatsapp-intake.ts is 998 lines, 1 more. The docs say what is remembered and when a STOP may pause once more.

Earlier pushes

Each push answered one review in the one overlay commit, with regression tests that failed on the head before it; its reply has the details, tests and mutation results.

  • da07c4cd → 1cbf1ccb (reply). The edit time is read before and after messages show reads the text, which is judged only with a time both reads agree on; while the time moves the text is read again, three times at most, and a time that moves at every read counts as a STOP for all groups.
  • 4882f0da → da07c4cd (reply). An edit is judged by wacli's own time of it, messages.edited_ts read from its store, instead of a hash of its text: the time judged last means a rewrite, any other a new edit, and a store that cannot tell fails closed. Its review showed that the time and the text read could belong to different edits.
  • 61c78a35 → 4882f0da (reply). The operator's chat reads every change past the checkpoint, so an edit made after the first start to an older message is no longer skipped. A message from before the first start that is not an edit stays history, the account's own edits never count, and mapped groups keep both time rules. wacli's rewrites of an edited STOP (migrateLIDSenderToPN, identity_move) stopped undoing a later START, by a hash of the text judged last, which its review showed could hide a STOP edited back to that text.
  • 02e98ab1 → 61c78a35 (reply). A reply reads the journal once more right before it goes to wacli, after its spacing, so a STOP stored during the wait applies even when its webhook drops. While that read finds more, the reply waits and its run stays in flight; a run's release is written at once, so a restart never finishes it again. A STOP in an edit counts from when the intake reads it, every edit is read from the journal, and the command in force, read again, says nothing only while it is the same command, so a START edited into STOP pauses.
  • 63346a02 → 02e98ab1 (reply). The operator's chat has no time cutoff and forgets no id before a complete catch-up, so no STOP is skipped as a change to a forgotten message. A command handled twice changes nothing, a STOP and a START of one second leave sending paused whatever their store order, and a START counts only in a message as sent. The adapter follows an identity_move that names only the new chat. A finished run's reply waits for a catch-up to the journal's end. The follower runs only from a checkpoint taken with a writable open, before which nothing is collected or sent (checkpoint_failed, one notice, retried with backoff); a lost journal stops it until a new one.
  • 0876d730 → 63346a02 (reply). A command counts from when it was sent, and the latest one for a group decides, whether it names the group or all groups; one that changes nothing gets one line back (pure logic, server/whatsapp-intake-commands.ts). The catch-up reads wacli's change journal from a checkpoint, in store order, instead of paging messages list, which stalled on a full page of one second. The checkpoint moves only past a handled page, and the catch-up fails closed: an unreadable journal makes replies drafts, and a lost one pauses sending until START.
  • 58b0ffa6 → 0876d730 (reply). A dropped message is no longer lost behind a later live one (a per-chat cursor then, wacli's journal since). The operator's chat skips the work queue, and every reply checks the pause again after its spacing. The bundle guard judges a read by realpath(3) and trusts a search only in a bundle with no link out and no second hard link. The verification fixture takes the intake only through an explicit parameter, with test files under the temporary folder and never a store.
  • 99d58452 → b43b47f0: the intake follows the bot's content (its SOUL, run contract, bundle, group map and red-team suite), a private module of the operator's Organization reviewed in its own pull request. The bot is given no client name, owner login or chat id: the instructions name only the group, the form of address and the mode, and the delivery id is wa:<key>:<message id>. The intake is the one writer of those instructions; a persona belongs in the bot's SOUL. The reply limit is the contract's 1,200 characters, the instructions state its shape rules, and parsing fails closed: a field outside the contract makes the output invalid, a missing commitment counts as one. The event body is the content's schema (source, group, trigger, trigger_message_ids, messages[]), as the docs show field by field.

What changes

One overlay commit, one row in the overlay table. Off unless OMB_WHATSAPP_INTAKE=1.

New Lazurio-only files

  • server/whatsapp-intake.ts (998 lines) holds routing, batching, the runs, what happens to the answer, the operator's chat, the catch-up, the state's reading and writing, the ledger, and the status.
  • server/whatsapp-intake-commands.ts is the order of the operator's STOP and START: which command is in force for a group, whether a change to an edited message is a new edit, and what the operator reads back.
  • server/whatsapp-intake-state.ts is the shape of the durable state: the latest commands, the catch-up's checkpoint, per chat the handled ids with their times and which are the account's own, and wacli's edit time judged last for the operator's edited messages.
  • server/whatsapp-intake-status.ts is what the status reports, and the one word that sums it up.
  • server/whatsapp-intake-prompt.ts is what a run is given: a group webhook's instructions and the run's event. server/whatsapp-intake-digest.ts writes the workday digest.
  • server/whatsapp-intake-guards.ts holds the bot's output contract and every guard. All are pure functions except the tool-log guard, which also resolves the paths a run read on the file system.
  • server/whatsapp-intake-wacli.ts is the one place that talks to wacli: the exact commands, both message shapes, the change journal, an edit's time read before and after its text and the newest time the reads gave (readEdit), the follower's event lines, the signed loopback receiver and the real spawn. The intake depends only on its Wacli interface, which the tests replace with a double.
  • server/whatsapp-intake-wacli-store.ts is the one read of wacli's store itself: when a message was last edited (edited_ts), read-only, and only from a store at wacli v0.25.1's newest migration.
  • server/whatsapp-intake-config.ts reads the OMB_WHATSAPP_INTAKE* settings and the group map.
  • server/routes/whatsapp-intake.ts serves GET /api/whatsapp-intake (status) and POST /api/whatsapp-intake/catch-up, both admin-scoped by default, and does the wiring.
  • server/testing/whatsapp-intake-fixture.ts turns the intake on in a verification fixture, only for a caller's fake wacli and test files under the temporary folder.
  • docs/lazurio-whatsapp-intake.md is the contract.
  • Tests: five unit files and one e2e (below).

Upstream files, already on allowed_upstream_changes for the GitHub intake:

  • server/index.ts (+8 lines): build the intake, push its routes, subscribe it to the runtime-event bus, start it once the server listens, stop it at shutdown.
  • scripts/control-omb.ts (+5): an optional last parameter of launchVerificationServer() that starts the intake on the caller's fake wacli. The launcher's own OMB_WHATSAPP_INTAKE* settings never reach the fixture.

Lazurio docs: a LAZURIO.md entry, the release runbook (the Machines setting and the overlay row) and the CI allowlist's comment.

How it behaves

The section numbers below refer to the plan's design.

Event flow (§3)

  1. A supervised wacli sync --follow posts each stored message, HMAC-signed, to a receiver on 127.0.0.1 at a port the OS picks. An unsigned or wrongly signed body gets a 401 before it is parsed.
  2. Only the operator's chat and the mapped groups count. Unmapped chats are ignored and listed in the status. A direct message from anyone else gets no reply; the operator gets a short report, at most one per chat in 30 minutes.
  3. A message from the account itself, or from a team member, marks the group as handled. Reactions, edits, revokes, polls and calls are never questions.
  4. A mention of the account (@<its number>), or a reply to it or to a message the intake sent, asks for a run now.
    • A team member who tags the bot asks too.
    • A mention older than an hour is not answered; the operator is told instead.
  5. Other client messages wait in a per-group batch. It runs after 10 quiet minutes or at 20 messages, but not while a team member wrote in the group in the last 15 minutes.
  6. One group runs at a time; whatever asks meanwhile runs right after, with the newer messages.
    • Plain code builds the run input from that group's last 40 messages only (wacli --read-only --json messages list --chat <group>). Messages are marked new by the previous run's ids, not by time, because WhatsApp times are whole seconds.
    • The run is delivered with deliverTrusted() to that group's own managed webhook, WhatsApp · <key>, bound to the one bot named by OMB_WHATSAPP_INTAKE_BOT. The webhook's instructions are written from the map and that group's note, so a client note never reaches another group's runs, and they carry no client name, owner login or chat id. It allows 2 pending runs, so one group cannot fill the bot's queue.
  7. A catch-up every 5 minutes, before each reply and once more right before it goes to wacli, and on POST …/catch-up recovers anything the best-effort webhook dropped.
    • The follower runs only from a checkpoint, taken before it stores anything, so history from before the first start is never handled. Until a checkpoint is taken nothing is collected or sent: the status says checkpoint_failed, the operator is told once, and the intake tries again with backoff.
    • It reads wacli's change journal from that checkpoint, in store order, 200 entries a page and up to 1,000 per catch-up, and the next tick goes on when more wait. A reply waits, neither sent nor drafted, until a catch-up reaches the journal's end, the one right before the hand-off included.
    • A message of the operator's chat or a mapped group that it has not handled is read with messages show and handled as if it had arrived live, also when later ones arrived first or many share its second.
    • The journal cannot tell a new message from a changed one. In the operator's chat, every entry for an id the intake no longer keeps is handled, so no command is ever skipped, and a change to one of the operator's messages it keeps is read again: an edit is judged once, by the edit time wacli keeps in its store, read before and after its text, and a STOP in it counts from when it is read. A mapped group takes an entry at or before the newest time it forgot for a change.
    • The checkpoint moves only past a handled page. When the journal cannot be read, every reply is a draft; when it no longer reaches the checkpoint, sending pauses until the operator's START, and the follower waits for a new checkpoint.

Isolation rules done in plain code (§5)

Rule How
1. Allowlist Only mapped chats reach a bot. DMs are reported, never answered.
2. One group per run The input is one group's messages and its note, through its own webhook. Tests prove another group's text and note are absent, and that no client name, owner login or chat id of the map reaches the bot.
4. Fixed destination The intake sends only to the run's group, quoting reply_to if it is an input id and otherwise the message that asked, or to the operator. Text only, --no-preview. A recipient never comes from the bot (an output that names one is invalid), and a name is never resolved: only full chat ids pass the adapter.
6. Client notes Each group's note goes only into its own webhook's instructions.
7. Messages are data They reach the bot only in the UNTRUSTED WEBHOOK EVENT DATA block. The intake acts only on WhatsApp facts and on the operator's exact commands in the operator's own chat. Sender chat ids are not passed to the bot.
8. Commercial guard Price, amount, discount, contract or order, invoice, deadline or guarantee, SLA or uptime, in Czech and English, plus commitment and low confidence, all draft.
9. Rate and kill switch 6 an hour and 30 a day per group, 80 a day overall, then drafts. Sends go out one at a time with 3–8 s random spacing, and the follower's --send-spacing 3s-8s paces them again. STOP, START, STOP <key> and START <key> are persisted, so a restart keeps a pause, and take effect as they arrive, outside the intake's work queue: every reply reads the journal and checks the pause again right before it goes to wacli. How commands are ordered and how edits count: Earlier pushes and the docs.
11. Retention wacli's --max-db-size (default 2GB). The ledger keeps 30 days of ids, times and actions. The bot's digest lines live only until the next digest. Neither the state file nor the log holds message text; a test checks both.

Rules 5 and 12 are content and people, not code:

  • Rule 5 is the bundle's curation. The intake enforces that a run reads nothing outside the bundle folder.
  • Rule 12 is the profile and the intro message. The intake never writes its own words into a client group; its own texts go only to the operator.

The cross-client outbound guard (new, D1 = C) drafts a reply that names another mapped group's name, client name or owner login. Matching ignores case and diacritics, uses whole words, and skips anything the group shares. It also drafts a repository link (GitHub, GitLab, Bitbucket) or an Environment link (a subdomain of an Environment domain) that no allowedLinks prefix covers.

The tool log. MausBot does give the intake the run's tool calls, so this guard is implemented and not only described. The Claude and Codex drivers emit an item.started runtime event for each call, with the tool's name, the shell command and a bounded, redacted input preview. The intake subscribes to the server's runtime-event bus and binds a run to its thread at the turn's start, so a run's tool log is complete or absent. A run is drafted when:

  • it used a shell (gh and wacli are named);
  • it used any tool other than Read, Grep, Glob or LS;
  • it read outside OMB_WHATSAPP_INTAKE_BUNDLE (a search without a path counts as the bot's working folder). A path counts where it really leads (realpath(3)), so a link out of the bundle is a read outside;
  • it searched (Grep, Glob, LS) a folder of a bundle that has a link leading out of it, or a file with a second hard link;
  • it read a dangling link, a missing file or a file with a second hard link;
  • it ran on an engine that does not report tool calls;
  • its start was not seen (after a restart);
  • the path of a call cannot be read from the preview.

Limits are in Risks.

Escalation and digest (§6)

  • An escalate reaches the operator's chat as [<group> · HH:MM] Eskalace, naléhavost …: <summary>, with the next step.

  • On workdays from 17:00 Europe/Prague, once a day, the operator gets a digest built from the ledger and group state alone:

    • replies, drafts, and escalations with how many are still open;
    • client messages without any reaction for over 2 h, with their times;
    • the bot's digest lines;
    • a health line.

    A failed send is retried every 15 minutes that evening.

Output contract (§7.2)

  • The contract is parsed fail-closed: anything else, a field outside the contract included, is "invalid output", nothing goes to the group, and the operator is told.
  • A missing or unknown confidence counts as low, and a commitment that is missing or anything but false counts as a commitment.
  • A reply over 1,200 characters is a draft.

What deliberately does not change

  • No confinement flag and no second server (D1 = C). The bot keeps the Environment's full access; the intake narrows what reaches a group and what the bot is told, not what the bot can do.
  • No upstream behaviour change. With the flag off, nothing starts: no listener, no process, no timer. The only always-on line is a runtime-event subscription that returns at once.
  • The event body. The content side now follows it; the intake's payload did not change in this push.
  • Nothing live. No wacli install, pairing or sign-in, and no message sent. No release and no Machines change.
  • Operator reply commands beyond STOP and START (1, 2, upravit:, issue, hotovo in the design) are not in this PR.
  • What MausBot keeps. Each run's conversation and the webhook's attempt preview are kept as for any webhook delivery. The docs say so.
  • No group admin actions, media, polls, status or channels. Never.

Verification

All of these ran on station-01, a Linux development server, unless noted, in clean clones with dependencies from npx -y pnpm@10.33.0 install --frozen-lockfile; nothing was installed globally. Lint, types, locales, Vitest and the contract tests ran on this head, bc36b67f (bc36b67f77485bb678c87be16b48a8ed48b9e52c); the rest on 04953e92, whose changes it keeps (This push).

  • Lint, types, locales. pnpm lint (oxlint --deny-warnings), pnpm typecheck (tsc -b && tsc -p tsconfig.server.json) and pnpm i18n:check passed. The pre-push hook ran all three again when this head was pushed.

  • Vitest (vitest run --config lazurio/vitest.config.mjs …): 14 files, 315 tests pass.

    • server/whatsapp-intake.test.ts (80: two new) covers:
      • the env and the map, routing, what a run is given, the one writer of the instructions, batching, and one run per group;
      • each guard through a real WebhookManager, including a link in the bundle to another group's note and a searched folder linked out of it;
      • a draft the operator's chat did not take, recorded as failed also after a reply went out;
      • STOP and START across a restart, and a STOP that arrives while a reply waits for its spacing, delivered or stored with its webhook dropped: nothing more reaches the group and the reply is a draft;
      • a reply's last look: 1,200 entries stored during its spacing hold it, neither sent nor drafted, until the next tick drafts it after a STOP among them or sends it; a journal that fails during the spacing drafts it (catch_up) with one notice; one that no longer reaches the checkpoint drafts it (paused);
      • the order of commands: a newer STOP kept when the catch-up finds an older START, for all groups and for one; commands for all groups and for one group overriding each other both ways; a STOP and a START of the same second, live, found in either store order, and mixed; a START in an edit or a reaction resuming nothing, and a STOP in an edit pausing;
      • edits in the operator's chat: Stoop, a START, a complete catch-up, then the edit to STOP, its webhook dropped, kept or forgotten: paused from the read, so that START cannot override it, the next reply a draft; the START in force edited into STOP b: b paused; an edit the webhook delivers, read once from the journal, so a later START stands; an edit to START, kept or forgotten, dropped or delivered: still paused, no line;
      • edits of messages from before the first start: an edit to STOP, its webhook delivered or dropped, pauses with one line; the account's own edit, and a command from then that wacli rewrites or stores only now, change nothing; an edit to START resumes nothing; each edit is read once, from wacli's store;
      • wacli's rewrites of an edit: an edited STOP, a START, then a rewrite of the row, which keeps its edit time, kept or forgotten, with and without a restart: still sending, no line, the next reply sent; an edit to another text, then back to STOP, each read by its own catch-up: paused again, no text of an edit in the state file;
      • the review of 4882f0da: a judged edit to STOP, a START, then an edit to another text and one back to STOP read by one catch-up, kept or forgotten, with and without a restart: paused, one line, the next reply a draft;
      • the review of da07c4cd: an edit to STOP stored between the reads of an earlier edit's time and its text, then a START, kept or forgotten, with and without a restart: paused once, then still sending;
      • the read order: an edit stored right after its text was read applies at that read, and its own entry after a START changes nothing;
      • a time that moves: twice, the third read settles; at all three reads, a STOP for all groups whatever the text, one notice until the store gives one time again, and after a START the edits read again change nothing;
      • the review of 1cbf1ccb (new): an earlier edit's time read, an edit to STOP stored, reads that fail, give its time and fail again, a START, then a store that reads again: paused once, one notice, then still sending, kept or forgotten, with and without a restart; a new edit to another text and back to STOP after the START pauses again;
      • only the first read gives a time (new): a STOP stored before it, read again after a START, changes nothing; one stored right after it pauses once more after the START; one notice;
      • a store that cannot tell the edit time, for no such column: edited_ts and file is not a database: two rewrites in one catch-up pause once, with one notice and the reason as the last error; a START, then another rewrite: paused again, no second notice; the store readable again: a rewrite changes nothing; unreadable once more: the operator is told again;
      • the edit memory: wacli's edit time judged last, never the text; the same time is a rewrite, a later or an earlier time or none a new edit; none leaves the time remembered; a text tied to no time is a new edit and remembers the newest time read; the newest 1,000 kept; a rewrite keeps a message from being forgotten next, and a forgotten one counts as new;
      • a STOP the webhook dropped, or a journal that cannot be read, listed among a draft's reasons;
      • the digest (17:00 on a workday, once, not on Saturday), and the ledger with no text and 30 days;
      • the catch-up from wacli's journal:
        • a dropped message and a dropped STOP after later live ones, and a message stored late;
        • a STOP dropped after 260 messages of the same second; after 900 of its second over three catch-ups, whose ids it no longer keeps; and after 5,001 of its second with no catch-up between, all of whose ids the operator's chat keeps until the complete catch-up;
        • a command read again after its id was forgotten: the one in force says nothing, an older one gets one line;
        • what it reads back: a message it has not handled and the operator's command, never a group message it handled, its own sends or a message the account wrote from its phone;
        • a client's edit of a group message the bot answered, dropped or delivered: no second run;
        • a backlog of 600, then 600 live ones with one dropped before them, and later changes to two of them in a mapped group;
        • a failure and a restart in the middle of 1,200, and the next tick going on past 1,000;
        • a finished run's reply held, neither sent nor drafted, while 1,200 dropped entries and a STOP past the page limit wait, then drafted once the STOP applies, or sent without it;
        • a STOP applied before a finished run's reply, and a message moved from a LID chat id;
        • a first checkpoint that fails: no follower, checkpoint_failed, one line to the operator, three tries over 6 s, then the follower, and a STOP dropped after it applies before the reply;
        • a journal that cannot be read; one that no longer reaches its checkpoint; and the follower stopped then, its late exit ignored, until a new checkpoint is taken;
      • the invariant: with a failing first checkpoint, 600 operator messages of one second whose ids are forgotten, 1,200 dropped entries and a dropped STOP of that second past the page limit, a finished run's reply never reaches the group and becomes a draft once the STOP applies;
      • a map reload, a restart mid-run, and a restart after a sent reply, which does not finish the run again.
    • server/whatsapp-intake-wacli-store.test.ts (3), on stores built from wacli v0.25.1's own chats and messages DDL and its 33 migrations, with synthetic rows only:
      • the edit time in ms, and 0 for a message never edited, also through the adapter's editedAt(), and none from an adapter with no store; the same read once wacli's handle is closed and its WAL folded back;
      • a read while another connection holds a write open: answered in under a second with the committed time, and the new time once that write commits;
      • no time, with the reason, for no store set, no file, a file that is not a database, a newest migration of 32 or 34, a renamed 33, no migrations recorded, no migrations table, no edited_ts, or a message the store does not hold.
    • server/whatsapp-intake-guards.test.ts (19): the output contract, every guard, the length guard at 1,200 and 1,201, links and hard links.
    • server/whatsapp-intake-wacli.test.ts (15):
      • the exact commands: messages list, messages show and messages changes --since, read-only, and the checkpoint messages changes --checkpoint as a writable open;
      • both payload shapes, a deleted stored row, and an edit in both: the webhook's with the edit's own time, the stored row with the edited message's time, both marked as an edit, and a deleted row as none;
      • readEdit: equal times, a time that moves once, twice or at every read, a store that cannot tell at both reads, one or by turns, and the newest time any read gave, also when the time goes back;
      • the follower's events and the signed receiver (401, 404, 413, 204);
      • the real spawn against a fake wacli script: one message read back, one the store no longer holds, a journal page with an identity_move, one with the new chat only, and a delete, and an expired checkpoint told apart from a busy store.
    • server/testing/whatsapp-intake-fixture.test.ts (2): the launcher's OMB_WHATSAPP_INTAKE* settings never reach a fixture, and only test files under the temporary folder are taken, never a store.
    • server/whatsapp-intake.e2e.test.ts (1, about 40 s): an isolated real server with a fake wacli handed over explicitly, which stores, signs and posts or drops messages and serves its store as the change journal, and the fake engine through the real Claude driver. It covers:
      • a mention answered in its group only, as a quote, with --no-preview;
      • a transcript with group A's note and without group B's text or note, and without group A's client name, owner login or chat id;
      • drafts to the operator from the cross-client guard, the gh tool guard, and a Read through a bundle link to group B's note;
      • STOP, then a draft, then START;
      • a dropped mention recovered from the journal by POST /api/whatsapp-intake/catch-up after a later message arrived live. messages show ran for that message and, once each, for the operator's STOP and START, whose changes it reads again; never for a group message delivered live or the bot's own sends. The checkpoint was taken before the follower started;
      • no message text in whatsapp-intake.json or in the server log;
      • a store in the launcher's environment never reaching the fake wacli: no call carried --store.
    • Around them:
      • server/host/team-messages.test.ts (18);
      • scripts/testing/file-size-ratchet.test.ts (1): server/whatsapp-intake.ts is 998 lines;
      • scripts/control-omb.test.ts (4) and server/verification-fixture.e2e.test.ts (2);
      • the GitHub intake's server/github-intake.test.ts (67) and server/github-intake.e2e.test.ts (4), as main has them;
      • server/lazurio-loopback-dispatch.e2e.test.ts (3) and server/lazurio-removals.e2e.test.ts (96).
  • The new tests fail on 1cbf1ccb. This head's server/whatsapp-intake.test.ts and server/whatsapp-intake-wacli.test.ts ran unchanged on 1cbf1ccb's source in a separate worktree, beside the store's and the e2e's test files, which this push does not change.

    • 5 of the 80 intake tests fail there:
      • the review of 1cbf1ccb: after the START, the catch-up pauses again;
      • only the first read gives a time: the STOP stored before that read, read again after the START, pauses again;
      • the edit memory: a text tied to no time remembers no time;
      • a time that moves, and a store that cannot tell: only the notices' and the status's new wording, the one expectation 04953e92 changed in earlier tests.
    • A test stops at its first failing variant, so both new tests also ran there with each variant first. In the review's sequence, a forgotten id, a restart before that catch-up and both pause again after the START. The new-edit variants and the STOP stored right after the first read pause there too, as they should, and fail only on the wording.
    • The other 75 pass there. Of the adapter's 15 tests only readEdit's fails there (no newest time); the store's 3 tests and the e2e pass there too.
  • Contract tests. node --test scripts/lazurio-release-contract.node-test.mjs scripts/lazurio-ci-scope.node-test.mjs scripts/lazurio-removals.node-test.mjs scripts/lazurio-browser-contract.node-test.mjs: 558 pass, 0 fail.

  • Mutation check, 159 of 159 caught. Each mutation broke one rule in the source; the intake's five unit files ran (with its e2e for the two rules on the bot's own sends), and the original bytes were restored (checksums compared after the run). The 159 are the 151 of the earlier pushes, 19 of them moved to this head's lines with the same rule broken (how an edit is judged and remembered, and what readEdit returns), and 8 new.

    • Eight new for 04953e92 (152–159): the newest time the reads gave, remembered when they tie the text to no time, and the operator told that a STOP may pause once more.

      Mutation Tests failing
      the operator is not told that a STOP may pause once more 4
      a text tied to no time remembers no time · the newest time is the first one read · a read that settles without a time gives no newest time 3 each
      a text tied to no time remembers the time read last · the newest time leaves out the read before the first text 2 each
      a text tied to no time is judged against the time remembered · the newest time is the last one read 1 each
    • The earlier 151 (1–151), re-run on 04953e92:

      Mutation Tests failing
      the checkpoint never moves 24
      STOP ignored 16
      a STOP in an edit does not count 14
      a change to a message it keeps is never read again · an edit is handled as a new message · the edit time is never read 13 each
      tool log off 11
      the ids are never trimmed after a catch-up 9
      the operator's chat skips a change at or before the forgotten time · every change to an edit counts as new 8 each
      an edit counts from the edited message's own time · a rewrite of an edit counts as a new edit 7 each
      reads outside the bundle allowed · the edit time is read before the text only 6 each
      only a message's first edit counts · an edit time the store cannot tell is a rewrite · an edit is judged by its text instead of its time · the next read compares with the first time read 5 each
      a tie keeps the earlier command · a tie is a START · a superseded command gets no line · the edit time is read after the text · a store that cannot tell is never told 4 each
      commercial guard off · cross-client names off · foreign links off · rate limits off · no pause check before dispatch · lexical paths · searches trust a bundle with links out · a late command for all groups replaces a newer one · a lost checkpoint does not pause · a lost checkpoint counts as a failed read · a START counts in an edit, a deleted message or a reaction · the follower starts without a checkpoint · the follower starts before the checkpoint · the tick goes on without a checkpoint · the operator is not told · no last look before the hand-off · the last look comes before the spacing wait · an edit to START resumes · the edits are not kept across a restart · a judged edit keeps the time judged first · a store that cannot tell at both reads never settles 3 each
      commitment ignored · low confidence ignored · draft mode may send · reply sent to another group · other groups' messages in a run · message text in the ledger · unsigned webhook accepted · the seal trusts links out · the command for all groups always decides · the ids are trimmed to 500 at every message · its own sends are read back · the command in force, read again, is told again · the operator is told at every failure · the status hides a failed checkpoint · a lost journal waits for the next catch-up for a checkpoint · the last look does not read the journal · a line written during the last look is awaited there · a released run is not written · an edit in the webhook is skipped as already handled · its own sends are read again · an edit time the store cannot tell is remembered as judged · a store that cannot tell is told every time · a store that tells again clears nothing · the edit time is read in seconds · no store set reads as never edited · the text is read twice at most · the text is read four times at most · a time read at one read only vouches for the text · a change whose time moved remembers no time · a time that moved is told as a store that cannot tell 2 each
      unasked batch may send · team activity ignored · old mentions answered · digest on weekends · DM answered instead of reported · unmapped group handed to the bot · client names and owner logins in the instructions · owner logins in the instructions · owner logins in the event body · the group's chat id in the delivery id · an edited webhook prompt is kept · the old 1,500-character limit · fields outside the contract accepted · fields outside the escalation accepted · a missing commitment counts as none · STOP waits behind the queue · Node's realpathSync instead of realpath(3) · the seal trusts dangling links · hard-linked file read trusted · the seal trusts hard links · .. resolved as text · fixture inherits OMB_WHATSAPP_INTAKE* · fixture takes any path · fixture trusts a link into an installed wacli · a late command for a group replaces a newer one · a group's own command always decides · the confirmation names no exceptions · the status lists a group whose STOP is overridden · the checkpoint moves before its page is handled · a change to a forgotten group message is a new one · the forgotten time excludes its own second · history from before the start is read back · handled messages are read back · moved messages are not followed · other chats are read back · no next tick while the journal holds more · replies sent while the journal cannot be read · a deleted message is a question · a message wacli no longer holds fails the catch-up · any journal error is a lost checkpoint · a lost checkpoint is not recognised · the next read starts where this one did · the operator's chat forgets ids at 5,000 before a complete catch-up · a tie goes to the later command · a LID chat's move without a new id is not followed · the checkpoint is a read-only open · a finished run's reply goes out without a catch-up · a finished run's reply goes out while the journal holds more · a failed checkpoint is retried at every tick · a lost journal keeps the follower running · a stopped follower is still heard · the last look sends while the journal holds more · a reply held by its last look is drafted · a reply held by its last look leaves its run · the last look ignores a journal that cannot be read · a send keeps asking after its last look · the command in force, read again, counts by its id alone · an edit in the webhook is judged there, and again from the journal · the account's messages are read again · the adapter loses the edit · the operator's chat skips an edit of a message from before the first start · a command from before the first start counts in the operator's chat · the account's own edit counts · a pre-start edit's webhook is judged by its own time · a rewrite does not keep an edit from being forgotten · the edits are never forgotten · only a later edit time counts as a new edit · another wacli's store is read · the newest migration's name is not checked · a store with no migrations recorded is read · a message the store does not hold reads as never edited · a store it cannot read reads as never edited · the store is opened writable, so a missing one is made · the adapter reads no store · a time that moved at every read gives the one read before the text · a change whose time moved is judged by its text · a time that moved is not told 1 each
  • The content side. Neither the event body nor the webhook instructions changed in this push, so what the content module validated on b43b47f0 still holds.

  • CI. On this head, Lazurio Fork CI's required Overlay, typecheck and lint passed; its four vitest shards follow.

  • Not verified, by design: real WhatsApp, the real wacli binary (the adapter, the journal, the edit fields and the store's schema were read against v0.25.1's source, not run against it; the store read ran against stores built from that schema) and a real Claude engine (the fake engine through the real driver).

wacli

Repository and version. steipete/wacli redirects to openclaw/wacli. The adapter was checked against v0.25.1 (tag b9e7bb10, commit b7603b69, released 2026-10-27, MIT), from its source.

Read in the source

  • Flags. sync has --follow, --events, --webhook, --webhook-secret, --webhook-allow-private (needed for 127.0.0.1), --send-spacing 3s-8s and --max-db-size. The secret exists only as a flag.
  • Webhook payload. ParsedMessage with Go field names plus ChatName. Receipts and presence carry EventType; messages don't.
  • Signature and delivery. X-Wacli-Signature: sha256=<hex HMAC-SHA256 of the body>, a 5 s request timeout, no retries.
  • Chat ids. Payload ids are the store's: a known LID becomes the phone number's id.
  • The follower stores a live message before it posts it, posts only live messages (never history), and drops what its webhook queue of 512 cannot hold.
  • messages list.
    • --chat and --limit, with global --read-only --json; the newest first.
    • It does not take the store lock, so it reads while the follower runs.
    • Rows are the store's Message (ChatJID, MsgID, quoted_msg_id, quoted_sender_jid, …).
    • On failure, --json prints the {success, data, error} envelope on stderr.
    • The docs warn against paging it by moving --after one second on, since messages share timestamps.
  • messages show --chat --id returns one stored row, a deleted one too (with deleted_at). For a message the store no longer holds it fails with SQL's no rows in result set. No store lock.
  • messages changes.
    • --checkpoint prints the present position; --since <checkpoint> --limit <n> reads the store's change journal after it, in store order, without text. No store lock.
    • A page is {store_id, changes, has_more, next_checkpoint}. A change has sequence, operation (upsert, delete or identity_move with new_chat_jid and new_msg_id), chat_jid, msg_id and message_time.
    • The journal does not tell a new message from a changed one. The INSERT and the UPDATE triggers on messages both record upsert (internal/store/changes.go). An edit is stored over the message it edits, which keeps its id and time.
    • What time an edit carries. The webhook posts it with the edited message's id, the edit's own time and Edited (ParseLiveMessage and extractProtocolMutation in internal/wa/messages.go). The store keeps the edited message's time on the row: UpsertMessage keeps ts when the incoming row is an edit. So the journal's message_time and messages show give that time, with Edited set. The edit's own time is stored as edited_ts, in whole seconds, which no command prints; wacli's docs call it the internal edit-order timestamp. Only a later edit moves it on (an edit of the same second keeps it and takes the newer text), a delete clears it, and the journal records an update only when another column changes (the docs: identical stored values produce no entry). Each entry also has recorded_at, the time this machine recorded it.
    • At every sync start, wacli rewrites the sender of all stored messages of a member whose LID it can now resolve, which records an upsert for each (migrateLIDSenderToPN). It rewrites only the sender and a quoted sender, so an edited row keeps its text and Edited.
    • Deleting a message, for everyone (MarkMessageRevoked) or for the account (MarkMessageDeletedForMe), clears its Edited, and the journal records a delete.
    • Moving a LID chat's messages to the phone number's chat records identity_move with new_chat_jid only, since the message id stays.
    • Retention is 30 days and at most 100,000 entries. A checkpoint past it, or of another store, is refused: expired checkpoint, checkpoint belongs to another store.
    • A read-only open of a store that no writable wacli has migrated yet refuses the journal; any writable open migrates it. The intake's checkpoint is one: it is taken while the follower is stopped.
  • The store itself, which the intake reads for edited_ts alone. wacli.db runs in WAL mode, and wacli's own read-only commands open it with mode=ro while sync writes. schema_migrations records each migration by version and name; v0.25.1's newest is 33, ordered chat auto-unarchive. messages has UNIQUE(chat_jid, msg_id), edited and edited_ts (migration 16). wacli doctor would print the store's folder, but it briefly takes the store's lock, so the intake does not run it and reads only the folder OMB_WHATSAPP_INTAKE_STORE names. It reads edited_ts before and after messages show and judges the text only when both agree.
  • send text.
    • --to, --message, --reply-to, --no-preview and the global --timeout.
    • --to with an @ is parsed as a chat id, never looked up by name.
    • While the follower runs, a send is delegated to it and paced.
    • The output is {sent, to, id}.
  • auth status --read-only --json reports authenticated, linked_jid and phone, but no LID.
  • --events writes NDJSON lines on stderr. The error texts are sync storage limit reached (the store cap), not authenticated and store locked.

Assumptions not verifiable from source. Verify them in P0 on the test group, in observe mode:

  1. A mention is written into the text as @ and the account's phone number. The payload has no mention list. If it shows LID digits instead, set OMB_WHATSAPP_INTAKE_SELF_IDS.
  2. The quoting client fills the quoted id and sender, so a reply to the account is recognised.
  3. Team members appear by their phone number's id. One seen only by an unknown LID counts as a client until the map lists that id.
  4. A message this linked device sends is not posted back as a live event. The intake does not depend on it.
  5. An edit arrives flagged as an edit (Edited): a group's edit is not judged again, and a STOP in an edit of the operator's counts.
  6. --reply-to shows as a quote to every group member.
  7. WhatsApp's limits for a linked device are unknown. The rate and spacing are deliberately low.
  8. The Environment's tool catalog installs the latest wacli release, unpinned. This PR was checked against v0.25.1 only, and the store of a later version gives no edit time, so edits then fail closed (Risks).

Risks

  • The accepted D1 = C risk remains the main one. The bot has full access, and the guards read text.
    • A reply that leaks another client's matter without a mapped name, login or link passes. So does a run on a tool the guard does not see.
    • The bot is no longer handed the client's owner login, but under full access it can still look things up on its own; only the guards, the SOUL and the operator stand in the way.
    • The operator stays in every group, a STOP takes effect as it arrives, and the mitigation is staying in draft mode until P1's red-team passes.
  • What a STOP cannot reach.
    • A reply already handed to wacli is not recalled; the follower paces it by up to another 8 s.
    • A STOP the webhook dropped takes effect at the next catch-up, within 5 minutes, and before any reply goes out, since each reply reads the journal once more right before the hand-off. The operator then sees no confirmation and can send it again; the docs say so.
    • A STOP in an edit counts from when the intake reads the edit. A START sent after such an edit, but before the intake read it, is overridden by it: sending pauses, the operator is told, and a new START resumes.
    • An edit counts once, by the edit time wacli keeps in its store. Since no wacli command prints that time, the intake reads it from the store itself, before and after the text. That decision has a cost:
      • It ties the intake to wacli's internal schema at v0.25.1. The read takes edited_ts from the messages table, and only from a store whose newest migration is v0.25.1's 33. A later wacli, or a store the intake cannot read, makes edits fail closed: every change to an edited message of the operator's counts as a new edit, so a STOP in one pauses even after a START, and each later rewrite of that message after another START pauses again. The operator is told once per outage, and a new START resumes. So a wacli upgrade needs the read checked again before the intake runs on it; A5 installs v0.25.1.
      • OMB_WHATSAPP_INTAKE_STORE must be set for the read. Without it the intake does not know which folder wacli uses (wacli resolves its default from an environment variable, its account registry and the platform's state folder), and edits fail closed as above. The Environment's unit should set it (A6).
      • The move to a documented field. Once wacli prints the edit time (a request is drafted; Follow-ups), the intake should take it from messages show instead and drop the store read.
      • A store that misbehaves costs a pause, never a STOP. If the edit time moves at all three reads of a change, the change counts as a STOP for all groups whatever its text. When the reads tie the text to no time, the newest time they gave is kept; an edit whose time no read gave, stored after the last read that gave one, may be the one judged, so a STOP in it pauses once more, also after a START, and the operator is told so. A START resumes.
      • What the edit time cannot show. wacli keeps it in whole seconds, so an edit within the second of the edit whose text was read, stored after that read, counts as that edit and is not read. That never undoes a START, nor hides a STOP behind one while this machine's clock and WhatsApp's agree: a START overrides an edited STOP only when it is later than the read that applied it, so any later edit has a later second. And wacli's journal records an update only when a column other than edited_ts changes, so an edit that keeps an edited message's text exactly leaves no entry and is not read. A STOP sent as a new message always counts.
      • An edit made before the intake's first start that wacli rewrites later, a sender it normalizes once it learns the operator's phone number say, is seen for the first time then, so a STOP in it pauses once. So does a rewrite of an edited message the intake forgot behind 1,000 newer ones. The operator is told each time, and a new START resumes.
      • An edit is judged by the text wacli holds when the catch-up reads it, and the edit's webhook starts that catch-up at once. An edit to STOP that the operator edits again before that read is judged by the later text only.
    • A STOP and a START of the same second always count as a STOP. The operator is told and can send START again.
  • The catch-up fails closed, which costs replies.
    • While wacli's journal cannot be read (for example a future wacli that changes messages changes), every reply is a draft. The operator is told once, and the status says catch_up_failed.
    • When the journal no longer reaches the checkpoint (30 days or 100,000 entries without a catch-up, or a new store), sending pauses until the operator's START.
    • Until a checkpoint can be taken, at the first start or after a lost journal, nothing is collected or sent. The status says checkpoint_failed, the operator is told once, and the intake keeps trying, every 5 minutes at the latest.
    • A reply waits while the journal holds more than one catch-up reads, 1,000 entries at a time, at least once per tick. A large backlog, after a history sync say, holds replies until it is read, and each message of the operator's chat or a mapped group in it that the intake has not handled costs one messages show, as does each change in the operator's chat to a message that is not the account's own, also to one from before the first start. A sender rewrite of the operator's whole chat therefore costs one messages show per message, once.
  • The dedup's limits. wacli's journal cannot tell a new message from a changed one, so the intake must guess for an id it no longer keeps, and it guesses differently per chat.
    • In a mapped group, an entry at or before the newest time the intake no longer remembers counts as a change to one it handled. A dropped message that wacli stored late with such a time would get no answer: one that comes after more than 500 newer ones of its group were handled and a catch-up completed, from a phone that sent it late, say. That rule cannot skip a command.
    • In the operator's chat every such entry is handled. A command wacli changed after the intake forgot its id, an old message whose sender it rewrote, say, changes nothing; if a newer command stands, the operator gets one more nic nemění line.
    • The operator's chat keeps every id until a complete catch-up. A flood there while the journal cannot be read grows the state file until the next complete catch-up.
  • The bundle guard sees the files as they are when the run ends.
    • A link or hard link that a run makes and removes again needs a shell, which is itself a hit. A bind mount inside the bundle is not detected.
    • A search walks the bundle, up to 10,000 entries; a larger bundle drafts every search.
  • The guards are heuristics. False positives become drafts, which is safe. False negatives are possible: the commercial patterns cover Czech and English only, and name matching is whole-word.
  • Stricter parsing costs a reply. An output with one field too many is now invalid rather than sent: the operator gets the notice, not a draft, and the run's conversation stays in MausBot.
  • The tool log depends on the driver.
    • Claude and Codex report every call.
    • A redacted or shortened preview counts as unverifiable, which drafts.
    • On Codex every file read is a shell command, so every reply drafts. Use the claude engine.
  • Secrets in process arguments. wacli takes the webhook secret and each sent text only as arguments, readable by the same OS user. That is the Environment's boundary anyway.
  • Late answers. A dropped webhook means up to about 5 minutes before the catch-up answers a mention. If assumption 1 fails, mentions go unseen until OMB_WHATSAPP_INTAKE_SELF_IDS is set; replies are still recognised.
  • A restart mid-run turns that run's reply into a draft, since its tool log is gone.
  • A hard crash (SIGKILL) can leave the follower running. On a systemd user unit, the control-group kill ends it.
  • Size. server/whatsapp-intake.ts is 998 lines after the commands, the digest, the webhook instructions, the run's event and the status moved into modules of their own; the ratchet lists files from 1,000 lines. Further growth should split it again.
  • WhatsApp account restrictions (design R1) are mitigated only by low volume and spacing.

Follow-ups

These are already the plan's steps; this PR files no duplicate issues.

  • Before merge: green CI on this head and an approving review; the rebase onto main is done. lazurio: backport upstream's fixed clock for the free/busy test of Marco's assistant (DEV-6691) #81 is merged.
  • A4: release, then Ring 0 with the feature off, then the pin moves.
  • A5: install wacli at the version checked here (v0.25.1); its catalog installs the latest release. A later version also needs the store read checked again before the intake runs on it.
  • A6: the bot and the intake env in the Environment's MausBot unit (with OMB_WHATSAPP_INTAKE_STORE set, so edits are judged by their time), the bundle, and the private group map and notes. The content module's map now has this intake's format, with the notes in the map's folder.
  • A7 (P0, observe): verify assumptions 1–6, a forced webhook drop and the catch-up from the real wacli's journal, and STOP/START.
  • A8: red-team in the test group.
  • A10: Machines (declare the env and the follower so a rebuild restores them) and Platform (pin wacli).
  • wacli upstream: a request that wacli print the edit time in messages show --json and in the journal, and record an edit that keeps its text, is drafted and not filed yet. Once wacli prints it, the intake takes it from there and drops the store read.
  • Not in this PR: the operator's reply commands (1, 2, upravit:, issue, hotovo) and unasked replies after the pilot (D7).

Work context

  • Agent / thread: Claude Code (Opus 5.5), implementation subagents of the DEV-6691 parent thread in T3 Code, one session per push, all local to the machine: the first head bebb628c-ccb4-459e-8705-93c00c84f5a0, the content push b1c9ff48-4099-41df-9eeb-60d084045c73, then the pushes for the reviews of 58b0ffa6 (b3f797ad-74bb-45ef-b56d-3c0ef95f1249), 0876d730 (695c05cf-190e-4e80-b4d5-c3da9f43be65), 63346a02 (bec6f306-2184-4789-bc4d-4ae0abdbe463), 02e98ab1 (1018906d-7561-48be-952c-4707fa6e7087), 61c78a35 (bb8c2e1c-1dc5-4ee4-a9d5-d45ca034d3a6), 4882f0da (7fdacf91-0067-4ba1-b3d6-6faa4e63c54f), da07c4cd (f1edd130-29c3-40df-b9db-99e9db6b74fe) and 1cbf1ccb (fa952fb1-2681-40c5-a407-ca5e576990a2), then the rebase of 04953e92 (c5d9ea99-4085-4e61-975d-aac565210b14, this push).
  • Machine: the Organization Admin's macOS workstation. The last nine pushes were built and tested on station-01, a Linux development server, in temporary clones that were removed afterwards.
  • Worktree: the HumanAndMachine-ai Organization root, .worktrees/productionspace/OpenMausBot/DEV-6691-whatsapp-intake, branch agent/DEV-6691-whatsapp-intake, on main at cdbe94931; the branch is on 25e5805ff since this push.
  • Mission Control: plan DEV-6691, in the Organization's private Mission Control data.

Plan: DEV-6691.

🤖 Generated with Claude Code

Note

Add WhatsApp intake for Lazurio support bot with wacli follower and reply guards

  • Adds a WhatsApp intake that watches mapped groups via the paired wacli executable and a signed loopback webhook, then runs one bot per group on mention or reply triggers (whatsapp-intake.ts, whatsapp-intake-wacli.ts)
  • Sends a reply only when guards pass: low confidence, commitments, commercial language, cross-client mentions, foreign links, tool-log misuse, length, pause state, and per-hour/per-day/overall rate limits. Flagged replies go to the operator as drafts instead (whatsapp-intake-guards.ts)
  • Intake is opt-in via OMB_WHATSAPP_INTAKE with a validated group-map JSON file and note files; disabled state returns { enabled: false } on the new /api/whatsapp-intake endpoints (whatsapp-intake-config.ts, routes/whatsapp-intake.ts)
  • Adds STOP/START operator commands, daily 17:00 Prague digest, catch-up for dropped webhook messages, and persisted ledger state with 30-day retention; message text is kept out of persisted state and logs
  • Risk: the new server wiring in index.ts and env forwarding in control-omb.ts require wacli installed and paired as the same user before enabling; see docs

Macroscope summarized 99d5845.

Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4

@immakermatty immakermatty self-assigned this Oct 30, 2026
@immakermatty
immakermatty marked this pull request as ready for review October 30, 2026 23:24
@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch 2 times, most recently from 67e7641 to b43b47f Compare October 31, 2026 10:41
@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from b43b47f to 58b0ffa Compare October 31, 2026 11:27

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. Catch-up can permanently miss messages (server/whatsapp-intake.ts:500, :855). A later live event advances lastSeenAt past an earlier dropped webhook, so the next scan starts after that missed message. This also affects a dropped operator STOP. Keep a catch-up cursor independent of live delivery (with overlap/deduplication), and test a dropped event followed by a later live event before the scan.

  2. STOP does not stop a queued send (server/whatsapp-intake.ts:329, :788). The work queue waits for the send and its spacing delay, so a STOP received during that delay cannot be processed until after the group message goes out. Process the stop independently and recheck the pause immediately before dispatch; test STOP arriving during the delay.

  3. The bundle guard follows lexical paths, not actual files (server/whatsapp-intake-guards.ts:181, :206, :222). A Read of a symlink inside the bundle pointing to another client's file passes and the reply can be sent. Check the resolved target, and fail closed when the actual read/search scope cannot be established, including symlinked directories. Add a symlink regression test.

  4. The verification fixture can connect to live WhatsApp (scripts/control-omb.ts:178). It copies every intake setting from the caller, including an absolute real wacli executable and store, into the supposedly isolated server. Launching the documented fixture from a configured Environment can start the real follower and send operator messages. Do not inherit live intake settings; require explicitly supplied fake dependencies for this test and prove real paths are rejected or absent.

Non-blocking

None.

— Pablo

@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from 58b0ffa to 0876d73 Compare October 31, 2026 12:41

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. A recovered START can undo a newer STOP (server/whatsapp-intake.ts:467, :827). If the webhook drops START and later delivers STOP, catch-up processes the older START after the live STOP and resumes group sends without a new operator instruction. Preserve operator-command ordering across live and recovered messages, and test this sequence (including scoped commands).

  2. Catch-up can stall at a full page with the same timestamp (server/whatsapp-intake.ts:825, :832). With 200 stored messages in the same second followed by a dropped STOP, the one-second overlap returns the same full page and newest <= after stops pagination on every scan; the STOP is never handled. Make pagination and deduplication progress without losing messages that share a timestamp, and test this case and a backlog exceeding the seen-ID retention.

Non-blocking

None.

— Pablo

@immakermatty

Copy link
Copy Markdown
Author

Thanks for the review of 58b0ffa6. All four blocking findings are fixed in the new head 0876d730 (0876d730bf9bfe53df92d7a84500536152c32d29), amended into the one overlay commit. Each fix has a regression test that fails on 58b0ffa6 and passes now. Every check below ran on station-01, a Linux development server.

1. Catch-up can permanently miss messages: fixed.

  • Each chat now has its own catch-up cursor (caughtUpTo in the state file), and only catch-up reads move it, never a live message. lastSeenAt now feeds only the status and the digest.
  • A scan reads from 10 minutes before the cursor, so a message stored late with an earlier time is read too; seen deduplicates.
  • It reads 200 messages a page, up to 5 pages per chat, so one full page cannot stall it. The cursor never moves past the server's clock, so a message dated in the future cannot make the scan skip what follows.
  • Tests in server/whatsapp-intake.test.ts, describe "catch-up":
    • your case: a STOP and a mention dropped, later live messages in both chats, then the scan. Both are recovered: the pause applies, the mention runs, its reply is drafted as paused, and a second scan finds nothing new;
    • a message stored 2 minutes before the cursor;
    • 251 dropped messages in one catch-up, and a message dated in the future.
  • The e2e now drops a mention, delivers a later message live, and only then calls POST /api/whatsapp-intake/catch-up.

2. STOP does not stop a queued send: fixed.

  • The operator's chat no longer goes through the work queue. receive() applies a command at once, and only its confirmation waits for a send slot.
  • send() asks the pause again after the spacing wait, right before it calls wacli. A paused reply goes to the operator as a draft with the reason paused.
  • Test, describe "STOP and START": a STOP that arrives while a reply waits in its spacing delay. The pause is set before the delay ends, nothing reaches the group, and the operator gets the confirmation and then the draft.
  • Known limit, stated in the code and the doc: a text already handed to wacli, whose follower paces it again, cannot be recalled.

3. The bundle guard follows lexical paths: fixed.

  • A read is judged by its real target: realpath(3) (fs.realpathSync.native) of the path as the tool gave it, against the bundle's real path.
    • Node's JS realpathSync collapses .. as text before it follows links, so bundle/link/../x would be judged as bundle/x. A test pins this.
  • Fail closed (read_unverified) for a dangling link, a missing path, and a regular file with a second hard link.
  • A search (Grep, Glob, LS) on a folder counts as inside only when the bundle is sealed: every symlink in it resolves inside, and no regular file has another hard link. That holds whether the search follows links or not. A bundle over 10,000 entries, or with an unreadable folder, fails closed.
  • Tests:
    • server/whatsapp-intake-guards.test.ts:
      • a symlinked file;
      • a symlinked folder: a read below it, link/../, Grep with and without a path, Glob and LS;
      • a dangling link, a missing file, a hard link, and links that stay inside the bundle, which still pass;
    • the intake test: both cases through the real WebhookManager;
    • the e2e: a Read of a bundle link to group B's note is drafted.

4. The verification fixture can connect to live WhatsApp: fixed.

  • verificationServerEnvironment() no longer copies any OMB_WHATSAPP_INTAKE* variable.
  • The intake runs in a fixture only through a new last parameter of launchVerificationServer(): the caller's explicit { wacli, bot, groups, operator, bundle }. The launch CLI never passes it.
  • server/testing/whatsapp-intake-fixture.ts takes only absolute paths whose real path is inside the temporary folder, so a link there to an installed wacli is refused. It has no store field. The fixture's home is its own, where wacli v0.25.1 keeps its default store.
  • Tests:
    • server/testing/whatsapp-intake-fixture.test.ts: live-looking settings in the launcher's environment do not appear in the child's environment;
    • the same file refuses a bare wacli, the node binary, a link to it, a missing file, the temporary folder itself, and a map or bundle outside the temporary folder;
    • the e2e puts a store in the launcher's environment and checks that no wacli call carried --store.

Also in this push

  • server/whatsapp-intake.ts would have crossed the ratchet's 1,000 lines. Its state schema moved unchanged, plus the cursor, into server/whatsapp-intake-state.ts; the file is now 943 lines.
  • The doc, the overlay table row and the CI allowlist comment describe the new behaviour.

Verification on 0876d730

  • Vitest: 13 files, 254 tests pass:
    • the intake's four unit files (73) and its e2e;
    • server/host/team-messages.test.ts and the file-size ratchet;
    • scripts/control-omb.test.ts and server/verification-fixture.e2e.test.ts;
    • the GitHub intake's unit and e2e tests, server/lazurio-loopback-dispatch.e2e.test.ts and server/lazurio-removals.e2e.test.ts.
  • On 58b0ffa6's source with these tests, 8 tests fail, and the fixture file fails because its module is missing. Checked directly, 58b0ffa6's verificationServerEnvironment() passes OMB_WHATSAPP_INTAKE, _WACLI and _STORE through.
  • Mutation check, 47 of 47 caught, sources restored byte for byte. That is the 29 earlier rules plus 18 new: 5 catch-up, 2 STOP, 8 real-file guard and 3 fixture. The PR description lists each with its failing test count.
  • Contract tests: 558 pass, 0 fail.
  • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.

@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from 0876d73 to 63346a0 Compare October 31, 2026 14:07
@immakermatty

Copy link
Copy Markdown
Author

Thanks for the review of 0876d730. Both blocking findings are fixed in the new head 63346a02 (63346a02b2d40d38eda17d4fff939ab8922d11ae), amended into the one overlay commit. Each fix has regression tests that fail on 0876d730 and pass now. Every check below ran on station-01, a Linux development server.

1. A recovered START can undo a newer STOP: fixed.

  • A command now counts from when the operator sent it, not from when it arrived. The state keeps the latest command for all groups and for each group, with its WhatsApp time and, for a command the catch-up found, wacli's store sequence.
  • For each group the latest command decides, whatever the arrival order:
    • a STOP or START for all groups overrides older commands of single groups;
    • a newer command of a group overrides an older one for all groups, so START b after STOP now sends to b while the other groups stay paused (before, b stayed paused).
  • Two commands of the same second are ordered by store sequence when both have one. Otherwise a STOP and a START of that second leave sending paused.
  • A command that changes nothing because a later one stands gets one line back, for example Příkaz START z 09:01 nic nemění, platí STOP z 09:02., and the ledger records start:superseded. A command for all groups names in its confirmation the groups whose own later command still decides.
  • A tick now runs the catch-up before it handles finished runs, so a STOP it finds applies before such a run's reply is sent.
  • The logic is pure, in server/whatsapp-intake-commands.ts.
  • Tests in server/whatsapp-intake.test.ts:
    • your case, for all groups and for b: STOP live, START dropped, STOP live, then the catch-up. Sending stays paused, the operator gets the one line, and a mention's reply is drafted;
    • the interplay both ways: STOP b then START; STOP then START b; a dropped STOP b older than a live START; a dropped STOP newer than START but older than START a;
    • the tie: STOP then START and START then STOP, live in one second; a dropped START and a live STOP of one second; two dropped commands ordered by store sequence;
    • a dropped STOP applied before the reply of a run that finished meanwhile.

2. Catch-up can stall on a full page of one second: fixed.

  • The catch-up reads wacli's change journal, wacli --read-only --json messages changes --since <checkpoint> --limit 200, ordered by store sequence. It no longer pages messages list --after.
    • Checked in v0.25.1's source (internal/store/changes.go, cmd/wacli/messages_changes.go, docs/messages.md).
    • A page is {store_id, changes, has_more, next_checkpoint}, each change sequence, operation, chat_jid, msg_id and message_time, without text. Like messages list, it takes no store lock.
  • One checkpoint per intake, in the state file, since the journal is global.
    • The first start takes --checkpoint before the follower stores anything.
    • The checkpoint moves past a page only once every entry on it is handled. After a failure or a restart the page is read again, and handled ids are skipped.
  • An entry for the operator's chat or a mapped group that the intake has not handled is read with messages show --chat --id and handled as if it had arrived live. An identity_move is followed to its new chat id. A catch-up reads up to 1,000 entries; when more wait, the next tick goes on 5 seconds later.
  • The dedup stays by message id, now with each id's time.
    • The ids are trimmed to the newest 500 per chat only after a complete catch-up (hard cap 5,000), so an id the webhook delivered is still known when the journal shows it, however many arrived after it.
    • An entry at or before the newest time no longer kept is a change to a message handled earlier (an edit, a sender id wacli normalized), not a new one.
    • The intake's own sends count as handled.
  • It fails closed.
    • When the journal cannot be read (a wacli without it, a store not yet migrated, a failing read), the operator is told once, the status says catch_up_failed, and every reply is a draft until a catch-up succeeds.
    • When wacli no longer holds the journal from the checkpoint on (expired checkpoint, checkpoint belongs to another store), the intake takes a new checkpoint, pauses sending as a STOP would, and asks the operator to send START. A STOP dropped in that gap cannot be found.
  • Tests in server/whatsapp-intake.test.ts, describe "catch-up":
    • your case: 260 operator messages of the same second delivered, then a dropped STOP, which is handled;
    • a backlog of 600 dropped messages, more than the 500 ids kept, then 600 live ones with one dropped before them: each handled once, and later journal changes to two of them are no new messages;
    • 1,200 dropped messages, a read failing in the middle of the second catch-up, then a restart: the checkpoint persisted, the rest is handled, nothing twice;
    • also: the next tick going on, a STOP filed first under the operator's LID and then moved, a journal that cannot be read, and one that expired.
  • The adapter test covers the exact commands, the journal's page shape, a message the store no longer holds, and an expired checkpoint told apart from another failure.
  • In the e2e, the fake wacli serves messages changes and messages show from its store.
    • The dropped mention is recovered from the journal, and it is the only message read back: not the live ones, nor the bot's own sends.
    • The checkpoint is taken before the follower starts.

Also in this push

  • server/whatsapp-intake.ts stays under the ratchet at 964 lines. The digest text and its Prague time helpers, and the webhook instructions, moved unchanged into server/whatsapp-intake-digest.ts and server/whatsapp-intake-prompt.ts.
  • messages show returns deleted messages too; a row with deleted_at is never a question.
  • The doc has a new section, "The catch-up", and the command order. The overlay row is unchanged; its file pattern covers the new modules.

Verification

The full Vitest set and the mutation run ran on ebdd82a3. The final amend, 63346a02, changed only the doc and three lines of the e2e test; on it, the intake's files, the e2e, the ratchet, the contract tests, the two e2e-dependent mutations and the run on 0876d730's source ran again.

  • Vitest: 13 files, 265 tests pass.
    • The intake's four unit files (84 tests) and its e2e.
    • server/host/team-messages.test.ts, the size ratchet, the fixture tests, the GitHub intake, loopback dispatch and removals.
  • On 0876d730's source, with this head's intake test file (its fake given back the messages list --after paging the old catch-up used), 14 of 50 tests fail:
    • 10 on the behaviour itself: the three order tests, your 260-message case (the STOP is never read), the backlog (800 of 1,201 handled twice), the next tick and the restart (the paging stalls on one second), the STOP before a finished run's reply, and the two fail-closed tests;
    • 4 only on the summary's new shape.
  • Mutation check: 79 of 79 caught, sources restored byte for byte. 37 are new for this push (12 for the command order, 25 for the journal and its adapter); 42 are the earlier ones that still apply, since round 1's five cursor mutations went with the cursor. The PR description lists each with its failing test count.
  • Contract tests: 558 pass, 0 fail.
  • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. A dropped STOP is discarded after ID eviction (server/whatsapp-intake.ts:803, :812). Once 501 messages in the operator chat share a timestamp and a catch-up trims IDs, a new dropped STOP with that same timestamp is treated as an old edit and permanently skipped. Distinguish new journal entries from changes to forgotten messages without using a timestamp cutoff that can discard new commands; cover same-second messages across catch-ups and the 5,000-ID cap.

  2. A pending journal page does not hold back replies (server/whatsapp-intake.ts:235, :242, :800). If a dropped STOP follows more than 1,000 journal entries, the first catch-up returns more, but the same tick sends completed runs before the next page is read. Keep replies as drafts or defer them until the catch-up reaches the end, and test a completed run with a STOP beyond the page limit.

  3. A failed initial checkpoint can lose STOP permanently (server/whatsapp-intake.ts:212, :213, :820). enqueue swallows the initial anchor failure and starts the follower anyway. If it stores a STOP whose webhook is dropped before a later anchor succeeds, that anchor skips the STOP and clears the failure, allowing replies. Do not start collection without a valid initial checkpoint, or pause until an operator explicitly resolves the gap; test recovery from the initial failure.

Non-blocking

None.

— Pablo

@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from 63346a0 to 02e98ab Compare October 31, 2026 17:18
@immakermatty

Copy link
Copy Markdown
Author

Thanks for the review of 63346a02. All three blocking findings are fixed in the new head 02e98ab1 (02e98ab171bd2261c10cea2a5c262af891505601), amended into the one overlay commit. Each fix has regression tests that fail on 63346a02 and pass now, and one test holds the whole promise: an operator's STOP stored by wacli is never lost, and it applies before any later group send, whatever the webhook dropped, the intake forgot or the journal pages. Every check below ran on station-01, a Linux development server.

1. A dropped STOP is discarded after ID eviction: fixed.

  • First, what wacli v0.25.1's journal can tell. It cannot tell a new message from a changed one:
    • in internal/store/changes.go, the INSERT and the UPDATE triggers on messages both record upsert;
    • an edit is stored over the message it edits, which keeps its id and its time (extractProtocolMutation in internal/wa/messages.go, then the ON CONFLICT branch of UpsertMessage);
    • at every sync start, wacli rewrites the sender of every stored message of a member whose LID it can now resolve (migrateLIDSenderToPN in internal/store/lid_migration.go), which adds an upsert for each of them.
  • So "new or already handled" is decided by the journal's position and the ids, never by the entry's time: an entry past the persisted checkpoint for an id the intake no longer keeps is handled.
  • The operator's chat has no time cutoff at all. Every such entry is read with messages show and handled, so no command there is ever skipped.
    • The chat forgets no id before a complete catch-up; the 5,000 cap now applies to mapped groups only.
    • Evicting there in the middle of a window would make the catch-up read every forgotten message back, and each one it remembers again would push out the next.
  • A command handled twice changes nothing, since the catch-up can now read a command again after a change:
    • A command counts by its own WhatsApp time, so it never undoes a newer one. The command still in force says nothing when read again; an older one gets the usual nic nemění line.
    • A STOP and a START of the same second leave sending paused, whatever order wacli stored them in. A command read again carries the sequence of a later change, so store order proves nothing any more. This replaces the store-sequence tie-break of round 2, and seq is gone from the state.
    • A START counts only in a message as the operator sent it: never in an edit (the stored row says Edited), a deleted message or a reaction. A STOP counts in any of them. This is how an edit of a forgotten message is kept from acting, with what wacli does record.
  • Mapped groups keep the time rule: an entry at or before the newest forgotten time counts as a change.
    • It cannot skip a command.
    • It keeps the sender rewrites above from waking the bot again for old messages.
    • Its cost: a dropped group message that wacli stored late, at or before that time, gets no answer.
  • Found while reading the source. wacli's LID chat merge records identity_move with new_chat_jid only, without new_msg_id, and round 2's adapter dropped such a move. A STOP filed first under the operator's LID would not have been followed. The adapter now keeps the message id when new_msg_id is absent.
  • Tests:
    • 900 operator messages of one second over three catch-ups, then a dropped STOP of that second: found;
    • 5,001 of them with no catch-up between, then a dropped STOP: found. The chat kept all 5,001 ids until the complete catch-up, then 500;
    • a command read again after its id was forgotten: the one in force is silent, an older one gets one line;
    • a START in an edit or in a reaction resumes nothing, while a STOP in an edit pauses;
    • a STOP and a START of one second, found in either store order: paused;
    • the adapter's identity_move with the new chat only.

2. A pending journal page does not hold back replies: fixed.

  • Before a finished run's reply is handled, the intake runs a catch-up right then.
  • While that catch-up finds more of the journal (has_more), the run stays in flight: its reply is neither sent nor drafted, and the next tick reads on.
  • Once a catch-up reaches the end, every command it found has applied, and the reply goes through the guards, the pause included. A journal that cannot be read still makes it a draft, as before.
  • What remains: a STOP whose webhook drops while a reply already waits for its 3 to 8 second spacing applies only at the next catch-up. It is documented as a known open risk, next to "a text already handed to wacli is not recalled".
  • Test: 1,200 dropped entries, then a dropped STOP past the 1,000-entry limit of one catch-up, and a finished run.
    • The first tick sends and drafts nothing.
    • The second reads the rest; the STOP applies and the reply becomes a draft (paused).
    • Without the STOP, the reply is sent at the second tick.

3. A failed initial checkpoint can lose a STOP: fixed.

  • The follower runs only from a checkpoint. start() takes it first, and a failure is no longer swallowed.
  • Until a checkpoint is taken:
    • neither the follower nor the ticks' work starts, so nothing is collected or sent;
    • the status says checkpoint_failed and the operator is told once;
    • the tick tries again after 1 s, 5 s, 30 s, 2 min, then every 5 min.
  • The checkpoint is now a writable open (--json messages changes --checkpoint, without --read-only). Round 2 relied on the follower's start to migrate a store no writable wacli had opened since it got the journal; with the follower waiting for the checkpoint, that would deadlock. Nothing else runs at that moment, and the catch-up's reads stay read-only.
  • The same rule covers a journal that no longer reaches the checkpoint. Sending pauses as before, and now the follower also stops; it starts again only from a new checkpoint. A stopped follower is no longer heard, so its late exit counts as no restart.
  • Tests:
    • a start with a failing checkpoint: no follower, checkpoint_failed, one line to the operator and three tries over 6 seconds. Then a success, then a mention and a dropped STOP: the STOP applies before the reply;
    • a lost journal with a failing new checkpoint: the follower stops, its late exit is ignored, and a new follower starts from the new checkpoint.

The invariant, in one test. It starts the intake with a failing checkpoint, then recovers. Then come 600 operator messages of one second over two catch-ups, so their ids are forgotten, then 1,200 dropped journal entries, a dropped STOP of that same second past the page limit, and a finished run. Nothing reaches a group, the STOP applies, and the reply is a draft.

Also in this push

  • runPayload() moved unchanged into server/whatsapp-intake-prompt.ts as runEvent(), which takes isTeam. That module now holds both halves of what a run is given. server/whatsapp-intake.ts is 977 lines.
  • The doc's catch-up section, commands section and wacli facts follow all of the above.

Verification (station-01, a clean clone at 63346a02 with this change)

  • Vitest: 13 files, 274 tests pass.
    • The intake's four unit files: 93 tests.
    • Its e2e: 1 test, 35 s.
    • Around them: server/host/team-messages.test.ts, the size ratchet, the fixture tests, the GitHub intake, loopback dispatch and removals.
  • This head's two changed test files on 63346a02's source: 11 tests fail there and the 61 unchanged ones pass.
    • All 9 new or changed intake tests fail: the two catch-up eviction tests, the 5,000 cap, the command read again, the held reply, the checkpoint at start, the follower after a lost journal, the tie in either store order, the START in an edit, and the invariant test.
    • The adapter's 2: the checkpoint's command, and the move with the new chat only.
  • Mutation check: 91 of 91 caught: 20 new for this push, 29 of round 2's that still apply and the 42 earlier ones. Sources were restored byte for byte. The PR description lists each mutation with its failing test count.
  • Contract tests: 558 pass, 0 fail.
  • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.

Decision on the mapped groups (parent thread, 2026-10-31): option B as built — the operator's chat never uses a time rule; mapped groups keep round 2's rule for entries at or before the newest forgotten time, because wacli's journal cannot tell a new message from a changed one and its LID sender migration rewrites old messages. The exact fix (a first-entry marker in wacli) is option D for later.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. A dropped STOP during send spacing does not stop the reply (server/whatsapp-intake.ts:604, :718). The reply's catch-up finishes before send() waits 3–8 seconds. If wacli stores STOP during that wait and its webhook drops, allowed() sees the old pause state and sends to the group; the STOP is only found later. Ensure a stored STOP is considered before handing the waiting reply to wacli, including a paged or failed journal, and add a regression test with a dropped STOP during the spacing wait.

  2. An edit to STOP is skipped while its original ID is remembered (server/whatsapp-intake.ts:435, :805). For example, deliver Stoop, complete a catch-up, then edit that message to STOP and drop the edit webhook. The journal entry is skipped because its ID is in seen, so the pause never applies. This contradicts the new command contract that a STOP in an edit counts. Handle such changes safely even for remembered IDs and cover both remembered and forgotten IDs, without letting edits to START resume sending.

Non-blocking

None.

— Pablo

@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from 02e98ab to 61c78a3 Compare October 31, 2026 18:56
@immakermatty

Copy link
Copy Markdown
Author

Thanks for the review of 02e98ab1. Both blocking findings are fixed in the new head 61c78a35 (61c78a35b60af8b9b0ab1d5f5c3f71b70ac313d4), amended into the one overlay commit. Each fix has regression tests that fail on 02e98ab1 and pass now. Every check below ran on station-01, a Linux development server.

1. A dropped STOP during send spacing does not stop the reply: fixed.

  • A reply now takes a last look after its 3 to 8 second spacing, right before the hand-off to wacli: one more bounded catch-up of the journal (up to 1,000 entries, like any catch-up), then the pause again. sendToGroup passes it to send() as ready, which runs after the wait.
  • A STOP stored during the wait applies even when its webhook drops. The reply becomes a draft (paused), after the STOP's confirmation.
  • A paged journal: while the last look finds more than it reads, the reply is neither sent nor drafted. Its run stays in flight with nothing recorded, and the next tick tries again: a draft if a STOP turns up, a send otherwise.
  • A failed journal makes the reply a draft (catch_up), and the operator is told once, as before. A journal that no longer reaches the checkpoint pauses sending and asks for START, so the reply is a draft (paused).
  • One detail of the mechanism: the last look runs inside the reply's turn in the send queue. A line it writes to the operator, such as that confirmation, is queued behind the turn, not awaited; awaited, it would wait for the turn itself.
  • The run's release. finishRun now releases the run only once its reply is sent or drafted, so a held reply keeps it. The release writes the state at once, so a restart never finishes a run again whose reply already went out; a new test restarts right after a send.
  • The known open risk this closes is gone from the docs and the description. What remains is a text already handed to wacli, which paces it again.
  • Tests:
    • a STOP stored with its webhook dropped while run 2's reply waits for its spacing: nothing more reaches the group, and the reply is a draft;
    • 1,200 entries stored during the wait, with and without a dropped STOP after them: the reply waits, then the next tick drafts it or sends it;
    • the journal failing during the wait: a draft (catch_up) and one notice. The journal no longer reaching the checkpoint during the wait: a draft (paused).

2. An edit to STOP is skipped while its original ID is remembered: fixed.

  • First, what time an edit carries in wacli v0.25.1 (b7603b69):
    • The webhook posts an edit with the edited message's id, the edit's own time and Edited (ParseLiveMessage and extractProtocolMutation in internal/wa/messages.go).
    • The store keeps the edited message's time on the row: UpsertMessage keeps ts when the incoming row is an edit. So the journal's message_time and messages show give the original time, with Edited set.
    • The edit's own time is stored as edited_ts, but no command prints it; wacli's docs call it the internal edit-order timestamp. Each journal entry also has recorded_at, the time this machine recorded it.
  • So the intake uses the moment it reads the edit. A STOP in an edit counts from then, never from the edited message's time, so a START sent between the message and its edit cannot override it.
    • recorded_at would order an edit against a START sent after it more faithfully. But it compares this machine's clock with WhatsApp's, and a clock behind WhatsApp's could let an older START win. The read time cannot.
    • The cost: a START sent after an edit to STOP, but before the intake reads the edit, is overridden by it. Sending pauses, the operator is told, and a new START resumes. The description lists it under Risks.
  • In the operator's chat, a journal change to one of the operator's messages that the intake still keeps is now read again with messages show. If the stored row is an edit, a STOP in it applies; a START in an edit still never resumes. For an id the intake no longer keeps, which the catch-up already read, an edit now also counts from the read.
  • Every edit is read from the journal, once: an edit's webhook only starts a catch-up. Judged from the webhook as well, the same edit would count again at the next catch-up, after a START sent in between.
  • The command in force, read again, stays silent only when it is still the same command, so a START edited into STOP pauses.
  • The account's own messages are marked own in the state and never read again. The extra reads cost one messages show per change to one of the operator's messages; the e2e now expects exactly those.
  • Mapped groups are unchanged, so reply dedup stays: a client's edit of a message the bot answered gets no second reply, dropped or delivered.
  • Tests:
    • your example, for a kept id and a forgotten one: Stoop, then a START, a complete catch-up, then the edit to STOP with its webhook dropped. Sending pauses, the operator is told, and the next reply is a draft;
    • the START in force edited into STOP b: b pauses;
    • an edit the webhook delivers: read once from the journal, so a START sent after it stands;
    • an edit to START, kept or forgotten, dropped or delivered: still paused, no line;
    • what the catch-up reads back: a message it has not handled and the operator's command, never a group message it handled, its own sends or a message the account wrote from its phone;
    • a client's edit of an answered group message, dropped and delivered: no second run.

Also in this push. The status's types and the one word that sums it up moved unchanged into server/whatsapp-intake-status.ts, so server/whatsapp-intake.ts is 974 lines, three fewer than before.

Verification (station-01, a clean clone at 02e98ab1 with this change)

  • Vitest: 13 files, 285 tests pass, including the intake's four unit files and its e2e.
  • This head's three changed test files on 02e98ab1's source: 6 of the 69 intake tests fail there, both findings' regression tests and the read-back among them; 2 of the 14 adapter tests (neither shape marks an edit); and the e2e, on what is read back. The five new tests that pin what must not change pass there.
  • Mutation check: 111 of 111 caught: 20 new for this push and the 91 earlier ones, eight of those with patterns moved to this head's code. Sources were restored byte for byte. The description lists each mutation with its failing test count.
  • Contract tests: 558 pass, 0 fail.
  • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. A STOP edit to a message from before activation is ignored (server/whatsapp-intake.ts:806). The journal's message_time is the original message's time, so the activation filter skips an edit made now to an older message. Even a delivered edit webhook only starts catch-up, leaving sending enabled. Handle post-checkpoint operator edits regardless of the original message time and cover both delivered and dropped webhooks.

  2. An old edited STOP can override a later START again (server/whatsapp-intake.ts:813, :441). After the edit is read and the operator sends START, a later journal upsert of that same row (for example wacli's documented sender normalization) reads its still-set Edited flag and applies STOP with the current time. Sending pauses again without a new STOP, contrary to the command ordering contract. Distinguish a new edit from another change to an already handled edit, including after restart, and test the STOP → START → row rewrite sequence.

Non-blocking

None.

— Pablo

@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from 61c78a3 to 4882f0d Compare October 31, 2026 19:56
@immakermatty

Copy link
Copy Markdown
Author

Thanks for the review of 61c78a35. Both blocking findings are fixed in the new head 4882f0da (4882f0daff12d0e746193fa5b80e7c22869d8428), amended into the one overlay commit. Each fix has regression tests that fail on 61c78a35 and pass now. Every check below ran on station-01, a Linux development server.

1. A STOP edit to a message from before activation is ignored: fixed.

  • The operator's chat no longer skips a journal change by the time of the message it changes. Every change past the checkpoint is read with messages show, so an edit made now to a message from before the first start is judged like any other: a STOP in it counts from the read, and a START in it never resumes. Mapped groups keep both time rules, the first start and forgottenUpTo.
  • A message from before the first start that is not an edit stays history: a command from then that wacli rewrites, or stores only now, changes nothing. The account's own edits never count.
  • One path judges each edit. A delivered edit webhook still only starts the catch-up, and that catch-up now judges the change from wacli's store; a dropped one is judged by the next catch-up.
  • Tests, for both the dropped and the delivered webhook: an edit to STOP of a message stored before the first checkpoint pauses sending, the operator gets one line, and the next reply is a draft. In the same test the account's own pre-start edit to STOP, a pre-start STOP that wacli rewrites and a pre-start STOP b stored only now change nothing, an edit to START of a pre-start message resumes nothing, and each edit is read once, from the store.

2. An old edited STOP can override a later START again: fixed.

  • The intake now tells a new edit from another change to an edit it already judged. For each of the operator's edited messages it keeps a hash of the text it judged last (the first 64 bits of its SHA-256, never the text) in the persisted intake state, so it holds across a restart.
    • A change whose text hashes the same is a rewrite, such as your example of migrateLIDSenderToPN rewriting the sender, or an identity_move, and changes nothing. That sender rewrite touches only the sender and a quoted sender, so an edited row keeps its text and Edited.
    • A different text is a new edit: it is judged and its hash stored. An edit to anything else in between updates the hash, so an edit back to STOP counts again.
  • The memory keeps the 1,000 edited messages judged last, and one judged again moves to the end. After eviction, a rewrite of the forgotten message counts as a new edit: a STOP in it pauses once more, the operator is told, and a new START resumes. An edit made before the first start that wacli rewrites later is seen for the first time then and pauses once the same way. Both, and an edit that keeps the text the intake judged last (taken for a rewrite), are under Risks in the description and in the docs.
  • Tests:
    • STOP edit → START → row rewrite with the same text: still sending, no line, and the next reply goes to its group. This runs for an id the intake keeps and one it forgot, each with and without a restart between the START and the rewrite;
    • edit to STOP → START → edit to another text → edit back to STOP: paused again, with no text of the edit in the state file;
    • a unit test of the memory: a hash, never the text; the same text again is a rewrite and another text a new edit; the bound of 1,000; a rewrite keeps a message from being forgotten next; a forgotten one counts as new;
    • an edit to START still never resumes, kept or forgotten, delivered or dropped, and now also for a message from before the first start.

Verification (station-01, a clean clone at 61c78a35 with this change)

  • Vitest: 13 files, 289 tests pass, including the intake's four unit files and its e2e.
  • This head's intake tests on 61c78a35's source: 3 of the 73 fail there. The pre-start edit to STOP is skipped, the rewrite after START pauses again, and the memory does not exist. Run first in turn, the delivered webhook and a restart before the rewrite, for a kept and for a forgotten id, fail there too. The test that pins what must not change, an edit back to STOP after another text, passes there.
  • Mutation check: 124 of 124 caught. That is 13 new for this push (4 for finding 1, 9 for finding 2) and the 111 earlier ones, three of them with patterns moved to the changed line. Sources were restored byte for byte. The description lists each mutation with its failing test count.
  • Contract tests: 558 pass, 0 fail.
  • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.
  • server/whatsapp-intake.ts is 976 lines; newEdit() sits with the commands in server/whatsapp-intake-commands.ts.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. An edited STOP can be ignored after START (server/whatsapp-intake-commands.ts:29, server/whatsapp-intake.ts:442). Edit a message to STOP and let catch-up process it, send START, then edit that message to another text and back to STOP before catch-up reads either edit. Both journal entries read the final STOP; its hash matches the last judged text, so both are treated as rewrites and sending remains enabled. I reproduced this on this head; the same sequence pauses when the new hash gate is removed. Distinguish a new edit from a rewrite using reliable edit metadata, or fail closed when they cannot be distinguished, and cover this sequence with a regression test. The documented limitation currently contradicts the STOP safety promise.

Non-blocking

None.

— Pablo

@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from 4882f0d to da07c4c Compare October 31, 2026 21:04
@immakermatty

immakermatty commented Oct 31, 2026 •

Copy link
Copy Markdown
Author

Thanks for the review of 4882f0da. The blocking finding is fixed in the new head da07c4cd (da07c4cdfe1b66301d78e3a35ddd30c55a00ca30), amended into the one overlay commit. Its regression tests fail on 4882f0da and pass now. Every check below ran on station-01, a Linux development server.

1. An edited STOP can be ignored after START: fixed, by wacli's own edit time.

  • The text cannot tell your cycle from a rewrite, so the intake no longer looks at the text to decide. wacli v0.25.1 keeps the time of a message's latest edit in its store, messages.edited_ts. Only a new edit moves it on, and a rewrite of the row (migrateLIDSenderToPN, a LID chat's move) leaves it as it was. Your sequence moves it twice, so it tells exactly the two cases apart.
  • No wacli command prints that time. messages show and messages list give the edited message's own time, and a messages changes entry gives that time and recorded_at; docs/messages.md calls the column the internal edit-order timestamp. So the intake reads that one column of the one row from <store>/wacli.db itself, in a new module that does nothing else (server/whatsapp-intake-wacli-store.ts, behind Wacli.editedAt()).
    • It opens the file read-only (node:sqlite), never immutable, and closes it at once. The store runs in WAL mode, so the read neither waits for wacli's writer nor holds it up; a test reads while another connection holds a write open.
    • It reads only a store whose newest migration is v0.25.1's (33, ordered chat auto-unarchive).
  • The rule: for each of the operator's last 1,000 edited messages, the state keeps the edit time judged last, persisted, instead of the hash. A change with that time is a rewrite and changes nothing; any other time is a new edit, judged as before. The state now holds no value derived from a text.
  • The edit time is read before the text, so an edit stored between the two reads has a later time than the one recorded and is judged again at its own entry.
  • Fail closed. If the store cannot be read, is not v0.25.1's, has no edited_ts, or no store is set (OMB_WHATSAPP_INTAKE_STORE), every change to an edited message counts as a new edit. A STOP in it pauses, also after a START. The operator is told once per outage, and the reason is the status's last error.
  • The documented limitation is gone. The docs and the description no longer say an edit back to the judged text is taken for a rewrite. What remains is what wacli's edit time cannot show, and both limits are stated:
    • wacli keeps the edit time in whole seconds, so a second edit within the second of an edit already judged counts as that edit. That cannot hide a STOP behind a START, while this machine's clock and WhatsApp's agree: a START that overrides an edited STOP is later than the read that judged it, so any edit after it has a later second.
    • wacli's journal records no change when only edited_ts changes ("identical stored values produce no journal entry"). So an edit of an already edited message that keeps its text exactly leaves no entry, and the intake does not read it, as before. A STOP sent as a new message always counts.
  • Tests.
    • Your sequence: STOP edit judged → START → edit to another text → edit back to STOP, both read by one catch-up. It pauses with one line, and the next reply is a draft, for an id the intake keeps and one it forgot, with and without a restart before the two edits.
    • A rewrite with unchanged edited_ts after START: still sending, also across a restart. This is round 5's test, unchanged except for its title.
    • An edit stored between the reads of its time and its text: judged at its own entry.
    • A store that cannot tell (no such column: edited_ts, file is not a database): it pauses once with one notice, pauses again on a later rewrite without a second notice, and is told again after it recovers and fails once more.
    • The rule's unit test, which replaces the hash test.
    • The store read against stores built from wacli v0.25.1's own schema with synthetic rows: the time in ms, a read during an open write, and no time from a missing, unreadable, other-version or column-less store.

The cost, under Risks in the description and in the docs:

  • The intake depends on wacli's internal schema at v0.25.1. A later wacli fails closed until the read is checked against it again.
  • OMB_WHATSAPP_INTAKE_STORE has to be set for the read.
  • Once wacli prints the edit time, the intake should take it from there and drop the store read. A request to wacli for that is drafted, not filed yet.

Verification (station-01, a clean clone at 4882f0da with this change)

  • Vitest: 14 files, 295 tests pass, including the intake's five unit files and its e2e.
  • This head's tests on 4882f0da's source: 3 of the 76 intake tests fail there. Your sequence leaves sending on, the store that cannot tell pauses nothing, and the rule's unit test meets a hash. Run first in turn, a forgotten id, a restart before the edits and both fail there too. The store reader's tests do not load there, since the module is new.
  • Mutation check: 140 of 140 caught. That is 18 new for this push and the 122 earlier ones that still apply, seven of them with patterns moved to the changed lines. Sources were restored byte for byte. The description lists each mutation with its failing test count.
  • Contract tests: 558 pass, 0 fail.
  • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.
  • server/whatsapp-intake.ts is 992 lines.

Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. A STOP read during the two store reads can undo a later START (server/whatsapp-intake.ts:826, :827). If catch-up reads the time of an earlier edit, then wacli stores a new edit to STOP before message() reads the text, the intake applies that STOP but remembers the earlier edit time. If the operator then sends START, the next catch-up sees the STOP's own journal entry and its newer edit time, treats the already-applied STOP as a new edit, and pauses again. I reproduced this on this head with the fake wacli. Ensure the time and text judged belong to the same edit (or otherwise prevent this replay from undoing a later START), and add a regression covering STOP between the reads followed by START before the next catch-up.

Non-blocking

None.

— Pablo

@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from da07c4c to 1cbf1cc Compare October 31, 2026 22:17
@immakermatty

Copy link
Copy Markdown
Author

Thanks for the review of da07c4cd. The blocking finding is fixed in the new head 1cbf1ccb (1cbf1ccbdd697130a45508e34b4cbda4e2ecc3e4), amended into the one overlay commit. Its regression tests fail on da07c4cd and pass now. Every check below ran on station-01, a Linux development server.

1. A STOP read during the two store reads can undo a later START: fixed, the time and the text judged now belong to one edit.

  • The intake reads the edit time before and after messages show reads the text, and takes the pair only when both reads agree (readEdit, in server/whatsapp-intake-wacli.ts). It remembers exactly that time.
    • Equal times are enough because wacli moves edited_ts on only for an edit of a later second: an edit of the same second keeps it, and a delete clears it. So every edit stored between two equal reads is of that second, and the text read is of an edit with that time.
    • When the times differ, an edit was stored meanwhile, so the text and the time are read again, up to three text reads within the same catch-up. In your sequence the second read settles on the STOP and its time. The STOP pauses once, its own journal entry later reads the time remembered and changes nothing, and the START stands.
  • Why messages show and not one store transaction. The text, its flags and a delete stay with wacli's documented command, as everywhere else in the intake, and the store read stays one column of one row, which goes once wacli prints the edit time. Reading the text from the store would also tie the intake to how wacli v0.25.1 builds its Message from text, display_text, media_caption and the delete columns.
  • A time that keeps moving fails closed. If the time moves at all three reads, the text may belong to another edit than any time read, possibly a STOP.
    • The change then counts as a STOP for all groups from the read, whatever its text.
    • The intake remembers the time it read last, so those edits, read again, never undo the START that resumes.
    • The operator is told once until the store gives one time again (Čas úpravy zprávy byl v úložišti wacli při každém čtení jiný, …), with the usual pause line, and the reason is the status's last error, as for a store that cannot tell.
    • Three moves need a new edit, each a second later than the one before, during each of the three reads, so this is for a store that misbehaves rather than a person editing.
  • Two edits within one second, and why that is safe. wacli keeps the edit time in whole seconds, so equal times can still come with an edit of the same second that wacli stores after the text read. The time remembered covers that edit, and the next catch-up reads its entry as a rewrite.
    • A rewrite changes nothing, so it never undoes a START. That is the property your finding is about.
    • It cannot hide a STOP behind a START while this machine's clock and WhatsApp's agree. A START overrides an edited STOP only when it is later than the read that applied the STOP (a tie is a STOP), so any edit sent after that START has a later second and is judged.
    • What it can miss is an edit to STOP within the very second of the edit whose text was read, stored after that read: two edits of one message within a second. The whole-second edit time could not tell those apart before either. A STOP sent as a new message always counts. The docs and the description's Risks say so.
  • Tests.
    • Your sequence: an edit's time read, an edit to STOP stored before its text is read, then a START before the next catch-up. The STOP pauses once, with one line. After the START, the next catch-up leaves sending on and the next reply goes to its group. This holds for an id the intake keeps and one it forgot, with and without a restart before that catch-up.
    • An edit stored right after the text read applies at that same read, and its own entry after a START changes nothing. This replaces round 6's read-order test, which expected that STOP only at the next catch-up.
    • A time that moves:
      • twice: the third read settles on the STOP;
      • at all three reads, with no STOP in the text read last: a STOP for all groups with one notice;
      • after a START, the edits read again change nothing;
      • once the store gave one time again, the notice comes again;
      • a second such change before the store gives one time pauses again, without a second notice.
    • readEdit itself: equal times, a time that moves once, twice or at every read, and a store that cannot tell at both reads, at one of them, or by turns.

Verification (station-01, a clean clone at da07c4cd with this change)

  • Vitest: 14 files, 298 tests pass, including the intake's five unit files and its e2e.
  • This head's tests on da07c4cd's source: 3 of the 78 intake tests fail there.
    • Your sequence: the catch-up after the START pauses again.
    • The read order: the edit stored right after the text read does not apply at that read.
    • A time that moves: the text read first is judged, and nothing pauses.
    • Run first in turn, a forgotten id, a restart before that catch-up and both fail there too, paused again after the START.
    • Of the adapter's 15 tests, only readEdit's fails there (readEdit is not a function).
  • Mutation check: 151 of 151 caught, 11 of them new for this push and 140 from earlier pushes, eleven of those with patterns moved to the changed lines. Sources were restored byte for byte. The description lists each mutation with its failing test count.
  • Contract tests: 558 pass, 0 fail.
  • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.
  • server/whatsapp-intake.ts is 997 lines.

Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. An intermittent edit-time read failure can replay the same STOP after START (server/whatsapp-intake-wacli.ts:113, server/whatsapp-intake.ts:449). If the first time read succeeds, STOP is stored, and the following reads alternate unknown / known / unknown, readEdit returns unsettled with an unknown final time. The intake applies its fail-closed STOP but newEdit(..., null) remembers no time. After the operator sends START and the store recovers, the STOP's pending journal entry is read with its real time and applies the same STOP again, pausing without a new instruction. I reproduced this with the fake wacli on this head. Preserve a safe reconciliation across an unknown last read so a STOP already applied cannot undo a later START, while a genuinely new STOP still pauses; cover the intermittent-read sequence and recovery in a regression test.

Non-blocking

None.

Order and migrations: #81 is merged and precedes this PR; install the checked wacli v0.25.1 before enabling the intake, then verify the pilot in observe mode before the red team.

— Pablo

@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from 1cbf1cc to 04953e9 Compare November 1, 2026 00:02
@immakermatty

Copy link
Copy Markdown
Author

Thanks for the review of 1cbf1ccb. The blocking finding is fixed in the new head 04953e92 (04953e92ea82561daaffacd34ec551ac973eb9b8), amended into the one overlay commit. Its regression tests fail on 1cbf1ccb and pass now. Every check below ran on station-01, a Linux development server.

1. An intermittent edit-time read failure can replay the same STOP after START: fixed, the intake keeps the newest time any read gave.

  • readEdit now also returns newest: the newest edit time any of its reads gave, including the read before the first text. When the reads tie the text to no time (the time moved, or the store could not tell it at the last read), the change still counts as a new edit, as before. newEdit now remembers that newest time instead of none. When no read gave a time, the time remembered stays, as before.

  • Why that time is safe to remember. wacli only moves edited_ts forward. If the store later gives the newest time read, nothing was edited since that read, and the change judged already covered that edit, in one of two ways:

    • with a store that could not tell at the end, its text was read after that time;
    • with a moving time, the change counted as a STOP for all groups.

    In your sequence the newest time is the STOP's. So the STOP's own entry, read with its real time after the START, is a rewrite and changes nothing, and the START stands. A new edit after the START has a later time, so a STOP in it still pauses.

  • When the stored data cannot tell, pausing wins. Take an edit whose time no read gave, because wacli stored it after the last read that gave one. It may be the edit judged or a new one, and nothing in the store tells them apart. A STOP in it therefore pauses once more, even after a START.

    • The operator learns this once, as for an unreadable store. Both notices now end with Až čas zase půjde přečíst, může takový STOP pozastavit odesílání ještě jednou, i po zprávě START, pokud z úložiště nepůjde poznat, že už platil.
    • The moving-time notice now says the time was different or could not be read (jiný nebo nešel přečíst), and the status's last error says the same (wacli's store gave another edit time, or none, at every read).
  • When only the first read gave a time, the intake remembers that time. The text judged was read after it. So if the store later gives that time, the text was that edit's, and its STOP does not apply again. Remembering none, as before, would pause again even then, and no later time is known. If the store later gives a later time, that edit was stored after the first read, so the rule above applies: a STOP in it pauses once more.

  • Newest, not last. While wacli moves the time only forward, the newest time read is the last one known. If a read ever went back, the intake keeps the newest, so a later read with the older time counts as a new edit and the doubt goes to the pause.

  • What this push does not change. Avoiding that remaining second pause would need another rule, for example ordering an edited STOP against the START by the edit's own time rather than by when the intake reads it. That changes the contract that a STOP in an edit counts from when it is read, so it is not in this push.

  • Tests.

    • Your sequence: an earlier edit's time is read, an edit to STOP is stored, the next reads fail, give the STOP's time and fail again, the operator sends START, and the store reads again. Sending pauses once, with one notice. The catch-up after the START reads the STOP's own entry and changes nothing, and the next reply goes to its group. This holds for an id the intake keeps and one it forgot, with and without a restart.
    • The same with a new edit after the START, to another text and back to STOP: sending pauses again, and the next reply is a draft.
    • Only the first read gives a time. A STOP stored before that read pauses once and, read again after a START, changes nothing. A STOP stored right after that read pauses once more after the START. The operator gets one notice.
    • readEdit's newest time in ten cases, among them your reads and a time that goes back, and newEdit remembering it.
    • Changed expectations: two earlier tests now expect the notices' new wording, and one of them the status's. No other expectation changed.

Verification (station-01, a clean clone at 1cbf1ccb with this change)

  • Vitest: 14 files, 300 tests pass, including the intake's five unit files and its e2e.
  • This head's tests on 1cbf1ccb's source: 5 of the 80 intake tests fail there.
    • Your sequence: the catch-up after the START pauses again. The same happens when each variant runs first: an id the intake forgot, a restart, and both.
    • Only the first read: the STOP stored before it pauses again after the START.
    • The edit memory: no time is remembered for a text tied to no time.
    • The two earlier tests: only the new wording.
    • With a new edit after the START, and for the STOP stored after the first read, 1cbf1ccb pauses as well. Those parts pass there apart from the wording.
    • Of the adapter's 15 tests, only readEdit's fails there, as it gives no newest.
  • Mutation check: 159 of 159 caught, 8 of them new for this push and 151 from earlier pushes, 19 of those moved to the changed lines with the same rule broken. Sources were restored byte for byte. The description lists each mutation with its failing test count.
  • Contract tests: 558 pass, 0 fail.
  • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.
  • server/whatsapp-intake.ts is 998 lines.

Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

  1. The required CI check is missing and the PR conflicts with main. GitHub requires Overlay, typecheck and lint, but reports no run of that check for this head; the only check shown is a skipped Macroscope check. GitHub also reports this PR as conflicting: the release runbook's overlay table has competing additions from main and this branch. Resolve the conflict while retaining both entries, then ensure the required check runs and passes on the updated head. Local typecheck and lint cannot replace the required GitHub check.

The previous STOP replay finding is resolved for the reported intermittent-read sequence; the new tests also cover a new STOP and restart. The documented ambiguous case continues to fail closed.

Non-blocking

None.

Order and migrations: #81 is merged and precedes this PR; install the checked wacli v0.25.1 before enabling the intake, then verify the pilot in observe mode before the red team.

— Pablo

…pport bot (DEV-6691)

A support bot in an ordinary MausBot server can now answer in the WhatsApp
groups its operator maps. Plain code receives the Environment's messages from
wacli (a supervised `sync --follow` posts each one, HMAC-signed, to a receiver
on 127.0.0.1), keeps only mapped groups, and wakes the bot only for a mention
of the account, a reply to it, or a batch of unasked messages after 10 quiet
minutes or 20 messages, never while a team member is active. Each run gets one
group's last 40 messages and that group's client note through the group's own
managed webhook, the same queue and UNTRUSTED wrapping as an HTTP delivery.

The intake is the one writer of that webhook's instructions: the group, the
address form, the mode, the output contract, the untrusted-data rule and the
note. They carry no client name, owner login or chat id, which only the guards
read, and an edit made in MausBot is replaced at the group's next run; who the
bot is and its rules belong in its own SOUL.

The bot answers with a JSON contract; anything else, a field outside it (a
recipient, say) included, is invalid and sends nothing. The intake alone
decides where the text goes: to the group that asked, quoting the message, or
as a draft to the operator's direct chat. Plain-code guards draft a reply that
is commercial, committed (a missing commitment counts as one) or unsure, longer
than 1,200 characters, names another mapped group, client or owner login, links
to a repository or Environment outside an allowlist, comes from a run whose
tool log shows a shell, gh, wacli, another tool or a read outside the bundle,
or exceeds 6 an hour, 30 a day per group or 80 a day overall. A read counts
where its path really leads, so a link out of the bundle is a read outside,
and a search counts as inside only while every link in the bundle leads back
into it and no file in it has a second hard link.

The operator's STOP, START, STOP <group> and START <group> pause and resume
sending durably and take effect as they arrive, also while a reply waits for
its send spacing: right before a reply goes to wacli, after that spacing, it
reads wacli's change journal once more and checks the pause again, so a STOP
stored meanwhile applies even when its webhook drops. A command counts from
when the operator sent it: for each group the latest command decides, whether
it names the group or all groups, and a STOP and a START of the same second
leave sending paused, so a command found late, or read again after the intake
forgot it, never undoes a newer one; the operator gets one line saying so. A
START counts only in a message as the operator sent it, never in an edit, a
deleted message or a reaction. A STOP counts in any of them; in an edit, which
wacli stores over the edited message with that message's time, it counts from
when the intake reads the edit, so a START sent before the edit cannot undo
it, also when the edited message is from before the intake's first start. An
edit counts once, by wacli's own time of the edit, which a rewrite of the row
leaves and only a new edit moves on. No wacli command prints it, so the intake
reads that one column of the one row (messages.edited_ts) from wacli's store,
read-only and only from a store at wacli v0.25.1's newest migration, before
and after it reads the text, and judges the text only with a time both reads
agree on, so the two belong to one edit; while the time moves it reads again,
and if it moves at all three reads, the change counts as a STOP for all
groups. For each of the operator's last 1,000 edited messages it keeps the
edit time it judged last, never the text, so a change wacli makes without a
new edit (a sender it normalized, say) never undoes a later START, also after
a restart, while an edit to another text and one back to STOP pause again,
also when one catch-up reads both. While the store cannot tell the time, every
change to an edited message counts as a new edit and the operator is told
once. When the reads tie the text to no time, as the time moved or the store
could not tell it, the intake keeps the newest time any read gave, so that
edit, read again once the store tells, never undoes a later START, while a new
edit to STOP pauses; one whose time no read gave may pause once more, which
the operator's notice says. Escalations and a workday digest at 17:00
Europe/Prague go to the same chat.

Every 5 minutes, and before each reply, a catch-up reads wacli's change
journal from its checkpoint, in store order, and handles every message of the
operator's chat and the mapped groups it has not seen, so a message or a STOP
the best-effort webhook dropped is handled once, however many came after it or
in the same second. A reply waits, neither sent nor drafted, until a catch-up
has read the journal to its end, also the one right before the hand-off. The
follower runs only from a checkpoint: until one is taken nothing is collected
or sent, the status says checkpoint_failed and the operator is told once. The
checkpoint moves past a page only once it is handled, and the ids the intake
keeps cover everything since the last complete catch-up. wacli's journal does
not tell a new message from a changed one, so in the operator's chat every
entry for an id the intake no longer keeps is handled and no command is ever
skipped, and a change to an operator's message it keeps, or to one from before
the first start, is read again, since it may be an edit; every edit is judged
there, its webhook only starts a catch-up. A mapped group takes an entry at or
before the newest time it forgot for a change, and never answers an edit of a
message it handled. The catch-up fails closed: while the journal cannot be
read every reply is a draft, and when the journal no longer reaches the
checkpoint, sending pauses until the operator's START. The ledger keeps 30
days of ids, times and actions; neither it nor the log holds message text.

Off unless OMB_WHATSAPP_INTAKE=1; nothing else changes. A verification fixture
runs the intake only on a fake wacli and test files its caller hands over,
never on the launcher's own OMB_WHATSAPP_INTAKE* settings, and never with a
store. Checked against wacli v0.25.1. Unit tests cover routing, what a run is
given, batching, every guard, STOP/START and their order, a STOP stored during
a reply's spacing, edits in the operator's chat (also of a message from before
the first start, rewritten by wacli across a restart, two edits one catch-up
reads together, an edit stored between two reads of its time, before or after
its text, a time that moves at every read, reads of the time that fail by
turns or after the first, and a store that cannot tell the time), the digest,
the ledger, catch-up from the journal, the checkpoint and restart, and that an
operator's STOP stored by wacli is never lost and applies before any later
group send; the read of wacli's store runs against stores built from wacli
v0.25.1's own schema with synthetic rows; the e2e runs the isolated server
with a fake wacli and the fake engine through the real Claude driver. Breaking
any of 159 guards or rules fails a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty
immakermatty merged commit 086aeb2 into main Nov 1, 2026
@immakermatty
immakermatty deleted the agent/DEV-6691-whatsapp-intake branch November 1, 2026 09:15
@immakermatty
immakermatty force-pushed the agent/DEV-6691-whatsapp-intake branch from 04953e9 to bc36b67 Compare November 1, 2026 09:49

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking

None. The previous conflict is resolved with both overlay entries retained, and the required Overlay, typecheck and lint check now passes on this head.

Non-blocking

The four GitHub vitest shards are still running. Local WhatsApp intake tests, typecheck, lint and release contract checks passed.

Order and migrations: #81 is merged and precedes this PR; install the checked wacli v0.25.1 before enabling the intake, then verify the pilot in observe mode before the red team.

— Pablo

@immakermatty

Copy link
Copy Markdown
Author

Thanks for the review of 04953e92. The blocking finding is resolved in the new head bc36b67f (bc36b67f77485bb678c87be16b48a8ed48b9e52c): the one overlay commit, rebased onto main at 25e5805ff, with the code you reviewed. The checks below ran on station-01, a Linux development server, apart from CI.

1. The required CI check is missing and the PR conflicts with main: resolved, both rows kept, and CI runs on the new head.

  • The conflict. 25e5805ff added its row at the end of the release runbook's overlay table, after the same row as this PR's. Only docs/operations/lazurio-fork-release.md conflicted, in one region: main's row against this PR's row, with nothing of the base between them. Both rows stay, main's first, since the table lists the overlay commits in branch order and this commit now sits on 25e5805ff. server/index.ts, which 25e5805ff also changed, merged without a conflict: main's line there ends the GitHub intake's options and now also passes runs: () => routines!.listRuns(), two lines above this PR's lines.
  • The code is the code you reviewed.
    • git range-diff cdbe94931..04953e92 25e5805ff..bc36b67f: the patch differs only in context lines, main's row now right above this PR's row, and that GitHub intake line in server/index.ts. No line this PR adds differs, and it removes none.
    • The PR's diff per file at both heads: the same 24 files with the same added and removed counts. 22 of them are byte-identical, every file under server/ and scripts/ among them, its tests included, except server/index.ts, whose diff differs only in that context line and its index line. The PR touches nothing under shared/. The runbook's diff has the same changed lines, with main's row as new context.
    • The other way round: from 04953e92 to bc36b67f exactly 25e5805ff's lines change, in its 8 files, and from main to bc36b67f exactly this PR's lines, in its 24.
    • The commit message and author are unchanged.
  • The full check passed on bc36b67f, in a clean clone with a frozen-lockfile install:
    • Vitest: 14 files, 315 tests pass, the intake's five unit files and its e2e among them. The GitHub intake's two files now have 67 and 4 tests, as main has them (300 before).
    • Contract tests: 558 pass, 0 fail.
    • pnpm lint, pnpm typecheck and pnpm i18n:check pass, and the pre-push hook ran them again on push.
    • With the code identical, the mutation check did not run again; the 159 of 159 from 04953e92 still describe this code.
  • CI started on bc36b67f with the push: Overlay, typecheck and lint, the required check, passed, and so did Archive / Archive linux-x64; vitest (shard 1/4) to vitest (shard 4/4) are running.

The description has a short entry for this push, and its CI line and "Before merge" are updated. Your approval of bc36b67f arrived while this reply was being written.

Agent: environment 0f732f59-9f07-474d-abcb-2149bb79f9d5 · T3 thread 996ea181-b716-4e78-8940-a048b7c5d4c4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants