Source document copied on 20 October 2026. Its dated evidence and limitations remain as written. Feedback and decisions can be given in chat.

Slack surface for the JR overhaul: source review, 2026-10-20

This is a source review and planning checkpoint, not a live integration pass. It is aligned to Astra's draft spec and tickets, which I read without editing. I made no Slack call, read no token, changed no scope, install or membership, ran no test and made no commit. Astra owns the spec, the tickets, the visual and the commit of this file. The sources and their revisions are in the source index, and the 340-method inventory is in the TSV.

Run by claude-haiku-5-5. The launching process's arguments read --model claude-haiku-5-5 --effort xhigh. That shows the requested model and effort flags. It does not show the effort applied to each call.

The slack-plugin-vs-yours doc is not in this worktree, the JA repo, the agent-setup repo or the orca workspace, so I could not compare against it.

Summary

Identity and scope readback

SLACK_JA_OPS_BOT_TOKEN is the Keychain service label for the bot token. Astra's auth.test on that token resolved to justanotheragent (U0BRM7JT8F4) in T0AUYDT2V2B. So the bot identity is one observed token, and this note does not treat it as a second app. The bot app's ID and its event and interactivity settings are still unread. That is app inspection at implementation time.

The table below uses the readback supplied on 2026-10-20. "Not in readback" means the full scope header did not list that scope. No service call has tested it, so nothing here shows a scope is rejected.

Token Present in readback Not in readback
Bot (justanotheragent) channels:join, chat:write.public, users:read, users:read.email. The earlier readback's history, read, chat:write, file and canvas scopes still stand lists:read, lists:write, app_mentions:read, reactions:read
User (toberton) users:read. The earlier readback's history, read, chat:write, file and canvas scopes still stand lists:read, lists:write, channels:write

The Socket Mode contract

Socket Mode is the native transport this review recommends. Here is the contract as the docs state it.

  1. The app calls apps.connections.open with the app-level token (xapp-, scope connections:write), sent in the Authorization header. The page says a POST parameter errors. Slack returns a WebSocket URL.
  2. The app connects. Slack sends a hello envelope, then pushes events_api envelopes and interactive envelopes (block_actions, view_submission and similar).
  3. Each envelope carries an envelope_id. The app acknowledges by replying with that ID. The Socket Mode page says the ack is how Slack decides whether to retry.
  4. Disconnects come as warning (about 10 seconds' notice), refresh_requested, or link_disabled when Socket Mode is switched off in app settings.
  5. Interactivity is an app setting, not a scope. The starter's manifest carries settings.interactivity.is_enabled.

The starter's entry point shows the split between the two tokens:

app = App(token=os.environ.get("SLACK_BOT_TOKEN"))
...
SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start()

The bot token identifies the app for every Web API call the App makes. The app-level token only opens the socket. Without connections:write on the app-level token, the socket fails to open, and the bot's Web API calls still work.

The starter is the reference for that shape, not something to run. Its sample scopes and events are replaced by JR's set, and the work happens in the isolated WobOS slice. Running it unchanged in a live workspace is not part of this plan.

HTTP is the alternative that was not chosen. The comparison shows why.

Socket Mode HTTP Request URL
Inbound exposure None; outbound WebSocket Public HTTPS endpoint
Event authenticity Pre-authenticated socket; no per-event validation needed (the page says so) Signature check on each request
Acknowledgment Reply with the envelope_id HTTP 2xx within 3 seconds
Retries Ack drives retries; the page gives no schedule Three retries (immediate, 1 minute, 5 minutes) with x-slack-retry-num; Delayed Events adds hourly retries for 24 hours when enabled
Limits Up to 10 connections; refresh every few hours Subscriptions disabled if over 95% of attempts fail within 60 minutes
Fit for a Mac-local app Good Needs a tunnel or hosted endpoint

The 3-second deadline is written for HTTP. The Socket Mode page states no deadline.

What Bolt does with an envelope

This comes from Bolt for Python at 649cfc8632254559d4ad133c99b02d60f1b0d15f, read as source. Astra's subsequent source check found no Slack/Bolt dependency in the candidate's pyproject.toml or uv.lock. Adoption and qualification of a concrete version belong to the later implementation.

The Socket Mode handler calls run_bolt_app, which calls App.dispatch. Then send_response acks the envelope, but only when the response status is 200. Any other status gets logged and nothing is sent. The Socket Mode adapter also copies Slack's retry_attempt into the x-slack-retry-num header, so listeners see retries the same way they do over HTTP.

Listener registration sets one of two acknowledgment paths.

Global middleware runs before either path, so it runs before any ack. If middleware raises, dispatch returns 500, and send_response sends no ack. Slack then treats the delivery as unacknowledged.

Two consequences follow from the source. Both are inferences from the code and the Socket Mode page, and neither has been run.

  1. With Bolt's ack-first event path, a crash inside the handler happens after Slack already has the ack. Slack's retry is decided by the ack, so it would not redeliver that event. Recovery has to come from JR's own Work record, which is what the receipt step below provides.
  2. A receipt written in global middleware happens before the ack. If the write fails and the middleware raises, no ack goes out, and Slack's retry path applies.

Where the native event ID goes

WobOS's Work module at 8a986ec already provides most of the mechanism:

So a native event reaches the Work by no existing identifier. The recommended binding, for WobOS to confirm at implementation:

  1. For a thread's first message, derive task_id from the team, channel and thread_ts. start creates the record exclusively and raises ValueError when the path exists. The middleware must handle that refusal in two ways. A replay, where the event ID is already in the accepted list, is acknowledged and adds nothing. A different first event that lost the create race reloads the record and appends itself under the same lock, because it is new input.
  2. For later messages, keep an accepted-event list on the Work record. Check the native event_id against it and append it under the same lock start and claim_session use. Do this in global middleware, before the ack. A replay finds its ID and does nothing new.
  3. A thread attached later, through an authorized exact reference, is recorded in a linked_threads list on the Work. An unknown thread is found by a bounded scan of the permitted open Work records in the same workspace and channel. No separate index is added.
  4. Each input carries its Slack actor ID in a per-input field. author stays fixed to captain: plus the task ID.
  5. Pending inputs are a new ordered pending_inputs list. The claim takes the oldest queued input, and restart returns an unfinished in-turn input to the queue. The single pending_ask value cannot hold an order.

The existing lock and restart path carry this. It adds fields and a list to the record, but no table and no timer. Astra's subsequent Git comparison found no WobOS/work.py difference between the spec's c15b3c4 and the reviewed 8a986ec. Replay behavior remains untested.

On the retry primitive itself, the Events docs say event_id is globally unique. They do not say whether a retry carries the same event_id, and the Socket Mode page does not describe the retry schedule. The design above holds only if retries reuse the ID, so that is the first thing to confirm when the slice runs.

Thread reads (conversations.replies)

conversations.replies returns a thread, cursor-paginated (tier 3). Its ts argument names the parent message or any message in the thread. Its frontmatter lists the same history scopes for a bot and a user. Scope names alone do not settle access, though.

The replies page has no access section and no not_in_channel error. The retrieving-messages guide says the method covers public and private channels and direct messages, without naming a token type. The rule that is published sits on conversations.history:

The tokens guide agrees on the user side. A user token with channels:history gets access to any public channel.

Working assumption, inferred and to be tested: conversations.replies follows the same rule. Under that assumption, a bot cannot read a thread in a channel it has not joined, a user token is what reads a public thread the bot is not in, and a private thread needs membership on either token. The replies page does not say this, so the checkpoint decides it.

Rate limits: conversations.replies is tier 3 for Marketplace and internal customer-built apps. For new, commercially distributed, non-Marketplace apps it is 1 request per minute with limit capped at 15, from 29 May 2025. Whether JA's app counts as internal customer-built is not established here.

Verified actors

The actor key in each payload is a Slack user ID. The socket is pre-authenticated, so the app does not re-verify each envelope, but it still has to decide which person each ID belongs to. The installation binding Astra described does that. The fields below are what it binds to.

Input Actor and location fields (from the page examples) What the app must do
Message event (message.*) user (the human who posted), channel, ts, event_ts, channel_type Use user as the speaker of this turn
Bot-posted message bot_id in place of a human user (the bot_message page) Ignore the app's own posts by bot_id, or it can answer itself. Whether Slack delivers the app's own posts as events is unverified
Threaded reply thread_ts (the thread_broadcast example) Treat the parent's ts as the thread key
Block action (button) user.id, team.id, container.message_ts, channel Bind the click to that user and that message
Event wrapper authorizations[].user_id names one installer who can see the event One event per workspace, even when several installers can see it, so visibility is not uniform

The spec says a stale click cannot approve newer content. message_ts identifies the message, not its text. So an approval has to be bound to a content hash that JR stored, not to the timestamp alone.

Threads and multi-person Work

A thread's identity is the team, the channel and the parent message's ts. Each reply carries thread_ts pointing at its parent. A resumed Work does not depend on thread history: it resumes from its stored pending inputs and source references. conversations.replies may supply a backfill or a scoped source read, and each message still names its own user, so a backfilled speaker comes from Slack, not from the model's memory. Its token rule and rate tier are the unverified items listed below.

The spec's rule is that a later speaker does not inherit an earlier speaker's access. Each turn therefore needs its own actor check. Reading the thread gives the app every actor's identity. It does not give the app permission to use what each actor could see. That check stays in JR.

Membership changes and audience checks

member_joined_channel and member_left_channel fire for public and private channels and group DMs. The join event excludes one-to-one DMs. Both carry user, channel, channel_type and team. The join event lists channels:read, groups:read and mpim:read. The leave event lists channels:read and groups:read.

An event is a change notice, not an audience proof. The spec wants a read before each context injection and each delivery:

The membership read and the send are separate calls. chat.postMessage has no precondition tied to the read, so a member who joins between them can receive the message. The join event arrives only after the fact. The spec's response is a hold on further sends in that thread and a context rebuild. The window itself is not closed.

Audience rules from WOB-72

WOB-72 is closed, with its accepted scope recorded on 9 October 2026. The rules the spec applies are these:

The recorded answers were "Use that scope" for finance and "Allow those edits to their own tasks" for task edits. WOB-72 also states that recording these decisions changed no app scopes, Slack membership, sharing, bot identity or finance access.

This review keeps two layers apart. The audience rules decide who may see an answer, and JR enforces them in its own logic. A physical grant (a scope, bot membership in a channel, or a List access entry) is a separate permission change, and it needs its exact effect approved after app inspection. WOB-72 settled the audience. It granted no Slack permission. The membership read is what makes the "every member" rule enforceable.

Lists: existing records, scopes and access

Revision-bound actions

Events and dedupe

Every event carries an event_id, which the Events page describes as globally unique. The dedupe rule and its limits are in the Bolt and WobOS sections above. A handler that acts on every delivery can act twice when a retry arrives, and the receipt on the Work record is what stops that.

Edits and deletions follow the same channel and ts identity. message_changed and message_deleted need channels:history and groups:history, as those pages state. chat.update edits only ordinary messages and returns cant_update_message for others. Its error table lists edit_window_closed. chat.delete depends on the token type. A user token may delete messages that user could delete in Slack. A bot token may delete only messages that bot posted.

For mentions, app_mention needs app_mentions:read. The event page's frontmatter writes app_mentions.read with a dot, while the scope index and the assistant manifest use the colon form. I treat the colon form as correct, and the event page as the outlier, unverified against the API. channel_joined lists no scope in its frontmatter, so its requirement is unverified.

No List change event appears in the Events index I read. Stated as a source finding, that means JR learns about row changes by reading Lists when a request or an authorized continuation needs them. It does not call for a timer, and it is not a claim that the API cannot emit such an event.

Vendor surface inventory

The method index lists 340 methods. I read each method page's frontmatter for token types and scopes. 104 accept both token types, 45 are bot-only, 147 are user-only and 44 list no scope. A method with no listed scope is not necessarily open. apps.connections.open, for example, takes an app-level token.

Family Methods Disposition for JR
conversations 28 Core: history, replies, join, members, info, invite
chat 13 Core: postMessage, update, delete, postEphemeral, scheduleMessage. Streaming methods are bot-only; use only if JR streams a reply
slackLists 12 Core, blocked on scopes and access
apps 23 apps.connections.open for Socket Mode; the rest is installation tooling
users 13 users.info and users.list for name resolution
views 4 Modals for approvals. views.open opens a modal from a trigger_id; the page marks that argument optional, and an expired one returns expired_trigger_id
assistant 5 Only if JR uses assistant threads
files 15 Defer until JR sends files. Use the getUploadURLExternal flow, not files.upload
21 other families 92 Adjacent or outside the spec. Defer
admin 121 Out of scope. Needs an Enterprise Grid org-level token, and the JA capabilities doc says JA is not on Grid
Deprecated 14 reminders (5), stars (3), search (3), rtm (2), dialog (1). Their pages list deprecated_endpoint or say so. Avoid

Capability table

"Readback" is the scope list supplied on 2026-10-20. "Not in readback" means the header did not show the scope, not that a call was rejected.

Capability Native route Token and scope (docs) Readback Disposition
Socket Mode transport apps.connections.open, envelopes App-level token, connections:write App token not inspected Use the native shape in the WobOS slice
HTTP request URL Events API request URL Public endpoint, signed requests n/a Defer
Thread reads conversations.replies (t3), keyed by parent ts History scopes; per-token membership rule not stated on this page History present Test bot in and out of channel; user token for public threads the bot has not joined
Thread and DM history conversations.history (t3) Bot: conversations it is a member of. User: all public and its private History present Use
New-message events message.channels, .im, .groups, .mpim *:history History present Use
Bot-posted messages bot_id on bot posts n/a n/a Filter the app's own posts
Edit and delete events message_changed, message_deleted channels:history, groups:history History present Use; confirm subscription
Mentions app_mention app_mentions:read Not in readback Use only if JR is mention-triggered
Membership events member_joined_channel, member_left_channel channels:read, groups:read, mpim:read (join) Read present Use
Current membership conversations.members (t4) *:read Read present Use before each injection and delivery
Shared and external flags conversations.info (t3) *:read Read present Use; Slack Connect behavior unverified
Join a public channel conversations.join (t3) Bot channels:join; user channels:write Bot channels:join present; user channels:write not in readback Only if JR joins channels
Post as the app chat.postMessage (t5, special: about 1 message per second per channel) chat:write Present Use
Post where the bot is not a member chat.postMessage chat:write.public Bot present The API permits posting; JR still requires its audience read before delivery
Edit and delete own posts chat.update, chat.delete (t3) chat:write Present Use, within the authorship rules above
Message revision chat.update by channel and ts; no precondition chat:write Present Content hash on JR's side
Private prompt chat.postEphemeral (t4) chat:write Present Use for approval prompts
Interactive approvals block_actions over the socket No scope; interactivity enabled in app settings Interactivity not inspected Use, with the 3-second ack rule
Modal revision views.update with hash; hash_conflict on mismatch None listed n/a Use for modal approvals
Name lookup users.info (t4), users.list (t2) users:read Present (bot and user); users:read.email present (bot) Use
Lists read slackLists.items.list, items.info (t2) lists:read Not in readback Blocked on scope
Lists write items.create, items.update (no precondition), items.delete, items.deleteMultiple, create, update lists:write Not in readback Blocked on scope
List access entries slackLists.access.set, access.delete (t3); read, write, owner; user or channel targets lists:write Not in readback Permission change; exact approval after app inspection
List change events None in the Events index read n/a n/a Read Lists on relevant requests and continuations
Event dedupe event_id (globally unique per docs) n/a n/a Receipt on the Work record, before the Bolt ack
Admin admin.* (121) Org-level token on Enterprise Grid n/a Out of scope
Deprecated reminders, stars, search, rtm, dialog, files.upload n/a n/a Avoid

Proof checkpoints

Nothing here has run, and nothing runs now. All checks happen at implementation time, inside the isolated WobOS slice, against Toberton's own DM or a labeled disposable test channel or List, under the standing live-testing permission. A simulated check, such as feeding the same envelope twice through a fake client, proves the receipt logic and not Slack's redelivery, and it must be labeled that way. Anything that changes a permission needs Toberton's approval of its exact effect first.

# Scenario What it proves Blocked by
1 Slice connects over Socket Mode; one self-DM event arrives and is acked The native transport shape works in WobOS App-level token and app settings not inspected
2 Timing log for the event path and an interactive button Ack order: events before the handler, interactive handlers within 3 seconds Slice built
3 Withhold an ack on a test event and watch for a redelivery Whether retries reuse event_id Hard to provoke; if no redelivery occurs, the receipt logic stays a labeled simulation
4 Bot and user threads in a labeled test channel the bot has not joined; one public, one private The conversations.replies rule per token Test channel; Toberton's approval for any membership change
5 Add and remove the bot in a labeled test channel with no other members Membership event payloads Subscriptions not inspected
6 items.list on a disposable test List in an isolated test area, then removal Lists read path and cleanup lists:read granted after inspection
7 Write and read back one row on that test List Lists write path and the revision race lists:write granted after inspection
8 Grant and revoke a write access entry for the bot on that test List Whether access entries apply to bot writes Permission change; exact approval first

The permission gap, kept separate

The scope gap is a fact about tokens. The audience rules in WOB-72 are a fact about who may see what. Neither decides the other. A later grant to any token will be presented with its exact effect after app inspection, and it is not a prompt in the current build.

Unverified

Tools and network