Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/adr/0007-settings-manifest-and-kv-store.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0007: Settings — Manifest + K/V Store + Resolver (Apple-style)

**Status**: Accepted — backend implemented; UI pending (objectui) (proposed 2026-05-20 · calibrated 2026-06-12)
**Status**: Accepted — backend implemented; UI pending (objectui) (proposed 2026-05-20 · calibrated 2026-06-12) · **Amended (2026-11-05, #30385)** — D6: a clear (`null`) of an env-pinned key is not refused; it clears the stored value beneath the override and changes nothing in force.
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (Metadata Customization Overlay), [ADR-0006 v4](./0006-project-environment-split.v4.md) (Project/Environment Split — this record was originally built on v3, superseded by v4)
**Consumers**: `@objectstack/spec`, `@objectstack/platform-objects`, new `@objectstack/service-settings`, `@objectstack/plugin-auth` (Setup app), `objectui` (Settings renderer)
Expand Down Expand Up @@ -83,7 +83,7 @@ ObjectStack's protocol-first DNA matches Apple's: a small set of declarative spe
RUNTIME WRITE SettingsService.set(ns, key, value, scope, ctx)
- upsert sys_setting row (encrypt via KMS if specifier.encrypted)
- audit row in sys_audit_log
- reject if effective source='env' (env wins, UI shows locked)
- reject if effective source='env' (env wins, UI shows locked) (a `null` clear excepted — D6)

RENDER (UI) SettingsView({ namespace })
GET /api/settings/:namespace → { manifest, values: { key: { value, source, locked } } }
Expand All @@ -98,7 +98,7 @@ ObjectStack's protocol-first DNA matches Apple's: a small set of declarative spe
3. **One resolver.** All reads must go through `SettingsService.get()`. Plugins must not query `sys_setting` directly. This is what makes Env > Tenant > User layering a guarantee, not a convention.
4. **Schema lives with code, values live in DB.** Manifests are part of the plugin bundle (versioned with code). Values are runtime state. Never serialise manifest into `sys_setting`.
5. **Encryption is a manifest concern, not caller's.** `specifier.encrypted: true` ⇒ `SettingsService` calls KMS on write/read transparently. Callers see plaintext (subject to permission).
6. **Env override is first-class.** Whenever `process.env[NS_KEY_UPPER]` is set, the resolver returns it with `source='env', locked=true`. Writes from UI are rejected with HTTP 409 + reason `"locked-by-env"`. The UI surfaces this as a badge — never silently overwritten.
6. **Env override is first-class.** Whenever `process.env[NS_KEY_UPPER]` is set, the resolver returns it with `source='env', locked=true`. Writes from UI are rejected with HTTP 409 + reason `"locked-by-env"`. A clear (`null`) is the one exception: the pin does not refuse it — the row locks and the key's write predicates still judge it like any clear — it sets the stored value beneath the override to `null` and changes nothing in force (the resolver still answers `source='env', locked=true`). *Amended 2026-11-05 (#30385): this sentence read "Writes from UI are rejected with HTTP 409 + reason `"locked-by-env"`." with no exception.* The UI surfaces this as a badge — never silently overwritten.
7. **UI is system-owned.** Plugins must not ship custom React for normal settings. They get the standard `<SettingsView>` for free. Custom UI requires invoking the escape hatch.
8. **Test actions are declarative.** Manifests declare `actions` with an HTTP handler descriptor. The UI renders standard buttons + toast. No bespoke component code.

Expand Down Expand Up @@ -300,7 +300,7 @@ export interface SettingsService {
get<T = unknown>(ns: string, key: string, ctx: RequestContext): Promise<ResolvedValue<T>>;
getNamespace(ns: string, ctx: RequestContext): Promise<Record<string, ResolvedValue>>;

// Write (rejected if effective source='env')
// Write (rejected if effective source='env'; a `null` clear excepted — D6)
set(ns: string, key: string, value: unknown, scope: 'tenant'|'user', ctx: RequestContext): Promise<void>;
setMany(ns: string, values: Record<string, unknown>, scope: 'tenant'|'user', ctx: RequestContext): Promise<void>;

Expand All @@ -320,7 +320,7 @@ export interface SettingsService {
|---|---|---|---|
| GET | `/api/settings` | — | `SettingsManifest[]` (filtered by permission) |
| GET | `/api/settings/:namespace` | — | `{ manifest, values: Record<key, ResolvedValue> }` |
| PUT | `/api/settings/:namespace` | `{ key: value, ... }` | `{ values }` (409 if any key locked-by-env) |
| PUT | `/api/settings/:namespace` | `{ key: value, ... }` | `{ values }` (409 if any key locked-by-env, except a `null` clear) |
| POST | `/api/settings/:namespace/:actionId` | action input | `ActionResult` |

### Env override convention
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"file": "packages/services/service-settings/src/settings-service.ts",
"adrs": [
"ADR-0007"
],
"invariant": "An env override in force wins: the resolver answers it with source='env', locked=true, and the setMany pre-flight refuses every write to that key with SettingsLockedError (HTTP 409 locked-by-env) except a clear (`null` / `undefined`). The pin does not refuse a clear; the row locks and the key's write predicates still judge it like any clear. A clear nulls the stored row beneath the override (never deletes it) and changes nothing in force (ADR-0007 D6, amended #30385)."
}
Loading