ConfigService
Stores per-module configuration in MongoDB with a 30-second Redis cache. Secret fields are AES-256-GCM-encrypted at rest and never returned through public API responses.
Each module declares its schema via ConfigSchema():
func (m *Module) ConfigSchema() module.ConfigSchema {
return module.ConfigSchema{
Fields: []module.ConfigField{
{Key: "SMTP_HOST", EnvVar: "SMTP_HOST"},
{Key: "SMTP_PASSWORD", EnvVar: "SMTP_PASSWORD", Secret: true},
},
}
}
Environment profiles
Each module can maintain multiple named environments (e.g. sandbox, production). The "active" environment is the one whose values get loaded. Switching is a single API call:
PUT /v1/admin/modules/{name}/active-environment
Activation-time validation
Switching the active environment normally performs no semantic validation — a module can activate any profile it has stored, including one an operator saved with values that later turned out to be invalid. That is deliberate: the stored profile might be a legacy-invalid one, and refusing to activate it would leave a deployment stuck. The exception is a module that implements the optional HasConfigActivationValidator interface:
type HasConfigActivationValidator interface {
ValidateConfigActivation(ctx context.Context, targetValues map[string]string) error
}
ValidateConfigActivation runs inside SetActiveEnvironment, after confirming the target environment exists and strictly before the repository write that flips the active profile and marks needsRestart: true. Returning an error — typically a *module.ConfigValidationError — aborts the switch before that write, so a rejected activation leaves both the previously active profile and needsRestart untouched. targetValues is the target profile's non-secret configuration map; secrets are never passed to the hook.
This is a separate seam from HasConfigValidator (the PATCH-time hook covered above): PATCH-time validation sees one merged profile as it is being edited, while activation validation judges an already-stored profile as a complete whole before it becomes active. A module can implement either, both, or neither. Modules that omit HasConfigActivationValidator keep today's validation-free activation exactly as before — this is the seam a tenant-provisioning-policy validator hooks into to refuse activating a profile whose policy is no longer satisfiable, without pkg/sdk knowing anything about tenants. When a module implements HasConfigSnapshotValidator, that hook takes precedence on activation too and neither older hook is called — see Snapshot validation.
Stable validation codes
ConfigValidationError carries an optional Code field:
type ConfigValidationError struct {
Field string
Message string
Code string // optional, e.g. "tenant.single_mode_conflict"
}
When a validator — PATCH-time or activation-time — returns a ConfigValidationError with a non-empty Code, the admin API responds with a stable {status, title, detail, code} envelope instead of the legacy text-only 422:
{
"status": 422,
"title": "Unprocessable Entity",
"detail": "mode: profile not activatable",
"code": "tenant.single_mode_conflict"
}
Leaving Code empty keeps the pre-existing text-only 422 Unprocessable Entity response, so an existing validator needs no changes to keep working. The mapping is applied uniformly across all three module-admin mutation surfaces — PATCH /v1/admin/modules/{name}, PATCH /v1/admin/modules/{name}/environments/{env}, and PUT /v1/admin/modules/{name}/active-environment — so a frontend can key off code the same way on any of them.
Snapshot validation
A module that needs a cross-field rule involving secrets — "this OAuth provider is fully configured" — implements the optional HasConfigSnapshotValidator instead of the two hooks above:
type ConfigValidationSnapshot struct {
Environment string
Values map[string]string // raw merged target; absent and "" are distinct
EffectiveValues map[string]string // runtime EnvVar/Default fallback applied
SecretPresent map[string]bool // per secret key: would a non-empty value be in force?
}
type HasConfigSnapshotValidator interface {
ValidateConfigSnapshot(ctx context.Context, snapshot ConfigValidationSnapshot) error
}
It runs on every mutation surface with the exact profile that would become effective: the active profile merged with a PATCH /v1/admin/modules/{name}, the named profile merged with a PATCH …/environments/{env}, or the stored target of a PUT …/active-environment. SecretPresent is computed from the target profile's own stored secrets, the secrets submitted in the same request, and the schema's EnvVar/Default — never from another profile, and never as plaintext. A request may therefore save a provider secret and flip a dependent switch atomically, and an inactive profile is never judged with the active profile's credentials. Modules that keep HasConfigValidator / HasConfigActivationValidator are unaffected.
Request lanes
PATCH /v1/admin/modules/{name} and PATCH …/environments/{env} carry config and secrets as two blocks, and the service refuses a key that does not belong where it was sent — a declared secret in config, a declared non-secret value or a record-list label in secrets, the SDK-owned roster key <field>.__items from either block, or any key the module does not declare — with 422 and the stable code module.config_key_invalid, naming the key. The check runs before validation, encryption or persistence, against the module's live schema, so a secret can never be written in plaintext through the wrong block. A module that declares no schema accepts anything, as before.
The lane rule governs new writes; what a legacy or malformed document already holds is handled separately. Any key the live schema declares as a secret — a scalar FieldSecret, or a record-list element's secret sub-field — is stripped from every non-secret map the service reads or writes: the validation snapshot (and the older ValidateConfig / ValidateConfigActivation hooks, which are handed the same stripped map), the configValues of every admin response (GET/PATCH /v1/admin/modules/{name} and …/environments/{env}), and the merged map each mutation persists, so the next ordinary write repairs the document. The three paths that copy a whole map are stripped too: the boot backfill (which therefore also realigns a legacy mirror that still carries plaintext), the lazy legacy→profiles migration, and the map an activation publishes into the legacy mirror. Both blocks are additionally checked against the roster: an element key for a slug that is not a member — and, on the record-list route, not being created by the same request — is refused with 409 and ErrUnknownSlug, so an orphan value is never parked in the document and an orphan ciphertext is never left for a later create to adopt.
Atomic writes and optimistic concurrency
Every module-config mutation is a single MongoDB UpdateOne guarded by the document's monotone configRevision: the service reads the document, validates the candidate, and writes profile values, secrets, the legacy mirror, needsRestart and configRevision + 1 together — or nothing. A write that lost the race returns 409 with body code module.config_revision_stale; reload the document and review the diff before saving again. The operator console does exactly that ("Reload & review"). needsRestart is persisted in the same write from the module's HotReloadConfig() declaration, so a successful edit to a hot-reloadable module never leaves a stale restart hint (the operator console renders the hint as a "Restart required" strip in the module detail header); the one clear that still happens afterwards — an enable/disable that carried no cold config change — is itself guarded by the revision that request observed, so a config change that landed in between keeps the restart hint it earned. Switching the active profile is the same single guarded write: the target's values and secrets are published into the legacy mirror by the service (stripped, per the lane rule above) rather than copied server-side, which the revision guard makes safe because any concurrent write moves configRevision and the activation then matches nothing. Enabling and disabling are separate from all of this, and a module whose Stop() fails is not disabled: the flag is restored and the request answers 422, because the module and its infrastructure are still running. A legacy document that predates environment profiles is migrated on first access through the same compare-and-swap discipline, and a read that cannot complete that migration fails rather than serving the unmigrated document.
Required documents
Lazy self-healing (an admin read rebuilding a wiped document from schema defaults) is the default, but a deployment can mark modules whose document must not be rebuilt at runtime — Orkestra marks auth, because its per-surface password-login policy is read strictly and a rebuilt default would silently re-enable password sign-in. For a marked module a missing document is an outage: GET /v1/admin/modules/{name} and every mutation return 503, GET /v1/admin/modules still lists every other module and shows the marked one as "status": "missing" with "missing": true. Restore the document, or fix the database and restart so boot seeding runs.
A module that must decide something across several keys at once — the auth module's "is this OAuth provider enabled and structurally complete, and which secret do I build it from" — reads them through ActiveConfigRequiredModule, which returns one consistent, read-once view of the active profile with every stored secret decrypted up front. A secret that no longer decrypts fails that read as an outage instead of quietly substituting the environment-variable fallback the way single-key reads do.
Repeatable fields (recordList)
A module that needs an operator-managed list of records — several named
delivery profiles, several webhook endpoints — declares one field of type
FieldRecordList and describes a single element with Items:
{
Key: "email.profiles",
Label: "Delivery profiles",
Type: module.FieldRecordList,
Items: []module.ConfigItemField{
{Key: "host", Label: "SMTP host", Type: module.FieldString, Required: true},
{Key: "password", Label: "Password", Type: module.FieldSecret},
},
}
Storage stays the flat key/value map every other field uses. Each element gets
an immutable slug, minted once from the name the operator types
(MailUp SMTP+ → mailup-smtp) and never changed by a later rename, and its
values live at dotted keys built from it:
| Key | Holds |
|---|---|
email.profiles.__items | the roster — comma-joined slugs, in order |
email.profiles.<slug>.__label | the element's editable display name |
email.profiles.<slug>.host | one declared sub-field |
Because an element's secret is an ordinary encrypted value at an ordinary key,
per-key AES-256-GCM encryption is untouched. The __ prefix is reserved to the
SDK; ValidateConfigDeclarations rejects a sub-field key that uses it, a
recordList with no Items, Items on any other type, a nested recordList,
and a sub-field condition that names anything but a sibling in the same element.
Read the list back as a Go slice:
type profile struct {
Slug string `module:"slug"` // the immutable key segment
Label string `module:"label"` // the display name
Host string `module:"host"`
Password string `module:"password"`
}
var cfg struct {
Profiles []profile `module:"email.profiles"`
}
err := svc.UnmarshalModule(ctx, "notification", &cfg)
Changing membership
Membership is explicit intent, never inferred from which keys a request happens to carry, and it is accepted only on the per-environment PATCH:
PATCH /v1/admin/modules/{name}/environments/{env}
{
"config": { "email.profiles.ses.__label": "SES bulk" },
"recordLists": [{ "field": "email.profiles", "create": ["ses"], "remove": ["old"] }],
"revision": 7
}
Every environment write is a compare-and-swap on a per-environment revision,
returned by the matching GET. A request that removes anything must send
one: removal destroys the element's keys, encrypted secrets included, and must
not be replayed against a state the operator never saw. A request that only
adds may omit it — two operators each adding an element is a compatible
outcome, and the service retries the loser of that race against the refreshed
roster rather than failing it.
Status codes follow what the client can do about the failure. 409 means the
roster moved underneath them (stale revision, creating a slug that now exists,
removing one that no longer does) — re-read and retry. 422 means the
request could never have succeeded as sent (a removal with no revision, the
same field twice, a slug in both create and remove, or more than 50
elements).
A list is capped at 50 elements regardless of the field's Max.
Audit
Every mutation through the admin API emits one audit event per actual result into the compliance trail — module.config.updated, module.config.environment_activated, module.enabled, module.disabled — carrying the acting user's UUID, tenant, IP and User-Agent, the key names the request submitted (and, for record lists, per-field created/removed counts), and the failure code when there is one. Values, secrets and slugs are never recorded. Emission is best-effort: a failed insert is logged and never rolls back the mutation, and the sink's own insert runs under a two-second timeout — the most latency an emit can add to the request.