Skip to content

Commit da9b5f7

Browse files
docs(adr): ADR-0007 D6 — a null clear of an env-pinned key is not refused (#30385) (#30698)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg7PpRrnuu2FgdSkRXPUGd2w)_ domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL ## 维护者速读 - 改了什么:ADR-0007 状态行加「已修订」;D6 补一句「环境变量锁定不拒绝清除(`null`),行锁和写入规则照常判」;REST 表 409 一栏、架构草图和服务接口注释各加同样的例外;新增一个 ADR 锚点文件,把 `settings-service.ts` 钉到 ADR-0007。 - 为什么改:执行 #30385 裁定 A(2026-11-05「同意」)。代码 PR #30448 允许清掉锁定键底下存的旧值,ADR 原文写的是「一律 409」,须先修订 ADR。 - 风险与回滚:纯文档,不动代码;生效值仍是环境变量,锁定标记不变。回滚即还原本 PR 一次提交。 - 先看:`docs/adr/0007-settings-manifest-and-kv-store.md` 第 3 行、第 86 行、第 101 行(D6)、第 303 行、第 323 行(REST 表),以及新锚点文件。 - 两分钟验证:`node scripts/check-adr-links.mjs && pnpm check:adr-anchors && pnpm check:adr-index`,预期全绿。 - 需要你做的:Tier H,审阅人 os-zhuang + hotlong;本 PR 先合,之后 #30448 再落地。 Refs #30385 Clause-②: no Metadata-protocol: none ## What changes ADR-0007 D6 said every UI write to an env-pinned key is refused with HTTP 409 `locked-by-env`. PR #30448 lets exactly one write through the pin: a clear (`null` / absent), which nulls the stored row beneath the override. The value in force does not move — the resolver still answers `source='env', locked=true` — and every other write stays 409 (`settings-service.ts` pre-flight: `const clears = patch[key] === null || typeof patch[key] === 'undefined'; if (!clears && this.effectiveEnvOverride(...)) throw new SettingsLockedError(...)`). Per AGENTS.md (a decision change amends the original ADR's status line), and per contract review 6671549520's three should-fixes: 1. **Status line** — `· **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.` 2. **D6** — the exception sentence (the pin does not refuse a clear; the row locks and the key's write predicates still judge it like any clear), with the superseded wording kept inline as an *Amended* note. 3. **REST table** — `PUT /api/settings/:namespace` row: `(409 if any key locked-by-env, except a null clear)`. 4. **Architecture sketch (L86) and the service-surface comment (L303)** — the same exception, `(a null clear excepted — D6)`, so the ADR does not contradict D6. Out of scope (known drift, untouched): `resetNamespace` wording (reset still skips pinned keys; widening it is #30664), D-tier drift, env-var prefixes. ## ADR anchor New `scripts/adr-anchors/packages__services__service-settings__src__settings-service.ts.json` (`ADR-0007`), as ruling 6670154789 item 3 names. Its invariant: an env override in force refuses every write but a clear; the clear nulls the stored row and changes nothing in force. `settings-service.ts` already cites ADR-0007 on `main`, so `pnpm check:adr-anchors` is green on `main` and on this branch. ## Verification At head `27a3e1c9`: derived gates for the two files, 29 of 29 exit 0; `--ran` reconcile 0 NOT-MEASURED. The first head (`26da6f27`) ran 21 of 21. Contract review PASS 6671549520 on the first head; the delta review covers the fix commit. No changeset: docs/adr only (`skip-changeset`). <!-- signed: domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3dcea42 commit da9b5f7

2 files changed

Lines changed: 12 additions & 5 deletions

File tree

‎docs/adr/0007-settings-manifest-and-kv-store.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0007: Settings — Manifest + K/V Store + Resolver (Apple-style)
22

3-
**Status**: Accepted — backend implemented; UI pending (objectui) (proposed 2026-05-20 · calibrated 2026-06-12)
3+
**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.
44
**Deciders**: ObjectStack Protocol Architects
55
**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)
66
**Consumers**: `@objectstack/spec`, `@objectstack/platform-objects`, new `@objectstack/service-settings`, `@objectstack/plugin-auth` (Setup app), `objectui` (Settings renderer)
@@ -83,7 +83,7 @@ ObjectStack's protocol-first DNA matches Apple's: a small set of declarative spe
8383
RUNTIME WRITE SettingsService.set(ns, key, value, scope, ctx)
8484
- upsert sys_setting row (encrypt via KMS if specifier.encrypted)
8585
- audit row in sys_audit_log
86-
- reject if effective source='env' (env wins, UI shows locked)
86+
- reject if effective source='env' (env wins, UI shows locked) (a `null` clear excepted — D6)
8787
8888
RENDER (UI) SettingsView({ namespace })
8989
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
9898
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.
9999
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`.
100100
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).
101-
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.
101+
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.
102102
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.
103103
8. **Test actions are declarative.** Manifests declare `actions` with an HTTP handler descriptor. The UI renders standard buttons + toast. No bespoke component code.
104104

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

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

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

326326
### Env override convention
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"file": "packages/services/service-settings/src/settings-service.ts",
3+
"adrs": [
4+
"ADR-0007"
5+
],
6+
"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)."
7+
}

0 commit comments

Comments
 (0)