From 26da6f272d44273eba58b22b2d8b1005ddf8c371 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 22:27:04 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(adr):=20ADR-0007=20D6=20=E2=80=94=20a?= =?UTF-8?q?=20null=20clear=20of=20an=20env-pinned=20key=20is=20not=20refus?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CaQzHSoRvseiSpfmLn --- docs/adr/0007-settings-manifest-and-kv-store.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0007-settings-manifest-and-kv-store.md b/docs/adr/0007-settings-manifest-and-kv-store.md index 0089ff3842e..a54cd8da3ae 100644 --- a/docs/adr/0007-settings-manifest-and-kv-store.md +++ b/docs/adr/0007-settings-manifest-and-kv-store.md @@ -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) @@ -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: it is not refused, 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 `` 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. @@ -320,7 +320,7 @@ export interface SettingsService { |---|---|---|---| | GET | `/api/settings` | — | `SettingsManifest[]` (filtered by permission) | | GET | `/api/settings/:namespace` | — | `{ manifest, values: Record }` | -| 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 From 27a3e1c9adb9a566da39ea25321f928b734d33fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 22:49:19 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(adr):=20ADR-0007=20D6=20=E2=80=94=20qu?= =?UTF-8?q?alify=20the=20two=20sketch=20lines,=20scope=20the=20exception?= =?UTF-8?q?=20to=20the=20pin,=20anchor=20settings-service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CaQzHSoRvseiSpfmLn --- docs/adr/0007-settings-manifest-and-kv-store.md | 6 +++--- ...rvices__service-settings__src__settings-service.ts.json | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 scripts/adr-anchors/packages__services__service-settings__src__settings-service.ts.json diff --git a/docs/adr/0007-settings-manifest-and-kv-store.md b/docs/adr/0007-settings-manifest-and-kv-store.md index a54cd8da3ae..1ba1e287967 100644 --- a/docs/adr/0007-settings-manifest-and-kv-store.md +++ b/docs/adr/0007-settings-manifest-and-kv-store.md @@ -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 } } } @@ -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"`. A clear (`null`) is the one exception: it is not refused, 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. +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 `` 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. @@ -300,7 +300,7 @@ export interface SettingsService { get(ns: string, key: string, ctx: RequestContext): Promise>; getNamespace(ns: string, ctx: RequestContext): Promise>; - // 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; setMany(ns: string, values: Record, scope: 'tenant'|'user', ctx: RequestContext): Promise; diff --git a/scripts/adr-anchors/packages__services__service-settings__src__settings-service.ts.json b/scripts/adr-anchors/packages__services__service-settings__src__settings-service.ts.json new file mode 100644 index 00000000000..565c4ffe3e9 --- /dev/null +++ b/scripts/adr-anchors/packages__services__service-settings__src__settings-service.ts.json @@ -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)." +}