Components¶
phpbotscout is twelve components and some wiring. This is what each one is for, and why the seams are where they are.
| Component | Responsible for | Deliberately cannot |
|---|---|---|
transport |
Owning the Discord connection, one generation at a time | Let a handle outlive its generation |
ingest |
Reading one space's messages and handing each on | Post anything, connect, or close |
gateway |
Turning each permitted message into an event on the bus | Wait on anything a consumer does |
events |
Defining what an event is: subjects, type, schema, payload | Carry a display name |
respond |
Deciding whether the bot was asked something, and answering it | Post, unless built to |
budget |
Bounding how many answers the bot gives in a window | Tell one asker from another |
review |
Recording a person's verdict on each shadow answer, and the tally | Lift shadow mode |
corpus |
Deciding what may be indexed, and cutting content into chunks | Reach a network |
indexer |
Running an indexing cycle: discover, fetch, write | Decide what qualifies |
index |
Storing chunks and retrieving them for a question | Compose prose |
answer |
Turning a question and its passages into a grounded answer or a decline | Invent a citation |
store |
Owning the single SQLite database | Know what is in it |
Several servers, one application¶
One serve process can serve several Discord servers, such as a stage server and the production
server it rehearses. The bot is one application invited to each, with one token and one gateway
connection, so a mention, a role and a permission behave the same everywhere. That sameness is what
makes stage a rehearsal.
The transport builds one scope per server, and respond holds a poster per server. Posting,
exclusions, the answer budget, the provider and model, and the slice of the corpus can each be set
per server. A server with its own corpus or AI settings gets its own answering service.
Data is kept apart in the one database. Questions, answers and topics record their server, a topic
never spans two, and review reads one server at a time. The documentation index and the user-hash
salt are shared. See
spec 0029.
transport — owns the connection, one generation at a time¶
The transport holds the Discord client, one scope per guild over it, and the goroutines that read them, as a single unit: built whole, retired whole, exactly one live at a time. That unit is a generation. When the supervisor restarts the transport, the whole generation is rebuilt on a fresh connection and the old one is closed, so nothing can hold a connection that looks healthy and is not.
Building a generation connects nothing. Every scope is minted first, because a message for a guild whose scope does not exist yet is discarded by the platform; connecting happens only once the generation is installed, so a build that loses a race has not opened a gateway or spent one of the day's identifies. Closing is the mirror: a release closes every reader and then the client on its own goroutine and returns at once, so the supervisor's retry loop never blocks on Discord, and the client is never closed while a post is in flight because the Discord REST layer deadlocks on exactly that overlap.
Health is client-wide by construction. A single guild cannot fail a probe on its own, and a
worker fault (a handler that ignores its deadline) fails readiness rather than health, because
restarting the transport cannot cure it and would cost an identify per attempt. Restart exhaustion
is terminal: liveness fails and the process exits non-zero, so an orchestrator and a bare process
converge on replacing it. Any other service serve runs that fails for good, the health listener
included, ends the process the same way.
Each scope is built to emit only public channels: text channels and forums that the server's
@everyone role can view, and the public threads under them. comms-discord decides this
per message, and drops a message whose visibility it cannot establish
(spec 0024).
A scope is fixed once built, so the operator's exclusions are applied by the pump instead, and a
rebuild carries over the latest exclusion list.
This is the only package that imports the comms client. Its supervision numbers are all
configuration; see the discord keys.
ingest — reads, and only reads¶
The pump reads one guild's feed, drops messages from excluded channels and their threads, and hands each permitted message
to a handler. In serve that handler is the gateway's, which publishes the message
onto the bus and returns.
It is incapable of posting, and that is a property of the types rather than a flag. It is built over the two reader methods it uses, messages and connection state, so it cannot connect, close, or reach an actor: the transport holds those. For a server that does not post, the scope is also minted read-only, so no actor exists to be reached anyway — not an actor that refuses, none.
The pump ranges over its channel until the transport closes it and never selects on a context: everything buffered before the close is still delivered, so a restart drains what it already had rather than dropping it. The handler runs synchronously under a deadline it is required to honour.
The package depends on the comms contract alone. The Discord implementation is imported at
the wiring point in the serve command, never inside the library, and a dependency-footprint test
fails the build if a vendor SDK reaches the library.
gateway — publishes, and never waits¶
The gateway turns each permitted message into a MessageReceived event, publishes it onto the
in-process message bus, and returns. It reaches the bus through one method, publish, so it cannot
subscribe, acknowledge, or wait on anything a consumer does (spec 0019 D2).
That keeps a slow consumer from becoming a slow reader. The answering path makes a model call that takes seconds, and the pump never waits for it, so the Discord adapter never sees the bot as a slow consumer (spec 0019 D1).
Reactions take the same route. The gateway publishes a ReactionChanged event for a 👍 or 👎
added or removed, and drops every other reaction before the bus, so the stream that reaches a
subscriber is only the part that can be a vote.
A publish that fails never reaches the bus, so the bus cannot count it. The gateway counts it instead, and logs the identifiers and the reason: never the message text or the author's name, because this is the one log line where a message body could appear without having passed through the event.
events — what an event is¶
A contract, not a mechanism: the subject names, the CloudEvent type, the schema URN, and the Go
type the bus encodes and decodes. Delivery, retries and storage belong to
gitlab.com/phpboyscout/go/messaging, and nothing here names them (spec 0019 D12).
A received message is published on phpbotscout.message.received.<channel> and carries
identifiers, the text, and two booleans: whether the author is a bot, and whether the bot was
addressed. Those are everything a consumer needs to decide whether to act without going back to
the platform. It carries no display name. A name is something a person chose, and an event that
never holds one cannot leak it (spec 0019 D13).
respond — decides whether to speak, and how¶
respond receives messages from the bus and decides whether the bot was asked something. There
are three triggers and nothing else: a mention, a reply to one of the bot's messages, or a
follow-up in a thread the bot opened. A thread stays the bot's for respond.thread_window after
its last message. A message from a bot, the bot's own included, is never answered: a bot answering
a bot is a loop with only a spend cap for a brake.
Every answer opens a thread titled with the subject it covers, such as go/config: layer
precedence, so a channel of answer threads reads as a list of subjects. The model writes the
title, and a guard decides whether it is published. The guard removes every mention form and
refuses a link, a title that repeats six or more of the asker's words, a title that redaction
would change, and a title sharing no term with the documentation headings behind the answer. A
refused title falls back to the cited heading, and then to phpbotscout answer. A decline is
titled the same way, grounded in the pages that were retrieved, but never takes their heading,
because those pages did not answer it. A decline whose title is refused, or that found nothing
sharing the question's subject, is titled phpbotscout: not in the docs, so declines and answers
are told apart in the channel list. A new topic is labelled with the same title, so the gap
backlog reads as subjects rather than raw questions (specs 0026 and 0030).
An answer's inline citations, [1], [2], count down the Sources list printed under it, whatever
number the model gave the passage. A marker for a source that is not listed is removed. Brackets
in code and in quoted lines are left alone. A decline lists the retrieved pages whose heading
shares a word with the question, matching configure to Configuration. When none does, it says
nothing in the documentation matched, which is the stronger sign that the subject is undocumented
(spec 0030).
An addressed question goes to answer, and the outcome sets the posture. Plain when the answer
rests on more than one page, or refutes the question's premise. Hedged when it rests on one
page, and says which to check, or when the documentation answers only part of the question, and
says so. A partial answer is also counted as a gap against its topic, beside the declines, so a
subject the docs half-cover still ranks in the backlog (spec 0030). Decline when the documentation does not answer it. Over
budget when budget has run out. A decline and an
over-budget reply both post, because the bot was addressed directly and silence would read as
broken.
Each question is recorded, passed through redact first, and kept for 180 days. So is the answer:
the message exactly as it was posted, or in shadow mode as it would have been, redacted the same
way, with each citation, the commit it was read at, and the thread's title. Answer text and titles
are kept for 120 days. A daily
purge inside serve enforces both, which is what makes the
published retention true rather than intended. Expiry clears an answer's
text and keeps its outcome and posture.
Questions are grouped into topics, and the count of questions per topic is what ranks a documentation gap. Two questions share a topic when enough of what they are about matches. Mentions and function words ("how", "does", "the") are ignored. Each remaining word counts for more the rarer it is in the indexed documentation, so "precedence" outweighs "config", which appears in thousands of pages. A question that matches nothing well enough opens its own topic. The rule errs that way on purpose, because a duplicate topic can be merged by hand, but two subjects counted as one cannot be seen at all (spec 0010 D5d).
Readers' votes are recorded. Every posted answer carries 👍 and 👎. The transport hears
reaction changes, the gateway publishes only those two emoji (any skin tone, either form) as a
ReactionChanged event, and respond records one verdict per person per answer against the
posted message. A change of mind replaces the verdict, and taking the reaction off withdraws it. A
moderator clearing reactions changes nothing, because a tidy-up is not evidence about the answer.
The voter is stored only as the salted hash the answers use, and never reaches a log line. Votes
cast while the bot is down are never seen: the platform's reaction stream is live only
(spec 0027).
Shadow mode is the default, and posting is given per server. A respond service posts only in
the servers it is built with through respond.PostIn, and serve names only those whose
post_answers is set, from the same setting the transport reads when it decides which scopes can
post. Everywhere else everything runs, from the trigger and the model call to the record, and the
thread stays empty. Setting post_answers on a server is how the bot graduates there, once thirty
answers to real questions have been reviewed and judged worth posting (spec 0010 D12, spec 0025).
budget — bounds how often the bot answers¶
A spend control wearing a count. The bot answers when mentioned, anyone on a public server can
mention it, and every answer is a model call somebody pays for. respond.answer_budget sets how
many answers the bot gives per hour, across everyone. 0, the default, sets no ceiling. A server
with its own answer_budget has a budget of its own, so one server's spending never blocks another.
The hour slides rather than resetting on the clock: each answer leaves the window an hour after it was given. Checking and spending are one step under one lock, because two questions that both asked before either was recorded would otherwise both be told yes. The check happens before the model call, since the call is the cost.
It is global, not per person. A global budget needs no identity, no store and no eviction, and it bounds every abuse scenario, including the ones nobody has thought of. What it gives up is fairness: one person can use up the bot for everybody, and that is the condition that would bring a per-person cap (spec 0019 D19).
review — counts the verdicts, and decides nothing¶
review walks the answers respond stored in shadow mode, oldest first, and records a person's
verdict on each: was it correct, and were its citations apt. It owns the verdicts and nothing else,
reading the answers through respond's recorder. It opens the same database serve writes, so a
review can run beside the daemon.
Its tally counts towards the thirty reviewed answers that stand between shadow mode and posting (spec 0010 D12), with declines counted and shown apart from answers. Both the queue and the tally are for one server, so production graduates on production's answers alone. It never says the gate is met. That is a judgement about the verdicts, and lifting shadow mode is a code change made after it (spec 0023 D7).
A verdict stays when the person its answer was for is forgotten, so the gate's count does not move. Its note may quote the answer, so the daily purge clears the note once the answer's text has expired.
corpus — decides what the bot is allowed to know¶
Two jobs that both come down to "what counts as knowledge": the predicate that admits or rejects a repository, and the splitter that cuts files into retrievable chunks.
It is the project's security boundary, and it makes no network calls of its own. Everything it judges arrives as a value describing what the forge reported. That is what makes the boundary testable without a forge, and what keeps a directory name on somebody's laptop from ever being an input to a visibility decision.
See what the bot is allowed to read.
indexer — runs the cycle¶
Joins the three pieces: discovery says what may be indexed, a fetcher gets the content, the store holds it. Everything it depends on is an interface, so a full indexing cycle runs in a test with no forge, no clone and no network.
A source that fails is recorded against that source and the run continues. Abandoning the cycle on the first failure would leave the whole corpus as stale as its unluckiest repository.
The scheduled refresher inside serve calls the same Refresh that index refresh calls. One
implementation, so the scheduled and manual routes cannot drift into disagreeing about what is
indexed or where it lives.
index — stores and retrieves¶
Writes chunks, and answers a query with scored, provenance-carrying results.
Retrieval sits behind a narrow interface with one implementation over SQLite FTS5. The interface earns its keep by being the seam a vector retriever would arrive through, and the one the benchmark harness measures — the harness depends on a contract rather than on a database.
Writing a document replaces everything previously indexed for that file, in one transaction. Without the replacement a refresh would accumulate every previous version of a page and happily cite prose that has since been rewritten: a citation that resolves, to text that no longer says what the answer claimed.
answer — grounds, or declines¶
Retrieval in, prose and citations out — or a decline. The judgement about whether the passages actually answer the question lives here, and is made against the passages rather than against a score.
See how an answer is produced.
store — one database, one writer¶
Index and state share a single SQLite database: FTS5 for retrieval, ordinary tables for sources and documents. One daemon means one writer, so SQLite's headline weakness does not apply, and keeping the index in the same transactional store as the state it cites is worth more than concurrency nobody needs.
The driver is the native one, under cgo, taken on measurement (spec 0012 D2: 4.97 ms against
9.00 ms for the query that runs on every question) once the release matrix was cut to Linux. It
needs the sqlite_fts5 build tag, which the justfile, CI and the release config all set. Without
it the driver would compile without FTS5 and every retrieval query would fail at runtime, so a
guard in pkg/store turns the missing tag into a compile error instead.
Foreign keys are enabled explicitly — they are off by default in SQLite, and without them a document could outlive the source it came from, leaving a citation pointing at nothing. The journal is write-ahead, also explicitly: in the default rollback mode a reader on a second handle starves behind a busy writer and a writer fails behind a held read, which is exactly the shape of a CLI query overlapping the daemon, or of one component holding a transaction while another queries. Under WAL neither happens. The mode persists in the file, so the first open sets it for every later one.
Migrations are versioned and applied one transaction each, with the version recorded in the same transaction, so a failure part-way through leaves the database at the last version that fully applied. An applied migration is never edited: twelve-month audit retention means this schema outlives several releases of the code that wrote to it.
Two capabilities that live upstream¶
Chat platforms and forge access are not implemented here. They are toolkit modules —
comms with its Discord provider, and forge with its GitLab provider — consumed as
released versions.
When a contract is wrong it gets fixed upstream and the release is consumed. A local shim would be quicker once and wrong every time after: two implementations of the same idea, diverging, with the local one untested against anybody else's use of it.