Repository navigation
Commit 243eba1
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
File tree
- .changeset
- content/docs
- protocol/kernel
- references/system
- docs
- adr
- audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- objectql/src
- lifecycle
- services/service-settings/src
- __tests__
- translations
- spec
- authorable-surface
- src/system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
267 | 267 | | |
268 | 268 | | |
269 | 269 | | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
270 | 282 | | |
271 | 283 | | |
272 | 284 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| 126 | + | |
| 127 | + | |
126 | 128 | | |
127 | 129 | | |
128 | 130 | | |
| |||
179 | 181 | | |
180 | 182 | | |
181 | 183 | | |
182 | | - | |
| 184 | + | |
183 | 185 | | |
184 | 186 | | |
185 | 187 | | |
| |||
206 | 208 | | |
207 | 209 | | |
208 | 210 | | |
| 211 | + | |
| 212 | + | |
209 | 213 | | |
210 | 214 | | |
211 | 215 | | |
| |||
231 | 235 | | |
232 | 236 | | |
233 | 237 | | |
| 238 | + | |
234 | 239 | | |
235 | 240 | | |
236 | 241 | | |
| |||
269 | 274 | | |
270 | 275 | | |
271 | 276 | | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
272 | 293 | | |
273 | 294 | | |
274 | 295 | | |
| |||
367 | 388 | | |
368 | 389 | | |
369 | 390 | | |
| 391 | + | |
370 | 392 | | |
371 | 393 | | |
372 | 394 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
| 3 | + | |
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| |||
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
| 109 | + | |
| 110 | + | |
109 | 111 | | |
110 | 112 | | |
111 | 113 | | |
| |||
120 | 122 | | |
121 | 123 | | |
122 | 124 | | |
123 | | - | |
| 125 | + | |
| 126 | + | |
124 | 127 | | |
125 | 128 | | |
126 | 129 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
639 | 639 | | |
640 | 640 | | |
641 | 641 | | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
642 | 652 | | |
643 | 653 | | |
644 | 654 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
0 commit comments