Conversation
…equired, promote-to-main.sh, aggregate tests job - .github/workflows/publish.yml: remove push triggers; add required expected-sha input; verify dispatched SHA matches HEAD; require successful test.yml run for this commit; require platform checks when installer surface changed. - .github/workflows/test.yml: run on preview/main only; add changes job and aggregate tests job so docs-only PRs satisfy required checks. - scripts/promote-to-main.sh: preview-to-main stable promotion with evidence, worktree isolation, PR merge, CI wait, and publish dispatch. - scripts/release.sh: removed. Caller tests and docs updated. - scripts/release-preview.sh: dispatch publish.yml after push with expected-sha. - scripts/require-release-evidence.mjs: add --changed-files-stdin mode; update sensitive path list for promote-to-main.sh. - tests/unit/release-scripts-contract.test.ts: branch policy assertions updated. - tests/unit/electron-version-sync.test.ts: promote-to-main.sh + release-preview.sh. - tests/unit/safe-install.test.ts: release/promote assertions updated. - README.md, structure/infra.md: release script references updated. Refs: #333
Settings schema v4 replaces the single `channel` field with `messaging.enabledChannels[]` + `messaging.homeChannel`, so Telegram, Discord and Slack can take inbound traffic at the same time instead of one at a time. - config: v3 -> v4 migration folds the old `channel` into a one-element enabled list and the same home channel. Each migration marker now keys off the version that introduced it (MULTI_SESSION_DEFAULT_SCHEMA_VERSION) rather than the current schema, so a later bump no longer re-asks a question the user already answered. - runtime: per-channel registry with running/error state, start/stop of a single transport, and a restart that touches only the channels whose enablement actually moved. TransportFns.init returns whether it started. - send: reply routing resolves target -> explicit channel -> home channel, so a reply lands where the message came from. - health: `activeInboundChannels[]` added alongside the existing `activeInbound` scalar, which keeps pointing at the home channel. Old parsers keep working. - surfaces: Settings API keeps a deprecated `channel` alias (with a Deprecation header on writes), `jaw init` gains --channels/--home-channel with --channel as a deprecated alias, manager UI gets a per-channel enablement control. Two integration tests that grepped runtime.ts for `channelSwitched` and `prevChannel !== nextChannel` are replaced with behavioural checks: those identifiers died with the single-channel model, but the contract they protected did not. Refs #334
…sed capability set Two lies removed before the ChannelAdapter contract is built on top of them. Transport start no longer returns a fabricated boolean. All three registrars hardcoded `return true`, while the vendor inits returned early — and silently — on real failures: Telegram when getMe gave no identity, Slack when auth.test failed or the instance was not the attach instance, Discord on lock contention. Every one of those reported "started" and landed in runningTransports. `TransportStartOutcome` replaces the boolean and separates the twelve early returns into what they actually are. Only `failed` is a fault; `not_configured`, `outbound_only`, `not_attach_instance` and `superseded` are states an operator chose or a concurrent call owns. `startMessagingTransport` records a transport error only for `failed`, and the boot log in server.ts stops printing "init failed" at an outbound-only Slack install on every start. `started: true` deliberately means accepted, not receiving. Telegram's poller and Discord's gateway both become ready after init returns — Discord's supervisor even swallows a login failure into a reconnect schedule — so readiness belongs to channel health, not to this value. The comment at each success return says so. Two supporting fixes fall out. Slack's queued follow-up init discarded its own result, so the retry that actually opened the socket returned into a void; it now hands its outcome back to the caller. And Slack refuses to open the socket when the workspace id cannot be resolved, because `slackEventKey` degrades an empty one to the literal 'unknown' and collapses separate workspaces into one dedup namespace. That mirrors Telegram refusing to poll without a bot identity. The capability declaration was also wrong in three places beyond the known `durableOffset`. Discord declared editText and interactiveActions true with no edit call and no component handler; Slack declared interactiveActions true while routing interactive events to a log. The set is now closed and named for behaviour rather than for Telegram's implementation: sendText, editText, deleteMessage, reaction, typing, fileUpload, voice, threads, interactiveActions, durableIngress, replayableTransport, maxMessageChars. `maxMessageChars` was decorative — the real limits lived as separate literals in three chunkers. The chunkers now export named constants and a test binds the declaration to them, so the two cannot drift apart while still reading as verified. Refs #335
…very receipt The closed port every transport is reached through, plus the two value types that cross its boundary, plus a suite that makes the capability declaration falsifiable. ChannelAdapter lists its entire callable surface. A method whose capability is false still exists and answers with an `unsupported` receipt, so a caller can never probe `if (adapter.editText)` and get an answer the declaration disagrees with. Adapters are registered as factories, not instances, because a disabled channel must not import its vendor SDK — discord.js alone costs ~48MB RSS. The factory takes optional dependencies so the conformance suite can run real adapter code against fixtures instead of the network. InboundEnvelope is where vendor Context, Message and SlackEnvelope stop. It requires accountId and a canonical target: without the account an ingress journal cannot namespace an event key, and without the target the core would re-derive per vendor where a reply goes. Three pure normalizers build it, each returning null rather than inventing a value it cannot determine. They deliberately avoid importing the bot modules, which run singletons and open SQLite at import time. DeliveryReceipt replaces per-transport result shapes. `platformMessageId` is nullable rather than optional so "no id issued" cannot be confused with "field omitted", and `unsupported` is modelled as a refusal, not a failure: nothing was dispatched, so `ambiguous` is false and no DeliveryFailure is attached. Slack's silent keyboard downgrade is gone. Sending a keyboard to Slack used to deliver the text with the actions dropped and report plain success, so a caller could not tell fidelity had been lost. It now returns 501 unless the caller opts in with `interactiveFallback: 'text'`, in which case the text is sent and the downgrade is recorded on the result. The request normalizer is an allowlist, so the opt-in is threaded through it explicitly — otherwise every HTTP keyboard send would have become a refusal. The capability matrix in structure/CAPABILITY_TRUTH_TABLE.md is now generated from the declaration and checked byte-for-byte inside gate:truth-table-fresh. The mirror rule that ties the table to the agbrowse repo is narrowed to the browser section, so a messaging capability change no longer implies an edit there. Refs #335
One persistent record of every inbound event, so "already handled" survives a restart the way "still to handle" already does. The three channels had arrived at three different answers: Telegram an offset frontier, Slack a ten-minute dedupe table, Discord a set in memory that a restart forgets. Nothing calls it yet. The channel migrations are separate units because the Slack one reorders a vendor protocol handshake, and that risk should not ride along with a schema. Four decisions worth recording, three of which came out of auditing the plan against the actual database rather than against its description. The connection is injected, never imported. `src/core/db.ts` opens the real database and runs its DDL as an import side effect, so a module that reaches for that singleton cannot be tested against a temporary one. `TelegramUpdateOffsetStore` already takes its connection as an argument; this follows it, and server bootstrap hands the singleton in explicitly. `append` accepts only a validated `InboundEnvelope`. Binding a raw vendor id would be a silent corruption: a numeric Telegram `update_id` stores in a TEXT primary key as "12345.0" while the same id as a string stores as "12345", so one logical event becomes two rows and dedupe stops working without any error. The journal mints its own trace id. This tree has no correlation-id producer — the single `traceId` declaration in it has no consumers — so a NOT NULL column with no upstream source would have pushed the obligation onto every future caller. The retention sweeper is built so it cannot quietly rot. With no child tables yet, a plain `DELETE ... WHERE tombstone_until <= now` would pass every test here and keep passing, then start deleting parents of live outbound attempts the day an outbox lands — a regression authored by a different milestone, which no test in this one could catch. So children register a predicate saying when their rows are finished, the sweep deletes child-before-parent because foreign keys are on, and a boot guard walks `sqlite_master` and refuses to start if any table references the journal without registering one. A test fires that guard against a synthetic child. Completion nulls the payload and sets a tombstone in one transaction instead of deleting the row: the payload is dead weight, but the fact that the event was handled is the only thing a redelivery needs to read. A dead letter keeps its payload, because replay needs the input. Refs #336
First production caller of an M2 normalizer, and the first channel whose restart behaviour comes from the shared journal rather than its own mechanism. The ordering is the whole change. The poller advances its offset only after `handleUpdateThroughFinalDelivery` resolves, so the update is appended and marked processing before the handler runs and completed before the call returns. An append that throws leaves the offset where it was and Telegram redelivers — which is why the journal write goes first rather than alongside. A redelivery that finds its row already present returns without re-running the handler. That is the case the offset alone could never cover: it lives in the same database write that the crash interrupted, so after a restart the offset says "unhandled" while the journal knows better. A handler failure marks the row back to received rather than dead-lettered. Telegram is about to redeliver the update anyway, so the redelivery is the retry; dead-lettering here would invent a manual recovery step for something the transport already handles. Updates the journal cannot identify — kinds carrying no chat or no sender — are still handled as before. They have no conversation to dedupe against, and refusing them would trade working behaviour for a guarantee they cannot use. The poller sees a raw `Update`, not the grammY `Context` that `buildTelegramTarget` consumes, so the routing fields are extracted from the update shapes that actually arrive: message, edited message, channel post, and the message carried by a callback query. Tests assert the ordering through the poller rather than by reading the source: an update runs once and advances the offset, a redelivery does not run twice, a handler failure leaves the frontier untouched, and a numeric update id produces the same journal key as its string form — bound raw it would land in the TEXT key as "12345.0" and quietly become a second row. Refs #336
Auditing M3b turned up a hole that would have lost messages. `admitTelegramUpdate` treated any already-present journal row as a duplicate and returned without handling the update. But a row sits in `processing` precisely because an earlier run died between claiming the event and finishing it — and the offset never advanced, so Telegram redelivers. That redelivery was the message's last chance, and the old code dropped it while letting the offset move past it. The append/claim ordering now lives in `durable-ingress` as `admitIngress` and `settleIngress`, shared by every transport instead of rewritten per channel. Only a `completed` row is a duplicate; anything else is admitted and re-claimed. `markProcessing` accepts `processing` as a source state for the same reason. The test previously built its own copy of the three-call ordering, so it would have kept passing against a parallel implementation while production diverged. It now calls the shipped functions, and the crash-recovery case fails without this fix. Refs #336
Socket Mode acked first, before any work. That is fast and it is what the 3s deadline pushes you toward, but it means an envelope whose record never reached disk is one Slack considers delivered: it will not send it again, and a crash in that window loses the message with nothing to show for it. The order is now gate, durable append, ack, dispatch. A preflight runs before the ack and must reach storage; throwing withholds the ack and recycles the socket, so Slack redelivers on the new connection instead. The 3s budget is still met — the preflight is one local SQLite insert — and the guard that leaves frames un-acked during a reconnect window is untouched. Three cases keep their old shape deliberately. An envelope type we never act on is acked immediately without journaling, because Slack should stop retrying a payload that has nowhere to go and the table should not fill with noise. A connection-local duplicate is acked and dropped, since the work was done and only our ack failed to land. And a caller that supplies no preflight — CLI paths, tests — keeps the previous behaviour rather than silently gaining a dependency on the journal. The preflight runs the same gate the dispatch path runs. Slack sends a `message` copy and an `app_mention` copy of one mention under a shared ts; journaling the copy the gate drops would claim the key and suppress the canonical delivery. `IngressAckPolicy` gains `after-durable-append`. M2 pinned Slack to `transport-first` with a comment saying the ack precedes any work, and that stopped being true here. The policy describes what the transport does, so it moved when the transport did. Tests assert the ordering rather than the end state, because the failure this guards against is invisible in the end state: ack-before-append and append-before-ack look identical once both succeed. Refs #336
…uthfully Discord was the one channel whose memory of what it had handled died with the process. A TTL set filtered duplicates inside one run; a restart forgot everything, so a gateway Resume could replay a message that had already been answered. Messages now go through the shared journal after the gates and the seen-set. The seen-set stays as the hot in-process filter — it is cheaper than a query and it still does its job — and the journal is what survives a restart. Discord reads its account id differently from the other two, and that difference is load-bearing. `client.user.id` is null before READY and can change across reconnects, so it is read per message rather than captured at startup. When it cannot be resolved the message is not admitted at all: handling it would run work whose durability record could not be written, and dropping it quietly is the exact failure this milestone exists to remove. The gateway still holds it and redelivers on the next generation. `discord.durableIngress` moves from false to true. That flag is checked by `gate:truth-table-fresh`, so the generated capability matrix moves with it, and a test asserts the claim against the behaviour rather than against itself: it handles a message, restarts the journal, and requires the replay to be ignored. `InteractionCreate` stays unjournaled. It has no normalizer yet and its consumer is the approval binding in M4, so it belongs there rather than half-built here. Refs #336
Three channels now write every inbound event to one SQLite table, and until now nothing could read it. A dead letter was invisible. A stuck row had no recovery path except hoping the vendor would send the same payload again. jaw messaging ingress lists, shows, and replays those rows. Replay is deliberately small: it marks the row received under a state CAS, records who asked and why in an append-only JSONL, and then stops. Nothing in this process re-runs the handler. The next vendor redelivery is what actually executes it, through the same admitIngress path a crash recovery already uses. Saying "queued" would have implied a worker that does not exist. A completed event is refused unless forced, and --force still fails once completion has dropped the payload — there is nothing left to run. A processing row is refused rather than stolen from a live handler. The CAS exists because a read-then-update here would reset a row another handler had just claimed, and that is a worse incident than a refused replay. Refs #336
…mand substrate /new /stop /approve cannot share a meaning across three channels until the identity they act on exists in one place. That identity is not the process-local spawn owner token in session-persistence.ts — that dies with the process. It is an integer on chat_sessions that survives a restart. The column is additive. Existing databases get generation 0 through the same PRAGMA table_info path every other chat_sessions column already uses. Rebinding a conversation onto a session has to delete a leftover UNIQUE row in the same transaction; otherwise two remotes would share one session or the upsert would throw. access-policy and RemoteCommandContext have no production caller yet. They are the same kind of substrate the ingress journal was in M3a: tested, default-deny, unused, so the first transport that needs them does not invent a third shape. Refs #337
… gate The six names are now on the three catalogs. Only the new privileged four go through RemoteCommandContext and access-policy. Putting default-deny in front of executeCommand would have taken /status and /help to zero on every live bot; those keep the channel allowlists they already had. /stop interrupts the current conversation scope, not the process. /queue list|drop reads that same scope. /deny is the public name for the existing cancel transition and still needs a digest. Slack cannot ship /status in its app manifest — Slack reserved the word — so the starter set adds the other five and leaves /status to the shared parser. Refs #337
…st on the wire A button cannot ship jti+digest: Telegram and Discord will truncate it, and the digest would sit in every transcript. The store now issues an opaque id bound to actor, conversation, session generation, and approve|deny. The text commands still require a digest. Restart still voids the in-memory store the same way it always did. Native keyboards are not routed yet. Slack interactive remains logged and dropped; this commit only makes a later renderer have somewhere honest to point. Refs #337
The previous commit shipped the store API without the SoT sentence. Native UI is still not routed. Refs #337
The pending-dispatch DM was a wall of jti+digest and a request to type it back. Telegram already had a callback router for elicitation, so the same operator now gets Approve/Deny buttons whose callback_data is only an opaque id. The digest never leaves the store. Slack and Discord stay text: their send helpers cannot carry actions yet, and pretending they can would be the silent downgrade we already banned. The delivery test still sees one Telegram DM. The keyboard rides on that same sendMessage. Refs #337
Telegram already had Approve/Deny. Discord operator DMs were still a wall of digest text because sendDiscordDm only posted content. The same opaque appr:/aprd: ids now ride on that one DM as an action row, and InteractionCreate routes the tap through handleApprovalCallback. Slash commands are unchanged. Slack stays text until Block Kit send exists. Refs #337
Telegram and Discord already had Approve/Deny. Slack still posted a wall of digest text because sendSlackText could not carry blocks, and because String(user) on an interactive payload is "[object Object]". The operator DM now attaches the same opaque appr:/aprd: ids as Block Kit actions. Events API text approve still works with a string user id. Generic Slack keyboard send stays refused — that capability is still false. Refs #337
The journal remembered events, not which conversation generation they belonged to. After /new or /reset the vendor can still redeliver, and admitIngress would have claimed the old row again. Rows now stamp session_generation. A redelivery whose generation no longer matches is refused and not marked processing. A crash mid-flight with the same generation is still admitted — that is still the message's last chance. The lookup goes through buildRemoteBindingKey, not envelope.conversationKey. Those strings are not the same thing. effect-once.ts is still not a file. Refs #338
The journal already minted trace_id. A second 128-bit id at admit time would have given operators two names for the same delivery. log.event now writes one JSON line onto the existing ring, and admitIngress enters ALS with the stored journal identity so a later event in that turn can stamp it. Metrics and a 22nd gate are still later work. Refs #339
Operators had transport chips and no journal numbers. /api/health now carries additive ingress counts and this-process metrics. Labels are only channel, state, and result — actor and event ids stay on the trace. jaw messaging doctor reads the same SQLite file locally; it cannot see the server ring, and pretending otherwise would be a lie. Refs #339
The three channels already had contract tests. Nothing in gate:all required them, so a deleted fixture would still publish. The 22nd gate re-runs those suites and writes a functional-certified artifact. It refuses exactly-once wording and refuses to call this release-certified. The twelve chaos cases are still not here. Refs #339
In-memory unit tests never closed the database. After a real process death the next boot opens the same jaw.db path. Completed stays already_handled. Mid-flight keeps the stored trace_id. A later generation is stale and is not claimed. Refs #339
append looked, then inserted. A second connection that lost the race used to throw SQLITE_CONSTRAINT and look like a broken delivery. The primary-key clash is now the same duplicate the first connection would have returned. A locked or full database still throws, so Slack can refuse ACK instead of pretending the event landed. Refs #339
Telegram already waited and retried the same send. Slack returned ratelimited immediately, so a later operator retry could post chunks that had already landed. A short Retry-After now waits and posts that chunk once more. A long pause and not_in_channel still fail after one call. The chunk loop does not start over. Refs #339
admitIngress entered the messaging trace and nothing downstream read it, so an operator could see an inbound event and its outbound reply as two unrelated lines. sendChannelOutput now stamps outbound.send with the same traceId. Outside a trace the field is simply absent — a send-side id would be a second name for one delivery. Refs #339
At-least-once ingress means a redelivery can reach a reset, an approval, or an employee dispatch a second time. effect_claims is where the second arrival learns the first already happened: a claim is owned by an id and a token, and completing it requires both. An expired lease is deliberately not an outcome. The owner may have died just before or just after the external write, so expiry only lets a new owner take the claim; deciding what happened belongs to a per-effect reconciler, and manual is the honest terminal state until one does. Non-terminal claims block the ingress sweep, so an unresolved effect cannot lose the event that caused it. Wiring the six production effects is the next unit. Refs #336
Reserve-before-send means the outbound row exists before the vendor call, so a crash between dispatch and receipt is a row in ambiguous instead of a gap. ambiguous is terminal for the automatic path — nothing in-process retries it, because retrying is exactly the duplicate the state exists to prevent. Refs #336
|
Important Review skippedToo many files! This PR contains 123 files, which is 23 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (123)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Messaging Runtime v2
Closes #332, #333, #334, #335, #336, #337, #338, #339.
What shipped
Release gate status
Not included (deferred)
Wiring the 6 production effects through effect_claims (effect substrate is landed)
Routing production sends through the outbound outbox (outbox substrate is landed)
Usage:
jaw messaging ingress list [options] List journaled inbound events
jaw messaging ingress show Show one event
jaw messaging ingress replay --reason
jaw messaging ingress audit [--limit 20] Show replay history
jaw messaging doctor [--json] Journal counts + this-process events
List options:
--channel telegram|discord|slack
--state received|processing|completed|dead_letter
--older-than 30m|24h|7d
--limit 50
--json
Replay options:
--reason Required. Recorded in the audit trail.
--force Replay a completed event. Refused by default: its effects
already happened, and running them twice is worse than not
replaying at all.
A successful replay only marks the journal row received. Nothing inside this
process re-runs the handler. The next vendor redelivery is what actually
executes it — Telegram's offset, Slack's retry, Discord's resume. and
Usage:
jaw messaging ingress list [options] List journaled inbound events
jaw messaging ingress show Show one event
jaw messaging ingress replay --reason
jaw messaging ingress audit [--limit 20] Show replay history
jaw messaging doctor [--json] Journal counts + this-process events
List options:
--channel telegram|discord|slack
--state received|processing|completed|dead_letter
--older-than 30m|24h|7d
--limit 50
--json
Replay options:
--reason Required. Recorded in the audit trail.
--force Replay a completed event. Refused by default: its effects
already happened, and running them twice is worse than not
replaying at all.
A successful replay only marks the journal row received. Nothing inside this
process re-runs the handler. The next vendor redelivery is what actually
executes it — Telegram's offset, Slack's retry, Discord's resume. operator CLIs
Reconcilers for expired claims
Live 3-channel canary
Chaos matrix (12 cases from 060 §5)