Skip to content

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".

ReadinessReader.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:

metric  discord_messages_rejected_total     (no labels)

plus a single line at startup naming the channels actually being watched:

INFO watching channels [1531227938622800055] in guild 1531227937678954747

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.

  1. The bot connects to the test guild and logs messages from allowlisted channels only.
  2. A message in a non-allowlisted channel of the same guild is not logged, and increments the rejected counter.
  3. /readyz reports not-ready while disconnected and ready once connected; /livez stays ready across a reconnect.
  4. A forced disconnect resumes, and LastReconnectLostEvents stays false — verified by the same TCP-proxy technique the provider's regression test uses, run against phpbotscout's own service rather than the library.
  5. SIGTERM shuts down cleanly: the reader closes, the loop ends, no panic, non-zero exit only on real failure.
  6. A second guild entry is rejected at startup with a clear error.
  7. pkg/ingest has ≥90% coverage, and its depfootprint guard proves no Discord client in its dependency graph.
  8. E2E BDD coverage in features/ for connect, ingest, allowlist rejection and graceful shutdown, gated behind INT_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.

BDDfeatures/discord-ingest.feature, step definitions in test/e2e/steps/, covering the service lifecycle as an operator experiences it.