Runtime settings
apistock.dev/modules/settings stores non-secret tunables in PostgreSQL so operators change them without a redeploy. Decision: ADR-0031. Admin endpoints: ops API reference.
Environment or runtime setting?#
| Put it in | When | Examples |
|---|---|---|
Environment (internal/app/config.go, .env.example) |
It is a secret, infrastructure, or needed before the database connects | DATABASE_URL, API keys such as RESEND_API_KEY, listen address, pool size |
Runtime setting (internal/app/settings.go) |
An operator should change it without a deploy | Code expiry, rate limits, sender name, frontend URL, maintenance mode |
A value is never in both, and secrets are never runtime settings.
Declaring settings#
Declare every setting before building the store, usually in internal/app/settings.go:
reg := settings.NewRegistry()
codeTTL := settings.Duration(reg, "auth.verification_code_ttl", 15*time.Minute,
settings.Describe("How long email verification codes stay valid."),
settings.Range(5*time.Minute, time.Hour),
settings.ReasonRequired(),
)
origins := settings.StringList(reg, "http.cors_origins", nil, settings.URL(), settings.MaxItems(20))
delivery := settings.Enum(reg, "mail.delivery", "async", []string{"sync", "async"})
Keys are dotted lowercase, namespaced by feature (module.name). Keys are public API: never rename one.
| Declaration | Go type | Stored and edited as |
|---|---|---|
Bool |
bool |
true |
Int |
int |
42 |
Float |
float64 |
0.5 |
String |
string |
"text" |
Enum |
string |
"async", one of the allowed values |
Duration |
time.Duration |
"15m", "1h30m" |
StringList |
[]string |
["a", "b"] |
| Option | Applies to | Effect |
|---|---|---|
Describe(text) |
all | Help text shown to operators |
Group(name) |
all | Listing group (default: the key's first segment) |
ReasonRequired() |
all | Every change needs a reason |
RestartRequired() |
all | Get keeps the startup value; changes apply after restart |
Range(lo, hi) |
Int, Float, Duration | Bounds; types must match (Range(0.0, 1.0) for Float) |
OneOf(values...) |
String, StringList items | Allowed values |
MaxLen(n) |
String, StringList items | Maximum characters |
MaxItems(n) |
StringList | Maximum items |
URL() |
String, StringList items | Absolute http(s) URL |
Email() |
String, StringList items | Bare email address |
Validate(func(T) error) |
all | Custom check; messages must not include the value |
An invalid declaration (bad key, duplicate, mismatched option, default outside its bounds) panics at startup.
Wiring the store#
store, err := settings.NewStore(ctx, pool, reg, recorder, settings.WithLogger(logger))
if err != nil {
return err
}
// Run it with the app's runners so changes from other instances arrive.
runners := []app.Runner{server, store}
NewStoreloads every stored value and freezes the registry.- The tables come from
settings.Migrations, copied intodb/migrations. Store.Runlistens with PostgreSQLLISTEN/NOTIFYon a dedicated connection, reloads after every reconnect, and reloads everything every 5 minutes (WithResyncInterval).
Reading settings#
Every handle implements config.Value[T]:
ttl := codeTTL.Get(ctx) // in-memory read, never a database call
Pass handles to use cases and library modules as config.Value[T], and call Get each time the value is needed. For a fixed value (tests, spec export), use config.Static(v).
Get returns the default when the setting was never changed, was reset, has a stored value that fails current validation, or before the store has loaded.
Changing settings#
Operators use PUT /ops/settings/{key}. In Go:
view, err := store.Set(ctx, "auth.verification_code_ttl", json.RawMessage(`"30m"`),
settings.Change{Version: view.Version, Reason: "support backlog"})
| Behaviour | Detail |
|---|---|
| Actor | The context must carry an authenticated actor (ErrActorRequired) |
| Versions | Change.Version must equal the current version (ErrVersionConflict); a never-changed setting is version 0 |
| Validation | *InvalidValueError with a reason that never includes the value |
| No-op | Setting the current value again, or resetting a default, changes nothing |
| History | Old value, new value, reason, actor and request ID per change (Store.History) |
| Audit | One settings.value.changed event per change, with version, reset and reason metadata |
| Propagation | This instance immediately; others through NOTIFY within moments |
| Reset | Store.Reset stores NULL, so versions never repeat |
Views for admin panels#
Store.List() and Store.Get(key) return a View with the effective and default values, Modified, InvalidStoredValue, Version, UpdatedAt, UpdatedBy, ReasonRequired, RestartRequired, RestartPending and Constraints (min, max, one_of, max_len, max_items, format). Store.UnknownKeys() lists stored keys no longer declared.
Storage#
| Table | Holds |
|---|---|
settings_values |
One row per changed setting: key, value (jsonb, NULL = default), version, updated_at, updated_by, org_id (reserved) |
settings_history |
Every change with old and new values, reason, actor and request ID |
Limitations#
- No per-organisation settings yet (the
org_idcolumn is reserved). - No feature flags or percentage rollouts (v1.1).
- A setting cannot be locked from the environment.