Discord ingest — the gateway as a managed service¶
Status: IMPLEMENTED 2026-07-28. All exit criteria met; see §9. Phase: 1b (delivery plan). Implements: R-ARCH-1, R-ARCH-2, R-ARCH-8, R-OPS-1, R-OPS-5, R-OPS-6, R-OPS-7, R-OPS-8, R-OPS-8a, R-OPS-8b, R-ANS-17, R-GOV-1.
1. What this phase is¶
The bot connects to Discord, watches the channels it is told to watch, and writes what it sees to a log. It posts nothing, answers nothing, and moderates nothing.
That is the entire scope, and the restraint is deliberate. Phase 1b is where the lifecycle is got right — connect, stay connected, notice when you did not, shut down cleanly — with no answering logic to obscure whether that lifecycle works. Every later phase runs on top of it.
Out of scope: answering, escalation, moderation, slash commands, interactions, the index, any
LLM call. Config.ReadOnly is set, so the provider yields no Actor and none of those are
reachable even by mistake.
2. Why read-only is a type, not a flag¶
chat-platform returns a Provider whose Actor is nil when Config.ReadOnly is set, which
removes Moderator, Interactive and Commands along with it.
Phase 1b sets it. The bot in this phase is incapable of posting, not merely configured not to — there is no method to call. This is the strongest form R-OPS-7 shadow mode can take, and it is worth having before the bot is ever pointed at a live server.
3. Package boundary¶
Library-first, per the project's standing rule: the feature lands in pkg/ and the command is a
thin caller.
pkg/ingest/ the gateway service: lifecycle, health, the message loop
pkg/ingest/config.go the configuration types and their validation
pkg/cmd/serve/ the `phpbotscout serve` command
pkg/ingest depends on chat-platform and never on chat-platform-discord except for the blank
import that registers it — which lives in the command, not the library. A depfootprint guard
asserts that pkg/ingest's graph contains no Discord client
(R-ARCH-1).
That boundary is what makes the ingest loop testable against a fake provider, and what makes a second platform a configuration change rather than a rewrite.
Dependencies¶
| Module | Version | Used for |
|---|---|---|
chat-platform |
v0.1.0 | the contract |
chat-platform-discord |
v0.1.0 | blank import in pkg/cmd/serve only |
controls |
v0.1.3 | service lifecycle, health reporting |
transport/http |
v0.2.0 | the health listener; v0.2.0 is the first release with ServerSettings.Host |
credentials |
v0.2.2 | token resolution |
redact |
v0.1.1 | the logging boundary |
4. Configuration¶
Under the discord key, resolved through the config Store with the PHPBOTSCOUT env prefix.
discord:
guilds:
- space: "1531227937678954747"
channels: ["1531227938622800055"]
token_ref: "env:DISCORD_PHPBOTSCOUT_TOKEN"
health:
host: "127.0.0.1"
port: 8081
| Key | Type | Default | Notes |
|---|---|---|---|
discord.guilds[].space |
string | required | The guild ID. One provider, one space. |
discord.guilds[].channels |
[]string | required | The allowlist. Empty permits nothing. |
discord.guilds[].token_ref |
string | env:DISCORD_PHPBOTSCOUT_TOKEN |
A credentials reference, never a literal. |
health.host |
string | 127.0.0.1 |
Explicit interface. Never 0.0.0.0. |
health.port |
int | 8081 |
The list shape is deliberate, and v1 enforces exactly one entry. Multi-tenancy remains a v1 non-goal (§2), so validation rejects a second guild and nothing untested ships. But a provider is scoped to one space at construction, so multi-guild is N provider instances and N registered services — the contract needs no change, and the loop that would iterate them exists from day one. The cost now is a list with one element; the cost of retrofitting is a breaking config migration for every operator plus a lifecycle refactor.
The allowlist fails closed. An empty list permits nothing, because a watchlist that silently
means everywhere is the wrong default for reading people's messages. Validation rejects an empty
channels at startup rather than starting a bot that reads nothing and looks fine.
Note that the allowlist, not space, is what actually constrains ingest: channel IDs are globally
unique snowflakes and the provider filters on them alone. space is an authorisation boundary for
guild-scoped actions — it stops a shared invite link turning an unintended server into one the
bot serves — and in this read-only phase it is carried but unused.
The token is a reference, not a value. It resolves through
gitlab.com/phpboyscout/go/credentials (R-OPS-6). No
bare os.Getenv anywhere in this phase.
Hot-reload¶
channels hot-reloads via Store.Watch. It is pure filtering with no session involved, so a new
allowlist takes effect on the next message.
space and token_ref are read once at Start. A change to either logs a WARN naming the field and
stating that a restart is required. This is a deliberate, narrow deviation from
R-OPS-5, which has been amended to carve it out so the
requirement stays literally true.
The reason is asymmetric risk. Applying an identity change means tearing down a live gateway session on a config write; if the new value is wrong the bot leaves Discord and cannot return until someone notices, and the failure arrives whenever the file happened to be edited rather than when anyone was watching. Ignoring the change costs nothing but a restart, and the WARN means the operator is told rather than left wondering why nothing happened.
5. Lifecycle¶
Two controls-managed services.
controller.Register("discord-gateway/"+g.Space,
controls.WithStart(svc.Start),
controls.WithStop(svc.Stop),
controls.WithLiveness(svc.Live),
controls.WithReadiness(svc.Ready),
controls.WithRestartPolicy(controls.RestartPolicy{...}),
)
transporthttp.Register(ctx, "health", controller, logger, nil,
transporthttp.ServerSettings{Host: cfg.Health.Host, Port: cfg.Health.Port}, tlsPair)
Start resolves the credential, builds the provider through chatplatform.Lookup("discord"),
calls Connect, and launches the message loop. It returns an error rather than retrying; restart
policy is controls' job, not ours.
Stop closes the reader, which closes the message feed and ends the loop. Close is safe on a
provider that never connected and safe twice, so shutdown after a failed start cannot panic and
bury the original error.
Restart policy uses exponential backoff. A gateway that cannot connect will not connect harder by being asked more often, and Discord rate-limits identifies.
6. What "healthy" means here¶
This is the part worth getting right, because a support bot's characteristic failure is silence, and silence looks identical to a quiet channel.
Liveness — the process is running and the message loop goroutine has not exited. Answers "should this be restarted".
Readiness — Reader.State().Healthy(), i.e. the gateway is connected. Answers "is this bot
currently seeing messages". A reconnecting bot is live but not ready, which is exactly the
distinction R-OPS-1 asks for.
Reconnect loss is a separate signal, not a health failure. ConnState.LastReconnectLostEvents
reports that a reconnect re-identified rather than resumed, and therefore dropped every message
buffered during the gap. That session is usable — restarting it helps nothing — so it must not
fail readiness. It is logged at WARN and counted, because it is the one failure that produces no
error and no crash, and for this bot it means questions nobody will ever answer.
This is the property chat-platform-discord's forced-disconnect regression test exists to protect,
and Phase 1b is where it first has a consumer.
transport/http mounts /healthz, /livez and /readyz against the controller, outside the
middleware chain, so none of this is local code. The listener binds loopback by default
(R-OPS-8b); transport v0.2.0 is the first release whose
ServerSettings carries Host, and pinning it is what keeps the bind explicit rather than
:port across all interfaces.
7. The message loop, and the logging boundary¶
for msg := range reader.Messages() {
log.Info("message",
"channel", msg.ChannelID,
"author", msg.Author.ID,
"content", redact.String(msg.Content),
)
}
Three rules, none negotiable:
Message content passes through redact (R-ANS-17).
Discord message content is untrusted input from members of a public server; it reaches a log line
here and an LLM prompt later. It is redacted at the boundary, once, rather than at each call site.
Identity is logged by ID, not by name. A display name is user-controlled and changes; an ID is stable and is what a moderator would act on. It also keeps the log free of text somebody chose.
No @ reaches any output. Nothing in this phase writes to a forge, but the log is read by
people and copied into issues. Logging handles as IDs sidesteps the question entirely until the
shared inert-mention helper arrives in Phase 3.
The loop must not block: the provider already drops rather than stalling the gateway, and the consumer must not undo that by doing slow work inline. Phase 1b only logs, so this is trivially satisfied — but it is stated because Phase 3 will be tempted.
Rejected messages¶
Discord's intents are guild-wide, not per-channel. There is no way to ask the gateway for only the allowlisted channels, so the bot receives every message in the guild and discards the ones it must not read. Filtering is necessarily client-side, and R-GOV-1's policy statement must distinguish what the bot receives from what it retains.
Since the receipt has already happened, the only question is what is persisted. The answer is one unlabelled counter and nothing else:
plus a single line at startup naming the channels actually being watched:
That lets an operator confirm the configuration is what they meant, and distinguishes a correctly filtering bot from a broken one, without retaining anything about activity in channels the bot was told not to read. No content, no channel ID, no per-channel counter — a labelled metric would build an activity profile of exactly those channels, and metrics outlive the rollout that justified them.
8. Resolved questions¶
All five resolved 2026-07-28, plus one raised during review.
Q-1b-1 — command surface. phpbotscout serve, with the gateway as its only registered
service in this phase. Not a dedicated ingest command: §10 of the requirements already specifies
serve as "run the daemon (all enabled subsystems)", so an ingest command would both contradict
an approved document and be vestigial by Phase 3. Later phases register more services alongside it.
Q-1b-1a — guild scoping (raised during review). Configuration takes a list of guild
blocks, with exactly one entry enforced in v1. Guild identity is discoverable — the gateway
supplies the guild list on READY and every message carries its own GuildID — so space is not
a lookup but an authorisation boundary against a shared invite link. Multi-tenancy stays a v1
non-goal, but the possibility of separate servers for spun-out projects is real enough that the
config shape should not have to break to accommodate it. See §4.
Q-1b-2 — hot-reload scope. Allowlist hot-reloads; space and token_ref require a
restart, logged as a WARN naming the field. A narrow, recorded deviation from R-OPS-5, which is
amended to carve it out. Rationale in §4: the failure mode of applying a bad
credential is a live outage arriving at an arbitrary time, and the failure mode of ignoring it is a
restart.
Q-1b-3 — rejected-message logging. A counter only, plus the effective allowlist logged at startup. No content, no channel ID, no labels. See §7.
Q-1b-4 — health listener. Ship it in 1b, via transport/http v0.2.0 bound to loopback.
Initially proposed for deferral on the grounds that the listener would be local code; that was
wrong — transport/http already mounts /healthz, /livez and /readyz against
controls.HealthReporter, and v0.2.0 added the Host bind setting that R-OPS-8b requires. The
mistaken analysis came from a checkout twenty commits stale, which is now a standing check.
Q-1b-5 — dry-run flag. Omitted. The phase cannot post by construction, so a shadow-mode flag would toggle nothing and would be untested until something used it. Shadow mode arrives in Phase 3, where the bot first has output to withhold.
9. Exit criteria¶
All met. pkg/ingest holds 93.9% coverage under the race detector with no lint
issues, and the daemon was verified against the live test guild.
Two findings came out of building it. The reconnect-loss check originally ran
only when a message arrived or readiness was polled, so a lossy resume during a
quiet spell — the one nobody would notice — could go unreported indefinitely; it
now has its own ticker, because the signal is about the connection rather than
about traffic. And the credential shape in §4 was corrected to the toolkit's
three-mode auth convention, replacing a token_ref string this spec had
invented and which exists nowhere in the estate.
- The bot connects to the test guild and logs messages from allowlisted channels only.
- A message in a non-allowlisted channel of the same guild is not logged, and increments the rejected counter.
/readyzreports not-ready while disconnected and ready once connected;/livezstays ready across a reconnect.- A forced disconnect resumes, and
LastReconnectLostEventsstays false — verified by the same TCP-proxy technique the provider's regression test uses, run against phpbotscout's own service rather than the library. SIGTERMshuts down cleanly: the reader closes, the loop ends, no panic, non-zero exit only on real failure.- A second guild entry is rejected at startup with a clear error.
pkg/ingesthas ≥90% coverage, and itsdepfootprintguard proves no Discord client in its dependency graph.- E2E BDD coverage in
features/for connect, ingest, allowlist rejection and graceful shutdown, gated behindINT_TEST_E2E=1.
10. Testing approach¶
Unit — the message loop, config validation and health functions are tested against a fake
chatplatform.Provider built in-package. No Discord, no network. This is what the contract
boundary buys.
Integration — against the dedicated test guild only, never the live server, gated on
INT_TEST_DISCORD=1. Covers a real connect, a real message, and the forced-disconnect resume.
BDD — features/discord-ingest.feature, step definitions in test/e2e/steps/, covering the
service lifecycle as an operator experiences it.