chat-platform and chat-platform-discord — phase 1a¶
- Authors
- Matt Cockayne, Claude Opus 5 (AI drafting assistant)
- Date
- 2026-07-27
- Status
- APPROVED — 2026-07-27
- Implements
- R-ARCH-2, R-ARCH-3, R-ARCH-9, R-ARCH-10, R-ARCH-11, R-ARCH-11a; phase 1a of the delivery plan. Library choice settled by the phase 1a-0 decision.
1. What this delivers¶
Two new modules in the phpboyscout Go toolkit:
| Module | Contains | Docs |
|---|---|---|
gitlab.com/phpboyscout/go/chat-platform |
Contract, registry, conformance harness, dependency guard | Own microsite |
gitlab.com/phpboyscout/go/chat-platform-discord |
Discord provider on disgoorg/disgo |
README only |
The split follows forge/forge-gitlab. The docs asymmetry follows chat/chat-anthropic: provider
modules are thin adapters too tightly coupled to the core to warrant a site, so all provider
documentation lives on the core site and the provider gets a detailed README, no docsite and no logo.
Neither module knows phpbotscout exists. phpbotscout is the first consumer, not the owner.
2. Design constraints¶
Carried from the requirements and the 1a-0 spike, and not open for reinterpretation here:
- No vendor SDK in the core. Enforced by a
depfootprintguard, not by discipline (R-ARCH-9). The spike verified the contract compiles against stdlib alone; that property must survive. - Read and act are separate interfaces (R-ARCH-10).
The spike showed this makes shadow mode structural — build read-only and no
Actorexists, so the process cannot post whatever the logic above it does. - Capabilities are optional and discovered by type assertion (R-ARCH-11). A provider without moderation is legitimate.
- Providers self-register under a plain string key (R-ARCH-2).
3. The contract¶
3.1 Identifiers¶
// ID is an opaque platform identifier. It is a string because every platform's
// ids are string-representable, but it is a distinct type so a signature says
// which strings are identifiers and so providers convert at one boundary.
type ID string
func (i ID) String() string { return string(i) }
func (i ID) Valid() bool { return i != "" }
Chosen over plain strings because two of three candidate libraries use typed identifiers; the 1a-0 spike measured roughly 55 lines of parse-and-convert boilerplate per adapter, whose error paths cannot fire since the ids originated from the platform. A single opaque type moves that conversion to one place.
Chosen over per-entity types (ChannelID, MessageID, …) because Discord uses one snowflake type
throughout; distinct types would add conversion for the provider we are actually shipping in order
to catch a class of bug the Ref struct already prevents by naming its fields.
3.2 Values¶
type Member struct {
ID ID
Name string // display name — NOT an identity for authorisation
Roles []ID // authorisation is decided from this
}
type Message struct {
ID ID
ChannelID ID
ThreadID ID // empty when not in a thread
Content string
IsBot bool
Author Member
}
// Ref identifies something to act upon without leaking platform types.
type Ref struct {
ChannelID ID
MessageID ID
ThreadID ID
}
Everything in Message is untrusted. It reaches an LLM prompt, a forge payload and a log line.
The contract deliberately offers no Markdown rendering and no mention resolution — conveniences that
would encourage a caller to treat it as safe.
3.3 Reader and Actor¶
type Reader interface {
Connect(ctx context.Context) error
Messages() <-chan Message
State() ConnState
Close() error
}
// ConnState answers R-OPS-1's "process alive versus Discord connected".
type ConnState struct {
Status Status // Connected | Reconnecting | Disconnected
// LastReconnectLostEvents reports whether the most recent reconnect
// re-identified rather than resumed. A re-identify means the events
// buffered during the gap were DROPPED — for this product, unasked
// questions and unseen moderation-relevant messages, failing silently.
// It is the exact condition disgo was chosen to avoid and is otherwise
// invisible, so the contract surfaces it deliberately.
LastReconnectLostEvents bool
Since time.Time
}
type Actor interface {
ReplyInThread(ctx context.Context, to Ref, threadName, content string) (ID, error)
React(ctx context.Context, to Ref, emoji string) error
ThreadHistory(ctx context.Context, threadID ID, limit int) ([]Message, error)
}
ThreadHistory is new since the spike and is required by
R-ESC-4: the escalated issue carries a transcript of
relevant thread context.
3.4 Optional capabilities¶
Four, all discovered by type assertion.
// Moderator — the destructive surface. Required from phase 5
// (R-ARCH-11a). Reaching it must be impossible from message content
// (R-ANS-16a).
type Moderator interface {
DeleteMessage(ctx context.Context, ref Ref, reason string) error
TimeoutMember(ctx context.Context, userID ID, d time.Duration, reason string) error
}
// MemberInspector — account age and join date for the report card (R-MOD-6).
type MemberInspector interface {
Member(ctx context.Context, userID ID) (Member, error)
MemberJoined(ctx context.Context, userID ID) (time.Time, error)
}
// Interactive — components. The common subset only.
type Interactive interface {
Prompt(ctx context.Context, to Ref, p PromptSpec) (ID, error)
OpenForm(ctx context.Context, tok ResponseToken, f FormSpec) error
Respond(ctx context.Context, tok ResponseToken, content string, ephemeral bool) error
// UpdateSource replaces the message the interaction came from — the mod
// card showing "dismissed by X" once actioned. Without it the buttons stay
// live and a second moderator actions the same report.
UpdateSource(ctx context.Context, tok ResponseToken, content string, choices []Choice) error
Interactions() <-chan Interaction
}
// Commands — slash commands.
type Commands interface {
RegisterCommands(ctx context.Context, cmds []CommandSpec) error
}
3.5 The interactive subset, and why it stops where it does¶
Modelled from the concrete surface the approved interface design requires — not from Discord's component tree:
| Interface need | Contract shape |
|---|---|
| Escalation consent buttons | PromptSpec with Choice{Key,Label,Style} |
| The editable consent modal | FormSpec with prefilled FieldSpec values |
| Moderator card actions | PromptSpec, StyleDanger for destructive choices |
Timeout ▾ dropdown |
Choice set on one prompt — no separate select-menu type |
| Card showing "actioned by X" | UpdateSource — prevents a second moderator actioning the same report |
Ephemeral /docs |
Respond(..., ephemeral: true) |
/ask, /appeal, /botscout … |
CommandSpec |
type Choice struct {
Key string // stable; returned on selection
Label string
Style ChoiceStyle // StyleDefault | StylePrimary | StyleDanger
}
type FieldSpec struct {
Key string
Label string
Value string // prefilled — this is what makes consent informed (P2)
Multiline bool
Required bool
MaxLen int
}
type Interaction struct {
Type InteractionType // ChoiceSelected | FormSubmitted | CommandInvoked
Token ResponseToken
Ref Ref
By Member // authorisation is decided from By.Roles
ChoiceKey string
Values map[string]string
Command string
Args map[string]string
}
Rows, custom-id encoding, component styling and message flags are deliberately absent. They are Discord's model, and a contract that reproduces them is Discord's API with different names — which is what the boundary exists to prevent.
The acknowledgement deadline must not leak
Discord requires an interaction be acknowledged within three seconds, after which the token is dead. A caller doing retrieval and an LLM call cannot meet that, and no other platform has the same rule.
The provider MUST acknowledge on receipt and defer, so ResponseToken stays valid for the
caller to respond whenever it is ready. This is Discord plumbing and belongs in the Discord
provider; a contract that exposed a three-second budget would push a platform quirk into every
consumer and make the common case — answer a question, then reply — impossible to write.
3.6 Registry and construction¶
type Config struct {
Token string
Space ID // the guild / workspace / network this provider serves
AllowedChannels []ID
ReadOnly bool // when true, Provider.Actor is nil
}
type Provider struct {
Name string
Reader Reader
Actor Actor // nil under ReadOnly
}
type Factory func(Config) (*Provider, error)
func Register(name string, f Factory)
func Lookup(name string) (Factory, bool)
func Registered() []string
A provider serves exactly one space. Config.Space is the guild, workspace or network; scoping
at construction is why Moderator and MemberInspector take no space parameter, and why the
contract never has to name a concept that differs on every platform it claims to anticipate. Serving
several spaces means several provider instances, each with its own allowlist — a clean pattern
rather than a workaround, and one that matches the stated non-goal of multi-tenancy.
AllowedChannels is enforced inside the provider, not by the consumer. An empty allowlist
observes nothing rather than everything — R-ANS-4 is a
safety property and must not depend on a caller remembering to filter.
4. The Discord provider¶
Built on disgoorg/disgo per the 1a-0 decision.
init.goregisters under"discord".- Intents:
GUILD_MESSAGES,MESSAGE_CONTENT,GUILD_MEMBERS— the minimum for the contract. EnableResumeURLstays on. It is the reason this library was chosen and MUST NOT be disabled; a test asserts the config carries it.- Implements all four optional capabilities.
- Its own
depfootprintguard: the provider may depend on disgo and onchat-platform, and on nothing else from the toolkit.
5. Conformance harness¶
chat-platform/test exports RunProviderConformance(t, ConformanceConfig), following forge/test.
The compiler checks a method set; only a harness checks behaviour. It MUST verify:
- an empty allowlist yields no messages
- a message outside the allowlist is never emitted
- bot-authored messages are filtered unless the test affordance is set
ReadOnlyconstruction yields a nilActor— the structural shadow-mode guarantee- capability type assertions succeed or fail cleanly, never panic and never return
(nil, nil) Closeis idempotent and does not panic on an unconnected provider- an unresolvable
Refreturns a sentinel rather than a bare platform error
6. Testing strategy¶
TDD throughout, ≥90% coverage on the contract module.
Fake gateway. disgo's Gateway is an interface — the only candidate whose gateway can be
substituted without a network, and a stated reason it was chosen. Unit tests drive the provider
through a fake, with no Discord connection.
Bot-filter affordance. Testing a chat bot needs either a second identity or a deliberate bypass,
since a bot's own messages are filtered (R-ANS-3). The
spike used a config field. It must be reachable only from tests — a _test package export or an
unexported field set by a test helper, never a public Config field a production build can reach.
Integration tests gate on INT_TEST_DISCORD=1, live in *_integration_test.go, and run against
the dedicated test guild — never a live server.
The forced-disconnect test. ✅ Passed 2026-07-27 — see the decision record. It must nonetheless ship as an automated regression test in this module, because it guards the single property disgo was chosen for and nothing else would catch a regression in it.
The method matters and is easy to get wrong: closing the socket locally yields net.ErrClosed,
which disgo correctly treats as a deliberate shutdown, and a protocol-level close such as 4005
invalidates the session so resume is impossible. Only a peer-side reset exercises the path. Route
the gateway through a local TCP relay via NetDialContext, drop the relayed connections with
SetLinger(0), and refuse reconnects for the length of the outage. Assert: op 6 RESUME rather than
IDENTIFY, session id unchanged, and every event posted during the gap replayed.
7. Documentation¶
chat-platform gets a full Diátaxis microsite at chat-platform.go.phpboyscout.uk, including an
explanation page for the read/act split and one for why the interactive subset stops where it does —
the second is the page that will stop the contract growing Discord's component tree by increments.
chat-platform-discord gets a README only, per the chat convention.
8. Versioning¶
The provider requires an exact core version, as forge-gitlab requires forge and chat-anthropic
requires chat. Both are v0.x, so breaking changes ship as minor bumps.
Release the core first, then the provider against the released version. phpbotscout consumes
released versions only (R-ARCH-8); development may use a
replace directive, but merging may not.
9. Implementation order¶
| Step | Deliverable | Done when | Status |
|---|---|---|---|
| 1a.1 | chat-platform skeleton — repo, CI at cicd v0.33.0, licence, docsite scaffold |
Pipeline green | Done |
| 1a.2 | Contract: identifiers, values, Reader/Actor, registry | Compiles; depfootprint proves stdlib-only |
Done — 100% coverage |
| 1a.3 | Optional capabilities, including the interactive subset | Type assertions resolve; mocks generated | Done |
| 1a.4 | Conformance harness | Every check in §5 present and failing against a deliberately broken stub | Done |
| 1a.5 | chat-platform-discord on disgo |
Conformance passes; fake-gateway unit tests green | Done — 94.2% coverage |
| 1a.6 | Forced-disconnect regression test in-module | Peer-side reset harness; asserts RESUME, stable session id, zero gap loss | Done — verified live |
| 1a.7 | Docs and release | Both modules released; microsite live | Done |
Phase 1a is complete. chat-platform v0.1.0 and chat-platform-discord
v0.1.0 are both released and resolving from the module proxy. Verified by
consuming both from a clean module cache: the blank import registers discord,
a read-only config yields a nil Actor, and no Moderator can be obtained from
it — the type-level guarantee survives the published artefacts.
The docs site is live at chat-platform.go.phpboyscout.uk, serving the tutorial,
two how-to guides, four explanation pages and the provider reference. DNS matches
the estate pattern (CNAME to phpboyscout.gitlab.io, Cloudflare proxy off so
GitLab can issue the certificate — Cloudflare's universal TLS does not cover
second-level subdomains).
Two things worth carrying forward from the release itself:
Release order is enforced by rebasing, not by hoping. releaser-pleaser cut
the provider's release branch from the commit before the pseudo-version was
swapped for the real tag. The MR was green and reported mergeable, and merging it
then would have published the provider against an untagged commit. Check the
release branch's own go.mod before merging a provider release.
The security stage is load-bearing. It failed the first provider release on
13 known vulnerabilities in golang.org/x/crypto, pulled in transitively through
disgo/voice. govulncheck passed throughout, because no vulnerable symbol is
reachable from a provider with no voice support — which is an argument for fixing
it rather than against, since reachability changes the moment somebody adds a
feature.
What 1a.5 and 1a.6 changed¶
Two contract-level findings came out of building the first real provider, both of which the harness or the wire-format tests surfaced rather than review.
Construction must not need credentials. disgo derives the application id
from the token, so building the client in New made the provider impossible to
construct — and therefore to conformance-test — without secrets. The client is
now built in Connect. This is a property worth holding every future provider
to: New validates configuration, Connect reaches out.
Discord's modal rule conflicts with the acknowledgement rule. The contract
requires providers to acknowledge an interaction on receipt so the token
outlives a retrieval-and-inference round trip. Discord accepts a modal only as
the initial response to an interaction, so an eager acknowledgement makes
OpenForm permanently impossible. Resolved with a lazy acknowledgement: the
caller gets first refusal on the response slot, and the timer fires only if they
do not take it. Any platform with a "first response is special" rule will need
the same treatment, so it belongs in the provider-authoring guidance for 1a.7.
Four bugs were found and fixed, recorded here because three of them are shapes other providers can repeat: a reconnect-loss signal inferred from state that could not carry it; an injected client that received no handlers; a panic in a gateway callback taking the process down; and a send racing a close.
10. Resolved questions¶
All four resolved 2026-07-27.
Q-1a-1 — connection state. Reader.State() returns a ConnState with a three-value status and
LastReconnectLostEvents. A bool would satisfy R-OPS-1 but could not distinguish a self-healing
resume from a sustained outage, and would hide the case that matters most here: a reconnect that
re-identified rather than resumed dropped every event buffered during the gap. That is the precise
failure disgo was chosen to avoid, it is otherwise invisible, and a support bot silently missing
questions is the worst failure shape available. The contract surfaces it deliberately.
Q-1a-2 — guild identity. The provider is scoped to one space at construction. Beyond
matching the multi-tenancy non-goal, this removes the need to name the concept at all: "guild" is
Discord's word, where Slack has workspaces, Matrix has spaces and IRC has networks, so a contract
carrying guildID in its signatures is already leaking. Config.Space names it once, at the edge.
Q-1a-3 — message editing. UpdateSource on Interactive, not a general Edit on Actor.
The original claim that nothing in phases 1b–5 needed editing was wrong: the moderation report card
carries action buttons, and once actioned the card must update or the buttons stay live and a second
moderator actions the same report. Double-actioning is a bug class, not a nicety. Putting the method
on Interactive keeps it where that problem lives and maps onto Discord's native update-message
response. A general Actor.Edit remains a later minor addition if answer revision is ever wanted.
Q-1a-4 — command registration. Declarative replace. RegisterCommands declares the full
set and the provider converges to it — idempotent, safe on every startup, and matching Discord's
bulk-overwrite endpoint. Partial updates become impossible, which for a single bot declaring its own
commands is not a constraint worth designing around.