List every provider kind with direct settings and admin links, move FedWiki under the integrations route, and add in-shell operator 404s. Report sync health from Temporal schedule executions and clear one-shot settings feedback parameters after display.
38 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Building an Integration |
|
How to build an integration: the internal/integrations/<slug> tree, the mandatory provider manifest, optional capability hooks (routes, workflows, config, UI), the database-authorship contract, and the capability-not-transport dispatch rule — FedWiki is the fully-conformant worked example, Stripe is partially conformant pending a follow-up. |
Building an integration — authoring guide
How to add an integration — member-console's term for a compiled-in package
that composes against one external service — to the console. FedWiki is the
fully-conformant worked example throughout this guide; Stripe is partially
conformant (§9) pending the payments-provider-seam follow-up. Landed by
OpenSpec change integration-extraction (M9 9d), which builds on the
resource-key/manifest contract from the earlier provider-extension-contract
change (M9 9a + 9c).
Terminology
Two terms, two meanings — used consistently across code, docs, and the database:
- Provider — the domain entity: a row in the
core.providersregistry, with a kind, a manifest of lifecycle operations and states, and owned resource keys. Providers are what entitlements, resource keys, and pool provisions reference; they exist in the database at runtime (§3). - Integration — the code unit: the compiled-in tree under
internal/integrations/<slug>/that implements a provider — migrations, workflows, HTTP handlers, config declaration, UI assets — and registers it.Integration.Provider()is the bridge: an integration ships exactly one provider.
"Extension" is a retired term: earlier M9 documents said "extension" or "provider extension" for what this guide calls an integration; it named no third concept. (The upstream design corpus draws the same line — provider as the genus, integration as the plumbing.)
Distribution model
Integrations are first-party and in-tree. Everything an integration
imports lives under internal/, which the Go toolchain restricts to this
module — an out-of-tree repository cannot import the contract surface, and
there is no dynamic-plugin loading; composition is compile-time, at
registry.go. To build an integration: branch this repository (external
contributors: fork), add your internal/integrations/<slug>/ tree plus the
one registry line, and open a PR.
An out-of-tree model — separate integration modules compiled in by a
builder tool, Caddy/xcaddy-style — would require publishing a semver-stable
public contract API and is an explicit non-goal for now; see
status/issues.md § Integration architecture.
1. What an integration is
An integration is one tree plus one registry line:
-
One directory,
internal/integrations/<slug>/, owns everything the integration ships: its database module, its Temporal workflows, its HTTP handlers, its templates and static assets, and its provider-registry manifest. -
One element in
internal/integrations/registry.go'sAll()installs it:func All() []Integration { return []Integration{ fedwiki.New(), stripe.New(), } }Adding an integration means adding a tree plus one slice element — nothing else in this file changes, and no
init()-time self-registration is involved (blank-import self-registration was considered and rejected: it hides the composition root and complicates testing subsets).
Import-direction rule (mirrors the FK rule for schemas): integration trees
MAY import core packages (internal/db, internal/integration,
internal/server, internal/workflows, internal/config, ...). Core
packages MUST NOT import integration trees. Exactly two composition roots
consume the registry: cmd/start.go (routes, workflows, config, UI wiring)
and internal/migrate/sources.go (migration sources). internal/server and
internal/workflows never import internal/integrations — they each
declare the capability interface their own domain cares about (§3), and an
integration satisfies it structurally, without either side importing the
registry package.
2. Tree layout — FedWiki as the worked example
internal/integrations/fedwiki/
fedwiki.go # Adapter: Slug, Provider, MigrationSource, plus the
# optional capability hooks it implements (routes,
# workflows, config, UI assets)
store/ # sqlc module: migrations/, queries/, generated code
migrations/00001_init.sql
queries/*.sql
*.sql.go, models.go, querier.go, provider.go, sqlc.yaml
workflows/ # Temporal workflows and activities
web/ # HTTP handlers: member API, HTMX partials, operator page
templates/ # slug-prefixed html/template files
static/ # slug-prefixed static assets (JS, ...)
store and workflows are separate Go packages from the top-level adapter
package (fedwiki), not files folded into it: web imports store and
workflows directly (its handler configs are typed against store.Querier
and it calls workflow-starting functions from HTTP handlers), and the
adapter package must import web to construct those handlers. Folding
store's or workflows' code into the adapter package would close an
import cycle (adapter → web → store/workflows → adapter). Keeping them as
sibling subpackages — imported by both the adapter and web, never the
reverse — satisfies "one tree owns everything the integration ships" at the
directory level without a cycle.
store's package name deliberately stays fedwiki (matching its
pre-extraction name) rather than store: sqlc.yaml's gen.go.package
field pins it, so regenerated code stays byte-identical across the move.
Only the import path changed; callers alias it fwmod by convention.
workflows has no such pin (hand-written Go, not sqlc-generated).
The adapter itself does not import internal/integrations: its methods
return or accept the concrete types the capability interfaces declare
(db.MigrationSource, integration.ProviderSource, server.Deps,
config.ConfigKey, io/fs.FS), so it satisfies those interfaces
structurally. registry.go — in package integrations — is the only place
a concrete adapter is named as an integrations.Integration.
Stripe's tree (internal/integrations/stripe/) follows the identical shape
(store/, workflows/, web/) for the parts it has extracted; see §9 for
what still lives outside it.
3. Mandatory surface
Every registered integration implements the mandatory Integration
interface:
type Integration interface {
Slug() string
Provider() integration.ProviderSource
MigrationSource() db.MigrationSource
}
Slug()— the integration's short identifier (e.g."fedwiki","stripe"); must match theSlugin the manifestProvider()returns.MigrationSource()— the integration'sdb.MigrationSource(own embedded FS, own goose ledger). See §4.Provider()— the provider-registry manifest, detailed below.
Concepts: provider, capability rows vs. operational rows
Provider is the genus for every external service the console integrates,
recorded in the core.providers registry. Each provider declares a
provider_kind:
| kind | examples | appears in operator "Integrations" |
|---|---|---|
provisioning |
FedWiki, (future) NextCloud, Discourse | yes |
payment |
Stripe | no |
notification, tax |
(future) | no |
The operator Integrations surface is exactly the provider_kind = 'provisioning'
subset. A payment provider like Stripe is a registry row for genus completeness
but never shows there.
Capability rows vs operational rows. A provider's capabilities — the
lifecycle operations it supports and the resource keys it owns — are registered
from code at boot (the manifest below). Its operational state — status,
plan-ladder bindings — is DB-canonical and operator-editable. A boot
re-registration refreshes capabilities and never clobbers operational state.
Resource keys: namespace-by-slug
The core.resource_keys table is a single flat, globally-unique namespace
(everything FKs into it, so a typo is a constraint violation, not a silent
mismatch). Ownership is a column, not a separate namespace:
- Platform owns the unprefixed namespace:
seats,members, a pooledstorage_bytes.provider IS NULL. - You own
<your-slug>_*:fedwiki_sites,nextcloud_storage_bytes.provider = '<your-slug>'.
Three separate roles — never conflate them:
| role | example | use |
|---|---|---|
| bare key (machine id / FK target) | fedwiki_sites |
the stored, queried key |
provider column (grouping/filter) |
fedwiki |
"all FedWiki resources"; NULL = platform |
display_name (UI label) |
Wiki Sites |
the only string shown to users |
There is no dotted provider.resource form — it collides with schema.table
notation and is redundant for prefixed keys. Treat resource keys as a bare id
plus a provider attribute; never parse a dotted string.
Shape (kind) — every key declares kind ∈ {'boolean','numeric'} at
INSERT time. numeric keys are conferred by limit rules and consumed via
numeric_entitlements; boolean keys are conferred by boolean rules and
consumed via boolean_entitlements (Discourse's discourse_posting is the
boolean template). The operator rule-authoring form derives the rule type from
kind, so a misdeclared kind locks the key to the wrong rule shape — declare
it explicitly rather than riding the column default ('numeric').
Slug hygiene (enforced at registration): a slug matches ^[a-z0-9]+$ (no
underscores) and may not be the leading _-token of any platform key (and vice
versa) — the slug space and key space share one string space.
Metering is independent of provider. A provider IS NULL key may be
metered (pooled storage, per-seat billing); do not couple "is metered" to
"has a provider".
Lifecycle operations
The contract's verb set: create, set_status, delete, plus a read class
list / describe. create provisions a new instance and delete permanently
(irreversibly) removes one; set_status(target) moves an existing instance
between the lifecycle states the provider declares (e.g. active /
readonly / archived), and is the reversible, both-directions path. active
is the implicit baseline; a provider declaring set_status declares its
non-active states in its manifest (persisted to provider_states). Declare
only the verbs you actually implement.
The console owns intent (counts, limits, requested transitions) and may keep an observed projection of per-instance state refreshed from the read class; the provider remains canonical. Dispatch — how a mutating verb is actually carried out — is per-integration, not mandated by this manifest; see §5 for the decision rule.
FedWiki declares
create/set_status/delete/list/describeand the statesactive/readonly/archived, backed by FarmManager v0.4.1's reversible PATCH-status transitions.
The manifest
func (providerSource) ProviderManifest() integration.Manifest {
return integration.Manifest{
Slug: "fedwiki",
Kind: integration.KindProvisioning,
DisplayName: "FedWiki",
OperatorSurfacePath: "/operator/integrations/fedwiki", // "" if none; keep new surfaces under /operator/integrations/
Operations: []integration.Operation{integration.OpCreate, integration.OpSetStatus, integration.OpDelete, integration.OpList, integration.OpDescribe},
States: []integration.State{integration.StateActive, integration.StateReadonly, integration.StateArchived}, // required iff OpSetStatus
ResourceKeys: []string{"fedwiki_sites"}, // each MUST be "<slug>_…"
}
}
internal/integration.RegisterProviders validates the manifest, upserts the
registry rows, reconciles the operation and state sets, and stamps
resource_keys.provider for your owned keys — all in one transaction,
idempotent. Validation covers: slug regex and slug/platform-key nesting; kind
must be a known ProviderKind; every declared Operation must be known;
declaring OpSetStatus requires active plus at least one non-active
state, and declaring states without OpSetStatus is rejected; every
ResourceKeys entry must be "<slug>_"-prefixed; duplicate slugs across
providers are rejected.
Registering it at boot requires no hand-maintained list: cmd/start.go
collects integ.Provider() from every entry in integrations.All() and
calls integration.RegisterProviders once, after migrations have run (so the
resource keys each manifest names already exist for stamping — a manifest
cannot invent a new resource key purely by declaring it; the key row must
already exist from your own migration).
Owning your resource keys — seed/own your <slug>_* keys in
core.resource_keys from your own migration stream (FK'ing into core),
declaring kind explicitly in the INSERT (see "Shape (kind)" above).
provider is left NULL by migrations; boot registration stamps it. Your
Down migration DELETEs the seeded key rows (they are your stream's
writes; "Down mirrors Up" §7 applies to cross-schema seeds too — see the
Discourse 00001's Down). Historical exception: FedWiki's fedwiki_sites
row predates this rule and lives in core's seed stream
(00002_seed_resource_keys.sql); copy Discourse, not FedWiki, here.
Operator surface — set OperatorSurfacePath and implement the routes
capability (§4) to serve it; derive the route you register from the
same manifest value your adapter returns, so the two never drift apart (see
FedWiki's RegisterRoutes, which reads
fwmod.ProviderSource().ProviderManifest().OperatorSurfacePath rather than
hardcoding the path a second time). Keep the path under
/operator/integrations/<slug> — that namespace is where operators expect
integration surfaces, unmatched paths under it 404 in-shell, and your
settings page already lives at /operator/integrations/<slug>/settings.
The operator sidebar's Integration group enumerates provisioning providers
from the registry generically — no template edit is needed to add your
nav entry.
Operator surface anatomy — the page's content is yours, but follow the shared skeleton so operators aren't relearning a layout per integration (the 2026-07-22 fresh-eyes audit found exactly that jarring — FedWiki and Discourse had converged on different idioms):
- Identity: an
<h1>naming the integration, a one-sentence intro that defines any term of art the page uses (what a "sweep"/"sync" is), a "Part of [Integrations]" link, and a Settings button to/operator/integrations/<slug>/settings. DeclareIAPosition: "integration:integrations:<slug>"andActiveCapability: "integrations"so the sidebar highlights both the parent section and your entry. - Delivery health: if your integration runs a reconciliation
schedule, lead with a "Sync health" card showing the latest run's
status and time via
workflows.ScheduleRunStatus(ctx, temporal, <yourworkflows>.SyncScheduleID). Resolve runs through the schedule, never by describing your workflow ID directly — schedule-spawned executions get timestamp-suffixed IDs, so a bare-ID describe reports "no sync yet" forever while sweeps run happily. - Delivered state: the integration-specific meat — FedWiki's site inventory, Discourse's group mappings and links.
- Actions: mutations last, using the shared confirm-action modal for destructive ones and the HTMX + CSRF-header pattern throughout.
Both existing pages follow this order; copy either.
Guardrails — member-console lint flags retired/bare resource-key
literals (ResourceKey: "..." and raw resource_key = '...'); keep your keys
namespaced. Add a registration test (see
internal/integration/registration_db_test.go) asserting your rows + stamp.
4. Optional capability hooks
An integration lacking a capability simply doesn't implement the
corresponding interface — the composition root discovers each hook by type
assertion against the concrete adapter returned from integrations.All()
(e.g. if rp, ok := integ.(server.RouteProvider); ok { ... }), so there is
no stub method to write for a capability you don't need.
Routes — server.RouteProvider
type RouteProvider interface {
RegisterRoutes(mux *http.ServeMux, deps Deps) error
CSRFExemptPaths() []string
}
RegisterRoutes constructs your handlers from Deps (the DB connection,
core module queriers, the Temporal client, the auth config, the logger — no
core code ever names an integration handler type) and registers them on the
shared mux. A handler that composes the operator page shell
(operator.html) must also parse the shell's own partial dependencies —
today partials/operator_lookup_result.html — into its template set:
html/template's escape analysis requires every referenced template to
exist even on branches your page never executes (parse only the shell and
every render 500s; see internal/server/operator.go:66 and the Discourse
operator handler for the worked pattern). CSRFExemptPaths declares any request paths that authenticate
a different way — e.g. a webhook verified by the provider's own signature
scheme. Declared paths bypass both CSRF protection and session auth:
the composition root collects every mount's exempt paths into the CSRF
middleware's ignore list and into the auth middleware's public-path set
(without the latter, provider deliveries bounce off the /login redirect —
caught live against a real Discourse; no handler-level test can see it
because the middleware stack isn't in the test). Your integration declares
the exemption itself instead of a literal path string being hardcoded into
core. Declare an exemption only for a route you are actually registering,
and only for routes that genuinely verify every request themselves
(Stripe's webhook adapter returns no exempt path when its webhook secret is
unconfigured and the route itself isn't mounted).
Workflows — workflows.WorkflowProvider
type WorkflowProvider interface {
RegisterWorkflows(w worker.Worker, database *sql.DB, logger *slog.Logger)
Startup(ctx context.Context, c client.Client, taskQueue string, database *sql.DB, logger *slog.Logger) error
}
RegisterWorkflows runs at worker construction and registers your Temporal
workflows/activities against the shared worker. Startup runs once per boot,
after the Temporal client is connected and the worker has started: use it for
one-off workflow starts (Stripe hand-starts its webhook-processor and
outbox-poller workflows here) or schedule creation (FedWiki sets up its sync
schedule here). By convention, Startup implementations log their own
failures and return nil — a failing integration should degrade that
integration's background functionality, not halt boot — though the
composition root also logs a non-nil error defensively. Integration-specific
configuration (a farm API URL, a webhook secret, ...) is read by the
integration itself (typically from viper), not threaded through either
method's generic parameters.
Config — config.ConfigProvider
type ConfigProvider interface {
ConfigSpec() []ConfigKey
}
See §6.
UI assets — integrations.UIProvider
type UIProvider interface {
Templates() fs.FS
Static() fs.FS
}
Both filesystems are slug-namespaced: every template file, every defined
template name, and every static asset path an integration contributes must
be prefixed with its slug (FedWiki's fedwiki_*.html templates and
fedwiki-create-form.js conform today). The composition root enforces this
at startup: Templates is parsed into the shared core template set, and any
*.html file whose name lacks the slug prefix — or any template parse error
— panics rather than returning an error, since this is a static
contract-conformance check on the integration's own asset naming (a
startup-time bug, never a runtime condition), mirroring the template.Must
convention core's own template set already uses. Static is mounted under
the existing /static/ route at the per-slug subpath /static/<slug>/ —
same origin, no new route surface, no CSP change. A mount with a nil or
empty Templates/Static is a no-op (an integration may implement
UIProvider for only one of the two).
Member dashboard cards — server.DashboardCardProvider
type DashboardCardProvider interface {
DashboardCards() []server.DashboardCard
}
An integration with a member-facing surface declares dashboard cards; core's
index.html renders one generic card shell per declaration (header from
Title, body self-loading via HTMX from PartialPath, re-loading on the
optional RefreshEvent fired on <body>) in registry order, and names no
integration anywhere. A DashboardCard is a shell, not markup:
- The body is integration-owned and HTMX-delivered. Core never renders integration templates inline into the dashboard page — no template-set coupling, no html/template escape-analysis surprises. Serve the body from your own partial route (session-authenticated inside your handler, like the rest of your member partials).
- Modal markup travels inside the partial, not at page level. Give each
modal
hx-preserve(with a stableid) so an open modal survives the refresh-event re-render — htmx carries the same-id DOM node across the swap; without it Bootstrap orphans the backdrop of a modal that is open when the card refreshes (FedWiki's create flow is exactly this case). Scriptsare page-level: each entry renders as<script defer src>in the page head. Point them at your/static/<slug>/mount; the strict CSP forbids inline scripts, and script tags inside swapped fragments have unreliable execution order.- Declare conditionally if your member route mounts conditionally. A dormant integration (config group absent) whose partial routes don't mount must return no cards — a declared card over an unmounted route 404s. Per-member state (entitlement held or not), by contrast, belongs inside the rendered partial as an explicit state, never in card visibility (Discourse's forum card renders "not included in your plan" rather than hiding).
FedWiki's sites card and Discourse's forum card are the two live examples
(internal/integrations/fedwiki/fedwiki.go and
internal/integrations/discourse/discourse.go, DashboardCards).
Why these interfaces live outside internal/integrations
RouteProvider and DashboardCardProvider, WorkflowProvider, and
ConfigProvider are declared in internal/server, internal/workflows,
and internal/config respectively — not in internal/integrations
alongside the mandatory Integration interface. Reason: internal/migrate imports internal/integrations (to
loop All() for migration sources), and internal/server's and
internal/workflows' own DB-backed test suites import internal/migrate —
so if internal/integrations imported any of those three packages to
declare a capability type, their test binaries would cycle back through the
registry. Declaring each interface at its point of consumption breaks the
cycle. Only cmd/start.go imports internal/integrations,
internal/server, internal/workflows, and internal/config together, so
it is the only place an integration is type-asserted against all of them.
5. Dispatch transport: capability, not transport
The manifest describes lifecycle verbs; it does not mandate how a
mutating verb (create/set_status/delete) is actually dispatched.
FedWiki dispatches directly through its own Temporal workflows. Stripe
dispatches through the transactional outbox (core.outbox), whose drainer
is itself a Temporal workflow (PollIntegrationOutbox, registered via the
Stripe adapter's RegisterWorkflows) — so the distinction is not "Temporal
vs. not Temporal" (Temporal is the universal execution substrate either way);
it is whether a Postgres transaction hands off to Temporal, or Temporal
workflow code owns the writes directly.
Use this decision rule when designing a new integration's mutating verbs:
-
If dispatch must be atomic with a domain commit — a caller needs its own row changes and the request to notify the provider to commit or roll back together (e.g.
EnsureStripeCustomerenqueues inside the caller's*sql.Tx, so billing-account creation and the sync intent either both land or neither does; Temporal's client API has no way to join a Postgres transaction) — use the outbox handoff. Enqueue via the shared helper:func Enqueue(ctx context.Context, db DBTX, providerSlug, actionType string, payload any) error(
internal/integration/outbox.go) inside the same*sql.Txas your other writes. This is the only supported write path intocore.outbox— core code must notINSERTinto it directly. A single logical action that needs more than one outbox row (e.g. "create the product, then its price") is two separateEnqueuecalls against the same transaction, not a batch API. A separate poller (your integration's own outbox-drainer workflow, mirroring Stripe'sPollIntegrationOutbox) executes the queued action against the external provider later. The outbox action-type vocabulary is free-form and unvalidated by the helper; a project-wide vocabulary is out of scope here. -
If the workflow owns the domain writes — your lifecycle operation orchestrates external calls and database writes as a single unit inside Temporal itself (FedWiki's create/set-status/delete/sync workflows call FarmManager and write
fedwiki.sitesin the same workflow) — dispatch directly via your ownRegisterWorkflows-registered workflows.
Forcing every integration onto one shape buys uniformity at the cost of the worse fit for whichever shape doesn't match: pure-Temporal dispatch for Stripe's shape would mean either a dual-write window patched by reconciliation sweeps, or workflow-mediated synchronous operator/provisioning writes. Both remain legitimate; pick the one your integration's atomicity requirement actually needs.
6. Configuration
Declare your keys once via ConfigProvider:
type ConfigKey struct {
Name string
Default any
Usage string
Secret bool
RequiredGroup string
}
Name— the viper/flag key, kebab-case (e.g."stripe-api-key").Default— bound viaviper.SetDefault; its dynamic type (stringor[]stringtoday) selects the cobra flag constructor the composition root uses.Usage— the flag's help text.Secret— marks the key sensitive: the composition root also binds a"<name>-file"flag and, before validation runs, resolves a configured file path intoNameby reading it from disk — the same handling core's own secrets (csrf-secret,oidc-sp-client-secret, ...) already receive.config.SecretPairsFrom(spec)derives the{Name, "<Name>-file"}pairs the composition root resolves, so you never hand-list your integration's secret keys a second time.RequiredGroup— ties this key to every other declared key sharing the same tag:config.ValidateStartrejects a configuration where some but not all of a group's keys are set (Stripe'sstripe-api-keyandstripe-webhook-secretshare the group"Stripe"— both must be set to enable billing, or both left empty to disable it; FedWiki'sfedwiki-farm-api-urlandfedwiki-admin-tokenshare"FedWiki"the same way).
The composition root binds flags/env/defaults for every declared key
generically (cmd/start.go's registerIntegrationConfigFlags, looping
integrations.All() type-asserted against ConfigProvider) and
config.ValidateStart validates every declared RequiredGroup the same
way — there is no hardcoded per-integration conditional in
internal/config/validate.go to extend. Keys with no RequiredGroup and no
Secret (e.g. stripe-mode) need nothing beyond the ConfigSpec entry
itself.
Boolean keys: ConfigKey.Default's flag constructors cover string and
[]string only — there is no bool arm. Declare a boolean setting with a
string default ("false"/"true") and read it with viper.GetBool, which
coerces the string (Discourse's discourse-auto-create-users is the worked
example). If a third integration needs this, add the real bool arm instead
of a third workaround.
Configuration that configures your own RegisterRoutes/Startup/activity
behavior rather than naming an external endpoint or secret (FedWiki's
sync-schedule knobs, its swap-cooldown duration) is not required to go
through ConfigSpec — read it directly from viper inside your own hook.
ConfigSpec exists for the keys the composition root and internal/config
need to see generically (defaults, secret-file resolution, required-together
validation), not as a mandatory home for every key you read.
No YAML duplication of compiled-in defaults. ConfigKey.Default is the
one source of truth for a key's default value; don't also hand-list that
same default in internal/embeds/mc-config.yaml or test/mc-config.yaml.
Those two files serve a different purpose — a starter template for
member-console init and the test stack's actual environment-specific
values, respectively — not a second default-registration point. Secrets
always stay env/file-based (-secret direct value or -secret-file path).
Runtime-managed settings (free with ConfigSpec). Every non-secret key
you declare is automatically operator-manageable at
/operator/integrations/<slug>/settings — one core handler/template pair
renders the page for all integrations from the declarations, so you add
nothing. Operator-set overrides live in core.integration_config_overrides
(one app-level table; integrations never grow their own config tables) and
resolve ahead of the environment: override → environment → default,
applied by a boot-time overlay (internal/config.ApplyOverlay) that
viper.Sets each stored value, so your existing viper.Get* reads pick
overrides up unmodified. Two consequences to design for:
- Changes apply on restart. The overlay runs once at boot (your config reads happen at registration time anyway); the settings page says so and badges keys whose stored override differs from the boot-effective value.
- Declare
Enumfor closed-set keys (stripe-modetest|live,fedwiki-site-schemehttp|https). Enum keys render as a select and are validated at save time and again by the overlay at boot. For the string-typed boolean workaround above,Enum: []string{"false","true"}gives the same closed UI (discourse-auto-create-usersis the worked example).
Secret keys (Secret: true) appear on the settings page by name only, as
environment-managed — never storable, never displayed, never editable.
Required keys stay a bootstrap contract: ValidateStart runs before the
database exists, so the environment alone must satisfy them; overrides
layer on top.
7. Database authorship contract
Your integration is the sole author of its schema. This section states what
that means concretely; see docs/database-management.md for the
project-wide migration-stream mechanics (goose ledgers, sqlc conventions)
this section assumes.
- Own schema.
CREATE SCHEMA <slug>in your ownmigrations/00001_init.sql, nested inside your module directory (internal/integrations/<slug>/store/migrations/for both FedWiki and Stripe today). - Own migration stream: native numbering, per-stream ledger. Your
db.MigrationSourceruns against its own goose ledger table (goose_db_version_<slug>), independent of the core stream's and every other integration's. Your files number from00001...on their own — there is no global version namespace to coordinate with.internal/ migrate.Sources()assemblesdb.BaseSources()(core) followed by oneinteg.MigrationSource()per registered integration; core must run first because the FK DAG is one-directional (see below), but your position relative to other integrations is insignificant. - Role triple, created by your own baseline — not by core's. Your
00001_init.sqlcreates{slug}_owner/{slug}_writer/{slug}_reader(allNOLOGIN), grantsUSAGEon your schema to all three andCREATEto{slug}_owner, and grants table privileges (ALLto owner+writer,SELECTto reader) — extended to cover every table you create, including ones added by a later migration in your own stream, not just00001. core_readergrant. Because your tables FK intocoreand your own code paths read core tables, your baseline grantsGRANT core_reader TO {slug}_writer. Core always runs first ininternal/migrate.Sources(), socore_readeralready exists by the time your grant runs.member_consolemembership, created by your baseline. The DSN login role (member_console, created by PostgresinitdbviaPOSTGRES_USER) must be an explicit member of your{slug}_writerrole —GRANT {slug}_writer TO member_console— stated in your own migration rather than relied upon implicitly.- FKs point from your schema into
coreonly — never into another integration's schema, and never the reverse (core never FKs into an integration schema). There are no integration-to-integration FK edges today, which is exactly why integration migration order is interchangeable. - Down migration mirrors Up in reverse: revoke the
member_consolemembership, revoke thecore_readergrant, revoke table/schema grants, drop triggers and tables, drop the schema, and drop the three roles last (seeinternal/integrations/fedwiki/store/migrations/00001_init.sqlandinternal/integrations/stripe/store/migrations/00001_init.sqlfor the worked pattern both streams follow). - Cluster-level caveat. Roles are global to the PostgreSQL cluster, not
schema-scoped:
CREATE ROLE {slug}_owner(etc.) errors if that role already exists — the same behavior core's own role creation has always had, not something newly introduced by moving role creation into per-integration streams. This is why the pre-production wipe-volumes convention applies uniformly across streams.
8. Test-stack obligations
Unlike the sections above, none of this is enforced by a registration hook —
it is convention, and the test stack is the one place where adding an
integration still means hand-editing shared files. What an integration owes
test/ today:
- A service or a fake. If your provider needs a live counterpart for
e2e tests, add a service to
test/'s docker compose (FedWiki runs a FarmManager container; Stripe uses the Stripe CLI's webhook forwarding — seetest/AGENTS.mdfor its constraint). If unit tests are enough, ship an in-process fake instead (internal/stripetestfakes thestripe-gobackend; it stays outside your tree because the payments-seam files in core import it). - Per-worktree isolation.
test/bootstrap-stack.shgenerates a collision-freetest/.envso multiple worktrees run stacks concurrently. Any service you add must take its host port from that mechanism, not a fixed port. - Seed data. Fixtures your service needs at stack boot live under
test/seed/<slug>/(seetest/seed/fedwiki/). - DB-backed tests. Self-migrating tests build their sources via
migrate.Sources()— which enumerates the registry, so your stream is included automatically; that part needs no hand-wiring. Seedocs/testing.md. Use an external test package (package <pkg>_test) for any test file in your tree that importsinternal/migrate: an in-package test would close a cycle that exists only in the test binary (<pkg>(test) → migrate → integrations → <adapter> → <pkg>) — the same cycle class §4 describes. Bridge unexported logic with anexport_test.go(see the Discourseworkflowspackage).
A registration-driven test-stack contract (an integration declaring its compose service and seeds the way it declares routes and config) does not exist yet; if a future integration makes the hand-editing painful, that is the signal to design one.
9. Conformance status
| Integration | Own module (store/) |
Own workflows | Own routes/handlers | Own DB roles + grants | Config declared |
|---|---|---|---|---|---|
| FedWiki | yes | yes | yes (member API, HTMX partials, operator page) | yes | yes |
| Stripe | yes | yes (incl. outbox drainer) | webhook handler only | yes | yes |
FedWiki is fully conformant: its module, workflows, HTTP handlers,
templates, and static assets all live under internal/integrations/fedwiki/,
and its 00001_init.sql creates its own role triple and member_console
membership.
Stripe is partially conformant. Its module (store/), Temporal workflows
(including the outbox drainer), webhook HTTP handler, ConfigSpec, and DB
role triple all live under internal/integrations/stripe/ and satisfy this
contract. What stays in core, deliberately, as the payments-provider-seam
follow-up: internal/fulfillment (all three non-test files import
stripe-go — a core-named package that is in fact Stripe's), the billing
checkout route (internal/server/billing.go), the operator billing catalog-
sync and outbox-enqueue UI (internal/server/operator_billing.go),
product_readiness.go, member_products.go, and internal/stripetest.
These encode "core billing UX consumes a payments provider" without a
payments-provider abstraction existing yet; designing that port against the
billing model is its own change, tracked as a follow-up rather than bundled
into a mechanical extraction. Until that follow-up lands, treat FedWiki —
not Stripe — as the template to copy for a new integration's routes/web
layer.