From e5750d8e11ee97a0660d57bff5ede9c65a6b3c6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 02:29:28 +0000 Subject: [PATCH 1/4] feat(spec): add the map settings specifier for keyed rows SpecifierType gains map: a keyColumn header and typed value columns (duration or positive_integer). One column stores a scalar per key, several store an object per key. The keys a map admits come from a runtime option source, not the manifest. ADR-0007 amended; json stays for raw config. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fBJ6xsLWJsnNJGMMx --- .../references/system/settings-manifest.mdx | 24 +++- .../0007-settings-manifest-and-kv-store.md | 7 +- .../system.md | 2 +- packages/spec/authorable-surface/system.json | 2 + .../spec/src/system/settings-manifest.test.ts | 67 ++++++++++- .../spec/src/system/settings-manifest.zod.ts | 111 +++++++++++++++++- 6 files changed, 206 insertions(+), 7 deletions(-) diff --git a/content/docs/references/system/settings-manifest.mdx b/content/docs/references/system/settings-manifest.mdx index 48af44ad9ae..19cd48a0aa3 100644 --- a/content/docs/references/system/settings-manifest.mdx +++ b/content/docs/references/system/settings-manifest.mdx @@ -123,6 +123,8 @@ const result = ResolvedSettingValueSchema.parse(data); | **pattern** | `string` | optional | Regex pattern (text only) | | **rows** | `integer` | optional | | | **handler** | `{ kind: 'http'; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH'>; url: string; body?: Record; … } \| { kind: 'action'; name: string; params?: Record; confirmText?: string \| Record } \| { kind: 'navigate'; url: string; target?: Enum<'_self' \| '_blank'> }` | optional | Action handler (action_button) | +| **keyColumn** | `{ label: string \| Record; description?: string }` | optional | Key column (map) | +| **columns** | `{ name: string; label: string \| Record; description?: string; kind: Enum<'duration' \| 'positive_integer'> }[]` | optional | Value columns (map): one stores a scalar per key, several an object | | **confirmOffText** | `string \| Record` | optional | Confirm dialog text shown before a toggle's value changes to off (omit = no confirm) | | **childNamespace** | `string` | optional | Sub-namespace (child_pane) | | **bannerText** | `string` | optional | Markdown body (info_banner) | @@ -179,7 +181,7 @@ const result = ResolvedSettingValueSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'group' \| 'child_pane' \| 'info_banner' \| 'title_value' \| 'text' \| 'textarea' \| 'password' \| 'email' \| 'url' \| 'phone' \| 'number' \| 'toggle' \| 'select' \| 'radio' \| … +5 more>` | ✅ | Specifier variant | +| **type** | `Enum<'group' \| 'child_pane' \| 'info_banner' \| 'title_value' \| 'text' \| 'textarea' \| 'password' \| 'email' \| 'url' \| 'phone' \| 'number' \| 'toggle' \| 'select' \| 'radio' \| … +6 more>` | ✅ | Specifier variant | | **id** | `string` | optional | Stable identifier (snake_case) | | **key** | `string` | optional | Storage key (snake_case) | | **label** | `string \| Record` | ✅ | Display label | @@ -206,6 +208,8 @@ const result = ResolvedSettingValueSchema.parse(data); | **pattern** | `string` | optional | Regex pattern (text only) | | **rows** | `integer` | optional | | | **handler** | `{ kind: 'http'; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH'>; url: string; body?: Record; … } \| { kind: 'action'; name: string; params?: Record; confirmText?: string \| Record } \| { kind: 'navigate'; url: string; target?: Enum<'_self' \| '_blank'> }` | optional | Action handler (action_button) | +| **keyColumn** | `{ label: string \| Record; description?: string }` | optional | Key column (map) | +| **columns** | `{ name: string; label: string \| Record; description?: string; kind: Enum<'duration' \| 'positive_integer'> }[]` | optional | Value columns (map): one stores a scalar per key, several an object | | **confirmOffText** | `string \| Record` | optional | Confirm dialog text shown before a toggle's value changes to off (omit = no confirm) | | **childNamespace** | `string` | optional | Sub-namespace (child_pane) | | **bannerText** | `string` | optional | Markdown body (info_banner) | @@ -231,6 +235,7 @@ const result = ResolvedSettingValueSchema.parse(data); * `slider` * `color` * `json` +* `map` * `action_button` ### Nested Shape: `Specifier.options[number]` @@ -269,6 +274,22 @@ const result = ResolvedSettingValueSchema.parse(data); | **url** | `string` | ✅ | Target URL or in-app route | | **target** | `Enum<'_self' \| '_blank'>` | optional (default: `"_self"`) | | +### Nested Shape: `Specifier.keyColumn` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string \| Record` | ✅ | Key column header | +| **description** | `string` | optional | Help text for the key column | + +### Nested Shape: `Specifier.columns[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Column identifier: the stored property name when the map has several value columns | +| **label** | `string \| Record` | ✅ | Column header | +| **description** | `string` | optional | Help text for the column | +| **kind** | `Enum<'duration' \| 'positive_integer'>` | ✅ | Value kind: duration literal or positive integer | + --- @@ -367,6 +388,7 @@ This schema accepts one of the following structures: * `slider` * `color` * `json` +* `map` * `action_button` diff --git a/docs/adr/0007-settings-manifest-and-kv-store.md b/docs/adr/0007-settings-manifest-and-kv-store.md index 1ba1e287967..d4a86193212 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) · **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. +**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. **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) @@ -106,6 +106,8 @@ ObjectStack's protocol-first DNA matches Apple's: a small set of declarative spe 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. +*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.* + | Specifier | Renders as | Persists | Notes | |---|---|---|---| | `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 | `multiselect` | checkbox list / tag picker | array | | | `slider` | range slider | number | `min`, `max`, `step` | | `color` | colour picker | hex string | | -| `json` | code editor | object | for advanced/raw config | +| `json` | code editor | object | for advanced/raw config — not for maps (see `map`) | +| `map` | keyed rows: a key column + typed value columns | object: `Record` with one value column, `Record` 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 | | `title-value` | read-only label | — | display computed/derived values | | `child-pane` | nav row → sub-page | — | references another manifest namespace | | `action-button` | button | — | triggers `handler` (HTTP / built-in) | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index fa2a30ff6f8..476b4d1dd44 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 397 | +| `system/` | 399 | diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index f2c56ca3e05..8fd0e406ef0 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1140,6 +1140,7 @@ "system/Specifier:bannerSeverity", "system/Specifier:bannerText", "system/Specifier:childNamespace", + "system/Specifier:columns", "system/Specifier:confirmOffText", "system/Specifier:default", "system/Specifier:deprecated", @@ -1149,6 +1150,7 @@ "system/Specifier:icon", "system/Specifier:id", "system/Specifier:key", + "system/Specifier:keyColumn", "system/Specifier:label", "system/Specifier:lockable", "system/Specifier:max", diff --git a/packages/spec/src/system/settings-manifest.test.ts b/packages/spec/src/system/settings-manifest.test.ts index d523b392e5d..b9d41b7be7c 100644 --- a/packages/spec/src/system/settings-manifest.test.ts +++ b/packages/spec/src/system/settings-manifest.test.ts @@ -20,7 +20,7 @@ describe('SpecifierType', () => { 'group', 'child_pane', 'info_banner', 'title_value', 'text', 'textarea', 'password', 'email', 'url', 'phone', 'number', 'toggle', 'select', 'radio', 'multiselect', - 'slider', 'color', 'json', 'action_button', + 'slider', 'color', 'json', 'map', 'action_button', ]; for (const k of kinds) { expect(() => SpecifierType.parse(k)).not.toThrow(); @@ -195,6 +195,71 @@ describe('SpecifierSchema — layout-only specifiers', () => { }); }); +describe('SpecifierSchema — `map`, keyed rows with typed value columns (#30208, ADR-0007 amended 2026-11-08)', () => { + const quotas = { + type: 'map', + key: 'quotas', + label: 'Row quotas', + keyColumn: { label: 'Object' }, + columns: [{ name: 'rows', label: 'Max rows', kind: 'positive_integer' }], + } as const; + const overrides = { + type: 'map', + key: 'retention_overrides', + label: 'Retention overrides', + keyColumn: { label: 'Object' }, + columns: [ + { name: 'maxAge', label: 'Keep for', kind: 'duration' }, + { name: 'expireAfter', label: 'Expire after', kind: 'duration' }, + ], + } as const; + + it('parses a one-column and a several-column map, keeping the columns as declared', () => { + const one = SpecifierSchema.parse(quotas); + expect(one.type).toBe('map'); + expect(one.keyColumn).toEqual({ label: 'Object' }); + expect(one.columns).toEqual([{ name: 'rows', label: 'Max rows', kind: 'positive_integer' }]); + const several = SpecifierSchema.parse(overrides); + // camelCase survives: the column name is the stored property. + expect(several.columns!.map((c) => c.name)).toEqual(['maxAge', 'expireAfter']); + }); + + it('is value-bearing: a map without a key is refused', () => { + const { key: _key, ...keyless } = quotas; + expect(() => SpecifierSchema.parse(keyless)).toThrow(/requires a 'key'/); + }); + + it('refuses a map without columns, with an empty column list, or without a key column', () => { + const { columns: _c, ...noColumns } = quotas; + expect(() => SpecifierSchema.parse(noColumns)).toThrow(/requires at least one value column in 'columns'/); + expect(() => SpecifierSchema.parse({ ...quotas, columns: [] })).toThrow(/requires at least one value column/); + const { keyColumn: _k, ...noKeyColumn } = quotas; + expect(() => SpecifierSchema.parse(noKeyColumn)).toThrow(/requires a 'keyColumn'/); + }); + + it('refuses a column kind outside the closed set, and a duplicate column', () => { + expect(() => SpecifierSchema.parse({ + ...quotas, columns: [{ name: 'rows', label: 'Rows', kind: 'number' }], + })).toThrow(); + expect(() => SpecifierSchema.parse({ + ...overrides, columns: [overrides.columns[0], overrides.columns[0]], + })).toThrow(/Duplicate map column 'maxAge'/); + }); + + it('refuses `columns` and `keyColumn` on any other type', () => { + expect(() => SpecifierSchema.parse({ + type: 'json', key: 'quotas', label: 'Row quotas', columns: quotas.columns, + })).toThrow(/must not declare 'columns' \(only a 'map' may\)/); + expect(() => SpecifierSchema.parse({ + type: 'text', key: 'x', label: 'X', keyColumn: { label: 'Object' }, + })).toThrow(/must not declare 'keyColumn'/); + }); + + it('`json` stays in the set for raw config', () => { + expect(SpecifierSchema.parse({ type: 'json', key: 'raw', label: 'Raw' }).type).toBe('json'); + }); +}); + describe('SpecifierValueDomainSchema — the closed standard-domain vocabulary', () => { it('accepts exactly the three domains with measured authoring pull', () => { for (const d of ['iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2']) { diff --git a/packages/spec/src/system/settings-manifest.zod.ts b/packages/spec/src/system/settings-manifest.zod.ts index b4208596d43..1764a6c7e81 100644 --- a/packages/spec/src/system/settings-manifest.zod.ts +++ b/packages/spec/src/system/settings-manifest.zod.ts @@ -6,6 +6,7 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { I18nLabelSchema } from '../ui/i18n.zod'; import { ValueDomainSchema } from '../shared/value-domain.zod'; +import { LIFECYCLE_DURATION_REGEX } from '../data/object.zod'; /** * Settings Manifest Protocol @@ -53,7 +54,8 @@ export const SpecifierType = z.enum([ 'multiselect', 'slider', 'color', - 'json', + 'json', // raw config only; a map of rows is a `map` (ADR-0007, amended 2026-11-08) + 'map', // keyed rows: a key column plus typed value columns // Actions 'action_button', // calls handler (test connection / rotate / etc.) @@ -63,13 +65,56 @@ export type SpecifierType = z.input; const SPECIFIERS_REQUIRING_KEY: ReadonlySet = new Set([ 'text', 'textarea', 'password', 'email', 'url', 'phone', 'number', 'toggle', 'select', 'radio', 'multiselect', - 'slider', 'color', 'json', + 'slider', 'color', 'json', 'map', ]); // --------------------------------------------------------------------------- // Sub-schemas // --------------------------------------------------------------------------- +/** + * The value kinds a `map` column may declare (#30208, ADR-0007 as amended + * 2026-11-08). Closed, like the specifier set: a new kind is an ADR amendment. + * + * - `duration` — a duration literal ``, unit `h`, `d`, `w` or `y` + * (`'6h'`, `'14d'`, `'12w'`, `'7y'`): the lifecycle window's own grammar, + * {@link LIFECYCLE_DURATION_REGEX}, which the settings doors judge by too. + * - `positive_integer` — a whole number greater than 0. + */ +const SpecifierMapColumnKindSchema = z.enum(['duration', 'positive_integer']) + .describe(`Map column value kind: 'duration' (a literal matching ${LIFECYCLE_DURATION_REGEX.source}) or 'positive_integer'`); + +/** + * One value column of a `map` specifier. + * + * `name` is the property the column's value is stored under when the map has + * several value columns (`retention_overrides` stores + * `{ "": { "maxAge": "1y", "expireAfter": "30d" } }`), so it keeps the + * stored property's own spelling, camelCase included. With one value column the + * name is not stored: the entry IS the value (`quotas` stores + * `{ "": 1000000 }`). + */ +const SpecifierMapColumnSchema = z.object({ + name: z.string() + .regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'A map column name is an identifier (letters, digits, `_`), stored as the property name') + .describe('Column identifier: the stored property name when the map has several value columns'), + label: I18nLabelSchema.describe('Column header'), + description: z.string().optional().describe('Help text for the column'), + kind: SpecifierMapColumnKindSchema.describe('Value kind: duration literal or positive integer'), +}); + +/** + * The key column of a `map` specifier: the header the renderer draws over the + * keys. It declares no list of keys. The keys a map admits are served at run + * time by the settings service's option source for that map key (objects that + * declare a lifecycle, the lifecycle classes): a list written here would be + * stale the day an object is added. + */ +const SpecifierMapKeyColumnSchema = z.object({ + label: I18nLabelSchema.describe('Key column header'), + description: z.string().optional().describe('Help text for the key column'), +}); + export const SpecifierOptionSchema = lazySchema(() => z.object({ value: z.union([z.string(), z.number(), z.boolean()]).describe('Stored value'), label: I18nLabelSchema.describe('Display label'), @@ -544,6 +589,27 @@ export const SpecifierSchema = lazySchema(() => z.object({ /** `action_button`: handler invoked on click. */ handler: SpecifierHandlerSchema.optional().describe('Action handler (action_button)'), + /** + * `map`: the key column (#30208). A `map` is a set of keyed rows: one key + * column, then the value columns in {@link columns}. Stored shape: + * + * - **one value column** stores `Record`: `{ "sys_audit_log": 1000000 }`; + * - **several value columns** store `Record`: + * `{ "sys_job_run": { "maxAge": "1y", "expireAfter": "30d" } }`, each entry + * carrying at least one declared column and no other property. + * + * The keys a map admits are not declared here. A runtime option source the + * settings service registers for the map key serves them (they are what the + * running system has: its objects, its lifecycle classes), both doors judge a + * key against it, and every manifest read serves them as this specifier's + * `options`. Without a source, a declared `options` table is the fallback, + * as for a `select`; without either, any key is admitted. + */ + keyColumn: SpecifierMapKeyColumnSchema.optional().describe('Key column (map)'), + + /** `map`: the value columns, in display order. See {@link keyColumn} for the stored shape. */ + columns: z.array(SpecifierMapColumnSchema).optional().describe('Value columns (map): one stores a scalar per key, several an object'), + /** * `toggle`: the confirm dialog text shown before the value changes to off * (omit = no confirm). The name mirrors a handler's `confirmText`; it is @@ -611,6 +677,47 @@ export const SpecifierSchema = lazySchema(() => z.object({ }); } } + // A `map` declares its key column and at least one value column; no other + // type carries either, which would be a declaration nothing draws or judges + // (Prime Directive #10). + if (spec.type === 'map') { + if (!spec.keyColumn) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['keyColumn'], + message: `Specifier of type 'map' requires a 'keyColumn'.`, + }); + } + if (!spec.columns || spec.columns.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['columns'], + message: `Specifier of type 'map' requires at least one value column in 'columns'.`, + }); + } else { + const seen = new Set(); + spec.columns.forEach((col, idx) => { + if (seen.has(col.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['columns', idx, 'name'], + message: `Duplicate map column '${col.name}'.`, + }); + } + seen.add(col.name); + }); + } + } else { + for (const slot of ['keyColumn', 'columns'] as const) { + if (spec[slot] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [slot], + message: `Specifier of type '${spec.type}' must not declare '${slot}' (only a 'map' may).`, + }); + } + } + } // action_button requires a handler. if (spec.type === 'action_button' && !spec.handler) { ctx.addIssue({ From 4fa7d9f23f5fd4d6cd586446ae169e67243c73ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 02:29:28 +0000 Subject: [PATCH 2/4] feat(service-settings): judge map values at the save and env doors One shared judgement for both doors: the value must be an object of keyed rows, each key admitted by the key column's option source and each cell of its column's kind. Refusals name the entry and the expected form. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fBJ6xsLWJsnNJGMMx --- .../src/__tests__/settings-door.testkit.ts | 164 +++++++++++ .../service-settings/src/map-shape.ts | 189 ++++++++++++ .../src/settings-json-shape-31241.test.ts | 227 ++++---------- .../src/settings-map-shape-30208.test.ts | 277 ++++++++++++++++++ .../service-settings/src/settings-service.ts | 135 ++++++++- 5 files changed, 817 insertions(+), 175 deletions(-) create mode 100644 packages/services/service-settings/src/__tests__/settings-door.testkit.ts create mode 100644 packages/services/service-settings/src/map-shape.ts create mode 100644 packages/services/service-settings/src/settings-map-shape-30208.test.ts diff --git a/packages/services/service-settings/src/__tests__/settings-door.testkit.ts b/packages/services/service-settings/src/__tests__/settings-door.testkit.ts new file mode 100644 index 00000000000..fd757a4238d --- /dev/null +++ b/packages/services/service-settings/src/__tests__/settings-door.testkit.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: a settings door over the real `ObjectQL` engine, the real + * platform setting objects and the real `PUT /api/settings/:namespace` route, + * with a Map driver underneath. Shared by the door suites that judge a value + * shape end to end (#31241 `json`, #30208 `map`). + */ + +import { vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform-objects/system'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import type { SettingsManifest } from '@objectstack/spec/system'; +import { SettingsService } from '../settings-service.js'; +import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from '../settings-service-plugin.js'; +import { withEngineRegistry } from './settings-organization.testkit.js'; +import { LocalCryptoProvider } from '../local-crypto-provider.js'; +import { registerSettingsRoutes } from '../settings-routes.js'; + +const OWNER_PACKAGE = 'com.objectstack.test.settings-door'; + +/** A driver over plain Maps — the verbs the settings path reaches through `ObjectQL`. */ +export function makeMemoryDriver() { + const store = new Map>>(); + let nextId = 0; + const copy = (r: Record) => ({ ...r }); + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + // `$or` is the one combinator the settings reads emit; anything else refuses, + // so a predicate this double cannot express never reads as "no such row". + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + // The caller's bound by PRESENCE, after the filter and before the copy. + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map(copy); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return copy(r); + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `row_${nextId}`; + const row = { ...data, id }; + rowsOf(object).set(id, row); + return copy(row); + }, + async update(object: string, id: string, data: Record) { + const s = rowsOf(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return copy(next); + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + return id && rowsOf(object).has(id) ? this.update(object, id, data) : this.create(object, data); + }, + async delete(object: string, id: string) { return rowsOf(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany(object: string, ast: any, data: Record) { + const rows = await this.find(object, ast); + const s = rowsOf(object); + for (const r of rows) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id }); + return rows.length; + }, + async deleteMany(object: string, ast: any) { + const rows = await this.find(object, ast); + for (const r of rows) rowsOf(object).delete(r.id as string); + return rows.length; + }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf }; +} + +export class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, h: RouteHandler) { this.routes.set(`${method} ${path}`, h); } + get(p: string, h: RouteHandler) { this.add('GET', p, h); return this as any; } + post(p: string, h: RouteHandler) { this.add('POST', p, h); return this as any; } + put(p: string, h: RouteHandler) { this.add('PUT', p, h); return this as any; } + delete(p: string, h: RouteHandler) { this.add('DELETE', p, h); return this as any; } + patch(p: string, h: RouteHandler) { this.add('PATCH', p, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +/** + * The pieces the running server binds: the real `ObjectQL` engine over a Map + * driver, the platform setting objects, a `SettingsService` with `manifest` + * registered, and the settings routes. `before` runs on the service before the + * manifest registers (an option source, as a plugin registers one). + */ +export async function bootSettingsDoor( + manifest: SettingsManifest, + options: { before?: (svc: SettingsService) => void } = {}, +) { + const engine = new ObjectQL(); + const { driver, rowsOf } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [SysSetting, SysPlatformSetting, SysSecret]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } + const svc = new SettingsService({ + env: {}, + engine: withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine), + cryptoProvider: new LocalCryptoProvider(), + secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), + }); + options.before?.(svc); + svc.registerManifest(manifest); + const http = new MockHttp(); + registerSettingsRoutes(http, svc, { + contextFromRequest: () => ({ enforced: true, permissions: ['manage_platform_settings'], userId: 'usr_admin' }), + }); + const put = http.routes.get('PUT /api/settings/:namespace')!; + const namespace = manifest.namespace; + /** One `PUT /api/settings/:namespace` with `body`, as the console sends it (a flat patch). */ + const save = async (body: Record) => { + const req: IHttpRequest = { params: { namespace }, query: {}, body, headers: {}, method: 'PUT', path: '/' }; + const state: { status: number; body?: any } = { status: 200 }; + const res: IHttpResponse = { + json: vi.fn((d) => { state.body = d; }) as any, + send: vi.fn() as any, + status: vi.fn((c: number) => { state.status = c; return res; }) as any, + header: vi.fn(() => res) as any, + }; + await put(req, res); + return state; + }; + const stored = (key: string) => + [...rowsOf('sys_platform_setting').values()].find((r) => r.namespace === namespace && r.key === key); + const seed = (row: Record) => + rowsOf('sys_platform_setting').set(String(row.id), { encrypted: false, locked: false, value_enc: null, ...row }); + return { svc, save, stored, seed }; +} diff --git a/packages/services/service-settings/src/map-shape.ts b/packages/services/service-settings/src/map-shape.ts new file mode 100644 index 00000000000..5fa85e07804 --- /dev/null +++ b/packages/services/service-settings/src/map-shape.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { LIFECYCLE_DURATION_REGEX } from '@objectstack/spec/data'; + +/** + * The shape judgement of a `map` specifier's value (#30208; ADR-0007 as amended + * 2026-11-08): ONE judgement, shared by both doors that produce an effective + * value, the save door (`SettingsService.validatePatch`) and the env door + * (`SettingsService.effectiveEnvOverride`), on the precedent of the option and + * pattern families (#5204, #6580). A comparison living on one door only is how + * those two defects happened. + * + * A `map` stores keyed rows. With one value column the entry IS the value + * (`{ "sys_audit_log": 1000000 }`); with several it is an object of the + * declared columns (`{ "sys_job_run": { "maxAge": "1y" } }`). Before this type, + * the three lifecycle maps were `json` keys that neither door judged: a misspelt + * object, `"1e6"` or `30 days` was stored, answered "saved", and silently did + * nothing. + */ + +/** The value kinds a column may declare — the spec's closed `kind` set. */ +export type MapColumnKind = 'duration' | 'positive_integer'; + +const COLUMN_KINDS: ReadonlySet = new Set(['duration', 'positive_integer']); + +/** One declared value column, as the doors judge it. */ +export interface MapColumn { + name: string; + kind: MapColumnKind; +} + +/** What a `map` specifier declares that a value is judged against. */ +export interface DeclaredMapShape { + columns: ReadonlyArray; +} + +/** + * The shape a `map` specifier declares, or `null` when it declares none this + * side can judge. `registerManifest` takes manifests as given (no Zod pass), so + * a hand-built manifest's malformed columns are dropped rather than trusted, and + * a map left with no column records nothing to enforce: the same leniency the + * option table and the pattern take, never a reject-everything hole. + */ +export function declaredMapShape(spec: { type?: unknown; columns?: unknown }): DeclaredMapShape | null { + if (spec.type !== 'map' || !Array.isArray(spec.columns)) return null; + const columns: MapColumn[] = []; + for (const col of spec.columns) { + if (!col || typeof col !== 'object') continue; + const { name, kind } = col as { name?: unknown; kind?: unknown }; + if (typeof name !== 'string' || name === '' || typeof kind !== 'string' || !COLUMN_KINDS.has(kind)) continue; + columns.push({ name, kind: kind as MapColumnKind }); + } + return columns.length > 0 ? { columns } : null; +} + +/** Why a value is not the map its specifier declares — see {@link firstMapShapeMiss}. */ +export type MapShapeMiss = + /** The value is not an object of keyed rows at all (JSON text, a scalar, an array). */ + | { kind: 'not_a_map'; shape: string } + /** An entry's key is not one the key column admits. */ + | { kind: 'key'; entry: string } + /** Several columns: an entry is not an object, or names none of the columns. */ + | { kind: 'not_an_entry'; entry: string; shape: string } + /** Several columns: an entry carries a property no column declares. */ + | { kind: 'unknown_column'; entry: string; column: string } + /** A cell's value is not of its column's kind. */ + | { kind: 'cell'; entry: string; column: MapColumn; shape: string }; + +/** + * The first way `value` is not the map `shape` declares, or `null` when it is. + * + * `null` / `undefined` pass: that is a clear, which falls back to the default. + * Entries are judged in their own order, each key before its value, so the + * verdict names the first entry a reader would trip over. `admitsKey` is the key + * column's domain (the option source the settings service registers for the + * key); absent, every key is admitted. + * + * A cell is judged as stored, never coerced: the one reader of these maps + * (`LifecycleService.loadGovernance`) applies a quota only when it is a number + * and a window only when it parses as a duration, so `"1000"` and `" 7y"` are + * exactly the values that are stored and then do nothing. + */ +export function firstMapShapeMiss( + shape: DeclaredMapShape, + value: unknown, + admitsKey?: (key: string) => boolean, +): MapShapeMiss | null { + if (value === null || typeof value === 'undefined') return null; + if (!isPlainRecord(value)) return { kind: 'not_a_map', shape: shapeOf(value) }; + const single = shape.columns.length === 1; + for (const [entry, cell] of Object.entries(value)) { + if (admitsKey && !admitsKey(entry)) return { kind: 'key', entry }; + if (single) { + const column = shape.columns[0]; + if (!cellFits(column.kind, cell)) return { kind: 'cell', entry, column, shape: shapeOf(cell) }; + continue; + } + if (!isPlainRecord(cell)) return { kind: 'not_an_entry', entry, shape: shapeOf(cell) }; + const declared = new Map(shape.columns.map((c) => [c.name, c])); + let named = 0; + for (const [prop, v] of Object.entries(cell)) { + const column = declared.get(prop); + if (!column) return { kind: 'unknown_column', entry, column: prop }; + // An absent cell (`null` / `undefined`) is a column left empty. + if (v === null || typeof v === 'undefined') continue; + if (!cellFits(column.kind, v)) return { kind: 'cell', entry, column, shape: shapeOf(v) }; + named += 1; + } + if (named === 0) return { kind: 'not_an_entry', entry, shape: 'an object that names no column' }; + } + return null; +} + +function cellFits(kind: MapColumnKind, v: unknown): boolean { + if (kind === 'duration') return typeof v === 'string' && LIFECYCLE_DURATION_REGEX.test(v); + return typeof v === 'number' && Number.isInteger(v) && v > 0; +} + +/** What a column of this kind takes, as a sentence fragment with an example. */ +export function columnKindPhrase(kind: MapColumnKind): string { + return kind === 'duration' + ? 'a duration such as 7y (a whole number followed by h, d, w or y)' + : 'a whole number greater than 0, such as 1000000'; +} + +/** + * The one sentence a {@link MapShapeMiss} is told in, at both doors. It names + * the entry (and the column), and the form expected. A string value is + * described by its length (`a string of 7 characters`), never printed, as the + * format family does (#24882); a whole number is named, since it is the + * number an operator typed and carries nothing secret. + */ +export function mapMissSentence( + label: string, + shape: DeclaredMapShape, + miss: MapShapeMiss, + allowedKeys?: readonly string[], +): string { + const columnNames = shape.columns.map((c) => c.name).join(', '); + switch (miss.kind) { + case 'not_a_map': + return `${label} must be an object of keyed rows, such as ${exampleOf(shape, allowedKeys)}, not ${miss.shape}. ` + + 'Send the parsed value, not its JSON text.'; + case 'key': + return `${label}: '${miss.entry}' is not a key this setting admits.` + + (allowedKeys && allowedKeys.length > 0 ? ` Allowed: ${allowedKeys.join(', ')}.` : ''); + case 'not_an_entry': + return `${label}: '${miss.entry}' must be an object with at least one of ${columnNames}, ` + + `such as ${entryExampleOf(shape)}, not ${miss.shape}.`; + case 'unknown_column': + return `${label}: '${miss.entry}' has a property '${miss.column}' this setting does not declare. ` + + `Allowed: ${columnNames}.`; + case 'cell': { + const where = shape.columns.length === 1 ? `'${miss.entry}'` : `'${miss.entry}'.${miss.column.name}`; + return `${label}: ${where} must be ${columnKindPhrase(miss.column.kind)}, not ${miss.shape}.`; + } + } +} + +function exampleCell(kind: MapColumnKind): string { + return kind === 'duration' ? '"7y"' : '1000000'; +} + +function entryExampleOf(shape: DeclaredMapShape): string { + const first = shape.columns[0]; + return `{ "${first.name}": ${exampleCell(first.kind)} }`; +} + +function exampleOf(shape: DeclaredMapShape, allowedKeys?: readonly string[]): string { + const key = allowedKeys?.[0] ?? 'key'; + const cell = shape.columns.length === 1 ? exampleCell(shape.columns[0].kind) : entryExampleOf(shape); + return `{ "${key}": ${cell} }`; +} + +function isPlainRecord(v: unknown): v is Record { + if (!v || typeof v !== 'object' || Array.isArray(v)) return false; + const proto = Object.getPrototypeOf(v); + return proto === Object.prototype || proto === null; +} + +/** A value's kind and, for a string, its length — never its content. */ +export function shapeOf(v: unknown): string { + if (v === null) return 'null'; + if (Array.isArray(v)) return 'an array'; + if (typeof v === 'string') return `a string of ${v.length} character${v.length === 1 ? '' : 's'}`; + if (typeof v === 'number') return Number.isInteger(v) ? `the whole number ${v}` : 'a number with a fraction'; + if (typeof v === 'object') return isPlainRecord(v) ? 'an object' : 'a non-plain object'; + return `a ${typeof v}`; +} diff --git a/packages/services/service-settings/src/settings-json-shape-31241.test.ts b/packages/services/service-settings/src/settings-json-shape-31241.test.ts index e1a8bab70b3..5ba094c0f08 100644 --- a/packages/services/service-settings/src/settings-json-shape-31241.test.ts +++ b/packages/services/service-settings/src/settings-json-shape-31241.test.ts @@ -8,8 +8,10 @@ * stored it: `PUT /api/settings/lifecycle` with `{ quotas: "{ … }" }` answered * 200 and `sys_platform_setting.value` held a string, so the lifecycle sweep * read every quota as absent. Pinned here over the real `ObjectQL` engine, the - * real platform setting objects and the real route, with the shipped - * `lifecycleSettingsManifest` (the only `json` specifiers in the platform): + * real platform setting objects and the real route. Since #30208 moved the + * three lifecycle maps to the `map` type (judged in + * `settings-map-shape-30208.test.ts`), no shipped manifest declares a `json` + * key, so a raw-config manifest stands in for the next one that does: * * - the save door refuses a string — and any other non-object — for a `json` * key with one `invalid_type` `FieldError`, and stores nothing; @@ -21,223 +23,100 @@ * layer; JSON text of an object is in force. */ -import { describe, it, expect, vi } from 'vitest'; -import { ObjectQL, lifecycleSettingsManifest } from '@objectstack/objectql'; -import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform-objects/system'; -import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { describe, it, expect } from 'vitest'; +import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; -import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; -import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; -import { LocalCryptoProvider } from './local-crypto-provider.js'; -import { registerSettingsRoutes } from './settings-routes.js'; - -const OWNER_PACKAGE = 'com.objectstack.test.settings-json-shape'; - -/** A driver over plain Maps — the verbs the settings path reaches through `ObjectQL`. */ -function makeMemoryDriver() { - const store = new Map>>(); - let nextId = 0; - const copy = (r: Record) => ({ ...r }); - const rowsOf = (object: string) => { - let s = store.get(object); - if (!s) { s = new Map(); store.set(object, s); } - return s; - }; - // `$or` is the one combinator the settings reads emit; anything else refuses, - // so a predicate this double cannot express never reads as "no such row". - const matches = (row: Record, where: any): boolean => { - if (!where || typeof where !== 'object') return true; - return Object.entries(where).every(([k, v]) => { - if (k === '$or') return (v as any[]).some((b) => matches(row, b)); - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return (row[k] ?? null) === (v ?? null); - }); - }; - const driver: any = { - name: 'memory', version: '0.0.0', supports: {} as any, - async connect() {}, async disconnect() {}, async checkHealth() { return true; }, - async execute() { return null; }, - async find(object: string, ast: any) { - // The caller's bound by PRESENCE, after the filter and before the copy. - const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); - const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; - return page.map(copy); - }, - async findOne(object: string, ast: any) { - for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return copy(r); - return null; - }, - async create(object: string, data: Record) { - nextId += 1; - const id = (data.id as string) ?? `row_${nextId}`; - const row = { ...data, id }; - rowsOf(object).set(id, row); - return copy(row); - }, - async update(object: string, id: string, data: Record) { - const s = rowsOf(object); - const cur = s.get(id); - if (!cur) return null; - const next = { ...cur, ...data, id }; - s.set(id, next); - return copy(next); - }, - async upsert(object: string, data: Record) { - const id = data.id as string | undefined; - return id && rowsOf(object).has(id) ? this.update(object, id, data) : this.create(object, data); - }, - async delete(object: string, id: string) { return rowsOf(object).delete(id); }, - async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, - async bulkCreate(object: string, rows: Record[]) { - return Promise.all(rows.map((r) => this.create(object, r))); - }, - async bulkUpdate() { return []; }, - async bulkDelete() {}, - async updateMany(object: string, ast: any, data: Record) { - const rows = await this.find(object, ast); - const s = rowsOf(object); - for (const r of rows) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id }); - return rows.length; - }, - async deleteMany(object: string, ast: any) { - const rows = await this.find(object, ast); - for (const r of rows) rowsOf(object).delete(r.id as string); - return rows.length; - }, - async syncSchema() {}, async dropTable() {}, - async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, - async commit() {}, async rollback() {}, - }; - return { driver, rowsOf }; -} - -class MockHttp implements IHttpServer { - routes = new Map(); - private add(method: string, path: string, h: RouteHandler) { this.routes.set(`${method} ${path}`, h); } - get(p: string, h: RouteHandler) { this.add('GET', p, h); return this as any; } - post(p: string, h: RouteHandler) { this.add('POST', p, h); return this as any; } - put(p: string, h: RouteHandler) { this.add('PUT', p, h); return this as any; } - delete(p: string, h: RouteHandler) { this.add('DELETE', p, h); return this as any; } - patch(p: string, h: RouteHandler) { this.add('PATCH', p, h); return this as any; } - use() { return this as any; } - listen() { return Promise.resolve(); } - close() { return Promise.resolve(); } - getInstance() { return null; } -} - -/** The pieces the running server binds, with the shipped lifecycle manifest and the settings routes. */ -async function boot() { - const engine = new ObjectQL(); - const { driver, rowsOf } = makeMemoryDriver(); - engine.registerDriver(driver, true); - await engine.init(); - for (const o of [SysSetting, SysPlatformSetting, SysSecret]) { - engine.registry.registerObject(o as any, OWNER_PACKAGE); - } - const svc = new SettingsService({ - env: {}, - engine: withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine), - cryptoProvider: new LocalCryptoProvider(), - secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), - }); - svc.registerManifest(lifecycleSettingsManifest); - const http = new MockHttp(); - registerSettingsRoutes(http, svc, { - contextFromRequest: () => ({ enforced: true, permissions: ['manage_platform_settings'], userId: 'usr_admin' }), - }); - const put = http.routes.get('PUT /api/settings/:namespace')!; - /** One `PUT /api/settings/lifecycle` with `body`, as the console sends it (a flat patch). */ - const save = async (body: Record) => { - const req: IHttpRequest = { params: { namespace: 'lifecycle' }, query: {}, body, headers: {}, method: 'PUT', path: '/' }; - const state: { status: number; body?: any } = { status: 200 }; - const res: IHttpResponse = { - json: vi.fn((d) => { state.body = d; }) as any, - send: vi.fn() as any, - status: vi.fn((c: number) => { state.status = c; return res; }) as any, - header: vi.fn(() => res) as any, - }; - await put(req, res); - return state; - }; - const stored = (key: string) => - [...rowsOf('sys_platform_setting').values()].find((r) => r.namespace === 'lifecycle' && r.key === key); - const seed = (row: Record) => - rowsOf('sys_platform_setting').set(String(row.id), { encrypted: false, locked: false, value_enc: null, ...row }); - return { svc, save, stored, seed }; -} +import { bootSettingsDoor } from './__tests__/settings-door.testkit.js'; + +/** A raw-config namespace: three `json` keys and a number, global scope. */ +const RAW_MANIFEST = { + namespace: 'raw_config', + label: 'Raw config', + scope: 'global', + readPermission: 'manage_platform_settings', + writePermission: 'manage_platform_settings', + specifiers: [ + { type: 'json', key: 'routes', label: 'Routes', default: {} }, + { type: 'json', key: 'payload', label: 'Payload', default: {} }, + { type: 'json', key: 'extra', label: 'Extra', default: {} }, + { type: 'number', key: 'count', label: 'Count', default: 0 }, + ], +} as unknown as SettingsManifest; + +const boot = () => bootSettingsDoor(RAW_MANIFEST); describe('[#31241] the save door: a `json` key takes a JSON object or array', () => { it('refuses the JSON text the old console sent: 400, one invalid_type FieldError per key, nothing stored', async () => { const { save, stored } = await boot(); const st = await save({ - quotas: '{\n "sys_audit_log": 1000\n}', - quota_defaults: '{\n "telemetry": 500\n}', + payload: '{\n "sys_audit_log": 1000\n}', + extra: '{\n "telemetry": 500\n}', }); expect(st.status).toBe(400); expect(st.body.error.code).toBe('SETTINGS_VALIDATION'); const fields = st.body.error.details.fields as Array>; expect(fields.map((f) => [f.field, f.code])).toEqual([ - ['quotas', 'invalid_type'], - ['quota_defaults', 'invalid_type'], + ['payload', 'invalid_type'], + ['extra', 'invalid_type'], ]); expect(fields[0].constraint).toEqual({ type: 'json' }); expect(fields[0].message).toBe( - 'Row quotas must be a JSON object or array, not a string of 27 characters. Send the parsed value, not its JSON text.', + 'Payload must be a JSON object or array, not a string of 27 characters. Send the parsed value, not its JSON text.', ); // The message names what to send, never the value. expect(JSON.stringify(fields)).not.toContain('sys_audit_log'); - expect(stored('quotas')).toBeUndefined(); - expect(stored('quota_defaults')).toBeUndefined(); + expect(stored('payload')).toBeUndefined(); + expect(stored('extra')).toBeUndefined(); }); it('refuses every other non-object: a number, a boolean, a blank string', async () => { const { save, stored } = await boot(); - const st = await save({ quotas: 5, quota_defaults: true, retention_overrides: '' }); + const st = await save({ payload: 5, extra: true, routes: '' }); expect(st.status).toBe(400); const fields = st.body.error.details.fields as Array>; expect(fields.map((f) => [f.field, f.code])).toEqual([ - ['retention_overrides', 'invalid_type'], - ['quotas', 'invalid_type'], - ['quota_defaults', 'invalid_type'], + ['routes', 'invalid_type'], + ['payload', 'invalid_type'], + ['extra', 'invalid_type'], ]); - expect(stored('quotas')).toBeUndefined(); + expect(stored('payload')).toBeUndefined(); }); it('CONTROL: stores an object and an array as themselves', async () => { const { svc, save, stored } = await boot(); - const st = await save({ quotas: { sys_audit_log: 1000 }, quota_defaults: [] }); + const st = await save({ payload: { sys_audit_log: 1000 }, extra: [] }); expect(st.status).toBe(200); - expect(stored('quotas')?.value).toEqual({ sys_audit_log: 1000 }); - expect(stored('quota_defaults')?.value).toEqual([]); - const got = await svc.get('lifecycle', 'quotas'); + expect(stored('payload')?.value).toEqual({ sys_audit_log: 1000 }); + expect(stored('extra')?.value).toEqual([]); + const got = await svc.get('raw_config', 'payload'); expect(got.value).toEqual({ sys_audit_log: 1000 }); expect(got.source).toBe('global'); }); it('accepts a clear (null): the key falls back to its default', async () => { const { svc, save } = await boot(); - await save({ quotas: { sys_audit_log: 1000 } }); + await save({ payload: { sys_audit_log: 1000 } }); - const st = await save({ quotas: null }); + const st = await save({ payload: null }); expect(st.status).toBe(200); - expect((await svc.get('lifecycle', 'quotas')).value).toEqual({}); + expect((await svc.get('raw_config', 'payload')).value).toEqual({}); }); it('a string already stored under one json key does not block a save that does not touch it', async () => { const { svc, save, seed } = await boot(); - seed({ id: 'legacy', namespace: 'lifecycle', key: 'quota_defaults', value: '{"telemetry": 500}' }); + seed({ id: 'legacy', namespace: 'raw_config', key: 'extra', value: '{"telemetry": 500}' }); - const st = await save({ growth_alert_rows: 10 }); + const st = await save({ count: 10 }); expect(st.status).toBe(200); - expect((await svc.get('lifecycle', 'growth_alert_rows')).value).toBe(10); + expect((await svc.get('raw_config', 'count')).value).toBe(10); }); }); @@ -245,17 +124,17 @@ describe('[#31241] the env door judges a `json` key the same way', () => { const envService = (value: string) => { const errors: string[] = []; const svc = new SettingsService({ - env: { OS_LIFECYCLE_QUOTAS: value }, + env: { OS_RAW_CONFIG_PAYLOAD: value }, logger: { error: (m: string) => void errors.push(m), warn: () => {} } as any, }); - svc.registerManifest(lifecycleSettingsManifest); + svc.registerManifest(RAW_MANIFEST); return { svc, errors }; }; it('CONTROL: JSON text of an object is in force, as the object', async () => { const { svc, errors } = envService('{"sys_audit_log": 1000}'); - const got = await svc.get('lifecycle', 'quotas'); + const got = await svc.get('raw_config', 'payload'); expect(got.value).toEqual({ sys_audit_log: 1000 }); expect(got.source).toBe('env'); @@ -265,13 +144,13 @@ describe('[#31241] the env door judges a `json` key the same way', () => { it('text that does not parse is not in force: the key resolves from the next layer, with one error line', async () => { const { svc, errors } = envService('{"sys_audit_log": 1000'); - const got = await svc.get('lifecycle', 'quotas'); - await svc.get('lifecycle', 'quotas'); + const got = await svc.get('raw_config', 'payload'); + await svc.get('raw_config', 'payload'); expect(got.value).toEqual({}); expect(got.source).not.toBe('env'); expect(errors).toHaveLength(1); - expect(errors[0]).toContain('OS_LIFECYCLE_QUOTAS'); + expect(errors[0]).toContain('OS_RAW_CONFIG_PAYLOAD'); expect(errors[0]).toContain("is not a JSON object or array, which its type 'json' declares"); expect(errors[0]).toContain('a string of 22 characters'); expect(errors[0]).not.toContain('sys_audit_log'); @@ -280,7 +159,7 @@ describe('[#31241] the env door judges a `json` key the same way', () => { it('text that parses to a scalar is not in force either', async () => { const { svc, errors } = envService('5'); - const got = await svc.get('lifecycle', 'quotas'); + const got = await svc.get('raw_config', 'payload'); expect(got.value).toEqual({}); expect(errors).toHaveLength(1); diff --git a/packages/services/service-settings/src/settings-map-shape-30208.test.ts b/packages/services/service-settings/src/settings-map-shape-30208.test.ts new file mode 100644 index 00000000000..dca7ffbdc46 --- /dev/null +++ b/packages/services/service-settings/src/settings-map-shape-30208.test.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30208 row 5, ruling A] The three lifecycle maps are `map` specifiers, and + * both settings doors judge their values by one shared judgement + * (`map-shape.ts`). + * + * As `json` keys they passed both doors unchecked: a misspelt object, `"1e6"` + * or `30 days` was stored, answered "saved", and the sweep silently ignored it. + * Pinned here over the real `ObjectQL` engine, the real platform setting + * objects and the real route, with the shipped `lifecycleSettingsManifest` and + * the key sources `ObjectQLPlugin` registers (`lifecycleObjectKeySource` over a + * registry of lifecycle-declared objects, `lifecycleClassKeySource`): + * + * - the save door stores the shapes the sweep reads, unchanged; + * - it refuses a key the running system does not have, a cell that is not its + * column's form, a property no column declares, and JSON text, each with one + * `FieldError` that names the entry and the form expected; + * - the env door (`OS_LIFECYCLE_*`) judges an override the same way: one + * `error` line, and the key resolves from the next layer. + */ + +import { describe, it, expect } from 'vitest'; +import { + lifecycleClassKeySource, + lifecycleObjectKeySource, + lifecycleSettingsManifest, + type LifecycleKeyRegistryLike, +} from '@objectstack/objectql'; +import { SettingsService } from './settings-service.js'; +import { bootSettingsDoor } from './__tests__/settings-door.testkit.js'; + +/** The objects the sweep would walk, and one it would not (`record`). */ +const REGISTRY: LifecycleKeyRegistryLike = { + getAllObjects: () => [ + { name: 'sys_audit_log', label: 'Audit log', lifecycle: { class: 'audit' } }, + { name: 'sys_job_run', label: 'Job run', lifecycle: { class: 'telemetry' } }, + { name: 'account', label: 'Account', lifecycle: { class: 'record' } }, + { name: 'contact', label: 'Contact' }, + ], +}; + +/** As `ObjectQLPlugin` registers them: before the manifest. */ +function registerKeySources(svc: SettingsService) { + const objects = lifecycleObjectKeySource(() => REGISTRY); + svc.registerOptionSource('lifecycle', 'retention_overrides', objects); + svc.registerOptionSource('lifecycle', 'quotas', objects); + svc.registerOptionSource('lifecycle', 'quota_defaults', lifecycleClassKeySource()); +} + +const boot = () => bootSettingsDoor(lifecycleSettingsManifest, { before: registerKeySources }); + +type Fields = Array>; + +describe('[#30208] the shipped lifecycle maps', () => { + it('declare the `map` type, with no list of keys in the manifest', () => { + const maps = lifecycleSettingsManifest.specifiers.filter((s) => s.type === 'map'); + expect(maps.map((s) => s.key)).toEqual(['retention_overrides', 'quotas', 'quota_defaults']); + for (const spec of maps) expect(spec.options).toBeUndefined(); + }); + + it('serve their keys from the running system as the map options (the key picker list)', async () => { + const { svc } = await boot(); + const page = await svc.getNamespace('lifecycle', { enforced: true, permissions: ['manage_platform_settings'] } as any); + const optionsOf = (key: string) => + (page.manifest.specifiers.find((s) => s.key === key)?.options ?? []).map((o) => o.value); + expect(optionsOf('quotas')).toEqual(['sys_audit_log', 'sys_job_run']); + expect(optionsOf('retention_overrides')).toEqual(['sys_audit_log', 'sys_job_run']); + expect(optionsOf('quota_defaults')).toEqual(['audit', 'telemetry', 'transient', 'event']); + }); +}); + +describe('[#30208] the save door judges a lifecycle map', () => { + it('CONTROL: stores the shapes the sweep reads, unchanged', async () => { + const { svc, save, stored } = await boot(); + + const st = await save({ + retention_overrides: { sys_job_run: { maxAge: '1y', expireAfter: '30d' }, sys_audit_log: { maxAge: '7y' } }, + quotas: { sys_audit_log: 1_000_000 }, + quota_defaults: { telemetry: 500_000, event: 100_000 }, + }); + + expect(st.status).toBe(200); + // `retention_overrides` is tenant-scoped: read back through the resolver. + expect((await svc.get('lifecycle', 'retention_overrides')).value).toEqual({ + sys_job_run: { maxAge: '1y', expireAfter: '30d' }, + sys_audit_log: { maxAge: '7y' }, + }); + expect(stored('quotas')?.value).toEqual({ sys_audit_log: 1_000_000 }); + expect(stored('quota_defaults')?.value).toEqual({ telemetry: 500_000, event: 100_000 }); + }); + + it('refuses a misspelt object: invalid_option, naming the entry and the keys it admits; nothing stored', async () => { + const { save, stored } = await boot(); + + const st = await save({ quotas: { sys_audit_logs: 1000 } }); + + expect(st.status).toBe(400); + const fields = st.body.error.details.fields as Fields; + expect(fields).toHaveLength(1); + expect(fields[0]).toMatchObject({ + field: 'quotas', + code: 'invalid_option', + constraint: { type: 'map', entry: 'sys_audit_logs', allowed: 'sys_audit_log, sys_job_run' }, + }); + expect(fields[0].message).toBe( + "Row quotas: 'sys_audit_logs' is not a key this setting admits. Allowed: sys_audit_log, sys_job_run.", + ); + expect(stored('quotas')).toBeUndefined(); + }); + + it('refuses an object the sweep never reads (a `record` object, an object with no lifecycle)', async () => { + const { save } = await boot(); + + const st = await save({ retention_overrides: { account: { maxAge: '1y' } }, quotas: { contact: 10 } }); + + const fields = st.body.error.details.fields as Fields; + expect(fields.map((f) => [f.field, f.code, (f.constraint as any).entry])).toEqual([ + ['retention_overrides', 'invalid_option', 'account'], + ['quotas', 'invalid_option', 'contact'], + ]); + }); + + it('refuses a lifecycle class that has no quota to default (`record`) and a misspelt one', async () => { + const { save } = await boot(); + + const recordClass = await save({ quota_defaults: { record: 10 } }); + const misspelt = await save({ quota_defaults: { telemtry: 10 } }); + + expect((recordClass.body.error.details.fields as Fields)[0]).toMatchObject({ code: 'invalid_option', constraint: { entry: 'record' } }); + expect((misspelt.body.error.details.fields as Fields)[0].message).toBe( + "Per-class quota defaults: 'telemtry' is not a key this setting admits. Allowed: audit, telemetry, transient, event.", + ); + }); + + it('refuses a duration that is not h/d/w/y: invalid_shape, naming the entry, the column and the form', async () => { + const { save, stored } = await boot(); + + const st = await save({ retention_overrides: { sys_job_run: { maxAge: '30 days' } } }); + + const fields = st.body.error.details.fields as Fields; + expect(fields[0]).toMatchObject({ + field: 'retention_overrides', + code: 'invalid_shape', + constraint: { type: 'map', entry: 'sys_job_run', column: 'maxAge', kind: 'duration' }, + }); + expect(fields[0].message).toBe( + "Retention overrides: 'sys_job_run'.maxAge must be a duration such as 7y " + + '(a whole number followed by h, d, w or y), not a string of 7 characters.', + ); + expect(stored('retention_overrides')).toBeUndefined(); + }); + + it('refuses a quota that is not a whole number above 0: "1e6" as text, 0, a fraction', async () => { + const { save } = await boot(); + + const text = await save({ quotas: { sys_audit_log: '1e6' } }); + const zero = await save({ quotas: { sys_audit_log: 0 } }); + const fraction = await save({ quota_defaults: { audit: 2.5 } }); + + expect((text.body.error.details.fields as Fields)[0].message).toBe( + "Row quotas: 'sys_audit_log' must be a whole number greater than 0, such as 1000000, not a string of 3 characters.", + ); + expect((zero.body.error.details.fields as Fields)[0]).toMatchObject({ code: 'invalid_shape', constraint: { kind: 'positive_integer' } }); + expect((fraction.body.error.details.fields as Fields)[0].code).toBe('invalid_shape'); + }); + + it('refuses a property no column declares (unknown_field), and an entry that names no column', async () => { + const { save } = await boot(); + + const typo = await save({ retention_overrides: { sys_job_run: { max_age: '1y' } } }); + const empty = await save({ retention_overrides: { sys_job_run: {} } }); + + expect((typo.body.error.details.fields as Fields)[0]).toMatchObject({ + code: 'unknown_field', + constraint: { entry: 'sys_job_run', column: 'max_age' }, + message: "Retention overrides: 'sys_job_run' has a property 'max_age' this setting does not declare. Allowed: maxAge, expireAfter.", + }); + expect((empty.body.error.details.fields as Fields)[0]).toMatchObject({ code: 'invalid_shape', constraint: { entry: 'sys_job_run' } }); + }); + + it('refuses JSON text of the map (the json-era console body): invalid_type', async () => { + const { save } = await boot(); + + const st = await save({ quotas: '{"sys_audit_log": 1000}' }); + + expect((st.body.error.details.fields as Fields)[0]).toMatchObject({ + code: 'invalid_type', + constraint: { type: 'map' }, + message: 'Row quotas must be an object of keyed rows, such as { "sys_audit_log": 1000000 }, ' + + 'not a string of 23 characters. Send the parsed value, not its JSON text.', + }); + }); + + it('accepts a clear (null): the key falls back to its default', async () => { + const { svc, save } = await boot(); + await save({ quotas: { sys_audit_log: 1000 } }); + + const st = await save({ quotas: null }); + + expect(st.status).toBe(200); + expect((await svc.get('lifecycle', 'quotas')).value).toEqual({}); + }); + + it('a malformed value already stored does not block a save that does not touch it', async () => { + const { svc, save, seed } = await boot(); + seed({ id: 'legacy', namespace: 'lifecycle', key: 'quotas', value: { sys_audit_logs: '1e6' } }); + + const st = await save({ growth_alert_rows: 10 }); + + expect(st.status).toBe(200); + expect((await svc.get('lifecycle', 'growth_alert_rows')).value).toBe(10); + }); +}); + +describe('[#30208] the env door judges a lifecycle map the same way', () => { + const envService = (env: Record) => { + const errors: string[] = []; + const svc = new SettingsService({ + env, + logger: { error: (m: string) => void errors.push(m), warn: () => {} } as any, + }); + registerKeySources(svc); + svc.registerManifest(lifecycleSettingsManifest); + return { svc, errors }; + }; + + it('CONTROL: JSON text of an admitted map is in force, as the object', async () => { + const { svc, errors } = envService({ + OS_LIFECYCLE_RETENTION_OVERRIDES: '{"sys_job_run": {"maxAge": "90d"}}', + OS_LIFECYCLE_QUOTA_DEFAULTS: '{"telemetry": 500000}', + }); + + const overrides = await svc.get('lifecycle', 'retention_overrides'); + const defaults = await svc.get('lifecycle', 'quota_defaults'); + + expect(overrides.value).toEqual({ sys_job_run: { maxAge: '90d' } }); + expect(overrides.source).toBe('env'); + expect(defaults.value).toEqual({ telemetry: 500000 }); + expect(errors).toEqual([]); + }); + + it('a misspelt object is not in force: one error line naming the variable, the entry and the keys', async () => { + const { svc, errors } = envService({ OS_LIFECYCLE_QUOTAS: '{"sys_audit_logs": 1000}' }); + + const got = await svc.get('lifecycle', 'quotas'); + await svc.get('lifecycle', 'quotas'); + + expect(got.value).toEqual({}); + expect(got.source).not.toBe('env'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('OS_LIFECYCLE_QUOTAS'); + expect(errors[0]).toContain("is not the map of keyed rows its type 'map' declares"); + expect(errors[0]).toContain("'sys_audit_logs' is not a key this setting admits. Allowed: sys_audit_log, sys_job_run."); + expect(errors[0]).toContain('The rejected value is an object of 1 entry; it is not printed.'); + }); + + it('a bad duration is not in force, judged by the save door\'s sentence', async () => { + const { svc, errors } = envService({ OS_LIFECYCLE_RETENTION_OVERRIDES: '{"sys_job_run": {"maxAge": "30 days"}}' }); + + const got = await svc.get('lifecycle', 'retention_overrides'); + + expect(got.source).not.toBe('env'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("'sys_job_run'.maxAge must be a duration such as 7y"); + }); + + it('text that does not parse is not in force', async () => { + const { svc, errors } = envService({ OS_LIFECYCLE_QUOTAS: '{"sys_audit_log": 1000' }); + + const got = await svc.get('lifecycle', 'quotas'); + + expect(got.source).not.toBe('env'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('must be an object of keyed rows'); + }); +}); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 3bab7f477ea..ee731e6b64e 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -12,6 +12,13 @@ import type { SettingsChangeHandler, SettingsUnsubscribe, } from '@objectstack/spec/system'; +import { + type DeclaredMapShape, + type MapShapeMiss, + declaredMapShape, + firstMapShapeMiss, + mapMissSentence, +} from './map-shape.js'; import { type CryptoAdapter, NoopCryptoAdapter, @@ -812,6 +819,44 @@ function jsonShapeMiss(type: string, value: unknown): JsonShapeMiss | null { return { shape: `a ${typeof value}` }; } +/** The `FieldError` code each kind of map miss is answered with (#30208; ADR-0114 catalog). */ +const MAP_MISS_CODES: Record = { + not_a_map: 'invalid_type', + key: 'invalid_option', + unknown_column: 'unknown_field', + not_an_entry: 'invalid_shape', + cell: 'invalid_shape', +}; + +/** + * The map miss as discrete values (`FieldError.constraint`, ADR-0114), so a + * client can mark the entry and the column without parsing the sentence. + */ +function mapMissConstraint(miss: MapShapeMiss, allowedKeys: string[]): Record { + switch (miss.kind) { + case 'not_a_map': + return { type: 'map' }; + case 'key': + return { type: 'map', entry: miss.entry, ...(allowedKeys.length > 0 ? { allowed: allowedKeys.join(', ') } : {}) }; + case 'unknown_column': + return { type: 'map', entry: miss.entry, column: miss.column }; + case 'not_an_entry': + return { type: 'map', entry: miss.entry }; + case 'cell': + return { type: 'map', entry: miss.entry, column: miss.column.name, kind: miss.column.kind }; + } +} + +/** A rejected override's map value, described for the log line without its content. */ +function describedMapValue(value: unknown): string { + if (typeof value === 'string') return `a string of ${value.length} character${value.length === 1 ? '' : 's'}`; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const n = Object.keys(value).length; + return `an object of ${n} entr${n === 1 ? 'y' : 'ies'}`; + } + return Array.isArray(value) ? 'an array' : `a ${typeof value}`; +} + /** The declared `type` of one specifier key, or `''` when the manifest declares no such key. */ function declaredTypeOf(manifest: SettingsManifest, key: string): string { const spec = (manifest.specifiers ?? []).find((s) => s.key === key); @@ -875,6 +920,14 @@ interface RegisteredManifest { * impossible pattern", which would reject everything. */ patterns: Map; + /** + * Every `map` specifier's declared shape (#30208), keyed by specifier key: + * its value columns, and the declared key table (`options`, string form) + * that stands for the key column's domain when no option source answers. + * An ABSENT key is not a map, or a map whose manifest declares no usable + * column: nothing to judge, never "reject everything". + */ + maps: Map; } /** @@ -1297,6 +1350,7 @@ export class SettingsService { const bounds = new Map(); const valueDomains = new Map(); const patterns = new Map(); + const maps = new Map(); const defaultScope = manifest.scope ?? 'tenant'; for (const spec of manifest.specifiers) { if (!spec.key || LAYOUT_ONLY_TYPES.has(spec.type)) continue; @@ -1323,6 +1377,16 @@ export class SettingsService { // behaviour at both call sites, never "reject everything". const pattern = declaredPattern(spec.pattern); if (pattern) patterns.set(spec.key, pattern); + // A `map`'s columns and declared key table (#30208), judged at both + // doors by `mapShapeVerdict`. Not an option table: its `options` name the + // KEYS a map admits, never the value, so the option family must not see it. + const mapShape = declaredMapShape(spec); + if (mapShape) { + maps.set(spec.key, { + shape: mapShape, + declaredKeys: declaredOptionValues((spec as { options?: unknown }).options), + }); + } if (OPTION_BEARING_TYPES.has(spec.type)) { // A manifest with no option table cannot say what is legal. The spec // refuses that shape at parse time, but `registerManifest` takes @@ -1345,6 +1409,7 @@ export class SettingsService { bounds, valueDomains, patterns, + maps, }); this.auditEnvOverrides(manifest.namespace); } @@ -1385,6 +1450,9 @@ export class SettingsService { ...reg.patterns.keys(), ...[...reg.scopes.keys()].filter((key) => DECLARED_VALUE_FORMATS.has(declaredTypeOf(reg.manifest, key))), + // A `map` (#30208): its key column's domain is a source when one is + // registered, judged when read for the reason sourced option keys are. + ...[...reg.maps.keys()].filter((key) => !this.optionSources.get(namespace)?.has(key)), ]); for (const key of enforceable) { this.effectiveEnvOverride(reg, namespace, key); @@ -1454,7 +1522,9 @@ export class SettingsService { // is on file: the same comparison in two places is how the env half came to // disagree with the save half in the first place. const domain = reg.valueDomains.get(key); - if (!domain) { + // A `map`'s key table names its KEYS (#30208): it is judged entry by entry + // in the map family below, never against the whole value here. + if (!domain && !reg.maps.has(key)) { // A key with no declared table has nothing to enforce — unchanged // behaviour (#5131's exhaustive-options semantics, untouched when no // domain is declared). @@ -1547,6 +1617,23 @@ export class SettingsService { return null; } + // A `map` (#30208): keyed rows of declared columns, the key admitted by the + // key column's domain. Same judgement as the save door + // ({@link mapShapeVerdict}). The line names the entry and the form + // expected, never the value. A key-domain miss while the option sources' + // verdicts are held is taken in force and judged again at the seal, as a + // sourced option is. + const mapVerdict = this.mapShapeVerdict(reg, namespace, key, value); + if (mapVerdict && !(mapVerdict.miss.kind === 'key' && this.holdsOptionSourceVerdict(namespace, key))) { + this.reportRejectedEnvOverride(reg, namespace, key, envName, value, { + what: "is not the map of keyed rows its type 'map' declares for", + detail: mapMissSentence(envName, mapVerdict.shape, mapVerdict.miss, mapVerdict.allowedKeys), + fix: 'JSON text of an object of keyed rows the setting admits', + shape: describedMapValue(value), + }); + return null; + } + // A `json` key persists a JSON object or array (ADR-0007; #31241). The // override's text was parsed against the key's object default // (`coerceEnvValue`); text that did not parse, or parsed to a scalar, is @@ -1875,6 +1962,30 @@ export class SettingsService { }; } + /** + * The map verdict for one key (#30208): the first way `value` is not the map + * its `map` specifier declares, with what the sentence needs, or `null` when + * it is the declared map, the key is not a map, or the value is a clear. + * + * ONE judgement for both doors ({@link firstMapShapeMiss}); the key column's + * domain is the key's option table as {@link optionTableOf} serves it, a + * registered source's answer else the declared `options`, so a source moves + * both doors at once. An empty table admits every key. + */ + private mapShapeVerdict( + reg: RegisteredManifest, + namespace: string, + key: string, + value: unknown, + ): { shape: DeclaredMapShape; miss: MapShapeMiss; allowedKeys: string[] } | null { + const map = reg.maps.get(key); + if (!map) return null; + const { allowed, judge } = this.optionTableOf(namespace, key, map.declaredKeys); + const admitsKey = allowed.length > 0 ? (entry: string) => judge(entry) === null : undefined; + const miss = firstMapShapeMiss(map.shape, value, admitsKey); + return miss ? { shape: map.shape, miss, allowedKeys: allowed } : null; + } + /** * The manifest a reader is served: the registered one, with each sourced * key's `options` replaced by its source's answer. The one resolution every @@ -3492,6 +3603,28 @@ export class SettingsService { } } + // A `map` (#30208): an object of keyed rows, each key admitted by the key + // column's domain and each cell of its column's kind. Same judgement as + // the env door ({@link mapShapeVerdict}). Not gated on `empty`: a blank + // string is not a map; a clear (`null`) passes inside the helper. The + // code names what failed: `invalid_type` for a value that is not a map at + // all, `invalid_option` for a key the domain does not admit, + // `unknown_field` for a property no column declares, `invalid_shape` for + // an entry or a cell that is not its column's form. The sentence names + // the entry and the form expected. + const mapVerdict = this.mapShapeVerdict(reg, namespace, key, value); + if (mapVerdict) { + const { shape, miss, allowedKeys } = mapVerdict; + errors.push({ + field: key, + code: MAP_MISS_CODES[miss.kind], + message: mapMissSentence(label, shape, miss, allowedKeys), + label, + constraint: mapMissConstraint(miss, allowedKeys), + }); + continue; + } + // A `json` key persists a JSON object or array (ADR-0007; #31241), so a // string — the JSON *text* the console's field used to send — or any // other scalar is refused here rather than stored for every reader to From ae153a528b81694e6628bf7898f8102c71e77e75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 02:29:28 +0000 Subject: [PATCH 3/4] feat(objectql): lifecycle maps use the map specifier retention_overrides, quotas and quota_defaults become map specifiers with their stored shapes unchanged. The plugin registers key sources before the manifest: lifecycle-declared objects, and lifecycle classes other than record. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fBJ6xsLWJsnNJGMMx --- packages/objectql/src/index.ts | 10 ++ .../lifecycle/lifecycle-key-options.test.ts | 133 ++++++++++++++++++ .../src/lifecycle/lifecycle-key-options.ts | 82 +++++++++++ .../src/lifecycle/lifecycle-settings.ts | 30 +++- packages/objectql/src/plugin.ts | 19 ++- 5 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 packages/objectql/src/lifecycle/lifecycle-key-options.test.ts create mode 100644 packages/objectql/src/lifecycle/lifecycle-key-options.ts diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index caa3dbaf823..7c21af63d1c 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -638,6 +638,16 @@ export type { RecycleBinRemovalOptions } from './recycle-bin/purge.js'; // hook's api, and the runtime's sandbox to every body's. export { refuseAppAuthoredPermanentDelete } from './recycle-bin/app-authored-delete.js'; export { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; +// [#30208] The key columns of the three lifecycle map settings, served from the +// running system; `ObjectQLPlugin` registers them with the settings service. +export { + lifecycleObjectKeySource, + lifecycleClassKeySource, + type LifecycleKeySource, + type LifecycleKeyAnswer, + type LifecycleKeyOption, + type LifecycleKeyRegistryLike, +} from './lifecycle/lifecycle-key-options.js'; // [#4551] Read-only referential-integrity audit — the reporting half of the // `isSystem` exemption #4441 deliberately left in the write-path guard. diff --git a/packages/objectql/src/lifecycle/lifecycle-key-options.test.ts b/packages/objectql/src/lifecycle/lifecycle-key-options.test.ts new file mode 100644 index 00000000000..d7f54e97b66 --- /dev/null +++ b/packages/objectql/src/lifecycle/lifecycle-key-options.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30208 row 5, ruling A] The key columns of the three lifecycle `map` + * settings are served from the running system, never listed in the manifest: + * + * - `retention_overrides` and `quotas` admit the objects the sweep walks + * (a lifecycle class other than `record`), read at the moment of asking; + * - `quota_defaults` admits the lifecycle classes the sweep reads a default + * for (`LifecycleClassSchema` without `record`); + * - `ObjectQLPlugin` registers the three sources with the settings service + * BEFORE the manifest, booted through a real `ObjectKernel`. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '../plugin.js'; +import type { ObjectQL } from '../engine.js'; +import { + lifecycleClassKeySource, + lifecycleObjectKeySource, + type LifecycleKeyRegistryLike, + type LifecycleKeySource, +} from './lifecycle-key-options.js'; + +describe('lifecycleObjectKeySource', () => { + const objects: ReturnType = [ + { name: 'sys_job_run', label: 'Job run', lifecycle: { class: 'telemetry' } }, + { name: 'sys_audit_log', lifecycle: { class: 'audit' } }, + { name: 'account', label: 'Account', lifecycle: { class: 'record' } }, + { name: 'contact', label: 'Contact' }, + ]; + + it('admits exactly the objects the sweep walks, sorted, named by their label', () => { + const answer = lifecycleObjectKeySource(() => ({ getAllObjects: () => objects }))()!; + expect(answer.options).toEqual([ + { value: 'sys_audit_log', label: 'sys_audit_log' }, + { value: 'sys_job_run', label: 'Job run' }, + ]); + expect(answer.admits('sys_job_run')).toBe(true); + expect(answer.admits('account')).toBe(false); + expect(answer.admits('contact')).toBe(false); + expect(answer.admits('sys_job_runs')).toBe(false); + }); + + it('reads the storage objects when the registry has them, as the sweep does', () => { + const answer = lifecycleObjectKeySource(() => ({ + getAllObjects: () => [], + getAllStorageObjects: () => [{ name: 'sys_session', lifecycle: { class: 'transient' } }], + }))()!; + expect(answer.options.map((o) => o.value)).toEqual(['sys_session']); + }); + + it('is asked at the moment of use: an object registered later is admitted from then on', () => { + const live = [...objects]; + const source = lifecycleObjectKeySource(() => ({ getAllObjects: () => live })); + expect(source()!.admits('sys_webhook_delivery')).toBe(false); + live.push({ name: 'sys_webhook_delivery', lifecycle: { class: 'event' } }); + expect(source()!.admits('sys_webhook_delivery')).toBe(true); + }); + + it('answers nothing while no registry is reachable (the declared table, none, then stands)', () => { + expect(lifecycleObjectKeySource(() => undefined)()).toBeUndefined(); + }); +}); + +describe('lifecycleClassKeySource', () => { + it('admits the lifecycle classes a quota default is read for — every class but `record`', () => { + const answer = lifecycleClassKeySource()()!; + expect(answer.options.map((o) => o.value)).toEqual(['audit', 'telemetry', 'transient', 'event']); + expect(answer.admits('record')).toBe(false); + expect(answer.admits('telemtry')).toBe(false); + }); +}); + +describe('ObjectQLPlugin registers the three key sources before the lifecycle manifest', () => { + const kernels: ObjectKernel[] = []; + afterEach(async () => { + while (kernels.length) { + const kernel = kernels.pop()!; + try { if (kernel.getState() === 'running') await kernel.shutdown(); } catch { /* noop */ } + } + }); + + it('at kernel:ready, against the engine registry', async () => { + const calls: string[] = []; + const sources = new Map(); + const settings = { + registerOptionSource(namespace: string, key: string, source: LifecycleKeySource) { + calls.push(`source ${namespace}.${key}`); + sources.set(key, source); + }, + registerManifest(manifest: { namespace: string }) { calls.push(`manifest ${manifest.namespace}`); }, + }; + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin()); + await kernel.use({ + name: 'test.settings-and-objects', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + ctx.registerService('settings', settings); + const ql = ctx.getService('objectql'); + ql.registerDriver({ + name: 'primary', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async find() { return []; }, async findOne() { return null; }, async count() { return 0; }, + } as any, true); + await ctx.getService<{ register(a: unknown): Promise | void }>('manifest').register({ + id: 'com.test.lifecycle-keys', + name: 'com.test.lifecycle-keys', + version: '1.0.0', + objects: [{ + name: 'test_ping', + fields: { note: { name: 'note', type: 'text' } }, + lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } }, + }], + }); + }, + } as any); + await kernel.bootstrap(); + + expect(calls).toEqual([ + 'source lifecycle.retention_overrides', + 'source lifecycle.quotas', + 'source lifecycle.quota_defaults', + 'manifest lifecycle', + ]); + expect(sources.get('quotas')!()!.admits('test_ping')).toBe(true); + expect(sources.get('retention_overrides')!()!.admits('test_ping')).toBe(true); + expect(sources.get('quota_defaults')!()!.admits('telemetry')).toBe(true); + }); +}); diff --git a/packages/objectql/src/lifecycle/lifecycle-key-options.ts b/packages/objectql/src/lifecycle/lifecycle-key-options.ts new file mode 100644 index 00000000000..201ba17efc0 --- /dev/null +++ b/packages/objectql/src/lifecycle/lifecycle-key-options.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { LifecycleClassSchema } from '@objectstack/spec/data'; + +/** + * The key columns of the three `lifecycle` map settings (#30208), served from + * the running system: the keys a `retention_overrides`, `quotas` or + * `quota_defaults` row may name are what this deployment has, never a list + * written in the manifest (ruling 6715997428, constraint 3). + * + * Each source has the shape of `@objectstack/service-settings`' + * `SettingsOptionSource`, spelled structurally here so this package takes no + * dependency on the settings service. The settings service registers them per + * map key; both of its doors (a save, an `OS_LIFECYCLE_*` override) judge each + * entry's key against the answer, and every manifest read serves the answer as + * the map's `options`, the key picker's list. + */ + +/** One option a source serves: the stored key and its name. */ +export interface LifecycleKeyOption { + value: string; + label: string; +} + +/** What a key source answers — `SettingsOptionSourceAnswer`'s shape. */ +export interface LifecycleKeyAnswer { + options: LifecycleKeyOption[]; + admits(value: string): boolean; +} + +/** A key source — `SettingsOptionSource`'s shape. */ +export type LifecycleKeySource = (input?: { current?: unknown; locale?: string }) => LifecycleKeyAnswer | undefined; + +/** The registry surface the object source reads: the objects the sweep reads. */ +export interface LifecycleKeyRegistryLike { + getAllObjects(): Array<{ name: string; label?: unknown; lifecycle?: { class?: string } | null }>; + getAllStorageObjects?(): Array<{ name: string; label?: unknown; lifecycle?: { class?: string } | null }>; +} + +function answerOf(options: LifecycleKeyOption[]): LifecycleKeyAnswer { + const values = new Set(options.map((o) => o.value)); + return { options, admits: (value) => values.has(value) }; +} + +/** + * The objects a lifecycle map row may name: every object that declares a + * lifecycle class other than `record`, which is exactly the set the sweep walks + * (`LifecycleService.sweep`). An object outside it is never read under its + * override or its quota, so naming it would be stored and silently do nothing. + * + * Asked at the moment it is used (each save, each override read, each page + * read), so an object a package registers later is admitted from then on. + * `undefined` while the registry is not reachable: the settings service then + * falls back to the manifest's declared table, which declares none, so every + * key is admitted rather than every save refused. + */ +export function lifecycleObjectKeySource(getRegistry: () => LifecycleKeyRegistryLike | undefined): LifecycleKeySource { + return () => { + const registry = getRegistry(); + if (!registry) return undefined; + const objects = typeof registry.getAllStorageObjects === 'function' + ? registry.getAllStorageObjects() + : registry.getAllObjects(); + const options = objects + .filter((o) => o?.lifecycle && o.lifecycle.class && o.lifecycle.class !== 'record') + .map((o) => ({ value: o.name, label: typeof o.label === 'string' && o.label !== '' ? o.label : o.name })) + .sort((a, b) => a.value.localeCompare(b.value)); + return answerOf(options); + }; +} + +/** + * The lifecycle classes a `quota_defaults` row may name: `LifecycleClassSchema` + * without `record`. A `record` object is never swept (its data is business + * truth, immortal by contract), so a `record` default is never read. + */ +export function lifecycleClassKeySource(): LifecycleKeySource { + const options = LifecycleClassSchema.options + .filter((c) => c !== 'record') + .map((c) => ({ value: c, label: c })); + return () => answerOf(options); +} diff --git a/packages/objectql/src/lifecycle/lifecycle-settings.ts b/packages/objectql/src/lifecycle/lifecycle-settings.ts index bf958b2d1ac..3d401148ef2 100644 --- a/packages/objectql/src/lifecycle/lifecycle-settings.ts +++ b/packages/objectql/src/lifecycle/lifecycle-settings.ts @@ -10,6 +10,12 @@ import type { SettingsManifest } from '@objectstack/spec/system'; * global > tenant > default), which is exactly ADR-0057 §3.2's "regulated * tenants set years; dev sets days — the same knob at different settings": * `retention_overrides` is tenant-scoped, so one deployment can carry both. + * + * The three maps are `map` specifiers (#30208, ADR-0007 as amended 2026-11-08): + * a key column, then typed value columns. Their stored shapes are the ones the + * sweep has always read (`Record`, + * `Record`, `Record`), unchanged. The keys each + * admits are served at run time (`lifecycle-key-options.ts`), never listed here. */ export const lifecycleSettingsManifest = { namespace: 'lifecycle', @@ -36,34 +42,44 @@ export const lifecycleSettingsManifest = { 'Master switch for the periodic sweep. Off = declared retention/ttl/rotation policies stop being enforced (data grows unbounded again).', }, { - type: 'json', + type: 'map', key: 'retention_overrides', label: 'Retention overrides', required: false, scope: 'tenant', default: {}, + keyColumn: { label: 'Object' }, + columns: [ + { name: 'maxAge', label: 'Keep rows for', kind: 'duration' }, + { name: 'expireAfter', label: 'Expire rows after', kind: 'duration' }, + ], description: - 'Per-object window overrides: { "": { "maxAge": "1y", "expireAfter": "30d" } }. ' + - 'Durations use h/d/w/y. Set per organization, so a regulated organization can keep years while a development one keeps days. ' + + 'Per-object windows that replace the declared ones. Durations use h, d, w or y, for example 30d or 7y. ' + + 'Set per organization, so a regulated organization can keep years while a development one keeps days. ' + 'An override shorter than the minimum a platform feature needs (for example the job queue\'s duplicate-detection window) ' + 'is ignored and logged as an error, and the default window keeps applying.', }, { - type: 'json', + type: 'map', key: 'quotas', label: 'Row quotas', required: false, default: {}, + keyColumn: { label: 'Object' }, + columns: [{ name: 'max_rows', label: 'Max rows', kind: 'positive_integer' }], description: - 'Per-object max row counts: { "": 1000000 }. A breach raises a governance alert — quotas never delete beyond the declared policy.', + 'Per-object maximum row counts. A breach raises a governance alert — quotas never delete beyond the declared policy.', }, { - type: 'json', + type: 'map', key: 'quota_defaults', label: 'Per-class quota defaults', required: false, default: {}, - description: 'Fallback quotas by lifecycle class: { "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }.', + keyColumn: { label: 'Lifecycle class' }, + columns: [{ name: 'max_rows', label: 'Max rows', kind: 'positive_integer' }], + description: + 'Fallback row quotas by lifecycle class (telemetry, transient, event, audit), for objects with no quota of their own.', }, { type: 'number', diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 96f57405ecd..9699f67f9a4 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -38,6 +38,11 @@ import { announceStoredExpressionHoles } from './stored-expression-backfill.js'; import { LifecycleService } from './lifecycle/lifecycle-service.js'; import { readRecycleBinRetentionEnv, type RecycleBinRetentionSettings } from './recycle-bin/retention.js'; import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; +import { + lifecycleClassKeySource, + lifecycleObjectKeySource, + type LifecycleKeySource, +} from './lifecycle/lifecycle-key-options.js'; import type { DanglingReferenceAuditOptions } from './integrity/dangling-reference-audit.js'; import { GLOBAL_ACTION_OBJECT_KEY, @@ -1211,7 +1216,19 @@ export class ObjectQLPlugin implements Plugin { // ADR-0057 P4: surface the lifecycle governance namespace in Settings // (overrides / quotas / growth alerts) when a SettingsService exists. try { - const settings = ctx.getService('settings') as { registerManifest?: (m: unknown) => void }; + const settings = ctx.getService('settings') as { + registerManifest?: (m: unknown) => void; + registerOptionSource?: (namespace: string, key: string, source: LifecycleKeySource) => void; + }; + // [#30208] The keys each lifecycle map admits are what this + // deployment has: its lifecycle-declared objects, the lifecycle + // classes. Registered BEFORE the manifest, so its registration-time + // audit of `OS_LIFECYCLE_*` overrides leaves these keys to be judged + // when read, against the registry as it then stands. + const objectKeys = lifecycleObjectKeySource(() => this.ql?.registry); + settings?.registerOptionSource?.('lifecycle', 'retention_overrides', objectKeys); + settings?.registerOptionSource?.('lifecycle', 'quotas', objectKeys); + settings?.registerOptionSource?.('lifecycle', 'quota_defaults', lifecycleClassKeySource()); settings?.registerManifest?.(lifecycleSettingsManifest); } catch { // No settings service — governance stays at declared defaults. From 971ec20d06b41104d4b7b55c5ac3daba67e73442 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 02:29:28 +0000 Subject: [PATCH 4/4] docs(settings): drop JSON examples from the lifecycle map help text Ten translation packs and the English manifest copy no longer teach the three keys as hand-written JSON. config-resolution.mdx states how an env override for a map key is judged. Changeset added. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fBJ6xsLWJsnNJGMMx --- .changeset/30208-settings-map-specifier.md | 26 +++++++++++++++++++ .../protocol/kernel/config-resolution.mdx | 12 +++++++++ .../lifecycle/lifecycle-key-options.test.ts | 2 +- .../src/settings-map-shape-30208.test.ts | 4 +++ .../service-settings/src/translations/ar.ts | 6 ++--- .../service-settings/src/translations/de.ts | 6 ++--- .../src/translations/es-ES.ts | 6 ++--- .../service-settings/src/translations/fr.ts | 6 ++--- .../src/translations/ja-JP.ts | 6 ++--- .../service-settings/src/translations/ko.ts | 6 ++--- .../src/translations/pt-BR.ts | 6 ++--- .../service-settings/src/translations/ru.ts | 6 ++--- .../src/translations/zh-CN.ts | 6 ++--- .../src/translations/zh-Hant.ts | 6 ++--- 14 files changed, 73 insertions(+), 31 deletions(-) create mode 100644 .changeset/30208-settings-map-specifier.md diff --git a/.changeset/30208-settings-map-specifier.md b/.changeset/30208-settings-map-specifier.md new file mode 100644 index 00000000000..444c31fb293 --- /dev/null +++ b/.changeset/30208-settings-map-specifier.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-settings': patch +'@objectstack/objectql': patch +--- + +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 + +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`) + + + +**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`; several store `Record`. 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. + +**`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`. + +**BREAKING** accept-set narrowing at the settings doors (`@objectstack/service-settings`), for those three keys only. + +- **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. +- **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. +- **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. + +**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}'`. + +**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. diff --git a/content/docs/protocol/kernel/config-resolution.mdx b/content/docs/protocol/kernel/config-resolution.mdx index 440b772d56f..2d2ac9e1618 100644 --- a/content/docs/protocol/kernel/config-resolution.mdx +++ b/content/docs/protocol/kernel/config-resolution.mdx @@ -267,6 +267,18 @@ The exception is a `json` specifier, which persists a JSON object or array cascade. The save door refuses the same values for a `json` key with an `invalid_type` field error, so a client sends the parsed value, never its text. +A `map` specifier (keyed rows, such as `lifecycle.quotas`) is judged further, +at both doors alike: its JSON text must parse to an object whose every key the +setting admits (the running system serves the admissible keys, for example the +objects that declare a lifecycle) and whose every value is of its column's kind, +such as a duration like `7y` or a whole number above 0. An override that is not +is not in force, with one `error` line naming the entry and the form expected: + +```bash +OS_LIFECYCLE_QUOTAS='{"sys_audit_log": 1000000}' +OS_LIFECYCLE_RETENTION_OVERRIDES='{"sys_job_run": {"maxAge": "90d"}}' +``` + **What is stored beneath a pin is listed by the namespace read.** An override in force answers `source: 'env'`, `locked: true`, and `get()` / `getMany()` build that answer from the env alone, without reading the store. `getNamespace()` — the diff --git a/packages/objectql/src/lifecycle/lifecycle-key-options.test.ts b/packages/objectql/src/lifecycle/lifecycle-key-options.test.ts index d7f54e97b66..6387de0e224 100644 --- a/packages/objectql/src/lifecycle/lifecycle-key-options.test.ts +++ b/packages/objectql/src/lifecycle/lifecycle-key-options.test.ts @@ -117,7 +117,7 @@ describe('ObjectQLPlugin registers the three key sources before the lifecycle ma }], }); }, - } as any); + }); await kernel.bootstrap(); expect(calls).toEqual([ diff --git a/packages/services/service-settings/src/settings-map-shape-30208.test.ts b/packages/services/service-settings/src/settings-map-shape-30208.test.ts index dca7ffbdc46..f09c997d8d2 100644 --- a/packages/services/service-settings/src/settings-map-shape-30208.test.ts +++ b/packages/services/service-settings/src/settings-map-shape-30208.test.ts @@ -127,6 +127,7 @@ describe('[#30208] the save door judges a lifecycle map', () => { const recordClass = await save({ quota_defaults: { record: 10 } }); const misspelt = await save({ quota_defaults: { telemtry: 10 } }); + // adr0112-ok: D6 — a field-addressed FieldErrorCode in a settings validation answer expect((recordClass.body.error.details.fields as Fields)[0]).toMatchObject({ code: 'invalid_option', constraint: { entry: 'record' } }); expect((misspelt.body.error.details.fields as Fields)[0].message).toBe( "Per-class quota defaults: 'telemtry' is not a key this setting admits. Allowed: audit, telemetry, transient, event.", @@ -161,6 +162,7 @@ describe('[#30208] the save door judges a lifecycle map', () => { expect((text.body.error.details.fields as Fields)[0].message).toBe( "Row quotas: 'sys_audit_log' must be a whole number greater than 0, such as 1000000, not a string of 3 characters.", ); + // adr0112-ok: D6 — a field-addressed FieldErrorCode in a settings validation answer expect((zero.body.error.details.fields as Fields)[0]).toMatchObject({ code: 'invalid_shape', constraint: { kind: 'positive_integer' } }); expect((fraction.body.error.details.fields as Fields)[0].code).toBe('invalid_shape'); }); @@ -172,10 +174,12 @@ describe('[#30208] the save door judges a lifecycle map', () => { const empty = await save({ retention_overrides: { sys_job_run: {} } }); expect((typo.body.error.details.fields as Fields)[0]).toMatchObject({ + // adr0112-ok: D6 — a field-addressed FieldErrorCode in a settings validation answer code: 'unknown_field', constraint: { entry: 'sys_job_run', column: 'max_age' }, message: "Retention overrides: 'sys_job_run' has a property 'max_age' this setting does not declare. Allowed: maxAge, expireAfter.", }); + // adr0112-ok: D6 — a field-addressed FieldErrorCode in a settings validation answer expect((empty.body.error.details.fields as Fields)[0]).toMatchObject({ code: 'invalid_shape', constraint: { entry: 'sys_job_run' } }); }); diff --git a/packages/services/service-settings/src/translations/ar.ts b/packages/services/service-settings/src/translations/ar.ts index cb94fb764f1..4fb63d286a6 100644 --- a/packages/services/service-settings/src/translations/ar.ts +++ b/packages/services/service-settings/src/translations/ar.ts @@ -699,15 +699,15 @@ export const ar: PlatformTranslationData = { }, retention_overrides: { label: 'تجاوزات الاحتفاظ', - help: 'تجاوزات النافذة لكل كائن: { "": { "maxAge": "1y", "expireAfter": "30d" } }. تستخدم المدد h/d/w/y. تُضبط لكل مؤسسة، فتحتفظ المؤسسة الخاضعة للتنظيم بسنوات بينما تحتفظ مؤسسة التطوير بأيام. يُتجاهَل التجاوز الأقصر من الحد الأدنى الذي تحتاجه ميزة في المنصة (مثل نافذة كشف التكرار في قائمة انتظار المهام) ويُسجَّل خطأً، وتظل النافذة الافتراضية سارية.', + help: 'تجاوزات النافذة لكل كائن. تستخدم المدد h/d/w/y. تُضبط لكل مؤسسة، فتحتفظ المؤسسة الخاضعة للتنظيم بسنوات بينما تحتفظ مؤسسة التطوير بأيام. يُتجاهَل التجاوز الأقصر من الحد الأدنى الذي تحتاجه ميزة في المنصة (مثل نافذة كشف التكرار في قائمة انتظار المهام) ويُسجَّل خطأً، وتظل النافذة الافتراضية سارية.', }, quotas: { label: 'حصص الصفوف', - help: 'الحد الأقصى لعدد الصفوف لكل كائن: { "": 1000000 }. يؤدي التجاوز إلى تنبيه حوكمة — ولا تحذف الحصص أبدًا ما يتجاوز السياسة المعلنة.', + help: 'الحد الأقصى لعدد الصفوف لكل كائن. يؤدي التجاوز إلى تنبيه حوكمة — ولا تحذف الحصص أبدًا ما يتجاوز السياسة المعلنة.', }, quota_defaults: { label: 'الحصص الافتراضية حسب الفئة', - help: 'حصص احتياطية حسب فئة دورة الحياة: { "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }.', + help: 'حصص احتياطية حسب فئة دورة الحياة (telemetry, transient, event, audit).', }, growth_alert_rows: { label: 'حد تنبيه النمو (صفوف لكل جولة)', diff --git a/packages/services/service-settings/src/translations/de.ts b/packages/services/service-settings/src/translations/de.ts index 0c66a8c918a..093bbf7ed9f 100644 --- a/packages/services/service-settings/src/translations/de.ts +++ b/packages/services/service-settings/src/translations/de.ts @@ -744,15 +744,15 @@ export const de: PlatformTranslationData = { }, retention_overrides: { label: 'Aufbewahrungs-Überschreibungen', - help: 'Fensterüberschreibungen pro Objekt: { "": { "maxAge": "1y", "expireAfter": "30d" } }. Dauern verwenden h/d/w/y. Pro Organisation gesetzt, sodass eine regulierte Organisation Jahre aufbewahren kann, eine Entwicklungsorganisation nur Tage. Eine Überschreibung, die kürzer ist als das Minimum, das eine Plattformfunktion braucht (etwa das Duplikaterkennungsfenster der Job-Warteschlange), wird ignoriert und als Fehler protokolliert; das Standardfenster gilt weiter.', + help: 'Fensterüberschreibungen pro Objekt. Dauern verwenden h/d/w/y. Pro Organisation gesetzt, sodass eine regulierte Organisation Jahre aufbewahren kann, eine Entwicklungsorganisation nur Tage. Eine Überschreibung, die kürzer ist als das Minimum, das eine Plattformfunktion braucht (etwa das Duplikaterkennungsfenster der Job-Warteschlange), wird ignoriert und als Fehler protokolliert; das Standardfenster gilt weiter.', }, quotas: { label: 'Zeilenkontingente', - help: 'Maximale Zeilenanzahl pro Objekt: { "": 1000000 }. Eine Überschreitung löst eine Governance-Warnung aus — Kontingente löschen nie über die deklarierte Richtlinie hinaus.', + help: 'Maximale Zeilenanzahl pro Objekt. Eine Überschreitung löst eine Governance-Warnung aus — Kontingente löschen nie über die deklarierte Richtlinie hinaus.', }, quota_defaults: { label: 'Standardkontingente pro Klasse', - help: 'Ersatzkontingente nach Lebenszyklusklasse: { "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }.', + help: 'Ersatzkontingente nach Lebenszyklusklasse (telemetry, transient, event, audit).', }, growth_alert_rows: { label: 'Wachstums-Warnschwelle (Zeilen pro Durchlauf)', diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index fa494ff9f82..f5e6ec23b2d 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -593,18 +593,18 @@ export const esES: PlatformTranslationData = { }, retention_overrides: { label: 'Sustituciones de retención', - help: 'Sustituciones de la ventana por objeto: { "": { "maxAge": "1y", "expireAfter": "30d" } }. Las duraciones usan h/d/w/y. ' + help: 'Sustituciones de la ventana por objeto. Las duraciones usan h/d/w/y. ' + 'Se define por organización, de modo que una organización regulada puede conservar años y una de desarrollo, días. ' + 'Una sustitución más corta que el mínimo que necesita una función de la plataforma (por ejemplo, la ventana de detección de duplicados de la cola de trabajos) ' + 'se ignora y se registra como error, y se sigue aplicando la ventana predeterminada.', }, quotas: { label: 'Cuotas de filas', - help: 'Número máximo de filas por objeto: { "": 1000000 }. Superarlo genera una alerta de gobernanza: las cuotas nunca eliminan más allá de la política declarada.', + help: 'Número máximo de filas por objeto. Superarlo genera una alerta de gobernanza: las cuotas nunca eliminan más allá de la política declarada.', }, quota_defaults: { label: 'Cuotas predeterminadas por clase', - help: 'Cuotas de respaldo por clase de ciclo de vida: { "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }.', + help: 'Cuotas de respaldo por clase de ciclo de vida (telemetry, transient, event, audit).', }, growth_alert_rows: { label: 'Umbral de alerta de crecimiento (filas por barrido)', diff --git a/packages/services/service-settings/src/translations/fr.ts b/packages/services/service-settings/src/translations/fr.ts index b9e2f6bf42c..c99d71fbbb1 100644 --- a/packages/services/service-settings/src/translations/fr.ts +++ b/packages/services/service-settings/src/translations/fr.ts @@ -753,15 +753,15 @@ export const fr: PlatformTranslationData = { }, retention_overrides: { label: 'Surcharges de rétention', - help: 'Surcharges de fenêtre par objet : { "": { "maxAge": "1y", "expireAfter": "30d" } }. Les durées utilisent h/d/w/y. Définies par organisation : une organisation réglementée peut conserver des années, une organisation de développement quelques jours. Une surcharge plus courte que le minimum requis par une fonction de la plateforme (par exemple la fenêtre de détection des doublons de la file de tâches) est ignorée et journalisée comme erreur, et la fenêtre par défaut continue de s’appliquer.', + help: 'Surcharges de fenêtre par objet. Les durées utilisent h/d/w/y. Définies par organisation : une organisation réglementée peut conserver des années, une organisation de développement quelques jours. Une surcharge plus courte que le minimum requis par une fonction de la plateforme (par exemple la fenêtre de détection des doublons de la file de tâches) est ignorée et journalisée comme erreur, et la fenêtre par défaut continue de s’appliquer.', }, quotas: { label: 'Quotas de lignes', - help: 'Nombre maximal de lignes par objet : { "": 1000000 }. Un dépassement déclenche une alerte de gouvernance — les quotas ne suppriment jamais au-delà de la politique déclarée.', + help: 'Nombre maximal de lignes par objet. Un dépassement déclenche une alerte de gouvernance — les quotas ne suppriment jamais au-delà de la politique déclarée.', }, quota_defaults: { label: 'Quotas par défaut par classe', - help: 'Quotas de repli par classe de cycle de vie : { "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }.', + help: 'Quotas de repli par classe de cycle de vie (telemetry, transient, event, audit).', }, growth_alert_rows: { label: 'Seuil d’alerte de croissance (lignes par passage)', diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index c73462187bb..84656112775 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -591,17 +591,17 @@ export const jaJP: PlatformTranslationData = { }, retention_overrides: { label: '保持期間の上書き', - help: 'オブジェクトごとの保持ウィンドウの上書き:{ "": { "maxAge": "1y", "expireAfter": "30d" } }。期間は h/d/w/y で指定します。' + help: 'オブジェクトごとの保持ウィンドウの上書き。期間は h/d/w/y で指定します。' + '組織ごとに設定するため、規制対象の組織は数年、開発用の組織は数日だけ保持できます。' + 'プラットフォーム機能が必要とする最小期間(例:ジョブキューの重複検出ウィンドウ)より短い上書きは無視されてエラーとして記録され、既定のウィンドウが引き続き適用されます。', }, quotas: { label: '行数クォータ', - help: 'オブジェクトごとの最大行数:{ "": 1000000 }。超過するとガバナンスアラートが発生します。クォータが宣言済みのポリシーを超えて削除することはありません。', + help: 'オブジェクトごとの最大行数。超過するとガバナンスアラートが発生します。クォータが宣言済みのポリシーを超えて削除することはありません。', }, quota_defaults: { label: 'クラス別の既定クォータ', - help: 'ライフサイクルクラスごとのフォールバッククォータ:{ "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }。', + help: 'ライフサイクルクラスごとのフォールバッククォータ(telemetry、transient、event、audit)。', }, growth_alert_rows: { label: '増加アラートのしきい値(クリーンアップ 1 回あたりの行数)', diff --git a/packages/services/service-settings/src/translations/ko.ts b/packages/services/service-settings/src/translations/ko.ts index 981be8b16a0..dcc11d97119 100644 --- a/packages/services/service-settings/src/translations/ko.ts +++ b/packages/services/service-settings/src/translations/ko.ts @@ -559,15 +559,15 @@ export const ko: PlatformTranslationData = { }, retention_overrides: { label: '보존 기간 재정의', - help: '객체별 보존 기간 재정의입니다: { "": { "maxAge": "1y", "expireAfter": "30d" } }. 기간 단위는 h/d/w/y입니다. 조직별로 설정하므로 규제 대상 조직은 수년, 개발용 조직은 며칠만 보관할 수 있습니다. 플랫폼 기능에 필요한 최소 기간(예: 작업 대기열의 중복 감지 기간)보다 짧은 재정의는 무시되고 오류로 기록되며, 기본 기간이 계속 적용됩니다.', + help: '객체별 보존 기간 재정의입니다. 기간 단위는 h/d/w/y입니다. 조직별로 설정하므로 규제 대상 조직은 수년, 개발용 조직은 며칠만 보관할 수 있습니다. 플랫폼 기능에 필요한 최소 기간(예: 작업 대기열의 중복 감지 기간)보다 짧은 재정의는 무시되고 오류로 기록되며, 기본 기간이 계속 적용됩니다.', }, quotas: { label: '행 할당량', - help: '객체별 최대 행 수입니다: { "": 1000000 }. 초과하면 거버넌스 알림이 발생하며, 할당량은 선언된 정책을 넘어 데이터를 삭제하지 않습니다.', + help: '객체별 최대 행 수입니다. 초과하면 거버넌스 알림이 발생하며, 할당량은 선언된 정책을 넘어 데이터를 삭제하지 않습니다.', }, quota_defaults: { label: '클래스별 기본 할당량', - help: '수명 주기 클래스별 대체 할당량입니다: { "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }.', + help: '수명 주기 클래스별 대체 할당량입니다 (telemetry, transient, event, audit).', }, growth_alert_rows: { label: '증가 알림 임계값(정리당 행 수)', diff --git a/packages/services/service-settings/src/translations/pt-BR.ts b/packages/services/service-settings/src/translations/pt-BR.ts index a30e1b8cd0d..1b1b76a3dce 100644 --- a/packages/services/service-settings/src/translations/pt-BR.ts +++ b/packages/services/service-settings/src/translations/pt-BR.ts @@ -737,15 +737,15 @@ export const ptBR: PlatformTranslationData = { }, retention_overrides: { label: 'Substituições de retenção', - help: 'Substituições de janela por objeto: { "": { "maxAge": "1y", "expireAfter": "30d" } }. As durações usam h/d/w/y. Definidas por organização, para que uma organização regulada guarde anos e uma de desenvolvimento guarde dias. Uma substituição mais curta que o mínimo exigido por um recurso da plataforma (por exemplo, a janela de detecção de duplicatas da fila de tarefas) é ignorada e registrada como erro, e a janela padrão continua valendo.', + help: 'Substituições de janela por objeto. As durações usam h/d/w/y. Definidas por organização, para que uma organização regulada guarde anos e uma de desenvolvimento guarde dias. Uma substituição mais curta que o mínimo exigido por um recurso da plataforma (por exemplo, a janela de detecção de duplicatas da fila de tarefas) é ignorada e registrada como erro, e a janela padrão continua valendo.', }, quotas: { label: 'Cotas de linhas', - help: 'Quantidade máxima de linhas por objeto: { "": 1000000 }. Ultrapassar gera um alerta de governança — as cotas nunca excluem além da política declarada.', + help: 'Quantidade máxima de linhas por objeto. Ultrapassar gera um alerta de governança — as cotas nunca excluem além da política declarada.', }, quota_defaults: { label: 'Cotas padrão por classe', - help: 'Cotas de reserva por classe de ciclo de vida: { "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }.', + help: 'Cotas de reserva por classe de ciclo de vida (telemetry, transient, event, audit).', }, growth_alert_rows: { label: 'Limite de alerta de crescimento (linhas por varredura)', diff --git a/packages/services/service-settings/src/translations/ru.ts b/packages/services/service-settings/src/translations/ru.ts index f43136c1262..4a03f8825ee 100644 --- a/packages/services/service-settings/src/translations/ru.ts +++ b/packages/services/service-settings/src/translations/ru.ts @@ -747,15 +747,15 @@ export const ru: PlatformTranslationData = { }, retention_overrides: { label: 'Переопределения хранения', - help: 'Переопределения срока для объектов: { "": { "maxAge": "1y", "expireAfter": "30d" } }. Длительности задаются в h/d/w/y. Задаются для каждой организации, так что регулируемая организация может хранить годы, а организация для разработки — дни. Переопределение короче минимума, нужного функции платформы (например, окна обнаружения дубликатов очереди заданий), игнорируется и записывается как ошибка, а срок по умолчанию продолжает действовать.', + help: 'Переопределения срока для объектов. Длительности задаются в h/d/w/y. Задаются для каждой организации, так что регулируемая организация может хранить годы, а организация для разработки — дни. Переопределение короче минимума, нужного функции платформы (например, окна обнаружения дубликатов очереди заданий), игнорируется и записывается как ошибка, а срок по умолчанию продолжает действовать.', }, quotas: { label: 'Квоты строк', - help: 'Максимум строк для объекта: { "": 1000000 }. Превышение вызывает оповещение управления — квоты никогда не удаляют сверх объявленной политики.', + help: 'Максимум строк для объекта. Превышение вызывает оповещение управления — квоты никогда не удаляют сверх объявленной политики.', }, quota_defaults: { label: 'Квоты по умолчанию для классов', - help: 'Резервные квоты по классу жизненного цикла: { "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }.', + help: 'Резервные квоты по классу жизненного цикла (telemetry, transient, event, audit).', }, growth_alert_rows: { label: 'Порог оповещения о росте (строк за проход)', diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 49f73bc46f4..20fe0174ea3 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -609,17 +609,17 @@ export const zhCN: PlatformTranslationData = { }, retention_overrides: { label: '保留期覆盖', - help: '按对象覆盖保留窗口:{ "": { "maxAge": "1y", "expireAfter": "30d" } }。时长单位为 h/d/w/y。' + help: '按对象覆盖保留窗口。时长单位为 h/d/w/y。' + '按组织设置,因此受监管的组织可以保留数年,开发用的组织只保留数天。' + '短于某项平台功能所需最短窗口(例如作业队列的重复检测窗口)的覆盖会被忽略并记为错误,继续沿用默认窗口。', }, quotas: { label: '行数配额', - help: '按对象的最大行数:{ "": 1000000 }。超出时触发治理告警——配额绝不会删除已声明策略之外的数据。', + help: '按对象的最大行数。超出时触发治理告警——配额绝不会删除已声明策略之外的数据。', }, quota_defaults: { label: '按类别的默认配额', - help: '按生命周期类别的兜底配额:{ "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }。', + help: '按生命周期类别的兜底配额(telemetry、transient、event、audit)。', }, growth_alert_rows: { label: '增长告警阈值(每次清理的行数)', diff --git a/packages/services/service-settings/src/translations/zh-Hant.ts b/packages/services/service-settings/src/translations/zh-Hant.ts index d9282d75918..e052211e730 100644 --- a/packages/services/service-settings/src/translations/zh-Hant.ts +++ b/packages/services/service-settings/src/translations/zh-Hant.ts @@ -489,12 +489,12 @@ export const zhHant: PlatformTranslationData = { enabled: { label: '執行生命週期原則', help: '週期性清理的總開關。關閉 = 已宣告的保留期/TTL/輪替原則不再執行(資料會再度無限成長)。' }, retention_overrides: { label: '保留期覆寫', - help: '依物件覆寫保留期:{ "": { "maxAge": "1y", "expireAfter": "30d" } }。時長使用 h/d/w/y。依組織設定,因此受監管的組織可保留數年,開發用組織只保留數天。短於某項平台功能所需最短期限(例如工作佇列的重複偵測期限)的覆寫值會被忽略並記錄為錯誤,繼續套用預設期限。', + help: '依物件覆寫保留期。時長使用 h/d/w/y。依組織設定,因此受監管的組織可保留數年,開發用組織只保留數天。短於某項平台功能所需最短期限(例如工作佇列的重複偵測期限)的覆寫值會被忽略並記錄為錯誤,繼續套用預設期限。', }, - quotas: { label: '列數配額', help: '依物件的最大列數:{ "": 1000000 }。超出時會發出治理警示——配額絕不會刪除超出已宣告原則的資料。' }, + quotas: { label: '列數配額', help: '依物件的最大列數。超出時會發出治理警示——配額絕不會刪除超出已宣告原則的資料。' }, quota_defaults: { label: '依類別的預設配額', - help: '依生命週期類別的備援配額:{ "telemetry": 1000000, "transient": 500000, "event": 100000, "audit": 5000000 }。', + help: '依生命週期類別的備援配額(telemetry、transient、event、audit)。', }, growth_alert_rows: { label: '成長警示門檻(每次清理的列數)', help: '宣告了生命週期的資料表在兩次清理之間增加超過這麼多列時發出警示。0 表示停用成長警示。' }, },