Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/30208-settings-map-specifier.md
Original file line number Diff line number Diff line change
@@ -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`)

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is removed, renamed or re-shaped: `SpecifierType` gains `map` and `Specifier` gains the optional `keyColumn` and `columns`. The three lifecycle keys keep their stored shape, their env variable names and their cascade. What narrows is the set of values the settings doors accept for those three keys: a stored value is runtime state in `sys_setting` / `sys_platform_setting` and an `OS_LIFECYCLE_*` override is a deployment's environment, so there is nothing for a migration to rewrite, and every refused value is one the sweep never applied. -->

**New: the `map` specifier** (`@objectstack/spec`, ADR-0007 as amended 2026-11-08). A setting whose value is keyed rows declares `type: 'map'` with a `keyColumn` (its header) and `columns` (`{ name, label, kind }`, `kind` is `duration` or `positive_integer`). One value column stores `Record<key, value>`; several store `Record<key, { [column]: value }>`. The keys a map admits are served at run time by an option source the settings service registers for the key, never listed in the manifest. `json` stays for raw config.

**`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.
12 changes: 12 additions & 0 deletions content/docs/protocol/kernel/config-resolution.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion content/docs/references/system/settings-manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>; … } \| { kind: 'action'; name: string; params?: Record<string, any>; confirmText?: string \| Record<string, string> } \| { kind: 'navigate'; url: string; target?: Enum<'_self' \| '_blank'> }` | optional | Action handler (action_button) |
| **keyColumn** | `{ label: string \| Record<string, string>; description?: string }` | optional | Key column (map) |
| **columns** | `{ name: string; label: string \| Record<string, string>; description?: string; kind: Enum<'duration' \| 'positive_integer'> }[]` | optional | Value columns (map): one stores a scalar per key, several an object |
| **confirmOffText** | `string \| Record<string, string>` | 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) |
Expand Down Expand Up @@ -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<string, string>` | ✅ | Display label |
Expand All @@ -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<string, any>; … } \| { kind: 'action'; name: string; params?: Record<string, any>; confirmText?: string \| Record<string, string> } \| { kind: 'navigate'; url: string; target?: Enum<'_self' \| '_blank'> }` | optional | Action handler (action_button) |
| **keyColumn** | `{ label: string \| Record<string, string>; description?: string }` | optional | Key column (map) |
| **columns** | `{ name: string; label: string \| Record<string, string>; description?: string; kind: Enum<'duration' \| 'positive_integer'> }[]` | optional | Value columns (map): one stores a scalar per key, several an object |
| **confirmOffText** | `string \| Record<string, string>` | 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) |
Expand All @@ -231,6 +235,7 @@ const result = ResolvedSettingValueSchema.parse(data);
* `slider`
* `color`
* `json`
* `map`
* `action_button`

### Nested Shape: `Specifier.options[number]`
Expand Down Expand Up @@ -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<string, string>` | ✅ | 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<string, string>` | ✅ | Column header |
| **description** | `string` | optional | Help text for the column |
| **kind** | `Enum<'duration' \| 'positive_integer'>` | ✅ | Value kind: duration literal or positive integer |


---

Expand Down Expand Up @@ -367,6 +388,7 @@ This schema accepts one of the following structures:
* `slider`
* `color`
* `json`
* `map`
* `action_button`


Expand Down
7 changes: 5 additions & 2 deletions docs/adr/0007-settings-manifest-and-kv-store.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 |
Expand All @@ -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<key, value>` with one value column, `Record<key, {column: value}>` with several | `keyColumn`, `columns: [{name, label, kind}]`, `kind` ∈ `duration` \| `positive_integer`; the keys a map admits are served by a runtime option source, never listed in the manifest |
| `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) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `system/` | 397 |
| `system/` | 399 |
10 changes: 10 additions & 0 deletions packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
133 changes: 133 additions & 0 deletions packages/objectql/src/lifecycle/lifecycle-key-options.test.ts
Original file line number Diff line number Diff line change
@@ -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<LifecycleKeyRegistryLike['getAllObjects']> = [
{ 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<string, LifecycleKeySource>();
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>('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> | 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' } },
}],
});
},
});
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);
});
});
Loading
Loading