ADR-0012 — Module configuration group contract (data-driven settings rail)
| Field | Value |
|---|---|
| Status | ✅ Accepted — adopted 2026-08-03 |
| Date | 2026-08-03 |
| Authors | @salvatore.balestrino |
| Related | ADR-0006 (core-only base — the contract is consumed by every fork's addons through the same Module seam the core uses); ADR-0007 (per-addon i18n namespaces — the label-resolution order below) |
Context
GET /v1/admin/modules/{name} returned a flat configSchema[]. The only grouping primitive was ConfigField.Group, a bare display string with no hierarchy, no description, no icon, and no ordering beyond declaration order — rendered as horizontal tabs inside one card. That shape does not scale: auth alone carries 62 of the base's ~79 core fields, 19 of them OAuth provider credentials that stayed on screen even when the provider was switched off; the other three schema-bearing core modules (notification, tenant, compliance) rendered a single flat column.
The settings surface is not core-private. Per ADR-0006 every optional module a fork adds is built against the same in-tree SDK contract the eight core modules use, and its configuration renders through the same /admin/modules/{name} page. So any layout primitive added here is a contract consumed by every fork — it must be expressible as declarative data, degrade cleanly for a module that declares nothing, and never force existing addons to change.
Three constraints shaped the design:
- The
Moduleinterface is frozen at v1. Adding a mandatoryConfigGroups()method would break every addon in every fork on the next sync. ConfigFieldis persisted (bson:"configSchema"onModuleConfig) and rewritten from the running binary byRefreshMetadataon every boot — so any new field member is persisted and must carry bothjsonandbsontags.- The client must not validate more strictly than the server — a rule the UI enforces that the backend accepts is a divergence an operator hits as a phantom error.
Decision
Make grouped, conditional module configuration a first-class, data-driven, forward-compatible part of the SDK contract.
-
Groups are declared through an optional interface, not the
Moduleinterface.ConfigGroup{Key, Label, Description, Icon, Parent, Order}is surfaced byHasConfigGroups(ConfigGroups() []ConfigGroup), resolved via the houseConfigGroupsOf(m)accessor (the same idiom asRequiredServicesOf). A module that does not implement it returnsnil.ConfigField.Groupnow carries aConfigGroup.Key, not a display label;Parentnests groups to any depth. -
Groups are never persisted.
ConfigGroupis presentational and fully code-derived, so — unlikeConfigField— it carriesjsontags only and is resolved live from the registry by the admin handler on each request (exactly asRequiredServicesOfalready is).RefreshMetadatais untouched, so a schema change propagates on the next boot with no migration. -
Per-field metadata rides on the persisted
ConfigField. New members —Advanced,DependsOn []FieldCondition(+DependsOnMatchall/any),Min,Max,Pattern,Placeholder,HelpURL— each carryjsonandbsontags,omitempty.DependsOnis a struct list ({Key, In}), not an expression string — no parser to keep consistent between Go and TypeScript. Visibility combines AND across entries, OR within one entry'sIn;DependsOnMatch: "any"ORs across entries (needed where a capability is reachable from more than one switch, e.g. an OAuth provider enabled per audience surface). A hidden field is never required, never enters the save diff, and keeps its stored value (switching a provider off must not discard its secret). -
i18n keys are derived, not declared. The translation key comes from the stable
Key:config.fields.<fieldKey>.{label,desc}andconfig.groups.<groupKey>.{label,desc}. Resolution order (twin ofhelpers/navLabel.ts): the addon's own namespace (ADR-0007) → the core bundle (moduleConfig.<module>.…) → the literalLabel/Descriptionthe backend sent. A present-but-empty key counts as absent, so an un-migrated addon keeps showing English rather than a raw key path — and the schema carries no redundant i18n field. -
Graceful degradation has two thresholds, both defaulting to zero work for a fork. The full master-detail page (Overview / configuration tree / Dependencies / Environments) is promoted only when a module declares
ConfigGroups()and its resolved tree has ≥2 top-level nodes (hasPageRail). A looser predicate (hasCardRail) gives the configuration card its own internal tab rail whenever there are ≥2 top-level nodes or any groups are declared at all — so a single declared group, or the legacy heuristic of ≥2 distinctfield.grouplabels, still opts into the card rail without being promoted to the whole-page framing. A module that declares no groups and whose fields fall into fewer than two legacy buckets renders the flat form, exactly as before. Every threshold reads off the declared data, so an un-migrated fork addon needs no changes and declaring none is a supported end state, not a gap. -
A declaration-integrity gate runs over the real catalog.
ValidateConfigDeclarations(invoked bycmd/server's catalog test) checks, for every registered module, that eachField.Groupresolves to a declaredConfigGroup.Key, eachDependsOn.Keyresolves to a field of the same module,DependsOnMatchis valid, andParenthas no cycles — among other structural checks (duplicate keys, an uncompilablePattern, invertedMin/Max, aDependsOnvalue outside its target field's domain). Living in the SDK package, it runs for fork addons too — a typo fails a test instead of producing a phantom rail entry. -
The declarative client validator replaces the stricter regex. Validation is generated from the same metadata (
Required/Min/Max/Pattern/type), so the UI never rejects a value the server accepts.Requiredremains a UI hint — nothing inpkg/sdk/moduleenforces it — so conditional visibility introduces no server/client divergence.
Consequences
- A fork gets the sectioned settings rail for free by declaring
ConfigGroups()and moving itsField.Groupvalues to keys — no page code to write. The four core modules (authin phase 4;notification/tenant/compliancein phase 5) are the reference migrations. - Nothing a fork already shipped breaks. An addon that declares no
ConfigGroups()renders exactly as before — the flat form, or the legacy card-internal rail if its fields already carry ≥2 distinct group labels — and keeps its English labels; the response merely gainsconfigGroupsand the sevenConfigFieldmembers, allomitempty. - The
ConfigField/ConfigGroupshapes are now part of the frozen v1 surface. They serialise intoopenapi/enterprise.json; a change to either is an OpenAPI diff gated byopenapi-check. Declaring data against them (a module's own groups/conditions) is not a shape change and produces no spec diff. - The forbidden shapes (ADR-0006) still hold: this contract lives in the single in-tree
pkg/sdk— no satellitego.mod, no published module.
Alternatives considered
- Extend the existing
Groupdisplay string (encode hierarchy/description into it) instead of a first-class type — rejected: no typed home for description, icon, order, or parent, and string-encoded hierarchy is fragile to author and parse.ConfigGroupis self-describing and validated. - Add
ConfigGroups()to theModuleinterface — rejected: the interface is frozen at v1, so a new mandatory method breaks every addon in every fork on the next sync. The optionalHasConfigGroupssub-interface (the house…Ofaccessor idiom) costs an un-migrated module nothing. - A DSL/expression string for
DependsOn(e.g.provider == "smtp") — rejected: a parser to write, ship, and keep byte-identical between Go and TypeScript. The{Key, In}struct list needs no parser and is checked structurally by the integrity gate. - Persist
ConfigGroupalongsideConfigField— rejected: it is presentational and fully code-derived, so persisting it would demand a data migration on every layout tweak. It is resolved live from the registry each request, and a test forbids ever adding absontag. - A declared i18n-key field on each field/group — rejected: redundant with the stable
Key. Derivingconfig.fields.<key>/config.groups.<key>keeps the schema free of translation concerns and lets an un-migrated addon fall back to the backend's English literal. - An explicit opt-in flag to turn the rail on — rejected in favour of the automatic
hasPageRail/hasCardRailpredicates, so a fork gets the right layout from its declared data with no extra switch to remember.