phpbotscout: draft requirements¶
- Authors
- Matt Cockayne, Claude Opus 5 (AI drafting assistant)
- Date
- 2026-07-25
- Status
- APPROVED — 2026-07-26
- Scope
- Governing requirements document. Individual feature specs live alongside this file and must
cite the
R-*identifiers they implement.
1. Problem¶
Support for the phpboyscout projects currently happens in two disconnected places. Questions arrive in Discord, where they are answered from memory, scroll out of view, and are asked again a fortnight later by someone else. Actionable problems raised in Discord rarely become GitLab issues, because doing so means a context switch, and the ones that do lose the conversation that produced them. Meanwhile the answers already exist — in documentation sites, READMEs, changelogs, and closed issues — but nobody reads them at 22:40 when they have a stack trace in front of them.
Moderation has the same shape. A public Discord server needs someone watching it, and that someone is currently a human who is asleep for eight hours a day.
phpbotscout closes both loops: it answers what is already documented, converts what is not into a tracked GitLab issue with the conversation attached, and flags moderation risk to a human queue rather than to nobody.
The third-order benefit is the one that matters most: every question the bot cannot answer is a documentation gap with a timestamp on it. The escalation stream is a prioritised docs backlog that writes itself.
2. Goals and non-goals¶
Goals¶
- Answer support questions in public Discord channels from a corpus of public phpboyscout documentation and codebases, with citations.
- Escalate unanswerable questions into GitLab issues that carry the full Discord context, with the asker's explicit consent.
- Triage moderation risk to a human mod queue, with a complete audit trail.
- Treat the chat platform and the issue tracker as provider contracts owned by the phpboyscout Go toolkit, so Discord and GitLab are the first implementations rather than the only ones, and the contracts outlive this bot.
- Surface knowledge gaps as a first-class reportable output.
- Run as a single, observable, gracefully-degrading service using the existing phpboyscout Go toolkit.
Non-goals (v1)¶
- Autonomous moderation enforcement. The bot never decides to act. It detects, scores and reports; a human decides. It does execute the decision an authorised moderator makes, one click earlier — that is agency, not autonomy, and the distinction is what keeps the audit trail from depending on self-reporting. See §6.
- Private or customer-confidential knowledge. The corpus is public sources only. There is no access-control model for answers in v1, so there must be nothing in the index that any Discord member may not see.
- Voice channels, DMs as a support surface, or ticketing-style private threads.
- Multi-tenant operation. One bot, one Discord guild, one GitLab group.
- Replacing human support. The bot answers the documented question and routes the rest.
3. Architecture¶
3.1 Provider contracts¶
The single most important design decision is that Discord and GitLab sit behind provider
contracts, not vendor SDKs threaded through the business logic. This mirrors the
gitlab.com/phpboyscout/go/forge design — a registry, a contract, and per-vendor provider modules
that register at init().
Decided: neither contract lives in this project. Issue operations extend the existing forge
module upstream; chat platforms become a new module pair in the toolkit. phpbotscout is the first
consumer of both, not the owner of either.
| Capability | Home | First provider | Later candidates |
|---|---|---|---|
| Chat platform — receive messages, post replies, manage threads, apply moderation actions | new: go/chat-platform + go/chat-platform-discord |
Discord | Slack, Matrix, Teams, IRC |
| Issue tracking — create/search/comment on issues, watch state changes | extend: go/forge (+ forge-gitlab) |
GitLab | GitHub, Gitea, Bitbucket |
| Knowledge source — enumerate and fetch indexable documents | local to phpbotscout (pkg/knowledge) |
GitLab repo, docsite | Blog, Confluence, local dir |
Issue support in forge (Q3 — resolved)¶
forge today is release-operations only (Provider, Release, ReleaseAsset,
ChecksumProvider, SignatureProvider) — it has no issue support. Extending it is the
intended trajectory for the module, so issue operations land there as an optional capability
interface discovered by type assertion, following the pattern ChecksumProvider and
SignatureProvider already establish.
R-ARCH-6 — Issue operations MUST be defined in gitlab.com/phpboyscout/go/forge as an
optional capability (working name IssueProvider), discovered by type assertion, so that adding it
breaks no existing forge.Provider implementation — including ones outside the phpboyscout
repositories.
R-ARCH-7 — The GitLab implementation MUST land in forge-gitlab, and the capability MUST be
covered by forge's existing conformance harness. A provider that cannot file an issue must fail
the type assertion cleanly, never panic or return (nil, nil).
R-ARCH-8 — phpbotscout MUST consume the released forge/forge-gitlab modules. It MUST NOT
vendor, fork, or shim the issue API locally; if the upstream API is wrong, fix it upstream.
Chat platform modules (Q4 — resolved)¶
No Discord module exists in the toolkit today. A new module pair is introduced, named and
structured to match the forge/forge-gitlab and chat/chat-anthropic precedents:
gitlab.com/phpboyscout/go/chat-platform— the contract, registry, credential chain, and conformance harness. No vendor SDK; enforced by adepfootprintguard.gitlab.com/phpboyscout/go/chat-platform-discord— the Discord provider, registering itself atinit().
R-ARCH-9 — chat-platform MUST carry no vendor SDK and no HTTP stack in its dependency graph,
guarded by a depfootprint test, exactly as forge does. The whole point of the boundary is that
accepting a chat-platform.Provider costs a consumer nothing but the contract module.
R-ARCH-10 — The contract MUST separate read (receive messages, fetch history, resolve
users) from act (post, thread, react, moderate) so a provider can be granted read-only
credentials and the boundary is enforced by the type system, not by convention. This is what makes
R-OPS-7 shadow mode structurally safe rather than a
runtime if.
R-ARCH-11 — Moderation actions (delete, timeout, ban) MUST be an optional capability discovered by type assertion, not part of the base contract. A platform provider without moderation support is legitimate — a read-only bridge, or a platform with no equivalent concept.
R-ARCH-11a — phpbotscout does exercise this capability from Phase 5, because the bot
executes moderator decisions (R-MOD-15a). The capability is therefore
required in the Discord provider on the Phase 5 timeline, not deferred as originally assumed — a
consequence of resolving Q-I4, and one that moves work into the
upstream chat-platform-discord module rather than this repository.
Cross-repo work
Phases 1 and 4 change three repositories beyond this one: go/forge, go/forge-gitlab, and
the two new chat-platform* modules. Follow the cross-repo worktree workflow — never edit a
sibling repository in place from this checkout — and land the upstream module releases before
the phpbotscout work that consumes them.
R-ARCH-1 — Business logic MUST depend only on the contract interfaces. A vendor SDK import
outside its provider package is a defect. Enforce with a depfootprint guard test, as forge
does.
R-ARCH-2 — Providers MUST self-register under a string key so a platform this project has never heard of can ship as a third-party module.
R-ARCH-3 — Each contract MUST ship a conformance harness (RunProviderConformance) that
verifies sentinel errors, nil-return discipline, and bound enforcement. The compiler checks method
sets; only a harness checks behaviour.
3.2 Service composition¶
The bot is a long-running daemon. Each subsystem registers with
gitlab.com/phpboyscout/go/controls as a managed service with declared startup ordering, a health
check, and a graceful shutdown path.
┌──────────────────────────────────┐
Discord gateway ───▶│ ingest → classify → route │
(websocket) └────────┬──────────────┬──────────┘
│ │
┌───────▼──────┐ ┌────▼─────────┐
│ support flow │ │ moderation │
└───────┬──────┘ │ flow │
│ └────┬─────────┘
┌───────────▼────────┐ │
│ retrieve (hybrid) │ │
│ index → agentic │ │
└───────────┬────────┘ │
│ │
┌───────────▼────────┐ ┌──▼──────────┐
│ answer + cite │ │ mod queue │
│ or escalate │ │ (private) │
└───────────┬────────┘ └─────────────┘
│
┌───────▼────────────┐
│ forge IssueProvider│
└────────────────────┘
R-ARCH-4 — Every subsystem MUST be independently disableable by config, so the bot can run answer-only, moderation-only, or observe-only.
R-ARCH-5 — Loss of any single downstream (Discord, GitLab, the LLM provider) MUST degrade that capability without terminating the process. A GitLab outage must not stop moderation.
3.3 Phase 1a-0: the Discord library spike¶
No Go Discord library is adopted on reputation. All three viable candidates are prototyped behind
the chat-platform contract and evaluated against a fixed rubric.
The spike has two objectives, and the second is the more valuable one:
- Choose the library backing
chat-platform-discord. - Validate the contract. An abstraction proven against exactly one provider is that provider's API with different names. Three independently-shaped libraries is the strongest available test of whether R-ARCH-10's read/act split and R-ARCH-11's optional moderation capability are real boundaries or wishful thinking. Contract changes forced by the spike are a success, not scope creep — they are far cheaper now than after Phase 3.
R-ARCH-12 — Each spike MUST implement the identical vertical slice: connect to the gateway, receive a message on an allowlisted channel, and post a threaded reply — written against the contract, not against the library directly.
R-ARCH-13 — Candidates MUST be scored on the rubric below, and the outcome MUST be written up
as a dated decision record in docs/development/specs/, including the rejected options and why.
A choice nobody can reconstruct the reasoning for is a choice that gets relitigated every year.
| # | Criterion | Why it matters | How it is tested |
|---|---|---|---|
| 1 | Reconnect and resume | The production failure mode. A library that silently re-IDENTIFYs instead of RESUMEing drops events, and the bot misses questions | Force-close the socket mid-session; assert session resumption and zero missed events. Exercise the 4xxx close codes and invalid-session paths |
| 2 | Rate-limit handling | Discord 429s aggressively; a naive client gets the bot throttled or banned | Drive past a bucket limit; assert bucket-aware backoff and global rate-limit handling |
| 3 | Contract fit | Direct measure of objective 2 | Lines and awkwardness of adapter code; whether read/act separates cleanly or the library forces a god-object |
| 4 | Privileged intents | MESSAGE_CONTENT is a privileged intent and the whole bot depends on it |
Confirm minimal-intent configuration; the library must not force requesting more than needed |
| 5 | Threads | R-ANS-10 puts every answer in a thread | Create a thread from a message, post to it, handle an archived thread |
| 6 | Dependency footprint | R-ARCH-9 | go mod graph size and transitive dependency count |
| 7 | Test ergonomics | Determines whether the provider is testable without network | Can the gateway be faked in-process, with no live Discord connection |
| 8 | Observability | Logging must reach GTB's logger | Quality of the logging hook; native slog support is a direct fit for logger.ToSlog |
| 9 | API currency and cadence | Discord deprecates gateway and API versions on a schedule | Default API version; release recency; whether pinning a tag is viable or main is required |
R-ARCH-14 — The spike MUST be time-boxed and its artefacts discarded. Spike code is evidence, not a foundation; the chosen provider is written properly, test-first, in Phase 1a.
4. Support and answering (R-ANS)¶
4.1 Triggering¶
R-ANS-1 — The bot MUST respond when directly addressed: a mention, a reply to one of its
messages, or an explicit /ask slash command.
R-ANS-2 — The bot MAY volunteer an answer to an untriggered question in a configured channel, subject to a confidence threshold set separately from — and higher than — the addressed threshold. Unsolicited answering is off by default. An eager bot that interjects with a wrong answer is worse than no bot.
R-ANS-3 — The bot MUST ignore its own messages, other bots, and any user or role on the configured ignore list.
R-ANS-4 — Channel scope MUST be an explicit allowlist. A channel is not watched until it is named in config.
4.2 Retrieval — hybrid¶
Retrieval is two-stage: a cheap local index first, an agentic tool loop when the index is insufficient.
R-ANS-5 — Stage one MUST query a persisted local keyword/BM25 index over the corpus and score the results.
R-ANS-5a — The index MUST be SQLite FTS5, sharing one database with all other persistent
state (§8.3). FTS5 ranks with BM25, supports incremental updates
(R-KB-7), and keeps the index in the same transactional store as the
audit trail, escalation links and feedback — so retention purges and deletion requests are a
DELETE, not a cross-store consistency problem.
CGO_ENABLED=0 is a default, not a constraint
The scaffolded pipeline sets CGO_ENABLED=0 and builds linux/darwin/windows × amd64/arm64.
That default exists to make a six-target cross-compilation matrix work for tools users
install on their own machines — gtb, keyrx, afmpeg.
phpbotscout is not that. It is a daemon that runs in one place (Q1 — resolved), so the distribution matrix itself is wrong before cgo is even considered: nobody installs a Discord daemon on Windows. Trim the matrix to the deployment target and cgo becomes a live option, to be taken when a dependency justifies it.
The build requirements this implies are R-OPS-18 and R-OPS-19.
R-ANS-5b — Retrieval MUST sit behind a narrow Retriever contract — query in, scored
provenance-carrying results out — with the SQLite FTS5 implementation as the first and only
implementation. The contract is roughly an hour of work and buys three things: adding a vector
retriever becomes a new implementation rather than a refactor, swapping the engine stays cheap if
FTS5 proves insufficient, and §8.1's benchmark can be run against
retrieval independently of model choice.
R-ANS-6 — When stage one does not yield material sufficient to answer, stage two MUST invoke an
agentic tool loop via gitlab.com/phpboyscout/go/chat, exposing tools for: search repository code,
fetch file at path, search issues, fetch issue, fetch documentation page.
Sufficiency is a judgement, not a score
This requirement originally read "when stage-one results fall below a relevance threshold". That was measured and found unworkable (knowledge-index Q-2-7): across 36 calibration questions, retrieval scores for answerable and unanswerable questions overlap, and no threshold separates them. Excluding every unanswerable question would cost 40% of the answerable ones.
The cause is structural rather than tuning. BM25 measures term overlap, not whether a passage answers a question — so a question about a subject the corpus covers scores well even when the answer is absent. A score cannot see the difference; it is the same signal either way.
A numeric floor still has a job: discarding noise. It cannot be the gate.
R-ANS-6a — Declining is a first-class outcome. The answering path MUST be able to conclude that retrieved material does not answer the question, and MUST return that conclusion as a deliberate result rather than as an empty answer, an error, or a hedged attempt. A component that can only produce answers will produce one for every question, including the questions whose correct answer is that the corpus does not contain one.
R-ANS-6b — Sufficiency MUST be judged against the retrieved passages, not against the question alone or the model's own knowledge. The judgement is "do these passages support an answer", and an answer that cannot be grounded in them MUST NOT be returned however plausible it is. This is what stops a confident answer arriving with a genuine citation to a page that does not say it — the failure calibration found at score 29.01, where a question about phpbotscout's production model matched phpbotscout's own policy page.
R-ANS-6c — A decline MUST be distinguishable in telemetry from a failure. "The corpus does not cover this" is a knowledge gap and the product's highest-value output; "the model was unreachable" is an incident. Recording them the same way loses the signal R-KB-6 and the gap backlog both depend on.
R-ANS-7 — The tool loop MUST be bounded — maximum iterations, maximum wall-clock, maximum token spend per question — and MUST fail into the escalation path rather than looping.
R-ANS-8 — The corpus MUST be restricted to an explicit source allowlist. A private repository appearing in the index is a P1 security defect, not a bug.
4.3 Answering¶
R-ANS-9 — Every answer MUST carry citations: linked source with a stable URL, ideally line-anchored, for each substantive claim.
R-ANS-9a — Citations MUST name the validated target, never the issue that led to it (R-KB-1d). An issue is a routing hint, not evidence; citing it lends third-party text authority it has not earned.
R-ANS-10 — Answers MUST be posted in a thread on the triggering message, not inline in the channel, so channels stay readable.
R-ANS-11 — The bot MUST state uncertainty explicitly rather than confabulating. "I could not find this documented" is a valid, and often correct, answer.
R-ANS-11a — Where the only topical material is issue prose carrying no validatable signpost, the question is unanswerable and MUST take the escalation path (R-KB-1b).
R-ANS-12 — Answers MUST be length-bounded to a Discord-sane size, with a link out to the full source rather than a wall of pasted code.
R-ANS-13 — Each answer MUST carry a feedback affordance (reaction-based: helpful / not helpful). Feedback is the only honest signal of answer quality and feeds §8 observability.
R-ANS-13a — The confidence score MUST be recorded alongside each answer and its feedback, even though it is never shown to the user. Confidence is surfaced by hedging the prose rather than by displaying a number (Q-I2) — a percentage invites arguing with the number and readers calibrate to it badly. Hiding it from the interface is a presentation decision, not a reason to discard it: the volunteer threshold (R-ANS-2) and the agentic-fallback threshold (R-ANS-6) can only be tuned by correlating confidence against feedback. Without the pair, both stay at whatever they were first guessed at.
R-ANS-14 — Per-user and per-guild rate limits MUST apply, with a cooldown after repeated low-value interactions.
R-ANS-18 — Graduation from shadow mode to live MUST be gated on a count of reviewed answers (30), not on elapsed time. Time measures the calendar and assumes traffic; a count is robust in both directions — a busy server clears it in days, a quiet one takes longer but still produces a real signal rather than an expired timer. The §8.1 benchmark remains the primary quality gate; shadow mode confirms behaviour in the wild, which is a different question from whether the answers are good.
4.4 Prompt injection¶
Discord message content is untrusted input that reaches an LLM prompt, a GitLab issue body, and a log line.
R-ANS-15 — Message content MUST NOT be able to alter the bot's instructions. Retrieved documents and user messages MUST be delimited as data, never concatenated into the instruction region of the prompt.
R-ANS-16 — No privileged action (issue creation, moderation action, config change) may be triggered by message content alone. Every write action requires either a human confirmation (R-ESC-2) or an authorised role.
R-ANS-16a — The moderation action path MUST be reachable only from a verified component interaction by an authorised role, and MUST share no code path with anything an LLM emitted or that message content can influence. The two paths must not merely be guarded separately; they must not meet.
This follows directly from R-MOD-15a: the bot executes moderator decisions, so the process holds destructive Discord permissions. Before that decision, the worst outcome of a successful prompt injection was a bad answer; after it, the blast radius includes deleting messages and timing out members. This separation is what keeps that blast radius theoretical.
R-ANS-17 — All free-form text written to logs or telemetry MUST pass through
gitlab.com/phpboyscout/go/redact.
5. Escalation to GitLab (R-ESC)¶
This is the highest-value and highest-risk flow: it is the bot writing to a system of record.
R-ESC-1 — When the bot cannot answer with sufficient confidence, it MUST offer to raise a GitLab issue on the asker's behalf.
R-ESC-2 — Issue creation MUST require explicit affirmative consent from the asker in the thread. The bot never files silently. This is both a quality gate and a privacy gate — filing an issue publishes a Discord user's words to a public tracker.
R-ESC-3 — Before offering, the bot MUST search existing issues for duplicates and, on a credible match, link the existing issue instead of opening a new one.
R-ESC-3a — On a duplicate the bot MUST also offer to subscribe the asker to the existing issue, so they are notified in their own thread when it resolves. Telling a Discord member to subscribe on GitLab is not a real option — many have no account there (Q-I3).
R-ESC-3b — Subscription counts MUST feed the knowledge-gap ranking. Repeated subscriptions to one issue are the most honest demand signal the bot produces: they say which gaps hurt repeatedly, rather than merely which were asked about once.
R-ESC-4 — The issue body MUST contain: the question as asked, a permalink to the Discord thread, the asker's Discord handle, what the bot searched and why it failed, and a transcript or summary of relevant thread context.
R-ESC-5 — Issues MUST be labelled for provenance (e.g. support, from-discord) so the
escalation stream is queryable and separable from human-filed work.
R-ESC-6 — The bot MUST route the issue to the correct project. Routing rules (keyword, channel, or LLM-classified) MUST be configurable, with a fallback project for the ambiguous case.
R-ESC-7 — On creation, the issue URL MUST be posted back into the Discord thread, closing the loop for the asker.
R-ESC-8 — When the issue is closed, or receives a maintainer comment explicitly marked for relay, the bot MUST notify the originating Discord thread and every subscriber (R-ESC-3a). This turns a fire-and-forget escalation into an actual support experience.
R-ESC-8c — Relay MUST be scoped: closure, plus comments a maintainer deliberately marks. Everything else — triage discussion, working theories, cross-references — stays on the tracker. Relaying every comment would push internal conversation into a public Discord channel, and maintainers who notice that begin self-censoring in their own issue tracker (Q-I8).
R-ESC-8d — The marking convention MUST be trivial, client-agnostic and mention-safe — a leading
marker line carrying no @ token (R-ESC-9) — and MUST be
documented in the issue footer the bot writes. The footer then serves two audiences: provenance for
the reader, and instructions for the maintainer who wants to answer the person waiting.
R-ESC-8e — Closure relay MUST carry the resolution reason, including the closing comment where one exists. "Closed" alone is nearly useless to the asker: fixed, won't-fix and duplicate mean entirely different things to someone who has been waiting.
R-ESC-8a — State changes MUST be discovered by polling, not by an inbound webhook (Q1 — resolved). The bot polls only the issues it created and has an open Discord thread for, so the query set is bounded by its own escalation volume rather than by project size. Poll interval MUST be configurable; minutes-scale latency is acceptable for this notification.
R-ESC-8b — The tracked link set MUST model one issue to many subscribers, each with their own thread — not a single issue-to-thread pair. Subscription (R-ESC-3a) means several people across several threads may await one issue. It MUST persist across restart: an escalation whose notification is lost because the process bounced is a broken promise to the asker.
R-ESC-9 — Issue content MUST NOT contain @-mention tokens. Bare tokens like @cli resolve to
real GitLab usernames and notify those people on every reference — and a Discord handle is not a
GitLab handle, so any collision is with an unrelated stranger who gets pinged on every reference to
an issue they have nothing to do with.
Attributing the asker is wanted, not avoided — with their consent
(R-ESC-2), the issue names who raised it. The requirement is that
the handle is rendered inert: strip the leading @ and wrap it in backticks, so the issue
reads reported by `someuser` in Discord rather than reported by @someuser.
R-ESC-9a — Neutralisation MUST be applied by a single shared helper at the point of issue-body
construction, not ad hoc per call site, and MUST cover every artefact the bot creates on any
forge: issue titles and bodies, comments, MR descriptions, and commit messages. Any @ reaching a
forge payload is a defect regardless of whether it came from a Discord handle, quoted message
content, or an LLM-generated summary.
R-ESC-9b — The helper MUST have direct unit tests over an adversarial corpus (handles with
embedded @, @everyone/@here, role and channel mention syntax, unicode look-alikes, @ inside
fenced code blocks) and MUST be exercised by an assertion that no generated forge payload matches a
live-mention pattern.
R-ESC-10 — Escalation MUST be rate-limited per user and globally. A malicious or confused user must not be able to turn the bot into an issue-spam cannon.
6. Moderation (R-MOD)¶
Per the v1 decision: no autonomous action. The bot never decides to act — but it does execute decisions an authorised moderator makes (R-MOD-15a). Thresholds are tuned against real traffic before any autonomous action is considered.
\"No autonomous action\" permits delegated action
These are different properties and the distinction determines what Discord permissions the bot is granted. Autonomy is the bot deciding to act — that is prohibited in v1 and beyond it until the false-positive rate justifies otherwise. Agency is the bot carrying out a decision a human already made, one click earlier. That is permitted, because the alternative — moderators acting by hand while the bot records what they say they did — makes R-MOD-11's twelve-month audit commitment depend on self-reporting, and self-reported audit trails decay (Q-I4).
6.1 Detection¶
R-MOD-1 — The rule engine MUST support layered detection: literal wordlists, regular expressions, heuristics (link reputation, message rate, duplication), and LLM classification for context-sensitive categories.
R-MOD-2 — Every regex originating from config MUST compile via
gitlab.com/phpboyscout/go/regexutil.CompileBounded. A moderation ruleset is user-supplied
patterns evaluated against attacker-supplied input — the canonical ReDoS setup.
R-MOD-3 — Detection categories MUST include, at minimum: harassment and targeted abuse, hate speech, sexual content, spam and scam links, phishing, doxxing and PII exposure, and self-harm content.
R-MOD-4 — Self-harm content MUST be handled as a distinct category with its own response path (escalate immediately, surface support resources, never auto-delete). Treating a person in crisis as a rule violation is a harm, not a moderation success.
R-MOD-5 — Context matters: quoted abuse, reclaimed language, and code containing flagged tokens are common false positives. Rules MUST be able to require LLM adjudication before escalating a category.
R-MOD-6 — Trusted roles MUST be allowlistable, with heightened scrutiny for new accounts and first messages.
6.2 Raid and spam protection¶
R-MOD-7 — The bot MUST detect anomalous patterns: join-rate spikes, message-rate spikes, identical content posted across multiple channels, and mass-mention messages.
R-MOD-8 — On a suspected raid, the bot MUST alert the mod channel immediately with a recommended action, since this is the one scenario where human latency is genuinely costly.
6.3 Reporting and audit¶
R-MOD-9 — Detections MUST be reported to a configured private moderator channel, never in the public channel where they occurred. Publicly announcing a detection amplifies the content and humiliates the user.
R-MOD-10 — A report MUST contain: message permalink, author, matched rule and category, confidence score, surrounding context, and a recommended action.
R-MOD-11 — Every detection, report, and human action taken MUST be written to an append-only audit log with a stable identifier, sufficient to answer "why was this user actioned" months later.
R-MOD-12 — Moderators MUST be able to mark a detection as a false positive, and false-positive rate MUST be a reported metric. A moderation system without a measured false-positive rate is unaccountable.
R-MOD-12a — The bar for graduating moderation from advisory to enforcing MUST be pre-registered before measurement: a false-positive rate below 5% across at least 50 detections. A threshold chosen after seeing the results is a threshold chosen to be met, and the whole value of the number is that it is fixed while there is no sunk cost in the ruleset.
R-MOD-12b — If the bar is not met, moderation remains advisory permanently. This is an explicitly legitimate terminal state, not a failed phase: a system that only ever surfaces detections to humans is worth having, and naming that outcome as acceptable in advance is what prevents a sunk-cost argument for enforcing before the ruleset has earned it.
R-MOD-12c — The self-harm category is excluded from this gate, and from the false-positive metric generally. Every other category treats a false positive as the harm to guard against; self-harm inverts it — a false positive offers someone support they did not need, a false negative misses someone in crisis. That category is therefore tuned for recall, accepting more false alarms. It also never enforces (R-MOD-4), so including it in an enforcement-readiness threshold would conflate two unrelated action classes and pull its tuning in precisely the wrong direction.
R-MOD-13 — The bot MUST support a shadow mode in which detections are logged and reported but visibly marked as non-acting, for threshold tuning before go-live.
6.4 Authority and appeals¶
v1 takes no autonomous action, so every enforcement decision is a human one that the bot merely informed. That shapes both halves of this section: authority governs who may act on a report, and appeals exist because a detection the bot got wrong becomes a human decision the affected user needs somewhere to contest.
Who may act¶
R-MOD-14 — Moderator authority MUST be an explicit allowlist of Discord role IDs in configuration, hot-reloadable under R-OPS-5. Authority MUST NOT be inferred from Discord's own permission bits: someone granted Manage Messages to pin announcements would otherwise silently acquire authority over the moderation queue.
R-MOD-15 — The bot MUST verify the actor's roles before honouring any action on a report, and MUST record the acting user in the audit entry (R-MOD-11). An unauthorised attempt MUST be refused and logged, not silently ignored — a failed attempt to action a report is itself worth seeing.
R-MOD-15a — The bot executes the action an authorised moderator selects — delete, timeout, ban — rather than recording an action taken elsewhere. The decision remains human; only the execution is delegated. This keeps the audit trail accurate by construction: the bot logs the action it actually performed.
R-MOD-15b — The bot MUST be granted the narrowest Discord permission set that supports the actions its interface offers. Permissions are added when a button demands one, never pre-emptively, and the reversible action is preferred wherever both would serve.
R-MOD-15c — Irreversible actions MUST confirm. Delete and timeout may execute on click; ban opens a confirmation carrying the reason, because its undo is a conversation rather than a button.
R-MOD-15d — The moderation action path MUST satisfy R-ANS-16a: reachable only from a verified component interaction by an authorised role, sharing no code path with anything an LLM emitted. Granting the bot destructive permissions raises the cost of a prompt-injection failure from a bad answer to a deleted message.
R-MOD-16 — Authority MUST be re-checked at the point of action, not cached from when the report was posted. Roles change, and a report can sit in the queue for days.
Appeals¶
R-MOD-17 — When a human acts on a report, the affected user MUST be told what happened, why, and how to contest it. Notification MUST be by direct message, never in the public channel — announcing an action publicly re-amplifies the content and humiliates the user, the same reasoning that puts reports in a private queue (R-MOD-9).
R-MOD-17a — Notification is best-effort; knowing whether it arrived is not. Many members block direct messages from server members, and some are actioned after leaving. The bot MUST record the delivery outcome in the audit entry and surface a failure on the report card, so a moderator can decide whether to reach the person another way (Q-I5).
R-MOD-17b — An undeliverable notice MUST NOT fall back to a public post. That would guarantee delivery at the cost of publishing an enforcement action against a named member — amplifying the incident and humiliating the subject, which is precisely what R-MOD-9 exists to prevent.
R-MOD-18 — An appeal MUST route to the moderator queue with the original audit record attached — the message, the matched rule, the score, and who actioned it. A moderator reviewing an appeal without the original evidence is guessing.
R-MOD-19 — Appeal outcomes MUST be recorded against the original audit entry, and an overturned appeal MUST count as a false positive feeding R-MOD-12. Without this the false-positive rate only ever counts the mistakes moderators catch themselves — precisely the ones least likely to be systematic — while the mistakes users catch go unmeasured.
R-MOD-20 — The self-harm category (R-MOD-4) MUST be excluded from the notification and appeals flow entirely and handled on its own path. Sending "your message violated a rule, here is how to appeal" to someone in crisis is a harm, not a process.
R-MOD-21 — The appeal route MUST be documented in the user-facing policy (R-GOV-1), not only surfaced at the moment of action. A user who has left the server, or who never received the DM because their privacy settings block it, still needs a route.
7. Knowledge index (R-KB)¶
7.1 Corpus scope — a discovery rule, not a curated list¶
The corpus is defined by a predicate, not a hand-maintained allowlist. A repository qualifies if it publishes a public documentation site built with zensical — the phpboyscout default docs toolchain. That marker is doing real work: it means the project is public, is documented, and has had documentation effort invested in it, which is exactly the population a support bot should answer from. It also means the corpus grows as the toolkit grows, with no config change.
R-KB-1 — A source qualifies for indexing if and only if all of the following hold:
- It resides in the
phpboyscoutGitLab group (including subgroups such asphpboyscout/go). - It carries a
zensical.toml— the docsite toolchain marker. - It publishes a publicly reachable GitLab Pages documentation site.
- The GitLab API reports its visibility as
public(R-KB-2a). - It does not match the exclusion list (R-KB-2).
Indexable content per qualifying source: the rendered documentation site, in-repo documentation and READMEs, changelogs, source code, and closed issues — the last on restricted terms, below.
Issues signpost; they do not answer¶
R-KB-1b — Issue and comment text MUST NOT be used as answer content — not quoted, not paraphrased, not summarised. Anyone with an account can open an issue on a public repository, so issue prose is arbitrary third-party text. R-ANS-15's delimiting defends the instruction form of that problem but cannot touch the content form, which does not attack the system at all: plausible, wrong prose is retrieved, found topical, and presented as fact with a genuine citation. An issue may tell the bot where to look; it may never tell the bot what to say.
R-KB-1c — Issues contribute signposts only: a resolvable reference to a documentation page, a file path in a qualifying repository, or a commit or merge request that resolves to code. A reference to another issue is not a signpost — signposts MUST terminate in documentation or code, since chaining would rebuild a path back to third-party prose.
R-KB-1d — Every signpost MUST be resolved and validated before it can inform a response. Validation requires all three of: the target resolves at the current ref; the target independently satisfies this predicate; and the target actually addresses the question. A signpost failing any check is discarded and its issue contributes nothing.
R-KB-1e — Validation MUST re-apply the corpus predicate to the target. Without this a
signpost is a laundering route for exactly the content the exclusion list refuses: a planted issue
linking into phpboyscout/infra would otherwise have the bot fetch and quote the AWS topology,
arriving via a source that looks legitimate.
The lost answers are the point
This forfeits a real category of answer — a maintainer explaining something in a thread that was never written up. That is the knowledge-gap loop working rather than failing: an answer existing only in an issue is a documentation gap. Previously the bot would have papered over it, satisfying the asker and leaving the gap indefinitely. Now it escalates and the gap surfaces as work. The bot going quiet exactly where the documentation is thin is correct behaviour for a product whose highest-value output is a ranked list of things to document.
Full reasoning: corpus trust.
R-KB-1a — The phpboyscout blog is an additional named source, indexed as markdown content only. Rendered pages, layouts, theme assets, and site scaffolding MUST NOT be indexed.
R-KB-2 — Exclusions MUST be evaluated before discovery and MUST be absolute — an excluded source can never be reinstated by satisfying the predicate. The following are permanently excluded:
| Excluded | Reason |
|---|---|
Any namespace other than phpboyscout — notably matt.cockayne/* |
Personal projects, out of scope, strictly prohibited |
phpboyscout/infra |
Infrastructure-as-code: account structure, role and bucket naming. A reconnaissance aid even when public |
phpboyscout/iac and its modules |
As above |
phpboyscout/sandbox |
Scratch/experimental work, not support surface |
Any repository whose visibility is not public |
R-KB-2a |
The exclusion list is load-bearing, not decorative
phpboyscout/infra and the iac modules carry zensical.toml and publish docsites — they
satisfy the discovery predicate and would be indexed automatically. The exclusion is the only
thing preventing a bot that answers public Discord questions from reciting your AWS account
topology on request. Any change to the discovery rule MUST re-verify that these remain
excluded, and a test MUST assert it.
R-KB-2a — Before indexing any content, the bot MUST independently verify the source's
visibility via the GitLab API and refuse anything not reported public, logging the refusal at
WARN. Configuration is a request to index, never an authority — this makes a mistyped or stale
entry a non-event rather than a disclosure. Verification MUST be repeated on every refresh, not
cached indefinitely: a repository's visibility can be changed to private at any time, and the index
must follow it down.
R-KB-2b — Namespace membership MUST be re-verified on every refresh. GitLab redirects renamed
and transferred projects, so a source that was in-group at first index can silently leave the
group. Resolve the canonical path each cycle and drop anything that has moved out of
phpboyscout.
R-KB-2c — When a source becomes ineligible — visibility flipped, transferred out of the group, newly excluded — its documents MUST be purged from the index within one refresh cycle, not merely skipped on subsequent crawls. A stale index entry is still an answerable document.
R-KB-2d — Per-source include/exclude path globs MUST be supported, so a qualifying repository can still withhold specific paths from the index.
R-KB-3 — The index MUST persist to disk and survive restart. Rebuilding the whole corpus on every boot is not acceptable.
R-KB-4 — The index MUST refresh on a schedule, polling each source for changes. Refresh interval MUST be configurable per source. Push-driven invalidation is explicitly deferred — it requires an inbound ingress the deployment does not have (Q1 — resolved) — so the indexer MUST be structured so that an event-driven trigger can be added later without reworking the refresh path.
R-KB-5 — Every document MUST carry provenance metadata: source, URL, commit SHA, and indexed timestamp. Citations depend on it and staleness reporting depends on it.
R-KB-6 — The bot MUST report index staleness, and degrade gracefully — answering from a stale index with a caveat beats not answering.
R-KB-7 — Indexing MUST be resumable and incremental. A failed run must not corrupt the existing index.
7.2 The hybrid-retrieval spike (Phase 2c)¶
Whether to build full hybrid retrieval — keyword plus embeddings plus a fusion layer — is deferred to a timeboxed spike rather than decided up front (Q6 — resolved). "Is it worth it" has two unknowns, effort and benefit, and they are answered in that order deliberately.
R-KB-8 — The spike MUST measure benefit before estimating effort. Using the R-OPS-13 benchmark corpus, run keyword-only retrieval and classify every failure by whether semantic search would plausibly have fixed it — vocabulary mismatch and paraphrase are candidates; a genuine documentation gap is not, and belongs in the §11 knowledge-gap stream instead. This costs almost nothing and can end the question outright: if keyword retrieval answers 28 of 30, no effort estimate is needed.
R-KB-9 — Only if benefit is demonstrated, the spike MUST produce a written effort estimate covering every part of the real cost, not just the retrieval code: embedding model selection and its running cost, the chunking strategy, the embedding-generation pipeline, vector storage and its index-size impact, re-embedding on every corpus refresh (R-KB-4), score fusion and its tuning, and the added surface in the corpus-safety tests. Embedding pipelines are routinely underestimated because only the query path is visible; the ingest path is where the work actually is.
R-KB-10 — The spike MUST be timeboxed and produce a dated decision record with an explicit verdict — build now, build later, or do not build — including the measured benefit figure that justified it. "We spiked it and it felt worthwhile" is not a decision record.
R-KB-11 — Whatever the verdict, R-ANS-5b's Retriever contract ships
in Phase 2 regardless. It is the precondition that lets the spike run against the real retrieval
path without either dirtying it or building it twice, and it is what makes a "build later" verdict
genuinely cheap to reverse.
8. Operations and observability (R-OPS)¶
R-OPS-1 — The service MUST expose health and readiness endpoints via controls, distinguishing
"process alive" from "Discord connected, index loaded, LLM reachable".
R-OPS-2 — Metrics MUST include: questions received, answers given, answers by confidence band, escalations offered vs accepted, moderation detections by category, false-positive rate, LLM token spend, and end-to-end answer latency.
R-OPS-3 — LLM spend MUST be budgeted, with a configurable cap and a circuit breaker that degrades to index-only answering when the budget is exhausted. An unbounded agentic loop against a metered API is a financial incident waiting to happen.
R-OPS-3a — The cap MUST be configurable and hot-reloadable from day one (R-OPS-5); the value is deliberately deferred and set from measured spend after the Phase 3 shadow run (Q5 — resolved). Shipping a guessed ceiling is how you get a breaker that either never fires or fires constantly.
8.1 Model selection and cost control¶
Cost modelling at a nominal 20 questions/day put monthly spend anywhere from ~\(3 to ~\)147 depending purely on model choice. Three findings shape the requirements below.
Spend is ~98% input tokens — roughly 27M input against 0.5M output per month. The dominant levers are therefore prompt caching and the agentic loop bounds, not the output side. If the hybrid design keeps most questions on the index path, total spend roughly halves regardless of model.
Self-hosting is the expensive option at this scale, not the cheap one. A 24/7 GPU instance
(AWS g6.xlarge, L4 24GB) runs ~$584/month — four to five times the most expensive hosted option,
for a GPU idle ~99% of the time. Self-hosting wins on sustained high utilisation; this workload is
the opposite shape.
These are three different workloads, not one. Moderation classification is high-volume, tiny, and latency-tolerant. Answering is low-volume with large retrieved context. Summarisation sits between them. Binding all three to one model wastes money on one and quality on another.
R-OPS-9 — Provider and model MUST be selectable per task (answering, moderation, summarisation, routing), not globally. Each task's provider is independent configuration.
R-OPS-10 — All LLM access MUST route through the gitlab.com/phpboyscout/go/chat contract. No
provider SDK may be imported outside its provider module. This is what keeps the choice reversible
and makes the benchmark below cheap to run.
R-OPS-11 — Prompt caching MUST be used for the stable prompt prefix — system prompt, tool definitions, and hot document chunks. On an input-dominated workload this is the single largest cost lever available, and cache reads are an order of magnitude cheaper than fresh input.
R-OPS-12 — Token spend MUST be attributed per task and per provider in telemetry (R-OPS-2). An aggregate spend figure cannot tell you which workload to optimise, and setting the R-OPS-3a ceiling depends on knowing the split.
The provider benchmark (Phase 2)¶
No model is adopted on price or reputation. Candidates are evaluated on real questions from the real corpus, through the provider abstraction, before one is chosen.
R-OPS-13 — A benchmark corpus of at least 30 representative questions drawn from actual documentation and real support history MUST be assembled, each with a known-good answer and the source that should be cited. Questions the corpus genuinely cannot answer MUST be included — the graceful-failure path (R-ANS-11) needs testing as much as the success path.
R-OPS-14 — Candidates MUST be scored on the rubric below and the outcome recorded as a dated decision record. Cost is one row of several, deliberately not the first.
| # | Criterion | Why it matters |
|---|---|---|
| 1 | Citation accuracy | A confidently wrong answer with a real-looking link is the worst output this bot can produce — worse than no answer |
| 2 | Graceful failure | Does it say "not documented" (R-ANS-11) or confabulate? Measured on the deliberately-unanswerable questions |
| 3 | Tool-loop competence | The agentic fallback (R-ANS-6) is a ReAct loop; weak tool calling wastes iterations and money |
| 4 | Instruction adherence | Length bounds, citation format, thread etiquette, and refusing to be redirected by message content |
| 5 | Prompt-injection resistance | Run the adversarial corpus (§13) against each candidate — resistance varies by model and is not a property of the prompt alone |
| 6 | Cost per answered question | Measured, not modelled, on the real corpus with caching enabled |
| 7 | Latency | Discord users abandon threads; a technically better answer 40s later may be worth less |
R-OPS-15 — The benchmark MUST be repeatable and committed, so it can be re-run when a model is deprecated, a price changes, or a new candidate appears. A one-off spreadsheet is not a benchmark.
Self-hosting¶
R-OPS-16 — The self-hosting seam MUST be preserved: chat-openai speaks the OpenAI-compatible
protocol, so Ollama or vLLM behind a local endpoint is a configuration change with no new code.
No work is required now to keep this option open — but no design decision may close it either.
R-OPS-17 — Should self-hosting later be adopted, moderation is the workload to move first: high volume, tiny outputs, latency-tolerant, and it keeps Discord message content out of a third party's hands entirely — a privacy gain under R-GOV-4, not merely a cost one. Answering is the poorest self-hosting candidate: latency-sensitive and quality-critical.
R-OPS-4 — A kill switch MUST exist: an authorised-role command that immediately mutes all bot output without a redeploy. This is the first thing you will want at 02:00 and the last thing you will think to build.
R-OPS-4a — The kill switch and the shadow-mode toggle are runtime state, not configuration, and are the only settings changeable from Discord (Q-I9). They answer "is the bot currently allowed to speak", which is not something a version-controlled file should own — which is also why there is no config-drift problem to solve here.
R-OPS-4b — Runtime toggles MUST persist to the state store and survive restart. A bot muted deliberately that silently un-mutes itself on the next deploy is a worse failure than having no kill switch at all.
R-OPS-4c — Current toggle state MUST be visible — in the status command and on any operator surface. A quiet bot must never leave anyone guessing whether it is muted, in shadow mode, or simply broken.
R-OPS-5 — All configuration MUST hot-reload via Store.Watch. Tuning a moderation threshold
must not require a restart.
R-OPS-5a — Connection identity is exempt. Settings that establish a platform session — a guild or workspace ID, and any credential reference — MAY be read once at start and require a restart to change. Applying them live means tearing down a working session on a config write, so a mistyped credential takes the bot off the platform until somebody notices, at a time nobody chose. A service that exempts one MUST log a warning naming the field and stating that a restart is required, so an ignored change is never a silent one. Everything else, including channel allowlists and thresholds, hot-reloads as R-OPS-5 requires. Resolved in Q-1b-2.
R-OPS-6 — Secrets (Discord token, GitLab PAT, LLM keys) MUST resolve through
gitlab.com/phpboyscout/go/credentials. No bare os.Getenv.
R-OPS-7 — A dry-run/shadow mode MUST exist for the whole bot: compute every answer, escalation, and detection, log what it would have done, post nothing. This is how the bot is safely introduced to a live server.
R-OPS-8 — The bot MUST NOT require an inbound listener reachable from the public internet. All outbound integrations — Discord gateway, GitLab, LLM provider — are client-initiated (Q1 — resolved).
R-OPS-8a — Listeners on a private network interface are permitted: loopback, the homelab LAN, or a private overlay network (Tailscale or equivalent). This covers health and readiness endpoints (R-OPS-1) and the operator dashboard (§10 of the interface design). The distinction is reachability, not existence: the prohibition is on public exposure, not on serving HTTP.
R-OPS-8b — Any listener MUST bind an explicitly configured interface, never 0.0.0.0.
Binding all interfaces and relying on a firewall to be correct is how private services become
public ones. The configured bind address MUST be logged at startup so an operator can see what was
actually exposed.
R-OPS-8c — No listener may become the bot's only path to a capability. If the overlay network is down, everything except the operator's own convenience keeps working — which is what keeps R-ARCH-5 true and stops a VPN outage becoming a bot outage.
R-OPS-8d — Private-network binding is a network boundary, not authentication. Any device on the tailnet reaches anything bound to it. A read-only dashboard is adequately protected by that boundary alone; a listener that can change state MUST additionally authenticate the caller — via the overlay's own identity headers where available (Tailscale Serve supplies these), or by a mechanism of equivalent strength. See Q-I10.
The overlay can be the bot's own responsibility
Tailscale's tsnet lets a Go process join the tailnet as its own node, binding only to its
tailnet address and never appearing on the LAN or any public interface at all. That satisfies
R-OPS-8b by construction rather than by configuration discipline, and it travels with the
container if the bot later moves to a cloud host — which is exactly the portability property
§8.2 is trying to preserve.
8.2 Deployment and portability¶
v1 runs on a homelab VM (Q1b — resolved). That is a deliberate deferral of the cloud decision, not an absence of one: when the bot outgrows the box, the destination may be AWS, GCP or Azure, and nothing in the design may presuppose which.
R-OPS-20 — The bot MUST run as a single process against a persistent local filesystem, with no dependency on any managed cloud service. Everything it needs — index, state, config, secrets — resolves from the local host or plain environment configuration.
R-OPS-21 — No cloud-provider-specific API may appear in the critical path. Where a hosted service is later wanted (object storage for backups, a queue for webhook ingest), it MUST sit behind an interface with a local-filesystem implementation as the default, so the cloud version is an added implementation rather than a migration.
R-OPS-22 — The bot MUST ship as a container image and MUST run correctly with a single mounted data volume and environment-supplied configuration. This is what makes "move it to a provider" a deployment change rather than a rewrite, and it costs nothing to satisfy now.
R-OPS-23 — All persistent state MUST live under a single configurable data directory. Not an aesthetic preference: it is what makes backup a directory copy, migration a directory move, and R-GOV-2c's "deletion propagates to every store" tractable.
R-OPS-18 — The build matrix MUST be narrowed to the actual deployment target rather than inherited from the CLI-tool scaffold, which builds linux/darwin/windows × amd64/arm64 for tools users install on their own machines. Nobody installs a Discord daemon on Windows.
R-OPS-19 — CGO_ENABLED=0 SHOULD remain the default — it keeps builds simple and images
minimal — but MAY be relaxed with a recorded justification, as it is for the SQLite driver
(R-OPS-26). Where cgo is enabled the cost MUST be paid deliberately: a C toolchain in
the build image, static linking against musl or a matching runtime base image, and — for a second
architecture — either a cross toolchain or a native runner. Cross-compiling cgo is the part that
hurts; single-target cgo is routine.
R-OPS-24 — Backup MUST be documented and MUST cover the moderation audit trail specifically. R-MOD-11 commits to answering "why was this user actioned" for twelve months; on self-hosted infrastructure nobody else is taking that backup. An unbacked-up audit log is a promise with a single point of failure under it.
8.3 Storage¶
R-OPS-25 — Index and state MUST share one SQLite database: FTS5 for BM25 retrieval (R-ANS-5) and ordinary tables for everything else — issue-to-thread links (R-ESC-8b), the audit trail, appeals, feedback, rate-limit state and spend attribution.
The reasoning, given the homelab decision:
- One writer by construction. One daemon, one guild. SQLite's headline weakness — concurrent writers — does not apply, so Postgres's main advantage buys nothing here.
- Real BM25. FTS5 ranks with BM25. Postgres's
ts_rank/ts_rank_cddo not, and the extension that would (pg_search) cannot be installed on managed Postgres — so managed Postgres would be worse at the one thing the index exists to do. - One store, not two. R-GOV-2c requires deletion to
propagate everywhere. Across two stores that is a consistency problem with no shared transaction;
within one it is a
DELETE. - No new failure mode. An embedded database cannot be unreachable or out of connections, which is what R-ARCH-5 asks for.
- Proportionate. A managed database would have been the largest line item in a system whose entire LLM budget is single-digit-to-low-tens of dollars per month.
R-OPS-26 — cgo MAY be enabled to use the native SQLite driver, per
R-OPS-19. On a single-target build for a host you control this is routine;
the pure-Go modernc.org/sqlite remains a fallback if the build cost is ever unwelcome, at the
price of extension support.
R-OPS-27 — Should the hybrid-retrieval spike (§7.2)
return build, sqlite-vec keeps vectors in the same database. Should it prove insufficient, that
is the point to reconsider the engine — with measured requirements in hand rather than
speculatively now.
R-OPS-28 — The database MUST be treated as a migratable schema from the first commit: versioned migrations, applied on startup, and never hand-edited in place. Twelve-month audit retention means the schema outlives several releases of the code that writes it.
9. Privacy and governance (R-GOV)¶
R-GOV-1 — The bot MUST identify itself as a bot and MUST publish, in a pinned message or linked policy, what it reads, what it logs, and what it retains.
R-GOV-2 — Personal data (Discord handles, user IDs, message content) MUST have a defined retention period, with deletion on request. The categories below have different justifications and therefore different windows; one blanket rule cannot serve both.
| Category | Window | Rationale |
|---|---|---|
| Cached message content — thread context, indexed recent messages, LLM request/response bodies | 30 days | Long enough to debug a bad answer, investigate an appeal, and analyse the knowledge-gap stream; short enough to bound a breach |
| Moderation audit records | 12 months, content pseudonymised after 30 days | R-MOD-11 requires answering "why was this user actioned" months later — a 30-day log defeats its stated purpose |
| Index documents | Lifetime of the source | Public documentation, not personal data. Purged when the source becomes ineligible (R-KB-2c) |
| Issue-to-thread links | Until the issue closes plus 30 days | Needed for R-ESC-8 notifications; useless afterwards |
R-GOV-2a — After 30 days, moderation audit records MUST be pseudonymised, not deleted: the decision, matched rule, score, action taken, acting moderator and outcome are retained for the full 12 months, while the message content is replaced by a hash and only the user ID is kept. Accountability survives; the flagged content — often the most sensitive content on the server — does not linger for a year.
R-GOV-2b — Expiry MUST be enforced by a scheduled purge that actually runs, and its last successful execution MUST be observable (R-OPS-2). A retention policy nobody can prove ran is a policy in name only.
R-GOV-2c — Deletion MUST propagate to every store holding the data — index, cache, audit, logs, and telemetry. A record deleted from the primary store but still present in a log file has not been deleted.
R-GOV-2d — Content already published to a GitLab issue is outside this regime and MUST be described as such in the policy. Once escalated with the user's consent (R-ESC-2), it lives in the tracker under the tracker's retention, is public, and may be indexed by third parties. Purging the bot's own copy achieves nothing — a deletion request covering escalated content is a request to edit or delete the issue, which is a human action on GitLab, not something the bot's purge job can do.
R-GOV-3 — Copying a Discord user's words into a public GitLab issue is a publication event. It MUST be consent-gated (R-ESC-2) and the consent prompt MUST make the consequence explicit.
R-GOV-4 — Message content MUST NOT be sent to an LLM provider for any channel not in the configured allowlist, and the policy MUST state which provider processes it.
10. CLI surface (R-CLI)¶
The bot is a GTB tool, so the CLI is a first-class operational and development surface — not an afterthought around a daemon.
| Command | Purpose |
|---|---|
phpbotscout serve |
Run the daemon (all enabled subsystems) |
phpbotscout ask "<question>" |
Answer a question from the terminal — the fast dev loop, no Discord required |
phpbotscout index build / refresh / status |
Corpus management and staleness reporting |
phpbotscout moderate test <file> |
Replay a message corpus against the ruleset; the tuning harness for R-MOD-13 |
phpbotscout gaps |
Report the knowledge-gap backlog: unanswered questions ranked by frequency |
phpbotscout issue triage |
Review and file pending escalations from the terminal |
R-CLI-1 — ask MUST exercise the identical retrieval and answering path as the Discord flow.
A divergent test path tests nothing.
11. Additional features worth considering¶
Beyond the two capabilities requested, these emerged as high-value and are proposed for post-v1 phases:
- Knowledge-gap reporting (strongly recommended — highest ROI)
- Cluster unanswered and low-confidence questions by topic and emit a ranked documentation backlog. The bot's failures become your docs roadmap. Cheap to build once escalation exists, and it is the feature that pays for the project.
- Release and CI announcements
- GitLab release published → formatted Discord post. Pipeline failure on
main→ alert channel. Uses theforgerelease APIs already in the toolkit; near-zero marginal cost. - Thread-to-issue summarisation
- A moderator or maintainer command that converts an existing sprawling thread into a well-formed issue, rather than only handling the bot-detected case.
- Issue and MR status sync
- Subscribed Discord threads receive updates when the linked issue moves. Extends R-ESC-8 into a general subscription model.
- Daily or weekly digest
- Open support issues, questions the bot could not answer, moderation summary, stale issues needing attention. Turns the bot into a standup.
- Onboarding flow
- Greet new members, point them at the right channels and docs, and answer the predictable first-day questions before they are asked.
- FAQ synthesis
- When the same question is asked N times and answered well, propose a docs MR containing the answer. The bot contributing to the documentation it reads is the natural end state of the knowledge-gap loop.
- Documentation search command
/docs <query>— retrieval without LLM synthesis. Cheaper, faster, and often what the user actually wanted.
12. Delivery phases¶
| Phase | Deliverable | Exit criterion |
|---|---|---|
| 0 | Bootstrap — GTB skeleton, CI, docs scaffold, agent instructions | ✅ Complete |
| 1a-0 | Discord library spike — identical vertical slice against disgo, discordgo, arikawa, behind the draft contract |
Rubric scored; decision record written; contract revised where the spike exposed a bad boundary |
| 1a | chat-platform modules (upstream) — contract, registry, conformance harness, depfootprint guard; Discord provider on the chosen library |
Both modules released; conformance suite green |
| 1b | Discord ingest — consume the modules, read-only ingest into controls lifecycle |
Bot connects, logs messages, posts nothing |
| 2 | Knowledge index — sources, indexer, persistence, index + ask CLI |
ask answers real questions with correct citations from the terminal |
| 2b | Provider benchmark — ≥30 real questions scored across candidates via the provider abstraction | Rubric scored; decision record written; per-task provider selections made |
| 2c | Hybrid-retrieval spike — measure keyword-only failure modes on the benchmark corpus; estimate effort only if benefit is shown | Decision record with an explicit build-now / build-later / never verdict and the benefit figure behind it |
| 3 | Discord answering — trigger, retrieve, cite, thread, feedback; shadow mode first | 30 shadow answers reviewed for quality and citation accuracy (R-ANS-18) |
| 4a | forge issue capability (upstream) — IssueProvider in forge, GitLab implementation in forge-gitlab, conformance coverage |
Both modules released; no existing provider broken |
| 4b | GitLab escalation — consent gate, dedupe, routing, back-link, mention neutralisation | Issues filed from Discord with full context and no @-mention leakage |
| 4c | Gap explorer — clustered unanswered questions, browsable (Q-I6) | The knowledge-gap loop is visible from the week escalation ships, not deferred to Phase 6 |
| 5 | Moderation — rule engine, categories, mod queue, audit log, enforcement capability in the Discord provider, shadow mode | False-positive rate below the pre-registered bar (R-MOD-12a), or advisory-only accepted as terminal |
| 6 | Feedback loop — knowledge-gap reporting, digests, announcements | Gap report drives a real documentation change |
Each phase ships behind a feature flag and enters shadow mode before it acts.
13. Testing strategy¶
Per AGENTS.md, TDD is mandatory and BDD is required for user-facing workflows.
- Unit — table-driven with
t.Parallel(), ≥90% coverage onpkg/. Dependencies injected via functional options; no package-level mocking hooks (they race under parallel tests). - Contract conformance — every
chat-platformandforgeprovider runs its module's shared conformance harness (R-ARCH-3, R-ARCH-7). Upstream module tests are the gate; phpbotscout does not re-test the providers, it tests its own orchestration against mocks. - Integration — env-var gated (
INT_TEST=1,INT_TEST_DISCORD=1,INT_TEST_GITLAB=1), in*_integration_test.gofiles. Tests run against a dedicated test guild and a sandbox GitLab project — never the live server. - E2E BDD (Godog) — warranted here and not optional. The core flows are multi-step user
workflows, which is precisely the case BDD exists for. Feature files in
features/: features/support/answer.feature— question asked → answer with citations in threadfeatures/support/escalation.feature— unanswerable → consent → issue → back-linkfeatures/moderation/detection.feature— violation → private mod report → audit entryfeatures/moderation/shadow.feature— shadow mode detects and reports but never actsfeatures/ops/degradation.feature— GitLab unavailable → answering continues, escalation queues- Adversarial — a dedicated prompt-injection corpus asserting that no message content can induce a privileged action (R-ANS-15, R-ANS-16). This suite must exist before Phase 4 ships.
- Corpus safety — a test suite asserting the exclusion rules hold
(R-KB-2): that
infra,iacandsandboxare rejected despite satisfying the discovery predicate, that a non-phpboyscoutnamespace is rejected, that a non-publicvisibility response causes refusal, and that a source becoming ineligible purges its documents. These assertions must fail loudly if someone "simplifies" the discovery rule later — that is their entire purpose.
14. Documentation requirements¶
Docs follow Diátaxis, with the established phpboyscout deviation that tutorials are blog posts on phpboyscout.uk, linked from the tutorials section rather than duplicated in-repo.
| Section | Content |
|---|---|
| How-to | Deploy the bot; add a knowledge source; write a moderation rule; route escalations to a project; tune thresholds in shadow mode |
| Reference | Full config schema; CLI commands; rule syntax. Module APIs (chat-platform, forge) are documented in their own module docsites, linked — not duplicated here |
| Explanation | Why hybrid retrieval; why every write action is human-gated; the moderation threat model; prompt-injection defences |
| About | Privacy and data-retention policy (R-GOV-1) — a user-facing requirement, not just a doc |
| Tutorials | Blog post: "Building a support bot for your Discord and GitLab", linked from the tutorials index |
15. Open questions¶
These block or shape implementation and need resolution before the phases they touch.
Resolved¶
Q3 — issue-tracking contract placement. ✅ Resolved 2026-07-25. Issue operations extend
gitlab.com/phpboyscout/go/forge upstream as an optional IssueProvider capability — this is the
intended trajectory for the module. See
§3.1, R-ARCH-6 through R-ARCH-8.
Q4 — chat platform modules. ✅ Resolved 2026-07-25. A new toolkit module pair,
go/chat-platform and go/chat-platform-discord, following the forge/forge-gitlab precedent.
See §3.1, R-ARCH-9 through R-ARCH-11. The library backing
the Discord provider remains open — see Q4a.
Q1 — Deployment shape. ✅ Resolved 2026-07-25. No inbound ingress. The Discord gateway is an outbound websocket, so the bot needs a persistent process but no public front door. GitLab state changes are discovered by polling (R-ESC-8a) and the index refreshes on a schedule (R-KB-4). This removes an ALB, a TLS cert, a DNS name, a webhook-secret scheme, and a permanently exposed listener — for an event volume of a handful per day. If notification latency later proves inadequate, the upgrade path is a hosted queue in front of the daemon (the daemon consumes; nothing inbound is exposed) — a shape available on every provider, so it commits to none.
Q1b — Compute host. ✅ Resolved 2026-07-25. A homelab VM, until scale makes that untenable — at which point the destination may be AWS, GCP or Azure, and is explicitly not chosen today. This removes the cloud from the v1 equation entirely: persistent local disk, no managed- service costs, no ephemeral-container storage problem, and cgo is a non-issue on a single-target build for a box you control. See §8.3, R-OPS-20 through R-OPS-24.
The binding constraint still holds: the host must sustain a long-lived outbound websocket.
Q2 — Corpus scope. ✅ Resolved 2026-07-25. Scope is a discovery predicate, not a
curated list: any phpboyscout-group repository publishing a public zensical docsite qualifies,
plus the phpboyscout blog as markdown content only. The matt.cockayne namespace is prohibited
outright; infra, iac and sandbox are permanently excluded — and that exclusion is
load-bearing, since infra and iac do satisfy the predicate. The bot independently verifies
visibility via the GitLab API and refuses anything not public. See
§7.1, R-KB-1 through R-KB-2d.
Q4a — Discord library. ✅ Resolved 2026-07-25 — resolved into a spike. All three
candidates (disgo, discordgo, arikawa) are prototyped behind the contract and evaluated
against a fixed rubric before one is chosen. See
§3.3. Candidate state at time of decision:
| Library | Latest release | Age | Importers | Licence |
|---|---|---|---|---|
bwmarrin/discordgo |
v0.29.0 | May 2025 (14 months) | 9,639 | BSD-3 |
disgoorg/disgo |
v0.19.6+ | Jun 2026 (1 month) | 111 | Apache-2.0 |
diamondburned/arikawa v3 |
v3.6.0 | Sep 2025 (10 months) | — | ISC |
Q5 — LLM provider and budget. ✅ Resolved 2026-07-25 — resolved into a benchmark. No model is adopted on price or reputation: candidates are evaluated on ≥30 real questions from the real corpus against a 7-point rubric in Phase 2, with cost deliberately not the first row. See §8.1, R-OPS-9 through R-OPS-15. Indicative pricing at decision time (per MTok, ~\(3–\)147/month at the modelled volume):
| Model | Input | Output | Est. monthly |
|---|---|---|---|
| Claude Opus 5 | $5 | $25 | ~$147 |
| Claude Sonnet 5 | $3 | $15 | ~$88 |
| Gemini 3.1 Pro | $2 | $12 | ~$60 |
| Gemini 3.6 Flash | $1.50 | $7.50 | ~$44 |
| Claude Haiku 4.5 | $1 | $5 | ~$29 |
| GPT-5.4 mini | $0.75 | $4.50 | ~$22 |
| Gemini 2.5 Flash | $0.30 | $2.50 | ~$9 |
| GPT-5.4 nano | $0.20 | $1.25 | ~$6 |
| Gemini 2.5 Flash-Lite | $0.10 | $0.40 | ~$3 |
Figures are from third-party pricing summaries and must be re-verified against official sources before any commitment. Self-hosting was assessed and rejected for now — a 24/7 GPU instance (~$584/month) costs several times the most expensive hosted option at this volume — but the seam is preserved at zero cost (R-OPS-16/17).
Q5b — Spend ceiling. ✅ Resolved 2026-07-25 — deferred by design. The breaker is configurable and hot-reloadable from day one; the value is set from measured spend after the Phase 3 shadow run (R-OPS-3a). A guessed ceiling either never fires or fires constantly.
Q6 — Index technology. ✅ Resolved 2026-07-25 (revised after the CGO and homelab
decisions). One SQLite database holds the FTS5 index and all relational state (R-ANS-5a,
R-OPS-25). An initial recommendation of Bleve was withdrawn: it rested on CGO_ENABLED=0 being a
hard constraint, which it is not, and it under-weighted how much of this bot's state is relational
rather than search — meaning "Bleve" in practice meant Bleve plus a database, with
R-GOV-2c's purge spanning two stores that share no transaction.
Postgres was considered and rejected for this deployment: the bot is a single writer by
construction, managed Postgres cannot rank with BM25 (ts_rank is not BM25, and pg_search cannot
be installed on RDS), and a managed database would have been the largest line item in the system.
That calculus changes if the bot ever outgrows one node — see
§8.3 for the reasoning and R-OPS-27 for the trigger to revisit.
A narrow Retriever contract ships alongside (R-ANS-5b). Whether to build hybrid retrieval
remains deferred to the Phase 2c spike — see §7.2,
R-KB-8 through R-KB-11.
Q7 — Moderation authority and appeals. ✅ Resolved 2026-07-25. Authority is an explicit allowlist of Discord role IDs in config, re-checked at the point of action and never inferred from Discord's permission bits. Affected users are notified by DM with what happened, why, and how to contest it; appeals route to the mod queue carrying the original audit record, and an overturned appeal counts as a false positive. The self-harm category is excluded from this flow entirely. See §6.4, R-MOD-14 through R-MOD-21.
Q8 — Retention. ✅ Resolved 2026-07-25. Cached message content: 30 days. Moderation audit records: 12 months, with content pseudonymised to a hash after 30 days so accountability outlives content exposure. Purge is scheduled, observable, and propagates to every store. Content already escalated to a GitLab issue is explicitly outside the regime. See R-GOV-2 and R-GOV-2a–2d.
Human gating and mention safety. ✅ Confirmed 2026-07-25. Every write action is human-gated; the bot asks permission before raising an issue (R-ESC-2). Discord handles do not map to GitLab usernames, so the asker is named in the issue with their consent — with the handle rendered inert rather than omitted (R-ESC-9). Prompt injection, the self-harm category, and pervasive shadow mode are all confirmed as stated.
Open¶
None blocking. Every question raised in the initial draft has been resolved. Four decisions are deliberately deferred to evidence rather than left open — each has a named phase, a defined method, and a required deliverable:
| Deferred decision | Resolved by | Phase |
|---|---|---|
Which Go Discord library backs chat-platform-discord |
Spike all three against a fixed rubric (§3.3) | 1a-0 |
| Which provider/model per task | Benchmark ≥30 real questions against a 7-point rubric (§8.1) | 2b |
| Whether to build hybrid retrieval | Measure benefit, then estimate effort (§7.2) | 2c |
| LLM spend ceiling | Set from measured spend after the shadow run (R-OPS-3a) | 3 |
A deferred decision is not an open question: the method, the criteria and the deliverable are all specified. What is missing is the measurement.
Revisit triggers¶
Two resolved decisions are correct for the current deployment and should be reopened on a specific signal rather than on a schedule:
| Decision | Reopen when |
|---|---|
| SQLite as the single store (§8.3) | The bot needs more than one instance, the corpus outgrows one node, or the 2c spike returns build and sqlite-vec proves insufficient (R-OPS-27) |
| Homelab VM as the host (§8.2) | Availability requirements exceed what one self-hosted box can offer. R-OPS-20 through R-OPS-24 exist to keep that move a deployment change rather than a rewrite |
Recording the trigger is the point. A decision with no stated reopening condition either gets relitigated constantly or outlives its own reasoning.