Skip to content

Slack channel with coherent runtime and access guardrails - #403

Draft
alex-clickhouse wants to merge 18 commits into
alex/slack-channel-foundationsfrom
alex/slack-channel
Draft

Slack channel with coherent runtime and access guardrails#403
alex-clickhouse wants to merge 18 commits into
alex/slack-channel-foundationsfrom
alex/slack-channel

Conversation

@alex-clickhouse

@alex-clickhousealex-clickhouse commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Part 2 of the Slack channel stack. Adds a Slack adapter over Socket Mode: it
receives events on an outbound WebSocket, so it needs no public URL. Access is
refused by default, each thread in a shared channel is its own session, and one
object owns every start, stop, and reload.

Stack

  1. Shared chat-channel foundations #404: shared chat-channel foundations
  2. Slack channel with coherent runtime and access guardrails #403: Slack channel, access guardrails, and runtime ownership (this PR)
  3. Deliver notifications through Slack #405: Slack notification delivery
  4. Add retry-safe live Slack contract tests #406: real-Slack contract tests and diagnostics

How a message becomes a session

  • A mention in a shared channel starts a thread, and that thread is one
    session keyed slack:<channel>:<thread_ts>. Replies in it continue the
    session with no further mention.
  • A thread the bot does not already own is ignored unless somebody mentions
    the bot in it.
  • A shared channel never gets a channel-wide session. Every conversation is a
    thread, so two people can run separate tasks in one channel without their
    turns mixing.
  • A direct message is one conversation, keyed slack:<dm_id>.
  • Messages another app wrote itself are ignored, so two agents in one channel
    cannot answer each other without end. A person posting through an
    integration still reaches the agent, because they keep their own user id.
  • slack.reply_in_thread is removed. A slash command payload carries no
    thread id, so in a shared channel a command either offers a picker of that
    channel's live threads or refuses and asks for a DM.

Who may talk to it

The agent can run tools and shell commands, so the gate is closed until it is
opened on purpose.

  • With no allow_users, no allow_channels, and no DM grant, every message
    is refused. Startup logs a warning saying so, because an unconfigured
    policy is silent otherwise.
  • Deny rules always win. An identity Slack could not fully resolve cannot
    clear a deny list.
  • Sender and conversation are separate checks and both have to pass.
  • A grant matches a member id, a handle, or a verified email. It never matches
    a display name or full name, because a member edits those and could
    otherwise rename themselves onto the allow list. Deny rules do match those
    names, since refusing on more names than a grant may rest on is safe. The
    matching itself lives in Shared chat-channel foundations #404; this PR decides which Slack aliases fall on
    which side.
  • Every entry point is checked: channel messages, mentions, reactions, slash
    commands, and button presses.
  • DMs need allow_direct_messages: true as well as a sender grant.

Starting, stopping, and reloading

Slack hands each event to exactly one of an app's open Socket Mode
connections. Two open connections therefore lose events rather than duplicate
them, and most of the rules below exist to keep that from happening.

SlackRuntime is the only thing that starts, stops, reconnects, or
reconfigures the channel. It holds one lock, so those operations run one at a
time.

A channel that is still starting can receive but cannot send. It is
registered with the router as soon as it exists, so an event arriving during
startup has somewhere to go. Anything asking whether it can send through
Slack gets no until the channel is fully running. Without that split, a
notification could be recorded as delivered through a channel with no working
connection.

Changing tokens keeps one connection at a time. New tokens are validated
against Slack first, which needs no connection. Only then is the old socket
closed, and only after that does the new one connect. If the new tokens fail
to connect, the previous connection is restored. If that restore also fails,
the channel stops rather than stay up with credentials nobody can name.

Tokens for a different workspace need a restart. Session keys and cached
ids belong to the workspace that issued them, so a live reload refuses the
change and reports why.

A config change lands whole or not at all. The running channel reads one
snapshot, and access rules, enabled commands, workspace identity, and tokens
all advance together after a successful reload. A failed token change keeps
the previous snapshot. Otherwise a message could be checked against new access
rules and answered with old credentials.

Shutdown is final. The daemon stops Slack before it stops the workspace
sync loop, and that loop gets a bounded wait to finish the cycle it is in,
which ends in a config reload. That reload does not bring Slack back.

Repairing a dropped socket blocks nothing else. The watchdog takes the
lifecycle lock only to decide whether a reconnect is needed, then reconnects
outside it. Holding the lock across a reconnect made every config reload and
the shutdown itself wait on an unreachable Slack.

Failure messages carry no tokens. Both the tokens being replaced and the
ones replacing them are removed before a message reaches a log or an HTTP
response.

Generic reload code stays generic.config_reload.py asks the runtime to
reconcile and holds no Slack token, construction, or drain logic.

Where the code lives

FileOwns
slack_access.pyComposing the Slack user, channel, and DM rules on top of the transport-neutral matching from #404.
slack_presentation.pyMarkdown to Slack mrkdwn, and Block Kit builders. Calls no API.
slack.pyTransport, identity lookup, and event handling.
slack_runtime.pyThe lifecycle above.

Other behavior worth knowing

  • An envelope is acknowledged before the agent runs, because Slack retries
    anything unacknowledged after three seconds and an agent turn takes longer.
    Repeat deliveries of one event collapse into a single run.
  • Concurrent dispatches are capped. Disabling the channel drains acknowledged
    work with a timeout before closing the socket.
  • Streaming edits are metered per conversation rather than per message,
    because that is how Slack meters chat.update. A failed edit still holds
    the interval, so a rate limit cannot turn into a retry loop inside the
    agent's token loop.
  • Attachments reuse the bounded archive handling from Shared chat-channel foundations #404.

@alex-clickhouse
alex-clickhouseforce-pushed the alex/slack-channel branch 6 times, most recently from 4589ac8 to 2a93b8bCompareAugust 23, 2026 17:52
@alex-clickhouse
alex-clickhouse changed the base branch from main to alex/slack-channel-foundationsAugust 23, 2026 17:52
@alex-clickhousealex-clickhouse changed the title Slack channel with access guardrailsSlack channel with coherent runtime and access guardrailsAug 25, 2026
@alex-clickhouse
alex-clickhouseforce-pushed the alex/slack-channel branch 2 times, most recently from 4ea7a12 to ca702e1CompareAugust 25, 2026 14:43
Implement Socket Mode transport, fail-closed sender and conversation policies, per-thread sessions, streaming, file handling, reactions, slash commands, reconnect supervision, configuration, and daemon lifecycle. Cover the channel with a local Web API/Socket Mode stand-in plus unit and integration tests.
Keep the stronger behavioral coverage while dropping duplicate and misleading cases. Consolidate long-message splitting and losslessness into one assertion path.
Rotate the Web API and Socket Mode clients as one active credential pair during config reload. Preflight both tokens, prevent overlapping sockets, bound connection attempts, and restore the prior transport on failure.
A button press was authorized only when the payload carried a channel, so
a block_actions envelope without one reached stop_session, the star
toggle, and the notification answer route with no policy check at all.
Slack omits the channel for interactions on a view surface. Refuse a
press that names no conversation, then authorize every press.
Allow rules matched profile.display_name and profile.real_name, which a
member edits at will, so any member could rename themselves onto
allow_users and take a full agent turn. Split the resolved aliases: a
grant may rest on the member ID, the handle, or the verified email, while
a deny rule keeps matching the self-set names too. A grant that matches
only a self-set name is refused with a reason that names the fix.
A shared-channel reply continues an owned thread with no further mention,
so two agents in one channel answered each other without end once a person
mentioned both. With a channel-only grant, which docs/config.md offers for
shared channels, nothing stopped the exchange.
bot_id alone cannot decide it: a person posting through an integration
keeps their own user id and gains the app's bot_id, and those messages are
meant to arrive. Ask users.info whether the sender is a bot user, cache the
verdict beside the resolved names, and treat an unresolved sender next to a
bot_id as an app. A message with no bot_id costs no lookup.
A Slack ts is unique inside one conversation, not across the workspace, but
the reaction cache was keyed on ts alone. A reaction in one channel could
therefore find a cached message from another, and the turn was authorized
against the channel the reaction came from while being routed into the
session of the channel that held the cache entry.
Key on the conversation and the ts together. The stored target still keeps
the thread, which the key does not.
Three faults in one owner, fixed together because they share its
structure.
shutdown() left no mark, so the next reconcile took the enable path and
built a fresh channel, socket and watchdog. The lifespan stops Slack
before it stops the sync loop, and that loop is given a bounded wait to
finish the cycle it is in, whose git phase runs in a worker thread past
cancellation. A reload therefore arrives after shutdown and reopened a
socket that outlived the process. Mark the runtime closed and report the
reload as such.
The watchdog held the lifecycle lock while rebuilding the socket, which
waits on a close and a connect. While Slack was unreachable that blocked
every config reload and the shutdown for up to the sum of those bounds.
Hold the lock for the decision, repair outside it, and refuse a rebuild
once the channel is stopping. Repair and rotation still serialize on the
channel's transport lock.
Redaction read the active generation after stopping had already cleared
it, so disabling Slack and removing its tokens in one edit put the live
token verbatim into the summary that reload_all returns over HTTP.
Capture the secrets before the transition and redact every failure
leaving reconcile against them.
Seven outbound methods checked only that a Web client existed. That
attribute is also set while the channel starts, and again while a rotation
validates the next credential pair before connecting its socket, so a
caller could reach Slack through a client whose generation is not the one
serving events.
send() had the worse shape: it returned quietly, and StreamAdapter reads a
quiet return as a delivered reply and deletes the streaming placeholder,
so the user was left with neither the placeholder nor the answer. It now
refuses, which is what its own docstring already promised, and the adapter
takes its recovery branch.
Post paths refuse. Best-effort paths, which already swallow their own
failures, stay quiet and now agree with is_available.
Slack rate limits chat.update per conversation, but the interval was held
per StreamAdapter and there is one of those per inbound message. Several
threads streaming in one channel therefore went over the limit together.
The SDK answers a 429 by sleeping inside the request, and the streaming
listener is awaited from the agent's token loop, so those sleeps stalled
the run.
Two changes. The adapter stamps the interval before the attempt, so a
failed edit still holds it instead of letting every later token retry at
once. And an edit the caller can afford to lose is marked throttle=True,
which lets Slack shed it against a per-conversation clock. A final or
recovery edit leaves the flag unset and always goes out, because that
path is the only thing between a failed send and a lost reply.
Raise the failed-edit log out of debug; the interval bounds its rate.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@alex-clickhouse