Files
member-console/docs/building-an-integration.md
T
cgalo5758 259c935981 Unify operator integration management
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.
2026-07-23 00:14:21 -05:00

38 KiB

title, audience, summary
title audience summary
Building an Integration
developer
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.providers registry, 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's All() 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 the Slug in the manifest Provider() returns.
  • MigrationSource() — the integration's db.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 pooled storage_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/describe and the states active/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):

  1. 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. Declare IAPosition: "integration:integrations:<slug>" and ActiveCapability: "integrations" so the sidebar highlights both the parent section and your entry.
  2. 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.
  3. Delivered state: the integration-specific meat — FedWiki's site inventory, Discourse's group mappings and links.
  4. 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.

Guardrailsmember-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 stable id) 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).
  • Scripts are 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. EnsureStripeCustomer enqueues 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.Tx as your other writes. This is the only supported write path into core.outbox — core code must not INSERT into it directly. A single logical action that needs more than one outbox row (e.g. "create the product, then its price") is two separate Enqueue calls against the same transaction, not a batch API. A separate poller (your integration's own outbox-drainer workflow, mirroring Stripe's PollIntegrationOutbox) 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.sites in the same workflow) — dispatch directly via your own RegisterWorkflows-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 via viper.SetDefault; its dynamic type (string or []string today) 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 into Name by 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.ValidateStart rejects a configuration where some but not all of a group's keys are set (Stripe's stripe-api-key and stripe-webhook-secret share the group "Stripe" — both must be set to enable billing, or both left empty to disable it; FedWiki's fedwiki-farm-api-url and fedwiki-admin-token share "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 Enum for closed-set keys (stripe-mode test|live, fedwiki-site-scheme http|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-users is 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 own migrations/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.MigrationSource runs against its own goose ledger table (goose_db_version_<slug>), independent of the core stream's and every other integration's. Your files number from 00001... on their own — there is no global version namespace to coordinate with. internal/ migrate.Sources() assembles db.BaseSources() (core) followed by one integ.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.sql creates {slug}_owner/{slug}_writer/{slug}_reader (all NOLOGIN), grants USAGE on your schema to all three and CREATE to {slug}_owner, and grants table privileges (ALL to owner+writer, SELECT to reader) — extended to cover every table you create, including ones added by a later migration in your own stream, not just 00001.
  • core_reader grant. Because your tables FK into core and your own code paths read core tables, your baseline grants GRANT core_reader TO {slug}_writer. Core always runs first in internal/migrate.Sources(), so core_reader already exists by the time your grant runs.
  • member_console membership, created by your baseline. The DSN login role (member_console, created by Postgres initdb via POSTGRES_USER) must be an explicit member of your {slug}_writer role — GRANT {slug}_writer TO member_console — stated in your own migration rather than relied upon implicitly.
  • FKs point from your schema into core only — 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_console membership, revoke the core_reader grant, revoke table/schema grants, drop triggers and tables, drop the schema, and drop the three roles last (see internal/integrations/fedwiki/store/migrations/00001_init.sql and internal/integrations/stripe/store/migrations/00001_init.sql for 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 — see test/AGENTS.md for its constraint). If unit tests are enough, ship an in-process fake instead (internal/stripetest fakes the stripe-go backend; it stays outside your tree because the payments-seam files in core import it).
  • Per-worktree isolation. test/bootstrap-stack.sh generates a collision-free test/.env so 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>/ (see test/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. See docs/testing.md. Use an external test package (package <pkg>_test) for any test file in your tree that imports internal/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 an export_test.go (see the Discourse workflows package).

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.