- Add deployment-name branding to titles, mastheads, and OG tags - Share one grant delivery-state query with lineage across grants surfaces - Show pool status/usage, org owners, and config readiness - Make billing views projection-aware with recency and sync vocabulary - Guard FedWiki creation without domains and render route-aware 404s
16 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Providers and Integrations |
|
How external services plug in: the provider registry, boot-time manifests, resource-key ownership, the shared webhook and outbox substrate, and runtime-managed configuration. |
Providers and Integrations
Purpose
The console is a hub for external services, and this model is the socket they plug into. Two words carry the whole design and must not be swapped: a provider is the domain entity — a row in the registry recording an external service's kind, its declared lifecycle capabilities, and the resource keys it owns; an integration is the code unit — the tree under internal/integrations/<slug>/ that implements and registers exactly one provider. ("Extension" is a retired synonym.) Three providers exist today: FedWiki (wiki hosting, the full per-instance lifecycle), Discourse (forums, delivering converged group membership — a managed forum group whose member set the console recomputes and pushes), and Stripe (payments — its webhook and outbox workers already live in its integration tree, while checkout, fulfillment, and catalog sync remain core-side pending the tracked payments-provider seam). One wording rule for this card: "provider kind" always means the registry's four-way category, and "key kind" always means a resource key's boolean/numeric shape — the bare word "kind" is never used alone.
The registry splits every provider's rows into two halves with different owners. Capability rows — the kind, the declared lifecycle verbs and states, the owned resource keys, the operator-surface path — are stamped from a typed manifest in code at every boot, idempotently. Operational rows — the provider's status, anything an operator edits — are database-canonical, and a boot re-registration never touches them. Everything registry-driven follows: the operator sidebar lists providers from the registry, the settings pages render from declared config specs (with operator overrides stored in one table and secrets never stored at all), and adding an integration is one code tree plus one line in a compile-time list — no core schema change.
Two shared substrate tables serve all providers: core.webhook_events stores verified inbound events, and core.outbox carries outbound actions that must commit atomically with a domain write. Dispatch transport is a capability choice, not a mandate: Stripe enqueues through the outbox because its actions must ride the caller's transaction; FedWiki and Discourse dispatch directly through Temporal because their workflows own the database writes end-to-end.
Vocabulary reused from the Entitlements card: resource key, key kind (boolean/numeric), boolean and numeric entitlements, usage counters. From Payments and Billing: webhook event store, outbox, dead-letter. Temporal is the workflow engine the console runs background jobs on; goose is the SQL migration tool, and each migration stream keeps its own goose version-ledger table with its own 00001-up numbering.
Where this lives
Registry and substrate tables sit in the core schema (internal/db/migrations/00001_init.sql); each integration owns its own schema and migration stream.
| Piece | What it is | Where |
|---|---|---|
core.providers |
The registry: slug (the key), provider kind, display name, the operator-surface path (the URL under which the provider's operator pages mount), and the operator-editable status with its suspended_at/retired_at timestamps |
00001_init.sql; 00010_schema_hardening.sql |
core.provider_operations |
One row per declared lifecycle verb per provider; the verb set is closed to five: create, set_status, delete, list, describe | 00001_init.sql |
core.provider_states |
One row per declared lifecycle state per provider — deliberately per-provider vocabulary, format-checked but not a closed list | 00001_init.sql |
core.resource_keys (provider, kind) |
Ownership and shape on the flat key namespace: provider names the owning provider or is empty for platform keys; the key kind is boolean or numeric |
00001_init.sql; 00008_resource_key_kinds.sql |
| Discourse convergence | The set-shaped delivery in action: a sweep recomputes each managed forum group's desired member set from boolean entitlements and pushes adds/removes, refreshing an observed projection | internal/integrations/discourse/workflows/; discourse.observed_group_members |
core.webhook_events, core.outbox |
The shared inbound and outbound substrate (described in the Payments and Billing card); both deliberately carry no foreign keys, staying provider-generic | 00001_init.sql |
core.integration_config_overrides |
Operator-set values for non-secret config keys, overlaid at boot over environment and defaults | 00009_integration_config_overrides.sql |
| The provider contract | The manifest types every integration fills in: kind, operations, states, resource keys, operator-surface path | internal/integration/contract.go |
| Boot registration | Validates every manifest, upserts registry rows, reconciles verb and state sets, and stamps key ownership — one idempotent transaction | internal/integration/registration.go |
integration.Enqueue |
The only supported write into core.outbox; takes the caller's transaction so the domain write and the queued action commit together |
internal/integration/outbox.go |
| The integration interface and registry | The mandatory capability (slug, manifest, migration source) and the compile-time list of integrations | internal/integrations/integration.go; internal/integrations/registry.go |
| Optional capability hooks | Routes, Temporal workflows, config specs, UI assets, and member-dashboard cards — each an optional interface the composition root discovers by type assertion | cmd/start.go; docs/building-an-integration.md |
| Config overlay | Applies stored overrides over environment over defaults at boot, validates enum keys, refuses secrets, and snapshots the effective values so the settings page can mark saved-but-not-yet-applied changes as pending a restart | internal/config/overlay.go |
| Operator settings surface | One generic handler renders /operator/integrations/<slug>/settings for every integration from its declared config spec |
internal/server/operator_integration_settings.go |
| Registry-driven operator surfaces | The sidebar's Integrations group lists provisioning-kind providers from the registry; the Integrations landing page deliberately lists every provider kind, so Stripe gets a row and a reachable settings link too | internal/server/operator_pages.go |
| Status presentation (ux-honest-surfaces) | The Integrations list and the landing surface's System region each show two distinct, honestly-labeled facts per provider — the operator-managed registry status (labeled so it cannot read as a health check; never changed by any connectivity probe) and a configuration-readiness signal (configured/missing-key names) derived from the same required-key resolution the settings page performs. Neither surface claims "connected" or "read live". The System region's outbox report also names, in its caption, which integrations' queued work it actually covers (today: Stripe's only) — an empty or draining outbox is never evidence that an uncovered integration is healthy |
internal/config.RequiredKeysUnresolved; internal/server.configurationReadiness (shared by operator_pages.go and operator_overview.go) |
| The three manifests | FedWiki (provisioning, all five verbs, states, owns fedwiki_sites), Discourse (provisioning, read verbs only, owns discourse_posting), Stripe (payment, no verbs, no keys) |
internal/integrations/*/store/provider.go |
| Per-integration schemas and migrations | Each integration's own schema, roles, and goose ledger with native numbering | internal/integrations/*/store/migrations/ |
| Migration ordering | Core first, then core-module streams (domains), then integrations in interchangeable order | internal/migrate/sources.go |
| Authoring guide | The normative how-to for building an integration, including the dispatch decision rule and conformance notes | docs/building-an-integration.md |
| Conformance record | The clause-level check of Discourse against the upstream design's provider-contract document (numbered doc-39 in design/documents/) |
status/doc39-conformance-2026-08-21.md |
Invariants
An invariant is a rule the model guarantees everywhere; code that would break one is wrong even if it works locally. Tags: [db] — a schema constraint rejects violations; [db-code] — database-side code upholds it; [app] — only Go code upholds it.
- [db] Registry hygiene is CHECK-enforced: a slug is lowercase alphanumeric with no underscores, a provider kind is one of payment / provisioning / notification / tax, a declared operation is one of the five verbs, and the operator-editable
statusis one of active / suspended / retired (withsuspended_at/retired_atrecording when a provider entered those states). Declared states are format-checked but deliberately not a closed list. - [app] A provider declaring
set_statusmust list theactivestate plus at least one other in its manifest; a provider withoutset_statuslists no states. Boot validation rejects violations. - [app] Every provider-owned resource key is prefixed with the provider's slug plus underscore, and the key row must already exist from a migration — a manifest stamps ownership onto existing keys, it cannot invent them. The rule's home for the row is the integration's own stream;
fedwiki_sitesis the one grandfathered exception, seeded core-side, and the authoring guide says to copy Discourse. - [db] A key's
providermust name a registered provider or be empty — a foreign key enforces it. Boot validation additionally rejects (app-side) a provider slug that would nest inside a platform key's first underscore-separated word: a provider namedexternalwould be refused because the platform keyexternal_domain_claimsstarts with that word. - [app] Boot re-registration refreshes capability rows in one transaction and never clobbers the operational
statuscolumn — operators own it. - [app] The operator sidebar shows only provisioning-kind providers; the Integrations landing page deliberately lists every provider kind, and any integration declaring config keys gets a reachable settings page regardless of kind — Stripe included.
- [app]
integration.Enqueueis the only path that inserts intocore.outbox, and it rides the caller's transaction — a domain write and its queued action commit or roll back together. (The drainer and the operator retry surface update existing rows' statuses; they never insert.) - [app] Inbound webhook dedup is handler-level: Postgres requires a partitioned table's unique constraints to include the partition column, so the uniqueness necessarily includes the received-at timestamp and cannot reject a redelivery on its own — every receiver therefore inserts with an existence check on (provider, event id). Adding the "obvious" two-column unique index is exactly the fix the partitioning forbids.
- [db] The substrate tables carry no foreign keys into any module table; entity references are text or payload fields, keeping both tables provider-generic.
- [app] Secret config keys are never stored: the settings page rejects writes to them and the overlay refuses to apply them; secrets live in the environment only.
- [app] Config resolves override → environment → declared default, once, at single-threaded boot. An unrecognized stored override is skipped; an invalid one aborts startup; changes take effect on restart only.
- [app] Schema references point from integration schemas into core, never the reverse and never sideways between integrations — which is exactly why integration migration order is interchangeable. Nothing mechanical blocks a violation; review must.
- [app] Integration UI is slug-namespaced — templates, template names, and static paths all carry the slug, and the composition root panics at startup on an unprefixed template — and member-dashboard cards are generic shells: core renders a header and a loading body per declaration, naming no integration anywhere; the card body is served by the integration's own routes.
- [app] An integration's
Slug()method and its manifest's slug must be the same string, and boot asserts it: a mismatch stops startup with an error naming the integration and both values, instead of registering the provider under one slug while mounting its UI and settings under the other. - [app] Every operator surface presenting a provider's
core.providers.statusmust label it as a registry/lifecycle value, never as connectivity or health — the console performs no provider health checks, so any wording implying one (e.g. "connected") would be a claim the system cannot back. A provider with anyRequiredGroup-tagged config key unresolved must be visibly marked not-configured wherever its status renders, derived from the one shared resolution (config.RequiredKeysUnresolved) — never a second, independently-drifting readiness check.
Dimensions
- Provider vs integration — the registry row vs the code tree. One-to-one, and the words are not interchangeable.
- Provider kind — payment / provisioning / notification / tax; the sidebar shows only the provisioning subset, while the Integrations landing page and settings surface span all kinds (invariant 6).
- Capability rows vs operational rows — boot-stamped from code and reconciled every boot, vs database-canonical and operator-owned. The line runs through the middle of
core.providers. - Mandatory surface vs optional hooks — every integration has slug, manifest, migrations; routes, workflows, config, UI, and dashboard cards are opt-in interfaces discovered at composition time.
- Dispatch transport — outbox handoff when dispatch must be atomic with a domain commit; direct Temporal when the workflow owns its own writes. A capability decision per integration, not a doctrine.
- Inbound vs outbound substrate —
webhook_events(verified events in, status-polled) vsoutbox(actions out, retried to dead-letter). - Key ownership × shape × metering — ownership (the
providercolumn) and metering (whether usage of the key is measured, via the usage counters in the entitlements model) are independent: a platform-owned key may be metered. Shape is not fully independent — as built, only numeric keys have usage machinery, so a boolean key cannot be metered. - The three roles of a key — the bare stored id (
fedwiki_sites), the grouping attribute (theprovidercolumn), and the display name shown to humans. A dottedprovider.keyform does not exist in storage and must never be parsed out of a key string. - Config key classes — secret vs overridable; boot-required vs runtime-managed; enum-closed vs free. Orthogonal, all declared in the spec.
- Per-instance lifecycle vs converged-set delivery — FedWiki's resource is instance-shaped (create / set_status / delete per site); Discourse's is set-shaped (a member-set converged by reconciliation, manifest declaring only the read verbs). The verb set models the first natively and merely permits the second — the registry cannot yet say "this provider reconciles by design".
- Registry status vs configuration readiness — the operator-managed
core.providers.status(a stored lifecycle value, changed only by an operator) vs whether the declared required config actually resolves (a read-time computation,config.RequiredKeysUnresolved). Independent facts, both shown, neither one a health check (invariant 15).