Skip to content

Commit 243eba1

Browse files
feat(spec): a map settings specifier; the lifecycle retention overrides and quotas use it (#31386)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi59ATBeDy7jECXRUFiXAgGB)_ Refs #30208 Clause-②: yes (widening) — `SpecifierType` gains a member; the save and env doors also start refusing malformed values for the three keys (a narrowing on values that never took effect, named in the PR's BREAKING note) Metadata-protocol: added — a package author can now declare a `map` specifier in a settings manifest (a new accepted value of `SpecifierType`) Skills-docs: no skill teaches the specifier set. `skills/**` names `SettingsManifest` once (`skills/objectstack-platform/references/plugin-development.md:355`) and the three lifecycle keys by name only (`skills/objectstack-data/rules/lifecycle.md:137-139`), never their value form, so nothing a skill teaches changes. Docs updated here: `content/docs/protocol/kernel/config-resolution.mdx` (how an `OS_*` override of a `map` key is judged), and the generated `content/docs/references/system/settings-manifest.mdx`. > **BREAKING** accept-set narrowing at the settings doors (`@objectstack/service-settings`), for `lifecycle.retention_overrides`, `lifecycle.quotas` and `lifecycle.quota_defaults` only. `PUT /api/settings/lifecycle` and `OS_LIFECYCLE_*` used to accept any object under them, so a misspelt object (`sys_audit_logs`), a quota written as text (`"1e6"`) or zero, a window written as `30 days` or a misspelt column (`max_age`) was stored, answered "saved", and applied nothing. The save door now refuses such a value with `400 SETTINGS_VALIDATION` and one field error that names the entry and the expected form; the env door rejects such an override with one `error` line and the key resolves from the next layer. Every refused value is one the sweep never applied. **Migration:** send keys the deployment has and cells in their column's form: `{ "quotas": { "sys_audit_log": 1000000 } }`, `{ "retention_overrides": { "sys_job_run": { "maxAge": "90d" } } }`; for an override, `OS_LIFECYCLE_QUOTAS='{"sys_audit_log":1000000}'`. Stored values, env names and the cascade are unchanged. ADR-0087 disposition: `not-required (no-migration-prescription)`, one marker, in `.changeset/30208-settings-map-specifier.md`. ## 维护者速读 - **改了什么:** 设置清单新增一种设置项类型 `map`("一行一个键,后面跟有类型的列")。「数据生命周期」的保留期覆盖、行数配额、按类默认配额三个设置改用它。存储的值、环境变量名和覆盖层级都不变。保存和环境变量两个入口开始检查这三个值:对象名必须是系统里真有的、声明了生命周期的对象,时长必须写成 `7y` 这种形式,配额必须是大于 0 的整数。ADR-0007 加了一条修订。 - **为什么改:** 以前这三个值存什么都行,对象名拼错、配额写成文字、时长写成 `30 days` 都会显示「已保存」,然后什么都不执行。这是 #30208 第 5 行的裁定 A(田騏 2026-11-08 02:07 「同意」)。 - **风险与回滚:** 以前存进去的错值不受影响,只有保存时碰到这个键才会检查。脚本如果写这些错值会收到 400,环境变量写错会打一条 error,不生效。控制台在 ui 卡落地前会把这三行显示成「不支持的设置项类型」,环境变量和 API 照常可用。回滚方式是 revert。 - **先看:** `packages/spec/src/system/settings-manifest.zod.ts` 的 `map`、`keyColumn`、`columns`;`packages/services/service-settings/src/map-shape.ts`,以及 `settings-service.ts` 里 `mapShapeVerdict` 的两处调用;`packages/objectql/src/lifecycle/lifecycle-key-options.ts`;`docs/adr/0007-settings-manifest-and-kv-store.md` 的修订。 - **两分钟验证:** `cd packages/services/service-settings && pnpm exec vitest run src/settings-map-shape-30208.test.ts`,16 个应该全过。其中会把拼错的对象名、坏时长、文字配额送进保存口和环境变量口,看它们被拒绝。 - **需要你做的:** 这个 PR 改了 ADR(Tier H),需要你审核并合并。类型名暂定 `map`,由 Fable 复审定。 ## What changes - **`@objectstack/spec`: the `map` specifier.** `SpecifierType` gains `map`, a value-bearing type. `Specifier` gains `keyColumn` (`{ label, description? }`, the key column's header) and `columns` (`[{ name, label, description?, kind }]`, `kind` is `duration` or `positive_integer`). A `map` requires both, with at least one column and unique column names; any other type declaring either is refused at parse. The doc states the storage rule: one value column stores `Record<key, value>`, several store `Record<key, { [column.name]: value }>`. Column names keep the stored property's spelling (`maxAge`), so the type covers today's shapes with no migration. The keys a map admits are served at run time by an option source the settings service registers for the key; every manifest read serves them as the map's `options`, and a declared `options` table is only the fallback. `json` stays for raw config. **Name:** `map` is proposed (it names the `Record` shape the value is; VS Code calls the same thing an object grid); the Fable review settles it. - **ADR-0007.** Status line amended (2026-11-08, #30208); a `map` row in §Specifier set; `json`'s row now says it is not for maps; a dated note under the set cites ruling record 6715997428. - **`@objectstack/service-settings`: one shape judgement for both doors (`map-shape.ts`).** `firstMapShapeMiss` judges a value against the declared columns and the key column's domain: not an object at all (`not_a_map`), a key not admitted (`key`), an entry that is not an object or names no column (`not_an_entry`), a property no column declares (`unknown_column`), a cell not of its kind (`cell`). `SettingsService.mapShapeVerdict` reads the domain through the existing `optionTableOf` (a registered source's answer, else the declared table), so a source moves both doors at once. The save door (`validatePatch`) answers one `FieldError` per key: `invalid_type`, `invalid_option`, `unknown_field` or `invalid_shape`, with `constraint: { type: 'map', entry, column?, kind?, allowed? }` and a sentence naming the entry and the form expected (`'sys_job_run'.maxAge must be a duration such as 7y …`). The env door (`effectiveEnvOverride`) rejects such an override through `reportRejectedEnvOverride` with the same sentence; the value is described by its shape, never printed. The option family skips a map key (its table names keys, not the value). A map key without a source is audited at registration; a sourced one is judged when read, and a key-domain miss is held until `sealOptionSources`, as a sourced option is. This subsumes #31279's `jsonShapeMiss` for these three keys: they are no longer `json`, and `jsonShapeMiss` stays for real `json` keys. - **`@objectstack/objectql`: the lifecycle maps.** `retention_overrides` (columns `maxAge`, `expireAfter`, both `duration`), `quotas` and `quota_defaults` (one `positive_integer` column each) are `map` specifiers, defaults and scopes unchanged. `lifecycle-key-options.ts` serves their keys: `lifecycleObjectKeySource` (the objects whose lifecycle class is not `record`, exactly the set the sweep walks, read at each ask) and `lifecycleClassKeySource` (`LifecycleClassSchema` without `record`). `ObjectQLPlugin` registers the three sources with the settings service at `kernel:ready`, before the manifest. `loadGovernance` reads the same shapes; #31279's legacy-text handling stays. - **Translations.** The help text of the three keys drops its JSON examples in the ten translation packs that carry them (`ar`, `de`, `es-ES`, `fr`, `ja-JP`, `ko`, `pt-BR`, `ru`, `zh-CN`, `zh-Hant`) and in the English manifest copy (`en.ts` has no `lifecycle` block; the manifest literal is the English text). `quota_defaults` names the four classes instead. - **Docs.** `config-resolution.mdx` says how an override of a `map` key is judged, with two examples. Generated: `content/docs/references/system/settings-manifest.mdx`, `packages/spec/authorable-surface/system.json`, the strictness-ledger counts. - **Changeset:** `.changeset/30208-settings-map-specifier.md`, `minor` for `@objectstack/spec`, `patch` for the two services, both declaration lines, the BREAKING section with its migration, and the ADR-0087 marker. ## Tests - `packages/spec/src/system/settings-manifest.test.ts` (+6): `map` is in the closed set and an unknown type still throws; a one-column and a several-column map parse, camelCase column names kept; a map without a key, without columns, with an empty column list or without a key column is refused; a column kind outside the set and a duplicate column are refused; `columns` / `keyColumn` on another type are refused; `json` still parses. - `packages/services/service-settings/src/settings-map-shape-30208.test.ts` (16), over real `ObjectQL`, the platform setting objects, the real route, the shipped `lifecycleSettingsManifest` and the two key sources: the manifest lists no keys and the namespace read serves them as `options`; the shapes the sweep reads are stored unchanged; a misspelt object, a `record` object, an object with no lifecycle, the `record` class and a misspelt class are `invalid_option`; `30 days`, `"1e6"`, `0` and `2.5` are `invalid_shape`; `max_age` is `unknown_field`; an entry naming no column is `invalid_shape`; JSON text is `invalid_type`; a clear falls back; a stored malformed value does not block an unrelated save; env JSON text of an admitted map is in force, while a misspelt object, a bad duration and unparseable text are not, each with one `error` line. - `packages/services/service-settings/src/settings-json-shape-31241.test.ts` (8, unchanged in substance): now over a raw-config manifest with `json` keys, since no shipped manifest declares one. - `packages/objectql/src/lifecycle/lifecycle-key-options.test.ts` (6): the object source admits exactly the swept objects, reads storage objects when present, admits an object registered later, answers nothing without a registry; the class source omits `record`; booted through a real `ObjectKernel`, `ObjectQLPlugin` registers the three sources before the manifest, and they admit an object the stack registered. - `packages/services/service-settings/src/__tests__/settings-door.testkit.ts`: the door scaffolding the #31241 suite carried, shared by both suites. ## Acceptance notes - **Name.** `map` is proposed; alternatives considered were `keyed_rows` and `table`. Renaming is mechanical (the enum member, the superRefine branch, `declaredMapShape`'s type check, the manifest, the ADR row). - **Column labels are English literals.** Translating them needs a `columns` group in the settings `keys[key]` translation entry and ui's copies of that schema; that belongs with the console control (ui card body in the report), not here. - **`retention_overrides` columns are not judged against the object's declared policy.** An override of `expireAfter` on an object with no `ttl` block is admitted, as the sweep simply has nothing to apply it to. Narrowing per column would need the source to answer per column; not part of ruling A. - **The console** draws the three keys as "Unsupported specifier type: map" until the ui card lands. The order puts `ui/**` out of scope. - **The claim's file surface** names `skills/**` lines (none needed changing) and does not name `content/docs/protocol/kernel/config-resolution.mdx`, `content/docs/references/**`, `packages/spec/authorable-surface/system.json` or the strictness-ledger counts; the first is the env paragraph this change extends, the rest are generated by the spec gates. - **Branch.** The merge queue deleted `claude/project-thread-4ntp5s` when #31279 landed, so the branch was recreated from `main` at `b85190a8` with this work on top, then `origin/main` (`2ba7a9d4`) merged in; nothing of #31279 is re-carried. ## Verification Ablations, each through `scripts/ablation-replace.mjs` on head `32b54d4d` (mutation landed, the suite red, restored blob equal to HEAD and `git diff HEAD` empty after each), against `settings-map-shape-30208.test.ts` (16): | # | Mutation | Red | |:--|:--|:--| | B1 | a misspelt key accepted: `mapShapeVerdict`'s `admitsKey` → `undefined` | 4/16: the misspelt object, the `record` / no-lifecycle objects, the `record` and misspelt class at the save door, and the misspelt object at the env door | | B2 | a bad duration accepted: `cellFits`' duration check drops `LIFECYCLE_DURATION_REGEX` | 2/16: `30 days` at the save door and at the env door | | B3 | an env value bypasses the judgement: the env door's map verdict → `null` | 3/16: the misspelt object, the bad duration and the unparseable text are in force | Local runs, all on the final head `32b54d4d` (the `origin/main` merge at `2ba7a9d4` included), under `os-verify-lock`: `@objectstack/spec` test 912 files / 27528 tests, exit 0; `@objectstack/objectql` test 569 files / 11501 tests, exit 0; `@objectstack/service-settings` test 73 files / 1397 tests and typecheck, exit 0; `@objectstack/spec` and `@objectstack/objectql` typecheck (test layer included), exit 0; `pnpm lint`, exit 0. **Gates, on `32b54d4d`:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 121 commands, each run with stdin from /dev/null after a full `packages/**` build, all `:: exit 0`. `--ran`: "121 derived, 121 run, 0 NOT-MEASURED, 0 UNRUN". The round before the merge caught `check:error-code-casing` (field codes in the new test, now marked `adr0112-ok: D6`) and `check:slot-lookup` plus an eslint `no-restricted-syntax` on an `as any` in the kernel wiring test (removed); both green here. **Dogfood, full battery on `32b54d4d`:** every `packages/**` package built, then `pnpm --filter @objectstack/dogfood test` under the lock, no shard and no path: `Test Files 437 passed | 3 skipped (440)`, `Tests 3618 passed | 41 skipped (3659)`, `VERDICT command-exit 0`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e863251 commit 243eba1

28 files changed

Lines changed: 1361 additions & 220 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-settings': patch
4+
'@objectstack/objectql': patch
5+
---
6+
7+
feat(spec): a `map` settings specifier for keyed rows; the data lifecycle retention overrides, row quotas and per-class quota defaults use it, and both settings doors judge them
8+
9+
Clause-②: yes (widening) — `SpecifierType` gains a member; the save and env doors also start refusing malformed values for the three keys (a narrowing on values that never took effect, named in the PR's BREAKING note)
10+
Metadata-protocol: added — a package author can now declare a `map` specifier in a settings manifest (a new accepted value of `SpecifierType`)
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is removed, renamed or re-shaped: `SpecifierType` gains `map` and `Specifier` gains the optional `keyColumn` and `columns`. The three lifecycle keys keep their stored shape, their env variable names and their cascade. What narrows is the set of values the settings doors accept for those three keys: a stored value is runtime state in `sys_setting` / `sys_platform_setting` and an `OS_LIFECYCLE_*` override is a deployment's environment, so there is nothing for a migration to rewrite, and every refused value is one the sweep never applied. -->
13+
14+
**New: the `map` specifier** (`@objectstack/spec`, ADR-0007 as amended 2026-11-08). A setting whose value is keyed rows declares `type: 'map'` with a `keyColumn` (its header) and `columns` (`{ name, label, kind }`, `kind` is `duration` or `positive_integer`). One value column stores `Record<key, value>`; several store `Record<key, { [column]: value }>`. The keys a map admits are served at run time by an option source the settings service registers for the key, never listed in the manifest. `json` stays for raw config.
15+
16+
**`lifecycle.retention_overrides`, `lifecycle.quotas` and `lifecycle.quota_defaults` are `map` specifiers** (`@objectstack/objectql`). Stored values, `OS_LIFECYCLE_*` names and the env, global, organization cascade are unchanged, and the sweep reads the same shapes. Their keys are served from the running system: the objects that declare a lifecycle class other than `record` for the first two, the lifecycle classes other than `record` for `quota_defaults`.
17+
18+
**BREAKING** accept-set narrowing at the settings doors (`@objectstack/service-settings`), for those three keys only.
19+
20+
- **Before.** `PUT /api/settings/lifecycle` and `OS_LIFECYCLE_*` accepted any object under them: a misspelt object (`sys_audit_logs`), a quota written as text (`"1e6"`) or zero, a window written as `30 days`, a column misspelt (`max_age`). Each was stored, answered "saved", and the sweep silently applied nothing for it.
21+
- **Now.** The save door refuses such a value with `400 SETTINGS_VALIDATION` and one field error for the key, which names the entry and the expected form: `invalid_option` for a key the running system does not have, `invalid_shape` for a cell that is not a duration such as `7y` or a whole number above 0, `unknown_field` for a property no column declares, `invalid_type` for a value that is not an object (JSON text included). The env door rejects such an override with one `error` line, and the key resolves from the next layer of the cascade. A value already stored is not judged until a save touches its key.
22+
- **Who moves.** Nobody whose values took effect. A script or a deployment that wrote one of the refused values was configuring nothing; the refusal now says so. Durations take `h`, `d`, `w` or `y` (`90d`, `7y`), quotas a whole number above 0, and keys are the object names (or classes) the refusal lists.
23+
24+
**The remedy.** Send keys the deployment has and cells in their column's form: `{ "quotas": { "sys_audit_log": 1000000 } }`, `{ "retention_overrides": { "sys_job_run": { "maxAge": "90d" } } }`; for an override, `OS_LIFECYCLE_QUOTAS='{"sys_audit_log":1000000}'`.
25+
26+
**The console.** Until its `map` control lands (a separate change), Setup → Data Lifecycle draws these three keys as an unsupported specifier type; env overrides and the API are unaffected.

‎content/docs/protocol/kernel/config-resolution.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,18 @@ The exception is a `json` specifier, which persists a JSON object or array
267267
cascade. The save door refuses the same values for a `json` key with an
268268
`invalid_type` field error, so a client sends the parsed value, never its text.
269269

270+
A `map` specifier (keyed rows, such as `lifecycle.quotas`) is judged further,
271+
at both doors alike: its JSON text must parse to an object whose every key the
272+
setting admits (the running system serves the admissible keys, for example the
273+
objects that declare a lifecycle) and whose every value is of its column's kind,
274+
such as a duration like `7y` or a whole number above 0. An override that is not
275+
is not in force, with one `error` line naming the entry and the form expected:
276+
277+
```bash
278+
OS_LIFECYCLE_QUOTAS='{"sys_audit_log": 1000000}'
279+
OS_LIFECYCLE_RETENTION_OVERRIDES='{"sys_job_run": {"maxAge": "90d"}}'
280+
```
281+
270282
**What is stored beneath a pin is listed by the namespace read.** An override in
271283
force answers `source: 'env'`, `locked: true`, and `get()` / `getMany()` build
272284
that answer from the env alone, without reading the store. `getNamespace()` — the

‎content/docs/references/system/settings-manifest.mdx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ const result = ResolvedSettingValueSchema.parse(data);
123123
| **pattern** | `string` | optional | Regex pattern (text only) |
124124
| **rows** | `integer` | optional | |
125125
| **handler** | `{ kind: 'http'; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH'>; url: string; body?: Record<string, any>; … } \| { kind: 'action'; name: string; params?: Record<string, any>; confirmText?: string \| Record<string, string> } \| { kind: 'navigate'; url: string; target?: Enum<'_self' \| '_blank'> }` | optional | Action handler (action_button) |
126+
| **keyColumn** | `{ label: string \| Record<string, string>; description?: string }` | optional | Key column (map) |
127+
| **columns** | `{ name: string; label: string \| Record<string, string>; description?: string; kind: Enum<'duration' \| 'positive_integer'> }[]` | optional | Value columns (map): one stores a scalar per key, several an object |
126128
| **confirmOffText** | `string \| Record<string, string>` | optional | Confirm dialog text shown before a toggle's value changes to off (omit = no confirm) |
127129
| **childNamespace** | `string` | optional | Sub-namespace (child_pane) |
128130
| **bannerText** | `string` | optional | Markdown body (info_banner) |
@@ -179,7 +181,7 @@ const result = ResolvedSettingValueSchema.parse(data);
179181

180182
| Property | Type | Required | Description |
181183
| :--- | :--- | :--- | :--- |
182-
| **type** | `Enum<'group' \| 'child_pane' \| 'info_banner' \| 'title_value' \| 'text' \| 'textarea' \| 'password' \| 'email' \| 'url' \| 'phone' \| 'number' \| 'toggle' \| 'select' \| 'radio' \| … +5 more>` | ✅ | Specifier variant |
184+
| **type** | `Enum<'group' \| 'child_pane' \| 'info_banner' \| 'title_value' \| 'text' \| 'textarea' \| 'password' \| 'email' \| 'url' \| 'phone' \| 'number' \| 'toggle' \| 'select' \| 'radio' \| … +6 more>` | ✅ | Specifier variant |
183185
| **id** | `string` | optional | Stable identifier (snake_case) |
184186
| **key** | `string` | optional | Storage key (snake_case) |
185187
| **label** | `string \| Record<string, string>` | ✅ | Display label |
@@ -206,6 +208,8 @@ const result = ResolvedSettingValueSchema.parse(data);
206208
| **pattern** | `string` | optional | Regex pattern (text only) |
207209
| **rows** | `integer` | optional | |
208210
| **handler** | `{ kind: 'http'; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH'>; url: string; body?: Record<string, any>; … } \| { kind: 'action'; name: string; params?: Record<string, any>; confirmText?: string \| Record<string, string> } \| { kind: 'navigate'; url: string; target?: Enum<'_self' \| '_blank'> }` | optional | Action handler (action_button) |
211+
| **keyColumn** | `{ label: string \| Record<string, string>; description?: string }` | optional | Key column (map) |
212+
| **columns** | `{ name: string; label: string \| Record<string, string>; description?: string; kind: Enum<'duration' \| 'positive_integer'> }[]` | optional | Value columns (map): one stores a scalar per key, several an object |
209213
| **confirmOffText** | `string \| Record<string, string>` | optional | Confirm dialog text shown before a toggle's value changes to off (omit = no confirm) |
210214
| **childNamespace** | `string` | optional | Sub-namespace (child_pane) |
211215
| **bannerText** | `string` | optional | Markdown body (info_banner) |
@@ -231,6 +235,7 @@ const result = ResolvedSettingValueSchema.parse(data);
231235
* `slider`
232236
* `color`
233237
* `json`
238+
* `map`
234239
* `action_button`
235240

236241
### Nested Shape: `Specifier.options[number]`
@@ -269,6 +274,22 @@ const result = ResolvedSettingValueSchema.parse(data);
269274
| **url** | `string` | ✅ | Target URL or in-app route |
270275
| **target** | `Enum<'_self' \| '_blank'>` | optional (default: `"_self"`) | |
271276

277+
### Nested Shape: `Specifier.keyColumn`
278+
279+
| Property | Type | Required | Description |
280+
| :--- | :--- | :--- | :--- |
281+
| **label** | `string \| Record<string, string>` | ✅ | Key column header |
282+
| **description** | `string` | optional | Help text for the key column |
283+
284+
### Nested Shape: `Specifier.columns[number]`
285+
286+
| Property | Type | Required | Description |
287+
| :--- | :--- | :--- | :--- |
288+
| **name** | `string` | ✅ | Column identifier: the stored property name when the map has several value columns |
289+
| **label** | `string \| Record<string, string>` | ✅ | Column header |
290+
| **description** | `string` | optional | Help text for the column |
291+
| **kind** | `Enum<'duration' \| 'positive_integer'>` | ✅ | Value kind: duration literal or positive integer |
292+
272293

273294
---
274295

@@ -367,6 +388,7 @@ This schema accepts one of the following structures:
367388
* `slider`
368389
* `color`
369390
* `json`
391+
* `map`
370392
* `action_button`
371393

372394

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

Lines changed: 5 additions & 2 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) · **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.
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. · **Amended (2026-11-08, #30208)** — §Specifier set gains `map` (keyed rows with typed value columns); `json` stays for raw config and is no longer the home for maps.
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)
@@ -106,6 +106,8 @@ ObjectStack's protocol-first DNA matches Apple's: a small set of declarative spe
106106

107107
A small, **closed** set of specifier types. Borrowed from Apple's PreferenceSpecifiers and trimmed to what enterprise settings actually need. New types require an ADR amendment.
108108

109+
*Amended 2026-11-08 (#30208, ruling record 6715997428): `map` added. A setting whose value is a map of rows (an object → a window, an object → a row count) declares that shape as a `map`, so the console draws a table and both doors (the save and the `OS_*` override) judge each key against the running system's domain and each cell against its column's kind, as they judge `options` and `pattern`. Its stored value is the object a `json` key held, unchanged. `json` stays in the set for genuinely raw config; it is no longer the home for maps.*
110+
109111
| Specifier | Renders as | Persists | Notes |
110112
|---|---|---|---|
111113
| `group` | section header + divider | — | `visible` expression supported for conditional sections |
@@ -120,7 +122,8 @@ A small, **closed** set of specifier types. Borrowed from Apple's PreferenceSpec
120122
| `multiselect` | checkbox list / tag picker | array | |
121123
| `slider` | range slider | number | `min`, `max`, `step` |
122124
| `color` | colour picker | hex string | |
123-
| `json` | code editor | object | for advanced/raw config |
125+
| `json` | code editor | object | for advanced/raw config — not for maps (see `map`) |
126+
| `map` | keyed rows: a key column + typed value columns | object: `Record<key, value>` with one value column, `Record<key, {column: value}>` with several | `keyColumn`, `columns: [{name, label, kind}]`, `kind` ∈ `duration` \| `positive_integer`; the keys a map admits are served by a runtime option source, never listed in the manifest |
124127
| `title-value` | read-only label | — | display computed/derived values |
125128
| `child-pane` | nav row → sub-page | — | references another manifest namespace |
126129
| `action-button` | button | — | triggers `handler` (HTTP / built-in) |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `system/` | 397 |
22+
| `system/` | 399 |

‎packages/objectql/src/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,16 @@ export type { RecycleBinRestoreOptions, RecycleBinRestoreResult } from './recycl
639639
// hook's api, and the runtime's sandbox to every body's.
640640
export { refuseAppAuthoredPermanentDelete } from './recycle-bin/app-authored-delete.js';
641641
export { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js';
642+
// [#30208] The key columns of the three lifecycle map settings, served from the
643+
// running system; `ObjectQLPlugin` registers them with the settings service.
644+
export {
645+
lifecycleObjectKeySource,
646+
lifecycleClassKeySource,
647+
type LifecycleKeySource,
648+
type LifecycleKeyAnswer,
649+
type LifecycleKeyOption,
650+
type LifecycleKeyRegistryLike,
651+
} from './lifecycle/lifecycle-key-options.js';
642652

643653
// [#4551] Read-only referential-integrity audit — the reporting half of the
644654
// `isSystem` exemption #4441 deliberately left in the write-path guard.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30208 row 5, ruling A] The key columns of the three lifecycle `map`
5+
* settings are served from the running system, never listed in the manifest:
6+
*
7+
* - `retention_overrides` and `quotas` admit the objects the sweep walks
8+
* (a lifecycle class other than `record`), read at the moment of asking;
9+
* - `quota_defaults` admits the lifecycle classes the sweep reads a default
10+
* for (`LifecycleClassSchema` without `record`);
11+
* - `ObjectQLPlugin` registers the three sources with the settings service
12+
* BEFORE the manifest, booted through a real `ObjectKernel`.
13+
*/
14+
15+
import { describe, it, expect, afterEach } from 'vitest';
16+
import { ObjectKernel, type PluginContext } from '@objectstack/core';
17+
import { ObjectQLPlugin } from '../plugin.js';
18+
import type { ObjectQL } from '../engine.js';
19+
import {
20+
lifecycleClassKeySource,
21+
lifecycleObjectKeySource,
22+
type LifecycleKeyRegistryLike,
23+
type LifecycleKeySource,
24+
} from './lifecycle-key-options.js';
25+
26+
describe('lifecycleObjectKeySource', () => {
27+
const objects: ReturnType<LifecycleKeyRegistryLike['getAllObjects']> = [
28+
{ name: 'sys_job_run', label: 'Job run', lifecycle: { class: 'telemetry' } },
29+
{ name: 'sys_audit_log', lifecycle: { class: 'audit' } },
30+
{ name: 'account', label: 'Account', lifecycle: { class: 'record' } },
31+
{ name: 'contact', label: 'Contact' },
32+
];
33+
34+
it('admits exactly the objects the sweep walks, sorted, named by their label', () => {
35+
const answer = lifecycleObjectKeySource(() => ({ getAllObjects: () => objects }))()!;
36+
expect(answer.options).toEqual([
37+
{ value: 'sys_audit_log', label: 'sys_audit_log' },
38+
{ value: 'sys_job_run', label: 'Job run' },
39+
]);
40+
expect(answer.admits('sys_job_run')).toBe(true);
41+
expect(answer.admits('account')).toBe(false);
42+
expect(answer.admits('contact')).toBe(false);
43+
expect(answer.admits('sys_job_runs')).toBe(false);
44+
});
45+
46+
it('reads the storage objects when the registry has them, as the sweep does', () => {
47+
const answer = lifecycleObjectKeySource(() => ({
48+
getAllObjects: () => [],
49+
getAllStorageObjects: () => [{ name: 'sys_session', lifecycle: { class: 'transient' } }],
50+
}))()!;
51+
expect(answer.options.map((o) => o.value)).toEqual(['sys_session']);
52+
});
53+
54+
it('is asked at the moment of use: an object registered later is admitted from then on', () => {
55+
const live = [...objects];
56+
const source = lifecycleObjectKeySource(() => ({ getAllObjects: () => live }));
57+
expect(source()!.admits('sys_webhook_delivery')).toBe(false);
58+
live.push({ name: 'sys_webhook_delivery', lifecycle: { class: 'event' } });
59+
expect(source()!.admits('sys_webhook_delivery')).toBe(true);
60+
});
61+
62+
it('answers nothing while no registry is reachable (the declared table, none, then stands)', () => {
63+
expect(lifecycleObjectKeySource(() => undefined)()).toBeUndefined();
64+
});
65+
});
66+
67+
describe('lifecycleClassKeySource', () => {
68+
it('admits the lifecycle classes a quota default is read for — every class but `record`', () => {
69+
const answer = lifecycleClassKeySource()()!;
70+
expect(answer.options.map((o) => o.value)).toEqual(['audit', 'telemetry', 'transient', 'event']);
71+
expect(answer.admits('record')).toBe(false);
72+
expect(answer.admits('telemtry')).toBe(false);
73+
});
74+
});
75+
76+
describe('ObjectQLPlugin registers the three key sources before the lifecycle manifest', () => {
77+
const kernels: ObjectKernel[] = [];
78+
afterEach(async () => {
79+
while (kernels.length) {
80+
const kernel = kernels.pop()!;
81+
try { if (kernel.getState() === 'running') await kernel.shutdown(); } catch { /* noop */ }
82+
}
83+
});
84+
85+
it('at kernel:ready, against the engine registry', async () => {
86+
const calls: string[] = [];
87+
const sources = new Map<string, LifecycleKeySource>();
88+
const settings = {
89+
registerOptionSource(namespace: string, key: string, source: LifecycleKeySource) {
90+
calls.push(`source ${namespace}.${key}`);
91+
sources.set(key, source);
92+
},
93+
registerManifest(manifest: { namespace: string }) { calls.push(`manifest ${manifest.namespace}`); },
94+
};
95+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
96+
kernels.push(kernel);
97+
await kernel.use(new ObjectQLPlugin());
98+
await kernel.use({
99+
name: 'test.settings-and-objects',
100+
dependencies: ['com.objectstack.engine.objectql'],
101+
init: async (ctx: PluginContext) => {
102+
ctx.registerService('settings', settings);
103+
const ql = ctx.getService<ObjectQL>('objectql');
104+
ql.registerDriver({
105+
name: 'primary', version: '0.0.0', supports: {},
106+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
107+
async find() { return []; }, async findOne() { return null; }, async count() { return 0; },
108+
} as any, true);
109+
await ctx.getService<{ register(a: unknown): Promise<void> | void }>('manifest').register({
110+
id: 'com.test.lifecycle-keys',
111+
name: 'com.test.lifecycle-keys',
112+
version: '1.0.0',
113+
objects: [{
114+
name: 'test_ping',
115+
fields: { note: { name: 'note', type: 'text' } },
116+
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } },
117+
}],
118+
});
119+
},
120+
});
121+
await kernel.bootstrap();
122+
123+
expect(calls).toEqual([
124+
'source lifecycle.retention_overrides',
125+
'source lifecycle.quotas',
126+
'source lifecycle.quota_defaults',
127+
'manifest lifecycle',
128+
]);
129+
expect(sources.get('quotas')!()!.admits('test_ping')).toBe(true);
130+
expect(sources.get('retention_overrides')!()!.admits('test_ping')).toBe(true);
131+
expect(sources.get('quota_defaults')!()!.admits('telemetry')).toBe(true);
132+
});
133+
});

0 commit comments

Comments
 (0)