Skip to content

Design: Tenant & User Settings Library

Services today are configured only at deploy time. The HOCON stack — CompositeConfigFactory layering into ConfigurationProvider, exposed read-only through the OAM /configuration endpoint — handles infrastructure plumbing (DB URLs, ports, ARNs, API keys) well. It is immutable, developer-owned, and file/env-sourced. What it cannot express is a value a user or a customer sets at runtime and that takes effect without a redeploy: a person’s language, an organization’s default, a tenant’s auto-approve threshold.

This library — deliberately named Settings to avoid colliding with the existing “configuration” concept — adds that layer. Developers declare what is configurable once, in code, as a typed SettingsDefinition. Users and admins set values at runtime, each scoped to a user, a tenant, or any other scope added later. A value lives on the service it affects (DQ-012): a tenant default or a single-service user preference is stored on that service, while a user setting that affects several services (e.g. timezone) lives on the user service rather than being duplicated per service — the config sits with the thing it configures. A read resolves the value by walking the setting’s declared scope order and returning the first scope that has a value; a write persists that value and clears the local cache so the change is live within a short TTL, with no restart.

The scope axis is deliberately generic and extensible (DQ-013). Rather than hardcoding tenant_id / user_id columns, a value is stored against a (settings_scope_type, settings_scope_id) pair, and SettingsScope is an enumUser, Tenant, and any future scope such as Facility — giving compiler-exhaustive handling on the authorization path. Adding a scope is one enum case with no schema or resolver change. Precedence between scopes is not a per-value flag and not an intrinsic rank; it is the ordered list each setting declares (DQ-008, DQ-010), so a personal-preference setting orders [User, Tenant] and a policy setting orders [Tenant, User] — and “enforcement” is simply the higher-ordered scope having a value set.

The design reuses existing platform machinery: values persist through the bitemporal universe base (identity via eId/rId, change history and audit for free); the catalog and admin surface follow ModuleRegistry and the ComponentBuilder OAM-endpoint pattern; and hot-reload follows the read-through-cache-plus-refresh shape already proven by MaterialRegistryRefresher rather than Postgres LISTEN/NOTIFY, which has no precedent in the codebase (DQ-004). The generalize-a-hardcoded-variant-into-a-type-tag move mirrors BusinessRoleReference (DQ-013).

Think of every service as a machine with knobs people can turn while it runs — no restart, no editing files.

  • Who turns a knob? A person, a workspace, or another group added later (like a site).
  • Developers list the knobs in code — each with a name, a value type, a default, and a ranked list of who’s allowed to turn it (e.g. “person first, then workspace”).
  • Values are saved with whatever they affect — usually the service’s own database; a preference that affects several services (like your timezone) is saved once by the user service. A value saved on the service records four things:
    • kind of owner — which kind of owner it belongs to (a person, a workspace, …)
    • whose it is — which specific owner (this person, this workspace)
    • which knobs it’s turning — the setting names
    • the value — the value itself
  • Reading a knob means walking that knob’s ranked list and taking the first owner who set a value — or the built-in default if nobody did.
  • A person’s setting follows the person — set once, it applies everywhere they go, and stays saved even when a workspace sets its own.
  • Changes appear within seconds (a brief per-server memory that refreshes), and history is kept automatically.

Two deliberate “simple over powerful” trade-offs: a knob’s who-wins order is fixed by the developer — a knob is either personal-style (the person’s own choice wins over the workspace’s) or policy-style (the workspace’s choice wins over the person’s), not both (DQ-010) — and a setting lives with what it affects: one that changes a single service is kept by that service (even a personal one), while a personal setting that spans services (like your timezone) is kept once by the user service so it reads the same everywhere (DQ-012).

#DecisionChosen Option
DQ-001What does “configurable” mean here?Runtime, user/tenant-scoped settings — not conf files, not infra config
DQ-002Where does the source of truth live?A Persistent Universe owned by the service + an API Endpoint supported by the Service
DQ-003Who defines the set of settings?Code-owned schema — a List<SettingsDefinition> (pure data) per set; values DB-owned
DQ-004Consistency / hot-reload mechanismRead-through cache, short TTL + in-process invalidation; no LISTEN/NOTIFY (V0: pass-through impl behind the cache seam)
DQ-005Admin API surfaceService mounts (opt-in) a secured settings route group per settings set (registry) — like DataAuthority route opt-in; 0..N per service
DQ-006Persistence modelThe bitemporal universe base (history/audit via eId/rId); generic, non-scoped (see DQ-013)
DQ-007SettingsScope discriminator on a stored valueSuperseded by DQ-013 — generic (settings_scope_type, settings_scope_id)
DQ-008How a definition declares scopes and precedenceAn ordered List<SettingsScope> per setting — membership and precedence, explicit (V0: fixed User → Tenant → default; per-setting order deferred)
DQ-009Default write authorizationTenant ⇒ Tenant Admin, User ⇒ self — one uniform rule in the write path, not per-setting (no EditPolicy)
DQ-010Tenant enforcement / precedence controlNo per-value flag — precedence is the per-setting order; a higher scope wins by having a value (V0: default-only — no policy enforcement)
DQ-011Does a user’s setting follow them across all their tenants?User-global — keyed by user_id only, no tenant, so it spans all the user’s tenants
DQ-012Where settings liveHome follows breadth of effect: single-service settings (incl. user-scoped) live on that service; user settings that span services live on the user service
DQ-013Extensible scope modelGeneric (settings_scope_type, settings_scope_id) storage + a SettingsScope enum (id a String, composite-ready)
DQ-014How a choice setting presents its optionsAn option is a value/label pair (ChoiceOption) — the schema carries the wording, not just the stored value

Full rationale in Decision Log.


The design above is the target shape. V0 is a deliberately reduced first cut — the smallest slice that delivers runtime user/tenant settings while leaving the door open to grow into the rest without a schema or resolver rewrite. Anything not listed under In V0 is explicitly deferred.

  • Two stored scopes — User and Tenant, both service-local — above a Global/Default tier. The Global/Default tier is the code-defined default (SettingsDefinition.default), not a stored DB row, so it always has a value. There are no cross-service (“General”) settings in V0: a setting lives on the one service it affects; the user-service home for cross-service settings (DQ-012) is post-V0.
  • Fixed precedence: User → Tenant → Global/Default. A read returns the user’s stored value, else the tenant’s stored value, else the code default. V0 has no per-setting order (DQ-008) and no tenant-mandated “policy” values (DQ-010) — a tenant value is only ever a default a user may override. This fixes the “default vs policy” question as default-only for V0.
  • No cache in V0 — pass-through behind a swappable seam. Each request reads the applicable settings straight from Postgres (small, scope-indexed tables) into a per-request in-memory view; writes go straight to the universe. Reads and writes flow through the same store seam a cache will occupy, so a read-through + write-through cache drops in later as a replacement implementation — no change to SettingsService or its callers (DQ-004).
  • settings_scope_id stored as varchar, not uuid. V0 holds a single UUID string, but the column type keeps the option of composite scope ids (several UUIDs joined by a separator) open without a later migration (DQ-013).
  • Cross-service / “General” settings (e.g. timezone, locale), the inter-component read path that resolves them, and where the user service holds them — field-on-model vs. configuration (DQ-003, DQ-012).
  • Composite scopes and AgentFor (a user within a tenant, or a per-service scope).
  • Per-setting precedence order and tenant policy enforcement (DQ-008, DQ-010).
  • The read/write cache (DQ-004).

A setting is described by pure, serializable data — a SettingsDefinition — over one shared closed set of value types (SettingsType) that carries each type’s codec and format validation. There is no per-setting behavior object: validation lives on the type, and write authorization is a uniform scope rule in the service (see Service Structure), not per setting. Persistence types are shown separately under Persistence.

PlantUML diagram

SettingsDefinition is pure data (no lambdas), so SettingsRegistry.all() — the schema — serializes straight into the API. SettingsType is a closed sealed hierarchy; each case owns its codec and, in decode, its type/format validation (a bad email/url/number simply fails to decode). Choice is the one case with per-definition data — the options behind a picker, each a ChoiceOption pairing the value that is stored and written with the label to show for it (DQ-014) — and its membership rule is a write-time check against the option values in the service rather than part of decode, so a value stored before an option was withdrawn still reads back. Other per-knob constraints (ranges) are out of V0. An EffectiveSetting is a definition resolved for a caller: the winning value and its source scope (null = the code default).

PlantUML diagram

Per DQ-003 the catalog is code-owned: each service builds a SettingsRegistry from a List<SettingsDefinition> (its schema) at bootstrap and mounts a route group per set (DQ-005). SettingsService is the single implementation of the Settings facade; it resolves by walking a setting’s order, reads through the optional SettingsCache (absent in V0 — pass-through, DQ-004), and delegates persistence to the SettingsUniverse. Writes are batched (a blob of values per scope) and gated by one uniform rule — Tenant ⇒ tenant-admin, User ⇒ self, always against the caller’s verified ApplicationContext ids (DQ-009).

  • Package: cards.arda.common.lib.settings
  • Responsibility: The closed set of value types a setting may have, each owning its codec and format validation.
  • Shape: a sealed hierarchy — the objects Text, TextList, Number, Boolean, Label, Date, DateTime, Url, Email, plus Choice(options: List<ChoiceOption>). Each case carries a name holding the lower-camel-case value served as the type field of a GET /settings entry (text, textList, dateTime, choice, …) — the same wire-naming convention as the scope field of a write body ("user", "tenant").
  • Key members: encode(value): JsonElement, decode(raw): Result<*>decode is where type/format validation lives (a bad email/url/number fails to decode). Choice.decode enforces only the string shape; membership is validated on write by SettingsService.set against optionValues — the options with their labels stripped off, derived rather than served — beside the unknown-key and wrong-scope rules. Other per-knob constraints (ranges) are out of V0.
  • Design decision: DQ-003 — a closed, UI-drivable type set with the codec on the type, rather than an open per-setting codec.
  • Package: cards.arda.common.lib.settings
  • Responsibility: One selectable value of a Choice, and how to name it to a person.
  • Key fields: value — what is stored, written, and resolved against; label — what a picker shows for it. Choice.optionValues is a derived accessor, the options with their labels stripped off: it is what the write check compares against, and is not part of the payload — options is.
  • Design decision: DQ-014 — an option is a value/label pair, so a client never has to know how to name a value it merely stores. The label is presentation only: nothing resolves or validates by it, and a definition with nothing better to say may repeat the value. Two options sharing a value is a bootstrap-time failure, beside the empty-options and default-outside-options checks.
  • Package: cards.arda.common.lib.settings
  • Responsibility: Name a scope a value can be set at, and know how to derive that scope’s id from a call context.
  • Shape: an enumUser, Tenant in V0; Facility/AgentFor/… added centrally later. Global/Default is not a variant — it’s the code-defined default.
  • Key members: idFrom(ctx): String? — e.g. User → ctx.ids[USER]; a future composite scope encodes several ids in the string (DQ-013).
  • Design decision: DQ-013 — an enum keeps when exhaustive on the authorization path and is a clean map/precedence key; the id is a String so composite scopes need no schema change.
  • Package: cards.arda.common.lib.settings
  • Responsibility: Declare a configurable setting — pure data, no behavior.
  • Key fields: key, type: SettingsType, label, description, default: T, order: List<SettingsScope> (highest first — also the allow-list of scopes it may be set at), sensitive.
  • Design decision: DQ-008 — order is explicit per setting (membership + precedence). Codec, validation, and authorization live on the type or in the service, so the definition stays serializable: List<SettingsDefinition> is the schema the API serves.
  • Responsibility: A definition resolved for a caller — the schema plus the current value.
  • Key fields: definition: SettingsDefinition<T>, value: T, source: SettingsScope? (null = the code default). describe returns List<EffectiveSetting>, giving the settings UI schema + values in one payload.
  • Responsibility: The facade services depend on — typed reads (get/getResolved), batched writes (set/clear), and enumeration for the admin UI (describe).
  • Key methods: get never fails (a corrupt stored value logs and falls through to the next scope); getResolved also returns the winning source scope. set/clear take a scope + a map/list of keys — a blob per scope, applied all-or-nothing.
  • Responsibility: Carry the resolution/authorization context: ids: Map<SettingsScope, String> (scope → id), plus the caller’s AuthPrincipal.
  • Design decision: built from the request’s ApplicationContext; the ids map is the only source of scope ids, which is what makes cross-tenant/cross-user access impossible by construction (DQ-006).
  • Not per setting. One uniform rule gates writes: Tenant ⇒ requires tenant-admin authority, User ⇒ acting on your own id — evaluated against the caller’s AuthPrincipal and the verified ApplicationContext ids. There is no per-setting EditPolicy (DQ-009); a service that wants a whole set admin-only mounts it behind an admin route (DQ-005).

Per DQ-006 / DQ-013 values persist through the bitemporal universe base (not the tenant-scoped ScopedTable — a user-global value has no tenant). The base provides eId/rId identity, the version chain (previous, retired), bitemporal times, and audit (btsAuthor/btsAuthorSub). On top of that base, each settings set has its own SETTING_TABLE (DQ-005), with one row per scope — not one row per setting — holding that scope’s overrides as a single JSON document:

ColumnTypePurpose
settings_scope_typevarcharWhich SettingsScope this row’s overrides are for — "user", "tenant", …
settings_scope_idvarcharThe id of that scope instance (the user’s id, the tenant’s id, …). Stored as text so a future composite scope can encode several ids with a separator, no migration needed (DQ-013).
overridesjsonbAll of this scope’s overrides for this service, keyed by SettingsDefinition.key: { "<key>": <encoded value>, … }. Each value is decoded/validated via the definition’s SettingsType.

Unique on (settings_scope_type, settings_scope_id) for the currently-valid record — one live row per scope. An edit carries every existing key in overrides forward into a new rId under the same eId (setting or clearing only the changed key), so history is retained at document granularity. Tenant isolation holds by construction: the resolver only ever loads the row for (scope, scope.idFrom(ctx)) where the id comes from the verified request ApplicationContext, so a caller can never address another tenant’s or user’s overrides (covered by the isolation by context integration test). There is no enforced/mandatory column (DQ-010) and no tenant_id/user_id column (DQ-013) — a user’s overrides are simply settings_scope_type = "user", settings_scope_id = <userId>, with no tenant, which is what makes them span all of that user’s tenants (DQ-011). This per-service user row is for user settings that affect only this service; a user setting that spans services (e.g. timezone) is not stored here — it lives on the user service (DQ-012).

PlantUML diagram

SettingsPayload.validate runs each changed key’s value through its SettingsType.decode, so a value that fails its type/format is rejected at the universe boundary, consistent with every other entity in the system.


A read walks the setting’s order — highest-precedence scope first — and returns the first scope that has a value. For each scope it derives that scope’s id from the context; a missing id (a scope that doesn’t apply to this caller) is skipped. Values are served through the optional per-scope cache (absent in V0 — a pass-through read straight from the universe, DQ-004); either way it loads that scope’s overrides document and reads the key from it. A read never fails: a stored value that no longer decodes logs and is treated as absent, so resolution continues down the order.

PlantUML diagram

Because precedence is the declared order, “enforcement” needs no special handling: a policy setting ordered [Tenant, User] returns the tenant value whenever the tenant has set one, and the user’s value only when the tenant has not — while the user’s value is still persisted and applies in the user’s other tenants that have not set a value.

A write is a batch for one scope (set(scope, ctx, values)): every key is checked against its setting’s order (is this scope allowed?) and its SettingsType (does the value decode?), and the write is authorized by the uniform rule (Tenant ⇒ tenant-admin, User ⇒ self) — all before any persistence. Then the universe reads the scope’s current overrides, merges in the changed keys, and writes the full document forward as a new record (stamping author, carrying every other key unchanged), and the writing instance invalidates that scope in its cache (a no-op in V0). Other replicas converge within the TTL (DQ-004). Error paths: a scope not in a setting’s order, or a value failing its type/format → AppError.ArgumentValidation before any DB work; an unauthorized write → AppError.NotAuthorized.

PlantUML diagram

A service mounts these routes (opt-in) per settings set — each backed by that set’s SettingsRegistry (DQ-005) — secured by the component’s existing authentication; the shapes below are shown at a representative path, but each mount uses its own service-chosen path. Scope ids come from the verified request ApplicationContext (JWT-derived); the scope in a write is a SettingsScope name (e.g. "user"). A read returns the whole schema resolved with the caller’s current values in one payload; writes operate on a blob of multiple settings at once, mirroring the per-scope overrides document — the whole batch is validated and authorized before any persistence, then applied as a single carry-forward write (all-or-nothing).

Mounting. A service declares one SettingsRegistry per set and mounts each at its own path; a service with no settings mounts none, a service with several mounts several independent groups:

val printSettings = SettingsRegistry(listOf(defaultPrinterDef, duplexDef))
val notificationSettings = SettingsRegistry(listOf(emailOptInDef, digestFrequencyDef))
mountSettings(printSettings, "/print-settings")
mountSettings(notificationSettings, "/notification-settings")

Each mount exposes the route shape below at its own path (/print-settings, /notification-settings, …), over that set’s registry and its own SETTING_TABLE.

  • GET /settings200: List<EffectiveSetting>, one entry per definition applicable to the caller, carrying both schema and resolved value in a single payload — the definition’s key, label, description, type, default, editable scopes, and order, plus the effective value and its source scope. A choice setting also carries options — the exact set a write will accept, each entry a { "value", "label" } object — so a picker reads both its legal values and their wording off the schema rather than hardcoding either (DQ-014):

    { "key": "printing.defaultCardSize", "type": "choice", "value": "SMALL", "source": "user",
    "options": [{ "value": "SMALL", "label": "Index Card 3\" × 5\" — 2 per card" },
    { "value": "LARGE", "label": "Avery 5395 Sheet — 8 per sheet" }] }

    A write carries the value; the label is never sent back. options is absent for every other type. Everything the settings UI needs in one call.

  • PUT /settings — body { "scope": "user" | "tenant" | …, "values": { "<key>": <json>, … } }. Upserts every listed setting for that scope in one call (merged into the scope’s overrides, carrying the rest forward). 200: applied; 400: any value fails type/validation or a key isn’t settable at scope; 403: the caller isn’t permitted to write scope (e.g. a non-admin writing tenant); 404: any unknown key. Nothing is written unless the whole batch passes.

  • DELETE /settings — body { "scope": "user" | "tenant" | …, "keys": ["<key>", …] }. Clears those overrides for the scope in one call; each cleared value falls to the next scope in order. 400/403/404 as above.


FilePackage/PathPurpose
SettingsScope.ktcards.arda.common.lib.settingsEnum of scope types + idFrom(ctx); User/Tenant in V0.
SettingsType.ktcards.arda.common.lib.settingsSealed set of value types, each with its codec + format validation; Choice carries its options as ChoiceOption value/label pairs.
SettingsDefinition.ktcards.arda.common.lib.settingsPure-data definition (order list) + companion factories.
SettingsRegistry.ktcards.arda.common.lib.settingsBuilt from a List<SettingsDefinition>; looks up + enumerates the schema.
Settings.ktcards.arda.common.lib.settingsFacade + EffectiveSetting, SettingsContext.
SettingsService.ktcards.arda.common.lib.settingsFacade impl — order-walk resolution, batch writes, uniform authz.
SettingsCache.ktcards.arda.common.lib.settingsPer-scope read-through cache with TTL + invalidation.
SettingsPersistence.ktcards.arda.common.lib.settings.persistenceSETTING_TABLE, SettingsRecord, SettingsPayload, SettingsUniverse.
SettingsEndpoints.ktcards.arda.common.lib.settings.apiMountable settings route group — mountSettings(registry, path), opt-in per settings set.
V001__settings.sqlconsuming service .../database/migrationsGeneric table + unique index (per settings set, shipped as a copyable template).
FileChange Description
common-modulecomponent/ComponentBuilder.ktExpose a mountSettings(registry, path) helper a service opts into (per settings set), mirroring the DataAuthority route opt-in rather than auto-installing.
  • Infrastructure configuration — deploy-time DB/AWS/auth config stays with the HOCON stack; this library never touches it.
  • Operator-defined dynamic keys — the catalog is code-owned (DQ-003); a runtime “create a new setting” capability is not in v1.
  • A general shared/global settings store — settings that affect a single service live on that service; user settings that affect several services (e.g. timezone) live on the user service (DQ-012). There is no standalone service owning arbitrary settings, and two services needing unrelated same-named settings still store them independently.
  • Per-value enforcement flag / per-tenant lock choice — precedence is fixed per setting via order (DQ-010); a setting that must let one tenant mandate while another offers an overridable default is an explicit non-goal for v1.
  • LISTEN/NOTIFY or external config services — TTL + in-process invalidation is the v1 mechanism (DQ-004).
  • Frontend settings UI — this design delivers the API that powers it.

TestTargetValidates
order-walk returns first set valueSettingsService.getResolved[User, Tenant] returns the user value and reports source = User.
policy order returns tenant when setSettingsService.getResolved[Tenant, User] returns the tenant value over a set user value.
policy order falls through when tenant unsetSettingsService.getResolved[Tenant, User] returns the user value when the tenant has none.
corrupt stored value skippedSettingsService.geta value failing SettingsType.decode logs and resolution continues down the order.
write rejects scope not in orderSettingsService.setAppError.ArgumentValidation before any DB work.
write rejects failed validationSettingsService.seta value that fails its SettingsType decode surfaces as AppError.ArgumentValidation.
write rejects a value outside a ChoiceSettingsService.seta Choice value not in optionValues surfaces as AppError.ArgumentValidation; a value drawn from them round-trips.
a choice option reaches the wire labelledEffectiveSetting.toVieweach options entry serializes as { "value", "label" }; a non-choice setting reports no options.
duplicate option value rejectedSettingsDefinition.choicetwo options sharing a value fail at bootstrap, like an empty option list or a default outside the options.
write authorization ruleSettingsServiceTenant requires admin; User requires matching id.
local write invalidates scopeSettingsCachea set evicts exactly the affected (settings_scope_type, settings_scope_id) snapshot.
future scope resolves genericallySettingsServicea test SettingsScope case added to order resolves with no resolver change.
TestSetupValidates
write-then-read round-tripContainerizedPostgres + SettingsUniversea persisted value resolves back through the universe.
user value spans tenantsContainerizedPostgresone settings_scope_type="user" row resolves for the same user under two different tenant contexts.
history retained across editsContainerizedPostgrestwo edits yield two records under one eId.
isolation by contextContainerizedPostgresa caller cannot read or overwrite another tenant’s or user’s row.
TestMethodPathExpected
describe effective settingsGET/settings200 with per-definition effective value + source.
set tenant value as adminPUT/settings200; GET reflects it with source = tenant.
user cannot set tenant scopePUT/settings403 for a non-admin writing scope=tenant.
clear falls through orderDELETE/settings200; GET returns the next scope’s value or default.

  • Decision Log
  • Project landing
  • common-modulecomponent/ConfigurationProvider.kt — the deploy-time configuration stack this library complements.
  • common-modulecomponent/ComponentBuilder.kt — the OAM endpoint-installer pattern the admin API mirrors.
  • common-modulemodule/ModuleRegistry.kt — the register-at-bootstrap catalog precedent for SettingsRegistry.
  • common-moduleruntime/ApplicationContext.kt (ServiceScope) — the platform scope precedent; SettingsScope is the settings analog (an enum, since its id is contextual rather than carried).
  • operationsreference/businessaffiliates/domain/BusinessRoleReference.kt — precedent for generalizing hardcoded variants into a type-tagged carrier.
  • operationsshopaccess/email/MaterialRegistryRefresher.kt — the read-through-plus-refresh precedent for hot-reload.


Copyright: (c) Arda Systems 2025-2026, All rights reserved