Skip to content

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 depfootprint guard, 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 Actor exists, 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.go registers under "discord".
  • Intents: GUILD_MESSAGES, MESSAGE_CONTENT, GUILD_MEMBERS — the minimum for the contract.
  • EnableResumeURL stays 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 depfootprint guard: the provider may depend on disgo and on chat-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
  • ReadOnly construction yields a nil Actor — the structural shadow-mode guarantee
  • capability type assertions succeed or fail cleanly, never panic and never return (nil, nil)
  • Close is idempotent and does not panic on an unconnected provider
  • an unresolvable Ref returns 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.