Skip to content

Decision Log: Tenant & User Settings Library

Tracks the design decisions for the Tenant & User Settings Library — a common-module library that makes services runtime-configurable per user, per tenant, or by any future scope. Decisions cover the library’s scope model, precedence, persistence, hot-reload mechanism, enforcement, and admin API.

#QuestionStatusDecisionRound
DQ-001What does “configurable” mean here?DecidedRuntime user/tenant settings — not conf filesR1
DQ-002Where does the source of truth live?DecidedA Persistent Universe owned by the service + admin API endpointR1
DQ-003Who defines the set of settings?DecidedCode-owned typed catalog; values are DB-ownedR1
DQ-004Consistency / hot-reload mechanismDecidedRead-through TTL cache + in-process invalidation; no LISTEN/NOTIFYR1
DQ-005Admin API surfaceDecidedLibrary auto-installs generic /settings endpointsR1
DQ-006Persistence modelDecidedBitemporal universe base (generic/non-scoped per DQ-013)R1
DQ-007Scope discriminator on a stored valueSupersededSee DQ-013 — generic (settings_scope_type, settings_scope_id)R1
DQ-008How a definition declares scopes and precedenceDecidedOrdered List<SettingsScope> — membership and precedenceR1
DQ-009Default write authorizationDecidedTenant ⇒ Tenant Admin, User ⇒ self; overridable via EditPolicyR1
DQ-010Tenant enforcement / precedence controlDecidedNo per-value flag; precedence is the per-setting orderR2
DQ-011Does a user’s setting follow them across all their tenants?DecidedUser-global (keyed by user_id only, spans all the user’s tenants)R2
DQ-012Where settings liveDecidedHome follows breadth of effect: single-service settings (incl. user-scoped) live on that service; cross-service user settings live on the user serviceR2
DQ-013Extensible scope modelDecidedGeneric (settings_scope_type, settings_scope_id) storage + a SettingsScope enumR2
DQ-014How a choice setting presents its optionsDecidedAn option is a value/label pair; the schema carries the wordingR3

DQ-001: What does “configurable” mean here?

Section titled “DQ-001: What does “configurable” mean here?”

Context: “Configuration common library” is ambiguous — the existing HOCON stack already covers infrastructure configuration. Each interpretation implies a different architecture.

OptionDescriptionTrade-offs
ARuntime tunables / feature flagsLive behavior change; needs dynamic delivery.
BUser/tenant-scoped settingsNeeds DB store + per-scope resolution; the actual ask.
CBetter static-config SDKStays deploy-time; does not deliver runtime user control.

Recommendation: Option B.

Decision: Option B — runtime, DB-backed, user/tenant-scoped settings. Infrastructure configuration remains with the HOCON stack.

Applied to: Design § Overview, § Out of Scope.


DQ-002: Where does the source of truth live?

Section titled “DQ-002: Where does the source of truth live?”

Context: A runtime setting must be stored somewhere editable at runtime.

OptionDescriptionTrade-offs
AService’s own Postgres DB + app-owned admin APIFits the per-service datasource model; no new infra.
BFiles/env onlyNo runtime edits; wrong tool.
CExternal config serviceBattle-tested delivery; new dependency + infra + ops.

Recommendation: Option A.

Decision: Option A. (Refined by DQ-012: a setting lives on the service it affects; a user setting that affects multiple services lives on the user service instead.)

Applied to: Design § Overview, § API Contract, § Persistence.


Context: Definitions can be developer-owned in code or operator-defined at runtime.

OptionDescriptionTrade-offs
ACode-owned typed catalogType-safe reads, versioned with the service; new key = code change.
BDB-owned dynamic keysOperators add keys at runtime; stringly-typed, no compile-time safety.
CHybridMost flexible, most complexity.

Recommendation: Option A.

Decision: Option A — a code-owned schema: each set is a List<SettingsDefinition> (pure data) used to build a SettingsRegistry at bootstrap, mirroring ModuleRegistry; values are DB-owned. Definitions carry no behavior — the codec and format validation live on the closed SettingsType set — so the schema serializes straight to the API.

Applied to: Design § Information Model, § SettingsDefinition<T>, § API Contract.


DQ-004: Consistency / hot-reload mechanism

Section titled “DQ-004: Consistency / hot-reload mechanism”

Context: Values must change without a restart, and operations runs 2–4 replicas, so a write on one pod must reach the others. A codebase survey found no cache library and no Postgres LISTEN/NOTIFY — the only “notify” is the in-process Observable/Listener bus. The canonical live-refresh precedent is MaterialRegistryRefresher: a per-pod snapshot refreshed by an event plus a scheduled-tick backstop.

OptionDescriptionTrade-offs
ARead-through cache, short TTL + in-process invalidationNo new infra; matches precedent; cross-pod lag bounded by TTL.
BPostgres LISTEN/NOTIFY + TTL backstopNear-instant; net-new pattern with no foothold.
CStartup-onlySimplest; violates the no-restart requirement.

Recommendation: Option A.

Decision: Option A. If propagation lag is ever measured to matter, the next step is a per-scope generation-counter poll (mirroring MaterialRegistryRefresher), not LISTEN/NOTIFY. V0 ships a pass-through implementation of this seam — no cache, reads/writes go straight to the universe — but reads and writes still flow through the same store interface, so the read-through + write-through cache is a drop-in replacement behind it (see V0 Minimal Scope).

Applied to: Design § Read/resolve flow, § Write flow, § Out of Scope.


Context: Services need read/write endpoints for settings.

OptionDescriptionTrade-offs
ALibrary auto-installs generic secured /settings endpointsFree CRUD + describe; consistent; service supplies EditPolicy.
BMachinery onlyMore control per service, more boilerplate.

Recommendation: Option A — mirroring the OAM /configuration endpoint installed by ComponentBuilder.

Decision: Option A, as an opt-in mount rather than an automatic install (per review). The library provides a mountable settings route group a service installs explicitly — like DataAuthority routes are opt-in at the route level — backed by that set’s SettingsRegistry. Mounting is per settings set: a service declares 0..N sets, each a registry + its own SETTING_TABLE + its own mounted path, so a service with no settings mounts none and a service with several (e.g. print settings and notification settings) mounts several independent route groups.

Applied to: Design § API Contract, § Persistence, § Files to Create, § Files to Modify.


Context: A setting value needs identity, history, and audit. The bitemporal universe base provides eId/rId, a version chain, and audit columns for free.

OptionDescriptionTrade-offs
ABitemporal universe baseFree history + audit; consistent with every entity.
BPlain non-temporal tableSimpler; must hand-roll audit + history.

Recommendation: Option A.

Decision: Option A — the bitemporal base. Amended by DQ-013: the table is the generic, non-scoped bitemporal base (not the tenant-scoped ScopedTable), because a user-global value has no tenant.

A tenant-scoped ScopedTable/Scoped Universe was considered for its automatic tenant-isolation guarantee, but rejected: a ScopedTable requires a tenant on every row, whereas a User-scoped value is user-global (keyed by user_id, no tenant — DQ-011) and cannot live in one. Isolation is instead enforced by the resolver only ever loading or writing the row for (scope.type, scope.idFor(ctx)), with the ids taken only from the verified request ApplicationContext — so a caller can never address another tenant’s or user’s overrides — and is covered by the isolation by context integration test. System defaults need no stored or HOCON layer: the Global/Default tier is the code-defined default on SettingsDefinition.

Applied to: Design § Persistence, § Testing Strategy.


DQ-007: Scope discriminator on a stored value

Section titled “DQ-007: Scope discriminator on a stored value”

Context: A stored row must record which scope it belongs to.

Decision: Superseded by DQ-013. The initial per-scope-column approach (nullable user_id) is replaced by generic (settings_scope_type, settings_scope_id) columns, which do not privilege tenant or user and extend to any future scope.

Applied to: superseded — see Design § Persistence.


DQ-008: How a definition declares scopes and precedence

Section titled “DQ-008: How a definition declares scopes and precedence”

Context: A definition must declare which scopes a setting may be set at and, when two scopes both hold a value, which wins. The representation evolved across the design: first a Set<SettingsScope> with an intrinsic specificity rank, then — after DQ-010 — an ordered list.

OptionDescriptionTrade-offs
ASet<SettingsScope> + intrinsic rank on the scope typeComposable; but precedence is global to all settings and can’t be flipped per setting without a separate mechanism.
BConfigurability enumSelf-documenting; rules out the empty set; less composable; still needs a separate precedence mechanism.
COrdered List<SettingsScope> per settingOne field is both the allow-list and the precedence order; flips per setting; subsumes rank.

Recommendation: (evolved) Option C.

Decision: Option C — an ordered List<SettingsScope>, highest precedence first, declared explicitly on each definition (no magic default, because the list also declares membership — a default would over-permit). This subsumes the intrinsic rank and, with DQ-010, removes the need for a per-value enforcement flag. Resolution walks the list and returns the first scope with a value.

V0 scope: the per-setting order list is target-design, deferred past V0. V0 uses a single hardcoded order — User → Tenant → Global/Default — for every setting (see § V0 Minimal Scope). The hardcoded V0 list is therefore the V0 subset of this decision, not a competing approach.

Applied to: Design § SettingsDefinition<T>, § SettingsScope, § Read/resolve flow.


Context: Writing a tenant-wide value must not be available to any user; writing a user value must be limited to that user (or an admin).

OptionDescriptionTrade-offs
ADefault policy: Tenant ⇒ Tenant Admin, User ⇒ matching id; overridable per setting via EditPolicySafe default, minimal boilerplate, escape hatch.
BEvery setting declares its own policyExplicit but verbose; easy to get wrong by omission.

Recommendation: Option A.

Decision: Option A’s ruleTenant ⇒ Tenant Admin, User ⇒ acting on your own id — but as one uniform check in the write path, not a per-setting EditPolicy (per review). It is evaluated against the caller’s AuthPrincipal and the verified ApplicationContext ids, so cross-tenant/cross-user writes are impossible by construction. A service that needs a whole set restricted mounts it behind an admin route (DQ-005). This drops the per-setting EditPolicy override that the original Option A carried. Making write-editability tenant-configurable (a tenant choosing what its users may edit) is a form of the deferred tenant-policy enforcement (DQ-010) and is post-V0 — the V0 rule is fixed.

Applied to: Design § Write authorization, § Service Structure, § Write flow.


Round 2: Enforcement, Cross-Tenant Scope, and Generalization

Section titled “Round 2: Enforcement, Cross-Tenant Scope, and Generalization”

Round 2 was surfaced while resolving DQ-008: deciding which scopes exist forced the questions of how they stack, whether a tenant can override a user, how far a user’s value reaches, and how to keep the whole scope axis extensible.

DQ-010: Tenant enforcement / precedence control

Section titled “DQ-010: Tenant enforcement / precedence control”

Context: A tenant sometimes needs to mandate a value users cannot override (a policy). The reversed precedence must not discard the user’s value — the same user, in another tenant, still relies on it. The question is how precedence and enforcement are expressed.

OptionDescriptionTrade-offs
APer-value enforced/mandatory flag on a stored valuePer-tenant flexibility; but a flag on every relevant config entry, plus “broadest-mandatory-wins” rules across 3+ scopes.
BPrecedence declared per setting via the ordered order list (DQ-008); no flagPrecedence is a schema property set once; a higher-ordered scope wins simply by having a value; user values still persist and apply where a higher scope has none.
CDefinable precedence list and a flagRedundant; two ways to express the same thing.

Recommendation: Option B.

Decision: Option B — no per-value flag. Precedence is the per-setting order. A policy setting orders [Tenant, User], so the tenant value wins whenever it is set and the user value applies only where the tenant has none; a preference setting orders [User, Tenant]. “Enforcement” is therefore implicit (presence of a higher-ordered value) and per-tenant de facto (a tenant enforces by setting a value, or allows override by leaving it unset). Accepted trade-off: a single setting cannot let tenant A mandate while tenant B offers an overridable default — the setting’s shape is fixed by its order. That rare case is an explicit non-goal.

V0 scope: V0 implements case #1 only — the tenant value is a default a user can always override (fixed User → Tenant → default precedence, no per-setting order). Policy enforcement (case #2 — values a user cannot override) is deferred past V0; in the target design it is expressed via [Tenant, User] ordering, and possible future per-tenant controls include marking a property non-editable at the user scope, tenant-only multi-valued constraints, or tenant-level validation rules. For V0, any “users shouldn’t override this” expectation is an organizational (paper) policy, not system-enforced.

Applied to: Design § Overview, § Read/resolve flow, § Out of Scope, § V0 Minimal Scope; supersedes the earlier enforced-flag proposal.


DQ-011: Does a user’s setting follow them across all their tenants?

Section titled “DQ-011: Does a user’s setting follow them across all their tenants?”

Context: A user belongs to multiple tenants. A personal preference (e.g. locale) should persist and apply across all of them, while any tenant may still set its own value.

OptionDescriptionTrade-offs
AUser-global across tenants — keyed by user_id only, no tenantOne preference spans all of a user’s tenants; tenant-independent.
BPer-tenant user valueSimpler; but the user re-sets it in each tenant.
CBoth layersMost expressive; most machinery.

Recommendation: (framed for the reviewer.)

Decision: Option A — user-global across tenants. A user value is keyed by user_id alone, so it applies in all of that user’s tenants within the service that stores it. This supersedes DQ-007 (no per-scope column) and is realized by the generic (settings_scope_type = "user", settings_scope_id = userId) row (DQ-013). A user setting that must reach across services (e.g. timezone) is not stored per service — it lives on the user service (DQ-012).

Applied to: supersedes DQ-007; Design § Persistence, § Overview.


Context: A setting’s home should follow what it affects. Tenant defaults and user preferences that change only one service’s behavior are meaningful only inside that service. But some user settings — timezone, locale — affect several services at once; storing those per service would let the same user hold divergent values and force every service to re-collect them.

OptionDescriptionTrade-offs
AThe affected service owns every setting scoped to it — tenant and user — including a user setting that changes only that one service.Config lives with the thing it configures; no cross-service read on resolution. But a genuinely cross-service user preference (timezone) is duplicated in every service that reads it, and a user could hold divergent copies.
BHome follows breadth of effect: the affected service owns single-service settings (tenant and single-service user); the user service owns user settings that span services.One authoritative copy of a cross-service user preference; service-specific config still lives with its service. Cost: a user setting’s home depends on its reach, and a setting whose reach grows must migrate.
CThe user service owns all user settings; services own only tenant settings.Simple ownership rule for user data. But a purely single-service user preference (e.g. a printing option) leaves the service that uses it, adding a cross-service read to its resolution path.

Recommendation: (reviewer-directed.) Option B.

Decision: Option B — a setting’s home follows its breadth of effect, not merely its scope:

  • A setting that affects one service lives on that service, whatever its scope. A tenant default and a single-service user preference (e.g. a user’s printing option) are both stored on the affected service; the user preference is a settings_scope_type = "user" row (DQ-011, DQ-013). The User scope in the per-service library remains necessary for exactly this case.
  • A user setting that affects multiple services (e.g. timezone, locale) lives on the user service — held either as user-service configuration or as a field on the user model (TBD) — so there is a single authoritative value rather than one copy per service.

Accepted trade-off: choosing a home requires judging a setting’s breadth of effect up front, and a setting whose reach later grows from one service to many must migrate from that service to the user service. In return, single-service settings keep the “config lives with the service” property (DQ-002) while cross-service user preferences avoid per-service duplication.

Post-V0 — General settings. Cross-service (“General”) settings (timezone, locale) have a single source of truth owned by the module for their scope — the User module (in accounts) for user-global values, the Tenant module for tenant-wide ones (AgentFor later). A component seeds a base SettingsRegistry with the General definitions; each service layers its own service-specific registry on top; SettingsService resolves a General value by inter-component query to the owning module rather than from the local table. This, and the field-on-model vs. configuration choice for where the owning module stores them, is deferred past V0 (V0 is service-local only).

Applied to: Design § Overview, § In Plain Terms, § Persistence, § Out of Scope, § V0 Minimal Scope.


Context: The design should accommodate future scopes (e.g. Facility, Org) without reworking storage or resolution. The codebase offers two relevant precedents: ModuleRegistry (register-at-bootstrap) and BusinessRoleReference (generalizing a hardcoded variant into a role-tagged carrier). The nearest analog to scope, ServiceScope, is deliberately a sealed hierarchy so authorization logic stays exhaustive.

OptionDescriptionTrade-offs
AGeneric (settings_scope_type, settings_scope_id) storage + a SettingsScope enumAdding a scope is one enum case; no schema or resolver change; keeps compiler-enforced exhaustiveness on the security-sensitive path.
BOpen, string-keyed ScopeRegistry (services register arbitrary scopes)Maximally open; loses exhaustiveness on scope-driven authorization; departs from the ServiceScope house style.
CKeep hardcoded tenant/user columns and typesSimplest today; every future scope is a schema + resolver change.

Recommendation: Option A.

Decision: Option A. Storage is generic (settings_scope_type, settings_scope_id); SettingsScope is an enum with an idFrom(ctx) accessor, extended centrally in common-module — an enum rather than a sealed hierarchy because the id is contextual (from SettingsContext), not carried on the scope, so no per-variant data is needed and when stays exhaustive. Precedence is not on the scope (it lives in each setting’s order, DQ-008). Adding Facility later is one enum case, with no change to the table or the resolver. settings_scope_id is a varchar so a future composite scope (e.g. AgentFor, or a service scope) can string-encode several ids with a separator — {tenant_id}::{user_id}::{service_name} — keeping single-column indexing and needing no migration.

Applied to: Design § SettingsScope, § Persistence, § Class Diagram; supersedes DQ-007; amends DQ-006.


Round 3: Presenting a Constrained Value Set

Section titled “Round 3: Presenting a Constrained Value Set”

Round 3 was surfaced by the first choice setting to reach a screen. The print-template settings store a slot name (SMALL, SPECIAL_03), which is meaningless to the person picking a template — the catalog’s description of stock and layout (Index Card 3″ × 5″ — 2 per card) is what they need to read. The stored value and the presented name are not the same string, so something has to carry both.

DQ-014: How does a choice setting present its options?

Section titled “DQ-014: How does a choice setting present its options?”

Context: Choice declares the exact set a write will accept, and GET /settings serves it so a picker needs no hardcoded list. A stored value is chosen for the store — an enum name, a slot key — and is often not what a person should read. Where the human-readable name comes from decides whether a client can render a settings screen from the schema alone.

OptionDescriptionTrade-offs
Aoptions stays a list of stored values; each client maps them to wordingNothing to add server-side; but every client re-derives the same wording, drifts from the source that owns it (here, the printing catalog), and cannot render an option a later revision adds.
BKeep options as values and add a parallel optionLabels mapAdditive on the wire, so existing readers keep working; but the two fields can disagree — one may be missing entries the other has — and a client must handle a partial map.
CAn option is a value/label pair (ChoiceOption) in optionsEvery option carries its wording by construction; one field, nothing to keep in step. Costs a breaking change to the options element type, and the label is server-supplied English.

Recommendation: Option C.

Decision: Option C — an option is a ChoiceOption(value, label), and options serves { "value", "label" } objects. The value keeps every load-bearing job (it is what a write carries, what resolution returns, and the only thing the membership check compares); the label is presentation only, so a definition with nothing better to say may repeat the value. Because the labels come from whatever owns the values — for the print-template settings, each slot’s catalog description — a revision to that source moves the wording with it, and no client holds a copy to update. Two options sharing a value is rejected at bootstrap, beside the existing empty-options and default-outside-options checks: two labels for one stored value have no single right answer.

Accepted trade-off: the options element type changed from a string to an object, so a client reading it as a list of strings must be updated in step. Taken now, while printing.default*Size are the only choice settings and no released UI reads them.

Deferred: localization. A label is a server-supplied string in one language. Translating it means a locale reaching the settings read — either the request carrying one or a cross-service user setting supplying it (DQ-012, post-V0) — and a message catalog keyed by something stable. Until then a label is English, and no client should key behavior off it.

Applied to: Design § Information Model, § SettingsType, § ChoiceOption, § API Contract, § Testing Strategy.



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