Skip to content

Slack channel ​

Verified message_deleted events enter the existing Socket Mode receiver before Bolt's automatic event acknowledgment. The configured bot workspace and exact retained source address determine cleanup; rendered text and previous-message user claims do not. Copied private input and queued snapshots must be durably erased before success acknowledgment. Unknown erasure is held, while non-deletion events preserve their normal handler and acknowledgment behavior. Canonical work and original owners are not replaced.

CriterionEvidence
A verified source deletion removes its retained copy and queued input before ACK, retains the original owner, and does not delete or stop unrelated work[unit] src/channels/slack.test.ts::Slack source deletion::erases the verified native copy and queued snapshot before acknowledging, without replacing its original owner

Slack is a pure transport: it turns Slack events into IncomingMessages, renders replies/status back, and adds nothing else. All Slack-specific behavior — triggers, receipts, formatting, attachments — lives here.

  • Code: src/channels/slack.ts, src/channels/slack/ (the adapter's concerns that stand apart: attachments.ts — which files reach the model and their budgeted downloads; requester.ts — who asked: the sender, or the person an app relayed for (item 13); threadTurns.ts — the pure thread-page-to-turns mapping history() and the conversation reader share; references.ts — the conversation reader of record 0037: this workspace's permalink grammar, the closed fresh classifier, the text-only fetch, the requester's standing; statusCard.ts — the card's Block Kit frame and the live-card record; dedupe.ts — the handled-set and the redelivery guard; lookups.ts — the cached display-name, email and team-URL reads and the permalink), src/core/dispatch/messages.ts (turnContent: the attachment assembly of a model turn), src/channels/slackTriggers.ts (pure trigger gating), src/channels/slackCatchUp.ts (reconnect catch-up + orphaned-card sweep), src/channels/slackCatchUpStatus.ts (catch-up status record + bot-scope check), src/channels/mrkdwn.ts, src/channels/slackEscape.ts, src/channels/health.ts (/healthz body), src/channels/processMetrics.ts (/healthz.process), src/core/drain.ts (drain deadline + catch-up window minimum), deploy/cloudflare/preflight.mjs (bot deploy preflight), src/deploy/restart.ts + deploy/cloudflare/worker.ts (deploy restart: /admin/restart authorization + refusal decision; the Container DO stop())
  • Docs: How a request flows, Worker topology, AGENTS.md invariant 1
  • Tests: src/channels/mrkdwn.test.ts, src/core/dispatch/messages.test.ts (attachment assembly), src/channels/slack.test.ts, src/channels/slack/attachments.test.ts, src/channels/slack/threadTurns.test.ts, src/channels/slack/statusCard.test.ts, src/channels/slack/dedupe.test.ts, src/channels/slack/lookups.test.ts, src/channels/slack/requester.test.ts, src/channels/slackCatchUp.test.ts, src/channels/health.test.ts, src/channels/processMetrics.test.ts, src/core/drain.test.ts, deploy/cloudflare/preflight.test.mjs, src/deploy/restart.test.ts, src/deploy/restartRun.test.ts, src/deploy/liveGate.test.ts, src/channels/slack/references.test.ts, src/channels/slackConfirmClick.test.ts (item 14: the action intake's Bolt-level wiring)

Behavior ​

  1. Triggers: (a) channel mention (@switchboard …); (b) DM — the app's Messages tab is enabled by the manifest (features.app_home.messages_tab_enabled, not read-only; docs/public/slack-app-manifest.yaml), since a Slack app without it refuses every DM at the composer and the message.im subscription never fires; (c) a thread follow-up in a thread the bot participates in, when the intake gate admits it (item 15) — no re-mention needed. Top-level channel posts still require a mention; a bot's un-mentioning message and non-file_share subtypes are ignored (no bot loop), while a bot's post that mentions the bot is handled like anyone's — its requester resolved per item 13. Participation is re-derived from the thread itself (threadIfBotInIt), never from in-memory state — and the thread page that check reads is handed to SlackIO.history() (SlackEvent.thread), so a follow-up costs one conversations.replies, not two identical ones; the mention path, which has no such check, fetches in history() as before.

  2. Acceptance receipt: at every verbosity, the moment a message is accepted for handling — for an unmentioned thread follow-up, once the intake gate has admitted it (item 15) — the bot reacts 👀 (eyes) on it, before any model/tool work; quiet keeps this receipt, including replies folded into an active run, while suppressing routine text and progress narration. Fire-and-forget: missing reactions:write scope or a duplicate reaction logs and never blocks handling. The adapter resolves verbosity before any visible receipt. Everything it does before dispatch() (the guard, this ack, the catch-up note, the downloads, the display names) is one slack.receive span with dedupe (fresh/duplicate), caughtUp and files attrs; a dropped redelivery is a root with that one child and no run (tracing.md item 18). The message carries receivedAt (our clock at entry) and originAt (the message ts), so the card can say queued 6m 00s before we saw it after a restart. A reconnect's delay note is routine lifecycle narration too: it is emitted at verbose and debug, never at quiet.

  3. Status card: one bot message per run, edited in place — spinner + agent + elapsed seconds headline (the model joins the label at verbose, routing-and-config.md item 28); body is the agent's own ✓/✱/○ checklist (via update_status). A ticking card means the run is alive; error paths mark ❌. Slack's native shimmer status is verbose/debug narration: it is never set or re-upped at quiet. At those higher levels the shimmer is re-upped every 75s during long runs. The shimmer is one Slack-side status per thread, so with two verbose/debug runs in one thread (a plan runner's coding child finishing as its review child starts) the newest run to start owns it: a run that finishes while the shimmer is its own clears it at once — the thread stops saying the bot is working for that run the moment its card closes to its done state — while a run whose sibling started during its finish still closes its own card to done but skips the clear, never wiping the live sibling's status; the sibling sets its own phrase with its first frame, and only the owner's 75s re-up speaks. A quiet run creates no shimmer owner or timer, yet its card, status budget and terminal cleanup follow the same lifecycle. The closed card's total is the run's own — the card shell is frozen at the registry's finish stamp (CardShell.freeze, tracing.md), so a close painted after a slow reply reads the same seconds as the run page and the index — and a run whose loop threw closes its card after the finish, from the same stamp. The card's clock is the request's receipt, and through setup a 5 s heartbeat repaints it with the step in flight — ◐ *coding* · 42s — attaching the workspace… — until the run loop's own heartbeat takes over; every close carries the request's elapsed time (📦 … · not started (repo access) · 12s, ❌ setup failed · … · 184s), and when the card's gate passes the shape line (32s getting ready · 2m 30s thinking · …) and the queued caption lead the closed card's detail above the checklist at debug (tracing.md item 18). A pipeline's card prints each unit's standing in the user's words (record 0066; agent-ship.md item 12a): the ending word merge-ready or round cap reached and the round-outcome word checks failed, through endingWordOf/roundOutcomeWordOf (src/core/pipelineStanding.ts), never an internal token like merge_ready.

  4. Formatting: agents write standard Markdown; the adapter converts to mrkdwn (bold/strike/headers/links/bullets) and never rewrites code (fenced or inline). Asterisk emphasis renders bold whichever dialect the model wrote — **x** collapses to *x* and a single *x* is already mrkdwn bold — because mapping *x* to italic (standard-Markdown semantics) made the same verdict line arrive bold or italic depending on the model's dialect of the moment; _x_ is the one italic form. This also matches the ingress reading (humanizeMessageText treats Slack *x* as bold). Valid native HTTP(S) links (<url|label> or <url>) render as links through the same URL encoding and label escaping as Markdown links; query ampersands and fragments survive. Link and prose entities (&amp;, &lt;, &gt;) decode once before escaping, and valid HTTP(S) Markdown destinations may be angle-wrapped. Encoded control syntax stays inert; code retains literal entity text. Code links remain literal; non-HTTP(S) or malformed native links, mentions and broadcasts remain escaped. Replies over 3500 chars are chunked at line boundaries.

  5. Attachments: files on the triggering message and thread history are downloaded (needs files:read) and passed to the model, budgeted and spent newest-first over thread history. Within one message the downloads run concurrently (candidates are chosen first — type, declared size, count budget — then fetched together, then the byte budgets are applied in the original order, so the file that gets cut on overflow is the same one the serial loop cut) and the image and document passes overlap; messages stay sequential because each one's spend decides the budget left for the next. Two families, each with its own budget:

    • Images (image/jpeg|png|gif|webp): ≤10/message, ≤5MB each; thread-wide ≤20 images/24MB. Sent as provider-native image blocks.
    • Documents — PDFs and text/code/CSV/log files (text/* plus a small allowlist of text-ish mimetypes; generic types resolved by filename extension): ≤10/message, ≤10MB each; thread-wide ≤20 docs/32MB. A PDF rides as base64 to a provider-native document block (Anthropic; @anthropic-ai/sdk 0.39 supports it in the stable Messages API); OpenAI-compatible endpoints get an inline-text placeholder naming the file (their binary-PDF support is inconsistent). A text/code file is decoded to UTF-8 and inlined as a fenced text part labeled with its name — provider-agnostic, no SDK dependency.
    • Secret-file denylist (defense against credential exfiltration to the model, including third-party OpenAI-compatible providers): a file whose name looks like credentials, a private key, or secret config is never inlined — it is skipped-with-note and its bytes are never downloaded or decoded into the prompt. The denylist overrides text classification (checked before the mimetype/extension allowlist), because the whole risk is a secret file whose type/extension otherwise reads as harmless text. Matched on the filename, case-insensitive, independent of path.extname (which returns "" for dotfiles like .env): .env in any position (config.env, .env.local, …), id_rsa*, credentials.json, *service-account*.json, *-key.json, and the extensions .pem .key .p12 .pfx .npmrc .netrc .ini .cfg .conf. Relatedly, application/json and .json/.jsonl are not inlinable-by-default (JSON is a common secret container), so a JSON file is never "fair game" just by its mimetype or extension.
    • Skipped attachments (unsupported type, secret-file denylist, oversize, over-budget, failed download) are named to the model so it never claims a file "didn't come through". A file is only reported skipped when it is neither a usable image nor a usable document.
    • On a generated hosted ship task, an accepted inline image/document keeps its Slack by-reference source beside its bytes and is persisted once as the generated unit's stable seed event. The coordinator's ordinary attachment fold gives the first coding child both forms: the provider media and a staging reference whose original zero-based Slack slot puts the file at attachments/0-<name>, appends that path to the prompt and records the file in the artifact catalogue. The seed is consumed only after that child registers; a failed spawn keeps it for retry, a replay stages no second file, a review spawn gets none, and a later coding round gets none after successful consumption. The Worker client applies the event cap before transport, so valid provider-sized media over the durable cap becomes the existing dropped-attachment note instead of HTTP 413; a triggering message without accepted media changes no hand-off bytes.
  6. Display-name resolution: the adapter resolves the channel's name (conversations.info, needs channels:read/groups:read) and the sender's display name (users.info, needs users:read; profile.display_name → real_name → handle) — and, with users:read.email, their email (resolveUserEmail, uncached), which the costs page's me toggle and the MCP connect ticket match the Access identity by and passes them to the core as IncomingMessage.channelName/userName (for the live-view run label), plus sourceUrl — the message's Slack permalink (slackPermalink: <team url>/archives/<C>/p<ts> with the thread qualifier for a reply), built from the auth.test URL resolved once per process — so the run page's Request block can link back to the thread (live-view.md item 13). Best-effort and cached (module-level Maps, one API call per new id, bounded to 1000 entries FIFO): a lookup failure leaves the field undefined — the core falls back to the raw id — and never delays or fails a dispatch. The core stays channel-agnostic; these are optional hints.

    • 6a. Channel visibility for authorization (authorization.md item 7): SlackChannelDirectory (src/channels/slackChannelDirectory.ts) implements the core's ChannelDirectory seam over the same conversations.info call — is_im / is_mpim → dm, is_private → private, else public — and the bot wires it as the dispatcher's channel facts (src/index.ts), so every run is stamped with its channel's visibility and a public channel's runs are readable by everyone while a private channel's or DM's stay grants-only. Unlike the name lookup this one is a fact the policy relies on, so it fails closed: any error, missing scope, or empty reply is unknown (grants-only, never public), remembered for the TTL (ten minutes, bounded to 1000 channels) and logged once per channel per window under [authz]; a slack:D… id is dm without an API call; non-Slack ids are the static mapping's. Scopes: channels:read for public channels and groups:read for private ones — both already in REQUIRED_BOT_SCOPES, so the startup scope check (item 7) names a missing one; the bot receives no group-DM events (message.mpim is not subscribed), so mpim:read is not needed and a missing scope only ever degrades to unknown, never a crash. The dispatcher waits at most CHANNEL_DIRECTORY_TIMEOUT_MS (1.5 s) for the answer and otherwise stamps unknown — one cold conversations.info per channel per TTL is the whole cost on the message path. The same directory supplies membership (authorization.md item 7): channelsOf(slack:U…) pages users.conversations for the person (public and private channels the bot is also in, archived excluded; the same two scopes), cached per person for MEMBERSHIP_TTL_MS, and the bot keeps it fresh from the events the manifest subscribes — member_joined_channel / member_left_channel forget that person (everyone, when the member is the bot itself), channel_left, group_left, channel_archive, group_archive, channel_deleted, group_deleted and every socket connected forget everyone — so the TTL is only the bound on a missed event. An installation whose app predates those events still works: its cache simply ages out on the TTL alone.
  7. Reconnect catch-up: Socket Mode does not queue or replay events while the app is disconnected, so every bot rollover (deploy → graceful drain closes the socket → cold start) was a silent blackout — mentions posted in it got no 👀, no run, and the caller waited forever. When catch-up is enabled (the default), on every websocket connected (first start and each reconnect) the adapter scans every channel the bot is a member of (users.conversations, needs channels:read/groups:read) and re-dispatches what it never saw, through the same handle() as a live event (👀, status card, run). Slack is the durable "was this handled" record (invariant 6 — no persisted last-seen ts an ephemeral container would forget): a message counts as handled when the bot has posted in its thread after it (a status card or answer), or it carries the bot's own 👀 reaction and is younger than ACK_GRACE_MS (30 s — the card is on its way). A 👀 alone on an older message is a run that died between the ack and its card (a rollover can kill the process seconds after it acked a thread reply; treating 👀 as terminal would make the reply vanish) and is re-run (the ack can be lost to a missing reactions:write; a status card cannot). A bounded same-process seen-set (channel:ts, 5000 FIFO) additionally stops a message that arrived live and appears in the scan from running twice. Selection mirrors the live triggers exactly via the shared slackTriggers.ts: a top-level message needs a mention; a thread reply needs a mention or a bot-participating thread; bot messages and non-file_share subtypes never count. Window: messages inside the last 30 min (slack.catchUp.windowMinutes, default DEFAULT_WINDOW_MS) — older unanswered mentions are left alone, because re-running a stale request is worse than the human re-posting it. The window is bound to the drain: the graceful drain closes the socket the moment SIGTERM arrives and holds the container until in-flight runs finish (DRAIN_DEADLINE_MS = 15 min, src/core/drain.ts), and Cloudflare boots the replacement only after exit — so a deploy over a run in flight blacks Slack out for the run's remaining duration, and the catch-up on the next connect is the ONLY recovery for mentions posted in that gap. The default window is therefore derived from the deadline: DEFAULT_WINDOW_MS ≥ MIN_CATCH_UP_WINDOW_MS = DRAIN_DEADLINE_MS + COLD_START_ALLOWANCE_MS (15 + 5 = 20 min), asserted at module load and by a unit test. A configured windowMinutes below that minimum (or non-positive) is kept as given — never clamped — and warned about at startup naming the drain deadline (catchUpWindowWarning). Keeping the socket open during the drain was rejected: a mention accepted at minute 14 would be killed at the 15-min cap with a frozen card, whereas the catch-up re-runs it intact. A replayed message says so in its thread (a request that sat minutes through a drain with no 👀 is indistinguishable to the caller from being ignored): the catch-up hands handle() the event with caughtUp: true, and before the run starts the thread gets ⏱ Picked up <N min | under a minute> after it was posted: the bot was restarting … Handling it now — no need to re-send. (catchUpDelayNote, pure; a negative/clock-skewed delay renders as "under a minute"). Best-effort like the 👀 ack — a failed note is logged, never fails the run. Thread parents are scanned 7 days back (≤5 pages of 200) so a follow-up in a days-old PR thread is found; replies are fetched only for threads whose latest_reply is inside the window, and paged in full (≤5 pages of 200) because Slack returns replies oldest-first — a single page of a long thread would drop exactly the newest, in-window messages. Scope and bounds (each fails toward a message staying un-run, with one bounded exception toward a re-run, noted below — never toward a silent drop): DMs are not caught up (only public_channel/private_channel; catch-up is about channel mentions and im:* scopes are off in the recommended rollout); a channel with >1000 top-level messages in 7 days, or a thread with >1000 replies, is truncated at the page cap — in such a truncated thread an acked-and-answered message older than the grace whose bot reply sits on a dropped page is re-run (its 👀 is not terminal; >1000 replies inside a 30-min window is the accepted trade-off); a broadcast reply whose parent is older than the 7-day lookback is never seen; and a caught-up run that dies before posting anything is re-dispatched on each reconnect inside the window regardless of the 👀 (with or without reactions:write — an ack past the grace is by design not a receipt), bounded by the window. Missed messages are dispatched oldest-first; a channel whose scan fails is logged and skipped, never blocking the others or the connect. The act phase reads the intake receipt before handle() (actOnMissedMessage in src/channels/slack.ts; item 15's catch-up half, record 0058): a candidate carrying a mention — including an edit that gained one, which arrives live as a subtype the live path skips and is caught up here inside the window — and one in an always thread are dispatched exactly as today, no read; any other unmentioned follow-up in a bot thread gets its stored receipt read first — a silent row marks the pair seen and is counted under silenced on the slack.catch_up root (read, never re-run); an addressed row dispatches the replay with SlackEvent.intakeDecided, so the gate never decides it twice; and no row (a failing read decides as if none) runs decideIntake over the thread's tail the scan already paged (MissedMessage.thread, the newest twelve turns, with the same evidence the live gate assembles) and proceeds by the stored verdict — a fresh ordinary silent is counted and marked seen like a stored one, while a fresh typed provider failure posts its one safe sentence before it is counted and marked. The same-process seen-set is re-checked AFTER the read (the scan's alreadyHandled ran at scan time and the read awaited since), so a live claim that landed between the scan and the act is skipped, never run twice. A thread_broadcast reply is never a candidate (a non-file_share subtype, in the thread and in the channel echo), and a deleted message is simply absent from the fetched history. Only the hand-off — the read and the verdict — is awaited; the dispatch through handle() stays fire-and-forget like a live event. The scan is two phases: READ — channels are scanned with bounded concurrency (CATCH_UP_CHANNEL_CONCURRENCY = 4 channels at once; within a channel CATCH_UP_THREAD_CONCURRENCY = 4 replies fetches at once; src/core/mapLimit.ts), so a busy workspace's scan costs the longest scan, not the sum of every round trip — this runs on every reconnect while a caller may be waiting for a receipt; then ACT — in the original channel order, so re-dispatch order is exactly the serial loop's. Default on; slack.catchUp.enabled: false skips the catch-up scan but keeps the connected and disconnected listeners for socket status and required-scope diagnostics. Observable without container logs: the container's stdout is not in Workers Logs, so a scan that cannot run (the token lacking channels:read/groups:read makes users.conversations fail missing_scope and the whole catch-up a silent no-op) must show somewhere else. Each scan records its outcome in an in-process, live-only record (slackCatchUpStatus.ts — a restart starts empty and the next connect refills it; not durable state, so invariant 6 holds): lastRunAt, channels, missed, skippedChannels (per-channel scan failures), and error when the channel listing — or the auth.test before it — failed; a clean scan clears error. On the first connected the adapter also compares the token's granted scopes (response_metadata.scopes on the auth.test result) with the adapter's required set (REQUIRED_BOT_SCOPES: the channel, DM, file, reaction and user scopes in src/channels/slackCatchUpStatus.ts), logs any missing ones at error level and keeps them as missingScopes (absent when none, or when the grant is unknown — advisory, never a false alarm). GET /healthz carries the record as catchUp with undefined fields omitted, and the bot deploy preflight (item 8) prints a WARNING — never a refusal, the deploy may be the fix — naming catchUp.error and the missing scopes. The pass is one slack.catch_up root on the span log (tracing.md item 20), its counts as attrs — channels, missed, silenced, orphans, skipped.

  8. Rollover-safe status cards: a wrangler deploy in deploy/cloudflare/ rolls the bot container. Cloudflare's rollout sends SIGTERM and allows up to 15 min before SIGKILL, and the bot's graceful drain (src/index.ts) uses that window to finish in-flight runs — so one deploy on top of a run is survivable. A second deploy while the first is still draining replaces the draining instance at once: the run dies, its card freezes mid-spin, and the run vanishes from /runs (the registry is in-memory) — an invisible state where the reader cannot tell "slow" from "dead". Three layers, each independent: (a) Deploy preflight — npm run deploy runs preflight.mjs first. Since the run ledger's handoff (run-history item 39) it no longer refuses for runs in flight: GET /healthz reporting inFlight > 0 or draining: true is a WARNING naming the count and the handoff (a resumable run continues on the next generation under its own card; a ship pipeline still in flight is what the drain warning names), and the deploy proceeds — nobody waits on a run. It still refuses while wrangler containers list --json shows the switchboard-switchboardserver application in a non-settled state (provisioning/updating/unknown — a rollout still in progress; a second rollout landing on one in progress kills the draining run) — deploy all retries every minute up to its budget and then fails by name (preflight still refusing after N min (…) — NOT deployed; re-run the deploy once it clears (a CI job: gh run rerun RUN_ID --failed), or --force …, preflightGaveUpLine), never a deploy over what refused — and fails closed: unreachable bot, a bare-ok body (a Worker predating the preflight), an impossible inFlight, a wrangler failure, or an app missing from the listing all refuse; SWITCHBOARD_DEPLOY_FORCE=1 is the explicit, warned bypass. deploy restart's decision (decideRestart) follows the same rule: runs in flight and a drain under way warn, only the fail-closed cases refuse. A full bot upload reads the named container application's version and configuration.image before Wrangler runs, captures Wrangler's resulting application target, and then uses the kind: "bot" gate (decideBotLive) to require both control-plane and serving evidence. When Wrangler printed an application target, the named application must report that exact image and a version greater than the pre-upload version; an unreadable baseline can never prove advancement. A Worker-only upload with no application change printed keeps the current application. In both cases public /healthz must be ready, non-draining, and serve the deployed full build.commit exactly — a prefix, another commit, missing identity, or an exact-commit container that is draining keeps waiting until the deadline. The bot gate does not read pre-upload health or use startedAt; deploy restart remains the separate path that proves a later process start on the unchanged application target. (b) Drain is visible — on SIGTERM the process sets a shutdown notice and every live card's heartbeat frame carries · ⏸ deploy in progress — this run continues through the bot restart; the closed card never does. Since the handoff (run-history item 39) the notice tells the truth for both kinds of run: a resumable run is handed to the next generation and continues there under the same card, a ship pipeline finishes here before the exit. /healthz is JSON ({ok, inFlight, draining, drainDeadlineMs, build, slack, process}, plus — while draining — drainStartedAt and held, the registry-active runs not handed off as {id, why} rows: what the drain actually waits for, which inFlight (the dispatcher's count) can miss — a registry row whose dispatcher-side run is gone reads inFlight: 0 while holding the drain for its full deadline — and generation — the run ledger generation this process writes under, run-history.md item 35 — when the ledger is on) so the preflight can read the same facts; process is {rssMb, heapUsedMb, eventLoopLagP99Ms} from src/channels/processMetrics.ts — the bot is one Node process and under many concurrent runs it is the first thing to fail, so the load harness (load-harness.md item 12) reads memory and event-loop lag here, the lag being the p99 over the window since the previous poll (the Worker's keep-alive and the deploy wake check only the HTTP status). slack is the Socket Mode state (src/channels/slackSocketStatus.ts, in-process and live-only like the catchUp record): {connected} plus, once known, since (ISO of the last transition) and connects (lifetime count for this process — 1 is the boot handshake, more means reconnects). The HTTP server starts BEFORE the Slack handshake, so on a cold start /healthz legitimately answers slack: {connected:false} until the socket lands — which is exactly how the listen-before-connect ordering is observable from outside (no container stdout needed), and how a silently dead socket (HTTP fine, bot deaf) shows off-box. build is { commit, builtAt? } — the commit the image was built from, stamped by deploy/cloudflare/write-build.mjs into a gitignored build.json that npm run deploy writes and the Dockerfile COPYs (optional glob: an image built by hand reports commit: "unknown"; a dirty tree gets a -dirty suffix). It exists because deployed ≠ live: after wrangler deploy the old container can keep answering /healthz while the independently managed application still names its retained image, so npx tsx src/cli.ts deploy all treats the bot step as done only when decideBotLive sees the expected application target and version advancement (when an application change was printed) plus ready, non-draining /healthz serving the exact full deployed commit. Every missing fact is a waiting reason; at the shared live deadline the last reason becomes deployed, not live and the command exits non-zero. (c) Orphan sweep — the reconnect catch-up (item 7) also scans, in threads active within the last 2 h, for the bot's own cards still wearing a live glyph (the spinner or the 👀 setup card — LIVE_CARD_PREFIXES, kept next to the glyphs in the dispatcher) whose run is live nowhere this process knows of — not driven here, and not leased on the run ledger by another generation (run-history.md item 36, isLiveCard) — and closes each with chat.update as ❌ interrupted · <label> · <elapsed> plus a detail saying the bot restarted mid-run and to re-send. Slack is the durable record here too (invariant 6): the card is the only trace of a run whose process died. Cards this process is driving (ownsLiveCard) are never touched, so a websocket reconnect without a restart cannot close a running run's card; a card older than 2 h is left alone rather than mislabeled; a failing close is logged and the sweep continues. The /runs index does not resurrect the dead run — the card is the user-facing state. (d) Restart without a build (deploy restart): freeze the selected account, application and admin/health/state endpoints once. Before POST, require the CLI’s exact parser identity to match the installed owned slot and the actual unique running native singleton; compare the current candidate source with that slot’s version/digest. Legacy, foreign, missing or unknown consumer/native evidence refuses even under force. A rotated secret goes live through the same /admin/restart route and a later healthy process; final observation must use the same frozen target and retain the exact application/config tuple. The reported generation is base-<full-CID> vN. Neither restart nor a private snapshot establishes initial migration, restoration or custody authority.

  9. Redelivered events are deduped — one message, one run: Slack re-delivers an event whose original delivery was never acked. The shape: a mention posted seconds into a deploy drain is answered by the reconnect catch-up (⏱ note, card, answer) and then re-delivered by Slack minutes later through the live socket path — a bare [event]+[run] for the same channel:ts with no [catch-up] line and no ⏱ note, while /healthz's catchUp.lastRunAt still reads the earlier scan. Unguarded, that path runs everything unconditionally — handle() marking the same-process seen-set is not enough when only the catch-up scan ever reads it — and the duplicate run repeats every side effect (a second review verdict, a second auto-approve). The guard (dedupeDelivery, first thing in handle()): (a) same-process — the (channel, ts) pair is already in the seen-set (handled live or by this process's catch-up) → dropped, [redelivery] … dropped: already handled in this process, no API call; the pair is claimed before the guard's only await, so concurrent deliveries of the same pair serialize onto check (a) however the awaits interleave, and catch-up's onMissed re-checks the set after its receipt read, just before dispatch (its scan-time alreadyHandled read may predate a live claim). (b) cross-process (the first handling belonged to a container that is gone): a live delivery older than STALE_DELIVERY_MS (60 s — live deliveries arrive in seconds; only redeliveries and blackout flushes are older) pays ONE conversations.replies fetch and is dropped when the bot has already posted in the thread after the message (botRepliedAfter, the same predicate the catch-up trusts — invariant 6: Slack is the durable record). A stale event with 👀 but no bot reply after it still runs (item 7's ack-then-killed shape — re-running it is the point), fresh events pay nothing, and an unfetchable thread fails OPEN (a lost request is worse than the duplicate this guard prevents; the worst case is exactly the pre-guard behavior). Catch-up replays (caughtUp: true) skip both checks — the scan already judged the message unanswered against the same Slack state — but still claim the pair.

  10. Long command output is a Markdown file in the thread (command-registry.md item 27). SlackIO.attach({ name, text, lead }) uploads text with files.uploadV2 into the thread (channel_id, thread_ts, filename/title = name, initial_comment = the lead in mrkdwn): Slack renders a .md upload as formatted Markdown (bold, lists, inline code, links) in a collapsed preview with an expand control — one message where a 12 000-char mcp show used to be four chunks. The dialect translation is the dispatcher's (toMarkdownDocument), not the adapter's: the adapter uploads the document it is handed. Needs files:write (now in REQUIRED_BOT_SCOPES, so /healthz names it when the app lacks it); an upload that fails for any reason falls back to the chunked text reply carrying lead + text, so the output always arrives. Its binary sibling, SlackIO.attachFile({ name, bytes, lead }) (agent-coding.md item 10), uploads the bytes as the file through the same files.uploadV2 (file = the bytes, filename/title = name, the lead as initial_comment in mrkdwn) — a PNG renders inline in the thread. Bytes have no text fallback, so a failed upload propagates and the tool behind it reports the platform's message. Its by-reference sibling, SlackIO.uploadTicket({ name, size }) (agent-coding.md item 10, record 0033), is the external-upload flow the bot process never carries bytes through: files.getUploadURLExternal({ filename, length }) mints a one-shot URL for exactly size bytes (a ticket answered without an upload_url and file_id is refused by name; a refused mint propagates), the run's container POSTs the file to it, and the ticket's complete(lead) shares the file into this thread with files.completeUploadExternal({ files: [{ id, title: name }], channel_id, thread_ts, initial_comment }) — nothing is shared until the POST succeeded. Same files:write scope. Inbound, with a store (execution.md item 20): the files neither ingestion pass could carry — a video, an image over the 5 MiB cap, a PDF over the 10 MiB cap, an archive — are not lost: stagedFiles leaves them on Slack by reference (IncomingMessage.staged: name, size, mimetype, url_private, the message ts) for a run with a workspace to stage, each within Slack's 1 GiB per-file ceiling, at most ten per message and within artifacts.inbound.maxBytesPerMessage; the ones that still cannot travel keep their place in the "could not be passed through" note with the reason, and the secret-file denylist refuses staging as it refuses inlining. The inline caps do not move. Without a store the note reads exactly as before.

  11. A child run's thread of its own (thread-admission.md item 6; agent-conductor.md). SlackIO.openThread(lead) posts lead (Markdown converted to mrkdwn) top-level in the conversation's channel with chat.postMessage — a Slack thread hangs off a top-level message, never off a reply, so a child's thread cannot be a reply in its parent's — and hands back the thread key slack:<channel>:<ts> of the posted message, its permalink as the thread's sourceUrl when the team URL is known, and a SlackIO built from the posted ts the way resumeSlackIO builds one from a row's parts: the same requester, the same clients and status budget, no triggering event. The child's replies, card, shimmer and history are the new thread's; the lead itself is skipped by history() like any triggering message. A chat.postMessage answer without a ts is refused by name (the spawn relays it as spawn_failed), never a thread keyed on undefined.

  12. The conversation reader (record 0037; src/channels/slack/references.ts, SlackConversationReader, registered in src/index.ts beside the channel directory; the core's contract is routing-and-config.md's references step). parseConversationUrl recognises https://<this workspace's auth.test host>/archives/<C|G|D…>/p<16 digits> and nothing else — a link on another workspace's host, a files path or a malformed ts is plain text, and until auth.test has answered nothing parses, because a guess about which host is ours would be exactly the cross-workspace confusion the grammar refuses; a reply's permalink (thread_ts) names its whole thread, a bare permalink names one message. classifyConversation is one fresh conversations.info per call under a 30 s cache of its own (never the directory's ten-minute stamp cache, which is fail-open for a read): is_im, is_mpim, missing visibility, a missing channel or any error is never; an individual DM is also marked direct so a verified destination can be identified; the shared flags are reported independently as shared, leaving the access decision to the core; otherwise public/private by is_private, readerHasAccess from is_member, and the fresh name for the block's header. A failure is not cached. readConversation pages through conversations.replies at up to 1000 messages per page and ten pages, refusing an incomplete thread instead of quoting its oldest slice, maps them through threadTurns (no skip ts, no mention strip — a linked thread has no triggering message; the Slack app footer is stripped from every quoted line exactly as it is from the request text and the current thread's history, so a plugin-posted message quotes the user's words and a footer-only message quotes nothing), keeps a bare permalink's one message — matched by its own ts; a permalink to a message the mapping dropped, or to a ts the thread does not carry, yields no messages and the step refuses rather than quoting the thread the link did not name — or the parent plus the newest replies under the caller's cap, names authors through the cached users.info lookup (an unresolvable user by id, an app as app) and humanises text as the current thread's is; it never returns files. requesterIsFullMember is users.info's is_restricted and is_ultra_restricted both false; a failure is false. No new scope: the four the startup check already requires plus users:read cover it.

  13. Who asked is the person, never unknown (src/channels/slack/requester.ts, resolveSlackRequester). A message a person typed names them in user and that is the requester (slack:U…). A message an app posted has no user — only a bot_id and the name the app chose — and by 2026-09 those were the largest "requester" in the run history: every review a Claude Code session asked for from its owner's thread. The person is still findable and the adapter finds them, in order: (1) the message's own user; (2) the relay footer the Claude Slack app appends to a post it makes from a session — Sent by Claude in <#C…|name> on behalf of <…> · <permalink|thread>, delivered in a context block rather than the message's text (so the adapter reads the message by rawTextOf(text, blocks), the text plus the block lines it lacks) — read only when the posting app's bot_id is one the operator named in slack.relayApps (a list of Slack bot ids, validated at load; absent or empty means no footer is ever honoured): the on behalf of mention IS the requester, with no API call; an older footer without it names only the person's own thread (thread_ts when the relayed message was a reply, the bare p<ts> otherwise), and one conversations.replies(limit: 1) reads that thread's parent, whose user is the requester; (3) the app itself, named — slack:bot:<bot_id> with the app's display name as userName — so a run nobody can be found behind still says who posted it. A footer is message text and any app can write one, so from an app that is not a configured relay — footer or not — the app is the requester, and a thread's parent is never read for an app's reply: the person who started a thread did not ask what an app posted into it (a person's own reply names them in user and needs no parent). A footer thread whose parent is itself an app's post, or that cannot be read, falls through to the app; a resolved thread parent is remembered (bounded FIFO, 1000) since one session posts many requests from one thread and a parent's author never changes, and a failed read is never remembered. A resolved relay keeps the poster: IncomingMessage.relayedBy → RunRecord.relayedBy (the app's display name), so a record can say "alice, via Claude [fixing the build]" without lying about either. The relay footer is chrome like the Sent using footer: read for the requester from the raw text, then removed by the same stripAppFooter (slack/threadTurns.ts) the request text, the thread history and a quoted thread all pass through. The slack.receive span carries requester: message | relay-footer | bot. Authority is not identity: the person was named by message text, and text is forgeable (the relay gate above stops an unlisted app; the configured relay app's own footer is still its claim, not the person's signature), so the run's actor is the app on the person's behalf — IncomingMessage.postedBy (slack:bot:<bot_id>) makes resolveChatActor return an agent actor with the app's own grants and onBehalfOf the person, whose effective grants are the intersection (authorization.md item 14): never more than the Slack baseline plus whatever config grants that app id by name, never more than the person — and every dispatch gate (the agent and repository gates, the GitHub write gate, the router, a steer) decides on that actor, not on the person's id (authorization.md item 14). The attribution itself stays as the footer says; an operator who wants a relay app to hold more names its slack:bot:<id> in grants. The catch-up scan (item 7) replays people's mentions only: an app's relayed mention that lands during a blackout is not re-dispatched, since a scan cannot resolve its person after the fact — the posting session sees no card and re-sends. Cost by user (costs.md item 10a) bills the run to them and the me toggle claims it (a slack:bot:… id is never looked up by email), and the run's label and Request block name them. Records written before this keep slack:unknown; their stored input text still carries the footer, so a backfill can re-resolve them later.

  14. A confirmation is two buttons in the thread (record 0044, "The confirmation"; routing-and-config.md item 25; SlackIO.offer, handleConfirmClick, clickSlackIO in src/channels/slack.ts). Slack is the first adapter to implement ChannelIO.offer. The offer is one chat.postMessage in the thread. It leads with the effect, followed by Approve (confirm.run, primary) and Cancel (confirm.cancel), each carrying only the saved offer id. The exact command is inside a native container titled Command details, collapsed by default; code spans and fences preserve backticks and escape &, <, >. The fallback text carries the effect, without a command echo or typed-confirm instructions. An offer with no declared effect says Approve this action?. A definite Slack invalid_blocks rejection falls back to command details below the buttons; any other failure propagates without a second post. A question's offer (record 0054; routing-and-config.md item 21) sets offer.question and posts the refusal's sentence as its own section, Did you mean: over the corrected line as the code span in a second, the evidence as the context, and Yes (confirm.run, primary) and No (confirm.cancel) carrying the offer's id — the same two actions, so one intake serves the confirmation and the question alike and Yes consumes the row exactly as Approve does; the fallback text is the question as a channel without blocks reads it, the line to type in it. The taken-offer note reads the pressed button's own label off the payload (*Yes* clicked by <@U…> · running…, takenOfferNote), falling back to Run and Cancel for older payloads on a payload without one. The click is a block_actions payload Bolt delivers over the same Socket Mode connection as the events, matched by app.action(/^confirm\./), and the intake runs in this order and no other: (1) ack() — Slack gives a listener three seconds, and nothing below may cost them; (1a) the offer is taken: one chat.update on the offer message drops its actions block and adds a context line naming the button and who pressed it (*Approve* clicked by <@U…> · running…, takenOfferBlocks), so a second press has nothing to press and Slack's brief grey flash never turns back into live buttons while the command runs — Slack has no disabled state for a button, and removing it is the idiom; a take that fails is logged and the click proceeds, since the core's consume is single-use whatever the message shows; (2) the clicker resolved as a message's requester is — resolveSlackRequester on the payload's user.id, then chatActorOf with the offer's channel and thread as origin — identity, never authority: the adapter compares nothing, the core's consume judges the requester against the actor (authorization.md item 16); (3) the handle, clickSlackIO, built on resumeSlackIO's shape for the offer's channel and thread, with the offer message (message.ts, its text, its blocks) taken from the payload — no store of posted messages, the click carries everything; (4) dispatchClick(deps, { kind: confirm | cancel, id: action.value, actor, io }); (5) the core's first reply through the handle completes the offer message with chat.update: the offer's own blocks minus the actions block — the line stays above the answer, because the core's refusals say "type the line to run it" — and the answer as one section under them (an answer longer than a section's 3000 characters continues in the thread), so a confirmed run reads the receipt and the command's text, a cancel reads Cancelled; nothing ran, and a refused click reads its named line; a later reply on the same handle — a deferred command's settle follow-up — posts in the thread as a typed command's does. Nothing thrown leaves the handler: a failure is logged under [confirm] and the offer message is completed with this click could not be handled; type the line to run it, best-effort; a payload the adapter did not post — no value, an unknown confirm.* id, no message — is acked, logged and ignored. The app setting is human-gated (release-and-deploy.md item 30): the manifest (docs/public/slack-app-manifest.yaml) turns interactivity on (settings.interactivity.is_enabled; with Socket Mode no request URL), and an app created from an earlier manifest needs Interactivity & Shortcuts switched on by hand. Saved-id text continuations remain supported by the core, but Slack does not advertise them. A requester who has the offer id can type confirm <saved-id> or cancel <saved-id> in the original thread. The core checks the same saved action, requester, expiry and current permissions; it never executes fresh arguments from that text. The offer is the only message Slack renders for a held write (record 0069's table; the one-execution-path plan's click unit): no To run this: line accompanies the click, whichever caller minted it, and a mint the core could not make reaches the channel as a refusal naming why (routing-and-config.md item 25), never a line to retype.

  15. The intake gate before the 👀 (record 0058; routing-and-config.md item 27; receiveSlackMessage, gateThreadReply and wireIntakeGate in src/channels/slack.ts; the verdict itself is the core's decideIntake, src/core/intake.ts). An unmentioned thread follow-up (SlackEvent.trigger: "thread-follow-up") gets its verdict before anything visible or costly: after dedupeDelivery has claimed the pair (item 9) and before the 👀 ack, the ⏱ note and the file downloads. A mention, a DM and a top-level post never pass through the gate in any mode, and a message a stored receipt already decided (SlackEvent.intakeDecided) is never decided twice — the catch-up's act sets the flag on every gated replay it dispatches (item 7, actOnMissedMessage): it reads the receipt — or decides receipt-less candidates itself over the paged thread — before handle() and acts on the stored verdict, so a replayed addressed reply whose run died is re-run instead of refused as another caller's row (the silent drop the old blanket caughtUp bypass existed to prevent; that bypass is lifted). A caught-up replay reaches the gate only when it arrives undecided — a mode flip between the scan and the act — and is then decided fresh like a live reply (its trigger labelled truthfully, item 7). The modes (intakeModeFor: thread scope > user > channel > the intake block's default, classify when unset): always — the adapter never calls intake and never touches the ledger for that reply, today's path byte for byte; mention — silent with source: mode, no model call, a receipt written; classify — one forced tool call over the thread's newest turns. The turns are the page threadIfBotInIt already fetched for a thread of 50 replies or fewer; past that, the gate cursors forward from the prefetched page's end (conversations.replies pages oldest-first, so a latest-bounded page would return the thread's head) — up to INTAKE_TAIL_PAGES more pages, duplicates dropped — and keeps the newest turns (best-effort — a failed or truncated fetch judges over what is in hand). The labeller compares each turn's user to the bot's own user id (threadTurns is unchanged): bot is the bot's own post, requester the person of the thread's newest addressed run (requesterOf over the runs page, read once and handed on to dispatch() as DispatchOptions.thread so the eight-run page is read once per reply), person anyone else; the facts computed by code ride beside them — the live run off the runs page, replier-is-requester, the bot's last turn, another person mentioned, the person a pending confirmation waits on (pendingByThread), the bot-started parent, and the operator's own pending question off the same runs page (pendingQuestionOf — routing-and-config.md items 27 and 29, issue 2046: the one fact that decides addressed without a model turn, so an answer to the bot's own question needs no mention) — both store reads best-effort, an empty fact never blocking the verdict. The verdict acted on is the stored one: only the caller whose insert stood — inserted, or the degraded failed/absent — acts on addressed; a row another caller stored means no action here, whatever the verdict reads. On addressed the 👀 lands after the receipt's write was attempted and the reply proceeds exactly as today; on an ordinary silent nothing happens — no reaction, no download, no card, no run — and the reply's only trace is the receipt and the span; on a provider failure the receipt carries providerFailure; the live path renders its no-lease sentence only for inserted, failed or absent, never for existing, and no reaction, download, card or run follows, so the intake door never drops an outage invisibly. Delivery is a separate durable fact: claimIntakeDelivery atomically names the receipt key, the unique poster and a bounded claim before a stored failure may post; success closes it permanently, rejection releases it, and catch-up may claim an existing undelivered receipt. The Slack snapshot reconciliation runs only after that claim, so concurrent processes cannot both post; an owner that dies before finishing is recoverable after the claim bound. Degraded, never silent by accident: a failing read decides as if no receipt; a failing write still dispatches with intakeReceipt: failed; the null ledger acts with absent, and the startup log prints degradedIntakeLine() exactly once (wireIntakeGate, wired in src/index.ts — where a missing model ref leaves the gate unwired and thread replies run as always, degrade open). The span says what happened: every verdict sets intake, intakeSource and intakeReceipt on the slack.receive span — three identifier-valued keys of the closed attribute table; the reason is free text and lives on the receipt row and the [intake] log line, never on a span.

  16. A person's actor id renders as a mention Slack resolves (mdToMrkdwn, src/channels/mrkdwn.ts). The core prints a person's platform-namespaced id (slack:U…, AGENTS.md invariant 4) as plain text everywhere it names someone — a child or unit thread's lead naming the requester (childThreadLead, unitLead) — and only this adapter turns the id into platform syntax: mdToMrkdwn, the one renderer every outbound message passes through (SlackIO.reply/post/openThread, the file leads, the index posters), rewrites a bare slack:U… standing alone as a word — and repairs a stray pre-wrapped <@slack:U…> — into <@U…>, stashed as structural syntax so the prose escape leaves the mention live. Non-Slack surfaces (the web dialect, HTTP, MCP, the CLI, model prompts such as the fold and follow-up attributions of threadEvents.ts) keep the plain id or the display name they use today. Word-bounded and deliberate: an id inside a longer token (user:slack:U… scope keys), inside a code span or fence, or inside a URL — a markdown link's (stashed) or a bare one (the lookbehind excludes URL-structural chars: =, /, ?, #, &) — stays literal, and a non-person id (slack:C…, slack:bot:B…) is untouched. The guard: no text the adapter sends may carry <@slack: or a bare slack:U… id — enforced suite-wide by the shared client wrapper (guardOutbound, src/channels/testing/outboundGuard.ts) the slack tests construct their fake Web API clients through, so a send site outside mdToMrkdwn's funnel (the status card's escapeMrkdwn path, SlackIO.offer) is scanned too. Shared permission guidance never names the admin roster: adminsHint returns "an admin" for config summaries, command errors, access refusals, onboarding and preflight.

Quiet repository command progress replaces the accepted confirmation message, removing the old command, risk and route echo. It uses the existing status card and terminal bullet result rather than posting a second outcome.

Validation criteria ​

CriterionProof
Terminal receipts distinguish acknowledged complete cards from failed, budget-deferred and clipped frames[unit] src/channels/slack.test.ts::SlackIO.status — verbosity and shimmer lifecycle::terminal acknowledgement distinguishes delivered, failed, deferred and clipped outcomes
Quiet repository progress reuses the accepted offer message and removes its repeated command and route text[unit] src/channels/slack.test.ts::SlackIO.status — verbosity and shimmer lifecycle::quiet command progress replaces the accepted offer with one compact message
Items 2–3 + routing item 28 (issue 2329): quiet keeps the acceptance reaction while suppressing the catch-up note and rotating native assistant status while intake, downloads, the activity card, its budget and terminal cleanup continue; verbose/debug retain the shimmer's set, re-up and clear lifecycle[unit] src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::the request directive and resolved requester reach verbosity resolution before Slack emits a receipt, ::quiet acknowledges acceptance before downloads while suppressing the catch-up lifecycle note; src/channels/slack.test.ts::SlackIO.status — verbosity and shimmer lifecycle::*
Item 7: the act reads the receipt — over a fixture of three unmentioned candidates in a bot thread, a silent receipt skips (counted silenced, marked seen), an addressed receipt dispatches with intakeDecided and no verdict, and no receipt runs decideIntake over the paged tail; a throwing read decides as receipt-less; a fresh silent verdict silences; an always thread and a gate-less process dispatch as today with no read; a live claim between the scan and the act is skipped after the read; a 60-reply thread hands the newest twelve turns; an edit that gained a mention dispatches as a mention with no read; every candidate carries its paged thread and a thread_broadcast reply is never one[unit] src/channels/slack.test.ts::the catch-up reads the receipt — onMissed's act (docs/reference/specs/slack-channel.md item 7)::the runner over a fixture…, ::a candidate whose receipt read throws…, ::a fresh silent verdict silences the candidate…, ::a caught-up intake provider failure renders the same ending-safe sentence once and remains undispatched, ::an always thread is dispatched exactly as today…, ::a live claim that landed between the scan and the act is skipped after the read, ::a thread of 60 replies hands the newest twelve turns to the verdict, ::an edited message now carrying a mention is a candidate through mentionsBot…; src/channels/slackCatchUp.test.ts::findMissed (pure selection over fetched history)::carries the paged thread…, ::a thread_broadcast reply is never a candidate…
Item 3: the pipeline card's unit lines and the plan summary print the ending in the user's words — merge-ready, idle · out of budget — never merge_ready or another internal token (record 0066, agent-ship item 12a)[unit] src/channels/adminCoordinator.test.ts::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::unit-end writes the ending and the pull request on the row…, ::unit-end with an idle ending writes idle…; src/core/pipelineStanding.test.ts::the user's words for endings and round outcomes (record 0066)::*
Item 15: the always golden — a thread follow-up under always is byte-identical to the gate-less path (dedupe, 👀, downloads, the message) and intake is never asked, live and caught up (the ⏱ note in its place); a mention and a DM never reach the gate in any mode (a top-level post never reaches handle()); a message a receipt already decided is never decided twice, and a caught-up replay with intakeDecided proceeds without a second verdict while an undecided one is gated like a live reply[unit] src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::the always golden…, ::a caught-up message at verbose keeps its golden: 👀, the ⏱ delay note, then the downloads, ::a mention and a DM never reach the gate in any mode…, ::a message a stored receipt already decided (intakeDecided) is never decided twice, ::the caught-up bypass is lifted…
Item 15: order of effects per mode — an ordinary silent produces no 👀, download, note or dispatch; a typed provider failure posts its one ending-safe sentence and dispatches nothing; the live path never posts another writer's existing receipt; addressed decides first, then the 👀, then the downloads, with the runs page handed out for dispatch; mention never calls the model, answers silent by mode and writes the receipt row[unit] src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::silent: no 👀, no download…, ::an intake provider failure is not silent…, ::a failed or absent provider-failure receipt still renders at the live no-lease door, ::the live path never posts a provider failure from another writer's existing receipt, ::addressed: the verdict and its receipt come first…, ::mode mention: the model is never called…
Item 15: durable provider-failure delivery — two concurrent readers of one existing receipt acquire one claim and post once; a rejected post releases its claim and one later catch-up delivers, while success closes delivery against every later reader[unit] src/channels/slack.test.ts::the catch-up reads the receipt — onMissed's act (docs/reference/specs/slack-channel.md item 7)::two concurrent readers of one existing provider-failure receipt post once through its durable claim, ::a rejected intake-failure post is delivered once on the next catch-up, never twice; deploy/cloudflare-memory/runLedger.test.ts::run ledger — intake receipts (item 59)::claims one failure delivery atomically, releases a rejected post, and closes a successful post
Item 15: one decider per process, one verdict across processes — a second live delivery of the same event is dropped by the redelivery guard with no second verdict, the guard's claim landing before its fetch; a receipt another caller stored yields no action here whatever it reads[unit] src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::a second live delivery of the same event…, ::not the inserter…; src/channels/slack/dedupe.test.ts::dedupeDelivery (redelivery guard)::the claim precedes the guard's fetch…
Item 15: degraded, never silent by accident — a throwing write still dispatches an addressed reply (intakeReceipt: failed), a throwing read decides as if none (inserted), the null ledger acts with absent, each on the span; the startup line prints exactly once when the ledger is null and never with one[unit] src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::degraded, never silent by accident…, ::wireIntakeGate prints the degraded startup line…
Item 15: the newest turns and the adapter's facts — a thread past the prefetched page pages forward to its tail (cursor followed, duplicates dropped, the newest turns kept — never its head) and a short thread never fetches; a userless event resolves the mode with no user scope; the turns are labelled bot/requester/person by user id against the runs page, and the facts carry the live run, replier-is-requester, the bot's last turn, the other mention, the pending confirmation's person, the bot-started parent and the operator's own pending question (issue 2046: pendingQuestionOf over the runs page — the fact that decides addressed without a model turn, so an answer to the bot's own question needs no mention)[unit] src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::a thread past the prefetched page…, ::a userless event resolves the intake mode…, ::the labels and the adapter's facts…, ::the operator's own pending question rides the facts (issue 2046): the newest run's on-mode question sets pendingQuestion, so the reply is addressed without a mention
Item 15 (R2): the runs page the gate read is used in place of the dispatcher's own thread read[unit] src/core/dispatcher.test.ts::…::DispatchOptions.thread is used in place of the thread read…
Item 12: only this host's archives/<C…>/p<ts> parses (a reply link carries its thread ts, a bare link its message id; another host, a files path, a malformed ts or an unknown host parse to nothing); every shared flag is reported independently of visibility; a DM, a group DM and a missing channel classify never even with is_private: false; a classification is served from the reader's cache for 30 s and re-asked after, a failure never cached; the fetch asks for 1000 replies once, keeps the parent plus the newest 49, drops status cards, names an app app and an unresolvable user by id, reads a bare permalink's one message alone and nothing for a dropped or absent one, takes the channel name from the fresh classification; a quoted line loses a trailing *Sent using* <@APP|Name> footer (same-line, own-line, with a bracketed sender) and a footer-only message is dropped while the phrase mid-text stays; a guest or a failed lookup is not a full member[unit] src/channels/slack/references.test.ts::SlackConversationReader.parseConversationUrl — this workspace's grammar::*, ::SlackConversationReader.classifyConversation — closed and fresh::*, ::SlackConversationReader.readConversation — text only, newest kept::* (incl. ::strips the Slack plugin's 'Sent using' footer from a quoted line as the request text is; a footer-only message is dropped; red-verified), ::SlackConversationReader.requesterIsFullMember — the requester's standing::*
threadTurns is the one mapping from a conversations.replies page to turns: the triggering ts skipped, the bot mention stripped, trailing app attribution stripped (stripAppFooter, also used for the request text) so a footer-only message is dropped, the status cards dropped, a bot's files dropped, an empty message dropped, at from ts when it parses, the role by bot_id, the author ids kept[unit] src/channels/slack/threadTurns.test.ts::threadTurns — the thread-page-to-turns mapping::*
A mention whose event omits files reads the canonical thread page once before dispatch and reuses it as run history, including for text-only mentions; if its source is beyond that page, an exact-source read recovers the attachment. A file-share message can use the exact read; an ordinary text-only DM does not. A prefetched or catch-up source is authoritative, and the intake span records raw and resolved file counts[unit] src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::an app mention missing file metadata reads its exact Slack message before the operator sees the request, ::a text-only app mention reuses its source read as the run's history page, ::an app mention beyond the history page still recovers its attached plan, ::a text-only DM does not fetch its source message just to look for files, ::a file-share DM with omitted file metadata recovers the attachment, ::a prefetched text-only message is authoritative and costs no second replies call, ::a catch-up message already read from Slack history does not fetch its source again
Item 11: openThread posts the lead top-level in the parent's channel (no thread_ts, mrkdwn), keys the thread by the posted ts, and the returned IO replies under that ts while history skips the lead; the lead's permalink rides as sourceUrl when the team URL is known and no key otherwise; an answer without a ts is refused by name[unit] src/channels/slack.test.ts::SlackIO.openThread (docs/reference/specs/slack-channel.md item 11)::*
Redelivery guard (item 9): same-process redelivery dropped with no API call (timestamps taken from a real redelivery); cross-process stale redelivery dropped after one replies fetch when the bot replied after it; stale-but-unanswered (👀-then-killed) runs; bot messages before the event don't count; fresh events never fetch and a repeat of the same ts is dropped; replies failure → runs (fail-open); no botUserId or malformed ts → no fetch, runs; caughtUp skips both checks but claims the pair[unit] src/channels/slack/dedupe.test.ts::dedupeDelivery (redelivery guard) (7; red-verified: check (a) disabled → the two same-process cases fail)
Redelivery guard — wiring: handle() consults the guard before the 👀 ack and before any work; catch-up's onMissed re-checks the seen-set at dispatch time[unit] src/channels/slack.test.ts::the catch-up reads the receipt — onMissed's act (docs/reference/specs/slack-channel.md item 7)::a live claim that landed between the scan and the act is skipped after the read (the onMissed half). [agent] code-review of handle() entry (item 7's Bolt-level harness drives the connected hook, not a live socket delivery into handle(), so that half stays code-review). [agent] live: next deploy-window mention that gets both a catch-up replay and a Slack redelivery shows one run and a [redelivery] … dropped container log line.
Quiet replies folded into an active run receive one eyes reaction each before dispatch; redelivery adds no reaction or run and the reply reaches the original run without a folding text note[unit] src/channels/slackGoldenFlow.test.ts::Slack golden flow::acknowledges a quiet reply folded into the active run once, without a folding note
Model-authored prose/link entities decode once; CommonMark angle-wrapped HTTP(S) destinations retain usable query/fragment links; encoded control syntax stays inert and code entity text stays literal[unit] src/channels/mrkdwn.test.ts::mdToMrkdwn::decodes link entities once and accepts angle-wrapped Markdown destinations, ::accepts angle-wrapped HTTP(S) Markdown destinations, ::decodes prose entities once without promoting encoded control syntax, ::keeps literal entities in code spans and fences; src/channels/slack.test.ts::SlackIO.reply — model link normalization::*
Native HTTP(S) and Markdown links preserve URL queries, fragments and escaped labels; code remains literal and invalid protocols or forged control syntax remain inert[unit] src/channels/mrkdwn.test.ts::mdToMrkdwn::normalizes native HTTP(S) links alongside Markdown links, ::keeps native links literal in inline code and fences, ::escapes invalid native links and control syntax
Markdown→mrkdwn conversions; code fences and inline code untouched; *x* and **x** both render bold (deterministic emphasis), _x_ the one italic form[unit] src/channels/mrkdwn.test.ts (incl. ::normalizes both emphasis dialects to bold; red-verified)
Item 16: the one renderer — a bare slack:U… renders as <@U…> with punctuation kept, a pre-wrapped <@slack:U…> is repaired instead of escaped to literal text, the produced mention survives the prose escape; an id in a code span or fence, in a longer token (user:slack:U…), or in a URL — a markdown link's or a bare one's query/path — stays literal, and slack:C…/slack:bot:B… are untouched; the sibling composers' texts (a refusal's admins hint, a child thread lead without a display name) render clean[unit] src/channels/slackActorMentions.test.ts::the actor-id mention renderer (mdToMrkdwn, docs/reference/specs/slack-channel.md item 16)::*
Item 16: the outbound guard — no text the adapter sends carries <@slack: or a bare slack:U… id: the slack tests build their fake Web API clients through the shared guardOutbound wrapper, so every chat.postMessage/chat.update/upload payload in the suite is scanned (a swallowed throw still fails via installOutboundGuard's afterEach); the guard's own tests drive it with the worst inputs and trip it on purpose[unit] src/channels/slackActorMentions.test.ts::the outbound guard — no raw actor id in any text the Slack adapter sends (item 16)::*
Mention triggers a run; reply lands in-thread[agent] Mention the bot in a channel with a trivial request; expect a status card then a reply in the same thread.
Thread follow-up works without re-mention[agent] After a completed run, reply in-thread without mentioning the bot; expect a new run.
👀 reaction lands on acceptance, before the status card[agent] Mention the bot; the 👀 reaction must appear on your message before/with the status card. Without reactions:write, expect a [ack] missing_scope log line and otherwise unchanged behavior.
Trigger gating (no bot-loop, no un-mentioned top-level posts, no mention double-handling)[unit] src/channels/slack.test.ts::classifyMessage… + ::threadIncludesBot… — the gating decision is a pure exported function the Bolt handler calls.
Image passthrough within budgets (types, size, count, thread byte budget, login-page detection, failed downloads)[unit] src/channels/slack/attachments.test.ts::fetchImages… (stubbed fetch fixtures). Live spot-check remains: attach a PNG and ask "what does this show?".
Document passthrough within budgets (PDF→base64, text/code→UTF-8, extension fallback for generic types, images/unsupported skipped, size/count/thread-byte budgets, login-page vs real .html, failed downloads)[unit] src/channels/slack/attachments.test.ts::fetchDocuments (PDF + text/code ingestion within budgets) (stubbed fetch fixtures). Live spot-check remains: attach a PDF and ask "summarize this".
Secret-file denylist: credential/key/config files (.env/config.env/.env.local, credentials.json, *service-account*.json, *-key.json, id_rsa*, .pem/.key/.p12/.pfx/.npmrc/.netrc/.ini/.cfg/.conf) are skipped-with-note and never decoded, overriding a text/JSON mimetype; plain .json no longer inlines by mimetype; safe .txt/.csv/code/PDF still ingest[unit] src/channels/slack/attachments.test.ts::classifyDocument (secret-file denylist …) + ::fetchDocuments (secret files skipped-with-note, never decoded) (stubbed fetch fixtures; the never-decoded guarantee is proven by fetch never being called for a denied file).
Attachment assembly: images then documents then user text; PDF→document part, text→fenced text part[unit] src/core/dispatch/messages.test.ts::turnContent (attachment assembly)
A hosted generated ship task carries the triggering Slack PNG's same media type/data and by-reference source through one durable seed into the first coding child; staging writes attachments/0-<name>, names it in the prompt and artifact catalogue, and a replay creates no second file; review and round two receive none, an uncertain pre-registration outcome retains it without replay, over-cap media is capped before Worker transport and named as dropped, and no accepted media appends no seed[unit] src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::an accepted PNG keeps its zero-based Slack staging source when the artifact path is configured, src/core/dispatch/ship.test.ts::runShipBranch — the agent:ship fork hands every admitted request to the plan runner::a Slack task carrying one PNG hands its accepted bytes and staging source to the generated unit as one durable seed event, src/core/dispatch/ship.test.ts::runShipBranch — the agent:ship fork hands every admitted request to the plan runner::a Slack attachment the inline path skipped stays named in the hosted request, while a request with no accepted media appends no seed event, src/core/coordinator/instanceStore.test.ts::WorkerCoordinatorInstanceStore — the wire::caps an attachment event before the Worker's request-body fence, so accepted over-cap media becomes a dropped-count row instead of HTTP 413, src/channels/adminCoordinator.test.ts::the fold — a unit's thread events reach the pipeline's next step (record 0051's fold rule)::a ship-request PNG reaches the coding child's attachment staging and catalogue once; replay folds no second file, src/core/dispatcher.test.ts::inbound staging (record 0033)::a coordinator coding child stages a folded accepted PNG at its zero-based path, appends that path to the prompt and records it in the asset catalogue, src/channels/adminCoordinator.test.ts::the fold — a unit's thread events reach the pipeline's next step (record 0051's fold rule)::an uncertain coding dispatch leaves its attachment unconsumed and never replays on retry, src/channels/adminCoordinator.test.ts::the fold — a unit's thread events reach the pipeline's next step (record 0051's fold rule)::a review spawn leaves the events unconsumed and folds nothing
A document part reaches the model as pi's own adapters carry it: the relay hands pi a document as its descriptor beside the image blocks, and the router's and reflection's path renders a document as the compatible adapter's text note (never raw base64)[unit] src/core/harness/pi/relay.test.ts::runRelayedTool — a native tool run in the bot for pi::images ride to pi as image blocks, a document as its descriptor, src/core/harness/piAi.test.ts::toPiContext — the completion vocabulary in pi's shape::*
Mention stripping: a leading literal @switchboard from a connector is transport addressing like the bot's real mention and leaves a head agent: directive intact; the same address is stripped from the bot's own thread history when its user ID is known, while linked-thread quotations, a mid-sentence name and another @name remain user text[unit] src/channels/slack.test.ts::stripMention::*, src/channels/slack/threadTurns.test.ts::threadTurns — the thread-page-to-turns mapping::*
Slack app footer stripping: receiveSlackMessage preserves raw text for relay identity, then normalizes the request before intake and dispatch; threadTurns uses the same stripAppFooter for current and linked history. A trailing Sent using attribution with a Slack app mention or matching Markdown emphasis may share the last request line; an unmarked plain app label must be on its own line. Any bounded app label works, including stacked footers. Asymmetric emphasis, quoted lines, nonterminal markers and unmarked same-line labels stay as user text. A bare agent:ship PR URL with a connector attribution therefore reaches Ship as only the URL[unit] src/channels/slack.test.ts::stripMention — Slack app 'Sent using' footer::*, src/channels/slack.test.ts::receiveSlackMessage — the intake gate (docs/reference/specs/slack-channel.md item 15)::hands a bare Ship PR continuation to dispatch without same-line or standalone connector attribution, src/channels/slack/threadTurns.test.ts::threadTurns — the thread-page-to-turns mapping::removes standalone connector attribution from thread history and drops a footer-only turn
Display-name resolution: channel + user names resolved and cached (one API call per new id), display_name→real_name→name preference, API error → undefined without throwing, failure not cached[unit] src/channels/slack/lookups.test.ts::resolveChannelName / resolveUserName (best-effort, cached)::*
Channel visibility (item 6a): conversations.info → public / private / dm (is_im, is_mpim), slack:D… and non-Slack ids answered without an API call, one lookup per channel per TTL (injectable clock) with a bounded FIFO cache, concurrent first lookups share one call, any failure → unknown remembered for the TTL and logged once per window; channelsOf / isMember from users.conversations per person, forgotten on forgetMember / forgetAll[unit] src/channels/slackChannelDirectory.test.ts::SlackChannelDirectory.info — visibility from conversations.info::*, ::SlackChannelDirectory.channelsOf — a person's channels from users.conversations::*, ::SlackChannelDirectory.isMember — the person's own channel set::*; the stamp and the bounded wait end to end: src/core/dispatcher.test.ts::run history write path …::Slack directory stamp…, ::a slow channel directory cannot hold a reply…
Reconnect catch-up — selection: un-acked top-level mention in-window is picked (threaded to itself); 👀-acked skipped only while younger than ACK_GRACE_MS, or once the bot replied after it — a 👀-acked message older than the grace with no bot reply after it is picked, top-level and in-thread; bot-replied-after skipped (a human reply is not a receipt); older-than-window / no-mention / bot / subtyped skipped; file_share kept with files; same-process seen-set skipped; in-thread mention picked even under a days-old parent; un-mentioned follow-up picked only in a bot-participating thread; reply the bot answered after skipped; oldest-first ordering[unit] src/channels/slackCatchUp.test.ts::findMissed… + ::isAckedByBot (red-verified: ack, bot-reply-after, and window checks each mutated → 5/2/2 failures)
Reconnect catch-up — window vs drain: DEFAULT_WINDOW_MS (30 min) ≥ DRAIN_DEADLINE_MS (15 min) + COLD_START_ALLOWANCE_MS (5 min); catchUpWindowWarning is silent for unset / ≥ 20 min, names the configured value, the 15 min drain deadline and the 20 min minimum below it, and flags a non-positive or non-finite value — never clamps[unit] src/core/drain.test.ts::catch-up window vs drain deadline + ::catchUpWindowWarning… (red-verified: DEFAULT_WINDOW_MS lowered to 15 min → module-load guard throws and the test file fails)
Reconnect catch-up — runner: scans every member channel (users.conversations public+private, unarchived); fetches replies only for threads active in the window; nothing dispatched when all acked (the common reconnect); a channel's API failure is logged and skipped without throwing; a rejecting dispatch does not stop the rest; history asked from the parent-lookback oldest and paged by cursor; a long thread's replies paged by cursor so an in-window follow-up on page 2 is found[unit] src/channels/slackCatchUp.test.ts::catchUpMissedMentions (runner over the Slack Web API)
Reconnect catch-up — wiring: the receiver always keeps its connected and disconnected listeners for socket status and required-scope diagnostics. When catch-up is enabled (default), connected scans and re-dispatches a missed mention through handle(); with slack.catchUp.enabled: false, it performs scope diagnostics without scanning.[unit] src/channels/slackConnectedHook.test.ts::connected-hook wiring (Bolt-level harness)::registers exactly one connected hook by default…, ::at verbose, re-dispatches a missed mention through handle(): 👀 ack, ⏱ note and one dispatch — and a second connect skips it via the seen-set, ::slack.catchUp.enabled: false still diagnoses required IM scopes without scanning
Reconnect catch-up — late-pickup note: a replayed message's thread gets ⏱ Picked up N min after it was posted … no need to re-send; whole minutes; sub-minute or negative delay → "under a minute"[unit] src/channels/slack.test.ts::catchUpDelayNote::*. [agent] The next deploy-time catch-up replay (see the live row below) shows the ⏱ note above the run card, with N matching the gap between the message and the new container's connect.
Reconnect catch-up — live: a mention posted during a bot deploy gets 👀 + a run once the new container connects, and is not run twice[agent] Post <@bot> agent:general say hi in a channel the bot is in ~30 s after npm run deploy in deploy/cloudflare/ (before the new container's [catch-up] scanned … log line); expect a [catch-up] C…: 1 missed message(s) re-dispatched log, then 👀 + status card + reply on the message; a second deploy must log 0 missed. A rollover where the old container drains while the new one connects has no blackout to catch, so pick a quiet moment and post the probe inside the gap; the container's stdout is not in Workers Logs — read the [catch-up] lines from the container directly, or curl -s https://<bot hostname>/healthz and read catchUp.missed / catchUp.lastRunAt.
Reconnect catch-up — status record: the runner records every scan (lastRunAt ISO, channels, missed, skippedChannels); a failed channel listing is recorded as error (and returns an empty scan); per-channel failures count as skippedChannels with no error; a clean scan clears a previous error; missingScopes survives across outcomes and an empty list is absent, not [][unit] src/channels/slackCatchUp.test.ts::catchUpMissedMentions — outcome record + src/channels/slackCatchUpStatus.test.ts::catch-up status record…
Reconnect catch-up — scope check: missingBotScopes returns the required scopes absent from the grant, in required order; extras ignored; array or comma-separated input; an unknown grant reports nothing; the required set is exactly the adapter's declared scopes[unit] src/channels/slackCatchUpStatus.test.ts::missingBotScopes…
Reconnect catch-up — /healthz carries catchUp with undefined fields omitted (error/missingScopes present only when set; {} before the first scan)[unit] src/channels/health.test.ts::healthPayload — catchUp
Reconnect catch-up — deploy preflight warns (never refuses) on catchUp.error or a non-empty missingScopes, naming each; a payload without catchUp or an empty list says nothing; warnings ride along on an allowed AND a refused decision[unit] deploy/cloudflare/preflight.test.mjs::bot deploy preflight — catchUpWarnings()
Reconnect catch-up — live: the scope check fires on first connect and the record reaches /healthz[agent] After a deploy, curl -s https://<bot hostname>/healthz | jq .catchUp → lastRunAt within the last few minutes, channels ≥ 1, no error, no missingScopes. Negative path: on a Slack app whose bot token lacks channels:read, expect error containing missing_scope and missingScopes: ["channels:read"], and node deploy/cloudflare/preflight.mjs printing WARNING (not blocking …) with both lines.
Socket state — the record starts {connected:false} at boot, a connect stamps since + lifetime connects, a disconnect flips it and moves since, a reconnect bumps the count, and reads are copies[unit] src/channels/slackSocketStatus.test.ts::slackSocketStatus::*
Socket state — /healthz carries slack with absent fields dropped ({connected:false} alone at boot); no key when the state is not given[unit] src/channels/health.test.ts::healthPayload — slack socket state::*
Socket state — the listen-before-connect ordering is provable from ONE /healthz poll on ONE clock: the payload's httpListeningAt (stamped in the listen() callback) is strictly earlier than slack.since of the boot connect (connects: 1). Racing a poller against the boot window instead is NOT observable through the Worker shim — startAndWaitForPorts releases held requests on a coarse port poll (~seconds), which exceeds the ~1.5–2 s window (requests sent before the process existed are answered a fraction of a second after the connect)[agent] After any cold start: curl -s https://<bot hostname>/healthz | jq '{httpListeningAt, slack}' → httpListeningAt < slack.since with connects: 1.
Rollover-safe cards — deploy preflight decision: idle + settled → allow; runs in flight → allow with a WARNING naming the count and the handoff (never a refusal); draining: true → allow with a WARNING naming the ship-pipeline risk; container app provisioning/updating/unknown → refuse naming the state (ready and active are settled); fail closed on unreachable bot, bare-ok/non-JSON body (names the old-Worker bypass), impossible inFlight, wrangler failure, app not listed; force → allow with a warning naming the problems; problems and warnings reported at once[unit] deploy/cloudflare/preflight.test.mjs::bot deploy preflight — decide()::* (CI job bot-worker)
Rollover-safe cards — a preflight still refusing at the end of the wait budget (a rollout still settling; in-flight bot runs never refuse) FAILS by name: the budget, the refusal, NOT deployed, the re-run recipe (gh run rerun RUN_ID --failed); deploy restart fails by the same line with its own words[unit] src/deploy/liveGate.test.ts::preflightGaveUpLine::*, src/core/commands/deploy.test.ts::deploy.all::a step whose preflight was still refusing at the end of the wait budget is \unavailable` like any stopped run…`
Rollover-safe cards — deploy restart decision: runs in flight and a drain under way are warnings, allowed without --force; no JSON body or an impossible count refuses (force bypasses)[unit] src/deploy/restart.test.ts::decideRestart::*
Rollover-safe cards — bot deploy gate: before upload the runner reads the named application; after upload a printed target requires that exact image and a newer application version, then ready non-draining /healthz must serve the full deployed commit exactly; an exact-commit health response that is draining still waits[unit] src/deploy/botLiveGate.test.ts::decideBotLive rollback fence::*, src/deploy/botGateRun.test.ts::deployStep (bot rollback fence)::*
Rollover-safe cards — deploy preflight live, fail-closed: against a Worker that predates item 8 (bare ok body) node preflight.mjs refuses with the old-Worker hint and exits 1[agent] Run SWITCHBOARD_BASE_URL=https://<bot hostname> node preflight.mjs in deploy/cloudflare/ (the origin comes from the profile; deploy all sets it) before the first item-8 deploy; expect preflight REFUSED … bot answered /healthz without JSON ("ok") — the running Worker predates the preflight and exit 1.
Rollover-safe cards — deploy preflight live, allow: with the item-8 Worker live and idle, /healthz is {"ok":true,"inFlight":0,"draining":false} and node preflight.mjs prints preflight ok and exits 0[agent] curl https://<bot hostname>/healthz then SWITCHBOARD_BASE_URL=https://<bot hostname> node preflight.mjs in deploy/cloudflare/ at a quiet moment.
Rollover-safe cards — drain wait bound: the full DRAIN_DEADLINE_MS holds only while a run holds the drain (registry-active, not handed off); the moment the held count reaches zero the bound collapses to HANDOFF_BUDGET_MS from that instant — never past the full deadline, never growing back — so a drain that begins with one run and sees it end exits within the grace it needs to publish the hand-over, not at the deadline, and the deploy's "waiting until live" shows 0 runs for seconds, not minutes[unit] src/core/drain.test.ts::createDrainDeadline — the drain's wait bound collapses when the last held run ends::*
Rollover-safe cards — drain notice: once setShutdownNotice is set mid-run, the next heartbeat frame's title ends with · ⏸ deploy in progress …; the closed ✅ card never carries it; with no notice, frames are unchanged[unit] src/core/dispatcher.test.ts::shutdown notice on the live status card
Rollover-safe cards — a dispatch is in flight from its first line: activeRunCount() is 1 before io.history(), the setup card or any executor attach, and 0 once dispatch() returns — on every path, including the config-command fast path and refusals — so a SIGTERM landing between the channel's 👀 ack and the first card finds a run to wait for instead of draining at once (the producer of the acked-then-vanished runs the catch-up grace re-runs)[unit] src/core/dispatcher.test.ts::a dispatch is counted in flight from entry — before history, the setup card or any attach …, ::an early-return path (config command) releases the count: 0 after dispatch
Rollover-safe cards — /healthz body: {ok:true, inFlight, draining, drainDeadlineMs} always, plus drainStartedAt (ISO) and held (the registry-active runs holding the drain, id and why, when non-empty) only while draining, so an operator can see how long the blackout can still last and what it waits for[unit] src/channels/health.test.ts::healthPayload; the wiring (inFlight() = runs + pending reflections, held = the registry's unfinished rows not handed off, draining flipped by the drain) is covered by the live preflight rows
Rollover-safe cards — restart's health-identity helper names what an old draining process actually holds: with held rows the waiting reason reads old container still draining — holding N registry-active run(s): <id> (not handed off) …; malformed or empty held falls back to the in-flight count; the drain's hold line (drainHoldLine) names the same ids and whys[unit] src/deploy/liveGate.test.ts::decideLive::the still-draining reason names what is actually held — the registry-active run ids and why …, ::a malformed or empty …, src/core/drain.test.ts::drainHoldLine::*
Rollover-safe cards — /healthz carries process ({rssMb, heapUsedMb, eventLoopLagP99Ms}) whenever the entrypoint samples it, and no key otherwise; the sampler reads whole MiB and a fresh lag window per read[unit] src/channels/health.test.ts::healthPayload — process metrics::*, src/channels/processMetrics.test.ts::startProcessMetrics::*
Rollover-safe cards — build identity: readBuildInfo parses build.json (commit, optional builtAt); missing/malformed/blank → {commit:"unknown"} without throwing; healthPayload carries build when given, omits builtAt when absent, no key when not given[unit] src/channels/health.test.ts::build identity on /healthz::*
Rollover-safe cards — bot gate decision: a printed target waits for its exact image, a readable pre-upload application version and a greater post-upload version; then transport/non-200/not-ready/missing identity/another or short commit/draining all wait by name, exact full commit on ready non-draining health is live, and the last waiting reason becomes failure at the deadline[unit] src/deploy/botLiveGate.test.ts::decideBotLive rollback fence::*
Rollover-safe cards — the plan gives the bot a kind: "bot" gate with its Wrangler-derived application name and healthUrl; memory and resident have none, the sandbox keeps its kind: "sandbox" gate, and --dry-run names the bot's application advancement plus exact-commit health fence[unit] src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::the bot and the sandbox carry live gates…
Rollover-safe cards — live: after npx tsx src/cli.ts deploy all, the bot step remains deployed, not live yet until Wrangler reports the printed image target on an application version newer than the pre-upload version and public /healthz is ready, non-draining, and reports the exact full deployed commit; the success line names the application target/version and exact commit, the result table reads live, and exit is 0[agent] Run a real deploy all from a clean origin/main checkout; capture the pre-upload application line, Wrangler target, waiting reasons and final live summary; compare the final application image/version with those receipts and /healthz.build.commit byte-for-byte with git rev-parse HEAD. Negative path: a retained target, unadvanced version, or draining/stale/short health commit reaches deployed, not live: … and exits 4.
Rollover-safe cards — orphan selection: a bot card starting with any live glyph (◐◓◑◒👀) inside the window is picked; ✅/❌/⏹ cards, human messages with a glyph, bot replies without one, and cards older than the window are skipped; a card this process owns is skipped[unit] src/channels/slackCatchUp.test.ts::findOrphanedCards…
The closed card's total is the run's own: freeze(finishedAt) ends every later frame's elapsed at the finish stamp, so a done close painted 9 s later still reads the run's duration[unit] src/core/statusCardFrame.test.ts::createCardShell — every paint comes from one builder::freeze(finishedAt) ends every later frame's elapsed…
Rollover-safe cards — interrupted frame: label + elapsed kept; spinner, the — thinking (…) / — running <tool> (…) suffix, and the trailing drain notice all dropped (the notice in both its unicode-⏸ and Slack :shortcode: history forms — an interrupted card must not claim the bot is "finishing this run"; a label merely mentioning a deploy mid-text is untouched); mrkdwn entities un-escaped; detail explains and says to re-send; a label carrying · routed: ends the detail with the routed card's override footer (routing-and-config.md item 21)[unit] src/channels/slackCatchUp.test.ts::interruptedCardFrame
A follow-up reuses the thread page the bot-in-thread check fetched: history() makes no second conversations.replies; the mention path still fetches[unit] src/channels/slack.test.ts::SlackIO.history — thread reuse and concurrent attachment downloads::uses the thread the handler already fetched…, ::fetches the thread itself when no prefetched page is given…
A rebuilt thread handle has no triggering Slack message to exclude: a coding child in the request's thread reads the parent message and its attachment, while an ordinary incoming reply still excludes only that reply[unit] src/channels/slack.test.ts::SlackIO.history — thread reuse and concurrent attachment downloads::a rebuilt handle keeps the parent message and its plan attachment…, ::uses the thread the handler already fetched…
A message's images download concurrently (every fetch in flight before any finishes) and come back in the original order with budgets intact[unit] src/channels/slack.test.ts::SlackIO.history — thread reuse and concurrent attachment downloads::downloads a message's images concurrently…; budgets: src/channels/slack/attachments.test.ts::fetchImages (attachment ingestion within budgets)::*
Catch-up scans channels concurrently (bounded) and still re-dispatches in channel order[unit] src/channels/slackCatchUp.test.ts::catchUpMissedMentions…::scans channels concurrently (bounded)…; src/core/mapLimit.test.ts::*
Rollover-safe cards — sweep runner: threads active inside the wider 2 h window are fetched (one replies call per thread, shared with the mention scan) and a frozen card in a thread quiet for 41 min is closed while its 👀-acked mention is NOT re-run; without ownedHere+onOrphanedCard the sweep is off (no wider fetch); a rejecting close is logged, the rest still closed, and only successful closes are counted (N of M … closed)[unit] src/channels/slackCatchUp.test.ts::catchUpMissedMentions — orphaned-card sweep
Rollover-safe cards — live: a card a killed process left spinning is closed as interrupted by the next container's connect[agent] Requires a deploy over a run: at a quiet moment start <@bot> agent:general count slowly to 200, one number per tool call and, while it runs, SWITCHBOARD_DEPLOY_FORCE=1 npm run deploy TWICE ~60 s apart (the incident shape); expect the card to first show ⏸ deploy in progress …, then freeze, then within ~2 min of the new container's [catch-up] scanned … log line be rewritten to ❌ interrupted · … with the re-send detail, and [catch-up] C…: 1 orphaned status card(s) closed in the log. A card older than 2 h stays as it is — the sweep deliberately does not relabel history.
Restart without a build — pure halves: decideRestart warns and proceeds over in-flight or draining runs (the handoff owns them), refuses a non-JSON body or impossible count unless forced; authenticateRestart reads identity from the Worker's token map; authorizeRestartDeployer compares that subject with the deployment-profile Worker var, 503 when absent and 403 on another subject, with no runtime-config input; decideRestarted keeps waiting on the OLD startedAt, a refusal-only config body, draining, or no startedAt, and is live only on a later ready generation[unit] src/deploy/restart.test.ts::*, deploy/cloudflare/coordinator.test.ts::the restart route's recovery grant stays outside runtime config::*, src/deploy/liveGate.test.ts::decideRestarted, src/channels/health.test.ts::startedAt on /healthz
Restart without a build — runner + command: freeze one deployment target; require matching parser, owned-slot receipt, complete singleton inventory and current source/durable digest before POST. Missing, changed, foreign or unknown evidence refuses, including under force. Report base-<full-CID> vN, POST the same admin endpoint and accept only a later ready process with the same native/config tuple; CLI only, bot alone[unit] src/deploy/restartRun.test.ts::*, src/core/commands/deploy.test.ts::deploy.restart::*
Restart without a build — live: in an authorized scope already serving an eligible owned consumer, use its exact CLI parser to publish current config to its owned slot, rotate a secret and restart. Verify the returned owned key/version/digest, later startedAt, unchanged full serving commit and native application/singleton, and the new secret. Source drift or foreign/unknown binding refuses before POST; an unauthorized bearer remains refused. This procedure does not bootstrap a missing owned slot or migrate a legacy consumer.[agent] Human-gated (production bot; needs the bearer).
Item 10: SlackIO.attach uploads the text as a snippet in the thread (files.uploadV2: channel, thread_ts, filename, content, the lead as initial_comment in mrkdwn); a failed upload falls back to the chunked text reply carrying lead + text; files:write is in the required scope set[unit] src/channels/slack.test.ts::SlackIO.attach…::*, src/channels/slackCatchUpStatus.test.ts
Item 10: SlackIO.attachFile uploads the bytes as the file in the thread (files.uploadV2: channel, thread_ts, filename/title = name, file = the bytes, no content, the lead as initial_comment in mrkdwn), never a chunked message; a failed upload propagates instead of a silent text fallback[unit] src/channels/slack.test.ts::SlackIO.attachFile…::*
Item 10: SlackIO.uploadTicket mints through files.getUploadURLExternal with the name and size and shares nothing until complete, which calls files.completeUploadExternal with the file id and title, this thread's channel and ts, and the lead in mrkdwn; a ticket without a URL or id is refused by name; a refused mint propagates the platform's words[unit] src/channels/slack.test.ts::SlackIO.uploadTicket::*
Item 10, inbound with a store: the files neither pass carried are staged by reference with their metadata (a video, a 6 MiB PNG, an over-cap PDF), a 1 MiB PNG the image pass carried is not; store off → nothing staged and the note's labels unchanged; a file over 1 GiB, an eleventh file and the overflow past the per-message budget are skipped with the reason; the secret denylist wins over staging[unit] src/channels/slack/attachments.test.ts::stagedFiles (by-reference classification)::*
Item 3: two runs in one thread — the finished run's markers are never skipped because a sibling started: finish then start, the done frame lands on its own card and the shimmer clear at once, the sibling's phrase set with its first frame; start during the finish, the done frame still lands while the live sibling's shimmer stays unwiped, the finished run's re-up goes silent, and the sibling's own done clears the thread[unit] src/channels/slack.test.ts::SlackIO.status — two runs in one thread (slack-channel.md item 3)::*
Items 2–3: the card ticks from the ack and names a slow attach; every close carries the elapsed time; the shape and queued lines lead the detail[unit] src/core/dispatcher.test.ts::executor provisioning by agent resources::a slow attach shows on the card…, src/core/statusCardFrame.test.ts::createCardShell — every paint comes from one builder::a close before the run started paints…, ::a close's shape and queued lines lead its detail…
Item 13: the relay footer parses to its source channel and thread (a reply permalink's thread_ts, a bare p<ts> as the parent; &amp; unescaped) and only as a whole trailing footer — a mid-text phrase, a non-archives link, a bad ts or a malformed URL parse to nothing; the regex removes exactly the footer[unit] src/channels/slack/requester.test.ts::parseRelayFooter::reads the source channel and the thread from the footer's permalink, unescaping Slack's &amp;, ::parseRelayFooter::a bare p<ts> permalink names the thread's parent itself, ::parseRelayFooter::the current footer names the person — on behalf of <@U…>, with or without a display name — and that is read alongside the thread, ::textOfBlocks / rawTextOf::flattens section, context and rich_text blocks to their texts, one line per block, runs rendered as the message text carries them; unknown shapes add nothing, ::textOfBlocks / rawTextOf::the raw text is the message text plus only the block lines that say something the text does not — the footer — and never the request a second time, ::parseRelayFooter::is a whole trailing footer or nothing: mid-text mentions of the phrase, a non-archives link, a bad ts and a malformed URL parse to nothing, ::parseRelayFooter::the regex removes exactly the footer, leaving the request
Item 13: the configured relay app's footer that names the person resolves to them with no API call (the live shape, read from the blocks); a person's own message resolves with no API call and no relay — a reply in another person's thread included; the configured relay app's older footer resolves to the footer thread's parent author, relayed by the app's name, with one replies read that the next request from the same thread does not repeat; an unreadable thread or an app-authored parent falls through to the app by name and the failure is not remembered; a top-level app post with no footer is slack:bot:<id> with the app's name — never unknown[unit] src/channels/slack/requester.test.ts::resolveSlackRequester::a footer that names the person resolves to them with no API call — the live shape, from the configured relay app, ::resolveSlackRequester::a person's own message is the requester, with no API call and no relay — a reply in another person's thread included, ::resolveSlackRequester::the configured relay app's post with an older footer resolves to the person who started the footer's thread, relayed by the app's name, ::resolveSlackRequester::a footer whose thread cannot be read, or whose parent is itself an app's, falls through to the app by name — and the failure is not remembered, ::resolveSlackRequester::a top-level app post with no footer is the app itself by id and name — never \unknown``
Item 13, the relay gate: an app that is not a configured relay is the requester itself whether its post carries the live footer, the older footer or none — the footer's name is not read and no thread is read, and a post with no poster facts is never the relay; with no relay app configured no footer is honoured; an app's reply inside a person's thread with no footer is the app's own, configured relay or not, and the thread's parent is never read; slack.relayApps loads as a list of Slack bot ids and a block that is not a mapping, a list that is not a list, or an entry that is not a bot id (the actor-id spelling, a number, an empty string) fails the load by name[unit] src/channels/slack/requester.test.ts::resolveSlackRequester::an app that is not the configured relay is the requester itself, footer or not — the footer's name is not read and no thread is read, ::resolveSlackRequester::with no relay app configured, no footer is honoured — the posting app is the requester, ::resolveSlackRequester::an app's reply inside a thread a person started, with no footer, is the app's own request — configured relay or not, the parent is never read, src/config/validate.test.ts::validateSlack — the relay apps::accepts an absent block, an absent list and a list of Slack bot ids, ::validateSlack — the relay apps::refuses a block that is not a mapping, a list that is not a list, and an entry that is not a bot id, each by name
Item 13: stripMention drops the trailing relay footer and only as a whole trailing footer; the record carries relayedBy for a relayed request and no key for a person's own, and the validator takes a string and refuses anything else; the viewer match never looks up a slack:bot:… id[unit] `src/channels/slack.test.ts::stripMention — Slack app 'Sent using' footer::drops the trailing 'Sent by Claude in <#C…> · <permalink
Item 13, deployed: a review a Claude Code session posts from a person's thread is billed to that person[agent] after deploy: find a run whose Slack source is a Claude [<session>] post carrying the Sent by Claude in footer; its record (/runs/<id> or the store) has userId: slack:U… of the thread's owner and relayedBy: Claude [<session>]; /costs/switchboard/users.json?days=1 lists no new slack:unknown row after the rollover
Item 14: Slack offers lead with the effect, show Approve and Cancel, and keep the exact escaped command in collapsed Command details; no typed-confirm instructions or duplicate pending-command reply; settings stay unchanged until the saved approval runs[unit] src/channels/slack.test.ts::SlackIO.offer (docs/reference/specs/slack-channel.md item 14)::*, src/core/dispatcher.test.ts::dispatch::quiet config clear shows one approval offer without a duplicate pending command and keeps settings until approval, ::the operator behind routing.operator (record 0057; routing-and-config item 29)::on: quiet config approval shows the offer once without a second command reply
Item 14: the intake acks before any other call, resolves the clicker as the requester (slack:U…, the offer's channel and thread as origin, grants by that id), hands dispatchClick the id and confirm or cancel; the first reply completes the offer message — its line and context kept, the actions block gone, the reply as a section under them, the fallback text carrying both — and a later reply posts in the thread; Cancelled; nothing ran and the three refusal lines read under the line; a click by someone else reaches the core as that actor; a throw is logged, the message completed with the failure line, the handler resolves — and a failure to complete is logged too; a payload without a value, with an unknown confirm.* id or without its message is acked and ignored; a reply longer than a section completes with its first part and posts the rest[unit] src/channels/slack.test.ts::handleConfirmClick — the action intake (docs/reference/specs/slack-channel.md item 14)::*
Item 14: the offer is taken right after the ack and before the core is asked — the first chat.update drops the actions block and adds the note naming the button and the clicker, the completion rebuilds from the offer as posted so the note is replaced by the answer; a take that fails is logged and the click still reaches the core and completes the message[unit] src/channels/slack.test.ts::handleConfirmClick — the action intake (docs/reference/specs/slack-channel.md item 14)::the buttons are gone before the core runs: the edit right after the ack drops the actions block and notes who pressed which, so a second press has nothing to press; the completion then replaces the note with the answer, ::handleConfirmClick — the action intake (docs/reference/specs/slack-channel.md item 14)::a take that fails is logged and the click still reaches the core and completes the message
Item 14: a question's offer posts the refusal's sentence, Did you mean: over the line as code, the evidence as context, and Yes (primary) and No carrying the id; the fallback text carries the line and the marker[unit] src/channels/slack.test.ts::SlackIO.offer (docs/reference/specs/slack-channel.md item 14)::a question's offer (record 0054) posts the refusal's sentence, \Did you mean:` over the line as code, the evidence as context, and Yes (primary) and No carrying the id`
Item 14: the taken note reads the pressed button's own label — a question's Yes reads Yes clicked by …, its No No clicked by … — and a payload without one falls back to Run and Cancel[unit] src/channels/slack.test.ts::handleConfirmClick — the action intake (docs/reference/specs/slack-channel.md item 14)::a question's Yes rides the same intake and the taken note reads the pressed button's own label: \Yes clicked by …` (record 0054)`
Item 14: wiring — a block_actions payload for confirm.run or confirm.cancel processed by the real App is acked and reaches dispatchClick with the button's value, the kind and the clicker's actor[unit] src/channels/slackConfirmClick.test.ts::the confirm.* action intake — Bolt-level wiring (docs/reference/specs/slack-channel.md item 14)::*
Item 14, live: in a channel with no agent scope, "use anthropic/claude-opus-5 for coding in this channel" replies the effect, Approve and Cancel, and collapsed command details, and config show is unchanged; Run updates that message with the receipt and the result and no buttons, config show reflects the change, and the run's audit line reads source: confirm; a second person's Run on a fresh offer reads only the requester can confirm this and changes nothing[agent] Human-gated: after this unit's release, with Interactivity & Shortcuts on (release-and-deploy.md item 30), the three probes above in order; the record of each on the receipts tracker.
Item 14, live, the setting off: the same offer posts with its buttons, a click is refused by Slack and never reaches the bot, and the requester's confirm <saved-id> runs the saved action once with a confirmed record[agent] Human-gated: with Interactivity & Shortcuts off, the probe above, then confirm <saved-id> typed into the original thread; runs list agent=command shows the offered record and the confirmed one. Confirming the id again refuses as already used.

| 8: the bot deploy preflight checks the native application bound by the selected deployment profile, including staging names, and still refuses an unsettled target | [unit] deploy/cloudflare/preflight.test.mjs::bot deploy preflight — selected application::*, src/deploy/plan.test.ts::planDeploy::binds a staging bot preflight to the profile's actual native application |

| Trusted host admission callbacks receive live mentions and reconnect deliveries with the original native author, timestamp message ID and thread. Default dispatch remains unchanged when no callback is installed. | [unit] src/channels/slackConnectedHook.test.ts::connected-hook wiring (Bolt-level harness)::passes live mentions and reconnect delivery to the trusted source callback with the original author and message id | | A confirmation button retains its exact saved ID and current Slack actor when handed to the trusted host callback; it does not execute through a second handler. | [unit] src/channels/slackConfirmClick.test.ts::the confirm.* action intake — Bolt-level wiring (docs/reference/specs/slack-channel.md item 14)::hands a saved button choice to the trusted callback without changing its actor or saved id |

| A private native media request retains the original Slack file IDs, source address, file order and accepted representation hashes. Rehydration uses the authenticated adapter and refuses changed text, edits, authors, files, representations or staging policy. It rechecks the source after downloading; staged-only hashes attest metadata, not file bytes. | [unit] src/channels/slack/nativeMedia.test.ts::Slack original media::* |