<!-- Written per the the-loop:writing skill. -->
Capability: gateway
One ingress process for many instances (issue-496): the gateway owns every inbound transport — the GitHub and Jira webhooks, one Slack Socket Mode connection per app, the pollers — stores each event on its work item's subject before acknowledging the provider, and fans it out on write to the queue of every instance that consumes that subject. Instances poll their own queue and run no ingress of their own. The instance-to-gateway boundary carries no authentication in this release (decision-146): the gateway binds loopback unless
listen.exposed: true, and an exposed gateway is readable, claimable and committable by any peer on that network — front it with the auth-terminating gateway of decision-059 or keep it on a private network. Everything that judges a human (the authorized-actor guard, the control grammar, the Slack allow-list and grants, the GitHub and Jira posting tokens) stays on the instance. Since issue-519 the gateway also makes the Slack Web API calls its instances ask for, with the bot token it already holds for Socket Mode (decision-151): an instance behind a posting gateway needs no Slack token of its own.
What it is
Several instances cannot share one Slack app (each listener is a second Socket Mode consumer) or one webhook URL. The gateway is the one process that holds those transports for a fleet. It knows which work item an event concerns (the subject) and which instances consume it (the consumers), and nothing else: the provider's payload travels verbatim to the instance, where today's pipeline — the router, the dispatcher, the Slack classifier — runs unchanged on the same bytes it would have received directly (decision-145).
The word gateway, unqualified, is this process; decision-059's is the auth-terminating gateway (decision-144).
Current behaviour
The process
the-loop gateway start|stop|restart|status|subjects|instancesrun the gateway the waythe-loop start|stop|status|restartrun the service: a pidfile under the gateway's own state root (<state.root>/gateway.pid) as the lock, a logfile beside it (logs/gateway.out), a foreground entry point (python -m the_loop.gateway.serve) for a supervisor.--confignames the gateway's config, resolved like the CLI config's ($THE_LOOP_GATEWAY_CONFIG,./.the-loop/gateway-config.yaml,~/.the-loop/gateway-config.yaml).gateway-config.yamlis validated against the fourth shipped schema,gateway-config.schema.json, and a violation refuses to start naming each key. Every secret is an environment-variable name (*Env); a pasted value fails the schema's pattern. The file'senv.fileis loaded first, under the CLI config's rules.- The file is re-read on the five-second hash cadence: the enabled set of ingresses is applied as membership (
config.reloaded,gateway.reloaded); a bad edit keeps the previous configuration (config.reload-failed) and stops nothing; the listen address, the Slack tokens and the store path are start-time only, andgateway statussaysrestart requiredwhile the file differs from the running values. - A missing secret variable leaves that ingress off (
ingress.hosted_failed: missing-secret, healthfailed:missing-secret) and the rest serves. A non-loopback bind withoutlisten.exposed: trueexits 2, and so does astore.pathor event-log path that resolves outsidestate.root— the gateway writes under its root only. The default port is 8790, beside the service's 4114 and the stdlib receiver's 8787, so all three start on one laptop with no configuration. - The gateway spawns nothing and holds no harness credential and no GitHub or Jira posting token; its GitHub poll token needs read access only. It does hold the Slack app's bot token — Socket Mode cannot start without it — and since issue-519 it posts with it for the instances (§ Outbound Slack).
The transports
POST /gh-webhookis GitHub's contract verbatim (X-Hub-Signature-256verified before the body is parsed; 401 stores nothing; 202 after the write; 500 when the store fails, so GitHub retries), so a webhook configured for an instance is re-pointed at the gateway without change.POST /jira-webhookis the doorbell's (X-Hub-Signature, 404 without a secret).- Slack Socket Mode is one connection per app for the life of the process, reconnecting on loss, never a second. The envelope is acknowledged after
store.appendreturns, inside Slack's three seconds; a store failure withholds the ack so Slack redelivers. The doctor's heartbeat is consumed by the connection that heard it and never stored. The HTTP routes (/slack/events,/slack/interactions,/slack/commands) sit behindslack.http.enabled, verify thev0signature within the 300 s replay window and answerurl_verification. Under a gateway Slack is read over Socket Mode only, so the mention rule of issue-389 is what applies on every instance: a plainmessagein a room or in a bound thread isnot-addressed, and the mention arrives as theapp_mentionevent — the app must holdapp_mentions:readand subscribe toapp_mention(an app installed before issue-389 needs the manifest re-applied and a reinstall;the-loop channels status --probelists the granted scopes). The poll reader (read.mode: poll), which took every reply in a bound thread as input, does not run under a gateway. - The gateway's poller (opt-in) reuses the instance poller's providers (GitHub, Jira) and produces the same delivery ids and webhook-shaped payloads (
poll-comment-<id>,poll-presence-<ref>-<uuid>,poll-close-…); its ledger lives in the store. At its first sight of an item the existing thread is baselined, not stored — the presence alone is offered and the spawned session reads the thread itself, as the instance poller does — so a new gateway never replays a history as deliveries; the instance poller's exception for an unprocessed control command is not reproduced (the gateway runs no control grammar), so a start posted before the first listing is posted again. A comment seen by webhook and by poll stores one row (the cross-transport dedup key: a comment by its id and a review by its id — an edit or a deletion of either is its own event, keyed on the action and its time — an issue or PR action by object,updated_atand the label, assignee or requested reviewer it names, a closure byclosed_at, CI by ref, name and conclusion), so the poller is the reconciliation path. Presence is offered while an instance has not judged the item started, never twice at once — a presence held with no consumer counts as offered; the instance's commit outcome (spawned,dropped:awaiting-start, …) updates the ledger across the process boundary.
Outbound Slack (issue-519)
- A pass-through, no state.
POST /api/v1/gateway/slack/call {instance, peer, method, params}makes one Slack Web API call with the gateway's bot token and returns Slack's status, body andRetry-Afterverbatim,ok: falseincluded;POST /api/v1/gateway/slack/download {url}fetches a message's file under the instance's own rules (Slack's hosts only, every redirect re-checked, 25 MiB). Nothing is stored, queued or retried on the gateway; a call it cannot make fails the instance's post. - Three checks and the usual guards.
slack.post.enabled(defaulttrue, hot-reloaded), the bot token variable set, and the method in a flat allow-list of the fourteen Slack methods the instance calls (422 method-not-allowed, nothing sent, otherwise) — so the gateway is not a generic bot-token oracle. Atokenkey inparamsis422 param-not-allowed: the SDK would use it in place of the gateway's, so the gateway posts with its own token or not at all (the security review's one finding). Posting off or no token is503 posting-unavailable {reason: disabled | no-token | socket-unconfigured}; a transport failure502 slack-unreachable. Theinstancesallow-list and the peer lease apply as on every consumer route (409). Bodies are bounded at the door (method ≤ 64,params ≤ 256 KiB,url ≤ 2048). - Said, not mitigated. The owner chose a pass-through over a per-instance channel registration at the design review: any peer the gateway accepts may post as the bot wherever the bot can see (decision-146's posture, now stated for posting too). The token never leaves the gateway process and never reaches a log line:
gateway.slack_callcarries instance, method, channel, status andok;gateway.slack_downloadthe host, status and size or reason. - Health says whether it posts.
GET /api/v1/gateway/healthcarriesslack.posting: on | disabled | no-token | socket-unconfigured;the-loop gateway statusprintsslack posting:and carriesslackPostingin--format json.
Subjects, queues, consumers
- Every stored event is on the subject
loop-<slug>of its work item — the tmux slug — with the ten-field envelope of the contract:id,subject,provider,source(webhook | socket | poll),deliveryId,receivedAt,workItem,kind,headers(an allow-list),payload(verbatim),attempt. - The gateway resolves only the reference a payload names directly, the bindings instances have published and the repositories, projects and rooms declared in its config. A pull request with no binding is its own subject; the instance that receives it recognises the linkage with today's router inference and publishes the binding; what was held on the pull request's own subject before the binding is routed onto the work item's claimants by it.
- A Slack event resolves, in order: the thread binding
<channel>:<thread_ts>, then the room an instance published for the channel (issue-505), then the staticslack.roomsofgateway-config.yaml, else it is held under<channel>:<ts>. A room is a work item's collaboration channel (slack@C…, issue-375): every message in it is about that work item, so a top-level message from any collaborator routes to the work item's claimants, whoever registered the author.slack.roomsremains for a room no instance declares. An object that can start no work item is dropped (gateway.dropped: unbound). - Consumers are decided on write, in order: the claimants of the subject and of each linked subject (a PR's ticket), else the one instance that registered the event's actor, else none — the event is held on
unassigned(subject.routed: unassigned) and routed when a claim, a binding or an actor registration later covers it (PUT /actorsanswersrouted). One delivery row per consumer, in the same transaction as the event. - One queue per instance.
POST /api/v1/gateway/fetchidentifies the instance byinstance.name(defaultwhen unset) and a persistedpeerid; refreshes the name's lease (a second peer under a live name is409 duplicate-instance— atPUT /actors,POST /commitand the claim routes as at fetch, so it replaces, commits and releases nothing of the holder's); commits the previous batch (a seq this queue never fetched is409 commit-undelivered, nothing applied, and the client drops that batch rather than resend it); reads the next deliveries aftermax(cursor, committed)withattemptsincremented; and holds an empty fetch open forwaitseconds (clamped toconsume.maxWaitSeconds) until something is appended — an append that lands between the empty read and the wait is not a lost wake-up, and a fetch that wakes after its own lease lapsed re-checks the name's holder before it reads.POST /commitis the step alone, for a stop. No streaming route exists. - At-least-once: a delivery not committed comes back with
attempt + 1; paststore.maxAttemptsit is committed as dead-lettered and kept for inspection. The instance's dedup layers drop a true duplicate. An optionalinstancesallow-list refuses an unknown name (409 unknown-instance). - Retention deletes committed deliveries after
store.retentionDays, dead rows after twice that, and expires pending deliveries and held events older than retention (subject.expired); a retired dedup key is kept in a bounded table so a late redelivery is still recognised.
The instance side
- A
gatewayblock incli-config.yaml(urlrequired;pollIntervalSeconds5,longPollSeconds20,actorsby provider) adds the gateway client to the hosted set and switches off one thing: the Slack listener (and the poll reader), because one app has one Socket Mode connection and the gateway holds it. The webhook receiver and the poller run exactly aswebhooksandpollingsay, gateway or not;the-loop status,the-loop startand health readoff · gateway configuredfor the listener alone. Without the block, exactly as before. The instance'schannels.slack.read.modebeside the block isgateway(issue-503): no socket here, every interactive feature on under its grants, and a deleted block reads nothing rather than opening a second socket. 20.3.0'ssocketthere keeps working, is flagged in the log and inchannels status, andmigrate-configrewrites it.channels listenrefuses to open a socket while the block is present. The gateway's own GitHub and Jira pollers are opt-in (github.poll.enabled,jira.poll.enabled, off in the template): the gateway is the webhook receiver and the Slack connection unless no inbound webhook is possible, in which case it polls for the fleet. - The client registers first (
PUT /actors, then every live session's claim re-asserted), polls its queue with the server'scommittedas cursor (or the highest delivery whose dispatch is still in flight, so nothing is fetched twice while a resume is spawning), dispatches each delivery through today's pipeline by provider — the router and dispatcher for GitHub, the Jira doorbell,slack.dispatch_envelopefor Slack — undergateway_event_idon everydispatch.*record, and sends the outcomes with its next fetch (delivered|spawned|dropped:<reason>|failed; a dispatch still in flight is asked again before each fetch, so a commit never runs ahead of the dispatcher).the-loop stopflushes withPOST /commit. - A registered session publishes a claim —
driverfor a ticket,reviewerfor a pull request whose control command isreview— and releases it when it closes; a linked pull request and a bound Slack thread publish bindings. A binding names its instance and peer like a claim: allow-listed, refused for a stale peer under a live name, and recorded asgateway.bound, so a binding that redirects a subject is never anonymous. - The instance publishes its Slack collaboration channels as rooms with
PUT /api/v1/gateway/rooms(issue-505): the whole set{channel id: work item ref}, replacing the instance's previous one. It publishes at every registration (so a gateway that lost its store, or a room removed while the instance was stopped, is repaired), at once when a declaration changes in the daemon (CollaborationChannelStore.on_change), and every 30 s for a change another process made (the-loop channels add). A new room drains the events held under its channel's keys onto the work item's subject. A room another instance holds with a live lease stays the holder's and is reported inrefused(gateway.refused: duplicate-room); an instance can replace or retract only its own rooms; a pruned instance's rooms go with it. Each publication is recorded asgateway.rooms, andGET /api/v1/gateway/instanceslists each instance's rooms. A room moves events and grants nothing: the instance applieschannels.slack's principals, the room's listen mode,routing.authorizedUsersand the roster to what it is handed. - Posting through the gateway (issue-519). The instance's Slack client comes from one resolver,
outbound_client(): its own bot token first (channels.slack.botTokenEnv, unchanged for every deployment that has one), else — with agatewayblock — a gateway-backed client with the SDK's method names, each call onePOST /slack/call; neither is today'sChannelError, now with the gateway hint. The channel, the name→id directory, the doctor and the attachment fetcher (through/slack/download) all use it, so a thread opens, is bound and is published exactly as with a local token. A gateway that is unreachable or refuses fails the post — one error, no fallback to a token, no retry logic of its own; the existing outbox treats it like any failed Slack call.GET /api/v1/gatewaycarriesslackPostingfrom the gateway's health and the Gateway card readsconnected · long-poll · posting via gateway. - The instance says when nothing can post (issue-519). With Slack enabled and no posting path — no token, and no gateway or a gateway reporting posting off (or one that could not be asked at start) —
the-loop startlogs one warning naming the variable and emitschannel.posting_unavailable, then starts anyway;the-loop statusappendsoutbound Slack needs <var> on this instanceto the gateway-client row (orposting through the gatewaywhen that is the path);channels statusprints aposting:line and a[!]finding (the one line of that command that asks something: the gateway client's document from the local service, never Slack; with the service down it says the answer is not yet known);doctor slackan[ok]/[!]/[?]gateway line, and its heartbeat probe isunverifiablewith the same reason. Slack disabled prints none of it. - When the gateway is unreachable the instance keeps running, records
gateway.unreachableonce, retries with back-off (1 s → 60 s), recordsgateway.connectedon recovery, and never falls back to its own ingresses. A409is logged with the holder and retried on the same schedule. A fetch answer that is not the contract's shape (a missing field, a non-list, an unknown provider, more than 64 MiB) is treated as the gateway being unreachable, never as an exception that ends the hosted client: the gateway's answers are untrusted input to the instance. A loopback URL is treated exactly as a remote one. A peer id never leaves the gateway: a 409 andgateway instancescarry a fingerprint of the holder's id (eight hex characters of its SHA-256), because the id admits a fetch under the name. GET /api/v1/gatewayon the instance API serves the client's document; the dashboard's instance pane renders it as the Gateway card (the state in words beside a decorative dot:connected · long-poll,connecting · attempt n,refused · duplicate instance (409),unreachable · retrying,not configured; the queue table by subject and role).the-loop doctor slackcounts the gateway's connection as the app's one listener.
Observability
- Two event families on the existing JSONL log:
gateway.*(started,stopped,received,dropped,refused,reloaded,claimed,released,registered,unreachable,connected,bound) andsubject.*(appended,routed,committed,redelivered,dead-lettered,expired). The payload never reaches a log line; a length-capped summary does. the-loop gateway subjectslists every subject with its claimants, events, held, pending and dead counts and the oldest uncommitted age;gateway instancesevery instance with its lease, last fetch, backlog, actors and claims;gateway statusthe pid, the ingress states, the Socket Mode state, the store, the live instances and what needs a restart.
What it does not do
- What an unauthenticated peer may do is bounded (security review, round 1): every body field has a length and the providers an enum (a 422 at the door writes nothing); at most one open long-poll per name and 32 in all, the rest answer as plain polls, and 256 open requests before the server answers 503; retention prunes a name whose lease lapsed with nothing queued (its claims, actors and condition) and a subject with nothing left;
/healthcarries no path and no bind address. On the provider side a declared body above 25 MiB is a 413 before it is read, a header value chosen by an unauthenticated peer is clipped to 200 characters before it is logged, and a non-ASCII signature is a 401.gateway status --format jsonlists each declared secret name as set or unset (secretsSet), never a value; a Jira poller without its secrets is off at start like the GitHub one. - No instance authentication (decision-146), no work-item lease over claims (one user's subjects on several instances), no multi-replica gateway; the
Storeinterface is the seam for the last, and the allow-list check is where a bearer token attaches. - The gateway runs no control grammar and no authorization: a forged event through the gateway is a forged event at the instance, judged as it always was. It does post to Slack for its instances (issue-519), and because the boundary carries no authentication an exposed gateway lets any peer on that network post as the bot;
slack.post.enabled: falseturns that off.
Design
docs/specs/issue-496/design.md · decision-144 (the name) · decision-145 (the queue contract) · decision-146 (no instance auth) · the contract · gateway command · moving to a gateway · gateway options (the instance's block) · instances · channels · webhook-triggers · control-plane · observability
History
| Work item | What changed | Links |
|---|---|---|
| issue-519 | Outbound Slack through the gateway, and a loud failure when it cannot (2026-10-23): POST /api/v1/gateway/slack/call and /slack/download make Slack Web API calls and file downloads for an instance with the bot token the gateway already holds — a pass-through behind slack.post.enabled (default on), a flat allow-list of the fourteen methods the instance calls and the usual consumer guards, no state, no retry; health.slack.posting and gateway status say whether it posts. On the instance one resolver picks the posting path (own token first, else the gateway, else today's error with the gateway hint) for the channel, the directory, the doctor and attachments; start, status, channels status and doctor slack say which path is in use and name the missing variable when nothing can post. The owner withdrew a per-instance channel registration at the design review: any accepted peer may post wherever the bot can | spec, decision-151, issue |
| issue-505 | Collaboration channels reach the gateway (2026-10-20): the instance publishes its declared Slack channels as rooms (PUT /api/v1/gateway/rooms, a replace-set per instance), the gateway resolves a Slack event through a published room after the thread binding and before slack.rooms, a late room drains the channel's held events, a live holder keeps its room, and GET /instances lists rooms. A collaborator's top-level message in a declared room is no longer held | spec, issue |
| issue-503 | The instance side gets channels.slack.read.mode: gateway (2026-10-20): the explicit "no Socket Mode here" setting, with interactivity keyed on reachability rather than on socket; fail closed without a block; migrate-config rewrites 20.3.0's socket beside a block | spec, channels, issue |
| issue-496 | The capability, whole (2026-10-19): the gateway process and its verbs, gateway-config.yaml and schema, the GitHub/Jira/Slack transports, the SQLite store with subjects and one queue per instance, consumers resolved at ingest by claim then actor, pull with commit and long-poll, at-least-once with dead-letter, the instance's gateway block and client, claims and bindings published by the instance, GET /api/v1/gateway, the Gateway card, the two event families | spec, decision-144, decision-145, decision-146, issue |