Decision Log: Tenant & User Settings Library
Purpose
Section titled “Purpose”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.
Decision Table
Section titled “Decision Table”| # | Question | Status | Decision | Round |
|---|---|---|---|---|
| DQ-001 | What does “configurable” mean here? | Decided | Runtime user/tenant settings — not conf files | R1 |
| DQ-002 | Where does the source of truth live? | Decided | A Persistent Universe owned by the service + admin API endpoint | R1 |
| DQ-003 | Who defines the set of settings? | Decided | Code-owned typed catalog; values are DB-owned | R1 |
| DQ-004 | Consistency / hot-reload mechanism | Decided | Read-through TTL cache + in-process invalidation; no LISTEN/NOTIFY | R1 |
| DQ-005 | Admin API surface | Decided | Library auto-installs generic /settings endpoints | R1 |
| DQ-006 | Persistence model | Decided | Bitemporal universe base (generic/non-scoped per DQ-013) | R1 |
| DQ-007 | Scope discriminator on a stored value | Superseded | See DQ-013 — generic (settings_scope_type, settings_scope_id) | R1 |
| DQ-008 | How a definition declares scopes and precedence | Decided | Ordered List<SettingsScope> — membership and precedence | R1 |
| DQ-009 | Default write authorization | Decided | Tenant ⇒ Tenant Admin, User ⇒ self; overridable via EditPolicy | R1 |
| DQ-010 | Tenant enforcement / precedence control | Decided | No per-value flag; precedence is the per-setting order | R2 |
| DQ-011 | Does a user’s setting follow them across all their tenants? | Decided | User-global (keyed by user_id only, spans all the user’s tenants) | R2 |
| DQ-012 | Where settings live | Decided | Home follows breadth of effect: single-service settings (incl. user-scoped) live on that service; cross-service user settings live on the user service | R2 |
| DQ-013 | Extensible scope model | Decided | Generic (settings_scope_type, settings_scope_id) storage + a SettingsScope enum | R2 |
| DQ-014 | How a choice setting presents its options | Decided | An option is a value/label pair; the schema carries the wording | R3 |
Round 1: Initial Design
Section titled “Round 1: Initial Design”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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Runtime tunables / feature flags | Live behavior change; needs dynamic delivery. |
| B | User/tenant-scoped settings | Needs DB store + per-scope resolution; the actual ask. |
| C | Better static-config SDK | Stays 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Service’s own Postgres DB + app-owned admin API | Fits the per-service datasource model; no new infra. |
| B | Files/env only | No runtime edits; wrong tool. |
| C | External config service | Battle-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.
DQ-003: Who defines the set of settings?
Section titled “DQ-003: Who defines the set of settings?”Context: Definitions can be developer-owned in code or operator-defined at runtime.
| Option | Description | Trade-offs |
|---|---|---|
| A | Code-owned typed catalog | Type-safe reads, versioned with the service; new key = code change. |
| B | DB-owned dynamic keys | Operators add keys at runtime; stringly-typed, no compile-time safety. |
| C | Hybrid | Most 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Read-through cache, short TTL + in-process invalidation | No new infra; matches precedent; cross-pod lag bounded by TTL. |
| B | Postgres LISTEN/NOTIFY + TTL backstop | Near-instant; net-new pattern with no foothold. |
| C | Startup-only | Simplest; 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.
DQ-005: Admin API surface
Section titled “DQ-005: Admin API surface”Context: Services need read/write endpoints for settings.
| Option | Description | Trade-offs |
|---|---|---|
| A | Library auto-installs generic secured /settings endpoints | Free CRUD + describe; consistent; service supplies EditPolicy. |
| B | Machinery only | More 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.
DQ-006: Persistence model
Section titled “DQ-006: Persistence model”Context: A setting value needs identity, history, and audit. The bitemporal universe base provides eId/rId, a version chain, and audit columns for free.
| Option | Description | Trade-offs |
|---|---|---|
| A | Bitemporal universe base | Free history + audit; consistent with every entity. |
| B | Plain non-temporal table | Simpler; 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Set<SettingsScope> + intrinsic rank on the scope type | Composable; but precedence is global to all settings and can’t be flipped per setting without a separate mechanism. |
| B | Configurability enum | Self-documenting; rules out the empty set; less composable; still needs a separate precedence mechanism. |
| C | Ordered List<SettingsScope> per setting | One 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.
DQ-009: Default write authorization
Section titled “DQ-009: Default write authorization”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).
| Option | Description | Trade-offs |
|---|---|---|
| A | Default policy: Tenant ⇒ Tenant Admin, User ⇒ matching id; overridable per setting via EditPolicy | Safe default, minimal boilerplate, escape hatch. |
| B | Every setting declares its own policy | Explicit but verbose; easy to get wrong by omission. |
Recommendation: Option A.
Decision: Option A’s rule — Tenant ⇒ 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Per-value enforced/mandatory flag on a stored value | Per-tenant flexibility; but a flag on every relevant config entry, plus “broadest-mandatory-wins” rules across 3+ scopes. |
| B | Precedence declared per setting via the ordered order list (DQ-008); no flag | Precedence 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. |
| C | Definable precedence list and a flag | Redundant; 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | User-global across tenants — keyed by user_id only, no tenant | One preference spans all of a user’s tenants; tenant-independent. |
| B | Per-tenant user value | Simpler; but the user re-sets it in each tenant. |
| C | Both layers | Most 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.
DQ-012: Where do settings live?
Section titled “DQ-012: Where do settings live?”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.
| Option | Description | Trade-offs |
|---|---|---|
| A | The 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. |
| B | Home 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. |
| C | The 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). TheUserscope 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.
DQ-013: Extensible scope model
Section titled “DQ-013: Extensible scope model”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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Generic (settings_scope_type, settings_scope_id) storage + a SettingsScope enum | Adding a scope is one enum case; no schema or resolver change; keeps compiler-enforced exhaustiveness on the security-sensitive path. |
| B | Open, string-keyed ScopeRegistry (services register arbitrary scopes) | Maximally open; loses exhaustiveness on scope-driven authorization; departs from the ServiceScope house style. |
| C | Keep hardcoded tenant/user columns and types | Simplest 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | options stays a list of stored values; each client maps them to wording | Nothing 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. |
| B | Keep options as values and add a parallel optionLabels map | Additive 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. |
| C | An option is a value/label pair (ChoiceOption) in options | Every 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
Copyright: © Arda Systems 2025-2026, All rights reserved