From d7a4e20a2ec140b5647922df794ca7d7beafaf37 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:07:51 +0000 Subject: [PATCH 1/7] build(spec): generate the migration registry at build; it leaves git packages/spec/src/migrations/registry.ts is now generated whole and git-ignored, at its unchanged path. gen:migration-registry writes it from the committed hand-written skeleton src/migrations/registry.ts.template (everything outside the os-generated markers, regions empty) and the per-entry files under src/migrations/entries/. It runs on pnpm install (the package's prepare), as the first step of build, and before typecheck, test and test:repo; turbo declares it as their prerequisite with the file as the task output. The generator's self-test runs inside every generation and now also covers ordering and determinism. check:migration-registry retires: there is no committed copy to compare. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- .gitignore | 6 + packages/spec/package.json | 10 +- .../spec/scripts/build-migration-registry.ts | 175 +- packages/spec/src/migrations/registry.ts | 30718 ---------------- .../spec/src/migrations/registry.ts.template | 3450 ++ turbo.json | 21 +- 6 files changed, 3599 insertions(+), 30781 deletions(-) delete mode 100644 packages/spec/src/migrations/registry.ts create mode 100644 packages/spec/src/migrations/registry.ts.template diff --git a/.gitignore b/.gitignore index 35cb15a207c..32dd2bfbc05 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,12 @@ packages/*/*.tsbuildinfo # Generated JSON Schema (rebuilt by `pnpm gen:schema` during build) packages/spec/json-schema/ +# Generated migration registry (rebuilt by `pnpm gen:migration-registry` on +# install and as the first step of build, from registry.ts.template + entries/), +# and the staging file its atomic write renames into place. +packages/spec/src/migrations/registry.ts +packages/spec/src/migrations/registry.ts.*.tmp + # Generated bundle size reports packages/spec/bundle-size-report.json diff --git a/packages/spec/package.json b/packages/spec/package.json index de5c9fa2dd4..9eba400e99c 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -264,7 +264,8 @@ "protocol-upgrade-guide.md" ], "scripts": { - "build": "pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=6144\" BUILD_DTS=true tsup; fi && node ../../scripts/check-dts-emitted.mjs && node ../../scripts/check-dts-references.mjs && node ../../scripts/check-dev-prereqs.mjs --stamp", + "build": "pnpm gen:migration-registry && pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=6144\" BUILD_DTS=true tsup; fi && node ../../scripts/check-dts-emitted.mjs && node ../../scripts/check-dts-references.mjs && node ../../scripts/check-dev-prereqs.mjs --stamp", + "prepare": "pnpm gen:migration-registry", "dev": "tsc --watch", "clean": "rm -rf dist", "gen:schema": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts", @@ -295,13 +296,12 @@ "check:llms-txt": "tsx scripts/check-llms-txt.ts --self-test && tsx scripts/check-llms-txt.ts", "check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check", "gen:migration-registry": "tsx scripts/build-migration-registry.ts", - "check:migration-registry": "tsx scripts/build-migration-registry.ts --self-test --check", "gen:spec-changes": "tsx scripts/build-spec-changes.ts", "check:spec-changes": "tsx scripts/build-spec-changes.ts --check", "gen:upgrade-guide": "tsx scripts/build-upgrade-guide.ts", "check:upgrade-guide": "tsx scripts/build-upgrade-guide.ts --check", - "test": "vitest run --project local", - "test:repo": "vitest run --project repo", + "test": "pnpm gen:migration-registry && vitest run --project local", + "test:repo": "pnpm gen:migration-registry && vitest run --project repo", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "check:liveness": "tsx scripts/liveness/check-liveness.mts", @@ -320,7 +320,7 @@ "check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/spec --project tsconfig.test.json", "gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/spec --project tsconfig.test.json", "check:scripts-typecheck": "tsc --noEmit -p tsconfig.scripts.json", - "typecheck": "tsc --noEmit && pnpm check:scripts-typecheck && pnpm check:test-typecheck" + "typecheck": "pnpm gen:migration-registry && tsc --noEmit && pnpm check:scripts-typecheck && pnpm check:test-typecheck" }, "keywords": [ "objectstack", diff --git a/packages/spec/scripts/build-migration-registry.ts b/packages/spec/scripts/build-migration-registry.ts index 685a0938592..37c58711763 100644 --- a/packages/spec/scripts/build-migration-registry.ts +++ b/packages/spec/scripts/build-migration-registry.ts @@ -2,10 +2,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * build-migration-registry — concatenate `src/migrations/entries/` into the - * marked regions of `src/migrations/registry.ts` (#7297, the registry half of - * #6957's ruling). + * build-migration-registry — write `src/migrations/registry.ts` from two + * committed sources: the hand-written skeleton `src/migrations/registry.ts.template` + * and the per-entry files under `src/migrations/entries/`, which it concatenates + * into the skeleton's marked regions (#7297, the registry half of #6957's ruling). * + * `registry.ts` itself is GENERATED WHOLE and git-ignored (#22554). It is never + * committed, never merged and never compared against a committed copy: this + * generator writes it on `pnpm install` (the package's `prepare`), as the first + * step of `build`, and as the turbo prerequisite of `typecheck` and `test` + * (`turbo.json`, which declares the file as the task's output so the cache + * restores it). Its `--self-test` runs inside every one of those generations. + * + * ## The failure this exists for * * `registry.ts` carried three hand-authored APPEND tables — each protocol @@ -36,11 +45,23 @@ * > rejected — the review diff of `spec-changes.json` / the upgrade guide is * > worth the laps it costs. * + * Its Option-B clause is SUPERSEDED, on the maintainer's own re-opening, by two + * later rulings: #22449 B′ (comment 6078203801, 2026-10-09: the two publish-time + * projections, `spec-changes.json` and the upgrade guide, leave git and are + * generated at publish) and #22554 B (comment 6092692730, 2026-10-10: this + * registry is generated at build and leaves git, its path unchanged). The rule + * the second one completes, verbatim: "a generated aggregate is never committed; + * one that source code imports is generated at build inside its package (as + * `gen:schema` already is), one that only ships is generated at publish (B′, + * #22449); CI checks that generation succeeds and no longer compares a committed + * copy." The review diff #6957 paid for survives for this registry as the entry + * file itself: one reviewed file per entry, the only source of each region. + * * `scripts/adr-anchors/` (#7301) is the pilot this mirrors. It differs in one * forced way, and the difference is worth naming because it bounds what this * generator can promise: * - * ## Why the concatenation is COMMITTED here and was in-memory there + * ## Why the concatenation is a FILE here and was in-memory there * * `scripts/adr-anchors.mjs` assembles its shards with `readdirSync` at read * time, so no aggregate is checked in at all. That option does not exist for @@ -52,24 +73,25 @@ * bundled library needs a STATIC module graph, and a static graph over N * entries needs one file that names all N. * - * So the concatenation lands where it already was: inside `registry.ts`, - * between markers. That choice is what keeps this change's blast radius at - * zero for every existing consumer — `check-adr-0087-registration.mjs` still - * reads every `id:` line out of `LEDGER_SOURCES`, `composeSpecChanges` still - * folds the same objects, the public API is byte-identical, and the review diff - * the ruling explicitly paid for is exactly the diff it was before. + * So the concatenation is a real module file, `registry.ts`, written between + * the template's markers — at build time, never committed. The path is the one + * the file always had, so every importer (the `./migrations` entry, the scripts, + * the tests) is unchanged, `composeSpecChanges` folds the same objects, and the + * public API is byte-identical. `check-adr-0087-registration.mjs` reads the ids + * of a rev from the template and the entry files that rev's generation reads. * - * ⚠️ **What this therefore does NOT claim.** The AUTHORED surface is now + * ⚠️ **What this therefore does NOT claim.** The AUTHORED surface is * conflict-free by construction: two cards retiring different things write * different files and merge clean; two cards editing the SAME entry write the * same filename and git reports an add/add conflict, which is correct and must - * stay true. The generated region is a different story — two entries whose ids - * sort ADJACENTLY insert at the same anchor and still conflict textually. What - * changed there is the class of the failure, not its existence: the region is - * generated from files git merged as a set, `--check` fails if it does not - * match them, and the only correct resolution is `gen:migration-registry`. A - * resolution that DROPS an entry is now caught by this gate instead of landing - * silently, which is the defect #6957 measured. + * stay true. The generated regions no longer merge at all — git never sees + * `registry.ts` — so two entries whose ids sort adjacently no longer meet at one + * anchor, and a resolution can no longer drop an entry from a region (the defect + * #6957 measured). What still merges as TEXT is the template: the hand-written + * skeleton outside the markers (this file's header, each step's `rationale` and + * `conversionIds`, the tables' doc comments). Step 18's rationale is shaped for + * that merge (#20535): fragments kept sorted by key, so two retirements insert + * at different lines. That half is a human's to resolve, as it always was. * * ## Two structural properties, both enforced below * @@ -86,19 +108,27 @@ * index. * * Usage: - * pnpm --filter @objectstack/spec gen:migration-registry - * pnpm --filter @objectstack/spec check:migration-registry # --self-test && --check + * pnpm --filter @objectstack/spec gen:migration-registry # self-test, then write registry.ts + * tsx scripts/build-migration-registry.ts --self-test # the self-test alone */ -import { readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -/** `src/migrations/registry.ts`, the file whose marked regions this writes. */ +/** `src/migrations/registry.ts`, the generated (git-ignored) file this writes whole. */ export const REGISTRY_PATH = 'src/migrations/registry.ts'; +/** + * The committed, hand-written skeleton `registry.ts` is rendered from: every line + * outside the `` markers, with each region left empty. Not a `.ts` + * file on purpose: no tsc program compiles it, and nothing can import it and + * receive empty tables in place of the real ones. + */ +export const TEMPLATE_PATH = 'src/migrations/registry.ts.template'; + /** The per-entry source root. Repo-relative to the package. */ export const ENTRIES_DIR = 'src/migrations/entries'; @@ -255,10 +285,19 @@ export function loadEntries(kind: Kind, root = pkgRoot): { entries: Entry[]; err // Sorted by id. Duplicates cannot occur — the name is a function of the id and // a directory cannot hold two files with one name — which is why nothing here // checks for them. - entries.sort((a, b) => (a.major - b.major) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + entries.sort(compareEntries); return { entries, errors }; } +/** + * The emitted order: by major, then by id in code-unit order — never by locale, + * so two machines can never disagree on it. Exported so `--self-test` drives the + * same comparator the generator sorts with. + */ +export function compareEntries(a: Pick, b: Pick): number { + return (a.major - b.major) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +} + /** The emitted body of one marked region, indented for its site (4 spaces). */ export function renderRegion(entries: Entry[]): string[] { const out: string[] = []; @@ -274,8 +313,8 @@ export function renderRegion(entries: Entry[]): string[] { const closeMarker = (kind: Kind, major: number) => ` // `; /** - * Splice every marked region of `registry.ts`. Pure over the file text, so the - * self-test drives it without touching the tree. + * Splice every marked region of the template, giving the text of `registry.ts`. + * Pure over the text, so the self-test drives it without touching the tree. * * A region present in the file with no entries on disk is emitted EMPTY rather * than removed — an empty region is a real state (a freshly opened step, or @@ -297,13 +336,13 @@ export function renderRegistry(source: string, byKind: Record): { const kind = m[1] as Kind; const major = Number(m[2]); if (!KINDS.some((k) => k.kind === kind)) { - errors.push(`${REGISTRY_PATH}:${i + 1}: unknown generated region kind \`${kind}\`.`); + errors.push(`${TEMPLATE_PATH}:${i + 1}: unknown generated region kind \`${kind}\`.`); out.push(lines[i]); continue; } const close = lines.indexOf(closeMarker(kind, major), i + 1); if (close < 0) { - errors.push(`${REGISTRY_PATH}:${i + 1}: region \`${kind}:${major}\` is never closed.`); + errors.push(`${TEMPLATE_PATH}:${i + 1}: region \`${kind}:${major}\` is never closed.`); out.push(lines[i]); continue; } @@ -318,7 +357,7 @@ export function renderRegistry(source: string, byKind: Record): { for (const e of byKind[kind]) { if (!seen.has(`${kind}:${e.major}`)) { errors.push( - `${e.file}: registers under protocol major ${e.major}, but ${REGISTRY_PATH} has no ` + + `${e.file}: registers under protocol major ${e.major}, but ${TEMPLATE_PATH} has no ` + `\`\` region. Add the region (and, for a semantic entry, ` + 'the migration step that owns it) before the entry can be concatenated.', ); @@ -423,6 +462,28 @@ function selfTest(): string[] { failures.push('unclosed region: expected an error, got ' + JSON.stringify(unclosed.errors)); } + // Ordering: by major first, then by id in code-unit order. `B` < `a` and + // `Z` < `_` in code units, and a locale compare would answer both the other + // way, so these two pairs are the cases a machine-dependent sort gets wrong. + const ordered = [ + { major: 18, id: 'a' }, + { major: 17, id: 'z' }, + { major: 17, id: 'a' }, + { major: 17, id: 'B' }, + { major: 17, id: '_x' }, + { major: 17, id: 'Zz' }, + ].sort(compareEntries); + eq('ordering', ordered.map((e) => `${e.major}.${e.id}`), ['17.B', '17.Zz', '17._x', '17.a', '17.z', '18.a']); + + // Determinism: the same template and entries render the same bytes, and the + // output is a fixed point — rendering it again changes nothing. Generation is + // the only way `registry.ts` comes to exist, on every machine and in every CI + // job, so two runs that disagreed would be two different registries. + const byKind = { semantic: [], 'retired-key': [okKey.entry!], 'retired-def': [] }; + const once = renderRegistry(doc, byKind).text; + eq('deterministic', renderRegistry(doc, byKind).text, once); + eq('fixed point', renderRegistry(once, byKind).text, once); + return failures; } @@ -436,23 +497,31 @@ function selfTest(): string[] { // must therefore run nothing: write mode rewrites `registry.ts`. Spelled as // `check-generated.ts` spells it, realpath on BOTH sides — node resolves // symlinks for the module graph but leaves `argv[1]` as typed, and a guard that -// compared the two raw would turn `check:migration-registry` into exit 0 with -// no output. `build-migration-registry-entry.test.ts` pins both directions. +// compared the two raw would turn `gen:migration-registry` into exit 0 with no +// output and no file. `build-migration-registry-entry.test.ts` pins both directions. +// +// Every generation runs the self-test first: a run that wrote `registry.ts` is a +// run whose splicing, ordering and determinism were just proven, so no separate +// gate has to remember to. `--self-test` alone stops after it. function main(argv: readonly string[]): void { - if (argv.includes('--self-test')) { - const failures = selfTest(); - if (failures.length) { - console.error('build-migration-registry --self-test FAILED:\n' + failures.map((f) => ` - ${f}`).join('\n')); - process.exit(1); - } - console.log('build-migration-registry --self-test: ok'); - if (argv.length === 1) process.exit(0); + const failures = selfTest(); + if (failures.length) { + console.error('build-migration-registry --self-test FAILED:\n' + failures.map((f) => ` - ${f}`).join('\n')); + process.exit(1); } + console.log('build-migration-registry --self-test: ok'); + if (argv.includes('--self-test')) process.exit(0); - const check = argv.includes('--check'); + const templatePath = join(pkgRoot, TEMPLATE_PATH); const registryPath = join(pkgRoot, REGISTRY_PATH); - const source = readFileSync(registryPath, 'utf8'); + let template: string; + try { + template = readFileSync(templatePath, 'utf8'); + } catch (e) { + console.error(`build-migration-registry: cannot read ${TEMPLATE_PATH} — ${(e as Error).message}`); + process.exit(1); + } const byKind = {} as Record; const errors: string[] = []; @@ -462,7 +531,7 @@ function main(argv: readonly string[]): void { errors.push(...loaded.errors); } - const { text, errors: spliceErrors } = renderRegistry(source, byKind); + const { text, errors: spliceErrors } = renderRegistry(template, byKind); errors.push(...spliceErrors); if (errors.length) { @@ -472,22 +541,18 @@ function main(argv: readonly string[]): void { } const counts = KINDS.map(({ kind }) => `${byKind[kind].length} ${kind}`).join(', '); - - if (check) { - if (text !== source) { - console.error( - `✗ ${REGISTRY_PATH} is stale — its generated regions do not match ${ENTRIES_DIR}/.\n\n` + - ' Run: pnpm --filter @objectstack/spec gen:migration-registry\n\n' + - ' If you reached this after resolving a merge conflict INSIDE a marked region: do not\n' + - ' hand-merge it. The entry files are the source and git merged them as a set; the only\n' + - ' correct resolution is to regenerate. This gate exists because a resolution that drops\n' + - " one side's entry produces no other error anywhere (#6957).", - ); - process.exit(1); - } + // Written only when the bytes differ, so an unchanged registry keeps its mtime + // and a watcher or an incremental tsc does not see a change that is not one. + // Written by RENAME, never in place: every tsc, vitest worker and generator that + // imports this module may be reading it while a parallel task regenerates it, + // and a truncate-then-write would hand one of them half a file. + const current = existsSync(registryPath) ? readFileSync(registryPath, 'utf8') : null; + if (current === text) { console.log(`✓ ${REGISTRY_PATH} is current (${counts})`); } else { - if (text !== source) writeFileSync(registryPath, text); + const staged = `${registryPath}.${process.pid}.tmp`; + writeFileSync(staged, text); + renameSync(staged, registryPath); console.log(`✓ wrote ${REGISTRY_PATH} (${counts})`); } } diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts deleted file mode 100644 index feb4843913d..00000000000 --- a/packages/spec/src/migrations/registry.ts +++ /dev/null @@ -1,30718 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The permanent metadata migration chain (ADR-0087 D3). - * - * One {@link MigrationStep} per protocol major that carried a break. Each step's - * mechanical transforms are the D2 conversions that graduated into it (referenced - * by id, so the transform + fixture pair are never duplicated), and its - * `semantic` list carries one entry per retirement family, even when a lossless - * D2 conversion also exists for it — D2 carries the mechanical data repair only. - * - * The chain is a **forever artifact**: every step back to - * {@link MIGRATION_SUPPORT_FLOOR} stays replayable, and CI replays the full chain - * from the oldest supported major's fixtures to current on every release - * (`migrations.test.ts`). The support floor is an explicit release-policy knob — - * how far back `migrate meta --from N` reaches — revisitable per major, never an - * accident of deletion. - * - * ## ⚠️ The three tables below are GENERATED (#7297) - * - * Each step's `semantic` list, {@link RETIRED_KEYS_BY_MAJOR} and - * {@link RETIRED_DEFS_BY_MAJOR} are concatenated into their `` - * regions from `./entries/`, **one file per entry**, sorted by entry id. Add an - * entry by adding a FILE and running - * `pnpm --filter @objectstack/spec gen:migration-registry` — never by typing - * between the markers. See `./entries/README.md`. - * - * They were three hand-authored APPEND tables, and every retirement card - * appended to the same tail line of the same two of them: `step17`'s semantic - * list and `RETIRED_KEYS_BY_MAJOR[17]` conflicted in **6 of 11** contended - * re-merge laps over 2026-08-06..10, for 613 hand-resolved lines of conflict - * markers in four days (#6957's measurement, ruling adopted 2026-08-10). The - * danger was never the wall-clock: **both tables are consumed as sets**, so a - * resolution that drops a sibling's entry produces no error anywhere and the - * retirement it declared silently stops being declared. - * - * Everything OUTSIDE the markers — this header, each step's `rationale` and - * `conversionIds`, and the two tables' load-bearing doc comments — is still - * hand-written and still merges as text. Step 18 is shaped for that merge - * (#20535): its `rationale` is fragments kept sorted by key, so two retirements - * insert at different lines ({@link STEP18_RATIONALE}), and its `conversionIds` - * is derived rather than kept as a second copy. - */ - -// A VALUE import for the generated regions: an entry literal below derives its -// group enumeration from this schema's own keys rather than restating them -// (`translation-per-app-settings-platform-only`). Entry files carry their own -// copy of this import, but the generator treats a file's imports as scaffolding -// and concatenates only the literal — so an entry that references a value needs -// that value in scope HERE, hand-written, outside the generated regions. -import { TranslationDataSchema } from '../system/translation.zod.js'; -// The other VALUE import: step 18's `conversionIds` is read off the list the -// loader applies instead of being restated here (see `step18`). -import { CONVERSIONS_BY_MAJOR } from '../conversions/registry.js'; -import type { MigrationStep } from './types.js'; - -/** - * The oldest protocol major the chain guarantees a replayable path from. - * `objectstack migrate meta --from N` supports any `N >= MIGRATION_SUPPORT_FLOOR`. - * A release-policy decision (ADR-0087 D3), not an accident of what still exists. - * - * Raised 10 → 16 by maintainer ruling, 2026-09-18 (#19056), verbatim and - * untranslated: - * - * > 升级只需要支持从 16.0版本开始。 - * - * Raising it is what RETIRES the steps below it: `step11`–`step16` went with - * this move, because a step the floor no longer reaches is replayed by nothing - * and CI stops proving it still works — dead code that reads as a promise. - * `applyMetaMigrations(doc, N)` now throws `MigrationFloorError` for - * N ∈ 10..15; those consumers reach 16 by another path first. - * - * ⚠️ The floor scopes the CHAIN and nothing else. {@link RETIRED_KEYS_BY_MAJOR} - * and {@link RETIRED_DEFS_BY_MAJOR} are keyed by major but are NOT floor-scoped: - * their only consumer (`scripts/build-schemas.ts`) folds every major into one - * set, so a row below the floor is still the live proof that its retirement was - * declared. ⛔ Never drop rows from either table when this constant moves. - */ -export const MIGRATION_SUPPORT_FLOOR = 16; - -/** - * Protocol 17 step. - * - * Mechanical: the three deprecated aliases that a schema transform used to - * fold into a canonical key and drop from the parsed output (`action.execute`, - * `field.conditionalRequired`, `agent.knowledge.topics`) are removed from the - * spec. Each is a pure key rename with an unchanged value, so each replays - * losslessly as a D2 conversion. None of the three carries a D3 entry: this - * step shipped before every retirement family was required to carry one - * (`SemanticMigration`, `./types.ts`), and it was not back-filled when that - * rule landed. The step's `semantic` list is NOT empty — its later retirements - * carry their D3 entries in the generated region below. - * - * The three conversions are `retiredFromLoadPath` from the day they land: 17 - * gives the aliases no acceptance window at all, and each schema tombstones its - * key with a fix-it error instead. This step is what makes that affordable — - * `os migrate meta --from ` lists the consumer's edits site by site rather - * than leaving them to hand-edit against a changelog. - */ -const step17: MigrationStep = { - toMajor: 17, - rationale: - [ - 'Protocol 17 removes the last three deprecated authorable aliases: action ', - '`execute` (use `target`), field `conditionalRequired` (use `requiredWhen`), and ', - 'agent `knowledge.topics` (use `knowledge.sources`). Each was already lowered into ', - 'its canonical key at parse time and dropped from the parsed output, so no runtime ', - 'behaviour changes — only the authorable surface shrinks to one spelling per slot. ', - 'All three are pure key renames with unchanged values and replay losslessly; the ', - 'schemas reject the removed spellings with a fix-it error naming the replacement.\n\n', - 'It also removes the sharing-rule access level `full`: declared as ', - '"Full Access (Transfer, Share, Delete)" but never enforced as anything but ', - '`edit` — both gates matched `edit`/`full` alike, so Setup promised admins a ', - 'delete grant it never issued (ADR-0078). Unlike the OWD `sharingModel: \'full\'` ', - 'alias retired at step 13, this one HAS a lossless target precisely because it ', - 'was inert — old and new shapes are behaviourally identical — so it converts ', - 'mechanically and leaves no semantic residue. It is the one protocol-17 ', - 'conversion that keeps a load-path acceptance window: it had no prior ', - 'deprecation, and a removed enum value cannot carry the fix-it error the three ', - 'key renames tombstone theirs with.\n\n', - 'Finally it removes agent `tools`: the legacy inline ', - '`{type,name,description}[]` fallback, which the runtime resolved against the ', - 'FULL tool registry with no surface check — the one seam that broke ADR-0064\'s ', - '"an agent reaches exactly its surface-compatible skills\' tools, nothing falls ', - 'through to the global registry". Unlike the renames above this has NO lossless ', - 'target: each entry has to become a reference inside a skill, which is a human ', - 'decision about which skill. The conversion therefore drops the dead key (the ', - 'cloud runtime had stopped reading it, so it already contributes nothing) and ', - 'emits a notice per agent so the author knows where capability must be ', - 're-declared; the schema tombstones the key with a fix-it error naming `skills`.\n\n', - 'Beyond those spec-surface removals, it graduates the seven flow-node config key ', - 'aliases the executors still ', - 'tolerated: the CRUD nodes\' `object` (use `objectName`) — the last tenant ', - 'of the `readAliasedConfig` executor shim, which is deleted with it — plus the six ', - 'open-coded fallbacks that never went through that shim: notify `to`/`subject`/', - '`body`/`url` (use `recipients`/`title`/`message`/`actionUrl`) and script ', - '`functionName`/`input` (use `function`/`inputs`). All are pure key renames with ', - 'unchanged values and replay losslessly. Like the sharing-rule access level above ', - 'they keep a load-path acceptance window: none carried a prior deprecation ', - 'warning, and `FlowNodeSchema.config` is an unconstrained record, so no schema ', - 'tombstone can reject them — the conversion layer is the only seam that can ', - 'declare, convert, and retire them.\n\n', - 'The same graduation covers `wait`, whose fallback was not a config-to-config ', - 'rename. `wait` keeps its contract in the declared `waitEventConfig` ', - 'block, not in `config` at all — yet the executor also read six loose `config` ', - 'keys, two of them (`duration`, `signal`) spellings the spec never declared. ', - 'The conversion lifts them onto the declared block in the executor\'s own `??` ', - 'precedence, so a value already declared wins and its loose counterpart is left ', - 'shadowed. One wrinkle makes this a rewrite rather than a delete: ', - '`waitEventConfig.eventType` is required once the block exists, and the loader ', - 'parses the CONVERTED flow — so a source carrying only `config: { duration }` ', - 'is stamped with `eventType: \'timer\'`, the exact default the executor applied ', - 'to that shape. Behaviour-preserving in both directions.\n\n', - '`connector_action` gets the same lift for the opposite reason. Its ', - 'contract also lives in a declared sibling block (`connectorConfig`), and the ', - 'executor never read `config` at all — but the node\'s descriptor published a ', - '`configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys, and ', - 'the Studio inspector derives its form from a published schema, so schema-driven ', - 'authoring wrote the trio to the wrong place and produced nodes that refused to ', - 'dispatch. The conversion lifts the trio onto the declared block (declared keys ', - 'win; a lift that cannot complete the required connectorId+actionId pair leaves ', - 'the node untouched rather than turning a step-time refusal into a load ', - 'failure), and the descriptor stops publishing the mis-rooted schema.\n\n', - 'The reconciliation that found those also found `map`, whose executor read a ', - 'bare `cfg.flowName ?? cfg.flow` for an undeclared `flow` spelling no schema ', - 'ever described. A pure rename, graduated the same way, so the ', - 'executor reads only the canonical `flowName`.\n\n', - 'And it removes the RLS-policy key `priority` (found by the liveness ledger\'s security re-verification): promised ', - '"conflict resolution" that cannot exist, because no outcome depends on an order: ', - 'applicable policies OR-combine on reads, and a write\'s check is chosen once per ', - 'operation across the applicable policies, then OR-combined (see ', - '`RowLevelSecurityPolicySchema.check`) — there is never a conflict to order, and nothing ever ', - 'read the key (call graph closed across the collection site, the projection ', - 'round-trip and the compiler). A pure lossless delete: outcomes are identical ', - 'with or without it; the schema tombstones the key with the same prescription.\n\n', - 'The same close-out retires the four inert tool authoring keys (`category`, ', - '`permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no ', - 'execution path read them. Two were misleading in the dangerous direction — ', - '`permissions` promised an invocation gate nothing enforced, and `active: false` ', - 'read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless ', - 'deletes; the strict ToolSchema rejects each with its prescription.\n\n', - 'The AppSchema sheds its seven dead authoring keys (2026-06 liveness audit, ', - 'the unknown-key strictness wave\'s app step): `version` (apps are versioned by manifest.version), `aria`, ', - '`objects`/`apis` (the self-described "config file convenience" — nothing read ', - 'them; the chatbot derives an app\'s objects from its nav items), `sharing`/', - '`embed` (a declared-but-unenforced public surface — the only live path is ', - 'FormView.sharing; ADR-0049), and `mobileNavigation` (fully unimplemented). ', - 'Pure lossless deletes — none ever had a runtime effect; each key is ', - 'tombstoned with its prescription.\n\n', - 'ADR-0113 splits the `required` tri-binding: post-17, `required` is ONLY the ', - 'write-time contract (insert must provide; update may not null out; legacy null ', - 'rows rest), and the physical NOT NULL is the explicit `storage.notNull`. ', - '⚠️ NOTHING converts the column half for you, and nothing tightens a column you ', - 'already have. A `field-required-notnull-explicit` conversion did stamp ', - '`storage.notNull: true` onto every `required: true` field; it was WITHDRAWN ', - '(maintainer ruling 2026-09-08), because stamping the constraint wherever ', - '`required: true` appears is exactly the implication the ADR abolished — and ', - 'because the artifact-ingestion door replays retired conversions, so the LOADER ', - 'applied it to 17-authored sources that deliberately omit it and then told their ', - 'authors to write the same tightening into the source, which on a populated ', - 'database is a destructive `tighten_not_null` migration prescribed as the remedy ', - 'for a deprecation notice. Post-17 a column is NOT NULL because its author wrote ', - '`storage: { notNull: true }`, and for no other reason. If you are upgrading a ', - 'pre-17 source whose columns ARE NOT NULL and you want them to stay that way, ', - 'add `storage: { notNull: true }` to those fields yourself — deliberately, and ', - 'knowing that doing it to a field whose column is currently nullable is a ', - 'destructive migration with a backfill ceremony.\n\n', - 'On the wire contract it also retires the `/analytics/query` request ENVELOPE ', - 'outright: `AnalyticsQueryRequestSchema` used to describe `{ cube, query: {...}, ', - 'format }` — the dialect of the degraded analytics shim (retired for serving unscoped aggregates) that the ', - 'real engine never understood (an envelope body inferred a column-less cube and ', - 'died as an SQL syntax error). The canonical request body is now the BARE ', - 'AnalyticsQuery — `cube` + `measures` at the top level — which is what every ', - 'real caller already sends; the schema tombstones `query`/`format`, and the ', - 'dispatcher entry validates bodies and answers 400 with the prescription. No ', - 'stored metadata carries this shape (it was HTTP-only), so the change is two ', - 'semantic TODOs for API callers rather than a stack conversion.\n\n', - 'The close-out sweep finishes the enforce-or-remove worklist across the ', - 'remaining types: action `shortcut`/`bulkEnabled` (no keydown path; the ', - "multi-select toolbar reads the view's bulkActions), flow `active`/`template`/", - 'node `outputSchema`/errorHandling `fallbackNodeId` (`active: false` never ', - 'stopped a flow — `status` is the enforced lifecycle; faults route via ', - 'per-node fault edges), the inert view keys (list `responsive`/`performance`, ', - 'form `data`/`defaultSort`/`aria` — list aria/data stay live), dashboard and ', - 'widget `aria`/`performance`, `agent.knowledge` (declaring sources never ', - 'scoped retrieval — absorbs the former topics→sources rename), and ', - "`skill.triggerPhrases` (phrases were never matched; routing is ", - 'triggerConditions + the agent allowlist). All pure lossless deletes, each ', - 'tombstoned at its schema with the prescription.\n\n', - 'One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` ', - '— it carried two defaults: `.default(0)` in FlowSchema and ', - "`maxRetries ?? 3` in the engine's retryExecution — and because `??` fires ", - 'only on `undefined`, an unstated count meant 0 retries for a flow parsed by ', - 'the schema and 3 for a definition handed to the engine directly: the retry ', - "count was a function of the route in, not of the authored flow. The engine's ", - 'copy is deleted (it reads the parsed block, no fallback), which makes an ', - "unstated count unambiguously 0 — and `strategy: 'retry'` that retries zero ", - "times is `strategy: 'fail'` under another name, the declared-not-delivered ", - 'shape ADR-0049 exists to close. The schema therefore requires `maxRetries` ', - "at least 1 under `'retry'`, in both spellings (omitted, and an explicit 0). ", - 'This is the one v17 flow change the chain cannot apply for you: choosing the ', - 'count is a judgment about re-running the WHOLE flow with its side effects, ', - 'so it is a semantic TODO rather than a rewrite.\n\n', - 'It also retires `api.requireAuth`: the deployment-wide opt-out that let a ', - 'stack serve its ENTIRE data plane anonymously with one boolean. Auth is a kernel ', - 'concern, not a deployment posture — anonymous access to object data is now denied ', - 'unconditionally on every HTTP surface. Every surface that legitimately serves a ', - 'session-less caller derives its own narrow authorization from a DECLARATION instead: ', - 'the control-plane allowlist, `publicFormGrant` (public form views), share-link tokens ', - "(read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped ", - 'with a notice rather than mapped — there is no replacement value, only a different ', - 'way to publish (by declaration). A stack that mounts no auth at all now fails at boot ', - 'when it would serve a data API, instead of receiving an implicit fail-open.\n\n', - 'The same major retires `BatchOptions.validateOnly`: a batch "dry-run" flag that ', - 'was declared but never implemented — every batch surface (`updateManyData` / ', - '`deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a ', - 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag ', - 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the ', - 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the ', - 'change is one semantic TODO for API callers rather than a stack conversion.\n\n', - 'The batch response rows converge on their declared schema in the same window: ', - 'the per-row results of `/batch`, `/updateMany` and `/deleteMany` used to carry a legacy ', - 'implementation shape — `error: string`, `record`, no `index` — while ', - '`BatchOperationResultSchema`, the published client SDK type and the reference docs all ', - 'declared `errors: ApiError[]` / `data` / `index`. A consumer written against the ', - 'declaration read `row.errors` and got `undefined` at runtime — the exact "photographed ', - 'from the schema, dead on the wire" failure the enhanced-api-error rename above records. ', - 'The wire now delivers the declared shape, and the ADR-0119 D4 rollback marking is ', - 'structured with it: `ROLLED_BACK:` / `NOT_ATTEMPTED:` message prefixes become ', - 'first-class `ApiError.code` values, so clients branch on `errors[0].code` instead of ', - 'regexing message strings. A RESPONSE surface, never stored in stack metadata, so there ', - 'is no source for the chain to rewrite — one semantic TODO for readers of the old row ', - 'keys.\n\n', - 'It also narrows `QueryAST.fields` to field names: the `FieldNode` union carried a ', - 'second `{ field, fields, alias }` nested-select member that nothing produced and nothing ', - 'consumed — every reader on the path treats the list as `string[]`, so the object form was ', - 'dropped by the SQL and memory drivers, projected as a column named "[object Object]" by ', - 'MongoDB, and refused by the REST ingress as an unknown field of that name. `expand` is the ', - 'one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one ', - 'capability, one contract). Like the two above it is a request shape, never stored, so the ', - 'chain has no source to rewrite.\n\n', - 'The `QueryAST` liveness sweep applies the same method to the rest of the request surface: `query.joins` ', - 'and `query.windowFunctions` are tombstoned — no engine or driver ever read either on the ', - 'query path, so every join and OVER clause a caller declared was silently dropped. Joins ', - 'were the second, broken spelling of related-record retrieval (`expand` is the live one; ', - 'the whole JoinNode cluster goes with the key), and window functions only ever ran behind ', - '`SqlDriver.findWithWindowFunctions()`, a driver-level door whose flat input shape the ', - 'spec vocabulary never matched (it declared `field`/`over`/`frame` members the door never ', - 'read — that cluster goes too). Request shapes again: two semantic TODOs, no source ', - 'rewrite.\n\n', - 'That sweep\'s close-out settles the remaining three. `having` is ENFORCED, not removed — ', - 'the engine applies it after aggregation on both paths, so the clause every SQL-literate ', - 'author expects now works (no migration; queries that carried it were silently returning ', - 'every group and now filter as written). `cursor` and `distinct` are tombstoned WITH ', - 'their shipped SDK producers (`QueryBuilder.cursor()` / `.distinct()` are deleted): no ', - 'driver ever implemented keyset pagination or SELECT DISTINCT, `cursor` re-served page 1 ', - "forever, and `distinct`'s only observable effect was mis-wired — it suppressed the REST ", - 'list count, which is now truthful again. Both are request shapes; two more semantic ', - 'TODOs, no source rewrite.\n\n', - 'The same kind of retirement covers `wait`\'s timeout pair. `waitEventConfig.onTimeout` ', - 'had ZERO readers — no path ever inspected it, so neither `fail` nor `continue` ever ', - 'happened, while its `.default(\'fail\')` stamped a decision nothing made onto every wait ', - 'node. `waitEventConfig.timeoutMs` said "maximum wait time before timeout" and its only ', - 'reader used it as the timer DURATION when `timerDuration` was absent: it did something, ', - 'just not what it said. Together they declared a timeout `wait` does not have — the run ', - 'resumes when its timer elapses or its signal arrives, never on a deadline. Rather than ', - 'retrofit an implementation to fit two keys that happened to be declared, the pair is ', - 'retired and real timeout semantics are left to be built to a requirement. `timeoutMs` ', - 'converts to `timerDuration` (stringified — the target is `z.string()` and ', - '`parseIsoDuration` reads a bare numeric string as milliseconds, so the wait is unchanged); ', - 'with `timerDuration` already set it is dropped, having been dead metadata. Like the other ', - 'keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the ', - 'load path: absorbing them silently would let an author keep believing they configured a ', - 'timeout.\n\n', - 'Closing the same audit on the data side, `datasource.readReplicas` is removed. ', - 'It described replica connections nothing ever opened: `ConnectableDatasource` and ', - '`DatasourceConnectionSpec` carry no replicas field, the driver factory never reads the ', - 'key, and no query path distinguishes a read from a write — read/write splitting does not ', - 'exist in the platform, so every statement always went to the primary. A lossless delete ', - 'with no target to move to; front replicas behind one endpoint (pgpool, ProxySQL, an RDS ', - 'reader endpoint) and point `config` at it. Notable as the case that shows how a key gets ', - 'MORE convincing as it stays dead: the change that closed the datasource-config gap taught the ', - 'schema to validate each replica entry against the declared driver\'s config contract, so ', - 'sources written in between carry replica blocks that were genuinely checked — precise ', - 'hosts, correct port types, typos rejected. Precision applied to an inert slot reads as ', - 'evidence the slot is live, which is why ADR-0049 asks for a consumer rather than for ', - 'rigor. Retired from the load path with the rest of the keys that misdescribed themselves.\n\n', - 'The datasource close-out also graduates the four legacy `datasource.config` spellings the ', - 'shared driver factory still tolerated via undeclared read-side `??` fallbacks (a follow-up to that ', - 'validation change): sqlite `file`/`database` (use `filename`), postgres/mysql ', - '`connectionString` (use `url`) and `user` (use `username`), and mongo `uri` (use `url`) ', - 'and `user` (use `username`). That validation change made the authoring gate reject each with a rename ', - 'hint, but a runtime datasource persisted in `sys_metadata` before the gate kept working ', - 'only because the factory read leniently — and deleting that tolerance without a ', - 'conversion would have silently moved data (a stored sqlite `file:` row falls back to ', - '`:memory:`). The `datasource-config-driver-key-aliases` conversion rewrites the stored ', - 'shape to the canonical keys at every rehydration seam, the factory now reads exactly one ', - 'spelling per key, and the four `??` chains are deleted. Driver-aware by construction: ', - '`database` renames only under sqlite, where it aliased the file path — for every other ', - 'driver it is a canonical key and is untouched. Retired from the load path not for lying ', - 'but because the authoring gate already rejects the spellings loudly; the chain and the ', - 'stored-row replay are the seams that accept them.\n\n', - 'Finishing the same datasource surface, the canonical driver id `mongo` is renamed to ', - '`mongodb`. The two spellings have both been accepted since `datasource.config` was first ', - 'parsed against its driver\'s own contract, and both still ', - 'are, so no boot breaks and no data moves — what changed is which one is CANONICAL, and ', - 'that string is published as `DRIVER_CATALOG.id` and is what the Studio connection form ', - 'writes into `datasource.driver`. Every row written before the rename therefore carries ', - '`mongo` while the form now emits `mongodb`, leaving one deployment with two spellings of ', - 'one driver and any reader that matches a stored driver against the published catalog id ', - 'silently missing the older rows. The `datasource-driver-mongo-to-mongodb` conversion ', - 'converges the stored value at every rehydration seam; it stays on the LIVE load path ', - '(unlike the config-key aliases beside it) precisely because `mongo` is still legal — ', - 'there is no loud rejection for it to pre-empt, and nothing to lose by converging early. ', - 'The rename is what let the driver-selection id and the config-contract id become one ', - 'string: `packages/spec`\'s driver vocabulary is now a single table both boot hosts read, ', - 'which closed the last fork where `OS_DATABASE_DRIVER=pg` booted under `os start` and was ', - 'refused by `os migrate`. `turso`/libSQL joins the same table with a real config contract, ', - 'so a libSQL `config` is validated instead of waved through.\n\n', - 'The `script` flow node converges on its one real path. It had four ways to name ', - 'what it ran and only one of them ran anything: `config.actionType: \'email\' | \'slack\'` ', - 'were logger-backed stubs that wrote a line, reported success and delivered nothing under ', - 'any configuration — with `config.template` / `.recipients` / `.variables` feeding a ', - 'message no channel ever sent; inline `config.script` was recognized and never executed ', - '(the built-in runtime has no server-side JS sandbox), so the node warned and no-op\'d; and ', - 'every other `actionType` value was shorthand for a registered-function name, a second ', - 'spelling of `config.function`. All five keys are retired and `function` becomes required, ', - 'which is also what finally made the contract PARSEABLE: while the legal key set depended ', - 'on `actionType`, a flat parse would either reject valid shapes or wave everything through, ', - 'so `script` (with `subflow`) now runs through the same execute-time config `parse()` the executor wiring ', - 'gave the flat builtins. A shorthand `actionType` CONVERTS into `function` — that is what ', - 'it meant — unless `function` is already set, in which case it was dead metadata the ', - 'executor never reached. The other four are dropped outright: nothing read them, so there ', - 'is no value to preserve, and rebuilding the intent is an authoring decision the tombstones ', - 'prescribe per branch (a `notify` node for mail — it delivers through the messaging ', - 'service, the in-app inbox by default and real email once `@objectstack/plugin-email` is ', - 'installed; a `connector_action` with the Slack connector, or an `http` node posting to a ', - 'webhook, for Slack; a registered function for an inline body). Retired from the load path ', - 'for the same reason as the rest: absorbing `actionType: \'email\'` silently would let an ', - 'author keep believing the flow sends mail.\n\n', - 'The same audit reaches the driver contract itself: `IDataDriver.findStream` is removed ', - 'from the contract. It was REQUIRED — every driver and every test double had to implement it — and ', - 'documented as the read "optimized for large datasets to avoid memory overflow", while ', - 'two of its three implementations awaited `find()` for the whole result set and then ', - 'yielded it row by row, reaching exactly the peak it promised to avoid; the third ', - 'streamed for real but was the one read in that driver that skipped `buildFindOptions`, ', - 'so it dropped `query.fields`. Nothing anywhere called it, which is why a contract ', - 'method could carry an inverted guarantee for this long and why ~20 test doubles could ', - 'satisfy it by throwing `not implemented`. Paged `find()` is the read that exists and is ', - 'enforced (its total-order guarantee is checked by the shared pagination-conformance ', - 'cases); a cursor-based read is worth building when a caller asks for one, which is the ', - 'honest order. A TS/API surface, never stored — one semantic TODO for driver authors, no ', - 'source rewrite, and no tombstone: `DriverInterfaceSchema` describes a contract that ', - 'code IMPLEMENTS and nothing ever `.parse()`d a driver, so tsc is the only channel that ', - 'could carry the prescription, and it carries it where it matters — at a call site.\n\n', - 'Separately, `object.managedBy: \'system\'` is retired in favour of `\'system-data\'`, ', - 'finishing the split ADR-0103 began in v16. That split was deliberately ADDITIVE: the 20 ', - 'engine-owned objects moved to the new explicit `engine-owned`, and the 8 admin/user-', - 'writable ones — the RBAC link tables, `sys_user_preference`, the three messaging config ', - 'grids — stayed behind on `system`. What was left is a value whose name describes the half ', - 'that had already moved out: "system" sitting on precisely the objects a user writes. That ', - 'is not a cosmetic complaint. An author choosing between `system` and `engine-owned` had ', - 'nothing in the vocabulary to choose on, so the bucket was re-overloadable by anyone ', - 'reading the name in good faith — a model author most of all. `system-data` states both ', - 'boundaries: the SCHEMA is the platform\'s (versus `platform`, which is tenant-modelled), ', - 'the DATA is the admin\'s or the user\'s (versus `engine-owned`, where the engine owns both). ', - 'Reusing `config` was considered and rejected — `sys_user_preference` is user-owned rather ', - 'than admin-authored, and `config` suppresses CSV import — as was `platform-data`, which ', - 'sits one word away from the unrelated `platform` in the same closed enum and would ', - 'reintroduce the confusion at the point of choosing. Because v16 already drained the ', - 'engine side, the conversion is a ONE-TO-ONE mechanical value rename with no judgement ', - 'call. One deliberate consequence: `system` defaulted LOCKED and each object re-opened its ', - 'writes through `userActions`, while `system-data` defaults WRITABLE on create, edit, ', - 'delete and exportCsv, so those blocks become redundant and are deleted (keep ', - '`userActions` only to NARROW). CSV `import` is the one verb that default deliberately ', - 'withholds (maintainer ruling 2026-08-03): it stays opt-in per object via `userActions: { import: true }`, so a ', - 'v16 `system` object — which resolved `import: false`, because the re-open blocks only ', - 'ever named create/edit/delete — keeps resolving `import: false` after the rename. The ', - 'reason is leverage, not authorization: three of the eight members are the RBAC link ', - 'tables, and a bulk-grant entry point on the permission model\'s grant surface should be ', - 'a per-object declaration rather than something inherited by being filed in the right ', - 'bucket. No enforcement ', - 'moves — the engine write guard, the DelegatedAdminGate, RLS and permission sets all ', - 'adjudicate off resolved affordances and the principal, never off the bucket name; ', - '`system-data` simply joins `platform`/`config` as a bucket the guard does not cover, ', - 'because a writable default has nothing to fail closed on. Retired from the load path: ', - 'the enum rejection is what teaches the new spelling, and absorbing `\'system\'` silently at ', - 'load would leave every author writing the name this rename exists to retire.\n\n', - 'Finally, five keys retire because the advisory lint could never have warned about them ', - '— mapping `extractQuery` / `errorPolicy` / `batchSize`, and app ', - '`contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, ', - 'and a default materialises at parse time — so the liveness lint cannot tell a value the ', - 'author wrote from one the schema supplied, and marking them would have warned on every ', - 'mapping and every selector in existence. For a key in that state removal is not the ', - 'escalation after a warning; it is the only channel that ever reaches the author, which ', - 'is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What ', - 'they claimed: `extractQuery` promised an export path no exporter implements (exports go ', - 'through the ordinary query API); `errorPolicy` offered skip/abort/retry where error ', - 'handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes ', - 'itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth ', - 'reading twice — it was not unread but deliberately DISOBEYED, because context selectors ', - 'are mandatory-scope and an "All" row would clear the scope: on Studio\'s package selector ', - 'that means listing the platform\'s own system/cloud kernel packages to a developer who ', - 'scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ', - 'ignored it. The mapping prescription for `batchSize` deliberately offers no rename: ', - 'bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all ', - 'live, but each is a different key sizing its own path — the same trap `datasource.', - 'retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.\n\n', - 'The sharpest removal in this step is two keys wide: `app.areas[].visible` and ', - '`app.areas[].requiredPermissions`. Read the class before the count — these were ', - 'not inert authoring keys but FAIL-OPEN access gates. At the time of the retirement the ', - 'server-side authority (`filterAppForUser`) checked the app\'s `requiredPermissions` and ', - 'then walked ONLY the top-level `navigation` tree; it never read `item.areas` at all, and ', - 'the client rendered every area in the switcher. So an author writing ', - '`requiredPermissions: [\'sales.admin\']` on an area got a clean parse, a stored value, and ', - 'an area visible to everybody — and had every reason to believe otherwise, because the SAME ', - 'key names are genuinely enforced one ', - 'level up and one level down: app-level `requiredPermissions` drops the whole app ', - 'server-side, and a navigation ITEM\'s `requiredPermissions` / `requiresService` are ', - 'stripped server-side and re-checked in the shell, whose item-level `visible` is a real ', - 'CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was ', - 'weighed and deliberately not taken here: it needs semantics decided first (does ', - 'filtering an area remove its items everywhere? does the server bind `user` for area ', - 'CEL?), and a retirement must not invent an authorization mechanism — while shipping a ', - 'major with the gate still declared would have kept authors writing it for all of 17.x. ', - 'The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading ', - 'author has to re-decide is only where the gate really goes: onto the items inside the ', - 'area, or onto the app — and BOTH of those destinations are server-enforced. The caveat ', - 'this prescription used to carry, that per-item gating INSIDE an area was enforced by the ', - 'shell only because the server did not walk `areas`, was CLOSED by a server-side fix inside this same ', - '17.0.0 window: `filterAppForUser` now runs the SAME `filterNav` over every ', - '`areas[].navigation`, so an ITEM\'s `requiredPermissions` / `requiresService` is stripped ', - 'server-side in BOTH trees and a gated entry never ships in the `/meta` body at all. Read ', - 'that as the boundary closing, NOT as the area-LEVEL keys coming back: those stay retired ', - 'and that fix gave an area no gate of its own — what it enforces are the items inside one. ', - 'The other half of the asymmetry is unchanged and is why `requiredPermissions` is the key ', - 'to reach for: `visible` (CEL) and `requiresObject` are still evaluated client-side ONLY ', - 'at every level, because server-side CEL needs a bound `user` context the read layer does ', - 'not have — so anything that must never reach the browser goes in `requiredPermissions`, ', - 'never in `visible`.\n\n', - 'The same window converges the retry policy. `@objectstack/spec/automation` and ', - '`@objectstack/spec/system` each exported a `RetryPolicy`/`RetryPolicySchema` resolving ', - 'to a DIFFERENT declaration, so which shape a consumer got depended only on the import ', - 'path — yet both computed `delay = base * multiplier^(retry-1)` and both ', - 'executors implemented that same formula. One declaration now serves both entries with ', - 'the union of their capabilities, so `job.retryPolicy` gains the `maxRetryDelayMs` ceiling ', - 'and `jitter` (both enforced in `runWithPolicy`, not merely declared — jitter is what stops ', - 'a fleet of jobs that failed on one outage from retrying in lockstep). The single ', - 'authorable casualty is the automation spelling of the base delay: `retryDelayMs` → ', - '`backoffMs`, a pure rename that replays losslessly and is what the already-enforced ', - 'retry policies (`job.retryPolicy`, `hook.retryPolicy`) call it.\n\n', - 'The subtle half is the defaults, and it is worth stating because no gate can see it: ', - '`job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` while the automation ', - 'shape defaulted 0 / 1, and the authorable-surface gate compares KEY SETS — a changed ', - 'default is invisible to it, to the tombstone mechanism and to `spec_changes` alike. The ', - 'merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in — the same ', - 'reading already recorded in `flow-retry-max-retries-required`), and the conversion writes ', - 'the pre-17 numbers into every existing `job.retryPolicy` that omitted them. Deployed ', - 'stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored ', - 'omission means.\n\n', - 'That convergence then had to be finished twice more, and WHY it was incomplete is the ', - 'part worth carrying forward. It was driven by the dual-source instrument, ', - 'which asks "how many declarations publish the same exported NAME?" — so it could not see ', - 'the two encodings of the identical policy that have no exported name at all, being ', - 'anonymous inline blocks nested in a bigger schema: `flow.errorHandling` and ', - '`ETLPipeline.retry`. The instrument was not broken and answered its own question exactly; ', - 'that question was simply not "how many shapes does this ONE concept have?", which is what ', - 'everybody read off it. The cost of the gap is concrete and falls on the author who did the ', - 'right thing: `shared/retry-policy.zod.ts` tombstoned `retryDelayMs` and told them to write ', - '`backoffMs`, and `flow.errorHandling` then rejected `backoffMs` and demanded `retryDelayMs` ', - '— reading the newer file was punished. Both blocks now build from one shared shape. ', - '`flow.errorHandling` costs nothing beyond the same `retryDelayMs` → `backoffMs` rename ', - '(every other key, bound and default already matched, which is exactly why it looked ', - 'reviewed), and the conversion covers it. `ETLPipeline.retry` costs a rename of the COUNT ', - '— `maxAttempts` → `maxRetries`, same number, do NOT subtract one: that adjustment belongs ', - "to `integration/connector.zod.ts`'s identically-spelled `RetryConfig.maxAttempts`, which ", - 'INCLUDES the first attempt — plus the same default flip (3 → 0) and three keys it never ', - 'had (`backoffMultiplier` / `maxRetryDelayMs` / `jitter`, so a nightly warehouse pipeline ', - 'can stop retrying flat, uncapped and unjittered every 60s). The ETL half gets a tombstone ', - 'and no conversion step, deliberately: an ETL pipeline is not a `defineStack` collection ', - 'and `etl.zod.ts` has no parse site in any of the three repos, so there is no stored ', - 'document to walk and a step for it would advertise coverage it does not have. Nothing ', - 'deployed moves; the migration surface is empty and this is the cheapest this convergence ', - 'will ever be.\n\n', - 'The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops ', - '`data.field.changed`. It had no producer anywhere — the engine emits ', - '`data.record.{created,updated,deleted}` and, since predicate writes got their own bulk event, `data.records.{updated,', - 'deleted}` — so a subscriber switching on it held a branch that could never run, and ', - 'the `switch` still compiled, which is why an empty member could sit in a public enum ', - 'this long. It could not have been implemented against this contract as written: ', - '`DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, ', - 'so the member advertised a granularity the payload has no room for. Nothing is lost — ', - 'per-field detail already rides on `data.record.updated` as `changes` (with `before` / ', - '`after`), one event per write instead of N on a wide table. Like the driver contract ', - 'above it is a runtime surface, never stored in stack metadata, so it is one semantic ', - 'TODO for event consumers rather than a source rewrite, and it carries no tombstone: a ', - 'removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` ', - 'retirement noted. Should a real per-field stream ever be wanted, it earns its own ', - 'contract, as the bulk `data.records.*` events did, rather than reclaiming this slot.\n\n', - 'The object capability block closes out the same ADR-0049 pass: `enable.trash` and ', - '`enable.mru` left the schema in the 16.x line (the close-out of the 11.0 dead-property removals — every ', - 'delete has always been a hard delete and MRU tracking was never implemented, so both ', - 'default-true flags gated nothing), and the `.strict()` capabilities block rejects them ', - 'with the prescription. This step registers the migration surface that removal was ', - 'missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, ', - 'and `os migrate meta --from 16` lists the mechanical edits for authored sources. Soft delete stays parked at ', - 'the proposal stage; if built it returns as a live enforced flag rather than by reviving these keys.\n\n', - 'The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block: ', - '`OpenApi31ExtensionsSchema` (`webhooks` / `callbacks` / `jsonSchemaDialect` / ', - '`pathItemReferences`) with `OpenApiWebhookEventSchema` and `CallbackSchema` under it. ', - "Declared-but-unenforced end to end: the REST server's `normalizeConfig` forwards only ", - '`api`/`crud`/`metadata`/`batch`/`routes`, the served /openapi.json is the pre-generated ', - 'contract enriched with the live server URL and registered objects, and `gen:openapi` ', - 'never read a webhook or callback — so a definition authored under `openApi31.webhooks` ', - 'never appeared in any served document, and zero import-level consumers existed across ', - 'objectstack / cloud / objectui. `RestServerConfig` is plugin TS configuration (the REST ', - 'plugin constructor / `plugin-hono-server` `restConfig`), never a stored metadata shape: ', - "the stack tree's own `api` block declares only its four scoping/auth knobs, so no ", - '`sys_metadata` row can carry `openApi31` and there is no source for the chain to ', - 'rewrite — one semantic TODO for config authors rather than a stack conversion, the ', - '`validateOnly` shape. The key itself is tombstoned (the schema is not `.strict()`; a ', - 'plain delete would strip it silently), and a config-driven webhooks/callbacks synthesis, ', - 'if ever wanted, returns via the enforce route of ADR-0049 through a new ADR.\n\n', - 'The same pass closes `activationEvents`: both keys that carried it — ', - '`DynamicLoadRequest.activationEvents` on the kernel side and ', - '`StudioPluginManifest.activationEvents` on the studio side — declared lazy plugin ', - 'activation ("plugins remain dormant until an activation event fires") that no runtime ', - 'in any repo ever implemented: every plugin has always activated immediately on ', - "load/registration, and cloud-v1's own ROADMAP recorded the capability as ", - 'unimplemented, planned for v0.4.0. A dual-source cleanup had just converged the two ', - '`ActivationEventSchema` declarations onto one structured `{ type, pattern }` ', - "vocabulary in this same unreleased major; with the maintainer's enforce-or-remove ", - 'ruling landing on REMOVE, that converged vocabulary retires before ever shipping — ', - 'composed across the two changes, a v16 author simply deletes the key in whichever ', - 'form they carried. Neither parent is stored metadata (`StudioPluginManifest` is TS ', - 'configuration parsed by `defineStudioPlugin`; `DynamicLoadRequest` is a runtime ', - 'request shape with no caller in any repo), so there is no source for the chain to ', - 'rewrite — one semantic TODO, the `validateOnly` shape. The kernel key is tombstoned ', - '(its schema is not `.strict()`; a plain delete would strip it silently), the studio ', - 'key is rejected by the strict manifest parse with its own guidance prescription, and ', - 'the orphaned `ActivationEventSchema` def is removed with them. Behaviour is ', - 'byte-identical: eager activation was always the only behaviour.\n\n', - 'That kernel-side tombstone was then SUPERSEDED inside the same unreleased major by ', - 'a removal that finished the enforce-or-remove pass one level up: the entire ', - '`plugin-runtime.zod` family — `DynamicLoadRequest`, `DynamicUnloadRequest`, ', - '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation` — is removed, because ', - 'the "Dynamic Loading" capability it described (runtime load / unload / reload without ', - 'a kernel restart, with sandboxing, integrity hashes, drain strategies and ', - 'dependent-cascade policy) has no server anywhere: no runtime in objectstack, cloud or ', - 'objectui ever received one of these requests or produced one of these results. An earlier removal from that module ', - 'had suspended the call on these five deliberately — "operation contracts, not security ', - 'promises" — in a changeset paragraph no issue carried; this removal is that decision, ', - 'answered REMOVE. So a v16 author who wrote `activationEvents` inside a ', - '`DynamicLoadRequest` value does not delete a key: the whole value has no shape and no ', - 'recipient, and importing `DynamicLoadRequestSchema` at all is TS2305 in v17. The ', - 'studio half of the `activationEvents` retirement is untouched and still rejects the ', - 'key with its own prescription — `defineStudioPlugin` remains a live authoring surface. ', - 'Behaviour is again byte-identical: nothing ever executed a dynamic plugin operation.\n\n', - "Finally it removes the script-body capability token 'crypto.hash'. Four layers ", - 'declared it — the `HookBodyCapability` enum, the doc table beside it, the CLI extractor ', - 'and `ScriptContext.crypto.hash` — and none implemented it: `installCtx` wired only ', - '`randomUUID`, so the one call the token authorised threw inside the VM every time. The ', - 'build-time inference made it worse than an ordinary declared-but-unenforced key: writing ', - '`ctx.crypto.hash(...)` made the CLI ADD the capability for you, so `os build` went green ', - 'on the body that was guaranteed to fail at the first record write. Removed rather than ', - 'implemented (ADR-0049) — hashing inside the sandbox widens its capability and ', - 'security-review surface, and a capability that throws on every use yet drew zero ', - 'complaints in its whole life is its own liveness verdict. This is an enum VALUE, not a ', - 'key, so there is no `retiredKey()` tombstone: the enum error map carries the ', - 'prescription, keyed on the received value so that only the spelling which used to be ', - 'legal is told it "was removed". The conversion strips the dead token from ', - '`body.capabilities` on hooks and actions; it deliberately does NOT touch the ', - '`ctx.crypto.hash(...)` call the body made under it, which never returned a value and ', - 'which the author must delete. Hashing returns only WITH an implementation, through the ', - 'capability admission process.\n\n', - "It also removes `connector.rateLimitConfig` and its whole shape. This one is not ", - '"declared but unread" — it is declared but UNIMPLEMENTED, one step worse. The only token ', - "bucket the platform owns (runtime `security/rate-limit.ts`) is INBOUND: the dispatcher ", - 'calls `consume(key)` on a request fingerprint and answers 429. Nothing anywhere throttles ', - 'the calls a connector makes OUT, and no provider — `connector-rest`, `connector-openapi`, ', - '`connector-mcp`, `connector-slack` — reads the key or has a seam that could. So ', - '`strategy`, `maxRequests`, `windowSeconds`, `burstCapacity`, `respectUpstreamLimits` and ', - '`rateLimitHeaders` parsed cleanly and capped nothing, on a surface where the author ', - "believed they had bounded their spend against a third party's quota. `ConnectorRateLimit", - 'Config` and the `RateLimitStrategy` enum it embedded had no other consumer and are removed ', - 'with the key, so importing either is TS2305 in v17 — the `plugin-runtime.zod` shape, and the same ', - 'implementation-first ruling: the vocabulary comes back WITH the engine, in one change. ', - 'It is deliberately NOT converted to `shared` `RateLimitConfig`, which limits the calls ', - 'others make to US; the dual-source cleanup split their names for precisely this confusion, and rewriting an ', - 'outbound cap into an inbound one would throttle the wrong direction. Delete the key and ', - 'rate-limit where the calls are actually made — the connector provider or upstream gateway.\n\n', - 'Last, it removes `dashboard.widgets[].responsive` — the straggler of the close-out ', - 'sweep above, which retired the literally same-named `view.responsive` on the same ', - 'evidence four days earlier. Re-measured before removal: no objectui code reads ', - '`widget.responsive` (DashboardRenderer, DashboardEditor and plugin-designer name it only ', - 'in comments), and there are zero authored instances repo-wide, so the conversion is ', - 'expected to be a no-op on every real source — it exists so that a stored dashboard ', - 'carrying the key is cleaned deterministically rather than meeting the tombstone at load. ', - 'What kept it alive was not evidence but a hole in the instrument: the liveness ledger ', - 'declares no `children` on `dashboard.widgets`, and the walk only drills one level through ', - 'an explicit `children`, so no widget-level key has ever been classified at all (filed as ', - 'its own finding, fixed separately). The removal was deliberately narrow — it took the widget EMBED, ', - 'not the shape, which at the time was believed live on `page.components[].responsive` via ', - 'objectui `useResponsiveConfig`. CORRECTED in 2026-08: that belief measured false ', - '— the hook had zero callers, nothing read the page key either — so protocol 18 retires ', - '`page.components[].responsive` and the `ResponsiveConfig` shape with it (see the ', - '`page-component-responsive-removed` step-18 entry). Authors who need breakpoint behaviour ', - 'use `responsiveStyles` (ADR-0065), the channel objectui really compiles. Per-widget ', - 'responsive layout returns if and when a renderer implements it.\n\n', - 'Finally it CONVERGES `dashboard.widgets[].compareTo` — the one entry in this step ', - 'that is not a removal but a vocabulary merge, and the one whose defect was worst-shaped. ', - 'The widget declared three arms with confident TSDoc; the analytics executor implements one ', - 'contract, `DatasetSelection.compareTo` = `{ kind, dimension? }`, which has no `offset` in ', - 'it. On the ADR-0021 dataset path the two string arms were DROPPED by the renderer (a ', - 'comparison silently absent from a widget whose author asked for one) and `{ offset }` was ', - 'forwarded into that contract with no dimension, so the executor threw ', - '`compareTo requires a timeDimension "undefined"` and errored the whole widget. All three ', - 'arms worked on the legacy inline chart path. Same key, two fates — and the failing one was ', - 'the path the spec itself calls canonical, which is why this ranks above an ordinary ', - 'declared-but-unread key: the documentation was actively teaching a shape that crashes. ', - 'The widget now declares the executor\'s own words, so `declared = enforced` holds by ', - 'construction with no second vocabulary left to drift. `dimension` is optional and resolved ', - 'by the EXECUTOR (one dated time dimension → that one; zero or several → a loud error ', - 'naming the candidates), which is a producer-side resolution rule, not the consumer-side ', - 'tolerance PD #12 forbids. The bare strings and `{ offset: \'1y\' }` replay mechanically; ', - 'every other `{ offset }` duration is a semantic TODO below, because `previousPeriod` ', - 'shifts by the resolved window\'s own length and rewriting `7d` into it would change which ', - 'rows the comparison counts. The converged slot is also union-free, which is not cosmetic: ', - 'zod collapses a failed union into one bare `Invalid input`, and a top-level-only error mapper showed that curated ', - 'guidance inside a union arm never reaches the author at all.\n\n', - 'The same widget drill retires four more keys: the action trio ', - '`actionUrl`/`actionType`/`actionIcon`, and `aria`. The trio described a per-widget action ', - 'BUTTON that no renderer in either repo has ever drawn — all 14 `actionUrl` reads in ', - 'DashboardRenderer are scoped to `header.actions[]`, a different schema — and `actionIcon` ', - 'had zero references anywhere outside its own declaration. `aria` is the dashboard-level ', - '`aria` removed by the close-out sweep, one level down: declared ARIA attributes that never ', - 'reached the DOM, i.e. an accessibility guarantee an author could state and nothing ', - 'honoured. It survived that sweep for the same reason `responsive` did — `widgets` had no ', - 'ledger drill until the instrument hole above was fixed — not on evidence. This removal also settles a second-order cost ', - 'the trio was carrying: `packages/lint`\'s dashboard action-ref rule enforced ', - 'ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key ', - '"the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so ', - 'an author could FAIL A BUILD because a control that cannot render pointed at an action ', - 'that also did not. That widget branch is deleted with the keys. Lossless deletes in every ', - 'case — the keys contributed nothing to any rendered output — and the shared `AriaProps` ', - 'shape is untouched, staying live on `page.aria` / `page.components[].aria` and the list ', - 'view `aria` — not on `app.aria`, which this same major retires (see ', - '`app-dead-authoring-keys-removed`, which strips it). Move a ', - 'dashboard-wide affordance to `header.actions[]` (where `icon` is the header spelling of ', - '`actionIcon`); for per-row click-through use a dataset-bound `table`/`pivot`, whose rows ', - 'drill through the semantic layer already.\n\n', - '⚠️ One protocol-17 change turns metadata ON rather than off, and it is the one to read ', - 'first: declarative `apis:` endpoints EXECUTE from 17. The surface used to be inert ', - 'end to end — no route mounted, no matcher, every key including `authRequired` parsed and ', - 'enforced nothing — which is why publish at first refused a non-empty `apis:` outright. 17 ships the ', - 'executor and narrows that refusal to a per-endpoint publish gate, so an endpoint that ', - 'passes the gate is MOUNTED and serves traffic the moment it is published. Any historical ', - '`apis:` block therefore changes meaning without changing a byte. Review every entry before ', - 'upgrading, and pay particular attention to an explicit `authRequired: false`: the schema ', - 'default is `true`, so an omission is safe, and only that explicit `false` opens anonymous ', - 'access — which ADR-0121 D6 now pairs with a mandatory armed `rateLimit` ', - '(`enabled: true`; the key defaults to `false`, so a budget written without it meters ', - 'nothing). Paths also move under the namespace carve-out `/api/v1/apps//', - '` (ADR-0121 D1/D2). The full checklist is the `declarative-apis-endpoints-live` ', - 'semantic entry below; it is a security review, not a rename, so nothing about it is ', - 'applied for you.\n\n', - 'Finally, the theme token scales retire (ADR-0049): `typography.fontSize`, ', - '`typography.fontWeight`, `typography.lineHeight`, `typography.letterSpacing`, ', - '`typography.fontFamily.heading`, `typography.fontFamily.mono`, `animation` and `zIndex`. ', - 'These are the reverse of the usual inert key and the distinction is the point: the theme ', - 'engine DID emit them — `--font-size-*`, `--font-weight-*`, `--line-height-*`, ', - '`--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, `--font-heading`, ', - '`--font-mono` all reached the document exactly as authored — and no first-party component ', - 'or stylesheet has ever read one, so a declared type scale was real CSS that styled ', - 'nothing. That is why the earlier theme sweep left them standing: its criterion was ', - '"never emitted", and these are emitted. `colors`, `borderRadius`, `shadows` and ', - '`typography.fontFamily.base` have live consumers and are untouched. The prescription is ', - '`customVars`, which emits `--: ` verbatim — so a tenant stylesheet that really ', - 'was reading `--z-modal` reproduces it byte for byte and loses no capability. The ', - 'conversion DELETES the keys and emits a notice per key rather than auto-populating ', - '`customVars`: a rewrite would hand back two dozen variables that still nothing reads, ', - 'turning a dead semantic slot into a dead literal one. Deciding which of them you actually ', - 'consume is yours to make; the notice names each one. Retired from the load path with the ', - 'other keys that misdescribed themselves.\n\n', - 'It closes the enforce-or-remove line with two `./ui` vocabulary shapes that never ', - 'had a key to be written into: `NotificationActionSchema` / `NotificationAction` ', - 'and `EmbedConfigSchema` / `EmbedConfig`. This is the class BELOW a declared-but-unread ', - 'key — there was no key at all. No schema anywhere declared a carrier, a BFS from all 24 ', - 'metadata-type roots plus `ObjectStackSchema` reached neither (with `Page` / `Action` / ', - '`DashboardWidget` / `Webhook` / `SharingConfig` as positive controls in the same run, and ', - 'a synthetic carrier flipping both), and no repo parsed either outside its own unit test. ', - 'Each was left behind by an earlier retirement one level up: the notification action by ', - 'the dual-source cleanup that deleted the two wrapper shapes that could have carried it, and the embed ', - 'config by 17.0.0\'s own `App.embed` tombstone. The strictness wave\'s 批 14 measured both and deliberately ', - 'declined to close them with `.strict()`, because strictness on a shape nothing parses ', - 'enforces nothing and only makes a dead slot look load-bearing; this removal is that ', - 'deferred call, answered REMOVE. Nothing is applied for you and nothing needs to be — ', - 'there is no key in any source to rewrite; the change is visible only as TS2305 on an ', - 'import. ⚠️ Read the scope precisely, because one of the two modules SPLITS: ', - '`ui/sharing.zod` keeps `SharingConfigSchema` and it stays LIVE — `FormView.sharing` ', - 'carries it and `rest-server.ts` mounts the anonymous form routes on ', - '`allowAnonymous` + `publicLink`, so public form sharing is untouched — and ', - '`ui/notification.zod` keeps `NotificationType` / `NotificationSeverity` / ', - '`NotificationPosition`. Only the two named shapes go.\n\n', - 'The same is true of the protocol-17 retirement that closes this list, and the pair is ', - 'worth reading together (ADR-0049): the five `@objectstack/spec/ui` interaction-config ', - 'modules — `touch.zod.ts`, `dnd.zod.ts`, `keyboard.zod.ts`, `animation.zod.ts` and ', - '`offline.zod.ts`, 22 `z.object` sites and 64 exported names — are deleted whole, with ', - 'their reference docs. They were never reachable: no schema in the protocol declared a ', - '`touch:` / `dnd:` / `keyboard:` / `animation:` / `offline:` key, so no metadata document ', - 'could carry one and none needs rewriting now. The defect was on the DOCUMENTATION side, ', - 'which is the half that made it urgent — `authorable-surface.json` carried 109 keys under ', - 'these defs and the generated `references/ui/*` pages rendered them as authoring tables, ', - 'so an AI author reading `dnd.mdx` wrote a `dnd:` block that `PageComponentSchema` then ', - 'rejected as an unknown key. That is a published capability the runtime does not deliver ', - '(Prime Directive #10), not a strictness gap: closing the shapes would have validated a ', - 'slot nobody can reach. Business reading behind the ruling: these five are RENDERER ', - 'BUILT-IN behaviour, decided by the component library rather than authored per page; ', - 'offline is a platform capability whose vocabulary belongs on a sync engine that has not ', - 'been built. ⚠️ The `animation` here is `ui/animation.zod.ts` ', - '(`ComponentAnimation` / `MotionConfig` / `PageTransition` / `AnimationTrigger`), a ', - 'DIFFERENT surface from the theme `animation` block retired above with the token scales — that one had ', - 'a carrier key and got a tombstone; this one had none and gets deletion. The one name ', - 'worth checking on upgrade is the bare `ConflictResolution` type: it left with ', - '`ui/offline.zod.ts` and is now published by nobody. `ConnectorConflictResolution` ', - '(`@objectstack/spec/integration`, connector sync) and `ConflictResolutionStrategy` ', - '(`@objectstack/spec/api`, route merge policy) are different concepts under their own ', - 'names and are untouched.\n\n', - 'The last enforce-or-remove entry of this step is on the RUNTIME context rather than on ', - 'anything authorable: `HookContext.session.roles`. It was declared in ', - '`data/hook.zod.ts`, read by exactly two consumers — the approvals record lock and the ', - 'delegation write guard, each opening with `session.roles?.includes(\'admin\')` — and ', - 'produced by nobody on the hook path: ObjectQL\'s `buildSession()` writes the session ', - 'field by field (`userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the skip ', - 'flags) and has no `roles` write, here or in `cloud`, whose hook consumers read ', - '`hookContext?.session?.userId` and nothing else (an ACTION body\'s `ctx.session` is a ', - 'different untyped object that does carry one, tracked apart). So both branches were dead ', - 'on every real ', - 'engine path: an authorization decision in shape only, and — worse for a reader — a SECOND ', - 'admin dialect competing with the one ADR-0095 D3 sanctions. The approvals plugin deleted the ', - 'two readers on the maintainer\'s ruling; this step removes the declaration that outlived ', - 'them, which is what ADR-0049 asks for once a key has neither end. Nothing observable ', - 'changes: a key nobody wrote and nothing read cannot alter a single decision. It is ', - 'tombstoned rather than deleted because `HookContextSchema` is deliberately NOT `.strict()` ', - '(strictness there would make an engine-internal enrichment a breaking change for anyone ', - 'parsing a context they were handed, as `provenance` was for schedule-triggered runs), so a plain delete would ', - 'strip the key in silence — the ADR-0104 failure this whole pass exists to end. ', - 'There is NO conversion and no source rewrite: a HookContext is built per operation by the ', - 'engine and never stored, so no `sys_metadata` row, example or template can carry the key ', - '— the `openApi31` / `activationEvents` shape, one semantic TODO for hook authors. The ', - 'live vocabulary is untouched and deliberately elsewhere: gate on `session.userId` / ', - '`session.isSystem` in the hook, and judge PRIVILEGE through the security service, which ', - 'reads capability grants (`permissions`), placements (`positions`) and the derived posture ', - 'off the execution context.\n\n', - 'The same enforce-or-remove reading reaches the storage contract: ', - '`IStorageService.list(prefix)` is removed. It had no ', - 'consumer — the only in-repo call site was a proxy pass-through — and the two shipped ', - 'adapters answered it with two different, silently incomplete semantics: the local ', - 'adapter listed a single level and reported directories as files, the S3 adapter ', - 'recursed and stopped at 1000 objects without reading `IsTruncated` / ', - '`ContinuationToken`. Enumerating a prefix without a cursor is the wrong signature to ', - 'inherit, so nothing replaces it in place: query the file records you wrote, and let a ', - 'real caller bring back a cursor-shaped `list(prefix, { cursor, limit })` with ', - 'adapter-conformance cases behind it. Same shape and same disposition as the ', - '`findStream` retirement above — a TS/API contract, no stored source, no tombstone, ', - 'tsc at the call site.\n\n', - 'Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and ', - '`indexes[].partial`. Neither ever had a DDL consumer: ', - '`SqlDriver.syncDeclaredIndexes` creates declared indexes through knex\'s `table.index()` / ', - '`table.unique()`, and the drift differ\'s `DeclaredIndexInput` carries only ', - '`name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no access ', - 'method and an authored `partial` produced a FULL index with its predicate discarded. ', - '`partial` was the more damaging of the two because it read as a correctness control: the ', - 'platform\'s own `sys_metadata` declared it for overlay uniqueness, and what the ', - 'declaration alone materialized was an unrestricted unique index (the active-row scoping ', - 'is delivered by a runtime migration, `metadata-protocol`\'s `ensureOverlayIndex`, not by ', - 'the key). `type` was the louder: its `.default(\'btree\')` put an inert knob into every ', - 'parse output, so it read as live configuration — the ADR-0078 no-silently-inert shape. ', - 'Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing needs ', - 'per-dialect algorithm mapping (`gin`/`gist` Postgres-only, `fulltext` MySQL-family), ', - 'raw-SQL `CREATE INDEX … WHERE` on the dialects that have partial indexes at all (MySQL ', - 'does not), and a redesign of how `isSyncReproducibleIndex` excludes partial indexes from ', - 'incremental sync — design cost for a capability nothing has asked for. Both are lossless ', - 'deletes: no DDL changes, because no DDL ever depended on them. Drift detection is ', - 'untouched — the `partial` flag it consumes is parsed back out of the database\'s OWN ', - '`CREATE INDEX` DDL and never came from this key.\n\n', - 'It also retires the field-mapping `transform` key and the whole five-member ', - '`FieldMappingTransform` union behind it: `constant` / `cast` / `lookup` / ', - '`javascript` / `map`, declared on `shared/FieldMapping` and inherited by ', - '`integration/ConnectorFieldMapping` and `data/ExternalFieldMapping`. Nothing ever ', - 'executed one. `fieldMappings` is spelled only inside `packages/spec` itself — the ', - 'connector packages, the automation engine, REST and objectui never read it, and no ', - 'code anywhere switches on `transform.type` — so all five members were ', - 'declared-but-unenforced together, not just the one that got the bug filed. That one ', - 'is the sharpest evidence though: `javascript`\'s `.describe()` recommended the ', - 'dialect `js`, which `ExpressionDialect` had already retired (ADR-0058 addendum), so the ', - 'envelope the documentation taught was rejected by the enum; the only spelling that ', - 'parsed was the bare string, which `ExpressionInputSchema` wraps as `cel`; and the CEL ', - 'that resulted could not evaluate the `value.toUpperCase()` the same line offered as ', - 'its example. Three surfaces disagreeing about a capability with no implementation ', - 'under any of them. Fixing the sentence alone was rejected (maintainer, 2026-08-06) as ', - 'gilding a member that cannot run. The key is tombstoned rather than deleted because ', - 'the schema and both extenders are plain `z.object`s and `ConnectorSchema.parse` is a ', - 'live receiver, so a bare deletion would strip silently. What is NOT affected, despite ', - 'the shared word: the import mapping\'s `mapping.fieldMapping[].transform`, a flat ', - 'string enum applied row by row by the REST import path and live in the liveness ', - 'ledger — including its own `javascript` value, which that path rejects with a 400 ', - 'rather than pretending to run.\n\n', - 'The last of the strictness wave\'s enforce-or-remove batches lands on two more `ui/` files (', - 'ADR-0049 — read it next to the interaction-config modules above, it is the same shape one batch later). ', - '`ui/widget.zod.ts` published a whole widget-REGISTRATION vocabulary — `WidgetManifest` ', - 'with `WidgetLifecycle` hooks, `WidgetEvent`s, `WidgetProperty` knobs and a ', - '`WidgetSource` npm/remote/inline implementation union — and `ui/i18n.zod.ts` published ', - '`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat` and `LocaleConfig`. Ten defs, ', - 'twenty exported names, and not one carrier key between them: nothing under ', - '`packages/spec/src` imported `widget.zod` at all, the only live imports of `i18n.zod` ', - 'name `I18nLabelSchema` / `AriaPropsSchema`, the BFS from all 24 metadata-type roots ', - 'plus `defineStack` reached none of them, and no repo ever parsed one. So again nothing ', - 'is applied for you and nothing needs to be — the change is TS2305 on an import, and a ', - '`field.widget: "my_picker"` string is untouched, because that key names a component the ', - 'RENDERER registered and never referenced `WidgetManifest`. ⚠️ Read this scope precisely ', - 'too, because BOTH files split. `ui/i18n.zod.ts` keeps `I18nLabelSchema` (the label ', - 'primitive the whole `ui/` tree imports) and `AriaPropsSchema` — a REAL door, carried as ', - '`aria:` on ~30 live shapes and closed by 批 16, untouched here. And `ui/widget.zod.ts` ', - 'keeps `FieldWidgetPropsSchema`, the one site of the nine whose evidence differs: it is ', - 'a React props contract rather than authorable metadata (it never appeared in the ', - 'authorable surface or the schema manifest — `onChange` is a `z.function()`), so having ', - 'no parse is its design; and an objectui change (2026-08-03) made it a live compile-time ', - 'consumer, renaming `@object-ui/fields`\' validation slot onto this contract\'s `error` ', - 'with no alias and pinning it as a deliberate tripwire. Retiring it would have broken ', - 'the one consumer the batch had, one day after it appeared. The measurement that decides ', - 'a site is the CURRENT one, not the one in the issue body.\n\n', - 'Last, it reconciles the SDUI component-props surface with the renderers that serve it ', - '— wiring the first parse gate `ComponentPropsMap` ever had found that the corpus it ', - 'landed on diverged in BOTH directions: keys objectui honours that the schema never ', - 'declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. ', - 'The maintainer ruled direction A (2026-08-06), the `record:details` sections rule again: the delivered and ', - 'authorized shape is the contract. So the honoured keys are declared ', - '(`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` ', - '`stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, ', - 'and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared ', - '`EmptyProps` while their renderers rendered a child list), and four keys retire. Two are ', - 'synonym renames: `element:record_picker.displayField` → `labelField` (the required key no ', - 'renderer read, while `labelField ?? \'name\'` is what actually renders the row — so an ', - 'author who followed the schema got a picker listing `name` with no diagnostic, the ', - 'ADR-0078 shape), and `page:card.body` → `children` (one composition key across every ', - 'container; the card renderer already reads both, and the showcase authors `children`). ', - 'Two are enforce-or-remove deletions: `element:record_picker.searchFields` and ', - '`.multiple` — the control is a shadcn single-select with no search input, binding ONE ', - 'record id into a page variable, so `searchFields` narrowed nothing and `multiple: true` ', - 'selected nothing extra while reporting success. Either returns the day the capability ', - 'is implemented. Not in scope, and deliberately: `page:card.visible` is a ', - 'component-level visibility predicate written into `properties` and hoisted by the ', - 'renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.\n\n', - 'That count turned out to be incomplete, and a second pass finishes it: five more keys the ', - 'renderers read were still undeclared. Four are plain additions with no behaviour change ', - '(`page:header` `recordChrome`/`showStar`/`showCopyId`, which select between the ', - 'record-chip header and the bare heading a dashboard wants, and `page:accordion.variant`, ', - 'which decides whether the accordion draws its own dividers or leaves the border to each ', - 'panel). The fifth is a rename, and the only one in the family whose defect is structural ', - 'rather than an oversight: the tab strip\'s visual style was declared as ', - '`page:tabs.type`, which collides with the page component\'s OWN dispatch key. objectui\'s ', - '`SchemaRenderer` refuses to hoist `properties.type` for exactly that reason, ', - '`sdui-parser`\'s `BASE_PROPS` contains `type` and skips it before any validation runs, ', - 'and in a flat or JSX carrier the node reads `{ type: \'page:tabs\', … }` so the name is ', - 'already taken. The key was therefore unauthorable in every carrier but the nested ', - '`properties` object, and unvalidated even there. It becomes `tabStyle` — the spelling ', - 'objectui publishes and the renderer already reads first in the flat carriers — which is ', - '`displayField` → `labelField` again: converge on the spelling that works, not the one ', - 'that declares well, and keep one spelling rather than two (Prime Directive #12).\n\n', - 'A third pass closes that reconciliation from the other side, on three keys the two earlier ', - 'passes left standing (maintainer ruling 2026-08-09, decision-inbox round: retire the header icon and ', - 'card actions upstream, and the details layout). Two are the plain B class — declared here, read NOWHERE. ', - '`page:header.icon` is resolved by objectui only per header ACTION (`action.icon`); the ', - "header's own props bag is never asked for one, and `@object-ui/layout`'s `` ", - 'takes an `icon` React prop from a host with no schema fallback beside the ', - '`schema?.actions ?? schema?.properties?.actions` fallback four lines away. ', - '`page:card.actions` has no actions area to render into at all: the card renderer builds ', - 'its `` from `title`, `bordered`, `children` and `footer`, full stop. Both sat in ', - "objectui's own unpublished-exemption map as \"spec declares it, NO renderer read point\", ", - 'which is what put the contract decision — wire it, publish it with a KNOWN GAP marker, or ', - 'retire it — in front of the maintainer; the ruling retired it. Neither has a lossless ', - 'rewrite target (a header has no second icon slot, and moving a card\'s action ids into ', - '`children` as components is a page rewrite, not a mechanical one), so both are pure ', - 'strips. ⚠️ `page:header.actions` is LIVE and untouched — the strip is scoped by component ', - 'type, never by key name.\n\n', - 'The third, `record:details.layout`, is a sharper shape and the one worth reading twice: ', - 'it IS read. The renderer computes ', - "`schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical'`, ", - 'while the declared enum is `auto | custom` — so neither legal value can match, both take ', - 'the same branch, and a key that was accepted and read still selected nothing, under a ', - '`.describe()` promising "auto uses object highlightFields, custom uses explicit sections". ', - 'The behaviour that prose describes is real, but the renderer keys it off whether ', - '`sections` was authored, never off this flag. Every gate stayed green because ', - '`check:react-declaration-parity` compares two DECLARATIONS and objectui declared the same ', - '`auto | custom` enum — perfect agreement over a key nothing honoured — while a THIRD ', - "spelling (`stacked | inline | compact`) sat in `@object-ui/types`' mirror. A pure strip ", - 'for the same reason: `auto`, `custom` and omission were behaviourally identical, so there ', - 'is no value to carry. ⚠️ `record:highlights.layout` is a different, live, honoured key ', - 'and is untouched. objectui\'s side of both rulings drops the exemptions, the input and the ', - 'dead branch on the next pin bump.\n\n', - 'Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave ', - '`AggregationFunction` (ADR-0049). The enum declared eight functions and the SQL ', - 'family compiles five — `SqlDriver.mapAggregateFunc` and the Turso ', - '`RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest ', - 'to one refusal — so three were declared-but-unenforced against the backends this platform ', - 'targets. What makes these two worse than an ordinary inert declaration is that another ', - 'package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and ', - '`string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they ', - "reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of ", - 'the requested value, with no error and no log. The maintainer SPLIT the three rather than ', - 'retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS ', - 'and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard ', - 'staple, already lowered by `service-analytics` — with its SQL implementation following on ', - 'its own card, so that declaration leads its implementation by decision rather than by ', - 'drift. These two take the remove leg: display conveniences with no measured pull, and ', - '`string_agg` never had one shape to lower to (the delimiter is a second argument in ', - 'PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). ', - 'This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no ', - '`retiredKey()` tombstone: the enum error map carries the prescription, keyed on the ', - 'received value so only the two spellings that used to be legal are told they "were ', - 'removed". Of the two authoring surfaces only one is stored metadata: the conversion ', - 'rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with ', - 'neither `aggregate` nor `derived` fails the dataset\'s own refinement, so stripping just ', - 'the key would emit an item that cannot parse) plus any derived measure the drop strands, ', - 'with a notice each. Nothing is lost: `compileDataset` refused both by name already, so ', - 'such a measure never produced a number. `QueryAST.aggregations[].function` is a request ', - 'surface with no stored source — one semantic TODO below. The mongodb and in-memory ', - 'backends that implemented these two were inside the maintainer\'s driver freeze when this was decided ', - '(it was lifted on 2026-08-11) and are untouched; their code is simply no longer reachable ', - 'through a spec-valid request.\n\n', - 'The same aggregation node loses one more member, and it is the sharper class of the two: ', - '`aggregations[].distinct` is removed (ADR-0049, maintainer ruling 2026-08-09). ', - 'The functions above were declared and UNLOWERED — a caller on a SQL datasource got a ', - 'refusal. This flag was declared and lowered by exactly ONE of the six faces that read an ', - 'aggregation: the engine\'s in-memory fallback deduplicated the values before applying the ', - 'function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, ', - '`driver-mongodb`\'s `buildAggregationStage`, `driver-memory`\'s `computeAggregate` and ', - 'service-analytics\' `AGGREGATE_SQL` all ignored it. So the same query answered a ', - 'deduplicated `sum` on the fallback path and an ordinary `sum` on every SQL datasource, ', - 'with the engine choosing between the two per query — by driver, by a non-UTC date bucket, ', - 'by whether the driver aggregates natively at all. That is the divergence class closed on this axis when ', - 'every SQL face got one aggregate spelling and one refusal, still open on this key, and it is worse to sit on because ', - 'the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing ', - 'for a dashboard author to notice. It survived the `QueryAST` sweep of this very schema because ', - 'that sweep asked which members no executor reads, and this one had a reader — the wrong ', - 'question for a key whose defect is WHICH executor reads it. Remove rather than enforce, ', - 'per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL ', - 'lowering has since landed) already covers the only deduplicating spelling with measured ', - 'demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling ', - 'mistake and would have to be lowered across five faces, two of them then frozen under ', - 'the driver freeze (lifted 2026-08-11, after this ruling), to buy it. The blast radius inside the ', - 'fallback is narrower than the key suggests and was ', - 'measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned ', - 'from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe ', - 'does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is ', - '`retiredKey()`-tombstoned rather than bare-deleted: a plain deletion would have made zod ', - 'silently STRIP what callers still send, trading a divergent flag for an ignored one ', - '(ADR-0104). One tombstone covers every aggregation door, because ', - '`QuerySchema.aggregations` and `EngineAggregateOptionsSchema.aggregations` reuse that one ', - 'schema by reference. No conversion: a request surface with no stored source — one ', - 'semantic TODO below, the disposition every other `data.query.*` retirement in this major ', - 'already takes.\n\n', - 'One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape ', - "protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` ", - "resolves to `'service'` instead of `'any'`, so a pausing node type that never states who ", - 'may continue its pauses is refused on the generic resume route rather than open to it ', - '(ADR-0044\'s 2026-07-28 amendment). Nothing is removed and no metadata shape ', - 'changes — the field has been optional since step one of the same issue — so tsc reports ', - 'nothing and only the MEANING of silence moved. That is exactly why it needs a ledger ', - 'entry: a third-party plugin author has no compile error to discover it with, and the ', - 'one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before ', - 'a user meets a run that will not continue.\n\n', - 'The same descriptor loses a key in this step, and the pairing is the point (', - 'ADR-0049). `ActionDescriptor.isAsync` and `ActionDescriptor.supportsPause` were two ', - 'spellings of one capability — "this node type can suspend the run" — and enforcing pauses split ', - 'them by evidence rather than by preference: `supportsPause` took the ENFORCE leg (the ', - 'engine now refuses a suspension the descriptor never declared, at the one seam every ', - 'suspension passes through), and `isAsync` takes the REMOVE leg, because a fresh ', - 'three-repo measurement found zero readers and no consumer it could grow into. What ', - 'makes the duplicate worse than an ordinary inert key is that five shipped descriptors ', - 'WROTE it, so the platform itself modelled a declaration that decided nothing — and a ', - 'plugin author copying `screen` (which declared BOTH) had no way to tell which of the ', - 'two the runtime honoured. It is tombstoned rather than deleted, so the answer arrives ', - 'as a rejection carrying the fix; and because a descriptor lives in executor TypeScript ', - 'rather than in stored metadata, its prescription is a semantic entry below rather than ', - 'a conversion `os migrate meta` could replay.\n\n', - 'The plugin manifest loses its whole `loading` block in this step (ADR-0049, ', - 'maintainer ruling 2026-08-04) — the same enforce-or-remove question asked of a block ', - 'rather than a key, and answered REMOVE on measurement: every reference to ', - '`manifest.loading.*` in objectstack, cloud and objectui lived inside `packages/spec` ', - 'itself, so a full loading policy parsed, entered the manifest, and configured nothing. ', - 'The reason it outranked ordinary inert-key cleanup is that one of its members was ', - '`sandboxing`, declaring process / vm / iframe / web-worker isolation and a service ACL: ', - 'an inert SECURITY control is worse than an absent one, because an author (very often an ', - 'AI, ADR-0033) reads the vocabulary as proof the isolation exists and stops looking. Hot ', - 'reload was a two-source defect on top of that — the retired `PluginHotReloadSchema` was ', - 'the dead one of two vocabularies, and the ruling converges on the live one, ', - '`HotReloadConfigSchema`, which `HotReloadManager` actually reads and which is KEPT ', - 'unenforced as the starting point for a separate future decision. Like `isAsync`, its ', - 'prescription is a semantic entry rather than a conversion: a manifest is not a stack ', - 'collection, so `os migrate meta` has no seam at which to rewrite one.\n\n', - 'The action LOCATION vocabulary loses `global_nav` in this step (ADR-0049, ', - 'maintainer ruling 2026-08-09). It was declared from the day `ACTION_LOCATIONS` was ', - 'written and no product surface ever served it: the console command palette composes its ', - 'groups from nav items, objects, dashboards, pages, reports, recent items and record ', - 'search, and reads no action metadata at all — so an action declaring this location never ', - 'reached a user. What lifts it above ordinary inert-declaration cleanup is that the ', - 'authoring tool PROMISED the surface: the Studio designer previewed a mock ', - '`⌘K · Command palette` frame for exactly this value, so an author (very often an AI, ', - 'ADR-0033) declared it, watched it "render", shipped it, and got nothing — the ADR-0078 ', - 'shape arriving through a location vocabulary rather than through a missing key. It was ', - 'retired rather than implemented because the demand evidence is empty: no user has asked ', - 'for command-palette actions and the only two declarers were our own showcase corpus, so ', - 'wiring the palette would have been capability expansion with no pull. This is an enum ', - 'VALUE, not a key, so — as with `crypto.hash` and the two aggregate functions above — ', - 'there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed ', - 'on the received value so only the spelling that used to be legal is told it "was ', - 'removed". The conversion strips the value from `action.locations` and KEEPS the key even ', - 'when the array empties, because on this surface `locations: []` and an absent ', - '`locations` are different declarations: the empty array is the documented headless shape ', - '(callable over REST/MCP/AI, capability gate and audit trail intact), while an absent key ', - "means nobody placed the action — which is what `packages/lint`'s `action-no-placement` ", - 'warns about. An object-less action, whose only reason for declaring `global_nav` was that ', - 'it has no row and no record header to render on, is therefore migrated to the ', - 'declaration it always meant.\n\n', - 'It also removes the three pass-through-only list-view display keys `striped` / ', - '`bordered` / `virtualScroll` (ADR-0049 enforce-or-remove, maintainer ruling ', - '2026-08-10). All three were graded live on reads that turned out to be forwarding ', - 'copies: the react spec-bridge, plugin-list and plugin-view/app-shell each copy the ', - 'key onto the next node, and the chain ends at ObjectGrid, which never spells any of ', - 'the three — so an author who wrote `striped: true` got a parse-clean no-op, the exact ', - 'silent-no-op shape enforce-or-remove exists to end. Copy-without-apply is dead in ', - 'effect; per the ruling, if objectui wants one of these as real behavior, that is an ', - 'implementation card filed first, and the key stays retired pending it.\n\n', - "Finally it removes the 'pdf' member of `view.exportOptions` formats (maintainer ", - 'ruling 2026-08-12). PDF export was declined platform-side as not planned, so the ', - "member was declared-but-unrenderable: ObjectGrid dropped the format from the export menu ", - "with only a runtime console.warn, so `exportOptions: ['xlsx', 'pdf']` type-checked, ", - 'validated, and silently rendered a menu without PDF. The same ruling adopted the OBJECT ', - 'form for `exportOptions` — `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, ', - 'streaming? }`, exactly the key set the renderer reads, ending the state where no ', - 'declaration was both type-legal and functional — with the legacy bare array still ', - 'accepted and lifted to `{ formats: [...] }` at parse, which is why the conversion strips ', - "only 'pdf' and does not rewrite the array spelling. This is an enum VALUE, not a key, so ", - '— as with `crypto.hash` above — there is no `retiredKey()` tombstone: the format enum\'s ', - 'error map carries the prescription, keyed on the received value so only the spelling ', - 'that used to be legal is told it "was removed", plus a union-level dispatch so the ', - 'refusal is the top-level message in either authored form. The strip keeps an emptied ', - '`formats` array rather than deleting the declaration.', - ].join(''), - conversionIds: [ - 'action-execute-to-target', - 'field-conditionalRequired-to-requiredWhen', - 'agent-tools-to-skills', - 'sharing-rule-access-level-full-to-edit', - 'flow-node-crud-object-alias', - 'flow-node-notify-config-aliases', - 'flow-node-wait-event-config-lift', - 'flow-node-connector-config-lift', - 'flow-node-map-flow-alias', - 'flow-node-subflow-flow-alias', - 'flow-node-script-config-aliases', - 'permission-rls-priority-removed', - 'tool-inert-authoring-keys-removed', - 'app-dead-authoring-keys-removed', - 'app-area-fail-open-gates-removed', - 'action-inert-keys-removed', - 'flow-inert-keys-removed', - 'view-inert-keys-removed', - 'view-list-passthrough-keys-removed', - 'view-export-options-pdf-removed', - 'dashboard-inert-keys-removed', - 'agent-knowledge-removed', - 'skill-trigger-phrases-removed', - 'stack-api-require-auth-removed', - 'datasource-capabilities-removed', - 'datasource-inert-blocks-removed', - 'mapping-inert-keys-removed', - 'book-translations-removed', - 'job-id-removed', - 'translation-validation-messages-removed', - 'flow-node-wait-timeout-keys-removed', - 'datasource-read-replicas-removed', - 'datasource-config-driver-key-aliases', - 'datasource-driver-mongo-to-mongodb', - 'flow-node-script-branch-keys-removed', - 'object-managed-by-system-to-system-data', - 'retry-policy-converged', - 'object-enable-trash-mru-removed', - 'hook-body-crypto-hash-removed', - 'connector-rate-limit-config-removed', - 'dashboard-widget-responsive-removed', - 'dashboard-widget-action-aria-removed', - 'dashboard-widget-compareto-converged', - 'theme-inert-token-scales-removed', - 'page-header-subtitle-alias', - 'object-index-type-partial-removed', - 'field-mapping-transform-removed', - 'record-picker-display-field-to-label-field', - 'record-picker-inert-keys-removed', - 'page-card-body-to-children', - 'dataset-measure-array-string-agg-removed', - 'inline-action-api-params-to-body-extra', - 'page-tabs-type-to-tab-style', - 'page-structure-inert-keys-removed', - 'record-details-layout-removed', - 'app-hidden-to-unpublished', - 'action-global-nav-location-removed', - ], - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - { - id: 'action-descriptor-is-async-retired', - surface: 'ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)', - replacement: - 'nothing to re-declare — delete the key. Suspension is `execute()` RETURNING ' - + '`suspend: true`, and permission to suspend is `supportsPause: true` on the same ' - + 'descriptor (with the `resumeAuthority` its pauses need)', - reason: - 'ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow ' - + 'awaiting an external reply" and NOTHING read it: a fresh three-repo measurement ' - + '(taken when the key was filed for retirement, and re-run at pickup) found zero ' - + 'property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of ' - + 'five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. ' - + 'So declaring it never made a node suspend and omitting it never stopped one, ' - + 'which is the silently-inert declaration ADR-0049 exists to end. It was always a ' - + 'second, weaker spelling of the capability `supportsPause` states, and the two ' - + 'diverged in exactly the way a duplicated declaration does: `screen` declared ' - + 'both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing ' - + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — ' - + '`AutomationEngine` now refuses a suspension whose type does not declare ' - + '`supportsPause: true` — so the capability this key gestured at is now a real, ' - + 'enforced fact under one name. This one had no consumer to grow into and takes ' - + 'the remove leg. ' - + 'Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from ' - + "an executor's TypeScript, never stored in stack metadata — no stack, example or " - + 'template carries the key — so there is no source for the chain to rewrite and ' - + '`os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` ' - + 'and descriptor authors delete the key themselves; that rejection (a `tsc` error ' - + 'at the authoring site, and a parse error inside `defineActionDescriptor`) is the ' - + 'channel a third-party plugin author actually meets. The ' - + '`EnhancedApiError.fieldErrors` disposition, one layer down.', - acceptanceCriteria: - 'No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, ' - + '`wait`, `approval`, `approval_revise`), not a plugin\'s. Every node type that ' - + 'returns `suspend: true` from `execute()` declares `supportsPause: true` on its ' - + 'descriptor together with a `resumeAuthority`, and its runs still pause and resume ' - + 'as before: the behaviour never depended on `isAsync`, so deleting the key changes ' - + 'no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails ' - + '`defineActionDescriptor()` at runtime with the prescription, instead of parsing ' - + 'clean and being stripped.', - }, - { - id: 'action-descriptor-resume-authority-default-flip', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). - surface: - 'automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing ' - + 'node descriptor (supportsPause: true, or any executor whose execute() returns ' - + 'suspend: true)', - replacement: - "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose " - + 'pauses really are meant to be continued through the generic resume route ' - + '(POST /automation/:name/runs/:runId/resume) — a screen-style collected-input ' - + "pause, or a signal wait an external producer resumes. Declare 'service' instead " - + 'if continuing is the tail of a decision your own service must authorize and ' - + 'record first. Either value is a one-line addition; only the silence changed ' - + 'meaning', - reason: - 'A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as ' - + "protocol 12's `rest-requireauth-default-flip`, and it is registered here for the " - + 'same reason: whether a given pause is genuinely open to the generic route is a ' - + 'trust judgment no transform can make. The generic resume route\'s authorization gate ' - + 'keys on the SUSPENDED ' - + "NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a " - + 'pausing node type shipped raw-resumable unless its author remembered the field. ' - + "It now resolves to `'service'` when absent: an unclaimed pause is refused on the " - + 'generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may ' - + 'continue it. The revise-window incident decided the direction — ADR-0044 pointed ' - + "an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and " - + 'the pause standing in a service-owned position inherited a fail-open value nobody ' - + 'chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote ' - + 'run. The two possible mistakes are asymmetric, which is the whole argument: ' - + "guessing `'any'` walks past a decision nothing recorded and is silent, while " - + "guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface " - + 'is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no ' - + 'source for a D2 conversion to rewrite and deliberately no schema tombstone — the ' - + 'disposition `data-driver-find-stream-retired`, `storage-service-list-retired` ' - + 'and `actor-user-roles-to-positions` already carry. It differs from ' - + 'those in one way a reader should not have to infer: nothing is REMOVED, so tsc ' - + 'reports nothing at all — the field was already optional after step one and an ' - + 'omission still compiles. The enforced channels are all run-time: a registration ' - + 'warning naming the node type (once per type per engine), the refusal message on ' - + 'the resume itself, and `check:resume-authority-declared` for executors living in ' - + 'this repo. For a third-party plugin the generated upgrade guide is the only ' - + 'channel that arrives BEFORE a user hits a run that will not continue. In-tree the ' - + 'flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, ' - + 'approval, approval_revise) declare their authority explicitly. ADR-0044 amendment ' - + '(2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam ' - + 'addendum to ADR-0019 (approval as a flow node).', - acceptanceCriteria: - 'Every action descriptor your plugin registers for a node type that can suspend ' - + 'declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but ' - + 'never declares resumeAuthority` warning naming one of your types, and a run parked ' - + 'on each of your pausing nodes can still be continued the way you intend: a resume ' - + "through the generic route succeeds for the ones you declared `'any'`, and answers " - + "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue " - + 'through your own service API instead. ⚠️ `supportsPause` is no longer the ' - + 'declaration nothing enforced: an executor whose ' - + '`execute()` returns `suspend: true` while leaving `supportsPause` false is still ' - + 'warned about by neither warning channel, but ' - + '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the ' - + 'one seam every suspension passes through — a guard-class failure no `fault` edge ' - + 'routes — so it needs no hand-check. The residue that does: an executor registering ' - + 'NO descriptor declares nothing for either warning or the refusal to read, so its ' - + 'pauses are still created and refused only later, on the resume route.', - }, - { - id: 'action-session-roles-to-positions', - surface: 'ui.actionSession.roles', - replacement: 'ui.actionSession.positions (an action body reads `ctx.session.positions`)', - reason: - 'The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this ' - + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed ' - + 'outright), while the ACTION body\'s `ctx.session` carries it ' - + 'produced-and-really-populated. `buildActionSession()` ' - + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` ' - + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under ' - + 'the one spelling that ADR bans — so a body author met two different answers to one ' - + 'key name on one platform: rejected in a hook, live and full of values in an action. ' - + 'The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare ' - + 'the shape as it stands first, then rename on the typed face): phase 1 declared the ' - + 'previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. ' - + '`positions` is now the canonical key on that schema and `roles` a deprecated alias of ' - + 'it; the producer emits both for one deprecation window (the runtime half of the same ' - + 'ruling), after which `roles` is removed on the path the v16 session-alias removal ' - + 'already walked (the hook session\'s `tenantId` alias: deprecated first, removed in the ' - + 'next major). ' - + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' - + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' - + 'dispatch and never persisted, so no `sys_metadata` row, example or template can ' - + 'carry the key — the `openApi31` / `activationEvents` / ' - + '`hook-context-session-roles-retired` shape. SECOND, the only place the key ' - + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed ' - + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform ' - + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the ' - + 'ADR-0090 wave delegated `current_user.roles` to the author at step 13 ' - + '(`cel-current-user-roles-to-positions`) instead of substituting text. ' - + 'Note what is deliberately NOT done here: the alias is not tombstoned. A ' - + '`retiredKey()` REJECTS the key, and a deprecation window exists precisely so the ' - + 'old spelling keeps working while its readers move — tombstoning during the window ' - + 'would be the removal it is meant to defer. The tombstone (or the plain deletion the ' - + 'authorable-surface ratchet adjudicates) belongs to the release that closes the ' - + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated ' - + 'upgrade guide are how a reader learns the rename before the removal reaches them. ' - + 'ADR-0090 D3, ADR-0087.', - acceptanceCriteria: - 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` ' - + 'and observes the same array (the rename is a rename — the VALUE is ' - + '`ExecutionContext.positions` on both sides, which the runtime pin ' - + '`action-session-shape-contract.test.ts` asserts independently of the key name). ' - + 'Privilege is NOT re-derived from either spelling: a read that was ' - + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security ' - + 'service (capability grants / placements / derived posture, ADR-0095), never ' - + 'renamed to `positions.includes(\'admin\')` — renaming that read migrates the defect ' - + 'rather than the code. Verify against a real dispatch, not a fixture: invoke an ' - + 'action as a caller holding positions and assert the body observed them under the ' - + 'canonical key. During the window both keys are present and equal, so a reader can ' - + 'be migrated and verified before the alias is removed; after it, `roles` is absent ' - + 'and a body still reading it sees `undefined` — which is why the read must be moved ' - + 'inside the window rather than at its close.', - }, - { - id: 'actor-user-roles-to-positions', - surface: 'action body / AI route: ctx.user.roles (req.user.roles)', - replacement: - 'ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, ' - + 'under the one spelling ADR-0090 D3 sanctions', - reason: - 'The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose ' - + 'surface the spec never declared. `ActorUser` ' - + '(`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` ' - + 'envelope handed to an action body as `ctx.user` and to an AI route handler as ' - + '`req.user`; it declared `positions` and `roles` side by side and filled them from a ' - + 'SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical ' - + 'on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, ' - + 'published straight into author-written code. The maintainer ruled it closed IMMEDIATELY ' - + '(2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation ' - + 'window, no dual-emit, the alias simply gone in 17. ' - + '⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: ' - + '`action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached ' - + 'through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, ' - + 'same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while ' - + '`ctx.session.roles` still answers for the length of its window. ' - + 'What makes this entry different in KIND from both session-side siblings: `ctx.user` has ' - + 'no spec schema and never had one. It is a runtime TS interface, so unlike ' - + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` ' - + 'once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared ' - + 'contract-first, as it stood, as the first stage of the session rename, precisely so its ' - + 'key could be renamed), there is no schema key here to tombstone and no ' - + '`retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` ' - + 'through a `.parse()`, so a prescription there would have no one to reach. The enforced ' - + 'channel is tsc, and it reports at the READ site inside the author\'s own body; for an ' - + 'untyped or sandboxed body there is no enforced channel at all, which is exactly why ' - + 'this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide ' - + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / ' - + '`IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no ' - + 'tombstone, tsc at the call site — applied to a surface that lives one layer further ' - + 'out than either: those two are at least DECLARED in `packages/spec/src/contracts`, ' - + 'this one only in `packages/runtime`. ' - + 'Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent ' - + 'grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` ' - + 'is constructed per dispatch and never persisted, so no `sys_metadata` row, example or ' - + 'template can carry the key (the `openApi31` / `activationEvents` / ' - + '`hook-context-session-roles-retired` shape). SECOND, the only place the key is ' - + 'ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or ' - + 'a sandboxed script. A declarative transform cannot safely rewrite an identifier inside ' - + 'free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to ' - + 'the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting ' - + 'text. ' - + 'The removal\'s hard precondition was met before it landed, and the result is recorded ' - + 'here because the ledger is where an upgrading consumer meets it: the declaration\'s own ' - + 'comment claimed the alias was "kept for the REST/AI shapes", and that claim was ' - + 'DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all ' - + 'of them in the pins the removal flipped; the four `ActorUser` construction sites build ' - + 'server-side envelopes that never enter a response body; objectui\'s `.roles` reads ' - + 'belong to two unrelated producers (the better-auth session, and the ' - + '`/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and ' - + 'is the one consumer face left unverified — this entry, and the changeset\'s FROM/TO ' - + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; ' - + 'every such read is `.positions` and observes the SAME array — the value was ' - + '`ExecutionContext.positions` on both sides, so this is a pure key rename and no value ' - + 'has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was ' - + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security service ' - + '(capability grants / placements / derived posture, ADR-0095), never renamed to ' - + '`positions.includes(\'admin\')` — renaming that read migrates the defect rather than the ' - + 'code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is ' - + 'already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed ' - + 'one silently sees `undefined` — move the read AS you upgrade, not after it. Verify ' - + 'against a real dispatch rather than a fixture: invoke an action (and an AI route) as a ' - + 'caller holding positions, assert the body observed them under the canonical key, and ' - + 'assert the old key is ABSENT by key existence (`\'roles\' in ctx.user === false`) rather ' - + 'than by `undefined`, which cannot tell a removed key from one left behind holding ' - + 'nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way.', - }, - { - id: 'aggregation-node-distinct-retired', - surface: 'data.query.aggregations[].distinct', - replacement: - 'the `count_distinct` aggregation FUNCTION for a deduplicated count — the one ' - + 'deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on ' - + 'both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it ' - + 'declared and retired `array_agg` / `string_agg`. ' - + '`SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no ' - + 'replacement: no backend ever computed them here, and a per-row measure that needs ' - + 'deduplicating before summing is a modelling problem to fix in the data', - reason: - 'A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the ' - + '`QueryAST` members no executor runs, the one that dispositioned every other ' - + '`data.query.*` member. That sweep asked which keys ' - + 'no executor reads; this one HAD an executor, exactly one out of six. The engine\'s ' - + 'in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the ' - + 'values before applying the function, while `SqlDriver.aggregate`, the Turso ' - + '`RemoteTransport.aggregate`, `driver-mongodb`\'s `buildAggregationStage`, ' - + '`driver-memory`\'s `computeAggregate` and service-analytics\' `AGGREGATE_SQL` all ' - + 'ignored the key. So `{ function: \'sum\', field: \'amount\', distinct: true }` ' - + 'answered a deduplicated sum when the engine fell back in memory and an ordinary sum ' - + 'on every SQL datasource: one query, two numbers, chosen by which backend happened ' - + 'to serve it — and unlike the divergences closed earlier on the same axis (an ' - + 'aggregate function name the remote Turso face compiled and the local face refused, and ' - + 'an unsupported function thrown as a bare error with no code on both SQL faces), the ' - + 'wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced ' - + 'it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — ' - + '`count` returned from its own branch before reaching the dedupe, `count_distinct` ' - + 'fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move ' - + '`min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): ' - + '`count_distinct` already covers the only spelling anyone has measured demand for, ' - + 'and lowering `SUM(DISTINCT …)` across five faces — two of them, driver-memory and ' - + 'driver-mongodb, then under the maintainer\'s 2026-08-05 investment freeze, a freeze ' - + 'lifted 2026-08-11, after this ruling — ' - + 'buys a shape that is near-universally a modelling mistake. A REQUEST surface — ' - + '`QueryAST` is the client SDK builder\'s output and the `POST /data/:object/query` ' - + 'body, never stored in stack metadata — so there is no source for the chain to ' - + 'rewrite and callers move their own queries: the disposition that sweep gave ' - + '`joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ' - + 'ADR-0049.', - acceptanceCriteria: - 'No caller sends `distinct` inside an `aggregations[]` entry, on the wire or through ' - + 'the SDK; a deduplicated count is written as `{ function: \'count_distinct\', field }` ' - + 'and reads the same number on every backend. A query still carrying the key fails to ' - + 'parse with the removal prescription — including through ' - + '`EngineAggregateOptionsSchema`, which reuses `AggregationNodeSchema` by reference — ' - + 'and `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a ' - + '`fields[]` entry at `aggregations..distinct` instead of serving a number. ' - + 'Authoring it is a `tsc` error at the call site. ⚠️ The observable NUMBERS change on ' - + 'exactly one path and that is the point of the change: a `sum`/`avg` that used to be ' - + 'deduplicated by the in-memory fallback now answers what every SQL face has always ' - + 'answered for the same query. Verify against the SQL answer, not against the ' - + 'pre-upgrade fallback answer — the two disagreed, which is why the key is gone.', - }, - { - id: 'analytics-query-request-envelope-retired', - surface: 'api.analyticsQueryRequest.query', - replacement: 'bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)', - reason: - 'The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded ' + - 'analytics shim (the fallback that answered /analytics/query when no analytics service ' + - 'was installed, and dropped the caller\'s identity and its `where` filter at the door), ' + - 'never stored in stack metadata — there is no source for the ' + - 'chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the ' + - 'query.* fields to the body top level themselves.', - acceptanceCriteria: - 'Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and ' + - 'succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription.', - }, - { - id: 'analytics-query-request-format-retired', - surface: 'api.analyticsQueryRequest.format', - replacement: '(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)', - reason: - 'The `format` key was declared but never implemented (declared ≠ enforced): every ' + - 'response is the JSON envelope regardless of the requested value, so there is no ' + - 'behaviour to preserve and nothing stored to rewrite.', - acceptanceCriteria: - 'No /analytics/query or /analytics/sql call sends `format`; exports go through the ' + - 'export surface.', - }, - { - id: 'api-runtime-create-withdrawn', - surface: 'PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)', - replacement: - 'Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) ' - + 'and ship it through `publishPackage`', - reason: - 'The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never ' - + 'honoured it. Measured on a real showcase boot: `PUT /api/v1/meta/api/' - + 'e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the ' - + 'declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` ' - + 'line, because the endpoint was never in the index to be excluded from. The serving ' - + 'criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> ' - + "`MetadataManager.listForIndex('api')`, which reads the manager's registry plus its " - + 'registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands ' - + 'in `sys_metadata`, which is in neither. A declared capability the runtime does not ' - + 'honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s ' - + 'forever is its most dangerous shape for the AI authors ADR-0033 targets. The ' - + 'maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because ' - + 'making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and ' - + "the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no " - + 'business pull for Studio-authored endpoints today (zero `.api.*` artifacts author ' - + 'them at runtime; showcase uses the artifact route, and its declared endpoints serve ' - + 'live). ' - + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' - + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' - + 'authorable one, and the artifact route it points authors toward is untouched — a ' - + '`**/*.api.ts` file valid before this change is valid after it, byte for byte. What ' - + 'changed is a runtime HTTP verdict, so it is one semantic TODO for operators and ' - + 'Studio callers rather than a stack conversion — the same disposition ' - + '`BatchOptions.validateOnly` takes. Consequently `gateApiDraftsForPublish` ' - + 'is retired with it: it gated a promotion into a state the matcher can ' - + 'never read, and with the inlet closed no `api` draft can exist for it to judge. ' - + 'Re-entry is recorded in the ruling: if the Studio metadata-coverage work promotes `apis` ' - + 'to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — ' - + 'implementation first, ' - + 'declaration second. The same refusal closes the direct-active write too, which had been a ' - + 'third path past the endpoint namespace and duplicate-path gates. ADR-0049 / ADR-0121.', - acceptanceCriteria: - 'No caller creates or updates an `api` item through the runtime metadata API. ' - + '`PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body ' - + 'naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' - + 'prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` ' - + 'as well as direct-active, because the gate runs before the draft/publish branch and ' - + 'does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole ' - + 'point of the change: a stack declaring `apis:` still compiles, still passes ' - + '`validateApiEndpointDeclarations` at publish (`publishPackage`) and at load ' - + '(`buildEndpointIndex`), and its endpoints still SERVE — that route was ' - + 'always the only one that served. An operator who genuinely needs the runtime door ' - + 'back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape ' - + 'hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and ' - + 'the endpoint still will not be served, which is why it is a diagnostic and not a ' - + 'workaround. Any `api` rows already sitting in `sys_metadata` from before this change ' - + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not ' - + 'gated by this refusal, so repair stays possible).', - }, - { - id: 'apimethod-enum-shrink', - surface: 'data.object.enable.apiMethods (the eight legacy non-primitive values)', - replacement: - 'the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: ' - + 'replace each legacy value with the primitives it derives from, de-duplicate, and ' - + 'delete the key entirely if the result names all six', - reason: - 'The authored `enable.apiMethods` enum is now exactly the six primitives. The eight ' - + 'legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, ' - + '`import`, `export` — are no longer authorable, because they are DERIVED effective ' - + 'operations resolved by the server\'s single derivation table, and an enum that lets an ' - + 'author name both a primitive and something derived from it has two spellings for one ' - + 'fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; ' - + '`import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; ' - + '`history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, ' - + 'because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ' - + 'ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That ' - + 'last row is why this is a semantic entry and not a mechanical conversion, and the ' - + 'reason is a security one: the mapping WIDENS. An allowlist naming `history` was ' - + 'granting read of one record\'s audit trail; rewritten to `get` it grants ordinary ' - + 'record reads, and an allowlist naming `search` becomes a grant of full `list`. A ' - + 'transform that applied the table silently would broaden real API permissions without ' - + 'anyone reading the diff, so the rewrite is delegated to the author with the widening ' - + 'flagged. The reporter codemod exists for exactly that shape: `node ' - + 'scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact ' - + 'replacement per site, and FLAGS the allowlists the mapping would widen so the edit ' - + 'stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing ' - + '(permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what ' - + 'an author may newly write. Registered late, by the stock reconciliation that compared ' - + 'the breaking changesets already on the v17 release train against this ledger: the enum ' - + 'shrink (phase 2 of the programme that made UI action buttons agree with the ' - + '`apiMethods` allowlist) predates the gate that makes a breaking changeset state its ' - + 'ledger disposition. ADR-0087.', - acceptanceCriteria: - 'No authored `enable.apiMethods` array names a legacy value; `objectstack validate` ' - + 'passes. Run the reporter codemod first and read its widening flags before applying ' - + 'anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For ' - + 'every object where `history` became `get` or `search` became `list`, confirm the ' - + 'broader operation is one the API should genuinely expose; where it is not, the answer ' - + 'is not a different value in this enum but a permission set that withholds the ' - + 'operation. Where the six primitives are all present, prefer deleting the key: that is ' - + 'equivalent to default-open and it tracks future primitives, whereas a hand-listed six ' - + 'silently stops granting anything added later. `restore` / `purge` are deleted with no ' - + 'replacement — if trash-like behaviour was being relied on, that capability left in the ' - + '11.0 dead-property removal and this entry is not where it returns.', - }, - { - id: 'approval-escalation-enabled-default-flip', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). - surface: - 'automation.ApprovalEscalation.enabled — an OMITTED value inside an approval ' - + "node's escalation block", - replacement: - 'nothing, for the common intent (escalate on timeout): an escalation block ' - + 'carrying timeoutHours is live by default. To declare an SLA OFF while keeping ' - + 'its configuration, write enabled: false explicitly — which is now the spelling ' - + 'the escalation sweep actually reads', - reason: - 'A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real ' - + "(maintainer ruling 2026-08-27, which moved the declared default to what the sweep had " - + "always done) — the same category as protocol 17's " - + '`import-run-automations-declared-default-corrected`: the schema promised ' - + '`enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never ' - + 'read the key at all — any escalation block with a positive `timeoutHours` ' - + 'escalated, and with `action: \'auto_approve\'` that silently approved requests ' - + 'their author had declared off the clock. The flip moves the default to `true` ' - + 'and, in the same change, the sweep starts honouring an explicit ' - + '`enabled: false`. The feature-level switch is whether an `escalation` block ' - + 'exists at all; within a block carrying `timeoutHours`, escalation is on unless ' - + 'explicitly turned off. Deployed metadata that OMITS `enabled` does not change ' - + 'behaviour: it escalated before (the sweep ignored the key) and escalates after ' - + '(the parse materializes `true`). Stored request snapshots written before the ' - + 'flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses ' - + 'config through the old schema before snapshotting), so the sweep keeps a ' - + 'read-side legacy window keyed on the snapshot\'s `created_at`: pre-flip ' - + 'snapshots keep escalating exactly as they do today, and the window retires ' - + 'itself as those pending requests drain. What DOES change is that an explicit ' - + '`enabled: false` finally binds — a flow that authored it (e.g. the console ' - + 'toggle switched off after a timeout was set) stops escalating on requests ' - + 'opened after the upgrade, which is the declared intent being honoured.', - acceptanceCriteria: - 'A flow whose approval node omits `enabled` inside `escalation` still escalates ' - + 'on timeout (no metadata edit needed). A flow that writes `enabled: false` ' - + 'stops escalating for newly opened requests — verify one such request stays ' - + 'pending past its `timeoutHours` with no `escalate` audit row and no ' - + 'auto-decision. Requests opened BEFORE the upgrade keep their pre-upgrade ' - + 'behaviour (they escalate) regardless of the stored `enabled` bit. Clients ' - + 'that parse metadata through the published JSON Schema now materialize ' - + '`enabled: true` where they materialized `false`; a client that needs the SLA ' - + 'off must write it explicitly.', - }, - { - id: 'audit-log-action-enum-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - "sys_audit_log.action — the values 'export' and 'permission_change' left the select " - + 'enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/' - + 'sys-audit-log.object.ts). The same two values also left the shipped list-view ' - + "filters on that object: 'permission_change' from the auth_events view and 'export' " - + 'from the config_changes view', - replacement: - 'nothing, for either value — both are removed rather than renamed, because neither ' - + 'named an event this platform records. For permission changes, read the ordinary ' - + '`create` / `update` rows on the permission objects themselves: a grant or binding ' - + 'write is an ordinary record write and the generic audit writer already ledgers it, ' - + 'so a second semantically-duplicate row was never minted. For `export` there is no ' - + 'replacement and nothing is lost: no export feature ever wrote an audit row. A ' - + 'consumer filtering `sys_audit_log` on either value was reading an empty result set ' - + 'on every deployment, and still is — what changed is that the contract no longer ' - + 'promises otherwise', - reason: - 'Maintainer ruling 2026-08-12 on the audit log\'s writerless actions, the retirement half ' - + 'of a two-half verdict: the cheap writers get built (`login` / `logout` on the auth ' - + 'session hooks, `config_change` from the settings service) and the enum ' - + 'values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的' - + '过滤器是可见产品缺陷;审计面宁窄勿谎. ' - + 'The defect was false compliance on a COMPLIANCE surface, which is the sharpest form ' - + 'of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the ' - + 'platform captured permission changes and data exports, and the shipped list views ' - + 'and dashboard widgets showed them a filter and a tile for exactly those events. ' - + 'Both were permanently empty. Measured by enumerating every `sys_audit_log` writer ' - + 'in the repo — there are exactly two: plugin-audit`s generic hook writer, whose ' - + '`actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing ' - + 'else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or ' - + '`permission_change`. ' - + 'This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement ' - + 'in the two ways `hook-body-crypto-hash-removed`, ' - + '`dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` ' - + 'already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) ' - + 'and the four surface ratchets are expected to be byte-identical (no def changed). ' - + 'It differs from all three in being a SEMANTIC entry rather than a D2 conversion, ' - + 'and the reason is that there is no source to rewrite: `sys_audit_log` is a ' - + 'platform-owned, append-only object whose every field is `readonly: true`. Nobody ' - + 'authors an audit row and nobody authors this enum — the values appear only in rows ' - + 'the runtime writes and in queries consumers send. A conversion rewrites authored ' - + 'metadata or a stored `sys_metadata` row; this surface is neither, so the ' - + 'disposition is the one `BatchOptions.validateOnly` and the notification cursor ' - + 'already take in this major. ' - + '⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a ' - + 'row with either value keeps it, and keeps reading it back: the enum is not enforced ' - + 'on this object at all (`validateRecord` skips `readonly` fields, and every field ' - + 'here is readonly), so nothing rejects stored history and no backfill is required or ' - + 'wanted. Deleting audit history to satisfy a schema narrowing would be the one ' - + 'genuinely destructive reading of this change. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No consumer filters `sys_audit_log` on `action = "export"` or ' - + '`action = "permission_change"` expecting rows: both were empty everywhere before ' - + 'this change, so a query that returned data has not been identified and a query that ' - + 'returned nothing behaves identically. Concretely, check three places. (1) Saved ' - + 'queries, dashboards and reports over `sys_audit_log`: a filter naming either value ' - + 'should be deleted, not re-pointed — for permission auditing, filter the permission ' - + 'objects` own `create`/`update` rows by `object_name` instead. (2) Any code branching ' - + 'on the action string (a badge map, a label switch, an `if (row.action === ...)`): ' - + 'the arms for these two values are now unreachable and should go, and a `switch` with ' - + 'an exhaustiveness check over the enum type will now fail to compile if they stay — ' - + 'that compile error is the enforced channel for TypeScript consumers. (3) Custom ' - + 'objects or plugins inserting `sys_audit_log` rows with either value: this is the ' - + 'only case that needs a real decision, because the write will NOT be refused ' - + '(readonly fields are not validated) — it will simply be a row whose action the ' - + 'object no longer declares. Pick a declared value or open an issue for the action ' - + 'you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is ' - + 'append-only and stays exactly as written.', - }, - // The third and last value retired from `sys_audit_log.action` in this major, - // and a separate entry from `audit-log-action-enum-retired` on purpose: two - // cards registering DIFFERENT entries merge clean, two cards editing the SAME - // entry collide in git — which is the layout `entries/README.md` chose on a - // registry where a dropped entry produces no error anywhere. - { - id: 'audit-log-action-restore-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - "sys_audit_log.action — the value 'restore' left the select enum declared by " - + 'plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). ' - + 'It also left the shipped writes_only list-view filter on that object, and the ' - + 'generated option label in all four plugin-audit translation bundles', - replacement: - 'nothing — the value is removed rather than renamed, because it never named an event ' - + 'this platform records. There is no undelete or restore capability to point at: ' - + 'deletes are hard deletes, and the record-level audit writer maps the ObjectQL ' - + 'lifecycle to `create` / `update` / `delete` only. A consumer filtering ' - + '`sys_audit_log` on this value was reading an empty result set on every deployment, ' - + 'and still is — what changed is that the contract no longer promises otherwise. If ' - + 'you were counting on a restore trail, the capability itself is the missing piece ' - + '(an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built ' - + 'yet), not this enum row', - reason: - 'The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one ' - + "value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 " - + 'widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. ' - + '`restore` is the least ambiguous member of the family: the record-level writer ' - + "could not have produced it even by accident, because `actionFor()` in " - + "audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller " - + 'early-returns on null. A tree-wide search finds no other producer. ' - + 'What made it a card rather than a tidy-up is that TWO shipped declarations ' - + 'asserted the opposite, so a declaration-reading audit scored the action as ' - + 'covered: the `writes_only` list view offered it as a filter value, and the module ' - + 'docblock of auth-event-audit.ts named it among the actions the writer emits. The ' - + 'comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a ' - + 'credential-storage audit had to settle by re-measuring two "hashed at rest" comments) — a ' - + 'sentence next to a mechanism, contradicted by the type signature of that very ' - + 'mechanism, with nothing in CI able to tell. Both declarations are corrected in one ' - + 'change, and the invariant behind the comment (every declared action has a writer) ' - + 'now has a pin test under it rather than prose. ' - + 'Bookkeeping is identical to the sibling entry, for the same reasons: an ' - + 'enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY ' - + 'changed) and leaves the four surface ratchets byte-identical (no def changed), and ' - + 'it is a SEMANTIC entry rather than a D2 conversion because there is no source to ' - + 'rewrite — `sys_audit_log` is a platform-owned, append-only object whose every ' - + 'field is `readonly: true`, so nobody authors an audit row and nobody authors this ' - + 'enum. ' - + '⚠️ This is a statement about the WRITER, not a product stance against undelete. ' - + 'Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the ' - + 'recycle bin are both held open, not declined. If that capability lands, this value ' - + 'returns WITH its writer — the ' - + 'emission point, its tests, and the view that surfaces it — never as a bare enum ' - + 'row again. ' - + '⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: ' - + 'the enum is not enforced on this object at all (`validateRecord` skips `readonly` ' - + 'fields), so any stored row keeps parsing and reading back, and no backfill is ' - + 'required or wanted. Deleting audit history to satisfy a schema narrowing would be ' - + 'the one genuinely destructive reading of this change. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No consumer filters `sys_audit_log` on `action = "restore"` expecting rows: it was ' - + 'empty on every deployment before this change and behaves identically after it. ' - + 'Concretely, check three places. (1) Saved queries, dashboards and reports over ' - + '`sys_audit_log`: a filter naming `restore` should be deleted, not re-pointed — ' - + 'there is no action that carries the meaning, because the platform records no ' - + 'restore event. (2) Any code branching on the action string (a badge map, a label ' - + 'switch, an option list in an audit-log filter UI): the `restore` arm is ' - + 'unreachable and should go, and a `switch` with an exhaustiveness check over the ' - + 'enum type will now fail to compile if it stays — that compile error is the ' - + 'enforced channel for TypeScript consumers. An option in a FILTER dropdown is the ' - + 'user-visible half and matters most: it offers an operator a choice that returns ' - + 'nothing. (3) Custom objects or plugins inserting `sys_audit_log` rows with this ' - + 'value: the write will NOT be refused (readonly fields are not validated), so it ' - + 'silently becomes a row whose action the object no longer declares. Pick a declared ' - + 'value, or open an issue for the action you actually need. ⚠️ Do NOT migrate or ' - + 'delete existing rows: audit history is append-only and stays exactly as written.', - }, - { - id: 'auth-config-unadvertised-reserved-features', - surface: 'api.authConfig.features.passkeys / api.authConfig.features.magicLink', - replacement: '(removed — no replacement flag; the capabilities are not advertised)', - reason: - 'Both flags were served by `GET /api/v1/auth/config` from introduction and read by no ' - + 'client: no login UI anywhere renders a passkey or magic-link affordance off them, so ' - + 'the payload advertised two sign-in methods a user could never reach, and a deployer ' - + 'setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable ' - + 'effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove ' - + 'over keep-as-reserved, so that a deployer cannot flip a flag that does nothing ' - + 'anywhere). The two are not equally empty: nothing at all is wired behind ' - + '`passkeys`, whereas `magicLink`\'s better-auth endpoints are live and only their ' - + 'advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists ' - + 'an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema ' - + 'tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is ' - + 'conditional: both return to the payload in the change that ships the login UI ' - + '(flag-gated passkey and magic-link entry points, which objectui defers until the ' - + 'maintainer schedules them). ADR-0049.', - acceptanceCriteria: - 'No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; ' - + 'a client that gated UI on either now treats the capability as absent rather than ' - + 'reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` ' - + 'with either key fails to parse with its own prescription instead of being silently ' - + 'stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts ' - + '`/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call ' - + 'directly.', - }, - // The protocol-17 leg of the #4001 unknown-key strictness wave, registered as ONE - // entry for the major rather than one per batch — the shape both existing - // precedents already take (`ui-schemas-strict-unknown-keys` at 15, - // `dashboard-widget-strict-unknown-keys` at 16), and the maintainer's ruling on - // #7630 (2026-08-12) against the two alternatives (eleven entries, or a - // per-surface grouping). An upgrader reads ONE prescription covering the whole - // wave; the per-batch trace lives here so an archaeologist can still walk it - // batch by batch. Batches folded into this entry, in landing order: - // - // 1. `unknown-key-strictness-tier-a` — #4001 Tier-A: `security/permission.zod.ts` - // (permission set / object / field / admin scope) and `automation/flow.zod.ts`'s four outer - // shapes (flow / node / edge / variable), plus the shared `strictUnknownKeyError` factory. - // 2. `unknown-key-strictness-step2` — #4001 step 2: `security/rls.zod.ts`, - // `security/sharing.zod.ts` (base + criteria + recipient), `identity/position.zod.ts`. - // 3. `strict-automation-control-flow-state-machine` — #4001 batch 10: five shapes in - // `automation/control-flow.zod.ts`, six in `automation/state-machine.zod.ts`. - // 4. `unknown-key-strictness-automation-batch11` — #4001 batch 11: flow's six NESTED blocks, - // `time-relative-trigger.zod.ts`, `flow-function.zod.ts`, `webhook.zod.ts`. - // 5. `unknown-key-strictness-ui-batch13` — #4001 batch 13: all four shapes in - // `ui/responsive.zod.ts` (strictness does not recurse, so `PageComponentSchema.strict()` - // had never reached them). - // 6. `unknown-key-strictness-ui-batch15` — #4001 batch 15: `ui/theme.zod.ts` 14/14, - // `ui/chart.zod.ts` 5/7 (two arms left open, measured `no gate`). - // 7. `unknown-key-strictness-ui-batch16` — #4001 batch 16: `AriaPropsSchema` - // (`ui/i18n.zod.ts`), carried as `aria:` on ~30 live shapes under six metadata roots. - // 8. `view-subblock-strictness-batch18` — #4001 batch 18: fifteen `ui/view.zod.ts` - // sub-blocks (view data arms, filter/gantt options, conditional formatting, empty state, - // subforms, submit behaviour). - // 9. `rare-jars-shave` — `ViewItemSchema` split into a strict - // authoring gate and a `.strip()` wire variant; `ViewFilterRuleSchema` and `ListView.sort[]` - // closed with it. - // 10. `user-filters-allow-add-tab-promote-and-close` — #5073: `userFilters.allowAddTab` promoted - // into the contract, `UserFiltersSchema` closed behind it, `ObjectUserFiltersSchema` - // rejecting the three page-only keys. - // 11. `view-union-identity-precondition` — #5599: the `view` write-path union stops - // matching every object — one level ABOVE the object schemas the ten batches closed. - // - // Two adjacent seams are deliberately NOT covered here and are recorded on #7630: the 45 `~` - // rows of the #6350 audit (a ledger file was touched by some commit, which does not prove the - // entry covers that face) and the 7 borderline candidates PR #7624 judged not-owed. - { - id: 'authoring-schemas-strict-unknown-keys', - surface: - 'the protocol-17 authoring schemas closed against undeclared keys by the unknown-key ' - + 'strictness wave — `automation/` ' - + '(flow and its six nested blocks, control-flow, state-machine, webhook, time-relative ' - + 'trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and ' - + '`identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` ' - + 'sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition ' - + 'one level above them', - replacement: - 'declared keys only. Each rejection names the surface, echoes the offending key and — where ' - + 'the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a ' - + 'prescription where a rename would be wrong. A `view` body must additionally carry at ' - + 'least one key some union member declares, discounting the identity keys the write path ' - + 'stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)', - reason: - "zod's default `.strip` discarded any key these schemas did not declare and let the parse " - + 'SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata ' - + 'that quietly ignored what they wrote. Closing them turns that into a loud parse error ' - + '(ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly ' - + 'convertible for the same reason the two precedent entries at majors 15 and 16 are not: ' - + 'an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly ' - + 'the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix ' - + 'the typo, move it to the layer that owns it, or delete dead metadata. The named renames ' - + 'the errors carry are a help, not a transform: a large share of this wave is ' - + 'PRESCRIPTIONS rather than renames precisely because renaming would be wrong ' - + "(`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` " - + 'counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` ' - + 'bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint ' - + 'vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real ' - + 'on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on ' - + '`try_catch` and `context` on a state machine have no key at all). The eleventh member ' - + 'is not an unknown-key close but the same defect one level up — the `view` union had an ' - + 'arm that both stripped and required nothing, so it matched every object and `saveMetaItem` ' - + 'persisted garbage as an ACTIVE view overlay that read back badged valid. ' - + 'This is ONE entry for the whole major by the maintainer\'s 2026-08-12 ruling that the ' - + 'wave is registered one entry per major, not one per batch, mirroring the ' - + "registry's only two precedents of this shape; the eleven batches it folds are the " - + 'changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, ' - + '`-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, ' - + '`view-subblock-strictness-batch18`, `rare-jars-shave`, ' - + '`user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, ' - + 'each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before ' - + 'closing its block: `userFilters.allowAddTab` was already read by objectui, so the ' - + 'maintainer\'s 2026-08-04 ruling declared it in the spec rather than let a correct-looking ' - + 'refusal tell authors to delete a working capability. The entry was registered in the ' - + 'backfill of the v17 train\'s breaking changesets, which had never been compared against ' - + 'the ledger. ADR-0049 / ADR-0078 / ADR-0087.', - acceptanceCriteria: - '`objectstack validate` passes with no unknown-key parse errors on any authoring surface — ' - + 'the sweep is "fix until nothing raises", and every rejection carries its own fix. ' - + '⚠️ Parsing clean is the weaker half on three of these faces, because the key was being ' - + 'dropped rather than refused and a config that silently did nothing looked exactly like ' - + 'one that worked: re-check that responsive/theme/chart styling actually renders as ' - + 'authored, that every `aria` block still names the element it was written for, and that ' - + 'each state machine and loop config still carries the transitions and caps you declared. ' - + 'For stored `view` bodies, `GET /api/v1/meta/diagnostics?type=view` lists every overlay ' - + 'the identity precondition now rejects, one row per view with the reason; each is fixed by ' - + 'giving the body a real view shape or deleting an overlay that was never a view.', - }, - { - id: 'batch-options-validate-only-retired', - surface: 'api.batchOptions.validateOnly', - replacement: '(removed — no dry-run today; open an issue to design a no-commit batch preview)', - reason: - 'The `validateOnly` key promised a dry-run ("validate records without persisting") but no ' - + 'batch surface ever read it — updateManyData / deleteManyData / batchData persist ' - + 'regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ' - + 'ever appeared in an HTTP request body). Callers must stop sending it.', - acceptanceCriteria: - 'No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that ' - + 'includes it answers 400 VALIDATION_FAILED with the retirement prescription.', - }, - { - id: 'batch-row-result-schema-shape', - surface: - 'api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse ' - + '(`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)', - replacement: - '`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on ' - + '`row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row\'s position ' - + 'in the request array)', - reason: - 'The rows the three bulk-write endpoints emitted had drifted from the schema that ' - + 'declared them: `BatchOperationResultSchema`, the client SDK\'s exported ' - + '`BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / ' - + '`data` / `index`, while the wire carried `error: string` / `record` and never sent ' - + '`index` at all. A TypeScript consumer written against the published type compiled, ' - + 'validated and read `undefined` at runtime — the declared-but-not-delivered shape this ' - + 'registry exists to close, on the response envelope (ADR-0119 D4 deferred the ' - + 'reconciliation off a bug fix; this is that tracked change, shipped in the 17 major ' - + 'window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and ' - + '`updateManyData` honour `atomic` carried to those two endpoints, is structured in the ' - + 'same move: the ' - + '`ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered ' - + '`ApiError.code` values (message keeps the human-readable cause and causal row index), ' - + 'so "attempted and undone" vs "never ran" is machine-readable instead of a regex ' - + 'convention. A RESPONSE surface — nothing stored in stack metadata carries a batch ' - + 'row, so there is no source for the chain to rewrite; consumers of the legacy keys ' - + 'move their reads themselves. Off-contract readers only: the legacy keys were never ' - + 'in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: ' - + 'the implementation moves to the schema\'s shape as a hard cut in the 17 major, with no ' - + 'dual-emit transition.', - acceptanceCriteria: - 'No consumer reads `row.error` or `row.record` on a batch result row; failures are read ' - + 'from `row.errors` (message via `errors[0].message`, rollback state via ' - + '`errors[0].code` — ROLLED_BACK / NOT_ATTEMPTED), records from `row.data`, and rows ' - + 'correlate to the request via `row.index`. Every row the three endpoints emit parses ' - + 'under `BatchOperationResultSchema` with those keys present.', - }, - { - id: 'client-delete-result-success', - surface: 'client.DeleteDataResult.deleted (the return of `client.data.delete()`)', - replacement: '`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name', - reason: - '`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a ' - + 'declaration that contradicted it: the interface declared `deleted: boolean` while ' - + '`DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never ' - + 'been declared by any schema and no server path has ever returned it on ' - + '`/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the ' - + 'project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / ' - + '`_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of ' - + 'it, and the claim was false in the one direction that matters: the compiler endorsed ' - + 'the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the ' - + 'branch was never taken; `if (r.success)` was rejected by the compiler and correct on ' - + 'the wire. So this rename REVEALS a defect rather than breaking working code — every ' - + 'reader of the old key was already reading `undefined`, on every deployment and not ' - + 'just some, because the protocol path has always answered `success`. It is registered ' - + 'as a semantic entry rather than a mechanical conversion for the reason the rewrite ' - + 'itself does not capture: the key is one token, but a call site that branched on ' - + '`r.deleted` has been taking the FALSE branch unconditionally since it was written, and ' - + 'whatever that branch did — or skipped — is what actually has to be re-read. There is ' - + 'no authored source for the chain to rewrite either; this is a published TypeScript ' - + 'surface whose enforced channel is tsc at the call site, and for an untyped JS caller ' - + 'there is no constrained channel at all, which is why the ledger entry is the only ' - + 'notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one ' - + 'producer shape, and a consumer accepting two spellings is what contract-first exists ' - + 'to prevent (the same rule already moved the runtime\'s ObjectQL fallback from answering ' - + '`deleted: true` to the declared `success`, on the producer side). No deprecated ' - + '`deleted?: boolean` transition key ships, for the same reason — a transition period is ' - + 'for keys that WORKED, and this one never did. Registered by the stock reconciliation of ' - + 'the v17 train\'s breaking changesets, which had never been compared against the ledger. ' - + 'ADR-0087.', - acceptanceCriteria: - 'No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.' - + 'delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller ' - + 'must be swept by hand because nothing will report it. Nothing about the request, the ' - + 'route, the status codes or the error shapes changes, and no server needs upgrading — ' - + 'the value you may now read is the one that was already arriving. ⚠️ The real work is ' - + 'behavioural: every `if (r.deleted)` has been false since it was written, so re-read ' - + 'what each of those branches was supposed to do. Post-delete cleanup, cache ' - + 'invalidation, audit writes and UI refreshes guarded that way have never run, and ' - + 'switching to `r.success` turns them ON for the first time — verify that is what you ' - + 'want rather than assuming it restores prior behaviour. Any test that passed while ' - + 'asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming.', - }, - { - id: 'connector-inline-authentication-publish-refused', - surface: 'connector.authentication on AUTHORED entries (defineStack `connectors:`, ' + - '`PUT /meta/connector/:name`) — previously refused only on provider-bound instances ' + - '(ADR-0097 §3), now refused on catalog descriptors too', - replacement: 'a catalog descriptor drops `authentication` (or sets `{ type: "none" }`) ' + - 'and documents the auth scheme in `description`; a dispatchable instance declares ' + - '`provider` and references its credential with `auth: { type, credentialRef }` ' + - '(ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape ' + - 'still carries resolved secrets inline.', - reason: - 'A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` ' + - '/ `password` / `clientSecret` is cleartext at rest, readable through the data API (the ' + - 'class a credential-persistence survey measured: any authored artefact whose schema ' + - 'permits an inline credential lands it there). No mechanical rewrite exists: whether ' + - 'the entry should become a `none` descriptor or a provider-bound instance with a ' + - '`credentialRef` — and which secret ' + - 'store receives the credential — is a judgment about the connector, not a rename.', - acceptanceCriteria: - 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no ' + - 'authored entry carries a non-`none` `authentication`; formerly inline credentials are ' + - 'reachable through `credentialRef` resolution and the connector still materializes.', - }, - { - id: 'dashboard-widget-compareto-offset', - surface: "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", - replacement: "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", - reason: - 'The widget declared three comparison arms; the analytics executor implements one shape, ' - + '`{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset ' - + 'path — the spec\'s single author-facing analytics shape — `{ offset }` was forwarded ' - + 'verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, ' - + 'taking the widget down; the arm ever only ran on the legacy inline chart path (measured ' - + 'when all three declared arms were found dead on the dataset path: two silently dropped, ' - + 'this one throwing). ' - + "The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every " - + 'other duration has NO faithful target: `previousPeriod` shifts by the length of whatever ' - + "window the widget's filter resolves to, which equals `7d` only when that window happens " - + 'to be seven days long. Rewriting mechanically would silently change which rows the ' - + 'comparison column counts — a wrong number rather than a missing one, which is strictly ' - + 'worse and exactly the class this convergence exists to end. Re-stating the intended ' - + 'window is a judgment about the presentation, not a transform.', - acceptanceCriteria: - 'No dashboard widget declares `compareTo.offset`. Each former offset comparison states its ' - + "window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` " - + "(or `'previousYear'`), and `dimension` is named wherever the selection dates more than one " - + 'time dimension. `objectstack validate` passes, and each affected widget renders a ' - + '`__compare` column over the window its author intended.', - relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, - }, - { - id: 'data-driver-find-stream-retired', - surface: 'contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream', - replacement: - 'find() with limit/offset — the paged read whose determinism IS enforced ' - + '(IDataDriver.find, data/pagination-conformance.ts)', - reason: - '`findStream` was a REQUIRED contract method documented as "optimized for large ' - + 'datasets to avoid memory overflow", and in two of its three implementations it ' - + 'delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for ' - + 'the ENTIRE result set and then yielded it row by row, so the peak memory a caller ' - + 'was promised protection from was already reached before the first yield. The third ' - + '(`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in ' - + 'that driver never routed through `buildFindOptions`, so it hardcoded ' - + '`projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever ' - + 'observed, because the method had NO caller in either repository: the engine exposes ' - + 'no stream entry, and the REST export, import and bulk-read paths all go through ' - + '`find()`. The ~20 driver test doubles that existed only to satisfy a required ' - + 'method almost all threw `not implemented`, and nothing ever noticed — which is the ' - + 'proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every ' - + 'test double with an implementation of a capability the platform does not have. ' - + 'Rather than build a caller to justify three implementations, the method is retired; ' - + 'a real cursor-based read should return WITH the caller that needs it (ADR-0049 ' - + 'enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never ' - + 'stack metadata — so there is no source for the chain to rewrite, and deliberately ' - + 'no schema tombstone either: nothing ever ran a driver object through ' - + '`DriverInterfaceSchema.parse()`, so a prescription there would have no one to ' - + 'reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ' - + 'ADR-0078.', - acceptanceCriteria: - 'No code calls `driver.findStream(...)`; large reads page through `find()` with ' - + '`limit`/`offset` (which guarantees a total order across the whole walk) or go ' - + 'through the export surface. Drivers and test doubles no longer implement the ' - + 'method — one left behind still compiles and is simply never reached, so removing ' - + 'it is cleanup rather than a break, while a CALLER of it no longer type-checks.', - }, - { - id: 'data-driver-query-omit-object', - surface: - 'contracts.IDataDriver query parameter — find / findOne / count / updateMany / ' - + 'deleteMany / explain', - replacement: - '`DriverQuery` (`Omit`): delete the redundant `object:` key from the ' - + 'query literal at the call site — the object name is already the FIRST argument', - reason: - 'Every one of these methods takes the object name as its first argument, and then ' - + 'required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, ' - + 'with two places for it to disagree. The layers above had already paid for that ' - + 'ambiguity: the objectql engine deliberately writes its key order as `{ ...query, ' - + 'object }` so a smuggled `query.object` cannot override the resolved name, and the wire ' - + 'layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The ' - + 'driver side paid in blanket casts: a direct caller holding only a `where` could not ' - + 'name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / ' - + '`fields` along with it — 20 such sites were measured in the downstream cloud codebase, ' - + 'and a `$like` the type layer would have caught reached runtime there through exactly ' - + 'that hole. This is a TS contract surface with ' - + 'no authored source for the chain to rewrite, which is why it is a semantic entry and ' - + 'not a D2 conversion; for a typed caller the compiler names every site (TS2353 ' - + '`\'object\' does not exist in type \'DriverQuery\'`), and for an untyped JS caller ' - + 'there is no constrained channel at all, which is exactly why this ledger entry must ' - + 'exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it ' - + 'unchanged (excess properties are only rejected on fresh literals), and an ' - + 'implementation still declaring `query: QueryAST` keeps compiling under parameter ' - + 'bivariance. What an implementation may no longer do is READ `query.object` — callers ' - + 'are now entitled to omit it. Registered by the stock reconciliation that compared the ' - + 'breaking changesets already on the v17 release train against this ledger. This change ' - + 'was that audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' - + 'omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose ' - + 'inside other entries, and the only subject-level hit on this interface is ' - + '`data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver ' - + 'call-parameter changes both registered, and both cite this narrowing as ' - + 'background; the larger sibling they derive from never got its own entry. ADR-0087 ' - + '(backfilled by that reconciliation).', - acceptanceCriteria: - 'No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find(' - + '"account", { object: "account", where: … })` becomes `driver.find("account", { where: ' - + '… })`. `tsc` is the verify loop for typed callers and reports every remaining site by ' - + 'name; an untyped JS caller must be swept by hand, because nothing will report it. Any ' - + 'driver IMPLEMENTATION that read `query.object` is rewritten to use the object-name ' - + 'argument instead — that read now yields `undefined` whenever a caller exercises its ' - + 'new right to omit the key, and it fails at runtime rather than at compile time, so it ' - + 'is the one change on this surface a type check cannot find for you.', - }, - { - id: 'data-engine-batch-retired', - surface: 'contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema', - replacement: - '`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata ' - + "protocol's `batchData` with `options.atomic: true` for a batch over one object; " - + '`POST {basePath}/batch` on the wire', - reason: - '`batch?` was declared on `IDataEngine` for as long as that contract existed and was ' - + 'never implemented by any engine: `ObjectQL` has no `batch` method and there is no ' - + 'other engine in the tree. It also had no caller — `DataEngineRequest` was imported ' - + 'by exactly one file, the contract declaring the member. Its entire specification ' - + 'was a three-word doc comment ("Batch Operations (Transactional)"), which settles ' - + 'nothing about partial failure, ordering, cross-object references, rollback scope, ' - + 'or what `transaction: false` was supposed to mean — the questions a batch API ' - + 'exists to answer. Contrast its neighbours `getDefaultDriverName?` / ' - + '`getDriverByName?`, whose optionality is evidenced: each names its implementer and ' - + 'its probing caller. The tell that nobody ever designed against it is in the schema: ' - + '`DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a ' - + 'batch could contain batches, with no statement anywhere about what that meant for ' - + 'ordering or rollback. The only test was a type pin — an ad-hoc object literal ' - + 'carrying a `batch` property, asserting the property was defined — which could not ' - + "fail while the declaration existed and would have passed unchanged for the " - + "member's whole life with no engine implementing it. What it claimed is now covered " - + 'by members that are real, so the removal deletes a false affordance rather than a ' - + 'capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 ' - + "made `batchData`'s `atomic` honest, while the wire batch has always validated with " - + '`CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from ' - + '`api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces ' - + 'only: an engine is CODE, never stack metadata, so there is no source for the chain ' - + 'to rewrite. Deliberately no schema tombstone either — nothing ever parsed ' - + '`DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one ' - + 'to reach; its three `authorable-surface.json` baseline lines and its ' - + '`json-schema.manifest.json` entry are dropped in the same change, deliberately. ' - + 'The enforced channel is tsc. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No code calls `engine.batch(...)` and no type references `DataEngineBatchRequest`; ' - + 'in-process multi-write atomicity goes through `IObjectQLEngine.transaction(cb)`, a ' - + 'batch over one object through `batchData` with `options.atomic: true`, and a ' - + 'cross-object batch over the wire through `POST {basePath}/batch`. Because no engine ' - + 'implemented the member, an implementation left behind still compiles and is simply ' - + 'never reached; a CALLER of it no longer type-checks — and there were none.', - }, - { - id: 'data-field-changed-event-retired', - surface: "api.DataEventType 'data.field.changed'", - replacement: - "the `data.record.updated` event, whose payload already carries the per-field " - + 'detail: `changes` (the changed fields), plus `before` / `after`', - reason: - '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the ' - + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since ' - + 'multi-record predicate writes were given events of their own) ' - + '`data.records.{updated,deleted}`, and no other producer exists in either ' - + 'repository. A subscriber that switched on it was waiting on an event no producer ' - + 'sends: the branch never ran, and because the surrounding `switch` still compiled, ' - + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the ' - + 'event vocabulary). `DataEventSchema` could not have carried the semantics even if ' - + 'something had emitted it — the payload is record-shaped (`recordId`, `changes`, ' - + '`before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member ' - + 'promised a granularity the contract has no room for. Per-field detail is therefore ' - + 'not lost: it has always ridden on `data.record.updated` as `changes`, which is one ' - + 'event per write rather than N events on a wide table. This is a runtime EVENT ' - + 'surface — no stack, example or template authors an event name (webhooks subscribe ' - + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already ' - + 'trimmed to producers that exist) — so there is no source for the chain to ' - + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a ' - + 'retiredKey() fix-it error the way an authorable object key can (the same limit the ' - + 'sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, ' - + 'which ' - + 'fails any consumer still naming the value in a `DataEventType` position, and the ' - + 'enum parse, which now rejects the name instead of accepting an event that never ' - + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest ' - + 'contract the way bulk writes were given theirs. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No consumer subscribes to or switches on `data.field.changed`; per-field change ' - + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / ' - + '`after` for the surrounding state). Deleting the dead branch changes no observable ' - + 'behaviour — it never executed — so the migration is removing code that could not ' - + 'run, not rebuilding a capability.', - }, - { - id: 'datasource-config-inline-credential-refused', - surface: 'datasource.config.password (postgres / mysql / mongo) and ' + - 'datasource.config.authToken (turso)', - replacement: "the datasource secret binder: the Setup → Datasources connection form's " + - 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + - 'or a direct `external.credentialsRef` secrets-store reference', - reason: - 'A datasource artefact is persisted whole into `sys_metadata`, which is served back by ' + - 'the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled ' + - 'on 2026-08-12 to close that per artefact: each schema that admitted an inline credential ' + - 'refuses it at publish and points at the secret mechanism the artefact already had, rather ' + - 'than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: ' + - 'moving the value requires ENCRYPTING it into a `sys_secret` row through a running ' + - "secret binder and deleting the cleartext, which a source-file transform cannot do — " + - 'auto-deleting the key alone would silently drop a live credential instead.', - acceptanceCriteria: - 'Every datasource parses with no `config.password` / `config.authToken` key; each ' + - 'affected datasource carries `external.credentialsRef` (or has its secret bound through ' + - 'the connection form) and still connects; no cleartext credential remains in any ' + - 'stored `sys_metadata` row or authored source.', - }, - { - id: 'datasource-config-placeholder-refused', - surface: 'connection-material string keys of the built-in driver configs — ' + - 'postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/' + - '`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), ' + - 'turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values ' + - 'containing `${…}` placeholder syntax', - replacement: 'the literal value. For secret material, the datasource secret binder: the ' + - "Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle " + - 'stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. ' + - 'For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` ' + - 'and friends are translated into driver config by the boot hosts and never pass through ' + - 'the publish door', - reason: - 'A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is ' + - 'stored verbatim in `sys_metadata` and handed verbatim to the database client at connect ' + - '(measured by the credential census while the inline-credential refusal was being built), ' + - 'so the connection fails, or connects somewhere ' + - 'unintended, with no error naming the unresolved placeholder — the masked-failure shape. ' + - 'The syntax looked supported: it parsed green, stored fine, and failed at a distance; two ' + - 'shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had ' + - 'to warn "do NOT substitute a placeholder" around the broken escape. The maintainer ruled ' + - 'on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; ' + - 'implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and ' + - 'zero measured pull for actual substitution. There is no mechanical rewrite: the ' + - 'placeholder names a value that exists only in the author\'s intended deployment ' + - 'environment, which a source-file transform cannot know — substituting anything would ' + - 'invent a connection target.', - acceptanceCriteria: - 'Every datasource parses with no `${…}` span in any connection-material config value; ' + - 'environment-driven deployments carry their DSN in the runtime environment ' + - '(`OS_DATABASE_URL` and friends) or bind secrets via `external.credentialsRef`, and ' + - 'still connect; no unresolved placeholder remains in any stored `sys_metadata` row or ' + - 'authored source.', - }, - { - id: 'datasource-config-url-userinfo-refused', - surface: 'datasource.config.url (postgres / mysql / mongo / turso) and ' + - 'datasource.config.syncUrl (turso) — the URL userinfo password segment ' + - '(`user:password@host`)', - replacement: 'the same URL with its userinfo password removed (a bare `user@host` stays ' + - "legal), plus the datasource secret binder: the Setup → Datasources connection form's " + - 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + - 'or a direct `external.credentialsRef` secrets-store reference', - reason: - 'The inline-credential closure refused the credential KEYS, and building it measured that ' + - '`config.url` still accepted the identical secret one syntax over — ' + - '`postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as ' + - '`config.password` did, and the key refusal itself steered authors there. The maintainer ' + - 'ruled on 2026-08-12 (Option A) to refuse the URL userinfo password at publish, through ' + - 'one value-level parse the driver schemas share. Runtime-environment DSNs ' + - '(`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected ' + - 'by construction. There ' + - 'is no mechanical rewrite, for the same reason as the sibling entry ' + - '`datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it ' + - 'into a `sys_secret` row through a running secret binder and stripping the cleartext, ' + - 'which a source-file transform cannot do — auto-stripping the userinfo alone would ' + - 'silently drop a live credential instead. Do not substitute a `${…}` placeholder into ' + - 'the URL: placeholders in authored metadata are resolved by nothing and reach the ' + - 'database client verbatim (measured when the inline-credential refusal was built).', - acceptanceCriteria: - 'Every datasource parses with a credential-free `config.url` / `config.syncUrl` (no ' + - 'userinfo password segment); each affected datasource carries ' + - '`external.credentialsRef` (or has its secret bound through the connection form) and ' + - 'still connects; no URL-embedded credential remains in any stored `sys_metadata` row ' + - 'or authored source.', - }, - { - id: 'declarative-apis-endpoints-live', - surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)', - replacement: - 'the same declarations, re-read as LIVE HTTP routes: `path` moved under ' - + '`/api/v1/apps//`, and every entry that declares ' - + '`authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying ' - + '`rateLimit: { enabled: true, … }`', - reason: - 'This is the one protocol-17 entry that turns metadata ON rather than off, so read it ' - + 'as a SECURITY review item and not as a rename. Before 17 the declarative endpoint ' - + 'surface executed NOTHING: no route was mounted for a declared `path`, no matcher ' - + 'existed, and every key — `authRequired` included — parsed green and gated nothing ' - + '(which is why the maintainer\'s 2026-08-04 ruling refused a non-empty `apis:` outright ' - + 'until an executor existed). Protocol 17 ships that ' - + 'executor and narrows the refusal to a per-endpoint ' - + 'publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as ' - + 'soon as the stack is published. So an `apis:` block written against an older major — ' - + 'or one restored from a source older than that ' - + 'refusal, or authored from a doc that predates the ' - + 'refusal — changes meaning without changing a byte: what used to be inert ' - + 'documentation becomes an execution entry point into the data and automation ' - + 'pipelines. Nothing about that transition can be applied mechanically, because the ' - + 'judgment it needs is "did the author of this endpoint mean for the internet to reach ' - + 'it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its ' - + 'schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT ' - + '`authRequired: false` is the only thing that opens anonymous access, and under ' - + 'ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key ' - + 'defaults to `false`, so a budget written without it meters nothing) or the stack ' - + 'refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with ' - + '`ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: ' - + '`const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the ' - + 'safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type ' - + '(defaults materialized, ADR-0122), where `authRequired` is required — annotating a ' - + 'declaration with it forces you to write the key out, and being made to think about a ' - + 'key whose only unrecoverable value is `false` is the one thing this entry is trying ' - + 'to avoid. Hold a parse RESULT with `ApiEndpointParsed`; write declarations ' - + 'as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you ' - + 'upgrade, delete the ones that were never meant to be public, and arm a budget on the ' - + 'ones that were. The path move is the mechanical-looking half and is still yours: ' - + 'ADR-0121 D1/D2 confine a declared path to your own namespace carve-out ' - + '(`/api/v1/apps//…`), the namespace comes from an explicit ' - + '`manifest.namespace` with no derivation fallback, and the subpath is the only part ' - + 'you name — rewriting it for you would silently change a URL third parties call.', - acceptanceCriteria: - 'You have READ every entry of every `apis:` block, not just the ones that fail to ' - + 'publish. Concretely: (1) each declared `path` is ' - + '`/api/v1/apps//` and the stack declares that ' - + '`manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is ' - + 'one you INTEND to be reachable without a session, and each carries ' - + '`rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not ' - + 'intended to be anonymous have the key removed so the safe default (`true`) applies; ' - + '(3) `objectstack validate` passes, which also proves no endpoint declares a shape ' - + '17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an ' - + '`object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, ' - + '`inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and ' - + '(4) after publishing, each endpoint answers as you expect — an anonymous request to ' - + 'a session-only endpoint returns 401 rather than data.', - relevantWhen: { kind: 'stack-declares', keys: ['apis'] }, - }, - { - id: 'delete-by-id-before-hook-repoint-retired', - surface: - 'a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, ' - + 'to move the delete onto that row', - replacement: - 'delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let ' - + 'the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, ' - + "have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected " - + 'and stays legal.', - reason: - 'The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` ' - + 'handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table ' - + 'still answering differently: it HONOURED a repoint, re-resolving the new target by ' - + "re-reading its pre-image and rebinding `previous` (the fix that first made a single-row " - + "delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row " - + 'actually deleted. It now refuses with ' - + '`HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: \'by-id\'`, exactly as the ' - + '`update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\n' - + 'Read this as a RULING, not a defect report — that distinction is the reason the entry ' - + 'is worth its length. That re-resolution was internally CORRECT and nothing stale ' - + 'ever leaked from it; the case that retires a rebind on `update()` (the write landing on ' - + 'a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) ' - + 'simply did not apply to it. The engine change that dispatches `before*` hooks per matched ' - + 'row on a bulk write therefore left the asymmetry standing on purpose rather than folding ' - + 'a behaviour removal into an ordering change, and filed it as a finding of its own. The ' - + '2026-08-09 maintainer ruling on that finding closed it on three ' - + 'measured axes instead: compatibility cost zero (a repository-wide grep for assignments ' - + "into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL " - + 'SIX are this family\'s own pins — no consumer anywhere repoints); one rule across both ' - + 'verbs beats two individually-correct rules an author has to memorize, since the ' - + 'justification for the split lived in an ADR rather than at the call site; and "a hook ' - + 'silently redirects which row gets deleted" is a top-grade footgun for authored — ' - + 'especially AI-authored — handlers however correctly the redirect is implemented. ' - + 'Correctness of a mechanism does not justify the surface it exposes. Aligning the other ' - + 'way, by building `update()` the same re-resolution, stays excluded by the recorded ' - + 'ruling that extended per-row hook semantics to `before*` hooks on bulk writes ("do not ' - + 'silently pick re-resolution instead").\n\n' - + 'Why this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as ' - + '`hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at ' - + 'this step: FIRST, there is no source to convert — a `HookContext` is constructed per ' - + 'write and never persisted, so no `sys_metadata` row, example or template can carry the ' - + 'assignment. SECOND, the only place it is ever SPELLED is inside a handler body: ' - + 'author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative ' - + 'transform cannot safely rewrite an assignment inside free-form code, and the intent is ' - + 'not recoverable anyway — only the author knows whether the repoint meant "delete that ' - + 'row INSTEAD" or "delete that row TOO".\n\n' - + 'What makes this one cheaper to meet than its two siblings, and worth saying because it ' - + 'bounds the work: the removed capability has an ENFORCED channel at run time. The refusal ' - + 'throws before anything is written and its message NAMES the retired capability and the ' - + 'three replacement routes, so a handler that still repoints fails loudly and self-' - + 'describingly on its first execution rather than going quiet. This ledger entry is the ' - + 'channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2.', - acceptanceCriteria: - 'No `beforeDelete` handler assigns `ctx.input.id` anything but the id it arrived with — ' - + 'grep handler bodies for assignments into `input.id` and rewrite each into an explicit ' - + '`ctx.ql.delete()` for the other row, a caller-side `{ multi: true, where: … }`, or a ' - + '`throw`. A delete-heavy smoke run completes with no `HookTargetRebindError` ' - + "(`ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, `event: 'beforeDelete'`) — and any that does " - + 'raise names its `expectedId` and `observedId`, which identifies the handler that moved ' - + 'the target.', - }, - { - id: 'driver-aggregate-undeclared-key-aliases-removed', - // No backticks in `surface`: the upgrade-guide renderer wraps this string - // in a code span of its own, and a nested pair renders as literal ticks. - surface: "driver aggregate() call argument — query.aggregate and aggregations[].func", - replacement: - 'query.aggregations and aggregations[].function — the spellings QueryASTSchema and ' - + 'AggregationNodeSchema have always declared', - reason: - '`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the ' - + 'Query Protocol has never declared: `query.aggregations || query.aggregate` and ' - + '`agg.function || agg.func`. "Never declared" is measured, not assumed — `git log ' - + '-S` over `data/query.zod.ts` finds no commit that ever introduced either name, ' - + 'there is no `retiredKey()` tombstone and no alias-table entry for them (the file\'s ' - + 'only alias table is `SortNode`\'s `direction` → `order`), and neither appears in any ' - + 'upgrade guide or release note. So this entry does not record a declared surface ' - + 'being withdrawn; it records a LENIENCY being withdrawn, which is why it is here ' - + 'rather than behind a tombstone. The only writers in this repository were the two ' - + 'driver packages\' own fixtures — the family of the org-axis red-line gate that read ' - + 'only rejected aliases while its own fixtures spelt them, so its tests stayed green ' - + 'and the rule stayed dead: a fixture spelling the alias keeps the tolerant limb green ' - + 'forever and no test in existence can go red on its deletion — so ADR-0049 ' - + 'enforce-or-remove applies once those are re-spelt. ⚠️ Do ' - + 'NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical ' - + 'key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ' - + 'ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most ' - + 'likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two ' - + 'surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, ' - + 'never stack metadata — so there is no source for the D2 chain to rewrite and ' - + 'deliberately no schema tombstone: nothing ever ran a query through ' - + '`QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call ' - + 'site, once the parameter is `DriverQuery` — and for an untyped JS caller there is ' - + 'no enforced channel at all, which is exactly why this ledger entry has to exist: ' - + 'the generated upgrade guide is the only way such a reader learns of the rename. ' - + 'Same disposition, and the same reason, as `data-driver-find-stream-retired` ' - + '(`IDataDriver.findStream`, removed with no tombstone because nothing parses a driver ' - + 'object), `storage-service-list-retired` (the zero-consumer `IStorageService.list`, ' - + 'whose two adapters answered differently and both incompletely) and ' - + '`actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the ' - + 'maintainer\'s word rather than given a window). The removal ran in a fixed order — ' - + 'the fixtures re-spelt first, the two alias branches deleted second, the parameter ' - + 'narrowed to `DriverQuery` last — because the reverse order yields red nobody can ' - + 'explain. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No caller passes `aggregate:` to a driver\'s `aggregate()`, and no aggregation entry ' - + 'spells its function `func:`; both are written `aggregations:` / `function:`. An ' - + 'inline literal still using either old spelling no longer type-checks (TS2353 at the ' - + 'call site). An untyped JS caller that keeps writing `aggregate:` silently receives ' - + 'no aggregate column — the grouping still happens, the measure is simply absent — ' - + 'and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the ' - + 'undeclared function, identically on the local driver and the Turso remote ' - + 'transport.', - }, - { - id: 'driver-capabilities-inert-bits-removed', - surface: - 'data.DriverCapabilities.create / data.DriverCapabilities.read / ' - + 'data.DriverCapabilities.update / data.DriverCapabilities.delete / ' - + 'data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / ' - + 'data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / ' - + 'data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / ' - + 'data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / ' - + 'data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / ' - + 'data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / ' - + 'data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / ' - + 'data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / ' - + 'data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / ' - + 'data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / ' - + 'data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / ' - + 'data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / ' - + 'data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / ' - + 'data.DriverCapabilities.queryCache', - replacement: - '(removed — delete the keys. A driver advertises a capability by implementing the ' - + 'corresponding IDataDriver method; the three bits that survive because method ' - + 'presence cannot carry the signal are `queryDateGranularity`, `autonumber` and ' - + '`batchSchemaSync`)', - reason: - 'Retiring `IDataDriver.findStream` (it had no production caller, and two of its three ' - + 'implementations read the whole result set into memory before yielding a row) left ' - + '`DriverCapabilities.streaming` pointing at a capability the contract no longer ' - + 'declares, and the follow-up audit checked ' - + 'every bit in the record the same way, across objectstack and cloud (objectui ' - + 'confirmed clean): of 34 declared bits, THREE have a decision-making reader — ' - + '`queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), ' - + '`autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ' - + 'ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` ' - + 'from a base whose transport batches while its own cannot) — and THIRTY-ONE were ' - + 'written by every driver and read by nothing. Their `.describe()` strings promised ' - + 'engine adaptation ("if false, ObjectQL will filter/sort/paginate in memory") that ' - + 'was never built, and zero readers let the values go WRONG unnoticed: SqlDriver ' - + 'declared `streaming: false` while implementing `findStream`; InMemoryDriver ' - + 'declared `streaming: true` over a full-table read (ADR-0078 false affordance, on ' - + 'the capability record itself). The real mechanism everywhere else is METHOD ' - + 'presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on ' - + '`typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the ' - + 'REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never ' - + 'stack metadata — `supports` literals live in driver classes and ' - + '`DriverConfig.capabilities` is plugin TS configuration, neither ever a ' - + '`sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was ' - + 'retired separately, as a whole block nothing read) — so there is no source for the ' - + 'D2 chain to rewrite ' - + 'and this entry is the D3 record. The keys are tombstoned rather than deleted ' - + 'because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed ' - + '(DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): ' - + 'a plain delete would silently strip a vendor\'s authored bit, replacing one ' - + 'silent no-op with another. `batchSchemaSync` also drops its `.default(false)` ' - + 'for `.optional()` — absence already meant false at both readers, and the default ' - + 'forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No `supports` literal or `DriverConfig.capabilities` object authors any of the 31 ' - + 'retired bits — a driver class that still writes one fails tsc against ' - + '`IDataDriver.supports` (the bit is `never`), and a parsed config fails with the ' - + 'per-key prescription. The three in-repo drivers (memory / mongodb / sql) declare ' - + 'only live bits; cloud\'s TursoDriver keeps compiling via its `...super.supports` ' - + 'spread (its stale explicit overrides are cleanup, tracked cloud-side). Engine ' - + 'behaviour is byte-identical: every removed bit had zero readers, and the three ' - + 'live bits keep their readers (engine.ts autonumber defer / aggregate dispatch, ' - + 'plugin.ts + engine.ts batched schema sync, verify date-bucket parity).', - }, - { - id: 'driver-sql-distinct-bare-filter-typed', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: 'SqlDriver.distinct() third argument — any value', - replacement: - 'a bare FilterCondition (@objectstack/spec/data) — the same value find() carries ' - + 'under query.where, never a query envelope', - reason: - 'This entry records a TYPE being added, not a surface being withdrawn, and it says ' - + 'so up front because the distinction decides who has to do anything. `distinct` is ' - + 'not declared on `IDataDriver`, so neither the narrowing of `IDataDriver`\'s query ' - + 'parameters to `DriverQuery` nor the follow-through that brought five drivers\' ' - + 'implementations in line ever reached it, and it kept ' - + '`filters?: any` while its body said something far more specific — ' - + '`applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` ' - + 'off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry\'s change: not one ' - + 'statement moved, so no upgrade breaks at run time and nothing that answered ' - + 'correctly stops. What the annotation removes is a compile-time hole, measured ' - + 'rather than assumed: a truthy NON-OBJECT third argument — ' - + '`distinct(\'orders\', \'product\', \'completed\')` — used to type-check and resolve ' - + 'the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy ' - + 'non-object, non-array filter. A call meaning "which products among completed ' - + 'orders" answered with EVERY product, silently. That spelling is now TS2345 at the ' - + 'call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there ' - + 'is no source for the D2 chain to rewrite and deliberately no schema tombstone, the ' - + 'disposition `data-driver-find-stream-retired`, `storage-service-list-retired`, ' - + '`actor-user-roles-to-positions` and ' - + '`driver-aggregate-undeclared-key-aliases-removed` already carry. ⚠️ It ' - + 'differs from those four in ONE measured way a reader should not have to infer: ' - + 'because nothing changed at run time, an untyped JS caller is not affected BY THE ' - + 'UPGRADE at all. The entry is here for a different reason — such a caller is exactly ' - + 'the one tsc can never reach, and the silent widening above is a defect they may ' - + 'ALREADY be sitting on, before and after this major. The generated upgrade guide is ' - + 'the only channel that reaches them, which is why the fix is written down rather ' - + 'than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can ' - + 'close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter ' - + 'key IS a field name, so a query envelope `{ object, where }` is structurally a ' - + 'valid filter — one constraining columns named `object` and `where` — and so is a ' - + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, ' - + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the ' - + 'BARE spelling returns the unfiltered set in silence — stayed open under the ' - + 'maintainer\'s 2026-08-05 investment freeze on driver-memory, which was lifted on ' - + '2026-08-11; it is still open, now unexcused rather than deferred (the measurement ' - + 'that found the two drivers reading this argument differently split the fix: the sql ' - + 'half is this entry, and the memory half was held back by that freeze). ADR-0087.', - acceptanceCriteria: - 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is ' - + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to ' - + 'parameter of type \'FilterCondition\'`); rewrite it as the bare filter it was ' - + 'always meant to be — `\'completed\'` becomes `{ status: \'completed\' }`. ⚠️ That ' - + 'is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the ' - + 'answer changes once fixed, and the changed answer is the one the call always meant. ' - + 'An untyped JS caller gets no compile error and no behaviour change — for them this ' - + 'entry is the only notice that the spelling never filtered anything. A query ' - + 'envelope or a FilterArray in that slot still compiles and is rejected at run time ' - + 'with INVALID_FILTER / 400.', - }, - { - id: 'engine-dotted-projection-refused', - surface: - 'engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a ' - + 'dotted entry (`account.name`) — the direct engine path, not the REST ingress', - replacement: - "read the related record with `expand` (`{ expand: { account: { object: '', " - + "fields: ['name'] } } }`), keeping the reference column itself in `fields` — the " - + 'relation is carried by that column and projecting it away leaves expansion nothing ' - + 'to resolve, the same silent no-op a nested projection omitting the related `id` ' - + 'produced before the engine began keeping that join key itself; or denormalise the ' - + 'value onto the queried object (a stored field, written when the source changes) ' - + 'and name that — the same remedy the REST ingress prescribes when it refuses a ' - + 'dotted projection, and the sort axis when it refuses a dotted sort', - reason: - "The REST ingress closed the PROJECTION axis' dotted leg first, refusing a dotted " - + 'entry instead of widening the response to every field ' - + '(`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything ' - + 'reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` ' - + 'DIRECTLY passed through none of it, and that caller set was measured, not assumed: ' - + "a flow `get_record` node's authored `fields: ['name', 'account.name']` " - + 'parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into ' - + "`data.find(...)`, cleared the engine's head-only projection filter on its head " - + 'segment (`account` IS a field), and reached the driver as a projection column — ' - + 'where SQL renders `"account"."name"` against a table that was never joined, the ' - + "DB answers `no such column`, and the driver's unknown-column recovery ladder — " - + 'there so an unknown column never reads as "no rows" — retries ' - + "`select('*')`. The caller asked to narrow and silently received EVERY field, " - + 'byte-identical to no projection at all, pointing away from both FLS and data ' - + 'minimisation.\n\n' - + 'Ruled by the maintainer on 2026-08-12: a dotted entry the engine cannot resolve is ' - + "refused loudly at the engine's own head-only projection filter, covering every " - + 'caller that reaches the engine. The check it replaces was justified by a comment ' - + 'claiming the engine resolves relationship paths "via populate"; a measurement found ' - + 'that NO populate step exists — once the spec and docs stopped prescribing a dotted ' - + '`fields` path, that comment was the last place in the repo asserting dotted-path ' - + 'resolution does — so what was removed is not a ' - + 'working feature but a path to widening, kept alive by a false premise. The ' - + 'unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown ' - + 'plain name still drops silently; an all-unknown projection still falls back to ' - + '`*`), a registry-less host gets no verdict (the driver-side recovery ladder remains ' - + 'its documented backstop, and a driver-side carve-out is measured-need only), and ' - + 'a dotted `fields` inside a nested `expand` degrades to an observable warning ' - + "rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation " - + '`catch` swallows every expand failure, the same posture the formula-sort refusal ' - + '(`engine-find-formula-order-by-refused`) records for the same catch.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` ' - + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. ' - + 'No mechanical rewrite exists: the platform cannot decide between `expand` and ' - + 'denormalisation for the caller, and it must not resolve the path itself — no ' - + 'driver ever did, and inventing a join here is a feature decision, not a ' - + 'migration — the line analytics already takes for a relation-traversing dotted ' - + 'measure, refused with a 400 naming the caller\'s spelling rather than computed ' - + 'against the wrong column. ADR-0112.', - acceptanceCriteria: - 'No `engine.find` / `engine.findOne` call site passes a dotted `fields` entry, no ' - + "flow `get_record` config authors one, and no saved report's " - + '`query.fields` names one — grep flow definitions and report definitions for a ' - + '`fields` entry containing a `.`, and rewrite each to `expand` (keeping the ' - + 'reference column projected) or to a denormalised stored column. Reads complete ' - + 'with no `INVALID_FIELD` whose message says "follows the relationship" or "a ' - + 'dotted path", and no "Failed to expand relationship field" warning whose error ' - + 'text does.', - }, - { - id: 'engine-find-formula-filter-refused', - surface: - 'a `where` / filter naming a `formula` field — at BOTH doors: the REST ingress ' - + '(`assertFilterFieldsExist`, covering everything that reaches `findData`) and the ' - + 'engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / ' - + '`delete`), which saved reports, flows and dashboard widgets reach directly', - replacement: - 'denormalise the value onto the object (a stored field, written when the source ' - + 'changes) and filter that — deliberately the same remedy, in the same words, the ' - + 'SORT axis prescribes when it refuses a formula sort, at the ingress and at the ' - + 'engine, and the SEARCH axis prescribes when it refuses a formula search field; ' - + '`summary` and `autonumber` fields need NO action, because both get ' - + 'real maintained columns and filter correctly', - reason: - '`formula` is the one field type no driver materialises a column for, and FILTER was ' - + 'the last of the three query axes still fail-open on it: SORT refuses it (at the ' - + 'REST ingress and at the engine) and SEARCH refuses it by name, while a ' - + '`where` on a `formula` field cleared every gate precisely BECAUSE the object ' - + 'declares the field, reached a driver with no column behind it, and answered 200 ' - + 'with zero rows. Measured on a real `ObjectQL` with `is_open` a `formula` over the ' - + "stored `status` column: `where {is_open: true}` and `where {is_open: false}` each " - + "returned 0 rows with NO error, while the controls `where {status: 'open'}` returned " - + '4 rows and `where {subtask_total: 5}` (a `summary`, which HAS a column) returned 1 ' - + 'row.\n\n' - + 'BOTH directions are wrong and the `false` one is the dangerous one: the same ' - + 'predicate against a STORED boolean returns every matching row, so a filter meaning ' - + '"not yet done" silently became "no records at all" — a row SET changed under a 200, ' - + 'which no amount of inspecting the response can reveal, and the formula READS ' - + 'correctly in that very same response, so the field is visibly populated and ' - + 'simultaneously unfilterable. That is strictly worse than the sort axis it mirrors: ' - + 'a refused sort returns the same rows in a different order, a refused filter changes ' - + 'which rows exist.\n\n' - + 'Both doors now refuse it with `400 INVALID_FIELD`, naming the ' - + 'offending key path and carrying the remedy sentence — the ingress gate ' - + '(`assertFilterFieldsExist`, `@objectstack/metadata-protocol`) for everything ' - + 'reaching `findData`, and `assertFilterIsMaterializable` ' - + "(`@objectstack/objectql`, `filter-comparand-shape.ts`) at the engine's own filter " - + 'seam, which every caller-supplied `where` passes through whichever verb it arrived ' - + 'by. Both judge the field by the SAME `@objectstack/spec/data` predicate the SEARCH ' - + 'axis uses (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`, which holds `formula` ' - + 'and nothing else), so gate and drivers cannot disagree about which types have a ' - + 'column: a gate widened to the spec\'s `COMPUTED_VALUE_TYPES` (the WRITE contract) ' - + 'would refuse two working types. DOTTED filter paths are deliberately not judged on ' - + 'this axis at either door.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`engine-find-formula-order-by-refused` and `engine-dotted-projection-refused` at ' - + 'this step — there is no `sys_metadata` row for the D2 chain to rewrite and this ' - + 'ledger entry is the notification channel. No mechanical rewrite exists in either ' - + 'direction: the platform cannot invent the stored column the remedy prescribes, and ' - + 'it must not filter post-hoc instead — `driver.find` has already applied `limit` / ' - + '`offset`, so a predicate applied after the formulas are evaluated would filter an ' - + 'ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment ' - + 'pagination is involved.\n\n' - + 'AUTHOR-REACHABLE SURFACES are why this is not merely a code-side note. A saved ' - + "report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into " - + '`engine.find` by `plugin-reports` (`report-service.ts`, `where: q.filter`), ' - + 'bypassing the ingress gate entirely; flow node `config.filter` and dashboard widget ' - + 'filters are author-written the same way. A report or flow authored to filter on a ' - + 'formula field used to run and quietly return the wrong row set; it now fails ' - + 'loudly, with the remedy in the message.\n\n' - + 'Registered on the ruling inherited from the SORT axis — its engine refusal was ' - + 'registered in this ledger although no stored row needs rewriting, because the ' - + 'ledger is the one channel that carries its rewrite instructions to the author — ' - + 're-affirmed for this axis at triage on 2026-08-13: the shape is identical to the ' - + 'sort axis and the consequence here is larger. ADR-0112.', - acceptanceCriteria: - 'No filter names a `formula` field on any surface — grep your saved report definitions ' - + "(`sys_saved_report.query.filter`), flow node `config.filter`, dashboard widget " - + 'filters and view filters for a filtered field whose object declares it as a ' - + '`formula`, and denormalise each onto a stored column written when the source ' - + 'changes. A `summary` / `autonumber` field needs no action: both have real ' - + 'maintained columns and filter correctly. Reads complete with no `INVALID_FIELD` ' - + 'naming a virtual `formula` field in a filter, at either door.', - }, - { - id: 'engine-find-formula-order-by-refused', - surface: - 'engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a ' - + '`formula` field — the direct engine path, not the REST ingress', - replacement: - 'denormalise the value onto the object (a stored field, written when the source ' - + 'changes) and sort by that — the same remedy the REST ingress prescribes when it ' - + 'refuses a dotted or formula sort; a `summary` field is unaffected and still sorts, ' - + 'because it gets a real maintained column', - reason: - 'The SORT axis is closed at the REST ingress for an unknown field, a dotted path and ' - + 'a `formula` field alike ' - + '(`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching ' - + '`findData`: the list route, `POST /data/:object/query`, the export route and the ' - + 'RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY ' - + 'passed through none of it, and a `formula` ORDER BY there was dropped in silence. ' - + 'Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion ' - + 'order, under a success, with the rows carrying the very values they were asked to ' - + 'be ordered by. No column exists to order by (a formula is computed on read, so no ' - + 'driver materialises one), so the ORDER BY reached the driver, found nothing, and ' - + 'the unknown-column backstop returned the rows unordered.\n\n' - + 'Ruled by the maintainer on 2026-08-10: an ORDER BY the engine cannot apply is a 4xx ' - + 'with guidance prose at the public boundary, never a silent drop — the same direction ' - + "as the analytics dataset refusal envelope and the ingress sort hint's stored-field " - + 'prescription. The ' - + "engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s " - + 'docblock) was to survive only behind a pinned internal path, and only if a MEASURED ' - + 'internal call site relied on it. The sweep of every in-tree `orderBy` reaching ' - + 'the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata ' - + 'loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real ' - + 'stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, ' - + '`started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo ' - + 'declares a `formula` field at all. So no internal path shipped, and there is no flag ' - + 'to opt back into the drop.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` ' - + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. ' - + 'No mechanical rewrite exists in either direction: the platform cannot invent the ' - + 'stored column the remedy prescribes, and it must not sort post-hoc instead — ' - + '`driver.find` has already applied `limit` / `offset`, so re-sorting after the ' - + 'formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small ' - + 'result sets and is wrong the moment pagination is involved.\n\n' - + 'ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a ' - + "code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded " - + 'verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A ' - + 'report authored to sort by a formula field used to run and return rows in an ' - + 'arbitrary order; it now fails loudly, with the remedy in the message. One further ' - + 'path is deliberately NOT a refusal: a nested `expand` sort raises this refusal ' - + 'inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` ' - + 'swallows every expand failure and retains the raw foreign keys — so that path moves ' - + 'from silent to OBSERVABLE (a warning naming the field and the fix) rather than ' - + 'refusing. Reversing that backstop is a separate decision on all expand failure ' - + 'modes. ADR-0112.', - acceptanceCriteria: - 'No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved ' - + "report's `query.orderBy` names one — grep your report definitions for an `orderBy` " - + 'field whose object declares it as a `formula`, and denormalise it onto a stored ' - + 'column written when the source changes. A `summary` / rollup field needs no action: ' - + 'it has a real maintained column and sorts correctly. Reads complete with no ' - + '`INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" ' - + 'warning whose error text names one.', - }, - { - id: 'engine-update-upsert-retired', - surface: 'data.engine.update options.upsert', - replacement: - '(removed — never implemented; express create-if-absent explicitly: `findOne` first, then ' - + '`insert` or `update` on what you find)', - reason: - 'The `upsert` flag promised insert-if-absent on `engine.update()` but no engine or driver ' - + 'path ever read it: the key was declared on both update-options schemas and allowlisted by ' - + 'the unknown-option gate, yet `ObjectQL.update()` never referenced it and it was not a ' - + 'driver pass-through key — `{ upsert: true }` was accepted and silently dropped and the ' - + 'update stayed a plain update (ADR-0049 declared-but-unenforced). There is no behaviour to ' - + 'preserve and nothing stored to rewrite (it only ever appeared in a call-time option bag). ' - + "Any future first-class upsert must reconcile with the engine's not-found gate — a by-id " - + 'update whose id names no row throws RECORD_NOT_FOUND rather than inserting — which is why the ' - + 'flag is removed rather than implemented here.', - acceptanceCriteria: - 'No caller passes `options.upsert` to `engine.update()`; a call that includes it is refused ' - + 'loudly (the engine gate and both schemas quote one removal prescription) instead of ' - + 'succeeding with the option silently ignored.', - }, - { - id: 'enhanced-api-error-field-errors-renamed', - surface: 'api.enhancedApiError.fieldErrors', - replacement: 'fields', - reason: - 'The wire has always carried `fields` — the validators, import coercion, ' + - 'validation-failure.ts, @objectstack/client and the console\'s field-error extractor ' + - 'all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it ' + - 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' + - 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' + - 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' + - 'via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the ' + - 'field-level error code catalog).', - acceptanceCriteria: - 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' + - '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' + - 'with the rename prescription instead of silently losing the array.', - }, - { - id: 'etl-pipeline-layer-retired', - surface: - 'automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / ' - + 'automation.etlDestination / automation.etlTransformation (the whole L2 layer of ' - + 'automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported ' - + 'names)', - replacement: - '(removed — no protocol surface replaces it, deliberately. Layer by layer: ' - + 'connector-attached synchronisation is `ConnectorSchema.syncConfig` ' - + '(`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT ' - + 'EXECUTED — a declared shape, not a running sync. ' - + '`AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the ' - + 'parsed definition; nothing reads `syncConfig` back off it, and the key has no ' - + 'reader outside `packages/spec` at all — the same measurement that deleted ' - + '`syncConfig.schedule` in @objectstack/spec 17 under ADR-0049, with the other cron-typed positions ' - + 'nothing reads. What the platform DOES ' - + 'execute on a connector is its `actions`: a flow\'s `connector_action` node ' - + 'resolves the registered handler and awaits it, so an author who needs data ' - + 'actually moved drives it from there. Per-field value ' - + 'transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied ' - + 'row by row by the REST import path and recorded key by key in ' - + '`packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has ' - + 'NO replacement is multi-source, multi-stage movement with joins and aggregations — ' - + 'because it never had an implementation either. It returns through the ENFORCE route: ' - + 'the engine first, the vocabulary second)', - reason: - 'The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its ' - + 'automation copy deleted as dead), re-measured one layer up and ' - + 'identical: narrative-only. No engine ever parsed, scheduled or executed an ' - + '`ETLPipeline`. Measured on origin/main immediately before the removal: the only ' - + 'non-spec references in this repo are two fumadocs-generated documentation sources ' - + '(`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there ' - + 'is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading ' - + 'on it — while the same file family\'s EXECUTED half does have one ' - + '(`liveness/mapping.json`), which is the contrast that makes the absence meaningful ' - + 'rather than an oversight. The `etl` string in this registry was the one untested ' - + 'link the finding named, and it is not a loader path: it was the id of the ' - + 'retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the ' - + 'retry convergence had not covered), absorbed here. ' - + 'The layer was ADR-0078\'s asymmetry in its purest form — an author could write a ' - + 'complete ten-stage pipeline, get no error, and get no execution. It was also ' - + 'advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the ' - + 'recommended destination for authors displaced by the L1 retirement and ' - + 'listed ten transformation types with copyable examples down to ' - + '`script | Custom JavaScript/Python`. That document is rewritten in the same change; ' - + 'a retirement whose own doc still recommends the retired layer is self-contradictory, ' - + 'and forwarding L1\'s authors to a second layer with no executor was the defect ' - + 'compounding rather than closing. ' - + '⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the ' - + '`activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an ' - + 'earlier tombstone go with the shape that carried it: both land in the unreleased ' - + 'protocol 17, so composed, a ' - + 'rename of `retry.maxAttempts` on a shape that does not survive the major has no ' - + 'observable effect — and keeping both would tell an upgrader to rewrite a key on a ' - + 'schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes ' - + 'with the shape that carried it, which is strictly stronger than the tombstone: there ' - + 'is no longer a `retry` block to author the key into. Route 3 — no carrier key, no ' - + 'parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this ' - + 'entry are the declaration. ADR-0049, ADR-0078.', - acceptanceCriteria: - 'No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, ' - + '`ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, ' - + '`ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, ' - + '`ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or ' - + 'the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 ' - + 'on any that survives. Every author who was pointed at L2 has been re-pointed by ' - + 'name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends ' - + '`ETLPipeline` as L1\'s destination and no longer advertises a transformation-type ' - + 'table. The surviving layers still parse unchanged — a connector declaring ' - + '`syncConfig` and an import declaring `mapping.transform` both behave exactly as they ' - + 'did in 16.x.', - }, - { - id: 'export-axis-opt-in', - surface: - 'security.permissionSet.objects[].allowExport (ABSENT — a permission set that never ' - + 'declared the key)', - replacement: - 'an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every ' - + 'permission set whose holders are meant to keep exporting', - reason: - 'A secure-default FLIP, not a shape change — the same class as ' - + '`rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and ' - + '`action-descriptor-resume-authority-default-flip`, and it is registered for the same ' - + 'reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will ' - + 'tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset ' - + 'inherited read; from 17 it denies. Reading a record and taking a bulk ' - + 'machine-readable copy of the whole table are different privileges — Salesforce ' - + '"Export Reports", Dynamics "Export to Excel", NetSuite "Export Lists" and SAP `S_GUI` ' - + '61 all separate them — and the axis now says so. This cannot be a mechanical ' - + 'conversion in either direction: writing `allowExport: true` wherever the key is absent ' - + 'would preserve today\'s behaviour while silently defeating the entire point of the ' - + 'flip, and writing `false` would revoke a capability the deployment may legitimately ' - + 'want. Whether a given set\'s holders SHOULD be able to take a bulk copy is exactly the ' - + 'segregation-of-duties judgement the axis exists to make explicit, and it belongs to ' - + 'the operator. Two details decide who is actually affected: package-shipped sets are ' - + 're-seeded on upgrade, so the built-ins are handled — at 17.0.0 `admin_full_access` and ' - + '`organization_admin` carried the grant explicitly, ON A `*` WILDCARD, and protocol 18 ' - + 'REMOVES it (see `admin-export-wildcard-removed`: the wildcard made the axis undeniable ' - + 'for an org admin, so from 18 an admin exports only what an app set grants — do not read ' - + 'this clause as a standing promise that the built-ins keep exporting) — while ' - + 'ENVIRONMENT-AUTHORED sets ' - + 'are not and must be edited by hand. `member_default` deliberately does NOT carry the ' - + 'grant, so ordinary authenticated users lose export until an admin grants it; that is ' - + 'the point of the flip, not an oversight. Merge semantics are unchanged and ' - + 'most-permissive, exactly like the CRUD bits: any set granting `true` grants export, ' - + 'and `false` is authoring intent rather than a veto, because permission sets are ' - + 'additive capability containers (ADR-0090). The super-user bits no longer confer it: ' - + '`viewAllRecords` / `modifyAllRecords` are "may see all data", not "may take a bulk ' - + 'copy". Registered (backfilled) by the stock reconciliation that compared the breaking ' - + 'changesets already on the v17 release train against this ledger: the export axis, and its ' - + 'extension to the CSV attachments scheduled reports mail out, both predate the gate that makes ' - + 'a breaking changeset state its ADR-0087 disposition. ADR-0087.', - acceptanceCriteria: - 'Every environment-authored permission set has been READ and decided, not just parsed: ' - + 'each object entry whose holders should keep exporting carries `allowExport: true`, and ' - + 'each one that should not is knowingly left without it. The verify loop is behavioural, ' - + 'because nothing fails at parse time — sign in as a holder of each affected set and ' - + 'confirm an export either succeeds or is refused as intended, including the report ' - + 'export path, which this change brought under the same axis. ⚠️ Silence is not success ' - + 'here: a deployment that upgrades without editing anything is VALID metadata whose ' - + 'ordinary users have quietly lost export, and the first sign will be a user report ' - + 'rather than an error. Check `member_default` explicitly — it is the set most likely to ' - + 'have been carrying export by inheritance.', - }, - { - id: 'export-field-meta-constraints-retired', - surface: - '@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / ' - + '.min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, ' - + 'reached as `PreparedImport.metaMap` from `prepareImportRequest`)', - replacement: - 'the object schema you already hold — read `fields[name].required` / `.system` / ' - + '`.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off ' - + 'the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ' - + 'ENGINE reads them and therefore the only copy that cannot drift', - reason: - 'ADR-0049 enforce-or-remove. These eight were never a source of truth: ' - + '`buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller ' - + 'passed in, so the map carried a second copy of facts the caller already held. ' - + "They existed for exactly one consumer — the import dry run's hand-copied " - + 'pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, ' - + 'added when the dry run was found skipping the field-level validation the real write ran) ' - + '— and the maintainer\'s 2026-08-06 ruling D (a validate-only protocol operation, so the ' - + 'dry run\'s prediction is the engine\'s verdict by construction) retired that mirror: the dry run ' - + "now asks `DataProtocol.validateData` for the engine's verdict, which reads the " - + "object's own schema. That left all eight computed on every import and read by " - + 'NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint ' - + 'vocabulary standing next to the presentation one with no enforcer behind it is ' - + 'precisely the thing an AI-authored consumer mistakes for a contract. Verified ' - + 'zero-reader before removal, per key and by type, across this repo (`packages/rest` ' - + 'itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, ' - + "verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's " - + 'identity import forwards `prepared.metaMap` into `runImport` but reads only the ' - + 'presentation keys through `coerceRow`. ' - + 'Why this needs a ledger entry despite that sweep: it is the `findStream` / ' - + '`IStorageService.list` / `actor-user-roles-to-positions` ' - + 'disposition — a published TS surface with NO spec schema, so there is no ' - + '`retiredKey()` tombstone and no parse rejection that could carry a prescription, ' - + 'and the ledger is the only channel that reaches an upgrader. It is if anything ' - + 'blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` ' - + '14.5.0) and have been published in every release since, and because they were ' - + 'OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading ' - + '`meta.required` after the upgrade gets `undefined` with no error at all — tsc ' - + 'reports at the read site only for a typed consumer. ' - + 'Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored ' - + 'or stored metadata changes shape — `required` / `min` / `maxLength` and the rest ' - + 'remain fully authorable on a field definition and fully enforced by the engine, ' - + 'which is where they always lived. The only place these eight are ever spelled is ' - + "inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can " - + 'reach them. ADR-0049 / ADR-0087; this is the removal the dry-run change deliberately ' - + 'deferred to a sweep of its own.', - acceptanceCriteria: - 'No code of yours reads any of the eight off a `buildFieldMetaMap` / ' - + '`prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / ' - + '`.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an ' - + '`ExportFieldMeta`-typed value; each hit moves to the object schema you already ' - + 'passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, ' - + 'so an untyped or `any`-typed read compiles clean and silently becomes `undefined` ' - + '— assert that the constraint your code acts on is still observed on a real import, ' - + 'not merely that the build is green. Note `hasDefault` has no one-to-one ' - + "replacement key: it was the derived predicate `defaultValue != null`, mirroring the " - + "engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply " - + 'that same `!= null` test yourself.', - }, - { - id: 'external-lookup-message-queue-families-retired', - surface: - 'data.externalLookup / data.externalDataSource / data.externalFieldMapping ' - + '(the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and ' - + 'system.messageQueue (the whole of system/message-queue.zod.ts — ' - + 'MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, ' - + 'DeadLetterQueue — 5 defs, 14 exported names)', - replacement: - '(removed — there is no replacement key, because there was never a key: neither ' - + 'family was reachable from any metadata-type binding, stack collection or /meta ' - + 'door, so no document could carry either. For external data: `object.external` ' - + '(`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference ' - + 'and connection credentials live in the datasource config — never inline in ' - + 'object metadata; `data/external-catalog.zod.ts` is that federated path\'s ' - + 'catalog surface and is untouched. For message queues: the LIVE surface is ' - + '`kernel/events/integrations.zod.ts`\'s `EventMessageQueueConfig` ' - + '(`EventBusConfig.messageQueue`), which deliberately carries NO credential ' - + 'field — broker connection and SASL credentials are runtime deployment ' - + 'configuration, not authorable metadata. Either capability returns via the ' - + 'ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin ' - + 'service first, the vocabulary second)', - reason: - 'Both families are the verdict of the 2026-08-12 census of spec schemas that ' - + 'permit inline credentials (fork (b): no `sys_metadata` door reaches them; ' - + 'accepted 2026-08-12): ' - + 'security-shaped declared surface with inline-credential sinks and ZERO ' - + 'consumers. `ExternalDataSourceSchema.authentication.config` is a record of ' - + 'unknown whose own docblock example wrote `"clientSecret": "..."` inline, and ' - + '`MessageQueueConfigSchema.sasl.password` was a required inline broker ' - + 'credential — the class of the `sys_metadata` cleartext-sink finding ' - + '(cleartext-at-rest credential sinks), except that unlike its two measured ' - + 'surfaces (driver config and connector `authentication`) nothing ever ' - + 'persisted these: no ' - + 'metadata-type binding (kernel/metadata-type-schemas.ts imports neither ' - + 'module), no stack collection, no object/field embedding (`object.external` ' - + 'binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/' - + 'columnMap, no authentication), and zero imports outside packages/spec ' - + 'repo-wide, with the corpus-reach control (`DatasourceSchema` under identical ' - + 'exclusions) returning hits in the same run. The consumed MQ near-namesake ' - + '`kernel/EventMessageQueueConfig` deliberately has no credential key, so the ' - + 'consumed shape had no credential and the credential-bearing shape had no ' - + 'consumer. A dead schema minus one field is still a dead schema, so the whole ' - + 'declarations go, not just the credential faces (the lesson of the plugin ' - + 'sandboxing config that was never wired to anything: an exported schema ' - + 'with no consumer reads as a capability to whoever finds it — here it read as ' - + 'an invitation to author secrets in cleartext). With no carrier key there is ' - + 'nothing to tombstone and no source or `sys_metadata` row for a D2 conversion ' - + 'to rewrite: route 3, the shape of the earlier removals of the dynamic ' - + 'plugin-loading family, the `ui/` interaction configs, the widget / i18n ' - + 'shapes and the sweep of five declared-but-inert surfaces — ' - + 'RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ' - + '⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping ' - + 'transform retirement (one of that retirement\'s three spellings; the whole ' - + 'transform union left because no runtime ever executed any of its five ' - + 'members) is SUBSUMED by the def retirement, the ' - + 'WidgetManifest.performance way: it goes with the shape that carried it. The ' - + 'base `shared/FieldMapping` tombstone and the `integration/' - + 'ConnectorFieldMapping` spelling are untouched and still reject `transform` ' - + 'with that retirement\'s prescription. ' - + '⚠️ The reopen trigger of the maintainer\'s 2026-08-12 ruling on the ' - + 'cleartext sink — it closed each artefact\'s contract (Option A) and parked ' - + 'the class-level `sys_metadata` write-boundary guard (Option B) until "a ' - + 'third measured artefact-type surface" — is NOT met by this census: that ' - + 'guard stays parked; this is the ADR-0049 leg of the fork the ' - + 'triage pre-agreed.', - acceptanceCriteria: - 'No code imports `ExternalLookup(Schema|Parsed)`, `ExternalDataSource(Schema)`, ' - + '`ExternalFieldMapping(Schema|Parsed)`, `MessageQueueConfig(Schema|Parsed)`, ' - + '`MessageQueueProvider(Schema)`, `TopicConfig(Schema|Parsed)`, ' - + '`ConsumerConfig(Schema|Parsed)` or `DeadLetterQueue(Schema|Parsed)` from ' - + '`@objectstack/spec`, `@objectstack/spec/data` or `@objectstack/spec/system` — ' - + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved ' - + 'symbol identity in `data/external-lookup-retirement.test.ts` and ' - + '`system/message-queue-retirement.test.ts`). No metadata document needs ' - + 'editing, because none could ever carry one of these shapes. ' - + '`kernel/EventMessageQueueConfig` (with its inline provider enum and no ' - + 'credential key), `data/external-catalog.zod.ts`, `object.external` and ' - + '`kernel/DeadLetterQueueEntry` survive unchanged.', - }, - { - id: 'field-runtime-create-withdrawn', - surface: 'PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)', - replacement: - 'Author the field inside its object and write the whole object — ' - + 'PUT /api/v1/meta/object/{object} with the new field in `fields` — ' - + 'or declare it in the object source (`**/*.object.ts`) and redeploy', - reason: - 'The `field` registry entry declared `allowRuntimeCreate: true` and the platform never ' - + 'built a read path for it. Measured end-to-end through the real HttpDispatcher -> ' - + 'ObjectStackProtocolImplementation -> SysMetadataRepository: ' - + "`PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with " - + '{"success":true,"state":"active","message":"Saved field …"}, the row persisted, and ' - + '`GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with ' - + 'zz_probe ABSENT — forever. The row is even self-readable by name ' - + '(`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which ' - + 'makes it well-formed and universally inert rather than malformed. The seam is that ' - + '`field` is the ONE declared type with no standalone existence: fields are authored ' - + 'inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ' - + "('field','.'), and nothing composes fragment rows into their parent — " - + "`applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` " - + '(`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot ' - + 'honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 ' - + 'rather than build the read path, which is a feature spanning at least three packages ' - + '(a composition step that does not exist, ~20 `gate.fields` call sites, physical ' - + 'schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a ' - + 'separate card — implementation first, declaration second. ' - + '⚠️ This is NOT the `api` withdrawal\'s rationale reused (`api-runtime-create-withdrawn`): ' - + 'that ruling rested on "zero ' - + 'business pull", and "add a field" is the opposite — a core Studio/CRM operation. The ' - + 'justification here is that the operation REMAINS AVAILABLE on the route that actually ' - + 'composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, ' - + 'broken SPELLING of adding a field, not the ability to add one. ' - + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' - + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' - + 'authorable one, and no authored source changes — an `**/*.object.ts` file valid before ' - + 'this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, ' - + 'so it is one semantic TODO for operators and Studio callers rather than a stack ' - + 'conversion — the same disposition `api` and `BatchOptions.validateOnly` take. ' - + 'ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No caller creates a standalone `field` item through the runtime metadata API. ' - + '`PUT /api/v1/meta/field/{object}.{name}` answers 403 with `code: "NOT_CREATABLE"` and ' - + 'a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' - + 'prescription `PUT /api/v1/meta/object/:object with the new field in `fields``. The ' - + 'plural spelling `PUT /api/v1/meta/fields/{object}.{name}` folds onto the singular ' - + 'and earns the same refusal — verify it, because it was a separate door until ' - + '2026-08-12. ⚠️ Verify the OBJECT route is UNAFFECTED, which is the whole point of the ' - + 'change: `PUT /api/v1/meta/object/{name}` with a new entry in `fields` still answers ' - + '200, and `GET /api/v1/meta/object/{name}` READS THE NEW FIELD BACK (assert on ' - + '`body.data.item.fields`, not `body.item`, which is undefined and makes an empty read ' - + 'look like a pass). Assert a DECLARED field is present in the same response, so a dead ' - + 'read cannot be what makes the check pass. ' - + '⚠️ The field overlay refusal is untouched and must stay: overwriting a field a code ' - + 'package ships is still 403 `NOT_OVERRIDABLE`, a different gate for a different ' - + 'question — making field OVERRIDES legal was never part of this decision. ' - + 'DISPOSITION OF EXISTING ROWS: `field` rows already written through the retired channel ' - + 'stay in `sys_metadata` and are INERT — they were inert before this change too, since ' - + 'no read path ever composed them into an object, so nothing that used to work stops ' - + 'working and no data is silently reinterpreted. They remain self-readable by name and ' - + 'still report `_diagnostics.valid: true`, which asserts only that the isolated document ' - + 'is well-formed (the envelope has no "in effect" axis). They may be deleted ' - + 'at leisure: `deleteMetaItem` is deliberately NOT gated by this refusal, so repair stays ' - + 'possible. An operator who needs the write door back on one deployment sets ' - + '`OS_METADATA_WRITABLE=field`; note this unlocks the WRITE only — the field still will ' - + 'not reach its object, which is why it is a diagnostic and not a workaround.', - }, - { - id: 'filter-regex-options-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'data.filter $regex / $options — in a STORED filter (dashboard widget filter and ' - + 'globalFilters, report runtimeFilter, page and component filter, solution-blueprint ' - + 'filter), and equally in the where clause of a query request', - replacement: - '$icontains for the case-insensitive substring match this was almost always used ' - + 'for, or $contains for a case-sensitive one — a pattern that genuinely needs a ' - + 'regular expression has no filter-level replacement', - reason: - 'Like `driver-aggregate-undeclared-key-aliases-removed` and ' - + '`driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being ' - + 'withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` ' - + 'and never a key on `StringOperatorSchema`. That is measured, not assumed — `git ' - + 'log -S\'$regex\'` over `packages/spec/src` returns only doc comments describing how ' - + '`$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: ' - + '$regex`), plus the retirement\'s own contract-half change, which added the name ' - + 'solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the ' - + 'disposition, so a reader should not have to infer it: those were driver CALL ' - + 'ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. ' - + '`FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) ' - + 'because a filter key is a field name, so a stored `{ name: { $regex: \'acme.*\' } }` ' - + 'parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open ' - + 'map, which is exactly why the ledger has to carry this. What such a stack used to ' - + 'get was four different answers from four backends: `driver-sql` and Turso\'s remote ' - + 'transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal ' - + '`a.b` and the regex was silently never a regex), `driver-memory` and objectql\'s ' - + '`having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an ' - + 'INVALID pattern was caught and answered `false` — zero rows, in silence), and ' - + '`driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. ' - + 'It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. ' - + 'There is deliberately NO D2 conversion and this sits in `semantic` rather than among ' - + 'the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in ' - + 'either direction — a regex metacharacter becomes a literal — so an auto-applied ' - + 'rewrite would silently change which rows a dashboard, report or permission filter ' - + 'selects, a wrong number rather than a missing one. Choosing the substring the ' - + 'pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers ' - + 'BOTH HALVES of the maintainer\'s 2026-08-06 ruling (option B: retire `$regex` loudly ' - + 'and add `$icontains`, rather than make five backends agree on one regex dialect), not ' - + 'just the driver one: the contract half ' - + '(the `$icontains` declaration, the `$contains` family pinned ' - + 'case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ' - + 'gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was ' - + 'never asked for a ledger entry; ' - + 'the driver half is where the refusal became executable. One surface, one ' - + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No stored filter and no request `where` spells `$regex` or `$options` — grep the ' - + 'stack for both. Each one is rewritten by asking what the pattern MEANT, not by ' - + 'transliterating it: a bare substring pattern becomes `$icontains` (or `$contains` ' - + 'when the match must stay case-sensitive), and its metacharacters are dropped rather ' - + 'than escaped, because they were never honoured as a regex on the SQL family in the ' - + 'first place. ⚠️ Expect the answer to CHANGE on any stack that ran on ' - + '`driver-memory`, `driver-mongodb` or objectql `having`, where the pattern really was ' - + 'evaluated as a regular expression; on the SQL family the rewritten filter returns ' - + 'what it always returned. A pattern that genuinely needs alternation, anchoring or ' - + 'character classes has no filter-level replacement — move that predicate into a ' - + 'formula field or a server-side view, or open an issue for it. Verify by loading the ' - + 'stack: a surviving `$regex` or `$options` is answered INVALID_FILTER / 400 with a ' - + 'message naming the replacement, on every backend.', - }, - // `etl-retry-converged-onto-retry-policy` (#4962) was registered in this step and - // ABSORBED by `etl-pipeline-layer-retired` (#6414), the §0 same-major - // rule: both land in the unreleased protocol 17, and composed, the rename - // `ETLPipeline.retry.maxAttempts` -> `maxRetries` has no observable effect - // because the shape carrying it does not survive the major. Leaving both - // would tell an upgrader to rewrite a key on a schema this same upgrade - // deletes, and would break the fixture-disjointness the replay contract - // asserts. The `agent.knowledge` / `WidgetManifest.performance` precedent: - // a tombstone goes with the shape that carried it, which is strictly - // stronger than the tombstone. - { - id: 'flow-retry-max-retries-required', - surface: "flow.errorHandling.maxRetries (under strategy: 'retry')", - replacement: 'an explicit count >= 1 (e.g. maxRetries: 3), or strategy: \'fail\'', - reason: - 'maxRetries had two defaults — FlowSchema `.default(0)` and the engine\'s ' + - '`maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 ' + - 'times through a hand-built definition. With the engine\'s copy removed the ' + - 'unstated count is unambiguously 0, and retrying zero times is exactly ' + - "`strategy: 'fail'`, so the schema now refuses the combination instead of it silently " + - 'doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow ' + - 'got but contradicts what its author wrote, and any positive count is a NEW decision ' + - 'about re-running the whole flow with its side effects. That choice is the author\'s.', - acceptanceCriteria: - "Every flow declaring `errorHandling.strategy: 'retry'` also declares " + - '`maxRetries` >= 1, and each count was chosen knowing a retry replays the flow FROM ' + - 'THE START (records re-created, callouts re-fired); flows that never actually wanted ' + - "retries say `strategy: 'fail'`. No flow fails to register with the maxRetries " + - 'prescription.', - }, - { - id: 'hook-context-session-roles-retired', - surface: 'data.hookContext.session.roles', - replacement: - '(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the ' - + 'security service, which reads `permissions` / `positions` / posture off the ' - + 'execution context, ADR-0095 D3)', - reason: - 'Declared on the runtime hook context, read by exactly two consumers, produced by ' - + 'nobody. The two readers were the approvals record lock and the delegation write ' - + 'guard, each opening with `session.roles?.includes(\'admin\')`; ObjectQL\'s ' - + '`buildSession()` builds the session field by field and has never written `roles`, ' - + 'and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud\'s hook ' - + 'consumers read `hookContext?.session?.userId`; objectui\'s `roles` are the ' - + '`/auth/me` user payload, a different surface; an ACTION body\'s `ctx.session` is a ' - + 'different untyped object that does carry `roles`, tracked apart and unaffected). ' - + 'Both branches were therefore dead on ' - + 'every real engine path — an authorization decision in shape only, and a second admin ' - + 'dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. An earlier fix removed ' - + 'both readers, returning the record lock and the delegation guard to the one permission ' - + 'vocabulary; this removes the declaration, per ADR-0049 enforce-or-remove. ' - + 'This is a RUNTIME context, not stored metadata: the engine builds a HookContext per ' - + 'operation and nothing persists one, so no `sys_metadata` row, example or template ' - + 'can carry the key and there is no source for the D2 chain to rewrite — the ' - + '`openApi31` / `activationEvents` shape, one semantic TODO rather ' - + 'than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately ' - + 'not `.strict()` — a plain delete would strip it silently, as a removed field key was ' - + 'measured to be, ADR-0104), so a ' - + 'consumer that parses a context it was handed still meets the prescription. ' - + 'ADR-0049.', - acceptanceCriteria: - 'No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / ' - + '`ctx.session.isSystem`, and privilege comes from the security service ' - + '(`permissions` / `positions` / posture). Constructing a HookContext session with ' - + '`roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` ' - + 'with the retirement prescription instead of being silently stripped. Nothing ' - + 'regresses at runtime: the key had no producer, so no decision anywhere ever saw a ' - + 'value in it.', - }, - { - id: 'hook-register-empty-object-target-refused', - surface: - "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose " - + '`excludeObjects` cancels its `object` entirely', - replacement: - "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for " - + "a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen " - + '`object` or drop the overlapping names from `excludeObjects`', - reason: - 'An earlier breaking fix established that an empty hook target is not "no target" and ' - + 'closed the shape at the ' - + "two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s " - + '`normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so ' - + 'all three spellings still registered, each producing a defect the author did not ' - + "write: `''` is FALSY, so the allow face was skipped entirely and the entry became a " - + "GLOBAL hook (that fix's headline failure mode — blank intent taking the broadest " - + "possible blast radius); `[]` and `['']` are truthy but admit no object name, so the " - + 'entry could never fire. The later `excludeObjects` face (a hook global except for the ' - + 'objects it names) then brought a ' - + 'fourth shape reached by arithmetic rather than by one bad name: an `object` list ' - + 'every member of which is also excluded admits nothing, so that entry can never fire ' - + 'either. All four are ADR-0078 silently-inert declarations, and all four are now ' - + 'refused at REGISTRATION.\n\n' - + 'No mechanical rewrite exists, in either direction. The refused values carry no ' - + "recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a " - + 'specific object name the author forgot to fill in, and those are opposite ' - + 'registrations; choosing between them is a judgment the chain cannot make. Nor could ' - + "the MATCHING read be changed instead: teaching the matcher that `''` is an " - + 'unmatchable name would silently convert a hook firing on every object into one ' - + 'firing on none — the same class of defect pointing the other way, which is why ' - + 'the `excludeObjects` change declined to do it in passing.\n\n' - + 'This is a RUNTIME registration API, not stored metadata, so — like ' - + '`hook-context-session-roles-retired` at this step — there is no `sys_metadata` row ' - + 'for the D2 chain to rewrite and the ledger entry is the notification channel. One ' - + 'metadata surface reaches it INDIRECTLY and is the reason this is not purely a ' - + "code-side note: a `record-change` flow's start node forwards `config.objectName` " - + 'verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with ' - + 'a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now ' - + "fails to bind instead, loudly — the automation engine's per-flow bind guard warns " - + 'and the `kernel:bootstrapped` binding audit re-reports it — which is the correct ' - + 'end state, but it is an observable change for that flow. ADR-0078.', - acceptanceCriteria: - 'No `registerHook` call site passes an empty `object` target, and none passes an ' - + '`excludeObjects` list covering every name in its `object` list. Every `record-change` ' - + 'flow start node declares a non-blank `config.objectName`, or omits the key if the ' - + 'flow is genuinely meant to fire on every object. Boot completes with no ' - + '"[ObjectQL] Hook ... declares an empty `object` target" throw and no ' - + '"[record-change] ... not bound" warning naming a flow you expect to fire.', - }, - { - id: 'http-request-errors-total-retired', - surface: - 'observability.SEMCONV.httpRequestErrorsTotal (the published metric name ' - + 'http_request_errors_total{method,route}, and its emission from the runtime ' - + "dispatcher's per-route wrapper)", - replacement: - 'the 5xx rate is `http_requests_total{status=~"5.."}` — the TRANSPORT emits that family ' - + 'through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; ' - + 'unhandled-exception rate specifically, which is the one thing the retired counter ' - + 'uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which ' - + 'still fires on every 5xx throw', - reason: - 'ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` ' - + 'published `http_request_errors_total` as part of a stable namespace declared "so ' - + 'hosts can wire alerts/dashboards against it", but the only emitter was ' - + '`@objectstack/runtime`\'s `instrumentRouteHandler`, applied only by the dispatcher\'s ' - + 'own route Proxy — so the series never saw auth\'s `getRawApp()` mount, the REST data ' - + 'API via `RouteManager`, or any other inbound surface. Its two siblings in the same ' - + 'family were moved to the transport seam (the request counter, then the latency ' - + 'histogram, both through the response-observing hook the transport was given) and this ' - + 'one could not follow: `HttpResponseObservation` carries ' - + '`{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every ' - + 'transport-side shape would have counted a DIFFERENT population rather than the same ' - + 'one more widely. The divergence was measured in both directions — the dispatcher ' - + 'answers its own errors through `errorResponseBase`, which sets a status and does not ' - + 're-throw, so the old counter MISSED those, while its `catch` incremented ' - + 'unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` ' - + 'already carries a `status` label, so a status-class error counter would be fully ' - + 'derivable from data the transport already publishes. Maintainer ruling 2026-08-20 ' - + '(option C of four presented, over B "move it to the transport as a status class" and ' - + 'D "keep it dispatcher-scoped and rename it"): RETIRE. A metric NAME is a RESPONSE ' - + 'surface, not authorable metadata — no stack, example or template carries it, so there ' - + 'is no source for a D2 conversion to rewrite and no schema to tombstone; a host names ' - + 'the series in its own dashboard or alert file, outside this repo. That is exactly why ' - + 'this entry exists: for an operator whose Grafana keys on the string, the ledger is the ' - + 'only notification channel there is. Same disposition, and the same reason, as ' - + '`runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ' - + 'ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No dashboard, alert rule or exporter config names `http_request_errors_total`: the ' - + 'series stops receiving samples the moment 17.2.0 is deployed, so a panel keyed on it ' - + 'draws a flat zero that reads as a healthy server rather than as a removed metric — ' - + 'the one failure mode this retirement can produce, and the reason the changeset ' - + 'announces it loudly. A 5xx-rate panel or alert is rewritten to ' - + '`http_requests_total{status=~"5.."}` and then PROVEN wider, not merely non-empty: ' - + 'make an auth route or a REST data-API route answer 5xx and confirm the new query ' - + 'moves, where the retired counter would not have moved at all. If the signal you were ' - + 'actually alerting on was "a handler threw rather than returning an error envelope", ' - + 'that is the `errorReporter`, not a counter — wire an APM adapter and assert one ' - + 'synthetic 5xx throw arrives. In code, `SEMCONV.httpRequestErrorsTotal` and ' - + '`RUNTIME_METRICS.httpRequestErrorsTotal` no longer resolve (tsc reports TS2339 at any ' - + 'surviving read) and no `metrics.counter` call names the string.', - }, - { - id: 'http-server-runtime-vocabulary-retired', - surface: - 'system.serverEvent / system.serverEventType / system.serverCapabilities / ' - + 'system.serverStatus (the lifecycle-event, capability-report and status vocabulary of ' - + 'system/http-server.zod.ts — 4 defs, 8 exported names)', - replacement: - '(removed — there is no replacement key, because there was never a key. Server lifecycle ' - + 'is the transport plugin\'s own start/stop seam; per-request and per-server ' - + 'observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus ' - + '`OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a ' - + 'transport plugin can DO it states by implementing the kernel plugin contract — the ' - + 'seams it registers are the capability statement, and a self-described capability ' - + 'record can only disagree with them. Server-level configuration that IS authorable ' - + 'lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)', - reason: - 'The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed ' - + 'the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring ' - + 'entry); this removes the RUNTIME half — a 7-member lifecycle event union with a ' - + 'timestamped envelope, an eight-boolean capability report, and a five-state status ' - + 'record with connection and request counters. Nothing ever emitted, consumed or ' - + 'parsed any of them. ' - + 'This card was HELD for four days rather than queued, on a specific and legitimate ' - + 'doubt: a response/capability vocabulary can be a REFERENCE surface for host ' - + 'implementers, so "zero consumers in this repo" is weaker evidence for one of those ' - + 'than for an authorable key (the CSS-variable rebuttal). The hold was lifted by ' - + 'measuring the reference reader itself rather than by re-running the same grep: ' - + '`plugin-hono-server`, the one in-tree host implementation, neither implements nor ' - + 'reports any of the three — it names no capability record, no status shape and no ' - + 'event union, and what it registers is routes and middleware through the kernel ' - + 'plugin contract. A declaration-site grep put every declaration in this one file, a ' - + 'quoted-name sweep across objectstack and objectui found no reader outside it, and ' - + 'the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, ' - + 'resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in ' - + 'this file when there was one. ' - + 'With no carrier key there is nothing to tombstone, and with no author there is no ' - + 'source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR ' - + 'plus this entry are the declaration — route 3, the same shape as the config half\'s ' - + 'removal in this very file and the earlier removals of the dynamic plugin-loading family, ' - + 'the `ui/` interaction configs and the widget / i18n shapes. If host-implementer ' - + 'conformance becomes a real ' - + 'requirement it returns through the ENFORCE route: an adapter contract with a checker ' - + 'behind it, vocabulary second. ADR-0049.', - acceptanceCriteria: - 'No source imports `ServerEvent`, `ServerEventType`, `ServerEventSchema`, ' - + '`ServerCapabilities`, `ServerCapabilitiesSchema`, `ServerCapabilitiesParsed`, ' - + '`ServerStatus` or `ServerStatusSchema` from `@objectstack/spec/system` — a grep over ' - + 'consumer code resolves none of them, and `tsc` reports TS2724/TS2305 on any that ' - + 'survives. The route-registration half of the same module still resolves ' - + '(`RouteHandlerMetadataSchema`, `MiddlewareType`, `MiddlewareConfigSchema`, ' - + '`MiddlewareConfig`), and `StackServerConfigSchema` — the one authorable server ' - + 'surface — is untouched: a stack declaring `server: { trustProxy, security }` parses ' - + 'exactly as it did in 16.x.', - }, - { - id: 'import-run-automations-declared-default-corrected', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). - surface: - 'api.ImportRequest runAutomations — the declared default of the key on BOTH import ' - + 'bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST ' - + '/api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema ' - + 'object). It was declared default(false) and described as "off by default for ' - + 'bulk"; it is now default(true), which is what the server has always done', - replacement: - 'an explicit runAutomations: false on any import request that is meant to load rows ' - + 'without firing triggers/hooks. That spelling is unchanged and has always been the ' - + 'only one the server read — what changes is that omitting the key now DECLARES what ' - + 'it already DID. Callers who want automations on need write nothing', - reason: - 'A DECLARATION corrected to match a runtime that did not move — the inverse of a ' - + "behaviour flip, and registered here for the reason protocol 12's " - + '`rest-requireauth-default-flip` and this major\'s ' - + '`action-descriptor-resume-authority-default-flip` are: whether a given import was ' - + 'meant to fire triggers is a judgment no transform can make, so the prescription is ' - + 'a TODO rather than a rewrite. The server decides in import-prepare.ts with ' - + '`body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has ' - + 'since the flag was first honoured — automations always ran on import historically ' - + '(the engine ignored the flag entirely before then), ' - + 'so opt-out was made the explicit act, matching ' - + 'platform convention. The schema said the opposite in both machine-readable and ' - + "human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s " - + 'JSON Schema, and the describe prose in the published reference tables for both ' - + 'defs. ' - + '⚠️ Nothing in this repo reconciled the two and NO deployed caller changes ' - + 'behaviour: no request path parses an import body through this schema — the route ' - + 'reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is ' - + 'the declarative `ImportJobApiContracts` catalog entry, a declaration and not a ' - + 'parse. That is exactly why this needed a ruling rather than a docs edit: the ' - + 'divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. ' - + 'A client or SDK that validated its request through the published schema ' - + 'materialised `runAutomations: false` from the declared default and sent it ' - + 'explicitly, and the server honoured it — so the same request body produced ' - + 'opposite behaviour depending on whether the caller validated before sending, with ' - + 'the validating caller silently losing its triggers. Nothing rejected it, nothing ' - + 'warned, and the reference page told an author the wrong thing in the other ' - + 'direction. There is deliberately NO schema tombstone and no D2 conversion: no key ' - + 'is removed, and an HTTP request body is neither authored nor persisted — the same ' - + 'disposition `notification-list-cursor-retired` takes for the sibling ' - + 'default on this major, and `batch-options-validate-only-retired` before it. The ' - + 'declared move itself is recorded mechanically, per key, in ' - + 'DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped ' - + 'default was found invisible to every gate — whose `from`/`to` fingerprints are ' - + 're-derived on every build. Maintainer ruling 2026-08-09, disposition A: ' - + 'the spec follows the runtime. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'Every import request of yours that must NOT fire triggers sends `runAutomations: ' - + 'false` explicitly, rather than omitting the key and trusting the old declared ' - + 'default. The check is worth doing precisely where it looks unnecessary: if you ' - + 'build the body by parsing it through `ImportRequestSchema` (or the published JSON ' - + 'Schema) and then send the PARSED object, your bulk loads were running with ' - + 'automations OFF and will now run with them ON — that is the only class whose ' - + 'behaviour changes, and it changes toward what an unvalidated caller always got. ' - + '⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as ' - + 'such: a body that omits `runAutomations` fired triggers before this change and ' - + 'fires them after, and `runAutomations: false` turns them off before and after. ' - + 'Nothing starts being refused — the route never validated this body against the ' - + 'schema and does not begin to. `dryRun` is unaffected and still runs NO automations ' - + 'whatever the flag says: it asks the engine\'s validate-only write path for its verdict, ' - + 'and that path deliberately fires no hooks.', - }, - { - id: 'job-retry-policy-constraints-tightened', - surface: 'job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)', - replacement: 'maxRetries <= 10, and backoffMultiplier >= 1', - reason: - 'The RetryPolicy converged onto one declaration from its automation and system copies ' - + 'keeps the automation side\'s bounds, which the job ' - + 'side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. ' - + 'Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a ' - + 'retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay ' - + 'that SHRINKS on each attempt — retrying a failing dependency ever faster, which is ' - + 'the opposite of backoff and was never a shape the engine meant to offer. Both now ' - + 'fail at parse time with the bound named, rather than being silently reinterpreted. ' - + 'Choosing the replacement count (or accepting the cap) is the author\'s call.', - acceptanceCriteria: - 'Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no ' - + '`backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a ' - + 'retry re-runs the handler with its writes and callouts. No job fails to register ' - + 'with the retry-policy bound prescription.', - relevantWhen: { kind: 'stack-declares', keys: ['jobs'] }, - }, - { - id: 'notification-list-cursor-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). - surface: - 'api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications ' - + '(ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor ' - + 'argument of the client SDK call client.notifications.list(). The same entry covers ' - + 'the limit default: the request schema no longer declares default(20)', - replacement: - 'a larger `limit` — the route answers the newest N notifications and has no page 2. ' - + 'There is no replacement for `cursor`, deliberately: nothing ever minted one, so no ' - + 'caller holds a value to carry over. Callers that looped on it were re-reading the ' - + 'first window and should read one window sized to what they display (the Console ' - + 'bell polls exactly this way). For the removed `limit` default, send the number you ' - + 'want explicitly if you were relying on 20 — omitting it takes the server window, ' - + 'which is 50 on the platform inbox and clamped into 1..200, and has been since ' - + 'before the declaration existed', - reason: - 'One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, ' - + 'Option A, ruled jointly with the repair that made `unreadCount` really count the whole ' - + 'inbox). `cursor` was declared on the request and on ' - + 'the response and honoured on neither: the dispatcher domain reads `read` / `type` / ' - + '`limit` and nothing else, and no emit site has ever written the response key. It ' - + 'was worse than inert because it had a shipped PRODUCER — the SDK appended it to the ' - + 'query string — so a caller paginating by the published contract looped on page 1 ' - + 'forever, with no error and no 400. Measured over a real boot with 60 unread before ' - + 'the removal: page2 === page1, both parsing green against the response schema, which ' - + 'is why no conformance gate could see it. ' - + 'This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the ' - + 'same verdict for the same reason, down to deleting the SDK producer alongside the ' - + 'key. A first-class inbox cursor, if one is ever designed, will be a ' - + 'response-minted opaque token — a different API — so keeping this one preserved a ' - + 'wrong design rather than a roadmap. ' - + 'The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the ' - + 'number: no request path parses a query string through this schema (the fix for ' - + 'request bodies never checked against their declared schemas wired the ' - + "catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never " - + 'stamped anything onto anything, and the server has always applied its own 50. ' - + 'Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a ' - + 'declaration that does not execute and merely made it coincide with the ' - + 'implementation until someone moved the clamp; `.optional()` plus prose is true ' - + 'about both the schema and the server. No constraint (`.int()` / `.max(200)`) is ' - + 'declared either, because the service CLAMPS an out-of-range limit rather than ' - + 'refusing it, and declaring a rejection the wire does not perform is the same defect ' - + 'mirrored. ' - + 'Route 2, and the split is worth stating exactly because the two halves of the ' - + 'bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, ' - + 'so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept ' - + 'sending — a clean parse and a parameter that never takes effect, which is this ' - + "issue's own defect re-created one layer down (the silent strip measured when a field " - + 'key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So ' - + '`cursor` is ' - + '`retiredKey()` on both halves, typed `never` for tsc and raising the prescription ' - + 'at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is ' - + 'NO D2 conversion: a conversion rewrites an authored source or a stored ' - + '`sys_metadata` row, and these two shapes are HTTP-only — nobody authors a ' - + '`ListNotificationsRequest` and nothing persists one. Request AND response shapes: ' - + 'two semantic TODOs for API callers, no stack conversion — the same disposition ' - + '`BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the ' - + '`AnalyticsQueryRequest` envelope keys ' - + 'already take in this major. The `limit` default is declared separately and ' - + 'mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot ' - + 'where a default or constraint change on an authorable key was recorded by no gate), ' - + 'whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes ' - + 'it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess ' - + 'property), which is the enforced channel — the removal is loud at compile time for ' - + 'every TypeScript consumer. Reading `response.cursor` no longer type-checks either, ' - + 'and always answered `undefined` before. ⚠️ Behaviour on the wire is deliberately ' - + 'UNCHANGED and must be verified as such: a request still carrying `?cursor=…` is ' - + 'IGNORED, not refused — the domain reads three named query keys and no route ' - + 'validates this query against a schema, so an unknown key has never produced a 400 ' - + 'and does not start doing so here. The declaration stopped promising what the wire ' - + 'never did; the wire did not change. `unreadCount` is untouched (it was the jointly ' - + 'ruled repair\'s business) and still ' - + 'reports the total across the whole matching inbox rather than the window. A caller ' - + 'that omitted `limit` receives the same 50 rows it always received.', - }, - { - id: 'package-uninstall-explicit-all-tenants', - surface: 'protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)', - replacement: 'explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it', - reason: - 'An uninstall that named no organization matched EVERY organization\'s rows — measured ' - + 'at 5 of 5 deleted, including a foreign org\'s, while uninstall\'s orphaned-row defect was ' - + 'being repaired. That width was never ' - + 'chosen; it fell out of a missing argument, and the two transports of the same route ' - + 'disagreed because of it. In protocol 17 the call is REFUSED instead: neither ' - + '`organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and ' - + 'deletes nothing, as does supplying both (they are contradictory, not redundant). ' - + 'Whether a given caller meant "this tenant" or "every tenant" is an intent no ' - + 'transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an ' - + 'accidental org-less call and a deliberate environment-wide one are byte-identical ' - + 'at the call site — which is the whole reason the parameter had to become explicit ' - + 'rather than conventional. Nothing in authored metadata spells this: it is a runtime ' - + 'call-site contract, so it is one semantic TODO for operators and API callers rather ' - + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) ' - + 'takes for its own default flip.', - acceptanceCriteria: - 'Every caller of `deletePackage` states its tenant scope. A caller that intends an ' - + 'environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped ' - + 'one passes `organizationId`; no caller passes both. An explicit `allTenants: false` ' - + 'is treated as undeclared and refused, since it is not an affirmative request for ' - + 'cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 ' - + '`TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means ' - + 'that script was relying on the cross-tenant reading and must now say so on purpose. ' - + 'The org-scoped path is unchanged — an uninstall carrying an `organizationId` still ' - + 'removes that org\'s rows AND the environment-wide (`organization_id IS NULL`) rows, ' - + 'exactly as the orphaned-row repair left it.', - }, - { - id: 'plugin-activation-events-retired', - surface: - 'kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents', - replacement: - '(removed — delete the key. Every plugin activates immediately on load/registration, ' - + 'which is the only behaviour that has ever existed; `activate()` still runs at ' - + 'registration time. Lazy activation, if built, returns via the enforce route of ' - + 'ADR-0049 through a new ADR, with a vocabulary its executor actually honours)', - reason: - 'Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary ' - + 'they embedded (`onCommand` / `onRoute` / … / `onView`, once the kernel and studio ' - + 'copies had converged on the kernel\'s structured `{ type, pattern }` shape) — ' - + 'promised lazy plugin activation ("plugins remain dormant until an activation event ' - + 'fires") that no runtime in objectstack, cloud, cloud-v1 or objectui ever ' - + "implemented: nothing anywhere read the key, every plugin activates immediately, and " - + "cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). " - + 'That is the ADR-0049 false-compliance shape in the semantically-lying direction: an ' - + 'author writing `activationEvents: [{ type: \'onMetadataType\', pattern: \'flow\' }]` ' - + 'expected deferral and got eager activation with a clean parse. Neither parent shape ' - + 'is stored metadata — `StudioPluginManifest` is TS configuration parsed by ' - + '`defineStudioPlugin` (a root schema, never part of a stack tree) and ' - + '`DynamicLoadRequest` is a runtime request shape with no caller — so no ' - + '`sys_metadata` row can carry the key and there is no source for the D2 chain to ' - + 'rewrite; this entry is the D3 record. The kernel key is tombstoned via ' - + '`retiredKey()` (its schema is not `.strict()`; a plain delete would strip an ' - + "authored value silently), the studio key is rejected by the strict manifest parse " - + 'with a guidance prescription (as are its former VS Code-flavoured aliases ' - + '`activation` / `events` / `onActivate`), and the orphaned `ActivationEventSchema` / ' - + '`ActivationEvent` exports are removed from `./kernel` and `./studio` with the keys ' - + '(the lesson of the unwired plugin sandboxing / integrity / approval config removed ' - + 'before this: an exported schema with no consumer is read as a capability). Both keys ' - + 'took ADR-0049\'s REMOVE answer, not ENFORCE, while protocol 17 was still unreleased. ' - + 'SUPERSEDED ON THE KERNEL SIDE by the maintainer\'s REMOVE ' - + 'ruling on the rest of the plugin-runtime family (same unreleased major): the whole ' - + '`DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — ' - + 'was removed, which took this key\'s `retiredKey()` tombstone with it. That is ' - + 'strictly stronger than the tombstone, not weaker: there is no longer a ' - + '`DynamicLoadRequest` to author the key INTO, so the prescription an author needs ' - + 'is no longer "delete this key" but "this request shape does not exist" (see ' - + '`plugin-runtime-family-retired`). The studio half of this entry is ' - + 'unaffected and still enforced by the strict manifest parse.', - acceptanceCriteria: - 'No `defineStudioPlugin` input authors `activationEvents` — authoring it is an ' - + 'unknown key on the strict studio manifest and a parse error carrying the ' - + 'prescription. On the kernel side the stronger criterion of the plugin-runtime ' - + 'family\'s removal applies instead: ' - + 'there is no `DynamicLoadRequest` type or schema left to author it into at all. No ' - + 'code imports `ActivationEventSchema` / `ActivationEvent` from ' - + '`@objectstack/spec/kernel` or `@objectstack/spec/studio` (TS2305 after upgrade). ' - + 'Runtime behaviour is byte-identical: plugins loaded eagerly before and after.', - }, - { - id: 'plugin-manifest-loading-retired', - surface: - 'manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / ' - + 'initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)', - replacement: - 'nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` ' - + 'registers them and the kernel runs `init` then `start` in an order topologically ' - + "resolved from each composed plugin's own `dependencies` / `optionalDependencies` " - + '(`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation ' - + '`loading.sandboxing` appeared to configure, note that the plugin trust tier ' - + '(`manifest.runtime`, ADR-0025 §3.6) does not supply it either: that tier is enforced ' - + 'at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the ' - + '`node` tier is rejected with HTTP 422 and forced to manual review), while load-side ' - + 'enforcement is NOT implemented, so a locally installed plugin is not isolated by the ' - + 'tier it declares. ⛔ Nor do the manifest permission declarations give it back: ' - + 'the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load ' - + 'and queried by nothing, so it refuses no operation. Neither surface confines a ' - + 'plugin today — do not author either one expecting isolation', - reason: - 'ADR-0049 enforce-or-remove; the maintainer ruled REMOVE on 2026-08-04, on condition ' - + 'that a bare-name sweep of cloud and objectui came back clean first. The block declared a ' - + 'complete plugin loading policy and NOTHING read it. A bare-name scan of all three ' - + 'repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), ' - + 'each with a control probe proving the scan saw the tree — put every hit inside ' - + '`packages/spec` itself: this module\'s own declaration, its own unit tests, the ' - + '`Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero ' - + 'readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key ' - + 'parsed, entered the manifest, and changed nothing — an exported schema with no ' - + 'consumer, read as a capability, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared ' - + 'process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ' - + 'ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform ' - + 'isolates plugins, wrote the config, and received a clean parse and zero isolation. An ' - + 'inert security control is worse than an absent one because it is believed. Hot reload ' - + 'was additionally a TWO-SOURCE defect: the docs pointed at this dead ' - + '`PluginHotReloadSchema` while the only implementation body, `HotReloadManager` ' - + '(`packages/core/src/hot-reload.ts`), reads a different vocabulary — ' - + '`HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on ' - + 'the surviving side: that schema is KEPT as the starting point for a future enforce ' - + 'decision (it has an implementation body but no runtime composes it yet), and ' - + 'enforcing it is deliberately a separate decision, not this retirement. ' - + 'Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and ' - + '`applyConversionsToStoredItem` maps a metadata type onto one of its collections. A ' - + 'package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` ' - + 'entry, so a manifest is not a stack collection member and a stored manifest row ' - + 'passes that seam through unchanged. A conversion would be a transform with no seam ' - + 'that ever runs.', - acceptanceCriteria: - 'No `objectstack.plugin.json` and no stored package manifest carries a `loading` key. ' - + 'The enforced channel is the one place a manifest is parsed with an author present: ' - + '`os plugin build` runs `ManifestSchema.safeParse` and exits non-zero, printing the ' - + 'tombstone prescription, so a manifest still declaring `loading` fails its build ' - + 'rather than shipping. TypeScript authors get it earlier still — `loading` is typed ' - + '`never`, so assigning it is a `tsc` error. ⚠️ Runtime behaviour is deliberately ' - + 'UNCHANGED and must be verified as such: nothing ever read the block, so removing it ' - + 'removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries ' - + '`loading` keeps working — the registry\'s `validate()` is an explicit diagnostic and ' - + 'not a gate (it catches, logs `[metadata_spec_invalid]`, and registers the item ' - + 'anyway, deliberately, so bad metadata is never a data outage), so such a row ' - + 'degrades to one log line at registration rather than a boot failure. Clear it by ' - + 'deleting the key from the source manifest and reinstalling.', - }, - { - id: 'plugin-runtime-family-retired', - surface: - 'kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult ' - + '/ kernel.pluginSource / kernel.dynamicPluginOperation', - replacement: - '(removed — there is no replacement shape, because there is no operation to describe. ' - + 'Plugins are composed at boot: `defineStack` registers them and the kernel runs ' - + 'register → init → start; the set is fixed until the process restarts. Delete the ' - + 'import and the value. Runtime plugin loading, if it is ever built, returns via the ' - + 'enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)', - reason: - 'The five schemas declared the "Dynamic Loading" capability — runtime load / unload / ' - + 'reload of plugins without a kernel restart, with sandboxing, integrity hashes, ' - + 'drain strategies and dependent-cascade policy — and NOTHING implemented it. A ' - + 'bare-name scan of objectstack, cloud and objectui found zero references outside ' - + "this package's own declaration, its unit tests and the generated artifacts: no " - + 'runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced ' - + 'a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most ' - + 'inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the ' - + 'published IDE bundle as proof the platform hot-loads plugins and constructs a ' - + 'request that parses clean and is received by nobody (an exported schema with no ' - + 'consumer is read as a capability). The earlier removal of this module\'s ' - + 'discovery/sandbox config island — plugin sandboxing, integrity and approval settings ' - + 'that nothing read — left these five in place explicitly: ' - + '"operation contracts, not security promises; the enforce-or-remove call on them is ' - + 'a design decision rather than a correction" — but that suspension lived only in a ' - + 'changeset paragraph with no issue carrying it. The maintainer\'s ruling of ' - + '2026-08-03 is that decision, answered REMOVE: hot loading is a real future ' - + 'capability, but nothing is being built and nothing pulls it, and when it is built ' - + 'its vocabulary enters the schema with the implementation. `experimental` was ' - + 'considered and rejected: it is only `.describe()` prose ' - + 'and cannot stop an import, the weakest of the three ADR-0049 channels. None of the ' - + 'five is stored metadata — they are root request/result payload shapes embedded in ' - + 'no parent schema and parsed against no metadata document — so no `sys_metadata` ' - + 'row can carry one and there is no source for the D2 chain to rewrite; this entry ' - + 'is the D3 record. The removal also subsumes the kernel half of ' - + '`plugin-activation-events-retired`: that tombstone goes with the shape ' - + 'that carried it. ADR-0049.', - acceptanceCriteria: - 'No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, ' - + '`DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` ' - + 'or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, ' - + '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, ' - + '`DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from ' - + '`@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after ' - + 'upgrade, on every public entry (pinned by symbol identity in ' - + '`plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because ' - + 'nothing called anything: a caller that believed it was hot-loading a plugin was ' - + 'already only building an object. Boot-time composition through `defineStack` is ' - + 'unchanged.', - }, - { - id: 'position-permissions-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_position.permissions — the "JSON-serialized array of permission strings" ' - + 'textarea column left the platform position table declared by plugin-security ' - + '(packages/plugins/plugin-security/src/objects/sys-position.object.ts), together ' - + 'with the clone_position copy entry that carried it between rows', - replacement: - 'nothing on this table — delete the key from any authored `sys_position` seed row ' - + '(stack `data` entries) or data-door write that still carries it. There are no ' - + 'direct position-level permission strings anywhere on the platform: capability ' - + 'reaches a position ONLY through permission-set bindings ' - + '(`sys_position_permission_set` rows, created in Setup or by an app\'s ' - + 'kernel:ready binder) and is resolved from the position `name` at request time. ' - + 'A value that was recording intent as documentation belongs in `description`, ' - + 'which remains declared', - reason: - 'Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ' - + 'ADR-0049 enforce-or-remove: REMOVE. The ' - + 'object-scoped census (all sys_position-naming files, with same-object positive ' - + 'controls resolving `active` / `delegatable` / `is_default` / `name` to real ' - + 'readers) measured the column at zero on both sides: the only row writers — the ' - + 'builtin and declared position bootstrappers — set label / description / ' - + 'managed_by / active / is_default, and position→grant resolution consults ' - + '`sys_position_permission_set` rows plus the position `name`, never this column. ' - + 'Its only in-repo reference was the clone_position action copying it between ' - + 'rows — a copy of a value nothing writes. objectui was searched under the same ' - + 'discipline (evidenceScope closure): no console surface names the column — the ' - + 'position pickers and Setup views read name / label / id only, so a designer ' - + 'preview consumer does not exist either. That left a declared free-text grant ' - + 'catalogue on a security object that no runtime enforced: an author — human or ' - + 'AI — who filled it believed they granted permission strings directly on the ' - + 'position, and nothing refused or honoured the value. ' - + 'This is a platform-object COLUMN retirement, not a spec-key retirement, so the ' - + 'bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands ' - + 'in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never ' - + 'declared `permissions`, and the surface ratchets are expected byte-identical), ' - + 'no liveness-ledger row is added (the ledger walks PositionSchema\'s shape, ' - + 'which never carried the key — a row would be an orphan), and the disposition ' - + 'is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain ' - + 'rewrites seed rows today and the measured author base is zero, while the loud ' - + 'channel already exists at runtime — the engine schema preflight refuses an ' - + 'undeclared field with 400 INVALID_FIELD before the driver or any hook runs — ' - + 'so this entry carries the prescription and the refusal carries the ' - + 'enforcement. The live-authoring half is the PositionSchema strict-parse ' - + 'guidance for `permissions`, which names the binding table in the rejection. ' - + '⚠️ Existing physical columns are deliberately untouched: schema sync is ' - + 'additive (ADR-0045), so a deployed database keeps the column; the platform ' - + 'stops declaring, projecting or accepting it. Zero producers means no rows are ' - + 'expected to carry a value; no backfill or destructive DDL is required or ' - + 'wanted. If position-level direct grants ever become a real need, the column is ' - + 're-declared then, WITH a runtime reader in the same PR — declare-and-enforce ' - + 'or do not declare.', - acceptanceCriteria: - 'No authored stack seeds `permissions` on a sys_position record, and no client ' - + 'write to that table carries the key. Concretely: (1) grep your stack sources ' - + 'for permissions next to sys_position — delete the key from any seed row; prose ' - + 'that was documenting intent belongs in `description`. (2) Boot and load your ' - + 'stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming ' - + 'the column — that refusal is the enforced channel, not a silent drop. (3) If ' - + 'you meant to grant capability, author it where it is enforced: bind permission ' - + 'sets to the position (`sys_position_permission_set` rows, created in Setup or ' - + 'by an app\'s kernel:ready binder) — the authz resolver then expands the ' - + 'bindings from the position name at request time.', - }, - { - id: 'query-array-string-agg-retired', - surface: "data.query.aggregations[].function ('array_agg' / 'string_agg')", - replacement: - 'an ordinary `fields` query, shaped in the caller — or a stored field that materialises ' - + 'the roll-up. For a deduplicated COUNT the live spelling is unchanged: ' - + '`count_distinct` stays declared', - reason: - 'The stored half of this retirement is a conversion ' - + '(`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. ' - + '`QueryAST` is never stored in stack metadata — it is the client SDK builder\'s output ' - + 'and the `POST /data/:object/query` body — so there is no source for the chain to ' - + 'rewrite and callers move their own queries. Both values were declared-but-unlowered ' - + 'on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso ' - + '`RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller ' - + 'following the schema against a SQL datasource got a refusal, not an array. They did ' - + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes ' - + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation ' - + 'that worked on one backend and failed on another is exactly the unpredictability the ' - + 'ruling ended, and the maintainer\'s 2026-08-05 investment freeze on driver-memory and ' - + 'driver-mongodb had both of those backends frozen at the time (that freeze ' - + 'was lifted on 2026-08-11). `count_distinct` was ' - + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s ' - + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049.', - acceptanceCriteria: - 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style ' - + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised ' - + 'as a stored field. A query still carrying either value fails to parse with the ' - + 'removal prescription naming it, and authoring it is a `tsc` error at the call site; ' - + '`count_distinct` continues to parse and is unaffected.', - }, - { - id: 'query-cursor-retired', - surface: 'data.query.cursor', - replacement: - 'a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` ' - + 'with the matching `orderBy` (the documented manual-keyset pattern)', - reason: - 'The `cursor` key promised keyset pagination and no driver implemented it: the cursor ' - + 'was accepted and ignored, so every page came back identical — a caller looping ' - + '"until hasMore is false" never terminates. Worse than inert, it had a shipped public ' - + 'producer (`QueryBuilder.cursor()`, removed with the key). The caller-built ' - + '`Record` shape also leaks sort/storage detail and squats on the ' - + 'reserved REST parameter set; a first-class cursor, if ever designed, will be a ' - + 'response-minted opaque token — a different API, so keeping this one preserved a ' - + 'wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to ' - + 'rewrite. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep ' - + 'pagination expresses the keyset as a `where` predicate on the sort key. A query ' - + 'still carrying `cursor` fails to parse with the removal prescription, and authoring ' - + 'it is a `tsc` error.', - }, - { - id: 'query-distinct-retired', - surface: 'data.query.distinct', - replacement: - '`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated ' - + "counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", - reason: - 'The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it ' - + 'was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list ' - + 'path treated a distinct query as not countable and silently degraded ' - + '`total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND ' - + 'worse pagination metadata, and a side effect that "confirmed" the flag was doing ' - + 'something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with ' - + 'the key). The count suppression is deleted in the same change — `total` is truthful ' - + 'for those queries again. A REQUEST surface, never stored; nothing to rewrite. ' - + 'ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; ' - + 'deduplication goes through `groupBy` / `count_distinct` / the drivers\' `distinct()` ' - + 'door. A query still carrying the key fails to parse with the removal prescription, ' - + 'and the REST list response reports a real `total` for queries that used to send it.', - }, - { - id: 'query-field-node-object-form-retired', - surface: 'data.query.fields', - replacement: - "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested " - + "query selects the related record's own columns — keeping the foreign key in your own " - + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " - + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' - + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' - + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' - + 'the response to every field). Where the value is wanted on the queried object itself, ' - + 'denormalise it onto that object (a stored field, written when the source changes) — the ' - + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' - + 'or rollup field materialises no column to sort or select by', - reason: - 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that ' - + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` ' - + '— objectql\'s formula projection and known-field filters, driver-sql\'s `select()` and ' - + 'driver-memory\'s projection all treat the list as `string[]`, driver-mongodb keyed its ' - + 'projection with the entry itself, and the REST ingress stringified it. Nested selection ' - + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST ' - + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report ' - + 'authors one), so there is no source for the chain to rewrite: the schema narrows to ' - + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller puts an object in `fields[]`; related records AND single related columns are ' - + 'read through `expand`, with the foreign-key column retained in the projection so ' - + 'expansion has something to resolve. A `fields` entry that is not a string ' - + 'fails to parse with the removal prescription, and the list/query/export routes answer ' - + '400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.', - }, - { - id: 'query-joins-retired', - surface: 'data.query.joins', - replacement: - "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested " - + "query selects the related record's own columns — keeping the foreign key in your own " - + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " - + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' - + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' - + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' - + 'the response to every field). Where the value is wanted on the queried object itself, ' - + 'denormalise it onto that object (a stored field, written when the source changes) — the ' - + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' - + 'or rollup field materialises no column to sort or select by', - reason: - 'The `joins` array was declared-but-inert: no engine or driver read `query.joins` ' - + 'anywhere on the query path, so a query carrying it behaved exactly as if the key were ' - + 'absent — while the name squatted on the reserved REST parameter set. Related-record ' - + 'retrieval already has a live spelling (`expand`, resolved by the engine via batch ' - + '`$in` queries), so the removal deletes the second, broken spelling rather than the ' - + 'capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with ' - + 'the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there ' - + 'is no source for the chain to rewrite; callers move their own queries. ' - + 'ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `joins`; related records AND single related columns are read through ' - + '`expand`, with the foreign-key column retained in the projection so expansion has ' - + 'something to resolve. A query that still carries `joins` fails to parse with the removal ' - + 'prescription (even as an empty array), and authoring it is a `tsc` error at the call site.', - }, - { - id: 'query-window-functions-retired', - surface: 'data.query.windowFunctions', - replacement: - '`aggregations` + `groupBy` for request-level analytics; ' - + '`SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource', - reason: - 'The `windowFunctions` array was declared-but-inert on the query path: `find()` never ' - + 'applied a window function, so every OVER clause a caller declared was silently ' - + 'dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, ' - + 'a driver-level door that is not on the `IDataDriver` contract and whose flat input ' - + 'shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never ' - + 'matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door ' - + 'never read, so that cluster is removed with the key rather than left as a false ' - + 'affordance. A REQUEST surface, never stored; no source to rewrite. ' - + 'ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `windowFunctions` in a query; request-level analytics use ' - + '`aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL ' - + "driver's `findWithWindowFunctions` door directly. A query that still carries the key " - + 'fails to parse with the removal prescription naming that door.', - }, - { - id: 'record-details-sections-object-form', - surface: 'ui.RecordDetailsProps.sections (the `record:details` page component)', - replacement: - 'an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the ' - + 'string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the ' - + 'heading translatable, and the new sibling `hideFields` omits named fields from the body', - reason: - '`record:details` declared `sections` as a list of section IDs — `["overview", ' - + '"financials"]` — a shape nothing produced and nothing consumed, while every real page ' - + 'authored the object form. This is authorable metadata on the publish/parse path, so it ' - + 'is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical ' - + 'conversion because a string ID carries no field list and the chain cannot invent one — ' - + 'only the author knows which fields the section named `overview` was meant to render. ' - + 'The measurement that made the type change safe is also what makes the prescription ' - + 'unambiguous: the ID-list form had zero read paths and zero producers. objectui\'s ' - + '`RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` ' - + '/ `s.fields`) with no string branch at all — a string entry spreads into a character ' - + 'map and renders nothing; `@object-ui/types`\' `RecordDetailsComponentProps` mirror ' - + 'already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can ' - + 'only author `{ label, columns, fields }`; `packages/lint` has modelled it as ' - + '`nestedSections` all along; and every page in this repo — three showcase pages plus the ' - + '`sys_user` platform page — authors the object form. So the break lands only on stored ' - + 'metadata written against a declaration nothing ever honoured, and it lands at publish ' - + 'time rather than rewriting data at rest. The same change DECLARED `hideFields`, which ' - + 'the `sys_user` platform page had been authoring undeclared. Registered late, by the ' - + 'stock reconciliation that compared the breaking changesets already on the v17 release ' - + 'train against this ledger: the change that declared the object form predates the gate ' - + 'that makes a breaking changeset state its ledger disposition, so nothing asked it ' - + 'what it had done about the ledger, and the sibling key on the same def — ' - + '`ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone ' - + 'while this face carried none. ADR-0087.', - acceptanceCriteria: - 'Every `record:details` component in authored metadata spells `sections` as an object ' - + 'array: each entry names the fields it renders (`fields: [...]`), optionally with ' - + '`label` / `name` / `columns`. `objectstack validate` passes and each detail page ' - + 'renders the same sections, in the same order, with the same fields as before the ' - + 'upgrade — a page whose sections silently render EMPTY is the signature of an ID list ' - + 'left in place. A section that existed only as an ID, with no field list recoverable ' - + 'from the page it belonged to, is a judgement for the author: name the fields it was ' - + 'meant to show, or delete the entry. Fields previously hidden by a convention outside ' - + 'the schema move onto the declared `hideFields`.', - }, - { - id: 'rest-server-openapi31-block-removed', - surface: 'restServer.openApi31', - replacement: - '(removed — no replacement key exists. Delete the key; for a real outbound webhook use ' - + '`Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 ' - + 'webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 ' - + 'through a new ADR)', - reason: - 'The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / ' - + '`pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with ' - + '`OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 ' - + "document synthesis nothing delivered: the REST server's `normalizeConfig` forwards " - + 'only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the ' - + 'pre-generated @objectstack/spec contract enriched with the live server URL and the ' - + 'registered objects — a webhook declared here never appeared in any served document ' - + '(ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector ' - + 'webhook and event enums one layer up). There is no behaviour ' - + 'to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS ' - + 'configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never ' - + "a `sys_metadata` shape — the stack tree's `api` block declares only its four " - + 'scoping/auth knobs. The three schemas are removed with the key (zero import-level ' - + 'consumers in objectstack / cloud / objectui); the key itself is tombstoned because ' - + 'the schema is not `.strict()` and a plain delete would strip it silently.', - acceptanceCriteria: - 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` ' - + '`restConfig`) carries `openApi31` — a config that includes it now fails the parse ' - + 'with the retirement prescription instead of being silently stripped. No code ' - + 'imports `OpenApi31Extensions(Schema)`, `Callback(Schema)` or ' - + '`OpenApiWebhookEvent(Schema)` from `@objectstack/spec/api` (TS2305 after upgrade). ' - + 'The served /openapi.json is byte-identical before and after — the block never ' - + 'reached it.', - }, - { - id: 'runtime-httpserver-wrapper-retired', - surface: 'runtime.HttpServer (the exported delegating wrapper class)', - replacement: - 'register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — ' - + '`HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one', - reason: - '`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its ' - + 'constructor, declared `implements IHttpServer`, and forwarded only the contract\'s ' - + 'REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / ' - + '`close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, ' - + '`setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs ' - + 'consumers to feature-detect exactly those members with `typeof server.X === ' - + '"function"` and to degrade when absent, so wrapping a capable adapter made every ' - + 'probe answer false and the capability vanish with the adapter underneath providing it ' - + 'the whole time. The sharpest consequence is worth writing down before anyone reaches ' - + 'for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered ' - + 'the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, ' - + 'because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints ' - + 'since publish stopped refusing them and 17 began executing them — was never forwarded. ' - + 'This is a TS/API contract surface: an HTTP server ' - + 'adapter is CODE, never stack metadata, so there is no authored source for the chain to ' - + 'rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a ' - + '`.parse()`. That is precisely why this entry must exist: for an untyped JS host the ' - + 'ledger is the only notification channel there is, and for a typed one tsc reports at ' - + 'the construction site. Same disposition, and the same reason, as ' - + '`storage-service-list-retired` and `data-driver-find-stream-retired`. ' - + 'Registered by the stock reconciliation that compared the breaking changesets already on ' - + 'the v17 release train against this ledger, not by the original change: the wrapper\'s ' - + 'removal landed before the gate that makes a breaking changeset state its ledger ' - + 'disposition existed, so nothing ever asked it what it had done about the ledger. ' - + 'ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of ' - + 'the name resolves — the export is gone, so a typed caller fails to compile at the ' - + 'construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE ' - + 'as `http.server` instead, and then proves the capability came back: `getPort()` returns ' - + 'the real bound port after `listen(0)`, `getRawApp()` returns the framework-native app, ' - + 'and a declarative `apis:` endpoint declared in metadata answers its route rather than ' - + '404 — the last of which is the failure a wrapper produced silently. An adapter that ' - + 'genuinely needs to intercept calls implements `IHttpServer` in full, forwarding the ' - + 'optional members too, rather than declaring `implements` and dropping them.', - }, - { - id: 'sharing-execution-context-retired', - surface: - '@objectstack/spec: the exported type `SharingExecutionContext` ' - + '(`contracts/sharing-service`), and its re-export from ' - + '@objectstack/plugin-sharing — the six-field context shape ' - + '(`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / ' - + '`isSystem`) that sharing, approval and report enforcement signatures used to name', - replacement: - '`ExecutionContext` from `@objectstack/spec` — the complete ' - + '`resolveAuthzContext` envelope the sharing, approval and report contracts have declared ' - + 'since they converged onto it. Every one ' - + 'of the retired type\'s six fields exists on it under the same name and type, so a ' - + 'value that satisfied the old type already satisfies the envelope: only the ' - + 'annotation is rewritten, never the value', - reason: - 'ADR-0049 enforce-or-remove, completing the maintainer\'s ruling of 2026-08-07 on the ' - + 'share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site ' - + 'subset). This type was the ' - + 'declared context parameter of 36 signatures across three contracts — ' - + '`ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — ' - + 'and it omitted four fields those gates need: `accessible_org_ids` (under the ' - + '`group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, ' - + '`posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR ' - + 'direction of the share-link twin, which that same ruling moved onto the whole context: ' - + 'nothing trimmed the VALUES — ' - + "the engine middleware always handed the whole context down — it was the declared " - + 'TYPE that was narrow, so an implementation could not READ what it had been given ' - + 'without casting out of its own contract (`const posture = (context as any).posture` ' - + "in plugin-approvals' privileged-override gate). One change converged the " - + 'contracts, two more re-annotated the four implementations (sharing and audit, then ' - + 'approvals and reports), and this change removes the now-unreferenced declaration, the ' - + 'deletion that split had deferred. ' - + 'Why this needs a ledger entry despite nothing in-repo referencing it: it is the ' - + '`export-field-meta-constraints-retired` / `hook-context-session-roles-retired` ' - + 'disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no ' - + '`retiredKey()` tombstone and no parse rejection that could carry the prescription, ' - + 'and the ledger is the only channel that reaches an upgrader. ' - + 'Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. ' - + 'The name is only ever spelled inside a consumer\'s own TypeScript, so no ' - + '`objectstack migrate meta` transform can reach it, and no `sys_metadata` row ' - + 'carries it. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or ' - + '`@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from ' - + '`@objectstack/spec` and the build is green. tsc IS a sufficient detector here, ' - + 'unlike the optional-key retirements at this step: the name is gone outright, so ' - + 'every remaining reference is a hard resolution error rather than a silent ' - + '`undefined`. ⚠️ Then check the direction tsc CANNOT see: widening an annotation ' - + 'never rejects a value, so an enforcement path that only ever received a hand-built ' - + 'six-field object still compiles and still under-adjudicates. Confirm each caller ' - + 'passes the context it was HANDED, unchanged, rather than a literal it assembled — ' - + 'and that any gate of yours reading `posture`, `accessible_org_ids`, `org_user_ids` ' - + 'or `tabPermissions` now reads them declared, with no `as any` in the path.', - }, - { - id: 'sharing-rule-recipient-reconcile', - surface: - 'security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules ' - + '(`type: owner` + `ownedBy`)', - replacement: - '`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose ' - + 'the records through a public form or a share link; `type: owner` → rewrite as a ' - + '`type: criteria` rule. `business_unit` is newly authorable for the single-unit case', - reason: - 'The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename ' - + 'and the enforced runtime, in both directions at once. It still offered the pre-rename ' - + '`group`, which the seed path silently SKIPPED, while omitting two recipients the ' - + 'runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and ' - + '`business_unit`). It also offered `guest`, which had no runtime recipient mapping at ' - + 'all. Each of those is a rule that validated and then materialised nothing — the ' - + 'ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: ' - + 'the author sees a valid rule and believes a set of people can reach the records, and ' - + 'no error ever contradicts them. Owner-type rules go for a different and sharper ' - + 'reason: they depend on live team / position membership, which the static materialiser ' - + 'cannot track, so they could not be made to work by fixing a name. They return as an ' - + 'enforced form only if membership-reactive re-materialisation is designed. This is a ' - + 'semantic entry rather than a mechanical conversion because only one of the three ' - + 'rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` ' - + 'have no target — the author has to decide who was actually meant to reach those ' - + 'records and say so in a form the runtime enforces, and a transform that guessed would ' - + 'be inventing an access grant. After this change every authorable recipient and rule ' - + 'type on the SharingRule surface is enforced; the `queue` recipient stays ' - + 'runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note ' - + 'the two neighbouring conversions cover DIFFERENT faces of this schema and not this ' - + 'one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and ' - + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came ' - + 'out of the metadata property liveness audit, which found security properties parsed but ' - + 'never enforced, and was registered late, by the stock reconciliation that compared the ' - + 'breaking changesets already on the v17 release train against this ledger. ADR-0078 / ' - + 'ADR-0090 D3 / ADR-0087.', - acceptanceCriteria: - 'No sharing rule names `group` or `guest`, and none carries `type: owner`; stale ' - + 'definitions now FAIL parse with the valid options listed, so the sweep is "fix until ' - + 'nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a ' - + 'rule that was silently materialising nothing looked exactly like one that worked. For ' - + 'every rule that named `group`, confirm the `sys_team` it now resolves to has the ' - + 'membership you expected, and that records reach the people the rule was written for. ' - + 'Each former `guest` rule needs an explicit decision about anonymous access — a public ' - + 'form grant or a share link, or knowingly no access at all — and each former owner-type ' - + 'rule needs a `criteria` predicate that names the same population, checked against a ' - + 'representative record. Where a single business unit was meant, use `business_unit`; ' - + '`unit_and_subordinates` is the subtree and grants strictly more.', - relevantWhen: { kind: 'stack-declares', keys: ['sharingRules'] }, - }, - { - id: 'sort-node-direction-rejected', - surface: 'data.query.orderBy[].direction (SortNode)', - replacement: - '`order` — `orderBy: [{ field: "updated_at", order: "desc" }]`. One word, same values ' - + '(`asc` / `desc`)', - reason: - '`SortNodeSchema` was a plain `z.object`, so zod\'s default `.strip` applied and a sort ' - + 'node spelling its direction `direction` lost the key silently. Measured on `main` ' - + 'before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` ' - + 'returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` ' - + 'falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the ' - + 'request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", ' - + 'that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary ' - + '200 with nothing in the response to distinguish it from the answer that was asked for. ' - + '`direction` is not a typo: it is the live vocabulary of a neighbouring contract, ' - + '`IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated ' - + 'between the two by hand — a translation known to be necessary and enforced nowhere, ' - + 'the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a ' - + '`strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` ' - + 'in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The ' - + 'alias is deliberate rather than left to the edit-distance fallback, because no edit ' - + 'distance bridges `direction` → `order` and a bare "unrecognized key" would leave the ' - + 'caller exactly where the silent strip did. This is registered as a semantic entry ' - + 'rather than a mechanical conversion for one reason worth stating: the rewrite itself ' - + 'is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the ' - + 'author may have written it meaning ascending and been silently GIVEN ascending, so ' - + 'the visible behaviour never contradicted them, and only they can say whether the ' - + 'sort they have been reading was the sort they asked for. Registered by the stock ' - + 'reconciliation of the v17 train\'s breaking changesets: the in-code alias tombstone ' - + 'shipped with the change that closed both doors ' - + '(maintainer ruling 2026-08-03), but the ledger half ' - + 'never did, and a retirement needs both — the tombstone is the proof the removal was ' - + 'declared, the ledger entry is what `spec-changes.json`, the upgrade guide and ' - + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No authored `orderBy` entry — in metadata, in a saved view\'s `sort[]`, or in a REST / ' - + 'RPC request body — spells the key `direction`. The upgrade\'s own verify loop is that ' - + 'the failure is now LOUD: a stale `direction` raises a named parse error (or `400 ' - + 'INVALID_SORT` at ingress) quoting `order`, so a sweep is "fix until nothing raises" ' - + 'rather than a search. ⚠️ Check the RESULTS, not just the parse: every list, report and ' - + 'paged query that carried `direction: "desc"` has been silently serving ASCENDING order ' - + 'and, wherever it was paired with `limit`, a different set of rows. After the rename ' - + 'those pages change what they return — that is the defect being corrected, not a ' - + 'regression, and any downstream expectation baked against the old output has to be ' - + 're-read rather than restored.', - }, - { - id: 'spec-type-alias-input-suffix-retired', - // Plain text, no markdown: build-upgrade-guide.ts renders this field inside a - // code span AND inside a table cell, so backticks here break both. - surface: - 'type alias: the 102 XInput names of @objectstack/spec ' - + '(ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ' - + 'ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ ' - + 'identity/ integration/ kernel/ security/ system/ ui/)', - replacement: - 'the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` ' - + 'a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the ' - + '`Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held ' - + 'a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already ' - + 'declared for every schema whose two shapes differ, so the target name has existed for ' - + 'a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, ' - + '`CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare ' - + 'aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, ' - + '`ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or ' - + '`Partial`-shaped) types no bare alias denotes.', - reason: - 'This entry exists for the reason `data-driver-find-stream-retired`, ' - + '`storage-service-list-retired` and `actor-user-roles-to-positions` ' - + 'exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack ' - + 'metadata, so there is no source for a D2 conversion to rewrite and deliberately no ' - + 'schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, ' - + 'and no `.parse()` ever saw it. Measured and verified rather than assumed: ' - + '`json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL ' - + 'across this change, because those generators enumerate runtime `z.ZodType` exports ' - + 'and never read a type alias. So nothing left the published metadata surface and ' - + 'RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim ' - + 'the metadata contract shrank. The enforced channel is tsc: the name is gone, so every ' - + 'consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the ' - + 'replacement — a compile error says `ConnectorInput` does not exist, not that ' - + '`Connector` now means what it meant. The generated upgrade guide is the only channel ' - + 'that carries the second half, which is precisely the gap ADR-0087 registration ' - + 'exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and ' - + 'that entry had to land separately. ⚠️ Deliberately ' - + 'NOT registered alongside it: the 1384 bare aliases ' - + 'the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and ' - + 'still resolve; what moved is which of a schema\'s two shapes they denote, and only ' - + 'where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op ' - + 'there, pinned as such). A consumer holding an authored literal is made MORE correct ' - + 'by it, silently; one holding a parse result gets a tsc error at the first defaulted ' - + 'key it reads. Registering that as a rename would misdescribe it — no name was ' - + 'retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9 (its ' - + 'phase 2, which moved every bare name to the author state).', - acceptanceCriteria: - 'No source imports a name ending `Input` from `@objectstack/spec` except the nine listed ' - + 'above: `rg "\\b\\w+Input\\b" --type ts` over consumer code resolves only to those. A ' - + 'literal annotated with a bare spec type compiles while listing ONLY the keys the ' - + 'author means — `const c: Connector = { name, label, type }` type-checks, which it did ' - + 'not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name ' - + 'no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal ' - + 'that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every ' - + 'bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced ' - + '`XInput` synonym.', - }, - { - id: 'storage-service-list-retired', - surface: 'contracts.IStorageService.list', - replacement: - 'track the keys you wrote (sys_file / file-reference records, queryable through ' - + 'ObjectQL with real pagination) instead of enumerating the bucket — and where no ' - + 'such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this ' - + 'entry reserved, restored since, once cloud proved to be the first-party caller this ' - + 'repository could not see', - reason: - '`list(prefix)` was an OPTIONAL contract method documented as "List files in a ' - + 'directory/prefix", and the two shipped adapters answered the same call with two ' - + 'different semantics — both of them silently incomplete. `LocalStorageAdapter.list` ' - + 'was a single-level `readdir`, so a nested key `a/b/c` was invisible under ' - + '`list(\'a\')` (only `a/b` came back), and a subdirectory that `stat` succeeded on ' - + 'was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is ' - + 'a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` ' - + 'was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither ' - + '`IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a ' - + 'caller received was the first page, with no signal. One contract method, two ' - + 'dialects, both quietly incomplete — and the first feature that genuinely needed to ' - + 'enumerate a prefix (backup, orphan sweep, migration audit) would have got two ' - + 'different answers on two deployments without an error on either. The email plugin\'s ' - + 'large-attachment storage work was nearly that feature: it planned to drive attachment ' - + 'reclamation off ' - + '`list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one ' - + 'level down, and switched to queue-driven deferred work instead. Nothing consumed ' - + 'it afterwards: the only in-repo call site was the `SwappableStorageService` ' - + 'pass-through (which itself rejects when the active adapter has no `list`), and ' - + 'REST, CLI and the storage routes never called it. Remove was chosen over ' - + 'align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two ' - + 'dialects): aligning would grow a ' - + 'conformance surface nobody walks, while a prefix listing that cannot paginate is ' - + 'the wrong signature to inherit — when a real caller needs enumeration it returns ' - + 'cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases ' - + '(nested keys, directory entries, >1000 objects) proving both backends agree. This ' - + 'is a TS/API contract surface — a storage adapter is CODE, never stack metadata — ' - + 'so there is no source for the chain to rewrite, and deliberately no schema ' - + 'tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription ' - + 'there would reach no one. The enforced channel is tsc, and it reports at the call ' - + 'site. Same disposition, and the same reason, as ' - + '`data-driver-find-stream-retired`. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No code calls `storage.list(...)` on the `file-storage` service or on any ' - + '`IStorageService` value. Code that needed "which files are under this prefix" ' - + 'reads the records it wrote — `sys_file` / file-reference rows carry the storage ' - + 'key and page deterministically through ObjectQL — rather than asking the bucket, ' - + 'which is also the only form that stays correct past 1000 objects and across both ' - + 'adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra ' - + 'method is not an error on a class) and is simply unreachable through the ' - + 'contract, so deleting it is cleanup that can follow. The break is on the CALLER ' - + 'side: `storage.list(...)` no longer type-checks, and a PROXY typed against ' - + '`IStorageService` that forwards to `inner.list` is exactly such a caller — the ' - + 'one in `@objectstack/service-storage` goes with the adapters\' own `list` ' - + 'implementations, removed in the retirement\'s implementation half. ' - + '⚠️ AMENDED 2026-08-09, under the maintainer\'s 2026-08-08 ruling on cloud\'s ' - + 'storage-enumeration callers (option B: restore enumeration upstream, correctly shaped, ' - + 'rather than hand-roll S3 pagination one repository over): the ' - + 'RESERVED route in the paragraph above was taken. `list` exists again on the ' - + 'contract, cursor-shaped — `list(prefix, { cursor, limit })` returning ' - + '`{ items, nextCursor }` — because cloud had two first-party callers this repo ' - + 'could not see when the measurement said "nothing calls it" (tenant attachment ' - + 'reclamation, marketplace snapshot GC). This does NOT un-retire anything and the ' - + 'acceptance criterion above is unchanged for what it actually governs: the ' - + 'single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written ' - + 'against it still fails to compile, and the two dialects it had are now pinned ' - + 'against each other in `storage-adapter-list.conformance.test.ts` rather than left ' - + 'to diverge. What changed for an upgrader is only the destination: prefer the ' - + 'records you wrote, and reach for the restored member when there are none.', - }, - { - id: 'tool-requires-confirmation-retired', - surface: 'ai.tool.requiresConfirmation', - replacement: - 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the ' - + 'flag the platform confirmation CONTRACT is written against, and that contract is ' - + 'ENFORCED. An AI-facing call on an action declaring the flag must carry the ' - + 'confirmation member `confirm: true` on the request and is REFUSED without it with ' - + '`ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact ' - + 'member to set. A gate, not a queue: nothing is parked, and a refused call did not ' - + 'run — no record was read and none was written. ⚠ Two bounds: the enforced set is the ' - + 'doors that enforce the author\'s `ai.exposed` opt-in, today the action door reached ' - + 'from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and ' - + 'sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the ' - + 'gate makes forgetting loud without proving a human approved', - reason: - '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' - + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' - + 'only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP ' - + 'bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a ' - + 'destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for ' - + 'a SAFETY property it is false compliance, the case ADR-0049 exists for — an author ' - + 'gates a destructive tool, sees the flag accepted, and ships believing a human is in ' - + 'the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries ' - + 'the same name and DOES work, so the mistake reads as correct in review. This is ' - + 'registered as a semantic entry rather than a mechanical conversion because the rewrite ' - + 'is not a rename at all — the replacement lives on a different metadata object at a ' - + 'different layer, and deciding which action should carry the gate (or whether the ' - + 'operation should be an action at all) is a judgement the chain cannot make. Deleting ' - + 'the key mechanically would be the worst possible transform here: it would leave the ' - + 'metadata parsing green while silently completing the removal of a safety gate the ' - + 'author believed was in place. `ToolSchema` was made `.strict()` in the same change, ' - + 'which is load-bearing rather than tidying — removing a key from a non-strict schema ' - + 'swaps one silent no-op for another, so the retired key now REJECTS and the parse error ' - + 'carries the prescription, that being the one channel every consumer bumping ' - + '`@objectstack/spec` is guaranteed to hit. Registered by the stock reconciliation of the ' - + 'v17 train\'s breaking changesets: the `retiredKey()` tombstone shipped with the change ' - + 'that executed ADR-0033\'s deletion of this unenforced key, and still stands in ' - + '`ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the ' - + 'tombstone is the proof the removal was declared, this entry is what `spec-changes.json`' - + ', the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ' - + 'ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No tool definition carries `requiresConfirmation`; the key now raises a located parse ' - + 'error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The ' - + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' - + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' - + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' - + 'true`, which is what the platform confirmation contract gates on — and that gate is ' - + 'PERFORMED: invoking the operation over an AI-exposed door without the confirmation ' - + 'member is REFUSED with `ACTION_CONFIRMATION_REQUIRED` (428) and nothing runs, so ' - + 'that call is a real check you can make rather than a destructive experiment. ⚠ Two ' - + 'bounds on what it proves: the enforced set is the doors that enforce the author\'s ' - + '`ai.exposed` opt-in — today the action door reached from the MCP `run_action` tool ' - + '— while REST `/actions` is not `ai.exposed`-gated and sits outside the gate, so an ' - + 'agent holding an API key on that route is still yours to put a human in front of; ' - + 'and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting ' - + 'loud without proving a human approved. The decision above is still the one this ' - + 'criterion asks you to make. If the operation does not need a human, delete the key ' - + 'knowingly. ' - + 'Deleting it without that decision leaves exactly the state the retirement exists to ' - + 'end: a destructive tool nobody is approving, now without even the false flag to show ' - + 'that somebody once meant to.', - relevantWhen: { kind: 'stack-declares', keys: ['tools'] }, - }, - { - id: 'ui-interaction-config-family-retired', - surface: - 'ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig ' - + '/ ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig ' - + '(the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ' - + 'ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)', - replacement: - '(removed — there is no replacement key, because there was never a key. Touch targets, ' - + 'drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN ' - + 'behaviour: the component library decides them, not a per-page metadata author. ' - + 'Offline is a platform capability, and its vocabulary belongs on the sync engine that ' - + 'owns the queue, the conflict policy and the cache — none of which exists yet. Delete ' - + 'the import and the value. Whichever of these earns real product pull returns WITH its ' - + 'own vocabulary and its executor — the way inbound rate limiting came back, as a new ' - + 'key carrying only what its executor consumes — not by un-retiring a declaration)', - reason: - 'Five `@objectstack/spec/ui` modules declared a full interaction-configuration ' - + 'vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, ' - + 'focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol ' - + 'carried them. This is the ADR-0049 false-compliance shape in its most inviting form ' - + 'for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: ' - + '`authorable-surface.json` listed 109 keys under these defs and ' - + '`content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them ' - + 'as authoring tables, so the published documentation advertised a vocabulary with no ' - + 'carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a ' - + 'page component was rejected by `PageComponentSchema` for an unrecognized key — the ' - + 'docs and the schema disagreeing about the platform (Prime Directive #10). Three ' - + 'independent measurements, each with its controls passing in the same run: (1) no ' - + 'module under `packages/spec/src` imported any of the five except the `ui/index.ts` ' - + 'barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph ' - + 'from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` (25 roots, ' - + '4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, ' - + '`WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic ' - + 'carrier flipped all 21 — so unreachability was a fact about the graph, not a broken ' - + 'walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud ' - + 'outside these modules\' own unit tests. objectui holds TYPE re-exports and parity ' - + 'ratchets, never validators, and says so (its types package deliberately dropped the ' - + 'spec/ui zod-validator re-exports and keeps type-only ones). The 2026-08-04 ruling ' - + 'retired the family — touch, drag-and-drop, keyboard and motion are renderer built-in ' - + 'behaviour and offline belongs to a sync engine, none of it per-page metadata — and weighed ' - + 'wiring a carrier key (option B) and rejected it: that is a feature with a renderer ' - + 'behind it, not ledger clean-up. It also weighed tightening the shapes to ' - + '`strictObject` and rejected that explicitly — strictness is a property of a PARSE and ' - + 'there is no parse, so it would spend a breaking change to leave "a precisely ' - + 'validated dead slot, the more convincing lie" (the lesson of the datasource capability ' - + 'flags: `readOnly` was precisely validated and read by nothing, while a shipped example ' - + 'called a datasource a read replica and wrote through it). Because there was no carrier ' - + 'key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 ' - + 'conversion to rewrite: this entry is the D3 record, the same route 3 as ' - + '`plugin-runtime-family-retired` (the kernel plugin-runtime family) and the ' - + '`HttpServerConfig` retirement (seven keys no runtime read and no authoring door ' - + 'reached, retired with their container). ⚠️ Not to be confused with the theme-token ' - + 'retirement (theme-driven typography is not a near-term capability, so nine token ' - + 'groups nothing consumed were retired), which retired the THEME `animation` block — a ' - + 'different file, different defs, and that one did have a carrier key and therefore a ' - + 'tombstone. ADR-0049.', - acceptanceCriteria: - 'No code imports any of the 64 retired names from `@objectstack/spec` or ' - + '`@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, ' - + '`SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, ' - + '`LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, ' - + '`TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, ' - + '`AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, ' - + '`MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, ' - + '`DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, ' - + '`FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, ' - + '`KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, ' - + '`ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, ' - + '`EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — ' - + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol ' - + 'identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs ' - + 'editing, because none could ever carry one of these blocks: a stack that parsed ' - + 'before parses byte-for-byte the same after. If you consumed the bare ' - + '`ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, ' - + 'declare that union locally — it is your client\'s policy, not the platform\'s. ' - + '`@objectstack/spec/integration`\'s `ConnectorConflictResolution` (connector sync) and ' - + '`@objectstack/spec/api`\'s `ConflictResolutionStrategy` (route merge policy) are ' - + 'different concepts and are untouched.', - }, - { - id: 'ui-notification-action-embed-config-retired', - surface: 'ui.notificationAction / ui.embedConfig', - replacement: - '(removed — there is no replacement shape, because there was never a key to write ' - + 'either into. Delete the import and the value. Notification presentation is still ' - + 'described by the surviving `NotificationType` / `NotificationSeverity` / ' - + '`NotificationPosition` vocabulary; public access to a form is granted by the LIVE ' - + '`FormView.sharing` block (`SharingConfig`), which is untouched. Notification action ' - + 'buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 ' - + 'through a new ADR — carrier key and renderer first, vocabulary second)', - reason: - 'Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. ' - + 'The v17 unknown-key strictness sweep, which measured each ui/ ' - + 'file for an authoring door before closing any shape, measured them three ways on ' - + '2026-08-03 and this retirement re-ran all ' - + 'three against `origin/main` before removing anything, each with a positive control ' - + 'that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared ' - + 'a key of either type (`ui/notification.zod`\'s only non-test importer was the ' - + 'barrel; `ui/sharing.zod`\'s were the barrel and `ui/view.zod.ts`, which names its ' - + 'SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than ' - + 'substring-matching, because the repo holds two `sharing.zod` modules and a substring ' - + 'test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 ' - + 'metadata-type roots plus `defineStack`\'s `ObjectStackSchema`, over ' - + '`build-schemas.ts`\'s own walk including its derived-clone bridge, never reached ' - + 'either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` ' - + 'itself all resolved `root-graph` in the same run and an injected synthetic carrier ' - + 'flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside ' - + 'their own unit tests. So nobody could author one and nothing ever validated one: ' - + 'the shape of the unwired plugin sandboxing config removed before it, an exported ' - + 'schema with no consumer read as a capability, and the ' - + 'ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle ' - + 'as proof the platform serves iframes. Neither is stored metadata and neither has a ' - + 'carrier, so no `sys_metadata` row can hold one and there is no source for the D2 ' - + 'chain to rewrite; this entry is the D3 record. The sweep deliberately did NOT close ' - + 'them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing ' - + 'parses buys only "a precisely-validated dead slot, the more convincing lie" (the lesson ' - + 'of the datasource capability flags, whose `readOnly` was precisely validated and read ' - + 'by nothing) — and left the disposition to ADR-0049\'s enforce-or-remove, which came ' - + 'back REMOVE on 2026-08-04: a dead surface with no authoring door retires ' - + 'implementation-first, as three same-shape rulings that week had already decided. Each ' - + 'was orphaned by an earlier retirement one level up: `NotificationAction` lost its ' - + 'wrappers when the dual-source cleanup removed the `./ui` copies of `NotificationSchema` ' - + '/ `NotificationConfigSchema` (the same names declared differently on other entry ' - + 'points) — that retirement\'s published "zero consumers" evidence was later ' - + 'falsified for objectui, which re-exported both names, and is corrected on ' - + '`ui/notification.zod`\'s tombstone; the removal itself stands — and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 ' - + 'liveness audit retired `App.embed` (no iframe route ever read it) — that key still ' - + 'stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already ' - + 'meets a prescription; this removes the value shape that outlived it. ⚠️ The ' - + 'retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, ' - + 'a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount ' - + 'the anonymous form routes, and `ui/notification.zod` keeps its three presentation ' - + 'enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY ' - + '(never a parse) to pin its own hand-written `NotificationActionButton` interface — ' - + 'which is exactly why "has a consumer" never meant "has an authoring door" here; that ' - + 'pin is adapted objectui-side when it refreshes this dependency. ADR-0049.', - acceptanceCriteria: - 'No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` ' - + 'or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are ' - + 'TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in ' - + '`notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the ' - + 'same run, and that half is equally load-bearing: `NotificationTypeSchema` / ' - + '`NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` ' - + 'must still be exported from `./ui`, and both modules must still load — a retirement ' - + 'that deleted either file would satisfy the absence half while destroying working ' - + 'surface. Nothing regresses at runtime, because nothing ever ran: no notification ' - + 'action was ever parsed from metadata and no iframe route ever read an embed config. ' - + 'Public form sharing is unaffected — `FormView.sharing` still gates the anonymous ' - + 'endpoints on `allowAnonymous` + `publicLink`.', - }, - { - id: 'ui-widget-i18n-family-retired', - surface: - 'ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty ' - + '/ ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat ' - + '/ ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the ' - + 'five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)', - replacement: - '(removed — there is no replacement key, because there was never a key. A custom field ' - + 'widget is still named the same way it always was: `field.widget` is a plain string ' - + 'naming a component the RENDERER has registered, and objectui\'s registry has always ' - + 'carried its own runtime manifest for that (`RuntimeWidgetManifest` / ' - + '`RuntimeWidgetSource` in `@object-ui/types`, renamed off the spec\'s names under ' - + 'objectui\'s rule that a symbol named like a spec export must import it or take a name ' - + 'of its own), which models ' - + 'different keys and never derived from these. For localisation: write the ' - + 'default-language string on `label` / `description` — the framework generates the ' - + 'translation key at registration time from the naming convention — and put ' - + 'translations in translation files, which is the LIVE `system/translation.zod.ts` ' - + 'surface. Widget registration and locale formatting as authorable protocol metadata ' - + 'return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / ' - + 'formatter first, the vocabulary second)', - reason: - '`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest ' - + 'with lifecycle hooks, custom events, configurable properties and an ' - + 'npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a ' - + 'structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the ' - + 'protocol carried either. Three independent measurements, re-run on `origin/main` ' - + 'immediately before the removal with their controls passing in the SAME run: (1) no ' - + 'module under `packages/spec/src` imported `widget.zod` at all, and the only imports ' - + 'of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no ' - + 'schema declared a carrier key — `field.widget` is a `z.string()` naming a registered ' - + 'component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory ' - + 'Zod graph from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` ' - + 'reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` ' - + 'in the same run and a synthetic carrier flipped every one of them; (3) zero ' - + '`.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files\' ' - + 'own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key ' - + '(`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, ' - + 'so the subtree was `no door` rather than `no gate` and goes whole — leaving the two ' - + 'leaves behind would strand exported schemas with no consumer, which an author reads ' - + 'as a capability. ' - + '`I18nObjectSchema` was additionally superseded by its own file-neighbour: ' - + '`I18nLabelSchema`\'s documentation already says translation keys are generated at ' - + 'registration time and translations live in translation files, and the live ' - + 'translation surface is `system/translation.zod.ts`, which uses none of these shapes. ' - + 'The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that ' - + 'is a feature with a registry and a renderer behind it, not ledger clean-up. ' - + 'Tightening them to `strictObject` was rejected earlier and explicitly, by the batch of ' - + 'the v17 unknown-key strictness sweep that measured this file as having no authoring ' - + 'door — strictness is a property of a PARSE and there is no parse, so it would spend a ' - + 'breaking change to leave "a precisely validated dead slot, the more convincing lie" ' - + '(the lesson of the datasource capability flags, whose `readOnly` was precisely ' - + 'validated and read by nothing). With no carrier key there is nothing to tombstone and no `sys_metadata` row ' - + 'or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, ' - + 'the same shape as `ui-interaction-config-family-retired`, ' - + '`plugin-runtime-family-retired` and the `HttpServerConfig` retirement. ' - + '⚠️ `WidgetManifest.performance`\'s own `retiredKey()` tombstone (left by the ' - + 'close-out sweep that removed the inert `performance` keys no renderer applied) is ' - + 'SUBSUMED here, the way the kernel `activationEvents` tombstone went with the removed ' - + 'plugin-runtime family: it goes with the shape that carried it, which is ' - + 'strictly stronger than the tombstone, because there is no longer a manifest to ' - + 'author the key INTO. ' - + '⚠️ One of the nine widget sites is deliberately NOT retired. ' - + '`FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than ' - + 'authorable metadata (it never appeared in `authorable-surface/` or ' - + '`json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its ' - + 'design and not its defect, and it acquired a live cross-repo compile-time consumer ' - + 'one day before that sweep batch measured: an objectui fix of 2026-08-03, made to follow the ' - + 'spec, renamed ' - + '`@object-ui/fields`\' validation slot onto the spec\'s `error` with no alias, the ' - + 'form renderer began producing it, and ' - + '`packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against ' - + '`import type { FieldWidgetProps } from \'@objectstack/spec/ui\'` as an intentional ' - + 'tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049.', - acceptanceCriteria: - 'No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, ' - + '`WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, ' - + '`WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, ' - + '`NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or ' - + '`LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — ' - + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol ' - + 'identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs ' - + 'editing, because none could ever carry one of these shapes: a stack that parsed ' - + 'before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string ' - + 'is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / ' - + '`FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still ' - + 'resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion ' - + 'PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` ' - + 'asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the ' - + '"a workaround should not outlive its reason" half of the rename tripwire objectui ' - + 'added when it stopped declaring symbols under names the spec owns, designed to go red ' - + 'exactly here), and `packages/types/src/widget.ts`\'s ' - + '"Renamed off the spec\'s `WidgetManifest` name" comments now point at names that no ' - + 'longer exist. Both are prescribed responses to this removal, not collateral damage.', - }, - { - id: 'ups-delegated-from-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the ' - + 'platform grant table declared by plugin-security (packages/plugins/plugin-security/' - + 'src/objects/sys-user-permission-set.object.ts). The sibling declaration on ' - + 'sys_user_position is untouched', - replacement: - 'nothing on this table — delete the key from any authored `sys_user_permission_set` ' - + 'seed row (stack `data` entries) or data-door write that still carries it. ' - + 'Delegation semantics live on `sys_user_position`, where `delegated_from` remains ' - + 'declared AND runtime-enforced: the delegated-admin gate is what makes a position ' - + 'insert a delegation, and the explain engine attributes "via delegation from X, ' - + 'until Y". A permission-set grant that needs a provenance note keeps `reason` ' - + '(free text), which remains declared on both grant tables', - reason: - 'Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this ' - + 'column on this object, ADR-0049 enforce-or-remove: REMOVE. The ' - + 'runtime delegation gate is structurally scoped to sys_user_position ' - + '(`isDelegationWrite` returns false for every other object, so ' - + '`assertSelfDelegation` is unreachable for this table), and the explain engine ' - + 'reads delegation provenance from sys_user_position rows only. On ' - + 'sys_user_permission_set the column was therefore declared and data-door-writable ' - + 'while NO runtime consumer read it — its only enforcement was an authoring-time ' - + 'lint (the D3 "delegation row needs a reason" rule), which a row written through ' - + 'the generic data door never meets. That is declared-but-unenforced in its pure ' - + 'form, on a security object: an author who stamped delegated_from on a ' - + 'permission-set grant believed they constrained delegation, and nothing refused or ' - + 'honoured it. Producers measured at zero — the only object literals naming both ' - + 'the table and the column were lint test fixtures. ' - + 'This is a platform-object COLUMN retirement, not a spec-key retirement, so the ' - + 'bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in ' - + 'RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are ' - + 'expected byte-identical), and the disposition is a SEMANTIC entry rather than a ' - + 'D2 conversion. A conversion over stack `data` seed records would be mechanically ' - + 'expressible, but no conversion in the chain rewrites seed rows today and the ' - + 'measured author base is zero; the loud channel already exists at runtime — the ' - + 'engine schema preflight refuses an undeclared field with 400 INVALID_FIELD ' - + 'before the driver or any hook runs — so this entry carries the prescription and ' - + 'the refusal carries the enforcement. ' - + '⚠️ Existing physical columns are deliberately untouched: schema sync is additive ' - + '(ADR-0045), so a deployed database keeps the column; the platform stops ' - + 'declaring, projecting or accepting it. Zero producers means no rows are expected ' - + 'to carry a value; no backfill or destructive DDL is required or wanted. ' - + 'If delegation at permission-set granularity ever becomes a real need, the column ' - + 'is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce ' - + 'or do not declare.', - acceptanceCriteria: - 'No authored stack seeds `delegated_from` on a sys_user_permission_set record, and ' - + 'no client write to that table carries the key. Concretely: (1) grep your stack ' - + 'sources for delegated_from next to sys_user_permission_set — delete the key from ' - + 'any seed row; a row that was recording genuine hand-over provenance should say it ' - + 'in `reason` instead, which the platform stores on both grant tables. (2) Boot and ' - + 'load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD ' - + 'naming the column — that refusal is the enforced channel, not a silent drop. ' - + '(3) If you meant actual delegation-of-duty, author it where it is enforced: a ' - + 'sys_user_position insert with delegated_from = the writer, a mandatory future ' - + 'valid_until within the ceiling, and a mandatory reason (ADR-0091 D3) — the ' - + 'delegated-admin gate then validates the whole shape at runtime.', - }, - { - id: 'view-filter-rule-value-shaped-by-operator', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ' - + 'ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a ' - + 'related-list component filter and a lookup picker filter. It accepted any declared ' - + 'scalar or array for EVERY operator; the accepted shape is now decided by the rule ' - + 'operator — in / not_in require an array, between requires exactly two bounds, and ' - + 'every other operator is unchanged', - replacement: - 'an ARRAY for in / not_in (a single value becomes a one-element list: value: "won" ' - + 'becomes value: ["won"]), and a two-element [min, max] array for between. The empty ' - + 'list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a ' - + 'scalar operator carrying an array, a string operator carrying a number, and a unary ' - + 'operator carrying an ignored value all still parse', - reason: - 'A publish-time gate catching up to a query-time one, not a new rule. An earlier fix ' - + 'closed the RUNTIME half: `assertListComparandShapes` ' - + '(@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered ' - + '`{ stage: { $nin: "won" } }` with a named 400 INVALID_FILTER, and before that it ' - + 'was a 500. The authoring surface stayed silent, so the failure was two-stage: the ' - + 'view published cleanly and only broke when someone opened it. That file names this ' - + 'very schema as the reachable authoring source of the defect. The tightening MIRRORS ' - + 'that gate exactly — three constraints, one for one — and deliberately goes no ' - + 'further, because an earlier fix already settled the opposite error (the ordering ' - + 'operators\' comparand widened to the strings the platform itself produces): a schema ' - + 'stricter than ' - + 'the runtime "in ways the runtime deliberately allows" was the WRONG side and was ' - + 'widened to match. So `in: []` is still accepted (a declared predicate both drivers ' - + 'implement), `equals: ["a","b"]` is still accepted (it lowers to a deep-equality ' - + 'comparand), and `is_empty: ""` is still accepted (the null predicates take their ' - + 'direction from the operator NAME — convertComparison ignores the value position, ' - + 'and the ObjectUI client deliberately sends a truthy placeholder there). ' - + '⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. ' - + 'A D2 entry replays a shape the platform once WROTE and renamed; this shape was ' - + 'never written by any first-party producer (every in / not_in rule in this repo, in ' - + 'objectui and in the cloud repo already carries an array — measured) and has never ' - + 'EXECUTED, since it 400s on first render today. Coercing it at load would be the ' - + 'platform guessing intent rather than replaying a rename, and it cannot guess ' - + 'honestly: value: "" would become the predicate [""] (a real filter on the empty ' - + 'string) rather than the "not filled in yet" a console row means, and between: 5 has ' - + 'no defensible second bound at all. The read path does not re-validate stored rows ' - + '(applyConversionsToStoredItem never validates, by its own contract), so no stored ' - + 'view becomes unreadable; what changes is that RE-SAVING such a view is refused at ' - + 'the write gate naming `value`, instead of storing a filter that 400s. ' - + 'ADR-0049 / ADR-0078 / ADR-0112.', - acceptanceCriteria: - 'Grep your authored views, pages and related-list components for a filter rule whose ' - + 'operator is in, not_in or between (including the alias spellings nin / notIn / ' - + 'notin) and whose value is not an array of the right arity, then wrap or complete ' - + 'it. `os validate` / `os lint` now report each one by path with the operator, the ' - + 'received shape and the corrected shape, so the sweep is mechanical rather than by ' - + 'eye. Two checks are worth doing where it looks unnecessary: a rule reading ' - + '`operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was ' - + 'meant to select rather than mechanically rewriting it to [""], which is a real and ' - + 'different predicate. And a view that already carried one of these shapes was never ' - + 'returning filtered rows: it answered 400 INVALID_FILTER on render, so ' - + 're-check what the view is supposed to show rather than assuming the old result set ' - + 'was correct.', - }, - { - id: 'view-management-protocol-retired', - surface: - 'api.listViews / api.getView / api.createView / api.updateView / api.deleteView ' - + '(the ViewProtocol interface and its ten Request/Response schemas in ' - + 'api/protocol.zod.ts — 10 defs, 25 exported names)', - replacement: - 'the two view surfaces that are actually routed. For a view\'s STORED definition, the ' - + 'generic metadata methods with `type: \'view\'` — `getMetaItem` / `getMetaItems` / ' - + '`saveMetaItem` / `deleteMetaItem`, served at `/api/v1/meta/view/:name`. For the ' - + 'RESOLVED render-time view, `getUiView` (`GetUiViewRequest` / `GetUiViewResponse`), ' - + 'served at `/api/v1/ui/view/:object/:type`. Neither is addressed by a `viewId`, which ' - + 'is the one thing the retired surface offered and the one thing nothing implemented', - reason: - 'A complete viewId-addressed CRUD surface — list (with a list/form filter), read, ' - + 'create, patch, delete — with none of the three things a protocol method needs. ' - + 'Measured on origin/main immediately before the removal: no implementation ' - + '(`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / ' - + '`createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), ' - + 'no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing ' - + 'viewId-addressed is reachable over HTTP at all), and no caller (the only ' - + '`ViewProtocol` mention outside its own file was the services checklist, which ' - + 'already recorded the five as declared-and-unrouted). The look-alike hits a bare-name ' - + 'grep turns up are all different contracts: `metadata-manager.ts`\'s ' - + '`getView(name: string)` is another class, and objectui\'s ' - + '`getView(objectName, viewId)` resolves through `client.meta.getItem(\'view\', …)`, ' - + 'i.e. the metadata route. ' - + 'What makes this worth a removal rather than a note is that the cost is already ' - + 'measured. A declared surface that is name-identical and semantics-adjacent to a real ' - + 'one is an attractive nuisance in every grep, and it mis-directed a decision once: ' - + 'The issue asking what `GET /ui/view/:object/:type` answers AND its 2026-08-07 maintainer ' - + 'ruling both read ' - + '`GetViewResponseSchema` (zero implementations) as the contract of ' - + '`GET /ui/view/:object/:type`, whose declared response is `GetUiViewResponseSchema` — ' - + 'one word apart, 250 lines up. That ruling\'s reasoning happened to survive the ' - + 'mix-up ("nobody can consume `{object, view}` successfully today" was true, though ' - + 'not for the stated reason), which is the luck this removal stops relying on. ' - + 'Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so ' - + 'there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry ' - + 'are the declaration. If reading and writing ONE view by id becomes a real ' - + 'requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling ' - + '2026-08-07.', - acceptanceCriteria: - 'No source imports `ListViewsRequest(Schema)`, `ListViewsResponse(Schema)`, ' - + '`GetViewRequest(Schema)`, `GetViewResponse(Schema)`, `CreateViewRequest(Schema)`, ' - + '`CreateViewResponse(Schema)`, `UpdateViewRequest(Schema)`, ' - + '`UpdateViewResponse(Schema)`, `DeleteViewRequest(Schema)` or ' - + '`DeleteViewResponse(Schema)` from `@objectstack/spec/api`, and no host declares a ' - + '`ViewProtocol` member. Reading and writing views still works end to end through the ' - + 'surfaces that were always the live ones: `GET /api/v1/meta/view/:name` returns the ' - + 'stored definition and `GET /api/v1/ui/view/:object/:type` returns the resolved view, ' - + 'both unchanged by this removal. `GetUiViewRequestSchema` / `GetUiViewResponseSchema` ' - + 'still resolve — they are the shapes that ruling meant.', - }, - { - id: 'workflow-service-slot-retired', - surface: - "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / " - + 'discovery routes.workflow / RestApiRouteCategory workflow', - replacement: - 'the live mechanisms the slot only ever pointed at: `state_machine` validation rules ' - + 'for record state machines, approval flow nodes on the approvals runtime (ADR-0019) ' - + 'for approvals, lifecycle hooks + `record_change` flows (service-automation) for ' - + 'record-triggered automation', - reason: - 'The workflow slot was declared end to end and implemented nowhere: no code in either ' - + 'repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches ' - + 'were plugin-dev\'s retired stub probe and the generic discovery walk), no ' - + 'implementation of any WorkflowProtocol method ever existed, and no host ever ' - + 'mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a ' - + 'stale list, named it among ' - + 'routes that never existed). Every part of it was ADR-0078\'s silently-inert ' - + 'declaration: a CoreServiceName nothing filled, a contract nothing implemented, a ' - + 'protocol nothing served, a discovery route field no builder could truthfully ' - + 'populate. These are TS/API surfaces and a discovery RESPONSE field — never stored ' - + 'in stack metadata, so there is no source for the chain to rewrite; consumers of the ' - + 'deleted types move their imports themselves. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/' - + 'Transition types resolves; no code calls getService(\'workflow\') or reads ' - + 'discovery `routes.workflow` / `services.workflow`; record state machines, ' - + 'approvals and record-triggered automation go through the replacement mechanisms. ' - + 'Discovery output on a default boot is unchanged (the slot was always reported ' - + 'unavailable; now it is simply absent).', - }, - // - ], -}; - -/** - * One retirement's sentences in a step's `rationale` (#20535). - * - * The rationale renders as ONE paragraph — the upgrade guide prints it and - * `migrate meta` shows it per hop — but an open major's is authored as - * fragments, because every retirement of that major adds to it and one shared - * string does not merge: each retirement PR rewrote its closing line. - */ -interface RationaleFragment { - /** - * Names the retirement: kebab-case, unique in its list, and its own D3 - * `semantic` entry id when it has one. The list is kept SORTED by this key. - */ - readonly id: string; - /** Where the fragment renders: ascending `order`, ties broken by `id`. */ - readonly order: number; - /** The sentences, with no leading or trailing space: fragments join with one. */ - readonly text: string; -} - -/** A rationale from its fragments: ascending `order` (ties by `id`), joined with one space. */ -function joinRationale(fragments: readonly RationaleFragment[]): string { - return [...fragments] - .sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : 1)) - .map((f) => f.text) - .join(' '); -} - -/** - * Step 18's `rationale`, one fragment per retirement. - * - * ⚠️ SORTED BY `id`, NOT BY LANDING ORDER — that is the point of the shape. - * Git reports a conflict whenever two branches insert into the SAME gap between - * two unchanged lines, whatever they insert, and GitHub's server-side merge runs - * no driver that could say otherwise. A list appended at its end is one gap, so - * any two retirements in flight conflicted here, exactly as they did when this - * was one `+` chain whose closing line each of them rewrote. Kept sorted by - * key, two retirements insert into different gaps and merge clean — one - * existing fragment between them is enough. `order`, not the position in this - * list, says where a fragment renders. - * - * To add one: `id` is your retirement's D3 semantic entry id; insert the - * fragment where that id sorts; `order` is one more than the highest here. Two - * retirements in flight may both take the same number, and then render in `id` - * order. `scripts/step18-rationale-merge.test.ts` holds the list sorted and - * proves the merge. - */ -const STEP18_RATIONALE: readonly RationaleFragment[] = [ - { - id: 'action-aria-retired', - order: 43, - text: - 'Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth ' - + 'member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the ' - + 'chart config\'s, and retired for the same measured reason: an ARIA block an author can ' - + 'declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an ' - + 'uncited "partial" note with no reader behind it; at the pinned renderer, none of the ' - + 'surfaces that render an action — button, icon, menu, group and bar, the row and bulk ' - + 'action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, ' - + 'because every one of them already takes the accessible name from the action\'s required ' - + '`label` (visible text, or `aria-label` on an icon-only action), and the node that places ' - + 'the actions carries the node-level `aria` block — a per-action block would be a second ' - + 'spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack ' - + 'actions and object-nested actions as a pure lossless delete, retired from the load path ' - + 'so authors are refused at parse; its D3 record is the semantic entry ' - + '`action-aria-retired`.', - }, - { - id: 'action-block-endpoint-spelling-retired', - order: 55, - text: - 'It also gives the executor target of an action one spelling on the page blocks that run ' - + 'one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the ' - + '`action:button` and `action:icon` component rows declared `endpoint` as a key of their own, ' - + 'and the console\'s `api` handler reads `target` only — so an `api` button authored with ' - + '`endpoint` was accepted by the props gate and called nothing. The rows now refuse it with ' - + 'the same rename, read from the one alias table both share. The D2 conversion ' - + '`action-block-endpoint-to-target` renames the key on an `api` action, where the rename is ' - + 'lossless, retired from the load path so authors are refused at the door while stored rows ' - + 'and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another ' - + 'one is left as stored and reported as a TODO. Its D3 record is the semantic entry ' - + '`action-block-endpoint-spelling-retired`.', - }, - { - id: 'admin-export-wildcard-removed', - order: 4, - text: - 'Finally, it removes the `objects["*"].allowExport` grant from the shipped admin ' - + 'permission sets — `admin_full_access`, `organization_admin` and the derived ' - + '`organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the ' - + 'export axis undeniable for an org admin: an application could declare an object ' - + 'exportable by nobody and the platform exported it anyway, with no supported opt-out, ' - + 'because a code-package set cannot be edited (`403 [not_overridable]`) and the admin ' - + 'held no app-authored set in which to write the per-object `false` that would have ' - + 'won. It is the earlier removal of `member_default`\'s CRUD wildcard applied to the export axis, ' - + 'which had kept its wildcard by omission rather than by decision. From 18 an admin ' - + 'exports exactly what an app-authored set grants — a posture the same run measured ' - + 'to be already precise. Unlike everything else in this step it changes no schema, so ' - + 'nothing refuses at publish: the upgrade signal is behavioural and belongs here.', - }, - { - id: 'agent-lifecycle-retired', - order: 62, - text: - 'It also retires an agent\'s conversation state machine, `agent.lifecycle` (ADR-0049 ' - + 'enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared ' - + 'state or refused an undeclared transition, and enforcing it would have meant a statechart ' - + 'interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served ' - + 'elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step ' - + 'process is a Flow, a record\'s status transitions are the `state_machine` validation rule — so ' - + 'authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` ' - + 'deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, ' - + 'kept by ADR-0020 only for this door, left the package with it.', - }, - { - id: 'agent-memory-store-retired-and-limits-required', - order: 61, - text: - 'It also makes an agent\'s memory contract state exactly what the runtime honours (ADR-0049 ' - + 'enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest ' - + '`maxEntries` long-term notes before the first round, writes one every `reflectionInterval` ' - + 'delivered interactions, and keeps them in its own database store; before an agent\'s first ' - + 'turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` ' - + 'missing either number, and a `reflectionInterval` without one. So `longTerm.store` is ' - + 'retired as a whole key — the memory store is platform infrastructure, not agent metadata — ' - + 'and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired ' - + 'from the load path; and with long-term memory enabled both numbers are required at ' - + 'authoring, with no default declared, so an upgrading author chooses them.', - }, - { - id: 'agent-structured-output-refused-members-retired', - order: 60, - text: - 'It also makes an agent\'s structured output JSON-only (ADR-0049 enforce-or-remove). The ' - + 'cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer ' - + 'and refused four of its members before an agent\'s first turn: the `regex`, `grammar` and ' - + '`xml` formats — no key ever carried a pattern or grammar to check against, and an answer is ' - + 'checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All ' - + 'four are refused at parse with a prescription, and the D2 conversion ' - + '`agent-structured-output-refused-members-removed` deletes a block whose `format` was ' - + 'retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, ' - + 'retired from the load path.', - }, - { - id: 'chart-config-aria-retired', - order: 31, - text: - 'Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer ' - + 'decision of 2026-09-12 — judge the protocol wrong for ' - + 'this one key). It is the last member of the `aria` family retired for the same measured ' - + 'reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an ' - + 'author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — ' - + 'it sits inside the widget’s `chartConfig` bag, which no drill had reached until the ' - + 'per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one ' - + '`ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares ' - + 'no `aria` prop, the presentation lowering names it nowhere, and the react block omits it ' - + 'from ``’s `dataProps`. Remove rather than enforce, because the same chart ' - + 'config already carries a WORKING accessible-name channel in `description` (lowered as ' - + '`role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name ' - + 'sources on one element behind a precedence rule nobody has written — one node, one ' - + 'accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies ' - + 'into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it ' - + 'from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, ' - + '`reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. ' - + 'The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became ' - + 'refusals carrying the same prescription rather than renames onto a tombstone.', - }, - { - id: 'compliance-deadline-keys-retired', - order: 23, - text: - 'It also retires the fourteen hour/minute/day-shaped deadline keys of the ' - + 'incident-response, training and change-management families (ADR-0049 ' - + 'enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response ' - + 'schemas, five on the training schemas and three nested in the change-management ' - + 'schemas, every one on the published surface and read by nothing — the schemas are ' - + 'mounted by no stack key and registered as no metadata type — so a compliance author ' - + 'who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All ' - + 'fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion ' - + 'would be a silent strip) with no D2 conversion, for the additionalTypes reason: ' - + 'none of these schemas is a stack collection member, so the chain has no seam. ' - + 'It then retires those three compliance-shaped families WHOLE (ADR-0049 ' - + 'enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen ' - + 'defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and ' - + '`system/change-management.zod.ts` — roughly a hundred declared keys, exported from ' - + '`@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, ' - + 'absent from the liveness ledgers, read by nothing repo-wide (examples, skills and ' - + 'objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 ' - + 'semantic entry per family; the fourteen deadline-key tombstones leave with their ' - + 'defs\' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean ' - + 'capability claims such as `notifyRegulators`, `requirePostIncidentReview`, ' - + '`trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape ' - + 'left: an author writing `notifyRegulators: true` held a compliance promise the platform ' - + 'never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped ' - + 'e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` ' - + '(`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey ' - + 'tombstones with no D2 conversion (`document` is no stack collection member), ' - + 'registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry.', - }, - { - id: 'connector-error-mapping-retired', - order: 22, - text: - 'It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ' - + 'ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) ' - + 'were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, ' - + 'through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no ' - + 'provider, dispatcher or materializer ever mapped an external error through the rules, so ' - + '`unmappedBehavior` configured nothing and a rule\'s `userMessage` was never shown to ' - + 'anyone. That spelling is the live API-error channel\'s (`ApiError.userMessage`), so an ' - + 'author who wrote a rule here reasonably believed they were marking a refusal for an end ' - + 'user; the failure was silent in both directions. The carrier key is a retiredKey ' - + 'tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), ' - + 'the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the ' - + 'orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and ' - + 'the mechanical conversion strips the block from `connectors[]` (pure lossless delete; ' - + 'it never had an effect to lose).', - }, - { - id: 'connector-provider-context-connection-timeout-ms-retired', - order: 35, - text: - 'It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; ' - + 'maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was ' - + 'owed after the ruling that made its nine ledger siblings live deliberately left this ' - + 'one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, ' - + 'so an author had every signal it worked — and no site ever applied it as a deadline. ' - + 'This retirement is NOT the zero-mention shape: five sites outside `packages/spec` ' - + 'read the key (the materialization fingerprint and the provider-context build in the ' - + 'automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider ' - + 'factories, and the `?? 30000` fallbacks that put it back on the reported def), but ' - + 'every one is a pass-through whose only termini are the def `GET /connectors` echoes ' - + 'and the fingerprint that decides whether to re-materialize. The one mapping from ' - + 'authored policy onto the platform\'s outbound `fetch` was handed `retryConfig` and ' - + '`requestTimeoutMs` only, so the key was carried and never honoured — the same ' - + 'parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was ' - + 'the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole ' - + 'operation and never the connect phase, so bounding time-to-response with it would ' - + 'kill a slow-but-connected upstream the author meant to allow with a large ' - + '`requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform ' - + 'can keep. The carrier key is a retiredKey tombstone on the non-strict ' - + '`ConnectorSchema` (a bare deletion would be a silent strip), registered under both ' - + 'def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers ' - + 'wrapping the same private `ConnectorBaseSchema`; the D2 conversion ' - + 'strips it from `connectors[]` as a pure lossless delete — it never had an effect to ' - + 'lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` ' - + 'door persists the authored value and the stored-row rehydration seam is live for ' - + 'this type, both measured); and the withdrawn `ConnectorProviderContext` member, ' - + 'which is code and has no authored source to rewrite, leaves via the paired semantic ' - + 'entry instead.', - }, - { - id: 'connector-resilience-keys-retired', - order: 44, - text: - 'It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): ' - + '`connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — ' - + '`connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no ' - + 'reader outside the spec package. No loop ever polled a connector endpoint or tripped a ' - + 'breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and ' - + 'participation is `enabled`); and a webhook nested in a connector was never registered as a ' - + '`webhook` item, so it was never materialized or delivered — the top-level `webhooks:` ' - + 'collection is the delivered one. The three carrier keys are retiredKey tombstones on ' - + '`ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted ' - + '`\'inactive\'`, joins `connectionTimeoutMs` in the retired-default residue stage, because ' - + 'every 17.x parse emitted it into every connector. Seven defs leave whole — ' - + '`ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, ' - + '`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion ' - + '`connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored ' - + 'rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them ' - + 'would start deliveries that never happened). It ABSORBS the breaker half of the duration ' - + 'rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer ' - + 'converted, because the whole block it lived in is now removed.', - }, - { - id: 'connector-sync-keys-retired', - order: 52, - text: - 'It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ' - + 'ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, ' - + '`timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and ' - + '`connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, ' - + '`syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` ' - + 'defaults read as configured policy and did nothing. The capability is mainstream, so the ' - + 'definition moves rather than lapses: every mainstream platform binds a sync to its ' - + 'TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls ' - + 'from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence ' - + '(no schedule key returns to the connector). That binding is declared in this step and ' - + 'executed in a later one. Both connector keys are retiredKey tombstones on ' - + '`ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, ' - + '`SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; ' - + 'and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` ' - + 'and stored rows as a pure lossless delete — never writing a `mapping`, which would start ' - + 'writes that never happened.', - }, - { - id: 'connector-triggers-retired', - order: 48, - text: - 'It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps ' - + 'connector-event triggers in its third tier, as their own trigger package): the ' - + '`ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and ' - + '`intervalSeconds` — was read by nothing. The automation engine registered a connector\'s ' - + 'actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever ' - + 'entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, ' - + 'so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on ' - + '`ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of ' - + 'the key, whose reason (the provider derives triggers) was untrue, is gone with it, since ' - + 'the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and ' - + 'the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and ' - + 'stored rows as a pure lossless delete — never turning a trigger into a flow, which is the ' - + 'author\'s decision (an `api` flow for an external event, a `schedule` flow for a scheduled ' - + 'pull, each calling the connector\'s action). It ABSORBS the trigger half of the connector ' - + 'duration rename (its breaker half went with `health` above), so ' - + '`connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer ' - + 'in this step.', - }, - { - id: 'cron-positions-deleted', - order: 26, - text: - 'It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ' - + 'ruling retired each family rather than marking it experimental): the two export-schedule crons, ' - + '`ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and ' - + 'the two disaster-recovery crons were parsed into the cron envelope and read by nothing ' - + '(the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no ' - + 'retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 ' - + 'semantic entry — so this step replays nothing for them and `migrate meta` lists no ' - + 'edit: the keys simply stop existing. That the chain is silent does NOT make the ' - + 'deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but ' - + 'above it `lintUnknownAuthoringKeys` names the dropped key for the one ' - + 'position a stack manifest reaches — `os validate` and `os build` both print ' - + '`connectors..syncConfig.schedule: \'schedule\' is not a declared connector ' - + 'key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that ' - + 'warning. The other six positions are unreachable from a manifest, so for those the ' - + 'parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 ' - + 'on the retirement PR, taken over the seat recommendation to keep the connector D2, on ' - + 'the reading that customers do not upgrade major by major in order.', - }, - { - id: 'cube-member-inner-name-retired', - order: 47, - text: - 'It retires the inner `name` on cube members — `measures..name` and ' - + '`dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: ' - + 'Cube.dev and LookML key a member by its declared name, with no second inner name that can ' - + 'disagree. Both member bags are records, and every consumer already resolved a member by its ' - + 'record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by ' - + 'nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey ' - + 'tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every ' - + 'stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it ' - + 'from every member of every cube, retired from the load path, and its notice prints a ' - + 'disagreeing value beside the key that stays. Its D3 record is the semantic entry ' - + '`cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling ' - + 'they meant.', - }, - { - id: 'cube-member-sql-expression-retired', - order: 53, - text: - 'It also narrows an analytics cube member\'s `sql` — `measures..sql` and ' - + '`dimensions..sql` — to a column reference: a field of the cube\'s object, a ' - + 'relationship path ending in one, or `\'*\'` (maintainer ruling D, ADR-0021 "zero raw ' - + 'SQL / zero raw expressions" carried from the dataset layer to the cube members it ' - + 'compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single ' - + 'field, so no platform check could judge which fields it reads, and the two analytics ' - + 'strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path ' - + 'refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset ' - + 'form — a measure with its own structured `filter` for a conditional count or sum, and ' - + '`derived: { op, of: [...] }` over named measures for a ratio, sum, difference or ' - + 'product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so ' - + 'the semantic entry `cube-member-sql-expression-retired` carries the move, including ' - + 'the scale change a ratio makes (a `derived` ratio is a 0–1 fraction).', - }, - { - id: 'cube-metric-expression-types-retired', - order: 62, - text: - 'It also retires a cube measure\'s custom-SQL-expression types — `number`, `string` and ' - + '`boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 ' - + 'enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with ' - + 'that `sql` now a column reference they had nothing left to compute: the raw-SQL path ' - + 'returned the column unaggregated and the ObjectQL path refused the measure. Each is refused ' - + 'at parse with a prescription naming the six aggregates. No D2 conversion: the column alone ' - + 'does not say which aggregate the author meant, so the semantic entry ' - + '`cube-metric-expression-types-retired` carries the choice, and a stored cube that still ' - + 'carries one is refused rather than rewritten.', - }, - { - id: 'cube-metric-filters-retired', - order: 10, - text: - 'It also retires `measures..filters` on analytics cubes (ADR-0049 ' - + 'enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both ' - + 'SQL strategies aggregate the metric\'s `sql` and never read `filters`, so a ' - + 'hand-authored `filters: [{ sql: "stage = \'closed_won\'" }]` parsed, registered, and ' - + 'silently returned the UNFILTERED aggregate under the author\'s metric name (the ' - + 'same defect the dataset path had, on a hand-authored cube; the dataset half was repaired ' - + 'through its own structured channel when the analytics strategy began compiling each ' - + 'dataset measure\'s `filter`). The raw-SQL fragment also ran against the ' - + 'platform\'s structured-FilterCondition direction — it cannot be parameterized, ' - + 're-targeted per driver dialect, or walked by the lint filter rules. The mechanical ' - + 'conversion strips the key from old sources (pure lossless delete — it never had an ' - + 'effect to lose); filter at query time with `where`, or use an ADR-0021 dataset ' - + 'measure\'s structured `filter` (a metric\'s own `sql` is a column reference, see ' - + '`cube-member-sql-expression-retired`).', - }, - { - id: 'cube-refresh-key-retired', - order: 49, - text: - 'It also retires a cube\'s `refreshKey` whole — the refresh cadence `every` and the ' - + 'data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no ' - + 'analytics result is cached, so a declared cadence refreshed nothing and every query was ' - + 'computed when it was asked, as it still is. The key is a retiredKey tombstone on ' - + '`CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from ' - + 'every cube as a pure lossless delete, retired from the load path. Its D3 record is the ' - + 'semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a ' - + 'result cache exists.', - }, - { - id: 'currency-config-precision-retired', - order: 41, - text: - 'It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): ' - + 'declared and validated against ISO 4217, read by no renderer or runtime — a currency ' - + 'amount\'s decimal places are its currency\'s ISO 4217 minor unit, derived from the ' - + 'currency itself. The D2 conversion `currency-config-precision-removed` strips it from ' - + 'every field\'s `currencyConfig` as a pure lossless delete, which matters most at rest: ' - + 'the schema used to bake `precision: 2` into parse output, so stored object rows and ' - + 'built artifacts carry it without anyone having written it. Retired from the load path; ' - + 'an authored key is refused with the prescription.', - }, - { - id: 'dashboard-widget-chart-config-structure-refused', - order: 32, - text: - 'It also states, and enforces, who owns a dataset-bound chart\'s STRUCTURE ' - + '(ADR-0021; maintainer ruling 2026-09-12): the dataset ' - + 'decides which series exist and which column each one reads, `chartConfig` carries ' - + 'appearance, and `dashboard.widgets[].chartConfig`\'s `type`, `xAxis`, `yAxis` and ' - + '`series` are refused by name on that carrier — the widget\'s own `type` is the chart ' - + 'family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a ' - + 'live membership channel: the renderer synthesised a series from it when the chart ' - + 'declared none, so one authored axis could silently re-point a dataset-bound series at ' - + 'another column and the chart still drew. The D2 conversion strips the four keys from ' - + 'dashboard widgets only — `ReportChartSchema` and the inline-data react `` ' - + 'tier keep their own axes — and the paired semantic entry carries what the stripped ' - + 'keys were saying, because an authored axis field may name a column the widget never ' - + 'selected and no walker can move that intent into the dataset.', - }, - { - id: 'dataset-member-field-expression-refused', - order: 58, - text: - 'It also holds an ADR-0021 dataset\'s `field` — `dimensions[].field` and `measures[].field` — to ' - + 'the accept set the cube members it compiles to already hold, from one shared declaration: a ' - + 'field of the dataset\'s object, a relationship path ending in one, and on a measure also ' - + '`\'*\'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The ' - + 'slot was a bare string that parsed any expression, while the analytics dataset door already ' - + 'refused one on every query, so an expression could be saved and never answered. It is now ' - + 'refused at parse with a prescription naming the ADR-0021 form — a measure with its own ' - + 'structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an ' - + 'empty string (a count omits `field` instead) and `\'*\'` on a dimension, which names no ' - + 'axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a ' - + '`count` measure\'s empty `field`, which still counts rows. An expression has no mechanical ' - + 'rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` ' - + 'carries the rest.', - }, - { - id: 'declared-index-bare-unique-true-retired', - order: 86, - text: - 'It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this ' - + 'protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was ' - + 'positional: it built the index over exactly `fields`, one holder across the whole ' - + 'installation, while reading like "unique per organization" to an author who knew the ' - + 'field-level meaning. The parse now refuses it with a prescription naming both words — ' - + '`\'global\'` (installation-wide, the index bare `true` built) and `\'organization\'` (one ' - + 'holder per organization). Field-level `unique: true` is untouched. The D2 conversion ' - + '`declared-index-unique-scope` rewrites a declared index\'s bare `true` to `\'global\'`, ' - + 'which is lossless and drift-free by construction, retired from the load path so authors ' - + 'are refused at the door while stored rows, built artifacts and `os migrate meta` replay ' - + 'it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: ' - + 'whether each respelled index was really meant installation-wide is the author\'s call.', - }, - { - id: 'deployment-plumbing-organization-columns-retired', - order: 86, - text: - 'It also takes the injected organization column off seven deployment-level platform tables — ' - + '`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, ' - + '`sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that ' - + 'attributes a row of any of them to an organization, so the column only ever held NULL, and under ' - + 'a walled posture the tenant wall hid every row from every reader. Each now declares ' - + '`systemFields: { tenant: false }` and the object-level capability gate ' - + '`requiredPermissions: [\'manage_platform_settings\']`: with no column there is no wall, so reads ' - + 'are governed by object permission, and the gate keeps one organization\'s administrator off ' - + 'another organization\'s rows. Nothing in stack metadata is rewritten; an existing database keeps ' - + 'the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` ' - + 'drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries.', - }, - { - id: 'document-schemas-retired', - order: 88, - text: - 'It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on ' - + 'PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print ' - + 'declaration; no new template type"): the four defs of `data/document.zod.ts` — ' - + '`data/DocumentTemplate` (a docx template with placeholders), `data/Document`, ' - + '`data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from ' - + '`@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by ' - + 'nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 ' - + 'semantic entry, so that "template" means one thing: a printable document is a page that ' - + 'declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def\'s source ' - + 'and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history.', - }, - { - id: 'duration-keys-unit-in-key', - order: 24, - text: - 'Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived ' - + 'only in its description into the key name (maintainer ruling 2026-09-02, ' - + 'no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` ' - + '(mechanical rename, retired from the load path), and the five keys with no stack ' - + 'seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and ' - + 'milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the ' - + 'tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the ' - + 'reference pages never published — are retiredKey tombstones with a ' - + 'semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and ' - + '`integration` remainder closes the same sweep: `dashboard.refreshInterval` → ' - + '`refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` ' - + '→ `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later ' - + 'absorbed by the removal of the block each key lived in — see the connector retirements ' - + 'below), and the two ' - + 'datasource config keys `memory config.persistence.autoSaveInterval` → ' - + '`autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the ' - + 'same file adapter, so splitting them would have left one value with two spellings) ' - + 'and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a ' - + 'connector and a datasource are stack collection members stored as rows; the two with ' - + 'no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, ' - + 'and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey ' - + 'tombstones with a semantic entry each. That remainder is what takes ' - + '`check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate ' - + 'goes red again by design when its declared population widens beyond that subtree.', - }, - { - id: 'element-filter-retired', - order: 7, - text: - 'Finally, it retires the whole `element:filter` element (ADR-0049 ' - + 'enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` ' - + 'retirement recorded and left for ' - + 'its own card): no renderer for the element ever shipped in any repo — objectui ' - + 'registers none, Studio\'s designer palette lists it as a no-renderer exclusion, and ' - + 'the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so ' - + 'every one of its six authorable keys was a capability claim nothing kept. All six ' - + 'are retiredKey tombstones; the mechanical conversion strips them from old sources ' - + '(pure lossless deletes) and leaves the bare node, which the parse then refuses by ' - + 'name — delete the component. List ' - + 'surfaces own their filtering: a view\'s `userFilters` quick-filter bar / the list ' - + 'toolbar\'s filter builder.', - }, - { - id: 'element-flat-data-binding-retired', - order: 90, - text: - 'It also retires the element layer\'s second data door (#11509, ruling A-narrow): the flat ' - + '`object` / `filter` / `sort` / `limit` keys of `element:record_picker` and ' - + '`element:repeater` and the flat `object` / `filter` of `element:number`, each the same query ' - + 'as a key of the node-level `dataSource` binding, resolved per element by three contradictory ' - + 'rules. The console moved all three elements onto the binding first, so from this major an ' - + 'element binds data through `dataSource` only; the keys are retiredKey tombstones, and the ' - + 'component-props gate requires `dataSource.object` on the three instead of waiving the flat ' - + 'key for them — which also closes the repeater that passed validation bound only through a ' - + 'binding it did not read. The D2 conversion `element-flat-data-binding-to-data-source` follows ' - + 'each element\'s old rule (move where the binding lacks the key, delete where the binding won, ' - + 'append `element:number`\'s filter, which AND-combined) and runs before the record-form filter ' - + 'conversion, which then converts a moved record form at `dataSource.filter`; what the old rule ' - + 'leaves undecided is a TODO, judged by the D3 entry.', - }, - { - id: 'element-form-retired', - order: 8, - text: - 'It also retires the whole `element:form` element (ADR-0049 enforce-or-remove ' - + 'at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement\'s own ' - + 'verdict sweep): no renderer for the element ever shipped in any repo — objectui ' - + 'registers none, Studio\'s designer palette lists it as a no-renderer exclusion ' - + 'naming the live replacement, and the 2026-06 page-liveness audit recorded it ' - + 'rendering "Unknown component type" — so every one of its six authorable keys was a ' - + 'capability claim nothing kept. All six are retiredKey tombstones; the mechanical ' - + 'conversion strips them from old sources (pure lossless deletes) and leaves the bare ' - + 'node, which the parse then refuses by name — delete the component. ' - + 'Use the object-bound `object-form` block instead ' - + '— rendered, designer-publishable, its props declared for the component-props gate, and ' - + 'carrying the same intent ' - + '(`objectName`, `fields`, `mode`, `submitText`).', - }, - { - id: 'element-input-target-variable-retired', - order: 6, - text: - 'It also retires `targetVariable` on `element:text_input` and ' - + '`element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint ' - + 'with zero readers in any repo — the live binding runs the other direction, ' - + 'resolved from the page variable whose `source` names the component\'s `id` ' - + '(PageVariableSchema) — so an author who wrote only `targetVariable` got an input ' - + 'that wrote nothing, with a success receipt. The mechanical conversion strips the ' - + 'key from old sources (pure lossless delete — it never had an effect to lose); ' - + 'the tombstone\'s prescription says how to declare the binding that works.', - }, - { - id: 'element-text-variant-heading-subheading-retired', - order: 68, - text: - 'It also completes the `element:text` `variant` convergence (the second release of ' - + 'the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, ' - + '`body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, ' - + 'which every release since the nine were added still accepted, are refused by name with a ' - + 'prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` ' - + 'rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — ' - + 'the heading element each one always rendered, so the outline is unchanged and the heading ' - + 'takes that level\'s style. The `body` default for an absent `variant` is unchanged.', - }, - { - id: 'field-inline-and-related-list-columns-closed', - order: 9, - text: - 'It also closes the two explicit column lists on relationship fields: ' - + '`field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema ' - + '(mirroring the objectui grid renderer\'s measured reads — objectui aligned the ' - + 'widget to `name` and retired the `field` spelling with no tolerant alias), and ' - + '`field.relatedListColumns` entries are child field-name strings (the only form the ' - + 'related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed ' - + 'column published clean and rendered as blank cells with the right row count. The ' - + 'mechanical conversion respells inline `{ field }` entries as `{ name }` and folds ' - + 'related-list column objects to their identity string; unknown keys are named ' - + 'rejections at publish from this major.', - }, - { - id: 'field-reference-to-spelling-retired', - order: 21, - text: - 'Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → ' - + '`reference` on lookup/master_detail fields (the server half of the maintainer\'s 2026-08-31 ' - + 'ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` ' - + 'has always refused `reference_to` by name, but stored `sys_metadata` rows written by ' - + 'seams that bypass the parse still carry it, held up today only by objectui\'s ' - + '`reference ?? reference_to` fallback arms — which the ruling\'s objectui half deletes. The ' - + 'mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical ' - + '`reference` wins, a disagreeing pair is kept for the author), replays on every ' - + 'stored-row rehydration so the serve face only ever emits the canonical spelling, ' - + 'and `os migrate meta` rewrites old sources; the authoring-surface rejection with ' - + 'its rename prescription is unchanged.', - }, - { - id: 'field-scale-precision-integer-refused', - order: 3, - text: - 'It also refuses malformed field `scale`/' - + '`precision` declarations: both are digit counts, so a non-integer or ' - + 'negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time ' - + '`scale` check, which refuses an over-scale value rather than rounding it, deliberately ' - + 'left it unenforced rather than invent ' - + 'floor/round semantics, which made the declaration silently inert. The schema now ' - + 'refuses both at parse (`z.number().int().min(0)`); the mechanical conversion ' - + 'deletes a malformed value from old sources and stored rows (behaviour-preserving), ' - + 'and the semantic entry tells the author to re-declare the count they meant.', - }, - { - id: 'flow-approval-node-config-contract-refused', - order: 84, - text: - 'It also judges an `approval` flow node\'s `config` at parse against the contract the spec ' - + 'declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any ' - + 'issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an ' - + 'undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the ' - + 'artifact. The approval node now joins a declared contract map beside the builtin executor ' - + 'contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin ' - + 'loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the ' - + 'contract\'s own words, its did-you-mean included, and a key left out as before. The builtin arm ' - + 'stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: ' - + 'the platform cannot know what the author meant. Its D3 record is the semantic entry ' - + '`flow-approval-node-config-contract-refused`.', - }, - { - id: 'flow-binding-variable-dollar-name-refused', - order: 92, - text: - 'The binding keys follow the same rule, so a flow cannot bind a `$` name it is then refused to read: ' - + 'a node\'s `outputVariable` (`get_record`, `create_record`, `map`, `script`, `subflow`) refuses a name ' - + 'that starts with `$`, and a `try_catch` `errorVariable` refuses every one but the engine\'s own ' - + '`$error`, its default. The remedy is the same name without the `$`, read as `{{ name }}`. Each key ' - + 'states the rule as a `pattern`, so the published JSON Schema refuses what the parse refuses, and the ' - + 'node contract, `registerFlow`, `objectstack validate` and the run itself refuse such a name at the ' - + 'key. No D2 conversion exists: the bare name may already be bound in the flow, and the reads of the ' - + 'old name sit in every dialect a flow string speaks, so the rename is the author\'s. Its D3 record is ' - + 'the semantic entry `flow-binding-variable-dollar-name-refused`.', - }, - { - id: 'flow-builtin-node-config-undeclared-keys-refused', - order: 89, - text: - 'Then the builtin key arm covers every builtin whose contract registration could judge: a key the ' - + 'executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, ' - + '`http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at ' - + '`nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the ' - + 'rename-or-remove remedy. Registration\'s descriptor walk refused those keys already, after ' - + '`objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those ' - + 'types, so each has one judge; the declared key sets were measured equal first, so registration ' - + 'refuses what it refused before. `try_catch` waits for its contract\'s `retry` to close (below): it ' - + 'stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 ' - + 'conversion exists. Its D3 record is the semantic entry ' - + '`flow-builtin-node-config-undeclared-keys-refused`.', - }, - { - id: 'flow-builtin-node-config-values-refused', - order: 85, - text: - 'Then the builtin arm stops being presence-only: a present value a builtin node\'s executor ' - + 'contract refuses is refused at parse, at `nodes.N.config.`, with the same ' - + '`node-config-refused-by-contract` code. Every builtin executor parses its config against that ' - + 'contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: \'1\'` ' - + 'used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that ' - + 'reached the node. The arm judges only what the build can know the run will parse: never a value ' - + 'carrying a `{token}`, whatever its slot\'s type (held back by ruling, not admitted: outside `http` ' - + 'such a token in a number or boolean slot still fails at its first run, so those slots take a ' - + 'literal); on `http`, which parses after interpolating, only ' - + 'token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a ' - + '`body`; on the region containers, never the region slots. Key membership is untouched. No key ' - + 'is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the ' - + 'value the author meant. Its D3 record is the semantic entry ' - + '`flow-builtin-node-config-values-refused`.', - }, - { - id: 'flow-cel-unbound-root-refused', - order: 92, - text: - 'It also refuses, at `objectstack validate`, a flow CEL root the flow does not bind — in a trigger gate, an ' - + 'edge, a `decision` branch, a screen field `visibleWhen` or a CEL value envelope. Such a root fails every run ' - + 'that reaches it with an unknown-variable fault, and the build used to pass it. The run-user spellings ' - + 'formulas, row-level security and the client accept (`user`, `ctx.user`, `os.user`) are the reachable case: ' - + 'flow CEL binds the run\'s user as `current_user` only, and the refusal names it. A flow whose run can bind a ' - + 'name the reader cannot see is left unjudged — through a node, or through an entrance the stack declares that ' - + 'hands it a record whose keys are not in hand — and so, for now, is every flow write at the runtime publish ' - + 'gate, whose snapshot carries no actions or other flows. No D2 conversion exists: none of these roots ever ' - + 'evaluated in a flow. Its D3 record is the semantic entry `flow-cel-unbound-root-refused`.', - }, - { - id: 'flow-decision-edge-branching-first-match', - order: 45, - text: - 'Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, ' - + '「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the ' - + 'branch, and taking every true branch is the declared `mode: \'inclusive\'`. The D2 ' - + 'conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision ' - + 'with two or more conditioned out-edges and no `conditions` list, so a flow written while ' - + 'every true branch ran keeps its behaviour; it is a default flip, so it is retired from ' - + 'the load path AND refused by the flow rehydration seam and the artifact-ingestion door, ' - + 'and replays only here — the paired semantic entry carries the judgment the diff then ' - + 'asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a ' - + 'decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites ' - + 'it; `os migrate meta --stored` lists each one for review, and `mode: \'inclusive\'` is ' - + 'the one-line fix where a node meant every branch.', - }, - { - id: 'flow-edge-unresolved-or-repeated-refused', - order: 86, - text: - 'It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats ' - + 'an earlier one. An edge\'s `source` and `target` must name nodes of the graph that declares ' - + 'it — the flow\'s own nodes, or the region body\'s for an edge inside a region — because the ' - + 'engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and ' - + 'an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier ' - + 'edge of that graph is refused, because the engine runs a target once per out-edge it selects ' - + 'and a copy ran it again. Both are judged in the region walk the node-id rule uses, so ' - + '`objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, ' - + 'so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent ' - + 'a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record ' - + 'is the semantic entry `flow-edge-unresolved-or-repeated-refused`.', - }, - { - id: 'flow-script-subflow-config-undeclared-keys-refused', - order: 87, - text: - 'And the builtin arm judges key membership where no other door does: a key a `script` or ' - + '`subflow` node\'s executor contract does not declare is refused at parse, at ' - + '`nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two ' - + 'descriptors publish no `configSchema`, so `registerFlow`\'s undeclared-key check skipped them, ' - + 'while their executors parse the strict contract and refuse the node on an undeclared key: a ' - + '`script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration ' - + 'and fail every run that reached the node. Every other builtin keeps its undeclared keys at ' - + 'registration, against its descriptor; a retired `script` key keeps its tombstone. No key is ' - + 'removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what ' - + 'an undeclared key was meant to be. Its D3 record is the semantic entry ' - + '`flow-script-subflow-config-undeclared-keys-refused`.', - }, - { - id: 'flow-text-slot-single-brace-refused', - order: 89, - text: - 'It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node\'s `title` and `message`, ' - + 'a `screen` node\'s `title` and `description`, a refusing `end` node\'s `message`: they render ' - + 'through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with ' - + 'an optional formatter (the engine\'s hole grammar now admits a `$`-named variable, so ' - + '`{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, ' - + '`registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for ' - + 'arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a ' - + 'variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently ' - + '(JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so ' - + 'the rewrite is the author\'s to check. Every other flow string keeps the single-brace dialect. Its ' - + 'D3 record is the semantic entry `flow-text-slot-single-brace-refused`.', - }, - { - id: 'flow-text-slot-unbound-dollar-root-refused', - order: 91, - text: - 'In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine ' - + 'binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`\'s ' - + '`$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a ' - + 'plain string and which renders nothing under the template engine, is refused by the node contract, ' - + '`registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the ' - + 'value with an `assignment` node, then write the variable as a hole. The template engine binds no new ' - + 'variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 ' - + 'record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`.', - }, - { - id: 'flow-value-slot-template-dialect-refused', - order: 88, - text: - 'It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the ' - + 'maintainer\'s ruling D on the flow expression dialects): the `assignment` node\'s values, in all ' - + 'three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value ' - + 'envelope is already the expression form. A string there is now the literal ' - + 'text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, ' - + '`registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. ' - + 'No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing ' - + 'under the template and fails under CEL; CEL divides two integers as integers), so which value an ' - + 'absent key should write is the author\'s judgment. The `$User` paths are refused too: the flow CEL ' - + 'scope binds `current_user`, the run\'s user or `null` in a run with none, so `{$User.Id}` is ' - + '`current_user.id`, guarded where a flow can run without a user, and the other `$User` paths, which ' - + 'never resolved, name a read of the user record. The date macros are refused too, with their CEL ' - + 'string form (`{TODAY() + 7}` is `isoDate(daysFromNow(7))`, `{NOW()}` is `isoDatetime(now())`), so no ' - + 'single-brace spelling is kept. Its D3 record is the semantic entry ' - + '`flow-value-slot-template-dialect-refused`.', - }, - { - id: 'flow-write-node-stored-metadata-target-refused', - order: 74, - text: - 'It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose ' - + '`objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of ' - + '2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only ' - + 'writer is the metadata protocol). The runtime already refused such a node before any write, at its ' - + 'first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at ' - + '`nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, ' - + 'with the runtime\'s prescription to change metadata through the metadata API: one of those three ' - + 'write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic ' - + 'target stay outside it: the run judges the name it hands the data engine. No key is removed, so there ' - + 'is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. ' - + 'Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`.', - }, - { - id: 'form-field-public-picker-retired', - order: 56, - text: - 'Finally, it retires the form field\'s `publicPicker` block (ADR-0087 D2, immediate — the ' - + 'maintainer\'s ruling E, which reverses the earlier ruling that had declared it): an ' - + 'anonymous public form no longer offers record search. The block opted a lookup, ' - + '`master_detail` or `user` field on a public form into a picker served by an ' - + 'unauthenticated route; that route is deleted, and the public-form resolve route now ' - + 'leaves those three field types off the anonymous rendering unconditionally. The schema ' - + 'refuses the key with the prescription; the mechanical conversion ' - + '`form-field-public-picker-removed` strips it from old sources and stored rows (lossless ' - + 'in effect — its only reader was the deleted route), and the semantic entry asks the ' - + 'author how a visitor should now choose: a `select` field with static `options`, or a ' - + 'form behind sign-in.', - }, - { - id: 'form-view-option-default-retired', - order: 19, - text: - 'Finally, it narrows the per-option `default` key OUT of the form-view options ' - + 'vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 ' - + 'on the console form renderer\'s analysis, disposition 甲): `SelectOptionSchema` serves ' - + 'two surfaces and only the OBJECT-field face reads `default` (enforced there by a ' - + 'maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked ' - + '`default: true`; that face, its alias rows and its precedence pin are untouched). On a ' - + 'form-view field\'s option list ' - + 'the key parsed clean and nothing read it — the insert-path fallback consults the ' - + 'object definition\'s options, never a form view\'s, and no form renderer seeds a value ' - + 'from it (measured against the console\'s form controls, none of which reads the key; ' - + 'the ruled census found ZERO authored occurrences ' - + 'across the tree, the example apps and the published *.form.ts corpus). The FormView ' - + 'vocabulary\'s own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses ' - + 'the key with the prescription; the mechanical conversion strips it from stored ' - + 'sources (pure lossless delete — it never had an effect on this surface to lose).', - }, - { - id: 'form-view-subform-columns-closed', - order: 54, - text: - 'It also closes the form view\'s inline grid columns: `subforms[].columns`, on `view.form` ' - + 'and on `formViews` entries, was `z.array(z.any())` while a relationship field\'s ' - + '`inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column ' - + 'published clean and drew a blank grid column, and `scale` on a currency column, which the ' - + 'other carrier refuses under the maintainer\'s rulings of 2026-09-23 (option B) and ' - + '2026-09-24 (option 乙), published green. The carrier now references that schema, so both ' - + 'carriers are judged by it, with its own prescriptions. The D2 conversion ' - + '`form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the ' - + 'respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored ' - + 'rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired ' - + 'from the load path, so an author writing `field` meets the refusal. A view saved with a ' - + 'failing column is refused with the column schema\'s prescription, and a stored row carrying ' - + 'one is diagnosed at rehydration; neither is stripped, because which column an unknown key ' - + 'or a mixed `field`/`name` entry meant is the author\'s call, and a conversion that dropped ' - + 'the key would accept at load what the parse now refuses. Its D3 record is the semantic ' - + 'entry `form-view-subform-columns-closed`. On both carriers, the reach of ' - + '`inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: ' - + 'such a column takes its type from the child field, which the column schema cannot see, ' - + 'when the console hydrates it, so `defineStack`\'s cross-reference check re-parses a column ' - + 'whose `name` is a `currency` field of the child object as the type it renders as, and the ' - + 'refusal of its `scale` is the column schema\'s own. Reach: the child object must be ' - + 'declared in the same stack; a column naming no field of it, or a subform whose child ' - + 'object comes from another package, is not judged there. It has no D2 conversion, for the ' - + 'declared-type entry\'s reason: deleting the key is the migration, and a conversion that ' - + 'dropped it would accept it at load, the grace window ruling B refused. Its D3 record is ' - + 'the semantic entry `inline-grid-column-identity-only-currency-scale-refused`.', - }, - { - id: 'hook-body-stored-metadata-target-refused', - order: 67, - text: - 'It also refuses, at parse, a hook whose `body` targets a table of stored metadata, ' - + '`sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an ' - + 'app-authored body may not touch those tables, whose only writer for a body is the metadata ' - + 'protocol). The runtime already refused such a hook where a body becomes a handler, so it ' - + 'never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the ' - + 'same set at `object`, or at the list member, with the runtime\'s prescription to change ' - + 'metadata through the metadata API, judged by the one predicate the runtime uses: a hook ' - + 'with a `body` in any form whose target names either table. A code `handler` and the ' - + 'wildcard `\'*\'` stay outside it, as they are at registration. No key is removed, so there is ' - + 'no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could ' - + 'keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`.', - }, - { - id: 'list-view-page-mount-retired', - order: 27, - text: - 'It also retires the `type: \'page\'` LIST-VIEW mount and its `pageName` binding (' - + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a ' - + 'view could render nothing of its own and delegate to an already-published page, but only ' - + 'the spec half landed: no renderer ever routed it — objectui\'s list-view switch shares its ' - + 'default arm with `grid` — so a page view drew an empty table where the page belonged, and ' - + 'the three parse refusals policing the binding policed a mount that never mounted anything. ' - + 'The enum VALUE carries its prescription on the `type` enum\'s own error map (an enum-value ' - + 'narrowing has no tombstone to hang one on, the `exportOptions` \'pdf\' precedent); ' - + '`pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS ' - + 'both keys rather than rewriting `type` to `\'grid\'`: `type` defaults to `grid` in the ' - + 'schema, so deleting it lands the row on exactly what it already rendered without this ' - + 'registry guessing a view type. The surviving page mount is the app navigation item ' - + '(`PageNavItem.pageName`), untouched.', - }, - { - id: 'list-view-sort-string-clause-retired', - order: 29, - text: - 'It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy ' - + 'string clause is retired, one spelling, the array). This is the PRODUCER ' - + 'half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now ' - + 'refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer ' - + 'rejects — a document that validated upstream failed downstream, and the author was told off ' - + 'by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to ' - + 'hang a prescription on, so the surviving array member\'s own error map carries it, keyed on ' - + '`issue.input` being a string. The D2 conversion REWRITES rather than strips, because the ' - + 'clause is losslessly mechanical: `\'created_at desc\'` is the tuple `{ field, order }`, a bare ' - + 'field name meant ascending and is written out as `order: \'asc\'`, and the comma-separated ' - + 'multi-key form becomes one entry per key in the same order. A string that does not parse as ' - + 'that grammar — the `\'-field\'` dialect above all — is left alone and meets the door instead: ' - + 'that dialect belongs to `RecordRelatedListProps.sort`, never reaches ' - + '`convertSortToQueryParams`, and retiring it was NOT ruled.', - }, - { - id: 'list-view-tabs-retired', - order: 46, - text: - 'It also retires the list view\'s own `tabs` (ADR-0049 enforce-or-remove). The ' - + 'key parsed and was stored at every list-view door and drew nothing: a list view\'s own ' - + '`tabs` has no reader, the one component that would draw it has no production mount, ' - + 'and the tab strip above an object\'s records is the saved-view switcher, which renders ' - + 'one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different ' - + 'key of the same element type, is read and rendered, and stays). The key is a ' - + '`retiredKey()` tombstone on the list-view shape (its ' - + 'prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` ' - + 'itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. ' - + 'The D2 conversion `view-list-tabs-removed` strips the key from every list payload in ' - + '`stack.views[]` as a lossless delete, and is retired from the load path, so authors are ' - + 'refused at parse rather than rewritten.', - }, - { - id: 'manifest-permissions-string-list-retired', - order: 85, - text: - 'It also retires the flat-list form of a package manifest\'s `permissions` (ADR-0049 ' - + 'enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings ' - + 'and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted ' - + 'on the list — the loader registers the consented grant set, never the manifest\'s request — ' - + 'so the block is now the only form. A list is refused at parse with its prescription, and the ' - + 'D2 conversion `manifest-permissions-string-list-removed` strips it from the stack\'s manifest ' - + 'and every `packages[].manifest` as a lossless delete, retired from the load path; translating ' - + 'what each dropped string meant into the four lists is the author\'s judgement, not a rewrite.', - }, - { - id: 'mapping-lookup-params-retired', - order: 13, - text: - 'It also retires the import mapping `lookup` transform\'s steering params (ADR-0049 ' - + 'enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired ' - + '`extractQuery` / `errorPolicy` / `batchSize`): ' - + '`fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a ' - + 'per-entry reference-resolution dialect the import path never implemented — `lookup` ' - + 'copies the cell through and resolution runs off the target field\'s own metadata — ' - + 'and `autoCreate` read as create-if-missing while an unresolved reference actually ' - + 'fails the row (`import_reference_not_found`), with or without the key. The eleven ' - + 'alias spellings convert to guidance so every spelling lands on the prescription; the ' - + 'mechanical conversion strips the four keys from stored sources (pure lossless ' - + 'deletes — none ever had an effect to lose).', - }, - { - id: 'memory-persistence-placeholder-refused', - order: 1, - text: - 'Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol ' - + '17 applied to datasource connection config, to the memory ' - + 'driver\'s config-material persistence keys: `persistence.path` (file persistence ' - + 'and the `auto` override) and `persistence.key` (localStorage and the `auto` ' - + 'override) refuse `${…}` placeholder syntax at publish. Nothing resolves a ' - + 'placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or ' - + 'write under the literal localStorage key — the same authored-under-a-false-belief ' - + 'shape, one surface over. The memory driver\'s `initialData` stays deliberately ' - + 'unjudged: it carries arbitrary record values, where a literal `${…}` may be ' - + 'legitimate data.', - }, - { - id: 'metadata-customization-protocol-retired', - order: 20, - text: - 'It also retires the paper metadata-customization protocol whole (ADR-0049 ' - + 'enforce-or-remove, maintainer ruling 2026-08-29): ' - + '`kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay ' - + 'model with field-level change tracking and a 3-way-merge story — was exported, ' - + 'documented as the customization architecture, and implemented ONLY by an unreachable ' - + '`packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` ' - + 'endpoints; the four optional service members were called only by their own unit ' - + 'tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against ' - + 'it"). The module\'s seven defs and the three section-5 API contracts leave via ' - + 'RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.' - + 'customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.' - + 'overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager ' - + 'configs are not stack collection members, the additionalTypes reasoning). The ' - + 'customization that actually ships: ADR-0005\'s org overlay and ADR-0126\'s ' - + 'packaged-metadata model.', - }, - { - id: 'metadata-manager-config-inert-cache-keys-retired', - order: 25, - text: - 'It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, ' - + '`ttlSeconds` (the duration rename\'s respelling of `ttl`, never shipped) and `maxSize` — that the ' - + 'rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and ' - + 'published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the ' - + 'loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey ' - + 'tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 ' - + 'conversion (a manager config is no stack collection member); the rename is folded into ' - + 'the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key.', - }, - { - id: 'metadata-plugin-additional-types-retired', - order: 2, - text: - 'It also retires `MetadataPluginConfig.additionalTypes` (' - + 'ADR-0049 enforce-or-remove): the key was documented as THE plugin ' - + 'kind-declaration channel and read by nothing — the manager\'s type registry is ' - + 'seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so ' - + 'authoring it configured nothing. A kind enters the live set as a side effect of ' - + 'registering an item of that kind.', - }, - { - id: 'object-grid-default-filters-retired', - order: 90, - text: - 'It also retires `object-grid`\'s `defaultFilters` (#11509, ruling A-narrow, in the shape ' - + 'of the `defaultSort` retirement): the legacy second spelling of `filter`, read only when ' - + '`filter` lowered to nothing, which an earlier narrowing in this major had shaped as the rule ' - + 'array and this retirement absorbs. The mechanical conversion moves the rules onto an empty ' - + '`filter` and deletes the key beside a `filter` with content (the renderer never read it ' - + 'there); it runs before the record-form filter conversion, which then converts a moved record ' - + 'form at `filter`.', - }, - { - id: 'object-grid-default-sort-retired', - order: 17, - text: - 'Finally, it retires `object-grid`\'s `defaultSort` (ADR-0049 enforce-or-remove; ' - + 'maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of ' - + 'objectui\'s `table.defaultSort` retirement, which the maintainer\'s 2026-08-22 「接受所有」 ruling ' - + 'on objectui\'s sort sink ordered): the legacy second ' - + 'spelling of `sort`, a single `{ field, order }` pair the renderer read only when ' - + '`sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, ' - + '`plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it ' - + '`[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two ' - + 'spellings; objectui\'s mirror schema is parity-test-only and parses nothing at ' - + 'runtime, so only the spec strictObject can refuse the key. The mechanical ' - + 'conversion carries the pair over — renamed to `sort` and wrapped in the array ' - + 'shape — when `sort` is absent, and strips it as a pure lossless delete when ' - + '`sort` is present (the renderer\'s own precedence made it unread then).', - }, - { - id: 'object-grid-resizable-columns-retired', - order: 62, - text: - 'It also retires `object-grid`\'s `resizableColumns` (ADR-0049 enforce-or-remove; ' - + 'objectui\'s ruling that `resizable` is canonical, under the startup rule of immediate ' - + 'retirement): the legacy second spelling of `resizable`, read only as ' - + '`schema.resizable ?? schema.resizableColumns` (measured at the ' - + '`.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two ' - + 'spellings, and zero ' - + 'writers in either repository, so there is no window. A retiredKey tombstone on ' - + '`ObjectGridPropsSchema` with one D2 conversion that follows the renderer\'s precedence: the ' - + 'value moves to `resizable` when that is absent, and strips as a lossless delete when it is ' - + 'present (it was never read then). Its D3 record is the semantic entry ' - + '`object-grid-resizable-columns-retired`.', - }, - { - id: 'object-kanban-quick-add-retired', - order: 28, - text: - 'It also retires `object-kanban`\'s `quickAdd` (ADR-0049 enforce-or-remove; the spec ' - + 'half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation ' - + 'path and retires the key). The board FORWARDED the key into the shared renderer but the ' - + 'affordance is gated ' - + 'on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON ' - + 'cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently ' - + 'false. The drop was NOT silent, and that is what made it worse than silence: objectui\'s ' - + 'html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so ' - + 'an author following the contract met a tool contradicting it with no way to tell which side ' - + 'was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that ' - + 'is a pure lossless DELETE (the key never had an effect to preserve) scoped by component ' - + '`type`. Delete the key; `object-kanban` offers no quick-add control.', - }, - { - id: 'object-master-detail-form-detail-sort-field-retired', - order: 70, - text: - 'It also retires an `object-master-detail-form` detail entry\'s `sortField` (ADR-0049 ' - + 'enforce-or-remove; the spec half of objectui\'s own retirement of the override). The console ' - + 'stopped reading the ' - + 'authored override: the field its line grid stamps with each line\'s position on ' - + 'drag-reorder is derived from the child object — its first field named `position`, ' - + '`sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had ' - + 'crossed that change while the spec still declared the key, so an authored value published ' - + 'green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 ' - + 'conversion that is a pure lossless DELETE scoped by component `type` and by position ' - + '(`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` ' - + 'carries the one judgment left, whether the child object declares the field the line order ' - + 'is kept in.', - }, - { - id: 'object-tenancy-organization-field-retired', - order: 34, - text: - 'Finally it retires object `tenancy.organizationField` (ADR-0049 ' - + 'enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as ' - + 'opposed to the column the object is WALLED by (`tenantField`); on an ordinary object ' - + 'those are the same column, and the entire protocol declared it exactly once — on ' - + '`sys_api_key`, a better-auth-managed credential table this platform ships and no ' - + 'application authors. Its three readers were all platform-row writers, scope-pinned by ' - + 'name, so an application declaration was inert by construction while still forcing ' - + 'every future piece of organization logic to ask "what if somebody set this?". The ' - + 'divergence is NOT retired, only its authorability: it moves to ' - + '`PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object ' - + 'name and read by the stamp face alone, so audit stamping, the approval-row writer and ' - + 'the automation-run recorder keep their behaviour with no authorable input. The ' - + 'conversion is a lossless delete, and a lossless delete still leaves the author a ' - + 'judgment, which the family\'s D3 entry `object-tenancy-organization-field-retired` ' - + 'carries — an application whose tenant column genuinely is not `organization_id` ' - + 'declares `tenancy.tenantField`, which both walls the object and stamps its platform ' - + 'rows.', - }, - { - id: 'page-assigned-profiles-audience-to-permission-set', - order: 30, - text: - 'It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; ' - + 'maintainer ruling 2026-09-12 \u300c\u540c\u610f\u300d). The key was authorable on the published ' - + '`PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema\'s own ' - + 'alias table CORRECTED an authored `profiles:` into it — two files from ' - + '`security/permission.zod.ts` answering the same word with "no Profile concept". Measured ' - + 'across this repository and objectui it had zero readers, so a page that "assigned ' - + 'profiles" was open to every caller who could reach it. It is a retiredKey tombstone on ' - + '`PageSchema` — the def is still parsed from the `page` root, so there is an author to ' - + 'teach — and the two alias entries became refusals naming the permission-set route. The ' - + 'D2 conversion STRIPS the key — there is no lossless target, because which permission set ' - + 'a given profile name corresponds to is a judgement no walker can make, which is what the ' - + 'paired D3 semantic entry is for.', - }, - { - id: 'page-component-filter-record-to-rule-array', - order: 36, - text: - 'Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter ' - + 'spelling platform-wide, the rule array) its ' - + 'mechanical half at rest (ruled 2026-09-12): the D2 conversion ' - + '`page-component-filter-record-to-rule-array` rewrites a record-form or single-level ' - + 'AST `filter` at the converged rule-array doors — `dataSource.filter` and the ' - + '`object-*` `filter` props — to the rule array wherever the mapping is lossless, ' - + 'and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless ' - + 'rule spelling) exactly as stored, because flattening a combinator changes which rows ' - + 'a page selects. It is retired from the load path, so authors are still refused at ' - + 'the door and taught the array; the stored-row seams and this chain replay it.', - }, - { - id: 'page-component-responsive-retired', - order: 15, - text: - 'Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` ' - + 'layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the ' - + 'key was the destination the `dashboard.widgets[].responsive` tombstone ' - + 'prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology ' - + 'with positive and negative controls) found the claim false — objectui\'s two ' - + 'implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero ' - + 'callers and nothing read `.responsive` off a page component, so the prescribed ' - + 'migration moved an inert key to an inert key while the platform\'s own error message ' - + 'vouched for it. The same change repairs every shipped text that carried that redirect. ' - + '`ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no ' - + 'other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live ' - + 'per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which ' - + 'objectui really compiles. The mechanical conversion strips the key from stored pages ' - + '(pure lossless delete — it never had an effect to lose).', - }, - { - id: 'page-header-breadcrumb-retired', - order: 51, - text: - 'It also retires the page header\'s `breadcrumb` switch (ADR-0049 enforce-or-remove): no ' - + 'renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and ' - + 'the navigation trail is drawn once, by the app shell\'s header. The key is a retiredKey ' - + 'tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 ' - + 'conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and ' - + '`false` alike, retired from the load path. Its D3 record is the semantic entry ' - + '`page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: ' - + 'the Studio page palette still offers it.', - }, - { - id: 'page-requires-non-compiled-kind-refused', - order: 66, - text: - 'It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; ' - + 'maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html ' - + 'page\'s source when the page is saved, while on `react`, `full` and `slotted` pages nothing ' - + 'derived it, the Studio page editor dropped it on every save, and a load-time warning was its ' - + 'one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated ' - + 'alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` ' - + 'included, naming the key, the page\'s kind and the compiled kinds. The key stays live on html ' - + 'pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` ' - + 'deletes the key from those pages, retired from the load path, so stored rows and artifacts ' - + 'replay clean while authored sources are refused until edited; the delete is lossless. Its D3 ' - + 'record is the semantic entry `page-requires-non-compiled-kind-refused`.', - }, - { - id: 'page-slots-details-beside-tabs-refused', - order: 92, - text: - 'It also refuses a page that authors both `slots.details` and `slots.tabs`. The two slots read ' - + 'as independent and are not: `details` is the body of the Details tab, that tab lives inside ' - + 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip, so the console\'s ' - + 'synthesizer never read a `details` authored beside it and its sections and hidden fields ' - + 'silently never applied. `PageSchema` now refuses the pair at `slots.details`, naming both ' - + 'slots and the fix — the `record:details` component as the `children` of a `tabs` item — so ' - + '`objectstack validate` and the metadata save door agree; the platform\'s own `sys_user_detail` ' - + 'page moved its details body into its first tab. No key is removed, so there is no tombstone, ' - + 'and no D2 conversion exists: which tab carries the body is the author\'s decision. Its D3 ' - + 'record is the semantic entry `page-slots-details-beside-tabs-refused`.', - }, - { - id: 'permission-restore-purge-bits-retired', - order: 18, - text: - 'Finally, it retires the object-permission lifecycle bits `allowRestore` and ' - + '`allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, ' - + 'decision-inbox batch 5, which chose retiring the two bits over gating operations that do ' - + 'not exist): the `restore` / ' - + '`purge` ObjectQL operations the bits claimed to gate have never existed — no ' - + 'destructive lifecycle verb is in the engine\'s dispatch vocabulary, which a test pins — ' - + 'so granting the bits delivered nothing, and an author who declared ' - + '`allowPurge: false` believed a lock on GDPR hard-deletion existed when the ' - + 'operation itself did not. Both keys are retiredKey tombstones; the evaluator\'s ' - + 'pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays ' - + 'denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ' - + 'ungated window), and the mechanical conversion strips the keys from every object ' - + 'grant in `permissions[].objects` (pure lossless delete — they never had an ' - + 'effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record\'s ' - + 'owner — and stays. The keys return ' - + 'with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their ' - + 'anchor.', - }, - { - id: 'permission-rls-tags-retired', - order: 42, - text: - 'It also retires the RLS policy\'s `tags` (ADR-0049 enforce-or-remove; graded ' - + 'RETIRE by the maintainer\'s criterion — no mainstream platform tags a row-level policy): ' - + 'the key promised categorization and reporting for governance and compliance, and nothing ' - + 'ever read it — the RLS compiler never consulted it and no preview rendered it. It is a ' - + '`retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key ' - + 'over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in ' - + '`permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that ' - + 'still carries it replays clean. It is retired from the load path, so authors are refused ' - + 'at parse rather than rewritten. Its D3 record is the semantic entry ' - + '`permission-rls-tags-retired`.', - }, - { - id: 'platform-global-object-organization-column-retired', - order: 89, - text: - 'It also makes the deployment\'s platform-global declaration total (ADR-0131 D7): an object a deployment ' - + 'declares platform-global in its `org-scoping` service\'s `platformGlobalObjects` gets no ' - + 'organization column on that deployment, because the injected-columns plan reads the declaration, ' - + 'so the organization wall and the driver agree by having nothing to scope. The engine reads it at ' - + 'its plugin start, before the first schema sync, once every plugin init has run, and re-plans the ' - + 'objects registered before it; the security layer\'s stand-down for such an object retires with ' - + 'it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. ' - + 'Nothing moves automatically: a declaring deployment\'s existing table keeps the column as an ' - + 'orphan the boot drift report names. The D3 record is the ' - + '`platform-global-object-organization-column-retired` semantic entry.', - }, - { - id: 'plugin-manifest-contributes-dead-members-retired', - order: 16, - text: - 'Finally, it retires nine of the eleven members of the plugin manifest\'s ' - + '`contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, ' - + 'cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, ' - + '`translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. ' - + 'A census of all three repos, with controls, measured that the whole monorepo ' - + 'contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; ' - + 'the other nine ' - + 'members parsed, entered the manifest, and changed nothing, while published docs and ' - + 'the schema\'s own JSDoc kept teaching them (`commands` documented Commander.js ' - + 'resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam ' - + 'that never existed). All nine are retiredKey tombstones mirroring `loading`; ' - + '`kinds` survives (live reader), and `routes` was left to a ruling of its own, which ' - + 'retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 ' - + 'semantic, no D2 conversion: a manifest is not a stack collection ' - + 'member, so a conversion would be a transform with no seam that ever runs. ' - + 'On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 ' - + 'enforce-or-remove; maintainer ruling 2026-08-24): the schema promised ' - + 'that declaring `globs` enables file-type discovery, but discovery globs ' - + '`filePatterns` off the metadata type registry — which `contributes.kinds` does ' - + 'not extend, as `metadata-plugin.zod.ts` records outright — so an authored ' - + '`globs` was accepted, stored, served back through `GET /metadata/kind`, and ' - + 'never consulted (zero value reads; the only non-test occurrences were the ' - + 'schema declaration and two type positions). The `kind` bucket itself and its ' - + '`id` are untouched; file-type discovery stays single-channel on `filePatterns`. ' - + 'D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning.', - }, - { - id: 'record-chatter-position-vocabulary-converged', - order: 5, - text: - 'Finally, it converges `record:chatter` / `record:discussion` `position` on the ' - + 'renderer\'s vocabulary (maintainer ruling 2026-08-15): the schema declared ' - + '`sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the ' - + 'schema\'s own `sidebar` default silently rendered in flow while the value that ' - + 'actually docks the panel (`right`) was refused at publish. The row now speaks ' - + '`bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings ' - + '(`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three ' - + 'schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per ' - + 'the `maxVisible` principle — renderer fallbacks stay the renderer\'s facts.', - }, - { - id: 'record-highlights-field-icon-retired', - order: 12, - text: - 'It also retires the `record:highlights` highlight-field `icon` (ADR-0049 ' - + 'enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census ' - + 'verdict): a declared key with zero read points in any direction — objectui\'s renderer ' - + 'normalized the authored object and carried `icon` into a highlight chip with no icon ' - + 'slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to ' - + 'carry it), and the Studio designer publishes the field list as plain strings — while ' - + 'six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, ' - + 'on the highlight chip). The mechanical conversion strips the key from the object ' - + 'entries of every `record:highlights` `fields[]` (pure lossless delete — the chip ' - + 'renders label and value only, so it never had an effect to lose); there is no ' - + 'replacement, and the live neighbour `readonly`, declared because the chip\'s read-only gate ' - + 'reads it, is untouched.', - }, - { - id: 'stack-themes-carrier-retired', - order: 11, - text: - 'Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 ' - + 'enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): ' - + 'the pipeline was live from the authoring gate through artifact ingest and stopped ' - + 'there — zero non-test readers of stored `theme` items, `theme` never a registered ' - + 'metadata type, no first-party app mounting the spec-aware provider, nothing ' - + 'selecting an active theme — so an authored theme shipped through every green gate ' - + 'and changed nothing on screen. `app.branding` stays the one colour surface; ' - + 'objectui\'s ThemeEngine/ThemeContext and their unit tests are retained. Semantic ' - + 'rather than mechanical: an authored palette has no lossless target (N themes vs ' - + 'M apps is a judgment), so the entry prescribes the hand move instead of deleting ' - + 'authored content silently.', - }, - { - id: 'sys-audit-log-organization-column-retired', - order: 88, - text: - 'It also takes the injected organization column off the compliance ledger, `sys_audit_log` ' - + '(ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the ' - + 'organization a row is about stays in the attribution field `tenant_id`, which every writer ' - + 'already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a ' - + 'platform administrator now reads the rows about no organization too; an organization reader is ' - + 'scoped to the rows about its active organization by the platform row policy ' - + '`sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ' - + 'ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant ' - + 'retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps ' - + 'the column as an orphan the boot drift report names, for the v18 ceremony to drop once its ' - + 'values are confirmed in `tenant_id`. The D3 record is the ' - + '`sys-audit-log-organization-column-retired` semantic entry.', - }, - { - id: 'sys-setting-global-rung-moved', - order: 87, - text: - 'It also moves the settings cascade\'s global rung out of the tenant-scoped `sys_setting` into ' - + 'the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the ' - + 'deployment, no organization column, reads governed by the `manage_platform_settings` ' - + 'capability. The settings service writes a global-scope key there and reads the rung from there ' - + 'alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The ' - + 'cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the ' - + 'v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open ' - + 'unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is ' - + 'the `sys-setting-global-rung-moved` semantic entry.', - }, - { - id: 'sys-view-definition-retired', - order: 90, - text: - 'It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework ' - + 'code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. ' - + 'The object, its two registrations, its `kernel:ready` active-row index migration and that ' - + 'migration\'s exports leave, and its name leaves the platform-object registry. Nothing in stack ' - + 'metadata is rewritten; an existing database keeps the table, which no platform path drops. ' - + 'The D3 record is the `sys-view-definition-retired` semantic entry.', - }, - { - id: 'time-default-zone-refused', - order: 50, - text: - 'It also narrows the `time` stored form to the zone-less wall clock the record validator ' - + 'already enforces (ADR-0053 D-C1), so a field default or an action param default or value ' - + 'with a `Z` or a UTC offset is refused when it is authored or submitted rather than on ' - + 'every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` ' - + 'drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero ' - + 'offset as stored for its author to rewrite; its D3 record is the semantic entry ' - + '`time-default-zone-refused`.', - }, - { - id: 'translation-component-submit-label-retired', - order: 14, - text: - 'Finally, it retires the component-translation copy key ' - + '`pages..components..submitLabel` and its `submit` alias (ADR-0049; ' - + 'maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key ' - + 'exists because some component in `ComponentPropsMap` declares it — and ' - + '`submitLabel`\'s only declarer was `element:form`, retired whole above, so ' - + 'the key had no declared component left to translate and the resolver overlay was ' - + 'its only reader. Retire won over re-anchor because the live form surface ' - + '(`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own ' - + 'authoring site; re-anchoring would have widened the face for one word. The ' - + 'mechanical conversion strips the key from stored bundles and items (pure lossless ' - + 'delete — nothing read it once `element:form` was retired), at the acknowledged cost ' - + 'of dropping the ' - + 'bespoke-component route for that one word.', - }, - { - id: 'translation-per-app-settings-platform-only', - order: 33, - text: - 'Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings ' - + 'copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle ' - + '(`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is ' - + 'keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both ' - + 'bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — ' - + 'but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` ' - + '(Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later ' - + 'source the leaf, so what an application had was a GAP FILLER on a namespace it does not own ' - + '— rendering only where the platform bundle carried no string for that key and locale. The ' - + 'registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app ' - + 'metadata type, two authoring doors, one accepted shape) and no longer declares ' - + '`settings` either; there the group had been STRONGER, because the runtime-authored layer is ' - + 'read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 ' - + 'conversion strips the group from per-app bundle entries and from bare items alike — the ' - + 'runtime translation sync replays it over every stored row before merging — and the paired ' - + 'semantic entry says what the strip means at each door, because a notice reading "(removed)" ' - + 'says neither that an item’s overrides give way to the platform’s string nor that a gap falls ' - + "back to the manifest's own English literal.", - }, - { - id: 'translation-widget-sub-caption-retired', - order: 60, - text: - 'It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses ' - + 'the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget ' - + 'translation key ' - + '`dashboards..widgets..subCaption` overlaid a widget\'s `options.description`, a key ' - + 'the dashboard schema never declared and no authored widget wrote, so the overlay in ' - + '`translateDashboard` was its only writer. The overlay is removed, `subCaption` is a ' - + '`retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias ' - + 'now carries the retirement instead of a rename onto a key that accepts nothing. A widget ' - + 'keeps one authored description, `widget.description`, which renders as the card-header ' - + 'subtitle and is translated by the widget\'s `description` key. The D2 conversion ' - + '`translation-widget-sub-caption-removed` strips the key from bundle entries and stored ' - + 'translation items as a lossless delete of what is served, retired from the load path so ' - + 'authors are refused at parse; its D3 record is the semantic entry ' - + '`translation-widget-sub-caption-retired`.', - }, - { - id: 'try-catch-and-retry-policy-undeclared-keys-refused', - order: 90, - text: - 'Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one ' - + 'declaration behind `job.retryPolicy` and a `try_catch` node\'s `retry`, refuses a key it does not ' - + 'declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a ' - + 'stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the ' - + 'five keys the descriptor declares, a key a `try_catch` node\'s contract does not declare is refused at ' - + 'parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the ' - + 'descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a ' - + 'different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no ' - + 'new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry ' - + '`try-catch-and-retry-policy-undeclared-keys-refused`.', - }, - { - id: 'ui-action-group-menu-member-params-array-only', - order: 83, - text: - 'It then closes the one static-values spelling those members still accepted and the containers drop: an ' - + '`action:group` or `action:menu` member\'s `params` takes the input list, an `ActionParam[]` array, only, ' - + 'unless the member\'s `type` is `api`, whose object `params` keeps its request-payload window. `params` ' - + 'carries one shape and no second value-bag key is declared, so an object `params` on any other member, which ' - + 'parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action ' - + 'with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a ' - + 'stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' - + '`ui-action-group-menu-member-params-array-only`.', - }, - { - id: 'ui-action-group-menu-members-typed', - order: 82, - text: - 'And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks ' - + '(the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so ' - + 'a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A ' - + 'member now takes `action:button`\'s keys with its executor spelled `type`, measured from the containers\' ' - + 'reads — an `action:menu` item reads no `size` and declares none — with the rows\' prescriptions; ' - + '`outcomeMessages`, a member `className` and a member `properties.params` are refused, and ' - + '`outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the ' - + 'component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. ' - + 'Its D3 record is the semantic entry `ui-action-group-menu-members-typed`.', - }, - { - id: 'ui-ai-chat-window-retired', - order: 65, - text: - 'It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), ' - + 'the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is ' - + 'wanted — the console leaves it unregistered on purpose, because the floating chat overlay ' - + 'it mounts on every page is the supported AI chat entry point — so a page that placed one ' - + 'validated clean and drew "Unknown component type", and its four props configured nothing. ' - + 'The name leaves `PageComponentType` and is refused by name at the node, its ' - + '`ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and ' - + 'the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only ' - + 'edit is deleting the node, a layout decision that is the author\'s. Its D3 record is the ' - + 'semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged.', - }, - { - id: 'ui-form-layout-inline-grid-retired', - order: 40, - text: - 'It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that ' - + 'declared the four-arm enum — the `object-form` page component and the form view ' - + '(ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a ' - + 'behaviour of its own: every form presentation folded both to `vertical`, multi-column ' - + 'is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row ' - + 'pattern rather than a record-form layout — redundant vocabulary under the maintainer\'s family ' - + 'criterion (a capability mainstream platforms have is served once, here by `columns`), retired ' - + 'with no alias window. Both enums refuse the two values with a ' - + 'per-value prescription naming `columns`; the D2 conversion ' - + '`form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, ' - + '`columns` untouched) on `object-form` page components, on every form payload a view ' - + 'carries, and on the assembled-manifest `viewItems` channel.', - }, - { - id: 'ui-object-form-custom-fields-typed', - order: 78, - text: - 'It also types the `object-form` page block\'s `customFields`, one of the two contracts the ' - + '`ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each ' - + 'member is the runtime form field the form draws, which the spec did not declare, so a member with no ' - + '`name` or a misspelled member passed every door and the form drew the field without it. The spec now ' - + 'declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — ' - + 'the `grid` widget\'s snake_case keys stay out until the widget reads a camelCase spelling — and the ' - + 'row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and ' - + 'loads, so no conversion is registered. Its D3 record is the semantic entry ' - + '`ui-object-form-custom-fields-typed`.', - }, - { - id: 'ui-object-form-fields-names-typed', - order: 74, - text: - 'It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page ' - + 'blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third ' - + 'stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, ' - + '`type` and `required` it silently dropped, and objectui has since retired that entry from every ' - + 'authoring face, drawing only a stored one by its name. So both rows take field names, objectui\'s own ' - + 'declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry ' - + 'is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate ' - + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the ' - + 'semantic entry `ui-object-form-fields-names-typed`.', - }, - { - id: 'ui-object-form-members-typed', - order: 67, - text: - 'It also types four members of the `object-form` page block (the third stage of the ' - + '`ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, ' - + '`navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, ' - + 'so a `submitBehavior` `kind` the form does not know passed every door and fell through to the ' - + 'thank-you panel. `submitBehavior` takes the form view\'s own block by reference; the other three ' - + 'take the measured shape. The form\'s `fields` and `sections` and the master-detail form\'s two ' - + 'stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, ' - + 'which the typed shapes would refuse — and `customFields` stays open until the spec declares the ' - + 'runtime form field its entries are. Read by the component-props gate (advisory); a stored page ' - + 'still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' - + '`ui-object-form-members-typed`.', - }, - { - id: 'ui-object-form-sections-typed', - order: 79, - text: - 'It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the ' - + 'other ruled fork: a section\'s `fields` draws an inline runtime form field beside a name and the form ' - + 'view\'s `{ field }` entry, which the stored form view\'s section refuses, so the sections stayed ' - + '`z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape ' - + 'of their own — the form view\'s section keys plus those three entry arms, the inline arm the runtime ' - + 'form field — in canonical spellings only: a page block\'s `properties` is never parsed on the way to ' - + 'the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at ' - + 'parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. ' - + 'Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' - + 'registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`.', - }, - { - id: 'ui-object-gantt-markers-typed', - order: 75, - text: - 'It also types the `object-gantt` page block\'s `markers` (the same stage): its entries were ' - + '`z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric ' - + '`date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The ' - + 'spec now declares objectui\'s own authoring declaration of a marker, `{ date, label?, color? }` with ' - + '`date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still ' - + 'saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' - + '`ui-object-gantt-markers-typed`.', - }, - { - id: 'ui-object-grid-columns-typed', - order: 73, - text: - 'It also types the `object-grid` page block\'s `columns` (the fifth stage of the ' - + '`ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid\'s ' - + 'group headers drew a column\'s `options`, which the list view\'s column entry does not declare, and ' - + 'objectui has since retired that read and takes the labels from the object field only. So the member ' - + 'takes the list view\'s own `columns` by reference — all field names or all column entries — and a ' - + 'column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, ' - + '`options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by ' - + 'the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' - + 'registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`.', - }, - { - id: 'ui-object-grid-export-options-closed', - order: 59, - text: - 'It also closes the export options of an `object-grid` page block. `exportOptions` ' - + 'was `z.unknown()`, so a bare format array — the list view\'s legacy spelling, which the list ' - + 'view lifts to `{ formats }` — was accepted on the grid, whose renderer reads ' - + '`exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and ' - + 'the author\'s list was dropped. The row now takes the list view\'s five-member export ' - + 'options object by identity, not the list view\'s union, and refuses a bare array with the ' - + 'object form named, a format outside the enum and an undeclared key. Page-component ' - + '`properties` is read by the component-props gate, which reports these as advisory ' - + 'findings, and is not parsed on the metadata save or load path, so a stored page still saves ' - + 'and loads and no conversion is registered: the bare array never worked here, and lifting it ' - + 'would change the menu a deployed grid shows. The authored census found nothing to respell. ' - + 'Its D3 record is the semantic entry `ui-object-grid-export-options-closed`.', - }, - { - id: 'ui-object-grid-kanban-calendar-list-members-typed', - order: 66, - text: - 'It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page ' - + 'blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid\'s ' - + '`fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban\'s ' - + '`columns` and the calendar\'s `calendar` were `z.unknown()` (an array of it for the lists), although ' - + 'each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door ' - + 'and was skipped. The members a list view declares take the list view\'s own by reference ' - + '(`batchActions`, the spelling the grid reads first, takes `bulkActions`\'s); the grid\'s `fields` ' - + 'and `selectable` and the kanban lane take the measured shape. The grid\'s `columns` stays open: its ' - + 'group headers draw an authored column\'s `options`, which the list view\'s column entry does not ' - + 'declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no ' - + 'conversion is registered. Its D3 record is the semantic entry ' - + '`ui-object-grid-kanban-calendar-list-members-typed`.', - }, - { - id: 'ui-object-grid-row-members-typed', - order: 63, - text: - 'It also types seven members of an `object-grid` page block: `rowHeight`, ' - + '`rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and ' - + '`operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid ' - + 'reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. ' - + 'The five a list view also declares take the list view\'s own schemas by reference; ' - + '`aggregations` takes the measured `[{ field, type }]` with the query AST\'s aggregation ' - + 'functions, and `operations` the four booleans a grid read point names (`create`, ' - + '`update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by ' - + 'the component-props gate (advisory); a stored page still saves and loads, so no conversion ' - + 'is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`.', - }, - { - id: 'ui-object-kanban-conditional-formatting-typed', - order: 77, - text: - 'It also types the `object-kanban` page block\'s `conditionalFormatting`, the one member the ' - + '`ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui\'s ' - + 'kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` ' - + 'passed every door and the board painted no card for it. objectui has since made the list view\'s ' - + '`{ condition, style }` rule the member\'s only authoring dialect, and the board evaluates it with the ' - + 'grid\'s evaluator, so the row takes the list view\'s own member by reference, as `object-grid` does. ' - + 'Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' - + 'registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`.', - }, - { - id: 'ui-object-map-gantt-tree-navigation-typed', - order: 64, - text: - 'It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks ' - + '(the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer ' - + 'hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, ' - + 'so `navigation: 42` and a bare mode string passed every door and opened the record page. The ' - + 'three rows now take the list view\'s `NavigationConfigSchema` by reference, the carrier the grid, ' - + 'kanban, calendar and timeline blocks already take. Read by the component-props gate ' - + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record ' - + 'is the semantic entry `ui-object-map-gantt-tree-navigation-typed`.', - }, - { - id: 'ui-object-master-detail-form-details-closed', - order: 56, - text: - 'It also closes the third carrier of the inline grid column: an ' - + '`object-master-detail-form` page block\'s `details` was `z.array(z.unknown())`, so a key ' - + 'its renderer does not read and `scale` on a currency column, which the other two carriers ' - + 'refuse under the maintainer\'s rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), ' - + 'went through `objectstack validate` green. ' - + 'Each detail entry is now a strict shape of the twelve keys the renderer reads, and its ' - + '`columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the ' - + 'component-props gate, which reports a failing entry or column as an advisory finding, ' - + 'and is not parsed on the metadata save or load path, so a stored page still saves and ' - + 'loads and no conversion is registered; the authored census found nothing to respell. ' - + '`defineStack`\'s identity-only check reaches the block wherever a page carries it, with ' - + 'the reach `inline-grid-column-identity-only-currency-scale-refused` records for the ' - + 'other two carriers. Its D3 record is the semantic entry ' - + '`ui-object-master-detail-form-details-closed`.', - }, - { - id: 'ui-object-metric-aggregate-trend-typed', - order: 69, - text: - 'It also types two members of the `object-metric` page block (the fourth stage of the ' - + '`ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although ' - + 'the tile reads each with one shape, so `aggregate: \'count\'` and a trend with no `value` passed ' - + 'every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. ' - + '`aggregate` takes the query AST\'s aggregation functions and the chart aggregate\'s `groupBy` ' - + 'union by reference, with `groupBy` optional because a metric is one number; `trend` takes the ' - + 'badge\'s measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key ' - + 'the tile never reads (the chart drill-down\'s `filter`, the dashboard comparison\'s `dimension`), and ' - + 'the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the ' - + 'component-props gate (advisory); a stored page still saves and loads, so no conversion is ' - + 'registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`.', - }, - { - id: 'ui-object-metric-compare-to-typed', - order: 71, - text: - 'It also types the `object-metric` page block\'s `compareTo` (the fifth stage of the ' - + '`ComponentPropsMap` `z.unknown()` close-out) to the tile\'s read, per the ruling between the ' - + 'reference and the read: `{ kind }`, with `kind` the dashboard widget comparison\'s own vocabulary by ' - + 'reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own ' - + '`filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a ' - + '`dimension` passed every door and compared the wrong window. Read by the component-props gate ' - + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the ' - + 'semantic entry `ui-object-metric-compare-to-typed`.', - }, - { - id: 'ui-object-metric-drill-down-report-typed', - order: 80, - text: - 'It then types the three members the stages above held open, as the maintainer ruled them on the ' - + 'decision card for those forks. The `object-metric` drill-down\'s `report` is `ReportSchema`, by ' - + 'reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, ' - + 'and since then every report the member admits is one the drill drawer draws — a report with no ' - + '`dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the ' - + 'records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, ' - + 'so no conversion is registered. Its D3 record is the semantic entry ' - + '`ui-object-metric-drill-down-report-typed`.', - }, - { - id: 'ui-object-metric-drill-down-typed', - order: 72, - text: - 'It also types the `object-metric` page block\'s `drillDown` to the tile\'s read (the same ' - + 'stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are ' - + 'the chart drill-down\'s own by reference, and `filter` and `mode` are refused by name, because a ' - + 'metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; ' - + 'both passed every door and were ignored. The drill `report` stays open: the tile draws a ' - + 'dataset-bound report, but the spec declares no drill report yet, and declares that contract first. ' - + 'Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' - + 'registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`.', - }, - { - id: 'ui-object-timeline-items-typed', - order: 81, - text: - 'It types the `object-timeline` page block\'s `items` (fork 4, letter B): each entry is one of objectui\'s ' - + 'two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` ' - + 'or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or ' - + 'epoch milliseconds — and a row refinement pairs each entry with the kind the block\'s `variant` selects, ' - + 'so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. ' - + 'A feed entry\'s `content` (child components) is held unjudged until a writer appears. Read by the ' - + 'component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. ' - + 'Its D3 record is the semantic entry `ui-object-timeline-items-typed`.', - }, - { - id: 'ui-object-timeline-mapping-typed', - order: 76, - text: - 'It also types the `object-timeline` page block\'s `mapping` (the same stage): the binding record — ' - + 'four optional field names for an entry\'s title, date, description and marker colour — was ' - + '`z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member ' - + 'passed every door and the rail drew the default field. The spec now declares objectui\'s own ' - + 'declaration of it, and the row takes it. The stage\'s other members — the metric drill-down\'s ' - + '`report`, the form\'s `customFields` and both forms\' `sections`, the timeline\'s `items` and the ' - + 'action containers\' members — stay open: each contract has more than one viable shape that no ruling ' - + 'decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no ' - + 'conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`.', - }, - { - id: 'ui-record-line-items-props-closed', - order: 57, - text: - 'It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` ' - + 'row — it was the one entry on the string-arm registration ledger — so the component-props ' - + 'gate skipped its props, and the showcase project page\'s five `field`-keyed columns published ' - + 'green over a grid of empty cells. The row declares the fifteen keys the renderer ' - + 'reads, requires `relationshipField` and at least one column, and its `columns` references ' - + '`InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The ' - + 'panel draws its columns as authored, with no hydration from the child object\'s field, so ' - + '`defineStack`\'s identity-only check does not reach it. Its D3 record is the semantic entry ' - + '`ui-record-line-items-props-closed`.', - }, - { - id: 'ui-report-joined-block-dataset-required', - order: 77, - text: - 'It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, ' - + 'enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each ' - + 'block is dataset-bound, but the joined arm of `ReportSchema`\'s refinement required only a non-empty ' - + '`blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, ' - + 'and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one ' - + 'falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each ' - + 'such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; ' - + '`dataset` stays optional on the block shape, which is read only on a `joined` report. No key is ' - + 'removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset ' - + 'a block was meant to show. Its D3 record is the semantic entry ' - + '`ui-report-joined-block-dataset-required`.', - }, - { - id: 'ui-report-joined-chart-retired', - order: 38, - text: - 'It also retires a `joined` report\'s `chart` at both coordinates (ADR-0049 ' - + 'enforce-or-remove): the joined renderer draws each block as a table and returns before ' - + 'the one container `chart` read, and no renderer reads a block\'s `chart` at all, so a ' - + 'chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. ' - + 'The key leaves `JoinedReportBlockSchema`\'s closed shape (its `guidance` table carries ' - + 'the prescription) and the joined arm of `ReportSchema`\'s refinement refuses a ' - + 'container `chart`; `chart` stays live on every non-joined report. The D2 conversion ' - + '`report-joined-chart-removed` strips both as a pure lossless delete — neither ever had ' - + 'an effect to lose — because a stored report row CAN carry them (the Studio report form ' - + 'offered a block `chart` input until this change); it is retired from the load path, so ' - + 'authors are refused at parse rather than rewritten.', - }, - { - id: 'view-item-owner-hidden-retired', - order: 37, - text: - 'It also retires the view item\'s `owner` and `hidden` (ADR-0049 ' - + 'enforce-or-remove). Both sat on the view-item identity layer, were accepted by the ' - + 'strict authoring door and by the wire member the `view` write door validates, and were ' - + 'stored verbatim — and nothing read either: both switcher read paths filter on ' - + '`viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no ' - + 'per-user scope ever read `owner`, so a view marked as one user\'s was listed for ' - + 'everyone who can read the object. Per-user view scoping is a parked direction ' - + '(ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` ' - + 'tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire ' - + 'member, where a bare deletion would be a silent strip. The D2 conversion ' - + '`view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, ' - + 'as a lossless delete, in both collections a record travels in — `views` (stack sources ' - + 'and stored rows) and the assembled-manifest `viewItems` channel (package export, ' - + 'environment artifacts), whose registration parse would otherwise refuse an artifact ' - + 'assembled before this release.', - }, - { - id: 'view-overlay-owner-hidden-retired', - order: 39, - text: - 'It retires the view item\'s `owner` / `hidden` pair on the flattened overlay door too ' - + '(ADR-0049; the view item\'s disposition for the same key pair, followed here as triage ' - + 'directed): the lean personalization ' - + 'PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and ' - + 'nothing read either. Both are `retiredKey()` tombstones on the two overlay members with ' - + 'the view item\'s own prescription texts, and the D2 conversion ' - + '`view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, ' - + 'no container slot) in `views` and `viewItems`, so a stored overlay row is served without ' - + 'them. A row that held other view keys is then valid again and re-saves; a row that held ' - + 'nothing but its identity and the two keys is left identity-only, which the door refuses, ' - + 'so it is badged invalid, refused on a whole-row re-save and reported `failed` by ' - + '`os migrate meta --stored --apply` until it is deleted or given the setting its author ' - + 'meant. Its D3 record is the semantic entry ' - + '`view-overlay-owner-hidden-retired`.', - }, -]; - -/** - * Protocol 18 step — accumulating, uncut. - * - * v17.0.0 was cut before these narrowings landed, so their migration - * prescriptions belong to the NEXT major: `composeMigrationChain` filters - * `m <= toMajor` (default `PROTOCOL_MAJOR`), so this step is inert for every - * default caller until the protocol major reaches 18. The enforcement itself - * ships earlier on the 17.x line (launch-window convention: accept-set - * narrowings ride minor releases); this step is where `migrate meta` users - * are told, at the major boundary where they look. - * - * Mechanical: the malformed field `scale`/`precision` delete (#8321) — a - * non-integer or negative digit count declared nothing #7501's enforcement - * could act on, so removing it is behaviour-preserving; retired from the - * load path (the schema refuses live authors), replayed by `migrate meta` - * and the stored-row rehydration seam. Semantic: the memory-driver - * persistence placeholder refusal (commit 4bfe1a539, PR #8666: `${…}` refused - * at publish in `persistence.path` / `persistence.key`) — the #8336 parent - * adjudication applied to the two config-material memory keys its deliberate - * `initialData` exclusion never covered — the - * `MetadataPluginConfig.additionalTypes` retirement (#8586, ADR-0049): the - * inert plugin kind-declaration key, tombstoned on the 17.x line, with no D2 - * conversion because a plugin config is not a stack collection member (the - * `kernel/Manifest:loading` precedent) — the field `scale`/`precision` - * integer refusal (#8321), whose non-mechanical half is that only the author - * knows the digit count they meant — the removal of the `'*'` export grant - * from the shipped admin permission sets (#8681), the one entry here that - * narrows a CAPABILITY rather than an accept set, and so the one whose silence - * on upgrade is a working export turning into a 403 rather than a publish - * refusal — and the `record:chatter`/`record:discussion` `position` vocabulary - * convergence (#8762): the row's old `sidebar`/`inline`/`drawer` set is - * respelled to the renderer's `bottom`/`right`/`left` and the row's three - * schema defaults are dropped. - */ -const step18: MigrationStep = { - toMajor: 18, - rationale: joinRationale(STEP18_RATIONALE), - // Derived, not listed: every major-18 conversion graduates into this step, in - // the order `CONVERSIONS_BY_MAJOR[18]` applies them — the order that list - // already curates. A hand-kept copy took an append from every retirement (its - // tail conflicted like the rationale's) and can fall out of step with the - // list it copies: step 17's names the same ids in a different order. Add a - // conversion there, never here. - conversionIds: CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id), - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // #20323 — ADR-0049 enforce-or-remove (triage record 5860351140) — the D3 entry - // of the `action-aria-removed` family (ruling B on #17152: one D3 entry per - // retirement family, even when D2 is lossless). Its OWN family, not a member of - // `chart-config-aria-retired`: a different schema, a different measurement and - // a different replacement channel (the action's `label`, not a chart - // `description`). Registered key: `ui/Action:aria`, over two authored sites. - // The strip changes nothing a screen reader hears; the name the author wrote - // was never announced, and moving it is the author's edit. - { - id: 'action-aria-retired', - surface: 'action.aria / object.actions[].aria — the ARIA block on an action', - replacement: "The action's required `label`, which every action renderer uses as the accessible " - + 'name (the visible button or menu-item text, and the `aria-label` of an icon-only action). ' - + 'To name the region that places the actions, the `aria` block of the placing node — ' - + '`page.components[].aria` or the list view `aria`.', - reason: 'The D2 conversion `action-aria-removed` deletes `aria` from every stack action and every ' - + 'object-nested action, and the delete is lossless: no surface that renders an action ever ' - + 'read the block, so the ARIA attributes it declared never reached the DOM. The residue is ' - + 'accessibility work the author did that no user benefited from. An author who wrote ' - + '`aria.ariaLabel` believed screen-reader users heard that name; they heard the `label`. The ' - + 'strip deletes the text along with the key, and only the author can say whether it should ' - + 'become the `label` — which sighted users read too — or whether it described the toolbar ' - + 'or list the action sits in, and belongs in that node\'s `aria` block instead.', - acceptanceCriteria: 'No action, top-level or nested under an object, carries `aria`; the parse ' - + 'refuses it. Every action that had carried an `aria.ariaLabel` has a `label` conveying what ' - + 'that name was meant to announce, or the author has moved the text to the placing ' - + "component's or list view's `aria` block, or confirmed the existing label already says it. " - + 'With a screen reader, focusing an icon-only action announces its label.', - }, - // #21005 — the D3 entry of the `action-block-endpoint-to-target` family (ruling - // B on #17152: one D3 entry per retirement family, even when D2 is lossless). - // The `action:button` / `action:icon` rows declared `endpoint` while - // `ActionSchema` refused it with the rename to `target`; the console's `api` - // handler reads `target` only. The rename is lossless on an `api` action and is - // declined, as a TODO, everywhere else — and it cannot reach code. - { - id: 'action-block-endpoint-spelling-retired', - surface: 'page.component.action:button.endpoint / page.component.action:icon.endpoint — the ' - + 'endpoint an `api` action button calls, on the two page blocks that run an action', - replacement: '`target` — the one key the action runner dispatches an executor on, and the key ' - + '`ActionSchema` already renames `endpoint` to.', - reason: 'The D2 conversion `action-block-endpoint-to-target` renames `endpoint` to `target` in ' - + 'author sources and on every stored-row rehydration, for a block whose `actionType` is ' - + '`api` — the one meaning the key declared, and the rename is lossless there. Three things ' - + 'are left. A block that carries `endpoint` with no `actionType` was called through the ' - + 'action runner\'s legacy API fallback, which a `target` with no type does not reach, so the ' - + 'author has to add `actionType: \'api\'` as well. A block with another `actionType` never read ' - + '`endpoint`, so only the author can say whether its value should become the `target` or be ' - + 'deleted. And a block carrying both spellings with different values is left for the author ' - + 'to keep one. Each is left as stored and reported as a TODO. Code is out of reach: a custom ' - + 'action handler that read `endpoint` off the action it was handed reads nothing once the ' - + 'block carries `target`.', - acceptanceCriteria: 'No `action:button` or `action:icon` block carries `endpoint` in source or ' - + 'at rest; each block that called an endpoint names it as `target` with `actionType: \'api\'`. ' - + 'Pressing such a button in the console sends one request to that endpoint, and `os validate` ' - + 'reports no `component-props-unknown-key` finding for `endpoint`. No custom action handler ' - + 'reads `endpoint` from an action dispatched by either block.', - }, - { - id: 'action-bulk-dispatch-contract-undeclared', - surface: '`action.execution` — the bulk dispatch contract an action’s body is written for', - replacement: - "Declare `execution: 'perRecord' | 'aggregate'` on every action a list view wires into the " - + 'selection bar, DERIVED from the wiring that action already has: a view naming it in ' - + "`bulkActions: ['']` (the bare-string form) dispatches it once per selected row with " - + "that row's `recordId` ⇒ `execution: 'perRecord'`; a `bulkActionDefs` entry naming it with " - + "`execution: 'aggregate'` dispatches it once for the whole selection with every id in " - + "`params._selectedIds` ⇒ `execution: 'aggregate'`. The derivation is exact wherever an " - + 'action is wired ONE way, because the wiring is what the body has been receiving all along ' - + '— declaring it changes no behaviour, it writes down the behaviour. ⛔ There is no default: ' - + 'an action no view bulk-wires, and an action whose body genuinely serves both contracts ' - + '(it reads `recordId` AND `_selectedIds` and copes with either), stays UNDECLARED rather ' - + 'than being given a value.', - reason: - 'Not losslessly convertible, because the fact being written down does not live on the item ' - + 'being rewritten. The declaration belongs to the ACTION and the evidence for it belongs to ' - + 'the VIEWS — potentially several, in other files or other packages — so no per-item ' - + 'transform has both halves in hand, and `objectstack migrate meta` rewrites stored metadata ' - + 'by key. The residue is genuinely a judgement: an action wired BOTH ways has no correct ' - + 'value, because one call and N calls have different side effects and the platform will not ' - + 'silently unify them (the 2026-09-12 ruling that made an action declare its dispatch ' - + 'contract refused exactly that option). Such an action is TWO ' - + 'actions — split the body along the line the two wirings already draw and declare each half ' - + '— or, if the body was deliberately written to serve both, it stays undeclared and the two ' - + 'wirings stand. The census that is this migration’s input was taken 2026-09-13 over ' - + 'objectstack@a9c64779046 (shipped app metadata, test fixtures excluded: 13 distinct ' - + 'bulk-wired actions — 11 unambiguously per-record, 1 unambiguously aggregate, 1 wired both ' - + 'ways) and hotcrm@c716a2ccb3d31574a1a238a590f3e331ddae0200 (3 distinct bulk-wired actions — ' - + '2 per-record, 1 aggregate, 0 wired both ways). So the both-ways residue is real but rare, ' - + 'which is why it is a structured TODO and not a blocking rewrite.', - acceptanceCriteria: - '`objectstack validate` (and `os lint` / `os build`) reports no ' - + '`action-dispatch-contract-mismatch` finding on the stack; every action a list view wires ' - + 'into the selection bar either declares the `execution` its wiring implies, or is ' - + 'deliberately left undeclared with the reason recorded beside it; no action is wired both ' - + 'ways while declaring either contract. Prove the derivation rather than assuming it: for ' - + "each action you declared `'aggregate'`, its body reads `params._selectedIds` and does NOT " - + "depend on `ctx.recordId`; for each you declared `'perRecord'`, the reverse. Run the bulk " - + 'button once per declared action against a multi-row selection and confirm the number of ' - + 'dispatches matches the declaration (N for per-record, one for aggregate) — a mismatch that ' - + 'used to be silent is what this key exists to surface.', - }, - // The action facade's `find` took the `where` HALF of a query while every other - // `find` on the platform took the whole envelope. The rewrite is mechanical and - // lossless, but it lives in an action HANDLER's source — a TypeScript function - // body, not a keyed metadata document — so `objectstack migrate meta` cannot - // reach it and it is a semantic entry rather than a D2 conversion. - { - id: 'action-engine-facade-find-query-envelope', - surface: 'Action handler body — `ctx.engine.find(object, filter)` ' - + '(`ActionEngineFacade.find`, `@objectstack/spec/ui`)', - replacement: '`ctx.engine.find(object, { where: filter })` — the engine\'s own query envelope ' - + '(`EngineQueryOptions`), the same options bag `IDataEngine.find` takes. The filter moves under ' - + '`where` verbatim: `find(\'task\', { status: \'open\' })` → ' - + '`find(\'task\', { where: { status: \'open\' } })`. An unfiltered `find(object, {})` is unchanged, ' - + 'and the rest of the envelope — `fields`, `orderBy`, `limit`, `offset`, `expand` — becomes ' - + 'reachable from a handler for the first time. A caller-supplied `context` is ignored: the ' - + 'facade is trusted and stamps its own elevated one.', - reason: - 'The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler ' - + 'is authored TypeScript, and the chain rewrites stored metadata by key, so no `os migrate meta` ' - + 'step can reach a call expression inside a function body. The change is a WITHDRAWAL of the ' - + 'parameter shape an earlier typing fix chose (the filter alone), ruled by the director ' - + 'seat on 2026-09-12, with the maintainer\'s agreement, on the long-term axis 「one platform, ' - + 'one query shape」. The facade had been ' - + 'given a shape different from the engine\'s — the `where` half alone — which made the most ' - + 'natural spelling the wrong one: an author who passed the engine\'s envelope got ' - + '`{ where: { where: … } }`, matching no row and resolving to `[]` with no error, while an ' - + 'unfiltered `{}` kept working under either belief so a dead handler looked partially alive. The ' - + 'alternative — refusing `where` at the top level with an intersection — was rejected because it ' - + 'asserts a vocabulary fact the spec declares nowhere, reserving the field name `where` across ' - + 'every customer\'s data model to buy one parameter\'s compile-time check.', - acceptanceCriteria: - 'Every `ctx.engine.find(...)` in the app\'s action handlers passes an envelope. Where the handler ' - + 'is annotated with the PUBLISHED `ActionHandlerContext`, `tsc --noEmit` finds every unmigrated ' - + 'call on its own — a bare filter is a compile error there, an object literal failing the ' - + 'excess-property check and a `FilterCondition` variable failing TS2559. ⚠️ Where it is NOT — a ' - + 'handler in an `objectstack.config.js` / `.mjs`, one annotated with a local copy of the context ' - + 'type, or a `(ctx: any)` handler — the type reaches nothing and a type-check alone proves ' - + 'nothing: those callers are refused at RUNTIME by the facade arm, with the same prescription, so ' - + 'the migration is complete for them only once each such handler has actually been RUN. Then ' - + 'confirm the reads that were already SILENTLY EMPTY: any handler that had been passing the ' - + 'envelope was resolving to `[]` on every call, so a suite written against the mistake passed and ' - + 'the row count is the only witness — re-run each migrated handler against seeded data and assert ' - + 'it now returns the rows its filter selects, rather than asserting it still resolves.', - }, - { - id: 'address-location-value-unknown-keys-refused', - surface: 'stored `address` and `location` field VALUES (`AddressSchema` / `AddressValueSchema`, ' - + '`LocationValueSchema` — ADR-0104 D1), and the two authoring doors that parse the same ' - + 'contract: a `location` / `address` field\'s literal `defaultValue` and an action param of ' - + 'those types — undeclared keys', - replacement: 'the declared key the rejection names. An address value accepts exactly `street`, ' - + '`city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`; a location value ' - + 'exactly `lat`, `lng`, `altitude`, `accuracy`. Every rejection carries the surface, the ' - + 'offending key and a rename (`postal_code` / `zipCode` / `zip` / `postcode` → `postalCode`, ' - + '`latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed ' - + 'at the producer — never tolerated at a consumer: an alias for an off-spec key in a ' - + 'consumer stays forbidden (contract-first — fix the metadata, not the runtime)', - reason: - 'Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both ' - + 'value classes were all-optional ' - + 'STRIPPING `z.object`s, so a value with a completely wrong key set parsed green and the ' - + 'wrong keys vanished from the parse output: the showcase seed wrote `postal_code`, the ' - + 'platform accepted it, dropped it, and rendered an empty ZIP box (found while counting ' - + 'stored address values for objectui\'s survey of which structured values its field ' - + 'validator checks; an earlier report had named the same stripping on the address ' - + 'widget\'s round-trip, whose ZIP input bound `zipCode` against a stored `postalCode`), ' - + 'while a stored-value scan over ' - + 'the class could only ever report a clean count it had no way to earn. Closing the two ' - + 'shapes restores declared = enforced and pulls "loose" back to the one deliberate ' - + 'exception (`FileValueSchema`, untouched). Where the refusal BITES is the ADR-0104 write ' - + 'path\'s own evidence-gated posture, deliberately unchanged: a record write carrying an ' - + 'undeclared key is refused only on a deployment that has attested `adr-0104-value-shapes` ' - + '(or opted in with `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`); everywhere else it stays ' - + 'warn-first and is reported to the admitted-violation sink, and `os migrate value-shapes` ' - + 'now COUNTS such keys, so a deployment holding them cannot attest until they are cleaned. ' - + 'No read path parses these shapes; a stored value reads back as written.', - acceptanceCriteria: - '`os migrate value-shapes` reports zero findings on `address` / `location` fields — every ' - + 'stored value carries only declared keys (`postalCode`, never `postal_code` / `zipCode`; ' - + '`lat` / `lng`, never `latitude` / `longitude` / `heading` / `speed`) — and every ' - + '`address` / `location` `defaultValue` literal and action-param value parses with only ' - + 'declared keys. Declared keys parse byte-identically to before; `FileValueSchema` still ' - + 'admits extra keys.', - }, - { - id: 'admin-export-wildcard-removed', - surface: - 'the SHIPPED platform admin permission sets `admin_full_access`, `organization_admin` and ' - + 'the derived `organization_admin_no_bypass` — their `objects["*"].allowExport = true` ' - + 'wildcard grant (REMOVED; the rest of the wildcard is unchanged)', - replacement: - 'an explicit `allowExport: true` on the object entries of an APP-authored permission set ' - + 'held by the principals meant to keep exporting. Nothing replaces the grant in the ' - + 'platform sets themselves', - reason: - 'A capability NARROWING of a published set, and — like `export-axis-opt-in`, whose 17.0 ' - + 'story this completes — one no gate can announce: the metadata is unchanged and still ' - + 'parses, the shipped sets are re-seeded on upgrade, and the only observable is that an ' - + 'export which returned 200 now returns 403 `EXPORT_NOT_PERMITTED`. `export-axis-opt-in` ' - + 'told upgraders that "package-shipped sets are re-seeded on upgrade, so the built-ins ' - + 'are handled — `admin_full_access` and `organization_admin` now carry the grant ' - + 'explicitly"; from this major they deliberately do NOT, so a deployment that read that ' - + 'sentence and left its admins to the built-ins must now act. What the wildcard did, ' - + 'measured on 17.0.0 GA across 40 export probes: an org owner exported three objects on ' - + 'which NO app permission set granted export, 200 with full rows, and the app had no way ' - + 'to refuse — editing a code-package set answers `403 [not_overridable]`, and the org ' - + 'admin holds no app-authored set in which to write the per-object `false` that would ' - + 'have won. So an application could declare an object exportable by nobody, ship, and be ' - + 'silently wrong on an exfiltration boundary — declared ≠ enforced, on the axis where a ' - + 'silent gap costs the most. This is the 2026-08-07 ruling on the member baseline ' - + 'applied to export: that change removed ' - + '`member_default`\'s CRUD wildcard because a wildcard in a set every principal resolves ' - + 'is not a default but a floor nobody can get under; the export wildcard survived by ' - + 'omission rather than by decision, one tier up. It cannot be mechanically converted, in ' - + 'either direction: re-granting `allowExport` wherever an admin holds a set would restore ' - + 'today\'s behaviour and defeat the entire point, and leaving it withheld may revoke ' - + 'export an operator legitimately wants. WHICH principals may take a bulk copy is the ' - + 'segregation-of-duties judgement the axis exists to make explicit, and it belongs to the ' - + 'operator. Note the boundary this does NOT move: the export gate itself is unchanged and ' - + 'was never the defect (controls C1–C3 of the same run show it enforcing exactly), ' - + 'specific-over-wildcard precedence is unchanged, `allowExport` on a `"*"` entry remains a ' - + 'supported authoring shape in an app\'s OWN sets, and READ is untouched — an admin still ' - + 'sees every record they saw before. ADR-0087; maintainer ruling 2026-08-15, which ' - + 'removed `allowExport` from the wildcard entry of both shipped admin sets.', - acceptanceCriteria: - 'For every principal whose ADMIN export you rely on, the grant is now authored where you ' - + 'control it: an app/environment permission set held by that principal names each object ' - + 'they must export and carries `allowExport: true` on it. Verify BEHAVIOURALLY — nothing ' - + 'fails at parse time, and a re-seed silently replaces the old built-ins: sign in as an ' - + 'org owner or platform admin and call `GET /api/v1/data//export`, confirming 200 ' - + 'where export is intended and 403 `EXPORT_NOT_PERMITTED` where it is not. ⚠️ Silence is ' - + 'not success: a deployment that upgrades without editing anything is VALID metadata ' - + 'whose administrators have quietly lost export on every object no app set grants, and ' - + 'the first sign will be a support report rather than an error. The reverse reading is ' - + 'worth one pass too — an object your app declares exportable by nobody is now genuinely ' - + 'exportable by nobody, which is the point of the change; confirm that is what you want ' - + 'before granting it back.', - }, - // The delegated-admin scope's one required key, required NON-BLANK. Stored - // scopes are deliberately not rewritten: the root of a subtree cannot be - // inferred from a blank, so the stored row is refused on its next write and - // named by the boot reconciliation's existing durability error instead. - { - id: 'admin-scope-business-unit-blank-refused', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'security.permission.adminScope.businessUnit (AdminScopeSchema, ADR-0090 D12) authored or stored ' - + 'BLANK: the empty string, or a value that is nothing but whitespace (spaces, a tab, a newline). ' - + 'The key is the scope\'s only required one and names the root business unit of the delegated ' - + 'subtree; a blank value satisfied the requirement while naming no unit', - replacement: - 'the sys_business_unit.name (machine name) of the business unit at the root of the subtree the ' - + 'delegate administers, written out: `businessUnit: \'north_america\'`. If the permission set ' - + 'should not delegate administration at all, remove `adminScope` from it. ⛔ There is no ' - + 'replacement that can be DERIVED from what was written: a blank names no unit, so the root the ' - + 'author meant is not recoverable, and the platform must not pick one.', - reason: - 'Maintainer ruling A, 2026-09-23: an empty or whitespace-only `businessUnit` is refused ' - + 'at parse, and stored scopes are not rewritten. ' - + '`AdminScopeSchema` declared `businessUnit` as a bare string with no minimum, so ' - + '`{ businessUnit: \'\' }` and `{ businessUnit: \' \' }` parsed green — measured against the ' - + 'published spec 17.4.0 and re-measured on `main` before the change. This narrows a published ' - + 'face: every other key of the scope is scoped TO this one, and ADR-0090 D12 declares the scope\'s ' - + 'WHERE as a business-unit subtree, which a blank does not name. The delegated-admin gate ' - + 'resolves the anchor by exact name, so a blank anchor resolves to an empty subtree and approves ' - + 'nothing on the subtree axes — no escalation was measured; the defect is a declaration that ' - + 'does not enforce what it declares, satisfied most readily by an author (an AI author above ' - + 'all) that knew the key was required and did not yet know the unit. The refusal is a ' - + 'NON-TRANSFORMING refinement at the key\'s own path, deliberately not a trim: the metadata save ' - + 'path persists the submitted body verbatim rather than the parsed value, so a trimming schema ' - + 'would validate one string and store another that the gate\'s exact lookup cannot resolve. A ' - + 'real name therefore parses byte-identical. Scope is blankness only: a real name with ' - + 'surrounding whitespace is not judged by this entry. ⚠️ STORED ROWS ARE NOT REWRITTEN and there ' - + 'is no D2 conversion (no lossless rewrite exists — the root cannot be inferred, and dropping ' - + 'the scope would silently change who is a delegate). The read path does not re-validate stored ' - + 'rows, so no stored permission set becomes unreadable. A stored blank-anchored scope is refused ' - + 'on its NEXT WRITE instead: a Setup or data-door edit of that permission set answers 422 ' - + 'INVALID_METADATA naming adminScope.businessUnit, and the boot reconciliation backfill of a ' - + 'legacy record with no metadata definition reports it through its existing durability ERROR ' - + '(ADR-0094 D4), whose own prescription is to make the record body spec-valid; restoring a ' - + 'trashed blank-anchored set brings the record back and reports the missing definition at ERROR ' - + 'the same way. ⛔ No path skips the row. Ships at once, no grace window and no dual spelling ' - + '(2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0090.', - acceptanceCriteria: - 'Search every authored and stored permission set for an `adminScope` whose `businessUnit` is ' - + 'empty or whitespace-only — metadata files, `sys_metadata` permission rows, and the ' - + '`admin_scope` column of `sys_permission_set` — and write the root unit\'s machine name, or ' - + 'remove `adminScope` where the set should not delegate. The sweep is mechanical for authored ' - + 'metadata: `AdminScopeSchema.safeParse` and `PermissionSetSchema.safeParse` answer exactly one ' - + '`custom` issue at `businessUnit` (or `adminScope.businessUnit` when the set is parsed whole) ' - + 'naming `sys_business_unit.name` as the valid anchor. For STORED rows the search above is the ' - + 'catch-all, because reads never refuse: a definition already stored in `sys_metadata` with a ' - + 'blank anchor loads and resolves exactly as before and says nothing until it is written again. ' - + 'Two write paths then name it: a re-save of the set (refused 422 at adminScope.businessUnit), ' - + 'and — for a legacy `sys_permission_set` record with no metadata definition only — the boot ' - + 'log, where the ADR-0094 D4 backfill\'s first-failure ERROR names the record and carries the ' - + 'offending key and its summary ERROR counts and names the failed records. ⛔ A clean boot is ' - + 'therefore NOT a completed sweep. An absent `businessUnit` is refused exactly as before, with its own invalid_type issue. Nothing is ' - + 'normalised on the way through: an accepted anchor arrives byte-identical to what was written. ' - + 'The repo and example apps carried zero blank anchors at the time of the change, so no fixture ' - + 'had to be rewritten.', - }, - { - id: 'advanced-plugin-lifecycle-config-retired', - surface: - 'kernel.advancedPluginLifecycle (the authorable config surface of ' - + '`plugin-lifecycle-advanced.zod.ts` — 3 defs, 9 exported names: ' - + '`AdvancedPluginLifecycleConfigSchema` / `AdvancedPluginLifecycleConfig` ' - + '/ `AdvancedPluginLifecycleConfigParsed`, `GracefulDegradationSchema` / ' - + '`GracefulDegradation` / `GracefulDegradationParsed`, ' - + '`PluginUpdateStrategySchema` / `PluginUpdateStrategy` / ' - + '`PluginUpdateStrategyParsed`)', - replacement: - '(removed — there is no declarative replacement, because nothing ever read ' - + 'the declaration. The supported lifecycle surface is the HOST-DRIVEN ' - + 'library in `@objectstack/core`: construct `PluginHealthMonitor` and ' - + 'pass a `PluginHealthCheck` per plugin, construct `HotReloadManager` and ' - + 'pass a `HotReloadConfig` — the `content/docs/protocol/kernel/' - + 'lifecycle.mdx` examples — rewritten to show the plugin exposing a method and ' - + 'the host registering it, never a declarative field — are the supported usage, and those ' - + 'input vocabularies (`PluginHealthStatus` / `PluginHealthCheck` / ' - + '`PluginHealthReport`, `HotReloadConfig` with its embedded ' - + '`DistributedStateConfig`, `PluginStateSnapshot`) SURVIVE in the same ' - + 'module as library parameter types. Degradation and update-strategy ' - + 'vocabularies return only via the ENFORCE route of ADR-0049 through a ' - + 'new ADR — the executor first, the vocabulary second)', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, route 2: ' - + 'retire the config container and keep the classes as a host-driven ' - + 'library. The container aggregated six config groups — `health`, ' - + '`hotReload`, `degradation`, `updates`, `resources`, `observability` — ' - + 'and NO group had a runtime reader, re-measured per group at the ' - + 'retirement\'s base commit (8cdd696) with positive controls: the kernel ' - + 'never constructs `PluginHealthMonitor` or `HotReloadManager` (only ' - + 'their own unit tests and `core/examples/phase2-integration.ts` do, ' - + 'passing config DIRECTLY to the classes, never through this container); ' - + '`degradation` / `updates` / `resources` / `observability` keys have no ' - + 'implementation body at all (controls: `checkMethod` resolves to ' - + '`core/src/health-monitor.ts` and `debounceDelay` to ' - + '`core/src/hot-reload.ts`, proving the scan sees real readers; the ' - + 'bare-name collisions — plugin-ordering\'s `optionalDependencies`, ' - + 'auth-manager\'s private `degradedFeatures`, plugin-security-advanced\'s ' - + '`resourceLimits.maxCpu` read by `sandbox-runtime.ts` — are different ' - + 'surfaces, verified structurally). No manifest, stack collection or ' - + 'metadata-type binding ever embedded the container, so no authored ' - + 'document could carry it: an author declaring `health: {...}` or ' - + '`rollback: { automatic: true }` got a clean parse and NOTHING — the ' - + 'shape of the plugin sandboxing / integrity / approval config that was ' - + 'never wired to anything, at container scale, sharpened by production-safety ' - + 'vocabulary (auto-restart, zero-downtime rolling updates, automatic ' - + 'rollback) an AI author (ADR-0033) reads as proof the capability ' - + 'exists. With no carrier key and no authored document there is nothing ' - + 'to tombstone and no seam for a D2 conversion: route 3, the shape of ' - + 'the dynamic plugin-loading family\'s removal and of the retired ' - + '`ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' - + 'declaration.', - acceptanceCriteria: - 'No code imports any of the 9 retired names from `@objectstack/spec` or ' - + '`@objectstack/spec/kernel` — every one is TS2305 after upgrade, on ' - + 'every public entry (pinned by resolved symbol identity in ' - + '`kernel/plugin-lifecycle-advanced-retirement.test.ts`). No metadata ' - + 'document needs editing: the container was reachable from no ' - + 'metadata-type binding, stack collection or manifest embed, so no ' - + 'document could ever carry it. The host-driven library vocabularies ' - + 'survive unchanged on `./kernel` (`PluginHealthStatusSchema`, ' - + '`PluginHealthCheckSchema`, `PluginHealthReportSchema`, ' - + '`HotReloadConfigSchema`, `DistributedStateConfigSchema`, ' - + '`PluginStateSnapshotSchema` — same pin), and `PluginHealthMonitor` / ' - + '`HotReloadManager` stay exported from `@objectstack/core` with their ' - + 'tests green. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ' - + 'ever read the container, so removing it removes no behaviour.', - }, - // #21320 — ADR-0049 enforce-or-remove (ruled D, retire, on - // objectstack-ai/cloud#2569) — the D3 entry of the `agent.lifecycle` - // retirement, one entry for the one family: the key and the XState - // `StateMachineSchema` exports that only it still reached leave for the same - // reason. The key's deletion is mechanical (the D2 conversion - // `agent-lifecycle-removed`); where the intent behind a deleted machine goes — - // a skill, a Flow, or a `state_machine` validation rule — is not, and that - // judgement is what this entry carries. - { - id: 'agent-lifecycle-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. - surface: - 'agent.lifecycle — the agent conversation state machine left the shape; with it the XState ' - + 'StateMachineSchema family left @objectstack/spec/automation (StateMachineSchema, StateNodeSchema, ' - + 'TransitionSchema, ActionRefSchema, GuardRefSchema and their types), and StateNodeConfig left the ' - + 'root and /ai entries', - replacement: - 'no key: delete `lifecycle` from every agent. Put what the machine meant where the platform enforces ' - + 'it — a phase of a conversation is a skill with its own `instructions` and `tools`, selected by its ' - + '`triggerConditions` and attached through the agent\'s `skills`; a multi-step process is a Flow; a ' - + 'record\'s status transitions are a `state_machine` validation rule on the object (a flat table of ' - + 'each state\'s allowed next states). Code that imported the state machine exports declares the shape ' - + 'it needs itself, or drops it', - reason: - 'ADR-0049 enforce-or-remove: `agent.lifecycle` was parsed and never read. No runtime — not this ' - + 'repository, not the cloud AI runtime that executes agents — moved an agent through a declared ' - + 'state or refused an undeclared transition, so an authored machine changed nothing an agent did. ' - + 'Enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 ' - + 'rejected, and what it reached for is already served: conversation phases by skills (ADR-0064), ' - + 'orchestration by Flow (ADR-0019), record transitions by the `state_machine` validation rule ' - + '(ADR-0020). Authoring now refuses the key with that prescription, and TypeScript rejects it. The ' - + 'D2 conversion `agent-lifecycle-removed` deletes it from existing sources and stored agent rows, ' - + 'losslessly. `StateMachineSchema` had kept its file only for this door (ADR-0020 implementation ' - + 'note 1), so the family left with it — which of the three destinations each deleted machine meant ' - + 'is the author\'s judgement, not a mechanical rewrite', - acceptanceCriteria: - 'No agent declares `lifecycle`; it is refused at parse with its prescription, and TypeScript rejects ' - + 'it. Every conversation phase a deleted machine described is a skill the agent lists in `skills`, ' - + 'with its own `instructions`, `tools` and `triggerConditions`; every multi-step process it described ' - + 'is a Flow; every record status transition it described is a `state_machine` validation rule on that ' - + 'object. No source imports StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, ' - + 'GuardRefSchema or their types from @objectstack/spec. Every agent parses under the new schema.', - conversionIds: ['agent-lifecycle-removed'], - }, - // #20274 — ADR-0049 enforce-or-remove (ruling record 5950198150, letter A′) — - // the D3 entry of the `agent.memory` contract: one entry for the one decision, - // because its two halves leave an upgrading author ONE job between them. The - // `store` half is mechanical (the D2 conversion - // `agent-memory-long-term-store-removed` deletes it, losslessly); the two - // required numbers are not — no default is declared, so only the author can - // choose them, which is the judgement this entry exists to carry. - { - id: 'agent-memory-store-retired-and-limits-required', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. - surface: - 'agent.memory — longTerm.store left the shape (the memory store is the platform\'s); ' - + 'longTerm.maxEntries and reflectionInterval are required when longTerm.enabled is true, and ' - + 'reflectionInterval is refused without an enabled longTerm; longTerm.enabled is unchanged', - replacement: - 'no storage key: delete `longTerm.store`, whatever it held — where long-term memory notes are ' - + 'kept is the platform\'s choice. An agent whose `longTerm.enabled` is true declares ' - + '`longTerm.maxEntries` (how many distilled notes are kept for each user; the newest are recalled ' - + 'before the first round and older ones evicted) and `memory.reflectionInterval` (how many ' - + 'delivered interactions pass between the reflections that write a note). An agent without ' - + 'enabled long-term memory declares no `reflectionInterval`', - reason: - 'ADR-0049 enforce-or-remove: the `agent.memory` contract states exactly what the runtime honours. ' - + 'The cloud AI runtime, the one runtime that executes agents, enforces long-term memory from ' - + '`enabled`, `maxEntries` and `reflectionInterval`: it recalls the newest `maxEntries` notes ' - + 'before the first round, writes one note every `reflectionInterval` delivered interactions, and ' - + 'evicts notes beyond `maxEntries`. It keeps the notes in its own database store, and before an ' - + 'agent\'s first turn it refused the `vector` store (the old default, so what an omitted `store` ' - + 'parsed to), `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` ' - + 'without an enabled `longTerm`. Authoring now refuses the same declarations, each with a ' - + 'prescription. The D2 conversion `agent-memory-long-term-store-removed` deletes `store` from ' - + 'existing sources and stored rows, losslessly: no value of it ever chose a backend. No default ' - + 'is declared for either number, because none has a measured basis — so an agent with long-term ' - + 'memory enabled and either number missing no longer parses, and only its author can choose the ' - + 'numbers it needs', - acceptanceCriteria: - 'No agent declares `memory.longTerm.store`, or a `backend`, `storage` or `provider` key under ' - + '`longTerm`; each is refused at parse with its prescription, and TypeScript rejects `store`. ' - + 'Every agent whose `longTerm.enabled` is true declares both `longTerm.maxEntries` and ' - + '`memory.reflectionInterval`, each an integer of at least 1 chosen for that agent, and no agent ' - + 'declares `reflectionInterval` without an enabled `longTerm`. Every agent parses under the new ' - + 'schema.', - conversionIds: ['agent-memory-long-term-store-removed'], - relevantWhen: { kind: 'stack-declares', keys: ['agents'] }, - }, - // #21277 — ADR-0049 enforce-or-remove (ruling record 5945617233, letter A) — - // the D3 entry of the `agent-structured-output-refused-members-removed` - // family: one entry for the four members, because they leave for one reason - // (the runtime that executes agents refused each before the first turn) and - // share one replacement channel (a JSON contract). Value-level retirements, so - // nothing lands in RETIRED_KEYS_BY_MAJOR: no authorable KEY changed, and the - // four surface ratchets stay byte-identical. - { - id: 'agent-structured-output-refused-members-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. - surface: - "agent.structuredOutput — the values 'regex', 'grammar' and 'xml' left StructuredOutputFormat " - + "(at format and fallbackFormat) and 'coerce_types' left TransformPipelineStep (at " - + "transformPipeline); 'json_object', 'json_schema', 'trim', 'parse_json' and 'validate' are " - + 'unchanged', - replacement: - 'a JSON contract: `format: json_schema` with a JSON Schema in `schema` when the answer must have ' - + 'a shape, or `format: json_object` when any JSON value will do — or no `structuredOutput` block ' - + 'at all when the agent needs no output contract. A fallback format names one of the two JSON ' - + 'formats or is left out. In place of `coerce_types`, declare the exact types in `schema`, so the ' - + 'answer is validated as the model wrote it', - reason: - 'ADR-0049 enforce-or-remove. The cloud AI runtime, the one runtime that executes agents, enforces ' - + '`structuredOutput` on every final answer and refuses an agent that declares any of these four ' - + 'members before its first turn: the spec never had a key to carry the pattern or grammar a ' - + '`regex` or `grammar` answer would be checked against, a final answer is checked only as JSON, ' - + 'and no coercion engine exists. Hosted model APIs constrain a final answer by JSON Schema only; ' - + 'regex and grammar constraints live in inference engines and in tool-input formats, not on an ' - + "agent's answer. The D2 conversion `agent-structured-output-refused-members-removed` makes each " - + 'stored or existing source parse: it DELETES a block whose `format` was retired, deletes a ' - + 'retired `fallbackFormat`, and drops `coerce_types` from the pipeline. The deletion of a block is ' - + 'the edit that needs judgement: it removes an output contract the runtime never kept, and only ' - + 'the author can say whether the agent should now carry a `json_schema` contract instead — the ' - + 'conversion cannot write the schema the author meant', - acceptanceCriteria: - 'No agent declares `format` or `fallbackFormat` as `regex`, `grammar` or `xml`, and no ' - + '`transformPipeline` lists `coerce_types`; each is refused at parse with its prescription, and ' - + 'TypeScript rejects each at a `StructuredOutputFormat` or `TransformPipelineStep` position. Every ' - + 'agent whose block the conversion deleted either carries a `json_schema` contract whose `schema` ' - + 'states the answer it must give, or the author has confirmed it needs no output contract. An ' - + 'agent that relied on coercion declares the exact types in `schema` and a test turn returns an ' - + 'answer that validates without conversion.', - conversionIds: ['agent-structured-output-refused-members-removed'], - relevantWhen: { kind: 'stack-declares', keys: ['agents'] }, - }, - { - id: 'ai-conversation-analytics-duration-unit-in-key', - surface: 'ConversationAnalytics.duration, the emitted session length whose name carried no ' - + 'unit (ai/conversation.zod.ts)', - replacement: 'durationSeconds — rename the key; the value is unchanged', - reason: - 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' - + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' - + 'value, never only in the describe prose, and no existing offender is grandfathered. ' - + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' - + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' - + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' - + 'totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field ' - + 'that carried a unit was the one field that did not say so, sitting in a block of ' - + 'twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, ' - + 'already spelled themselves; the measurement between them did not. Tombstoned with ' - + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' - + 'an emitter writing the old spelling would lose the value with no error anywhere. ' - + 'Why a semantic entry and not a D2 conversion: conversation analytics are computed at ' - + 'runtime and handed to a consumer, never authored by hand and never stored as a ' - + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' - + 'same disposition every runtime-emitted measurement in this stack has taken. ' - + 'ADR-0087.', - acceptanceCriteria: - 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' - + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' - + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' - + 'than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is ' - + 'the same half hour duration: 1800 was, the key stays optional, and the non-negative ' - + 'bound rides along with the renamed key so a negative session length is still refused. ' - + 'The migration is proved correct when no source in the tree spells a bare duration on ' - + 'this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of ' - + 'them has read a count as a duration and over-applied the rule.', - }, - // #21289 — the two JSON-Schema slots the cloud AI runtime compiles, - // `action.ai.outputSchema` and `agent.structuredOutput.schema`, were open - // records, so a schema whose untyped subschema carries a type-scoped keyword - // (`properties`, `items`, `pattern`, …) passed every authoring door and was then - // refused by the runtime's one shared guard before the action or agent ran. - // Both slots now come from one factory (`shared/ai-json-schema-slot.ts`) that - // mirrors that guard's keyword set and descent exactly and refuses at the - // subschema's path. D3 only: the runtime already refused every such schema, so - // nothing that worked stops working; supplying a `type` is a judgment about the - // author's intent (adding one also narrows what the schema accepts), not a - // lossless rewrite; and the authored census found nothing to respell. - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'ai-json-schema-untyped-subschema-refused', - surface: 'action.ai.outputSchema (stack actions and object-nested actions) and ' - + 'agent.structuredOutput.schema — a JSON Schema in which an object subschema with no type ' - + 'carries a type-scoped keyword', - replacement: 'the same schema with a `"type"` declared on every subschema that carries a ' - + 'type-scoped keyword: `"object"` beside `properties`, `required`, `additionalProperties`, ' - + '`patternProperties`, `propertyNames`, `minProperties` or `maxProperties`; `"array"` beside ' - + '`items`, `prefixItems`, `contains`, `minItems`, `maxItems` or `uniqueItems`; `"string"` ' - + 'beside `minLength`, `maxLength`, `pattern` or `format`; `"number"` or `"integer"` beside ' - + '`minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` or `multipleOf`. A subschema ' - + 'meant to accept several types declares them as an array (`"type": ["string", "null"]`).', - reason: 'Both slots are compiled by the cloud AI runtime — `action.ai.outputSchema` before the ' - + 'action runs, to validate its result, and `agent.structuredOutput.schema` as the agent\'s ' - + 'structured-output contract — and both readers call one guard whose schema reader does not ' - + 'check a type-scoped keyword on a subschema that declares no `type`. That guard refuses the ' - + 'whole schema before anything runs. The spec declared both slots as open records, so such a ' - + 'schema passed `defineStack`, `objectstack validate` and the metadata save door, and the ' - + 'author learned of it only when the action or agent was invoked. Both slots are now one ' - + 'declaration that mirrors the guard exactly — the same 22 type-scoped keywords (`properties`, ' - + '`required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties`, ' - + '`maxProperties`, `items`, `prefixItems`, `contains`, `minItems`, `maxItems`, `uniqueItems`, ' - + '`minLength`, `maxLength`, `pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`, ' - + '`exclusiveMaximum`, `multipleOf`), present with any value on an object node whose `type` is ' - + 'absent; the same descent into every value of `properties`, `patternProperties`, `$defs`, ' - + '`definitions` and `dependentSchemas` and into the single subschema or each array entry of ' - + '`items`, `additionalProperties`, `contains`, `propertyNames`, `not`, `if`, `then`, `else`, ' - + '`unevaluatedProperties`, `unevaluatedItems`, `anyOf`, `oneOf`, `allOf` and `prefixItems`, ' - + 'under typed and untyped parents alike, without following `$ref` — and refuses each offending ' - + 'subschema at its own path with the `type` to declare named. Boolean subschemas, `{}`, a ' - + 'node with any `type` value, and an untyped node carrying only keywords outside the list ' - + '(`enum`, `const`, `$ref`, `anyOf`, `title`, …) are accepted, as the runtime accepts them. ' - + 'Measured on the built package: the per-type schema the metadata save door validates with ' - + 'refuses an action, an object-nested action or an agent carrying such a schema at the ' - + 'subschema path, and `defineStack` throws with the same path; the same schema with `type` ' - + 'declared is accepted at both. Read from source and not run: a row already stored still ' - + 'loads, because the database loader replays the conversion chain and parses nothing, and its ' - + 'next save is refused until the `type` is declared. No conversion is registered: ' - + 'every refused schema was already refused by the runtime, so nothing that worked stops ' - + 'working; and supplying a `type` is a judgment about what the author meant, not a lossless ' - + 'rewrite, because declaring one also narrows what the schema accepts. Population measured at ' - + 'the change, on origin/main 135daaa06b: zero untyped subschemas in the three authorings of ' - + 'either slot across the package fixtures (one action `ai.outputSchema`, two ' - + '`structuredOutput.schema`), and zero authorings of either slot in the examples, the ' - + 'documentation and the published skills; the one `outputSchema` the examples carry is a ' - + 'connector action\'s, a different key. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `ai.outputSchema` on an action (stack-level and object-nested) and ' - + 'every `structuredOutput.schema` on an agent parses: `objectstack validate` reports no ' - + 'issue whose message reads `uses "…" without a "type"` at either slot, and every subschema ' - + 'in either schema that carries a type-scoped keyword declares its `type`. The action or ' - + 'agent then runs past the AI runtime\'s schema compilation instead of being refused before ' - + 'it runs.', - }, - { - id: 'analytics-authorable-unknown-keys-refused', - // Same-major bookkeeping (#10414): batch D also closed the nested - // `Metric.filters[]` item, but `Metric.filters` was REMOVED outright later - // in this same unpublished major (retired-key entry `data/Metric:filters`, - // conversion `metric-filters-removed`), so this entry no longer names a - // surface an 18.x author can reach. The same holds for the cube's - // `refreshKey` block, which batch D closed and #20637 then retired whole - // (conversion `cube-refresh-key-removed`). - surface: 'analytics cube definitions (`defineCube` / `defineStack({ analyticsCubes })`: the ' - + 'cube, each metric, each dimension, each ' - + 'join) and the `/analytics/query` body\'s nested `timeDimensions[]` items — undeclared keys', - replacement: 'the declared key the rejection names. Every rejection carries the surface, the ' - + 'offending key and a rename suggestion (`title` → `label` on a metric/dimension, `label` → ' - + '`title` on the cube, `granuarity` → `granularity`, `orderBy` → `order`; `filters` on a ' - + 'query gets the `where` prescription). A key that names no supported capability is simply ' - + 'removed', - reason: - 'The unknown-key strictness campaign (the sweep that ended silent stripping of undeclared ' - + 'keys as the default, one schema family at a time), its data/ batch. These shapes parsed ' - + '`.strip` — an ' - + 'undeclared key on an authored cube was silently dropped, so a join authored with a ' - + 'typo\'d `relationship` registered with the `many_to_one` default (a different join than ' - + 'the author declared) and a metric\'s misspelled key vanished under a successful parse. ' - + 'The subtle half: `/analytics/query`\'s top level has been strict since the degraded ' - + 'shim\'s envelope dialect was retired (one URL, one request body), but ' - + 'top-level strictness does not recurse — `timeDimensions: [{ dimension, granuarity: ' - + '\'day\' }]` rode through the strict wrapper with the typo stripped, bucketing the whole ' - + 'range as one group under an ordinary 200. Undeclared keys on all eight sites are now ' - + 'refused at parse time with a prescriptive message. (Two of the eight were themselves ' - + 'removed later in this major, because nothing ever read them: the nested metric ' - + '`filters[]` item, by `metric-filters-removed`, and the cube\'s `refreshKey` block, by ' - + '`cube-refresh-key-removed`.)', - acceptanceCriteria: - 'Every cube in `defineStack({ analyticsCubes })` / `defineCube` parses with only declared ' - + 'keys at every level (cube, measures, dimensions, joins); ' - + 'every `/analytics/query` body\'s `timeDimensions[]` items carry only ' - + '`dimension`/`granularity`/`dateRange`. Declared keys parse byte-identically to before.', - }, - // A declared-default correction plus the enforcement that makes the key real — - // the shape of `17.approval-escalation-enabled-default-flip`. There is no D2 - // conversion: no spelling moves, and a written `public: false` is a legitimate - // value that the chain cannot tell from a materialized old default, so the - // judgement is the author's. The `data/Cube:public` row of - // `DEFAULT_CHANGES_BY_MAJOR` records the default move itself. No backticks in - // `surface`: the upgrade guide renders it inside a code span and a table cell. - { - id: 'analytics-cube-public-default-visible-enforced', - surface: - 'data.Cube.public — an analytics cube that declares public: false, and every cube in an ' - + 'artifact built by os compile before this release (the compiler writes the parsed stack, so ' - + 'it carries a materialized public: false on each cube that omitted the key)', - replacement: - 'nothing, to keep a cube queryable: cubes are visible by default. Delete an authored ' - + '`public: false` that only restated the old default, and write it only on a cube that must ' - + 'stay out of the analytics API. Recompile every `os compile` artifact built before this ' - + 'release', - reason: - 'A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real. The analytics ' - + 'cube schema declared `public` with a default of `false` under an access-control comment, ' - + 'and nothing read it: `/analytics/meta` listed every cube and every query door answered it. ' - + 'The analytics service now reads it. A cube declared `public: false` is left out of ' - + '`/analytics/meta`, and `/analytics/query` and `/analytics/sql` refuse it with 404 ' - + '`CUBE_NOT_FOUND` — the same refusal, byte for byte, that an unknown cube name gets, so the ' - + 'refusal does not confirm a hidden cube exists. Enforcing the old default as declared would ' - + 'have hidden every cube that omits the key, so the default moves to `true` in the same ' - + 'change, and a cube that omits the key stays visible exactly as it was. Two holdings change ' - + 'behaviour on upgrade. An authored `public: false` — including one copied from the example ' - + 'app, which carried it — now hides the cube and refuses its queries. And an artifact built ' - + 'by `os compile` before this release carries a materialized `public: false` on every cube ' - + 'that omitted the key, because the compiler writes the parsed stack with its defaults ' - + 'applied; a host that registers cubes from such an artifact hides all of them until the ' - + 'artifact is recompiled. The key is visibility, not row security: records stay governed by ' - + 'object permissions and row-level security on every door, and the metadata door keeps ' - + 'serving cube definitions.', - acceptanceCriteria: - '`GET /analytics/meta` lists every cube your dashboards and reports query, and a query naming ' - + 'each one answers 200. For each authored `public: false`, either delete it (the cube is meant ' - + 'to be queried) or keep it and confirm that `/analytics/meta` omits the cube and that a query ' - + 'naming it answers 404 `CUBE_NOT_FOUND`. Every compiled artifact in use was built by ' - + '`os compile` from this release or later.', - relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, - }, - // An inert key made real, and the request class that goes from answered to - // refused because of it: the engine aggregate path's whole refusal set, which a - // newly bucketed query reaches by leaving the raw-SQL path. The shape of - // `analytics-cube-public-default-visible-enforced`. There is no D2 conversion: - // no spelling moves, and whether a one-interval list means "bucket by this by - // default" or "the one interval I happened to list" is the author's intent, - // which the chain cannot read. The protocol-18 conversion - // `cube-sub-day-granularities-removed` is why the entry is owed even to an - // author who never wrote a one-interval list. No backticks in `surface`: the - // upgrade guide renders it inside a code span and a table cell. - { - id: 'analytics-cube-single-granularity-default-enforced', - surface: - 'data.Cube.dimensions.granularities — an analytics cube time dimension whose granularities ' - + 'list holds exactly one interval, whether an author wrote it that way or the protocol 18 ' - + 'conversion cube-sub-day-granularities-removed reduced a longer list to it', - replacement: - 'nothing, when that one interval is the bucket the dimension should be grouped at by default. ' - + 'When it is not, list every interval the dimension serves (two or more state no default) or ' - + 'omit the key. A dashboard or report whose query the engine aggregate path cannot evaluate ' - + '(a custom-SQL measure, or a member of a cube with `joins` that resolves through one) either ' - + 'stops grouping by such a dimension or groups by one that declares no single interval', - reason: - 'An inert key made real. A cube time dimension\'s `granularities` was read only for a cube the ' - + 'dataset compiler minted, where a one-interval list is the dataset\'s default bucket. A cube ' - + 'authored with `defineCube()` or `defineStack({ analyticsCubes })` never reached that reader, ' - + 'so grouping by its time dimension grouped raw timestamps, one group per distinct instant, ' - + 'whatever the list said. The analytics service now reads every cube by the compiled-dataset ' - + 'rule: on `/analytics/query` and on the `/analytics/sql` dry run, a time dimension the query ' - + 'groups by without stating a granularity is bucketed at the one interval its list declares. ' - + 'A granularity the query states still wins, one the list does not name is not refused, and a ' - + 'list of two or more states no default. Two holdings change on upgrade. A query grouping by ' - + 'such a dimension answers one row per bucket where it answered one row per timestamp. And a ' - + 'bucketed query leaves the raw-SQL path, which declines every bucketed query, for the engine ' - + 'aggregate path, which answers 400 `INVALID_FIELD` for every member it cannot evaluate — the ' - + 'same refusal, byte for byte, that the same query already got with that granularity stated by ' - + 'hand. Those members are: a custom-SQL measure (a `number`, `string` or `boolean` measure ' - + 'whose `sql` is an expression); and, on a cube whose members resolve through its `joins`, a ' - + 'measure or a `where` field over a joined object, a `timeDimensions` entry over a joined object ' - + '(bucketed or a window, so grouping by a one-interval time dimension over a joined object is ' - + 'refused too), a dimension that traverses more than one relationship, and an `avg` or ' - + '`count_distinct` measure beside any dimension over a joined object. The raw-SQL path serves ' - + 'every one of these, so each such query grouped by such a dimension goes from answered to ' - + 'refused. On a host whose `queryCapabilities` offers raw SQL with no engine aggregate bridge ' - + '(a hand override: the analytics plugin wires both), no strategy remains for a bucketed ' - + 'query, so every newly bucketed query, a plain count included, goes from answered to "No ' - + 'strategy can handle query". The protocol-18 conversion `cube-sub-day-granularities-removed` strips the retired ' - + 'sub-day intervals from every authored and stored cube, so a dimension that offered one ' - + 'sub-day interval and one coarser interval now holds a one-interval list: a default bucket its ' - + 'author never wrote.', - acceptanceCriteria: - 'Every cube time dimension whose `granularities` lists exactly one interval is one you mean to ' - + 'bucket at that interval by default: a query that groups by it on `/analytics/query` answers ' - + 'one row per bucket, and `/analytics/sql` shows the bucketed statement. Every time dimension ' - + 'that should have no default lists two or more intervals or omits the key. No dashboard or ' - + 'report groups by a one-interval dimension a query the engine aggregate path refuses — a ' - + 'custom-SQL measure; a measure, `where` field or `timeDimensions` entry over a joined object; ' - + 'a dimension that traverses more than one relationship; an `avg` or `count_distinct` measure ' - + 'beside a dimension over a joined object — or each one that did now groups by a dimension ' - + 'without a single interval. A host that overrides `queryCapabilities` to raw SQL only either ' - + 'adds an engine aggregate bridge or groups by no one-interval dimension.', - relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, - }, - { - id: 'analytics-date-range-array-two-bounds-required', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'the ARRAY arm of timeDimensions[].dateRange on an analytics query — ' - + 'AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset ' - + 'selection\'s timeDimensions, and any AnalyticsQuery a host passes to ' - + 'AnalyticsService.query in-process — authored with anything other than EXACTLY two ' - + 'string bounds: a one-element window such as ["2026-01-01"], the empty array [], and ' - + 'three or more bounds such as ["2026-01-01", "2026-01-31", "2026-02-28"]', - replacement: - 'exactly two string bounds — `[start, end]`. A ONE-ELEMENT window is that day written as ' - + 'BOTH bounds: `[\'2026-01-01\']` becomes `[\'2026-01-01\', \'2026-01-01\']`, the shape ' - + 'the shipped migration table for the closed preset vocabulary already prescribes for a ' - + 'single day, and the shape all four analytics faces have selected that one day with since ' - + 'the fix that made them read the array arm one way. ⛔ The EMPTY ' - + 'array and THREE-OR-MORE bounds have NO replacement that can be derived from what was ' - + 'written: an empty array names no window at all, and a 3+ array names no pair — decide ' - + 'the window the widget was meant to show and write its two bounds, or drop the ' - + 'dateRange entirely (the field is optional, and absent means the query is not ' - + 'time-bounded). A relative window is a preset name from the closed vocabulary ' - + '(`\'last_7_days\'`) or a date-macro pair (`[\'{7_days_ago}\', \'{today}\']`).', - reason: - 'Maintainer ruling A of 2026-09-12, re-affirmed 2026-09-13, which tightened the array arm ' - + 'to exactly two string bounds: the arm was a bare `z.array(z.string())` with NO length ' - + 'constraint, ' - + 'while the refusal sentence in the same source file said verbatim that "an explicit ' - + 'window is the two-element array [start, end]" and the shipped migration table for the ' - + 'closed preset vocabulary told an author to write a single day as ' - + '`[\'2026-01-20\', \'2026-01-20\']`. So only the TYPE was weaker than the prose beside it, ' - + 'and a measurement of one authored document on each face found what that bought: one ' - + 'authored `[\'2026-01-01\']` meant a point window on ObjectQLStrategy, NO time clause at ' - + 'all on NativeSQLStrategy (the whole of history), an unbounded-above window in the ' - + 'draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the ' - + 'same document, four backends, four different numbers, no error on any of them. The fix ' - + 'that followed made all four faces refuse it with the ADR-0112 envelope `400 ' - + 'ANALYTICS_DATE_RANGE_UNRECOGNIZED`, which left the contract door LOOSER than every ' - + 'reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and ' - + 'no stored-metadata rewrite, deliberately: rewriting `[\'2026-01-01\']` to the same day ' - + 'twice at load would be the platform deciding, silently, that the author meant one day ' - + 'rather than a window whose end they forgot — and for the empty array and 3+ bounds ' - + 'there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored ' - + 'dashboard carrying a now-refused range loses that widget with the accurate refusal ' - + 'shown, and the dashboard still loads. Since that fix every such stored range already ' - + 'failed at QUERY time with the same code and status, so this adds no new class of ' - + 'breakage — it moves the refusal to authoring time and states it accurately. ' - + 'ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep every authored `timeDimensions[].dateRange` ARRAY — dashboard widget datasets, saved ' - + 'analytics queries, SDK / MCP callers, in-process `AnalyticsService.query` calls — and ' - + 'count its bounds. Two string bounds parse byte-identically to before, as do every preset ' - + 'name and an absent `dateRange`; anything else now answers one prescriptive issue at ' - + '`timeDimensions.N.dateRange` naming the arity it received, so `AnalyticsQuerySchema.' - + 'safeParse` and `POST /analytics/query` both make the sweep mechanical. ⚠️ Do not trust ' - + 'the numbers a one-element window used to produce: the four analytics faces disagreed ' - + 'about what it meant, so a widget that showed a plausible figure may have been reading ' - + 'all of history on one backend and a single day on another. Re-check what each converted ' - + 'widget was supposed to show against its two explicit bounds.', - }, - { - id: 'analytics-query-window-non-negative-integer', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'the row window of an analytics query — limit and offset on AnalyticsQuerySchema, the ' - + 'POST /analytics/query and /analytics/sql bodies, and a dataset selection ' - + '(POST /analytics/dataset/query) — authored as a negative number (limit: -1, offset: -1), ' - + 'a fraction (limit: 1.5), or an integer above Number.MAX_SAFE_INTEGER', - replacement: - 'a non-negative integer, or no key at all: delete `limit` to return every row (a ' - + '`limit: -1` written to mean "no limit" is exactly that), write `limit: 0` only for no ' - + 'rows, and delete `offset` (or write `offset: 0`) to skip nothing; a fraction becomes the ' - + 'integer page size that was meant. An `offset` with no `limit` stays valid and returns ' - + 'every row after the offset, on SQLite and PostgreSQL alike', - reason: - 'Both members were a bare `z.number()`, and every value outside the non-negative integers ' - + 'answered differently per driver and per face. Measured at POST /api/v1/analytics/query on ' - + 'SQLite and PostgreSQL 16, through the real dispatcher route: `limit: -1` returned every row ' - + 'on the native SQLite face, a 500 on native PostgreSQL and all but the last row on the ' - + 'ObjectQL face; `limit: 1.5` answered a 500, two rows and one row; `offset: -1` a 500 on ' - + 'both native drivers and every row on the ObjectQL face. No value had one answer, so the ' - + 'contract refuses them instead of any engine guessing (contract first, ADR-0049): the two ' - + 'members are `z.number().int().nonnegative()` on `AnalyticsQuerySchema`, the dataset ' - + 'selection reads the same two declarations off its shape, and the runtime doors answer the ' - + 'ADR-0112 envelope `400 VALIDATION_FAILED` naming `limit` or `offset` before any engine ' - + 'runs. ⚠️ No D2 conversion and no stored-metadata rewrite: the window is a QUERY-time ' - + 'request field, not a `sys_metadata` shape, and the refused values meant different windows ' - + 'on different backends, so coercing one would be the platform guessing which the author ' - + 'meant. The one stored producer that lowers into a selection, a dashboard widget\'s ' - + '`limit`, is already declared a positive integer. Measured in this repository at the ' - + 'change: no example, fixture, document or published skill authors a negative or ' - + 'fractional analytics window. ADR-0049 / ADR-0112.', - acceptanceCriteria: - 'Grep every authored analytics `limit` and `offset` — saved analytics queries, SDK and MCP ' - + 'callers, dataset selections, and queries a host builds in-process — and rewrite each ' - + 'negative or fractional value as described. POST /analytics/query and /analytics/sql answer ' - + '`400 VALIDATION_FAILED` with `details.fields[].field` naming `limit` or `offset`, and ' - + 'POST /analytics/dataset/query names `selection.limit` or `selection.offset`, so a sweep is ' - + 'mechanical; `AnalyticsQuerySchema.safeParse` reports the same issue at the member. ' - + 'Non-negative integer windows parse byte-identically to before, `limit: 0` still answers ' - + 'no rows, and absence stays absence. The service does not parse a query passed to it ' - + 'in-process, so a host that builds one parses it with `AnalyticsQuerySchema` first. A ' - + 'query that carried a refused value was never returning one window, so re-check what the ' - + 'widget was meant to show rather than trusting the old result set.', - }, - // #21409 (ADR-0049 enforce-or-remove) — the row wildcard `'*'` is admitted only - // where a `count` consumes it: a cube measure under `type: 'count'` and a dataset - // measure under `aggregate: 'count'`. A cube dimension's `sql` takes the column - // path without the wildcard arm (the dataset dimension's pattern since - // `dataset-member-field-expression-refused`), and both measure slots ask one - // shared predicate. Semantic only, with no D2 conversion: such a member never - // produced an answer, and there is no lossless rewrite — `count` changes the - // figure the author asked for, and a column is the author's to name. - { - id: 'analytics-row-wildcard-outside-count-refused', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span AND a table cell. - surface: - 'analyticsCubes[].measures..sql, analyticsCubes[].dimensions..sql and ' - + 'datasets[].measures[].field (data.MetricSchema.sql / data.DimensionSchema.sql / ' - + 'ui.DatasetMeasureSchema.field) authored as the row wildcard * where no count consumes it — a cube ' - + 'measure whose type is anything but count, any cube dimension, and a dataset measure whose ' - + 'aggregate is anything but count or that declares none (a derived measure)', - replacement: - 'what the member meant. A row count: `type: \'count\'` on a cube measure or `aggregate: \'count\'` ' - + 'on a dataset measure, keeping `\'*\'` (a dataset count may also omit `field`). An aggregate of ' - + 'values: the column it aggregates — a field of the object (`amount`) or a relationship path ' - + 'ending in one (`account.amount`). A cube dimension: the column it groups by; to count rows, ' - + 'declare a `count` measure instead. A `derived` measure: delete the `field` key, which nothing ' - + 'read — a derived measure combines other measures by name', - reason: - '`\'*\'` is the row wildcard a `count` aggregates (`COUNT(*)`): it reads no field value, so no ' - + 'other aggregate has a column to read over it, and a dimension has no aggregate at all. The ' - + 'contract nevertheless admitted it in a cube member\'s `sql` on any measure and on a dimension, ' - + 'and in a dataset measure\'s `field` under any aggregate, and the analytics strategies passed it ' - + 'to the database as written. Measured at POST /api/v1/analytics/dataset/query over a real ' - + 'SQLite driver, on the native-SQL and the ObjectQL strategy alike: a dataset measure aggregating ' - + '`\'*\'` under `sum`, `avg`, `min`, `max` or `count_distinct` answered 500 DATABASE_ERROR — a ' - + 'server fault for an authoring mistake the contract had admitted. A dataset measure compiles to ' - + 'the cube measure it names verbatim, so the same reading covers an authored cube measure; a ' - + 'dimension over `\'*\'` (GROUP BY *) was measured the same way when the dataset dimension was ' - + 'narrowed. Such a member never produced an answer, so no working document changes meaning: the ' - + 'failure moves from the query to the authoring parse, which names the slot and the aggregate and ' - + 'prescribes a `count` or a column. There is no D2 conversion: rewriting to `count` would change ' - + 'the figure the author asked for, and only the author knows which column a sum over `\'*\'` was ' - + 'meant to read. A STORED document is not rewritten: a metadata read still serves it as stored, ' - + 'with the refusal on its read diagnostics, and a re-save through the metadata write door is ' - + 'refused at the slot. The dataset query door parses every dataset it is handed, inline or saved, ' - + 'so a stored dataset carrying such a measure is refused 400 VALIDATION_FAILED on EVERY query — ' - + 'including a query that selects only its other measures, which used to answer: it fails closed ' - + 'until the member is fixed. An authored cube reaches the analytics runtime through the stack ' - + 'definition, whose parse refuses it when the stack is built. In-repo census before the change: no ' - + 'example, platform object, doc, skill or fixture authored one, and neither did objectui at the ' - + 'pinned commit; deployed metadata was NOT measured. ADR-0021 / ADR-0049 / ADR-0087', - acceptanceCriteria: - 'Every analytics cube and dataset parses: `CubeSchema`, `DatasetSchema`, the analytics_cube and ' - + 'dataset write doors, defineCube and defineStack refuse `\'*\'` on a cube measure whose `type` is ' - + 'not `count` and on a dataset measure whose `aggregate` is not `count` (at its `sql` / `field`, ' - + 'code custom), and on a cube dimension (at its `sql`, code invalid_format), each naming the slot ' - + 'and prescribing a `count` or a column, so the sweep is mechanical — parse each document, and each ' - + 'refusal is one member to change. For each changed member, a query that selects it returns a ' - + 'figure instead of a 500, and a dashboard bound to a stored dataset that carried one answers ' - + 'again on every widget. A `count` over `\'*\'`, a dataset count with no `field`, and every member ' - + 'that names a column parse byte-identically to before and run on both strategies.', - }, - { - id: 'analytics-time-dimension-date-range-vocabulary-closed', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'the bare-STRING arm of timeDimensions[].dateRange on an analytics query — ' - + 'AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset ' - + 'selection\'s timeDimensions, and any AnalyticsQuery a host passes to ' - + 'AnalyticsService.query in-process — authored as anything other than one of the ' - + 'thirteen declared date-range preset names (today, yesterday, this_week, last_week, ' - + 'this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, ' - + 'last_30_days, last_90_days): the display spelling "Last 7 days" the schema comment used ' - + 'to show, the driver-memory dialect "last N days" / "last 3 months", or a bare ISO date ' - + 'such as "2026-01-20" (the SQL strategies\' single-day dialect)', - replacement: - 'a preset name from the closed vocabulary — `\'last_7_days\'` for "Last 7 days" / "last 7 ' - + 'days", `\'last_30_days\'`, `\'this_month\'`, and so on (`DATE_RANGE_PRESETS` in ' - + '`@objectstack/spec/data` is the list; the rejection prints it) — or, for an explicit ' - + 'window, the two-element array the array arm always accepted: `[\'2026-01-20\', ' - + '\'2026-01-20\']` for the single day a bare ISO string used to mean on SQL, ' - + '`[\'2026-01-01\', \'2026-01-31\']`, or `[\'{7_days_ago}\', \'{today}\']` in date-macro tokens', - reason: - 'Maintainer ruling of 2026-09-06 on the analytics date-range string (option A — contract ' - + 'first): the protocol is the baseline, so the vocabulary is declared once in the schema and ' - + 'the drivers align to it, in a driver change of their own, instead of each guessing. The ' - + 'arm was a bare `z.string()` ' - + 'whose only documented example, `"Last 7 days"`, no driver could parse: driver-memory ' - + 'recognised exactly `today` and a case-sensitive `last N ` and fell every other ' - + 'string through to a `[range, range]` pseudo-window that — measured through mingo on ' - + '2026-09-05 — matched EVERY `Date`-typed row, 2099 included, because a `Date` compares ' - + 'above a `String` under BSON cross-type ordering; the SQL strategies read the same string ' - + 'as a single ISO day. A dashboard asking for one week silently got all of history on one ' - + 'backend and one day on the other, with no error on either. The string arm is now ' - + '`z.enum(DATE_RANGE_PRESETS)` — derived from `data/date-range-presets.ts`, the vocabulary\'s ' - + 'single source of truth since the dashboard date filter\'s three copies of the list were ' - + 'folded into it, so the two cannot drift — and any other string is ' - + 'refused at parse time with one prescriptive issue at the field\'s own path; the runtime ' - + 'door answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` ' - + '(`api/error-code-ledger.zod.ts`). ⚠️ No D2 conversion and no stored-metadata rewrite: ' - + 'this value is a QUERY-time request field, not a `sys_metadata` shape, and the two ' - + 'retired dialects meant different windows on different backends, so coercing one would ' - + 'be the platform guessing which the author meant. Measured in this repository at the ' - + 'ruling: three authored `\'Last 7 days\'`, all in spec tests, and no published dashboard ' - + 'authors the string arm at all (the shipped console lowers presets to the array arm). ' - + 'ADR-0049 / ADR-0112.', - acceptanceCriteria: - 'Grep every authored `timeDimensions[].dateRange` string — dashboard datasets, saved ' - + 'analytics queries, SDK / MCP callers, in-process `AnalyticsService.query` calls — and ' - + 'rewrite each bare string that is not one of the thirteen preset names: a relative ' - + 'phrase to its preset (`\'last_7_days\'`), an ISO day to the two-element array ' - + '`[day, day]`. `POST /analytics/query` now answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` ' - + 'naming the value and the vocabulary, so a sweep is mechanical; `AnalyticsQuerySchema.' - + 'safeParse` reports the same issue at `timeDimensions.N.dateRange`. Preset names and ' - + '`[start, end]` arrays parse byte-identically to before. A query that carried one of the ' - + 'retired spellings was never returning the window it named (all rows on driver-memory, ' - + 'one day on SQL), so re-check what the widget was supposed to show rather than trusting ' - + 'the old result set.', - }, - { - id: 'api-assembled-entry-split', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span. - surface: - 'api.AssembledInstalledPackageSchema / api.InstalledPackageAtEitherStageSchema / ' - + 'api.ListInstalledPackagesResponseSchema / api.GetInstalledPackageResponseSchema / ' - + 'api.PackageApiContracts, with the types AssembledInstalledPackage, InstalledPackageAtEitherStage, ' - + 'ListInstalledPackagesResponse, GetInstalledPackageResponse and their Parsed twins — imported from ' - + '@objectstack/spec/api', - replacement: - 'the same names, unchanged, imported from `@objectstack/spec/api-assembled` — change the import ' - + 'path and nothing else. Every schema parses and refuses exactly what it did, the route map has the ' - + 'same four entries, and the JSON Schema ids are unchanged (`json-schema/api/AssembledInstalledPackage.json` ' - + 'and its three siblings are still published under `api/`). Every OTHER Package API declaration — ' - + 'the request schemas of both read doors, the install / uninstall / upgrade / rollback shapes, ' - + '`PackageApiErrorCode` — stays on `@objectstack/spec/api`.', - reason: - 'Maintainer ruling of 2026-09-17, option B (narrow the entry, rather than add a bundle-weight ' - + 'rule to the browser-reachability ledger or accept the weight as it stood): split the API entry ' - + 'so its browser-facing half no longer carries the assembled-package declarations. Those five embed ' - + 'the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the ' - + 'datasource declaration and the driver-config validators; declared inside `@objectstack/spec/api`, ' - + 'that tree was part of every bundle of the entry, and a browser module importing two string ' - + 'constants from it paid for all of it. Measured on the splitting PR: that module (objectui ' - + '`@object-ui/core` column-sortability) bundles to 166,529 bytes gzipped instead of 311,124. The ' - + 'split moves an import path, which is TypeScript source rather than metadata — nothing authors, ' - + 'stores or parses it — so there is no source a D2 conversion could rewrite, and the move is ' - + 'recorded here.', - acceptanceCriteria: - 'No code imports any of the five names, their types or their Parsed twins from ' - + '`@objectstack/spec/api` — each such import is a TS2305 "has no exported member" error after ' - + 'upgrade (TS2724 with a did-you-mean when a similarly named export exists; the suggested name is ' - + 'a different schema, not the replacement), and at runtime the binding is undefined. The same ' - + 'names import cleanly from ' - + '`@objectstack/spec/api-assembled`. No metadata document, stored row or JSON Schema reference ' - + 'needs editing: the schemas and their published ids did not change.', - }, - // #15677 (stack card 2/6 of #14478, maintainer ruling B: a duration key - // carries its unit in its NAME) — the D3 entry of the - // `api-endpoint-cache-ttl-to-cache-ttl-seconds` family (ruling B on #17152: - // one D3 entry per retirement family, even when D2 is lossless). The card's - // other eleven keys have no conversion and carry their own D3 entries; this - // one has both. - { - id: 'api-endpoint-cache-ttl-unit-in-key', - surface: 'apis[].cacheTtl — the response-cache lifetime of a declared API endpoint', - replacement: '`cacheTtlSeconds` — the same lifetime, in seconds, with the unit in the key name. It ' - + 'still applies to GET endpoints only.', - reason: 'The D2 conversion `api-endpoint-cache-ttl-to-cache-ttl-seconds` renames `cacheTtl` to ' - + '`cacheTtlSeconds` in the `apis` collection and on stored endpoint rows, keeping the value, ' - + 'and the rename is lossless: the key always meant seconds. The judgment is whether the ' - + 'author knew that. The unit lived only in the description, on the same endpoint surface ' - + 'where `rateLimit.windowMs` spells its unit in milliseconds, so a value written in ' - + 'milliseconds — `cacheTtl: 60000` meant as one minute — cached responses for almost ' - + 'seventeen hours, and the rename carries 60000 over unchanged. A cache that lives a ' - + 'thousand times longer than intended serves stale data long after the underlying records ' - + 'change, with no error anywhere. Only the author can say which unit each value was written ' - + 'in.', - acceptanceCriteria: 'No endpoint carries `cacheTtl`; the parse refuses it with the rename. Every ' - + '`cacheTtlSeconds` value is the cache lifetime the author intends in seconds — an endpoint ' - + 'meant to cache for one minute reads `cacheTtlSeconds: 60`. A GET to the endpoint repeated ' - + 'inside that window is answered from the cache, and one repeated after it reflects a record ' - + 'changed in between.', - relevantWhen: { kind: 'stack-declares', keys: ['apis'] }, - }, - { - id: 'api-error-retry-after-unit-in-key', - surface: 'EnhancedApiError.retryAfter (api/errors.zod.ts) — the ADR-0112 error envelope on the wire', - replacement: 'retryAfterSeconds — rename the key; the value (seconds) is unchanged', - reason: - 'Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' - + 'BREAKING ON THE WIRE, and ruled in deliberately: its 2026-09-05 population ruling puts the ' - + '~16 runtime-emitted ' - + 'measurements in scope because they are read by humans and agents even if nobody authors ' - + 'them, and names ApiError.retryAfter explicitly, with its own BREAKING note. The ambiguity ' - + 'here is sharper than the usual bare duration. A consumer meets TWO retry-after values on ' - + 'the same 429 response: this envelope field, which has always been delta-seconds, and the ' - + 'HTTP Retry-After header, which per RFC 9110 section 10.2.3 may carry EITHER delta-seconds ' - + 'OR an HTTP-date. Spelled identically, they read as one value in two places; spelled ' - + 'retryAfterSeconds, the envelope states its own unit and the header keeps its own rules. ' - + 'THE HTTP HEADER IS A SEPARATE, UNCHANGED SURFACE — its name is fixed outside this repo and ' - + 'nothing in this rename touches it. Do not "fix" the header to match, and do not read a ' - + 'green grep for `retry-after` in transport code as leftover work. A SEMANTIC entry rather ' - + 'than a D2 conversion because an error envelope is emitted, never stored: it is not a stack ' - + 'collection member and never a sys_metadata row, so the conversion chain has no seam that ' - + 'would see one. ADR-0087, ADR-0112.', - acceptanceCriteria: - 'No producer emits `retryAfter` on an ApiError envelope and no consumer reads it; the old ' - + 'spelling is a retiredKey() tombstone that fails tsc at the construction site and fails the ' - + 'parse with the rename prescription. Concretely, check three things. (1) Code building a ' - + 'rate-limit error body: rename the key to `retryAfterSeconds`, value unchanged. (2) Client ' - + 'code backing off on a 429: read `error.retryAfterSeconds`. (3) The transport layer is NOT ' - + 'part of this change — a handler setting the `Retry-After` response header, and a client ' - + 'reading it (including the HTTP-date branch), keep the RFC 9110 spelling and are correct as ' - + 'they stand. A local rate-limiter decision object of the shape { allowed, retryAfter } is ' - + 'not this key either: it is not an ApiError envelope and is untouched.', - }, - { - id: 'api-runtime-config-durations-unit-in-key', - surface: 'two api-layer runtime configuration durations whose name carried no unit: ' - + 'DataLoaderConfig.cacheTtl (api/contract.zod.ts) and RouteDefinition.timeout ' - + '(api/router.zod.ts)', - replacement: 'cacheTtlSeconds (seconds) and timeoutMs (milliseconds) — rename each key; both ' - + 'values are unchanged', - reason: - 'Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' - + 'Two keys on two shapes, in one entry because they share a disposition and an audience: ' - + 'both are api-layer runtime configuration a host or plugin builds in code, and neither is ' - + 'part of a published metadata document. DataLoaderConfig.cacheTtl named seconds only in its ' - + 'describe on a batching config whose other numbers are counts (maxBatchSize, ' - + 'maxConcurrency); RouteDefinition.timeout said "Execution timeout in ms" in prose and ' - + 'nothing else. Both are retiredKey() tombstones — neither shape is strict, so a bare ' - + 'deletion would strip the old key in silence and an unknown-key error could not carry the ' - + 'rename. Why a semantic entry and not a D2 conversion: a DataLoaderConfig is a per-request ' - + 'batch-loader construction argument and a RouteDefinition is a router registration built by ' - + 'a plugin at start — neither is a stack collection member and neither is ever stored, so the ' - + 'chain has no seam that would run on them (the kernel/Manifest:loading precedent). Worth ' - + 'knowing while grepping: packages/runtime declares its OWN local RouteDefinition interface ' - + 'for the ai:routes hook payload — a different type with no duration key at all, untouched by ' - + 'this rename. ADR-0087.', - acceptanceCriteria: - 'Every DataLoaderConfigSchema.parse(…) and RouteDefinitionSchema.parse(…) site spells ' - + '`cacheTtlSeconds` / `timeoutMs`; authoring either old spelling fails to compile (input type ' - + '`never`) and fails to parse with the rename prescription naming the suffixed key. A ' - + 'DataLoader configured with `cacheTtlSeconds: 60` expires per-request cache entries after 60 ' - + 'seconds exactly as `cacheTtl: 60` did, and its `min(0)` bound rides along, so a negative ' - + 'TTL is still refused. A route declared with `timeoutMs: 30000` aborts after 30 seconds ' - + 'exactly as `timeout: 30000` did.', - }, - { - id: 'approval-position-address-role-retired', - surface: - 'approvals position address role: — the approverId filter of the approvals ' - + 'request list, the actorId of every decision, and a stored pending_approvers slot', - replacement: - '`position:`, the one spelling of a position address; a flow approver authored ' - + 'as `{ type: \'role\', value: }` becomes ' - + '`{ type: \'position\', value: }`', - reason: - 'The fourth face of the ADR-0090 D3 `role` retirement, beside ' - + '`actor-user-roles-to-positions` and `action-session-roles-to-positions`, and like them ' - + 'a runtime face with no spec schema. The approvals service read `role:` as a ' - + 'second spelling of `position:` wherever it compares a slot with the caller ' - + '(the "My Pending" filter, the participant gate, `viewer.can_act`, and the slot test of ' - + 'every decision), because 15.x-era slots and the stock console\'s identity list carried ' - + 'it. ADR-0090 D3 retires the word with no alias window, so once the pinned console sent ' - + '`position:` the arm came out in one edit (maintainer ruling, 2026-10-04). ' - + '`position:` is now the only position address: a `role:` ask matches ' - + 'only a slot stored under that exact spelling, and a `role:` actor is refused ' - + 'with 403 `FORBIDDEN`. ' - + 'The same ruling closed the one WRITER of the spelling. The deprecated `role` approver ' - + 'TYPE already resolved as `org_membership_level` (the org-membership tier: owner, admin, ' - + 'member), but when that lookup found no one the fallback slot kept the AUTHORED spelling, ' - + '`role:`, and a holder of a same-named position decided it through the arm, so ' - + 'the runtime silently honoured a membership-tier declaration as a position. The fallback ' - + 'now writes the canonical `org_membership_level:`, and no path writes a `role:` ' - + 'slot. ' - + 'Two classes of pending request are therefore decided only by the privileged override, ' - + 'or by a reassign to a real approver: a request a 15.x-era release stored as ' - + '`role:`, and a new request from a flow that still authors ' - + '`{ type: \'role\', value: }` and whose tier lookup finds no one. No ' - + 'stored slot is rewritten: the ruling refused a one-time rewrite as the permanent ' - + 'migration debt ADR-0090\'s first forcing fact names. ' - + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds. ' - + 'FIRST, no metadata key moves: the address is runtime DATA (a request slot, a query ' - + 'parameter, a decision body\'s actor), never a `sys_metadata` row, so there is no source ' - + 'for a declarative transform to rewrite. SECOND, the one authored shape that leads here, ' - + '`{ type: \'role\', value: }`, is ambiguous by construction: the deprecated alias ' - + 'means the membership TIER, and whether its author meant a position instead is a ' - + 'judgment only that author can make, so a mechanical rewrite to either type would guess. ' - + 'The `ApproverType` `role` alias itself is a separate retirement and is unchanged here. ' - + 'ADR-0090 D3, ADR-0087.', - acceptanceCriteria: - 'No client sends `role:` as an `approverId` or an `actorId`: every such value is ' - + '`position:`, and a request routed to the position is listed for its holder, ' - + 'served with `viewer.can_act: true`, and decided by that holder with no `actorId` named. ' - + 'Every flow approver authored as `{ type: \'role\', value: }` is reviewed by its ' - + 'author: `` a position name becomes `{ type: \'position\', value: }`, and `` a ' - + 'membership tier (owner, admin, member) becomes ' - + '`{ type: \'org_membership_level\', value: }`. `os lint` reports the first as ' - + '`approval-approver-not-membership-tier` and the second as ' - + '`approval-approver-type-deprecated`. Every pending request whose slot reads ' - + '`role:`, or `org_membership_level:` from such a flow, is ' - + 'either decided by a platform admin or a tenant admin of its organization (the approve ' - + 'or reject is recorded with `via_override: true` and resumes the run) or reassigned to ' - + 'the position\'s holder, who then decides it. Verify on a running app, as an admin: the ' - + 'pending list filtered by `approverId` set to each such slot address answers no rows.', - }, - // #15219 — maintainer ruling A for both keys (2026-09-04, director relay, - // verbatim 「同意」): `plugins` and `devPlugins` are artifact ENVELOPE keys — - // top level only, never inside `packages[]`. Registered as D3 SEMANTIC and - // deliberately NOT as a D2 conversion, on the D2 scope guard (lossless only): - // a plugin declared inside an assembled package body has no lossless target. - // `plugins` is `z.array(z.unknown())` and may hold live instances that never - // survived JSON in the first place, and hoisting a serialisable entry to the - // artifact top level changes WHO loads it (the host, not a package) — a - // judgment the author makes. Nothing is retired: both keys stay declared on - // the stack schema at the top level, so no RETIRED_KEYS entry, and the - // refusal inside a body is the manifest's own strict close naming the key. - { - id: 'assembled-package-body-plugins-envelope', - surface: - 'artifact `packages[].manifest.plugins` and `packages[].manifest.devPlugins` — the two ' - + 'keys inside an ASSEMBLED package body (`AssembledPackageBodySchema`, ADR-0130 D4)', - replacement: - 'Declare `plugins` / `devPlugins` at the stack TOP LEVEL only — the artifact envelope, ' - + 'where `os serve` / `os migrate` / `os dev` read them and where `composeStacks` still ' - + 'concatenates them (`concat` is unchanged for in-memory composition). Delete both keys ' - + 'from every `packages[i].manifest` body: a multi-package artifact that carried them is ' - + 'rebuilt from source (`os build` / `composeStacks(…, { manifest: \'preserve\' })` no ' - + 'longer folds them into a body), and a hand-written `packages[]` entry drops them.', - reason: - 'A classification error, not a new special case (maintainer ruling A, 2026-09-04: both ' - + 'keys are artifact envelope keys, top level only, never inside `packages[]` — decided ' - + 'while one artifact was being taught to carry several co-owning packages). ' - + '`plugins` and `devPlugins` were the only members of the assembled-body key set whose ' - + 'values are runtime ASSEMBLY instructions rather than serialisable metadata: `plugins` ' - + 'holds what a host hands to `kernel.use()` — live plugin instances, manifests or package ' - + 'names — and `devPlugins` is the `os dev` load list. Inside an artifact a package body is ' - + 'inert JSON, so a plugin written under `packages[i].manifest` could never be constructed ' - + 'by any loader; every reader (`serve.ts`, `schema-migration-plugins.ts`) reads the top ' - + 'level, and the "resolve `packages[]` when the top level is absent" repair every other ' - + 'reader took would have turned a silent skip into a boot that registers garbage. Options ' - + 'B (readers resolve JSON descriptions into live plugins) and C (the emitter special-cases ' - + 'the two keys) were refused. After the ruling, "an artifact carries metadata, a host ' - + 'assembles plugins" is one sentence every reader inherits. Not losslessly convertible: ' - + 'hoisting a body-level plugin to the envelope changes who loads it, and a live instance ' - + 'has no JSON form to move.', - acceptanceCriteria: - 'No `packages[i].manifest` in any artifact carries `plugins` or `devPlugins`; the body ' - + 'schema refuses either with `unrecognized_keys` naming the key (pinned in ' - + '`assembled-package-body.test.ts`), and `artifact-packages.ts` refuses the entry at ' - + 'load with that path. A stack declaring `plugins` / `devPlugins` at the top level ' - + 'parses byte-identically to before, `composeStacks` still concatenates both in stack ' - + 'order, and `manifest: \'preserve\'` emits package bodies without them. An existing ' - + 'multi-package artifact that carried a body-level `plugins` is rebuilt from source.', - }, - { - id: 'audience-posture-default-invite-only', - surface: 'system.AuthConfig.audience', - replacement: - "explicit `auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '' } }` " + - '(deployments that intend open self-registration only)', - reason: - 'The default audience posture flipped when one declared posture replaced the emergent ' + - 'self-registration default: an UNDECLARED `audience` now means ' + - '`invite_only` — email/password self-registration (and social-provider JIT sign-up) is ' + - 'refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. ' + - 'Previously the emergent default was open self-registration with no email verification. ' + - 'Whether a deployment truly means to admit strangers (public portal) or was open only by ' + - 'accident is a security judgment no transform can make — and a posture that opens ' + - 'self-registration must also DECLARE the permission set a self-registrant receives and ' + - 'accepts forced email verification, neither of which can be invented mechanically.', - acceptanceCriteria: - 'A deployment that relies on open self-registration declares `audience.posture` ' + - "('open', or 'email_domain' with `allowedEmailDomains`) plus `selfRegistrationPermissionSet`, " + - 'and its sign-up flow still works end to end (verification email delivered, registrant holds ' + - 'the declared permission set). Every other deployment verifies operators can still add users ' + - '(invitation, admin create-user / import, SCIM, or an operator-registered identity provider) ' + - 'and that anonymous sign-up now answers 403 SELF_REGISTRATION_CLOSED.', - }, - { - id: 'automation-flow-list-route-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'GET /api/v1/automation — the flow-list route of the automation door, together with ' - + 'its request and response schemas ListFlowsRequestSchema and ListFlowsResponseSchema ' - + '(and their ListFlowsRequest, ListFlowsRequestParsed, ListFlowsResponse and ' - + 'ListFlowsResponseParsed types), FlowSummarySchema and its FlowSummary type, the ' - + 'listFlows entry of AutomationApiContracts, and the automation.list method of ' - + '@objectstack/client. Every other automation route is unchanged, including ' - + 'POST /api/v1/automation (create a flow) at the same path', - replacement: - 'GET /api/v1/meta/flow — flows are metadata (ADR-0106), and this is the governed read of ' - + 'them; from the SDK it is `client.meta.getItems` with the type `flow`. It answers the ' - + 'full flow definitions rather than bare names, so a caller that only needs the names ' - + 'maps each item to its `name`. The runtime enablement and trigger binding of every flow ' - + '— the one piece of engine state a definition does not carry — is ' - + '`GET /api/v1/automation/_status` (`client.automation.getRuntimeStatus`), which is ' - + 'unchanged', - reason: - 'Maintainer ruling of 2026-09-25 on the list doors found declaring `limit` / `cursor` and ' - + 'never reading them (this route is door ④; verbatim 「退役,统一走 /meta/flow」, given when ' - + 'asked why the flow list does not use the standard ' - + 'API), under ADR-0049 enforce-or-remove. The ' - + 'route\'s contract described a capability nobody built: ListFlowsRequestSchema declared ' - + '`status`, `type`, `limit` (default 50) and `cursor`, and the handler read none of them — ' - + 'it asked the automation service for its flow names with no arguments at all. ' - + 'ListFlowsResponseSchema declared a page of FlowSummary rows with `total`, `nextCursor` ' - + 'and `hasMore`, and the handler answered a bare array of names beside a literal ' - + '`hasMore: false`. So a caller filtering by status received every flow, a caller paging ' - + 'with a cursor re-read the only page forever, and a caller reading FlowSummary fields read ' - + 'undefined — each with a 200 and no error. ' - + 'Measured before removal, on the main branch of this repository and cloud and on objectui at ' - + 'both its pinned commit and main: zero callers of the route or of the SDK method outside ' - + 'their own tests, while both real flow lists in the product — the Console flow-runs page ' - + 'and the Setup packaged-automation page — already read GET /api/v1/meta/flow. ' - + 'Implementing the declared contract instead would have built a second, weaker metadata list ' - + 'beside the governed one; retiring it leaves one read. ' - + 'There is no alias and no transition window: GET simply stops being mounted there. There is ' - + 'no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ' - + 'ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in ' - + 'RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106.', - acceptanceCriteria: - 'On the composition `objectstack serve` builds, GET is no longer mounted at ' - + '/api/v1/automation (nor at its environment-scoped twin), so the host gives its standard ' - + 'unmatched answer with no residual refusal text of its own. Because POST still lives at ' - + 'that path, on the Hono host that answer is 405 METHOD_NOT_ALLOWED with an Allow header ' - + 'naming POST — the same answer any path where only another verb is registered gets, for ' - + 'anonymous and signed-in callers alike. A transport that forwards every automation path ' - + 'to the dispatcher is told the domain does not handle it and answers its own not-found ' - + '404 (the @objectstack/hono catch-all does), and there the domain\'s anonymous floor still ' - + 'answers an unidentified caller 401 first, as it does for every automation path. The ' - + 'automation ' - + 'service\'s flow-name enumeration is never called by any HTTP request. The route-ledger row ' - + 'for the route is gone, AutomationApiContracts has eight entries and none of them is a GET ' - + 'at the bare path, and a TypeScript import of any of the removed schemas or types is a ' - + 'compile error (TS2305). @objectstack/client no longer declares automation.list, so a call ' - + 'to it is a compile error rather than a request to a path that no longer answers. ' - + 'POST /api/v1/automation still creates a flow, and every other automation route — the ' - + 'single-flow reads and writes, trigger, toggle, clone, runs, resume, cancel, ' - + 'restore-suspension, screen, _status and the actions and connectors catalogs — answers ' - + 'exactly as before.', - }, - { - id: 'automation-runs-cursor-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'api.listRuns cursor — the pagination query parameter of ' - + 'GET /api/v1/automation/:name/runs declared by ListRunsRequestSchema, its slot on ' - + 'IAutomationService.listRuns, and its option on all three @objectstack/client run-list ' - + 'surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). ' - + 'The limit parameter of the same door is NOT part of this retirement and is unchanged, ' - + 'default(20) included', - replacement: - 'a wider `limit` — this door does read it, bounded to 1..100, and it is spent as the run ' - + "store's history window. There is no replacement for `cursor` itself, deliberately: " - + 'nothing ever minted one, so no caller holds a value to carry over, and the response ' - + '`nextCursor` it would have paired with has never been emitted. Read the response ' - + '`hasMore` to learn whether the window was short — it is now computed from the engine ' - + 'rather than the constant `false` it used to be, so for the first time it answers the ' - + 'question a caller reaching for a cursor was actually asking', - reason: - 'ADR-0049 enforce-or-remove (maintainer ruling 2026-09-21 on the list doors found ' - + 'declaring `limit` / `cursor` and never reading them — this door is door ①, and the ' - + 'ruling took letter C of three for it; letter A — build a cursor ' - + 'protocol for a 100-row window — and letter B — retire the key and leave the ' - + '`hasMore` lie standing — were both considered and refused). `cursor` was declared on ' - + 'the request, VALIDATED at the boundary, forwarded into a `cursor?: string` slot on ' - + 'the service contract, and read by no implementation: the engine never looked at the ' - + 'option, and no emit site has ever written the response half `nextCursor`, so a caller ' - + 'looping until the cursor ran out re-read the first and only window forever with no ' - + 'error. ' - + '⭐ The `limit` half of this door was NOT retired, and the distinction is the ruling, ' - + 'not an oversight. The sibling `/packages` door retired its `limit` with its `cursor` ' - + '(the 2026-09-13 ruling aligning that door\'s declaration with its reads: pagination is ' - + 'no part of a small bounded list) because nothing read it; the parent ruling ' - + 'explicitly does not transfer here. On this door `limit` is read end to end — the ' - + 'boundary enforces the declared 1..100 range off the schema itself, the service takes ' - + 'it as an option, and the engine spends it as `RunStore.listHistory`\'s window — and ' - + "the Console's flow-runs page sends it today. Retiring it would have been a " - + 'regression, and its `.default(20)` stays with it. ' - + 'The same card computes `hasMore`, which is the half a bare retirement would have left ' - + 'lying. `GET /api/v1/automation/:name/runs` shipped a literal `hasMore: false` beside a ' - + 'list the engine had already truncated with `.slice(0, limit)`, so a caller asking for ' - + 'one row of a thousand was handed one row and told that was all of them. The engine ' - + 'now reports truncation to the door through a new optional contract member, ' - + '`IAutomationService.listRunsPage`, which returns `{ runs, hasMore }`: it over-reads ' - + 'its history source by exactly one row and compares the merged, filtered, ordered set ' - + 'to the caller\'s window. The over-read is what makes the answer sound — ' - + '`runs.length === limit` cannot tell a flow with exactly `limit` runs from one with ' - + 'ten thousand, and `RunStore.listHistory`\'s signature is deliberately unchanged ' - + 'because over-reading is expressible in the `limit` it already takes. ' - + 'There IS a tombstone: the request schema is non-strict, so a bare deletion would have ' - + 'made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and ' - + "a parameter that never takes effect, which is this card's own defect re-created one " - + 'layer down (ADR-0104). `cursor` is therefore a `retiredKey()`, typed `never` for tsc ' - + 'and raising the prescription at any parse, and is registered in ' - + 'RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an ' - + 'authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody ' - + 'authors a `ListRunsRequest` and nothing persists one. The `os migrate meta` house ' - + 'sentence is therefore correctly absent from the prescription. There is no ' - + '`acceptRetiredDefaultResidue` stage either: `cursor` carried no default, so it ' - + 'materialized into no artifact and there is no residue to accept. ' - + 'The SDK half is part of the retirement rather than a follow-up: `@objectstack/client` ' - + 'declared `cursor` and appended it on all three run-list surfaces, so retiring the key ' - + 'in the schema alone would have left the one generated client this repo ships typing it ' - + '`string` and sending it into a route that silently drops it — the ADR-0104 shape the ' - + 'tombstone exists to prevent, re-created one layer down. The same call was made when ' - + 'the notifications `cursor` was retired: the client dropped the option and recorded ' - + 'the removal in its docblock. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No caller sends `cursor` to `GET /api/v1/automation/:name/runs`, and that is true of every ' - + 'channel this repo ships rather than of the schema alone. Writing it on a ' - + '`ListRunsRequest` is a `tsc` error (the input type is `never`), and any value reaching a ' - + 'parse raises the prescription rather than a generic unrecognized-key issue. The option is ' - + 'gone from `IAutomationService.listRuns`, so an implementation can no longer declare a slot ' - + 'for it. ⭐ It is also gone from the SDK, which is the channel most callers actually reach ' - + 'this door through: `@objectstack/client` no longer declares `cursor` on ' - + '`automation.runs.list`, `automation.listRuns` or ' - + '`client.environment(id).automation.listRuns`, and no longer appends `?cursor=` on any of ' - + 'the three — so the key cannot be smuggled past the retired schema by an untyped caller. ' - + 'Without that half the retirement would have re-created its own defect one layer down: ' - + 'the schema typing the key `never` while the shipped client typed it `string` and sent it, ' - + 'silently dropped by a route that no longer reads it (ADR-0104). ' - + '⚠️ ONE wire behaviour CHANGES and must be verified as such, because it reverses a ' - + 'decision recorded when this door\'s query parameters were first validated where they are ' - + 'read (the fix for `?limit=abc` reaching the engine as `NaN`): a repeated ' - + '`?cursor=a&cursor=b` used to answer ' - + '`400 VALIDATION_FAILED` with a `details.fields[]` entry naming `cursor`, and now ' - + 'answers `200` with the key ignored like any other unrecognised query name. That fix ' - + 'validated the key rather than deciding it, so that a future cursor implementation ' - + 'would not be the one to discover the type was unenforced; this ruling decides it ' - + 'instead — there will be no cursor implementation on this door — so the refusal would ' - + 'be validating a key the contract no longer has. This route declares no closed query ' - + 'set (AGENTS.md route-ownership rule 5), so an unrecognised name has never been refused here ' - + 'on its own account. ' - + '⚠️ `hasMore` also changes, from a constant to an answer: a request whose window is ' - + 'shorter than the matching run set now receives `hasMore: true` where it previously ' - + 'received `false`. A caller that treated `false` as "this is the whole history" was ' - + 'always wrong and is now told so. ⚠️ Read the new `false` with one qualification: ' - + 'unfiltered it is exact, but under `?status=` it means "no further match inside the ' - + 'window that was scanned" rather than "none exists", because the durable history ' - + 'source has no status slot and the window is taken before the filter is applied. ' - + 'Pushing the filter down is a store-contract change this card did not scope. ' - + '`nextCursor` stays absent — nothing mints one — and ' - + '`limit` behaves exactly as it did, including its `.default(20)`. ' - + 'A deployment whose automation service does not implement `listRunsPage` answers `501` ' - + 'naming the member, and ⛔ never a `200` carrying an invented `hasMore`.', - }, - { - id: 'autonumber-default-unique-organization', - surface: '`fields..unique` on a `type: \'autonumber\'` field when the author OMITS the key — ' - + 'the contract default moves from `false` (no index) to `\'organization\'` (one holder per ' - + 'organization; the NULL-safe tenant-composite unique index ' - + '`(COALESCE(organization_id, \'__global__\'), )` on an organization-scoped object, a ' - + 'plain unique index where the object has no organization key)', - replacement: 'keep the omission to take the default — an auto-number is a business identifier and ' - + 'is unique per organization from now on with zero application-side declaration; write ' - + '`unique: false` EXPLICITLY on the one autonumber field that is a display-only sequence and ' - + 'is never used to identify the record. Every other field type keeps `unique: false` as its ' - + 'default, and every authored spelling (`true` / `\'organization\'` / `\'global\'` / `false`) ' - + 'parses exactly as before', - reason: - 'Not losslessly convertible because the change is data-dependent, not textual: a table that ' - + 'already holds duplicate auto-numbers (a counter that re-issued a burned number, or the ' - + 'seed/API tenancy split running two counters for one object) cannot take the index the ' - + 'default now declares. The SQL driver refuses to silently degrade — it logs at `error` ' - + 'naming the index, the columns and the remedy, the same boot\'s drift pass names the ' - + 'conflicting key groups with row counts, and `os migrate plan` reports the blocked ' - + '`create_index` with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps ' - + 'the number is a business decision no migration entry can make. Maintainer ruling ' - + '2026-08-31, on a downstream CRM\'s measurement that eight of its nine auto-numbered ' - + 'business identifiers could be issued twice: an auto-number that may repeat is not an ' - + 'identifier, so unique is the platform default and opting out is the declaration, not ' - + 'the other way round.', - acceptanceCriteria: - 'Every `autonumber` field without an authored `unique` parses to `unique: \'organization\'` ' - + '(`FieldSchema.parse({ type: \'autonumber\' }).unique === \'organization\'`, and through ' - + '`ObjectSchema` the same); an authored `unique: false` on an autonumber field parses to ' - + '`false`; every non-autonumber field type without an authored `unique` still parses to ' - + '`false` at the same key position. On a serving boot, each organization-scoped object with ' - + 'such a field carries `uniq__organization_id_`; a table whose data blocks it ' - + 'shows the blocked `create_index` with its conflicting groups in `os migrate plan` until ' - + 'the rows are deduplicated and the plan is re-run, and `os migrate duplicates` lists the ' - + 'holder rows of any value minted across organization partitions.', - }, - { - id: 'branded-identifier-schemas-retired', - surface: - 'the six branded identifier schemas of `@objectstack/spec/shared` ' - + '(`shared/branded-types.zod.ts`, removed whole): `ObjectNameSchema`, ' - + '`FieldNameSchema`, `ViewNameSchema`, `AppNameSchema`, `FlowNameSchema`, ' - + '`RoleNameSchema`, and their type exports (`ObjectName`/`ObjectNameParsed` ' - + 'through `RoleName`/`RoleNameParsed`).', - replacement: - '(removed — no replacement brand layer. Parse an identifier through the ' - + 'schema of the surface that stores it: object and field names through ' - + '`ObjectSchema`/`FieldSchema` (inline snake_case regex), flow names ' - + 'through `FlowSchema`, app names through `AppSchema` ' - + '(`SnakeCaseIdentifierSchema`), position/role names through ' - + '`PositionSchema`. A caller that wants a standalone identifier check ' - + 'uses `SnakeCaseIdentifierSchema` or `SystemIdentifierSchema` from ' - + '`@objectstack/spec/shared` directly — both stay published.)', - reason: - 'Maintainer ruling 2026-09-01 (director decision batch C, ' - + 'verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The brands ' - + 'promised compile-time safety ("you cannot pass an ObjectName where a ' - + 'FieldName is expected") that no consumer could obtain: no schema in ' - + 'either repository ever composed a brand, so nothing produced or ' - + 'accepted a branded value, while the surfaces the brands were named for ' - + 'are validated by inline regexes or bare `SnakeCaseIdentifierSchema` ' - + 'three files away. Binding was weighed and not adopted: zero consumers ' - + 'exist, binding would silently change five surfaces\' accept sets (the ' - + 'inline regexes admit a leading underscore the brand base does not), ' - + 'and a future real need for centralized identifier grammar re-opens ' - + 'freely against actual pull.', - acceptanceCriteria: - 'No code imports any of the six schemas or their types from ' - + '`@objectstack/spec/shared` (TS2305 after upgrade — the module is ' - + 'removed, not stubbed); the five surfaces\' validators are byte-for-byte ' - + 'untouched (inline regexes at `data/object.zod.ts`, `data/field.zod.ts`, ' - + '`automation/flow.zod.ts`; bare `SnakeCaseIdentifierSchema` at ' - + '`ui/app.zod.ts`, `identity/position.zod.ts`); ' - + '`SnakeCaseIdentifierSchema` and `SystemIdentifierSchema` themselves ' - + 'remain published and unchanged; the six def keys (`shared/ObjectName`, ' - + '`shared/FieldName`, `shared/ViewName`, `shared/AppName`, ' - + '`shared/FlowName`, `shared/RoleName`) leave ' - + '`json-schema.manifest/shared.json` in the same change that registers ' - + 'this entry. No authored metadata document ever embedded a branded ' - + 'value, so no source rewrite ships and `objectstack migrate meta` has ' - + 'nothing to visit.', - }, - // A write-door answer and verdict, not an authorable key: there is no D2 - // conversion and nothing for `objectstack migrate meta` to rewrite. The entry - // carries the changed answer to the one reader the ledger serves here — the - // upgrade guide — because a client that branched on the old 403 has no schema - // error to find it by. No backticks in `surface`: the upgrade guide renders it - // inside a code span and a table cell. - { - id: 'by-id-write-unreadable-row-not-found', - surface: - 'the data write doors — a by-id update or delete of a row the caller cannot read, on every ' - + 'object and for every principal', - replacement: - 'read 404 `RECORD_NOT_FOUND` on a by-id update or delete as "no row you can see has this id" — ' - + 'the read door\'s meaning — and keep a 403 for a row the caller can read but may not write', - reason: - 'A WRITE-DOOR ANSWER, made one with the read door\'s. A by-id update or delete of a row the ' - + 'caller cannot read used to answer a 403 — `PERMISSION_DENIED` where a write-class row filter ' - + 'binds the caller, otherwise a later gate\'s own 403, such as `FORBIDDEN` from record sharing ' - + 'or a parent-derived gate\'s code on attachments and comments — while an id that names no row ' - + 'answered 404, so the write door told a hidden row apart from a missing one. The by-id write ' - + 'pre-image check now asks every principal whether it can read the row it addressed, through ' - + 'a by-id read in its own context that every data middleware\'s visibility applies to, and ' - + 'answers a row that read does not return with the read door\'s not-found: the same code, ' - + 'status and body a nonexistent id gets. It also refuses a by-id write a principal no row ' - + 'filter binds could previously land on a row hidden from it, such as an attachment\'s ' - + 'uploader or a comment\'s author whose parent record they can no longer read. A caller who ' - + 'can read the row but may not write it keeps its 403. Writes the platform issues under the ' - + 'caller\'s context — the engine\'s cascade delete, a hook\'s write, the referential clear of a ' - + 'lookup — keep their previous answer, and writes not routed by id are unchanged.', - acceptanceCriteria: - 'Every client that handles a by-id update or delete treats 404 `RECORD_NOT_FOUND` as "not ' - + 'found or not visible" and no longer reads a 403 there as proof the row exists; an operator ' - + 'who needs a user to write a row grants that user read access to it first.', - }, - { - id: 'cache-warmup-scheduled-strategy-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: - "CacheWarmup.strategy — the value 'scheduled' left the warmup-strategy enum " - + '(packages/spec/src/system/cache.zod.ts), and the enum describe stopped promising ' - + '"scheduled (cron)". The key itself, DistributedCacheConfig.warmup.strategy, is ' - + 'unchanged and still authorable', - replacement: - "'eager' to warm at startup or 'lazy' to warm on first access — the two strategies " - + 'the vocabulary ever described without pointing outside itself. There is no ' - + 'replacement for the cadence: a warmup on a schedule is a job. Declare a `job` with ' - + 'schedule.expression (system/job.zod.ts) whose handler does the warming — that is ' - + 'the one cron slot this platform evaluates, and it is the slot deliberately kept ' - + 'when the seven cron-typed positions nothing read were deleted', - reason: - 'ADR-0049 enforce-or-remove, closing the residue the retirement of the seven cron-typed ' - + 'positions nothing reads left inside the schema it had just edited. That retirement ' - + 'deleted CacheWarmup.schedule — the cron key this enum member ' - + 'selected — and declined the member itself on the reading that it is "a value, not a ' - + "position this ruling names\". That is a statement about the ruling's SCOPE, not a " - + 'finding that the value was sound: after the deletion the member declared a warmup ' - + 'cadence with no key left to configure it, no engine that has ever run one, and a ' - + '.describe() still promising "(cron)" — ADR-0049 declared-not-enforced in the form ' - + 'Prime Directive 10 names outright, a capability advertised that the runtime does ' - + 'not deliver. Re-measured on main at 690f083f83 with a lit control rather than ' - + 'inherited from the card: CacheWarmupSchema has zero runtime consumers outside its ' - + 'declaring file (six files reference it — the generated reference page import, the ' - + 'declaration-map and export-origins catalogues, the ADR-0058 D7 ledger comment and ' - + 'two spec test files — while the control, ConnectorSchema, resolves to 46 files), ' - + 'and no cache-warmup engine exists anywhere on the platform. Bookkeeping follows the ' - + "hot-reload-inert-state-strategies-retired and crypto.hash precedents: an enum-VALUE " - + 'narrowing puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and ' - + 'leaves the four surface ratchets byte-identical (no def changed, and they key on ' - + "positions and names, never on a def's value set), so the prescription hangs on the " - + "enum's own error map dispatched by issue.input — telling the author of a TYPO that " - + 'their value "was removed" would misinform. It is a SEMANTIC entry rather than a D2 ' - + 'conversion because there is no source to rewrite: CacheWarmup is bound to no ' - + 'metadata type and embedded in no stack collection, so no authored document and no ' - + 'stored row has ever carried this value, and os migrate meta has nothing to list. ' - + 'Route 3 of the retirement playbook, the shape of the dynamic plugin-loading family\'s ' - + 'removal and the advanced plugin-lifecycle config\'s retirement: this entry IS the ' - + 'declaration. ADR-0049, ADR-0087.', - acceptanceCriteria: - "No configuration passes strategy: 'scheduled' to CacheWarmupSchema or to " - + 'DistributedCacheConfigSchema.warmup. TypeScript callers cannot: ' - + "CacheWarmup['strategy'] is now 'eager' | 'lazy', so the literal is a compile error " - + 'at the authoring site. Callers that arrive as JSON get a parse REFUSAL — not the ' - + 'silent strip the cron-position retirement left for ' - + 'the schedule key beside it, because a narrowed enum ' - + 'rejects rather than drops — carrying the prescription, which names the job route. ' - + 'Concretely, check two places. (1) Any host or deployment config embedding a ' - + 'DistributedCacheConfig: a warmup block selecting the retired strategy now fails to ' - + 'parse where it previously parsed green; change it to eager or lazy. (2) Anything ' - + 'that was waiting on the cadence to take effect: it never did. No warmup has ever ' - + 'run on a schedule on this platform, so migrating the value changes no runtime ' - + 'behaviour whatsoever — what changes is that the contract stops promising it. If a ' - + 'scheduled warmup is genuinely wanted, it comes back through the ENFORCE leg of ' - + 'ADR-0049: the engine first, the declaration with it, never as a bare enum row ' - + 'again.', - }, - { - id: 'cbp-master-detail-required-forced', - surface: 'object.fields..required on a `master_detail` reference under ' - + '`sharingModel: \'controlled_by_parent\'` — authored via `ObjectSchema.create()`', - replacement: '`required: true` on the master reference (or nothing at all — the builder now ' - + 'forces `required: true` when the key is omitted). An explicit `required: false` on that ' - + 'shape is refused at `ObjectSchema.create()` with a located error carrying this same ' - + 'prescription. Metadata at rest is untouched: raw `.parse()`/`.safeParse()` still accept ' - + 'the old shape, the security gate\'s derived enforcement stays, and the lint rule ' - + '`relationship/master-detail-required` stays `warning` until its own v18 promotion ' - + '(Direction 1 of the 2026-08-16 maintainer ruling whose Direction 2 this is)', - reason: - 'A `controlled_by_parent` detail derives ALL of its record access from the master that its ' - + '`master_detail` reference names (ADR-0055). With the reference not `required`, an insert ' - + 'may omit the master FK: the row lands with a null FK that the derived read filter ' - + '`masterFK IN (accessible master ids)` can never match — unreadable by everyone — and ' - + 'every later by-id write answers `422 MISSING_REQUIRED_FIELD`. The finding behind the ' - + 'ruling measured that only ' - + 'the security gate closed this shape while the declaration surface still accepted it. ' - + 'The maintainer ruling (2026-08-16, Direction 2) makes the unsafe shape impossible to ' - + 'NEWLY declare at the builder; whether to keep `required: false` was never a real choice ' - + '(the value contradicts the sharing model), so the flip is forced rather than convertible ' - + '— and an explicitly authored `false` is refused rather than silently rewritten ' - + '(ADR-0032 "no silent failure").', - acceptanceCriteria: - 'Every `ObjectSchema.create()` call declaring `sharingModel: \'controlled_by_parent\'` ' - + 'either omits `required` on its `master_detail` reference(s) (now emitted as ' - + '`required: true`) or declares `required: true` explicitly; the authored app builds and ' - + 'boots. An explicit `required: false` there fails the build with ' - + '`ObjectSchema.create(...): field ... declares required: false on a master_detail ' - + 'reference under sharingModel: controlled_by_parent`. Stored metadata keeps loading ' - + 'byte-identically (`safeParse` green, `required` unrewritten).', - }, - { - id: 'cbp-master-detail-required-lint-error', - surface: 'object.fields.MASTER.required / .readonly / .system, where MASTER is a ' - + '`master_detail` reference on an object declaring `sharingModel: \'controlled_by_parent\'` ' - + '— as judged by `os lint` under `relationship/master-detail-required`', - replacement: '`required: true` on every `master_detail` reference of a `controlled_by_parent` ' - + 'object, with neither `readonly: true` nor `system: true` on it: declare the master ' - + 'reference as an ordinary required field. `os lint` now reports each of the three unsafe ' - + 'shapes there — `required` absent or `false`; `required: true` + `readonly: true`; ' - + '`required: true` + `system: true` — at `error` under `relationship/master-detail-required`, ' - + 'so `os lint` exits non-zero and the metadata-generation rubric marks the stack invalid. On ' - + 'every other object the rule is unchanged: a `warning` for a `master_detail` without ' - + '`required: true`, and no finding for the two flagged shapes.', - reason: - 'A `controlled_by_parent` detail derives ALL of its record access from the master that its ' - + '`master_detail` reference names (ADR-0055). Record validation never checks a field that is ' - + 'not `required`, and skips `readonly` and `system` fields before its required check is ' - + 'reached, so on these three shapes nothing but the security gate refuses an insert that ' - + 'omits the master FK. A record that lands without it anyway is readable by nobody — the ' - + 'derived read filter `masterFK IN (accessible master ids)` never matches null — and every ' - + 'later by-id write is refused. Before this step the lint predicate was `required !== true` ' - + 'at `warning` on every object: the two flagged shapes drew no finding at any severity, and ' - + 'the third drew a warning that an author or a generator could ignore. The maintainer ruling ' - + 'of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary as a deliberate ' - + 'narrowing of the authoring contract. Its builder half (the ' - + '`cbp-master-detail-required-forced` entry) forces `required: true` at ' - + '`ObjectSchema.create` but never inspects `readonly` or `system`, so two of the three shapes ' - + 'still pass the builder and meet their first authoring-time refusal here, and the third ' - + 'still reaches it from any object not authored through the builder. Runtime tolerance is ' - + 'unchanged on purpose: the security gate keeps refusing these inserts, and keeps resolving ' - + 'the master for metadata already at rest.', - acceptanceCriteria: - '`os lint` reports no `relationship/master-detail-required` finding at `error`: on every ' - + 'object declaring `sharingModel: \'controlled_by_parent\'`, each `master_detail` reference ' - + 'declares `required: true` (or omits it and is authored through `ObjectSchema.create`, ' - + 'which emits `required: true`) and carries neither `readonly: true` nor `system: true`. ' - + '⚠️ WHICH DOOR: the refusal is `os lint`\'s and the metadata-generation rubric\'s only. The ' - + 'rule is not in the authoring-rule registry, so `os build`, `os validate` and the metadata ' - + 'save door do not run it, and a stack carrying the shape still builds and publishes — read ' - + '`os lint`\'s exit code, not a green build. Stored metadata is not rewritten and keeps ' - + 'loading, and the security gate still refuses an insert that omits the master FK on these ' - + 'shapes. Repo census at the time of the change: 129 authored objects across the example ' - + 'apps, the platform and plugin objects and the CLI\'s golden eval corpus, 7 of them ' - + '`controlled_by_parent`, 0 findings at `error`.', - }, - // The CEL-lowering face of the list-comparand refusal: the pushdown compiler - // every row-level policy and declared sharing rule compiles through, plus the - // driver-mongodb face that answered the lowered shape. Recorded as its own entry - // because the surface an author rewrites is a CEL predicate string, and on - // MongoDB a stored query filter. - { - id: 'cel-predicate-list-comparand-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a ' - + 'CEL predicate comparing a field with != or == against a list, either a list literal or a ' - + 'current_user membership set the runtime resolves to an array (org_user_ids, positions, ' - + 'accessible_org_ids, or a key staged into rlsMembership), and the negation of such a ' - + 'comparison. On driver-mongodb, also a query filter carrying $ne with an array comparand, at ' - + 'any depth under $and / $or / $not', - replacement: - 'the list operator the comparison was standing in for. "One of these values" is in: ' - + 'record.status in ["open", "pending"], or record.reviewer_id in current_user.org_user_ids. ' - + '"None of these values" is the negated in: !(record.status in ["closed", "archived"]). In a ' - + 'query filter, $in and $nin. Scalar != and ==, null, in, and field-to-field comparisons lower ' - + 'exactly as before', - reason: - 'The @objectstack/formula pushdown compiler lowered such a comparison to a $ne carrying the ' - + 'array, to a bare-array equality, or to a $not around one. A row-level using clause is ' - + 'composed into the query after the engine\'s comparand-shape check, and driver-mongodb passed ' - + 'the shape to the server: measured through mingo, the named proxy for MongoDB query ' - + 'semantics, $ne against an array and the $nor that a negated equality becomes selected every ' - + 'row storing a scalar, so the read returned the rows the policy was written to hide. A check ' - + 'written != against a membership set admitted and stored every write, on driver-sql as on ' - + 'driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS ' - + 'compiler drops the policy and fails closed when no other policy applies: reads under it ' - + 'return no rows and check writes ' - + 'are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and ' - + 'never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a ' - + 'membership set holds its value only per request, so that form is refused at request time. ' - + 'driver-mongodb refuses $ne with an array comparand with INVALID_FILTER / 400, as driver-sql ' - + 'and driver-memory already do. Metadata AT REST is not rewritten and this entry adds no D2 ' - + 'conversion: the platform cannot tell which list operator a list comparison was standing in ' - + 'for, and a policy rewritten on the author\'s behalf would change which rows it admits, which ' - + 'is the policy author\'s decision. ADR-0058 D4 / ADR-0087.', - acceptanceCriteria: - 'Grep the rowLevelSecurity using and check predicates of your permission sets, and the ' - + 'condition of your sharing rules, for != or == whose other side is a list literal or a ' - + 'current_user membership set, and for the negation of such an ==, then rewrite each with in ' - + 'or its negation. On driver-mongodb, ' - + 'grep stored query filters for $ne with an array value and rewrite each with $nin.', - }, - // Stage 2d of #19886: the comparisons ruling A's letter (a list under != and in - // the equality slot) did not name, each measured admitting and storing writes a - // row-level check was written to refuse. Two faces refuse them: the CEL pushdown - // compiler every row-level policy and declared sharing rule compiles through, - // and the @objectstack/formula evaluator the write check runs. Recorded as its - // own entry because the surface an author rewrites is a CEL predicate string. - { - id: 'cel-predicate-one-value-comparand-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a ' - + 'CEL predicate whose comparison is handed something other than one value: an ordering ' - + 'operator (>, >=, <, <=) against a list literal or a current_user membership set; an in ' - + 'list with a member that is itself a list; a comparison with no field at all against a ' - + 'membership set (current_user.org_user_ids != "x"); an ordering operator against the ' - + 'current_user root or a key that resolves to an object; and a field compared with another ' - + 'field (==, !=, or an ordering operator) where either column holds a list or an object on ' - + 'the record, as a json column or a multiple lookup does. In a filter passed to ' - + 'matchesFilterCondition, also $gt / $gte / $lt / $lte with an array, and $in / $nin with an ' - + 'array member', - replacement: - 'the comparison the predicate was standing in for. "One of these values" is in: ' - + 'record.status in ["open", "pending"], or record.reviewer_id in current_user.org_user_ids; ' - + '"none of these values" is !(record.status in ["closed", "archived"]), with the list flat. ' - + 'An ordering takes one bound: record.status > "m", and a range is two comparisons joined by ' - + '&&. A comparison against the caller names one key: record.reviewer_id > current_user.id. ' - + 'A field compared with a json or multiple field has no pushdown form: compare with a ' - + 'single-valued column, or move the condition into a validation rule or hook. One-value ' - + 'comparisons, flat in lists, and field-to-field comparisons between single-valued columns ' - + 'lower and evaluate exactly as before', - reason: - 'Ruling A of 2026-09-24 refused a list under != and in the equality slot, holding both to ' - + 'the declared comparand — a literal or a `{ $field }` reference; stage 2d closes the ' - + 'same fault one position over, measured through the real plugin-security on driver-sql and ' - + 'driver-memory. !(record.status in [["closed", "archived"]]) lowered to a negated $in whose ' - + 'only member was a list, which the strictly comparing write-check evaluator matched on no ' - + 'record, so the negation admitted and stored every write, and driver-memory returned every ' - + 'row on a read. record.status > ["m"] compared the list as the string "m". ' - + 'current_user.org_user_ids != "x" and current_user.org_user_ids > "a" folded to "no ' - + 'restriction": every write admitted and every row read. record.reviewer_id > current_user ' - + 'compared the whole caller object as a string. record.status != record.tags, with tags a ' - + 'json or multiple field, matched every post-image, so the check admitted and stored every ' - + 'write. The CEL compiler now refuses the first four with reason unsupported, so the RLS ' - + 'compiler drops the policy and fails closed when no other policy applies (reads return no ' - + 'rows, check writes are refused 403, the analytics read scope is the deny scope) and a ' - + 'declared sharing rule is skipped at bootstrap; the authoring lint reports what the source ' - + 'shows (a list literal, the current_user root). The compiler cannot see a column\'s type, so ' - + 'the last is refused by the write-check evaluator on the record whose compared column holds ' - + 'a list or an object: INVALID_FILTER / 400, nothing stored. Metadata AT REST is not ' - + 'rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison a ' - + 'predicate was standing in for, and rewriting it on the author\'s behalf would change which ' - + 'writes and rows it admits, which is the policy author\'s decision. ADR-0058 D4 / ADR-0087 / ' - + 'ADR-0112.', - acceptanceCriteria: - 'Grep the rowLevelSecurity using and check predicates of your permission sets, and the ' - + 'condition of your sharing rules, for an ordering operator next to a list or to ' - + 'current_user alone, for an in list that nests a list, for a comparison with no record ' - + 'field against a current_user membership key, and for a field compared with a json or ' - + 'multiple field; rewrite each as the replacement says. Then re-check what each policy is ' - + 'supposed to admit rather than assuming what it admitted before was right: several of these ' - + 'admitted every write, and two folded to no restriction at all.', - }, - // The variable-ROOT sibling of cel-predicate-list-comparand-refused, one - // comparand kind over: the same pushdown compiler, the same consumers, the same - // fail-closed path. Recorded as its own entry because the surface an author - // rewrites is a different comparand, with a different replacement. - { - id: 'cel-predicate-variable-root-comparand-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a ' - + 'CEL predicate comparing with != or == against the bare current_user root, the variable with ' - + 'no key named after it, whether the other side is a field or a literal, and the negation of ' - + 'such a comparison. For a caller of the published compiler that binds its own variables, also ' - + 'a variable that resolves to an object', - replacement: - 'the key of current_user the comparison means: record.owner_id == current_user.id, or ' - + 'current_user.organization_id, or current_user.email. A membership test is in: ' - + 'record.owner_id in current_user.org_user_ids. Scalar keys, membership sets under in, ' - + 'literals, null and field-to-field comparisons lower exactly as before', - reason: - 'The @objectstack/formula pushdown compiler resolved the bare root to the whole caller context ' - + 'object, every kernel-resolved key at once with the membership arrays included, and lowered ' - + 'the comparison to a $ne carrying that object, to a bare-object equality, or to a $not around ' - + 'one; a constant comparison such as current_user != "guest" folded to no restriction. A ' - + 'strict compare never equals an object, so through the real SecurityPlugin on driver-sql a ' - + 'check written != against the root, or its negated ==, admitted and stored every insert and ' - + 'by-id update it was written to refuse, a USING-only such policy admitted every insert on ' - + 'the write pass, and explain reported the read as narrowed with the caller membership sets ' - + 'echoed in its readFilter. ADR-0058 D2 declares the operand opposite a field as a literal, a ' - + 'current_user scalar or a pre-resolved current_user set, and the published $eq / $ne ' - + 'contract declares a literal or a { $field } reference; the root is none of them. The ' - + 'compiler now refuses it with reason unsupported in both of its modes, so the authoring lint ' - + 'reports it (rls-predicate-unenforceable on either clause, sharing-rule-unlowerable-condition ' - + 'on a sharing condition), and the RLS compiler drops the policy and fails closed when no ' - + 'other policy applies: reads under it return no rows, check writes are refused 403, and ' - + 'explain answers denies. A declared sharing rule with such a condition is skipped at ' - + 'bootstrap as it already was, now with reason unsupported instead of unresolved-variable. ' - + 'Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot ' - + 'tell which key the author meant, and a policy rewritten on the author\'s behalf would change ' - + 'which rows it admits. ADR-0058 D2 / ADR-0087.', - acceptanceCriteria: - 'Grep the rowLevelSecurity using and check predicates of your permission sets, and the ' - + 'condition of your sharing rules, for != or == whose other side is current_user with no key ' - + 'after it, then rewrite each against the key it means (current_user.id, ' - + 'current_user.organization_id or current_user.email), or with in against a membership set.', - }, - { - id: 'change-management-duration-keys-retired', - surface: - 'change-management duration keys: `ChangeImpact.downtime.durationMinutes`, ' - + '`RollbackPlan.steps[].estimatedMinutes`, ' - + '`ChangeRequest.implementation.steps[].estimatedMinutes`', - replacement: - 'nothing to re-declare — delete the keys. No change-management engine exists on the ' - + 'platform: nothing schedules a maintenance window, executes or times an implementation ' - + 'or rollback step, or compares an estimate with what happened, so there is no live ' - + 'mechanism to declare a duration to', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' - + '(ruled A: retire per ' - + 'family). Three minute-shaped keys, at three nested sites, sat in the exported ' - + 'change-management schemas and in the generated reference docs — an author could write ' - + '`estimatedMinutes: 15` on a rollback step and reasonably expect it to feed a schedule — ' - + 'and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted ' - + 'by no stack key, registered as no metadata type, absent from the 2026-06 liveness ' - + 'ledgers, and the reader census over every package outside `packages/spec` (tests and ' - + 'changelogs excluded) and over objectui at the pinned sha returned zero hits for every ' - + 'key. All three sites are NESTED (`downtime.durationMinutes`, `steps[].estimatedMinutes` ' - + 'twice), so the authorable-surface ratchet — which walks top-level def properties — never ' - + 'listed them; their `RETIRED_KEYS_BY_MAJOR[18]` entries carry the nested spelling for the ' - + 'spec-changes / upgrade-guide projection. Why D3 semantic and not a D2 conversion: the ' - + 'chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type ' - + 'onto one of its collections; none of these schemas is either, so a conversion would be ' - + 'a transform with no seam that ever runs (the ' - + '`kernel/MetadataPluginConfig:additionalTypes` precedent).', - acceptanceCriteria: - 'No `ChangeImpact.downtime` block carries `durationMinutes`, and no implementation or ' - + 'rollback step — in a `RollbackPlan` or inside a `ChangeRequest` — carries ' - + '`estimatedMinutes`. TypeScript authors get the refusal at compile time (each key is ' - + 'typed `never`); a value reaching the parse is refused with the prescription ' - + '(`invalid_type` at the nested path of the key, e.g. `rollbackPlan.steps.0.estimatedMinutes`). ' - + '⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ' - + 'ever read the keys, so removing them removes no behaviour.', - }, - { - id: 'change-management-family-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the change-management family, retired whole: the six defs system/ChangeImpact, ' - + 'system/ChangePriority, system/ChangeRequest, system/ChangeStatus, system/ChangeType ' - + 'and system/RollbackPlan, and every name system/change-management.zod.ts exported from ' - + '@objectstack/spec/system (the six *Schema consts, their z.input aliases and the ' - + 'ChangeRequestParsed alias)', - replacement: - 'nothing to re-declare — no change-management engine exists on the platform, so there is ' - + 'no working configuration to migrate to. Nothing routed a change request for approval, ' - + 'walked its implementation steps, honoured a rollback plan or gated on ' - + '`securityImpact.requiresSecurityApproval` / `approval.required`; a change record the ' - + 'organisation keeps is ordinary object data, declared as an object with its own fields, ' - + 'and an approval that must actually gate something is a flow (ADR-0018) with an approval ' - + 'node. Metadata change tracking on the platform is `sys_metadata` history and the ' - + 'package model (ADR-0126), unrelated to this vocabulary. If ITIL change management ' - + 'becomes a product capability it re-declares fresh, through the enforce route of ' - + 'ADR-0049 — the engine first, the vocabulary second', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' - + 'and defs (ruled A: retire the ' - + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' - + 'integration/ErrorMappingConfig precedent; not roadmapped). Six defs and roughly fifty ' - + 'declared keys sat on the exported surface and in the generated reference docs, and were ' - + 'read by NOTHING: the schemas were exported from `@objectstack/spec/system`, mounted by ' - + 'no `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 liveness ' - + 'ledgers, and the reader census over every package outside `packages/spec` (tests and ' - + 'changelogs excluded), over `examples/**` and `skills/**`, and over objectui at the ' - + 'pinned sha returned zero hits for every exported name, with a lit control. ' - + '`ChangeRequest.approval.required` and `ChangeRequest.securityImpact.requiresSecurityApproval` ' - + 'read as gates the platform enforced, and neither ever did — the worst form of the ' - + 'declared-but-unenforced shape, on a security-adjacent surface. Tagging the family ' - + '`[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take (a human-only ' - + 'signal). The duration-key tombstones of the 2026-09-02 per-family ruling (three nested ' - + 'sites, ' - + '`RETIRED_KEYS_BY_MAJOR[18]`, D3 `change-management-duration-keys-retired`) leave with ' - + 'their defs\' source; their registry entries stay as history. Why D3 semantic and not a ' - + 'D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` ' - + 'maps a metadata type onto one of its collections; none of these schemas is either, so ' - + 'a conversion would be a transform with no seam that ever runs (the ' - + '`kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there ' - + 'is no shape on which a tombstone could sit.', - acceptanceCriteria: - 'No code imports ChangeImpactSchema, ChangePrioritySchema, ChangeRequestSchema, ' - + 'ChangeStatusSchema, ChangeTypeSchema or RollbackPlanSchema — or any of their type ' - + 'aliases — from @objectstack/spec or @objectstack/spec/system: every such import is ' - + 'TS2305 after upgrade, and no working replacement exists to point at because the ' - + 'vocabulary described nothing real. `kernel/MetadataChangeType` (M92 of the type-alias ' - + 'pin, a different declaration with a live consumer) is unaffected. The six defs are ' - + 'absent from `json-schema.manifest/system.json`, the api-surface / declaration-map / ' - + 'export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is ' - + 'deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these ' - + 'shapes, so removing them removes no behaviour.', - }, - // Maintainer decision batch #118 item 2 (ADR-0049 enforce-or-remove) — the D3 - // entry of the `chart-config-aria-removed` family (ruling B on #17152: one D3 - // entry per retirement family, even when D2 is lossless). Registered keys: - // `ui/ChartConfig:aria` and `ui/ReportChart:aria`, over three authored sites. - // The strip changes nothing a screen reader hears; the accessible name the - // author wrote was never announced, and moving it is the author's edit. - { - id: 'chart-config-aria-retired', - surface: 'dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria — ' - + 'the ARIA block on a chart config', - replacement: 'The sibling `description`, which the chart renderer lowers onto the chart graphic ' - + 'as its accessible name (`role="img"` with an aria-label). One accessibility vocabulary per ' - + 'chart node.', - reason: 'The D2 conversion `chart-config-aria-removed` deletes `aria` from every dashboard widget ' - + 'chart config, report chart and report block chart, and the delete is lossless: no chart ' - + 'renderer on either face ever applied the block, so the ARIA attributes it declared never ' - + 'reached the DOM. The residue is accessibility work the author did that no user benefited ' - + 'from. An author who wrote `aria.label` for a chart believed screen-reader users heard that ' - + 'name; they heard the `description` if one was set, and nothing specific if not. The strip ' - + 'deletes the label text along with the key, and only the author can say whether that text ' - + 'should become the chart\'s `description` — a field that other readers of the chart may also ' - + 'show — or whether the existing description already says it.', - acceptanceCriteria: 'No chart config on a dashboard widget, a report or a report block carries ' - + '`aria`; the parse refuses it. Every chart that had carried an `aria.label` has a ' - + '`description` conveying what that label was meant to announce, or the author has confirmed ' - + 'the existing description does. With a screen reader, focusing the chart graphic announces ' - + 'the description as its name.', - relevantWhen: { kind: 'stack-declares', keys: ['dashboards', 'reports', 'pages'] }, - }, - { - id: 'cli-command-contribution-retired', - surface: - 'kernel.cliCommandContribution (the orphan exported schema of ' - + '`cli-extension.zod.ts` — 1 def, 2 exported names: ' - + '`CLICommandContributionSchema` / `CLICommandContribution`)', - replacement: - '(removed — there is no declarative replacement, because no declarative ' - + 'surface ever carried it. CLI commands are registered through oclif\'s ' - + 'native plugin discovery: the plugin package declares an `oclif` ' - + 'section in its own `package.json` — `OclifPluginConfigSchema` in the ' - + 'same module describes that live surface and SURVIVES, as does the ' - + 'module docblock\'s Commander.js migration record, which the ' - + '`manifest.contributes.commands` tombstone cites)', - reason: - 'ADR-0049 enforce-or-remove, applied to the exported orphan-value-schema ' - + 'class: an exported schema with no consumer reads as a capability, the ' - + 'lesson of the plugin sandboxing / integrity / approval config that was ' - + 'never wired to anything. The schema described a "CLI Command Contribution ' - + 'declaration in the manifest" and claimed retention "for describing ' - + 'command metadata in plugin manifests" — but after the retirement of the ' - + 'plugin manifest\'s nine dead `contributes` members tombstoned ' - + '`manifest.contributes.commands` (protocol 18), no manifest surface ' - + 'could legally carry these entries: the export advertised a shape whose ' - + 'only declared carrier rejects it. The manifest never referenced this ' - + 'schema even before the tombstone — its inline `commands` item schema ' - + 'was an independent duplicate. Zero consumers outside spec\'s own test ' - + 'and generated artifacts, measured at the retirement\'s base commit ' - + '(146f448a5) with positive controls in objectstack, objectui (pinned ' - + 'sha) and cloud. With no carrier key and no authored document there is ' - + 'nothing to tombstone and no seam for a D2 conversion: route 3, the ' - + 'shape of the advanced plugin-lifecycle config\'s retirement and of the ' - + 'retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' - + 'declaration.', - acceptanceCriteria: - 'No code imports `CLICommandContributionSchema` or ' - + '`CLICommandContribution` from `@objectstack/spec` or ' - + '`@objectstack/spec/kernel` — both are TS2305 after upgrade, on every ' - + 'public entry (pinned by resolved symbol identity in ' - + '`kernel/cli-command-contribution-retirement.test.ts`). No metadata ' - + 'document needs editing: the def was reachable from no metadata-type ' - + 'binding, stack collection or manifest embed — the only surface that ' - + 'ever claimed to carry command contributions ' - + '(`manifest.contributes.commands`) already rejects the key with its ' - + 'own tombstone prescription, which is unchanged by this retirement. ' - + '`OclifPluginConfigSchema` / `OclifPluginConfig` survive on `./kernel` ' - + '(same pin). ⚠️ Runtime behaviour is deliberately UNCHANGED: the CLI ' - + 'never resolved commands from this declaration — commands are ' - + 'oclif-auto-discovered, before and after.', - }, - { - id: 'client-envelope-convergence-analytics-automation', - surface: - 'client.analytics.query / client.analytics.meta / client.analytics.explain / ' - + 'client.automation.trigger — the resolved value of four published ' - + '`@objectstack/client` methods: the runtime dispatcher\'s ' - + '`{ success, data }` envelope before, its `data` member after', - replacement: - 'the payload — `r.data.X` → `r.X` on all four. `client.analytics.query`, called ' - + 'with a query, now resolves to `AnalyticsResult`: `r.data.rows` → `r.rows`. ' - + '`client.analytics.meta`, with or without a cube name, resolves to ' - + '`AnalyticsMetadataResponse[\'data\']`, the bare cube list: `r.data[0].name` → ' - + '`r[0].name`. `client.analytics.explain` resolves to ' - + '`AnalyticsSqlResponse[\'data\']`, `{ sql, params }`: `r.data.sql` → `r.sql`. ' - + '`client.automation.trigger`, given a trigger name and a payload, resolves to ' - + '`AutomationResult`: `r.data.status` → `r.status` and `r.data.runId` → ' - + '`r.runId` — the same value ' - + '`client.automation.execute` already answered for the same handler. Same call, ' - + 'same wire body, one SDK calling convention', - reason: - '`ObjectStackClient` had two response readers. `unwrapResponse` strips the runtime ' - + 'dispatcher\'s `{ success, data }` envelope and hands back `data`; every other ' - + 'dispatcher-served method already used it, and these four alone ended ' - + '`return res.json()`, so their callers alone had to read `.data`. All four now end ' - + '`return this.unwrapResponse(res)` and their return declarations are the payload ' - + 'types. THE WIRE IS BYTE-IDENTICAL: every route answers exactly the body it ' - + 'answered before, no Zod schema moves, no `packages/spec` declaration moves, no ' - + 'authorable key and no stored representation is involved — the landing diff ' - + 'touches no `packages/spec` path at all — so a raw-HTTP caller is unaffected and ' - + '`objectstack migrate meta` has nothing to rewrite. This is registered rather than ' - + 'exempted because the change is NOT wholly compiler-delivered, and the gap is ' - + 'exact rather than theoretical. For the three analytics methods it is: every old ' - + 'read is `error TS2339: Property \'data\' does not exist on type …`, so tsc names ' - + 'each site. `client.automation.trigger` is the exception — `AutomationResult` ' - + 'itself declares `success: boolean` and `error?: string` ' - + '(`AutomationResult` in `packages/spec/src/contracts/automation-service.ts`, ' - + 'byte-identical at the merge base and at this landing), so `r.success` and ' - + '`r.error` COMPILE ON BOTH SIDES while their meaning moves: before, `r.success` ' - + 'was the envelope\'s flag — always `true` on a resolved call — and `r.error` was ' - + 'never set on a 2xx; now they are the run\'s own, and a refusal the door does not ' - + 'classify as 400 / 409 / 422 is answered 200 carrying `success: false` with ' - + '`error` set. A consumer branching on either reads a DIFFERENT QUESTION at the ' - + 'same spelling, with no diagnostic anywhere. And there is no authored source for ' - + 'the conversion chain to rewrite: this is a published TypeScript surface whose ' - + 'enforced channel is tsc at the call site, and for an untyped JS caller there is ' - + 'no constrained channel at all — `.data` simply reads `undefined` — which is why ' - + 'the ledger entry is the only notification that reaches them. That is the same ' - + 'argument the two sibling entries on this package make ' - + '(`client-delete-result-success`, `client-meta-reset-result-reset`), and this one ' - + 'is the stronger case of the three: those corrected declarations that were ' - + 'UNINHABITED, revealing a defect rather than breaking working code, whereas this ' - + 'moves reads that work today. ⛔ Do not write `r.rows ?? r.data.rows`: there is ' - + 'one producer shape, and a consumer accepting two spellings is what contract-first ' - + 'exists to prevent. The failure path is unchanged and deliberately so — ' - + '`ObjectStackClient.fetch` rejects on every non-2xx BEFORE either reader runs, ' - + 'carrying the ADR-0112 error envelope, and `unwrapResponse` itself never throws. ' - + 'ADR-0087 D3.', - acceptanceCriteria: - 'No code reads `.data` off a `client.analytics.query`, `client.analytics.meta`, ' - + '`client.analytics.explain` or `client.automation.trigger` result. For the ' - + 'three analytics methods `tsc` names every site for a typed caller (TS2339); an ' - + 'untyped JS caller must be swept by hand for the four spellings, because nothing ' - + 'will report it. ⚠️ `client.automation.trigger` needs the hand sweep even WITH a ' - + 'type-checker: every branch on `r.success` or `r.error` off `trigger` has to be ' - + 're-read one by one, because both compile before and after while their subject ' - + 'moved from the envelope to the run. A branch that treated `r.success` as "the ' - + 'call was accepted" now asks "the run succeeded" — the two differ on exactly the ' - + '200-answered refusals — and a `catch`-only error path now has a resolved ' - + '`success: false` sibling it never had to consider. Nothing about the request, the ' - + 'route, the status codes or the thrown error shapes changes, and no server needs ' - + 'upgrading: the value you may now read is the one that was already arriving, one ' - + 'level in. `client.analytics.queryDataset` is NOT part of this move — it is served ' - + 'with no envelope at all and resolved to the bare payload before and after. ' - + 'Populations: in the ObjectStack repo, zero production call sites and the loud ' - + 'test pins that ship with this change; in objectui, one production row-extraction ' - + 'chain that tolerates both spellings today and is tightened to the post-unwrap ' - + 'spelling once this lands; `objectstack-ai/cloud` is NOT MEASURED — a `.data` read ' - + 'on any of the four there is a runtime break after this change, and this entry is ' - + 'the only notice it gets.', - }, - { - id: 'client-meta-reset-result-reset', - surface: - 'client.meta.deleteItem(...).deleted / .type / .name (the return of ' - + '`client.meta.deleteItem()` and the environment-scoped ' - + '`client.environment(id).meta.deleteItem()`)', - replacement: - '`reset` — `r.deleted` → `r.reset`. Same call, same wire body, declared shape. Both ' - + 'twins now declare `DeleteMetaItemResponse` (`@objectstack/spec/api`); `type` and ' - + '`name` have no replacement because the reset door never echoed them — the caller ' - + 'already holds both, it passed them in', - reason: - 'Both `deleteItem` declarations on `@objectstack/client` declared ' - + '`Promise<{ type: string; name: string; deleted: boolean }>` while ' - + '`DeleteMetaItemResponseSchema` declares `{ success, reset?, message? }`. The ' - + 'declaration was not merely imprecise, it was UNINHABITED: ' - + '`DELETE /meta/:type/:name` ends in `res.json(result)` with `deleteMetaItem`\'s ' - + 'return, and not one of that method\'s four return branches carries `type`, `name` ' - + 'or `deleted`. Both surfaces are pure `unwrapResponse` / `_unwrap` passthroughs — ' - + 'and the reset body carries no `data` key, so nothing is stripped — which makes the ' - + 'declaration a CLAIM about the wire, never a rewrite of it, and the claim was false ' - + 'in the one direction that matters: the compiler endorsed a spelling no server has ' - + 'ever sent. `if (r.deleted)` compiled and read `undefined` on EVERY reset, including ' - + 'the ones that really removed an overlay row; `if (r.reset)` was rejected by the ' - + 'compiler and correct on the wire. So this REVEALS a defect rather than breaking ' - + 'working code — every reader of the old key was already reading `undefined`, on ' - + 'every deployment and not just some. The truthful flag also carries the distinction ' - + 'the phantom one could not express at all: `reset: true` means an overlay row was ' - + 'deleted, `reset: false` means none existed and the item was already at its artifact ' - + 'default. Registered as a semantic entry rather than a mechanical conversion for the ' - + 'reason the rewrite does not capture: a call site that branched on `r.deleted` has ' - + 'been taking the FALSE branch unconditionally since it was written, and whatever ' - + 'that branch did — or skipped — is what has to be re-read. There is no authored ' - + 'source for the chain to rewrite either; this is a published TypeScript surface whose ' - + 'enforced channel is tsc at the call site, and for an untyped JS caller there is no ' - + 'constrained channel at all, which is why this entry is the only notification that ' - + 'reaches them. ⛔ Do not write `r.reset ?? r.deleted`: there is one producer shape, ' - + 'and a consumer accepting two spellings is what contract-first exists to prevent. No ' - + 'deprecated `deleted?: boolean` transition key ships, for the same reason — a ' - + 'transition period is for keys that WORKED, and this one never did. The identical ' - + 'correction one door over is `client-delete-result-success`; the wire is ' - + 'deliberately untouched here, per the 2026-08-29 ruling that reality is the ' - + 'contract. ADR-0087.', - acceptanceCriteria: - 'No code reads `.deleted`, `.type` or `.name` off a `client.meta.deleteItem()` / ' - + '`client.environment(id).meta.deleteItem()` result; `tsc` names every site for a ' - + 'typed caller, and an untyped JS caller must be swept by hand because nothing will ' - + 'report it. Nothing about the request, the route, the status codes or the error ' - + 'shapes changes, and no server needs upgrading — the value you may now read is the ' - + 'one that was already arriving. ⚠️ The real work is behavioural: every ' - + '`if (r.deleted)` has been false since it was written, so re-read what each of those ' - + 'branches was supposed to do. Cache invalidation, registry refreshes and UI reloads ' - + 'guarded that way have never run, and switching to `r.reset` turns them ON for the ' - + 'first time — verify that is what you want rather than assuming it restores prior ' - + 'behaviour. Note `reset` is OPTIONAL in the contract and distinguishes two successful ' - + 'outcomes, so `if (r.reset)` and `if (r.success)` are different questions: the former ' - + 'asks whether a row went away, the latter whether the call was accepted. Any test ' - + 'that passed while asserting on `deleted` was asserting on `undefined` and needs ' - + 'rewriting, not renaming.', - }, - { - id: 'client-oauth-applications-delete-void', - surface: - 'client.oauth.applications.delete(clientId) — both halves of what a caller of this ' - + 'published `@objectstack/client` method observes: the DECLARED return, ' - + '`Promise` before and `Promise` after, and the SETTLE BEHAVIOUR, which ' - + 'rejected with `SyntaxError: Unexpected end of JSON input` on every successful ' - + 'delete before and resolves after', - replacement: - 'no value — `void`. There is nothing to move a read TO, because the promise never ' - + 'resolved for a caller to read anything off it. The migration is on the settle ' - + 'path instead: `try { await client.oauth.applications.delete(id); } catch { ' - + '/* it probably worked */ }` → drop the workaround, the `catch` was executing on ' - + 'EVERY successful delete and now executes only on a real failure. A read off the ' - + 'resolved value — `(await client.oauth.applications.delete(id)).deleted` — was ' - + 'unreachable code that has never executed and now stops compiling (TS2339). Same ' - + 'call, same request, same wire body', - reason: - 'The route answers HTTP 200 with a ZERO-BYTE body: `POST {auth}/oauth2/delete-client` ' - + 'returns nothing from its handler, the vendor declares the endpoint `void`, and the ' - + 'response carries `content-type: application/json` with NO `content-length` header ' - + 'at all. The method ended `return res.json()`, so it rejected `SyntaxError: ' - + 'Unexpected end of JSON input` on every successful delete — after the row had ' - + 'already been removed server-side. There was no success path a caller could ' - + 'observe, and the obvious recovery made it worse: the retry failed DIFFERENTLY, ' - + 'with the route\'s 404 `not_found`, because the client was already gone. The method ' - + 'now reads the body as text, returns on the empty case, and still parses (and still ' - + 'throws on) a non-empty one — so the ONLY behaviour that moved is the zero-byte ' - + 'case, which is the defect itself. THE WIRE IS BYTE-IDENTICAL: same route, same ' - + 'request body, same status codes, same error bodies — which on this route are ' - + 'better-auth\'s FLAT `{ error, error_description }`, NOT ObjectStack\'s nested ' - + 'ADR-0112 envelope; no Zod schema and ' - + 'no `packages/spec` declaration moves, no authorable key and no stored ' - + 'representation is involved, so a raw-HTTP caller is unaffected and ' - + '`objectstack migrate meta` has nothing to rewrite. This is registered rather than ' - + 'exempted because the change is NOT compiler-delivered where it matters, and the ' - + 'gap is exact rather than theoretical. The change has two halves and only one of ' - + 'them has a diagnostic. (1) The declared return moves from a ledgered `any` to ' - + '`void`, so a typed caller that read a property off the resolved value now gets ' - + '`error TS2339` — but that read was UNREACHABLE, since the promise never resolved, ' - + 'so the compiler names only code that has never run. (2) The half that DID run on ' - + 'every call — a `try`/`catch` wrapped around the delete — compiles identically ' - + 'before and after, with no diagnostic anywhere, while its `catch` block stops ' - + 'executing. So for the only behaviour that was ever observable, `tsc` names ZERO ' - + 'sites; and for an untyped JS caller there is no constrained channel at all. That ' - + 'is why the ledger entry is the only notification that reaches an upgrader — the ' - + 'same argument the three sibling entries on this package make ' - + '(`client-delete-result-success`, `client-meta-reset-result-reset`, ' - + '`client-envelope-convergence-analytics-automation`). ⚠️ Note the DIRECTION, which ' - + 'is the inverse of the usual break: this does not stop working code from working, ' - + 'it makes a method that could never succeed succeed. The hazard is therefore ' - + 'inverted too — code written to survive a permanent failure is now inert, and any ' - + 'alerting or error budget fed by this method\'s rejections goes quiet. ⛔ Do not ' - + 'keep the old behaviour behind a flag or a wrapper that re-throws: there is one ' - + 'producer shape, and the rejection was never a contract, it was a parse of an empty ' - + 'string. ⛔ Do not synthesise `{ deleted: true }` either: the 200 carries zero bytes ' - + 'and therefore zero information, and "it was already gone" is distinguished on the ' - + 'ERROR channel — a client that is not there answers 404 `{ error: \'not_found\' }`, ' - + 'which `ObjectStackClient.fetch` raises as a throw before the body reader runs — so ' - + 'a synthesised success value would be a shape the wire never sends and strictly ' - + 'less informative than the 404 the caller already receives. ADR-0087 D3.', - acceptanceCriteria: - '⚠️ The real work is behavioural and NOTHING will report it: every `try`/`catch` ' - + 'wrapped around `client.oauth.applications.delete()` has to be re-read one by one, ' - + 'because it compiles identically before and after while its `catch` block goes from ' - + 'running on every successful delete to running only on a real failure. Anything ' - + 'that block did — treating the delete as failed, retrying it (the retry answered ' - + '404 `not_found`, which may itself have been swallowed), skipping post-delete ' - + 'cleanup, cache invalidation, audit writes or a UI refresh, or reporting the delete ' - + 'to a user as failed — is now on the other branch, and the cleanup paths that were ' - + 'skipped run for the first time. Verify that is what you want rather than assuming ' - + 'it restores prior behaviour. Alerting, error budgets and dashboards fed by ' - + '`SyntaxError` rejections from this method drop to zero: that is the fix landing, ' - + 'not an outage. Any test that passed while asserting this call rejects on a ' - + 'successful delete was asserting on the defect and needs rewriting, not renaming. ' - + 'On the type side, no code reads a property off the resolved value; `tsc` names ' - + 'those sites for a typed caller (TS2339), but every one of them was unreachable, so ' - + 'a clean type-check is NOT evidence that the sweep above was done. An untyped JS ' - + 'caller gets no report at all. Nothing about the request, the route, the status ' - + 'codes or the thrown error shapes changes and no server needs upgrading — the ' - + 'server has always answered this way; only the client stopped mis-reading it. ' - + 'Populations, measured at this landing: in the ObjectStack repo, ZERO production ' - + 'call sites — the only references are the pins that ship with this change ' - + '(`oauth-applications-delete.test.ts`, `return-type-precision.test.ts`); in ' - + 'objectui at the pinned `.objectui-sha`, ZERO — neither `oauth.applications` nor ' - + '`delete-client` appears anywhere in that tree; `objectstack-ai/cloud` is NOT ' - + 'MEASURED, and a `catch` there that swallowed this method\'s rejection is now dead ' - + 'code that this entry is the only notice of.', - }, - { - id: 'cloud-subpath-retired', - surface: - '`@objectstack/spec/cloud` — the whole published subpath (`packages/spec/src/cloud/`, ' - + '11 modules, 94 JSON-Schema defs): the cloud control plane\'s own contracts ' - + '(`environment.zod`, `environment-package.zod`, `tenant.zod`, `developer-portal.zod`, ' - + '`marketplace-admin.zod`, `app-store.zod` — 62 defs) and the package & marketplace ' - + 'format (`package.zod`, `package-version.zod`, `marketplace.zod`, `package-l10n`, ' - + '`template-manifest.zod` — 30 defs)', - replacement: - 'Two answers, by owner. (1) The package & marketplace FORMAT moved unchanged to ' - + '`@objectstack/spec/marketplace` (`packages/spec/src/marketplace/`): rewrite the import ' - + 'path — `import { PackageSchema } from \'@objectstack/spec/cloud\'` becomes ' - + '`from \'@objectstack/spec/marketplace\'` — and nothing else; every def, key and JSON ' - + 'Schema is byte-identical under its new `$id` category (`RENAMED_DEFS`, 32 entries). ' - + '`EnvironmentType(Schema)` — the 7-member taxonomy the discovery fold table is total ' - + 'over — is re-declared in `@objectstack/spec/api` (`api/discovery.zod.ts`); the ' - + 'environment-artifact envelope was only ever a re-export and is imported from ' - + '`@objectstack/spec/system`. (2) The cloud control plane\'s contracts have NO ' - + 'open-source replacement: `environment.zod` and `tenant.zod` are re-declared in the ' - + 'cloud repo beside their producer, and `developer-portal.zod`, ' - + '`marketplace-admin.zod`, `app-store.zod`, `environment-package.zod` are deleted outright — ' - + 'zero consumers in any repo (maintainer ruling 2026-09-07, option A: cloud does not host ' - + 'the four consumer-less files, the move deletes them). Recoverable from git ' - + 'history at `d5d8d50db` if a declaration is ever wanted again; that is a new card in the ' - + 'cloud repo, not a re-import.', - reason: - 'Maintainer direction (2026-09-06, verbatim, untranslated): 「我一直觉得 cloud 的协议应该放在云端,' - + '没必要开源」; ruled option B "cut by owner" on 2026-09-07: the control-plane half leaves ' - + 'the open-source spec, the package & marketplace format half stays. ' - + 'The control-plane schemas\' producer and every consumer live in the closed cloud repo — the ' - + 'open-source tree read exactly one type from them (`EnvironmentType`, for the discovery fold ' - + 'table). Leaving them published made the obvious-looking binding of `client.environments.*` ' - + 'to a camelCase `Environment` row compile and read `undefined` at runtime against a ' - + 'snake_case wire (the client SDK\'s cloud methods carried no return annotation and were ' - + 'typed from `any`, ' - + 'and `@objectstack/spec/cloud` declared camelCase rows for a control plane that speaks ' - + 'snake_case); with the declarations gone the mis-binding is ' - + 'structurally impossible rather than warned about in a docblock. No alias and no ' - + 'deprecation window, per the standing 2026-08-27 ruling 「项目在创业阶段,用户也很少,短期不考虑渐进。」. ' - + 'Not losslessly convertible: an import path is TypeScript source, not a metadata document ' - + '`objectstack migrate meta` can rewrite.', - acceptanceCriteria: - 'No code imports anything from `@objectstack/spec/cloud` — the specifier is not an `exports` ' - + 'key and every such import fails to resolve (TS2307) after upgrade. Package-format consumers ' - + 'resolve the same symbols from `@objectstack/spec/marketplace` (pinned by resolved symbol ' - + 'identity in `kernel/package-dependency-dual-source.test.ts` and ' - + '`system/environment-artifact.test.ts`). `api/discovery-environment-subset.pin.test.ts` ' - + 'still proves DiscoveryEnvironment ⊂ EnvironmentType against the re-declared enum. No ' - + 'metadata document needs editing: the 509 `cloud/*` authorable-surface baseline keys are ' - + 'discharged by the deletion gate\'s own proofs — 30 defs carried by declared rename, 62 by ' - + 'whole-def retirement (`RETIRED_DEFS_BY_MAJOR[18]`) — not by a tombstone an author could hit. ' - + '⚠️ Runtime behaviour is deliberately UNCHANGED: `os package publish`, the marketplace routes ' - + 'and the metadata plugin\'s artifact ingest parse byte-identically before and after.', - }, - { - id: 'cluster-driver-dangling-values-removed', - surface: 'kernel.cluster.driver (ClusterDriverSchema, kernel/cluster.zod.ts) ' - + '- the `postgres` and `nats` enum values', - replacement: 'the drivers that actually ship - `memory` (single-process ' - + 'default), `redis` (@objectstack/service-cluster-redis, the production ' - + 'recommendation), or `custom` + registerClusterDriver(name, factory) for ' - + 'a self-provided transport. A config naming `postgres` or `nats` never ' - + 'worked: pick `redis`, or register the transport yourself under `custom`', - reason: - 'Maintainer ruling of 2026-08-24 on the cluster driver line-up (option B ' - + 'adopted): single-node is the ObjectOS EE boundary, multi-node is Cloud ' - + 'differentiation, and a DB-first postgres cluster driver is not built ' - + 'absent concrete customer pull. The ruling\'s principle rider decides ' - + 'this entry: a schema-valid value must not be an unconditional runtime ' - + 'throw. Both removed values were dangling by the same measurement - the ' - + 'only non-test registerClusterDriver() caller is service-cluster-redis, ' - + 'so `driver: \'postgres\'` or `driver: \'nats\'` passed schema ' - + 'validation and then reached defineCluster()\'s unconditional `Cluster ' - + 'driver "" is not registered` throw. It is a SEMANTIC entry ' - + 'rather than a mechanical conversion because the right replacement is a ' - + 'deployment decision (which transport actually backs this cluster), not ' - + 'a rename a codemod could apply; nothing at rest breaks, because a ' - + 'stored config naming either value never survived boot in the first ' - + 'place. The ruling records its own reversal condition: a value returns ' - + 'to the enum only in the release that ships an implementation behind ' - + 'it. No authorable KEY was retired (the `useExistingPool` field stays, ' - + 'reworded), so nothing lands in RETIRED_KEYS_BY_MAJOR.', - acceptanceCriteria: - 'No `cluster.driver` config names `postgres` or `nats`; ' - + '`ClusterDriverSchema.parse` on the chosen driver value succeeds; a ' - + 'deployment that needed a distributed transport boots on `redis` (or ' - + 'its `custom` registration) and `defineCluster()` no longer throws ' - + '`Cluster driver "" is not registered` at startup. TypeScript ' - + 'call sites that typed the removed spellings against `ClusterDriver` ' - + 'fail tsc on upgrade; the fix is choosing a shipped driver, never ' - + 'widening a local mirror of the enum.', - }, - // The `connector_action` sibling of `wait-node-event-config-required`: a node - // whose one input is a SIBLING block (not `config`) owes that block, and the - // flow parse now refuses what the executor's own read refuses — the block - // absent, or an id in it empty. - // - // Form D: no tracker number anywhere in the author-shown text; the decision is - // stated in words. - // - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'connector-action-config-required', - surface: - 'The connectorConfig block of every type: \'connector_action\' flow node — the BLOCK, and its ' - + 'connectorId and actionId once it is written. The block was optional on the node and both ids ' - + 'were any string inside it, so a node with no block, or with connectorId or actionId empty or ' - + 'only whitespace, parsed. That is the state of a node authored without its configuration, and ' - + 'of a new connector node from the Studio flow designer, which seeds both ids empty. At any depth, ' - + 'including an ADR-0031 region body. Reachable wherever a flow is authored or stored: ' - + 'defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, ' - + 'a flow saved from the Studio flow designer (a connector node added and saved before it is ' - + 'configured), and a flow row already sitting in sys_metadata. Also reached: connectorId, actionId ' - + 'or input written under the node\'s config instead of the block, where the load-time conversion ' - + 'cannot complete the pair and leaves them there', - replacement: - 'Declare what the node dispatches, on the node: `connectorConfig: { connectorId: \'slack\', ' - + 'actionId: \'chat.postMessage\', input: { channel: \'C0WINS000\', text: \'Done\' } }` — ' - + '`connectorId` the registered connector\'s `name`, `actionId` one of the action keys that ' - + 'connector declares, `input` optional. Keys written under the node\'s `config` move into the ' - + 'block. A node you cannot configure yet is deleted until you can: there is no placeholder ' - + 'connector, and a block with empty ids names nothing to dispatch to', - reason: - 'The block is the node\'s whole contract: the connector_action executor reads nothing else and ' - + 'refuses the node when `connectorId` or `actionId` is empty. The build doors checked only the ' - + 'block\'s shape once it was written, so `FlowSchema.parse`, `AutomationEngine.registerFlow` and ' - + '`objectstack validate` all admitted a node with no block, or with an empty id, and every run ' - + 'that reached the node then failed at the executor\'s guard — a guard refusal, never routed to a ' - + '`fault` edge, and no rerun could succeed because the config is metadata. The flow parse now ' - + 'refuses what that read refuses, in the walk that reaches every region body, so all three doors ' - + 'answer alike. A whitespace-only id is refused with the empty one: a connector `name` is a ' - + 'snake_case identifier, so whitespace names nothing a dispatch can reach. ' - + '⚠️ No D2 conversion: the platform cannot know the connector or the action the author left out, ' - + 'and no value it could write would dispatch anything. ' - + '⚠️ Where such a node already sits the whole flow is refused: registered from the metadata ' - + 'registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, ' - + 'while the flows beside it register; a `defineStack({ flows })` source throws ' - + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ' - + 'ADR-0087, ADR-0031.', - acceptanceCriteria: - 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' - + 'stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at ' - + '`nodes.N.connectorConfig` for an absent block, at `nodes.N.connectorConfig.connectorId` or ' - + '`nodes.N.connectorConfig.actionId` for an empty or whitespace-only id, or at the region path ' - + '`nodes.N.config.body.nodes.M.connectorConfig…`, and `objectstack validate` prints the same path ' - + 'under `flows.K.`. For each hit write the block, per the replacement. Two proofs. (1) For a stack ' - + 'authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each ' - + 'flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a ' - + 'row that exists only in `sys_metadata`. A connector node carrying a complete block parses, ' - + 'registers and dispatches as before, and `input` stays optional.', - }, - // ADR-0049 enforce-or-remove — the D3 entry of the - // `connector-error-mapping-removed` family, which landed in commit 13c48c2a5: - // eleven inert authorable keys, one of them spelled like the live - // `userMessage` channel. One D3 entry per retirement family, even when D2 is - // lossless (ruling B on #17152). The family is the key on both carriers - // (`integration/Connector:errorMapping`, - // `integration/DeclarativeConnectorEntry:errorMapping`) and the shape that - // leaves with it — `integration/ErrorMappingConfig`, - // `integration/ErrorMappingRule` and `integration/ConnectorErrorCategory` in - // RETIRED_DEFS_BY_MAJOR[18]. - { - id: 'connector-error-mapping-retired', - surface: 'connector.errorMapping — the rules / defaultCategory / unmappedBehavior / logUnmapped ' - + 'block and its per-rule keys, on a connector and on a stack connectors[] entry', - replacement: '(removed — no connector engine maps an external error through authored rules.) ' - + 'Retry behaviour is `retryConfig`, which the outbound fetch applies. No connector-level ' - + 'channel shows an end user a message: an error users must read is surfaced by whatever ' - + 'handles the connector call\'s failure.', - reason: 'The D2 conversion `connector-error-mapping-removed` deletes the whole block from every ' - + 'connector, stack entry and stored connector row, with one notice per connector, and the ' - + 'delete is lossless: no provider, dispatcher or materializer ever mapped an external error ' - + 'through the rules, so the eleven nested keys configured nothing. The judgment is about what ' - + 'the rules were written to achieve. A rule marking an upstream code `retryable` never ' - + 'changed a retry — if that retry matters, it belongs in `retryConfig`. A rule with a ' - + '`userMessage` never showed that message to anyone, although the spelling matches the live ' - + 'API-error channel and read as a user-facing refusal; if users need that text, whatever ' - + 'handles the failed call has to surface it. `unmappedBehavior` and `logUnmapped` suppressed ' - + 'or logged nothing. Which of these intents still matters is known only to the connector\'s ' - + 'author.', - acceptanceCriteria: 'No connector and no stack connector entry carries `errorMapping`; the parse ' - + 'refuses it, and no code imports ErrorMappingConfig, ErrorMappingRule or ' - + 'ConnectorErrorCategory. Calls through each connector fail and retry exactly as they did ' - + 'before the upgrade. For every rule whose intent still matters: a retry the author wanted is ' - + 'expressed in `retryConfig` and observed on a failing upstream, and a message the author ' - + 'wanted users to read is shown to them, by the caller that handles the failure, when the ' - + 'upstream fails.', - }, - { - id: 'connector-provider-context-connection-timeout-ms-retired', - surface: 'ConnectorProviderContext.connectionTimeoutMs, the declared connect deadline handed ' - + 'to every ConnectorProviderFactory (integration/connector-provider.ts)', - replacement: 'requestTimeoutMs for the deadline the platform keeps; for a connect-only bound, ' - + "the provider's own providerConfig, where the provider owns the vocabulary", - reason: - 'ADR-0049 enforce-or-remove, maintainer ruling 2026-09-22 letter A: retire ' - + 'connector.connectionTimeoutMs. The spec key is tombstoned and its authored sources are ' - + 'rewritten by the D2 conversion connector-connection-timeout-ms-removed; this entry ' - + 'carries the half a conversion cannot reach. The key was placed on this context by the ' - + 'round that made the connector resilience policy live, explicitly as a CARRY — handed ' - + 'over so that a custom provider on a transport able to separate the phases could honour ' - + 'it. Measured before removal, none did, and the carry itself was the last thing keeping ' - + 'the key alive in argument: the built-in rest and openapi factories read ' - + 'ctx.connectionTimeoutMs only to deposit it back onto the def that GET /connectors ' - + 'echoes, and connectorFetchOptions — the one mapping from authored policy onto the ' - + "platform's outbound fetch — was never handed it. Being handed a value is not honouring " - + 'it, so the carry is the same parsed-unmarked-unenforced state on one more surface, and ' - + 'it leaves with the key rather than outliving it as an orphan a factory could still ' - + 'read. Why a semantic entry and not a D2 conversion: a provider factory is CODE. There is ' - + 'no authored source and no sys_metadata row holding a read of ctx.connectionTimeoutMs, so ' - + 'the chain has no seam to rewrite — the removal reaches a factory author as a tsc error ' - + 'and as this entry, never as a mechanical edit. The declaration cannot be made honest by ' - + 'implementing it either: a WHATWG fetch exposes one AbortSignal over the whole operation ' - + 'and never the connect phase, so bounding time-to-response with this key would kill a ' - + 'slow-but-connected upstream the author meant to allow with a large requestTimeoutMs. ' - + 'ADR-0087, ADR-0097.', - acceptanceCriteria: - 'No ConnectorProviderFactory reads ctx.connectionTimeoutMs; the member does not exist on ' - + 'ConnectorProviderContext and reading it fails to compile. A factory that genuinely needs ' - + 'a connect-phase bound declares it in its own providerConfig and applies it itself, on a ' - + 'transport that can observe the connect phase — it does not receive one from the host. ' - + 'Behaviour is unchanged for every shipped provider, because none applied the value: a ' - + 'connector that authored connectionTimeoutMs made exactly the same calls with exactly ' - + 'the same deadlines before and after. What does change is observable and intended: the ' - + 'def served by GET /connectors no longer echoes a connect deadline nobody keeps, and ' - + 'requestTimeoutMs — which resilientFetch applies as each attempt deadline — is the only ' - + 'timeout on the surface. The sibling members retryConfig and requestTimeoutMs ' - + 'deliberately do NOT move, and a sweep that removed either has over-applied this entry: ' - + 'both resolve to real reads at the fetch site.', - }, - // ADR-0049 enforce-or-remove — the D3 entry of the connector resilience family: - // `connector.health` (the `healthCheck` probe and the `circuitBreaker`), - // `connector.status` and the connector-nested `webhooks`, sixteen authorable keys - // retired as one batch. One D3 entry per retirement family, even when D2 is - // lossless (ruling B on #17152): the D2 conversion - // `connector-resilience-keys-removed` repairs the data, and this entry carries - // what only the author can judge. It also names the CHAIN through the same - // protocol step: `connector-health-and-trigger-durations-unit-in-key` used to - // rename `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`, and - // that half was absorbed here — the renamed key is itself removed. (Its trigger - // half was absorbed later by `connector-triggers-removed`, and the conversion - // left the table.) - { - id: 'connector-resilience-keys-retired', - surface: 'connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — ' - + 'on a connector and on a stack connectors[] entry', - replacement: '(removed — nothing replaces the probe, the breaker or an authored status.) ' - + 'Participation is `enabled` (and `provider` on a declarative instance); whether a registered ' - + 'connector can be dispatched is the computed `state` (`ready` / `degraded`) on ' - + '`GET /api/v1/automation/connectors`; a webhook that is actually delivered is declared in ' - + 'the top-level `webhooks:` collection; probes and circuit breaking belong in the connector ' - + 'provider or an upstream gateway.', - reason: 'The D2 conversion `connector-resilience-keys-removed` deletes `health`, `status` and ' - + '`webhooks` from every connector, stack entry and stored connector row, one notice per key, ' - + 'and the delete is lossless: no loop ever polled a connector endpoint, counted failures or ' - + 'tripped a breaker, no code read an authored status, and a webhook nested in a connector ' - + 'was never registered, materialized or delivered. Three judgements remain. First, a probe ' - + 'or breaker the author believed was protecting a flaky upstream never was — if that ' - + 'protection matters, it has to be built where calls are made (the connector provider) or ' - + 'in front of the upstream (a gateway). Second, `status` values like `active` or `error` ' - + 'gated nothing; an author who used `status` to switch a connector off needs `enabled: ' - + 'false` on the declarative entry instead. Third, the nested webhooks are STRIPPED, not ' - + 'moved: redeclaring one in the top-level `webhooks:` collection STARTS deliveries that ' - + 'never happened before, so which of them should exist is the author\'s call — and their ' - + '`events` (`sync.completed`, `auth.expired` and the rest) and `signatureAlgorithm` have no ' - + 'counterpart there. The chain: in this same protocol step, ' - + '`connector-health-and-trigger-durations-unit-in-key` no longer renames ' - + '`health.circuitBreaker.monitoringWindow` to `monitoringWindowMs` — the whole block that ' - + 'key lived in is removed, so an author holding either spelling ends with no key at all. ' - + 'That conversion\'s other half, `triggers[].interval` to `intervalSeconds`, was absorbed ' - + 'the same way by the removal of the whole `triggers` array (`connector-triggers-removed`), ' - + 'so the rename itself is no longer in the step.', - acceptanceCriteria: 'No connector and no stack connector entry carries `health`, `status` or ' - + '`webhooks`; the parse refuses each with its prescription (a stored `status: \'inactive\'` ' - + 'default is accepted and stripped as inert residue), and no code imports ConnectorHealth, ' - + 'HealthCheckConfig, CircuitBreakerConfig, ConnectorStatus, WebhookConfig, WebhookEvent or ' - + 'WebhookSignatureAlgorithm. Every connector dispatches exactly as it did before the ' - + 'upgrade. Each declarative connector instance the author meant to be switched off carries ' - + '`enabled: false` and is observed absent from `GET /api/v1/automation/connectors`; each ' - + 'nested webhook that is still wanted ' - + 'is declared in the top-level `webhooks:` collection and observed delivering; and each ' - + 'probe or breaker the author relied on is provided by the connector provider or a gateway ' - + 'and observed tripping against a failing upstream.', - }, - // ADR-0049 — the D3 entry of the connector-attached sync family: - // `connector.syncConfig` (the `DataSyncConfig` block) and - // `connector.fieldMappings` (the `ConnectorFieldMapping` list), fourteen keys - // no engine ever executed, retired from the connector as one batch because the - // sync definition MOVED to its target (the ruled ENFORCE route: a `mapping` - // whose `connectorSource` names the connector, with a `job` for the cadence). - // One D3 entry per retirement family, even when D2 is lossless (ruling B on - // #17152): the D2 conversion `connector-sync-keys-removed` repairs the data, - // and this entry carries what only the author can judge — above all, that the - // conversion never writes the replacement `mapping`, because a pulled mapping - // would START writes that never happened. - { - id: 'connector-sync-keys-retired', - surface: 'connector.syncConfig (strategy / direction / realtimeSync / timestampField / ' - + 'conflictResolution / batchSize / deleteMode / filters) and connector.fieldMappings[] ' - + '(source / target / defaultValue / dataType / required / syncMode), on a connector and on a ' - + 'stack connectors[] entry', - replacement: 'A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, ' - + '`mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector ' - + 'instance it pulls from (`connector`), the action that reads the records (`action`, with a ' - + 'fixed `input` and a `recordsPath`) and, for a timestamp-incremental pull, a `watermark` ' - + '(`field` on the record, `param` on the request); a `job` sets the cadence. The pull executor ' - + 'reads the binding when a `job` drives it — a `job` whose `pull: { mapping }` names the mapping, ' - + 'on the job\'s schedule; the binding alone moves no rows.', - reason: 'The D2 conversion `connector-sync-keys-removed` deletes `syncConfig` and ' - + '`fieldMappings` from every connector, stack entry and stored connector row, one notice per ' - + 'key, and the delete is lossless: no engine ever ran a connector-attached sync or moved a ' - + 'value through a connector field mapping, so nothing the upgrade removes was ever happening. ' - + 'Three judgements remain. First, any part of the deployment designed around a connector ' - + 'sync running has never been running, so the author decides which syncs should now exist ' - + 'as target-side mappings; the conversion STRIPS the keys and never writes a `mapping`, ' - + 'because a mapping that is pulled STARTS writes into a table that never received them — ' - + 'its target object, match key and cadence are the author\'s. Second, the retired block ' - + 'named a direction, a conflict policy and a delete policy that no runtime applied — every ' - + 'delete is a hard delete, and `latest_wins` resolved nothing — and the pull that replaces ' - + 'it is one-way (external to local) and writes only through the mapping\'s `mode` and ' - + '`upsertKey`: an author who relied on `export`, `bidirectional`, `soft_delete` or a conflict ' - + 'policy decides what to do without them. Third, a connector field map moved values ' - + 'nowhere; carrying its `source` → `target` pairs into `mapping.fieldMapping` makes them ' - + 'real for the first time, including any `defaultValue`, which the import mapping spells as ' - + 'a `constant` transform, and `required`, which the target field declares.', - acceptanceCriteria: 'No connector and no stack connector entry carries `syncConfig` or ' - + '`fieldMappings`; the parse refuses either key with its prescription, and no code imports ' - + 'DataSyncConfig, SyncStrategy, ConnectorConflictResolution or ConnectorFieldMapping or ' - + 'their schemas. Every connector registers and dispatches its actions exactly as it did ' - + 'before the upgrade. Each sync the author still wants is a `mapping` whose ' - + '`connectorSource` names a `rest` or `openapi` connector instance, with a `job` whose `pull` ' - + 'names that mapping chosen for its cadence.', - }, - // ADR-0049 enforce-or-remove — the D3 entry of the connector triggers family: - // `connector.triggers`, the whole `ConnectorTrigger` array, retired as one - // batch by ruling (ADR-0041 unchanged: connector-event triggers stay in its - // third tier, as their own trigger package). One D3 entry per retirement - // family, even when D2 is lossless (ruling B on #17152): the D2 conversion - // `connector-triggers-removed` repairs the data, and this entry carries what - // only the author can judge. It also names the CHAIN through the same protocol - // step: `connector-health-and-trigger-durations-unit-in-key` used to rename - // `triggers[].interval` to `intervalSeconds`; that half was absorbed here (the - // breaker half already was, by the `health` removal), so the rename left the - // table, and this family's former rename entry left with it. - { - id: 'connector-triggers-retired', - surface: 'connector.triggers — the ConnectorTrigger array (key / label / description / type / ' - + 'intervalSeconds, and the interval spelling it was renamed from), on a connector and on a ' - + 'stack connectors[] entry', - replacement: '(removed — nothing replaces a connector trigger.) Start the work from a flow that ' - + 'calls the connector\'s action in a `connector_action` node: an external event starts an ' - + '`api` flow that the event\'s sender calls, and a scheduled pull is a `schedule` flow.', - reason: 'The D2 conversion `connector-triggers-removed` deletes `triggers` from every connector, ' - + 'stack entry and stored connector row, one notice per connector, and the delete is lossless: ' - + 'the automation engine registered a connector\'s actions only, no polling loop read an ' - + 'interval, no receiver was driven by a `webhook` trigger, and no provider derived one — so a ' - + 'declared trigger never started a flow, before or after the upgrade. Three judgements ' - + 'remain. First, any part of the deployment designed around a connector trigger firing has ' - + 'never been running, so the author decides which of those triggers should now exist as ' - + 'flows: a `polling` trigger becomes a `schedule` flow whose `connector_action` node calls ' - + 'the connector\'s read action, and a `webhook` trigger becomes an `api` flow that the ' - + 'external sender calls. The conversion STRIPS the array and never writes a flow, because ' - + 'a flow that runs STARTS work that never happened before — its cadence, its action and ' - + 'what it does with the result are the author\'s. Second, a polling cadence is in SECONDS: ' - + 'the key was renamed from `interval` to `intervalSeconds` earlier in this same protocol ' - + 'step because the bare `interval` means milliseconds elsewhere in this spec, so a trigger ' - + 'written `interval: 60000` for one minute asked for once every sixteen hours or so — carry ' - + 'the intended cadence, not the stored number, into the schedule. Third, turning a ' - + '`webhook` trigger into an `api` flow opens an inbound endpoint that never existed before ' - + '(the trigger declared no receiver and no verification), and the platform refuses an `api` ' - + 'flow with no per-flow secret and verifies a signature on every call — so whether the ' - + 'external sender can sign its calls decides whether that flow can receive them directly. ' - + 'The chain: ' - + 'in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no ' - + 'longer renames `triggers[].interval` — the whole array that key lived in is removed, so an ' - + 'author holding either spelling ends with no key at all.', - acceptanceCriteria: 'No connector and no stack connector entry carries `triggers` in any ' - + 'spelling; the parse refuses the key with its prescription, and no code imports ' - + 'ConnectorTrigger or ConnectorTriggerSchema. Every connector registers and dispatches its ' - + 'actions exactly as it did before the upgrade. Each connector trigger the author still ' - + 'wants is a flow that is observed running: a scheduled pull as a `schedule` flow whose ' - + '`connector_action` node calls the connector\'s action at the intended cadence in seconds, ' - + 'and an external event as an `api` flow observed starting when the sender calls it.', - }, - { - id: 'cube-join-sql-and-relationship-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: - 'analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship — the ' - + 'authored ON clause and the declared cardinality on a cube join', - replacement: - 'analyticsCubes[].joins..name alone. The ON clause is DERIVED from the declared ' - + 'relationship between the two cubes\' objects, as a foreign-key equality: NativeSQLStrategy ' - + 'emits the LEFT JOIN and its ON from the dotted member path, and ObjectQLStrategy lowers the ' - + 'same alias to a relationship traversal with no ON clause at all. The record KEY is the ' - + 'foreign-key FIELD on the base object, never a second spelling of the object the join ' - + 'reaches.', - reason: - 'The KEYS convert mechanically and do: the paired D2 conversion ' - + '`cube-join-sql-and-relationship-removed` deletes both from every join, which is lossless ' - + 'because neither ever had an effect to lose, and names the cube in each notice. What does ' - + 'NOT convert is the INTENT. `sql` was REQUIRED and documented as the ON clause, and no ' - + 'reader ever consulted it: an authored condition was REPLACED by the synthesised ' - + 'foreign-key equality and the aggregate came back under a 200, joined on something the ' - + 'author had not asked for. `relationship` carried a `.default(\'many_to_one\')` that nothing ' - + 'dispatched on, so `one_to_many` parsed, changed no SQL, and kept the many-to-one ' - + 'arithmetic. Deleting the keys restores honesty but does not give an author who wanted a ' - + 'non-FK join the thing they wanted, and it does not re-check the numbers the replaced join ' - + 'already produced. That is why this entry is a TODO addressed to them rather than a claim ' - + 'that the strip finished the job. A custom join condition is a capability card with its ' - + 'injection / allow-list boundary decided first, which the ruling deferred deliberately.', - acceptanceCriteria: - 'Delete `sql` and `relationship` from every entry of every cube `joins` map; keep `name`. ' - + 'The paired D2 conversion `cube-join-sql-and-relationship-removed` performs that same ' - + 'strip mechanically wherever the chain is replayed — including over metadata already at ' - + 'rest, so a deployed artifact keeps booting while you do. Then check three things. (1) Did any deleted `sql` express something OTHER than the foreign-key ' - + 'equality between the two objects — a filtered join, a non-key column, a literal predicate? ' - + 'If so, the query you were getting was already the FK-equality answer and not the one you ' - + 'wrote, so re-read the numbers that join produced before assuming this change moved them; the ' - + 'fix is to model the relationship on the object, or to open a capability request for an ' - + 'authorable join condition. (2) Did any deleted `relationship` say anything but ' - + '`many_to_one`? If so, the aggregate was already computed as many-to-one and still is — this ' - + 'change alters no result, it only stops the declaration from claiming otherwise. (3) Is each ' - + 'join KEYED by a foreign-key field of the cube\'s own base object? The key is the column the ' - + 'derived ON clause reads, so a join keyed after the object it REACHES never resolved at all. ' - + 'Nothing else regresses: `joins..name` is unchanged, and it is what both the joined ' - + 'table and the per-object RLS/tenant read scope are resolved from.', - relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, - }, - // #20300 — ADR-0049 enforce-or-remove (triage verdict RETIRE) — the D3 entry of - // the `cube-member-inner-name-removed` family (one D3 entry per retirement - // family, even when D2 is lossless). Registered keys: `data/Metric:name` and - // `data/Dimension:name`. The strip changes no query and no discovery answer; - // what it cannot decide is which of two DISAGREEING names an author meant. - { - id: 'cube-member-inner-name-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: - 'analyticsCubes[].measures..name / analyticsCubes[].dimensions..name — the ' - + 'inner name a cube member used to require', - replacement: - 'The record key. `measures` and `dimensions` are records, and the key a member is declared under ' - + 'IS its name: the analytics API publishes it as `.` and a query names it that way. To ' - + 'rename a member, rename its key.', - reason: - 'The D2 conversion `cube-member-inner-name-removed` deletes the inner `name` from every metric and ' - + 'dimension of every cube, and the delete is lossless in behaviour: every consumer — discovery, both ' - + 'query strategies, the in-memory driver — resolves a member by its record key, so the inner value ' - + 'was never read. Where it EQUALED its key there is nothing left to decide. Where it DISAGREED, the ' - + 'key was already the name every query, dashboard and report used, and the inner value was a spelling ' - + 'nothing read; the conversion notice prints both. Only the author can say whether the disagreeing ' - + 'spelling was the one they meant — in which case the member must be re-keyed, and every consumer ' - + 'that names `.` changes with it — or a stale copy to drop.', - acceptanceCriteria: - 'No metric or dimension of any cube carries `name`; the parse refuses it with the prescription. ' - + 'For every conversion notice whose `from` shows a name that differed from its record key, the ' - + 'author has either kept the key (nothing else changes) or re-keyed the member to the intended ' - + 'name and updated every query, dashboard and report that names `.`. ' - + '`GET /api/v1/analytics/meta` lists each member as `.` exactly as before the upgrade.', - relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, - }, - // #20943, maintainer ruling D — a cube member's `sql` is a column reference; - // the expression half is retired at the contract (ADR-0021's zero raw - // expressions, carried from the dataset layer to the cube members it compiles - // to). Semantic only, with no D2 conversion: an expression has no mechanical - // rewrite — it moves to another metadata type, and a ratio changes scale on - // the way — so the upgrader owes the judgement this entry states. - { - id: 'cube-member-sql-expression-retired', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span AND a table cell. - surface: - 'analyticsCubes[].measures..sql and analyticsCubes[].dimensions..sql ' - + '(data.MetricSchema.sql / data.DimensionSchema.sql) authored as a SQL expression — a CASE ' - + 'expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling, or any ' - + 'other value that is not a column reference', - replacement: - 'a column reference: a field of the cube\'s object (`amount`), a relationship path ending ' - + 'in one (`account.amount`), or `\'*\'` for a count. A derived value moves to an ' - + 'ADR-0021 dataset over the same object: a conditional count or sum is a dataset measure ' - + 'with its own structured `filter` (`{ name: \'done_count\', aggregate: \'count\', filter: ' - + '{ status: \'done\' } }`), and a ratio, sum, difference or product of measures is ' - + '`derived: { op, of: [...] }` over measures named in the same dataset (`{ name: ' - + '\'done_rate\', derived: { op: \'ratio\', of: [\'done_count\', \'task_count\'] }, format: ' - + '\'0.0%\' }`). A dimension that bucketed a column with a CASE expression has no expression ' - + 'form in either layer: group by the column itself, or keep the bucket as a field of the ' - + 'object and name that field', - reason: - 'Maintainer ruling D (2026-09-30), from the analytics field-level read gate: a member whose ' - + '`sql` is an expression names no single field, so no platform check can judge which fields ' - + 'it reads, and the analytics strategies never agreed on it — the raw-SQL path emitted it ' - + 'verbatim and the ObjectQL path refused it. ADR-0021 already set the direction for the ' - + 'author surface ("zero raw SQL / zero raw expressions"); it governed the dataset layer and ' - + 'left the cube members it compiles to open, which is the gap this closes. The dataset form ' - + 'is the declared home of a derived value because every field it reads is named: a measure ' - + 'filter names its fields, and a derived measure references other measures by name only. ' - + 'There is no D2 conversion: the rewrite moves a member to a different metadata type and ' - + 'cannot be derived from the expression text in general, so only the author can say which ' - + 'dataset measures express what the expression meant. A ratio also changes SCALE on the way: ' - + 'a `derived` ratio is a 0–1 fraction, while an expression that multiplied by 100 returned ' - + 'percentage points — pair the ratio with a `%` numeral pattern (the server marks a ratio ' - + 'column\'s percent scale as a fraction) and re-check any consumer that read the old number ' - + 'raw. ADR-0021 / ADR-0049 / ADR-0087', - acceptanceCriteria: - 'Every analytics cube parses: `CubeSchema`, the analytics_cube write door and defineStack ' - + 'refuse an expression member at its `sql` with a prescription that names the dataset form, ' - + 'so the sweep is mechanical — parse each cube, and each refusal is one member to move. For ' - + 'each moved measure, a dataset over the same object declares it, and a query over a fixture ' - + 'where the condition excludes rows returns the same figure the expression returned (a ' - + 'ratio: the same value divided by 100 when the expression returned percentage points). ' - + 'Every dashboard, report or saved query that named the cube member now names the dataset ' - + 'measure. A cube member that aggregates a column parses byte-identically to before.', - relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, - }, - // #21000 (ADR-0049 enforce-or-remove) — `AggregationMetricType`'s `number`, - // `string` and `boolean` declared a custom SQL expression returning that type, - // and a cube member's `sql` has been a column reference since - // `cube-member-sql-expression-retired`, so the three had nothing left to - // compute. A value-level retirement (`enumWithRetiredValues`), semantic only: - // no D2 conversion, because no rewrite can say which aggregate the author - // meant, and a stored cube carrying one is refused at every door rather than - // rewritten. - { - id: 'cube-metric-expression-types-retired', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span AND a table cell. - surface: - 'analyticsCubes[].measures..type (data.AggregationMetricType) authored as number, string ' - + 'or boolean — the custom-SQL-expression metric types', - replacement: - 'the aggregate the measure means: `sum`, `avg`, `min` or `max` over the column, `count` (over ' - + '`\'*\'` for a row count, or over a column for its non-null values), or `count_distinct`. A value ' - + 'computed per row becomes a field of the object (a stored or formula field) that the measure ' - + 'aggregates; a ratio or other value derived from measures is `derived: { op, of: [...] }` on an ' - + 'ADR-0021 dataset', - reason: - 'The three types existed to mark a measure whose `sql` was the whole computation — a ratio, a ' - + 'CASE, a window function — and named only what it returned. Since ' - + '`cube-member-sql-expression-retired` a member\'s `sql` is a column reference, so the types had ' - + 'nothing left to declare: measured before this retirement, the raw-SQL strategy emitted the ' - + 'referenced column unaggregated (a bare column in a grouped statement, by SQL\'s own rules an ' - + 'error on PostgreSQL and an arbitrary row\'s value on SQLite) and the ObjectQL strategy refused ' - + 'the measure. There is no D2 conversion: the column alone does not say which aggregate the author ' - + 'wanted — a `number` over `amount` may have meant its sum, its average or its largest value — ' - + 'so only the author can choose, and a measure whose old expression computed something per row ' - + 'needs that value stored on the object before any aggregate can read it. Nothing is rewritten ' - + 'or dropped at rest: a stored or built cube that still carries one of the three is refused, ' - + 'with the prescription, at the boot and write doors, and a cube that reaches the analytics ' - + 'service without meeting the parse is refused at query time with the same text. ADR-0049 / ' - + 'ADR-0087', - acceptanceCriteria: - 'Every analytics cube parses: `CubeSchema`, the analytics_cube write door and defineStack refuse ' - + 'a measure typed number, string or boolean at its `type` with a prescription naming the six ' - + 'aggregates, so the sweep is mechanical — parse each cube, and each refusal is one measure to ' - + 'retype. For each retyped measure, a query over a fixture with more than one row per group ' - + 'returns the aggregate the author chose, and every dashboard, report or saved query that read ' - + 'the measure is checked against the number it now returns. A measure typed with one of the six ' - + 'aggregates parses byte-identically to before.', - relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, - }, - // #10414 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `metric-filters-removed` family (ruling B on #17152: one D3 entry per - // retirement family, even when D2 is lossless). The unknown-keys entry - // `analytics-authorable-unknown-keys-refused` names the conversion only in - // passing; this is the family's own entry. The strip preserves observed - // behaviour exactly — which is the problem: the observed behaviour was an - // unfiltered number under a filtered name. - { - id: 'cube-metric-filters-retired', - surface: 'analyticsCubes[].measures..filters — the per-metric raw-SQL filter list', - replacement: 'One of the two filters that ARE applied: a `where` condition at query time, or an ' - + 'ADR-0021 dataset measure with a structured `filter`. (A third channel — folding the condition ' - + 'into the metric\'s own `sql` expression — left with `cube-member-sql-expression-retired`: a ' - + 'member\'s `sql` is a column reference.)', - reason: 'The D2 conversion `metric-filters-removed` deletes `filters` from every cube metric, and ' - + 'the delete is lossless in the narrow sense: neither SQL strategy ever read the key, so a ' - + 'metric authored with `filters: [{ sql: "stage = \'closed_won\'" }]` already returned the ' - + 'UNFILTERED aggregate under the author\'s metric name, and still does. That is exactly why the ' - + 'strip does not finish the job. The author wrote a condition because they wanted a filtered ' - + 'number; every dashboard, report and export reading that metric has been showing a larger ' - + 'one. Only the author can say which of the two live mechanisms expresses the condition ' - + 'they meant — a query-time `where` changes every query, a dataset measure moves the metric ' - + 'to the governed layer — and whether numbers already published from the unfiltered metric ' - + 'need to be revisited.', - acceptanceCriteria: 'No cube metric carries `filters`; the parse refuses the key by name. For ' - + 'each metric that carried one, the author has either re-expressed the condition through one ' - + 'of the two live mechanisms or decided the unfiltered aggregate is what they want — and ' - + 'renamed the metric if its name promised the filter. With the condition re-expressed, a query ' - + 'over a fixture where the condition excludes rows returns the filtered aggregate (strictly ' - + 'smaller for a positive sum over excluded rows), not the unfiltered one.', - relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, - }, - // #20637 — ADR-0049 enforce-or-remove (maintainer ruling, letter C) — the D3 - // entry of the `cube-refresh-key-removed` family (one D3 entry per retirement - // family, even when D2 is lossless). Registered key: `data/Cube:refreshKey`. - // The strip changes no query answer; what it cannot decide is whether anything - // the author built assumed that cube results were cached or refreshed. - { - id: 'cube-refresh-key-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: - 'analyticsCubes[].refreshKey (every, sql) — a cube\'s declared refresh cadence and data-change probe', - replacement: - 'Nothing: delete the key. No analytics result is cached, so every query against a cube is computed ' - + 'when it is asked. A refresh cadence is declared again when a result cache exists.', - reason: - 'The D2 conversion `cube-refresh-key-removed` deletes `refreshKey` from every cube, and the delete is ' - + 'lossless: nothing read `every` or `sql`, and no analytics result was ever cached for them to ' - + 'refresh, so no query answers differently. What the conversion cannot check is whether anything the ' - + 'author built assumed that cube results were cached or refreshed on a schedule. They never were.', - acceptanceCriteria: - 'No cube carries `refreshKey`, and the parse refuses one with the prescription. Every analytics ' - + 'query answers as it did before the upgrade. Nothing the author maintains relies on cube results ' - + 'being cached or refreshed on a schedule.', - relevantWhen: { kind: 'stack-declares', keys: ['analyticsCubes'] }, - }, - // #19992 (ADR-0049 enforce-or-remove; triage direction REMOVE under ruling 乙 - // on #19910: 「a currency's decimal places are the currency's, not a - // setting」) — the D3 entry of the `currency-config-precision-removed` family - // (ruling B on #17152: one D3 entry per retirement family, even when D2 is - // lossless). Registered key: `data/CurrencyConfig:precision`; the never-accepted - // `decimals` / `scale` spellings are answered by the same prescription and have - // no stored form to convert. The delete changes no rendered amount; what it - // leaves is a width belief, and code outside the platform that may have read the - // served key. - { - id: 'currency-config-precision-retired', - surface: 'object.fields.*.currencyConfig.precision — the decimal-places key of a currency ' - + 'field\'s configuration, and its never-accepted `decimals` / `scale` spellings', - replacement: '(removed — nothing replaces it.) A currency amount\'s decimal places are its ' - + 'currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), derived from the ' - + 'currency itself and declared nowhere. Delete the key. Do not move the number to the ' - + 'field-level `precision`: that key is the amount\'s total digit count, not its decimal ' - + 'places, and it is unchanged.', - reason: 'The D2 conversion `currency-config-precision-removed` deletes the key from every ' - + 'field\'s `currencyConfig` on objects and object extensions — in author sources, in stored ' - + 'object rows and in built artifacts, which can carry a `2` the old schema wrote into parse ' - + 'output without anyone authoring it — and the delete is lossless: no renderer or runtime ' - + 'ever read the key. Every display face derives the width from the currency. Two judgments ' - + 'remain, and neither is a rewrite. First, a width that never applied: the old contradiction ' - + 'check judged an authored value only on a `fixed` field whose code has a known ISO 4217 ' - + 'minor unit, so on a `dynamic` field, and on a `fixed` field whose code has none (a crypto ' - + 'or custom code), an author could declare a width other than the one the field displays — ' - + 'and read amounts as if it applied. Whether the displayed width is acceptable for that ' - + 'field is the author\'s call. Second, code the chain cannot reach: a plugin, integration or ' - + 'export of your own that read `currencyConfig.precision` from served object metadata now ' - + 'finds no key, and must derive the width from the field\'s currency the way the platform\'s ' - + 'renderers always did.', - acceptanceCriteria: 'No field\'s `currencyConfig` carries `precision`, `decimals` or `scale` — ' - + 'in sources, in stored object rows or in built artifacts; the parse refuses each by name ' - + 'with the prescription, and a stored row or artifact written before the upgrade loads ' - + 'without a refusal over it. No code of your own reads `currencyConfig.precision`; where it ' - + 'needed a width, it derives one from the field\'s currency. Every currency field renders ' - + 'its amounts exactly as before the upgrade, because the key never changed a rendered ' - + 'amount. `os migrate meta --stored --apply` rewrites stored rows so the per-row notice ' - + 'stops. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; ' - + '`--write` applies the ones it can prove, and you apply the rest by hand.', - }, - { - id: 'dashboard-header-modal-target-page-only', - surface: - 'dashboard `header.actions[]` entries with `actionType: \'modal\'` — an `actionUrl` naming ' - + 'a defined action, a bare object name, or the `_` prefix form ' - + '(`create_`/`new_`/`add_`/`edit_`/`update_` + object). The keys themselves are unchanged ' - + 'and still parse; what changed is what the string RESOLVES to', - replacement: - 'a declared page name (`stack.pages`, by `name`) — a modal target names a PAGE, only. To ' - + 'open an object\'s create/edit form from a dashboard header, use `actionType: \'form\'` ' - + 'with an `.` form-view target (`actionType` accepts the full action-type ' - + 'enum, so that shape reaches this surface too)', - reason: - 'Maintainer ruling A on modal targets (2026-08-09): a `type: \'modal\'` string target names ' - + 'a PAGE, only — the spec TSDoc, the published docs and `defineStack`\'s cross-reference ' - + 'walk already agreed, and the renderer\'s page-then-object leniency (self-labelled ' - + 'Back-compat) was retired rather than codified. One objectui change deleted the object ' - + 'fallback in the shared `useActionModal`; a second deleted `DashboardView`\'s own second copy ' - + 'of the prefix convention (which had no page resolution at all), after enumerating both ' - + 'repos\' corpora and finding zero producers of the prefix form. The `os validate` lint ' - + 'rule (`validateDashboardActionRefs`) then still pointed the other way: it accepted the ' - + 'retired shapes — blessing buttons that dispatch to a named refusal at runtime — and ' - + 'ERRORED on a page-named target, the one shape the runtime serves. The rule now resolves ' - + 'a modal target against declared pages, only. The ruling explicitly declined the middle ' - + 'shape (keep the prefix, reject bare object names): `create_opportunity` names the page ' - + '`create_opportunity`, or it names nothing.', - acceptanceCriteria: - 'Every dashboard `header.actions[]` entry with `actionType: \'modal\'` has an `actionUrl` ' - + 'naming a declared page (`os validate` passes the dashboard-action-refs rule); header ' - + 'buttons meant to open an object\'s form declare `actionType: \'form\'` with an ' - + '`.` target instead. Clicking each converted button opens the intended ' - + 'page or form rather than a refusal dialog.', - relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, - }, - // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its - // unit in its NAME) — the D3 entry of the - // `dashboard-refresh-interval-to-refresh-interval-seconds` family (ruling B on - // #17152: one D3 entry per retirement family, even when D2 is lossless). The - // one key of that stack whose reader lives in another repository, released on - // its own schedule — so besides the unit check, the author owes a look at the - // running console. - { - id: 'dashboard-refresh-interval-unit-in-key', - surface: 'dashboard.refreshInterval — the auto-refresh cadence of a dashboard', - replacement: '`refreshIntervalSeconds` — the same cadence, in seconds, with the unit in the key ' - + 'name. The old rename hints (`refresh`, `autoRefresh`, `pollInterval`) now point at it.', - reason: 'The D2 conversion `dashboard-refresh-interval-to-refresh-interval-seconds` renames ' - + '`refreshInterval` to `refreshIntervalSeconds` in the `dashboards` collection and on stored ' - + 'dashboard rows, keeping the value, and the rename is lossless: the key always meant seconds. ' - + 'Two judgments remain. First, the unit: nothing in the old name said seconds, and three ' - + 'other spellings authors reached for named no unit either, so a value written in ' - + 'milliseconds — `refreshInterval: 30000` meant as thirty seconds — asked for a refresh about ' - + 'every eight hours, and the rename keeps 30000. Second, the reader: the dashboard renderer ships ' - + 'in the separately released console, and when this rename landed it still read the old ' - + 'key, so a console that has not yet moved sees no cadence and starts no timer. A dashboard ' - + 'that stops refreshing after the upgrade is that lag, not a wrong value — which only a look ' - + 'at the running console can tell apart.', - acceptanceCriteria: 'No dashboard carries `refreshInterval`; the parse refuses it with the rename. ' - + 'Every `refreshIntervalSeconds` value is the cadence the author intends in seconds — a ' - + 'dashboard meant to refresh every thirty seconds reads `refreshIntervalSeconds: 30`. In the ' - + 'console the deployment runs, an open dashboard re-queries its widgets at that cadence; ' - + 'where it does not, the console build predates the renderer\'s move to the new key, and the ' - + 'author has recorded that until the console is upgraded.', - relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, - }, - // The judgement half of `dashboard-widget-chart-config-structure-removed`. The - // D2 conversion strips the four keys mechanically; what they CARRIED cannot be - // moved by a walker, because the intent lands one level up in a selection the - // stripped widget may not hold — an authored `xAxis.field` can name a dataset - // dimension the widget never selected, and a `series[]` entry can name a - // measure outside `values` entirely. - { - id: 'dashboard-widget-chart-config-structure-refused', - surface: - '`dashboard.widgets[].chartConfig.type` / `.xAxis` / `.yAxis` / `.series` — the four keys ' - + 'that said which chart family to draw, which series exist and which column each one reads ' - + 'on a DATASET-BOUND widget (REMOVED)', - replacement: - 'the widget’s own `type` and its ADR-0021 dataset selection. `chartConfig.type` becomes the ' - + 'widget’s `type` (the chart family has always been the widget’s — the dashboard renderer ' - + 'maps the widget type to the chart family and never read the chart config’s). ' - + '`chartConfig.xAxis.field` becomes an entry in the widget’s `dimensions`: the dataset ' - + 'dimension the category axis plots. Each `chartConfig.yAxis[].field` becomes an entry in ' - + 'the widget’s `values`: the dataset measure that axis plots, one entry per mark, and a ' - + 'second axis is a second measure rather than a second axis declaration. Each ' - + '`chartConfig.series[].name` is the same measure name, so a series list that matched ' - + '`values` needs nothing and one that did not was already being ignored. What has NO ' - + 'replacement, and is the reason this is a TODO rather than a rewrite: the PRESENTATION ' - + 'those objects carried alongside the binding — `ChartAxis.title` / `format` / `min` / ' - + '`max` / `stepSize` / `showGridLines` / `position` / `logarithmic`, and ' - + '`ChartSeries.label` / `color` / `type` / `yAxis` / `stack` / `dashArray` / `opacity`. ' - + 'The dataset’s own dimension and measure declarations are what label and format a ' - + 'dataset-bound chart now; `colors` on the same chart config remains the palette channel, ' - + 'and a per-series mark type (the combo chart a widget could author through ' - + '`series[].type`) has no authoring channel on this face at all.', - reason: - 'Maintainer ruling of 2026-09-12 on the dataset-bound chart config, taking options ' - + 'C+D together: the protocol states the ownership split AND refuses the structural keys by ' - + 'name, because stating it without refusing them leaves the declared-but-inert shape ' - + 'ADR-0049 exists to end, and refusing them without stating it leaves an author with no ' - + 'reason. The defect being closed is not cosmetic: an authored `yAxis[].field` was a LIVE ' - + 'MEMBERSHIP CHANNEL — the renderer synthesised a series from the authored axes when the ' - + 'chart declared none — so one authored axis could silently re-point a dataset-bound ' - + 'series at a different column while the chart still drew, which reads as a true statement ' - + 'about the data. ⛔ Not mechanically convertible: the D2 conversion can delete the keys ' - + 'from a stored widget, but moving what they MEANT into the dataset selection needs facts ' - + 'the item does not carry — whether the dataset declares a dimension by that name, whether ' - + 'the measure is in the dataset at all, and whether the author wanted the axis they wrote ' - + 'or the one the selection derives. An authored field naming a column outside the ' - + 'selection is exactly the case where a walker guessing would produce a different chart ' - + 'rather than a refused one. The keys are NOT retired from the chart config itself: ' - + '`ReportChartSchema` keeps its own `xAxis`/`yAxis` (narrowed to its bound dataset’s ' - + 'dimension and measure names), and the react `` tier keeps all ' - + 'four, because an inline-data chart has no dataset to derive structure from and the ' - + 'author’s axes are the only ones there are.', - acceptanceCriteria: - 'Measured against the shipped schema, not restated from the card. (1) No dashboard widget ' - + 'carries `chartConfig.type`, `.xAxis`, `.yAxis` or `.series`: the D2 conversion ' - + '`dashboard-widget-chart-config-structure-removed` strips them from authored sources on a ' - + 'chain replay and `os migrate meta --stored --apply` covers rows already at rest, and a ' - + 'value that reaches a parse is refused at that key’s own path with the prescription ' - + 'naming the dataset selection. (2) For every widget that carried one, the chart it draws ' - + 'after the migration is the chart the author meant: the family is the widget’s `type`, ' - + 'the category axis plots the dimension named in `dimensions`, and there is one mark per ' - + 'measure named in `values` — verified by rendering the dashboard, not by reading the ' - + 'metadata, because the pre-migration chart may have been plotting a column the selection ' - + 'never named. (3) A widget whose authored axes AGREED with its selection renders ' - + 'identically before and after, and that is the expected case; a widget that renders ' - + 'differently was relying on the membership channel this removes and is the case the ' - + 'ruling was made about. (4) Axis titles, number formats, axis bounds, grid lines and ' - + 'per-series labels/colours/mark types are gone from the widget and are NOT expected back: ' - + 'a dataset-bound chart takes them from the dataset’s dimension and measure declarations. ' - + 'A combo chart that was authored through `series[].type` on a dataset-bound widget has no ' - + 'authoring channel on this face after the change — that capability loss is ruled, not ' - + 'incidental, and an inline-data react `` is where a per-series mark type is ' - + 'still authored.', - }, - { - id: 'dashboard-widget-dimensionless-multi-measure-refused', - surface: 'dashboard widget measure arity WITHOUT a dimension — `dashboard.widgets[].values` ' - + '(`DashboardWidgetSchema.values`) on a widget whose `dimensions` is absent or empty and whose ' - + '`type` is one of the seven chart types that declare no rendering for several measures: ' - + '`pie` / `donut` / `funnel` / `scatter` / `radar` / `treemap` / `sankey`', - replacement: 'Pick a visual that renders several measures, or split the widget. With no ' - + 'dimension, `type: \'table\'` renders a row of measures and a bar-family type (`bar` / ' - + '`column` / `horizontal-bar`) renders one bar per measure; both keep the unbounded `values` ' - + 'they have always had. The full set of types that render several measures on a dimensionless ' - + 'widget is the exported constant `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` — at this release ' - + '`table`, `pivot`, `bar`, `column`, `horizontal-bar`, `line`, `area` and `combo` — and the ' - + 'refusal prints it from that constant. Or keep the type and give each measure its OWN widget: a ' - + 'new `id`, the same `dataset`, that one measure in `values`, and its own `layout` if the ' - + 'dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion ' - + 'could: whether a dimensionless two-measure pie meant a table, a bar chart or two pies is an ' - + 'authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT ' - + 'decision about a dashboard the registry has never seen.', - reason: - 'Maintainer ruling D, on objectui\'s finding that a widget silently drops every measure after ' - + 'the first, applying the maintainer\'s standing rule 「协议不正确的应该先修改协议」 — the ' - + 'protocol is fixed where it admits measures a widget type cannot render. Its first application ' - + 'bounded the metric FAMILY to one measure (`dashboard-widget-metric-family-multi-measure-refused`); ' - + 'this entry applies the same principle to the chart types. Measured in objectui by the dev who ' - + 'delivered the multi-measure renderings for `table` / `pivot` and the bar, line, area and combo ' - + 'families: the other seven `ChartTypeSchema` members, given no dimension and two or more ' - + 'measures, render `values[0]` and drop the rest — the dataset query selects and computes every ' - + 'measure, and all but the first are thrown away. Every door accepted the document, because ' - + '`values` is `z.array(z.string()).min(1)` with no upper bound outside the metric family. That ' - + 'is the declared≠delivered shape ADR-0049 exists to end. The census before the change found ' - + 'zero authored dimensionless multi-measure widgets of any type in the platform\'s examples or in ' - + 'objectui\'s example apps, so this ships at once with no deprecation window: there is no window ' - + 'in which a queried-and-discarded measure does anything. Relaxing later is free and needs no ' - + 'second migration — if one of the seven gains a declared multi-measure rendering (a radar of ' - + 'measures, a funnel of measure stages) it joins the constant, while leaving the key unbounded ' - + 'costs an author a widget that silently drops what they declared. ⛔ This change does not ' - + 'invent those renderings.', - acceptanceCriteria: - '⚠️ WHICH DOOR: the refusal is the spec\'s, and it reaches every door that parses the spec ' - + 'schema — measured on `defineStack`, which throws naming the widget; on `os validate`, which ' - + 'loads the configuration through `defineStack` and fails there with that same issue; on the ' - + 'stack schema and the `dashboard` metadata-type schema; and on the metadata save path, where ' - + 'an ACTIVE save and a DRAFT save of such a dashboard both answer `422 INVALID_METADATA` at ' - + '`widgets[N].values` and persist nothing. It is NOT refused by objectui\'s client-side ' - + 'authoring door until that door chains the new export: `@object-ui/types` builds its ' - + '`DashboardWidgetSchema` from a `.shape` spread of the spec\'s, which carries the FIELDS and ' - + 'drops every object-level check, so its editor keeps accepting a dimensionless two-measure ' - + '`pie` and the author meets the refusal at publish. ⇒ Do not read a green editor as a clean ' - + 'dashboard; re-parse through the spec. ' - + '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose emitted once ' - + 'per hop, with no per-document interpolation and no filtering by whether the stack carries the ' - + 'shape, so `os migrate meta` prints THIS paragraph, not a list of your widgets. The refusal is ' - + 'what names them, per widget, on the re-parse — drive the fix off `os validate`, not off the ' - + 'migrate output. ' - + 'WHAT IS REFUSED, exactly: ONE `custom` issue at `widgets[N].values`, naming the widget\'s ' - + '`id`, the number of measures and the authored `type`, when `dimensions` is absent or an empty ' - + 'array, `values` carries two or more measures, and `type` is `pie`, `donut`, `funnel`, ' - + '`scatter`, `radar`, `treemap` or `sankey`. The check is the exported ' - + '`checkDashboardWidgetChartMeasureArity` (exported as ' - + '`checkDashboardWidgetDimensionlessMeasureArity` until ' - + '`dashboard-widget-single-series-multi-measure-refused` gave it a second arm and renamed it), and ' - + 'the set it reads is the exported ' - + '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` — one list, which the check, the refusal text and the ' - + '`values` doc string all read. ' - + 'WHAT IS NOT, so this is not read as complete: the same seven types WITH a dimension are ' - + 'outside THIS entry — `scatter` and `radar` keep accepting several measures with a dimension, and ' - + '`pie` / `donut` / `funnel` / `treemap` / `sankey` are refused with a dimension too, by ' - + '`dashboard-widget-single-series-multi-measure-refused`; one measure parses on every type; every type in ' - + '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with no ' - + 'dimension; the metric family (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`, and a ' - + 'widget that declares no `type`, which resolves to `metric`) keeps its OWN refusal, unchanged ' - + 'and still ONE issue — that check refuses a second measure at any dimensionality, and this one ' - + 'steps aside for the family rather than adding a second issue on the same `values`; an EMPTY ' - + '`values` keeps the field\'s own `too_small`; a `type` outside `ChartTypeSchema` reports the ' - + 'TYPE refusal alone (zod treats that `invalid_value` as aborting and skips object-level checks), ' - + 'and called directly on a wider type enum the export judges only the types the spec declares; ' - + 'and whether each measure EXISTS in the bound dataset is still unreachable from this schema. ' - + 'VERIFY by re-parsing each dashboard: a dashboard that had one dimensionless two-measure `pie` ' - + 'should end with a `table` or bar-family widget carrying both measures, or with two widgets of ' - + 'one measure each — check the rendered grid afterwards, because the second measure is a number ' - + 'the dashboard was ALREADY paying to compute and had never shown.', - }, - { - id: 'dashboard-widget-metric-family-multi-measure-refused', - surface: 'dashboard widget measure arity — `dashboard.widgets[].values` ' - + '(`DashboardWidgetSchema.values`) on a widget whose `type` is one of the metric ' - + 'FAMILY (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`), INCLUDING a widget ' - + 'that declares no `type` at all and so resolves to the `metric` default', - replacement: 'ONE measure per tile. Keep the measure the tile is actually for — in ' - + 'practice `values[0]`, which is the only one that has ever rendered — and give each ' - + 'of the others its OWN widget: a new `id`, the same `dataset`, that one measure in ' - + '`values`, and its own `layout` if the dashboard pins grid positions. ⛔ The ' - + 'migration does not do this for you and no conversion could: N tiles need N ids and ' - + 'N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the ' - + 'registry has never seen. If several numbers in ONE widget is what was meant, that ' - + 'is a different visual and the arity rule is not in its way: `type: \'table\'` ' - + 'renders a row of measures, and the chart families (`bar` / `line` / `area` / ' - + '`combo`) render one mark per measure — all of them keep the unbounded `values` ' - + 'they have always had.', - reason: - 'Maintainer ruling D of 2026-09-12, on objectui\'s finding that a metric tile silently ' - + 'drops every measure after the first, applying the maintainer\'s standing rule ' - + '「协议不正确的应该先修改协议。」 — judge the protocol ' - + 'wrong rather than invent display semantics for `values[1..]`. Measured in ' - + 'objectui\'s first report of the defect: `values` was `z.array(z.string()).min(1)` with NO upper ' - + 'bound on every widget type, so a `metric` tile could declare three measures; the ' - + 'dataset query selected and computed all three, and the tile rendered `values[0]`. ' - + 'The other two were queried and dropped on the floor — the declared≠delivered shape ' - + 'ADR-0049 exists to end, kept alive by a runtime warning rather than closed. ' - + 'An objectui fix (merged) added the declared sub-caption, and objectui\'s interim half made ' - + 'the tile SAY that the extra measures are not rendered: that makes the tile honest ' - + 'about dropping them, it does not make the document legal. A metric tile answers ONE ' - + 'number — that is what the family means on every mainstream dashboard product, and ' - + '`ChartTypeSchema` groups these five under "Performance (single value)" in its own ' - + 'words. Several numbers is a DIFFERENT visual, not a variant of this one, so the ' - + 'repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm ' - + '(the wish, from a downstream application\'s manager dashboard, for several numbers on ' - + 'one tile): under this ' - + 'ruling that is a request for a different widget type, and it stays reachable ' - + 'through `table` / the chart families, which this narrowing does not touch. ' - + 'Ships at once, no deprecation window: there is no window in which a queried-and-' - + 'discarded measure does anything. Widening later (a real gauge renderer that draws ' - + 'a target band, say) costs an author nothing and needs no second migration — a ' - + 'narrowing that is later relaxed is free, while leaving the key unbounded costs ' - + 'them a tile that silently drops what they declared.', - acceptanceCriteria: - '⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored ' - + 'dashboard carrying more than one measure on a metric-family widget is refused the ' - + 'next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the ' - + 'metadata publish path, and any server-side door that parses the spec schema — with ' - + 'ONE `custom` issue at `widgets[N].values` naming the widget\'s `id`, the number of ' - + 'measures it declared, and the authored `type`. It is NOT refused by objectui\'s ' - + 'client-side authoring door: `@object-ui/types` builds its own ' - + '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, ' - + '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping ' - + 'every object-level check, so until that package imports and chains ' - + '`checkDashboardWidgetMetricMeasureArity` the dashboard EDITOR keeps accepting three ' - + 'measures on a `metric` and the author meets the refusal later, at publish. ⇒ Do not ' - + 'read a green editor as a clean dashboard; re-parse through the spec. ' - + '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose ' - + 'emitted once per hop — `applyMetaMigrations` maps `step.semantic` straight onto the ' - + 'result with no per-document interpolation and no filtering by whether the stack ' - + 'even carries the shape — so `os migrate meta` prints THIS paragraph, not a list of ' - + 'your dropped measures. The refusal is what names them, per widget, on the re-parse. ' - + 'Drive the fix off `os build`, not off the migrate output. ' - + 'WHAT IS REFUSED, exactly: two or more `values` members on `metric`, `kpi`, `gauge`, ' - + '`solid-gauge` or `bullet`, and on a widget that declares no `type` (it resolves to ' - + '`metric`, and the message says so rather than claiming you wrote it). ' - + 'WHAT IS NOT, so this is not read as complete: a single-measure tile of any of those ' - + 'five types parses byte-identically to before; all fifteen OTHER members of ' - + '`ChartTypeSchema` — `bar`, `horizontal-bar`, `column`, `line`, `area`, `pie`, ' - + '`donut`, `funnel`, `scatter`, `treemap`, `sankey`, `combo`, `radar`, `table`, ' - + '`pivot` — keep accepting three measures, unmoved; an EMPTY `values` keeps the ' - + 'field\'s own `too_small` from `.min(1)` and gains no second issue ("exactly one" is ' - + 'the conjunction of that lower bound and this upper one, so a mirror re-attaching ' - + 'this export onto a shape without `.min(1)` gets the upper bound only); a widget ' - + 'whose `type` is outside `ChartTypeSchema` reports the TYPE refusal ALONE (zod treats ' - + 'that `invalid_value` as aborting and skips object-level checks), so the arity ' - + 'refusal arrives on the next parse and the two are never seen together; and whether ' - + 'the surviving measure EXISTS in the bound dataset is still unreachable from this ' - + 'schema — a tile naming one measure nobody declared parses exactly as it did before. ' - + 'Nothing new is broken for consumers that DERIVE this schema: ' - + '`.omit()`/`.pick()`/`.partial()` already threw on it before this change, because it ' - + 'already carried `checkDashboardWidgetStageOrder`; `.extend()` is unaffected — except ' - + 'that zod 4.4.3 refuses an `.extend()` which OVERWRITES a key on a refined object ' - + '("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` ' - + 'instead"), which was already true here and is why a per-`type` union arm was not the ' - + 'spelling chosen. VERIFY by re-parsing each dashboard and reading the widget count: ' - + 'a dashboard that had one three-measure `metric` tile should end with three ' - + 'single-measure tiles and the same three numbers on screen — check the rendered grid ' - + 'afterwards, because the two new tiles are numbers the dashboard was ALREADY paying ' - + 'to compute and had never shown.', - }, - { - id: 'dashboard-widget-single-series-multi-measure-refused', - surface: 'dashboard widget measure arity WITH a dimension on a single-series chart type — ' - + '`dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` ' - + 'declares one or more dimensions and whose `type` is `pie`, `donut`, `funnel`, `treemap` or ' - + '`sankey`; and the check export `checkDashboardWidgetDimensionlessMeasureArity` (from ' - + '`@objectstack/spec/ui`), renamed `checkDashboardWidgetChartMeasureArity`', - replacement: 'Keep ONE measure on the widget, or pick a visual that renders several. With a ' - + 'dimension, `type: \'table\'` renders a column per measure and a bar-family type (`bar` / ' - + '`column` / `horizontal-bar`) renders one bar per measure in each category; both keep the ' - + 'unbounded `values` they have always had. Or keep the type and give each measure its OWN ' - + 'widget: a new `id`, the same `dataset` and `dimensions`, that one measure in `values`, and its ' - + 'own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you ' - + 'and no conversion could: whether a two-measure pie by stage meant a table, a grouped bar chart ' - + 'or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, ' - + 'which is a LAYOUT decision about a dashboard the registry has never seen. A mirror that ' - + 'chained the old check export by name imports `checkDashboardWidgetChartMeasureArity` instead: ' - + 'same signature, same attachment point, and it refuses everything the old name refused.', - reason: - 'Triage\'s ruling on objectui\'s finding that a dimensioned pie draws only its first measure: ' - + 'the spec refuses, the renderer does not invent. It extends ' - + '`dashboard-widget-dimensionless-multi-measure-refused`, which applied maintainer ruling D ' - + '(「协议不正确的应该先修改协议」) to a widget with NO dimension, to the dimensioned arm for ' - + 'the five types that draw one series whatever the dimension. Measured in objectui\'s shared ' - + 'chart renderer: the `pie` / `donut`, `funnel`, `treemap` and `sankey` arms each bind the ' - + 'first series and read no other, so `{ type: \'pie\', dimensions: [\'stage\'], values: ' - + '[\'revenue\', \'cost\'] }` drew one slice per stage for `revenue` and no trace of `cost` — the ' - + 'dataset query selects and computes every measure, and all but the first are thrown away. ' - + 'Every door accepted the document, because the dimensionless rule stepped aside for any ' - + 'widget that declared a dimension. That is the declared≠delivered shape ADR-0049 exists to ' - + 'end. A pie of several measures has zero measured pull, so no rendering is invented for it. ' - + 'The census before the change found zero authored dimensioned multi-measure widgets of the five ' - + 'types in the platform\'s examples, its first-party dashboards or objectui\'s example apps, so ' - + 'this ships at once with no deprecation window. Relaxing later is free and needs no second ' - + 'migration — a type whose renderer gains a declared rendering for several measures with a ' - + 'dimension leaves the single-series set — while leaving the shape accepted costs an author a ' - + 'widget that silently drops what they declared. The check export is renamed in the same change ' - + 'because its old name said a widget with a dimension was outside it, which stopped being true; ' - + 'no first-party consumer chained it under that name.', - acceptanceCriteria: - '⚠️ WHICH DOOR: the refusal is the spec\'s, attached at the same point as the dimensionless rule, ' - + 'so it reaches every door that parses the spec schema — measured on `defineStack`, which throws ' - + 'naming the widget, and on the stack schema, the dashboard schema and the `dashboard` ' - + 'metadata-type schema, each refusing at `widgets[N].values`. It is NOT refused by objectui\'s ' - + 'client-side authoring door until that door chains the export: `@object-ui/types` builds its ' - + '`DashboardWidgetSchema` from a `.shape` spread of the spec\'s, which carries the FIELDS and ' - + 'drops every object-level check, so its editor keeps accepting a dimensioned two-measure `pie` ' - + 'and the author meets the refusal at publish. ⇒ Do not read a green editor as a clean ' - + 'dashboard; re-parse through the spec. ' - + '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose emitted once ' - + 'per hop, with no per-document interpolation and no filtering by whether the stack carries the ' - + 'shape, so `os migrate meta` prints THIS paragraph, not a list of your widgets. The refusal is ' - + 'what names them, per widget, on the re-parse — drive the fix off `os validate`, not off the ' - + 'migrate output. ' - + 'WHAT IS REFUSED, exactly: ONE `custom` issue at `widgets[N].values`, naming the widget\'s ' - + '`id`, the number of measures and the authored `type`, and saying the type draws one series ' - + 'whatever its `dimensions`, when `dimensions` declares at least one dimension, `values` carries ' - + 'two or more measures, and `type` is `pie`, `donut`, `funnel`, `treemap` or `sankey`. The check ' - + 'is the exported `checkDashboardWidgetChartMeasureArity` — the dimensionless rule\'s own check, ' - + 'with a second arm; the single-series set it reads is not exported, and the refusal prints it. ' - + 'WHAT IS NOT, so this is not read as complete: `scatter` and `radar` WITH a dimension keep ' - + 'accepting several measures exactly as before (the ruling named the five: `radar` draws every ' - + 'series, and `scatter` says on the chart that it draws one); every type in ' - + '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with a dimension; ' - + 'one measure parses on every type; a DIMENSIONLESS widget of the five keeps the dimensionless ' - + 'refusal, word for word and still ONE issue; the metric family (`metric` / `kpi` / `gauge` / ' - + '`solid-gauge` / `bullet`, and a widget that declares no `type`, which resolves to `metric`) ' - + 'keeps its OWN refusal, unchanged; an EMPTY `values` keeps the field\'s own `too_small`; a ' - + '`type` outside `ChartTypeSchema` reports the TYPE refusal alone, and called directly on a ' - + 'wider type enum the export judges only the types the spec declares; and whether each measure ' - + 'EXISTS in the bound dataset is still unreachable from this schema. ' - + 'VERIFY by re-parsing each dashboard: a dashboard that had one two-measure `pie` by stage should ' - + 'end with a `table` or bar-family widget carrying both measures, or with two widgets of one ' - + 'measure each — check the rendered grid afterwards, because the second measure is a number the ' - + 'dashboard was ALREADY paying to compute and had never shown.', - }, - { - id: 'dashboard-widget-stage-order-non-funnel-refused', - surface: 'dashboard widget stage order — `dashboard.widgets[].options.stageOrder` ' - + '(`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything ' - + 'other than `funnel`, INCLUDING a widget that declares no `type` at all and so ' - + 'resolves to the `metric` default', - replacement: 'either `type: \'funnel\'` on the widget that meant to declare a stage ' - + 'order, or — for every other widget type — DELETE `stageOrder` and order the widget ' - + 'with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as ' - + '`order: { : \'asc\' | \'desc\' }` instead of re-sorting what it returned. ' - + 'There is no third spelling: no other widget type has ever read the key, so nothing ' - + 'is lost by removing it that was not already absent from what rendered. The refusal ' - + 'lands at `options.stageOrder` and names the type the widget carries, the one type ' - + 'that reads the key, and the two keys to reach for instead.', - reason: - 'The first finding of the report that `options.stageOrder` is honoured by the funnel ' - + 'branch only, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose ' - + 'whole content was SILENCE. `options` is the open renderer-extras bag, so ' - + '`stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, ' - + '`table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and ' - + 'forwarded the array to the renderer, which never consulted it. Measured at this ' - + 'repo\'s `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded ' - + '`categoryOrder` prop has exactly one read in the charts plugin ' - + '(`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits ' - + 'inside the `chartType === \'funnel\'` guard opened at line 1473; the prop\'s other ' - + 'two occurrences in that file are its declaration and its destructure. The producer ' - + 'side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for ' - + 'ANY widget and forwards it whenever non-empty. So the authored order was accepted ' - + 'by the metadata layer, carried all the way to the chart, and dropped there, with ' - + 'nothing anywhere to say so: the widget rendered in whatever order the analytics ' - + 'query returned and looked deliberate. The reporter measured exactly that in a live ' - + 'app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered ' - + 'alphabetically by display label. The four SIBLING members of the same bag are not ' - + 'in this narrowing and were measured not to share the defect: `dateGranularity`, ' - + '`sortBy`, `sortOrder` and `limit` are read unconditionally at the top of ' - + '`DatasetWidget` (lines 443-455, outside every type branch) and lower into the ' - + '`DatasetSelection` the server compiles, so they act on every widget type. ' - + '`stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT ' - + 'the other arm of the card ("or ordered marks honour it"): teaching `bar` / `line` / ' - + '`area` to sort by a category order is a renderer change in the objectui repo, and ' - + 'widening the set of types that read the key can be done later WITHOUT a second ' - + 'migration — a narrowing that is later relaxed costs an author nothing, while ' - + 'leaving the key accepted-and-inert costs them a chart that silently lies. Ships at ' - + 'once, no deprecation window: there is no window in which an inert key does ' - + 'anything.', - acceptanceCriteria: - '⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored ' - + 'dashboard whose widgets carry `options.stageOrder` on a non-`funnel` type is refused ' - + 'the next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the ' - + 'metadata publish path, and any server-side door that parses the spec schema — with one ' - + '`custom` issue at `widgets[N].options.stageOrder` naming the authored type. It is NOT ' - + 'refused by objectui\'s client-side authoring door: `@object-ui/types` builds its own ' - + '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, ' - + '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every ' - + 'object-level check (measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts the ' - + 'widget and reports zero checks, while `.extend({})` keeps the refusal). At the ' - + '`.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` that package re-attaches NONE ' - + 'of the spec\'s exported checks, so until it imports and chains ' - + '`checkDashboardWidgetStageOrder` the dashboard EDITOR still accepts the key on a `bar` ' - + 'and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a ' - + 'clean dashboard; re-parse through the spec. Fix each by writing ' - + '`type: \'funnel\'` where a funnel was meant, and by deleting the key elsewhere — ' - + 'check the rendered order afterwards, because a widget that was silently ignoring ' - + 'the key renders EXACTLY as it did before once the key is gone, and `sortBy` / ' - + '`sortOrder` is what changes it. A `funnel` widget carrying `stageOrder` parses ' - + 'byte-identically to before, a non-`funnel` widget carrying the other four ' - + '`options` members is untouched, and a widget with no `options` at all is ' - + 'untouched. ⚠️ Three more shapes this does NOT reach, so do not read it as complete ' - + '(the objectui door above is the first): a ' - + 'widget whose `type` is outside `ChartTypeSchema` reports the TYPE refusal alone ' - + '(zod treats that as aborting and skips object-level checks), so the stage-order ' - + 'refusal arrives only on the next parse; and the array\'s CONTENTS are still ' - + 'unconstrained, so a `funnel` carrying a stage value the dimension never declares ' - + 'still parses and still renders that stage in the sentinel position; and a consumer ' - + 'that derives this schema with `.omit()` / `.pick()` / `.partial()` now gets a THROW ' - + 'from zod rather than a schema, because zod 4 refuses all three on an object carrying ' - + 'a refinement — latent rather than live (no consumer in either repo derives the widget ' - + 'schema that way today), and `.extend()` is unaffected. Repo census at ' - + 'the time of the change: zero authored widgets carry the key anywhere in the ' - + 'monorepo — 59 occurrences outside changelogs, all of them schema, tests, generated ' - + 'reference pages, the sdui-parser census and the gate that derives it.', - }, - { - id: 'data-file-value-duration-unit-in-key', - surface: 'FileValue.duration, the media length on the expanded file/image/avatar/video/audio ' - + 'read shape, whose name carried no unit (data/field-value.zod.ts)', - replacement: 'durationSeconds — rename the key; the value is unchanged, and a fractional ' - + 'second is still legal', - reason: - 'Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type ' - + 'could express: rename the key and ' - + 'record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of ' - + 'anything already stored. ' - + 'This key declared its unit in NO channel at all — no `.describe()`, no JSDoc, no unit ' - + 'token in the name — so the published reference page printed a bare number and the ' - + 'authoring site printed nothing. What makes the bare name worth a registry row is the ' - + 'company it kept: the only other number on FileValue is `size`, a BYTE count, so the one ' - + 'member that measured time was indistinguishable from a count at the very site an author ' - + '(very often a model, ADR-0033) writes it. `durationSeconds` rather than a mechanical ' - + '`durationSec` or `lengthSeconds`: this spec already spells a length of time ' - + '`durationSeconds` in SIX places, and they are ENUMERATED rather than counted because a ' - + 'bare number in shipped prose cannot be re-checked against the tree — ' - + 'ai/conversation.zod.ts ConversationAnalytics.durationSeconds; and on system/metrics.zod.ts ' - + 'MetricAggregationConfig.window.durationSeconds, ServiceLevelIndicator.window.durationSeconds, ' - + 'ServiceLevelObjective.period.durationSeconds, ' - + 'ServiceLevelObjective.errorBudget.burnRateWindows[].durationSeconds and ' - + 'MetricsConfig.retention.durationSeconds. The media length is therefore the SEVENTH ' - + 'spelling of one vocabulary, not the first of a second one. The retired-key tombstone ' - + 'entry data/FileValue:duration carries the same six keys in the same order, so the two ' - + 'surfaces that state one fact cannot drift apart. ' - + 'The value type is deliberately UNCHANGED at `z.number().optional()`: a fractional second ' - + 'is the ordinary shape of a media length, so the closed `DurationSeconds` type ' - + '(`.int().nonnegative()`, published beside `EpochMs` as a closed duration type) was ' - + 'considered and REFUSED by the ruling, and so was an ' - + '`.int()` floor. That refusal is the load-bearing half — this row is one of the six ' - + 'genuine durations the closed types\' unit set was derived from, and it is the one that ' - + 'takes a NAME instead of a TYPE. ' - + 'Tombstoned with retiredKey(); FileValueSchema is the one deliberate z.looseObject in this ' - + 'file, so a bare deletion would wave the old spelling through as an unrecognised extra key ' - + 'and the prescription would never be spoken. ' - + 'Why a semantic entry and not a D2 conversion: FileValueSchema is the ADR-0104 D3 wave-2 ' - + 'EXPANDED READ form, derived at read time from a sys_file id — the STORED form is ' - + 'FileReferenceIdValueSchema, an opaque string — so a file value is never authored as this ' - + 'shape and never persisted as a sys_metadata row, and the conversion chain has no seam ' - + 'that would ever see one. ' - + 'ADR-0104, ADR-0087.', - acceptanceCriteria: - 'Every producer that BUILDS an expanded file value spells durationSeconds, and every ' - + 'consumer that reads a media length reads durationSeconds. Authoring duration fails to ' - + 'compile (input type `never`) and fails to parse with the rename prescription rather than ' - + 'riding through the loose shape as an unrecognised extra. Behaviour is unchanged: ' - + 'durationSeconds: 12 is the same twelve seconds duration: 12 was, the key stays optional, ' - + 'and durationSeconds: 12.34 still PARSES — a sweep that added .int() or adopted ' - + 'DurationSeconds has narrowed a value the ruling refused to narrow, and is the one ' - + 'over-application to look for. The five sibling members — url, name, size, mimeType, alt — ' - + 'are untouched; `size` in particular is a BYTE count, not a duration, so a sweep that ' - + 'suffixed it has read a count as a length of time.', - }, - { - id: 'data-nosql-query-options-timeout-unit-in-key', - surface: 'NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no ' - + 'unit (data/driver-nosql.zod.ts)', - replacement: 'timeoutMs — rename the key; the value is unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02): the unit of a duration-shaped ' - + 'z.number() lives in the key NAME or in a unit-carrying value, never only in the describe ' - + 'prose, and no existing offender is grandfathered. ' - + 'It stands alone because it is the only offender on its file. The neighbour is what ' - + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' - + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' - + 'order of magnitude, so two adjacent bare integers meant milliseconds and documents ' - + 'respectively with nothing at the call site to separate them. Tombstoned with ' - + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' - + 'the query would run with no deadline at all while its author believed one was set — ' - + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' - + 'conversion: these options are a per-call driver argument, reached only through ' - + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' - + 'sys_metadata row stores, so the chain has no seam. ADR-0087.', - acceptanceCriteria: - 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' - + 'to compile (input type `never`) and fails to parse with the rename prescription. ' - + 'Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and ' - + 'the positive-integer bound rides along with the renamed key so a zero or negative ' - + 'deadline is still refused. Two neighbours on this same shape deliberately do NOT move, ' - + 'and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of ' - + 'documents, not a duration, and consistency / projection / hint are not numbers at all.', - }, - // The analytics carriers' half of filter-equality-array-comparand-refused-at-save. - // That entry refuses an equality-slot list on save wherever the shared face - // refuses it, and names a list inside a nested-relation condition as a position - // it deliberately leaves to execution. This one closes that position on the two - // carriers the analytics where door charts — a dataset filter and a measure - // filter — because that door flattens a nested relation to a dotted member and - // refuses the list there. Recorded as its own entry because the surface is - // narrower (two carriers, one position) and the shared schema does not move. - { - id: 'dataset-filter-nested-relation-equality-array-refused-at-save', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'ui.Dataset filter and ui.DatasetMeasure filter — an ARRAY as an EQUALITY comparand on a ' - + 'field INSIDE a nested-relation condition, now refused when the dataset is PARSED: the ' - + 'implicit form { account: { region: [...] } } and the explicit form ' - + '{ account: { region: { $eq: [...] } } }, the empty array included, at any relation depth ' - + 'and under $and / $or / $not. Every other schema that carries a FilterCondition keeps the ' - + 'shared schema\'s reach', - replacement: - 'the operator the list was standing in for, on the same field inside the same relation, ' - + 'exactly as in filter-equality-array-comparand-refused-at-save. "One of these values" is ' - + '$in: { account: { region: { $in: ["a", "b"] } } } (authoring spelling "in"). "The stored ' - + 'multi-value field holds this value" is $contains with ONE member (authoring spelling ' - + '"contains"), and an $or of those for any-of. A filter that meant a single value writes that ' - + 'value: { account: { region: "a" } }. The list operators keep their arrays, empty lists ' - + 'included; every scalar, null above all, is untouched; and $ne is NOT judged by this entry', - reason: - 'Measured on origin/main 9e7824a445 before the change: DatasetSchema parsed a dataset whose ' - + 'filter was { account: { region: ["a"] } }, and one whose measure filter was ' - + '{ account: { region: { $eq: ["a"] } } }, GREEN — while the analytics where door, which ' - + 'charts both carriers on every path (the native-SQL and ObjectQL strategies and the draft ' - + 'preview), flattens the relation to the dotted member account.region and hands the list to ' - + 'the shared comparand-shape face, which refuses it with INVALID_FILTER / 400. So such a ' - + 'dataset saved clean and every chart built on it failed, for a different person, later. ' - + 'The shared FilterConditionSchema does not descend a field spec with no $ key, because the ' - + 'engine reads one as a deep-equality comparand; ruling A of 2026-09-24, which made the schema ' - + 'door refuse what the compile face refuses, drew the line there and it stays there. Triage on ' - + '2026-09-25 routed the fix to the two analytics carriers instead, rather than stop the ' - + 'analytics door descending, which would change what a nested list means: they refine their filter with the analytics door\'s own walk ' - + '($and / $or arrays and $not descended, other $ keys skipped, a plain object with no $ key ' - + 'descended as a nested relation at any depth) and refuse, inside a nested relation only, ' - + 'exactly what that door refuses there, in the face\'s words from the one builder both doors ' - + 'import. The one difference is that the door appends the location (at ' - + 'where.account.region) and the carrier does not, because its issue carries the location as ' - + 'its path (filter.account.region, measures.0.filter.account.region.$eq). A list outside a ' - + 'nested relation is the shared schema\'s refusal and is reported once. No filter changes ' - + 'meaning: the refusal moves from chart time to save. Metadata AT REST is not rewritten and ' - + 'this entry adds no D2 conversion, for the reason the runtime entry gives: an array on ' - + 'equality has no single honest value. The read path does not re-validate stored rows, so a ' - + 'stored dataset keeps loading; what changes is that re-saving it through the metadata ' - + 'protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter\'s ' - + 'path. Such a filter has failed every chart since the analytics door began refusing it, so ' - + 'the refusal is a repair and not a loss. In-repo census at 9e7824a445: no dataset or ' - + 'measure filter in examples, platform objects, docs or skills carries the shape; deployed ' - + 'datasets were NOT measured. ADR-0021 / ADR-0087.', - acceptanceCriteria: - 'Validate every stack and re-save every stored dataset: os validate or defineStack, and a ' - + 'save through the metadata protocol, report each list in an equality slot inside a nested ' - + 'relation by path, with the field, the received list and both remedies, so the sweep of the ' - + 'two carriers is mechanical. Decide per filter what it meant — one of these values ($in), ' - + 'the stored list holds a value ($contains, an $or of them for several), or one value — and ' - + 're-check what each chart is supposed to show: the filter had been failing every chart. A ' - + 'filter that reaches the analytics door by any other route, such as a caller where or a ' - + 'dataset selection runtimeFilter, is still refused only when it is charted, with ' - + 'INVALID_FILTER / 400 naming the field and the path.', - }, - { - id: 'dataset-measure-aggregate-field-type-refused', - surface: 'dataset measure `aggregate` × `field` pairs (`DatasetMeasureSchema`, the rows ' - + 'inside `Dataset.measures[]`) over a TEMPORAL field — `date`, `datetime`, `time` — ' - + 'whose aggregate that declared `FieldType` cannot carry: `avg` and `sum` over any of ' - + 'the three. ⚠️ This entry is ONE OF TWO on this leg, and its scope sentence is kept ' - + 'as written: it covered the temporal class and nothing else when it was registered. ' - + 'The non-temporal `sum` / `avg` rows followed in a later change, which registered NO ' - + 'entry of its own — it declared `not-required (already-registered ' - + 'dataset-measure-aggregate-field-type-refused)` against THIS id — so its widening ' - + 'rides this entry\'s prescription rather than a separate one. The `count_distinct` ' - + 'row\'s JSON-stored types (`json`, `composite`, `repeater`, `record`, `location`, ' - + '`address`, `vector`, `multiselect`, `checkboxes`, `tags`) left the table in a third ' - + 'change, which likewise registered no entry and rides this one: no two backends compare ' - + 'those values for equality alike, so `count` is the aggregate that stays. The `min` / `max` rows ' - + 'over every class the table refuses are the second entry, ' - + '`dataset-measure-selecting-aggregate-field-type-refused`. ⇒ Read BOTH when ' - + 'migrating; there is no third', - replacement: 'an aggregate the field\'s type accepts, per ' - + '`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`): ' - + '`min` / `max` for a temporal field — both return a real instant of the field\'s own ' - + 'type — or `count` / `count_distinct`, which read no arithmetic off the value. ' - + 'A DURATION is not recoverable from an aggregate over instants: store it as a ' - + 'number (a computed "days open" field) and aggregate that. A `derived` measure whose `of` ' - + 'names a refused measure is fixed by fixing that measure, not the `derived` one', - reason: - 'Nothing between the author and the driver correlated a measure\'s ' - + 'aggregate with its field type, so `avg` over a `Field.datetime` compiled to ' - + '`AVG(col)` and reached the backend — where the ANSWER was decided by the dialect ' - + 'rather than by the data. Measured on both halves: SQLite coerces the column\'s ' - + 'canonical UTC text to a number by reading its leading digits, so ' - + '`avg(submitted_at)` over 2026-05 and 2025-01 returns `2025.5` — the average YEAR, ' - + 'no error, no log; PostgreSQL 16 answers `function avg(timestamp with time zone) ' - + 'does not exist` (SQLSTATE 42883). ⚠️ The two halves are not evidenced alike: the ' - + 'SQLite half is PINNED by a live `sql.js` suite in ' - + '`__tests__/aggregate-datetime-measure-refusal.test.ts`, while the Postgres half was ' - + 'MEASURED IN-SESSION on PostgreSQL 16.13 and is not pinned by any test — the live PG ' - + 'conformance job carries no cell for it. Nothing depends on it: the refusal is ' - + 'decided from declared metadata before a driver is reached. ⭐ The silent half is ' - + 'the dangerous one, and it is the DEV default: ' - + '`derived: { op: \'difference\', of: [avg_a, avg_b] }` over two ' - + 'such averages rendered `-0.85` on a tile labelled "average cycle time delta" — ' - + 'indistinguishable from a correct answer, which is the shape Prime Directive #12 ' - + 'exists to remove. Which pairs are accepted is therefore a contract, declared once ' - + 'in `@objectstack/spec` under the director\'s ruling of 2026-09-06 ("both legs, table ' - + 'in spec") and executed by the consumer legs; the ' - + 'compile-time leg (`dataset-compiler`, `service-analytics`) refuses the pair with ' - + '`DATASET_INVALID` / 400 before any query is built, using the declared type the ' - + 'host already supplies through `AnalyticsServiceConfig.sourceFieldMeta`. ' - + '⚠️ A `date` / `datetime` used as a DIMENSION — grouping, bucketing, date-range ' - + 'filtering — is untouched: this is about aggregation only.', - acceptanceCriteria: - 'Every dataset measure over a `date` / `datetime` / `time` field pairs that field with ' - + 'an `aggregate` the temporal class accepts — `min`, `max`, `count`, `count_distinct` ' - + '— and none pairs it with `avg` or `sum`. ⚠️ The criterion as WRITTEN reaches no ' - + 'further: a measure over a field of any other class was not judged by the leg this ' - + 'entry was registered for. It is covered all the same — by this entry\'s own ' - + 'prescription, widened by a later change (which registered `not-required` against this ' - + 'id rather than an entry of its own) to `sum` / `avg` over every field class, and by a ' - + 'third, the same way, to `count_distinct` over the JSON-stored types; and by ' - + '`dataset-measure-selecting-aggregate-field-type-refused` for `min` / ' - + '`max`. ⛔ There is no third entry to look for. At protocol major 18 as a whole, ' - + 'every refused pair in `AGGREGATE_FIELD_TYPE_COMPATIBILITY` is refused at the ' - + 'compile door. ' - + 'Accepted pairs compile and execute byte-identically to before ' - + '(`avg` over `number` / `currency`, `min` / `max` over `datetime`, `count` over ' - + 'anything); a refused pair answers `400 DATASET_INVALID` naming the measure, the ' - + 'field, its declared type and the accepted set, with no SQL emitted. The refusal ' - + 'stands down rather than guessing wherever the type cannot be resolved: no ' - + '`sourceFieldMeta` wired, an unknown field, or a `relationship.field` path whose ' - + 'column lives on a joined object.', - relevantWhen: { kind: 'stack-declares', keys: ['datasets'] }, - }, - { - id: 'dataset-measure-selecting-aggregate-field-type-refused', - surface: 'dataset measure `aggregate` × `field` pairs (`DatasetMeasureSchema`, the rows ' - + 'inside `Dataset.measures[]`) pairing `min` or `max` with a field whose declared ' - + '`FieldType` that aggregate cannot carry — every type outside the numeric, temporal ' - + 'and boolean classes. Named in full so an author can grep their own metadata: the ' - + 'string family (`text`, `textarea`, `email`, `url`, `phone`, `password`, `secret`, ' - + '`markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`), the option ' - + 'types (`select`, `radio`), the references (`lookup`, `master_detail`, `tree`, ' - + '`user`), `autonumber`, the multi-option types (`multiselect`, `checkboxes`, `tags`), ' - + 'the file family (`image`, `file`, `avatar`, `video`, `audio`), the structured-JSON ' - + 'types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) ' - + 'and `formula` — 37 field types × 2 aggregates = 74 pairs', - replacement: 'an aggregate the field\'s type accepts, per ' - + '`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`), or a ' - + 'different way of asking the question. ⚠️ There is no lossless rewrite, which is why ' - + 'this is a semantic TODO and not a D2 conversion: nothing can compute "the smallest ' - + 'text value" in a way every backend agrees on, so no transform can preserve the ' - + 'answer. The three routes an author actually has, per intent: ' - + '① the measure was COUNTING in disguise ("how many distinct owners") ⇒ ' - + '`count`, which accepts every type because it reads no value, or `count_distinct`, ' - + 'which accepts every type but the JSON-stored ones (the structured-JSON types and ' - + '`multiselect` / `checkboxes` / `tags`, whose values no two backends compare for ' - + 'equality alike); ' - + '② the measure wanted a FIRST or LAST RECORD ("the earliest-titled task") ⇒ that is ' - + 'a SORT on a list or report, which orders once in a declared direction, not an ' - + 'aggregate that asks each backend for its own smallest value; ' - + '③ the measure wanted a QUANTITY that happens to be stored as text or JSON ⇒ store ' - + 'it as a numeric or temporal field (a computed column) and aggregate that. ' - + 'A `derived` measure whose `of` names a refused measure is fixed by fixing that ' - + 'measure, not the `derived` one', - reason: - 'Director ruling B of 2026-09-13: the compile door enforces the table for every aggregate. ' - + 'The table refused these 74 pairs from the day it was declared and NOTHING executed the refusal: the compile ' - + 'leg (`dataset-compiler`, `service-analytics`) carried an explicit scope condition — ' - + '`if (!DERIVING_AGGREGATES.has(aggregate)) return;` — so `min` / `max` were never ' - + 'judged whatever the field type, and `service-analytics`\' `measureResultType` went ' - + 'further and typed `min` / `max` over the string classes as a supported `\'string\'` ' - + 'result and over a `formula` field from its declared `returnType`. ' - + 'Four declarations, three answers, one pair — the worst shape of declared≠enforced, ' - + 'because nobody could tell which sentence was the contract. ⭐ The divergence is ' - + 'real and it is the ORDER rather than the arithmetic: string order is ' - + 'collation-dependent, so two backends answer two different "smallest" values for one ' - + 'metadata document, and `min(jsonb)` does not exist on PostgreSQL at all — the same ' - + 'shape Prime Directive #12 exists to remove. The ruling settled all three ' - + 'sub-questions together rather than per field class, because one shared fixture drove ' - + 'members of both halves: the string classes stay REFUSED as the director\'s 2026-09-06 ' - + 'ruling put them (「`min`/`max` numeric plus `date`/`datetime`; everything else ' - + 'refused」) and the table is NOT amended; the non-string classes are refused AND ' - + 'enforced; and `formula` is refused on the table\'s own storage ground — it is ' - + 'VIRTUAL in SQL storage, no column is emitted, so no aggregate can be lowered to it ' - + 'whatever `returnType` says. ⚠️ The "ruled C — the table is to be AMENDED to accept ' - + 'the string rows" note the tree carried in two test files had no ruling behind it: the ' - + 'card it cited is closed as a duplicate with zero rulings on it, and ' - + 'the earlier recorded ruling on this table says the opposite. Business pull was measured ' - + 'and is zero — the shipped `min` / `max` cases were in-tree fixtures pinning a result ' - + 'TYPE, not customer datasets reading one. ⚠️ Confidence gap, recorded rather than ' - + 'hidden: customer datasets in the `cloud` repository were not readable when this was ' - + 'decided.', - acceptanceCriteria: - 'Every dataset measure declaring `aggregate: \'min\'` or `\'max\'` pairs it with a ' - + 'field the class accepts — the numeric class (`number`, `currency`, `percent`, ' - + '`rating`, `slider`, `progress`, `summary`), the temporal class (`date`, `datetime`, ' - + '`time`) or the boolean class (`boolean`, `toggle`) — and none pairs it with a field ' - + 'of any other declared type. Accepted pairs compile and execute byte-identically to ' - + 'before, including `min` / `max` over a temporal field, which still carries ' - + '`fields[].type: \'time\'`; a refused pair answers `400 DATASET_INVALID` naming the ' - + 'measure, the field, its declared type and the accepted set, with no SQL emitted. ' - + '⚠️ A `text` / `select` / `lookup` / `formula` field used as a DIMENSION — grouping, ' - + 'labelling, bucketing, filtering — is untouched, and so is `count` / `count_distinct` ' - + 'over one: this is about the two SELECTING aggregates only. The refusal stands down ' - + 'rather than guessing wherever the type cannot be resolved: no `sourceFieldMeta` ' - + 'wired, an unknown field, or a `relationship.field` path whose column lives on a ' - + 'joined object. A measure column over such a pair also stops carrying a corrected ' - + '`fields[].type`, because the pair no longer produces a column at all.', - relevantWhen: { kind: 'stack-declares', keys: ['datasets'] }, - }, - // #21220 (ADR-0049 enforce-or-remove) — an ADR-0021 dataset dimension's and - // measure's `field` is a column reference, the accept set the cube members it - // compiles to hold since `cube-member-sql-expression-retired`, from one shared - // declaration. The D3 entry of the `dataset-count-measure-empty-field-removed` - // family: that D2 conversion carries the one lossless repair (a `count` - // measure's empty `field` is dropped, and the measure still counts rows); this - // entry carries the rest, which has no mechanical rewrite — an expression - // becomes a measure filter, a derived measure or a field of the object, and - // only the author knows which. - { - id: 'dataset-member-field-expression-refused', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span AND a table cell. - surface: - 'datasets[].dimensions[].field and datasets[].measures[].field (ui.DatasetDimensionSchema.field / ' - + 'ui.DatasetMeasureSchema.field) authored as anything but a column reference — a SQL expression ' - + '(an arithmetic, an aggregate, a CASE, a subquery, a function call), a quoted or $-prefixed ' - + 'spelling, a padded or empty string, a broken path, or * on a dimension', - replacement: - 'a column reference: a field of the dataset\'s object (`amount`), or a relationship path ending ' - + 'in one (`account.amount`) whose relationships are declared in `include`; on a measure also ' - + '`\'*\'` for a count, and a count may omit `field` altogether (never `field: \'\'`). A derived ' - + 'value takes its ADR-0021 form: a conditional count or sum is a measure with its own structured ' - + '`filter` (`{ name: \'done_count\', aggregate: \'count\', filter: { status: \'done\' } }`), and a ' - + 'ratio, sum, difference or product of measures is `derived: { op, of: [...] }` over measures ' - + 'named in the same dataset (`{ name: \'done_rate\', derived: { op: \'ratio\', of: ' - + '[\'done_count\', \'task_count\'] }, format: \'0.0%\' }`). A dimension that bucketed a column ' - + 'with an expression has no expression form: group by the column itself, or keep the bucket as a ' - + 'field of the object and name that field', - reason: - 'The dataset layer was declared to take no raw SQL (ADR-0021 "zero raw SQL / zero raw ' - + 'expressions"), and its `field` was documented as a field or a relationship path, but the slot ' - + 'was a bare string and parsed anything — declared, never enforced (ADR-0049). The runtime had ' - + 'already closed the other end for an expression: the analytics dataset door refuses one with a ' - + '403 refusal, inline or saved, because an expression names no single field and no platform ' - + 'check can judge which fields it reads. So an expression could be saved and never answered. ' - + 'That door never judged an empty `field` — it skips one. The cube members a dataset compiles ' - + 'to were narrowed to the same accept set earlier (`cube-member-sql-expression-retired`); the ' - + 'dataset compiler copies `field` ' - + 'into the member\'s `sql` verbatim, so the two slots now share one declaration. A dimension ' - + 'additionally refuses `\'*\'`: grouping by every column is no axis, and both analytics ' - + 'strategies answered such a dimension with a 500 database fault. An empty string is refused on both: ' - + 'a dimension groups by nothing, and a count spells "no field" by omitting the key. One empty ' - + 'string had a working row and has a lossless repair: a `count` measure with `field: \'\'` (the ' - + 'shape a blank Field box in Studio\'s dataset inspector stores) compiled to the row count on ' - + 'SQLite\'s native-SQL path, and without the key it compiles to `COUNT(*)` — the D2 conversion ' - + '`dataset-count-measure-empty-field-removed` drops it from stored rows and sources. Everything ' - + 'else has no mechanical rewrite into a column: an expression becomes a measure filter, a derived ' - + 'measure or a field of the object, and a ratio changes scale on the way (a `derived` ratio is a ' - + '0–1 fraction, so an expression that multiplied by 100 returned percentage points). ADR-0021 / ' - + 'ADR-0049 / ADR-0087', - acceptanceCriteria: - 'Every dataset parses: `DatasetSchema`, the dataset write door and defineStack refuse a ' - + 'non-column `field` at `dimensions.N.field` / `measures.N.field` with a prescription that names ' - + 'the column-reference contract and the ADR-0021 form, so the sweep is mechanical — parse each ' - + 'dataset, and each refusal is one member to change. A count measure that carried `field: \'\'` ' - + 'loses the key by the D2 conversion, parses, and still counts rows; a non-count measure or a ' - + 'dimension with an empty `field` is left as stored and refused until it names a column. For ' - + 'each moved measure, a query over a fixture where the ' - + 'condition excludes rows returns the figure the expression meant (a ratio: the same value ' - + 'divided by 100 when the expression returned percentage points). A dimension or measure whose ' - + '`field` is a column or a relationship path parses byte-identically to before.', - conversionIds: ['dataset-count-measure-empty-field-removed'], - }, - { - id: 'datasource-config-mongo-options-credential-refused', - surface: 'datasource.config.options.auth.password (mongodb) — a login credential written ' + - 'into the MongoClient options passthrough', - replacement: 'remove the `auth` block from `options` (its other keys — `replicaSet`, `tls`, ' + - 'timeouts — stay legal) and bind the secret: the Setup → Datasources connection form\'s ' + - 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + - 'or a direct `external.credentialsRef` secrets-store reference, with the username kept in ' + - 'the URL (`mongodb://user@host/db`)', - reason: - 'The FOURTH spelling of the same inline secret: earlier publish refusals closed the ' + - 'top-level `password` key, the URL userinfo password and the credential-bearing URL query ' + - 'parameters — and the `options` passthrough stayed open one syntax over. `options: { auth: { username, password } }` ' + - 'parsed green, persisted the password cleartext into `sys_metadata` (served back by the ' + - 'ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block ' + - 'into `MongoCredentials` (measured), so the workaround was live, not inert. A non-empty ' + - 'string `auth.password` is now refused at publish with the binder prescription; ' + - '`auth.username` alone stays writable (the asymmetry the URL grammar keeps between its ' + - 'two userinfo halves — a username is not credential material), as do all non-credential ' + - 'passthrough options. The bound secret wins over a passthrough `auth` block at connect ' + - '(measured when the bound secret was made to reach the mongo client on its URL branch), ' + - 'so the replacement changes which ' + - 'store holds the secret, never which credential connects. There is no mechanical ' + - 'rewrite, for the same reason as the sibling entries ' + - '`datasource-config-inline-credential-refused`, `datasource-config-url-userinfo-refused` ' + - 'and `datasource-config-url-query-credential-refused`: moving the value requires ' + - 'ENCRYPTING it into a `sys_secret` row through a running secret binder, which a ' + - 'source-file transform cannot do — and auto-dropping only the nested password would ' + - 'leave an `auth` block the client refuses at construction (measured: `credentials must ' + - 'be an object with \'username\' and \'password\' properties`). Runtime-environment DSNs ' + - '(`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected ' + - 'by construction. The read path now also redacts the stored passthrough secrets ' + - '(`options.auth.password`, `options.proxyPassword`, TLS key material, ' + - '`AWS_SESSION_TOKEN`) instead of serving them back in cleartext.', - acceptanceCriteria: - 'Every mongodb datasource parses with no `auth.password` inside `config.options`; each ' + - 'affected datasource carries `external.credentialsRef` (or has its secret bound through ' + - 'the connection form) with the username in its URL, and still connects; no passthrough ' + - 'credential remains in any stored `sys_metadata` row or authored source.', - }, - { - id: 'datasource-config-options-nested-credential-spelling-refused', - surface: 'datasource.config.options.**: any credential-SPELLED key (`password`, `authToken`, ' + - 'or a former alias — `passwd`/`pwd`/`token`/`jwt`/`auth_token`/`authtoken`) holding a ' + - 'non-empty string at any object depth of the mongodb options passthrough', - replacement: 'remove the nested key (no measured client behaviour reads any such position ' + - 'other than `auth.password`, which has its own refusal); if a real secret must reach the ' + - 'connection, bind it — the Setup → Datasources connection form\'s secret field (encrypted ' + - 'into `sys_secret`, handle stored at `external.credentialsRef`) or a direct ' + - '`external.credentialsRef` reference', - reason: - 'The nested-position closure of the `datasource-config-mongo-options-credential-refused` ' + - 'family: that entry refused the one MEASURED login position (`options.auth.password`) and ' + - 'left every other nested spelling of the same secret accepted — `options.auth.token`, ' + - '`options.pool.password`, any credential-spelled key one object level down parsed green, ' + - 'persisted cleartext into `sys_metadata` (served back by the ordinary data API), and was ' + - 'served on the datasource read doors with `redactedConfigKeys: []` because the read-side ' + - 'nested judgment was a hand-enumerated path table. Publish now refuses a non-empty string ' + - 'under any credential SPELLING at any object depth of the passthrough — the same one ' + - 'spelling list the top level refuses and the read path redacts, so a nested position is ' + - 'treated identically to the top-level key it mirrors. Arrays are off the walk (row-shaped ' + - 'data is not config). The read path now also redacts these spellings at every depth, for ' + - 'every driver, and carries them forward on an untouched Save. There is no mechanical ' + - 'rewrite, for the same reason as the sibling credential entries: moving a value into ' + - '`sys_secret` requires a running secret binder, which a source transform cannot do — and ' + - 'unlike `auth.password`, a nested spelling at an unmeasured position buys nothing at ' + - 'connect, so the usual outcome is deletion, which only the author can confirm.', - acceptanceCriteria: - 'Every mongodb datasource parses with no non-empty credential-spelled string at any object ' + - 'depth of `config.options`; any real secret found there is re-bound through ' + - '`external.credentialsRef` (or the connection form) and the datasource still connects; no ' + - 'nested credential remains in any stored `sys_metadata` row or authored source.', - }, - { - id: 'datasource-config-postgres-url-unparseable-refused', - surface: 'datasource.config.url (postgres) — connection URLs the `pg` client cannot parse ' + - "(libpq's multi-host `h1:5432,h2:5433` form, a non-numeric port, a scheme-less non-URL, " + - 'a malformed percent-escape), plus the filesystem-reading query parameters ' + - '`?sslcert=` / `?sslkey=` / `?sslrootcert=`', - replacement: 'a single-host URL `pg` itself parses — ' + - '`postgresql://[user@][host][:port][/dbname][?params]` (unix-socket forms stay accepted: ' + - 'a leading-`/` path, `socket:`, or a percent-encoded socket host). For a multi-host ' + - 'cluster, point the URL at one node or at a proxy/pooler in front of the cluster — ' + - '`pg` does not implement libpq\'s multi-host DSN, so no spelling of it can connect. For ' + - 'certificate material, use the datasource-level `ssl` block (`ssl: { ca: …, cert: …, ' + - 'key: … }` next to `driver`) instead of file-path query parameters', - reason: - "`PostgresConfigSchema.url`'s own describe text documents the postgres URL grammar, but " + - 'until protocol 18 the value was only string-scanned for credentials (the URL userinfo ' + - 'password and credential query parameters) and `${…}` placeholders — deliberately so at ' + - 'the SHARED helper, whose refusal to parse is load-bearing for mongo\'s multi-host/`+srv` ' + - 'forms (`new URL()` rejects the multi-host form outright, and the mongo arm hands the ' + - 'authored URL to its client untouched). For postgres that leniency ' + - 'was no check at all: `pg@8.22.0` does not implement libpq\'s multi-host DSN — both ' + - "`pg-connection-string`'s `parse` and `pg`'s `ConnectionParameters` throw " + - '`TypeError [ERR_INVALID_URL]` on `postgresql://app@h1:5432,h2:5433/app` (measured) — ' + - 'so an operator could publish exactly that URL, see it saved, and discover only at ' + - 'connect time that it can never open a connection, via a bare `Invalid URL` whose ' + - '`input` field `pg` redacts. The refusal now asks the same grammar one door up: ' + - '`parse` from `pg-connection-string` (the parser `pg` itself uses) runs at publish, ' + - 'per-driver, and what it throws on is refused with the value\'s path named. Two ' + - 'adjacent shapes are refused as structurally unusable rather than parse-refused, both ' + - 'measured: a scheme-less value "parses" only by resolving against the parser\'s ' + - 'placeholder base (`postgres://base`), i.e. `pg` would connect to the literal host ' + - '`base` with the authored text as the database name; and `?sslcert=`/`?sslkey=`/' + - '`?sslrootcert=` make `parse` itself call `fs.readFileSync`, so the verdict would ' + - 'depend on the validating host\'s filesystem — certificate material already has its ' + - 'declared home in the datasource-level `ssl` block. There is no mechanical rewrite: a ' + - 'URL `pg` cannot parse does not carry enough structure to say which single host the ' + - 'author meant (a multi-host DSN names several on purpose), so the choice of target is ' + - "the author's. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass " + - 'through the publish door and are unaffected by construction.', - acceptanceCriteria: - 'Every postgres datasource parses with a `config.url` that `pg-connection-string` ' + - 'parses without throwing, that carries a scheme (or is a unix-socket path), and that ' + - 'carries no `?sslcert=`/`?sslkey=`/`?sslrootcert=` query parameter; each affected ' + - 'datasource still connects to the intended single host; certificate material, where ' + - 'needed, lives in the datasource-level `ssl` block; mongo/mysql/turso datasources are ' + - 'byte-identical before and after (their URL checks are unchanged).', - }, - { - id: 'datasource-config-url-query-credential-refused', - surface: 'datasource.config.url / datasource.config.syncUrl (turso) and ' + - 'datasource.config.url (postgres) — credential-bearing URL query parameters ' + - '(`?authToken=` on turso, `?password=` on postgres)', - replacement: 'the same URL with the credential query parameter removed (non-credential ' + - 'parameters such as `?tls=` / `?sslmode=` stay legal), plus the datasource secret binder: ' + - "the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, " + - 'handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` ' + - 'secrets-store reference', - reason: - 'The inline-credential closure refused the credential KEYS and the next refusal the URL ' + - 'userinfo spelling; the query string was the third spelling of the identical secret, one ' + - 'syntax over. `libsql://x.turso.io?authToken=eyJ…` landed in `sys_metadata` cleartext ' + - 'exactly as `config.authToken` did — and at connect `@libsql/core` assigns the URL token ' + - 'OVER the binder-injected one (measured), so the workaround also silently defeated the ' + - 'bound secret; `pg-connection-string` likewise honours `?password=` over userinfo ' + - '(measured). Only parameters a measured client actually reads are refused: mysql and ' + - 'mongo ignore `?password=` (measured), so their URLs are unaffected. ' + - 'Runtime-environment DSNs (`OS_DATABASE_URL`, `OS_DATABASE_AUTH_TOKEN` and friends) ' + - 'never pass through the publish door and are unaffected by construction. There is no ' + - 'mechanical rewrite, for the same reason as the sibling entries ' + - '`datasource-config-inline-credential-refused` and ' + - '`datasource-config-url-userinfo-refused`: moving the value requires ENCRYPTING it into ' + - 'a `sys_secret` row through a running secret binder and stripping the cleartext, which a ' + - 'source-file transform cannot do — auto-stripping the parameter alone would silently ' + - 'drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: ' + - 'placeholders in authored metadata are resolved by nothing and reach the database client ' + - 'verbatim (measured when the inline-credential refusal was built).', - acceptanceCriteria: - 'Every datasource parses with no credential-bearing query parameter in `config.url` / ' + - '`config.syncUrl` (no `?authToken=` on turso, no `?password=` on postgres); each ' + - 'affected datasource carries `external.credentialsRef` (or has its secret bound through ' + - 'the connection form) and still connects; no URL-embedded credential remains in any ' + - 'stored `sys_metadata` row or authored source.', - }, - // The COMPOSED-branch twin of `datasource-credentialsref-mongo-url-no-user-refused` - // (#9147 widening commit d491625c1's refinement). Same silent discard, one branch over, and a - // DIFFERENT remedy — which is why it is its own entry rather than a widened surface - // on that one: with no `url` the discrete `config.username` is the live field, so the - // fix is `config.username`, not the URL's userinfo. - { - id: 'datasource-credentialsref-mongo-composed-no-username-refused', - surface: 'datasource (mongodb) — `external.credentialsRef` bound while `config` authors no ' + - '`url` and names no `username`', - replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + - 'add `username` to `config` so the bound secret is interpolated beside it into the ' + - 'composed connection URI at connect — or, for a datasource genuinely meant to ' + - 'connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the ' + - 'orphaned `sys_secret` row via the Setup → Datasources form). Authoring a `config.url` ' + - 'that names a user is a third valid shape, judged by the prescription of the sibling ' + - 'URL-branch entry, `datasource-credentialsref-mongo-url-no-user-refused`.', - reason: - 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + - 'every door it passed. With no `config.url` the driver factory COMPOSES the connection ' + - 'URI from the discrete fields, and the bound secret has exactly one route into it — the ' + - 'userinfo written beside a username (`buildMongoUrl`: `const auth = user ? … : \'\'`). A ' + - 'falsy `username` closes that route, and the branch has no second one: `buildMongoAuth` ' + - 'returns early when there is no `url`, because the composed branch injects THROUGH the ' + - 'URI it builds rather than beside it. So `credentialsRef` bound with no `url` and no ' + - '`username` composed `mongodb://host:port/db`, connected ANONYMOUSLY, and told the ' + - 'operator nothing. Nothing can be fabricated to rescue it: a MongoDB handshake cannot ' + - 'authenticate from a password alone — the same measured asymmetry behind the sibling ' + - 'URL-branch refusal. Both branches had always agreed on this input, so this inherits that ' + - 'ruling rather than re-opening it, and lands at the same authoring/publish door — the one ' + - 'place both halves are visible at once — as the "absence must be loud" half of the family ' + - 'of driver-factory arms, closed one driver at a time, that each dropped something declared ' + - 'without a word: the optional-driver arms that answered a missing package with no remedy, ' + - 'the turso arm that never read its bound secret, and the mysql and mongo DSN branches that ' + - 'discarded one. Deliberately NOT refused, each measured: a ' + - 'discrete `username` that is present and non-empty (the secret is live there — the ' + - 'composed branch has always interpolated it), an empty-string `credentialsRef` (not a ' + - 'binding — the connect path resolves under a truthy check), a non-string `username` (the ' + - 'driver config gate already reports the type error), and every other driver arm (the ' + - 'postgres equivalent is judged on its own client\'s measurement, never inherited — `pg` ' + - 'receives the bound password regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the ' + - 'sibling entry\'s present-but-empty userinfo carve-out: there MongoClient itself throws ' + - '(`URI contained empty userinfo section`) so the shape is already loud, while here ' + - '`username: \'\'` composes the same userinfo-free URI and connects — silently. There is ' + - 'no mechanical rewrite because the valid fixes are CONTRADICTORY intents — authenticate ' + - '(name the user) versus anonymous (drop the binding) — and choosing between them requires ' + - 'knowing what the datasource is for.', - acceptanceCriteria: - 'Every mongodb datasource that binds `external.credentialsRef` and authors no `config.url` ' + - 'names a non-empty `config.username` and connects authenticated as that user; every ' + - 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource parse ' + - 'reports this composed-branch refusal.', - }, - { - id: 'datasource-credentialsref-mongo-url-no-user-refused', - surface: 'datasource (mongodb) — `external.credentialsRef` bound while `config.url` names ' + - 'no user in its userinfo', - replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + - 'add the username to the URL\'s userinfo (`mongodb://user@host/db`) so the bound secret ' + - 'is injected at connect — or, for a datasource genuinely meant to connect ' + - 'unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned ' + - '`sys_secret` row via the Setup → Datasources form)', - reason: - 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + - 'every door it passed. MongoClient credentials need a username as well as a password, ' + - 'and with `url` present the discrete `username` field is superseded — the only place ' + - 'the username can come from is the URL\'s own userinfo. So the connect-time injection of ' + - 'the bound secret on the URL branch is conditional on the URL naming a user: `mongodb://app@host/db` + bound secret ' + - 'authenticates, while `mongodb://host/db` + bound secret connects ANONYMOUSLY with the ' + - 'secret unused and the operator told nothing. Injecting anyway was measured worse ' + - '(mongodb@7.5.0): fabricating an empty username turns a connection that works ' + - 'anonymously today into a guaranteed handshake failure, and refusing at connect would ' + - 'contradict `MongoConfigSchema.url`\'s published contract ("bind the secret … and it is ' + - 'injected at connect time") while planting a per-branch asymmetry inside the driver ' + - 'factory — the defect class closed when each DSN branch was made to inject the bound ' + - 'secret its composed branch already used. The refusal therefore lands at the ' + - 'authoring/publish door, the one place both halves are visible at once, as the ' + - '"absence must be loud" half of the family of driver-factory arms, closed one driver at a ' + - 'time, that each dropped something declared without a word: the optional-driver arms that ' + - 'answered a missing package with no remedy, the turso arm that never read its bound ' + - 'secret, and the mysql and mongo DSN branches that discarded one. Deliberately ' + - 'NOT refused, each measured: the present-but-empty userinfo forms (`mongodb://@h/db`, ' + - '`mongodb://:p@h/db` — MongoClient itself throws `MongoParseError: URI contained empty ' + - 'userinfo section`), an empty-string `credentialsRef` (not a binding — the connect path ' + - 'resolves under a truthy check), the composed branch (no `url`, where the discrete ' + - '`username` is live), and every other driver arm (the postgres equivalent is judged on ' + - 'its own client\'s measurement, never inherited — `pg` injects on a user-less DSN by its ' + - 'own measured mechanism). There is no mechanical rewrite because the two valid fixes are ' + - 'CONTRADICTORY intents — authenticate (add the username) versus anonymous (drop the ' + - 'binding) — and choosing between them requires knowing what the datasource is for.', - acceptanceCriteria: - 'Every mongodb datasource that binds `external.credentialsRef` and authors `config.url` ' + - 'has a username in that URL\'s userinfo and connects authenticated as that user; every ' + - 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource ' + - 'parse reports this URL-branch refusal.', - }, - // ADR-0120 D1 / D2 / D5a, staged by D7 to protocol 18: the declared index's - // positional `unique: true` is refused, and the chain respells it `'global'`. - { - id: 'declared-index-bare-unique-true-retired', - surface: '`indexes[].unique: true` on a declared index (`objects[]` and `objectExtensions[]`) — ' - + 'the bare boolean, the one `unique` spelling whose scope was positional', - replacement: 'a stated scope: `unique: \'global\'` (one holder across the whole installation — ' - + 'exactly the index bare `true` built, which is what the chain writes) or ' - + '`unique: \'organization\'` (one holder per organization — the driver prepends the NULL-safe ' - + 'organization key part `COALESCE(organization_id, \'__global__\')` to `fields` at ' - + 'registration). `unique: false` / omitted is unchanged, and field-level `unique: true` is ' - + 'unchanged and stays valid (it means per organization there)', - reason: - 'The mechanical rewrite keeps every index exactly as it was built — `\'global\'` IS the verbatim ' - + 'column list bare `true` materialized, so nothing on disk changes. What the chain cannot know ' - + 'is what the author MEANT. On a declared index bare `true` read like "unique per ' - + 'organization" to anyone who knew the field-level meaning, and silently built an ' - + 'installation-wide constraint instead: an index meant per organization has been refusing a ' - + 'second organization\'s value all along, and its refusal told that organization somebody ' - + 'else holds it. Each respelled index is therefore a decision the owner makes once: keep ' - + '`\'global\'` for a genuinely installation-wide key (a hostname, an external provider id, an ' - + 'engine dedup key), or move it to `\'organization\'` so each organization may hold the value ' - + 'once — a change to the physical index that `os migrate plan` shows before anything is ' - + 'applied.', - acceptanceCriteria: - 'No declared index in the sources carries `unique: true`: `os validate` passes, and every ' - + 'stored `object` row reads back with `\'global\'` where it held bare `true`. `os migrate plan` ' - + 'against the existing database shows no index operation for an index kept at `\'global\'`. ' - + 'Each index moved to `\'organization\'` appears in that plan as a planned index change.', - conversionIds: ['declared-index-unique-scope'], - }, - { - id: 'device-request-response-interval-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'DeviceRequestResponse.interval (api/auth-endpoints.zod.ts) — the polling cadence ' - + 'in the device-flow response body', - replacement: 'intervalSeconds — rename the key; the value (seconds, default 2) is unchanged', - reason: - 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' - + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' - + 'value, never only in the describe prose, and no existing offender is grandfathered. ' - + 'This key was ATTRIBUTED to RFC 8628 by the campaign card and reached this card only after ' - + 'the attribution failed verification, so the evidence is recorded here rather than left in a ' - + 'PR body. Ruling B exempts a key that mirrors a name fixed outside this repo, declared on the ' - + 'schema as .meta({ externalVocabulary }) — and DeviceRequestResponseSchema does not mirror ' - + 'RFC 8628 as a SET: `code` is not `device_code`, `verificationUrl` is not ' - + '`verification_uri`, and `expiresAt` is not `expires_in` — a different name AND a different ' - + 'type, an ISO-8601 instant where the RFC carries a relative lifetime. A schema that has ' - + 'already renamed every RFC field it carries into house style cannot claim the standard fixes ' - + 'the one name it left bare. So it is a rename, and deliberately NOT a marker: a wrongly ' - + 'marked key is exempted permanently and silently, while a wrongly renamed one is visible. ' - + 'A SEMANTIC entry rather than a D2 conversion because the shape is RUNTIME-EMITTED — the ' - + 'body of POST /api/v1/auth/device/request, never a stack collection member and never a ' - + 'sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087.', - acceptanceCriteria: - 'No producer emits `interval` and no consumer reads it. The old spelling is a retiredKey() ' - + 'tombstone, so authoring it fails tsc (the key types never) and fails the parse with the ' - + 'rename prescription. Concretely: a CLI or client polling the device-token endpoint reads ' - + '`intervalSeconds` off the request response and waits that many seconds between polls, ' - + 'exactly as `interval` did — the value and its unit are unchanged, only the key name moves.', - }, - { - id: 'document-schemas-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the document family, retired whole: the four defs data/DocumentTemplate, data/Document, ' - + 'data/ESignatureConfig and data/DocumentVersion, and every name data/document.zod.ts ' - + 'exported from @objectstack/spec/data (DocumentTemplateSchema, DocumentSchema, ' - + 'ESignatureConfigSchema, DocumentVersionSchema, their z.input aliases and their Parsed ' - + 'aliases)', - replacement: - 'a printable document is a PAGE that declares `print` — no separate template type. Author ' - + 'the document (an invoice, a delivery order, a letter, a report) as an ordinary `page` ' - + 'with `kind: \'full\'`, its blocks in `regions` drawn from the printable block subset ' - + '(`record:details`, `record:highlights`, `record:line_items`, `element:text`, ' - + '`element:image` and the rest of PRINTABLE_PAGE_COMPONENT_TYPES), and a `print` block for ' - + 'the paper, margins, running header and footer, page numbers and page-break hints. A ' - + 'docx-with-placeholders template, a stored document with versions, and an e-signature ' - + 'workflow have no replacement, because nothing on the platform ever merged, stored or ' - + 'sent any of them; a document record the organisation keeps is ordinary object data, and ' - + 'its files are `sys_file` attachments', - reason: - 'ADR-0049 enforce-or-remove, by the ruling of record on the PDF / print document card ' - + '(letter B′, 2026-10-08): "A document is a page with a print declaration; no new template ' - + 'type", and "The zero-reader DocumentTemplateSchema, DocumentSchema and ' - + 'ESignatureConfigSchema retire in v18 under ADR-0049 with ADR-0087 entries, so that ' - + '\'template\' means one thing." Four defs sat on the exported surface and in the generated ' - + 'reference docs — a docx template with typed placeholders, a document with versioning, ' - + 'access control and an e-signature block, and the signer workflow — and were read by ' - + 'NOTHING: they were exported from `@objectstack/spec/data`, mounted by no `stack.zod.ts` ' - + 'key, registered as no metadata type and absent from every liveness ledger, and the ' - + 'reader census over every package, app and example outside `packages/spec` (generated ' - + 'reference docs, release notes and changelogs aside), over objectui at its pin and its ' - + 'main, and over hotcrm returned zero hits for every exported name, against lit controls. ' - + 'Keeping them would have given an author two meanings of "template" — the dead docx one ' - + 'and the print page — and an AI that imports DocumentTemplateSchema a schema no runtime ' - + 'reads. DocumentVersionSchema had one carrier, DocumentSchema.versioning, and leaves with ' - + 'it. The ESignatureConfig deadline-key tombstones (RETIRED_KEYS_BY_MAJOR[18], D3 ' - + '`esignature-config-deadline-keys-retired`) leave with their def\'s source; their registry ' - + 'entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a ' - + 'normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its ' - + 'collections; none of these schemas is either, so a conversion would be a transform with no ' - + 'seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and ' - + 'with no carrier key there is no shape on which a tombstone could sit. `cloud` and real ' - + 'customer code are UNMEASURED.', - acceptanceCriteria: - 'No code imports DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema or ' - + 'DocumentVersionSchema — or any of their type aliases — from @objectstack/spec or ' - + '@objectstack/spec/data: every such import is TS2305 after upgrade. A printable document ' - + 'is authored as a page with a `print` block, which `os validate` checks: the parse refuses ' - + '`print` on a page that does not print its own authored blocks, and the printable block ' - + 'subset refuses any other block inside it. `data/DocumentSchemaValidation` (the NoSQL ' - + 'driver\'s schema-validation block, a different declaration) is unaffected. The four defs ' - + 'are absent from `json-schema.manifest/data.json`, the api-surface / declaration-map / ' - + 'export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is ' - + 'deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these ' - + 'shapes, so removing them removes no behaviour.', - }, - { - id: 'driver-options-timeout-to-timeout-ms', - surface: '`DriverOptions.timeout` (data/driver.zod.ts) — the per-call options argument of every `IDataDriver` method', - replacement: '`DriverOptions.timeoutMs` (milliseconds) — rename the key; the value is unchanged', - reason: - 'Maintainer ruling 2026-09-02 on duration units (ruled B — no grandfathered baseline): the unit of a ' - + 'duration-shaped `z.number()` key lives in the key NAME, never only in the description. ' - + '`timeout` said "Timeout in ms" in prose and nothing else. Tombstoned with retiredKey ' - + '(`DriverOptionsSchema` is not strict, so a bare deletion would strip the old key in ' - + 'silence) and registered as `data/DriverOptions:timeout`. Why a semantic entry and not a ' - + 'D2 conversion: a `DriverOptions` object is built at a call site and handed to a driver ' - + 'method — it is not a stack collection member and is never stored, so the chain has no ' - + 'seam that runs on it. Measured on ca46f8f12: no in-repo driver reads the key (the ' - + 'engine\'s own per-call budget is a separate `timeoutMs` on its options), so callers move ' - + 'their spelling with no behaviour change.', - acceptanceCriteria: - 'No caller passes `{ timeout }` in a `DriverOptions` argument; a call spelling it fails to ' - + 'compile (input type `never`) and `DriverOptionsSchema.parse({ timeout: 5000 })` fails with ' - + 'the rename prescription naming `timeoutMs`; `{ timeoutMs: 5000 }` parses to the same ' - + 'number.', - }, - // #21226 — registered by the change that put the caller's tenant scope on the - // remote libSQL face's doors, not by a later reconciliation. A driver call is - // code, never stack metadata, so there is no authored source for the chain to - // rewrite and no schema tombstone: this entry is the migration channel beside - // the changeset's FROM → TO table, as for its sibling - // `driver-upsert-cross-organization-conflict-refused`. - { - id: 'driver-remote-doors-tenant-scoped', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'IDataDriver find, findOne, count, aggregate, update, delete, bulkUpdate, bulkDelete, ' - + 'updateMany, deleteMany, create and bulkCreate on TursoDriver\'s remote (libSQL) face, ' - + 'called with a tenant context', - replacement: - 'a tenant-scoped call on the remote face reaches the rows the local face reaches for the ' - + 'same options: the caller\'s organization, rows with no organization, and under the group ' - + 'posture the caller\'s membership set. A by-id `update` outside that scope answers `null`, ' - + 'a by-id `delete` answers `false`, and a predicate write counts only the rows in scope. ' - + '`create` stamps the caller\'s organization on a row that names none. To reach rows of ' - + 'every organization, call without `tenantId`, as on the local face', - reason: - 'The engine hands every driver the caller\'s organization as `DriverOptions.tenantId`, and ' - + 'the group posture\'s membership set as `tenantIds` (ADR-0131 D8, ADR-0105 D2). ' - + 'TursoDriver\'s local face applies them through `SqlDriver.applyTenantScope` on every read ' - + 'and on every update and delete predicate, and stamps the organization on insert. Its remote ' - + 'face compiles its own statements, and its doors received no driver options: their ' - + 'statements carried the caller\'s filter and nothing else, and a remote `create` wrote no ' - + 'organization. Where the engine\'s Layer 0 wall composes a predicate above the driver, that ' - + 'wall held other organizations\' rows back. Where it composes none (the posture in which ' - + 'Layer 0 is inert, or an elevated caller that carries its organization), the driver scope ' - + 'is the only fence, and on the remote face there was none. The remote doors now compile the ' - + 'local face\'s own predicate, by asking the same chokepoint, and AND it onto each ' - + 'statement, so the two faces answer the same rows by construction. The remote `create` ' - + 'stamps the organization as the local `create` does. `distinct` still refuses a ' - + 'tenant-scoped call on the remote face. The call signatures are unchanged, so nothing ' - + 'reaches the compiler. Code that relied on a tenant-scoped remote call reaching another ' - + 'organization\'s rows now gets the miss answer each door already declares, and a remote ' - + '`create` that relied on landing a row with no organization now finds it under the ' - + 'caller\'s. ADR-0131 D8 / ADR-0087.', - acceptanceCriteria: - 'No caller of a remote-mode TursoDriver passes `tenantId` and expects to read, count, ' - + 'aggregate, update or delete a row of another organization; a caller that means to reach ' - + 'every organization calls without a tenant context, as on the local face. No caller relies ' - + 'on a tenant-scoped remote `create` landing a row with no organization. Proven when a ' - + 'tenant-scoped call on each door answers the same rows on the remote face as on the local ' - + 'face for the same options: another organization\'s row excluded, `null`, `false` or ' - + 'untouched, and the caller\'s own rows and rows with no organization answered as before.', - }, - // Registered by the change that removed the three methods (#20822, PR #20988), - // not by a later reconciliation. - { - id: 'driver-sql-calendar-day-methods-removed', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'SqlDriver protected methods calendarDayExclusiveUpperBound, calendarDayUpperBoundRewrite ' - + 'and calendarDayBetweenRewrite (inherited by SqliteWasmDriver and TursoDriver)', - replacement: - 'lower the filter before the driver compiles it — ' - + '`lowerFilterCondition(where, { isDatetimeColumn })` from `@objectstack/spec/data` — ' - + 'instead of calling or overriding a driver method; leave `isDatetimeColumn` out and the ' - + 'whole-day rule applies to every column', - reason: - 'The exported `SqlDriver` class of `@objectstack/driver-sql` declared three `protected` ' - + 'methods that were its own copy of the whole-day rule of ADR-0053 D-D1: on a `datetime` ' - + 'column, a bare-day inclusive upper bound (`$lte \'2026-01-05\'`, or the maximum of a ' - + '`$between`) compiled as `$lt` the next day, and the last supported day (`9999-12-31`) ' - + 'compiled as no upper bound. `calendarDayExclusiveUpperBound` computed that bound, ' - + '`calendarDayUpperBoundRewrite` rewrote a `$lte` with it, and `calendarDayBetweenRewrite` ' - + 'rewrote a `$between` with it. The shared filter lowering in `@objectstack/spec/data` ' - + '(`lowerFilterCondition`) now applies the rule once, at the engine\'s `where` seam and ' - + 'at the RLS compile seam, before any driver sees the filter, so the driver\'s copy was ' - + 'deleted, and the three methods with it (ADR-0053 D-D1 items 5 and 9, as amended). Two ' - + 'consequences reach a subclass, and only one of them reaches the compiler. A subclass ' - + 'that CALLS one of the three, or declares one with `override`, stops compiling: ' - + 'TS2339 and TS4113, measured with tsc 6.0.3 against the published declaration. A ' - + 'subclass that re-declares one ' - + 'WITHOUT `override` compiles cleanly, with `noImplicitOverride` off and also with it ' - + 'on, because the base class no longer has a member to override. That declaration is ' - + 'never called: the driver calls none of the three any more, so the override goes ' - + 'silently dead and the rule it carried stops applying. An untyped JS subclass gets a ' - + '`TypeError` at a call and the same silent death for an override. A driver subclass is ' - + 'CODE, never stack metadata, so there is no authored source for the chain to rewrite ' - + 'and no schema tombstone. For the silent half, this entry is the only notice there is: ' - + 'the same disposition as `driver-sql-distinct-bare-filter-typed` and ' - + '`runtime-httpserver-wrapper-retired`. In this repo the one caller was `TursoDriver`\'s ' - + 'remote face, changed in the same PR. A read through the engine or the RLS compile seam ' - + 'answers as before, because the seam lowers first; a filter handed to the driver ' - + 'directly is now compared as written. ADR-0053 / ADR-0087.', - acceptanceCriteria: - 'No subclass of `SqlDriver`, `SqliteWasmDriver` or `TursoDriver` names ' - + '`calendarDayExclusiveUpperBound`, `calendarDayUpperBoundRewrite` or ' - + '`calendarDayBetweenRewrite`. Search the source for the three names rather than relying ' - + 'on tsc, because a re-declaration without `override` compiles and is never called. A ' - + 'subclass that called one to widen a bound hands the driver a lowered filter instead: ' - + '`lowerFilterCondition(where, { isDatetimeColumn })`. One that overrode one to change ' - + 'which columns take the whole-day bound passes its own `isDatetimeColumn`. Proven when, ' - + 'on a `datetime` column, `where: { signed_on: { $lte: \'2026-01-05\' } }` reaches the ' - + 'driver through that path and returns a row stamped `2026-01-05T15:00:00.000Z`. Handed ' - + 'to the driver unlowered, the same filter compares against that day\'s midnight and ' - + 'drops the row. A host that reads only through the engine (`find`, `count`, ' - + '`aggregate`) or through RLS policies needs no change: those seams lower the filter ' - + 'before the driver sees it.', - }, - { - id: 'driver-sql-unresolvable-where-column-refused', - surface: - 'a `where` naming a column the table does not have, on `driver-sql` (and its ' - + '`TursoDriver` / `SqliteWasmDriver` subclasses) — `find()` / `findOne()` answered ' - + '`[]` and `count()` threw the dialect\'s own error; both now refuse with ' - + '`INVALID_FILTER` / 400. The `aggregate()` door of `TursoDriver`\'s remote face ' - + 'answered `[]` for a missing column or a missing table, and now refuses as the local ' - + 'face does: `INVALID_FILTER` / 400 for a `where` column the table lacks, ' - + '`INVALID_FIELD` / 400 for a `groupBy` or aggregation column the table lacks, and ' - + '`DATABASE_ERROR` / 500 for an object whose table is absent', - replacement: - 'name a column the object actually has, or run schema sync so a recently declared ' - + 'field exists as a column before filtering, grouping or aggregating on it, and so ' - + 'the object\'s table exists. A caller that legitimately wants ' - + '"no rows unless this matches" gets that from a predicate over a real column; there ' - + 'is no spelling of an unresolvable column that means "match nothing", which is ' - + 'exactly what the old empty list was mistaken for', - reason: - 'One predicate had two answers. `SqlDriver.findRows()` carries the unknown-column ' - + 'recovery ladder (an unsortable query loses its ORDER BY, not its rows), whose rungs ' - + 'are all built from `buildBase()` — and ' - + '`buildBase()` always re-applies `query.where`. So the ladder can drop a projection ' - + 'and can drop an ORDER BY, but it can never drop the clause that failed when the ' - + 'unresolvable column is in the WHERE: both rungs raise the same error and the method ' - + 'fell to `return []`. `SqlDriver.count()` runs a separate statement with no ladder at ' - + 'all, so the identical predicate threw. Measured on better-sqlite3, one seeded row: ' - + "`where { 'title.x': 'y' }` gave `find()` 0 rows and NO error, while `count()` threw " - + "`code: 'SQLITE_ERROR'`, `status: undefined`, message `select count(*) as \\`count\\` " - + "from \\`task\\` where \\`title\\`.\\`x\\` = 'y' - no such column: title.x`.\n\n" - + 'A list view calls both halves, so one query produced an empty page from the rows ' - + 'half and a 500-shaped failure from the total half — and a caller reading only the ' - + 'rows got a silent empty page saying "no records exist" for what was really "your ' - + 'predicate never ran". That is the single most AI-legible failure to get wrong: an ' - + 'agent reads "no matching records" and writes its next query on that belief. The ' - + "thrown half was no better — the dialect's own `code`, no `status` (an unclassified " - + '5xx at the REST boundary rather than a caller mistake), and the statement\'s bound ' - + 'literals inlined in the message, the same predicate-text disclosure shape the ' - + 'driver\'s field-reference filter refusals had already been made to stop echoing ' - + '(the full diagnostic goes to the server log, never the response).\n\n' - + 'Ruled by the maintainer on 2026-08-15: refuse BOTH halves with `INVALID_FILTER` / ' - + '400, naming the column. The envelope is not minted here — it is what every sibling ' - + 'refusal on ' - + 'this path already answers, required on both SQL drivers by ' - + '`cross-field-conformance-cases.ts` and pinned by `sql-driver-boolean-identity.test.ts` ' - + 'and `sql-driver-cross-field-conformance.test.ts` — so what closes is a declared-vs-' - + 'enforced gap, not a new posture. Recover-both was excluded by the ruling\'s own ' - + 'argument: dropping a WHERE returns rows the caller explicitly excluded, and the ' - + 'ladder\'s own premise — rows matter more than their order — is an argument about ' - + 'how rows are PRESENTED, which does not transfer to a predicate. The ladder KEEPS both of its recoveries — ' - + 'only the WHERE-failure terminal became a refusal.\n\n' - + 'Reach, stated rather than assumed: the refusal fires on the wordings the ladder has ' - + 'always recognised — SQLite (`no such column: x`) and Postgres (`column "x" does not ' - + "exist`). MySQL spells it `Unknown column 'x' in 'where clause'`, which neither arm " - + 'matches, so on MySQL this condition still travels out as the raw dialect error; ' - + 'widening that predicate would also hand MySQL the ladder\'s recoveries it has never had, ' - + 'which is an accept-set change in the opposite direction and is filed separately.\n\n' - + 'Addendum 2026-08-16. The paragraph above is kept as the ' - + 'state at registration; this amends it. MySQL joined the one shared predicate, so the ' - + 'reach is now all three dialects this driver speaks, and a MySQL reader must NOT ' - + 'conclude the migration does not apply — it applies exactly as it does on SQLite and ' - + 'Postgres. Because that predicate serves both consumers at once, the accept set moved ' - + "in BOTH directions on MySQL in one line, and both halves were ruled together (option " - + 'A, maintainer, 2026-08-16; a split predicate — the envelope while withholding the ' - + 'recoveries — was considered and refused). (1) THE ENVELOPE: an unresolvable WHERE ' - + 'column now refuses with the same `INVALID_FILTER` / 400 naming the column, instead of ' - + "travelling out as the raw `ER_BAD_FIELD_ERROR` with the statement's bound literals " - + 'inlined — that disclosure shape closed on the last dialect that still had it. ' - + '(2) THE RECOVERIES: MySQL also gained the ladder\'s projection and ORDER-BY ' - + 'recoveries it had never had, so an unresolvable column in a projection or an ORDER BY now returns ' - + 'recovered rows where it used to throw. The two halves arrive together because ' - + '`ER_BAD_FIELD_ERROR` spells every clause position with one sentence — ' - + "`Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'` — so all " - + 'three ride one arm of the predicate; that is pinned as the ruled direction by the ' - + 'widened predicate sweep in `sql-driver-unresolvable-where-column-refusal.test.ts`. ' - + 'The widening can never drop a predicate: every ladder rung is rebuilt from ' - + '`buildBase()`, which unconditionally re-applies `query.where`. Unchanged by the ' - + 'ruling: a DOTTED filter key is still classified per dialect (Postgres raises ' - + 'undefined_table, which neither arm matches), the axis owned by the dotted-filter ' - + 'verdict, which refuses a dotted key whose head is a relation, a formula or a plain ' - + 'column at the protocol and engine doors. The entry id, ' - + 'surface and prescription are unchanged — this is a text amendment, not a new ' - + 'migration.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`engine-dotted-projection-refused` and `engine-find-formula-filter-refused` — there ' - + 'is no `sys_metadata` row for the D2 chain to rewrite and this entry is the ' - + 'notification channel. No mechanical rewrite exists: the platform cannot know which ' - + 'real column a mistyped filter key meant, and guessing one would answer with rows the ' - + 'caller never asked for. ADR-0112.', - acceptanceCriteria: - 'No saved report `query.filter`, flow condition, sharing/permission rule or hook ' - + 'filters on a name the queried object has no column for, and no report or dashboard ' - + 'groups by, or aggregates over, such a name. Reads, counts and aggregates complete ' - + 'with no `INVALID_FILTER` whose message says "names a column that object" or "names a ' - + 'column the database could not resolve", no `INVALID_FIELD` whose message says "has ' - + 'no column for, so the aggregate never ran", and no aggregate refused with ' - + '`DATABASE_ERROR` / 500 because the object\'s table is absent. Where a filter key was a relationship ' - + 'traversal spelled as a dotted path, rewrite it against a column on the queried ' - + 'object — the driver never resolved such a path and answered `[]`, so any list that ' - + 'looked correct under one was already showing nothing.', - }, - { - id: 'driver-sql-upsert-cross-row-identity-merge-refused', - surface: - 'an `upsert` with no `conflictKeys` — or naming the primary key — on a MySQL table that ' - + 'carries a non-primary UNIQUE key, in `driver-sql` (and its `TursoDriver` / ' - + '`SqliteWasmDriver` subclasses). It merged onto whichever UNIQUE key the row collided ' - + 'with, silently rewriting a DIFFERENT row; when that happens the write is now rolled ' - + 'back and the call refuses with `VALIDATION_ERROR` / 400', - replacement: - 'name the business key you meant to merge on (`conflictKeys`), so the intent is checkable ' - + 'and the pre-flight can answer for it; or drop/rename the extra UNIQUE key so the ' - + 'primary key is the only thing a row can collide on; or run the object on SQLite / ' - + 'PostgreSQL, which compile `ON CONFLICT (...)` and honour the named arbiter. There is ' - + 'no spelling of "merge onto whatever key happens to collide" that was ever correct — ' - + 'the old behaviour rewrote a row the caller never identified', - reason: - 'MySQL\'s only merge statement is `ON DUPLICATE KEY UPDATE`, which carries NO conflict ' - + 'target: knex drops the named keys before the statement leaves the process, so the ' - + 'merge lands on whichever UNIQUE index the row collides with first. Two earlier ' - + 'pre-flight refusals closed the half where no unique index backed a caller-named ' - + 'target and the half where a rival unique key could absorb a caller-named one. This ' - + 'entry closes the residue those two left by ' - + 'construction: the `conflictKeys`-less call and the `[\'id\']` call, which compile ' - + 'byte-identically and which no pre-flight can judge, because neither names anything.\n\n' - + 'Measured on live MySQL 8.0.46 through the same knex + `mysql2` path `upsert` takes, ' - + '`email` and `tax_id` both `unique: true`, NO `conflictKeys` at all: seeding ' - + "`{email:'d@b.com', tax_id:'T-9', title:'first'}` inserted id `iVvD35rMk4BIayYc`, and " - + "`{email:'e@b.com', tax_id:'T-9', title:'second'}` then RESOLVED with no error — one " - + 'row, the SEEDED one, its `email` rewritten `d@b.com` -> `e@b.com`. The id the caller ' - + 'was handed back was in no row at all. The identical pair on SQLite raises `UNIQUE ' - + 'constraint failed: ….tax_id` and leaves the seeded row untouched.\n\n' - + 'Ruled by the maintainer on 2026-08-15, as a contract principle rather than a MySQL ' - + 'detail: *an ' - + '`upsert` must never modify a row whose identity the caller did not supply and whose ' - + 'conflict key it did not name.* Enforcement was delegated to the drivers lane with ' - + 'blanket refusal excluded by name — refusing every `conflictKeys`-less upsert on any ' - + 'table with a business unique key would refuse the platform\'s own lifecycle archiver. ' - + 'Measured before choosing: on this path the merge target is always the primary key, so ' - + 'EVERY non-primary UNIQUE key is a rival and "narrowed to tables carrying a rival key" ' - + 'and "every table with a business unique key" are the same set — the narrowing that ' - + 'made a pre-flight refusal proportionate for a caller-named target does not exist ' - + 'here.\n\n' - + 'So the enforcement is a post-hoc identity check instead, and it is exact rather than ' - + 'heuristic: `id` is insert-only on the merge path (made so once a merge on a ' - + 'non-primary conflict key was measured rewriting the existing row\'s primary key), ' - + 'so a row merged on the ' - + 'primary key always still carries the id the call supplied, and a row merged on any ' - + 'other key never does. Absence of that row after the statement is therefore a ' - + 'biconditional for "this landed on a row the caller never identified", which is why the ' - + 'refusal has no false positives. It runs inside a transaction with the statement — ' - + '"never modify" is not satisfied by noticing afterwards — and only on MySQL tables that ' - + 'carry a rival UNIQUE key, so a table whose only key is its primary key keeps its ' - + 'single autocommitted round trip unchanged.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`driver-sql-unresolvable-where-column-refused` — there is no `sys_metadata` row for ' - + 'the D2 chain to rewrite and this entry is the notification channel. No mechanical ' - + 'rewrite exists: the platform cannot know which business key an unnamed merge meant, ' - + 'and guessing one would merge onto a row the caller never named, which is the defect. ' - + 'ADR-0112.', - acceptanceCriteria: - 'On MySQL deployments only. For every object whose rows are written with `upsert` and ' - + 'whose table carries a UNIQUE key besides the primary key, confirm the writer either ' - + 'supplies the `id` of the row it means to update or passes that business key as ' - + '`conflictKeys`. Sweeps, imports and archival copies complete with no ' - + '`VALIDATION_ERROR` whose message says "the merge landed on a row this call never ' - + 'identified". Where such a refusal appears, the old behaviour was silently overwriting ' - + 'an unrelated row on that table — audit the object for rows whose business key is ' - + 'correct but whose other columns belong to a different record, since no error was ever ' - + 'raised for those writes.', - }, - { - id: 'driver-turso-config-local-path-wasm-retired', - surface: '`@objectstack/driver-turso`\'s published `TursoConfigSchema` — the Spec / Studio mirror ' - + 'of the turso connection config a host may render configuration UI from — keys `localPath` ' - + 'and `wasm`', - replacement: 'delete both keys. The embedded replica\'s local file is named by `url` ' - + '(`file:./replica.db`) with `syncUrl` pointing at the remote primary, which is what the driver ' - + 'has always read; nothing selects a WASM build of libSQL, and a runtime that cannot load native ' - + 'bindings uses the remote arm (`libsql://` / `https://`), which needs none', - reason: - 'ADR-0049 enforce-or-remove, ruled per key by the maintainer on 2026-09-06, once all three ' - + 'of this package\'s unread config keys had been measured: both keys ' - + 'were declared on the package schema with a describe promising behaviour ("Local file path for ' - + 'embedded replica", "Use WASM build for edge/browser environments") and were read by no code ' - + '— the driver names the replica file via `url`, and no mechanism picks a WASM build. Forwarding ' - + '`localPath` would have created a second way to say what `url` says; forwarding `wasm` would ' - + 'have meant building a WASM selection that does not exist. Why a semantic entry and not a D2 ' - + 'conversion: `@objectstack/spec`\'s own turso contract (`data/TursoConfig`, strict) never ' - + 'declared either key, so no stack source or stored datasource row that passed the spec door can ' - + 'carry them, and a value that never did anything has no lossless rewrite — the key is deleted by ' - + 'hand. Both stay declared on the package schema as `z.never()` tombstones (the shape is a plain ' - + 'z.object, so a bare deletion would strip in silence) carrying this prescription. The third key ' - + 'the same measurement found, `TursoDriverConfig.timeout`, was forwarded rather than removed and ' - + 'needs no entry. ADR-0049, ADR-0087.', - acceptanceCriteria: - 'No `TursoConfigSchema.parse(…)` input spells `localPath` or `wasm`; authoring either fails to ' - + 'compile (input type `never`) and fails to parse with the prescription naming the key. A replica ' - + 'config that named its file only through `url` + `syncUrl` parses byte-identically to before, ' - + 'and every other declared key — `url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, ' - + '`sync`, `timeoutMs` — keeps its bound, default and optionality.', - }, - // #21185 — registered by the change that fenced the merge leg (PR #21225), not by - // a later reconciliation. Executes the ruling record 5934879010 (letter A, - // refinements 1/2/3). A driver call is code, never stack metadata, so there is no - // authored source for the chain to rewrite and no schema tombstone: this entry is - // the migration channel beside the changeset's FROM → TO table. - { - id: 'driver-upsert-cross-organization-conflict-refused', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'IDataDriver upsert on SqlDriver, SqliteWasmDriver and TursoDriver (both faces): a ' - + 'tenant-scoped call whose conflict lands on a row of another organization, and the ' - + 'tenant column on the merge leg', - replacement: - 'a tenant-scoped upsert merges only into a row of the organization it writes under; a ' - + 'conflict anywhere else answers `UNIQUE_VIOLATION` / 409 and writes nothing, so handle it ' - + 'as the colliding insert it is from the caller\'s organization. To move a row between ' - + 'organizations, call the driver\'s `update` door on that row: an upsert keeps the stored ' - + 'row\'s organization on merge', - reason: - '`upsert` resolves its conflict against the whole table, and the primary key and a ' - + '`unique: \'global\'` column are installation-wide (ADR-0120 D1). So the row a tenant-scoped ' - + 'call (`options.tenantId` on an object with a tenant column) collided with could belong to ' - + 'an organization the caller cannot read. The merge leg wrote every payload column except ' - + 'the insert-only ones onto that row, and the tenant column was not insert-only: the other ' - + 'organization\'s columns were overwritten and the row was re-parented to the caller\'s ' - + 'organization, with no error. That was the one driver door the tenant predicate did not ' - + 'reach (ADR-0131 D8). The merge leg is now fenced to rows whose stored tenant column equals ' - + 'the written one, for any conflict target, the primary key included. A conflict anywhere ' - + 'else, including a row with no organization, is refused with `UNIQUE_VIOLATION` / 409, the ' - + 'registered code a colliding insert gets, and the refusal names no organization. The fence ' - + 'is a predicate inside the merge statement on SQLite, PostgreSQL and the remote libSQL face. ' - + 'MySQL\'s merge statement takes no predicate, so there the statement and a read of the ' - + 'landed row run in one transaction (a savepoint inside a caller\'s transaction) and the ' - + 'read\'s failure rolls the write back. The tenant column also joined ' - + '`insertOnlyUpsertColumns`, so an upsert with no tenant context keeps the organization of ' - + 'the row it merges into. Two things can break, and neither reaches the compiler, since the ' - + 'call signature is unchanged. Code that let a tenant-scoped upsert land on another ' - + 'organization\'s row now gets a refusal where it got a silent merge. Code that relied on ' - + 'a payload\'s tenant value to move a row on merge now finds the row where it was. ' - + 'ADR-0131 D8 / ADR-0087.', - acceptanceCriteria: - 'Every caller that upserts with a tenant context handles `UNIQUE_VIOLATION` / 409 as a ' - + 'colliding insert, and none expects a merge into a row its organization cannot read. No ' - + 'caller relies on an upsert payload\'s tenant value to change which organization owns a ' - + 'row; that move goes through the `update` door. Proven when a tenant-scoped upsert on a key ' - + 'another organization\'s row holds answers `UNIQUE_VIOLATION` and that row reads back ' - + 'unchanged, while the same call on a row of the caller\'s own organization merges as ' - + 'before. An upsert with no tenant context that merges into a row leaves the row\'s ' - + 'organization as it was.', - }, - { - id: 'element-data-source-and-object-block-filter-rule-array', - surface: - 'Page-component `dataSource.filter` (`ElementDataSourceSchema`, the binding every ' - + 'data-bound element carries) and the `filter` prop of the four `object-*` blocks in ' - + '`ComponentPropsMap` — `object-grid`, `object-metric`, `object-kanban`, `object-calendar` ' - + '(the FORM: the MongoDB-style `FilterConditionSchema` record at the binding, and the ' - + 'accept-anything `z.unknown()` at the four block doors, vs the `ViewFilterRule` array)', - replacement: - '`z.array(ViewFilterRuleSchema)` at all five doors — the rule array ' - + '`[{ field, operator, value }, ...]` every other `filter` door in the map already carries ' - + '(`record:related_list`, its Add-affordance picker, `element:number`, ' - + '`element:record_picker`). A record-form filter `{ status: \'active\' }` becomes ' - + '`[{ field: \'status\', operator: \'equals\', value: \'active\' }]`; an operator object ' - + '`{ status: { $ne: \'done\' } }` becomes ' - + '`[{ field: \'status\', operator: \'not_equals\', value: \'done\' }]`; several keys become ' - + 'several rules (they AND). An ObjectQL AST tuple array ' - + '`[[\'owner_id\', \'=\', \'{current_user_id}\']]` — which the `z.unknown()` block doors ' - + 'also took — becomes `[{ field: \'owner_id\', operator: \'equals\', value: ' - + '\'{current_user_id}\' }]`; the value placeholders and date macros are unchanged. Legacy ' - + 'operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. ' - + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family, judged on its ' - + 'own, and is not moved by this entry; `object-grid.defaultFilters` is a different key and is not named ' - + 'by the ruling this entry records.', - reason: - 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: a ' - + 'filter door takes the `ViewFilterRule` array rather than keeping a record-shaped ' - + 'exception every author and AI would have to remember) reached two more locations the ' - + 'ComponentPropsMap census could not see (ruled 2026-09-06, option A: converge the binding ' - + 'and the four block doors family-wide, under one entry, rather than record an exception). The binding-level ' - + '`dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the ' - + 'consumer\'s own pins author at that key, and `element:record_picker` carried two ' - + 'orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the ' - + 'record) resolved through one `??` in the renderer — the shape in which a dropped or ' - + 'misread filter returns the wrong rows without an error. The four `object-*` doors said ' - + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13, when the ' - + '`object-*` blocks first got props schemas in the map, ' - + 'twelve days before the ruling, not an exception to it — so an author following the ' - + 'showcase wrote the record and an author following the manifest wrote an array, and each ' - + 'got a silent success receipt while the html tier already declared `array` for the grid ' - + 'and the metric. The record\'s `$and` / `$or` / `$not` keys were misread by every gate ' - + 'block anyway (the console\'s filter converter had no branch for them), so the exception would have preserved a capability the ' - + 'consumer does not honour. Sequenced measurement-first, as the family had to be: at the ' - + 'objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that ' - + '`POST /analytics/query` refused with 400 on every array form, so the converge ' - + 'was parked behind a bump of that pin; at the pin this repo builds against (`53ded82b`) ' - + 'the adapter lowers an authored array through `translateFilterArray` and ' - + 'the spec\'s own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule ' - + 'array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim ' - + 'to `$filter` where `convertQueryParams` lowers it, and the binding\'s composition seam ' - + 'AND-combines it with the named view\'s rules through `mergeFilterNodes`. The ruled ' - + 'migration check ran with the change: the in-repo sweep found four spec test fixtures ' - + 'at the binding (`page.test.ts`, all record form), five showcase authors at the block ' - + 'doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple ' - + 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array ' - + 'in the same change, and zero outside those files; this entry carries the prescription ' - + 'for authors outside the repo. ' - + 'Metadata AT REST: the mappable part of the table above is a D2 conversion, ' - + '`page-component-filter-record-to-rule-array` (ruled 2026-09-12, option B: convert what ' - + 'maps losslessly and name what does not, rather than leave every stored row to its next ' - + 'save or flatten combinators), so ' - + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) rewrites ' - + 'a stored page whose `filter` is a flat record, an operator object whose operators the ' - + 'rule vocabulary spells, several such keys, or a single-level AST tuple array, and every ' - + 'stored-row read replays the same rewrite until it does. It is retired from the load ' - + 'path: an author writing the record form is still refused at the `filter` door. ⚠️ A ' - + 'filter carrying `$and` / `$or` / `$not` is left exactly as stored — the rule array ' - + 'only ANDs, and flattening a combinator changes which rows the page selects — and so is ' - + 'any filter with a part that has no lossless rule spelling: a `null` value (where a block ' - + 'queries an object the renderer skips that key, so it constrains nothing, and where its ' - + 'rows are inline it selects the rows whose value is null — no one rule keeps both, so the ' - + 'TODO names the `is_null` rule for the rows with no value and leaves which rows to select ' - + 'to the author), an ' - + 'operator such as `$null` / `$exists` or an AST `like`, an array or object comparand in ' - + 'equality position, or an AST `and` / `or` group. None of this depends on where a ' - + 'block\'s rows come from: a filter on a component whose rows are inline (`data: { ' - + 'provider: \'value\' }` or `staticData`) — the binding\'s included — is ' - + 'rewritten or left exactly as it would be on a block that queries an object, because the ' - + '`object-map`, `object-tree`, `object-calendar` and `object-gantt` blocks of the objectui ' - + 'version this release pins match a rule array against those rows and select the rows the ' - + 'stored form selected. A row left as stored keeps loading unchanged ' - + '(`applyConversionsToStoredItem` replays the chain without validating, by its own ' - + 'contract), and its `filter` door refuses the form: at `dataSource.filter` on the page\'s ' - + 'next save; at a block\'s `properties.filter` — like `properties.defaultFilters`, a key of ' - + 'the open `properties` bag — only as the component-props gate\'s advisory finding ' - + '(os validate, os build, os lint), since a re-save through the metadata API is not ' - + 'refused there. For a combinator record that refusal names the combinator and says why no ' - + 'rule spells it. `os migrate meta --stored` lists each such filter as a TODO under its ' - + 'row, naming the block and what blocks the rewrite (a value that is not a record or AST ' - + 'form at all — a bare string or number one of the former `z.unknown()` doors took — is ' - + 'neither converted nor reported, and a row carrying nothing else reads as already on ' - + 'protocol); a row whose only finding is such a TODO is reported `skipped`, and the run\'s ' - + 'exit code does not change for it.', - acceptanceCriteria: - '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' - + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' - + 'array; `ComponentPropsMap[\'object-grid\' | \'object-metric\' | \'object-kanban\' | ' - + '\'object-calendar\'].safeParse({ filter: })` raises no issue at `filter`; ' - + 'a record-form `filter: { status: \'active\' }` is refused at the `filter` path of all ' - + 'five doors (`invalid_type`, expected array), and an AST tuple array is refused at ' - + '`filter.0` (expected object). No `filter` door in `ComponentPropsMap` accepts the ' - + 'record any more (the twin of the census pin that asks whether any `filter` door still ' - + 'refuses the array). At runtime each block and the ' - + 'binding select exactly the rows the array selects — the same filter a list view ' - + 'renders — including the `object-metric` aggregate tile, whose analytics `where` is the ' - + 'lowered condition. Downstream (objectui, after a released spec version reaches the ' - + 'pin): the seventeen `dataSource.filter` test authors at the pin (fifteen tuple arrays, ' - + 'two records) become off-spec fixtures and `ElementDataSourceConfig.filter`\'s ' - + '"three shapes" note narrows — objectui cards filed by the seat, not blocked on here.', - }, - { - id: 'element-filter-and-form-node-refused', - surface: - 'page.component.element:filter / page.component.element:form — the bare component ' - + 'node itself, left standing by the `element-filter-removed` and ' - + '`element-form-removed` conversions after they strip its properties', - replacement: - 'Delete the component node. `element:filter` → a list surface owns its own ' - + "filtering: use a view's `userFilters` quick-filter bar or the list toolbar's " - + 'filter builder. `element:form` → the object-bound `object-form` block, which is ' - + 'rendered, designer-publishable and carries the same intent (`objectName`, ' - + '`fields`, `mode`, `submitText`). Nothing is placed where the node was unless the ' - + 'page needs it — which region keeps its layout is the judgment this step delegates', - reason: - 'Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no ' - + 'renderer for either ever shipped in objectui, framework or cloud, so every ' - + 'authorable key was a capability claim nothing kept. The conversions are mechanical ' - + 'where they can be — they strip all twelve keys losslessly — and stop at the node, ' - + 'because removing an authored page node changes the LAYOUT of a page the author ' - + 'composed, and a conversion cannot know whether the region should close up, hold a ' - + 'replacement, or keep its slot. That residue is no longer inert: both names are ' - + 'members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses ' - + 'them by name, and a stack that replays the chain and stops there is schema-INVALID. ' - + 'Mechanical where it can be, delegated where it cannot — this entry is the ' - + 'delegation, in writing', - acceptanceCriteria: - 'No `element:filter` and no `element:form` component remains in any page — regions, ' - + 'named slots and nested containers alike (the conversions walk all three, so every ' - + 'place they stripped properties is a place a bare node can be sitting). `os validate` ' - + 'is clean: the refusal is reported at the node\'s `type` path with ' - + '`params.retiredComponentType` naming the element, so a remaining node is named ' - + 'individually rather than as one page-level failure. Replaying the same 17 → 18 chain ' - + 'over the edited source then reports the migrated stack schema-valid — ' - + '`schemaValid: true` in `--json`, and the run closes with the schema-valid line ' - + 'rather than the manual-changes warning', - }, - // #11509 (v18, ruling A-narrow) — the D3 entry of the - // `element-flat-data-binding-to-data-source` family: one entry for the ten - // keys, because they are one retirement (the element layer's second data door) - // and an upgrader moves them together. It absorbs the two protocol-18 - // narrowings of the element flat `filter` to the rule array - // (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`): - // the key they narrowed is gone in the same major, and the rule array they - // prescribed is the binding's own form. The conversion follows each element's - // old rule, so what it cannot follow is listed as a TODO and judged here. - { - id: 'element-flat-data-binding-retired', - surface: - 'page.component properties of element:record_picker (object, filter, sort, limit), ' - + 'element:number (object, filter) and element:repeater (object, filter, sort, limit) — the flat ' - + 'data-binding keys beside the node-level dataSource', - replacement: - '`dataSource` on the component node — `{ object, view?, filter?, sort?, limit? }`, a sibling of ' - + '`type` rather than a key inside `properties` — the one binding each of the three elements reads. ' - + 'Each key moves unchanged: `properties: { object: \'deal\', limit: 20 }` becomes ' - + '`dataSource: { object: \'deal\', limit: 20 }`, and a filter keeps its rule-array form ' - + '`[{ field, operator, value }, ...]`. `element:number` reads `object` and `filter` only. With a ' - + '`view`, the view supplies the baseline, an explicit binding key overrides it, and the binding ' - + 'filter AND-combines with the view\'s.', - reason: - 'One node carried two doors onto one query, resolved by three different rules: the record picker ' - + 'let the binding win (its flat key was read only when the binding, or the saved view the binding ' - + 'named, supplied none), `element:number` resolved `object` binding-first and AND-combined the two ' - + 'filters, and the repeater read its flat keys alone and ignored the binding — while the ' - + 'component-props gate waived a missing flat `object` whenever `dataSource.object` was present, ' - + 'so a repeater bound only through `dataSource` passed validation and drew an empty list. The ' - + 'console moved all three elements onto the binding first, and in v18 the flat keys are ' - + 'refused. The D2 conversion `element-flat-data-binding-to-data-source` follows each element\'s ' - + 'old rule: a key the binding lacks moves there, a key the binding already set is deleted where ' - + 'the binding won, and `element:number`\'s filter is appended to the binding\'s. Three cases are ' - + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' - + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' - + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' - + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater read neither until the console ' - + 'put its binding first, so what it applied depends on the console version); and an `element:number` filter pair that is not ' - + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' - + 'compare it with what the list showed. A flat filter in the retired record form moves to ' - + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' - + 'wherever the mapping is lossless; that entry lists the rest. Code that builds these props — a ' - + 'host, a generator, a designer — must write the binding, which no conversion reaches. And the ' - + 'gate now requires `dataSource.object` on all three elements: a node with none names no object ' - + 'and is reported.', - acceptanceCriteria: - 'No `element:record_picker`, `element:number` or `element:repeater` node carries `object`, ' - + '`filter`, `sort` or `limit` inside `properties`; the parse refuses each. Every such node has ' - + '`dataSource.object`, and `os validate` reports no missing-binding finding for it. In the running ' - + 'page each picker offers, each number aggregates and each repeater lists the records the author ' - + 'intends — checked first on every node the migration listed as a TODO, and on every repeater ' - + 'that already carried a `dataSource`.', - conversionIds: ['element-flat-data-binding-to-data-source'], - }, - // #9198 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `element-input-target-variable-removed` family. Every retirement family - // carries one D3 entry even when a lossless D2 conversion repairs its data - // (ruling B on #17152, on the maintainer's #15954 authority). The strip is - // lossless because the key never bound anything; what it cannot do is create - // the binding the author meant to declare, because only the author knows which - // page variable an input was supposed to feed. - { - id: 'element-input-target-variable-retired', - surface: 'page.component.element:text_input.targetVariable / ' - + 'page.component.element:record_picker.targetVariable — the declarative binding hint on the ' - + 'two input elements', - replacement: 'Declare the binding on the page variable instead: a `variables[]` entry whose ' - + '`source` is the input component `id`. That reverse lookup is the one binding the renderer ' - + 'has ever honoured; the variable name is the author\'s choice, and `targetVariable` named it ' - + 'from the wrong end.', - reason: 'The D2 conversion `element-input-target-variable-removed` deletes `targetVariable` from ' - + 'every text-input and record-picker component, and the delete is lossless: no renderer, hook ' - + 'or runtime ever read the key, so an input authored with it and without a matching ' - + '`variables[].source` wrote nothing, with a success receipt and no diagnostic. What the delete ' - + 'cannot do is restore the intent. An author who wrote `targetVariable: \'contact_email\'` meant ' - + 'that input to feed that variable, and after the strip the page is exactly as unbound as it ' - + 'always was — now without even the hint that says so. Whether the variable exists, whether ' - + 'its `source` already names this component, and whether anything downstream (a flow input, a ' - + 'filter, a visibility predicate) reads it are facts about the author\'s page that no ' - + 'conversion can see, so the binding is delegated rather than invented.', - acceptanceCriteria: 'For every `element:text_input` and `element:record_picker` component that ' - + 'carried `targetVariable`: either the page declares a variable whose `source` equals the ' - + 'component `id`, or the author has decided the input needs no binding. With the binding ' - + 'declared, typing into the input (or picking a record) and then reading the variable — from ' - + 'whatever consumes it on the page — returns the value entered. No component authors ' - + '`targetVariable`; the parse refuses it by name.', - }, - // #21015 — release 2 of objectui#7450's ruling B: `element:text` `variant` - // refuses the pre-convergence spellings `heading` and `subheading` by name - // (`enumWithRetiredValues`). The family's one D3 entry; the D2 half is - // `element-text-variant-heading-levels`, which rewrites each to the heading - // element it always rendered. This entry carries the judgement the chain - // cannot make — whether that level is the one the page means, now that it - // draws in that level's style. - { - id: 'element-text-variant-heading-subheading-retired', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span AND a table cell. - surface: - 'page components of type element:text — properties.variant authored as heading or subheading ' - + '(ElementTextPropsSchema.variant)', - replacement: - "one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' " - + "→ 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the " - + 'page outline means', - reason: - 'The ruling converged `element:text` on the vocabulary `ui:text` already publishes, because a ' - + 'heading is a document level, not a text style: `heading` and `subheading` named a style and ' - + 'left the renderer to pick a level. It landed in two releases so authors outside this repository ' - + 'could move first — 17.5.0 added the nine and refused nothing, and 17.6.0 was a full release in ' - + 'which both vocabularies parsed. The D2 conversion `element-text-variant-heading-levels` makes ' - + 'the ruled edit: `heading` → `h2`, `subheading` → `h3`. That keeps the heading element (the ' - + 'renderer drew `heading` as an h2 element and `subheading` as an h3 element), so the document ' - + 'outline a screen reader walks is unchanged, but not the size: `heading` drew in the `h3` style ' - + 'and `subheading` in a medium-weight small heading style, and `h2` / `h3` draw their own, larger ' - + 'styles. Whether the page wanted that level is the author\'s call — a heading placed for its ' - + 'size rather than its place in the outline may want a deeper level. Nothing is dropped at rest: ' - + 'a stored page replays the rewrite at rehydration; a page component\'s `properties` is not ' - + 'parsed on the save path, and the component-props gate reports an old spelling as an advisory ' - + '`component-props-invalid` finding, carrying the prescription, on `os validate`, `os build` and ' - + '`os lint`. ADR-0087', - acceptanceCriteria: - 'No `element:text` page component carries `variant` `heading` or `subheading`; `os validate` ' - + 'reports no `component-props-invalid` finding under `properties.variant` for these blocks. For ' - + 'each rewritten block, open the page and check the heading: it renders the same heading element ' - + 'as before, in its level\'s style. Where the old, smaller look mattered more than the level, ' - + 'pick the level whose style you want and confirm the outline still reads in order. A block that ' - + 'omits `variant` still renders as `body`.', - conversionIds: ['element-text-variant-heading-levels'], - }, - { - id: 'engine-dotted-filter-refused', - surface: - 'a `where` / filter whose KEY is a dotted path with a relation, virtual-`formula` or ' - + 'plain-scalar head (`{"project_id.name": …}`, `{"is_open.x": …}`, `{"title.x": …}`) — at ' - + 'BOTH doors: the REST ingress (`assertFilterFieldsExist`, covering everything that reaches ' - + '`findData`) and the engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` ' - + '/ `update` / `delete`), which saved reports, flows and dashboard widgets reach directly', - replacement: - 'denormalise the value onto the queried object (a stored field, written when the source ' - + 'changes) and filter that — the same remedy, in the same words, the SORT axis ' - + 'prescribes when it refuses the dotted spelling. To read a related column, `$expand` ' - + 'is unchanged; to CONDITION on one, the stored denormalised field is the supported shape. ' - + 'A dotted path into a structured/JSON field (`{"address.city": …}`) is NOT refused and ' - + 'keeps its current per-driver behaviour', - reason: - 'FILTER was the last of the four query axes with no verdict for a dotted name: SORT refuses ' - + 'it, PROJECTION refuses it at both doors, and the FILTER gates judged a ' - + 'key on its HEAD SEGMENT only — so `where {"project_id.name": "Apollo"}` cleared the ' - + 'unknown-name check (which refuses a key naming no field of the object) because ' - + '`project_id` is a real field, reached a driver that cannot ' - + 'serve the path, and answered 200 with zero rows. The formula verdict deliberately ' - + 'skipped dotted keys, so the axis answered one unserviceable intent two ways by spelling: ' - + '`{is_open: true}` was refused while `{"is_open.x": true}` rode through.\n\n' - + 'Measured across all THREE drivers before ruling: relation-head, formula-head, ' - + 'system-column-head and plain-scalar-head dotted filters return ZERO rows on ' - + '`driver-memory`, `driver-sql` AND `driver-mongodb`, each under an ordinary 200 ' - + 'indistinguishable from an empty table. There is no working capability for this refusal ' - + 'to remove: an ObjectStack lookup stores the related record\'s SCALAR id (SQL: a string ' - + 'column plus FK; Mongo: a single-key index on a scalar), while Mongo\'s dotted paths ' - + 'traverse EMBEDDED DOCUMENTS — so the spelling is well-formed Mongo that matches nothing. ' - + 'On driver-sql, knex reads the dot as a table qualifier and emits a column no dialect can ' - + 'resolve; `find()` falls into the unknown-column recovery ladder and returns `[]` ' - + 'silently (the same measurement caught the list and count halves answering that query ' - + 'two different ways, a divergence with its own entry, ' - + '`driver-sql-unresolvable-where-column-refused`, that now refuses it on both).\n\n' - + 'Both doors now refuse the three measured-dead head classes with `400 INVALID_FIELD`, ' - + 'naming the whole offending key exactly as the caller wrote it and carrying the remedy ' - + "sentence — no new mechanism, no new error class, per the maintainer's ruling. Both " - + 'judge the head by the SAME `@objectstack/spec/data` classification ' - + '(`classifyDottedFilterHead`), the one-source move the formula verdict made with ' - + '`isVirtualSearchField`, ' - + 'so the doors cannot drift into answering one spelling two ways. Precedence mirrors the ' - + 'sort axis, verdict for verdict: `unknown` > `dotted` > unmaterializable.\n\n' - + 'DELIBERATELY UNJUDGED, per the same ruling: a dotted path whose head is a ' - + 'structured/JSON field (`address.city`) — the one spelling the drivers genuinely disagree ' - + 'on (live on memory and mongodb, 2 rows in the measurement; silently empty on sql). ' - + 'Refusing it for symmetry would delete a working capability on two of three backends; ' - + 'declaring JSON-path filtering a capability (`supports`) waits for a real consumer. ' - + 'Array-valued heads (`multiple: true`, tag types) and file heads are unjudged for the ' - + 'same measured reason. The nested-relation OBJECT form `{ owner: { region: "NA" } }` is ' - + 'untouched: the refusal targets the dotted-STRING spelling alone.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`engine-find-formula-filter-refused` and `engine-dotted-projection-refused` one step ' - + 'down — there is no `sys_metadata` row for the D2 chain to rewrite and this ledger entry ' - + 'is the notification channel. No mechanical rewrite exists: the platform cannot invent ' - + 'the stored column the remedy prescribes, and it must not join or post-filter instead — ' - + 'the drivers have already applied `limit`/`offset`, so any post-hoc predicate would ' - + 'filter an arbitrary page.\n\n' - + 'AUTHOR-REACHABLE SURFACES: a saved report\'s `query.filter` (`sys_saved_report`) is ' - + 'forwarded VERBATIM into `engine.find` by `plugin-reports`, bypassing the ingress; flow ' - + 'node `config.filter` and dashboard widget filters are author-written the same way. A ' - + 'dotted filter path is exactly what an AI author writes by analogy with `$expand`, ' - + 'projection spellings and SQL joins — and it used to answer an empty list ' - + 'indistinguishable from "no matching records", often with the related value reading ' - + 'correctly in the very same response. It now fails loudly, with the remedy in the ' - + 'message. Registered on the same inherited ruling as its siblings — the SORT-axis engine ' - + 'refusal was registered in this ledger although no stored row needs rewriting, ' - + 're-affirmed for the FILTER axis on 2026-08-13. ADR-0112.', - acceptanceCriteria: - 'No filter key is a dotted path whose head is a relation (`lookup` / `master_detail` / ' - + '`user` / `tree`), a `formula`, or a plain scalar — grep your saved report definitions ' - + '(`sys_saved_report.query.filter`), flow node `config.filter`, dashboard widget filters ' - + 'and view filters for keys containing a dot, and for each either denormalise the related ' - + 'value onto a stored field of the queried object, or (for a relation id test) filter the ' - + 'head field itself (`{"project_id": }`). A dotted path into a structured/JSON field ' - + '(`{"address.city": …}`) needs NO action — it is deliberately not judged. Reads complete ' - + 'with no `INVALID_FIELD` naming a dotted filter key, at either door.', - }, - { - id: 'epoch-instant-keys-renamed', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'four epoch-instant keys whose name carried no unit: ' - + 'WebSocketEvent.timestamp, SimplePresenceState.lastSeen, ' - + 'KernelContext.startTime (inherited by TenantRuntimeContext) and ' - + 'HealthStatus.timestamp', - replacement: - 'the same instants named for what they mark and typed with the new shared ' - + 'EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt ' - + 'and checkedAt. The VALUE is unchanged in every case — still ' - + 'milliseconds since the Unix epoch, still Date.now(). Only the key name ' - + 'and the declared schema move', - reason: - 'Maintainer ruling B (2026-09-05, on the population the 2026-09-02 rule reaches): a ' - + 'duration-shaped z.number() carries its unit in the key NAME, minus two ' - + 'structural classes declared ON THE SCHEMA rather than in a gate ledger. ' - + 'Epoch instants are the first class. They read to the rule exactly like ' - + 'an offending duration — a bare name plus a describe that says ' - + '"milliseconds" — but renaming them the way the rule prescribes would ' - + 'resolve the wrong confusion: measured on this package own authorable ' - + 'surface, all 51 distinct keys ending in Ms are durations (timeoutMs, ' - + 'backoffMs, latencyMs, uptimeMs) and all 51 distinct keys ending in At ' - + 'are instants (createdAt, expiresAt, lastUsedAt). Spelling an instant ' - + 'with the Ms suffix would move it INTO the duration family. So the ' - + 'exemption is a declaration on the contract: the value becomes EpochMs, ' - + 'which states the epoch-millisecond unit once, and the key takes this ' - + 'package established At convention. Two of the six instants ruling B ' - + 'names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were ' - + 'already correctly named and only changed schema, so they are not ' - + 'retirements and appear in no table. A SEMANTIC entry rather than a D2 ' - + 'conversion because all four keys are RUNTIME-EMITTED — a WebSocket ' - + 'event and a presence payload are wire messages, a kernel context is ' - + 'constructed by host code at boot, a health report is emitted by the ' - + 'startup orchestrator — so none is ever stored as a sys_metadata row and ' - + 'the conversion chain has no seam that would see one. That is the same ' - + 'disposition kernel/KernelContext:previewMode already carries on one of ' - + 'these very defs, and ruling B prescribes it explicitly: an ADR-0087 ' - + 'conversion where the key is authorable, a semantic entry where it is ' - + 'runtime-emitted. ADR-0087.', - acceptanceCriteria: - 'No producer emits the old key and no consumer reads it. All four are ' - + 'tombstoned with retiredKey(), so each fails tsc at the construction ' - + 'site (the key types never) and fails the parse with the rename ' - + 'prescription. Concretely, check four places. (1) Code building a ' - + 'WebSocketEvent: rename timestamp to occurredAt. (2) Code building a ' - + 'SimplePresenceState: rename lastSeen to lastSeenAt — and note that the ' - + 'neighbouring PresenceState.lastSeen (api/realtime-shared.zod.ts) is a ' - + 'DIFFERENT key holding an ISO-8601 datetime string, which is untouched ' - + 'and must not be renamed with it. (3) Host boot code composing a ' - + 'KernelContext or a TenantRuntimeContext: rename startTime to startedAt. ' - + '(4) Code building a kernel HealthStatus: rename timestamp to checkedAt. ' - + 'In every case the value is carried across unchanged. One behavioural ' - + 'note: WebSocketEvent.timestamp and SimplePresenceState.lastSeen were ' - + 'declared z.number() with no integer constraint and EpochMs is ' - + 'z.number().int(), so a fractional epoch that used to parse is now ' - + 'refused at those two sites — a tightening, and Date.now() has always ' - + 'satisfied it.', - }, - { - id: 'esignature-config-deadline-keys-retired', - surface: - 'e-signature deadline keys: `ESignatureConfig.expirationDays` / `reminderDays` ' - + '(`document.eSignature.expirationDays` / `document.eSignature.reminderDays`)', - replacement: - 'nothing to re-declare — delete the keys. No e-signature engine exists on the platform: ' - + 'no signature request is sent, expired or reminded by any layer, so there is no live ' - + 'mechanism to declare an expiry window or a reminder interval to. `ESignatureConfig` ' - + 'itself stays (`provider` / `enabled` / `signers`), unchanged', - reason: - 'ADR-0049 enforce-or-remove; the 2026-09-02 ruling on the unread deadline keys held this ' - + 'pair on one ' - + 'condition — "no roadmap ⇒ they retire with the other three families" — and the ' - + 'maintainer answered it on 2026-09-05 (no roadmapped e-signature ' - + 'consumer), so the ruling\'s own branch resolves to retirement. Two day-shaped keys sat ' - + 'on the published authorable surface (`authorable-surface/data.json`) and in the ' - + 'generated reference docs — an author could write `expirationDays: 30` and reasonably ' - + 'expect a signature request to lapse after thirty days — and were read by NOTHING: the ' - + 'reader census over every package outside `packages/spec` (tests and changelogs ' - + 'excluded), over `examples/**` and `skills/**`, and over objectui at the pinned sha ' - + 'returned zero hits for `expirationDays`, `reminderDays`, `eSignature` and the ' - + '`ESignatureConfig` names, with a lit control inside `packages/spec`. Both carried ' - + 'defaults (30 days, 7 days) that were materialized into every parsed configuration ' - + 'without ever being consulted. `cloud` and real customer configurations are UNMEASURED. ' - + 'Why D3 semantic and not a D2 conversion: `DocumentSchema` is not a stack collection ' - + 'member and `document` is no metadata type, so the chain has no seam that would ever ' - + 'see one (the `kernel/MetadataPluginConfig:additionalTypes` precedent); the ' - + 'prescription reaches authors through the `retiredKey()` tombstones (`tsc` + the parse) ' - + 'and this entry.', - acceptanceCriteria: - 'No `ESignatureConfig` literal — standalone or nested as `Document.eSignature` — carries ' - + '`expirationDays` or `reminderDays`. TypeScript authors get the refusal at compile time ' - + '(each key is typed `never`); a value reaching the parse is refused with the ' - + 'prescription (`invalid_type` at the path of the key, on the base schema and through ' - + 'the `DocumentSchema.eSignature` carrier). Parsed configurations no longer carry the ' - + 'two former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be ' - + 'verified as such: nothing ever read the keys, so removing them removes no behaviour.', - }, - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'evaluated-expression-slots-source-required', - surface: - 'every EVALUATED expression slot in the spec — the 34 declaring positions of the census of ' - + 'engine-evaluated slots outside the flow ledger that survive into this ' - + 'major, enumerated by identity and not by a name scan: the formula ' - + 'Field.expression; the predicate ' - + 'keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / ' - + 'visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ' - + 'ScriptValidation, CrossFieldValidation, ConditionalValidation, Hook, ObjectFieldGroup, ' - + 'RowCrudActionOverride, CriteriaSharingRule, PluginPermission.filter, MultiVersionSupport ' - + 'routing, Action and ActionParam (including each param option), BaseNavItem, BulkActionDef, ' - + 'PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection and the ' - + 'settings-manifest Specifier and manifest visible — authored either ' - + 'as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), ' - + 'or with a source that is blank after trimming, through the envelope key ' - + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it. ⚠️ The census ' - + 'this entry was written against counted 36, and the two that are deliberately absent here are ' - + 'the ServiceLevelIndicator successCriteria and TraceSamplingConfig composite condition ' - + 'expression arms. They are not lost: they were RETIRED OUTRIGHT in this same unpublished ' - + 'major by the observability-cel-predicates-retired entry of this step, under ADR-0049 ' - + 'enforce-or-remove, because nothing evaluated either. Both entries first ship together, so an ' - + 'upgrader never meets those two slots under THIS rule — the composite of the two changes is ' - + 'the retirement alone, and stating the narrowing for a slot that no longer accepts an ' - + 'expression at all would send the upgrader to author one. That absorption is the only reason ' - + 'the count here is not the census figure of 36. The published ' - + 'TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable ' - + 'wherever metadata is authored or stored: defineStack sources, an exported stack passed to ' - + 'objectstack validate, a POST body on any of these metadata types, and a row already sitting ' - + 'in sys_metadata', - replacement: - 'a non-blank `source`. ⭐ For an `ast`-only envelope the recovery is MECHANICAL and lossless ' - + 'for `cel`, which is the one dialect in this population that has an AST at all: ' - + '`printCelAst(ast)` from `@objectstack/formula` (shipped with this narrowing, the inverse ' - + 'of `parseCelToAst`) ' - + 'prints the AST back to surface syntax, and the recovered string is the new `source` — keep ' - + 'the `ast` beside it if you want, an `ast` BESIDE a string `source` is untouched and stays ' - + 'admitted everywhere. ⚠️ Lossless is about MEANING, not bytes: the printer re-renders from ' - + 'the parse tree, so single-quoted literals come back double-quoted ' - + '(`record.p == \'x\'` → `record.p == "x"`) and parentheses the parser dropped do not come ' - + 'back. It answers `null` — never a guess — for an `ast` it cannot round-trip through the ' - + 'platform\'s own bounded parser; that `null` is the hand-migration case. ' - + 'For a BLANK `source` there is nothing to print from, so this entry delegates the judgment, ' - + 'and it is the same fork the flow-edge `condition` narrowing named: author the predicate the ' - + 'slot was meant to carry, or ' - + 'REMOVE the key entirely. ⚠️ Those two are not interchangeable and the choice is per slot, ' - + 'not per file. A refused predicate reached its evaluator and faulted, and what the fault DID ' - + 'differs by slot: on the fail-closed ones (`ObjectFieldGroup.visibleWhen`, ' - + '`RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two settings-manifest ' - + '`visible` slots) it HID or EXCLUDED, so removing the key REVEALS what was hidden; on the ' - + 'fail-soft ones (the rest) it left the gate open, so removing the key preserves what was ' - + 'happening. Removing to clear the refusal is therefore safe on one half of the population ' - + 'and a silent disclosure on the other', - reason: - 'Maintainer ruling 2026-09-12, option A: every engine-evaluated expression slot requires a ' - + 'non-blank `source`, with an ADR-0087 migration path, ' - + 'while the persistence contract stays wide. The ' - + 'rule first set for the flow-node ledger, and then carried to `FlowEdgeSchema.condition`, ' - + 'generalises to every other slot an engine evaluates. Each of those slots now composes ' - + '`EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot ' - + 'is held to what the engine can actually run. The engine reads `source` alone ' - + '(`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist `source`"), so ' - + 'both refused spellings landed in its fault arm on every release that carried them — and ' - + 'measured at the chokepoint, the engine never silently SUCCEEDS on either: it returns a ' - + '`parse` fault, and what happened next was decided entirely by the slot\'s fail policy. ' - + 'Nothing between the author\'s keystroke and that fault said a word — the authoring lint ' - + '`validateVisibilityPredicates` measured 0 findings on an `ast`-only envelope and 0 on a ' - + 'blank `source`, against two control legs that each measured 1. The refusal is one rule with ' - + 'one sentence, `EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ' - + '⚠️ `ExpressionSchema` / `ExpressionInputSchema` are deliberately NOT narrowed and neither is ' - + 'their alias `PredicateInputSchema`: they are the PERSISTENCE contract (`source` OR `ast`) ' - + 'and stay wide by the same ruling\'s item 2. The narrowing is at the evaluated slots only. ' - + '⚠️ Why this is a D3 entry and not a D2 conversion, even though a printer now exists. The ' - + 'conversion layer lives in `packages/spec`, which is dependency-free by Prime Directive #2 ' - + 'and carries no engine — `packages/formula`\'s own `normalize.ts` header states the same ' - + 'boundary from the other side ("Spec layer cannot do step 2 because it must remain ' - + 'dependency-free; this package owns the engine import"). A conversion that had to call the ' - + 'CEL printer could not live where conversions live, and a conversion that guessed without ' - + 'one would be the platform inventing a predicate. So the printer ships as a named, tested ' - + 'export the migration PRESCRIBES, and the judgment the printer cannot make — a blank ' - + '`source`, an opaque `ast` that does not round-trip, any future dialect with no printer — ' - + 'stays here as the structured TODO, naming the object, field and slot. ' - + '⚠️ And for a row ALREADY STORED the consequence is wider than the key. ' - + '`applyConversionsToStoredItem` replays the conversion chain on rehydration, but no ' - + 'conversion can supply a `source` that was never written, so a stored row carrying either ' - + 'spelling now fails its schema parse at the seam that loads it rather than parsing and ' - + 'faulting later. That is the intended direction — the refusal moves from run time, where it ' - + 'was invisible on the fail-soft slots and destructive on the fail-closed ones, to load time, ' - + 'where it names the row. ADR-0087, ADR-0058, ADR-0049.', - acceptanceCriteria: - 'Sweep every authored metadata source and every `sys_metadata` row for the two spellings on ' - + 'the slots named in `surface`: an expression envelope with no `source` key, and a `source` ' - + '(or bare-string shorthand) that is empty after trimming. ⚠️ Sweep by SLOT, not by key name ' - + '— `visible` is on this list for actions, action params, nav items, bulk actions, record ' - + 'alerts and settings manifests, and is NOT an expression slot elsewhere; and ONE of the ' - + 'positions is a union member, `RecordAlertProps.visible`, whose sibling arm is untouched: it ' - + 'still takes a boolean literal, so a boolean there is not a hit. ⚠️ The two OTHER union ' - + 'members the census listed — `ServiceLevelIndicator.successCriteria` and ' - + '`TraceSamplingConfig.composite[].condition` — are deliberately NOT on this sweep, because ' - + 'their expression arms were retired outright in this same major (see `surface`). Sweep those ' - + 'two under `observability-cel-predicates-retired` instead, whose instruction is the opposite ' - + 'of this one: there, an expression is not repaired, it is replaced by the structured shape or ' - + 'moved out of application metadata. For each hit: if it carries an `ast`, run ' - + '`printCelAst(ast)`; a string result IS the migration and needs no judgment beyond reading ' - + 'it back. A `null` result, or a blank `source`, is the hand-migration case — decide per the ' - + '`replacement` note whether the slot was meant to carry a predicate (author the `source`) or ' - + 'to be ungated (remove the key), and ⛔ do not default to removal on a fail-closed slot, ' - + 'where removal reveals rather than preserves. Two proofs. (1) `objectstack validate` is ' - + 'clean on a stack authored in config files: each offender is located by path with the ' - + '`EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — one `invalid_union` issue at the slot for ' - + 'an `ast`-only envelope or a blank bare string, one `custom` issue at `source` for a blank ' - + '`source` inside an envelope. There is no CLI verb that lowers a stored row back into a ' - + 'config file, so this proof does not reach metadata that exists only in `sys_metadata`. ' - + '(2) For stored rows, load the tenant and confirm every metadata item of the affected types ' - + 'still rehydrates: a row carrying either spelling now fails its parse at the load seam and ' - + 'is reported there, naming the object, the field and the slot. A row whose every evaluated ' - + 'slot carries a non-blank `source` parses byte-identically to before — the narrowing removes ' - + 'accepted shapes and adds none.', - }, - { - id: 'event-name-schema-retired', - surface: - '`EventNameSchema` and its `EventName` type ' - + '(`@objectstack/spec/shared`, `shared/identifiers.zod.ts`), and the ' - + 'dot-notation grammar it imposed on its only three binding fields: ' - + '`EventTypeDefinitionSchema.name` and `EventSchema.name` ' - + '(`kernel/events/core.zod.ts`) and `EventMessageSchema.eventName` ' - + '(`api/websocket.zod.ts`).', - replacement: - '(removed — no replacement grammar layer. The three binding fields stay ' - + 'and widen to plain `z.string()`; the event vocabulary the platform ' - + 'actually checks is the closed literal enums `DataEventType` / ' - + '`BulkDataEventType` (`@objectstack/spec/api`, `api/events.zod.ts`), ' - + 'which stand as the only event-name contract. A caller that imported ' - + '`EventNameSchema` for standalone validation deletes the import; if it ' - + 'was validating platform event names, it parses through the enums ' - + 'instead.)', - reason: - 'Maintainer ruling 2026-09-01 (director decision batch C, ' - + 'verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The schema ' - + 'presented itself as the platform\'s event-name grammar while nothing ' - + 'that runs consumed its three binding schemas, and the closed enums ' - + 'that do the real checking never referenced it. The event surface is ' - + 'platform-defined, not author-extensible, so a grammar layer for a ' - + 'hypothetical extension surface is a trap, not a reserve: a generator ' - + 'satisfying `EventNameSchema` has satisfied nothing the platform will ' - + 'check, while one emitting outside the closed enums is refused by a ' - + 'rule the identifier file never mentioned.', - acceptanceCriteria: - 'No code imports `EventNameSchema` or `EventName` from ' - + '`@objectstack/spec/shared` (TS2305 after upgrade); ' - + '`EventTypeDefinitionSchema.name`, `EventSchema.name` and ' - + '`EventMessageSchema.eventName` parse as plain strings (the accept set ' - + 'at those three fields widens — every previously valid document stays ' - + 'valid, so no source rewrite ships and `objectstack migrate meta` has ' - + 'nothing to visit); `DataEventType` / `BulkDataEventType` are ' - + 'byte-for-byte untouched; `WebSocketEventSchema.channel` remains a ' - + 'deliberate bare `z.string()` (the ruling adds no constraint there); ' - + 'the `shared/EventName` def key leaves ' - + '`json-schema.manifest/shared.json` in the same change that registers ' - + 'this entry.', - }, - { - id: 'execution-step-iteration-single-valued', - surface: - '`ExecutionStepLog.iteration` on a step whose `regionKind` is ' - + '`parallel-branch` — the per-step records under `ExecutionLog.steps`, as ' - + 'the automation run endpoints return them — and the new optional ' - + '`ExecutionStepLog.branch` key', - replacement: - 'Read the parallel branch index from `branch`. `iteration` is now ' - + 'single-valued: the zero-based iteration of the enclosing `loop`, carried ' - + 'through any nesting, so a branch step of a `parallel` node that sits ' - + 'inside a loop body carries BOTH keys — `iteration` for the row and ' - + '`branch` for the branch. A consumer that grouped or labelled steps by ' - + '`iteration` under `regionKind: parallel-branch` moves that read to ' - + '`branch`; a consumer reading `iteration` on `loop-body`, `try` or ' - + '`catch` steps changes nothing.', - reason: - 'The key was declared as the zero-based loop iteration OR the parallel ' - + 'branch index of the enclosing region — one field, two meanings, told ' - + 'apart only by reading `regionKind` first. The engine tagged each step ' - + 'with its innermost region only, so for a `parallel` node inside a `loop` ' - + 'body every branch step recorded the branch index and no step of that ' - + 'branch recorded the loop iteration: a per-row failure inside a branch ' - + 'was attributable to a branch, never to the row the sweep was processing. ' - + 'The sibling try/catch rule had already settled the containment case — a ' - + 'try/catch region has no index of its own, so it carries the loop ' - + 'iteration — and deliberately left `parallel` open, because there the ' - + 'two indexes genuinely compete for one field. The maintainer ruling of ' - + '2026-09-03 took option A: `iteration` always means the enclosing loop ' - + 'iteration and the branch index moves to its own optional key, so a ' - + 'reader no longer has to branch on `regionKind` to know which number it ' - + 'holds, and getting that wrong no longer silently books a failure against ' - + 'the wrong row. Option B — keep the overload and add a second index whose ' - + 'presence depends on nesting shape — was not taken. This is not a ' - + 'mechanical conversion: a step record written before this change carries ' - + '`iteration` under `parallel-branch` with the branch-index meaning, and ' - + 'only its producer knows whether the parallel node sat inside a loop. The ' - + 'measured corpus held zero `loop { parallel }` nestings and one consumer ' - + 'reading the key — a grouping key in the objectui flow-runs panel — so ' - + 'the migration is a consumer-side read move, not a data rewrite. The ' - + 'engine tagger that writes both keys follows this contract change as its ' - + 'own card; until it lands, `branch` is declared and unwritten, and ' - + '`iteration` on a `parallel-branch` step written by an older engine still ' - + 'holds the branch index.', - acceptanceCriteria: - 'No consumer reads `iteration` as a branch index: every read of a ' - + '`parallel-branch` step\'s index goes through `branch`, and every read of ' - + 'the enclosing loop iteration goes through `iteration` regardless of ' - + '`regionKind`. A step record carrying `regionKind: parallel-branch`, ' - + '`iteration: 3`, `branch: 1` parses under `ExecutionStepLogSchema` with ' - + 'both numbers intact, and a negative or fractional `branch` is refused at ' - + 'the `branch` path. A record written before the engine follow-on carries ' - + 'no `branch` key; treat its `iteration` under `parallel-branch` as the ' - + 'legacy branch index only when the record predates the engine build that ' - + 'writes `branch`.', - }, - { - id: 'export-job-family-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the export-job API family, retired whole: the twelve defs api/ExportJobStatus, ' - + 'api/CreateExportJobRequest, api/CreateExportJobResponse, api/ExportJobProgress, ' - + 'api/ScheduledExport, api/GetExportJobDownloadRequest, api/GetExportJobDownloadResponse, ' - + 'api/ListExportJobsRequest, api/ExportJobSummary, api/ListExportJobsResponse, ' - + 'api/ScheduleExportRequest and api/ScheduleExportResponse with every name ' - + 'api/export.zod.ts exported for them from @objectstack/spec/api (the Schema consts, ' - + 'their z.input aliases and their Parsed aliases) and the ExportApiContracts route map; ' - + 'the IExportService contract with its six types (CreateExportJobInput, ' - + 'CreateExportJobResult, ExportJobDownload, ListExportJobsOptions, ExportJobListResult, ' - + 'ScheduleExportInput) from @objectstack/spec/contracts; and automation/ScheduleState ' - + '(ScheduleStateSchema, ScheduleState, ScheduleStateParsed) from @objectstack/spec/automation', - replacement: - 'nothing to re-declare for the job family — no route ever served it, so no caller holds ' - + 'a job id, a progress body or a download link to carry over. The export the platform ' - + 'DOES serve is the synchronous streaming door GET /api/v1/data/:object/export ' - + '(@objectstack/rest, the SDK method data.export): it answers the file itself as CSV, ' - + 'JSON or XLSX. ExportFormat stays published (ExportImportTemplate still references it). ' - + 'A recurring export is a Job (system/job.zod.ts) whose handler you write, with its ' - + 'cadence on Job.schedule.expression — the one cron slot the platform evaluates. A ' - + 'scheduled flow declares its cadence on its start node (config.schedule), and its run ' - + 'history is ExecutionLog / FlowRunSummary; ScheduleState had no counterpart to point at ' - + 'because no scheduler ever kept one. The import-job family in the same module ' - + '(ImportJob…, ListImportJobs…, ImportJobApiContracts) is served and is NOT part of this ' - + 'retirement', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling A of 2026-09-12 (retire the family, ' - + 'IExportService and ScheduleExportInput; ScheduleState retired with it unless a live ' - + 'consumer is measured), the landing route the maintainer ruled on 2026-09-24 (route A: ' - + 'objectui retires its own side of the unimplemented async-export path first, then this ' - + 'retirement), and a scope note the maintainer agreed on 2026-09-25 that folds in the ' - + 'declared limit / cursor of the export-job list — one of three sibling list doors found ' - + 'declaring them and never reading them. The family declared an ' - + 'asynchronous export API, create / progress / download / list / schedule / cancel under ' - + '/api/v1/data/export and a POST on /api/v1/data/:object/export, that NOTHING served: ' - + '@objectstack/rest mounts no /api/v1/data/export route and only the GET on ' - + '/api/v1/data/:object/export, IExportService recorded no evidenced provider binding, and ' - + 'the reader census over objectstack outside packages/spec, over objectui at the pinned ' - + 'sha (which carries objectui\'s own retirement) and over cloud main returned zero code ' - + 'files naming any of the forty-three exported names, each beside a lit control. An AI ' - + 'reading the contract found a complete, well-typed export-job API and wrote calls that ' - + 'answer 404 — and once the retirement of the cron-typed positions nothing read had ' - + 'deleted theirs, ScheduledExport / ScheduleExportRequest kept a REQUIRED schedule block that could hold no schedule, so an author who filled in ' - + 'its timezone believed they had scheduled something. ScheduleState described the ' - + 'runtime state of a scheduled flow that no scheduler wrote or read. Why D3 semantic and ' - + 'not a D2 conversion: the chain walks a normalized STACK and applyConversionsToStoredItem ' - + 'maps a metadata type onto one of its collections; none of these shapes is either — they ' - + 'are HTTP bodies, a route map, a service interface and an unpersisted runtime record — so ' - + 'a conversion would be a transform with no seam that ever runs, and with no carrier key ' - + 'there is no shape on which a tombstone could sit. Those earlier cron-position deletions ' - + 'on three of these defs registered nothing and stay unregistered; the defs themselves are ' - + 'now the RETIRED_DEFS_BY_MAJOR[18] entries.', - acceptanceCriteria: - 'No code imports any of the twelve export-job Schema consts or their type aliases from ' - + '@objectstack/spec or @objectstack/spec/api, reads ExportApiContracts, implements or ' - + 'imports IExportService or its six types from @objectstack/spec/contracts, or imports ' - + 'ScheduleStateSchema / ScheduleState / ScheduleStateParsed from ' - + '@objectstack/spec/automation: every such import is TS2305 after upgrade, and there is no ' - + 'working replacement to point at because nothing ever served them. The thirteen defs are ' - + 'absent from json-schema.manifest/api.json and json-schema.manifest/automation.json, from ' - + 'the api-surface / declaration-map / export-origins shards and from the generated ' - + 'reference docs. ExportFormat, ExportImportTemplate, the import validation shapes and ' - + 'the whole import-job family (including ImportJobApiContracts) are unaffected. ⚠️ Runtime ' - + 'behaviour is deliberately UNCHANGED and must be verified as such: GET ' - + '/api/v1/data/:object/export answers exactly as before, and every request to a retired ' - + 'path answers exactly as it always did, because nothing ever mounted one. ⚠️ Readers ' - + 'outside objectstack, objectui and cloud are NOT MEASURED — @objectstack/spec is ' - + 'published.', - }, - { - id: 'field-currency-scale-refused', - surface: 'object.fields..scale on a field whose `type` is `currency` — any declared value, ' - + '`scale: 0` included; the `Field.currency` helper passes it through unchanged. `scale` on ' - + '`number`, `percent`, `rating`, `slider` and `formula` is untouched', - replacement: 'no `scale` on a currency field. DELETE the key — that is the whole migration: a ' - + 'currency amount\'s decimal places are its currency\'s, not a field setting. The currency\'s ' - + 'ISO 4217 minor unit decides how the amount displays, and the field\'s write allowance stays ' - + 'unconstrained — a currency write is accepted with the decimals it carries, as it always was ' - + 'on a currency field that declared no `scale`. ⛔ Nothing replaces the key: do not re-declare ' - + 'its value under any other key.', - reason: - 'The maintainer\'s ruling of 2026-09-23 (option B) retires `scale` from the `currency` field ' - + 'type, and the ruling of 2026-09-24 (option 乙 — a currency\'s decimal places are the ' - + 'currency\'s, not a setting) words the remedy. On a currency field the ' - + 'key was three-faced: the metadata-admin field designer offered it as stored metadata, the ' - + 'amount\'s cell never read it (fraction digits come from the currency\'s ISO 4217 minor ' - + 'unit), and the record validator\'s `max_scale` branch still refused writes carrying more ' - + 'decimals — so an author who set it bought a narrower write contract and no visible change. ' - + '`FieldSchema` now refuses the key on `currency` at parse, and the validator stops reading it ' - + 'for the type in the same release, so a stored declaration narrows nothing either. ⛔ No ' - + 'alias and no grace window, per the ruling. NOT mechanically converted, deliberately: a ' - + 'conversion that dropped the key would accept it on every load, which is the grace window ' - + 'the ruling refused; the refusal names the key and its one-line fix instead. Two behaviour ' - + 'changes ride along and are part of what an upgrade means: (1) a currency write with more ' - + 'decimals than a former `scale` is now ACCEPTED — the write allowance stays unconstrained, ' - + 'the contract every currency field without `scale` already had; (2) at the console pin ' - + 'measured when this was written, the grid summary footer and the dashboard metric widget ' - + 'read a currency column\'s `scale ?? 0`, and the ruling lands this change only after the ' - + 'console derives both faces from the currency, the way the cell does, and the pin has moved ' - + 'past that change. Population measured at the change, on origin/main 1f89ba0d70 by AST ' - + 'sweep: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 ' - + 'documentation examples carried `scale`, every one of them `scale: 2`; all were deleted in ' - + 'the same change.', - acceptanceCriteria: - 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' - + 'issue on a `scale` path of a `currency` field. A currency field that carried `scale` no ' - + 'longer declares it, and a diff of the field shows that one line deleted and no key added. ' - + 'Its amount\'s cell renders the currency\'s ISO 4217 minor-unit digits, as before, and a ' - + 'write with more decimals than the old `scale` is accepted where it was refused with ' - + '`max_scale`; `number` / `percent` / `rating` / `slider` fields keep their `scale` and ' - + 'still refuse over-scale writes.', - }, - // #9227 — the D3 entry of the `field-column-lists-canonicalized` family (ruling - // B on #17152: one D3 entry per retirement family, even when D2 repairs the - // data). The conversion respells and folds every entry it can resolve; it - // deliberately leaves the two shapes it cannot resolve for the parse to refuse, - // and the related-list fold drops decoration keys by design. Both halves are - // the author's to finish. - { - id: 'field-inline-and-related-list-columns-closed', - surface: 'field.inlineColumns[] and field.relatedListColumns[] on lookup and master_detail ' - + 'fields — the two column lists that used to accept any object', - replacement: '`inlineColumns` entries are strict, name-keyed columns — `{ name, label?, type?, … }`, ' - + 'where `{ name }` alone hydrates the rest from the child object field. `relatedListColumns` ' - + 'entries are child field-name strings.', - reason: 'The D2 conversion `field-column-lists-canonicalized` rewrites what it can resolve without ' - + 'guessing: an inline column spelled `{ field: \'x\' }` becomes `{ name: \'x\' }` with every other ' - + 'key kept, and a related-list column object folds to its identity string. Two things remain ' - + 'the author\'s. First, the conversion leaves alone, on purpose, an inline entry that carries ' - + 'BOTH `field` and `name` (rewriting a live key on the strength of a stale one would guess) ' - + 'and a related-list object with no resolvable identity (a conversion must not invent data) — ' - + 'those now fail the parse and only the author knows which column was meant. Second, the fold ' - + 'DROPS a related-list object\'s decoration keys — a label, a width — because no object ' - + 'spelling rendered reliably on that list; the author decides whether a label they wrote there ' - + 'belongs on the child field itself instead. Both lists used to accept any object, so a ' - + 'mis-keyed column published clean and drew blank cells: a column that was blank before this ' - + 'release was usually one of these, and the author should confirm it now names a real child ' - + 'field.', - acceptanceCriteria: 'The object parses: no `inlineColumns` entry carries `field`, and every ' - + '`relatedListColumns` entry is a string. Every inline column `name` and every related-list ' - + 'string names a field that exists on the child object, and the inline grid and the related ' - + 'list render a value — not a blank cell — in each column for a record that has one. Any label ' - + 'that the fold dropped from a related-list column is either no longer wanted or now lives on ' - + 'the child field definition, where the list reads it from.', - }, - { - id: 'field-master-detail-set-null-refused', - surface: "object field `deleteBehavior: 'set_null'` authored on a `master_detail` field", - replacement: "an explicit `deleteBehavior: 'restrict'` or `'cascade'` (or no declaration, " - + 'which is the cascade default) — re-declared deliberately, because only the author knows ' - + "which they meant. There is deliberately NO automatic conversion: `'set_null'` here asked " - + 'for the child rows to be KEPT, and both mechanical rewrites betray that intent in a ' - + "different direction — stripping the key silently ratifies the cascade the author did not " - + "ask for (the same collapse of intent that produced the defect), while `'restrict'` is the " - + 'only rewrite that cannot lose data (the parent delete is refused while children exist — ' - + 'the closest honest reading of "keep my children") but turns a delete that silently ' - + 'succeeded into a loud refusal. If the children genuinely must survive the parent, the ' - + 'field wants to be a `lookup`, not a `master_detail`', - reason: - "`FieldSchema` accepted `deleteBehavior: 'set_null'` on a `master_detail` while the engine's " - + '`cascadeDeleteRelations` resolves every value except `restrict` on that type to `cascade` ' - + '— so the declaration asked for the children to be kept and the engine DELETED them, ' - + 'silently, at the moment the parent went away: data loss relative to the declared intent, ' - + 'the ADR-0049 declared-but-unenforced shape on a delete path. Honoring the value is ruled ' - + 'out (maintainer, 2026-08-19): a detail row whose master reference is nulled becomes an ' - + 'unreachable orphan, which is precisely what the orphan-detail work exists to prevent. The ' - + 'schema now refuses the authored combination at parse time (declared = enforced), and the ' - + 'engine logs loudly if a raw registration or a pre-tightening stored row still carries it ' - + 'to the coercion site. A BARE `master_detail` is untouched: the default still materializes ' - + "as `'set_null'` in parse output (byte-identical to before) and still resolves to cascade.", - acceptanceCriteria: - "No `master_detail` field declares `deleteBehavior: 'set_null'`. Bare `master_detail` " - + 'declarations, authored `cascade`/`restrict`, and `set_null` on `lookup` parse ' - + 'byte-identically to before. Stored `sys_metadata` rows carrying the refused combination ' - + 'keep loading and serving (registry validation is a diagnostic, not a gate) but flag ' - + '`metadata_spec_invalid` and are refused on their next authoring-path save — re-declare ' - + 'the field deliberately when that happens.', - }, - { - id: 'field-max-length-malformed-or-misplaced-refused', - surface: 'object field `maxLength` declarations — `maxLength: 0`, negative or non-integer ' - + 'values on any type, and the key with any value on field types outside ' - + '`BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, ' - + '`json`, `secret`, …)', - replacement: 'a positive-integer `maxLength` (>= 1) on a bounded-string field type — `text`, ' - + '`textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, ' - + 'plus `signature`/`qrcode`, which joined once the write seam enforced a declared bound on ' - + 'them (the set is `BOUNDED_STRING_FIELD_TYPES`; the narrowing itself landed on the ' - + 'ten-member set of its day) ' - + '— or no declaration at all. Deleting the key is mechanical and behaviour-preserving ' - + 'for a MISPLACED declaration: the write-time validator only ever applied `max_length` ' - + 'inside its bounded-string branch, so the key was inert by construction on every other ' - + 'type. A MALFORMED value on a bounded-string type is the judgment case — the ' - + 'validator\'s raw `>` comparison did consume it (`maxLength: 0` accepted only the ' - + 'empty string, a negative value refused every write, `maxLength: 12.5` behaved as ' - + '"at most 12"), and the SQL schema-drift planner consumed `maxLength: 0` as ' - + '`varchar(0)` DDL until it was taught to stop reading a malformed bound as authoritative ' - + '— so only the author knows ' - + 'the bound they MEANT: re-declare it as a positive integer, or delete it deliberately ' - + 'accepting the unbounding', - reason: - 'Maintainer ruling of 2026-08-24, tightening both halves — the value\'s shape and the ' - + 'types the key applies to (enforcement shipped on the 17.x line — accept-set narrowings ' - + 'ride minors, and this entry tells `migrate meta` users at the major boundary; ' - + 'registration was deferred to a follow-up because the registry file ' - + 'was serialized behind an in-flight change when the enforcement landed). Shape: a ' - + 'character length is a positive integer, so the key tightened from `z.number()` to ' - + '`z.number().int().min(1)` — `maxLength: 0` measurably sent schema-drift planning ' - + '`varchar(0)` DDL no server accepts, at severity error/destructive, before that consumer ' - + 'was taught to stop reading a malformed bound as authoritative (the house pattern the ' - + '`precision`/`scale` integer refusal set). ' - + 'Applicability: the key sat on the BASE field schema — authorable on `boolean` / ' - + '`lookup` / `autonumber`, types where nothing bounded is stored — while the write-time ' - + 'validator (objectql `record-validator.ts`) only ever enforced it on its ten ' - + 'bounded-string types, the one list of the three that had a measured reader; that list ' - + 'is promoted to the protocol as `BOUNDED_STRING_FIELD_TYPES`, the schema refuses the ' - + 'key outside it (ADR-0078 declared=enforced), and both authoring forms ' - + '(`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key ' - + 'for exactly that set.', - acceptanceCriteria: - 'Every field declaring `maxLength` carries a positive integer and is a bounded-string ' - + 'type. Well-formed declarations (a positive-integer `maxLength` on a bounded-string ' - + 'type) parse byte-identically to before; fields declaring no `maxLength` are ' - + 'untouched, and absence stays absence — no default materializes. Deleting a misplaced ' - + 'key changes no runtime behaviour (it sat outside the validator\'s bounded-string ' - + 'branch and enforced nothing). For a malformed value on a bounded-string type the ' - + 'author decides: re-declare the intended positive-integer bound (enforced by the ' - + 'write-time validator from the next write on, and honoured by schema drift as ' - + '`varchar(n)`), or delete the key and accept the type\'s unbounded/default column ' - + 'shape — either way the accidental old behaviour (empty-only writes under ' - + '`maxLength: 0`, unwritable fields under a negative value) is gone by decision, not ' - + 'by silence.', - }, - { - id: 'field-min-length-malformed-or-misplaced-refused', - surface: 'object field `minLength` declarations — `minLength: 0`, negative or non-integer ' - + 'values on any type, and the key with any value on field types outside ' - + '`BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, ' - + '`json`, `secret`, …)', - replacement: 'a positive-integer `minLength` (>= 1) on a bounded-string field type — `text`, ' - + '`textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, ' - + '`signature`, `qrcode` (the twelve-member `BOUNDED_STRING_FIELD_TYPES` set; ' - + '`signature`/`qrcode` joined once the write seam enforced a declared bound on them) ' - + '— or no declaration at all ("no minimum" is expressed by OMITTING the key, never by ' - + '`minLength: 0`). Deleting the key is mechanical and behaviour-preserving for a ' - + 'MISPLACED declaration (the write-time validator only ever applied `min_length` inside ' - + 'its bounded-string branch, so the key was inert by construction elsewhere) and for ' - + '`minLength: 0` / negative values anywhere (a string length is never below zero, so the ' - + 'check could not fire). A FRACTIONAL value on a bounded-string type is the judgment ' - + 'case: the validator\'s raw `<` comparison did consume it (`minLength: 2.5` behaved as ' - + '"at least 3"), so only the author knows the integer they MEANT — re-declare it ' - + 'deliberately if the constraint was wanted', - reason: - 'Maintainer ruling of 2026-08-25 (option B, the lower bound at 1): ' - + '`minLength` carried the exact defect pair the 2026-08-24 ruling closed for `maxLength` ' - + '(`field-max-length-malformed-or-misplaced-refused`), and converges on the same template. ' - + 'Shape: the key was ' - + '`z.number()`, so `minLength: -5` and `minLength: 2.5` parsed cleanly while describing ' - + 'no character length; it is now `z.number().int().min(1)`. The lower bound is 1 by ' - + 'ruling: `minLength: 0` is refused loudly — a vacuous always-true declaration is ' - + 'exactly the noise an AI metadata author mass-produces, and the refusal surfaces it at ' - + 'authoring time. Applicability: the key sat on the BASE field schema — authorable on ' - + '`boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the ' - + 'write-time validator (objectql `record-validator.ts`) only ever enforced it on the ' - + 'bounded-string set; the schema now refuses it outside `BOUNDED_STRING_FIELD_TYPES` ' - + '(ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 ' - + 'types; `object.form.ts`, previously 9) show the key for exactly that set.', - acceptanceCriteria: - 'Every field declaring `minLength` carries a positive integer and is a bounded-string ' - + 'type. Well-formed declarations (a positive-integer `minLength` on a bounded-string ' - + 'type) parse byte-identically to before; fields declaring no `minLength` are untouched, ' - + 'and absence stays absence — no default materializes. Deleting a misplaced key or a ' - + '`0`/negative value changes no runtime behaviour (misplaced keys sat outside the ' - + 'validator\'s bounded-string branch; a `0`/negative bound could never fire). Deleting a ' - + 'fractional value on a bounded-string type relaxes the write seam by up to one ' - + 'character — the author decides whether to delete or re-declare the integer they ' - + 'meant; a wanted minimum is re-declared as a positive integer and enforced by the ' - + 'write-time validator from the next write on.', - }, - { - id: 'field-multiple-non-capable-type-refused', - surface: 'object.fields..multiple — an authored `multiple: true` on a field whose ' - + '`type` is outside MULTI_CAPABLE_TYPES (`select` / `radio` / `lookup` / `user` / `file` / ' - + '`image`) union MULTI_OPTION_TYPES (`multiselect` / `checkboxes` / `tags`) — e.g. ' - + '`master_detail`, `tree`, `text`, `boolean`, `datetime`, `avatar`', - replacement: 'a multi-capable type that actually holds several values: `multiselect` / ' - + '`checkboxes` / `tags` for several option codes, a `lookup` with `multiple: true` for ' - + 'several related records (the replacement for a multi-valued `master_detail` / `tree`), ' - + '`file` / `image` with `multiple: true` for several attachments — or, where the field ' - + 'really does hold one value, dropping the `multiple` key. `MULTI_CAPABLE_TYPES` and ' - + '`isMultiValueField` are unchanged, so every field that was ALREADY multi-valued by that ' - + 'predicate keeps its declaration, its storage and its read path verbatim.', - reason: - 'Maintainer ruling of 2026-09-13, option 1′ (the earlier rule refusing an authored `radio` ' - + 'with `multiple: true`, generalised): two definitions of "multi-valued" disagreed. `FieldSchema` ' - + 'accepted `multiple: true` on ANY type; driver-sql\'s `isJsonField` read it raw ' - + '(`|| !!field.multiple`) and built a JSON ARRAY column; `isMultiValueField` — the spec ' - + 'predicate consumers shape queries from — answered "not multi-value" for the same field. ' - + 'A related list therefore composed `=` against a JSON array column and the driver answered ' - + 'the user a 400 (the console\'s related list pinned the divergence on the consumer side ' - + 'when it began shaping that filter from the spec predicate, and its follow-up recorded the ' - + 'driver half as owed and not filed). There is NO lossless conversion: the column was ' - + 'physically built as a JSON array, so the stored value is an array while the replacement ' - + 'type may want one scalar, several ids, or several option codes — which of those the author ' - + 'meant is a business judgment the chain cannot make. Hence a structured TODO rather than an ' - + 'auto-rewrite (ADR-0087 D3 "never silence", ADR-0032 "no silent failure"). Population ' - + 'measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every ' - + '`multiple: true` there is on `lookup` / `select`; re-measured on origin/main 689d606f ' - + 'by AST sweep, still 0. ' - + 'WIDER THAN THE JSON-COLUMN DECISION ALONE: every site in driver-sql that asked ' - + '`field.multiple` "is this value multi-valued" now asks `isMultiValueField` \u2014 the DDL ' - + 'writer, the read-side deserializer, the varchar-width mirror, the cross-field ' - + 'comparison class, the four scalar read-coercion registries on both of their fills, ' - + 'the two MySQL temporal-widening candidate sets, and the schema differ. So a stored ' - + 'field in the retired shape also LEAVES the JSON read path and ENTERS the scalar one: ' - + 'its column is no longer deserialized as JSON, the declared-type text-operator gate ' - + 'applies to it, and a `$contains` against it answers the declared no-match instead of ' - + 'a membership test.', - acceptanceCriteria: - 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' - + 'issue on the `multiple` path. For each field the refusal names — the message states the ' - + 'object-qualified field name and its `type` — the author has either dropped `multiple` or ' - + 'moved the field to a multi-capable type AND migrated the stored column, because the two ' - + 'storages differ: the old column holds a JSON array, the new one holds a scalar (dropping ' - + '`multiple`) or a differently-shaped array (changing `type`). Prove the data half by ' - + 'reading one migrated row back through the API and asserting the value shape the new ' - + 'declaration promises; `=` filters against the field answer rows instead of a 400, and a ' - + '`$contains` against it answers by member rather than the declared no-match. Fields ' - + 'already multi-valued by `isMultiValueField` need no change and must read back ' - + 'byte-identically.', - }, - // The authoring half of ADR-0137 D2 for one shape: a field-level predicate that - // reads THROUGH a reference field. The runtime already refuses the writes such a - // predicate reaches (or, for an option, never enforces it); what moved is that - // `objectstack validate` now says so before deploy. No D2 conversion — see - // `reason`. - // - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'field-predicate-reference-traversal-refused', - surface: - 'the field-level predicates objects[].fields[].requiredWhen and objects[].fields[].readonlyWhen, ' - + 'and a select option\'s objects[].fields[].options[].visibleWhen, whose CEL reads THROUGH a ' - + 'reference field (a lookup, master_detail, user or tree field): record.account.tier where ' - + 'account is such a field; likewise previous.account.tier, and parent.account.tier on a ' - + 'master-detail line item whose master declares account. Refused wherever objects are ' - + 'validated as authored: objectstack validate, build and lint over defineStack({ objects }) ' - + 'sources and exported stacks', - replacement: - 'the check as a `validations[]` rule of `type: \'script\'` — the one predicate the server reads one ' - + 'hop through a reference (the related record is loaded before it runs) — whose `condition` states ' - + 'the FAILURE. For `requiredWhen: P` on field F: P and F empty, e.g. ' - + '`record.account.tier == \'enterprise\' && (record.po_number == null || record.po_number == \'\')`. ' - + 'For `readonlyWhen: P` on F: P and F changed, on updates only (`events: [\'update\']`), e.g. ' - + '`record.account.tier == \'gold\' && record.discount != previous.discount`. For an option gated by ' - + 'P: that option picked while P does not hold — the option is then offered to everyone and refused ' - + 'on save. Or read a column the object itself declares (denormalise the related value onto it). ' - + 'A read through `previous` or `parent` has no hydrated seam at all, a validation rule included: ' - + 'read a column the bound record declares instead', - reason: - 'Triage routed this on 2026-09-25 to remedy A: refuse the traversal at authoring, with a ' - + 'prescription. The field level is never hydrated: ' - + '`rule-validator.ts` evaluates `requiredWhen` / `readonlyWhen` / an option\'s `visibleWhen` ' - + 'against the record alone, so a reference there holds the related record\'s bare id and every ' - + 'read through it faults, on every row. Measured on the engine before this change: a traversing ' - + '`requiredWhen` refused every insert and every update that reached it, a traversing ' - + '`readonlyWhen` refused every update that wrote its field (an insert is exempt), and an option ' - + 'gated through a reference was admitted whatever the related record said (option visibility is ' - + 'fail-open) — while `objectstack validate` passed a stack carrying all three, exit 0. ADR-0137 D2 ' - + 'made the runtime fail closed; the defect was that authoring did not say so first (NORTH-STAR ' - + 'priority rule 4). The same traversal inside a `validations[]` `script` rule is served, one hop ' - + 'deep, and stays accepted. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: ' - + 'moving a field predicate into a validation rule turns a condition into a FAILURE condition, ' - + 'moves an option from hidden to offered-then-refused, and the right `events` scope depends on ' - + 'what the author meant — none of it mechanical. Hydrating the field level instead is a ' - + 'capability of its own and is not done here. ADR-0087, ADR-0137.', - acceptanceCriteria: - 'Run `objectstack validate` over the stack. Each such predicate is refused as ' - + '`expression-invalid`, located at `object \'O\' · field \'F\' requiredWhen` (or `readonlyWhen`, ' - + 'or `option \'V\' visibleWhen`), and the message names the reference path read through ' - + '(`through record.account`) and the repair — that is the TODO\'s locator. Rewrite each per the ' - + '`replacement` note until validate is clean. Then prove the behaviour on a running stack: a ' - + 'write meeting the condition is refused by the new rule (`rule_violation` carrying its ' - + '`message`), and one that does not is accepted — where before, every write reaching the field ' - + 'predicate was refused with `could not be evaluated … write rejected`, or the option was ' - + 'admitted unchecked. ⚠️ An object already stored in `sys_metadata` is not re-validated by this ' - + 'change: its writes keep being refused at run time exactly as before, and that refusal names ' - + 'the reference for a `record` read — its own locator.', - }, - // #13700 (ui#6837 half 1) — the D3 entry of the `field-reference-to-alias` - // family (ruling B on #17152: one D3 entry per retirement family, even when D2 - // is lossless). The rename is lossless for every row it can decide; it leaves - // the one row it cannot decide, and it cannot reach code. - { - id: 'field-reference-to-spelling-retired', - surface: 'field.reference_to — the legacy runtime spelling of a lookup or master_detail target, ' - + 'on object fields and object-extension fields', - replacement: '`reference` — the one spelling the field schema has ever accepted, and the one the ' - + 'wire serves.', - reason: 'The D2 conversion `field-reference-to-alias` renames `reference_to` to `reference` in ' - + 'author sources and on every stored-row rehydration, so the wire only ever carries ' - + '`reference`; for a row with only the legacy spelling the rename is lossless. Two things are ' - + 'left. First, a row carrying BOTH spellings with DIFFERENT targets is left untouched, on ' - + 'purpose: the loader will not pick a target for the author, so that field keeps failing its ' - + 'parse until someone decides which object it points at. Second, code is out of reach: a ' - + 'plugin, script, custom renderer or external client that read `reference_to` off served ' - + 'field metadata worked only because a stored row happened to carry the legacy spelling, and ' - + 'it now reads nothing — the frontend fallback that tolerated the spelling is scheduled to ' - + 'go, after which a missed reader degrades a lookup to a picker with no target. The camelCase ' - + '`referenceTo` is a different surface (resolved action params) and is not part of this ' - + 'family.', - acceptanceCriteria: 'No object or object-extension field carries `reference_to` in source or at ' - + 'rest — every stored field serves `reference`. Each field that had carried both spellings ' - + 'names one target, chosen by the author. No code outside the metadata reads `reference_to` ' - + 'from a field definition. Every lookup and master_detail field opens a picker scoped to the ' - + 'object its `reference` names, and saving a selection stores that object\'s record id.', - }, - { - id: 'field-scale-precision-integer-refused', - surface: 'object field `scale` / `precision` declarations (`Field.number` and friends) — ' - + 'non-integer or negative values (`scale: 2.5`, `precision: -1`)', - replacement: 'a non-negative integer digit count, or no declaration at all. The mechanical ' - + 'conversion (`field-malformed-scale-precision-removed`) deletes a malformed value — ' - + 'behaviour-preserving, because the write-time `scale` enforcement deliberately skipped malformed ' - + 'declarations, so they enforced nothing — but only the author knows the count they ' - + 'MEANT (`scale: 2.5` was probably `2` or `3`): re-declare it deliberately if the ' - + 'constraint was wanted', - reason: - 'Both keys are digit COUNTS ("Total digits" / "Decimal places"), and `z.number()` admitted ' - + 'values with no defined meaning as a count. That looseness became load-bearing when `scale` ' - + 'was made enforced at write time (an over-scale write refused, never rounded): the runtime branch deliberately guards on ' - + '`Number.isInteger(def.scale) && def.scale >= 0` — inventing floor/round semantics in a ' - + 'consumer would be PD #12 guessing — so a typo\'d declaration (`scale: 2.5`) silently got ' - + 'no enforcement at all: exactly the declared-but-inert shape that hides AI-authored ' - + 'metadata errors. The schema now refuses non-integer and negative values for both keys at ' - + 'parse time (`z.number().int().min(0)`, ADR-0078 declared=enforced). ' - + '`CurrencyConfigSchema.precision` (under `currencyConfig`) was a different surface with ' - + 'its own bounds and alias table — retired in this same protocol major by ' - + '`currency-config-precision-removed`, not enforced here.', - acceptanceCriteria: - 'Every field declaring `scale` or `precision` carries a non-negative integer. Well-formed ' - + 'declarations (`0`, `2`, any non-negative integer) parse byte-identically to before; ' - + 'fields declaring neither key are untouched. Stored `sys_metadata` rows carrying a ' - + 'malformed value keep loading (the rehydration seam replays the conversion, which drops ' - + 'the meaningless key).', - }, - // The authoring-door half of the class #13495 ruled at the matcher. That card - // taught driver-memory what to do with a missing bound; this one decides what - // the SCHEMA does with one. - { - id: 'filter-between-blank-endpoint-refused', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'either endpoint of a $between range, authored BLANK — the empty string, or an absent ' - + '(undefined) bound — in any filter this platform stores or executes. The carriers split ' - + 'in two, because they are DETECTED differently and only one half answers on save. ' - + '(a) REFUSED AT SAVE — the enforced FieldOperatorsSchema / RangeOperatorSchema copy ' - + 'itself, reached by a caller that validates a filter against it directly, and the ' - + 'NormalizedFilter AST. (b) NOT JUDGED AT SAVE — every stored metadata carrier, and that ' - + 'is BOTH authoring dialects, not only the loose one. A view, page or component filter ' - + 'RULE (ViewFilterRuleSchema) admits it: the rule value accepts a string and the ' - + 'operator-shape check judges ARITY alone, so a two-element range with a blank element is ' - + 'a well-formed rule. A dashboard widget filter, a dashboard options-source filter, a ' - + 'dataset filter, a dataset measure filter, a report runtimeFilter, a rollup ' - + 'summaryOperations filter and a relatedListFilter are typed FilterConditionSchema, a ' - + 'loose record intersected with the $and / $or / $not shape and carrying one refinement. ' - + '⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare ' - + 'date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a ' - + '$between endpoint — is refused at that position\'s own path, and it is refused through ' - + 'an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused ' - + 'is that rule, on this same carrier set. What these carriers never judge is a $between ' - + 'endpoint for BLANKNESS or for ARITY. Measured: a dashboard widget whose filter reads ' - + 'close_date $between 2026-01-01 and an empty string parses GREEN, as do a one-element, a ' - + 'three-element and an empty $between, while the same widget carrying a preset endpoint is ' - + 'refused at filter.close_date.$between.0. So for THIS shape every one of those documents ' - + 'parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the engine ' - + 'comparand-shape door. ARITY is not what changed: a ' - + 'blank bound is a well-formed TWO-element range one of whose elements means nothing', - replacement: - 'two endpoints that are present and non-empty — the bound the author meant, written out. ' - + 'If only ONE side is genuinely bounded, that is not a range at all: drop `$between` and ' - + 'write the side you have as a scalar comparison, `{"$gte": min}` for a lower bound and ' - + '`{"$lte": max}` for an upper one, which every backend already answers. ⛔ There is no ' - + 'replacement that can be DERIVED from what was written: the bound the author did not type ' - + 'is not recoverable from the one they did, and picking either reading (drop the operator, ' - + 'or treat the blank side as unbounded) would be the platform inventing a filter. `null` ' - + 'bounds are a different entry: they were already refused by the 2026-08-31 ruling, whose ' - + 'message prescribes the null predicate because a `null` author was reaching for absence, ' - + 'not for a bound', - reason: - 'Maintainer ruling A of 2026-09-17: a blank `$between` endpoint is refused at the ' - + 'authoring door, and the refusal names the blank side. ' - + '`FieldOperatorsSchema.safeParse({ $between: [1, \'\'] })` answered `success: true` — ' - + 'measured on the card against the installed spec 17.4.0 and re-measured on `origin/main` ' - + 'before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a ' - + 'declared one: the endpoint contract shared by both bounds says verbatim that "Each ' - + 'endpoint is a number, a Date, or a string", and the empty string is a string, so the ' - + 'acceptance was conformant. What made it wrong is the other half of the same contract — ' - + '"Closed interval [min, max]" — which no backend can honour against a blank: driver-sql ' - + 'binds it into `whereBetween`, the JS matchers compare it as a value, and the range stops ' - + 'bounding on that side while still reading as a complete range. The reference matcher had ' - + 'already been taught to survive the null-bound form of exactly this (a bounded range ' - + 'answered EVERY valued row, because both of the arm\'s comparisons are false against a ' - + 'missing bound); the door that admitted it was never addressed. The only producer ever ' - + 'measured is a UI builder padding a HALF-TYPED pair with `\'\'` so that a length-based ' - + 'completeness check passes it — nobody WANTS a blank bound, which is why it is refused ' - + 'rather than given a published meaning (option B was declined: a semantics nobody asked ' - + 'for, to be honoured per driver). The refusal names the blank SIDE (MIN / MAX plus the ' - + 'index) because with a padded pair both bounds are present and the author is the one ' - + 'person who cannot see which is empty. Scope is the empty string and `undefined` and ' - + 'nothing wider: whitespace-only endpoints are deliberately NOT judged, since narrowing a ' - + 'published face further than the ruling is the seat call this card\'s whole history ' - + 'refuses to make. Ships at once, no grace window and no dual spelling (2026-08-27 ' - + 'maintainer ruling 「短期不考虑渐进」). ' - + '⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather ' - + 'than assumed: `applyConversionsToStoredItem` — the one primitive every stored-row ' - + 'rehydration seam calls — never throws and never validates, and replays only the ' - + 'positively-recognised lossless transforms in the conversion registry; measured on ' - + '`origin/main`, a stored view carrying `{ close_date: { $between: [\'2026-01-01\', \'\'] } }` ' - + 'comes back as the SAME object reference. So the load path today neither drops a refused ' - + 'operator nor refuses the row, and no conversion in the registry drops a filter OPERATOR ' - + '(the three filter-adjacent entries are key strips and a key rename). That is also the ' - + 'precedent the two nearest narrowings of this same surface set — ' - + '`filter-preset-ordering-comparand-refused` and ' - + '`analytics-date-range-array-two-bounds-required` — both of which decline a D2 conversion ' - + 'on the ground that rewriting would be the platform guessing which bound was meant. ' - + 'Dropping the operator would be worse than guessing: it deletes a constraint the author ' - + 'wrote and WIDENS the result set silently, the failure mode `$nin` carries in the same ' - + 'file. The read path does not re-validate stored rows, so no stored view becomes ' - + 'unreadable — and, because every stored carrier is typed loosely or judged by arity ' - + 'alone (see `surface`), re-saving one is not refused either. What changes is the enforced ' - + 'operator schema itself, which answers at the endpoint\'s own path with the blank side ' - + 'named, and the engine comparand-shape door, which refuses an executed filter carrying ' - + 'one. The objectui half — the builder stops padding a half-typed pair, so ' - + 'the console never meets this refusal mid-typing — is a change to the console\'s own ' - + 'filter builder and lands on its own ' - + 'schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087.', - acceptanceCriteria: - 'Grep every authored `$between` array — view, page and component filter rules, dashboard ' - + 'widget and options-source filters, dataset and dataset-measure filters, report ' - + 'runtimeFilters, rollup and related-list filters, saved AST filters, SDK and MCP callers ' - + '— and read BOTH of its elements. A range with two present, non-empty endpoints parses ' - + 'byte-identically to before, numbers, Dates, ISO days, UTC instants, clock times and ' - + 'non-temporal text included, and `[\'0\', \'9\']` and `[0, 100]` are untouched (the rule ' - + 'is blankness, not falsiness). An empty-string or absent bound now answers one ' - + 'prescriptive issue at that endpoint\'s own path (`$between.0` / `$between.1`) naming MIN ' - + 'or MAX, and a range blank on BOTH sides reports both positions. ⚠️ THE DETECTOR IS NOT ' - + 'THE SAME ON EVERY CARRIER, and re-saving the document is mechanical on NONE of them. ' - + '`FieldOperatorsSchema.safeParse` is mechanical, and it is the whole of what the save ' - + 'door offers: it answers for a caller that validates a filter against that schema, or ' - + 'against the NormalizedFilter AST, directly. ⛔ Re-saving surfaces NOTHING for a stored ' - + 'document — not a dashboard, dataset, report, rollup or related list, whose filter slots ' - + 'are FilterConditionSchema, and not a view, page or component filter RULE either, whose ' - + 'value check judges arity and not blankness, so a range written `[\'2026-01-01\', \'\']` ' - + 'saves exactly as green as it always did. Measured, not assumed. For those carriers the ' - + 'detectors are the GREP above and EXECUTING the surface, where the engine ' - + 'comparand-shape door refuses with INVALID_FILTER / 400 naming the index and the side. ' - + '⛔ Do not read a clean re-save of a dashboard — or of a view — as a completed sweep. ' - + 'Nothing is normalised on the way through — no bound is trimmed, defaulted or copied ' - + 'from its neighbour — so an accepted range arrives byte-identical to what was written. ' - + '⚠️ Do not assume a converted range was previously showing the window it named: a blank ' - + 'bound stopped bounding on that side at every backend, so the surface was reading a wider ' - + 'set than its filter claimed. Decide the window from what the surface was SUPPOSED to ' - + 'show, and if only one side was ever meant, write it as `$gte` / `$lte` rather than ' - + 'inventing a second bound. `null` bounds are unaffected by this entry and keep their own ' - + 'refusal and prescription.', - }, - // The ledger's FIRST record of the 2026-08-11 removal, registered when the - // RUNTIME door finally enforced it. The schema-door half shipped without an - // entry, so this one covers both doors rather than only the second. - { - id: 'filter-between-field-reference-endpoint-refused', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'either endpoint of a $between range, authored as a { $field } column reference, in any filter ' - + 'this platform stores or executes. The carriers split in two, because they are DETECTED ' - + 'differently and only one half answers on save. (a) REFUSED AT SAVE — a view, page or ' - + 'component filter RULE (ViewFilterRuleSchema, whose value is shaped by the operator) and the ' - + 'NormalizedFilter AST the query faces validate against, plus the enforced FieldOperatorsSchema ' - + 'copy itself. (b) NOT JUDGED AT SAVE — a dashboard widget filter, a dataset filter, a report ' - + 'runtimeFilter, a rollup filter and a relatedListFilter: every one of those slots is typed ' - + 'FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and ' - + 'carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not ' - + 'unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte ' - + 'value, or a $between endpoint — is refused at that position\'s own path, and it is refused ' - + 'through an otherwise GREEN document; the sibling entry ' - + 'filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these ' - + 'carriers never judge is a $between endpoint for a COLUMN REFERENCE or for ARITY. Measured: a ' - + 'dashboard widget whose filter reads close_date $between { $field: "contract.start" } and ' - + '2026-12-31 parses GREEN, as do a one-element, a three-element and an empty $between, while ' - + 'the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So ' - + 'for THIS shape every one of those documents parses GREEN and the endpoint is refused only ' - + 'when the filter is EXECUTED, at the runtime lowering door this change closes. ARITY is not what ' - + 'changed: a reference endpoint is a well-formed TWO-element range one of whose elements no ' - + 'backend resolves', - replacement: - 'a literal bound — the value the range was meant to stop at, written out. If the range was ' - + 'genuinely meant to be COLUMN-TO-COLUMN, that is not a $between at all: write the two ' - + 'bounds separately as scalar comparisons, {"$gte": {"$field": "a"}} for the lower bound and ' - + '{"$lte": {"$field": "b"}} for the upper one, which is the position the column-to-column ' - + 'comparison compiles on every ' - + 'face. ⛔ There is no replacement that can be DERIVED from what was written: the literal a ' - + 'reference stood for is not recoverable, and dropping the operator would delete a ' - + 'constraint the author wrote and WIDEN the result set silently. A reference remains legal, ' - + 'unchanged, as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte', - reason: - 'Maintainer ruling of 2026-08-11 on column-reference range endpoints, ADR-0049 ' - + 'enforce-or-remove: REMOVE. Both $between ' - + 'endpoint unions carried FieldReferenceSchema and no backend ever resolved one in a list ' - + 'position — matches-filter.ts leaves the list unresolved and orders against the raw ' - + 'reference OBJECT, so the range silently matches nothing, and both SQL faces refuse the ' - + 'position with INVALID_FILTER / 400. The published endpoint contract has stated the rule ' - + 'verbatim since that day: "A { $field } reference is NOT an endpoint shape" ' - + '(RANGE_ENDPOINT_DESCRIPTION, packages/spec/src/data/filter.zod.ts). ' - + '⚠️ That ruling shipped at the AUTHORING SCHEMA door alone, and no ledger entry was written ' - + 'for it — measured before this change: no semantic entry, no retired key, no spec-changes ' - + 'row and no upgrade-guide line named the shape. That was not an omission, and this entry ' - + 'SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset ' - + 'carried the disposition not-required (no-migration-prescription), reviewed and ' - + 'accepted with that ruling and shipped in the published CHANGELOG. What changed is the fact that ' - + 'disposition rested on. It was claimed for a removal whose reach was believed to be the ' - + 'authoring schema alone; the runtime half now ships with a migration prescription of its own ' - + '(below), and a body carrying a prescription is exactly what that category refuses. So the ' - + 'transition is registered here, covering BOTH doors, and the earlier not-required reading is ' - + 'retired by this record. The runtime lowering door disagreed with ' - + 'the declaration for the whole of that window: parseFilterAST({ f: { $between: [{ $field: ' - + '"a" }, "M"] } }) returned the filter unchanged, same object reference, measured on ' - + 'origin/main immediately before the change and re-measured after. One published sentence, ' - + 'two truth values, decided by which door a caller came through — and the door that passed ' - + 'it is the one an embedder reaches by handing a lowered filter straight to a driver. This ' - + 'entry therefore registers the transition for BOTH doors, not only the second, which is ' - + 'why it is filed as an entry of its own rather than as an already-registered rider. ' - + '⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather ' - + 'than assumed: applyConversionsToStoredItem — the one primitive every stored-row ' - + 'rehydration seam calls — never throws and never validates, and replays only the ' - + 'positively-recognised lossless transforms in the conversion registry; measured on this ' - + 'branch, a stored view carrying { close_date: { $between: [{ $field: "contract.start" }, ' - + '"2026-12-31" ] } } comes back as the SAME object reference. Rewriting is not available in ' - + 'principle here, not merely declined: the literal the author meant is not recoverable from ' - + 'a reference, and the column-to-column reading has a different OPERATOR SHAPE (two scalar ' - + 'bounds), so producing it would be the platform rewriting one filter into another. That is ' - + 'the same ground the two nearest narrowings of this surface set stand on — ' - + 'filter-between-blank-endpoint-refused and filter-preset-ordering-comparand-refused. The ' - + 'read path does not re-validate stored rows, so no stored view becomes unreadable; what ' - + 'changes is that RE-SAVING one is refused, at the endpoint\'s own path, with the side ' - + 'named. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling ' - + '「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep every authored $between array — view and dashboard widget filters, dataset filters, ' - + 'report runtimeFilters, page and component filters, rollup filters, saved AST filters, SDK ' - + 'and MCP callers — and read BOTH of its elements for a { $field } key. A range with two ' - + 'literal endpoints parses byte-identically to before, numbers, Dates, ISO days, UTC ' - + 'instants, clock times and non-temporal text included; nothing is trimmed, defaulted or ' - + 'copied from its neighbour, so an accepted range arrives byte-identical to what was ' - + 'written. ⚠️ THE DETECTOR IS NOT THE SAME ON EVERY CARRIER, and re-saving the document is ' - + 'mechanical on only one half of them. Re-saving DOES answer for a view, page or component ' - + 'filter RULE and for the NormalizedFilter AST: a reference endpoint reports an issue at the ' - + 'rule\'s own value path (for a list view, list.filter.N.value) or at the endpoint\'s own AST ' - + 'path ($between.0 / $between.1) — note the schema door names the INDEX, while the runtime ' - + 'door additionally names the side, MIN or MAX. ⛔ Re-saving surfaces NOTHING for a dashboard ' - + 'widget filter, a dataset filter, a report runtimeFilter, a rollup filter or a ' - + 'relatedListFilter: those slots are FilterConditionSchema, whose one refinement judges bare ' - + 'date-range preset comparands and nothing about a reference endpoint, so such a document ' - + 'parses green — measured, not assumed. For those carriers the detectors are the GREP above ' - + 'and EXECUTING the ' - + 'surface, where the runtime lowering door now refuses with INVALID_FILTER / 400 naming the ' - + 'index and the side. ⛔ Do not read a clean re-save of a dashboard as a completed sweep. A ' - + 'range whose BOTH endpoints are references reports both positions at the schema door; the ' - + 'runtime door throws on the first. ⚠️ Do not assume such a range was showing the window it ' - + 'named: at every backend it either matched NOTHING (the in-memory matchers) or was refused ' - + '(both SQL faces), so a surface carrying one was never answering the query its filter ' - + 'claimed — decide the window from what the surface was SUPPOSED to show. If the intent was ' - + 'column-to-column, the replacement is the two-bound spelling and it needs testing as a NEW ' - + 'filter, because nothing was ever evaluating the old one. Endpoints that are null, blank or ' - + 'of the wrong type keep their own refusals and their own entries. $in / $nin MEMBERS ' - + 'carrying a reference are ruled out by the same 2026-08-11 decision and refused at the ' - + 'authoring schema door (SET_MEMBER_DESCRIPTION); they are outside THIS entry\'s transition ' - + 'and are worth sweeping in the same pass.', - }, - // The second half of the schema door's parity with the query faces. The first - // (filter-query-face-comparands-refused-at-save) asked the comparand-SHAPE face - // and the flag rule; this one asks the comparand-TYPE face too, and makes a - // dashboard widget filter and both report runtimeFilters analytics carriers, so - // the slots inside a nested relation that the analytics where door judges are - // judged on save there as on a dataset filter. The faces stay the judges; the - // save door only asks them. - { - id: 'filter-comparand-types-and-widget-nested-slots-refused-at-save', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'data.FilterCondition — a comparand the comparand-type face refuses, now refused when the ' - + 'document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, ' - + 'text or flag comparand such as { a: 1 }, including a { $field } whose name is not a ' - + 'string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond ' - + 'plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand ' - + 'or an $in / $nin / $between list member. On every schema that carries a FilterCondition, ' - + 'at the reach the save door already had (the field entries of a condition and of every ' - + '$and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a ' - + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, ' - + 'INSIDE a nested-relation condition as well, for these shapes and for every shape the ' - + 'earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ' - + 'ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset ' - + 'carriers', - replacement: - 'a value of one of the six accepted comparand types — a string, number, bigint within plus ' - + 'or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column ' - + 'is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / ' - + '$ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a ' - + 'string or within range. Inside a nested relation on a widget filter or a report ' - + 'runtimeFilter, write the same ' - + 'spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} ' - + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' - + 'within 2^53 are untouched, and the save door keeps a bigint as written', - reason: - 'The save door narrows to exactly what the query faces already refuse (the second stage ' - + 'of closing the family of comparand shapes the save door accepted and the query faces ' - + 'refused). The comparand-type face (normalizeFilterComparandTypes, the accepted set the ' - + 'maintainer ruled on 2026-08-12: string, number, bigint, boolean, null and Date) refuses these values on every query: parseFilterAST, the engine ' - + 'seam, the analytics where door and the read-scope compiler all run it. Measured on ' - + 'origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a ' - + 'dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined ' - + 'report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, ' - + '{ stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type ' - + 'face and the analytics where door refused each with INVALID_FILTER / 400. And a ' - + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter ' - + 'parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and for a list in a nested ' - + 'equality slot, which the analytics where door refuses when they are charted, because ' - + 'only the two dataset carriers had the nested-relation walk. ' - + 'The save door now asks the type face itself, read-only, after the shape face, so it ' - + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' - + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' - + 'own words less its location clause. At the top level of a filter and in its $and / $or / ' - + '$not members, a second issue on a slot a face already refused (the schema door\'s own ' - + '$icontains and date-preset arms) is no longer raised; inside a nested relation on an ' - + 'analytics carrier those two arms still judge the slot beside the faces, so a nested ' - + '$icontains with a refused comparand, or a nested one-bound $between of a preset name, can ' - + 'carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same ' - + 'analytics-carrier filter as the dataset carriers, so their nested-relation slots are ' - + 'judged by the same walk: every stored filter the analytics where door charts now refuses ' - + 'on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 ' - + 'conversion: none of these values has a single honest meaning as a comparand, which is why ' - + 'each was refused. The read path does not re-validate stored rows, so a stored document ' - + 'keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), ' - + 'defineStack or os validate is refused at the filter\'s path. A JSON document can carry ' - + 'only the plain-object cells; the others arrive only from TypeScript authoring. Such a ' - + 'filter has failed every query since the type face\'s ruling, so the refusal is a repair ' - + 'and not a loss. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Validate every stack and re-save every stored document that carries a filter: os validate ' - + 'or defineStack, and a save through the metadata protocol, report each refused slot by ' - + 'path, for example widgets.0.filter.acct.stage.$in.1 or filter.stage.$eq, with the type ' - + 'face\'s sentence and the accepted set. A producer census before the change — a literal ' - + 'scan with a lit control per shape over examples, the non-test packages of this repository, ' - + 'the console repository at its pin and the cloud repository, plus a runtime walk of every ' - + 'filter in the example stacks — found no authored filter carrying one of these values and ' - + 'no widget filter or report runtimeFilter with a nested-relation condition holding a ' - + 'list or an operator map.', - }, - // The RUNTIME door's half of the question the sibling entry - // view-filter-rule-scalar-operator-array-refused answered at the view-rule - // schema door: that entry refuses an array on a scalar view operator when the - // rule is authored; this one refuses the lowered shape itself, at the shared - // comparand-shape face every query crosses, whichever vocabulary produced it. - // Recorded as its own entry because the surface is different (the $ dialect - // and the FilterArray sugar, not ViewFilterRule) and because its scope stops at - // the EQUALITY slot, where the view entry covers every scalar operator. - { - id: 'filter-equality-array-comparand-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'data.FilterCondition — an ARRAY as an EQUALITY comparand, at the runtime filter doors (the ' - + 'shared comparand-shape face that parseFilterAST and the engine lowering seam both run): the ' - + 'implicit form { field: [...] } — which the FilterArray sugar ["field", "equals", [...]] ' - + 'lowers to, and likewise "=", "==" and "eq" — and the explicit form { field: { $eq: [...] } }, ' - + 'at any depth under $and / $or / $not, the empty array included', - replacement: - 'the operator the list was standing in for. "One of these values" is $in: ' - + '{ field: { $in: ["a", "b"] } } (authoring spelling "in"). "The stored multi-value field ' - + 'holds this value" is $contains with ONE member: { field: { $contains: "a" } } (authoring ' - + 'spelling "contains"), and an $or of those for any-of. A filter that meant a single value ' - + 'writes that value: { field: "a" }. The list operators ($in / $nin / $between) keep their ' - + 'arrays, empty lists included; every scalar equality comparand, null above all (the ' - + 'has-no-value predicate), is untouched; and $ne is NOT judged by this entry', - reason: - 'Maintainer ruling of 2026-09-23 (option 乙 — rather than declaring an array equality the ' - + 'SQL-family backends would have to invent, or documenting a divergence that stays silent ' - + 'on one backend): ' - + 'an array in the implicit-equality slot is refused at the shared face, for every driver at ' - + 'once — no alias, no grace window. The comparand-shape face declared that moving a rule ' - + 'to it 「closes that door for every driver at once」, and before this change it judged ' - + 'only the list-operator slot; the equality slot passed both shared doors and each backend ' - + 'answered it alone. Measured on the lowered node { tags: ["a"] } at this release, beside a ' - + 'scalar and an $in control. driver-sql on SQLite REFUSED it with INVALID_FILTER / 400 at ' - + 'the top level, and nested under $and / $or / $not answered 500 DATABASE_ERROR instead ' - + '(driver-turso and driver-sqlite-wasm are built on driver-sql and were not run separately). ' - + 'driver-memory REFUSED it with INVALID_FILTER / 400 at every depth. The formula matcher ' - + 'returned no row, including a row storing exactly ["a"]. driver-mongodb ANSWERED it: its ' - + 'translateFilter emits the array unchanged, and MongoDB equality on an array operand ' - + 'selects a stored array equal to ["a"] or holding ["a"] as an element — mingo 7.2.4, the ' - + 'named proxy, over ["a"], "a", ["a","b"], ["b","a"], [["a"],"x"], [["a"]], "b" and [] ' - + 'selected ["a"], [["a"],"x"] and [["a"]]. The service-analytics filter normalizer read the ' - + 'FilterArray form as MEMBERSHIP: ["stage", "=", ["won", "lost"]] charted as stage IN ' - + '(won, lost), and its OBJECT form read the same list four ways: { stage: [...] } as IN, ' - + '$eq with a list as its first member alone, $eq with an empty list as no predicate, and ' - + 'an empty implicit list as the FALSE constant. A live mongod, MySQL, PostgreSQL and a live ' - + 'Turso server were NOT measured. So one stored filter was a 400 on most backends and a ' - + 'silent, differently-shaped row set on one. The shared face now refuses it with ' - + 'INVALID_FILTER / 400 before any driver runs, naming the field, the path and both remedies. ' - + 'Which doors refuse it at this release, and with what: the shared face, inside ' - + 'parseFilterAST and at the engine lowering seam, with INVALID_FILTER / 400; the analytics ' - + 'where door in BOTH spellings, the FilterArray form through parseFilterAST and the OBJECT ' - + 'form because that door hands each equality-slot list to the shared face before it builds ' - + 'a node, with the same INVALID_FILTER / 400 and the same sentence (that door alone, among ' - + 'the runtime doors, also refuses a list inside a nested-relation condition, which it ' - + 'flattens to a dotted member); and, on SAVE, the schema door (FilterConditionSchema and the ' - + '$eq operator slot), with the same sentence as a parse issue at the filter\'s own path, ' - + 'which is the sibling entry filter-equality-array-comparand-refused-at-save, plus the two ' - + 'carriers that analytics door charts (a dataset filter and a measure filter) inside a ' - + 'nested relation too, which is dataset-filter-nested-relation-equality-array-refused-at-save. ' - + 'The ruling records the hosted product as running on the SQL family, where the top-level ' - + 'shape was already a 400, so the population that can observe a change is self-hosted ' - + 'driver-mongodb, plus any filter nested under a combinator on the SQL family (a 500 ' - + 'becomes a 400). $ne carrying an ' - + 'array measured the same split and is deliberately left to its own ruling. Metadata AT ' - + 'REST is not rewritten and this entry adds no D2 conversion: an array on equality has no ' - + 'single honest value, and choosing between $in and $contains is the author\'s call, not ' - + 'the platform\'s. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep stored filters, dataset and widget filters, flow node filters and code that builds ' - + 'a where for a field whose value is an array — { field: [...] }, { field: { $eq: [...] } }, ' - + 'or a FilterArray triple on =, ==, eq or equals carrying an array — then decide per filter ' - + 'what it meant: one of these values ($in), the stored list holds a value ($contains, an ' - + '$or of them for several), or one value. Each is refused at query time with INVALID_FILTER ' - + '/ 400 naming the field and the path, so a test suite that exercises the query finds ' - + 'every one; a stored carrier is also refused on save, which is the sibling entry ' - + 'filter-equality-array-comparand-refused-at-save. A dashboard or dataset filter written as ' - + 'the FilterArray sugar with an array on equality, or as the implicit object form, charted ' - + 'as membership through the analytics normalizer; $in is the spelling that charts the same ' - + 'rows. On driver-mongodb re-check what the query is supposed to return rather than ' - + 'assuming the old rows were right: the old answer was MongoDB array equality, which ' - + 'neither $in nor $contains reproduces.', - }, - // The SCHEMA door's half of filter-equality-array-comparand-refused. That entry - // refuses the shape at the runtime filter doors, where a query is compiled; this - // one refuses the same shape where a filter is SAVED, in the same words, so a - // stored filter stops publishing clean and failing later for someone else. - // Recorded as its own entry because the surface is different (every schema that - // carries a FilterCondition, and the $eq operator slot) and because what an - // upgrading author sees changes at a different moment: on save, not on query. - { - id: 'filter-equality-array-comparand-refused-at-save', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'data.FilterCondition and the $eq slot of data.FieldOperators — an ARRAY as an EQUALITY ' - + 'comparand, now refused when the document is PARSED: the implicit form { field: [...] } and ' - + 'the explicit form { field: { $eq: [...] } }, the empty array included, on every schema ' - + 'that carries a FilterCondition — a dataset filter and a dataset measure filter, a ' - + 'dashboard widget filter and an options-source filter, a report and joined-report-block ' - + 'runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a ' - + 'solution-blueprint summary filter, an analytics query where, a dataset selection ' - + 'runtimeFilter, a query where and having, the data-engine aggregate call\'s having, an ' - + 'aggregation filter and a query-filter where ' - + '— plus FieldOperatorsSchema.$eq, its documentation copy EqualityOperatorSchema.$eq, and ' - + 'the NormalizedFilter AST that validates against it', - replacement: - 'the operator the list was standing in for, exactly as in ' - + 'filter-equality-array-comparand-refused. "One of these values" is $in: ' - + '{ field: { $in: ["a", "b"] } } (authoring spelling "in"). "The stored multi-value field ' - + 'holds this value" is $contains with ONE member: { field: { $contains: "a" } } (authoring ' - + 'spelling "contains"), and an $or of those for any-of. A filter that meant a single value ' - + 'writes that value: { field: "a" }. The list operators ($in / $nin / $between) keep their ' - + 'arrays, empty lists included; every scalar equality comparand, null above all, a Date and ' - + 'a { $field } reference are untouched; and $ne is NOT judged by this entry', - reason: - 'Ruled on 2026-09-24 (option A), applying the standing refusal of an array in the ' - + 'equality slot to the schema door: FilterConditionSchema (implicit equality) ' - + 'and FieldOperatorsSchema.$eq refuse an array comparand at parse, with the SAME remedy ' - + 'text the shared compile face emits — one constant, two doors; a stored filter carrying ' - + 'the shape is refused loudly on its next save, and never silently dropped, because a ' - + 'dropped filter shows MORE rows than intended. Measured on origin/main a0920b42dc before ' - + 'the change: a dataset whose filter was { stage: ["won", "lost"] }, and one whose measure ' - + 'filter was { stage: { $eq: ["won", "lost"] } }, both parsed GREEN, as did ' - + 'FilterConditionSchema and FieldOperatorsSchema on the bare shapes, while the shared ' - + 'comparand-shape face refused both with INVALID_FILTER / 400. So such a document ' - + 'published clean and then failed every query that used it, for a different person, ' - + 'later. The schema door now prints the face\'s own sentence, from one builder both doors ' - + 'import; the only difference is that the face appends the location (at where.stage) and ' - + 'the schema door does not, because its issue carries the location as its path ' - + '(filter.stage, measures.0.filter.stage.$eq). The reach is the face\'s and no wider: the ' - + 'field entries of a condition and of every $and / $or / $not member, but NOT a field spec ' - + 'with no $ key (a nested-relation or deep-equality condition), which the face never ' - + 'descends either. ⚠️ Three positions therefore still refuse only at execution. (1) A list ' - + 'inside a nested-relation condition, { account: { region: ["a"] } }: the analytics where ' - + 'door flattens that to the dotted member account.region and refuses it when the filter is ' - + 'charted; a dataset filter and a measure filter refuse it on save as well, which is the ' - + 'sibling entry dataset-filter-nested-relation-equality-array-refused-at-save. (2) The where ' - + 'option of the data-engine calls (find, count, ' - + 'update, delete, aggregate, vector find): its type is a union whose first arm is an open ' - + 'record, so it parses and the face refuses it when the call runs. (3) $ne carrying a ' - + 'list, which no ruling has decided. Two request doors parse these carriers and now answer ' - + 'the shape before the analytics compiler does: the REST dataset selection (its ' - + 'runtimeFilter) and the analytics query body (its where) refuse with VALIDATION_FAILED / ' - + '400 and this sentence at the field, one step ahead of the compiler\'s INVALID_FILTER / ' - + '400. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the ' - + 'reason the runtime entry gives: an array on equality has no single honest value. The ' - + 'read path does not re-validate stored rows, so a stored document keeps loading; what ' - + 'changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), ' - + 'defineStack or os validate is refused at the filter\'s path. Such a filter has failed ' - + 'every query since the runtime entry, and on the SQL family before it at the top level, ' - + 'so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Validate every stack and re-save every stored document that carries a filter: os validate ' - + 'or defineStack, and a save through the metadata protocol, report each array in an ' - + 'equality slot by path with the field, the received list and both remedies, so the sweep ' - + 'is mechanical for the carriers listed in the surface. Decide per filter what it meant — ' - + 'one of these values ($in), the stored list holds a value ($contains, an $or of them for ' - + 'several), or one value — and re-check what the surface is supposed to show rather than ' - + 'assuming the old rows were right: on most backends the filter had been failing every ' - + 'query. ⛔ A clean re-save is NOT a complete sweep for the three positions the reason ' - + 'names: grep nested-relation conditions and data-engine where options for a field whose ' - + 'value is a list, and exercise them, where the runtime doors refuse with INVALID_FILTER ' - + '/ 400 naming the field and the path. A dataset filter or a measure filter is the ' - + 'exception: its nested-relation lists are refused on save too ' - + '(dataset-filter-nested-relation-equality-array-refused-at-save).', - }, - // One entry for two doors on purpose: the two vocabularies spell one operator - // and the rows being answered are one pair. Splitting it would put half the - // prescription in front of an author who wrote the other spelling. - { - id: 'filter-icontains-comparand-refused-at-parse', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect ' - + 'key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, ' - + 'dashboard and analytics filters) and the infix spelling icontains on ' - + 'ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the ' - + 'EMPTY STRING or is not a string at all', - replacement: - 'a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate ' - + 'that constrains nothing, so the repair is to DROP the condition rather than to write ' - + 'something in it. A comparand that was a number, boolean or null is written as the ' - + 'string it was meant to match: value 42 becomes value "42" only if a substring match on ' - + 'the two characters is really what was meant, and if it is not, the operator was the ' - + 'wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand ' - + 'and this rule says nothing about it', - reason: - 'The protocol half of the maintainer\'s 2026-09-20 ruling (option C-prime) on the console\'s ' - + 'filter converter, whose first rule reads, verbatim and untranslated: 「the differences are ' - + 'the protocol\'s to close」. The platform already ' - + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' - + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' - + 'code INVALID_FILTER and each requiring the refusal to name the operator. All five ' - + 'driver packages run both rows in their own suites, and the drivers re-run for this ' - + 'change (driver-sql on SQLite, driver-memory, driver-mongodb\'s translateFilter) each ' - + 'refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse ' - + 'them, it answers false for every row. Nothing applied them at PARSE on either ' - + 'vocabulary, so the ' - + 'protocol declared the refusal and then admitted the document that would hit it — the ' - + 'declared-not-enforced shape ADR-0049 exists to close. ' - + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' - + 'the published predicate isRefusedTextComparand and the published reason text ' - + 'textComparandRefusalReason, the pair published in this package beside FILTER_TEXT_CASES ' - + 'for exactly ' - + 'this reason, so a row added to the table reaches both doors without an edit at either. ' - + '$contains, $startsWith, ' - + '$endsWith, $like and $ilike keep the answer they give today, ' - + 'because widening by analogy is the table\'s decision and not a door\'s. ' - + 'The two vocabularies differ on one point and it is a fact about them rather than an ' - + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' - + 'unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand ' - + 'slot is the refused non-string shape — the same reading the comparand-type door ' - + 'already takes of that cell. ' - + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. ' - + 'An empty comparand has no lossless replacement (dropping a condition changes which ' - + 'rows a view returns, which is the author\'s decision) and a non-string one has no ' - + 'honest coercion (the platform refuses to answer a query nobody wrote). The read path ' - + 'does not re-validate stored rows, so a stored filter keeps loading; what changes is ' - + 'that RE-SAVING it is refused, with the reason text three shipped consumer faces ' - + 'already show at query time. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep your authored filters for the case-insensitive contains operator in either ' - + 'spelling and read each comparand: an empty one means the condition was a placeholder ' - + 'and the repair is to delete it, and a non-string one means either a missing pair of ' - + 'quotes or the wrong operator. At this release neither comparand returns rows: each of ' - + 'the five driver packages answers both with INVALID_FILTER at query time, and the ' - + 'formula matcher answers false for every row. How earlier releases answered them was ' - + 'NOT measured, so re-check ' - + 'what the view is supposed to show rather than assuming the old result set was correct. ' - + 'Both refusals now arrive at the authoring path.', - }, - // #20446 — the last step of ruling A on #20399: `$empty` joins FILTER_OPERATORS - // and the view operators is_empty / is_not_empty lower to it instead of `$null`. - // Nothing stored is rewritten; a stored 「is empty」 rule is re-read under the new - // meaning, which widens on text and multi-value fields and refuses where no face - // holds the column's declared type. The changeset declares Clause-② yes - // (narrowing); this entry is where the prescription reaches os migrate meta, - // the upgrade guide and spec-changes.json. - { - id: 'filter-is-empty-lowers-to-empty-operator', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. No ' / ' either: the - // schema build reads that separator as a boundary between registered clauses. - surface: - 'data.FilterCondition — the lowering of the view operators is_empty, isempty, is_not_empty ' - + 'and isnotempty (on a ViewFilterRule, a sharing rule and any filter array), and a $empty ' - + 'object written as a record field value', - replacement: - 'Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: ' - + 'true } } and is_not_empty to { field: { $empty: false } }, answered by the field\'s declared ' - + 'type — a text-like field is empty when it is null or the empty string, a multi-value field ' - + 'when it is null or the empty list, every other type only when it is null. Where no face holds ' - + 'the column\'s declared type the rule is refused: on the built-in id, write is_null / ' - + 'is_not_null; on a federated object whose driver does not implement external-object ' - + 'registration (driver-memory, driver-mongodb), bind it on a driver that implements federation ' - + '(the boot error names the object); on an AnalyticsService built without sourceFieldMeta, pass ' - + 'sourceFieldMeta or write is_null / is_not_null; on a multi-value column over a SQL dialect ' - + 'driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty ' - + 'object as a field value writes the value itself instead; a filter belongs in where', - reason: - 'One ruling set what 「is empty」 means once, per field type; a second spelled it as the $empty ' - + 'operator, which each compile face expands from the field\'s declaration. It was staged out ' - + 'of FILTER_OPERATORS until every face answered it, then added in the same change that flipped ' - + 'the lowering, after measuring that no face drops it. Two consequences reach stored metadata. ' - + 'A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the ' - + 'empty string or the empty list, which the $null lowering missed. ' - + 'And the rule is refused ' - + 'where the face that answers it holds no declaration for the column — the four ' - + 'compositions the replacement names — where the $null lowering compiled IS NULL. The same ' - + 'change made the write door refuse a $empty object as a field value, because that door ' - + 'refuses every filter operator the protocol enforces as a value: before, a text-like field ' - + 'stored it. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 ' - + 'conversion: the stored spelling is unchanged, and its new meaning is the ruled one. ' - + 'ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text ' - + 'or multi-value field, re-check what the view or rule is supposed to select. ' - + 'On the built-in id, rewrite it ' - + 'to is_null / is_not_null. If a federated object on driver-memory or driver-mongodb, or an ' - + 'AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails ' - + 'instead of answering — bind the object on a federation-capable driver, or pass ' - + 'sourceFieldMeta. No insert or update payload carries a $empty object as a field value.', - }, - // Ruling A on #19886, item 1: the $ne slot's half of the question - // filter-equality-array-comparand-refused answered for equality. One entry for - // both named positions — the shared comparand-shape face every query crosses, - // and the $ne operator slot of FieldOperatorsSchema — because the ruling gives - // them one remedy text and the two land together. The formula evaluator and - // driver-mongodb faces landed earlier under their own entries - // (rls-predicate-array-comparand-refused, cel-predicate-list-comparand-refused). - { - id: 'filter-ne-array-comparand-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'data.FilterCondition and the $ne slot of data.FieldOperators — an ARRAY as the comparand of ' - + '$ne. At the runtime filter doors (the shared comparand-shape face that parseFilterAST and ' - + 'the engine lowering seam both run): { field: { $ne: [...] } }, which the FilterArray sugar ' - + '["field", "ne", [...]] lowers to, and likewise "!=", "<>", "neq", "not_equals" and ' - + '"notequals", at any depth under $and / $or / $not, the empty array included. At parse: ' - + 'FieldOperatorsSchema.$ne, its documentation copy EqualityOperatorSchema.$ne, and the ' - + 'NormalizedFilter AST that validates against it', - replacement: - 'the declared list-negation operator. "None of these values" is $nin: ' - + '{ field: { $nin: ["a", "b"] } } (authoring spellings "nin", "not_in", "notin"). A filter ' - + 'that meant a single value writes that value: { field: { $ne: "a" } }. $ne: null (the ' - + 'has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and ' - + 'the list operators ($in / $nin / $between) keep their arrays, empty lists included', - reason: - 'Ruled on 2026-09-24 by the director seat, on the standing contract text (option A): ' - + 'the shared comparand-shape face refuses an array under $ne for every driver, and ' - + 'FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator by its ' - + 'spec spelling — no alias, no window. The governing text is $ne\'s own published describe: ' - + 'the comparand is a literal, or a { $field } reference to another column of the same table. ' - + 'An array is neither, so the refusal pulls the doors back to what $ne already declared. ' - + 'Measured on the card before any stage landed, on the lowered { tags: { $ne: ["a"] } }: ' - + 'driver-sql and driver-memory REFUSED it with 400; driver-mongodb ANSWERED it as MongoDB ' - + 'reads $ne against an array operand, not equal to that array and not holding it as an ' - + 'element, which is every scalar row (mingo, the named proxy; a live mongod was NOT ' - + 'measured); and the formula evaluator matched EVERY row, which on the row-level write check ' - + 'admitted every write a != policy against a list was written to refuse. Those two answering ' - + 'faces were closed first, each at its own face, under rls-predicate-array-comparand-refused ' - + 'and cel-predicate-list-comparand-refused. Measured on origin/main 9e7824a4, after both and ' - + 'before this change: the shared face passed the shape at every depth (so did its ' - + 'FilterArray lowering, and the engine\'s delegating wrapper), and FieldOperatorsSchema, ' - + 'EqualityOperatorSchema and the NormalizedFilter AST all parsed it GREEN. Now the face ' - + 'refuses it with INVALID_FILTER / 400 before any driver runs, and the operator slot refuses ' - + 'it on parse, with one sentence from one builder: the face names the field and appends the ' - + 'location (at where.tags.$ne); the slot cannot see either, and its issue carries the ' - + 'location as its path. On the SQL family and driver-memory the verdict does not move (400 ' - + 'before, 400 after); the text and the moment move, to the face, before any driver. ⚠️ Not ' - + 'moved by this entry: FilterConditionSchema, the schema every stored filter carrier parses ' - + 'through (dataset, dashboard widget, report, rollup and the rest), does not parse a field\'s ' - + 'operator map through FieldOperatorsSchema and its own walk does not judge $ne, so such a ' - + 'carrier still SAVES a $ne list and the face refuses it at query time; the ruling names the ' - + 'face and the operator slot, not that walk. Metadata AT REST is not rewritten and this entry ' - + 'adds no D2 conversion: a list under $ne has no single honest value, and whether it meant ' - + 'none of these values or one value is the author\'s call. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep stored filters, dataset and widget filters, flow node filters and code that builds a ' - + 'where for $ne whose comparand is an array — { field: { $ne: [...] } }, or a FilterArray ' - + 'triple on ne, !=, <>, neq, not_equals or notequals carrying an array — then decide per ' - + 'filter what it meant: none of these values ($nin), or one value ($ne with that value). Each ' - + 'is refused at query time with INVALID_FILTER / 400 naming the field, the path and $nin, so ' - + 'a test suite that exercises the query finds every one; code that parses a filter with ' - + 'FieldOperatorsSchema or the NormalizedFilter AST is refused on parse at the $ne path. ⛔ A ' - + 'clean re-save of a stored carrier is NOT a sweep: the carrier schema does not refuse the ' - + 'shape, so exercise each stored filter or grep it. On driver-mongodb re-check what the query ' - + 'is supposed to return rather than assuming the old rows were right: the old answer was ' - + 'MongoDB array inequality, which $nin does not reproduce.', - }, - { - id: 'filter-preset-ordering-comparand-refused', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'a dashboard date-range preset name (last_7_days / last_30_days / last_90_days, today, ' - + 'yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, ' - + 'this_year, last_year) authored as a bare ORDERING comparand in a filter — a ' - + '$gt / $gte / $lt / $lte value or a $between endpoint, a greater_than / less_than / ' - + 'before / after / between view filter rule value, or an ordering [field, op, value] ' - + 'filter triple. WHICH DOOR refuses it at publish is decided by the carrier\'s declared ' - + 'type and by its key. The carriers measured fall in groups, and the groups are a ' - + 'list of what was measured, not a closed partition: the grep in the acceptance criteria ' - + 'is the catch-all. (1) A slot typed FilterConditionSchema — a ' - + 'dashboard widget filter, a dashboard global-filter options-source filter ' - + '(optionsFrom.filter), a dataset filter, a dataset measure filter, a report runtimeFilter ' - + '(on the report or on a joined-report block), a rollup summaryOperations.filter and a ' - + 'relatedListFilter — is refused at PARSE, at the comparand\'s own path, and the ' - + '@objectstack/lint filter-preset-comparand rule reports it as well. (2) A filter under a ' - + 'key the lint walks, whose declared type carries no preset check, parses GREEN, and the ' - + 'lint rule is the only door that refuses it: a ViewFilterRuleSchema rule array (a view\'s ' - + 'filter, a page element\'s dataSource.filter, a page component\'s filter prop), and a ' - + 'Mongo-shape filter record typed as a loose record rather than FilterConditionSchema (a ' - + 'flow CRUD node\'s config.filter). The lint is likewise what refuses a preset in an ' - + 'ordering filter triple wherever its walk meets one', - replacement: - 'the date-macro window the preset already means — { $gte: "{30_days_ago}" } for ' - + 'last_30_days, { $between: ["{week_start}", "{week_end}"] } for this_week, and so on ' - + '(the rejection names the exact window per preset; DATE_RANGE_PRESET_MACRO_WINDOWS in ' - + '@objectstack/spec/data is the table) — or an ISO date such as 2026-01-15. The preset ' - + 'names themselves stay fully legal where a layer resolves them to a window: the dashboard ' - + 'date-filter positions (dateRange.defaultRange, a date global filter defaultValue) and an ' - + 'analytics query\'s timeDimensions[].dateRange. A filter comparand is not one of those ' - + 'positions', - reason: - 'The authoring half (option C) of the maintainer\'s 2026-08-15 ruling on uninterpretable ' - + 'temporal comparands, ruled alongside the engine door (option B) that refuses them at ' - + 'query time. ' - + 'The preset vocabulary is declared in the dashboard schema and lowered to {date-macro} ' - + 'bounds by the shipped console before any query is sent — so the names were declared in ' - + 'one layer and unrecognised in the next, with no error at the boundary. Authored as a ' - + 'bare comparand (a saved report, an integration, an MCP client, an AI-authored query), ' - + 'the name reached the driver as written and compared false against every row: HTTP 200, ' - + 'count 0, indistinguishable from "there is no data" (measured on the defect report: $gte ' - + '"last_30_days" returned 0 of 51 seeded rows where the macro spelling returned the 38 ' - + 'in-window). The engine now refuses the bare name on a declared temporal field at query ' - + 'time (INVALID_FILTER / 400); this entry records the AUTHORING-time half, and that half ' - + 'is two doors with different reach, not one: the FilterConditionSchema parse refuses the ' - + 'shape on the slots typed that way, and the @objectstack/lint filter-preset-comparand ' - + 'rule refuses it on every filter its walk reaches, which makes it the only door for a ' - + 'walked filter whose declared type carries no preset check. Both answer at publish, where ' - + 'the author — an AI author in particular — can still act on the message; ' - + 'the surface\'s groups say which measured carrier sits under which door. Ordering ' - + 'positions only at the schema door, deliberately: it judges no equality or membership, ' - + 'because a select/picklist column legitimately stores values that collide with preset ' - + 'names and a schema has no field type in hand. The lint rule, which reads the stack\'s ' - + 'object metadata, additionally refuses a preset in an equality or membership position, ' - + 'in a filter its walk reaches, on a field it can resolve to a declared date or datetime ' - + '(where the filter binds to no object, or the field resolves to nothing, that arm cannot ' - + 'fire), and on a temporal field the engine door already refuses those with the field ' - + 'type in hand. ⚠️ Metadata AT REST is deliberately not rewritten and there is no D2 conversion: ' - + 'this shape was never written by any first-party producer (every preset in this repo ' - + 'and the example apps sits in a dashboard date-filter position — measured) and never ' - + 'executed usefully (it returned a silent zero before the engine door and a 400 after). ' - + 'Coercing ' - + 'it at load would be the platform guessing which bound the author meant. The read path ' - + 'does not re-validate stored rows, so no stored dashboard becomes unreadable; what ' - + 'changes is that RE-SAVING one is refused with the window named. ' - + 'ADR-0049 / ADR-0078 / ADR-0112.', - acceptanceCriteria: - 'Grep your authored filters for the thirteen preset names in ordering positions — a ' - + '$gt/$gte/$lt/$lte value, a $between endpoint, a greater_than/less_than/before/after/' - + 'between view rule value, an ordering filter triple, a gt/gte/lt/lte lookup filter value ' - + '— and rewrite each to the {date-macro} window the rejection names (or an ISO date). That ' - + 'grep is the catch-all; the surface\'s groups are the carriers measured. The sweep is ' - + 'mechanical for groups (1) and (2): `os validate` / `os lint` report each one by path, and ' - + 'a group (1) slot is also refused by a `safeParse` of the schema that declares it, at the ' - + 'comparand\'s own path. Leave presets in dashboard ' - + 'date-filter positions (dateRange.defaultRange, date global filter defaultValue) ' - + 'untouched — they remain the declared vocabulary there. A filter that carried one of ' - + 'these shapes was never returning the window it named (silent zero before the engine ' - + 'door, 400 after), so re-check what the surface was supposed to show rather than ' - + 'assuming the old result set was correct.', - }, - // The SCHEMA door's half of every comparand refusal the query faces already - // give. The shared comparand-shape face and the query faces' boolean-flag checks - // refuse these slots on every query; this entry records that the same slots are - // now refused where a filter is SAVED, so a stored filter stops publishing clean - // and failing later for someone else. The face itself is the judge at the save - // door, so the two doors cannot drift apart. - { - id: 'filter-query-face-comparands-refused-at-save', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'data.FilterCondition — every comparand slot the query faces refuse, now refused when the ' - + 'document is PARSED: a $null or $exists flag that is not a boolean (a string such as ' - + '"false", null, a number); a null $gt / $gte / $lt / $lte comparand; an $in or $nin ' - + 'comparand that is not a list, or a list holding null; a $between comparand that is not a ' - + 'two-element list, or whose endpoint is null, blank or a { $field } reference; and an ' - + 'array under $ne. On every schema that carries a FilterCondition: a dataset filter and a ' - + 'dataset measure filter, a dashboard widget filter and an options-source filter, a report ' - + 'and joined-report-block runtimeFilter, a field relatedListFilter and a rollup ' - + 'summaryOperations filter, a solution-blueprint summary filter, an analytics query where, ' - + 'a dataset selection runtimeFilter, a query where and having, the data-engine aggregate ' - + 'call\'s having, an aggregation filter and a query-filter where; and, on a dataset filter ' - + 'and a dataset measure filter only, the same slots INSIDE a nested-relation condition', - replacement: - 'the spelling the refusal prescribes, which is the one the query faces already prescribe. ' - + 'A flag is the boolean itself: $null true is "has no value", $null false is "has a value", ' - + 'and $exists is the inverse. Absence is the null predicate, never null in an ordering or ' - + 'list position: $eq null is "has no value", $ne null is "has a value", and "one of these ' - + 'values OR has no value" is an $or of an $in and a $null true. A single value for $in is a ' - + 'one-member list, or plain equality. A range is two bounds in a two-element list; a range ' - + 'bounded on one side is a $gte or a $lte; a column-to-column range is a $gte and a $lte ' - + 'whose comparands are { $field } references. "None of these values" is $nin, never $ne ' - + 'with a list. The null predicate itself, a { $field } reference as a whole comparand, ' - + 'an empty $in or $nin list and a whitespace endpoint are untouched', - reason: - 'The save door narrows to exactly what the query faces already refuse (the family of ' - + 'comparand shapes the save door accepted and the query faces refused; the $ne member is ' - + 'route A, the same reach and the same one ' - + 'sentence as the equality slot of filter-equality-array-comparand-refused-at-save). The ' - + 'shared comparand-shape face refuses on every query a null ordering comparand (ruled ' - + '2026-09-01), a non-list $in / $nin and a malformed $between range, a null list member or ' - + 'endpoint (ruled 2026-08-31), a blank endpoint (ruled 2026-09-20), a { $field } endpoint ' - + '(ruled 2026-08-11) and an array under $ne (ruled 2026-09-24); every query face refuses a ' - + 'non-boolean $null / $exists flag, because the backends read one in opposite directions. ' - + 'Measured on origin/main af32cf9a before the change: a dataset filter, a dataset measure ' - + 'filter, a dashboard widget filter and a report runtimeFilter each parsed GREEN for one ' - + 'instance of every shape the surface names, while the face refused each one with ' - + 'INVALID_FILTER / 400 and the analytics where door refused every one of them, the flags ' - + 'included. So such a document published clean and then failed every chart built on it. ' - + 'The save door now asks the face itself about each slot, so it refuses exactly what the ' - + 'face refuses and passes what the face passes; the words are the face\'s, or the ' - + 'sentence the enforced operator slot already prints for the same comparand, never the ' - + 'face\'s location clause, which the issue\'s path carries instead. The reach is the ' - + 'face\'s and no wider: the field entries of a condition and of every $and / $or / $not ' - + 'member, and NOT a field spec with no $ key (a nested-relation condition), which neither ' - + 'the face nor the drivers\' flag checks descend. The analytics where door DOES descend ' - + 'one (it flattens the relation to dotted members and judges each), so the two dataset ' - + 'carriers, whose own nested-relation walk already refused an equality list there ' - + '(dataset-filter-nested-relation-equality-array-refused-at-save), now ask the same ' - + 'judge about every slot inside a relation. ⚠️ So one position still refuses only at ' - + 'execution: a refused shape INSIDE a nested-relation condition on a dashboard widget ' - + 'filter or a report runtimeFilter, which reach the analytics where door too but carry ' - + 'the shared schema\'s reach only. Metadata AT REST is not rewritten and this entry adds no D2 ' - + 'conversion: none of these shapes has a single honest meaning (that is why each was ' - + 'refused), and a conversion would have to pick one. The read path does not re-validate ' - + 'stored rows, so a stored document keeps loading; re-saving it through the metadata ' - + 'protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter\'s ' - + 'path. Such a filter has failed every query since the runtime refusal of its shape, so ' - + 'the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Validate every stack and re-save every stored document that carries a filter: os validate ' - + 'or defineStack, and a save through the metadata protocol, report each refused slot by ' - + 'path with the operator, the field and the prescription, so the sweep is mechanical for ' - + 'the carriers the surface lists. Decide per filter what it meant and write that spelling; ' - + 'on most backends the filter had been failing every query, so re-check what the surface ' - + 'is supposed to show rather than assuming the old rows were right. One producer was ' - + 'measured before the change: a filter builder that writes "is empty" / "is not empty" as ' - + 'an $in / $nin list holding null and the empty string (the Studio filter-condition widget, ' - + 'at the console pin of that date); what it wrote is refused on its next save. ⛔ A clean ' - + 'save is NOT a complete sweep for the one position the reason names: search dashboard ' - + 'widget filters and report runtimeFilters for a nested-relation condition whose inner ' - + 'field carries one of these shapes, and chart it, where the analytics where door refuses ' - + 'with INVALID_FILTER / 400 naming the field and the path.', - }, - { - id: 'filter-text-operator-declared-type-refused', - surface: 'a STORED filter body the engine executes, where a text operator names a ' - + 'field whose declared type can never store a string. Measured carriers: ' - + '`sys_saved_report.query_json.filter` (executed verbatim as `engine.find(object, ' - + '{ where: q.filter })`, and reached again by every `sys_report_schedule` row ' - + 'through its `report_id`), `FieldSchema.summaryOperations[].filter` (ANDed with ' - + 'the parent-FK match and handed to `engine.aggregate`), `ListView.filter` and tab ' - + 'filters (`ViewFilterRuleSchema`, whose `contains` / `not_contains` / `icontains` ' - + '/ `starts_with` / `ends_with` spellings lower to the same operators through ' - + '`AST_OPERATOR_MAP`), and the `FilterConditionSchema` carriers on dashboards ' - + '(widget `filter`, `GlobalFilter`), datasets and reports (`runtimeFilter`), plus ' - + '`FieldSchema.relatedListFilter`. NOT this surface: an RLS / sharing / tenant ' - + 'predicate, which the platform composes onto the AST AFTER this door and which ' - + 'the door therefore never judges.', - replacement: 'compare the field with an operator its declared type can answer — `$eq` ' - + '/ `$ne` / `$in`, or a range (`$gte` / `$lt`) for a temporal or numeric field — or ' - + 'aim the text operator at a text-valued field instead. A dotted path into a ' - + 'structured-JSON field (`address.city`) stays legal and is deliberately unjudged. ' - + 'NO rewrite is mechanical: the author\'s intent is not recoverable from the stored ' - + 'condition — `{ amount: { $contains: \'5\' } }` may have meant `$eq: 5`, a range, ' - + 'or a filter on a different column altogether — so the loader must not choose one.', - reason: - 'Maintainer ruling of 2026-09-05 (option C-deny: refuse now, over the type sets the ' - + 'contract already declares, minting no new vocabulary), landed at the engine seam. A ' - + 'text operator (`$contains` / ' - + '`$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) ' - + 'over a field whose DECLARED type can never store a string — `NUMERIC_VALUE_TYPES` ' - + '∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ ' - + '`CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES` — is refused at the engine\'s ' - + 'field-aware door with `INVALID_FILTER` 400 instead of reaching a driver. It is a ' - + 'RUNTIME narrowing over an AUTHORED surface, which is why it is registered here ' - + 'rather than disposed of as needing no prescription: NO schema changed, so a ' - + 'stored filter carrying the refused shape still parses and still loads — ' - + '`FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` ' - + 'takes `field: z.string()` with `contains` in its operator enum — and the first ' - + 'sign of it is a 400 on the read that executes it. Before the door those reads ' - + 'answered `[]` (or every row for `$notContains`, or a SQLite coercion accident) ' - + 'with no diagnostic, which is the silent cell the ruling closed. `objectstack ' - + 'migrate meta` cannot repair the stored bodies for the reason `replacement` ' - + 'records, so this is a structured TODO rather than a graduated conversion.', - acceptanceCriteria: - 'Every stored filter body listed under `surface` executes without an ' - + '`INVALID_FILTER` 400 naming a declared type: run each saved report, list view, ' - + 'dashboard widget, dataset and roll-up once after the upgrade and read the ' - + 'refusals — each message names the filter key, the field\'s declared type and the ' - + 'operator, which is the whole repair list. A filter re-authored onto a typed ' - + 'operator returns the rows its author meant; one left as written keeps answering ' - + '400, and NOTHING silently rewrites it. This door judges a field by its declared ' - + 'type alone, so it never refuses a text operator over a text-valued field — ' - + '`select` / `radio` codes, `multiselect` / `checkboxes` / `tags`, lookup and `user` ' - + 'ids, `autonumber` and the file classes. Over every such field that is not stored ' - + 'as a JSON column (below), filters must keep answering exactly as before; that is ' - + 'the control which proves a repair pass did not over-reach. A DIRECT driver call ' - + 'bypasses this door entirely and keeps ' - + 'answering the `FILTER_TEXT_CASES` stored-value row (a stored value that is not a string ' - + 'never satisfies a positive text operator and satisfies `$notContains`), so a ' - + 'driver-level test is not evidence about this migration in either direction. A ' - + 'field stored as a JSON column is NOT that control, because a separate door judges ' - + 'it by its storage rather than its declared type: `multiselect` / `checkboxes` / ' - + '`tags`, any field declared `multiple: true` (a multi-valued lookup or `user` among ' - + 'them) and, on a SQL deployment still inside the ADR-0104 dual-encoding window (its ' - + 'media columns not yet moved), a single-value file-class field. That door refuses ' - + 'every text operator there except the membership pair `$contains` / `$notContains` ' - + '— `$startsWith`, `$endsWith`, `$icontains`, `$like` and `$ilike`, beside the ' - + 'scalar comparisons it already refused — with an `INVALID_FILTER` 400 that names no ' - + 'declared type, so a stored filter left on one of those operators there answers ' - + 'that 400 after the upgrade and is outside this entry\'s repair list. On a multi-valued field its ' - + 'repair is membership, which no rewrite chooses either: `$contains` for one member, ' - + 'an `$or` of `$contains` for any-of. A single-value file-class field is not a ' - + 'membership question: it answers text operators again once its deployment finishes ' - + 'the media-column move (the column step of `objectstack migrate files-to-references ' - + '--apply`).', - }, - // #21850 — the D3 entry for the build doors judging an `approval` node's config - // against the contract the spec declares for it (`ApprovalNodeConfigSchema`), - // whole: the declared contract map in `flow-node-config-refusals.ts`, read by - // the one judge `flowNodeConfigRefusals`. It narrows a flow's accept set; no key - // is removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There - // is no D2 conversion either: the platform cannot know the approvers, the key - // or the value the author meant, and the runtime never ran such a node. - // - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - { - id: 'flow-approval-node-config-contract-refused', - surface: - 'an approval flow node whose config the approval node contract (ApprovalNodeConfigSchema) refuses — ' - + 'a key it does not declare (escalation.bogusKey, a top-level key such as steps or onApprove, an ' - + 'alias such as escalation.timeout), a value it refuses (escalation.timeoutHours below 1, an ' - + 'unknown behavior or escalation.action, an empty approvers list, a fallbackApprovers list under any ' - + 'policy but fallback), or a key it requires left out (approvers; escalation.timeoutHours inside an ' - + 'escalation block). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, ' - + 'defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved ' - + 'from the Studio flow designer, and a flow row already sitting in sys_metadata', - replacement: - 'the shape the approval contract declares, written on the node\'s `config`: `approvers` with at least ' - + 'one approver, and inside an `escalation` block a `timeoutHours` of at least 1 (wall-clock hours; ' - + '`timeoutHours: 1` is the shortest SLA the contract accepts). An undeclared key is renamed to the key ' - + 'the refusal\'s did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → ' - + '`minApprovals`) or deleted; a process-level key (`steps`, `entryCriteria`, `onApprove`, `onReject`, ' - + '`rejectionBehavior`) moves onto the flow graph as the refusal\'s guidance says. To turn an SLA off, ' - + 'delete the whole `escalation` block — an `escalation: { enabled: false }` with no `timeoutHours` ' - + 'is refused like any block missing it', - reason: - 'An approval node\'s executor (`plugin-approvals`) parses `node.config` against ' - + '`ApprovalNodeConfigSchema` before it does anything else and fails the node on ANY issue. ' - + 'Registration already refused an undeclared key, against the descriptor\'s published `configSchema`, ' - + 'but a refused value (`timeoutHours: 0.5`) registered and then failed every run that reached the node ' - + '— the config is metadata, and no rerun could succeed. The build doors asked about neither: ' - + '`FlowSchema.parse` judged only the builtin node types\' ' - + 'executor contracts, and only for a key left out, so `objectstack validate` and `objectstack compile` ' - + 'exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the ' - + 'artifact. The contract is the spec\'s own, so the build can judge it with no plugin loaded: the ' - + 'approval node joins a declared contract map beside the builtin executor contracts, read by the one ' - + 'judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and ' - + '`objectstack validate` share (`flowNodeConfigRefusals`), and is judged WHOLE — every issue the ' - + 'contract raises is refused, because the executor refuses on every one. An undeclared key or a ' - + 'refused value is `node-config-refused-by-contract`, anchored at the key, in the contract\'s own ' - + 'sentence (its did-you-mean included); a key left out keeps `node-config-key-missing` or ' - + '`node-config-key-required-by-rule`. The builtin arm is unchanged and stays presence-only. ' - + 'A plugin node type whose contract the spec does not declare stays outside the build doors, as ' - + 'before. ⚠️ No D2 conversion: the platform cannot know the approvers, the key or the value the ' - + 'author meant, and no value it could write would keep what the flow did. ⚠️ Where such a node ' - + 'already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` ' - + 'at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it ' - + 'register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; ' - + 'an artifact file is refused whole at load. ADR-0087, ADR-0019.', - acceptanceCriteria: - 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' - + 'stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at ' - + '`nodes.N.config.` (`nodes.N.config.escalation.bogusKey`, `nodes.N.config.escalation.' - + 'timeoutHours`, `nodes.N.config.approvers`), `objectstack validate` prints the same path, and ' - + '`validateStackExpressions` phrases it as `node \'gate\' (approval) config.escalation.bogusKey`. ' - + 'For each hit write what the contract accepts, per the replacement. Two proofs. (1) For a stack ' - + 'authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each ' - + 'flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a ' - + 'row that exists only in `sys_metadata`. An approval node the contract accepts parses and ' - + 'registers byte-identically to before.', - }, - // #22502 — the binding half of the rule the text-slot judge reads - // (`flow-text-slot-unbound-dollar-root-refused`): the dollar names are the flow - // engine's, so a node's outputVariable and a try_catch errorVariable refuse one - // (the engine's own $error excepted, as errorVariable's default). It narrows a - // flow's accept set; no key is removed, so there is no tombstone and no - // RETIRED_KEYS_BY_MAJOR row. Semantic-only — no D2 conversion: the bare name may - // already be bound in the flow, and the reads of the old name sit in every - // dialect a flow string speaks. - // - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - { - id: 'flow-binding-variable-dollar-name-refused', - surface: - 'flows[].nodes[].config.outputVariable of a get_record, create_record, map, script or subflow node, and ' - + 'flows[].nodes[].config.errorVariable of a try_catch node — a variable name that starts with a dollar sign ' - + 'such as $caught, other than the default $error on errorVariable. Reachable wherever a flow is authored or ' - + 'stored: defineStack flows sources, defineFlow, an exported stack passed to objectstack validate or ' - + 'objectstack compile, a flow saved from the Studio flow designer, and a flow row already in sys_metadata', - replacement: - 'the same name without the dollar sign, read as a hole over that name: errorVariable: \'caught\' read as ' - + '{{ caught.message }}, outputVariable: \'lead\' read as {{ lead.name }}. A try_catch may instead drop ' - + 'errorVariable and read the engine\'s default, {{ $error.message }}. Rename every read of the old name ' - + 'with it — a text-slot hole, a CEL expression, a single-brace token in a value position', - reason: - 'The dollar-named variables are the flow engine\'s own: it binds $record, $runId, $flowName, $flowLabel and ' - + '$error, a flat-graph loop binds $loopItems and $loopIndex, and a resume signal may not write any dollar ' - + 'name. A flow text slot refuses a hole whose root is a dollar name the engine does not bind, so a flow ' - + 'that bound $caught as its errorVariable could not read {{ $caught.message }}: the refusal told the ' - + 'author to drop the dollar sign, while the binding key itself took any string. One contract had two ' - + 'answers to whether an author may own a dollar name. The binding keys now give the text slots\' answer: ' - + 'each key states the rule as a JSON Schema pattern, so the published schema refuses what the parse ' - + 'refuses, and the node contract, registerFlow, objectstack validate and the run itself refuse such a ' - + 'name at the key, naming the same name without the dollar sign. A binding over an engine name ' - + '(outputVariable: \'$record\') would also have overwritten the engine\'s value for the rest of the run. ' - + 'No D2 conversion exists: the bare name may already be bound in the flow, and the reads of the old ' - + 'name sit in every dialect a flow string speaks, so the rename is the author\'s. Where such a node ' - + 'already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot ' - + 'it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a ' - + 'stack source throws StackSchemaInvalidError for the whole stack. ADR-0087, ADR-0031.', - acceptanceCriteria: - 'Run objectstack validate over every stack authored in config files, and boot every deployed stack. ' - + 'Each refusal names the node and the key — nodes.N.config.outputVariable or nodes.N.config.errorVariable, ' - + 'or the region path nodes.N.config.try.nodes.M.config.outputVariable — and the name to write. Rename the ' - + 'binding and every read of it, then (1) objectstack validate is clean, (2) each flow registers at boot ' - + 'with no failed to register flow warn for it, and (3) the flow paths that read the variable — a ' - + 'notification, a screen, a later node — carry its value, with no blank fragment.', - }, - // #21982 — the D3 entry for the build doors refusing an UNDECLARED KEY on the - // builtin node types whose undeclared keys only `registerFlow` judged until - // now: `get_record`, `create_record`, `update_record`, `delete_record`, - // `notify`, `http`, `screen`, `map`, `loop` and `parallel`. It completes the - // key half of the executor-contract arm of `flowNodeConfigRefusals` that - // `flow-script-subflow-config-undeclared-keys-refused` opened for `script` and - // `subflow`, and makes that arm the one judge of a builtin's undeclared key: - // `registerFlow`'s descriptor walk stands aside for these types. It narrows the - // build doors' accept set to what registration already refused; no key is - // removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row, and no D2 - // conversion exists: the platform cannot know what an undeclared key was meant - // to be. - // - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - { - id: 'flow-builtin-node-config-undeclared-keys-refused', - surface: - 'a get_record, create_record, update_record, delete_record, notify, http, screen, map, loop or parallel ' - + 'flow node whose config carries a key its executor contract does not declare — a typo (titl), a key ' - + 'the walk at registration already named (fieldValues on a write node, bulk on update_record, visibleIf ' - + 'on a screen field), a key copied from another node type (outputVariable on an http node, flowName on a ' - + 'loop), or a key nothing reads (bogusKey) — at the config itself, or on a screen field or one of its ' - + 'options, a body-less legacy loop included. Never a key inside a free-form map (a filter, fields, ' - + 'headers, defaults, input, payload or templateData key is author data), never a key on a region object ' - + '(a loop body, a parallel branch) or on its nodes and edges (the region check at registration owns ' - + 'those), and never a ' - + 'try_catch key, which try-catch-and-retry-policy-undeclared-keys-refused covers once the retry policy ' - + 'closed. Reachable wherever a flow is ' - + 'authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to ' - + 'objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row ' - + 'already sitting in sys_metadata', - replacement: - 'the key the contract declares, or no key: rename a typo to the declared key it meant (the refusal ' - + 'carries the contract\'s did-you-mean for a near miss), follow the contract\'s own prescription for a ' - + 'known slip (`fieldValues` → `fields`, `bulk` / `all` / `multiple` → `multi: true`, `options: { multi }` → ' - + 'a top-level `multi`, a screen field\'s `visibleIf` → `visibleWhen`, a loop\'s `itemVariable` → ' - + '`iteratorVariable`), and delete a key nothing reads (an `http` node\'s `outputVariable` among them: the ' - + 'http executor binds no output variable)', - reason: - 'Each of these executors (`service-automation` `builtin/crud-nodes.ts`, `notify-node.ts`, ' - + '`http-nodes.ts`, `screen-nodes.ts`, `map-node.ts`, `loop-node.ts`, `parallel-node.ts`) parses the ' - + 'node\'s `config` against a strict contract before it acts. Until now the build doors\' executor-contract ' - + 'arm held key membership back on these types, on the premise that registration judges it: ' - + '`registerFlow`\'s undeclared-key walk (`validateNodeConfigKeys`) refuses such a key against the node ' - + 'type descriptor\'s `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, ' - + '`objectstack validate` and `objectstack compile` (which copied it into the artifact), and then ' - + 'registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio ' - + 'was stored and then silently not registered. The one judge `FlowSchema.parse`, ' - + '`AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door ' - + 'share (`flowNodeConfigRefusals`) now refuses such a key on these types as `node-config-refused-by-contract`, ' - + 'anchored at the key, one refusal per key, in the contract\'s own words and closed with the ' - + 'rename-or-remove remedy, and the descriptor walk stands aside for every type that judge covers ' - + '(`builtinNodeConfigKeysJudged`), so each type has one judge. Measured before the move: on each of ' - + 'these types the descriptor\'s declared key sets, at every position the walk descends to, equal the ' - + 'keys the contract accepts there, so registration refuses exactly what it refused before. ⚠️ ' - + '`try_catch` is the one builtin not moved here: its contract\'s `retry` was the shared ' - + '`RetryPolicySchema`, which stripped an unknown key, while its descriptor closes `retry` to five keys. ' - + 'It moves in `try-catch-and-retry-policy-undeclared-keys-refused`, once that schema closed. ⚠️ A ' - + 'body-less legacy `loop` is not parsed at run time, and it is judged here on ' - + 'key membership alone, which is what registration refused there already. ⚠️ A spelling an ADR-0087 D2 ' - + 'conversion still rewrites at load (`object` and `filters` on a CRUD node, `to` / `subject` / `body` / ' - + '`url` on a `notify`, `flow` on a `map`) is converted before the judge at every door that converts ' - + 'first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared ' - + 'key. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where ' - + 'such a node already sits the whole flow is refused, as registration already refused it: from the ' - + 'metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not ' - + 'armed, while the flows beside it register; a `defineStack({ flows })` source throws ' - + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load; a save from ' - + 'Studio answers 422 naming the key. ADR-0087, ADR-0031.', - acceptanceCriteria: - 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' - + 'stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at ' - + '`nodes.N.config.` (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`, or the region ' - + 'path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and ' - + '`validateStackExpressions` phrases it as `node \'n\' (notify) config.bogusKey`. For each hit rename or ' - + 'delete the key per the replacement. Two proofs. (1) For a stack authored in config files, ' - + '`objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to ' - + 'register flow` warn for it — that warn line is the locator for a row that exists only in ' - + '`sys_metadata`. A node of these types whose keys its contract declares parses and registers ' - + 'byte-identically to before.', - }, - // #21898 — the D3 entry for the build doors refusing a VALUE a builtin flow - // node's executor contract refuses: the value half of the executor-contract arm - // of `flowNodeConfigRefusals` (`flow-node-config-refusals.ts`), beside the - // presence half (`flow-node-config-required-keys-refused`) and the approval - // contract judged whole (`flow-approval-node-config-contract-refused`). It - // narrows a flow's accept set; no key is removed, so there is no tombstone and - // no RETIRED_KEYS_BY_MAJOR row. There is no D2 conversion either: the platform - // cannot know the value the author meant, and the runtime never ran such a node. - // - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - { - id: 'flow-builtin-node-config-values-refused', - surface: - 'a builtin flow node (get_record, create_record, update_record, delete_record, notify, http, screen, ' - + 'script, subflow, map, loop, parallel, try_catch) whose config carries a value its executor ' - + 'contract refuses — a value of the wrong type (create_record outputVariable 42, a screen field min ' - + 'written as the string 1, get_record limit as a string, update_record multi as a string), a value ' - + 'outside the declared set or range (notify severity loud, screen mode view, loop maxIterations 0, ' - + 'try_catch retry.maxRetries above 10), an empty script function or subflow flowName, or a rule ' - + 'finding on present keys (a notify template beside an inline title). Never a value carrying a ' - + 'token in braces, an undeclared or retired key, a region slot, or an http signingSecret. ' - + 'Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an ' - + 'exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio ' - + 'flow designer, and a flow row already sitting in sys_metadata', - replacement: - 'the value the contract declares, written at the key the refusal names: a string where it wants a ' - + 'string (`outputVariable: \'taskId\'`), a number where it wants a number (`min: 1`, `limit: 10`, ' - + '`maxIterations: 5`, `timeoutMs: 5000`), a boolean where it wants a boolean (`multi: true`, ' - + '`durable: true`), one of the declared values (`severity: \'warning\'`, `mode: \'edit\'`), or a value ' - + 'inside the declared range. Outside `http`, a number or boolean slot takes a LITERAL only: those ' - + 'executors parse the config as authored, so a `{token}` template there (`limit: \'{page.size}\'`, ' - + '`maxIterations: \'{cap}\'`) passes the build doors and still fails every run. Only `http` ' - + 'interpolates its config before it parses, so only an `http` slot may also take a sole-token template ' - + 'that resolves to the declared type (`timeoutMs: \'{timeout}\'`, `durable: \'{durable}\'`). For a rule ' - + 'finding, follow the rule\'s own sentence (keep `template` or the inline `title` / `message`, not both)', - reason: - 'Every builtin executor (`service-automation` `builtin/`) parses its node\'s `config` against the ' - + 'contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any ' - + 'finding. The build doors judged only the keys that contract requires, left out, so a present value ' - + 'it refuses — `create_record` `outputVariable: 42`, a screen field `min: \'1\'` (the shape the Studio ' - + 'designer used to store for a field\'s Min / Max) — passed `FlowSchema.parse`, `objectstack ' - + 'validate` and `objectstack compile`, registered, and then failed every run that reached the node: ' - + 'the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, ' - + '`AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share ' - + '(`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored ' - + 'at the key, in the contract\'s own words — the code the approval contract already uses. It judges ' - + 'only what the build can know the run will parse, and holds one more class back by ruling: a value ' - + 'carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is ' - + 'no promise it runs, since every builtin but `http` parses its config as authored and so still ' - + 'refuses a token in a number or boolean slot at its first run; `http` parses after interpolating its ' - + 'whole config, so only token-free ' - + 'values are judged there and never `signingSecret`, which the credential channel may supply; a ' - + '`loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of ' - + '`loop`, `parallel` and `try_catch` are judged as graphs of their own and by `validateControlFlow`. ' - + 'An undeclared or retired key, a `predicate` ledger slot (a screen field `visibleWhen`) and a `value` ' - + 'ledger slot (a CRUD `fields` value) keep the judges they had. ⚠️ No D2 conversion: the platform ' - + 'cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is ' - + 'refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a ' - + '`warn` naming it, its trigger not armed, while the flows beside it register; a ' - + '`defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact ' - + 'file is refused whole at load. ADR-0087, ADR-0031.', - acceptanceCriteria: - 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' - + 'stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at ' - + '`nodes.N.config.` (`nodes.N.config.outputVariable`, `nodes.N.config.fields.0.min`, or the ' - + 'region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, ' - + 'and `validateStackExpressions` phrases it as `node \'mk\' (create_record) config.outputVariable`. ' - + 'For each hit write the value the contract declares, per the replacement. Two proofs. (1) For a ' - + 'stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm ' - + 'each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for ' - + 'a row that exists only in `sys_metadata`. A node whose values its contract accepts parses and ' - + 'registers byte-identically to before. A `{token}` template parses and registers as before too, and ' - + 'runs only where the run parses it after interpolation (`http`) or where the slot takes a string; in a ' - + 'number or boolean slot of any other builtin it fails at its first run exactly as it did, so write a ' - + 'literal there.', - }, - // A flow CEL expression's record root is the record the run was handed, or - // unbound. The flow engine used to bind record to the run's own variables - // whenever the run held no record, so record.assignee silently read a flow - // variable named assignee. That spelling now fails the run with an unknown- - // variable fault on record. Semantic-only: whether a run holds a record depends - // on the entrances that start the flow (a trigger, an action, a parent flow, a - // map item), which are not visible from the flow alone, so no D2 conversion can - // rewrite the source mechanically. - { - id: 'flow-cel-record-variables-alias-retired', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'flows[].nodes[].config.condition, flows[].edges[].condition, a decision node\'s conditions[].expression, a ' - + 'screen node\'s fields[].visibleWhen, and the CEL value envelopes of an assignment node\'s assignments and of ' - + 'a create_record or update_record node\'s fields — a CEL source reading record.X, or record bare, on a run ' - + 'that holds no record, where X names a flow variable', - replacement: - 'the variable by its name, or through the vars namespace: record.assignee becomes assignee, or vars.assignee. ' - + 'A record.X read on a run that was handed a record is unchanged, and reads that record\'s field X', - reason: - 'Flow CEL binds record to the record the run was handed: a record-change trigger\'s row, a time-relative ' - + 'sweep\'s row, the inbound hook\'s request body, a flow action\'s record (the loaded row, or an empty record ' - + 'carrying at most the id it was given), a subflow or map parent\'s record, or a map item that carries an id. ' - + 'A flow can also bind a variable named record itself. A run with none of these, one started through the REST ' - + 'trigger route, a declared endpoint or a cron schedule, or a child of such a run, used to see record bound to ' - + 'its own variables map, so record.X answered a flow variable named X instead of failing: a second spelling of ' - + 'every variable, which hid the author\'s mistake. Such a run now fails the expression with an unknown-variable ' - + 'fault on record, carrying the source, as every other unbound root does. No D2 conversion exists: whether a ' - + 'run holds a record depends on the entrances that start the flow, which the flow alone does not show, so ' - + 'only the author can tell a variable read from a record field read. Measured at the change: 0 flows in this ' - + 'repository\'s example apps, platform objects and dogfood suites, and 0 in objectstack-ai/hotcrm, read record ' - + 'with no record entrance.', - acceptanceCriteria: - 'For each flow whose CEL reads record, list the entrances that start it. Where any of them hands no record, ' - + 'replace each record.X that names a flow variable with the variable\'s name or vars.X. Re-run each flow path ' - + 'started with no record and confirm every expression evaluates instead of failing with an unknown-variable ' - + 'fault on record; re-run a path started with a record and confirm record.X still reads that record\'s field.', - }, - // A flow CEL expression is refused when it reads a root the flow does not bind. - // Formulas, row-level security and the client bind the run's user as user, - // ctx.user and os.user as well as current_user; flow CEL binds current_user only, - // so an alias there failed every run that reached it while objectstack validate - // passed it. Semantic-only — none of these roots ever evaluated in a flow, so no - // D2 conversion has behaviour to carry over. - { - id: 'flow-cel-unbound-root-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'flows[].nodes[].config.condition, flows[].edges[].condition, a decision node\'s conditions[].expression, a ' - + 'screen node\'s fields[].visibleWhen, and the CEL value envelopes of an assignment node\'s assignments and of ' - + 'a create_record or update_record node\'s fields — a CEL source whose root identifier the flow does not bind, ' - + 'such as user.id, ctx.user.id or os.user.id', - replacement: - 'current_user for the run\'s user: user.id, ctx.user.id and os.user.id become current_user.id, and a flow that ' - + 'can run without a user guards it as current_user != null ? current_user.id : null. Any other root becomes a ' - + 'name the flow binds — a declared variable, an outputVariable, an iterator, index or error variable, an ' - + 'assignment target, a node id or a screen field — or a field of the trigger record, read bare or as ' - + 'record.FIELD', - reason: - 'A flow CEL expression evaluates in one scope per run: the flow\'s variables spread to top level, the trigger ' - + 'record\'s fields flattened beside them, and record, previous, vars and current_user bound by the engine. A ' - + 'root outside that scope fails the expression with an unknown-variable fault on every run that reaches it, ' - + 'and the build door, which judged a flow expression\'s syntax only, passed it. The run-user spellings are ' - + 'the reachable case: formulas, row-level security and the client bind the run\'s user under user, ctx.user ' - + 'and os.user as well, and flow CEL does not, because a top-level user would collide with a variable or a ' - + 'lookup field of that name. objectstack validate now refuses such a root, naming current_user for the user ' - + 'spellings and the in-scope names for any other. A flow whose run can bind a name the reader cannot see is not ' - + 'judged: one with a script or connector_action node, a wait, subflow or map node a resume can fold a bag into, ' - + 'a screen with no field list, or a plugin node type; and one an entrance the stack declares hands a record ' - + 'whose keys are not in hand: a record trigger or time-relative sweep on an object the stack does not declare, ' - + 'an api trigger (the inbound hook hands the request body in as the record), a flow action on an undeclared ' - + 'object, a map node whose itemObject is undeclared or absent, or a subflow or map parent that is itself such a ' - + 'flow. The runtime publish gate does not give this verdict yet: it judges a flow write without the stack\'s ' - + 'actions and other flows, so it cannot see those entrances. No D2 conversion exists: none of these roots ever ' - + 'evaluated in a flow, so there is no behaviour to carry over, and a run without a user needs a guard only the ' - + 'author can choose.', - acceptanceCriteria: - 'Run objectstack validate: it reports each refused root as expression-invalid at the flow, the node or edge and ' - + 'the slot, naming the root and its remedy. Replace each run-user spelling with current_user, guarded where the ' - + 'flow can run without a user, and bind or correct every other root. Re-run the flow paths that evaluate those ' - + 'expressions and confirm each one evaluates instead of failing with an unknown-variable fault.', - }, - // The absent half of the decision-branch predicate rule. A SEPARATE entry from - // `flow-predicate-slot-blank-string-refused` on purpose: that one keeps the - // run a blank predicate made (it evaluated `false`, so `'false'` runs the same - // route), while an absent predicate made no run to keep — the executor refused - // it at the branch — so the two carry different prescriptions, and only the - // decision branch is in this one (an absent screen `visibleWhen` stays legal). - // - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'flow-decision-branch-expression-absent-refused', - surface: - 'a decision node branch — an element of config.conditions[] — written without its expression ' - + 'key, or with expression: null, at any depth including an ADR-0031 region body. That includes ' - + 'a branch whose predicate sits under another key (condition is the edge spelling). Reachable ' - + 'wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an ' - + 'exported stack passed to objectstack validate, a flow saved from the Studio flow designer ' - + 'with a branch row whose expression cell is empty, and a flow row already sitting in ' - + 'sys_metadata', - replacement: - 'the predicate the branch was meant to test, as non-blank bare CEL text under `expression` ' - + '(`{ label: \'high\', expression: \'record.amount > 10000\' }`); a predicate written under ' - + '`condition` moves to `expression`. To keep the branch and its label but never take it, ' - + 'write `expression: \'false\'` — that is a CHANGE of behaviour, not a preserved one: a run ' - + 'that reached the branch used to fail there (`condition evaluation error`), and now routes on ' - + 'to the next branch or the declared fallback. ⚠️ Not by dropping a decision\'s only branch: ' - + 'with no `conditions` the node routes by its out-edges alone, so the out-edge that branch ' - + 'labelled is no longer held back', - reason: - '`DecisionConditionSchema` declares a branch `{ label, expression }` with ' - + '`expression` a required `z.string()`, but nothing parses a decision node\'s open config ' - + 'against it, and the expression-ledger resolver skipped an absent value as "not authored" — ' - + 'so a branch with no predicate passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and ' - + '`objectstack validate`, and the decision executor then handed `evaluateCondition` an envelope ' - + 'with no `source`, which it refuses: the build accepted what the run refused. The ledger now ' - + 'marks the slot `required` (reconciled against that schema\'s own `required` list), the ' - + 'resolver emits the absent value there, and all three doors refuse it through ' - + '`predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL` — the walk, function ' - + 'and sentence that already refuse the blank string. ' - + '⚠️ No D2 conversion: the platform cannot know the rule the author left out, and `\'false\'` ' - + 'would change what the flow does rather than keep it. ' - + '⚠️ Where such a branch already sits the whole flow is refused: registered from the metadata ' - + 'registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not ' - + 'armed, while the flows beside it register; a `defineStack({ flows })` source throws ' - + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ' - + 'ADR-0087, ADR-0032.', - acceptanceCriteria: - 'Grep every flow node in `defineStack({ flows })` sources, exported stacks and every flow ' - + 'row in `sys_metadata` — including nodes inside a `loop` / `parallel` / `try_catch` region ' - + 'body — for a `decision` node whose `config.conditions[i]` has no `expression` key, or ' - + '`expression: null`. Each refusal names the node and the branch: `FlowSchema.parse` anchors ' - + 'a `custom` issue at `nodes.N.config.conditions.I.expression` (or the region path ' - + '`nodes.N.config.body.nodes.M.config…`), and `objectstack validate` prints the same path; ' - + '`validateStackExpressions` phrases it as ' - + '`node \'check\' (decision) decision branch expression at config.conditions[0].expression`. ' - + 'For each hit write the predicate the branch was meant to test, or `expression: \'false\'` ' - + 'where the branch should keep its label and never be taken. Two proofs. (1) For a stack ' - + 'authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm ' - + 'each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell ' - + 'it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register ' - + 'flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that warn line is ' - + 'the locator for a row that exists only in `sys_metadata`. A branch carrying a non-blank ' - + 'predicate parses and registers byte-identically to before, a decision with no `conditions` ' - + 'still routes by its out-edges, and an absent screen field `visibleWhen` is still legal.', - }, - { - id: 'flow-decision-edge-branching-first-match', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: - 'flow.nodes[].config.mode (decision) — an OMITTED mode on a decision that branches ' - + 'on its out-edges and carries two or more conditioned ones', - replacement: - 'nothing, where the out-edge conditions partition (exactly one can hold for any ' - + 'record): an omitted `mode` now means exclusive, the first true edge in declaration ' - + 'order wins, and the run is what it always was. `mode: \'inclusive\'` where the flow ' - + 'RELIES on more than one branch running for one record — the value the D2 conversion ' - + '`flow-decision-mode-inclusive-explicit` writes onto every such decision so nothing ' - + 'changes silently. Where the conditions overlap by accident (a `!=` guard beside a ' - + 'later `==` branch), neither: narrow them into a partition, or mark the fallback ' - + '`isDefault: true`, and delete the written key.', - reason: - 'A DEFAULT FLIP of a shipped node type, ruled rather than patched: the schema, the docs ' - + 'and the engine\'s own comment all called an edge-branched decision an exclusive gateway ' - + 'while the traversal took EVERY out-edge whose condition held, one after another, and ' - + 'reported nothing — a CRM application\'s lead-conversion flow rendered a refusal screen AND ' - + 'ran the conversion in one ' - + 'execution. The traversal now matches the declaration (BPMN exclusive gateway, ' - + 'Salesforce Flow Decision, n8n Switch default), and the every-true-edge behaviour is the ' - + 'BPMN inclusive gateway an author must write down. The KEY converts mechanically and ' - + 'does: `flow-decision-mode-inclusive-explicit` writes `mode: \'inclusive\'` wherever two ' - + 'or more conditioned out-edges leave a decision that declares no `conditions` list, so ' - + 'the migrated source runs exactly as before. What does NOT convert is the INTENT: the ' - + 'count cannot tell a partition (where the key is redundant) from a reliance on ' - + 'multi-branch runs (where it is load-bearing) from an accidental overlap (where the ' - + 'old behaviour was the bug), so the mechanical edit list the chain replay prints is ' - + 'where that judgment is made, node by node. And the conversion replays ONLY there: it is a ' - + 'default flip, so the authoring funnel never rewrites a source written against the ' - + 'new contract, and the automation engine\'s flow rehydration seam and the ' - + 'artifact-ingestion door both refuse it by id (a code-shipped flow, a REST body, a Studio ' - + 'save and a scaffolded artifact all arrive undated). BREAKING for stored rows, by ' - + 'maintainer ruling: the promise that a flow keeps its behaviour is kept by authored ' - + 'sources and built artifacts only. A decision stored in `sys_metadata` ' - + 'with no `conditions` list, no `mode` and two or more conditioned out-edges takes the new ' - + 'meaning on upgrade — it evaluates first-match — and nothing rewrites the row: no ' - + 'stored-row migration, no cutoff, no read-path completion, because nothing about a stored ' - + 'row says it was saved before the flip. The one-line fix, for a stored node that meant ' - + 'every branch, is `mode: \'inclusive\'`; `os migrate meta --stored` lists every such node, ' - + 'report only, so an operator can review the candidates before and after the upgrade.', - acceptanceCriteria: - 'Review every `flow-decision-mode-inclusive-explicit` line the chain replay lists for ' - + 'each authored stack: (1) where the two (or more) ' - + 'out-edge conditions partition — a predicate and its negation, `>` beside `<=`, or a ' - + 'guard beside `isDefault: true` — delete the written `mode`; the run is unchanged either ' - + 'way and the exclusive default is the honest declaration; (2) where the flow relies on ' - + 'more than one branch running for one record, keep `mode: \'inclusive\'`; (3) where the ' - + 'conditions overlap by accident, narrow them into a partition and delete the key, then ' - + 're-run the flow on a record that satisfied both and confirm exactly one successor ' - + 'ran — the passed-over branch now leaves a `skipped` step in the run log. `os validate` ' - + 'reports `flow-decision-inclusive-overlap` on every decision that keeps the key with ' - + 'two or more conditioned out-edges, so the review list is the lint output. Then each ' - + 'deployment: `os migrate meta --stored` lists, under `decisionModeReview`, every stored ' - + 'decision with two or more conditioned out-edges and no `mode` — each one already ' - + 'evaluates first-match, and the pass writes none of them — so where one of those nodes ' - + 'meant every branch, declare `mode: \'inclusive\'` on it in the designer; a node ' - + 'that declares `mode` either way leaves the list. A decision registering with ' - + '`mode` beside a non-empty `conditions` list, or with a `mode` outside ' - + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' - + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. ' - + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - // The edits this entry judges: every `mode: 'inclusive'` that conversion - // writes is one decision to keep, delete or narrow, per the criteria above. - conversionIds: ['flow-decision-mode-inclusive-explicit'], - }, - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'flow-edge-condition-evaluated-slot-source-required', - surface: - 'a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch ' - + 'predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition ' - + 'on a flow NODE, which is a decision node predicate and on a start node the trigger gate — ' - + 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } ' - + 'with no source), or with a source that is blank after trimming, through the envelope key ' - + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it ' - + '(condition: \' \'). The node slot joined this entry with the two later changes that rebound ' - + 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather ' - + 'than deriving a second one; it is the same decision reaching the second slot, which is why ' - + 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored ' - + 'or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, ' - + 'a POST /api/v1/automation body, and a flow row already sitting in sys_metadata', - replacement: - 'a non-blank `source` — `{ dialect: \'cel\', source: \'record.amount > 10\' }`, or the bare ' - + 'string `\'record.amount > 10\'` — if the edge was meant to branch; or REMOVE the ' - + '`condition` key entirely if it was meant to be unconditional. ⚠️ Those two are not ' - + 'interchangeable, and the choice is the judgment this entry delegates: a refused condition ' - + 'evaluated to a silent `false`, so the edge NEVER fired, while an absent `condition` is an ' - + 'unconditional edge that ALWAYS fires. Deleting the key to clear the refusal inverts the ' - + 'edge rather than preserving it. An `ast` BESIDE a string `source` is untouched and stays ' - + 'admitted everywhere', - reason: - 'The evaluated-slot rule, carried to the edge condition — the line that first refused an ' - + '`ast`-only envelope no engine can evaluate, and refused a non-string node predicate at ' - + 'registration instead of letting the evaluator answer it a silent `false`: ' - + '`FlowEdgeSchema.condition` now composes ' - + '`EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot ' - + 'is held to what the engine can actually run. The engine reads `source` alone ' - + '(`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist `source`"), ' - + 'so both refused spellings landed in its empty-source arm and answered a SILENT `false` on ' - + 'every release that carried them — they parsed, registered, passed `objectstack validate`, ' - + 'and then produced a branch that quietly never fired (measured on 2026-09-05 by driving an ' - + '`ast`-only envelope through `AutomationEngine.evaluateCondition` directly). The refusal is ' - + 'one rule with one sentence, ' - + '`EVALUATED_EXPRESSION_SOURCE_REQUIRED`. ' - + '⚠️ No D2 conversion is possible, and this is exactly why the change needs a D3 entry ' - + 'rather than none. An `ast`-only envelope carries no `source` to derive one from — ' - + 'lowering an AST to surface syntax is the compiler direction the platform does not run — ' - + 'and dropping a blank `condition` would flip the edge from never-fires to ALWAYS-fires, ' - + 'which is the platform guessing which of two different flows the author meant. ' - + '⚠️ And the consequence for a flow ALREADY STORED is wider than the edge, which is the ' - + 'part no author-time prescription reaches. `applyConversionsToStoredItem` is deliberately ' - + 'not applied to `flow` (`spec/src/conversions/stored.ts`, and the same skip in ' - + '`metadata/src/loaders/database-loader.ts` `rowToData`) because flow-node conversions need ' - + 'the automation engine\'s live executor registry; flows canonicalize at `registerFlow` ' - + 'instead, which parses through `canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the ' - + 'three boot paths in `service-automation/src/plugin.ts` wraps that call in try/catch, logs ' - + 'one `warn` naming the flow, and CONTINUES — so a stored `sys_metadata` flow with such an ' - + 'edge is no longer registered at all: its trigger is never armed and the WHOLE flow stops ' - + 'running, not just the branch, announced only by that warn line. A repo-wide census at ' - + '`ae19f5edb` (examples/, packages/, content/, skills/) found zero edge conditions of either ' - + 'spelling against a lit control, so there is nothing in THIS repository to rewrite — a ' - + 'repo reading, which is why the notification is registered here rather than skipped. ' - + 'ADR-0087, ADR-0032.', - acceptanceCriteria: - 'Grep every authored structural condition — BOTH `edges[].condition` and a node\'s ' - + '`config.condition` (a `decision` node\'s predicate, and on a `start` node the trigger ' - + 'gate) — in `defineStack({ flows })` sources, exported stacks and `POST /api/v1/automation` bodies, and ' - + 'every flow row in `sys_metadata`, for an envelope with no `source` key and for a `source` ' - + '(or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the ' - + 'node key unswept, and the node key is the one with no schema in front of it. For each ' - + 'hit decide, per the `replacement` note, whether the condition was meant to branch (author ' - + 'the `source`) or to be unconditional (remove the key) — do not default to removal; on a ' - + '`start` node removal opens the trigger gate rather than preserving it. Two proofs, ' - + 'and the second is the one that matters for stored rows. (1) For a stack authored in config ' - + 'files, `objectstack validate` is clean: it locates each offender with the ' - + '`EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — an edge at ' - + '`flows.N.edges.N.condition`, and a node by the slot phrase the structural pass builds, ' - + "e.g. `node 'gate' (start) condition` — and " - + 'an `ast`-only envelope is also reported by the lint path as ' - + '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`, which is the sentence the node slot earns for that ' - + 'spelling as well. There is no CLI verb that lowers a stored row back ' - + 'into a config file, so this proof does not reach a flow that exists only in ' - + '`sys_metadata`. (2) Boot the stack and ' - + 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot ' - + 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed ' - + 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`), and ' - + 'its trigger is armed. That warn line IS the locator for a stored row: for an edge its ' - + '`issues[].path` names `edges[N].condition`, and for a node the refusal carries that same ' - + "slot phrase. A flow that boots without that warn is unaffected; every structural " - + 'condition carrying a non-blank `source` parses byte-identically to before.', - }, - // Two refusals in one entry because they are one walk and one remedy family: - // an edge must resolve in the graph that declares it, and must not be a copy - // the engine cannot tell from an earlier edge. Neither has a D2 conversion — - // a dangling endpoint carries no intent a rewrite could recover, and dropping - // a repeated edge changes how many times its target runs. - // - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'flow-edge-unresolved-or-repeated-refused', - surface: - 'a flow edge whose source or target is not the id of a node in the graph that declares it — ' - + 'the flow\'s own nodes for a top-level edge, the region body\'s nodes for an edge inside a ' - + 'loop, parallel or try_catch region, so a top-level edge into a region node is one of them — ' - + 'and a later edge of the same graph with the same source, target, type, condition and branch ' - + 'label as an earlier one. Reachable wherever a flow is authored or stored: defineStack flows ' - + 'sources, defineFlow, an exported stack passed to objectstack validate, a flow saved from the ' - + 'Studio flow designer after a node was removed (its edges were left behind, and a node added ' - + 'later under the reused id picked them up), and a flow row already sitting in sys_metadata', - replacement: - 'an edge whose `source` and `target` are node ids declared in the same graph as the edge: ' - + 're-point the endpoint at the node it was meant to reach, or delete the edge. Deleting a ' - + 'dangling edge changes nothing a run did, with one exception: a conditioned edge into a ' - + 'missing node still counted as the branch taken when its condition held, so a default ' - + 'sibling was passed over and, on an exclusive `decision`, the later conditioned siblings ' - + 'were skipped — where a flow relied on that, point the edge at a node that ends the branch. ' - + 'For a repeated edge, delete the later copy: the target then runs once per traversal ' - + 'instead of once per copy — a CHANGE of behaviour wherever the copies ran it more than once, ' - + 'which is the defect being removed. An edge meant to take its own route needs its own ' - + '`condition` or branch `label`', - reason: - 'The engine resolves an edge\'s endpoints in the graph that declares it — traversal looks the ' - + 'target up there, and a region runs against a view of its own nodes and edges — and runs a ' - + 'target once per out-edge it selects. `FlowSchema` held node ids and edge ids unique and ' - + 'checked neither that an edge names a node of its graph nor that it is not a copy of another, ' - + 'so a draft holding an edge into a node it no longer had, or one edge three times, passed ' - + '`FlowSchema.parse`, `objectstack validate` and the metadata save door, published with ' - + '`_diagnostics.valid: true`, and ran: the dangling edge carried the run nowhere, silently, ' - + 'and the repeated edge ran its target once per copy (one record update created three ' - + 'identical records). The parse now refuses both at every depth the region walk reaches: an ' - + 'endpoint at `edges.N.source` / `edges.N.target` (or the region path ' - + '`nodes.N.config.body.edges.M.target`), naming the missing id and, when it is a node of ' - + 'another graph, that graph; a repeated edge at `edges.N`, naming the earlier copy. Repeated ' - + 'means the key the engine selects on — `source`, `target`, `type`, `condition` (its dialect ' - + 'and source) and branch `label` — so two nodes joined by edges with different conditions, a ' - + '`fault` edge beside a default one, or `approve` and `reject` branches into one node stay ' - + 'legal. A region edge naming no node of its region was already refused at registration by ' - + 'the region analysis; the top-level half had no refusal anywhere. ' - + '⚠️ No D2 conversion: a dangling endpoint carries no intent a rewrite could recover, and ' - + 'dropping a repeated edge changes how many times its target runs. ' - + '⚠️ Where such an edge already sits the whole flow is refused: registered from the metadata ' - + 'registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not ' - + 'armed, while the flows beside it register; a `defineStack` flows source throws ' - + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ' - + 'ADR-0087, ADR-0031.', - acceptanceCriteria: - 'Grep every flow in `defineStack` flows sources, exported stacks and every flow row in ' - + '`sys_metadata` — including the `edges` of each `loop` / `parallel` / `try_catch` region ' - + 'body — for an edge whose `source` or `target` is not the `id` of a node in the `nodes` list ' - + 'beside that `edges` list, and for two edges in one `edges` list with the same `source`, ' - + '`target`, `type`, `condition` and `label`. Each refusal names the edge: `FlowSchema.parse` ' - + 'anchors a `custom` issue at `edges.N.source`, `edges.N.target` or `edges.N` (or the region ' - + 'path `nodes.N.config.body.edges.M…`), and `objectstack validate` prints the same path. For a ' - + 'dangling endpoint, point it at the node the edge was meant to reach or delete the edge; for ' - + 'a repeated edge, delete the later copy. Two proofs. (1) For a stack authored in config ' - + 'files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: ' - + 'no `failed to register flow` warn for it (the three boot paths spell it `[Automation] failed ' - + 'to register flow`, `[Automation] flow re-sync: failed to register flow` and `[Automation] ' - + 'cold-boot flow bind: failed to register flow`) — that warn line is the locator for a row ' - + 'that exists only in `sys_metadata`. A flow whose edges all resolve in their own graph and ' - + 'repeat nothing parses and registers byte-identically to before.', - }, - // ONE entry for the family, not one per node type: every member is the same - // decision — a node config its executor cannot run is refused where the flow - // is built, by one judge (`flowNodeConfigRefusals`), instead of registering - // and failing (or, for a branch with no label, misrouting) at run time. - // - // Form D: no tracker number anywhere in the author-shown text; the decision is - // stated in words. - // - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'flow-node-config-required-keys-refused', - surface: - 'a flow node whose config leaves out a key its executor contract requires — objectName on ' - + 'get_record / create_record / update_record / delete_record, recipients on notify (and title ' - + 'when there is no template), url on http, function on script, flowName on subflow, collection ' - + 'and flowName on map, collection on a loop that has a body, branches on parallel, try on ' - + 'try_catch, and on screen each field name, each option value and label, and a lookup field ' - + 'reference — and a decision node whose conditions is not an array, holds a branch that is not ' - + 'an object, or holds a branch whose label is absent, null, blank or not a string; at any depth ' - + 'including an ADR-0031 region body. Reachable wherever a flow is authored or stored: ' - + 'defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, ' - + 'a flow saved from the Studio flow designer (a node added and saved before it is configured; a ' - + 'decision branch row whose label cell is empty; a screen field row whose name cell is empty), ' - + 'and a flow row already sitting in sys_metadata', - replacement: - 'the missing key, written on the node\'s `config` — the value the node was meant to act on ' - + '(`objectName: \'account\'`, `url: \'https://…\'`, `collection: \'{rows}\'`, …). For a decision ' - + 'branch, the label of the out-edge the branch should take (`{ label: \'approved\', expression: ' - + '\'record.amount > 1000\' }`, beside an out-edge labelled `approved`), `conditions` written as an ' - + 'array of such objects, and a bare predicate string moved under `expression`. To branch on the ' - + 'out-edges instead, delete `conditions` and put each predicate on its edge\'s `condition`. A ' - + 'legacy flat-graph `loop` (no `body`) needs no `collection` and is untouched', - reason: - 'A flow node\'s `config` is an open record, so what its executor requires was checked by no build ' - + 'door: `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` all admitted ' - + 'a node missing a key its executor contract requires, and the executor\'s own contract parse then ' - + 'refused the node on every run that reached it — the config is metadata, so no rerun could ' - + 'succeed. A decision branch with no label was worse: it never failed, the matched branch reported ' - + 'no label and traversal took EVERY out-edge, so the flow ran green down the wrong paths. All ' - + 'three doors now refuse these shapes through one judge, `flowNodeConfigRefusals`, which parses ' - + 'each builtin node\'s config against the very contract its executor parses against ' - + '(`getBuiltinNodeConfigContracts()`, reconciled against the executors\' own parse calls) and keeps ' - + 'only the keys left out — a present value of the wrong type and an undeclared key are judged where ' - + 'they were before — plus the decision branch shape its executor reads raw. A key a rule of the ' - + 'contract requires (a notify with no template needs a title; a lookup screen field needs its ' - + 'reference) is refused in the contract\'s own words. ' - + '⚠️ No D2 conversion: the platform cannot know the object, URL, collection, function or ' - + 'out-edge label the author left out, and no value it could write would keep what the flow did. ' - + '⚠️ Where such a node already sits the whole flow is refused: registered from the metadata ' - + 'registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, ' - + 'while the flows beside it register; a `defineStack({ flows })` source throws ' - + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ' - + 'ADR-0087, ADR-0031.', - acceptanceCriteria: - 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' - + 'stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at ' - + '`nodes.N.config.` (`nodes.N.config.fields.0.name`, `nodes.N.config.conditions.0.label`, or ' - + 'the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same ' - + 'path, and `validateStackExpressions` phrases it as `node \'fetch\' (get_record) config.objectName`. ' - + 'For each hit write the key the node was meant to carry, per the replacement. Two proofs. (1) For ' - + 'a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm ' - + 'each flow REGISTERS: no `failed to register flow` warn for it (the three boot paths spell it ' - + '`[Automation] failed to register flow`, `[Automation] flow re-sync: failed to register flow` and ' - + '`[Automation] cold-boot flow bind: failed to register flow`) — that warn line is the locator for a ' - + 'row that exists only in `sys_metadata`. A node carrying every key its contract requires parses ' - + 'and registers byte-identically to before, a decision with no `conditions` (or `conditions: null`, ' - + 'or an empty list) still routes by its out-edges, and a legacy `loop` with no `body` still needs ' - + 'no `collection`.', - }, - // The ledger `predicate` slots' half of the blank-predicate rule. A SEPARATE - // entry from `flow-edge-condition-evaluated-slot-source-required` on purpose: - // that one carries the structural slots (`edges[].condition`, - // `config.condition`), refused by the evaluated-slot rule under - // EVALUATED_EXPRESSION_SOURCE_REQUIRED, where removing a blank condition - // INVERTS the edge. These slots are declared `z.string()`, are refused under - // PREDICATE_SLOT_STRING_REFUSAL, and keep their run with `'false'` or no - // `visibleWhen` — a different prescription, which one entry cannot carry for both. - // - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'flow-predicate-slot-blank-string-refused', - surface: - 'the two ledger predicate slots on a flow node — config.conditions[].expression on a decision ' - + 'node (a branch predicate) and config.fields[].visibleWhen on a screen node (a field visibility ' - + 'predicate) — authored as a string that is blank after trimming (\'\', \' \', a tab or a ' - + 'newline), at any depth including an ADR-0031 region body. Reachable wherever a flow is ' - + 'authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to ' - + 'objectstack validate, and a flow row already sitting in sys_metadata', - replacement: - 'the predicate the branch or field was meant to test, as non-blank bare CEL text ' - + '(`expression: \'record.amount > 10\'`, `visibleWhen: \'amount > 0\'`); or KEEP what the blank did. ' - + 'On a screen field, drop the `visibleWhen` key: an absent `visibleWhen` shows the field ' - + 'unconditionally, which is what a blank one already did at run time (the resume contract ' - + 'treated it as absent, and the renderer fell back to showing the field). On a decision ' - + 'branch, write `expression: \'false\'`: the evaluator answered the blank `false`, so the branch ' - + 'keeps its label and is still never taken. ⚠️ Not by dropping a decision\'s only branch: with ' - + 'no `conditions` the node routes by its out-edges alone, so the out-edge that branch labelled ' - + 'is no longer held back. On a structural condition removal differs again: dropping a blank ' - + '`condition` turns a never-firing edge into an always-firing one ' - + '(`flow-edge-condition-evaluated-slot-source-required`)', - reason: - 'Maintainer ruling A of 2026-09-13: the two sibling predicate slots refuse a blank string ' - + 'at authoring. Both slots are declared bare CEL text (`z.string()`) ' - + 'and both admitted a blank string at every door: the expression ledger resolver skipped it ' - + 'as "not authored", and `AutomationEngine.evaluateCondition` answered it `false` — so a ' - + 'decision branch carrying it was never taken, with nothing said at any layer, and a screen ' - + 'field carrying it was shown with its predicate ignored. An earlier fix had pinned that ' - + 'admission as correct because the two sides agreed. The ruling is that self-consistency ' - + 'between parser and evaluator is not a defence when the author\'s intent is silently ' - + 'dropped — the third instance of one rule, after the structural `config.condition` and a ' - + 'blank evaluated `source`. The blank is now refused at `FlowSchema.parse`, ' - + 'at `AutomationEngine.registerFlow` (which parses first) and at `objectstack validate`, all ' - + 'three through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL`. ' - + '⚠️ No D2 conversion, and the reason is the judgment this entry delegates: the blank is ' - + 'where an author meant to write a rule, and the platform cannot tell a predicate somebody ' - + 'forgot from one they meant to delete. Keeping what ran is mechanical; writing the predicate ' - + 'is what the author intended; only the author knows which. ' - + '⚠️ Where such a blank already sits the whole flow is refused: registered from the metadata ' - + 'registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not ' - + 'armed, while the flows beside it register; a `defineStack({ flows })` source throws ' - + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ' - + 'ADR-0087, ADR-0032.', - acceptanceCriteria: - 'Grep every flow node in `defineStack({ flows })` sources, exported stacks and every flow ' - + 'row in `sys_metadata` — including nodes inside a `loop` / ' - + '`parallel` / `try_catch` region body — for a `decision` node whose ' - + '`config.conditions[i].expression`, or a `screen` node whose `config.fields[i].visibleWhen`, ' - + 'is a string that is empty after trimming. Each refusal names the node and the branch or ' - + 'field, which is the TODO\'s locator: `FlowSchema.parse` anchors a `custom` issue at ' - + '`nodes.N.config.conditions.I.expression` (or `…config.fields.I.visibleWhen`, or the region ' - + 'path `nodes.N.config.body.nodes.M.config…`), and `objectstack validate` prints the same ' - + 'path; `validateStackExpressions` phrases it as ' - + '`node \'check\' (decision) decision branch expression at config.conditions[0].expression`. ' - + 'For each hit decide, per the `replacement` note, whether to write the predicate or to ' - + 'keep what the blank did. Two proofs. (1) For a stack authored in config files, ' - + '`objectstack validate` is clean. (2) Boot the stack and ' - + 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot ' - + 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed ' - + 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that ' - + 'warn line is the locator for a row that exists only in `sys_metadata`. A non-blank ' - + 'predicate parses and registers byte-identically to before, and a non-string in these ' - + 'slots keeps its own earlier refusal (at `registerFlow` and `objectstack validate`).', - }, - // #21982 — the D3 entry for the build doors refusing an UNDECLARED KEY on a - // `script` or `subflow` node's config: the key half of the executor-contract - // arm of `flowNodeConfigRefusals` (`flow-node-config-refusals.ts`), beside the - // value half (`flow-builtin-node-config-values-refused`) and the presence half - // (`flow-node-config-required-keys-refused`). It narrows a flow's accept set; - // no key is removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR - // row. There is no D2 conversion either: the platform cannot know what an - // undeclared key was meant to be, and the runtime never ran such a node. - // - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - { - id: 'flow-script-subflow-config-undeclared-keys-refused', - surface: - 'a script or subflow flow node whose config carries a key its executor contract does not declare — a ' - + 'typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an ' - + 'approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and ' - + 'outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key ' - + '(actionType, template, recipients, variables, script), which keeps its own path, and never a key on ' - + 'any other builtin node type, whose undeclared keys registration already judges against the node ' - + 'type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, ' - + 'defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved ' - + 'from the Studio flow designer, and a flow row already sitting in sys_metadata', - replacement: - 'the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, ' - + '`inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move ' - + 'a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), ' - + 'move a `subflow` timeout to the node itself (`{ id, type: \'subflow\', timeoutMs: 30000, config: { … } }`), ' - + 'and delete a key nothing reads. The refusal carries the contract\'s own sentence, with its ' - + 'did-you-mean for a near miss', - reason: - 'The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, ' - + '`builtin/subflow-node.ts`) parse the node\'s `config` against a strict contract ' - + '(`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared ' - + 'key. No door before the run judged one: `registerFlow`\'s undeclared-key check derives the declared ' - + 'set from the node type descriptor\'s `configSchema`, and these two descriptors publish none (the ' - + 'schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors\' executor-contract arm ' - + 'judged required keys and present values but held key membership back on the premise that ' - + 'registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack ' - + 'validate` and `objectstack compile` (which copied the key into the artifact), registered, and then ' - + 'failed every run that reached the node: the config is metadata, and no rerun could succeed. The one ' - + 'judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack ' - + 'validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as ' - + '`node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract\'s own ' - + 'words — the code the value half and the approval contract already use. Every other builtin keeps its ' - + 'undeclared keys where they were judged: at registration, against its descriptor, with that check\'s ' - + 'own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an ' - + 'undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone ' - + 'path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or ' - + '`flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` ' - + 'on a `subflow`) is converted before the judge at every door that converts first; met by a direct ' - + '`FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing ' - + 'canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was ' - + 'meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the ' - + 'metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not ' - + 'armed, while the flows beside it register; a `defineStack({ flows })` source throws ' - + '`StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ' - + 'ADR-0031.', - acceptanceCriteria: - 'Run `objectstack validate` over every stack authored in config files, and boot every deployed ' - + 'stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at ' - + '`nodes.N.config.` (`nodes.N.config.bogusKey`, or the region path ' - + '`nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and ' - + '`validateStackExpressions` phrases it as `node \'summarize\' (script) config.bogusKey`. For each hit ' - + 'rename, move or delete the key per the replacement. Two proofs. (1) For a stack authored in config ' - + 'files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no ' - + '`failed to register flow` warn for it — that warn line is the locator for a row that exists only in ' - + '`sys_metadata`. A `script` or `subflow` node whose keys its contract declares parses and registers ' - + 'byte-identically to before.', - }, - // The flow text slots read ADR-0032 §3's double-brace template holes, rendered - // by the formula template engine; the single brace is deleted from them. - // Semantic-only — the two renderers answer differently for some value of every - // token spelling, so no D2 conversion rewrites any of them, and no spelling is - // kept. - { - id: 'flow-text-slot-single-brace-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node ' - + '(message) — a string, or the source of a template envelope, carrying a single-brace template token', - replacement: - 'a double-brace template hole, rendered by the formula template engine over the flow\'s variables: a variable ' - + 'path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, ' - + '{{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an ' - + 'assignment node — arithmetic and functions as a CEL value envelope, the date macros as the CEL value ' - + 'envelope of their string form (isoDate(today()), isoDatetime(now())), the run user\'s id as the CEL value ' - + 'envelope current_user.id — and written as {{ variable }}', - reason: - 'ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides ' - + 'with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator ' - + 'and the template engine render the same text for a path holding a string, a number, a boolean, null, an ' - + 'absent key or variable, an ISO date string, an object or an array, but not for every value — a Date ' - + 'rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, ' - + 'screen description or end message that was one token holding an object, an array or a Date rendered ' - + 'String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function ' - + 'calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, ' - + 'never logic. A flow carrying a single-brace token in a text slot is refused at registration, by ' - + 'objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn ' - + 'naming it.', - acceptanceCriteria: - 'Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the ' - + 'slot\'s key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for ' - + 'a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run ' - + 'the flow paths that send those notifications or show those screens and compare the text with the text ' - + 'the 17.x renderer produced — in particular any slot that renders a date value or a whole object.', - }, - // A flow text slot refuses a double-brace hole whose root is a dollar-named - // variable the flow engine does not bind. The 17.x node contracts typed these - // slots as plain strings, so such a hole was accepted there; under the 18 text - // slots, before this refusal, it rendered nothing. Semantic-only — nothing maps - // the hole to a value the run has, so no D2 conversion exists. - { - id: 'flow-text-slot-unbound-dollar-root-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node ' - + '(message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a ' - + 'dollar-named variable the flow engine does not bind, such as {{ $User.Id }}', - replacement: - 'a variable the run has, written as a hole. The run user\'s id is computed first, with an assignment node ' - + 'whose CEL value envelope reads current_user, the run\'s user (assignments: { by: { dialect: \'cel\', source: ' - + '\'current_user.id\' } }), then written as {{ by }}; every other run-user path never resolved in any shipped ' - + 'run, and an email or a name is read from the user record by current_user.id. ' - + 'A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch ' - + 'errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine\'s own ' - + 'variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, ' - + '{{ $flowLabel }}, and a flat-graph loop\'s {{ $loopItems }} / {{ $loopIndex }}', - reason: - 'The dollar-named variables are the flow engine\'s own: it binds $record, $runId, $flowName, $flowLabel and ' - + '$error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers ' - + 'to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole ' - + 'spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the ' - + 'inner token and left a literal brace on each side; the 18 text slots render holes through the template ' - + 'engine, where such a hole renders nothing and the run reports success. It is now refused by the node ' - + 'contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a ' - + 'stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author ' - + 'meant the hole to read is not in the flow, and the template engine binds no new variable to answer it.', - acceptanceCriteria: - 'Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the ' - + 'slot\'s key, naming the hole and its remedy. For a run-user hole, add the assignment the remedy names and ' - + 'write its variable as the hole; for a variable the flow binds under a dollar name, drop the dollar sign at ' - + 'the binding and in the hole. Re-run the flow paths that send those notifications or show those screens and ' - + 'confirm the text carries the value, with no stray brace and no missing fragment.', - }, - // A value a flow reads, not an authorable key: there is no D2 conversion and - // nothing for `objectstack migrate meta` to rewrite. The sibling of - // `18.by-id-write-unreadable-row-not-found` in kind — the entry carries the - // changed answer to the one reader the ledger serves here, the upgrade guide, - // because a flow that read a credential off its trigger record has no schema - // error to find it by. No backticks in `surface`: the upgrade guide renders it - // inside a code span and a table cell. - { - id: 'flow-trigger-record-credential-masked', - surface: - 'the record and previous roots a record-change flow receives — a password or secret field, ' - + 'and an internal field, of the triggering record, on every object', - replacement: - 'read a credential through a privileged binder — the flow credential channel for an http node\'s ' - + 'signing secret, or a privileged server-side read such as the engine\'s resolveSecretField — ' - + 'never off `record` or `previous`; on those roots ' - + 'a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an ' - + '`internal: true` field is absent', - reason: - 'ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and ' - + 'every generic channel serves the mask. The record-change trigger built a flow\'s record and ' - + 'previous from the engine\'s own write result, which keeps the stored row whole for privileged ' - + 'in-process callers, so a password field\'s plaintext, a secret field\'s stored handle and an ' - + 'internal field\'s value reached the flow — and from there its variables, a paused run\'s ' - + 'persisted state and that state\'s read doors. The trigger now projects both roots through the ' - + 'same helper every external write response uses: a credential-class field (secret, and ' - + 'password outside the exempt managedBy buckets) carries the mask, or null when unset, and an ' - + 'internal field is omitted. Every other field keeps its value, every other variable is ' - + 'untouched, and the engine\'s own write result, the stored row and the privileged read paths ' - + 'are unchanged.', - acceptanceCriteria: - 'No flow reads a password, secret or internal field off its trigger record or previous values ' - + 'expecting the stored value; a flow that needs a credential obtains it through a privileged ' - + 'binder; a start or edge condition that compared such a field against a literal is rewritten to ' - + 'test whether it is set (not null).', - }, - // The template dialect leaves the flow value slots: one dialect for a computed - // value, CEL. Semantic-only — every token spelling authored in flows was - // measured lossy under conversion, so no D2 conversion rewrites any of them. - // The run-user paths are refused too: the flow CEL scope binds current_user, - // the run's user or null. So are the date macros, with their CEL string form - // (isoDate / isoDatetime): no single-brace spelling is kept. - { - id: 'flow-value-slot-template-dialect-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the ' - + 'legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a ' - + 'string anywhere inside an array or object value, carrying a single-brace template token, the run-user ' - + 'paths beginning $User. and the date macros NOW() and TODAY() with an optional day offset included', - replacement: - 'a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path ' - + '(record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read ' - + 'through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written ' - + 'as a double (round(x * 100) / 100.0), and text with holes is one concatenation (\'Hello \' + o.name). The ' - + 'run user\'s id, $User.Id, is current_user.id — current_user is the run\'s user, or null when the run has ' - + 'none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which ' - + 'writes null where the template wrote nothing, so on update_record it clears a stored value the template ' - + 'left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: ' - + 'current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email ' - + 'or a name is read from the user record by current_user.id (a get_record node on sys_user). A date macro is ' - + 'its CEL string form on the UTC calendar: TODAY() is isoDate(today()), TODAY() + N and TODAY() - N are ' - + 'isoDate(daysFromNow(N)) and isoDate(daysAgo(N)), NOW() is isoDatetime(now()), and NOW() plus or minus N is ' - + 'isoDatetime(addDays(now(), N)) with N signed, which keeps the time of day where daysFromNow lands on ' - + 'midnight; a variable offset is isoDate(addDays(today(), days)). Where an envelope is literal data — a string ' - + 'inside an object or list value, or either legacy assignment shape — an envelope written in its place is ' - + 'stored as the object it spells, so the whole value is one envelope building a CEL map or list literal, and ' - + 'a legacy assignment moves into the assignments map. A string with no token is the literal text it spells, ' - + 'and braces meant literally are a CEL string literal', - reason: - 'The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no ' - + 'conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index ' - + 'wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and ' - + 'CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. ' - + 'Where a value may be absent, which of nothing, null or a default the field should take is the author\'s ' - + 'decision — the template decided it silently. The run user\'s id was the run\'s userId under the template, ' - + 'and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope ' - + 'binds current_user to the run\'s user and to null in such a run, never a pseudo-user, so current_user.id ' - + 'fails there and its guarded form writes null. The other run-user paths read a user object no run carries, ' - + 'so they wrote nothing in every run. The date macros wrote the ISO text of the UTC day or instant, which ' - + 'isoDate and isoDatetime write byte for byte for a whole number of days, but the template read an offset it ' - + 'could not use as 0 — a variable it did not find, a value that is not a number, text that is neither — ' - + 'where CEL fails the run, and it truncated a fractional offset after adding it to the day of the month, where ' - + 'addDays truncates the offset itself and daysFromNow and daysAgo refuse a fraction at build. A flow carrying ' - + 'a refused value is refused at registration, by objectstack validate and by the executor; a stored flow ' - + 'carrying one is skipped at boot with a warn naming it.', - acceptanceCriteria: - 'Run objectstack validate: it reports each refused value as expression-invalid at the node and the value\'s ' - + 'path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be ' - + 'absent, guard it a step at a time from vars, which holds only the variables the run has bound ' - + '(has(vars.x) ? vars.x : null, has(vars.source) && has(vars.source.id) ? vars.source.id : null; a guard on ' - + 'the last key alone, has(source.id), fails the run when source itself was never bound) or route around the ' - + 'node. For the run user, find which flows can run without one (a schedule, a record change ' - + 'a system write can make): there, guard current_user.id, or skip the node with a start condition or a ' - + 'decision on current_user != null where an update_record must leave the stored value alone. For a date ' - + 'macro with a variable offset, check the variable is always set to a number where the flow runs; write a ' - + 'fractional offset as the whole number of days meant. Re-run the flow paths that write those fields and ' - + 'compare the stored values with the ones the template wrote.', - }, - // #21654 — the D3 entry for `FlowSchema`'s refusal of a write node aimed at a - // stored-metadata table: the save-time half of #21624, which applies #21520's - // ruling A (record 5965059068) to flows, whose run-time half refuses the same - // node before any write. It narrows a flow's accept set; no key is removed, so - // there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is no D2 - // conversion either: a refused node carries no intent the chain could rewrite - // into one the runtime runs. - { - id: 'flow-write-node-stored-metadata-target-refused', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - surface: - 'a create_record, update_record or delete_record flow node whose config.objectName is the string ' - + 'sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body', - replacement: - 'Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), ' - + 'where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at ' - + 'the object the flow really means to write. Elevation (`runAs`, a system context) does not change this.', - reason: - '`FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` ' - + 'names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ' - + 'ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their ' - + 'only writer, where a change is validated and its provenance recorded, and a flow is app-authored ' - + 'automation. The runtime enforces that at the node, refusing the write before it resolves a filter, ' - + 'computes a field or calls the data engine, under every run identity; but every authoring door still ' - + 'accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses ' - + 'it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and ' - + '`objectstack validate` share (`flowNodeConfigRefusals`), with the runtime\'s prescription: ' - + '`objectstack validate`, `defineStack`, compile, an artifact\'s parse, `registerFlow` and the metadata ' - + 'save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the ' - + 'runtime\'s: one of those three write nodes, whose `objectName` is a string naming a stored-metadata ' - + 'table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic ' - + 'target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the ' - + 'run judges the name it hands the data engine. No authored flow writing either table was measured in this ' - + 'repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the ' - + 'node or deleting it each changes what the author wrote, and the runtime already never ran it. Where ' - + 'such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is ' - + 'skipped with a warn naming it, its trigger not armed, while the flows beside it register.', - acceptanceCriteria: - '`objectstack validate` reports no issue at a flow node\'s `config.objectName`: no `create_record`, ' - + '`update_record` or `delete_record` node names `sys_metadata` or `sys_metadata_history`. Every change ' - + 'those nodes made to metadata is made through the metadata API instead. Saving each formerly affected ' - + 'flow through the metadata API succeeds instead of answering a 422 that names `config.objectName`, ' - + 'and boot logs no `failed to register flow` warn for it.', - }, - // #21180 — ADR-0087 D2, immediate retirement (the maintainer's ruling E on - // #21079, comment 5933054144, reversing the #7467 ruling) — the D3 entry of the - // `form-field-public-picker-removed` family (ruling B on #17152: one D3 entry - // per retirement family, even when D2 is lossless). Registered key: - // `ui/FormField:publicPicker`. The strip changes nothing a visitor sees — the - // resolve route already leaves the field off the anonymous rendering — but the - // visitor's way to choose a value is gone, and only the author can say what - // replaces it. - { - id: 'form-field-public-picker-retired', - surface: 'view.form.sections[].fields[].publicPicker — the anonymous public-form record-search picker', - replacement: 'No record search on an anonymous public form. For a choice from a fixed list, a ' - + '`select` field with static `options`. For a choice of an existing record, the same form ' - + 'behind sign-in, where the lookup field renders with the signed-in user\'s access.', - reason: 'The D2 conversion `form-field-public-picker-removed` deletes `publicPicker` from every ' - + 'form field, and the delete is lossless in effect: the block\'s only reader was the ' - + 'anonymous lookup route, which is gone, and the public-form resolve route now leaves ' - + 'lookup, `master_detail` and `user` fields off the anonymous rendering whatever the row ' - + 'carries. What the strip cannot decide is the visitor\'s path. A public form that used the ' - + 'picker let an anonymous visitor search and pick a record; after the upgrade that field is ' - + 'simply absent from the form, so a submission arrives without the value. Whether the ' - + 'choice was really from a small fixed set (a `select` with static `options`), or needs a ' - + 'real record and therefore a signed-in user, is a product decision only the author can make.', - acceptanceCriteria: 'No form field carries `publicPicker`; the parse refuses it. Every public ' - + 'form that had carried one either replaces the lookup field with a `select` field whose ' - + 'static `options` list the allowed choices, or is served behind sign-in, or the author has ' - + 'confirmed the form works without the value. Fetching the public form anonymously ' - + '(`GET /forms/:slug`) shows no lookup, `master_detail` or `user` field in its sections.', - }, - // The D3 entry of the `form-view-option-default-removed` family, which landed - // in commit c459da6bc: a maintainer-ruled narrowing on the objectui#6263 - // analysis that took per-option `default` out of the form-view vocabulary and - // left the object-field face enforcing it. One D3 entry per retirement family, - // even when D2 is lossless (ruling B on #17152). The strip changes no form; - // the prescribed replacement changes MORE than one form, and that difference - // is the author's call. - { - id: 'form-view-option-default-retired', - surface: 'view.form.sections[].fields[].options[].default — the per-option pre-selection on a ' - + 'form view\'s own option list', - replacement: 'The object field\'s own option list, where `default` is enforced: `default: true` ' - + 'on that field\'s options entry, or the field-level `defaultValue`.', - reason: 'The D2 conversion `form-view-option-default-removed` deletes `default` from every option ' - + 'of every form-view field it reaches, and the delete is lossless: nothing on the form path ' - + 'ever read it — the insert-path default falls back to the OBJECT definition\'s options, and ' - + 'no form renderer seeds a value from a form view\'s. So a form that marked an option as ' - + 'default never pre-selected it, and still does not. The judgment is in the replacement. The ' - + 'form-view key was scoped to ONE form; the object field\'s `default` applies on EVERY insert ' - + 'path — every form of that object, the API, imports. Moving the marker there makes the form ' - + 'do what its author wanted and also changes what records created elsewhere receive when the ' - + 'value is omitted. Only the author can say whether that wider default is correct, or whether ' - + 'the pre-selection should be dropped.', - acceptanceCriteria: 'No form-view option carries `default`; the parse refuses it. For each form ' - + 'field that had marked one: either the object field now declares the default and the author ' - + 'has accepted it for every insert path — a record created through the form or the API with ' - + 'the field left empty is stored with that value — or the author has decided the form needs ' - + 'no pre-selection and the object field is unchanged.', - }, - // #20901 — the form-view carrier of the inline grid column. `subforms[].columns` - // was `z.array(z.any())` while the other carrier, a relationship field's - // `inlineColumns`, has been the strict `InlineGridColumnSchema` since #9227; the - // carrier now REFERENCES that schema, so both carriers are judged by one - // contract. The one mechanical respelling, `field` → `name`, is the D2 - // conversion `form-view-subform-columns-canonicalized` (the respelling - // `field-column-lists-canonicalized` makes on `inlineColumns`); every other - // refused shape is a judgment only the author can make. A view saved with a - // failing column is refused with the column schema's own prescription, and a - // stored row carrying one is diagnosed at rehydration; neither is stripped. - { - id: 'form-view-subform-columns-closed', - surface: 'view.form.subforms[].columns[] and view.formViews..subforms[].columns[] — the ' - + 'form view\'s inline grid columns, which used to accept any value', - replacement: 'each entry is the strict, name-keyed inline grid column a relationship field\'s ' - + '`inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest ' - + 'from the child object\'s field. Write `name` where a column said `field` (or `fieldName`, ' - + '`key`); delete `scale` from a column declaring `type: \'currency\'`; delete any key the ' - + 'column schema does not declare.', - reason: 'Both carriers feed the one console grid, which reads only the keys the column schema ' - + 'declares and keys a column by `name` alone. On the form view the columns were never judged, ' - + 'so a mis-keyed column published clean and drew a blank grid column, and a key the other ' - + 'carrier refuses — `scale` on a currency column, under the maintainer\'s ruling of 2026-09-23 ' - + '(option B, `scale` retired from the currency type) and the remedy ruled on 2026-09-24 ' - + '(option 乙 — a currency\'s ISO 4217 minor unit decides its display) — published green here. ' - + 'The carrier now references the column schema, so every rule it holds applies here too, with ' - + 'its own prescription. Only the `field` spelling is converted mechanically — by the conversion ' - + '`form-view-subform-columns-canonicalized`, which rewrites stored rows and assembled artifacts ' - + 'and lists the edit under `os migrate meta`, while an author writing `field` meets the refusal. ' - + 'Which column an unknown key or a mixed `field`/`name` entry meant is the author\'s call — a ' - + 'conversion that dropped the key would accept on every load what the parse now refuses. ' - + 'Population measured at the change, on ' - + 'origin/main cb4c31dd52: zero authored `subforms` in the repository (the showcase derives its ' - + 'master-detail grids from the data model instead), against one authored `inlineColumns` block ' - + 'as the control. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every view in the stack parses: `objectstack validate` and a view parse report ' - + 'no issue on a `subforms[].columns[]` path. Every column entry is an object carrying `name`, ' - + 'no entry carries `field`, `fieldName` or `key`, and no column declaring `type: \'currency\'` ' - + 'carries `scale`. Each column `name` names a field of the subform\'s `childObject`, and the ' - + 'master-detail grid renders a value — not a blank cell — in each column for a row that has one.', - }, - // #21565 — the D3 entry for `HookSchema`'s refusal of a hook body bound to a - // stored-metadata table: the authoring half of #21520's ruling A (record - // 5965059068), whose runtime half refuses the same hook at bind. It narrows the - // hook's accept set; no key is removed, so there is no tombstone and no - // RETIRED_KEYS_BY_MAJOR row. There is no D2 conversion either: a refused hook - // carries no intent the chain could rewrite into one the runtime runs. - { - id: 'hook-body-stored-metadata-target-refused', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - surface: - 'hook.object naming sys_metadata or sys_metadata_history, as the string or as any member of the ' - + 'list, on a hook that carries a body', - replacement: - 'Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), ' - + 'where it is validated and its provenance is recorded. Delete the hook, or point its `object` at ' - + 'the tables the logic really concerns. Elevation (`runAs`, a system context) does not change this.', - reason: - '`HookSchema` accepted a hook whose `body` targets `sys_metadata` or `sys_metadata_history`, the ' - + 'tables that hold stored metadata. The maintainer ruled (2026-10-03) that an app-authored body may ' - + 'not touch those tables: for a body, the metadata protocol is their only writer, where a change is ' - + 'validated and its provenance recorded. The runtime enforces that where a body hook becomes a ' - + 'handler, refusing such a hook at registration so that it never runs, but every authoring door ' - + 'still accepted it: the metadata save door answered 200, and the author learned otherwise only ' - + 'from a server log. The parse now refuses it too, with the runtime\'s own prescription, so ' - + '`objectstack validate`, `defineStack`, compile, an artifact\'s parse and the metadata save door ' - + '(a 422) each name the target at `object`, or at the list member. The refused set is exactly the ' - + 'runtime\'s: a hook carrying a `body`, in any form, whose `object` names a stored-metadata table, ' - + 'as the string or as any member of the list, and one such member refuses the whole hook. A hook ' - + 'with no `body` (a code `handler`, which is how the platform writes its own hooks) and the ' - + 'wildcard `\'*\'` are outside it, as they are at registration: a wildcard names no stored-metadata ' - + 'table, so it binds, and the runtime never runs its body for those tables\' events. No authored ' - + 'hook targeting either table was measured in this repository, its examples or hotcrm. There is no ' - + 'mechanical rewrite: retargeting the hook, dropping its body or deleting it each changes what the ' - + 'author wrote, and the runtime already never ran it. A stored hook row of this shape still loads, ' - + 'now with a `[metadata_spec_invalid]` warning and a `_diagnostics` badge, and is still never bound.', - acceptanceCriteria: - '`objectstack validate` reports no issue at a hook\'s `object` path: no hook that carries a `body` ' - + 'names `sys_metadata` or `sys_metadata_history` in its `object`, as the string or in the list. ' - + 'Every change those hooks made to metadata is made through the metadata API instead. Saving each ' - + 'formerly affected hook through the metadata API succeeds instead of answering a 422 that names ' - + '`object`, and boot logs no binding refusal naming one of those tables for a hook.', - }, - { - id: 'hook-register-undispatched-lifecycle-event-refused', - surface: - "engine.registerHook('beforeFindOne' | 'afterFindOne' | 'beforeCount' | 'afterCount' | " - + "'beforeAggregate' | 'afterAggregate', handler)", - replacement: - "for the findOne pair, register on 'beforeFind' / 'afterFind' — they already fire for " - + "`findOne`; for the count and aggregate pairs there is no hook seam at all, so move the " - + 'logic to `engine.registerMiddleware(fn)` and read ' - + "`ctx.operation === 'count' | 'aggregate'`, composing the predicate onto `ctx.ast.where`", - reason: - '`registerHook` took `event: string` and, for a name outside the dispatched set, warned and ' - + 'then REGISTERED the handler anyway. Six of those names are inside the engine\'s own ' - + "lifecycle namespace — (`before`|`after`) x `OperationContext['operation']` minus the eight " - + 'the engine dispatches — so an author writing one of them believes they are subscribing to ' - + 'an engine lifecycle event, and what they get back is an inert declaration: ADR-0078\'s ' - + 'prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam.\n\n' - + 'The measured consequence is a data-visibility one, which is why this is not a cosmetic ' - + 'warning. A downstream consumer registered READ FILTERS on `beforeFindOne` and ' - + '`beforeCount`, expecting them to scope single-record reads and list totals; they sat inert ' - + 'through every boot behind about forty warning lines. `findOne` was still filtered — ' - + '`beforeFind` covers it — so the mistake gave no signal there. `count` was not: a `limit`ed ' - + 'list answered a `total` counting rows the caller could not see. `aggregate` was not ' - + 'either: a `groupBy` was not narrowed at all. A filter that was supposed to narrow ' - + 'visibility and silently did not run is a guardrail the author believes they armed.\n\n' - + 'Refused at REGISTRATION rather than repaired on the dispatch side. Making `count()` and ' - + '`aggregate()` dispatch hooks would widen what a hook may intercept — a different and much ' - + 'larger decision — and it would also be the wrong seam: read authorization and row ' - + 'filtering are the middleware chain\'s job, which is what `HookEvent` in `@objectstack/spec` ' - + 'already says and what `count()` already honours (its AST rides the operation context ' - + 'precisely so the security and sharing middlewares can scope it). The refusal names the ' - + 'per-seam repair in its own message, because "this never fires" alone cannot tell the two ' - + 'seams apart: one is a rename, the other is a different API.\n\n' - + 'The refusal is scoped to those six names, not to everything outside the dispatched set. ' - + '`triggerHooks` is public, so a plugin dispatching its own event under a name outside the ' - + "engine's vocabulary (`'myPlugin:flush'`) is a legitimate reading — that is why the change " - + 'that collapsed the hook taxonomy to the eight dispatched events made this branch a warn — ' - + 'and it still warns and still registers. The population is DERIVED ' - + 'from the operation union rather than typed out, so a new engine verb widens it without an ' - + 'edit; a hand-written list of refused names would be this same defect one layer up.\n\n' - + 'This is a RUNTIME registration API, not stored metadata, so — like ' - + '`hook-register-empty-object-target-refused` at the previous step — there is no ' - + '`sys_metadata` row for the D2 chain to rewrite, and the ledger entry is the notification ' - + 'channel. The metadata door was never open on this axis: `HookSchema.events` is ' - + '`z.array(HookEvent)`, and `HookEvent` enumerates exactly the eight dispatched names, so no ' - + 'authored or stored hook could ever carry one of the six. The exposure was entirely on the ' - + 'code door. ADR-0078.', - acceptanceCriteria: - 'No `registerHook` call site passes `beforeFindOne`, `afterFindOne`, `beforeCount`, ' - + '`afterCount`, `beforeAggregate` or `afterAggregate`. Every read filter that was written ' - + 'against one of those names has been moved: the findOne pair to `beforeFind` / `afterFind`, ' - + 'the count and aggregate pairs to a middleware registered with ' - + '`engine.registerMiddleware`. Boot completes with no ' - + '"[ObjectQL] Hook \'...\' is an engine lifecycle event name the engine never dispatches" ' - + 'throw, and any list `total` or `groupBy` that was expected to be scoped is scoped by a ' - + 'middleware rather than by a hook.', - }, - // #14478 (maintainer ruling B: a duration key carries its unit in its NAME) — - // the D3 entry of the `hook-timeout-to-timeout-ms` family (ruling B on #17152: - // one D3 entry per retirement family, even when D2 is lossless). The rename - // keeps the number, which is exactly the part the ruling was about: whether - // that number was ever in milliseconds is the author's to say. - { - id: 'hook-timeout-unit-in-key', - surface: 'hook.timeout — the per-invocation time limit of a data hook', - replacement: '`timeoutMs` — the same limit, in milliseconds, with the unit in the key name.', - reason: 'The D2 conversion `hook-timeout-to-timeout-ms` renames `timeout` to `timeoutMs` in author ' - + 'sources and on stored hook rows, keeping the value, and the rename is lossless: the key ' - + 'always meant milliseconds, and the conversion leaves an already-canonical `timeoutMs` alone ' - + 'and refuses a pair that disagrees. The judgment is the one the rename exists for. The unit ' - + 'used to live only in the key\'s description, beside body-level keys that spelled theirs, so ' - + 'an author who wrote a seconds value — `timeout: 30` meaning thirty seconds — got a limit of ' - + 'thirty milliseconds and no error, and the rename carries that 30 over unchanged. Only the ' - + 'author knows which unit they meant, so each value needs reading once. A pair left ' - + 'unconverted because the two spellings disagree needs the author to choose, and code that ' - + 'builds or reads a hook definition in TypeScript is outside the chain\'s reach.', - acceptanceCriteria: 'No hook carries `timeout`; the parse refuses it with the rename. Every ' - + '`timeoutMs` value is the limit the author intends expressed in milliseconds — a hook meant ' - + 'to be allowed thirty seconds reads `timeoutMs: 30000`. A hook that runs longer than its ' - + '`timeoutMs` fails with a timeout at that limit, and one that finishes inside it completes as ' - + 'it did before the upgrade. No code reads or writes `timeout` on a hook definition.', - relevantWhen: { kind: 'stack-declares', keys: ['hooks'] }, - }, - { - id: 'hot-reload-inert-state-strategies-retired', - surface: - "`HotReloadConfig.stateStrategy` values 'disk' and 'distributed', plus the " - + '`HotReloadConfig.distributedConfig` key and the `DistributedStateConfig` def ' - + 'it carried (3 exported names: `DistributedStateConfigSchema` / ' - + '`DistributedStateConfig` / `DistributedStateConfigParsed`)', - replacement: - "'memory' for in-process state preservation across a reload, or 'none' to " - + 'disable it — the two values `PluginStateManager` actually implements. There ' - + 'is no in-tree replacement for durable or distributed plugin state: persist ' - + 'it in the host, which owns the process lifetime these strategies pretended ' - + 'to outlive. Real disk or distributed persistence returns only via the ' - + 'ENFORCE route of ADR-0049 — the implementation first, the declaration with ' - + 'it.', - reason: - 'ADR-0049 enforce-or-remove, applied one level INSIDE the library the ' - + 'maintainer\'s 2026-08-25 ruling on the advanced plugin-lifecycle config ' - + 'kept. That ruling retired the authorable ' - + 'lifecycle-config container and deliberately kept `HotReloadConfigSchema` as ' - + 'a host-driven library parameter type; this card measured the kept ' - + "vocabulary's own remainder and found the same defect in it. Measured at " - + 'cdbd9204b6 with a firing positive control (`stateStrategy` resolves to real ' - + 'readers in `core/src/hot-reload.ts`, so the scan sees readers): the ' - + "'disk' and 'distributed' arms of `PluginStateManager.saveState` both wrote " - + "to the SAME in-memory Map as 'memory' — the in-source comments said " - + "'memory fallback' — and announced the substitution at DEBUG level only, so " - + 'a host that asked for durable or cluster-replicated state got process-local ' - + 'memory and no error: state that does not survive the restart it was ' - + 'configured to survive. `distributedConfig` had ZERO readers anywhere ' - + '(every reference inside `packages/spec` itself plus the generated reference ' - + 'page; nothing in objectui), so an author could name a Redis endpoint, a TTL ' - + 'and a replication factor and nothing ever opened a connection — the shape ' - + 'of the plugin sandboxing / integrity / approval config that was never wired ' - + 'to anything (an exported schema no runtime reads is read as a capability), ' - + 'sharpened by cluster-persistence vocabulary an AI author (ADR-0033) ' - + 'reads as proof the capability exists. The key left with the enum value its ' - + 'own doc comment named it "required" for, and `DistributedStateConfig` was ' - + 'its orphan value schema. Two routes in one card because the surface has two ' - + 'shapes: an enum-VALUE narrowing is invisible to the four ratchets (the def ' - + 'still emits), so its prescription hangs on the enum\'s own `error` map ' - + 'dispatched by `issue.input` (the `crypto.hash` / `managedBy: \'system\'` ' - + 'precedent); the whole-def removal MUST move them, and that movement is its ' - + 'own evidence. No D2 conversion and no tombstone: `HotReloadConfig` is not ' - + 'an authorable surface — no metadata-type binding, stack collection or ' - + 'manifest embed ever carried it, and nothing in the tree parses ' - + '`HotReloadConfigSchema` outside its own unit test — so there is no authored ' - + 'document to rewrite and no one who could receive a parse-time ' - + 'prescription. Route 3, the shape of the dynamic plugin-loading family\'s ' - + 'removal and of that lifecycle-config ruling: this entry IS the ' - + 'declaration.', - acceptanceCriteria: - "No host passes `stateStrategy: 'disk'` or `'distributed'` to " - + '`HotReloadManager.registerPlugin`. TypeScript hosts cannot: ' - + "`HotReloadConfigParsed['stateStrategy']` is now `'memory' | 'none'`, so " - + 'either value is a compile error at the call site. JavaScript hosts, and ' - + 'config that arrived as JSON, get a loud registration-time refusal carrying ' - + 'the prescription — an ADR-0112 envelope (`code: VALIDATION_ERROR`, ' - + '`status: 400`) thrown BEFORE the `enabled` check, so a disabled config ' - + 'cannot smuggle the false declaration through. No import of ' - + '`DistributedStateConfigSchema`, `DistributedStateConfig` or ' - + '`DistributedStateConfigParsed` from `@objectstack/spec` or ' - + '`@objectstack/spec/kernel` survives — every one is TS2305 after upgrade, ' - + 'pinned by resolved symbol identity in ' - + '`kernel/plugin-lifecycle-advanced-retirement.test.ts`. ⚠️ Runtime state ' - + "behaviour is UNCHANGED for every config that worked: 'disk' and " - + "'distributed' already stored to memory, so a host that migrates either to " - + "'memory' keeps byte-identical behaviour — what changes is that the two " - + 'spellings which never described what happened are now refused instead of ' - + 'silently honoured. That ruling\'s keep itself stands: `HotReloadConfigSchema`, ' - + '`PluginStateSnapshotSchema` and the health vocabularies still export from ' - + '`./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still export ' - + 'from `@objectstack/core` with their tests green.', - }, - { - id: 'hot-reload-watch-placeholder-retired', - surface: - '`HotReloadConfig.watchPatterns`, and the `HotReloadManager.startWatching` ' - + 'placeholder that read it', - replacement: - 'Run your own watcher and call ' - + '`HotReloadManager.scheduleReload(pluginName, reloadFn)` when a file ' - + 'changes — that is the debounced integration point this class actually ' - + 'implements, and it is unchanged. Declare your globs wherever your ' - + 'watcher reads them; there is no in-tree replacement for the key, ' - + 'because file watching is the HOST\'s job in this host-driven library. ' - + 'The platform already depends on `chokidar` in `@objectstack/metadata`, ' - + '`@objectstack/metadata-fs` and `@objectstack/cli` — never in ' - + '`@objectstack/core` — so a host has a working model to copy.', - reason: - 'ADR-0049 enforce-or-remove, applied one symbol over from the inert ' - + "'disk' / 'distributed' state strategies retired in the same file, and on " - + 'the same per-key test. `HotReloadManager.startWatching` ' - + 'contained NO watcher: its whole body was a guard plus ' - + "`logger.info('File watching started', { patterns })` above an in-source " - + 'note saying real watching "would require chokidar or similar / This is a ' - + 'placeholder for the integration point". `watchHandles` was only ever ' - + 'read, deleted, iterated and cleared and NEVER set, so `stopWatching`\'s ' - + 'cleanup branch and the teardown loop over its keys were structurally ' - + 'UNREACHABLE rather than merely untaken (measured with a firing positive ' - + 'control: `reloadTimers.set` resolves a real writer in the same file and ' - + 'the same scan; `watchHandles.set` resolves nothing anywhere). So ' - + '`watchPatterns` had no reader that ACTED on it — its only two uses were ' - + 'log lines — and an author could declare a glob while no file change ' - + 'could ever trigger a reload. This is the shape of the plugin sandboxing ' - + 'config that was never wired to anything, with the volume turned up: the ' - + 'inert state-strategy fallback at least announced itself at DEBUG, ' - + 'whereas this said "File watching started" at INFO — positive ' - + 'confirmation of a capability that did not exist, which an operator, or ' - + 'an AI author (ADR-0033), reads as proof and stops looking. Neither of ' - + 'the other two ADR-0049 states was available: ENFORCE would build for a ' - + 'caller that does not exist (no runtime composes `HotReloadManager` — ' - + 'only its own unit test and `core/examples/phase2-integration.ts` ' - + 'construct it, the same fact that decided the state-strategy retirement\'s ' - + 'route), and ' - + 'EXPERIMENTAL requires a roadmap, where a scan of every planning doc ' - + 'returned ZERO mentions of hot-reload file watching against 145 control ' - + 'hits in the same files. Route 3 again: `HotReloadConfig` is not an ' - + 'authorable surface — no metadata-type binding, stack collection or ' - + 'manifest embed ever carried it, and nothing in the tree parses ' - + '`HotReloadConfigSchema` outside its own unit test — so there is no ' - + 'authored document to rewrite and nobody who could receive a parse-time ' - + 'prescription, so there is no D2 conversion either — it would be a ' - + 'transform with no seam that ever runs. The key is TOMBSTONED rather ' - + 'than deleted, and the BUILD is what decided that: the plain deletion ' - + 'was tried first and `gen:schema` gate (a) refused it, because ' - + '`HotReloadConfigSchema` is not `.strict()` and a bare deletion would ' - + 'be a silent strip (the failure measured when a field key pruned from a ' - + 'non-strict schema still parsed successfully and simply vanished, ' - + 'ADR-0104) — the very defect being retired, one layer down. The ' - + 'state-strategy retirement could take route 3 because what left there was ' - + 'a whole DEF; a key leaving a SURVIVING def has no such exit. This ' - + 'entry IS the declaration.', - acceptanceCriteria: - 'No host passes `watchPatterns` to `HotReloadManager.registerPlugin`, and ' - + 'no host calls `HotReloadManager.startWatching`. TypeScript hosts cannot ' - + 'do either: `watchPatterns` is typed `never` by the tombstone, and ' - + '`startWatching` returns `never`. JavaScript hosts, and config that ' - + 'arrived as JSON, get a loud refusal carrying the prescription — an ' - + 'ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown for ' - + 'a leftover `watchPatterns` BEFORE the `enabled` check so a disabled ' - + 'config cannot smuggle the false declaration through, and thrown ' - + 'unconditionally from `startWatching` so the placeholder can no longer ' - + 'report success. `startWatching` is kept as a throwing door rather than ' - + 'deleted precisely so that caller meets a prescription instead of a bare ' - + '`TypeError: not a function`. Runtime reload behaviour is UNCHANGED for ' - + 'every config that worked: nothing was ever watched, so nothing that ' - + 'used to happen stops happening — `registerPlugin`, `scheduleReload`, ' - + '`reloadPlugin` and state preservation are untouched, and ' - + '`stopWatching` keeps the half that always did something (it cancels a ' - + 'pending debounced reload; its unreachable `watchHandles` branch left ' - + 'with the placeholder). What the maintainer\'s 2026-08-25 ruling on the ' - + 'advanced plugin-lifecycle config kept still stands: ' - + '`HotReloadConfigSchema` and `PluginStateSnapshotSchema` still export ' - + 'from `./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still ' - + 'export from `@objectstack/core` with their tests green.', - }, - { - id: 'identity-api-key-schema-retired', - surface: - 'identity.apiKey (the whole of `ApiKeySchema` in identity/identity.zod.ts — ' - + '1 def, 3 exported names: `ApiKeySchema`, `ApiKey`, `ApiKeyParsed`)', - replacement: - '(removed — there is no replacement schema, because the deleted one never ' - + 'described the real table. The single declaration of `sys_api_key` is the ' - + 'ObjectSchema in `@objectstack/platform-objects` ' - + '(`identity/sys-api-key.object.ts`): columns `name, prefix, user_id, ' - + 'active_organization_id, scopes, expires_at, last_used_at, revoked, key, ' - + 'id, created_at, updated_at`, snake_case, `revoked` as the kill switch — ' - + 'not `enabled`. Rows are minted by `POST /api/v1/keys` ' - + '(`runtime/src/domains/keys.ts`) and verified by ' - + '`core/src/security/api-key.ts`, keyed by the `osk_` prefix. Per-key rate ' - + 'limiting returns only via the ENFORCE route of ADR-0049 through a new ' - + 'ADR — the executor first, the vocabulary second)', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15, ' - + 'disposition B: delete the schema. `ApiKeySchema` documented better-auth\'s `apiKey` ' - + 'PLUGIN schema — a plugin this platform does not load ' - + '(`plugin-auth/src/managed-extension-fields.ts` states the table is ' - + 'hand-rolled ObjectStack): `start` and `lastRefetchAt` name columns that do ' - + 'not exist; `enabled` inverts the real `revoked` column\'s polarity; ' - + '`rateLimitEnabled` / `rateLimitTimeWindow` / `rateLimitMax` / `remaining` ' - + 'advertise a per-key rate-limit capability nothing implements (the sharpest ' - + 'PD #10 instance — a reader can reasonably conclude API keys support rate ' - + 'limiting); `permissions` and `metadata` have no columns; `organizationId` ' - + 'is camelCase fiction next to the real snake_case ' - + '`active_organization_id`. Zero consumers measured (08-14, re-verified at ' - + 'the retirement\'s base commit): only its own unit test, the export ' - + 'snapshots, the generated reference page and a prose mention in ' - + '`cloud/developer-portal.zod.ts` (corrected in the same PR — the ' - + 'marketplace-key plan it gestured at is ruled NOT live). One table had two ' - + 'declarations and the published one was fiction; the generated reference ' - + 'page rendered it faithfully, which is how the defect surfaced as a docs ' - + 'card. With no carrier key and no authored document there is nothing to ' - + 'tombstone and no seam for a D2 conversion: route 3, the shape of the ' - + 'earlier removals of the dynamic plugin-loading family, the `ui/` ' - + 'interaction configs, the widget / i18n shapes, five declared-but-inert ' - + 'surfaces and two credential-bearing schemas no `sys_metadata` door ' - + 'reached — RETIRED_DEFS_BY_MAJOR plus this entry ARE ' - + 'the declaration.', - acceptanceCriteria: - 'No code imports `ApiKeySchema`, `ApiKey` or `ApiKeyParsed` from ' - + '`@objectstack/spec` or `@objectstack/spec/identity` — every one is TS2305 ' - + 'after upgrade, on every public entry (pinned by resolved symbol identity ' - + 'in `identity/api-key-retirement.test.ts`). No metadata document needs ' - + 'editing: the schema was reachable from no metadata-type binding, stack ' - + 'collection or /meta door, so no document could ever carry it. ' - + '`UserSchema` / `AccountSchema` / `VerificationTokenSchema` and the ' - + 'organization module survive unchanged (the ruling accepts the sibling ' - + 'asymmetry deliberately), and the `sys_api_key` ObjectSchema in ' - + '`@objectstack/platform-objects` still declares the real column set ' - + '(pinned in `sys-api-key-single-declaration.test.ts`). ' - + '⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever ' - + 'read the schema, so removing it removes no behaviour — mint and verify ' - + 'work byte-identically before and after.', - }, - { - id: 'incident-response-deadline-keys-retired', - surface: - 'incident-response deadline keys: `IncidentResponsePhase.targetHours`, ' - + '`IncidentNotificationRule.withinMinutes` / `regulatorDeadlineHours`, ' - + '`IncidentNotificationMatrix.escalationTimeoutMinutes`, ' - + '`IncidentResponsePolicy.triageDeadlineHours` / `retentionDays`', - replacement: - 'nothing to re-declare — delete the keys. No incident-response engine exists on the ' - + 'platform: nothing tracks a phase against a clock, sends or times an incident ' - + 'notification, notifies a regulator, walks the escalation chain on a timer or sweeps ' - + 'incident records on a schedule, so there is no live mechanism to declare a deadline ' - + 'to. Retention of stored records is the object-level `lifecycle` block (ADR-0057), ' - + 'declared on the object that stores the records and enforced by the LifecycleService — ' - + 'not a number on this policy document', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' - + '(ruled A: retire per ' - + 'family). Six hour/minute/day-shaped keys sat on the published authorable surface and ' - + 'in the generated reference docs — an author could write `triageDeadlineHours: 4` and ' - + 'reasonably expect the platform to escalate after four hours — and read by NOTHING: ' - + 'the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, ' - + 'registered as no metadata type, absent from the 2026-06 liveness ledgers, and the ' - + 'reader census over every package outside `packages/spec` (tests and changelogs ' - + 'excluded) and over objectui at the pinned sha returned zero hits for every key. Three ' - + 'of the six carried defaults (30 minutes, 1 hour, 2555 days) that were materialized ' - + 'into every parsed document without ever being consulted. A compliance-shaped deadline ' - + 'that fails silently is the worst form of the declared-but-unenforced shape ADR-0049 ' - + 'names; tagging it `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not ' - + 'take. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and ' - + '`applyConversionsToStoredItem` maps a metadata type onto one of its collections; none ' - + 'of these schemas is either, so a conversion would be a transform with no seam that ' - + 'ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent).', - acceptanceCriteria: - 'No `IncidentResponsePhase`, `IncidentNotificationRule`, `IncidentNotificationMatrix` or ' - + '`IncidentResponsePolicy` literal — standalone or nested in an `Incident` — carries ' - + '`targetHours`, `withinMinutes`, `regulatorDeadlineHours`, `escalationTimeoutMinutes`, ' - + '`triageDeadlineHours` or `retentionDays`. TypeScript authors get the refusal at compile ' - + 'time (each key is typed `never`); a value reaching the parse is refused with the ' - + 'prescription (`invalid_type` at the path of the key). Parsed documents no longer carry ' - + 'the three former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be ' - + 'verified as such: nothing ever read the keys, so removing them removes no behaviour.', - }, - { - id: 'incident-response-family-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the incident-response family, retired whole: the eight defs system/Incident, ' - + 'system/IncidentCategory, system/IncidentNotificationMatrix, ' - + 'system/IncidentNotificationRule, system/IncidentResponsePhase, ' - + 'system/IncidentResponsePolicy, system/IncidentSeverity and system/IncidentStatus, ' - + 'and every name system/incident-response.zod.ts exported from @objectstack/spec/system ' - + '(the eight *Schema consts, their z.input aliases and the three *Parsed aliases)', - replacement: - 'nothing to re-declare — no incident-response engine exists on the platform, so there is ' - + 'no working configuration to migrate to. Nothing classified, tracked, escalated or ' - + 'notified an incident and nothing notified a regulator; a compliance record the ' - + 'organisation keeps is ordinary object data, declared as an object with its own ' - + 'fields and enforced by the object engine (validation, permissions, the ' - + 'object-level `lifecycle` block under ADR-0057). If incident response becomes a product ' - + 'capability it re-declares fresh, through the enforce route of ADR-0049 — the engine ' - + 'first, the vocabulary second', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' - + 'and defs (ruled A: retire the ' - + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' - + 'integration/ErrorMappingConfig precedent; not roadmapped). Eight defs and roughly ' - + 'forty declared keys sat on the exported surface and in the generated reference docs, ' - + 'and were read by NOTHING: the schemas were exported from `@objectstack/spec/system`, ' - + 'mounted by no `stack.zod.ts` key, registered as no metadata type, absent from the ' - + '2026-06 liveness ledgers, and the reader census over every package outside ' - + '`packages/spec` (tests and changelogs excluded), over `examples/**` and `skills/**`, ' - + 'and over objectui at the pinned sha returned zero hits for every exported name, with ' - + 'a lit control. Several keys were boolean capability claims of exactly the shape ' - + 'ADR-0049 names — `IncidentNotificationRule.notifyRegulators`, ' - + '`IncidentResponsePolicy.requirePostIncidentReview` — so an author (very often an AI, ' - + 'ADR-0033) could write `notifyRegulators: true`, parse clean, and hold a compliance ' - + 'promise the platform never kept, with no error and no feedback. Tagging the family ' - + '`[EXPERIMENTAL — not enforced]` was the fallback the ruling did not take: it is a ' - + 'human-only signal, and an AI generating from the schema still writes the key and ' - + 'believes it. The deadline-key tombstones of the 2026-09-02 per-family ruling (six sites, ' - + '`RETIRED_KEYS_BY_MAJOR[18]`, ' - + 'D3 `incident-response-deadline-keys-retired`) leave with their defs\' source; their ' - + 'registry entries stay as history. Why D3 semantic and not a D2 conversion: the chain ' - + 'walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto ' - + 'one of its collections; none of these schemas is either, so a conversion would be a ' - + 'transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` ' - + 'precedent), and with no carrier key there is no shape on which a tombstone could sit.', - acceptanceCriteria: - 'No code imports IncidentSchema, IncidentCategorySchema, IncidentNotificationMatrixSchema, ' - + 'IncidentNotificationRuleSchema, IncidentResponsePhaseSchema, ' - + 'IncidentResponsePolicySchema, IncidentSeveritySchema or IncidentStatusSchema — or any ' - + 'of their type aliases — from @objectstack/spec or @objectstack/spec/system: every such ' - + 'import is TS2305 after upgrade, and no working replacement exists to point at because ' - + 'the vocabulary described nothing real. The eight defs are absent from ' - + '`json-schema.manifest/system.json`, the api-surface / declaration-map / export-origins ' - + 'shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED ' - + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' - + 'them removes no behaviour.', - }, - { - id: 'inline-grid-column-currency-scale-refused', - surface: 'object.fields..inlineColumns[].scale on an inline grid column that declares ' - + '`type: \'currency\'` — any declared value, `scale: 0` included, computed or not. `scale` on a ' - + '`number` column is untouched. A column that declares no `type` is judged as the type it ' - + 'renders as: over a `currency` field of the child object it is entry ' - + '`inline-grid-column-identity-only-currency-scale-refused`', - replacement: 'no `scale` on a currency inline grid column. DELETE the key — that is the whole ' - + 'migration: a currency amount\'s decimal places are its currency\'s, not a column setting. The ' - + 'currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' - + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' - + 'any other key.', - reason: - 'The maintainer\'s ruling of 2026-09-23 (option B) retired `scale` from the `currency` field ' - + 'type, and the ruling of 2026-09-24 (option 乙 — a currency\'s ISO 4217 minor unit decides its ' - + 'display) worded the remedy. Neither reached the inline grid ' - + 'column, the strict mirror of the console grid\'s column, which still offered per-column ' - + 'decimals on a `currency` column; triage read the column as inherited from both rulings, so ' - + '`InlineGridColumnSchema` now refuses the key on a column declaring `type: \'currency\'` at ' - + 'parse, with the field refusal\'s first sentence and remedy. ⛔ No alias and no grace window, ' - + 'per ruling B. NOT mechanically converted, deliberately, for the reason the field entry ' - + '`field-currency-scale-refused` gives: a conversion that dropped the key would accept it on ' - + 'every load, which is the grace window the ruling refused; the refusal names the key and its ' - + 'one-line fix instead. The same change rewords the column\'s `prefix` description: it replaces ' - + 'the resolved currency\'s symbol and has no default (the grid no longer falls back to a fixed ' - + 'yen sign). Reach: the column schema judges only a DECLARED column `type` — a column that ' - + 'declares none takes its type from the child field when the console hydrates it, which the ' - + 'schema cannot see; `defineStack` judges that column instead (entry ' - + '`inline-grid-column-identity-only-currency-scale-refused`). Population measured at the ' - + 'change, on origin/main 1c8b320a89: one authored `inlineColumns` block in the tree (the ' - + 'showcase invoice, seven identity-only columns, none declaring `type` or `scale`), no platform ' - + 'object, skill, documentation example or JSON fixture declaring an inline grid column at all, ' - + 'and one test fixture carrying `scale: 2` on a currency column, re-judged in the same change. ' - + 'Deployed metadata NOT MEASURED.', - acceptanceCriteria: - 'Every field in the stack parses: an `ObjectSchema` parse and `objectstack validate` report no ' - + 'issue on an `inlineColumns[].scale` path of a column declaring `type: \'currency\'`. A ' - + 'currency column that carried `scale` no longer declares it, and a diff of the column shows ' - + 'that one line deleted and no key added. `number` columns keep their `scale`, and so does a ' - + 'column declaring no `type` unless it names a `currency` field of the child object (entry ' - + '`inline-grid-column-identity-only-currency-scale-refused`); a column\'s `prefix` is still ' - + 'accepted on a currency column.', - }, - // #20901 — the reach of `inline-grid-column-currency-scale-refused`, extended to - // the column that declares no `type`. The column schema judges only a DECLARED - // type; an identity-only column takes its type from the child field when the - // console hydrates it, and the child field is a fact the stack holds. So - // `defineStack`'s cross-reference check re-parses such a column as the type it - // renders as and reports the column schema's own refusal — no second scale - // rule. D3 only, for the reason the declared-type entry gives: a conversion - // that dropped the key would accept it on every load, the grace window the - // ruling refused. - { - id: 'inline-grid-column-identity-only-currency-scale-refused', - surface: 'object.fields..inlineColumns[].scale and view.form.subforms[].columns[].scale ' - + '(and each formViews entry) on a column that declares NO `type` and whose `name` is a ' - + '`currency` field of the child object — any value, `scale: 0` included. A column declaring ' - + '`type: \'number\'`, and a column over a field of any other type, keep `scale`', - replacement: 'no `scale` on the column. DELETE the key — that is the whole migration: the column ' - + 'renders as a currency column, and a currency amount\'s decimal places are its currency\'s. ' - + 'The currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' - + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' - + 'any other key, and do not add `type: \'number\'` to keep it on a currency amount.', - reason: 'The refusal of `scale` on a currency inline grid column (entry ' - + '`inline-grid-column-currency-scale-refused`, under the maintainer\'s rulings of 2026-09-23, ' - + 'option B, and 2026-09-24, option 乙) reached only a column that DECLARES `type: \'currency\'`, ' - + 'because the column schema cannot see the child field. An identity-only column — the ' - + 'recommended form — over a currency field renders as a currency column all the same, so it ' - + 'published green carrying the refused key, and the console ignored it. `defineStack`\'s ' - + 'cross-reference check, which holds the child object\'s fields, now judges such a column as the ' - + 'type it renders as and refuses it with the column schema\'s own message. Reach: the child ' - + 'object must be declared in the same stack; a column naming no field of it, or a subform whose ' - + 'child object comes from another package, is not judged there. Population measured at the ' - + 'change, on origin/main cb4c31dd52: one authored `inlineColumns` block (the showcase invoice, ' - + 'seven identity-only columns, none carrying `scale`) and zero authored `subforms`. Deployed ' - + 'metadata NOT MEASURED.', - acceptanceCriteria: '`objectstack validate` and `defineStack` report no cross-reference finding on ' - + 'an `inlineColumns[].scale` or `subforms[].columns[].scale` path. A column that carried `scale` ' - + 'over a currency child field no longer declares it, and a diff of the column shows that one line ' - + 'deleted and no key added. Columns over `number` fields, and columns declaring `type: ' - + '\'number\'`, keep their `scale`.', - }, - // #14478 (maintainer ruling B: a duration key carries its unit in its NAME) — - // the D3 entry of the `job-timeout-to-timeout-ms` family (ruling B on #17152: - // one D3 entry per retirement family, even when D2 is lossless). The job half - // of the hook rename, with its own audience: whoever schedules background work. - { - id: 'job-timeout-unit-in-key', - surface: 'job.timeout — the per-attempt time limit of a scheduled job', - replacement: '`timeoutMs` — the same per-attempt limit, in milliseconds, beside the sibling ' - + '`retryPolicy.backoffMs` that already spelled its unit.', - reason: 'The D2 conversion `job-timeout-to-timeout-ms` renames `timeout` to `timeoutMs` in author ' - + 'sources and wherever the chain is replayed, keeping the value, and the rename is lossless: ' - + 'the key always meant milliseconds. The judgment is whether the author knew that. The unit lived ' - + 'only in the description while `retryPolicy.backoffMs` beside it spelled its own, so one ' - + 'job definition carried two conventions; a seconds value copied in — `timeout: 300` for a ' - + 'five-minute job — became a 300-millisecond limit with no error, and the rename carries the ' - + '300 over unchanged. A limit that short fails every attempt and burns the retry budget, ' - + 'which is easy to misread as a flaky job. Only the author can say which unit each value was ' - + 'written in, and code that builds job definitions in TypeScript is outside the chain\'s ' - + 'reach.', - acceptanceCriteria: 'No job carries `timeout`; the parse refuses it with the rename. Every ' - + '`timeoutMs` value is the per-attempt limit the author intends in milliseconds — a job meant ' - + 'to be allowed five minutes reads `timeoutMs: 300000`. An attempt that runs past its ' - + '`timeoutMs` fails with a timeout and is retried under `retryPolicy`, and an attempt that ' - + 'finishes inside it succeeds as before. No code reads or writes `timeout` on a job ' - + 'definition.', - relevantWhen: { kind: 'stack-declares', keys: ['jobs'] }, - }, - { - id: 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', - surface: 'CompatibilityMatrixEntry.estimatedMigrationTime, the migration effort estimate whose ' - + 'unit lived only in a source JSDoc (kernel/plugin-versioning.zod.ts)', - replacement: 'estimatedMigrationTimeHours — rename the key AND state the unit in the ' - + 'describe; the value (hours) is unchanged', - reason: - 'Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type ' - + 'could express (this one in hours, the other in fractional seconds): rename the key and ' - + 'record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of ' - + 'anything already stored. ' - + 'The key said "Estimated migration time in hours" in a source JSDoc and carried no ' - + '.describe() at all — the JSDoc-channel shape (a unit stated only in a source comment ' - + 'the reference page never prints), one def over. The JSDoc ' - + 'stops at the source file; .describe() is what content/docs/references/** renders, so the ' - + 'published page printed a bare number directly beside migrationComplexity, whose scale IS ' - + 'named (trivial/simple/moderate/complex/major). A reader comparing "major" with "40" had ' - + 'no way to know whether 40 was minutes, hours or days. ' - + 'The remedy is BOTH halves, and the second is not optional: renaming alone would leave the ' - + 'two channels that name the unit — the key name and a source comment — agreeing about ' - + 'something the published page does not print, which check:duration-unit-keys refuses as ' - + 'unit-in-jsdoc-not-in-describe (that agreement shape — a unit in the key name and the ' - + 'JSDoc, none in the describe — was ruled an offence on 2026-09-18). So the unit moves ' - + 'INTO the describe and the key name carries it too. ' - + 'HOURS is kept rather than converted to seconds: the value is unchanged, the ruling ' - + 'forbade narrowing, and an effort estimate is authored in hours by the human who writes ' - + 'the plugin manifest. Tombstoned with retiredKey(): CompatibilityMatrixEntrySchema is a ' - + 'plain z.object, not strict, so a bare deletion would strip the old spelling in silence ' - + 'and a manifest would lose its one effort figure with no error anywhere. ' - + 'Why a semantic entry and not a D2 conversion: a compatibility matrix is a plugin-published ' - + 'version manifest — stack.zod.ts declares no collection of them and it is not a registered ' - + 'metadata kind stored as a sys_metadata row — so the chain has no seam that sees one. ' - + 'ADR-0087.', - acceptanceCriteria: - 'Every plugin manifest that declares a migration estimate spells estimatedMigrationTimeHours ' - + 'and every consumer reads that key. Authoring estimatedMigrationTime fails to compile ' - + '(input type `never`) and fails to parse with the rename prescription rather than a bare ' - + 'unrecognized-key error. Behaviour is unchanged: estimatedMigrationTimeHours: 8 is the ' - + 'same eight hours estimatedMigrationTime: 8 was, the key stays optional and stays a bare ' - + 'z.number() — a sweep that added .int() or adopted a closed duration type has narrowed a ' - + 'value the ruling refused to narrow. The migration is proved correct when the reference ' - + 'page for CompatibilityMatrixEntry prints the unit rather than a bare number, and when ' - + 'check:duration-unit-keys reports the key as satisfied rather than listing it unjudged. ' - + 'testCoverage on the same shape is a PERCENTAGE, not a duration, and does not move.', - }, - { - id: 'kernel-context-preview-mode-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - "context.mode — the value 'preview' left the RuntimeMode enum — and " - + 'context.previewMode, the whole PreviewModeConfig block it keyed ' - + '(autoLogin / simulatedRole / simulatedUserName / readOnly / ' - + 'expiresInSeconds / bannerMessage, declared on KernelContext and on the ' - + 'TenantRuntimeContext extension). The exported ' - + 'PreviewModeConfigSchema / PreviewModeConfig / PreviewModeConfigParsed ' - + 'names left with the def', - replacement: - 'nothing declarative — the capability the block described was never ' - + 'implemented by any layer, so there is no working configuration to ' - + 'migrate to. Preview/demo DEPLOYMENTS belong to the deployment layer, ' - + 'which owns auth per-project (ArtifactKernelFactory in the cloud ' - + 'distribution); the OS_PREVIEW_MODE environment variable stays exactly ' - + 'as it is — deployment ROUTING (widening the trusted-origin list for ' - + 'preview subdomains), unrelated to identity. If a preview experience ' - + 'becomes a product capability it re-declares fresh, with the ' - + 'production-posture hard-refusal as the first-landed half (as the ' - + 'removal ruling recorded)', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-27 ' - + '(Option A: remove). The declaration was the sharpest ' - + 'declared-≠-enforced shape on a SECURITY surface: the schema promised ' - + '"bypass auth, simulate admin identity" and named a production guard ' - + '"the runtime must enforce", and NO code path implemented either half. ' - + 'Measured zero consumers in all three repos, each leg with positive ' - + 'controls: objectstack — no runtime branches on the mode; the only ' - + "non-declaration hits for RuntimeMode or mode === 'preview' are the " - + 'schema unit test, a type-alias pin and a measurement-test comment ' - + '(re-verified at dispatch, 2026-08-27, origin/main 15bf9e8). objectui — ' - + 'zero consumers, measured when the removal was ruled. cloud — a census ' - + 'closed 2026-08-26: OS_PREVIEW_MODE there is a routing-only switch ' - + "(the same switch this repository's serve.ts reads only to add " - + "preview-domain wildcards to better-auth's trusted origins); RuntimeMode " - + 'has zero hits repo-wide; the positive control ArtifactKernelFactory (where serve.ts ' - + 'predicted preview auto-login would live if it existed) has 20+ hits ' - + 'and never touches previewMode. An author — very often an AI (ADR-0033) ' - + '— could write the six-key block per the reference docs, parse cleanly, ' - + 'and get no behaviour and no diagnostic, while a reader of the docs had ' - + 'no way to tell the block from the keys that work. Bookkeeping: the ' - + "enum-VALUE half ('preview') puts nothing in RETIRED_KEYS_BY_MAJOR and " - + 'leaves the four surface ratchets untouched by itself — its ' - + "prescription hangs on the enum's own error map (the HookBodyCapability " - + 'precedent); the KEY half is tombstoned with retiredKey() on the ' - + 'non-strict KernelContextSchema (both walked-shape copies registered in ' - + 'RETIRED_KEYS_BY_MAJOR[18]); the DEF half (kernel/PreviewModeConfig, ' - + 'with no carrier left) is registered in RETIRED_DEFS_BY_MAJOR[18]. It ' - + 'is a SEMANTIC entry rather than a D2 conversion because there is no ' - + 'source to rewrite: a kernel context is constructed by host code at ' - + 'boot — not a stack collection member, never stored as a sys_metadata ' - + 'row — so the conversion chain has no seam that would ever see one ' - + '(the kernel/Manifest:loading disposition). ADR-0049 / ADR-0087.', - acceptanceCriteria: - "No host constructs a kernel context with mode: 'preview' or a " - + 'previewMode block: both now fail tsc at the authoring site and fail ' - + 'the parse with the prescription (pinned in ' - + 'kernel/preview-mode-retirement.test.ts). Concretely, check three ' - + "places. (1) Host boot code composing a KernelContext: delete `mode: " - + "'preview'` (mode defaults to production; use development for local " - + 'demo work) and delete any previewMode block — neither ever changed ' - + 'runtime behaviour, so removing them changes nothing observable. ' - + '(2) Code importing PreviewModeConfigSchema, PreviewModeConfig or ' - + 'PreviewModeConfigParsed from @objectstack/spec or @objectstack/spec/' - + 'kernel: every one is TS2305 after upgrade; no working replacement ' - + 'exists to point at, because the vocabulary described nothing real. ' - + "(3) TypeScript branching on the RuntimeMode type (a mode === " - + "'preview' arm, a switch over modes): the arm is now unreachable and " - + 'an exhaustiveness check will fail to compile if it stays — that ' - + 'compile error is the enforced channel for TypeScript consumers. ' - + 'Preview deployment ROUTING is untouched: OS_PREVIEW_MODE and ' - + 'OS_PREVIEW_BASE_DOMAINS keep working exactly as documented ' - + '(deployment routing, never identity).', - }, - { - id: 'kernel-event-bus-retention-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the two event-bus retention windows whose name carried no unit: ' - + 'EventPersistence.retention (kernel/events/handlers.zod.ts) and ' - + 'EventSourcingConfig.retention (kernel/events/queue.zod.ts)', - replacement: 'retentionDays on both — rename each key; both values are unchanged, and so is ' - + 'the 365 default on EventSourcingConfig', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'What makes these two one entry rather than two is the neighbour they share and the one ' - + 'they do not. Both hang off EventBusConfig, so an author configuring a bus met the same ' - + 'bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare ' - + 'retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, ' - + 'not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind ' - + 'of number and are not. Suffixing the duration separates the families at the authoring ' - + 'site; snapshotRetention keeps its name, because a count has no unit to carry. Both are ' - + 'retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in ' - + 'silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event ' - + 'bus construction argument a host builds in code (stack.zod.ts declares no eventBus key ' - + 'and no metadata kind is bound to one), so it is never a stack collection member and ' - + 'never a stored sys_metadata row, and the conversion chain has no seam that would see ' - + 'one. That is what ruling B prescribes for a key that is not authorable metadata, and ' - + 'the disposition the epoch-instant renames on this same kernel took ' - + '(epoch-instant-keys-renamed). ADR-0087.', - acceptanceCriteria: - 'Every EventPersistenceSchema.parse(…) / EventSourcingConfigSchema.parse(…) site and every ' - + 'literal handed to an event bus spells retentionDays; authoring either old spelling fails ' - + 'to compile (input type `never`) and fails to parse with the rename prescription. ' - + 'Behaviour is unchanged in both cases: a bus configured with `retentionDays: 90` keeps ' - + 'events for ninety days exactly as `retention: 90` did, and a config that omits the key ' - + 'still gets the 365 default on EventSourcingConfig. The positive-integer bound rides ' - + 'along with the renamed key, so a zero or negative window is still refused — the pin ' - + 'covering that in kernel/events.test.ts was moved onto the new spelling rather than ' - + 'dropped.', - }, - { - id: 'kernel-health-check-and-hot-reload-durations-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the three plugin-lifecycle durations whose unit lived in a source JSDoc only: ' - + 'PluginHealthCheck.interval, PluginHealthCheck.timeout and HotReloadConfig.debounceDelay ' - + '(kernel/plugin-lifecycle-advanced.zod.ts)', - replacement: 'intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values ' - + '(milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged', - reason: - 'Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the ' - + 'maintainer\'s 「同意」: a duration key whose JSDoc names a unit its describe does not is ' - + 'refused, and the keys in that shape are remediated per file before that refusal lands — ' - + 'the duration-unit rule (the unit lives in the key name or its value type, never in prose ' - + 'alone) executed file by file. Each key named milliseconds in its JSDoc ' - + '— "Health check interval in milliseconds", "Timeout for health check in milliseconds", ' - + '"Debounce delay before reloading (milliseconds)" — and the JSDoc above a key is NOT what ' - + '`content/docs/references/**` renders; `.describe()` is. Measured on this tree by the ' - + 'gate\'s own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] ' - + '— no unit in the name and none in the published prose either. `interval` is the sharpest ' - + 'of the three: its describe carried one unit-shaped token, the parenthetical ' - + '"(default: 30s)", which names SECONDS for a value the schema bounds and defaults in ' - + 'MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, ' - + 'published to the one reader who cannot see the source. The suffix is the family\'s own ' - + 'spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, ' - + 'timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already ' - + 'uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only ' - + 'debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this ' - + 'one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing ' - + 'is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the ' - + 'Ttl-versus-TTL question the sibling round had to settle, there is no competing family ' - + 'spelling to choose between. All three old spellings are retiredKey() tombstones: neither ' - + 'PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would ' - + 'be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the ' - + 'parse succeeded and the removed key was dropped without a word) — and here the ' - + 'stripped value lands on a setInterval ' - + 'period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 ' - + 'conversion: the conversion chain walks a normalized STACK, and neither def is an ' - + 'authorable surface — no metadata-type binding, stack collection or manifest embed carries ' - + 'either, and both are library parameters a host passes to PluginHealthMonitor / ' - + 'HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an ' - + 'implementation when the manifest-side copy was removed, and as a host-driven library ' - + 'when the declarative lifecycle config container was retired) — so a conversion would be a ' - + 'transform with no seam that ever runs. That is the same disposition ' - + 'plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded ' - + 'for keys on these two defs. The registration-time refusals in ' - + 'PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for ' - + 'the audience that does not parse. Measured on 884e8347d: the only in-repo readers are ' - + 'packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in ' - + 'this same change; and the pinned objectui checkout — the pin this repo builds ' - + 'against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names ' - + 'neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and ' - + 'the string debounceDelay each occur 0 times across its 8351 tracked files (0 across the 8281 at 47b1f0bb7, the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the ' - + '8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit ' - + 'controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, ' - + 'which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at ' - + 'f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78, to 17980 and 7523 at 47b1f0bb7 and to 18047 and 7545 at this pin (git grep -o -F, the method that reproduces ' - + 'every earlier count).', - acceptanceCriteria: - 'Every producer and reader of a PluginHealthCheck spells intervalMs and timeoutMs, and every ' - + 'one of a HotReloadConfig spells debounceDelayMs — concretely ' - + 'packages/core/src/health-monitor.ts, whose loop now reads setInterval(..., ' - + 'config.intervalMs) and whose race reads config.timeoutMs, and ' - + 'packages/core/src/hot-reload.ts, whose debounce now reads config.debounceDelayMs. ' - + 'Authoring any old spelling fails to compile (input type `never`) and fails to parse with ' - + 'the rename prescription naming the suffixed key; handing one to registerPlugin on either ' - + 'class is refused with an ADR-0112 VALIDATION_ERROR / 400 before the plugin is stored. ' - + 'Behaviour is unchanged: the same milliseconds, the same 30000 / 5000 / 1000 defaults and ' - + 'the same min bounds (1000 / 100 / 0), and the published describes now name milliseconds. ' - + 'The sibling shutdownTimeout on HotReloadConfig is deliberately NOT renamed with them: its ' - + 'JSDoc reads "Graceful shutdown timeout" and names no unit anywhere, so it is the ' - + 'unit-nowhere shape (no unit in the name or in the published describe, first measured on ' - + 'two tenant timeouts) that the duration-unit gate leaves outside its verdict, not part of ' - + 'this row set.', - }, - { - id: 'kernel-package-lifecycle-durations-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the three package and version lifecycle durations whose name carried no unit: ' - + 'UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), ' - + 'PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and ' - + 'MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)', - replacement: 'estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every ' - + 'value is unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'These three are one entry because they are one story told to one audience — a package ' - + 'being planned, resolved and rolled out — and because the group is precisely where the ' - + 'unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are ' - + 'MILLISECONDS, three adjacent measurements of the same install, two units, none of them ' - + 'named. A reader who learned the unit from one of these three learned it wrongly for the ' - + 'other two. The rollout case adds a second confusion of its own: duration sat directly ' - + 'beside the unit-less percentage, so one block carried a proportion and a span as ' - + 'indistinguishable bare numbers; percentage keeps its name, because a proportion has no ' - + 'time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so ' - + 'a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' - + 'an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a ' - + 'PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport ' - + 'is a version-routing argument a host constructs — none is a stack collection member or ' - + 'a stored sys_metadata row, so the conversion chain has no seam that would see one. That ' - + 'is what ruling B prescribes for a key that is not authorable metadata. ADR-0087.', - acceptanceCriteria: - 'Every IPackageService.planUpgrade() implementation returns estimatedDurationSeconds and ' - + 'every caller reads it under that name; every dependency-resolution producer returns ' - + 'resolvedInMs; every multi-version rollout literal spells durationMs. Authoring any old ' - + 'spelling fails to compile (input type `never`) and fails to parse with the rename ' - + 'prescription. Behaviour is unchanged in every case, and the unit split is the thing to ' - + 'check by hand rather than by search-and-replace: estimatedDurationSeconds: 120 is two ' - + 'MINUTES, while durationMs: 3600000 is one HOUR — a mechanical rename that moved a value ' - + 'between the two would be a thousand-fold error the parse cannot catch, since both ' - + 'bounds accept any non-negative integer.', - }, - { - id: 'kernel-plugin-health-report-durations-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the two plugin health-report metrics whose name carried no unit: ' - + 'PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime ' - + '(kernel/plugin-lifecycle-advanced.zod.ts)', - replacement: 'uptimeMs and responseTimeMs — rename each key; both values are unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'uptime is the case this rule was written for, and this repo had already paid for it in ' - + 'documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a ' - + 'MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a ' - + 'standing paragraph whose whole job was telling the two apart ("metrics.uptime is in ' - + 'milliseconds, unlike the seconds-valued uptime of GET /health above"). A prose warning ' - + 'that has to exist is the symptom; the key name is where the fix belongs. responseTime ' - + 'moves with it because it is a sibling in the same metrics block and because the ' - + 'identical bare name means HOURS on ' - + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. ' - + 'The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a ' - + 'percentage, activeConnections is a count and errorRate is a rate — none is a duration, ' - + 'and this rule reaches durations only. Both are retiredKey() tombstones inside the live ' - + 'metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 ' - + 'conversion: a health report is EMITTED by the monitor each round ' - + '(packages/core/src/health-monitor.ts) and kept in memory — never authored into a ' - + 'metadata document, never a stored sys_metadata row — so the conversion chain has no ' - + 'seam that would see one, the same disposition HealthStatus.timestamp took ' - + '(epoch-instant-keys-renamed). ADR-0087.', - acceptanceCriteria: - 'Every producer of a PluginHealthReport spells uptimeMs and responseTimeMs — concretely ' - + 'packages/core/src/health-monitor.ts, the one production writer, whose metrics block now ' - + 'reads `uptimeMs: Date.now() - startTime`. Every consumer reading result.metrics?.uptime ' - + 'moves to result.metrics?.uptimeMs. Authoring either old spelling fails to compile ' - + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' - + 'unchanged: the value is still Date.now() - startTime in milliseconds, and a report that ' - + 'omits metrics entirely is still valid. ⚠️ Two identically-spelled keys NEARBY are not ' - + 'part of this and must not be renamed with it: the seconds-valued uptime of the ' - + 'GET /health response body, and the free-form HealthStatus.details record, which is a ' - + 'z.record whose contents this rule does not reach.', - }, - { - id: 'kernel-plugin-security-durations-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the four plugin-security durations whose name carried no unit: ' - + 'SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, ' - + 'KernelSecurityPolicy.auditLog.retention and ' - + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime ' - + '(kernel/plugin-security-advanced.zod.ts)', - replacement: 'timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — ' - + 'rename each key; every value is unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'These four are one entry because they are one document — everything here hangs off a ' - + 'PluginSecurityManifest — and because together they are this rule\'s clearest case in ' - + 'the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units ' - + '(milliseconds, seconds, days, hours) and not one of them said so in its name. The ' - + 'sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher ' - + 'promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by ' - + 'the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a ' - + 'day on one kernel shape and a fortieth of a second on another, with nothing at the ' - + 'authoring site to tell them apart. The policy was already inconsistent with itself, ' - + 'too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled ' - + 'windowMs, so one security policy carried both conventions. All four are retiredKey() ' - + 'tombstones inside live blocks whose siblings must keep parsing; no shape here is ' - + 'strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 ' - + 'conversion: a PluginSecurityManifest is a package artifact a publisher ships and a ' - + 'SandboxConfig is the isolation argument a host constructs, so neither is a stack ' - + 'collection member or a stored sys_metadata row and the conversion chain has no seam ' - + 'that would see one. That is what ruling B prescribes for a key that is not authorable ' - + 'metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this ' - + 'same file names its unit only in the JSDoc above it ("Execution timeout in ' - + 'milliseconds"), a channel the gate does not read: it reads `.describe()` and ' - + '`.meta({ description })`, and that key\'s describe ("Maximum execution time") names ' - + 'none. So the gate lists it among the duration-shaped keys without judging it — neither ' - + 'an offender nor an exemption — and it is outside this rename; that JSDoc-channel gap ' - + 'was filed as a finding of its own and is closed for this key by ' - + 'kernel-runtime-config-timeout-unit-in-key. ADR-0087.', - acceptanceCriteria: - 'Every SandboxConfigSchema.parse(…), KernelSecurityPolicySchema.parse(…) and ' - + 'PluginSecurityManifestSchema.parse(…) site, and every literal handed to a plugin ' - + 'sandbox or security manifest, spells the suffixed keys; authoring any old spelling ' - + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' - + 'Behaviour is unchanged in every case: a sandbox given `timeoutMs: 30000` kills a spawned ' - + 'process after thirty seconds exactly as `timeout: 30000` did, a policy with ' - + '`tokenExpirationSeconds: 3600` still expires tokens hourly, `retentionDays: 90` still ' - + 'keeps ninety days of audit log, and `responseTimeHours: 24` still promises a ' - + 'twenty-four-hour disclosure response. Every integer bound rides along with its renamed ' - + 'key. Verify the sharp pair explicitly: a manifest and a health report in the same ' - + 'codebase must now read responseTimeHours and responseTimeMs respectively, and neither ' - + 'accepts the bare name.', - }, - { - id: 'kernel-runtime-config-timeout-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'RuntimeConfig resourceLimits.timeout (kernel/plugin-security-advanced.zod.ts)', - replacement: 'resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged', - reason: - 'This entry COMPLETES what the kernel-directory duration renames deliberately left alone, ' - + 'and the two are meant to be read as a sequence. That round renamed the four ' - + 'plugin-security durations on this same file ' - + '(`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was ' - + 'out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc ' - + 'above it ("Execution timeout in milliseconds"), a channel check:duration-unit-keys does not ' - + 'read — it reads `.describe()` and `.meta({ description })` — and that key\'s describe ' - + '("Maximum execution time") named none, so the gate listed it among the duration-shaped keys ' - + 'without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed ' - + 'as a finding of its own, and that round\'s statement about its own scope stays true. The ' - + 'finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, ' - + 'carrying the maintainer\'s 「同意」, which keeps the refusal of a duration key whose JSDoc ' - + 'names a unit its describe does not, remediates the 21-row JSDoc-channel population per ' - + 'file, and lands that widened gate last, into a tree already clean. So the reader who most ' - + 'needs the unit — the reader of the published reference page, who never sees the source ' - + 'JSDoc — got a bare integer on ' - + 'content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 ' - + 'milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the ' - + 'same stroke, because under the duration-unit rule (the unit lives in the key name or a ' - + 'unit-carrying value, never in the describe prose alone) moving the unit into the ' - + 'describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token ' - + 'SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, ' - + 'the suffixed family spells it that way in every member (29 key-position `timeoutMs` ' - + 'declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no ' - + 'timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned ' - + 'with retiredKey() because the nested resourceLimits object is not strict, so a bare ' - + 'deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a ' - + 'RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest ' - + 'constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection ' - + 'and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on ' - + 'it; the same reading the kernel-directory round recorded for the four keys it renamed. ' - + 'Measured on 146c291943: ' - + 'no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one ' - + 'consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and ' - + 'spells timeout 0 times; outside the zod file and its test the only live occurrences are the ' - + 'generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this ' - + 'rename regenerates. The pinned objectui checkout — this is the pin we build against, ' - + '`.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — ' - + 'spells resourceLimits.timeout 0 times across ' - + '8351 tracked files, against lit controls timeout 1694, RuntimeConfig 337 and resourceLimits ' - + '2 on the same corpus (0 across 8281, and 1674 / 337 / 2, at 47b1f0bb7; 0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and ' - + '1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording ' - + 'that objectui\'s own AppShellRuntimeConfig shares not one key with the spec\'s ' - + 'RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087.', - acceptanceCriteria: - 'Every RuntimeConfigSchema.parse(…) site, and every literal handed to a plugin sandbox as its ' - + 'runtime block, spells resourceLimits.timeoutMs; authoring resourceLimits.timeout fails to ' - + 'compile (input type `never`) and fails to parse with the rename prescription naming ' - + 'timeoutMs and the shape it belongs to. Behaviour is unchanged: a runtime given ' - + 'timeoutMs: 60000 aborts execution after sixty seconds exactly as timeout: 60000 did, and ' - + 'the min(0) integer bound rides along with the renamed key. The published describe reads ' - + '"Maximum execution time in milliseconds". Verify the two same-named keys on this one file ' - + 'apart: RuntimeConfig.resourceLimits.timeout and SandboxConfig.process.timeout both retire ' - + 'to a key spelled timeoutMs, and each refusal names its own shape so an upgrading author ' - + 'edits the right block.', - }, - { - id: 'kernel-startup-orchestrator-durations-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the three startup-orchestration durations whose name carried no unit: ' - + 'StartupOptions.timeout, PluginStartupResult.duration and ' - + 'StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)', - replacement: 'timeoutMs, durationMs and totalDurationMs — rename each key; every value is ' - + 'unchanged, and so is the 30000 default on StartupOptions', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'These three are one entry because they are one boundary: a host passes StartupOptions ' - + 'in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back ' - + 'from the same call. The file already contained its own counter-example — ' - + 'IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter ' - + 'timeoutMs while the options object beside it said timeout, so one contract carried both ' - + 'conventions and the suffixed one was already the honest half. totalDuration is the sum ' - + 'of the per-plugin durations, so the two had to move together or the aggregate would ' - + 'have been spelled unlike its parts. All three are retiredKey() tombstones; none of ' - + 'these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry ' - + 'and not a D2 conversion: StartupOptions is a boot-time call argument and the two result ' - + 'shapes are emitted measurements, so none is ever a stack collection member or a stored ' - + 'sys_metadata row and the conversion chain has no seam that would see one — the same ' - + 'disposition HealthStatus.timestamp took on this very file ' - + '(epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ' - + 'ADR-0087.', - acceptanceCriteria: - 'Host boot code calling orchestrateStartup(plugins, options) spells timeoutMs; every ' - + 'implementation that BUILDS a PluginStartupResult spells durationMs and every one that ' - + 'builds a StartupOrchestrationResult spells totalDurationMs. Authoring any old spelling ' - + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' - + 'Behaviour is unchanged in every case: an orchestrator given `timeoutMs: 5000` waits ' - + 'five seconds per plugin exactly as `timeout: 5000` did, an omitted key still defaults ' - + 'to 30000, and the non-negative bounds ride along with the renamed keys so a negative ' - + 'timeout or a negative duration is still refused. One thing this rename deliberately ' - + 'does NOT touch: packages/core/src/plugin-loader.ts declares its own local ' - + 'PluginStartupResult interface — a different type, carrying startTime rather than any ' - + 'duration key — which is not a reader of this schema and is unchanged.', - }, - // The declared default of `ListView.userActions.editInline` moved from `false` - // to `true` (maintainer ruling 2026-10-10, the v18 line, verbatim: - // 「乙 v18 把 spec 默认翻成 true,editInline: false 变成关法。」). A default move - // reaches every silent document with no parse error and nothing in the - // author's diff, so the upgrade path carries it as a TODO: only the deployment - // can say which list that never declared the key was relying on read-only - // cells. The same class as `view-pagination-page-size-default-50` and - // protocol 12's `rest-requireauth-default-flip`: no key is removed, so there - // is no tombstone and no RETIRED_KEYS_BY_MAJOR row, and no D2 conversion - // exists — a mechanical pass writing `editInline: false` into every silent - // view would preserve the old posture and defeat the ruling, and one writing - // `true` would add nothing the default does not already do. The one-vocabulary - // rule the printed `reason` names is objectui#5144 (a boolean view-level - // `inlineEdit` folds into `editInline`; an explicit `editInline` wins); the - // citation lives here, not in the printed guidance, which carries no tracker - // number. - { - id: 'list-view-edit-inline-default-on', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: 'ui.UserActionsConfig.editInline — an OMITTED editInline inside a list view\'s ' - + 'userActions block, or a page interfaceConfig.userActions block', - replacement: 'nothing, to take the platform default: the list is editable in place by a user who ' - + 'may update the object, under the permission gate that already exists. To keep a list ' - + 'read-only in place — a log, an audit trail, a history, a report roll-up — write it: ' - + '`userActions: { editInline: false }`', - reason: - 'A RULED behaviour change on a default, so there is nothing to rewrite and nothing to ' - + 'refuse: the maintainer\'s ruling of 2026-10-10 made a list view editable in place by ' - + 'default on the v18 line, and the declared default of `UserActionsConfigSchema.editInline` ' - + 'moved from `false` to `true`. A `userActions` block that omits `editInline` now parses to ' - + '`true` — the renderer offers the inline-edit toggle, and a user who may `update` the object ' - + 'edits a cell in place with the field\'s type-aware widget; a user without `update` sees no ' - + 'toggle, because the permission gate is untouched. A view with no `userActions` block at all ' - + 'parses with none on either side; the renderer reads an absent key as the spec default. The ' - + 'one-vocabulary rule objectui ruled for the fold stands: a boolean view-level `inlineEdit` folds into ' - + '`editInline` (`true` reads on and opens the grid in edit mode), and an explicit `editInline` ' - + 'wins. Not losslessly convertible because the question is intent, not text: only the ' - + 'deployment knows which silent lists were relying on read-only cells. The accept set is ' - + 'unchanged — a boolean — and every authored `editInline` parses exactly as before. A ' - + 'document that serialised an earlier parse (an `os compile` artifact) carries a written ' - + '`false` and stays read-only in place; recompile it, or delete the key.', - acceptanceCriteria: - 'An empty `userActions` block parses to `editInline: true`, and a list view carrying ' - + '`userActions: {}` parses to `userActions.editInline` true; an authored ' - + '`userActions: { editInline: false }` still parses to `false`; a view-level `inlineEdit: true` ' - + 'still parses as before, with no `userActions` block materialised. In the console, a grid view ' - + 'that declares neither key offers the inline-edit toggle to a user with `update` on the object ' - + 'and not to a user without it; `userActions: { editInline: false }` removes it; ' - + '`inlineEdit: true` still opens the grid in edit mode. Every list that must stay read-only in ' - + 'place declares `userActions: { editInline: false }` and shows no toggle.', - }, - { - id: 'list-view-navigation-view-retired', - surface: 'view.list.navigation.view', - replacement: - 'page assignment — assign a `record` page to the object and let `isDefault` pick the one ' - + 'that opens. That is the machinery that resolves a detail layout by name; a list view\'s ' - + '`navigation` block decides only HOW the detail is surfaced (`mode`, `size`, ' - + '`preventNavigation`, `openNewTab`, `width`), and every one of those keys is unchanged', - reason: - 'DECLARED, CONSUMED, AND WRONG — which is why this is a semantic TODO rather than a ' - + 'mechanical strip. The key\'s describe promised "the form view to use for details" and ' - + 'no layer from spec to console ever resolved a view by name. Its only read in the ' - + 'shipped console put the value in the SECOND argument of `onNavigate`, the slot that ' - + 'otherwise carries the navigation-MODE token: an authored `view` did not select a view, ' - + 'it SUBSTITUTED for the mode. At least one consumer in the same bundle reads that ' - + 'argument against a closed two-value vocabulary (`edit` / `view`), so any other authored ' - + 'value matched neither branch — invisible on grids whose handler takes one argument, a ' - + 'dead row click on the ones that do not. The enumeration behind the removal was ' - + 'exhaustive rather than sampled: every `.view` property read in the bundle (three) and ' - + 'every `formViews` read, and NO read anywhere is keyed by an authored view name, so ' - + 'there is no path by which this key or any sibling could have resolved one. ' - + 'ADR-0049 enforce-or-remove; zero authored instances in this repo and the one external ' - + 'author removed its occurrence, so the pull that would justify ENFORCE is zero. ' - + 'A mechanical D2 strip was weighed and declined with the direction: deleting the key ' - + 'silently discards the author\'s actual intent — "open the detail in THIS layout" — and ' - + 'leaves no record of which list view carried it, which is exactly the judgement a ' - + 'semantic TODO exists to hand back. Should "open the detail in a chosen view" ever be ' - + 'pulled, it belongs to the page-assignment machinery (`record` pages, `isDefault`), not ' - + 'to a string on a list view.', - acceptanceCriteria: - 'For EACH list view that declared `navigation.view` — the TODO names the surface, you name ' - + 'the view: delete the key from that view\'s `navigation` block, then decide whether the ' - + 'detail layout it asked for was ever actually delivered. It was not, so nothing regresses ' - + 'by deleting it: confirm the record detail opens exactly as it did before (the surviving ' - + '`mode` and `size` decide that, and both are untouched). If the named layout is one you ' - + 'still want, publish it as a `record` page on that object and mark the one that should ' - + 'open `isDefault`. Done when no `navigation` block in your sources carries `view`; a block ' - + 'that still does fails to parse with the removal prescription, at `ListViewSchema`, at ' - + '`ObjectListViewSchema` and at the `PUT /api/v1/meta/view` overlay door, and authoring it ' - + 'is a `tsc` error at the call site. ⚠️ Nothing else in the block moves — a `navigation` ' - + 'that carries only live keys (`{ mode: \'drawer\', size: \'lg\' }`) parses byte-for-byte ' - + 'as it did before.', - }, - // #17063 (maintainer ruling, decision batch #107 item 1) — the D3 entry of the - // `view-page-mount-removed` family (ruling B on #17152: one D3 entry per - // retirement family, even when D2 is lossless). Registered keys: - // `ui/ListView:pageName` and `ui/ObjectListView:pageName`, plus the `'page'` - // value of the list-view `type` enum. The strip lands every view on what it - // already drew — an empty grid — which is exactly why it cannot be the end of - // the migration. - { - id: 'list-view-page-mount-retired', - surface: 'view.list / view.listViews.* — the list-view type page and its pageName binding', - replacement: 'Publish the page and give the app a navigation item for it — ' - + '`{ type: \'page\', pageName: \'\' }` under the app `navigation`, the page mount ' - + 'that has always rendered. Keep the list view only if it should draw rows of its object, as ' - + 'a `grid` or one of its siblings.', - reason: 'The D2 conversion `view-page-mount-removed` deletes `type: \'page\'` (the schema default ' - + 'then parses the view as `grid`) and `pageName` from every view payload in `stack.views[]`, ' - + 'in all three persisted spellings, and the delete is lossless in pixels: no renderer ever ' - + 'routed the page member, so a page view has always drawn an empty grid, and it still does. ' - + 'The author wanted a PAGE in front of users at that place in the app, and the view never ' - + 'showed it. Whether to reach the page through a navigation item, and whether the now-plain ' - + 'grid view should exist at all, are the author\'s decisions. One boundary is theirs by ' - + 'construction: a page mount declared under `objects[].listViews` is reached by no ' - + 'conversion, so it is refused at its own door until edited by hand.', - acceptanceCriteria: 'No list view in `stack.views[]` or in any object `listViews` map declares ' - + '`type: \'page\'` or `pageName`; the parse refuses both by name. For each view that did: the ' - + 'page it named is reachable from the app navigation and renders when opened, and the list ' - + 'view either draws rows of its object or has been deleted. No navigation entry points at a ' - + 'view that now renders an empty grid by accident.', - }, - // #17053 (objectui#8221 decision batch #77, option B: one sort orthography - // platform-wide) — the D3 entry of the `list-view-sort-string-clause-to-array` - // family (ruling B on #17152: one D3 entry per retirement family, even when D2 - // is lossless). The rewrite is lossless for every clause in the grammar it - // knows; the clauses it cannot lower, and the collection it cannot reach, are - // the author's. - { - id: 'list-view-sort-string-clause-retired', - surface: 'view.list.sort / view.listViews.*.sort — the bare string sort clause', - replacement: 'The structured array, `sort: [{ field, order }]`, with `order` written out — a bare ' - + 'field name meant ascending — and one entry per key of a comma-separated clause, in the same ' - + 'order.', - reason: 'The D2 conversion `list-view-sort-string-clause-to-array` rewrites a string clause in the ' - + 'grammar the wire normalizer splits on — `\'created_at desc\'`, a bare field name, a ' - + 'comma-separated list — into the array, losslessly, across every view payload in ' - + '`stack.views[]`. Two cases are deliberately left for the author. A string that does NOT ' - + 'parse as that grammar — above all the leading-minus dialect, `\'-created_at\'` — is left ' - + 'alone and refused at the door, because guessing a direction would invent an ordering the ' - + 'author never wrote. And a clause under `objects[].listViews` is reached by no conversion, ' - + 'so it is refused at its own door until rewritten by hand. The clause was minted by the ' - + 'schema and refused by the renderer that lowers it into a query, so a view carrying one may ' - + 'already have been failing to load; which order the author meant is theirs to state.', - acceptanceCriteria: 'No list view in `stack.views[]` or in any object `listViews` map carries a ' - + 'string `sort`; the parse refuses one with the rewrite prescription. Every rewritten array ' - + 'names fields that exist on the view\'s object with the direction the author intends, and ' - + 'the view loads — rather than failing at the renderer — with its rows in that order.', - }, - // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the - // maintainer's #18900 criterion) — the D3 entry of the `view-list-tabs-removed` - // family (ruling B on #17152: one D3 entry per retirement family, even when D2 - // is lossless). Registered keys: `ui/ListView:tabs` and `ui/ObjectListView:tabs`. - // The strip changes no screen — nothing ever drew the tabs — which is exactly - // why it cannot be the end of the migration: what the author wanted was named - // presets, and those are a `listViews` entry each. - { - id: 'list-view-tabs-retired', - surface: 'view.list.tabs / view.listViews.*.tabs — the list view\'s own tab definitions', - replacement: 'One named list view per tab, under the object\'s `listViews` — the saved-view ' - + 'switcher above the object\'s records renders every entry as a tab. The tab\'s `name` ' - + 'becomes the entry\'s key, its `label` the entry\'s `label`, and its `filter` rules join the ' - + 'view\'s own `filter` on that entry, beside the `columns` the tab should show. A tab whose ' - + '`view` already named a list view needs nothing more.', - reason: 'The D2 conversion `view-list-tabs-removed` deletes `tabs` from every list payload in ' - + '`stack.views[]`, in all three persisted spellings, and the delete is lossless in pixels: no ' - + 'renderer ever mounted a tab bar for the key, so a view that declared tabs has always drawn ' - + 'without them, and it still does. The judgment the conversion cannot make is the author\'s ' - + 'intent: each tab was a named preset the author wanted end users to switch to, and the ' - + 'platform delivers that as a named list view, not as a sub-key of one. Which tabs deserve an ' - + 'entry, what each should filter and show, and whether the switcher already lists an ' - + 'equivalent, are the author\'s decisions. The tab keys with no list-view counterpart — ' - + '`icon`, `order`, `pinned`, `isDefault`, `visible` — never had an effect either. One ' - + 'boundary is the author\'s by construction: tabs declared under `objects[].listViews` are ' - + 'reached by no conversion, so such an object is refused at its own door until edited by hand.', - acceptanceCriteria: 'No list view in `stack.views[]` or in any object `listViews` map declares ' - + '`tabs`; the parse refuses the key by name at every list-view door. For each view that did: ' - + 'every tab the author still wants is a `listViews` entry with its own `label`, `filter` and ' - + '`columns`, and it appears as a tab in the switcher above the object\'s records and shows ' - + 'the rows its filter selects; a tab nobody wants is simply gone. No page-level ' - + '`userFilters` preset bar changes — that `tabs` is a different key, and it stays.', - }, - { - id: 'logging-durations-unit-in-key', - surface: 'HttpDestinationConfig `batch.flushInterval` / `retry.initialDelay` / `timeout` and ' - + 'LoggingConfig `buffer.flushInterval` (system/logging.zod.ts)', - replacement: '`batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / ' - + '`timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` ' - + '(default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged', - reason: - 'Maintainer ruling A, 2026-09-11: the gate that reads a duration key\'s JSDoc lands last, ' - + 'after its offenders are fixed file by file — so this entry executes, per file, the rule ' - + 'that a duration number key carries its unit in its name. All four keys named ' - + 'milliseconds in a ' - + 'source JSDoc — "Flush interval in milliseconds", "Initial retry delay in milliseconds", ' - + '"Timeout in milliseconds" — and the JSDoc above a key is not what ' - + '`content/docs/references/**` renders; `.describe()` is, and none of the four carried one at ' - + 'all. Measured by the `check:duration-unit-keys` census on this tree before the change, all ' - + 'four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, ' - + 'so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 ' - + 'and nothing on the page decided milliseconds from seconds. Under that rule\'s gate, moving ' - + 'the unit into the describe alone is itself a violation (unit in prose, none in the name), ' - + 'so each key is renamed and given the describe it never had in the same stroke. ' - + '⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two ' - + 'different defaults — 5000 on the HTTP destination\'s batch and 1000 on the logging buffer ' - + '— so they are two keys, each with its own tombstone and its own registered row; the ' - + 'prescriptions name their def so a reader who lands on one is not sent to the other. The ' - + '`Ms` suffix is the family\'s own spelling, counted in key position on this tree: 272 ' - + '`*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing ' - + 'unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this ' - + 'repo (MongoDB\'s `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres\'s ' - + '`idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the ' - + '`Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to ' - + 'choose between. All three target spellings were already attested as key-position `*.zod.ts` ' - + 'declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, ' - + 'same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` ' - + 'rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` ' - + 'itself and its nested `batch` and `retry`, and `LoggingConfig`\'s nested `buffer` — is ' - + '`.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic ' - + 'entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither ' - + '`LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in ' - + '`packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam ' - + 'that runs on an authored logging document — the same reading ' - + '`tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: ' - + 'no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` ' - + 'and its test the only occurrences are the generated rows in ' - + '`content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned ' - + 'objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells ' - + '`flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 ' - + 'across its 8351 tracked files, against lit controls `useState` 2630 and `timeout` 1694 on ' - + 'the same corpus (all four 0 across 8281, against 2630 and 1674, at 47b1f0bb7, 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, ' - + 'against 2391 and 1096, at f8a9d0fb0, and 0 across ' - + '8303, against 2389 and 1086, at 62597c588).', - acceptanceCriteria: - 'Every HTTP log destination spells `batch.flushIntervalMs`, `retry.initialDelayMs` and ' - + '`timeoutMs`, and every logging buffer spells `buffer.flushIntervalMs`; authoring any of the ' - + 'four retired spellings fails to compile and fails to parse with a rename prescription ' - + 'naming the suffixed key and its def; the parsed defaults are 5000 / 1000 / 30000 / 1000 as ' - + 'before; and each published describe names milliseconds.', - }, - // ADR-0131 D6 (C5, stage S3) — a platform-capability RETIREMENT, not a spec-key - // retirement: `systemPermissions` is a list of plain strings, so no authorable - // key moves, nothing lands in RETIRED_KEYS_BY_MAJOR and no D2 conversion exists - // to pair with. Measured: a permission set naming the capability still parses - // and loads (the schema accepts any string, and an undeclared name is - // back-derived as a capability the stack declares); only the grant goes inert. - { - id: 'manage-org-presentation-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in ' - + '@objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported ' - + 'by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its ' - + 'holders to org-scoped writes of the five org-overridable types through the /meta item doors', - replacement: - 'grant `manage_metadata` to whoever must author views, dashboards, reports, translations or ' - + 'email templates through Studio or `PUT /api/v1/meta//`; such a write now lands ' - + 'environment-wide (`organization_id` NULL) and is served to every organization of the ' - + 'deployment. There is no organization-bounded authoring capability: delete ' - + '`manage_org_presentation` from every permission set\'s `systemPermissions`, and delete any ' - + 'import of `ORG_PRESENTATION_AUTHORING_CAPABILITY`. `metaWriteCapabilityVerdict` takes ' - + '`{ isSystem, systemPermissions, operation }`: drop the `canonicalType` and ' - + '`activeOrganizationId` members from the call', - reason: - 'ADR-0131 D6 retires the per-organization overlay axis, and the /meta doors stop carrying an ' - + 'organization into a metadata write (the companion entry meta-doors-organization-scope-retired). ' - + 'The capability admitted an organization admin to exactly the writes those doors threaded ' - + 'into the admin\'s own organization; with no organization threaded, keeping it would have ' - + 'admitted its holders to environment-wide authoring, which is the reach of manage_metadata ' - + 'and a wider one than the capability ever granted. It was granted by no shipped permission ' - + 'set, so a deployment that never granted it by hand observes nothing.', - acceptanceCriteria: - 'PLATFORM_CAPABILITY_NAMES no longer holds manage_org_presentation, so the authoring lint ' - + 'resolves the name only where a stack itself declares or grants it. A caller holding ' - + 'manage_org_presentation and not manage_metadata is answered 403 on PUT, DELETE, publish ' - + 'and rollback of /api/v1/meta// (FORBIDDEN on the REST doors, PERMISSION_DENIED ' - + 'on the dispatcher), whatever its active organization. A persisted permission set naming the ' - + 'capability still loads and its other grants still apply. The sys_capability row the ' - + 'platform seeded for it earlier is not pruned (the seeder upserts only); it names a ' - + 'capability nothing consults, and an operator may delete it in Setup.', - }, - // A rename is the one thing this entry deliberately does NOT prescribe - // mechanically. An id is an IDENTITY: it is what the registry addresses the - // package by (`manifest_id`), what an installed row is keyed on, and what a - // dependent declares. Rewriting `com.acme.my_app` to `com.acme.my-app` on the - // author's behalf would silently make the artifact a DIFFERENT package from the - // one already installed somewhere — so this is a structured TODO the human - // answers, not a D2 conversion. - { - id: 'manifest-id-reverse-domain-required', - surface: 'manifest.id — `ObjectStackManifest.id`, i.e. `defineStack({ manifest: { id } })` ' - + 'and the `id:` key of a package manifest — and its registry face ' - + '`PackageSchema.manifestId` (`marketplace/package.zod.ts`)', - replacement: 'a reverse-domain identifier matching `MANIFEST_ID_PATTERN` ' - + '(`kernel/manifest.zod.ts`): two or more lowercase dot-separated segments of letters, ' - + 'digits and inner hyphens, each opening with a letter or a digit, never a hyphen — ' - + '`com.acme.crm`, `org.apache.superset`. ⛔ Underscores are not admitted, so `manifest.namespace` is ' - + 'never a legal id and never a legal last segment of one: `com.acme.my_app` becomes ' - + '`com.acme.my-app`. A bare word gains a prefix: `blank` becomes `com.example.blank`. ' - + 'The refusal carries the repaired value it has already checked against the pattern, so ' - + 'the prescription is in the error text, not only here.', - reason: - 'Two declarations named one identity and drifted. `PackageSchema.manifestId` — what the ' - + 'registry stores and addresses a package by — has always carried the reverse-domain ' - + 'regex; `ManifestSchema.id`, the key an author actually writes, was `z.string()` and ' - + 'accepted anything. So a package scaffolded, validated, built and booted with an id the ' - + 'publish path would refuse, and the author met the rule for the first time at the one ' - + 'moment it was most expensive to meet. The two sites now reference ONE exported ' - + 'constant, which is what makes a future divergence a visible edit rather than a silent ' - + 'one. Why the rule holds for a package nobody publishes: the TSDoc\'s own words are ' - + '"unique across the entire ecosystem" — an id names the artifact for the ecosystem it ' - + 'may one day join, so a private app is named under the same rule as a listed one. ' - + 'Why it is a D3 semantic TODO and not a D2 conversion: the value IS the identity. A ' - + 'mechanical rewrite would re-point every install, dependency declaration and stored ' - + '`manifest_id` row at a package that, to the registry, is a different one — and the ' - + 'safe choice between "rename the package" and "keep the id and change nothing that ' - + 'depends on it" is not derivable from the metadata.', - acceptanceCriteria: - 'Every `manifest.id` you author matches the pattern, and `defineStack` / ' - + '`objectstack validate` report no `manifest.id` finding. Prove the rename side ' - + 'separately, because the schema cannot: for each id you changed, confirm nothing still ' - + 'addresses the old value — no installed row, no `dependencies` entry in another ' - + 'package\'s manifest, and no registry listing. If any does, the correct answer is a ' - + 'deliberate republish under the new id, not an in-place edit.', - }, - // ADR-0049 enforce-or-remove, ruled option A on the plugin-permissions parent - // card (2026-08-30): the legacy flat-list arm of a package manifest's - // `permissions` leaves, and the structured ADR-0025 §3.2 block is the only - // form. The list's deletion is mechanical (the D2 conversion - // `manifest-permissions-string-list-removed`); what each dropped string meant - // in terms of services, hooks, network hosts and filesystem paths is not, and - // that judgement is what this entry carries. - { - id: 'manifest-permissions-string-list-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. - surface: - 'manifest.permissions as a flat list of permission strings (and packages[].manifest.permissions) — ' - + 'the legacy arm of ManifestPermissionsSchema left; the schema is now the structured plugin ' - + 'permission block alone', - replacement: - 'the structured block `permissions: { services, hooks, network, fs }` — each a list naming the ' - + 'platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it ' - + 'reaches and the filesystem paths it touches; or no `permissions` key when the plugin needs none', - reason: - 'ADR-0049 enforce-or-remove: the flat list was parsed and never acted on. The loader registers the ' - + 'consented grant set on the environment artifact with the permission enforcer, never the ' - + 'manifest\'s request, so a list granted, refused and requested nothing at load; the only code that ' - + 'met one was two reports saying it had been skipped. The D2 conversion ' - + '`manifest-permissions-string-list-removed` deletes the list from existing sources and stored ' - + 'artifacts, losslessly for every load. What it cannot do is translate: a capability string such as ' - + '`system.user.read` names no service, hook, host or path, so whether the plugin needs a grant at ' - + 'all, and which, is the author\'s judgement. Authoring now refuses a list at parse with that ' - + 'prescription, and TypeScript rejects it', - acceptanceCriteria: - 'No manifest — the stack\'s own, any packages[] entry, any objectstack.plugin.json — declares ' - + '`permissions` as a list; a list is refused at parse with its prescription, and TypeScript rejects ' - + 'it. Every plugin whose dropped list stood for a real need declares it in the structured block, ' - + 'naming each service, hook, network host and filesystem path it touches, and `os plugin build` ' - + 'parses the manifest clean. A plugin that needs no grant declares no `permissions` key.', - conversionIds: ['manifest-permissions-string-list-removed'], - }, - // A D3 semantic TODO, not a D2 conversion, and the reason is the widening half. - // The mechanical part of this move is trivial in one direction — `01.1.1` - // becomes `1.1.1` — but the chain cannot know whether an author who wrote a - // leading zero meant the padded spelling of that version or a different one, - // and a version IS how a release is addressed: rewriting it would re-point - // whatever already installed the old string. The widening half needs no edit at - // all, which is why this entry prescribes a check rather than a rewrite. - { - id: 'manifest-version-semver-2-0-0', - surface: 'manifest.version — `ObjectStackManifest.version`, i.e. the `version:` key of ' - + '`defineStack({ manifest })` and of a package manifest — and its three sibling ' - + 'declarations `MetadataPluginManifestSchema.version` (`kernel/metadata-plugin.zod.ts`), ' - + '`PluginRegistryEntrySchema.version` (`kernel/plugin-registry.zod.ts`) and ' - + '`PluginMetadataSchema.version` (`kernel/plugin-validator.zod.ts`), plus the ' - + '`PATCH /api/v1/packages/:id` door in `@objectstack/runtime`', - replacement: 'a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` ' - + '(`kernel/version-grammar.ts`). ⭐ This is a WIDENING for almost every author: ' - + 'prerelease and build suffixes are accepted for the first time, so `2.0.0-beta.1`, ' - + '`17.0.0-rc.5`, `1.0.0+20230101` and `1.0.0-rc.1+exp.sha.5114f85` now pass a key that ' - + 'refused all of them, and identifiers may carry either ASCII case. ⛔ The one thing ' - + 'that stops being accepted is a leading zero in the numeric core: `01.1.1` becomes ' - + '`1.1.1` — or a different version, if the padded form was standing in for one.', - reason: - 'One concept — "the version of a package or plugin" — was judged by four different ' - + 'grammars across ten carriers in two repositories, and the strictest of them, this ' - + 'one, refused `2.0.0-beta.1`: the exact string a sibling declaration documented as an ' - + 'example of itself. The contradiction was observable between doors on the same ' - + 'resource, not merely between schema files — the build step refused a prerelease the ' - + 'publish door accepted, while the install door parsed nothing at all. The maintainer ' - + 'ruled one canon, and named it after the standard the repository already claimed in ' - + "this key's own `.describe()`, in the generated reference docs, in the Studio help " - + 'text and in two ADRs: SemVer 2.0.0. Why the narrowing is not losslessly convertible: ' - + 'a version is an identity. `01.1.1` and `1.1.1` are the same release to a reader and ' - + 'different strings to every registry row, dependency declaration and installed ' - + 'artifact that stored one of them, and which of the two an author meant is not ' - + 'derivable from the metadata.', - acceptanceCriteria: - 'Every `manifest.version` you author is a SemVer 2.0.0 string, and `defineStack` / ' - + '`objectstack validate` / `os plugin build` report no `version` finding. The only ' - + 'values that need touching are those with a leading zero in a numeric segment — the ' - + 'in-repo authoring corpus measured ZERO of them, so most consumers have nothing to ' - + 'change. For each one you do change, confirm nothing still addresses the old string: ' - + 'no installed row, no `dependencies` range in another manifest, no registry listing. ' - + 'Prove the widening separately and cheaply: a prerelease version that used to be ' - + 'refused at build time now builds.', - }, - // ADR-0049 enforce-or-remove — the D3 entry of the - // `mapping-lookup-params-removed` family, which landed in commit 15d58dbf1: - // the import path never read the four lookup steering params, so they were - // retired and the conversion strips them. One D3 entry per retirement family, - // even when D2 is lossless (ruling B on #17152). The strip preserves observed - // import behaviour exactly; the judgment it leaves is whether the author's - // import was ever doing what the four keys said. - { - id: 'mapping-lookup-params-retired', - surface: 'mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate — the ' - + 'per-entry reference-resolution keys of a lookup mapping', - replacement: 'Nothing on the mapping. A `lookup` entry copies the cell through, and reference ' - + 'resolution runs afterwards off the TARGET field\'s own metadata: its `reference` names the ' - + 'object searched, and the cell is matched as a display value (a name, an email or a record ' - + 'id). Records a row points at must exist before the import runs.', - reason: 'The D2 conversion `mapping-lookup-params-removed` deletes the four keys from every ' - + 'mapping entry\'s params, and the delete is lossless: the import path never read them, so ' - + 'stripping them changes no imported row. The judgment is about what the author believed. ' - + '`autoCreate` read as "create the referenced record when nothing matches", and nothing was ' - + 'ever created — an unresolved cell fails its row with `import_reference_not_found`, with or ' - + 'without the key. An import pipeline built on that belief has been losing those rows, and ' - + 'now needs the referenced records seeded first. `object`, `fromField` and `toField` read as ' - + 'the target and the matching columns, and were never consulted: where they named something ' - + 'OTHER than the target field\'s own `reference` or a column the resolver matches on, the rows ' - + 'were linked by the field\'s metadata, not by the mapping — and only the author knows which ' - + 'one they meant.', - acceptanceCriteria: 'No mapping entry carries the four keys; the parse refuses them. For each ' - + 'mapping that carried them: the target field\'s `reference` names the object the author meant ' - + 'the rows to link to, and a dry run of a representative file resolves every reference cell ' - + '(no `import_reference_not_found` row) — or the missing referenced records are created by a ' - + 'step that runs before the import, since the import itself never creates them. Row counts ' - + 'and links match the pre-upgrade import of the same file.', - relevantWhen: { kind: 'stack-declares', keys: ['mappings'] }, - }, - // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its - // unit in its NAME) — the D3 entry of the - // `memory-persistence-auto-save-interval-to-ms` family (ruling B on #17152: one - // D3 entry per retirement family, even when D2 is lossless). Registered keys: - // `data/FilePersistenceConfig:autoSaveInterval` and - // `data/AutoPersistenceConfig:autoSaveInterval` — both arms of the persistence - // union, because both forward the same value to the same file adapter. - { - id: 'memory-persistence-auto-save-interval-unit-in-key', - surface: 'datasource.config.persistence.autoSaveInterval on the memory driver — the file and ' - + 'auto persistence arms', - replacement: '`autoSaveIntervalMs` — the same interval, in milliseconds, on both arms; the ' - + 'minimum of 100 and the file arm\'s 2000 default are unchanged.', - reason: 'The D2 conversion `memory-persistence-auto-save-interval-to-ms` renames the key on both ' - + 'persistence arms of every memory-driver datasource and on stored datasource rows, keeping the ' - + 'value, and leaves a string persistence mode, a custom adapter and every other driver\'s ' - + 'config alone; the rename is lossless because the key always meant milliseconds. The ' - + 'judgment is whether each value was written in that unit. Nothing in the old name said so, ' - + 'and the auto arm\'s description named no unit at all. A seconds value below 100 was already ' - + 'refused by the bound, but one above it was not — `autoSaveInterval: 300` meant as five ' - + 'minutes saved every 300 milliseconds, and the rename keeps 300. The interval also bounds how ' - + 'much in-memory data a crash can lose, so the author is choosing a durability trade-off, not ' - + 'only a number.', - acceptanceCriteria: 'No memory-driver datasource carries `autoSaveInterval` on either arm; the ' - + 'parse refuses it with the rename. Every `autoSaveIntervalMs` value is the interval the author ' - + 'intends in milliseconds — a store meant to save every five seconds reads ' - + '`autoSaveIntervalMs: 5000`. With file persistence on, a write followed by waiting longer than ' - + 'that interval leaves the change in the persisted file, and the author accepts losing at most ' - + 'that interval of writes on a crash.', - }, - { - id: 'memory-persistence-placeholder-refused', - surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' + - 'override) and `persistence.key` (localStorage and the `auto` override) — values ' + - 'containing `${…}` placeholder syntax', - replacement: 'the literal path or key. For environment-specific destinations, leave the ' + - 'key unset and let the shared datasource factory scope the default per datasource, or ' + - 'compute the config value in code before it enters `defineStack`', - reason: - 'The unresolved-placeholder defect one surface over from the datasource connection keys, ' + - 'where it is already refused: a `${…}` placeholder in memory persistence config ' + - 'is resolved by NOTHING — the driver would create and write a literal `./${DATA_DIR}/…` ' + - 'path, or write under the literal placeholder-bearing localStorage key, so the dump ' + - 'lands in a wrongly-named location with no error naming the unresolved placeholder ' + - '(authored under the same false belief the 2026-08-13 ruling closes: placeholder syntax ' + - 'in connection-material keys is refused at publish, because nothing resolves it). These ' + - 'two keys ' + - 'are config-material like the connection keys, so the parent adjudication applies with ' + - 'its reason intact; the memory driver\'s `initialData` stays deliberately UNJUDGED — it ' + - 'carries arbitrary record values, where a literal `${…}` may be legitimate data. There ' + - 'is no mechanical rewrite: the placeholder names a value that exists only in the ' + - 'author\'s intended deployment environment, which a source-file transform cannot know.', - acceptanceCriteria: - 'Every memory datasource parses with no `${…}` span in `persistence.path` or ' + - '`persistence.key`; `initialData` record values containing literal `${…}` keep parsing ' + - 'byte-identically.', - }, - // ADR-0131 D6 (C5, stage S3) — a runtime-door narrowing with no authorable key: - // the /meta doors of both transports stop carrying the caller's organization - // into a metadata write or read. Registered because legacy organization-scoped - // rows stop being served, which an operator must be told (stage 0's F10 of the - // retirement card: a single-posture deployment observes it too). - { - id: 'meta-doors-organization-scope-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the organization the /meta doors of @objectstack/rest and of the runtime dispatcher thread ' - + 'into a metadata write (PUT, DELETE, publish, rollback) or read (item, list, layered, ' - + 'published, drafts, history, audit, diff, diagnostics, references) of the five ' - + 'org-overridable types; the organizationIdForMetaWrite export of @objectstack/metadata-core; ' - + 'the metaReadOrganizationId export of @objectstack/rest; and the Default Organization read ' - + 'of the email-template boot sweep in @objectstack/plugin-email', - replacement: - 'nothing to write: every `/meta` write now lands environment-wide (`organization_id` NULL) ' - + 'and every `/meta` read resolves environment → code, for every caller and every tenancy ' - + 'posture. Delete any import of `organizationIdForMetaWrite` (a write carries no ' - + 'organization) or `metaReadOrganizationId` (a read carries none either). A caller that ' - + 'needs the vetted organization of a request for another purpose still reads ' - + '`metaCallerOrganizationId`', - reason: - 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by ' - + 'Studio, by the cloud build agent or by a template install belongs to the whole deployment. ' - + 'The doors threaded the active organization for view, dashboard, report, translation and ' - + 'email_template, so under the single posture, where the Default Organization is active, ' - + 'every Studio save of those types was stored under that organization. The read and the ' - + 'write flip together: reads-first would hide the organization rows the doors still wrote, ' - + 'writes-first would let those rows shadow new environment saves.', - acceptanceCriteria: - 'A manage_metadata caller with an active organization saves a view through PUT ' - + '/api/v1/meta/view/ on either transport: the stored row carries organization_id ' - + 'NULL and GET serves it. An organization-scoped row stored before this release, including ' - + 'a single-posture Studio save filed under the Default Organization, is no longer served by ' - + 'any /meta read (the environment row or the code definition is), nor projected by the ' - + 'email-template boot sweep; it stays in sys_metadata untouched until the promotion ' - + 'ceremony (ADR-0131 C7) carries it to the environment layer. Re-save such an item in ' - + 'Studio to make the edit live on the /meta doors now. Public forms are the exception: ' - + 'until that ceremony the anonymous form doors read a form view in the Default Organization ' - + 'and prefer its overlay for the form\'s body, while a withdrawal in either layer closes the ' - + 'form, fail-closed. So a legacy organization overlay of a public form keeps serving its ' - + 'body there: a Studio re-save of that body (an environment row) does not change the body ' - + 'the public form serves, and a Studio withdrawal (an environment row) still closes it.', - }, - { - id: 'metadata-changed-event-payload-retired', - surface: - 'kernel.cluster metadata change event payload (`MetadataChangedEventPayloadSchema` ' - + 'in kernel/cluster.zod.ts — 2 defs, 4 exported names: ' - + '`MetadataChangedEventPayloadSchema`, `MetadataChangedEventPayload`, ' - + '`MetadataChangeOperationSchema`, `MetadataChangeOperation`)', - replacement: - 'Nothing to migrate to, because nothing ever emitted or consumed it. The ' - + 'cluster invalidation channels that actually run are the three lanes ' - + 'documented in content/docs/kernel/cluster.mdx §6.2: `metadata.changed` ' - + '(`ClusterMetadataChangedPayload` in `@objectstack/metadata` — the origin ' - + 'node, the metadata type and the replayed watch event), `metadata.mutated` ' - + '(`ClusterMetadataMutationPayload` in `@objectstack/metadata-protocol`) and ' - + '`datasource.mutated` (`ClusterDatasourceMutationPayload` in ' - + '`@objectstack/service-datasource`). A host that needs cross-node cache ' - + 'invalidation subscribes to one of those; a host that held the retired type ' - + 'for a transport of its own keeps a local type — the spec no longer declares ' - + 'one.', - reason: - 'ADR-0049 enforce-or-remove (triage ruling 2026-09-02 on the spec seat: ' - + 'remove via the ADR-0087 route, not "make a consumer" — that is contract ' - + 'growth with no pull). The docblock declared that all metadata persistence ' - + 'layers MUST emit a `metadata:changed` event with this payload and that ' - + 'every reader MUST subscribe and compare `version` before invalidating. ' - + "Measured at the retirement's base commit with positive controls: zero " - + 'runtime producers, zero subscribers, zero imports outside packages/spec ' - + '(its own unit test, the isomorphic alias pin and the generated artifacts) ' - + 'in objectstack, and nothing in objectui at the pinned sha. It was ' - + 'unenforceable by construction — the `version` field is `z.bigint()`, ' - + 'which the standard JSON serializer refuses, so the payload as declared ' - + 'could not cross any pubsub transport without a codec no driver ships: a ' - + 'MUST-emit contract no conforming emitter could satisfy. The shipped ' - + 'channels all carry an address-only signal whose receiver re-reads its own ' - + 'store (the 2026-09-01 ruling for the registry lane), the opposite of the ' - + 'declared version-compare receipt, so the one plausible future consumer was ' - + 'decided against; the 2026-08-27 ruling on transitions removes a staged ' - + "window. `MetadataChangeOperationSchema` existed only to type the payload's " - + '`operation` field and leaves with it as its orphan value schema (the ' - + '`DistributedStateConfig` precedent). Route 3: not an authorable surface — ' - + 'no metadata-type binding, stack collection or manifest embed ever carried ' - + 'it, and nothing parsed it outside its own unit test — so no tombstone and ' - + 'no D2 conversion; `RETIRED_DEFS_BY_MAJOR[18]` ' - + '(`kernel/MetadataChangedEventPayload`, `kernel/MetadataChangeOperation`) ' - + 'plus this entry ARE the declaration.', - acceptanceCriteria: - 'No code imports `MetadataChangedEventPayloadSchema`, ' - + '`MetadataChangedEventPayload`, `MetadataChangeOperationSchema` or ' - + '`MetadataChangeOperation` from `@objectstack/spec` or ' - + '`@objectstack/spec/kernel` — every one is TS2305 after upgrade (pinned by ' - + 'runtime namespace probes in kernel/cluster.test.ts, with ' - + '`ClusterCapabilityConfigSchema` as the positive control). No metadata ' - + 'document needs editing: the schema was reachable from no metadata-type ' - + 'binding, stack collection or /meta door. ⚠️ Runtime behaviour is ' - + 'deliberately UNCHANGED: no emitter or subscriber ever existed, and the ' - + 'three shipped cluster lanes publish the same bytes before and after — the ' - + 'retirement removes a false declaration, not behaviour.', - }, - { - id: 'metadata-customization-protocol-retired', - surface: - 'the paper metadata-customization protocol: `kernel/metadata-customization.zod.ts` ' - + 'whole (`MetadataOverlay`, `FieldChange`, `CustomizationOrigin`, `MergeConflict`, ' - + '`MergeStrategyConfig`, `MergeResult`, `CustomizationPolicy`) / the section-5 ' - + 'Overlay/Customization API contracts (`api/MetadataOverlayResponse`, ' - + '`api/MetadataOverlaySaveRequest`, `api/MetadataEffectiveResponse`) / the optional ' - + '`getOverlay`/`saveOverlay`/`removeOverlay`/`getEffective` members of ' - + '`contracts/metadata-service.ts` / the authorable keys ' - + '`MetadataPluginConfig.customizationPolicies`, `MetadataPluginConfig.mergeStrategy` and ' - + '`MetadataManagerConfig.persistence.overlayWritable` (tombstoned; see ' - + '`RETIRED_KEYS_BY_MAJOR[18]`)', - replacement: - 'nothing to re-declare — delete any authored keys. The customization mechanisms that ' - + 'actually ship: ADR-0005\'s org-scoped overlay (opt-in via `allowOrgOverride` on ' - + '`DEFAULT_METADATA_TYPE_REGISTRY`, stored as `sys_metadata` org rows, written through the ' - + 'REST meta write doors and read back through `getMetaItemLayered`\'s ' - + '`code`/`overlay`/`effective` layers), and ADR-0126\'s packaged-metadata customization ' - + 'model (clone with a new machine name + ledger disable — never a field-level patch ' - + 'overlay)', - reason: - 'ADR-0049 enforce-or-remove; the maintainer\'s ruling of 2026-08-29 adopted retirement and ' - + 'rejected a re-scope, and it was executed widened to the full coupling set the fork report ' - + 'on that ruling measured: ' - + 'the module declared a three-layer platform/user patch-overlay protocol with field-level ' - + 'change tracking and a 3-way-merge story, published reference docs described it as the ' - + 'customization architecture — and nothing reachable implemented it. The one ' - + 'implementation (`packages/metadata`\'s manager limb) was served by no route and called ' - + 'only by its own unit tests; no merge engine ever existed; no code read a ' - + '`CustomizationPolicy`. ADR-0126 §6 wall 4 supersedes the protocol as a matter of record ' - + '("nothing may build against it") — the per-field overlay layer it described is ' - + 'precisely what the 2026-08-24 lock-and-clone ruling (lock the packaged base, customize a ' - + 'clone) left deliberately unchartered. Why D3 ' - + 'semantic and not a D2 conversion: the defs leave with no carrier key in any stack ' - + 'collection, and the three tombstoned keys live on plugin/manager configs, which are not ' - + 'stack collection members (`PLURAL_TO_SINGULAR` has no `plugins` entry) — a ' - + 'MetadataConversion would be a transform with no seam that ever runs (the ' - + '`kernel/Manifest:loading` precedent).', - acceptanceCriteria: - 'No import of `metadata-customization.zod` (or of the retired names from ' - + '`@objectstack/spec/kernel` / `@objectstack/spec/api`) compiles anywhere; no ' - + '`MetadataPluginConfig` carries `customizationPolicies` or `mergeStrategy`; no ' - + '`MetadataManagerConfig` carries `persistence.overlayWritable` (TypeScript authors get ' - + 'the refusal at compile time — the keys are typed `never` — and a value reaching the ' - + 'parse is refused with the prescription at the key\'s path). ⚠️ Runtime behaviour is ' - + 'deliberately UNCHANGED and must be verified as such: no route ever served the paper ' - + '`…/overlay` / `…/effective` endpoints, so removing the limb removes no served ' - + 'behaviour — the ADR-0005 org-overlay read/write path (`getMetaItemLayered`, the REST ' - + 'meta write doors) stays exactly as it was, before and after.', - }, - { - id: 'metadata-endpoints-switch-radius-repartitioned', - surface: 'restServer.metadata.endpoints.items / restServer.metadata.endpoints.item', - replacement: - 'An `endpoints.*` switch now gates exactly the face its name states, reads and writes alike. ' - + '`items` gates `GET {prefix}/:type` and nothing else; the whole-store operations it used to take ' - + 'with it — `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and the `POST {prefix}/_migrate-stored` ' - + 'write door — answer to the new key `endpoints.maintenance` (default `true`). `item` now gates the ' - + 'WHOLE per-item face: `GET` / `PUT` / `DELETE {prefix}/:type/:name`, `/references`, `/layers`, the ' - + 'history family (`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) and ' - + '`GET {prefix}/book/:name/tree`. ⇒ An embedder that authored `endpoints: { items: false }` to close ' - + 'the whole-store family writes `endpoints: { items: false, maintenance: false }`. An embedder that ' - + 'authored `endpoints: { item: false }` to close only the per-item READS has no key that keeps the ' - + 'writes: the per-item face is one face, so leave `item` on and close the surface at ' - + '`api.enableMetadata`, or per object at `enable.apiEnabled` / `enable.apiMethods`. ' - + '`types` is unchanged and `api.enableMetadata` remains the master switch above all four.', - reason: - 'Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise ' - + 'reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an ' - + 'optional boolean, so `{ items: false }` compiles and parses exactly as before and simply mounts a ' - + 'different route table. A D2 conversion would have to GUESS which of the four routes the author ' - + 'meant to close, and the two readings differ by a write door — rewriting `{ items: false }` to ' - + '`{ items: false, maintenance: false }` preserves the old mounts but presumes an intent the author ' - + 'never expressed, while leaving it alone re-mounts `POST {prefix}/_migrate-stored`. That is a ' - + 'judgment, so it is delegated rather than automated. The change itself is the ADR-0049 ' - + 'declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all ' - + 'three keys were genuinely live, and what had drifted was each one\'s RADIUS against its own ' - + '`describe()` — `items` gated a migration write door while naming a listing read, and ' - + '`item` gated four reads while its own `PUT` / `DELETE` and the history family answered to ' - + '`api.enableMetadata` alone. The maintainer ruled the two together on 2026-09-06 as one ' - + 'principle: every `endpoints.*` switch gates exactly the face its name states, and the ' - + 'whole-store family gets a key of its own. Measured ' - + 'population at the time of the move: ZERO — no shipped boot path constructs a `RestServerConfig`, ' - + 'so only programmatic embedders can have authored these keys at all.', - acceptanceCriteria: - 'For each `RestServerConfig` the consumer constructs, `new RestServer(...).registerRoutes()` followed ' - + 'by `getRoutes()` yields the route table the consumer intends — specifically: with ' - + '`endpoints.items: false` authored, `GET {prefix}/diagnostics`, `GET {prefix}/_drafts` and ' - + '`POST {prefix}/_migrate-stored` are PRESENT unless `endpoints.maintenance: false` is also authored; ' - + 'and with `endpoints.item: false` authored, `PUT {prefix}/:type/:name`, ' - + '`DELETE {prefix}/:type/:name` and the six history routes are ABSENT. A consumer that authored ' - + 'neither key is unaffected and needs no change: all four switches default `true` and the default ' - + 'route table is byte-identical to before. The reference measurement is ' - + '`packages/rest/src/rest-config-mount-table.pin.test.ts`, which asserts each switch\'s radius as a ' - + 'set difference against the all-true baseline in both directions.', - }, - { - id: 'metadata-item-name-grammar-enforced', - surface: 'metadata item names (the `name` half of the `type`/`name` addressing pair — ' - + '`saveMetaItem` / `publishMetaItem`, `PUT /api/v1/meta/:type/:name` and the compound ' - + '`:type/:section/:name` fold)', - replacement: 'lowercase snake_case segments, optionally dot-qualified — ' - + 'the pattern family of METADATA_ITEM_NAME_PATTERN, i.e. one or more [a-z][a-z0-9_]* ' - + 'segments joined by single dots (`crm_lead`, `crm_lead.pipeline`). A name that spelled ' - + 'a sub-resource with a slash (`views/all_leads`) is re-authored with a dot qualifier ' - + '(`crm_lead.pipeline` — the `ViewItemNameSchema` convention, now enforced with the ' - + 'qualifier optional) or flattened with an underscore (`views_all_leads`); containment ' - + 'is expressed by structure, never by a separator inside the identity string.', - reason: - 'Maintainer ruling (2026-08-25): metadata item names must not contain `/` — ' - + 'identity-with-separator is the measured root cause of a defect family (URL arity ' - + 'mismatches, dual-arity route-mount obligations, route shadowing, a two-rule URL ' - + 'spelling split in one SDK file). The grammar was entirely unconstrained at the door: ' - + 'the empty string, `//` and `Views/All Leads` were all accepted and stored as item ' - + 'names, and a slash in the name bypassed the unrecognised-metadata-type refusal ' - + '(`type=fieldz name=a/b` was accepted while `type=fieldz name=a` was 400). Whether a ' - + 'stored slash-name (out-of-repo deployments only — the in-repo census measured zero) ' - + 'should be renamed, and to what, is a judgment the chain cannot make, so no mechanical ' - + 'conversion ships with the narrowing.', - acceptanceCriteria: - 'Every write through `saveMetaItem` / `publishMetaItem` whose name is lowercase ' - + 'snake_case segments optionally joined by single dots succeeds exactly as before, flat ' - + 'and dotted alike. Any other name — slash, empty, whitespace, uppercase, ' - + 'leading/trailing/double dots — is refused `400 INVALID_REQUEST` with the grammar and ' - + 'the dotted prescription in the message, and nothing is persisted. Reads and ' - + '`deleteMetaItem` still answer for pre-grammar residue rows, so any stored junk name ' - + 'remains listable and clearable.', - }, - { - id: 'metadata-manager-config-cache-ttl-unit-in-key', - surface: 'MetadataManagerConfig `cache.ttl` / `cache.databaseLoader.ttl` (kernel/metadata-loader.zod.ts)', - replacement: '`cache.databaseLoader.ttlMs` (milliseconds, default 60000) — rename the nested key; the ' - + 'value is unchanged. The outer `cache.ttl` has NO replacement: its respelling `ttlSeconds` ' - + 'was retired before it shipped (see `metadata-manager-config-inert-cache-keys-retired`) ' - + '— delete the key; nothing ever read it', - reason: - 'Maintainer ruling B of 2026-09-02 on duration-shaped keys (no grandfathered baseline): the unit of a ' - + 'duration-shaped `z.number()` key lives in the key NAME or in a unit-carrying value, never ' - + 'only in the description. This block was the founding specimen: two keys spelled `ttl` ' - + 'fourteen lines apart, the outer one in SECONDS (3600) and the nested DatabaseLoader one in ' - + 'MILLISECONDS (60000), each unit named only in `.describe()`. An author who copied the outer ' - + 'number into the inner block got a 3.6-second cache with no error anywhere — the number was ' - + 'valid, the type was right, the cache was simply cold. Both keys are retiredKey tombstones ' - + '(the nested objects are not strict; a bare deletion would strip the old key in silence). ' - + 'Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime ' - + 'MetadataManager\'s constructor config, not a stack collection member and never a stored ' - + 'row, so the chain has no seam that ever runs on it (the `kernel/Manifest:loading` and ' - + '`metadata-plugin-additional-types-retired` precedent). The one in-repo reader, ' - + '`DatabaseLoader` (`packages/metadata`), reads `cache.databaseLoader.ttlMs` at the same ' - + 'magnitude it read `ttl`; the outer `cache.ttl` had no runtime reader (measured on ' - + 'ca46f8f12, and retired on its own under ADR-0049 before this rename shipped — ' - + 'so this entry\'s outer half is a deletion, not a rename, and the `ttlSeconds` spelling ' - + 'never reached a published release).', - acceptanceCriteria: - 'Every `new MetadataManager({ cache: … })` / `MetadataManagerConfigSchema.parse(…)` site spells ' - + '`cache.databaseLoader.ttlMs` and no outer TTL at all; authoring either old `ttl` fails to ' - + 'compile (input type `never`) and fails to parse with a prescription — the nested one naming ' - + '`ttlMs`, the outer one prescribing deletion and naming `cache.databaseLoader.ttlMs`; a ' - + 'DatabaseLoader configured with `ttlMs: 60000` expires entries after 60 seconds exactly as ' - + '`ttl: 60000` did.', - }, - { - id: 'metadata-manager-config-inert-cache-keys-retired', - surface: 'MetadataManagerConfig `cache.enabled` / `cache.ttlSeconds` (formerly `cache.ttl`) / ' - + '`cache.maxSize` (kernel/metadata-loader.zod.ts; tombstoned, see `RETIRED_KEYS_BY_MAJOR[18]`)', - replacement: 'nothing to re-declare — delete the three outer keys. The cache that actually runs ' - + 'is the DatabaseLoader read-through LRU under `cache.databaseLoader`: its `enabled` ' - + '(default true) is the switch, `ttlMs` (milliseconds, default 60000) the TTL and `maxSize` ' - + '(an entry count, default 500) the cap', - reason: - 'ADR-0049 enforce-or-remove (the owning seat\'s ruling, conditioned on the measurement ' - + 'below and re-taken on the merged ref): the outer `cache` block of ' - + '`MetadataManagerConfig` advertised three knobs — `enabled` (default true), `ttlSeconds` ' - + '(default 3600; `ttl` until the duration-unit rename) and `maxSize` ("bytes") — that no ' - + 'runtime read. The only consumer of the block is `MetadataManager` (`packages/metadata`), which hands ' - + '`cache.databaseLoader` and nothing else to `new DatabaseLoader({ cache })`; a ' - + 'repo-wide reader census over `packages/**` (tests and changelogs excluded) found no ' - + 'runtime reader of any outer key, while the same grep shape found the nested ' - + '`cache?.databaseLoader` read twice (the positive control). An author writing ' - + '`cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse and a ' - + 'cache that behaved exactly as before, and the published reference page documented ' - + 'all three as if they configured something. All three are retiredKey tombstones (the ' - + 'nested object is not strict; a bare deletion would strip them in silence — the same ' - + 'no-op one layer down). The duration-unit ruling\'s `ttl` → `ttlSeconds` rename, ' - + 'registered under this same major and never shipped, is folded into the removal: `cache.ttl`\'s tombstone now ' - + 'prescribes deletion rather than a rename to a key that is itself retired, so a 17.x ' - + 'author sees one hop. Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` ' - + 'is the runtime MetadataManager\'s constructor config, not a stack collection member and ' - + 'never a stored row, so the chain has no seam that ever runs on it (the ' - + '`kernel/MetadataManagerConfig:persistence.overlayWritable` precedent). The other ' - + 'candidate — wiring readers for a second cache layer — was not taken: no consumer for ' - + 'one exists, and an implementation for an unmeasured need is the shape ADR-0049 refuses.', - acceptanceCriteria: - 'No `new MetadataManager({ cache: … })` / `MetadataManagerConfigSchema.parse(…)` site ' - + 'spells `cache.enabled`, `cache.ttlSeconds`, `cache.ttl` or `cache.maxSize` (TypeScript ' - + 'authors get the refusal at compile time — the keys are typed `never` — and a value ' - + 'reaching the parse is refused with the prescription at the key\'s path, naming ' - + '`cache.databaseLoader`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be ' - + 'verified as such: the DatabaseLoader read-through cache configured under ' - + '`cache.databaseLoader` (`enabled` / `maxSize` / `ttlMs`) behaves exactly as before, and a ' - + 'config that never wrote the outer keys parses to the same output minus the two former ' - + 'defaults (`enabled: true`, `ttlSeconds: 3600`) that were materialized and never consulted.', - }, - { - id: 'metadata-plugin-additional-types-retired', - surface: 'metadata plugin `config.additionalTypes` (on `MetadataPluginConfig`)', - replacement: - 'nothing to re-declare — delete the key. There is no declared-kind channel: a kind ' - + 'enters the live metadata-type set as a side effect of registering an ITEM of that ' - + 'kind (`SchemaRegistry.registerItem` during app/manifest registration, or ' - + '`MetadataManager.register` at runtime). Bind the kind\'s schema with ' - + '`registerMetadataTypeSchema(type, schema)` from the plugin\'s `init(ctx)` so ' - + '`GET /api/v1/meta` serves a real JSON Schema for it', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with ' - + 'refusing unknown types at the `/meta` boundary by the static registry. The key was ' - + 'declared, authorable, on the published authorable surface, and documented on four ' - + 'docs pages as THE way a plugin registers a custom metadata type — and read by ' - + 'NOTHING. The only production writer of the manager\'s type registry is ' - + '`setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)` (`packages/metadata/src/' - + 'plugin.ts`), called exactly once outside tests, and it REPLACES the array outright; ' - + 'nothing ever merged `additionalTypes` into it. Measured against the real ' - + '`MetadataManager`: declared count == live count (27 == 27), ' - + '`getRegisteredTypes()` sorted equals the built-in registry sorted. So an author ' - + 'who followed the published instructions wrote the key, got no error, and nothing ' - + 'happened — the same silence trap as the plugin lifecycle\'s `onInstall` (a documented ' - + 'hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). ' - + 'The joint consequence: with this plugin-declared channel removed, ' - + 'the static registry is the total universe of legal metadata kinds, which makes ' - + 'refuse-by-static-registry at the /meta boundary safe by construction. ' - + 'Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and ' - + '`applyConversionsToStoredItem` maps a metadata type onto one of its collections. ' - + 'A metadata-plugin config is neither — `PLURAL_TO_SINGULAR` has no `plugins` ' - + 'entry, so it is not a stack collection member and a conversion would be a ' - + 'transform with no seam that ever runs (the `kernel/Manifest:loading` precedent).', - acceptanceCriteria: - 'No `MetadataPluginConfig` — inline in TypeScript or embedded at the manifest\'s ' - + '`config` key — carries `additionalTypes`. TypeScript authors get the refusal at ' - + 'compile time (`additionalTypes` is typed `never`); a value reaching the parse is ' - + 'refused with the prescription (`invalid_type` at path `additionalTypes`). ' - + '⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: ' - + 'nothing ever read the key, so removing it removes no behaviour — the live type ' - + 'set stays exactly `DEFAULT_METADATA_TYPE_REGISTRY` plus item-population growth, ' - + 'before and after.', - }, - // ADR-0131 D6 (C5, stage S5) — the read half of the retirement whose write half - // `metadata-write-organization-scope-refused` records: every metadata read is - // environment → code. Registered because a read that named an organization - // served that organization's rows and now serves the environment's, because - // the `organizationId` key leaves six declared request shapes and the drafts - // listing, and because `overlayScope` loses its `org` value. - { - id: 'metadata-read-organization-scope-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the organizationId member of the GetMetaItems, GetMetaItem, GetMetaItemLayered, AuditMetaItem, ' - + 'HistoryMetaItem and GetMetaItemCached request schemas and of each ListDraftsResponse draft of ' - + '@objectstack/spec, and the org value of GetMetaItemLayeredResponse overlayScope; and every ' - + 'metadata read of @objectstack/metadata-protocol that served a row stored organization-scoped: ' - + 'the item, list, layered, cached, history, diff, audit, drafts, commit timeline, lock, search ' - + 'and references reads, and the boot hydration — plus an environment overlay row of an item a ' - + 'managed package ships on a type sealed against overlays (any type whose registry entry admits ' - + 'no overlay: a managed flow, action, hook, object or datasource, for example), which the reads ' - + 'served over the package definition', - replacement: - 'drop `organizationId` from the read request: every read serves the environment row, else the ' - + 'code definition, to every caller, and `overlayScope` is `env` or null. A legacy row stored ' - + 'organization-scoped is served by no read; boot names each one, per type, and the v18 migration ' - + 'ceremony (ADR-0131 D10) carries it — nothing is deleted or rewritten before it. An environment ' - + 'overlay of sealed managed content is not served either, and boot names it: keep the change by ' - + 're-expressing it as a new item under a new name (a linkage-free clone), or delete the stored ' - + 'row. The anonymous public-form doors alone keep reading the Default Organization\'s legacy ' - + 'view rows, fail-closed, until the ceremony carries them', - reason: - 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata belongs to the ' - + 'whole deployment. The doors stopped carrying an organization ' - + '(meta-doors-organization-scope-retired) and the protocol refuses every organization-scoped ' - + 'write (metadata-write-organization-scope-refused); a read that still served a legacy ' - + 'organization row would keep a retired layer live, one caller at a time. D6 also seals managed ' - + 'content: an overlay of a managed item of a sealed type sits in no regime D6 recognises, ' - + 'so the package definition wins at read as the write doors already make it win at write. ' - + 'Permission sets keep their own ruling on stored forks.', - acceptanceCriteria: - 'With a legacy organization-scoped view row and an environment row of the same name stored, ' - + 'getMetaItem, getMetaItems, getMetaItemLayered and getMetaItemCached serve the environment row ' - + 'whether or not the request names the organization, and the organization-only name answers ' - + 'nothing; historyMetaItem, auditMetaItem, listDrafts and listCommits show no legacy ' - + 'organization row; the ETag is one validator for every caller; overlayScope is env or null. A ' - + 'stored environment row of an action a managed package ships is not served — the package ' - + 'definition is. Boot logs metadata_org_scoped_unserved and metadata_sealed_overlay_unserved, ' - + 'per type. Remove organizationId from any typed read request literal: the key no longer ' - + 'type-checks.', - }, - // ADR-0131 D6 (C5, stage S4) — the protocol half of the door narrowing - // `meta-doors-organization-scope-retired` records: the metadata protocol itself - // refuses every organization-scoped write, and the per-organization write path - // behind it is deleted. Registered because a caller that still names an - // organization is refused where it was accepted, and because the - // `organizationId` key leaves three declared request shapes. - { - id: 'metadata-write-organization-scope-refused', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request ' - + 'schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of ' - + '@objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, ' - + 'deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, ' - + 'discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — ' - + 'including the five types that declared allowOrgOverride (view, dashboard, report, translation, ' - + 'email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller ' - + 'publishing a package, which a published seed draft whose records named no organization was ' - + 'loaded into under the group posture', - replacement: - 'drop `organizationId` from the request: every metadata write lands environment-wide ' - + '(`organization_id` NULL), where every organization reads it. The key is stripped at a spec ' - + 'parse and refused at the protocol: a request that still names an organization is refused with ' - + '403 `NOT_OVERRIDABLE`, before anything is read or written, and the message names the tenancy ' - + 'posture in force. A seed draft sets `organization_id` on each record (ADR-0131 D12, item 12); ' - + 'under group a seed record that names none is refused at load', - reason: - 'ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, ' - + 'by the cloud build agent or by an install belongs to the whole deployment. The /meta doors ' - + 'already carry no organization (meta-doors-organization-scope-retired); this is the protocol ' - + 'refusing the same write from every other door — the /packages verbs, the stored-row ' - + 'migrations, a plugin — so no path is left that stamps an organization on a metadata row. ' - + 'The audit and commit ledgers are environment-level too (ADR-0131 D7) and record no ' - + 'organization. Legacy organization-scoped rows are not touched: the stored-metadata migration ' - + 'reports them as skipped, the flow credential move reports them as not moved, and the ' - + 'promotion ceremony (ADR-0131 C7) carries them to the environment layer.', - acceptanceCriteria: - 'A protocol write naming an organization — a saveMetaItem of a view with organizationId set, a ' - + 'publishPackageDrafts or a revertCommit with one — answers 403 NOT_OVERRIDABLE and writes ' - + 'nothing, for every metadata type and every tenancy posture; the same call without the key ' - + 'succeeds and stores organization_id NULL. POST /meta/_migrate-stored reports each ' - + 'organization-scoped row as skipped, naming the promotion ceremony, and re-saves none. A ' - + 'commit recorded in a legacy organization layer is refused by revertCommit with the same code, ' - + 'and duplicatePackage and reassignOrphanedMetadata copy or adopt the environment rows only. ' - + 'Remove organizationId from any typed SaveMetaItem / PublishMetaItem / DeleteMetaItem ' - + 'request literal: the key no longer type-checks.', - }, - // The ADR-0087 D3/D4 surface leaves the package root for its own subpath, so the - // migration registry's text stops riding in every bundle of the root entry. The - // conversion layer (D2) stays on the root: the authoring funnel reads it at run time. - // - // Form D: no tracker number anywhere in the author-shown text; the decision is - // stated in words. - // - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'migrations-entry-split', - surface: - 'The ADR-0087 migration chain and change manifest, imported from the package root ' - + '@objectstack/spec: MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, ' - + 'RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, applyMetaMigrations, composeMigrationChain, ' - + 'MigrationFloorError, composeSpecChanges, composeReleaseChanges, the seven change-manifest ' - + 'schemas (SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, ' - + 'SpecSurfaceRemoveSchema, SpecReleaseChangesSchema, SpecReleaseSurfaceSchema), and the types ' - + 'MigrationStep, MigrationApplication, MigrationChainResult, MigrationHopResult, MigrationTodo, ' - + 'SemanticMigration, SpecChanges, SpecConverted, SpecMigrated, SpecSurfaceAdd, SpecSurfaceRemove, ' - + 'SpecReleaseChanges, SpecReleaseSurface, SurfaceDiff, ReleaseSurfaceDiff and ' - + 'PreviousReleaseRegistries', - replacement: - 'the same names, unchanged, imported from `@objectstack/spec/migrations` — change the import ' - + 'path and nothing else. The chain, its steps and semantic entries, the retired-key and ' - + 'retired-def tables and the change-manifest schemas are the same objects, and ' - + '`objectstack migrate meta` replays the same chain. The ADR-0087 conversion layer stays on the ' - + 'package root: `ALL_CONVERSIONS`, `CONVERSIONS_BY_MAJOR`, `applyConversions`, ' - + '`applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`, the ' - + 'three `CONVERSION_*_CODE` constants and their types still import from `@objectstack/spec`.', - reason: - 'The maintainer ruled that the console first-screen size ceiling is raised now and paid back at ' - + 'the source; this split is that payback. The migration registry is mostly the guidance text ' - + '`objectstack migrate meta` prints, and the package root re-exported it. The registry does work ' - + 'when its module loads (the list of majors and each step\'s rationale are computed then), so no ' - + 'bundler could prove it unused, and all of that text rode in every bundle of the root, whatever ' - + 'the consumer imported: 1,761,987 of the root ESM bundle\'s 3,766,221 bytes. With the chain on ' - + 'its own subpath the CommonJS root is 2,009,810 bytes instead of 3,780,033, and a browser bundle ' - + 'of the ten names the Studio console imports from the root drops from 700,884 to 301,287 bytes ' - + 'gzipped. The conversion layer does not move: `defineStack` and `normalizeStackInput` read it ' - + 'at run time, so moving its names would narrow the root and shrink it by under two kilobytes. The split ' - + 'moves an import path, which is TypeScript source rather than metadata — nothing authors, ' - + 'stores or parses it — so there is no source a D2 conversion could rewrite, and the move is ' - + 'recorded here.', - acceptanceCriteria: - 'No code imports any of these names from the package root `@objectstack/spec` — each such ' - + 'import is a TS2305 "has no exported member" error after upgrade, and at run time the binding ' - + 'is undefined. The same names import cleanly from `@objectstack/spec/migrations`. No metadata ' - + 'document, stored row or JSON Schema reference needs editing: the chain, its tables and the ' - + 'schemas did not change, and `objectstack migrate meta` rewrites the same documents it did ' - + 'before.', - }, - { - id: 'object-block-sort-item-array', - surface: - 'The `sort` prop of `object-grid` and `object-calendar` in `ComponentPropsMap` ' - + '(the FORM: the accept-anything `z.unknown()` at both block doors, vs the ' - + '`SortItem` array `[{ field, order }, ...]`)', - replacement: - '`z.array(SortItemSchema)` at both doors — the array `ElementDataSourceSchema.sort`, ' - + '`ListPageSchema.sort` and `element:record_picker`\'s flat `sort` shorthand already ' - + 'carry. The legacy OData-ish clause `sort: \'created_at desc\'` becomes ' - + '`sort: [{ field: \'created_at\', order: \'desc\' }]`; a bare field name ' - + '`sort: \'created_at\'` meant ascending and becomes ' - + '`sort: [{ field: \'created_at\', order: \'asc\' }]` — `order` is required in ' - + '`SortItemSchema`, so it is written out rather than omitted. A comma-separated ' - + 'clause becomes one array entry per key, in the same order. `record:related_list` ' - + 'is NOT moved by this entry: its string is the `\'field\'` / `\'-field\'` dialect ' - + 'read by `RelatedList.normalizeSortSpec`, which never reaches ' - + '`convertSortToQueryParams`, and retiring it was not ruled. ' - + '`object-grid.defaultSort` is a different key, retired separately by the ' - + '`ui__ObjectGridProps__defaultSort` entry.', - reason: - 'One `sort` spelling platform-wide, the array: the maintainer\'s ruling of 2026-09-07 ' - + '(option B) retired the legacy string `sort` clause, and its consumer half is the objectui ' - + 'change that drops the string arm from `convertSortToQueryParams`. One item of that ' - + 'ruling is this entry\'s subject: 「`ComponentPropsMap` for ' - + '`object-calendar` and `object-grid` constrains the `sort` value to the array shape ' - + '(today it accepts anything), so the spec, the registrations and the helper agree; ' - + 'that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at ' - + 'both doors was a read-point record from the change that brought the `object-*` blocks ' - + 'into `ComponentPropsMap` (the maintainer\'s ruling of 2026-08-12), the same vintage as ' - + 'the `filter` doors ' - + 'the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an ' - + 'exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string ' - + 'and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name ' - + 'on the same call, so key checking was live and only the VALUE was unheld. Meanwhile ' - + 'objectui\'s own html tier has published `type: \'array\'` for the grid all along ' - + '(`plugin-grid/src/index.tsx:222`) and answered `type-mismatch` on the string — a ' - + 'spelling `@object-ui/core` implemented, the docs taught and the validator refused, ' - + 'which is what made this a ruling rather than a mechanical widening. ' - + 'Sequenced measurement-first: at the objectui pin this repo builds against ' - + '(`53ded82b`) the string is still lowered — `ObjectGrid.tsx:1844-1851` carries an ' - + 'explicit `typeof === \'string\'` arm onto `$orderby`, and `ObjectCalendar.tsx:431` ' - + 'hands `schema.sort` to `convertSortToQueryParams`, whose string arm is still present ' - + 'at `sort-query.ts:66-70`. So this declaration lands AHEAD of the pinned consumer, ' - + 'which the ruling permits explicitly (either order; the registrations already declare ' - + 'the array). The in-repo sweep found ZERO authored `sort` on either block — the two ' - + 'showcase pages that author `object-grid` (`command-center.page.ts`, ' - + '`my-work.page.ts`) declare none — with the same grep shape finding 40+ string `sort` ' - + 'values at OTHER doors (view definitions, ObjectQL `query.sort`) as the control that ' - + 'the sweep fires; so this entry carries the prescription for authors outside the repo. ' - + '⚠️ Metadata AT REST is deliberately NOT rewritten and this disposition adds no D2 ' - + 'conversion: `os migrate meta --stored` replays D2 conversions only, and the read path ' - + 'does not re-validate stored rows (`applyConversionsToStoredItem` replays the chain ' - + 'without validating, by its own contract), so a stored page carrying a string `sort` ' - + 'keeps loading and is still rendered by objectui at the pinned `.objectui-sha`. What ' - + 'changes is that RE-SAVING it is refused at the `sort` door, on its next save and not ' - + 'before. ADR-0049, ADR-0087.', - acceptanceCriteria: - '`ComponentPropsMap[\'object-grid\' | \'object-calendar\'].safeParse({ objectName, ' - + 'sort: [{ field: \'created_at\', order: \'desc\' }] })` succeeds and the parsed `sort` ' - + 'is that same array, equal value-for-value to ' - + '`ElementDataSourceSchema.parse({ object, sort: }).sort`. The legacy ' - + 'string clause is refused at the `sort` path on both doors (`invalid_type`, expected ' - + 'array), and so is a bare number; a misspelled or ABSENT direction is refused at ' - + '`sort.0.order` (`invalid_value` — `order` is a required enum, so both take one ' - + 'verdict) and a missing field at `sort.0.field` (`invalid_type`). An undeclared key ' - + 'is still refused BY NAME on the same call (`unrecognized_keys` naming it), the ' - + 'control that makes those refusals verdicts rather than a schema reporting nothing. ' - + 'No `sort` door in `ComponentPropsMap` accepts a string except `record:related_list`, ' - + 'which is the one deliberate exception. At runtime each block orders exactly as the ' - + 'array orders — the same `$orderby` the string lowered to.', - }, - { - id: 'object-grid-data-view-data-converged', - surface: - "`object-grid` component props — `data` (the KIND: bare array `z.array(z.unknown())` " - + 'vs the `ViewDataSchema` provider object)', - replacement: - "`ViewDataSchema` — the provider-discriminated object (`provider: 'object' | 'api' | " - + "'value' | 'schema'`). Static inline rows move from `data: [...]` to " - + "`data: { provider: 'value', items: [...] }` — the same rows, wrapped in the one " - + 'arm that means "hardcoded data array". The other three arms are unchanged ' - + '`ViewDataSchema` semantics; `staticData` (the deprecated bare-array shortcut the ' - + 'renderer still reads) keeps its shape but is not the prescription', - reason: - 'Two entries of one contract disagreed on the KIND (contract-vs-contract, found by ' - + "objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare " - + "array ('Static inline " - + "rows — bypasses the object query') while `ViewDataSchema` — the authority " - + 'objectui aligned the grid\'s registry declaration to, pinned by ' - + '`gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is ' - + "an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: " - + "`{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the " - + 'props-map entry (`expected array, received object`) while the bare array parsed. ' - + 'Whichever authority a value satisfied, the other refused it, and the objectui ' - + 'parity gate had to carry the reasoned exemption `object-grid.data:object` to look ' - + 'away. The maintainer\'s ruling of 2026-08-25 (option A) converged the props-map entry ' - + 'onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that ' - + 'objectui\'s deprecated-alias carve-out already refuses to publish as authoring surface. ' - + 'The ruled migration check ran with the ' - + 'change: the sweep of generated artifacts, templates and first-party corpora ' - + '(examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array ' - + '`data` authors, so no rewrite ships — this entry carries the prescription for ' - + 'authors outside the repo.', - acceptanceCriteria: - "`ComponentPropsMap['object-grid'].safeParse({ data: { provider: 'value', items: [] } })` " - + 'succeeds (and the other `ViewDataSchema` arms parse through the same entry); a ' - + 'bare-array `data: [...]` is refused at the `data` path. An author carrying ' - + "`data: [...]` writes `data: { provider: 'value', items: [...] }` — same rows, " - + 'one wrapping object. Downstream (objectui, after a released spec version reaches ' - + 'the pin): the `object-grid.data:object` exemption entry in ' - + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes the ' - + 'objectui finding that the two authorities disagreed.', - }, - // #11509 (v18, ruling A-narrow, sub-question 1) — the D3 entry of the - // `object-grid-default-filters-removed` family, in the shape the - // `object-grid-default-sort-retired` entry beside it took. It absorbs the - // protocol-18 narrowing of the same key (`object-grid-default-filters-rule-array`, - // never released in a major): #19514 narrowed `defaultFilters` to the rule array - // and left its removal to a ruling of its own, which this is. - { - id: 'object-grid-default-filters-retired', - surface: - 'page.component.object-grid.defaultFilters — the legacy second spelling of the grid base filter', - replacement: - '`filter: [{ field, operator, value }, ...]` — the one base-filter key every read path honours; ' - + 'the same rules, unchanged.', - reason: - 'The grid read `defaultFilters` only when `filter` lowered to nothing, so one intent had two ' - + 'spellings on one block. The D2 conversion `object-grid-default-filters-removed` follows that ' - + 'precedence: where `filter` was empty (absent, null, `[]` or `{}`) the fallback WAS the grid\'s ' - + 'filter, so its rules move onto `filter`; where `filter` had rules the fallback was never read, ' - + 'so it is deleted. Both preserve what the grid showed, and the second is where the judgment ' - + 'sits: a grid that authored both keys has always listed the rows `filter` selects, while its ' - + 'author may believe `defaultFilters` applied. The conversion keeps the rows users have been ' - + 'seeing and discards the rules that were written; only the author can say which were meant. ' - + 'A `filter` that is neither empty nor rules (a bare string, a number) is left as stored and ' - + 'listed as a TODO: the grid fell back to `defaultFilters` there too, and moving it would ' - + 'overwrite what was written at `filter`. A fallback in the retired record form moves to `filter` ' - + 'and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping ' - + 'is lossless; that entry lists the rest. Code that builds object-grid props (a host, a ' - + 'generator) must also stop emitting the key, which no conversion reaches.', - acceptanceCriteria: - 'No `object-grid` component carries `defaultFilters`; the parse refuses it. Each grid\'s `filter` ' - + 'holds the rules the author intends, and the grid lists exactly the rows they select. For every ' - + 'grid that had authored both keys, the author has compared the discarded `defaultFilters` rules ' - + 'with the kept `filter` and confirmed the kept one.', - conversionIds: ['object-grid-default-filters-removed'], - }, - // #11805 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `object-grid-default-sort-removed` family (ruling B on #17152: one D3 entry - // per retirement family, even when D2 is lossless). The conversion follows - // the renderer's own precedence exactly, so it preserves what the grid did — - // including the case where what the grid did was not what the author wrote. - { - id: 'object-grid-default-sort-retired', - surface: 'page.component.object-grid.defaultSort — the legacy single-pair second spelling of ' - + 'the grid sort', - replacement: '`sort: [{ field, order }]` — the array every read path honours; a single pair is a ' - + 'one-entry array.', - reason: 'The D2 conversion `object-grid-default-sort-removed` follows the renderer\'s own ' - + 'precedence: where `sort` was absent the `defaultSort` pair WAS the grid\'s sort, so it moves ' - + 'to `sort` as a one-entry array; where `sort` was present the pair was never read, so it is ' - + 'deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid ' - + 'that authored both keys with DIFFERENT orders has always loaded in the `sort` order while ' - + 'its author may believe `defaultSort` governed the initial load — the key\'s name says it ' - + 'should have. The conversion keeps the order users have been seeing and discards the one the ' - + 'author wrote; only the author can say which one they meant. Code that builds object-grid ' - + 'props (a host, a generator) must also stop emitting the key, which no conversion reaches.', - acceptanceCriteria: 'No `object-grid` component carries `defaultSort`; the parse refuses it. ' - + 'Each grid\'s `sort` array lists the fields and directions the author intends, and the grid ' - + 'loads with its rows in that order and shows that column as sorted. For every grid that had ' - + 'authored both keys, the author has compared the discarded `defaultSort` pair with the kept ' - + '`sort` and confirmed the kept one.', - }, - // #21445 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `object-grid-resizable-columns-removed` family (one D3 entry per retirement - // family, even when D2 is lossless). The conversion follows the renderer's own - // precedence exactly, so it preserves what every grid did — including where - // what the grid did was not what the author wrote. - { - id: 'object-grid-resizable-columns-retired', - surface: 'page.component.object-grid.resizableColumns — the legacy second spelling of the grid ' - + 'column-resize switch', - replacement: '`resizable: true | false` — the one spelling the grid reads; the value is the same ' - + 'boolean.', - reason: 'The D2 conversion `object-grid-resizable-columns-removed` follows the renderer\'s own ' - + 'precedence, `resizable ?? resizableColumns`: where `resizable` was absent the legacy value WAS ' - + 'the grid\'s setting, so it moves to `resizable` unchanged; where `resizable` held a value the ' - + 'legacy key was never read, so it is deleted. Both are behaviour-preserving, and the second is ' - + 'where the judgment sits. A grid that authored both keys with DIFFERENT values has always ' - + 'behaved as `resizable` said, while its author may believe the other key governed it. The ' - + 'conversion keeps what users have been seeing and discards the value the author also wrote; ' - + 'only the author can say which one they meant. Code that builds object-grid props (a host, a ' - + 'generator) must also stop emitting the key, which no conversion reaches.', - acceptanceCriteria: 'No `object-grid` component carries `resizableColumns`; the parse refuses it. ' - + 'Each grid that should let users drag column borders either omits `resizable` (the renderer ' - + 'default is on) or sets it to `true`, and each that should not sets `resizable: false`. For ' - + 'every grid that had authored both keys, the author has compared the discarded value with the ' - + 'kept `resizable` and confirmed the kept one.', - conversionIds: ['object-grid-resizable-columns-removed'], - }, - { - id: 'object-index-unknown-keys-refused', - surface: 'object `indexes[]` entries (`IndexSchema`) — undeclared keys', - replacement: 'the declared surface: `name` / `fields` / `unique` (ADR-0120 scope). A key that ' - + 'names no declared capability is simply removed. `where` — the console fallback editor\'s ' - + 'drifted spelling for a partial-index predicate, removed when objectui converged that editor ' - + 'onto `IndexSchema` — ' - + 'gets a curated prescription: partial indexes are built at the database layer ' - + '(`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration), never declared here', - reason: - 'The unknown-key strictness campaign held this site open on a measured risk, the kind that ' - + 'had already made a console save answer 422 (a strict schema refusing a key the console ' - + 'itself writes): ' - + 'objectui\'s embedded index editor shipped a drifted hand-copied schema offering `where` ' - + 'and `brin`, spliced its output into `object.indexes[]` and PUT the whole object, so ' - + 'closing the shape would have 422\'d a control the console itself rendered. objectui then ' - + 'converged that editor to the declared surface, spending the hold\'s evidence. Before this ' - + 'close an undeclared key on an index parsed clean and was silently dropped — an admin ' - + 'filling the old "Partial-index predicate" control got a green save while no driver ever ' - + 'read the predicate (`syncDeclaredIndexes` consumes `name`/`fields`/`unique` only). ' - + 'Undeclared keys are now refused at parse time with a prescriptive message; the ' - + 'protocol-17 `type`/`partial` tombstones keep answering their own migration text.', - acceptanceCriteria: - 'Every `indexes[]` entry parses with only `name` / `fields` / `unique`. Declared keys parse ' - + 'byte-identically to before, at every ADR-0120 `unique` spelling. A stored body from the ' - + 'drift window carrying `indexes[].where` is rejected with the database-layer prescription ' - + 'rather than saved with the key silently dropped.', - }, - // #17260 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `object-kanban-quick-add-removed` family (ruling B on #17152: one D3 entry - // per retirement family, even when D2 is lossless). The strip changes nothing a - // user sees; the decision it leaves is whether the board needed the control - // the author asked for. - { - id: 'object-kanban-quick-add-retired', - surface: 'page.component.object-kanban.quickAdd — the per-column quick-add switch on the ' - + 'metadata-driven board', - replacement: '(removed from the metadata board.) Delete the key; `object-kanban` offers no ' - + 'quick-add control. On a metadata board, records are created through the object\'s ordinary ' - + 'create action.', - reason: 'The D2 conversion `object-kanban-quick-add-removed` deletes `quickAdd` from every ' - + '`object-kanban` component, and the delete is lossless: the board forwarded the flag, but the ' - + 'control also needs a host-supplied `onQuickAdd` function that JSON cannot carry and no ' - + 'producer ever put on an object-kanban node, so the gate was permanently false and no board ' - + 'ever showed the control. The residue is the requirement behind the flag. An author who set ' - + '`quickAdd: true` wanted users to add a card inside a column; that never happened and still ' - + 'does not. Whether the board can live without it is a product decision about that board — ' - + 'not something a key delete can make.', - acceptanceCriteria: 'No `object-kanban` component carries `quickAdd`; the parse refuses it. Each ' - + 'board renders the same columns and cards as before the upgrade. For each board that had set ' - + 'the flag, the author has accepted creating records through the object\'s create action: ' - + '`object-kanban` offers no quick-add control.', - }, - // #21589 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `object-master-detail-form-detail-sort-field-removed` family (one D3 entry - // per retirement family, even when D2 is lossless). Registered key: - // `ui/ObjectMasterDetailFormProps:details.sortField`. The strip changes - // nothing a user sees, because the console already ignored the authored - // value; what it leaves is the one judgment a delete cannot make — whether the - // child object carries the field the line order is kept in. - { - id: 'object-master-detail-form-detail-sort-field-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: 'page.component.object-master-detail-form.details[].sortField — a detail entry\'s ' - + 'authored line-position field', - replacement: 'Nothing on the entry: delete the key. The line grid stamps each line\'s position into ' - + 'the child object\'s own field, derived from the child object: its first field named ' - + '`position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. To keep the line ' - + 'order a drag-reorder sets, give the child object one of those fields (under the name the ' - + 'deleted key named, when it is one of them).', - reason: 'The D2 conversion `object-master-detail-form-detail-sort-field-removed` deletes ' - + '`sortField` from every `object-master-detail-form` detail entry, and the delete is lossless: ' - + 'the console stopped reading the authored override, and the line grid stamps the field it ' - + 'derives from the child object whatever the entry says. What the conversion cannot decide is ' - + 'where the line order lives. An entry whose key named a field the derivation does not pick — ' - + 'a name outside that list, or a second sort-named field after the first — saves its line ' - + 'order into the derived field instead, or nowhere when the child object has none. An entry ' - + 'that names `relationshipField` and at least one column and gives every column a `type` is ' - + 'kept exactly as authored: no child schema is loaded for it, so no line position is stamped ' - + 'and a drag-reorder is not saved, before and after the upgrade alike.', - acceptanceCriteria: 'No `object-master-detail-form` detail entry carries `sortField`; the props ' - + 'lint reports one with the prescription. For each entry that had set it, the child object ' - + 'declares the field the line order is kept in under one of the derived names, and after a ' - + 'drag-reorder and save the lines reload in the order they were dragged into.', - }, - // #19054 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18) — the D3 - // entry of the `object-tenancy-organization-field-removed` family (ruling B on - // #17152: one D3 entry per retirement family, even when D2 is lossless). This - // is the family the pre-ruling rationale said had "no semantic residue". The - // delete changes no stamp; what it leaves is an author who asked for a - // divergence and should know they never got it. - { - id: 'object-tenancy-organization-field-retired', - surface: 'object.tenancy.organizationField — the column a platform row is stamped from, as ' - + 'distinct from the column the object is walled by', - replacement: '`tenancy.tenantField` — one column that both walls the object and stamps its ' - + 'platform rows. The stamp-only divergence is a platform-internal fact now, kept for the ' - + 'platform\'s own credential table.', - reason: 'The D2 conversion `object-tenancy-organization-field-removed` deletes the key from every ' - + 'object\'s `tenancy` block in author sources and on stored object rows, and the delete is ' - + 'lossless: the key\'s only readers were three platform-row writers, pinned by name to ' - + 'platform tables, so an application that declared it was never read. The judgment is what ' - + 'the declaration was for. An author who set `organizationField` to a column other than ' - + '`tenantField` asked for platform rows (audit stamps, approval rows, automation-run records) ' - + 'to carry a different organization column than the one walling the data — and never got ' - + 'it. If the object\'s real tenant column is not `organization_id`, the fix is ' - + '`tenancy.tenantField`, which moves the wall as well as the stamp; whether moving the wall ' - + 'is correct for that object is a data-isolation decision only its author can make.', - acceptanceCriteria: 'No object declares `tenancy.organizationField`; the `tenancy` block refuses ' - + 'it with the prescription. Every object whose tenant column is not `organization_id` ' - + 'declares that column as `tenancy.tenantField`. A record created by a user of one ' - + 'organization is stored with that organization in the tenant column, a user of another ' - + 'organization cannot read it, and the audit stamp on the change names the same ' - + 'organization.', - }, - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'observability-cel-predicates-retired', - surface: - 'metrics.slis[].successCriteria, the CEL predicate arm of the union (the structured ' - + '{ threshold, operator, percentile? } arm is untouched) / tracing.sampling.composite[].' - + 'condition, the CEL predicate arm of the union (the structured filter arm is untouched). ' - + 'Both arms were reachable in two spellings: the bare-string shorthand and the ' - + '{ dialect: \'cel\', source } envelope. Reachable wherever metadata is authored or stored: ' - + 'defineStack sources, an exported stack passed to objectstack validate, a POST body on a ' - + 'metrics or tracing config, and a row already sitting in sys_metadata', - replacement: - 'the structured arm each slot already carried, or your observability infrastructure. On ' - + '`successCriteria` write the threshold rule — `{ threshold: 300, operator: \'lt\', ' - + 'percentile: 0.95 }` — which is the shape an SLO product consumes. On a composite sampling ' - + '`condition` write a structured filter: a plain object of match criteria carrying no ' - + '`dialect` key, e.g. `{ service: \'api\', attributes: { \'http.route\': \'/v1/orders\' } }`. ' - + '⚠️ Neither replacement is mechanical, and neither is a like-for-like: a criterion or a ' - + 'sampling rule the structured shape cannot express has no home in application metadata at ' - + 'all and belongs in the SLO product or the OpenTelemetry sampler configuration that ' - + 'actually evaluates it', - reason: - 'DECLARED, DOCUMENTED, AND EVALUATED BY NOTHING — which is why this is a semantic TODO ' - + 'rather than a mechanical strip. Both arms parsed, normalized a bare string to ' - + '`{ dialect: \'cel\', source }`, registered and were served back, and no service, plugin, ' - + 'runtime or CLI path ever read either key: an identity scan over the whole tree finds every ' - + 'hit for `successCriteria`, `ServiceLevelIndicatorSchema` and `TraceSamplingConfigSchema` ' - + 'outside `packages/spec/src` to be a generated artefact or prose, and inside it the only ' - + 'readers are the schemas\' own unit tests plus the two census tests that enumerate ' - + 'expression slots. So an author — very often an AI reading the generated reference page, ' - + 'ADR-0033 — who wrote `successCriteria: \'p95 < 300ms\'` got a green parse and no signal, ' - + 'indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ' - + 'ruled A by the maintainer on 2026-09-18: by the ' - + 'standing criterion that a declared-but-unread capability is kept only when mainstream ' - + 'platforms in the domain have it, application platforms do not carry SLI success criteria ' - + 'or trace-sampling conditions as authorable application metadata — that lives in ' - + 'observability infrastructure (SLO products, OTel sampling policy) and is structured there, ' - + 'not a free expression. The `cron-declared-unwired` family was retired outright under the ' - + 'same ADR after the same measurement. A mechanical D2 strip was weighed and declined: a ' - + 'predicate is an intent no threshold/operator pair or attribute filter records, so ' - + 'stripping the key would delete what the author meant and leave no trace of which SLI or ' - + 'which sampling branch lost it — exactly the judgment a semantic TODO exists to hand back. ' - + '⚠️ And a strip here is not merely lossy, it is INVALID: `successCriteria` is a REQUIRED ' - + 'key, so removing it leaves an SLI that no longer parses, and a composite sampling branch ' - + 'that loses its `condition` declares no condition at all — inert today, and the moment a ' - + 'sampler is wired it reads as UNCONDITIONAL. That is the difference from the ' - + 'two error-map precedents this retirement copies its MECHANISM from — `crypto.hash` on ' - + 'HookBodyCapability and `managedBy: \'system\'` — both of which also registered a D2 ' - + 'conversion, because for each of them a mechanical rewrite existed. Here none does, which is ' - + 'what makes D3 the right disposition rather than merely an available one. ' - + '⚠️ The structured arm of each union is NOT decided here: it is equally unread today, and ' - + 'it is measured on its own card. ADR-0087, ADR-0058 D7, ADR-0049.', - acceptanceCriteria: - 'Sweep every authored metadata source and every `sys_metadata` row of the metrics and ' - + 'tracing config types for a CEL predicate at the two slots — in BOTH spellings: a bare ' - + 'string, and an object carrying a `dialect` key. For each hit, decide per the `replacement` ' - + 'note whether the intent is expressible as the structured shape (write it) or belongs in ' - + 'your observability stack (delete the key and move the rule there). ⛔ Do not translate a ' - + 'predicate into a threshold by guessing the number — nothing was evaluating it, so there is ' - + 'no behaviour to preserve and a wrong number is worse than an absent one. Two proofs. ' - + '(1) `objectstack validate` is clean on a stack authored in config files: a surviving ' - + 'predicate is refused at the slot with the retirement prescription. ⚠️ TWO CHANNELS, and ' - + 'they do not cover the same set — measured, not assumed. `tsc` catches the BARE-STRING ' - + 'spelling at both slots, and the `{ dialect, source }` envelope at `successCriteria` only ' - + '(the structured arm is a closed object literal, so the envelope is an excess-property ' - + 'error). It does NOT catch the envelope at `condition`: the surviving arm there is a record ' - + 'of string to unknown, which admits `{ dialect, source }` structurally, so that one spelling ' - + 'compiles and is refused at PARSE by the arm\'s `dialect` rule. ⛔ Do not read a clean ' - + '`tsc` as a clean sweep of `condition`. The PRESCRIPTION divides differently again: it ' - + 'reaches the author for every refused spelling at `condition`, and for the string spelling ' - + 'only at `successCriteria`, where the envelope is refused by the structured arm\'s own ' - + 'missing-key issues (`threshold`, `operator`). All three legs are pinned in the schemas\' ' - + 'unit tests. (2) For stored rows, load the tenant and confirm every ' - + 'metrics and tracing config still rehydrates: a row carrying a predicate at either slot now ' - + 'fails its parse at the load seam and is reported there, naming the slot. A row whose ' - + '`successCriteria` is a structured rule and whose sampling `condition` objects carry no ' - + '`dialect` key parses byte-identically to before — the retirement removes accepted shapes ' - + 'and adds none.', - }, - { - id: 'package-api-contracts-unmounted-entries-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span. - surface: - 'api.PackageApiContracts.upgradePackage / api.PackageApiContracts.resolveDependencies / ' - + 'api.PackageApiContracts.uploadArtifact — the three contract-map entries that bound ' - + 'POST /api/v1/packages/upgrade, POST /api/v1/packages/resolve-dependencies and ' - + 'POST /api/v1/packages/upload', - replacement: - 'nothing — no route serves any of the three paths, so there is no entry to read instead. ' - + 'Delete every read of `PackageApiContracts.upgradePackage`, ' - + '`PackageApiContracts.resolveDependencies` and `PackageApiContracts.uploadArtifact`, and every ' - + 'URL built from them or from the three hard-coded paths: a request to any of them was never ' - + 'answered. The per-route request/response schemas (`PackageUpgradeRequestSchema`, ' - + '`PackageUpgradeResponseSchema`, `ResolveDependenciesRequestSchema`, ' - + '`ResolveDependenciesResponseSchema`, `UploadArtifactRequestSchema`, ' - + '`UploadArtifactResponseSchema`) stay published, bound to no route. The four surviving entries ' - + '(`listPackages`, `getPackage`, `installPackage`, `uninstallPackage`) are unchanged. If the ' - + 'platform later serves a package upgrade, dependency-resolution or upload route, its entry ' - + 'arrives in the same change that mounts it.', - reason: - 'Maintainer ruling of 2026-09-23 (option A: retire the three contract-map entries that name paths ' - + 'nothing mounts). The contract map is the declaration SDKs, codegen and AI clients are entitled to ' - + 'trust, and three of its seven entries named paths the composed runtime mounts nowhere: the ' - + 'package dispatcher has no branch for a single-segment POST under /packages and ' - + '`@objectstack/rest` mounts only /packages/publish there, so all three answered handled=false ' - + 'while the four surviving entries answer 200/201 (measured on one HttpDispatcher over a real ' - + 'SchemaRegistry) — and the generated reference page printed ' - + 'all three as live endpoints. Unlike `installPackage` (rebound by an earlier fix onto the serving ' - + 'POST /api/v1/packages), no serving door existed to rebind them onto, and mounting three ' - + 'capabilities with zero measured pull was ruled out (ADR-0049 enforce-or-remove). Zero ' - + 'consumers measured at the retiring PR\'s base: across this repository the three paths occur ' - + 'only in the declaring file, its unit test and the generated page, and the pinned objectui ' - + 'checkout names none of the three keys, none of the paths and not `PackageApiContracts` ' - + 'itself. A contract-map entry is not metadata — nothing authors, stores or parses it — so ' - + 'there is no source a D2 conversion could rewrite, and the removal is recorded here.', - acceptanceCriteria: - 'No code reads `PackageApiContracts.upgradePackage`, `.resolveDependencies` or ' - + '`.uploadArtifact` from `@objectstack/spec` or `@objectstack/spec/api` — each is a TS2339 ' - + 'property error after upgrade, and at runtime the key is absent (pinned in ' - + 'api/package-api.test.ts together with the rule that no surviving entry is bound to any of the ' - + 'three paths). No client, route table or generated artefact of yours still names ' - + 'POST /api/v1/packages/upgrade, /resolve-dependencies or /upload. No metadata document needs ' - + 'editing. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever mounted the three paths ' - + 'or built a route from the entries, so every request answers exactly as before — the removal ' - + 'retracts a false claim, not a capability.', - }, - // Registered as a structured TODO (ADR-0087 D3) rather than a conversion (D2), - // for the reason every strictness entry gives: an arbitrary unknown key has no - // mapping target, and deleting it automatically is the silent data loss - // ADR-0078 bans. Nothing is retired either — no key leaves the declaration — - // so there is no RETIRED_KEYS entry: the refusal is the shape's own strict - // close naming the key. HTTP-only, like the list-pagination entry beside it: - // nobody authors a PackageInstallRequest into a metadata source and nothing - // persists one, so `objectstack migrate meta` has no file to rewrite and the - // TODO lands with whoever builds the request body. - { - id: 'package-install-request-unknown-keys-refused', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'api.installPackage request body, WRAPPED form — an undeclared TOP-LEVEL key beside ' - + 'manifest on POST /api/v1/packages (PackageInstallRequestSchema, the wrapped branch of ' - + 'PackageInstallBodySchema)', - replacement: - 'the declared wrapped body: `manifest`, plus any of the declared install options ' - + '(`settings`, `enableOnInstall`, `overwrite`, `platformVersion`, `artifactRef`). A ' - + 'misspelled option is respelled as the option it meant — `enabledOnInstall` → ' - + '`enableOnInstall`, which the refusal itself offers — and any other undeclared key is ' - + 'removed. The bare form (a manifest as the whole body) is unchanged: it was already ' - + 'closed, and it still carries no install options.', - reason: - 'One rule for the whole install contract (the maintainer\'s ruling of 2026-09-27, option A: ' - + 'the wrapped form refuses an unknown top-level key by name). The manifest and the bare form ' - + 'already refused an unknown key by ' - + 'name; the wrapped top level was the one position still declared strip mode, so ' - + '`{ manifest, enabledOnInstall: false }` — a misspelled `enableOnInstall` — parsed green ' - + 'with the key DROPPED, and the install door, which answers exactly what this declaration ' - + 'says since it parses the whole body (c02fa1276), installed the package ENABLED: the ' - + 'caller\'s explicit `false` inverted, with no word said. The sentence that had forbidden ' - + 'this close rested on «the declaration must not refuse a body the door answers 201 to», ' - + 'which held only while the door did not parse its body; with the door answering per ' - + 'declaration the premise became circular and constrains nothing. No alias and no grace ' - + 'window. Not losslessly convertible: an unknown key has no mapping target, and ' - + 'auto-deleting it would repeat the silent drop this closes, so each occurrence needs the ' - + 'caller\'s decision — respell or remove. First-party reach measured before the close: ' - + 'the SDK install call sends only `manifest`, `settings`, `enableOnInstall` and ' - + '`overwrite`, and the objectui package dialog sends only `{ manifest }`, so no in-repo ' - + 'caller breaks. Out-of-repo callers are NOT MEASURED — a caller that sends a private ' - + 'top-level key now gets a 400 naming it.', - acceptanceCriteria: - 'Every wrapped install body carries only `manifest` and declared install options. A body ' - + 'with any other top-level key is refused `400` / `VALIDATION_ERROR` at POST ' - + '/api/v1/packages with the key named and nothing installed, and PackageInstallRequestSchema ' - + 'answers the same body with one `unrecognized_keys` issue at the top level naming the ' - + 'key. `{ manifest }` alone, and `manifest` with every declared option, parse and install ' - + 'exactly as before.', - }, - // The carrier nobody had named: a bare `z.string()` on a published schema, - // constraining nothing while its own siblings enforced a grammar. Its narrowing - // is the widest of the four by accept-set area and the least likely to be felt, - // because what it starts refusing is not a version at all. - { - id: 'package-manifest-version-grammar-enforced', - surface: 'PackageManifestSchema.version (`marketplace/package-version.zod.ts`) — the ' - + '`version` key inside the manifest snapshot frozen into ' - + '`sys_package_version.manifest_json` at publish time', - replacement: 'a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` ' - + '(`kernel/version-grammar.ts`). This key was a bare `z.string()`, so it is the one ' - + 'carrier where the grammar is entirely new: `latest`, `v1.0.0`, `1.0`, the empty ' - + 'string, a trailing space and `2.0.0-beta.1extra!` were all accepted and sealed into ' - + 'a published snapshot, and each is refused now. A dist-tag becomes the version it ' - + 'pointed at (`latest` → `1.4.2`); a `v`-prefixed string drops the prefix (`v1.0.0` → ' - + '`1.0.0`); a two-segment string gains its patch (`1.0` → `1.0.0`).', - reason: - 'A downstream told "the spec validated it" got no validation at all from this carrier. ' - + 'The sibling key it belongs to — `PackageVersionSchema.version`, the row this manifest ' - + 'hangs off — enforced a grammar the whole time, so the SAME release was judged by a ' - + 'rule in one field and by nothing in the adjacent one, and the unjudged value is the ' - + 'one that got frozen and shipped. That is the shape Prime Directive #10 refuses: a ' - + 'declaration advertising a constraint the runtime never applies. The canon ruling gave ' - + 'every carrier of this concept one grammar, and a carrier with no grammar could not be ' - + 'left out of it without keeping the hole open under a new name. Why a D3 semantic TODO ' - + 'rather than a D2 conversion: the repairs above are one-directional guesses. `latest` ' - + 'names whichever release was current when the snapshot was sealed, which is not ' - + 'recoverable from the snapshot, and `1.0` may mean `1.0.0` or the newest `1.0.x` — a ' - + 'transform that picked either would seal a different release under the same checksum.', - acceptanceCriteria: - 'Every package your registry serves still installs, and `manifestJson.version` parses ' - + 'for each one. The check is cheap and exhaustive: read `manifest_json` on each ' - + '`sys_package_version` row and test its `version` against the grammar. A row that ' - + 'fails was already carrying a value no other carrier would have accepted — confirm ' - + 'what release it was meant to name before choosing the replacement, because the ' - + 'snapshot cannot tell you, and republish rather than editing a frozen snapshot in ' - + 'place. In this repository the measured count of such rows is zero.', - }, - { - id: 'package-rollback-response-retired', - surface: - 'api.packageRollbackResponse (`PackageRollbackResponseSchema` in ' - + 'api/package-api.zod.ts — 1 def, 3 exported names: ' - + '`PackageRollbackResponseSchema`, `PackageRollbackResponse`, ' - + '`PackageRollbackResponseParsed` — plus the `PackageApiContracts.' - + 'rollbackPackage` contract-map entry that bound it to ' - + '`POST /api/v1/packages/:packageId/rollback`)', - replacement: - '`RollbackToPackageCommitResponseSchema` (api/package-lifecycle.zod.ts) — ' - + 'the transcription of what the live route actually answers: the ' - + 'dispatcher routes `POST /packages/:id/rollback` (body `{ commitId }`) ' - + 'to `rollbackToPackageCommit`, the ADR-0067 COMMIT rollback, whose ' - + 'declared return is `{ success, revertedCommits: string[], ' - + 'failed: Array<{ commitId, error }> }`. Consumers of the retired type ' - + 'were reading a VERSION-rollback shape (`restoredVersion`) the route has ' - + 'never answered; read `revertedCommits`/`failed` instead. ' - + '`PackageRollbackRequestSchema` stays published (ruled out of the ' - + 'retirement), bound to no route.', - reason: - 'Maintainer ruling of 2026-08-27 on the client SDK\'s unbound response ' - + 'contracts, sub-question 3A: retire this false declaration first, then ' - + 'author the true one. The schema declared a version rollback — ' - + '`{ success, restoredVersion?, message? }`, matching its file header ' - + '"Rollback a package" — while the live path it was contract-bound to ' - + 'serves the ADR-0067 commit rollback: a different operation with a ' - + 'different result. Binding it in the SDK would compile and be false ' - + '(the change that typed the SDK\'s un-annotated return values left a ' - + 'compile-time guard against exactly that substitution). ' - + 'Zero consumers measured across objectstack, objectui and cloud ' - + '(the ruling\'s own survey, re-verified at the retiring PR\'s base): only its ' - + 'own unit test and that negative guard. A published declaration that ' - + 'outran the implementation is the hazard of response bodies never checked ' - + 'against the schemas that declare them, realised in the opposite direction ' - + '— not "no declaration" but a WRONG one — and it is ' - + 'retired BEFORE the true schema is authored so no window exists in ' - + 'which both claims are published.', - acceptanceCriteria: - 'No code imports `PackageRollbackResponseSchema`, ' - + '`PackageRollbackResponse` or `PackageRollbackResponseParsed` from ' - + '`@objectstack/spec` or `@objectstack/spec/api` — every one is TS2305 ' - + 'after upgrade (pinned by runtime namespace probes in ' - + 'api/package-api.test.ts). `PackageApiContracts` carries no entry whose ' - + 'path is `/api/v1/packages/:packageId/rollback` (same pin). No metadata ' - + 'document needs editing: the schema was reachable from no metadata-type ' - + 'binding, stack collection or /meta door. ⚠️ Runtime behaviour is ' - + 'deliberately UNCHANGED: nothing ever registered routes or generated ' - + 'SDKs from the contract entry, and the route\'s handler emits the same ' - + 'bytes before and after — the retirement removes a false claim, not ' - + 'behaviour.', - }, - // ADR-0131 D6/D12 (C5, stage S4) — the ruled retirement of the uninstall's - // organization-scope guard: once every package-owned metadata row is - // environment-wide, an uninstall is environment-wide by construction and an - // organization names nothing. Retires what `package-uninstall-explicit-all-tenants` - // (protocol 17) introduced. - { - id: 'package-uninstall-environment-wide', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the organizationId and allTenants members of the deletePackage request of ' - + '@objectstack/metadata-protocol (DeletePackageRequest), the two TENANT_SCOPE_REQUIRED refusals ' - + 'of deletePackage, and the organization-scope refusal of DELETE /api/v1/packages/:id on the ' - + 'runtime dispatcher', - replacement: - 'call `deletePackage({ packageId })` with neither key: the uninstall removes every row bound to ' - + 'the package in this environment. A request still carrying `organizationId` or `allTenants` ' - + 'is refused with 400 `INVALID_REQUEST` and removes nothing; drop the key and retry. Who may ' - + 'uninstall is the package door\'s operator gate', - reason: - 'The guard existed because an uninstall naming no organization once matched every ' - + 'organization\'s rows, so a cross-tenant uninstall had to be declared (allTenants: true) and a ' - + 'scoped one named (organizationId). ADR-0131 D6 removes that premise: no metadata write lands ' - + 'organization-scoped any more, so a package\'s rows belong to the environment and an ' - + 'organization names nothing. The HTTP door never sent allTenants, so an operator with no active ' - + 'organization could not uninstall over HTTP at all. The keys are refused rather than ignored, ' - + 'because a caller still sending one believes it scopes the uninstall. Legacy ' - + 'organization-scoped rows bound to the package are removed with it, as the declared ' - + 'cross-tenant uninstall removed them, rather than stranded for the promotion ceremony.', - acceptanceCriteria: - 'DELETE /api/v1/packages/:id by a manage_metadata caller with no active organization succeeds ' - + 'and removes every sys_metadata row bound to the package, environment-wide and legacy ' - + 'organization-scoped alike; the same call with an active organization behaves identically. A ' - + 'deletePackage request carrying organizationId or allTenants (true or false) answers 400 ' - + 'INVALID_REQUEST and changes nothing. No response carries TENANT_SCOPE_REQUIRED. Remove both ' - + 'keys from every deletePackage caller, and any deploy script that passed allTenants: true.', - }, - // The published release row's half of the version canon. It moves in BOTH - // directions at once — gaining uppercase identifiers, losing the degenerate - // forms — which is why the prescription below has to state each separately - // rather than reading as one tightening. - { - id: 'package-version-row-semver-2-0-0', - surface: 'PackageVersionSchema.version (`marketplace/package-version.zod.ts`) — the ' - + '`version` column of a `sys_package_version` row, and through ' - + '`CreatePackageVersionRequestSchema.version`, which references it, the version a ' - + 'draft release is created with', - replacement: 'a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` ' - + '(`kernel/version-grammar.ts`). Two changes, opposite in direction. ⭐ WIDER: suffix ' - + 'identifiers may now carry either ASCII case, because SemVer 2.0.0 is ' - + 'case-preserving — `1.0.0-Beta.1` and `1.0.0+Build.5` are accepted where this key ' - + 'used to demand lowercase, and the plugin boot path has always accepted them. ⛔ ' - + 'NARROWER: the forms the standard forbids are refused — `01.1.1` (§2), `1.0.0-0123` ' - + 'and `1.0.0-alpha..1` (§9), `1.0.0+.` (§10).', - reason: - "This key's own docstring advertised `2.0.0-beta.1` as an example of itself while a " - + 'sibling carrier of the same concept refused that exact string — the contradiction ' - + 'the canon card was filed over. The lowercase restriction was the narrowest published ' - + 'accept set of the four and had no standard behind it: it made a release row refuse a ' - + 'version the runtime that loads the release accepts, so a publisher could be turned ' - + 'away for a capitalisation the loader would never have noticed. Why the narrowing is ' - + 'a D3 semantic TODO rather than a mechanical rewrite: a published version row is ' - + 'immutable by contract — `manifestJson` and `checksum` freeze on transition to ' - + '`published` — so a stored degenerate version is not edited in place at all. It is ' - + 'republished under a version that sorts, and whether the old row should be deprecated ' - + 'or left standing is a release decision the chain cannot make.', - acceptanceCriteria: - 'Publishing and installing every release you have works unchanged. The widening needs ' - + 'no action and can be confirmed cheaply: a mixed-case prerelease that used to be ' - + 'refused at publish now creates a draft. For the narrowing, list your ' - + '`sys_package_version` rows and check each `version` against the grammar — a leading ' - + 'zero in a numeric segment, or a doubled or trailing dot in a suffix, are the only ' - + 'shapes affected. Any row that fails stays readable and installable; what it can no ' - + 'longer do is receive a NEW draft at that spelling, so cut the next release at a ' - + 'version that sorts.', - }, - { - id: 'packages-list-pagination-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'api.listPackages limit and cursor — the two query parameters of ' - + 'GET /api/v1/packages declared by ListInstalledPackagesRequestSchema. The same entry ' - + 'covers the limit default: the request schema no longer declares default(50)', - replacement: - 'the `status`, `type` and `enabled` filters — this route answers the whole installed set and has ' - + 'no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted ' - + 'one, so no caller holds a value to carry over, and the response `nextCursor` it ' - + 'would have paired with was never emitted. Callers that looped on it were re-reading ' - + 'the first and only page. For the removed `limit` default, there is nothing to send ' - + 'instead and nothing to restore: the server has never capped this list, so a caller ' - + 'that omitted the key received every installed row before this change and receives ' - + 'every installed row after it. A client that sized a buffer to the declared 50 should ' - + 'size it to the installed set instead', - reason: - 'One capability, both halves, never half-deleted (maintainer ruling 2026-09-13, ' - + 'route 2 of three; routes 1 — build paging — ' - + 'and 3 — refuse unknown names — were considered and refused). `limit` and `cursor` ' - + 'were declared on the request and honoured on neither: the serving door filters on ' - + '`status`, `type` and `enabled` and then returns every remaining row, and no emit site has ever ' - + 'written the response half `nextCursor`. `limit` is the sharper of the two because ' - + "the repo's own ingress rule names it as the parameter whose silent drop is worst, " - + 'and it is the silent-WIDENING half that was live: a caller asking for one row was ' - + 'handed the whole table alongside a `hasMore: false` that agreed with it. ' - + 'The `.default(50)` goes with the key because the FICTION WAS THE MECHANISM, not the ' - + 'number: nothing parses a query string through this schema, so the default has never ' - + 'stamped anything onto anything, while a reader of the published contract was ' - + 'entitled to believe an unparameterised list is capped. Re-spelling it as the real ' - + 'cap was not available — there is no cap. ' - + 'Pagination was removed rather than implemented because the installed-packages list ' - + 'is a small bounded collection and paging is not part of its meaning: route 1 would ' - + 'have grown a cursor protocol for a table of tens of rows, and the dispatch checked ' - + 'first whether a platform-wide cursor convention already existed that this door could ' - + 'have joined by reuse. It does not — no REST list door in the tree paginates, the one ' - + 'encode/decode cursor pair in the repo belongs to the storage-adapter list contract ' - + 'and is imported by no door, and the travel of this platform is the other way: ' - + '`data.query.cursor` and `api/ListNotificationsRequest:cursor` were ' - + 'both retired before this one, for the same reason. ' - + 'Route 2, and the bookkeeping splits exactly as the notifications `cursor` retirement ' - + 'did. There IS a tombstone: the ' - + 'schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever ' - + "a generated client kept sending — a clean parse and a parameter that never takes " - + "effect, which is this issue's own defect re-created one layer down (ADR-0104). So " - + 'both keys are `retiredKey()`, typed `never` for tsc and raising the prescription at ' - + 'any parse, and both are registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 ' - + 'conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, ' - + 'and this shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and ' - + 'nothing persists one. There is no `acceptRetiredDefaultResidue` stage either, for ' - + 'the same reason one layer along: nothing ever parsed this schema, so the retired ' - + 'default materialized into no artifact and there is no residue to accept. ' - + 'The same card closes the divergence in the OTHER direction, which is not a migration ' - + 'for anyone and is recorded here only so the two are not read apart: `type` (list), ' - + '`version` (by-id) and `keepData` (uninstall) are query parameters the doors already ' - + 'executed and no request schema declared, and they are now declared where they are ' - + 'executed. No accept set moves — the doors served them before and serve them ' - + 'identically now. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No caller sends `limit` or `cursor` to `GET /api/v1/packages`: writing either on a ' - + '`ListInstalledPackagesRequest` is a `tsc` error (the input type is `never`), which ' - + 'is the enforced channel, and any value reaching a parse raises the prescription ' - + 'rather than a generic unrecognized-key issue. ' - + '⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a ' - + 'request still carrying `?limit=1&cursor=x` is IGNORED, not refused — the door reads ' - + 'named query keys and no route validates this query against a schema, so an unknown ' - + 'key has never produced a 400 and does not start doing so here. The declaration ' - + 'stopped promising what the wire never did; the wire did not change. `hasMore` stays ' - + 'the constant `false` it already was and is now true by construction rather than by ' - + 'coincidence — with no request-side way to ask for a page there can be no next one — ' - + 'and `nextCursor` stays absent. A caller that omitted `limit` receives every ' - + 'installed row, exactly as it did before.', - }, - { - id: 'page-assigned-profiles-audience-to-permission-set', - surface: '`page.assignedProfiles` — the per-page audience list (REMOVED)', - replacement: - "the object's permission sets, bound to people through positions. The page shows DATA; gate " - + 'that data with the permission sets on the objects it reads (`objects..allowRead` and ' - + 'the field-level bits), and bind each set to the people who should hold it through a position ' - + '(`sys_position_permission_set`). There is no per-page audience key to move the list into, ' - + 'and ADR-0090 D2 deleted the Profile concept the old list was written in, so each name in a ' - + 'retired `assignedProfiles` list has to be re-expressed as a permission set + position pair.', - reason: - 'The D2 conversion `page-assigned-profiles-removed` STRIPS the key mechanically, but the strip ' - + 'is not the whole migration and must not read as one: the author who wrote the list was ' - + 'declaring an intent ("only these people see this page") that the platform never honoured. ' - + 'Measured at the ruling: zero readers in this repository and zero in objectui — no renderer, ' - + 'route or metadata read door consulted the key — so the page has been open to every caller ' - + 'who could reach it for as long as the key existed. Deleting it therefore changes no ' - + 'behaviour and closes no hole; it makes an unkept promise stop being made. Which permission ' - + 'set corresponds to a given profile name is a judgement no walker can derive, which is why ' - + 'this is a TODO rather than a rewrite.', - acceptanceCriteria: - 'No page metadata carries `assignedProfiles` (the D2 conversion ' - + '`page-assigned-profiles-removed` strips it from authored sources on a chain replay; ' - + '`os migrate meta --stored` covers rows already at rest). For every page that carried one, ' - + 'each name in the old list resolves to a permission set held by the intended people through ' - + 'a position, and a caller OUTSIDE that audience, signed in, is refused the data the page ' - + 'reads — verified against the running deployment, not against the metadata alone. A caller ' - + 'who was previously outside an `assignedProfiles` list and could nonetheless open the page ' - + 'is the pre-existing state, not a regression introduced by the removal.', - }, - // #11027 (ADR-0049) — the D3 entry of the `page-component-responsive-removed` - // family (ruling B on #17152: one D3 entry per retirement family, even when D2 - // is lossless). The family is the key plus the shape that leaves with it — - // `ui/ResponsiveConfig`, `ui/BreakpointColumnMap`, `ui/BreakpointName` and - // `ui/BreakpointOrderMap` in RETIRED_DEFS_BY_MAJOR[18]. The strip changes no - // pixel; translating an intended layout into CSS that IS applied is a design - // decision the conversion cannot make. - { - id: 'page-component-responsive-retired', - surface: 'page.components[].responsive — the per-breakpoint columns / order / hiddenOn block, ' - + 'and the exported ResponsiveConfig shape with its breakpoint maps', - replacement: 'The sibling `responsiveStyles` (ADR-0065): per-breakpoint CSS maps compiled to ' - + 'id-scoped CSS at render — for example `responsiveStyles: { xsmall: { display: \'none\' } }` ' - + 'to hide a component on the narrowest screens.', - reason: 'The D2 conversion `page-component-responsive-removed` deletes `responsive` from every ' - + 'page component wherever one can be authored, and the delete is lossless: no renderer ever ' - + 'read the block, so the per-breakpoint columns, order and visibility it declared parsed, ' - + 'validated and did nothing. This was also the block an earlier tombstone prescribed as the ' - + 'live alternative for dashboard widgets, so an author who followed that advice moved an ' - + 'inert key to an inert key and may still believe their page adapts to small screens. What ' - + 'remains is theirs to decide: whether the layout they declared is one they still want, and ' - + 'if so how to say it in CSS that is applied — `hiddenOn` maps to a `display` rule per ' - + 'breakpoint, while column spans and order are layout choices with no one-to-one CSS ' - + 'rewrite. Code that imported the retired shape (ResponsiveConfigSchema, BreakpointName, the ' - + 'breakpoint maps) must drop the import; nothing replaces it.', - acceptanceCriteria: 'No page component carries `responsive`; the parse refuses it, and no code ' - + 'imports the retired shape (each such import is a compile error). Each page renders exactly as ' - + 'it did before the upgrade at every breakpoint. Where the author re-expressed an intended ' - + 'adaptation through `responsiveStyles`, resizing the viewport across the named breakpoints ' - + 'shows it — a component declared hidden on the narrowest breakpoint is absent there and ' - + 'present above it.', - }, - // #20758 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `page-header-breadcrumb-removed` family (one D3 entry per retirement family, - // even when D2 is lossless). Registered key: `ui/PageHeaderProps:breadcrumb`. - // The strip changes no trail, because none was ever drawn; what it leaves is - // the one visible trace either value had, the empty slot's spacing. - { - id: 'page-header-breadcrumb-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: 'page.component.page:header.breadcrumb — the page header\'s "Show breadcrumb" switch', - replacement: - 'Nothing: delete the key, whether it was `true` or `false`. The navigation trail is drawn once, ' - + 'by the app shell\'s header, and is unchanged.', - reason: - 'The D2 conversion `page-header-breadcrumb-removed` deletes `breadcrumb` from every page header, ' - + 'and no trail is lost: the renderer drew an empty slot for it and nothing ever filled that slot. ' - + 'The slot was the only thing either value changed — present for `true` and for an absent key, ' - + 'gone for `false` — so a header that said `false` reads as absent after the strip and shows the ' - + 'empty slot\'s spacing again until the renderer stops drawing it. What the conversion cannot ' - + 'decide is whether a page needs a trail of its own: inside an app the shell already draws one, ' - + 'and a page outside the shell that needs one is a feature to ask for, not a key to keep.', - acceptanceCriteria: - 'No page header carries `breadcrumb`, and the props lint reports one with the prescription. Every ' - + 'page shows the same navigation trail in the app shell\'s header as before the upgrade, and each ' - + 'page header shows the same title, subtitle and actions.', - }, - // #21459 — the D3 entry of the `page-requires-non-compiled-kind-removed` family - // (one D3 entry per retirement family, even when D2 is lossless): page - // `requires` narrowed to the kinds whose source the save door compiles, ruling - // record 5964312254, letter A. It is a narrowing by `kind`, not a key removal: - // the key stays live on html / jsx pages, so there is no tombstone and no - // RETIRED_KEYS_BY_MAJOR row — the parse refuses it through - // `checkPageRequiresKind` (ui/page.zod.ts) on the other kinds. - { - id: 'page-requires-non-compiled-kind-refused', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - surface: - 'page.requires on a page whose kind is react, full or slotted — a page that omits kind ' - + 'included, since its kind is full', - replacement: - 'Nothing: delete the key. On an html page (and its deprecated jsx alias) the platform derives ' - + '`requires` from the source at save and stores it, so it is omitted there too; on a react, full ' - + 'or slotted page nothing ever derived or enforced it, and nothing takes its place.', - reason: - '`PageSchema` admitted `requires` on every page kind, but the platform derives it only on the ' - + 'kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server ' - + 'that has the deployment\'s SDUI component manifest compiles the source, stores the plugin ' - + 'namespaces it uses as `requires`, and refuses a written list that disagrees. A react source is ' - + 'executed at render and never compiled at save, and full and slotted pages have no source, so on ' - + 'those kinds nothing derived the key, the Studio page editor dropped it on every save, and its ' - + 'one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted ' - + 'only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a ' - + 'page that omits kind is a full page: `objectstack validate`, the metadata save door (a 422) and ' - + 'every other door that parses a page name the key, the page\'s kind and the compiled kinds. An ' - + 'empty list is refused like a full one, because the key is what is refused. No page body ' - + 'authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. ' - + 'The D2 conversion `page-requires-non-compiled-kind-removed` deletes it from such pages: stored ' - + 'rows and built artifacts replay it at load, with a notice, and `objectstack migrate meta --from ' - + '17` lists the edit for authored sources, which the parse refuses until it is made. The delete ' - + 'loses nothing a page did. What it cannot decide is whether the page should have been an html ' - + 'page: an author who wrote the list to have plugin presence checked gets that check only on an ' - + 'html page, where the platform derives the list from the source and judges it at save and load.', - acceptanceCriteria: - '`objectstack validate` reports no issue at a page\'s `requires` path: no react, full or slotted ' - + 'page, and no page that omits kind, carries the key, and each html or jsx page either omits it ' - + 'or carries exactly the list its source compiles to. Saving each formerly affected page through ' - + 'the metadata API succeeds instead of answering a 422 that names `requires`. Replaying ' - + '`objectstack migrate meta --from 17` over the edited source lists no ' - + '`page-requires-non-compiled-kind-removed` edit, and every page renders as it did before the ' - + 'upgrade.', - conversionIds: ['page-requires-non-compiled-kind-removed'], - }, - // #22568 — page `slots.details` refused beside `slots.tabs`. A narrowing of - // the slot map, not a key removal: both slots stay live on their own, so there - // is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the parse refuses the - // pair through `checkPageSlotPair` (ui/page.zod.ts). No D2 conversion: where - // the details body goes inside an authored tab strip (which item, under which - // label) is the author's call, and the move makes visible a body that never - // rendered. - // - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - { - id: 'page-slots-details-beside-tabs-refused', - surface: - 'page.slots.details authored beside page.slots.tabs on one page — either slot a single component ' - + 'or an array, an empty details array included, whatever the page kind', - replacement: - 'One `tabs` slot whose items carry the details body: the `record:details` component (its ' - + '`sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by ' - + 'convention — e.g. `{ label: \'Details\', children: [{ type: \'record:details\', properties: { … } }] }` ' - + '— and no `details` slot. To keep the synthesized tab strip with the authored details body in ' - + 'its Details tab instead, delete `slots.tabs`.', - reason: - 'The slot map declared `details` and `tabs` as two independent optional slots, and they are not: ' - + 'on a slotted record page `details` replaces the body of the Details tab, that tab lives inside ' - + 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console\'s ' - + 'default-page synthesizer therefore reads `tabs` and never reads `details` when both are ' - + 'authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save ' - + 'door while the authored details body — its sections, its hidden fields — silently never ' - + 'applied. The platform\'s own `sys_user_detail` page authored both, so its Identity and Audit ' - + 'sections never showed and the ban columns it hides were never hidden by it; it now carries its ' - + '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, ' - + 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), ' - + '`objectstack validate` and the metadata save door (`422 INVALID_METADATA`). ' - + 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the ' - + 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and ' - + '`discussion` only. ' - + '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the ' - + 'author\'s decision, and moving it makes visible a body that never rendered — a change to the ' - + 'page, not a respelling. ' - + '⚠️ A page row already stored with the pair is replayed unchanged at load (no conversion ' - + 'touches it), so it renders as before — the authored tabs, without the details body — while ' - + 'its read diagnostics name the pair and saving it again is refused until the details body ' - + 'moves. ADR-0087.', - acceptanceCriteria: - 'Grep every page in `defineStack` pages sources, exported stacks and every page row in ' - + '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the ' - + '`details` component(s) into the `tabs` items — as the `children` of a tab item, the first one by ' - + 'convention, with its `sections` and `hideFields` unchanged — and delete `slots.details`, or ' - + 'delete `slots.tabs` to keep the synthesized tabs. Then `objectstack validate` reports nothing ' - + 'at `pages.N.slots.details`, saving each formerly affected page through the metadata API ' - + 'succeeds instead of answering a 422 that names `slots.details`, and the record page shows the ' - + 'authored details body (its sections, without its hidden fields) in the tab that now carries it. ' - + 'A page authoring only one of the two slots parses and renders byte-identically to before.', - }, - // #12497 (ADR-0049, maintainer ruling accepting #1883's recommendation B) — the - // D3 entry of the `permission-allow-restore-purge-removed` family (ruling B on - // #17152: one D3 entry per retirement family, even when D2 is lossless). The - // family is the four registered keys: `allowRestore` / `allowPurge` on - // `security/ObjectPermission` and on `security/EffectiveObjectPermission`. - // The strip changes no access decision; what it leaves is a governance - // question, because the bits sat on the two most destructive verbs. - { - id: 'permission-restore-purge-bits-retired', - surface: 'permission.objects..allowRestore / permission.objects..allowPurge — ' - + 'the object-permission bits for undelete and hard delete', - replacement: '(removed — the `restore` and `purge` operations they claimed to gate do not ' - + 'exist.) A dispatched `restore` or `purge` is denied fail-closed by the permission ' - + 'evaluator\'s destructive-operation backstop for every principal. The bits return together ' - + 'with the operations they gate. `allowTransfer`, the third lifecycle bit, is enforced and ' - + 'stays.', - reason: 'The D2 conversion `permission-allow-restore-purge-removed` deletes both keys from every ' - + 'object permission in author sources (both values, `true` included), and the delete is ' - + 'lossless: no destructive lifecycle verb is in the engine\'s dispatch vocabulary, so a grant ' - + 'delivered nothing and a denial locked nothing — every such request was, and stays, denied. ' - + 'The judgment is about what people believed. An admin who wrote `allowPurge: false` believed ' - + 'a lock existed; an admin who wrote `allowPurge: true` for a compliance role believed that ' - + 'role could hard-delete a record on request — a GDPR erasure, for instance. Neither was ever ' - + 'true. Any process, runbook or audit statement that relies on either belief needs another ' - + 'path, and deciding that path is a governance decision no conversion can make. Separately, ' - + 'an artifact built by the 17.x toolchain carries both keys materialized as the literal ' - + '`false`; that one value is tolerated at load as inert residue and stripped, and every ' - + 'other value — `true`, or a string or number spelling — is refused with the prescription.', - acceptanceCriteria: 'No authored object permission carries `allowRestore` or `allowPurge`; the ' - + 'parse refuses any value but the tolerated `false` residue. Access decisions are unchanged: ' - + 'a request for `restore` or `purge` is denied for every principal before and after the ' - + 'upgrade, and `allowTransfer` behaves as before. Every documented process that assumed a ' - + 'restore or purge grant — an erasure-request runbook, an access review, an audit control — ' - + 'names the mechanism it actually uses instead.', - relevantWhen: { kind: 'stack-declares', keys: ['permissions'] }, - }, - // #20321 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `permission-rls-tags-removed` family (ruling B on #17152: one D3 entry per - // retirement family, even when D2 is lossless). The strip changes no access - // decision; what it leaves is whatever process was built on the belief that a - // policy's tags were read. - { - id: 'permission-rls-tags-retired', - surface: 'permission.rowLevelSecurity[].tags — the free-form categorization tags on a row-level ' - + 'security policy', - replacement: '(removed — no mainstream platform tags a row-level policy, and nothing here ever ' - + 'read one.) A policy is identified by its `name` and its `object`, and reported by those and ' - + 'its predicate; its purpose belongs in `description`. Whom a policy applies to is decided by ' - + '`positions`, never by a tag.', - reason: 'The D2 conversion `permission-rls-tags-removed` deletes `tags` from every row-level ' - + 'security policy in author sources and in stored permission rows, and the delete is ' - + 'lossless: the RLS compiler never consulted the key and nothing else acted on it — no ' - + 'report, audit filter or review queue selected on it — so no access decision changes. ' - + 'The judgment is about what people ' - + 'believed. An admin who tagged a policy `gdpr` or `pci` may have expected a compliance ' - + 'report, an audit filter or a review queue to pick it up; none ever did. An author who ' - + 'wrote a tag such as `managers_only` may have believed it scoped the policy; it never did ' - + '— only `positions` narrows whom a policy applies to. Any report, runbook or control that ' - + 'relies on either belief needs another path, and choosing that path is a governance ' - + 'decision no conversion can make.', - acceptanceCriteria: 'No authored or stored row-level security policy carries `tags`; the parse ' - + 'refuses the key with the prescription. Access decisions are unchanged: every policy admits ' - + 'and refuses exactly the rows it did before the upgrade. Every policy whose tag expressed an ' - + 'audience has that audience in `positions`, and every compliance report, audit filter or ' - + 'review process that assumed policy tags names the mechanism it actually uses instead.', - }, - // #15207 (ADR-0131 D7, C6 item 4) — the #12699 deployment declaration made - // total: an object a deployment declares platform-global - // (OrgScopingEntitlement.platformGlobalObjects) loses its injected organization - // column ON THAT DEPLOYMENT. A column retirement keyed on a deployment fact, not - // a spec-key retirement: no authorable key moves, so nothing lands in - // RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with. The stand-down - // semantics it replaces retire with it. Existing rows keep the orphaned column - // until the operator removes it (ADR-0131 D14; C7's inventory and the - // declarer's own backfill), which this entry does not perform. - { - id: 'platform-global-object-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'OrgScopingEntitlement.platformGlobalObjects — an object a deployment declares platform-global no ' - + 'longer keeps its injected organization_id column with the organization wall stood down over it; on ' - + 'that deployment the injected-columns plan withholds the column, and the engine registers the object ' - + 'with no organization_id and declaring systemFields.tenant false', - replacement: - 'Nothing to rewrite where no deployment declares the object. On the declaring deployment, the declared ' - + 'object has no `organization_id`: rewrite any authored filter, list-view column, report grouping, ' - + 'formula or seed key that names `organization_id` on it, or drop it; a write naming it is refused ' - + '`INVALID_FIELD` and a filter `INVALID_FILTER`. The object is governed by object permission, not by ' - + 'the organization wall', - reason: - 'ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that ' - + 'deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by ' - + 'having nothing to scope". Before this, the declaration stood the security layer\'s organization ' - + 'wall down for the object while the column stayed, so the SQL driver went on scoping a read by the ' - + 'caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture ' - + 'provider, before the change. ADR-0131 retires that stand-down ("replaced by D7\'s no-column"). The ' - + 'engine reads the declaration at its plugin start(), before the first schema sync: every plugin ' - + 'init() has completed by then (ADR-0116, the Phase 1/2 split) and the org-scoping provider registers ' - + 'the service in its init(), declared in providesServices, so an object registered earlier is ' - + 're-planned before its table is created. An absent declaration leaves every object\'s plan ' - + 'byte-identical; a malformed one is refused loudly and declares nothing. An object that declares its ' - + 'own organization_id keeps it and stays walled on it. Existing databases: schema sync is additive, so ' - + 'the physical column stays on a declaring deployment and the boot drift report names it orphaned; ' - + 'the operator removes it, and nothing moves at boot.', - acceptanceCriteria: - 'On a deployment whose org-scoping service declares an object platform-global, the object is registered ' - + 'and provisioned with no `organization_id`, the security layer composes no organization wall on it, ' - + 'and a read carrying the caller organization reaches every row of its table; a non-declared object ' - + 'on the same deployment keeps its column and its wall. With no declaration, or a malformed one, every ' - + 'object keeps its column.', - }, - { - id: 'platform-timezone-columns-iana-domain-refused', - surface: - 'The two platform audit time-zone columns — `sys_job.timezone` and ' - + '`sys_report_schedule.timezone` — carrying a string that is not a member of the ' - + 'IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).', - replacement: - 'The canonical IANA zone id the deployment meant, written in the spelling the tzdb ' - + 'uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member ' - + 'and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the ' - + '`Intl.supportedValuesOf(\'timeZone\')` enumeration, which omits `UTC` and would ' - + 'refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never ' - + 'repaired on the deployment\'s behalf: the correct zone behind a typo is a fact only ' - + 'the deployment holds, which is what makes this entry semantic rather than a D2 ' - + 'conversion.', - reason: - 'The change validating `sys_job.timezone` and `sys_report_schedule.timezone` against the ' - + 'IANA domain gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared on ' - + '`sys_business_unit.timezone` / `sys_organization.timezone` since those two objects ' - + 'first gained a timezone column. It is a ' - + 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value ' - + 'already stored outside the domain is never re-read against it, no DDL is planned, ' - + 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped ' - + 'it says so in those words, and this entry does not contradict it. What the ' - + 'changeset had no way to carry is that a deployment holding such a value now has ' - + 'WORK TO DO: the next write of that row is refused with the ADR-0114 field code ' - + '`value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing ' - + 'the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to ' - + 'croner, whose throw was caught and turned into a silent fall back to ' - + '`interval_minutes`, so "every weekday 09:00 Asia/Shanghai" became "every 1440 ' - + 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, ' - + 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own ' - + 'breaking-change detector classified it non-breaking and asked for no ADR-0087 ' - + 'disposition at all — measured on the shipped changeset. A ruling closed that hole ' - + '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose ' - + 'banner) and this row is the other half of the same ruling: the narrowing that ' - + 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. ' - + 'Maintainer ruling 2026-09-07, option B: the clause-② declaration gains a widen / narrow ' - + 'arm that the ADR-0087 classifier reads, and each narrowing that already shipped ' - + 'without a banner is recorded as one ledger row. ' - + 'The direct precedents for registering a change ' - + 'no transform can apply are `schedule-flow-acting-organization-required` (protocol ' - + '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a ' - + 'deployment judgement, registered anyway because the prescription is real.', - acceptanceCriteria: - 'Every `sys_job.timezone` and `sys_report_schedule.timezone` value stored in the ' - + 'deployment is an IANA member. The one-line fix per offending row: write the ' - + 'canonical zone id (`UPDATE … SET timezone = \'Asia/Shanghai\'`), or clear the ' - + 'column — `sys_report_schedule` documents a `UTC` default and `sys_job` has no ' - + 'reader at all. Rows already holding a member parse and behave byte-identically to ' - + 'before; rows holding none are readable, are returned unchanged, and fail only on ' - + 'their next WRITE. A report schedule that was silently running on ' - + '`interval_minutes` resumes its cron cadence once its zone is a member — that ' - + 'resumption, not the absence of an error, is how the fix is verified. ⚠️ The two ' - + 'columns\' `maxLength` (100 vs 64) and defaults (none vs `UTC`) are deliberately ' - + 'still unconverged and are NOT part of this entry; no member is longer than 32 ' - + 'characters on the current Node baseline, so neither bound admits anything the ' - + 'domain does not.', - }, - { - id: 'plugin-auto-restart-never-reinitialised', - surface: - '`PluginHealthCheck.autoRestart`, `PluginHealthCheck.maxRestartAttempts` ' - + 'and `PluginHealthCheck.restartBackoff`, and the ' - + '`PluginHealthMonitor.attemptRestart` path that read them', - replacement: - 'Poll `PluginHealthMonitor.getHealthStatus(pluginName)` / ' - + '`getHealthReport(pluginName)` and act on `unhealthy` / `failed` in the ' - + 'HOST. There is no in-tree replacement for the keys, because restarting ' - + 'a plugin is the host\'s job in this host-driven library and the monitor ' - + 'could not do it even in principle: `Plugin.init(ctx)` needs a ' - + '`PluginContext`, which only the kernel constructs and which it exposes ' - + 'to nobody (`ObjectKernel.context` is private, `KernelBase.createContext` ' - + 'is protected). Recreate the kernel, or let your supervisor restart the ' - + 'process — whichever level actually owns the plugin\'s lifetime. The ' - + 'monitor reports; it does not act.', - reason: - 'ADR-0049 enforce-or-remove, applied one class over from the two ' - + 'hot-reload retirements in the same host-driven lifecycle library — the ' - + 'file-watching placeholder whose `startWatching` logged success while ' - + 'watching nothing, and the `\'disk\'` / `\'distributed\'` state strategies ' - + 'that fell back to memory in silence — and for a sharper ' - + 'reason than either: this key HAD a reader that acted, and what it did ' - + 'was not what the key declared. `attemptRestart` called ' - + '`plugin.destroy()` and stopped there. The comment above the call read ' - + '"Call destroy and init to restart", and `init` appeared in ' - + '`health-monitor.ts` ONLY inside that comment. So what a plugin actually ' - + "got was: destroy, a log line reading 'Plugin restarted', status " - + '`recovering`, and periodic health checks continuing to run against the ' - + 'destroyed instance — which the default check when no `checkMethod` ' - + "resolves (`{ name: 'plugin-loaded', status: 'passed' }`) passes " - + 'indefinitely. The TERMINAL report on a destroyed, never-re-initialised ' - + 'plugin was therefore `healthy`, reproduced at ee3595cefd with ' - + '`successThreshold: 3` as failed -> recovering (destroyed=1, alive=false) ' - + '-> recovering -> recovering -> healthy (destroyed=1, alive=false). ' - + 'The fix that made `successThreshold` bind from every status that records ' - + 'a failure made that MORE convincing rather than less, because reaching ' - + '`healthy` now costs `successThreshold` CONSECUTIVE passing rounds, so ' - + 'the plugin has to earn a declared number of passes to be misreported. ' - + 'Meanwhile `restartAttempts` was incremented as though a restart had ' - + 'occurred, and `maxRestartAttempts` / `restartBackoff` scheduled further ' - + '"restarts" of a plugin that was never brought back up. Neither of the ' - + 'other two ADR-0049 states was available: ENFORCE would have to BUILD ' - + 'the restart, and the class cannot host one — `Plugin.init(ctx)` needs a ' - + '`PluginContext`, and the only two `plugin.init(...)` call sites in the ' - + 'tree are the kernel\'s own boot loops over the full plugin list, with a ' - + 'context that is private on `ObjectKernel` and protected on ' - + '`KernelBase`, so a host-provided re-init hook would have had nothing to ' - + 'call (positive control: the same scan resolves five real non-test ' - + '`plugin.destroy()` call sites, so it sees lifecycle drivers). Building ' - + 'that API for a caller that does not exist — no runtime constructs ' - + '`PluginHealthMonitor`, which is why the maintainer retired its declarative ' - + 'config container on 2026-08-25 and kept the classes as a host-driven ' - + 'library — is exactly the speculation ADR-0049\'s ' - + 'staged decision names as the wrong default at this milestone, where the ' - + 'shippable liability is the false promise and not the missing feature. ' - + 'EXPERIMENTAL requires a roadmap, and a scan of the whole `docs/` ' - + 'planning + ADR corpus returned ZERO mentions of plugin auto-restart ' - + 'against 118 control hits for "health" and 13 for "hot reload" in the ' - + 'same corpus. The other two keys leave with the first rather than as a ' - + 'tidy-up: with no restart, "Maximum restart attempts before giving up" ' - + 'and "Backoff strategy for restart delays" have nothing left to be the ' - + 'vocabulary OF — the same test that took `distributedConfig` out with ' - + 'the `stateStrategy` value it was documented as being required for ' - + '(ruled 2026-08-26: a vocabulary of nothing is not a vocabulary). All ' - + 'three are TOMBSTONED rather than deleted, for the reason the file-watching ' - + 'retirement recorded: a key leaving a SURVIVING def has no route-3 exit, and ' - + '`PluginHealthCheckSchema` is not `.strict()`, so a bare deletion would ' - + 'be a silent strip (ADR-0104; an earlier field-key prune measured exactly ' - + 'that — the parse succeeded and the removed key was dropped without a ' - + 'word) — a milder form of the very defect ' - + 'being retired. There is no D2 conversion, because `PluginHealthCheck` ' - + 'is not an authorable surface: no metadata-type binding, stack ' - + 'collection or manifest embed ever carried it, so there is no authored ' - + 'document to rewrite. This entry IS the declaration.', - acceptanceCriteria: - 'No host passes `autoRestart`, `maxRestartAttempts` or `restartBackoff` to ' - + '`PluginHealthMonitor.registerPlugin`. TypeScript hosts cannot: all ' - + 'three are typed `never` by the tombstones. JavaScript hosts, and config ' - + 'that arrived as JSON, get a loud refusal carrying the prescription — an ' - + 'ADR-0112 envelope (`code: VALIDATION_ERROR`, `status: 400`), thrown ' - + 'BEFORE any state is stored so a refused config cannot leave a ' - + 'half-registered plugin behind. `PluginHealthMonitor` no longer calls ' - + '`plugin.destroy()` at all: `attemptRestart` and `calculateBackoff` are ' - + 'gone with the `restartAttempts` counter, and a plugin that crosses ' - + '`failureThreshold` is reported `degraded` / `unhealthy` / `failed` and ' - + 'left running. The rest of the monitor is UNCHANGED: registration, ' - + 'periodic checks, the `timeout` race and its guard timer (kept ref\'d ' - + 'while the race is undecided, cleared the moment it settles), the two ' - + 'failure routes — a returned failure and a thrown or timed-out check — ' - + 'sharing one failure counter and one threshold comparison, and ' - + '`successThreshold` binding from every status that records a failure ' - + 'all behave exactly as before — `recovering` is now written ' - + 'only by the success branch, which is the one writer that ever meant it. ' - + 'The maintainer\'s 2026-08-25 keep of the host-driven library still ' - + 'stands: `PluginHealthCheckSchema` still exports ' - + 'from `./kernel` and `PluginHealthMonitor` still exports from ' - + '`@objectstack/core` with its tests green.', - }, - { - id: 'plugin-manifest-contributes-dead-members-retired', - surface: - 'manifest.contributes.events / manifest.contributes.menus / manifest.contributes.themes / ' - + 'manifest.contributes.translations / manifest.contributes.actions / ' - + 'manifest.contributes.drivers / manifest.contributes.fieldTypes / ' - + 'manifest.contributes.functions / manifest.contributes.commands (nine of the block\'s ' - + 'eleven members; `kinds` and `routes` are NOT part of this retirement)', - replacement: - 'delete the keys — each capability already has its one enforced channel: `events` → ' - + "subscribe imperatively in plugin code (`ctx.hook('kernel:ready', …)` from `init`/`start`); " - + '`menus` → the app `navigation` tree or `manifest.navigationContributions` (ADR-0029 D7); ' - + '`themes` → the stack-level `themes` metadata collection (an unrelated `ThemeSchema` ' - + 'surface); `translations` → the `translation` metadata type, authored with ' - + '`defineTranslationBundle` in `defineStack({ translations })`; `actions` → the stack ' - + '`actions` collection or `engine.registerAction`; `drivers` → register a kernel service ' - + 'named `driver.*` (objectql calls `registerDriver` on it); `fieldTypes` → nothing (no ' - + 'registration seam exists; the vocabulary is the spec `FieldType` enum); `functions` → ' - + '`defineStack({ functions })` → `engine.registerFunction`; `commands` → oclif native ' - + "plugin auto-discovery (an `oclif` section in the plugin's own `package.json`; see " - + '`cli-extension.zod.ts`)', - reason: - 'ADR-0049 enforce-or-remove: the nine members retire together, once the cloud half of ' - + 'the census below had come back clean. A census, monorepo-wide and non-test with ' - + 'control probes, measured that the ENTIRE monorepo contains exactly one read of ' - + '`manifest.contributes` — ' - + '`packages/objectql/src/engine.ts`, member `kinds` — so all nine members above parsed, ' - + 'entered the manifest, and changed nothing. The census stands on three repos: objectstack ' - + '(re-verified on current main at claim time), objectui (0 property reads; control: 63 ' - + 'files carry the bare word), and cloud (measured clean 2026-08-24 at `5b5925a`: zero ' - + '`manifest.contributes` reads, controls held). Several members were actively misleading: ' - + '`events` was authored in-repo by a plugin that already subscribes imperatively; ' - + '`commands` documented Commander.js resolution the CLI dropped for oclif auto-discovery; ' - + '`fieldTypes` advertised a registration seam that has never existed. ' - + 'Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK ' - + 'and `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a ' - + 'stack collection member and a conversion would be a transform with no seam that ever ' - + 'runs (the `kernel/Manifest:loading` precedent, recorded verbatim in its retired-key ' - + 'entry).', - acceptanceCriteria: - 'No `objectstack.config.ts` manifest and no packaged `manifest.json` authors any of the ' - + 'nine members. The enforced channel is the one place a manifest is parsed with an author ' - + 'present: `os plugin build` runs `ManifestSchema.safeParse` and exits non-zero printing ' - + 'the per-key tombstone prescription; TypeScript authors fail earlier still (each key is ' - + 'typed `never`). `contributes.kinds` keeps parsing and registering ' - + '(`registry.registerKind`), and `contributes.routes` is left to its own enforce-or-remove ' - + 'fork (since decided: retired, see `plugin-manifest-contributes-routes-retired`). ' - + '⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: ' - + 'nothing ever read the nine members, so removing them removes no behaviour. A package ' - + 'ALREADY INSTALLED whose stored manifest carries one degrades to a single ' - + '`[metadata_spec_invalid]` log line at registration (the registry\'s `validate()` is a ' - + 'diagnostic, not a gate) rather than a boot failure; clear it by deleting the key from ' - + 'the source manifest and reinstalling.', - }, - { - id: 'plugin-manifest-contributes-routes-retired', - surface: - 'manifest.contributes.routes (the one member the nine-member retirement deliberately ' - + 'left to its own fork; `kinds` is now the block\'s sole surviving live member)', - replacement: - 'delete the key. A route that needs real handler CODE is mounted imperatively: ' - + 'resolve the `http.server` service from the plugin context and register the ' - + 'handler on `kernel:ready` (plugin-hono-server registers the service; ' - + '`examples/app-showcase` mounts POST /api/v1/showcase/recalc that way). A ' - + 'declarative endpoint over a pipeline the platform already runs — query/return ' - + 'records, trigger a flow — is `defineStack({ apis })` (live since protocol 17, ' - + 'once the declarative endpoint executor was built and the loud refusal of a ' - + 'non-empty `apis:` became execution)', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-22 (Option B of the ' - + 'enforce/remove/enforce-later fork, accepted verbatim 「接受所有」 on the decision ' - + 'batch carrying the four-axis analysis): remove the key, and redirect every ' - + 'author-facing recommendation of it to the imperative `http.server` mount. A ' - + 'monorepo-wide census with control probes measured zero readers of the key: the ' - + 'HttpDispatcher never registered a prefix ' - + 'from the declaration, so an entry parsed cleanly and served nothing — while FOUR ' - + 'published surfaces presented it as working machinery, one of them a ' - + 'customer-published skill (`skills/objectstack-api` told authors to choose it when ' - + '"the endpoint needs real handler CODE"). That is ADR-0049\'s silent no-op with a ' - + 'published recommendation attached. Per the ruling\'s own sequencing the ' - + 'author-facing corrections landed FIRST (the skill\'s decision table, the ' - + 'dispatcher protocol doc, ADR-0088:40 and app.mdx, each redirected to the ' - + 'imperative mount), and the two remaining teaching sites (the plugin-rest-api.zod.ts ' - + 'worked manifest example, the metadata-plugin.zod.ts `router` delivered-form ' - + 'comments) are redirected in the removal PR itself. The cloud precondition was ' - + 'discharged first: a census of the cloud repository at 5b5925a found zero ' - + '`manifest.contributes` reads, controls green. Enforce ' - + '(fork A) was weighed and rejected on all four facets: net-new execution surface ' - + 'plus a prefix-claim authority question (who may claim `/api/v1/…`) for a ' - + 'declarative spelling with zero measured authors, while the capability is already ' - + 'reachable imperatively. Why D3 semantic and not a D2 conversion: a manifest is ' - + 'not a stack collection member (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` ' - + 'entry), so a conversion would be a transform with no seam that ever runs.', - acceptanceCriteria: - 'An authored `contributes.routes` is a loud rejection through every spec-validating ' - + 'path — `retiredKey()` types it `never` (tsc error at the authoring site) and the ' - + 'parse raises the prescription itself (`os plugin build` exits non-zero printing ' - + 'it). `contributes.kinds` — the block\'s sole surviving member — keeps parsing and ' - + 'registering (engine → `registry.registerKind`). No author-facing material still ' - + 'recommends the key: every former teaching site points at the imperative ' - + '`http.server` mount (and `defineStack({ apis })` for declarative projections). ' - + '⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: ' - + 'nothing ever read the member, so removing it removes no behaviour. A package ' - + 'ALREADY INSTALLED whose stored manifest carries one degrades to a single ' - + '`[metadata_spec_invalid]` log line at registration (the registry\'s `validate()` ' - + 'is a diagnostic, not a gate) rather than a boot failure; clear it by deleting the ' - + 'key from the source manifest and reinstalling.', - }, - { - id: 'plugin-manifest-dead-containers-retired', - surface: - 'manifest.capabilities / manifest.configuration / manifest.extensions (three top-level ' - + 'containers; retiring the container settles every key beneath it — ' - + '`capabilities.{implements,provides,requires,extensionPoints,extensions}` and ' - + '`configuration.{title,properties}` — at once)', - replacement: - 'delete the keys — each declared purpose either has its one enforced channel or never ' - + 'existed: `configuration` (a `{ title, properties }` settings surface no UI rendered ' - + 'and no loader resolved) → pass options to the plugin\'s constructor in ' - + '`defineStack({ plugins: [new MyPlugin({ … })] })`, the channel hosts already use; ' - + '`capabilities` (protocol/interface declarations sold as "interoperability and ' - + 'automatic discovery") → nothing — no discovery path ever existed; real dependency ' - + 'resolution runs off top-level `manifest.dependencies`, which stays; `extensions` (an ' - + 'untyped `z.record(z.string(), z.unknown())` catch-all) → the enforced extension ' - + 'channels: `contributes.kinds` registers metadata kinds, `navigationContributions` ' - + '(ADR-0029 D7) injects navigation, and code-level extension lives in the plugin itself ' - + '(`init`/`start`)', - reason: - 'ADR-0049 enforce-or-remove, dispatched once the cloud half of the census below came ' - + 'back clean. A census, monorepo-wide and non-test with control probes, measured ZERO ' - + 'reads of each container itself, which settles all eight keys ' - + 'beneath them — a key cannot be read if the object holding it never is. The census ' - + 'stands on three repos: objectstack (re-verified on current main at claim time; every ' - + 'bare `.capabilities` hit classifies to a different surface — driver loader contracts, ' - + 'the QuickJS sandbox argument set, REST discovery, the ADR-0066 stack-level ' - + '`capabilities` collection), objectui (0 container reads; control: `manifest.(id|name|' - + 'namespace|version)` reads findable), and cloud (measured clean 2026-08-29 at ' - + '`15f55df`: zero reads of all three, controls positive). ' - + '`configuration.properties.secret` made this false compliance rather than tidying: its ' - + 'describe() promised "value is encrypted/masked (e.g. API Keys)" and nothing ever ' - + 'encrypted, masked or parsed it, so the key\'s own text was an unkept assurance about ' - + 'credential handling. ' - + 'Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized ' - + 'STACK and `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry (re-verified — its ' - + '`capabilities` entry is the unrelated ADR-0066 stack collection), so a manifest is ' - + 'not a stack collection member and a conversion would be a transform with no seam that ' - + 'ever runs (the `kernel/Manifest:loading` precedent, recorded verbatim in its ' - + 'retired-key entry). `PluginCapabilityManifestSchema` stays published: the ' - + 'plugin-registry surface (`plugin-registry.zod.ts`) still declares it, so this is a ' - + 'carrier-key tombstone with no def removal.', - acceptanceCriteria: - 'No `objectstack.config.ts` manifest and no packaged `manifest.json` authors any of the ' - + 'three containers (the two in-repo authors — driver-memory and plugin-hono-server, ' - + 'both writing `configuration` and `capabilities` blocks nothing read — were cleaned ' - + 'with this retirement). The enforced channel is the one place a manifest is parsed ' - + 'with an author present: `os plugin build` runs `ManifestSchema.safeParse` and exits ' - + 'non-zero printing the per-key tombstone prescription; TypeScript authors fail earlier ' - + 'still (each key is typed `never`). Live neighbours are untouched and must be verified ' - + 'as such: `manifest.dependencies` keeps resolving dependencies, `contributes.kinds` ' - + 'keeps registering, `navigationContributions` keeps merging. ⚠️ Runtime behaviour is ' - + 'deliberately UNCHANGED: nothing ever read the three containers, so removing them ' - + 'removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries one ' - + 'degrades to a single `[metadata_spec_invalid]` log line at registration (the ' - + 'registry\'s `validate()` is a diagnostic, not a gate) rather than a boot failure; ' - + 'clear it by deleting the key from the source manifest and reinstalling.', - }, - { - id: 'plugin-manifest-kind-globs-retired', - surface: 'manifest.contributes.kinds[].globs (the `kind` bucket itself and its `id` are untouched)', - replacement: - 'delete the key — a kind entry is `{ id, description? }`. File-type discovery is ' - + "single-channel on the metadata type registry's `filePatterns` " - + '(`MetadataTypeSchema`, registered via `registerMetadataTypeSchema` / the default ' - + 'registry), which `contributes.kinds` never extended; if plugin-extensible ' - + 'discovery is ever wanted, it gets designed against that registry, not revived ' - + 'here', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24 (「接受你的建议。」) on ' - + 'the aligned four-facet analysis: remove, through the full ADR-0049 ceremony. The sub-field was declared-but-unenforced on an ' - + 'authorable published surface: the schema promised that declaring `globs` "enables the ' - + 'system to parse and validate new file types" (its own example: a BI plugin handling ' - + '`*.report.ts`), and the platform accepted it, stored it, and served it back through ' - + '`GET /metadata/kind` — while the discovery the description promised never ran, because ' - + 'real glob-driven artifact discovery reads `filePatterns` off the metadata type registry ' - + 'and `metadata-plugin.zod.ts` records outright that `contributes.kinds` does not extend ' - + 'it. Measured by the engine-lane fix that made kind registration log its declared ' - + '`id` (which also found the `kind` bucket itself reachable through `GET /metadata/:type`), ' - + 'and re-verified at claim time with a positive control: zero value reads anywhere (the only non-test occurrences of the path are the ' - + 'schema declaration and two type positions), and no in-repo manifest authors the key ' - + 'outside test fixtures. Enforce was weighed and rejected on all four facets: it would ' - + 'build a SECOND discovery channel parallel to `filePatterns` for a spelling with zero ' - + 'pull. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection ' - + 'member (`PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry), so a conversion would ' - + 'be a transform with no seam that ever runs.', - acceptanceCriteria: - 'An authored `contributes.kinds[].globs` is a loud rejection through every ' - + 'spec-validating path — `retiredKey()` types it `never` (tsc error at the authoring ' - + 'site) and the parse raises the prescription itself (`os plugin build` exits non-zero ' - + 'printing it). `contributes.kinds` with `{ id, description? }` still parses and still ' - + 'registers (`engine` → `registry.registerKind`), and the registered bucket stays ' - + 'reachable via `GET /metadata/kind`. ⚠️ Runtime behaviour is deliberately UNCHANGED ' - + 'and must be verified as such: nothing read the value, so removing it removes no ' - + 'behaviour; the `registerKind` / `getAllKinds` type positions drop `globs` from their ' - + 'declared shapes (a type-only change — the parameter widens). A stored kind item that ' - + 'still carries `globs` keeps serving as stored data; clear it by deleting the key from ' - + 'the source manifest and republishing.', - }, - { - id: 'plugin-security-scan-result-surface-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: 'the plugin-security scan-result family: the defs ' - + 'KernelSecurityScanResult and KernelSecurityVulnerability ' - + '(kernel/plugin-security-advanced.zod.ts), their two authorable carriers on ' - + 'PluginSecurityManifest — scanResults and vulnerabilities — and the sibling ' - + 'verdict block PluginQualityMetrics.securityScan (kernel/plugin-registry.zod.ts)', - replacement: - 'nothing to re-declare — delete the keys and every import of the two types. Plugin ' - + 'security scanning is not a platform capability and there is no replacement schema. ' - + 'What the platform does still enforce, and what to reach for instead: `permissions` and ' - + '`sandbox` on the same PluginSecurityManifest are unchanged, and artifact provenance is ' - + 'answered by `verifyPluginArtifactIntegrity` and the plugin signature verifier — which ' - + 'tell you an artifact is the one its publisher signed, and never that it is safe. For ' - + 'dependency vulnerabilities use the tools built for it against your own project (npm ' - + 'audit / pnpm audit, Dependabot, the GitHub Advisory Database, OSV) and treat an ' - + 'unaudited third-party plugin as untrusted code. A publisher who used scanResults to ' - + 'advertise diligence keeps the surviving securityContact and vulnerabilityDisclosure ' - + 'blocks, which are contact terms rather than a verdict.', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-07 (adopted verbatim 「同意」): ' - + 'retire the scan-result family and its securityScan sibling, because once the scanner ' - + 'was gone nothing so much as imported their types. ' - + 'This is the second half of the scanner retirement recorded as ' - + 'plugin-security-scanner-retired. That retirement removed PluginSecurityScanner — a ' - + '@objectstack/core class that shipped as a SECURITY control and could not fail, whose ' - + 'verdict was status "passed" for every plugin it was ever handed. The SCHEMAS the ' - + 'scanner fed survived it, and the scanner had been their only importer of any kind (a ' - + 'type-only import in packages/core/src/security/security-scanner.ts), so the family went ' - + 'from one type-only importer to zero consumers while staying fully published: 27 ' - + 'authorable rows across kernel.json, six api-surface exports, two authorable defaults ' - + 'and two json-schema manifest keys. An author could write any of it, be accepted, and ' - + 'get nothing — declared-not-enforced, Prime Directive #10, one layer out from the class ' - + 'removed for the same reason. The census was taken on origin/main after that removal landed, ' - + 'with a lit control (five hits for PluginSecurityManifest inside the declaring module) ' - + 'proving the file greppable, and found no .parse or .safeParse site against either ' - + 'schema anywhere in packages/**. securityScan is the sharpest member: scanResults ' - + 'published a report, but securityScan.passed published a VERDICT, so a plugin could ' - + 'declare itself clean with nothing behind it. Route: the two defs leave the build whole ' - + '(RETIRED_DEFS_BY_MAJOR[18]) because nothing parses them and a prescription nobody can ' - + 'receive is not worth its cost; the three authorable keys are retiredKey() tombstones ' - + '(RETIRED_KEYS_BY_MAJOR[18]) because both carrying shapes are non-strict, where a bare ' - + 'deletion is a silent strip (ADR-0104). Why this entry and not a D2 conversion: a plugin ' - + 'security manifest and a plugin registry entry are package artifacts a publisher ships, ' - + 'never stack collection members and never stored sys_metadata rows, so the conversion ' - + 'chain has no seam that would see one — the disposition the sibling ' - + 'kernel-plugin-security-durations-unit-in-key entry already records for this same ' - + 'manifest. No deprecation window (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). ' - + 'Scope note, recorded rather than acted on: PluginSecurityManifest.vulnerabilities is a ' - + 'forced consequence rather than a name the ruling listed — it was the last authorable ' - + 'referent of KernelSecurityVulnerability and could not outlive the def. Two neighbours ' - + 'the ruling made CONDITIONAL are deliberately untouched here because the repository the ' - + 'condition names, objectstack-ai/cloud, is not reachable from the session that executed ' - + 'this: the marketplace "scanning" status stays exactly as it is — unremoved, and NOT ' - + 'recorded as checked. Its two siblings were MEASURED rather than assumed, and the ' - + 'record is corrected here: both were ALREADY GONE when that ruling was written. The ' - + 'incident "malware" type was a member of system/IncidentCategory, and the whole ' - + 'incident-response family was retired whole, with the training and change-management ' - + 'families (maintainer ruling 2026-09-05: not roadmapped, so retired rather than marked ' - + 'experimental — two days BEFORE the 2026-09-07 ruling that made it conditional); see ' - + 'incident-response-family-retired. And marketplace-admin.zod.ts was deleted outright ' - + 'with the cloud subpath (ruled 2026-09-07: cloud does not re-host the control-plane ' - + 'files it never consumed); see cloud-subpath-retired. Verified on this tree by ' - + 'shape: both files return zero tree entries and no *.zod.ts names malware at all, ' - + 'against a lit control where "scanning" still returns a live declaration in ' - + 'marketplace.zod.ts. So the conditional question is ONE enum member wide, not three, ' - + 'and the objectstack-ai/cloud producer grep it still owes is that much smaller. ' - + '⚠️ The out-of-repo consumer population is NOT MEASURED. @objectstack/spec is published, ' - + 'so this removal is breaking for consumers no download, dependent or source telemetry ' - + 'was consulted for — accepted as an input to the ruling, exactly as that retirement states ' - + 'of its own three exports, and not a reason to soften the removal. ADR-0049, ADR-0087.', - acceptanceCriteria: - 'No source imports KernelSecurityScanResult, KernelSecurityVulnerability or either ' - + 'Schema from @objectstack/spec/kernel: both defs are absent from the built kernel ' - + 'barrel and from api-surface/kernel.json, so a TypeScript consumer gets the refusal at ' - + 'compile time at the import site rather than a missing runtime value. Authoring ' - + 'PluginSecurityManifest.scanResults, PluginSecurityManifest.vulnerabilities or ' - + 'PluginQualityMetrics.securityScan fails to compile (input type `never`) and fails to ' - + 'parse with the tombstone prescription naming that key — verified by refusal pins that ' - + 'assert the issue code, the path naming WHICH key was refused, and the prescription ' - + 'text, plus a positive pin that the surrounding manifest still parses and grows no such ' - + 'property. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: ' - + 'nothing ever read any of these keys, so deleting one removes no check that was running. ' - + 'A publisher who believed a declared scanResults entry gated anything was never getting ' - + 'that gate; the remediation is to audit with a real tool, not to find a replacement key. ' - + 'The surviving neighbours must still parse and still be exported — permissions, sandbox, ' - + 'policy, codeSigning, certifications, securityContact and vulnerabilityDisclosure on the ' - + 'manifest, testCoverage/documentationScore/codeQuality/conformanceTests on the quality ' - + 'metrics, and the separately-declared SecurityScanResultSchema / ' - + 'SecurityVulnerabilitySchema in kernel/plugin-security.zod.ts, which this change does ' - + 'not touch.', - }, - { - id: 'plugin-security-scanner-retired', - surface: - '`@objectstack/core` runtime exports: `PluginSecurityScanner`, and the two types ' - + 'declared only to feed it, `ScanTarget` and `SecurityIssue`', - replacement: - 'nothing to re-declare — delete the import and every call. Plugin security scanning is ' - + 'not a platform capability and there is no replacement export. A caller that branched ' - + 'on `result.status === "passed"` takes that branch unconditionally, because it is the ' - + 'only branch the scanner ever produced. What the platform does still enforce, and what ' - + 'to reach for instead: artifact integrity and signatures ' - + '(`verifyPluginArtifactIntegrity`, the plugin signature verifier) answer "is this the ' - + 'artifact the publisher signed?" and never "is this artifact safe?"; plugin permissions ' - + 'and the sandbox resource limits are unchanged. For dependency vulnerabilities use the ' - + 'tools built for it against your own project — `npm audit` / `pnpm audit`, Dependabot, ' - + 'the GitHub Advisory Database, OSV — and treat an unaudited third-party plugin as ' - + 'untrusted code.', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05: retire the class and its two ' - + 'companion types with no replacement export, rather than repair it. The class shipped on ' - + '`@objectstack/core`\'s public barrel as a SECURITY control and could not fail. `scan()` ' - + 'composed five private scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, ' - + '`scanConfiguration`) allocated an empty issue array, logged and returned it with no code ' - + 'in between, so none could report a finding for any input; the fifth, `scanDependencies`, ' - + 'ran a real loop but matched only against an in-memory vulnerability database whose sole ' - + 'writer, the public `addVulnerability`, had zero callers in objectstack, in objectui at ' - + 'the pinned sha, or in the one demonstration that constructed the scanner, and ' - + '`updateVulnerabilityDatabase()` logged twice and fetched nothing. The database was ' - + 'therefore empty on every code path that has ever executed: no issue was ever produced, ' - + 'the score stayed 100, and the verdict was `status: "passed"` for every plugin the ' - + 'scanner was ever handed — a malicious one as readily as a benign one. Repair was refused ' - + 'by name: a real vulnerability scanner is a feature with a design surface, not a defect ' - + 'fix. Why this entry exists at all, and why D3 semantic rather than a D2 conversion: ' - + '`PluginSecurityScanner` has no spec schema and never had one — it is a runtime TS class, ' - + 'so there is no authorable key to tombstone with `retiredKey()`, no stored `sys_metadata` ' - + 'row that could carry it (a scanner was constructed per call and every result lived in a ' - + 'per-instance Map discarded with the object), and hence no seam `applyConversionsToStored' - + 'Item` would ever reach. The enforced channel is tsc, at the consumer\'s own import site; ' - + 'for anyone it does not reach, this ledger entry and the generated upgrade guide are the ' - + 'only channel there is. That is the disposition of `contracts.IDataDriver.findStream` ' - + '(removed with no tombstone, because nothing parses a driver object) and of ' - + '`actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the ' - + 'maintainer\'s word rather than given a window) — a TS/API contract, no stored ' - + 'source, no tombstone, tsc at the call site — applied to a surface one layer further out ' - + 'than either: those are declared in `packages/spec`, this one only in `packages/core`. ' - + '⚠️ The out-of-repo consumer population is NOT MEASURED. Zero constructors were found in ' - + 'objectstack, in objectui at the pinned sha, and in the deleted example, but no download, ' - + 'dependent or source telemetry was consulted for consumers of the published package, so ' - + 'this is breaking for an unmeasured population rather than a removal proven to break ' - + 'nobody.', - acceptanceCriteria: - 'No source imports `PluginSecurityScanner`, `ScanTarget` or `SecurityIssue` from ' - + '`@objectstack/core` (or from `@objectstack/core/security`, a subpath the package has ' - + 'never declared in its `exports` and which therefore resolved for nobody). A TypeScript ' - + 'consumer gets the refusal at compile time at the import site — the export is absent from ' - + 'the built `dist/index.d.ts`, not merely undocumented. ⚠️ Runtime behaviour is ' - + 'deliberately UNCHANGED and must be verified as such: every scan this class ever ' - + 'performed returned zero issues and `status: "passed"`, so deleting a call removes no ' - + 'check that was running. A caller that treated a passing scan as evidence of safety was ' - + 'never getting any, and its remediation is to audit dependencies with a real tool, not to ' - + 'find a replacement symbol — there is none. Verified in-repo by export-list assertions on ' - + 'both barrels (`packages/core/src/security/security-scanner-retirement.pin.test.ts`), not ' - + 'by a grep: the name legitimately survives in the tombstone comments that explain the ' - + 'retirement.', - }, - // The boot path's half of the version canon, and the one whose BOUND is the - // load-bearing fact: a widen-never-narrow ruling governs this key, and this - // entry narrows it on eight strings and nothing else. Registered as a D3 - // semantic TODO rather than a D2 conversion because every one of the eight has - // more than one defensible repair and the chain can pick none of them: is - // `1.0.0-alpha..1` meant to be `1.0.0-alpha.1`, or `1.0.0-alpha`? - { - id: 'plugin-version-semver-2-0-0', - surface: 'plugin.version — `PluginSchema.version` (`kernel/plugin.zod.ts`), the key a ' - + 'plugin object carries into `kernel.use()`, and the boot-path predicate that judges ' - + 'the same string in `@objectstack/core` (`plugin-loader.ts`)', - replacement: 'a SemVer 2.0.0 string matching `SEMVER_2_0_0_VERSION_PATTERN` ' - + '(`kernel/version-grammar.ts`). ⭐ Only EIGHT strings stop loading, all of them forms ' - + 'the standard forbids: `01.1.1`, `1.01.1`, `1.1.01` (§2, a leading zero in a numeric ' - + 'identifier — drop it); `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` ' - + '(§9, a prerelease identifier that is empty or carries a leading zero — name it, or ' - + 'remove the empty segment); `1.0.0+.` (§10, an empty build identifier — name it or ' - + 'drop the `+` suffix). ⛔ Nothing else moves: every valid prerelease and build form ' - + 'this key accepts today it still accepts, `1.0.0-alpha.1` and ' - + '`1.0.0-rc.1+exp.sha.5114f85` included.', - reason: - 'The canon ruling made one grammar serve every carrier of "the version of a package or ' - + 'plugin", and named it after the standard: SemVer 2.0.0. This key had the widest of ' - + 'the four accept sets, which is why it is the only one that narrows without also ' - + 'widening. The narrowing is bounded deliberately, and the bound is what keeps the ' - + 'earlier widen-never-narrow ruling on this path honoured rather than reversed: that ' - + 'ruling\'s subject is what LOADS, and none of the eight is a valid prerelease. What ' - + 'they have in common is that no precedence order exists for any of them — ' - + '`dependency-resolver.ts` in `@objectstack/core` can place none of them in an order — ' - + 'so a plugin versioned this way could be published and never compared against its own ' - + 'successor, which is a worse outcome than the refusal. Why it is a D3 semantic TODO ' - + 'and not a D2 conversion: each of the eight has several defensible repairs and the ' - + 'metadata does not say which was meant, and a version is how a release is addressed — ' - + 'rewriting one silently re-points whatever already resolved the old string.', - acceptanceCriteria: - 'Every plugin you ship boots: `kernel.use(plugin)` resolves for each of them, on both ' - + '`ObjectKernel` and `LiteKernel`. The only versions needing an edit are the eight ' - + 'forms above — a `git grep` for a leading zero in a numeric segment and for a doubled ' - + 'or trailing dot in a suffix finds them all, and the in-repo authoring corpus measured ' - + 'ZERO producers of any of them. For each one you change, confirm nothing still ' - + 'resolves the old string: no `dependencies` range in another manifest, no installed ' - + 'row, no lockfile pin. ⛔ Do not repair one by widening the check back — the grammar ' - + 'is the contract now, on nine carriers at once.', - }, - // A write-door answer, not an authorable key: there is no D2 conversion and - // nothing for `objectstack migrate meta` to rewrite. The sibling of - // `18.by-id-write-unreadable-row-not-found`, for the predicate door. The entry - // carries the changed answer to the one reader the ledger serves here — the - // upgrade guide — because a caller that branched on the old answer has no - // schema error to find it by. No backticks in `surface`: the upgrade guide - // renders it inside a code span and a table cell. - { - id: 'predicate-write-unreadable-row-not-matched', - surface: - 'the data write doors — a predicate-scoped (multi) update or delete, on every object and for ' - + 'every principal', - replacement: - 'read a predicate update or delete as reaching only the rows the caller can read: the result ' - + 'counts those rows alone, a predicate that reaches only hidden rows succeeds with zero rows, ' - + 'and a predicate whose readable match exceeds one write\'s row ceiling is refused with 400 ' - + '`INVALID_FILTER` — narrow it and write in batches', - reason: - 'A WRITE-DOOR ANSWER, made one with the read door\'s, on the predicate door as on the by-id ' - + 'door. The rows a predicate update or delete matched came from its write scope alone, so a ' - + 'row the caller cannot read was matched whenever that scope reached it: a per-row gate then ' - + 'refused the write with a 403, or the row was written and counted. Either answer told a ' - + 'hidden row apart from no row. The write middleware now asks the read door which rows the ' - + 'caller\'s own predicate returns — a read in the caller\'s context that every data ' - + 'middleware\'s visibility applies to — and narrows the matched set to them, so a row the ' - + 'caller cannot read is not written, not counted and not refused. A read the read door ' - + 'refuses keeps the write\'s previous answer, and a readable match larger than one predicate ' - + 'write\'s row ceiling is refused rather than cut off. A caller who can read a matched row but ' - + 'may not write it keeps its answer. Writes the platform issues under the caller\'s context — ' - + 'a cascade, a hook\'s own write, the referential clear of a lookup — keep their previous ' - + 'answer, and by-id writes are unchanged.', - acceptanceCriteria: - 'Every caller that issues a predicate update or delete reads its count as the rows it can see ' - + 'and no longer reads a 403 there as proof a hidden row matched; an operator who needs a user ' - + 'to change rows grants that user read access to them first; a predicate whose readable match ' - + 'exceeds the row ceiling is narrowed and written in batches.', - }, - // #20289 (family `qa-runner`, verdict ENFORCE; the `requires` key ruled B) — the - // D3 entry of the family (one D3 entry per retirement family). Registered key: - // `qa/TestScenario:requires.plugins`. No D2 conversion: a QA suite is a loose - // JSON file `os test` loads, never a stack collection member or a stored row, so - // there is no source for a conversion to rewrite — and the move is a judgement - // (which service did the plugin stand for?), not a mechanical edit. This entry - // is where the prescription reaches `os migrate meta`, the upgrade guide and - // `spec-changes.json`. - { - id: 'qa-scenario-requires-plugins-retired', - surface: 'qa.scenarios[].requires.plugins', - replacement: - '`requires.services` — the discovery service keys the scenario needs (for example `auth`, `analytics`, ' - + '`automation`, `ai`), each judged against the target\'s discovery document: met only when the target declares ' - + 'the service `enabled` with status `available`. The plugin → service mapping follows the provider table ' - + 'discovery itself reports (`CORE_SERVICE_PROVIDER`): `@objectstack/plugin-auth` fills `auth`, ' - + '`@objectstack/service-analytics` fills `analytics`, `@objectstack/service-automation` fills `automation`, ' - + 'and so on. A plugin that fills no discovery service slot has no service to require.', - reason: - '`requires.plugins` was declared as a precondition and checked by nothing: `os test` reaches its target over ' - + 'HTTP, no served surface lists the loaded plugins, and the plugin spelling (package name or `plugin.name`) ' - + 'was never defined — so a scenario naming a missing plugin ran anyway and failed, or passed, on whatever the ' - + 'missing plugin caused. The block is now enforced (ADR-0049): core\'s TestRunner judges `requires` before the ' - + 'first step, and an unmet entry makes the scenario SKIPPED with a reason, counted separately and never as ' - + 'passed. `plugins` could not join that judgement honestly, so it retires into `services`, which the target\'s ' - + 'discovery document already answers (ADR-0076 D12: advertise only what is mounted). The consumer still owes ' - + 'the judgement because a plugin name does not always map to one service — a plugin that fills no discovery ' - + 'slot was never a checkable precondition, and only the suite\'s author knows what the scenario needed it for.', - acceptanceCriteria: - 'No QA suite (`qa/*.test.json`) carries `requires.plugins` — `os test` now refuses such a file at load time ' - + 'with the retirement prescription, naming the key, instead of running it; `tsc` refuses the key at a typed ' - + 'authoring site (`never`). Each scenario that declared plugins declares the services it needs in ' - + '`requires.services`, and `os test` against a target that serves them runs the scenario, while against one ' - + 'that does not it prints the scenario as skipped with the unmet service and the services the target declares ' - + 'available. A suite without `requires` runs exactly as before.', - }, - // The realtime subscription vocabulary moves to the names the runtime emits. - // Semantic only, with no D2 conversion: a subscription is not a stack - // collection member and never a stored row, so no conversion seam would ever - // see one — the refusal on the enum's own error map carries the prescription. - { - id: 'realtime-event-type-unemitted-values-retired', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span AND a table cell. - surface: - "api.RealtimeEventType — the values 'record.created', 'record.updated', " - + "'record.deleted' and 'field.changed' left the enum. It types " - + 'SubscriptionEvent.type, so it reaches Subscription.events[].type and ' - + 'RealtimeConfig.subscriptions[].events[].type', - replacement: - 'the names the runtime emits, which are now the whole enum: ' - + "'data.record.created' / 'data.record.updated' / 'data.record.deleted' for " - + "a single-record write, and 'data.records.updated' / 'data.records.deleted' " - + 'for a predicate write (multi: true), which carries a count and no record. ' - + "'record.created' becomes 'data.record.created'; 'record.updated' and " - + "'record.deleted' become their data.record twins, plus the data.records " - + 'twin where a predicate write must be heard too; ' - + "'field.changed' becomes 'data.record.updated', whose DataEvent payload " - + 'lists the changed fields in changes', - reason: - 'ADR-0049 enforce-or-remove. RealtimeEventType was published in the ' - + 'generated API reference as the vocabulary of a realtime subscription, and ' - + 'no producer anywhere emitted any of its four values. What the runtime ' - + 'publishes is the DataEventType / BulkDataEventType vocabulary: the ObjectQL ' - + 'engine sends data.record.created, data.record.updated and ' - + 'data.record.deleted for each written record and data.records.updated / ' - + 'data.records.deleted for a predicate write, and it parses every event ' - + 'through DataEventSchema / BulkDataEventSchema before publishing. A ' - + 'subscription written with the only names the reference showed could ' - + 'therefore never fire, and nothing said so. The direction was settled ' - + 'before this change: the enum moves to the emitted names, and the runtime ' - + "keeps publishing exactly what it published — changing the runtime's live " - + 'event names to match an enum nothing had ever used would break every ' - + 'real subscriber. field.changed is the same dead spelling that ' - + 'DataEventType already dropped in protocol 17 (the entry ' - + 'data-field-changed-event-retired): no per-field event exists, because an ' - + "update's per-field detail rides on data.record.updated as changes. Metadata " - + 'change events (metadata.{type}.{action}) were not added: a subscription ' - + 'event is record-shaped (object names a data object, filters narrows ' - + 'records), and metadata events have their own MetadataEventType contract ' - + 'and client primitive. Bookkeeping: an enum VALUE puts nothing in ' - + 'RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched; its ' - + "prescription hangs on the enum's own error map (the HookBodyCapability " - + 'precedent). It is a SEMANTIC entry rather than a D2 conversion because ' - + 'there is no source to rewrite: stack.zod.ts has no realtime key, no ' - + 'metadata type holds a subscription, and the open framework mounts no ' - + 'realtime transport that would parse one (maintainer ruling of 2026-09-04: ' - + 'realtime stays out of open core) — so the conversion chain has no seam ' - + 'that would ever see a subscription. ADR-0049 / ADR-0087.', - acceptanceCriteria: - "No code or document names 'record.created', 'record.updated', " - + "'record.deleted' or 'field.changed' as a RealtimeEventType value. " - + 'TypeScript rejects each one at a RealtimeEventType or SubscriptionEvent ' - + 'position, because the type no longer contains it, and a SubscriptionSchema, ' - + 'SubscriptionEventSchema or RealtimeConfigSchema parse refuses it with its ' - + 'per-value prescription (pinned in api/realtime.test.ts). A subscriber that ' - + 'meant field.changed listens on data.record.updated and reads the field ' - + "from the DataEvent payload's changes map. A handler keyed on an old name " - + 'never ran, since nothing emitted it, so renaming it changes behaviour only ' - + 'by making it fire.', - }, - { - id: 'record-chatter-position-vocabulary-converged', - surface: - '`record:chatter` / `record:discussion` component props (one shared schema object): ' - + '`position` vocabulary, and the schema defaults on `position` / `collapsible` / ' - + '`defaultCollapsed` (DROPPED)', - replacement: - "`position: 'bottom' | 'right' | 'left'` — the renderer's own vocabulary " - + "(`right`/`left` dock a side panel, `bottom` renders in flow). 'sidebar' → 'right', " - + "'inline' → 'bottom', 'drawer' → 'right' (no overlay drawer ever existed). " - + 'No key replaces the dropped schema defaults: an unset key now stays unset and the ' - + "renderer's own fallbacks apply (position 'bottom', collapsible off, defaultCollapsed off)", - reason: - 'The schema declared a `position` vocabulary no read point ever compared ' - + '(`sidebar`/`inline`/`drawer`), while the renderer chain — panel branches, designer ' - + 'registration, merge fallback, three sites in agreement, measured at objectui pin ' - + '`665661ab0932` — speaks exactly `bottom`/`right`/`left`. So the spec-valid `sidebar` ' - + '(the schema\'s own DEFAULT, materialized onto every parsed node that said nothing) ' - + 'silently fell through to the in-flow render, and the value that actually docks the ' - + 'panel (`right`) was refused at publish — declared ≠ enforced in both directions on the ' - + 'same key. The maintainer ruling of 2026-08-15 on this row converged it on the ' - + 'renderer\'s vocabulary with no mapping layer, and dropped all three schema defaults per ' - + 'the `maxVisible` principle (renderer fallbacks stay the renderer\'s facts): the old ' - + '`collapsible` default (`true`) additionally INVERTED the renderer merge\'s own fallback ' - + '(`false`), so "the author said nothing" parsed into "the author asked for collapsible". ' - + 'The mechanical rewrite is the ADR-0087 D2 conversion ' - + '`record-chatter-position-vocabulary` (retired from the load path — the enum refuses ' - + 'the old spellings at parse with a per-value prescription; stored rows replay clean ' - + 'via the rehydration seam). This semantic entry exists for the two judgements the ' - + 'chain cannot make: whether `drawer` → `right` (a docked panel standing in for a ' - + 'never-implemented overlay) is the presentation the author wants, and whether a page ' - + 'that relied on the old materialized `collapsible: true` default should now author it ' - + 'explicitly. ADR-0087, maintainer ruling 2026-08-15.', - acceptanceCriteria: - 'No authored `record:chatter` / `record:discussion` component carries `position: ' - + "'sidebar' | 'inline' | 'drawer'`; `objectstack validate` passes. Review the rewritten " - + "values against intent: 'sidebar' and 'drawer' became 'right' (a docked side panel — " - + 'what both spellings meant, but NOT what they did: both used to fall through to the ' - + "in-flow render, so the page's visible layout changes to the docked panel the author " - + "originally asked for). Where the in-flow presentation was actually wanted, write " - + "'bottom'. If a panel relied on the old schema default `collapsible: true`, author " - + '`collapsible: true` explicitly — an unset key now defers to the renderer, which does ' - + 'not collapse.', - }, - // #10054 (ADR-0049 enforce-or-remove) — the D3 entry of the - // `record-highlights-field-icon-removed` family (ruling B on #17152: one D3 - // entry per retirement family, even when D2 is lossless). The strip changes no - // pixel; what it leaves is the meaning the author put in an icon nobody drew. - { - id: 'record-highlights-field-icon-retired', - surface: 'page.component.record:highlights.fields[].icon — the per-chip icon on an object entry ' - + 'of the highlights field list', - replacement: '(removed — the highlight chip has no icon slot.) Carry whatever the icon was meant ' - + 'to signal in what the chip does render: its label, or the field\'s own value.', - reason: 'The D2 conversion `record-highlights-field-icon-removed` deletes `icon` from the object ' - + 'entries of every `record:highlights` field list, and the delete is lossless: the chip ' - + 'renders a label and a value and nothing else, the registration path carries field names ' - + 'only, and the Studio designer publishes the list as plain strings, so an authored icon was ' - + 'accepted and drawn by nothing. The residue is the author\'s intent. Six author-facing ' - + 'surfaces advertised the key, so an author may have chosen an icon to carry meaning — a ' - + 'warning glyph beside a risk score, a flag beside a region — and designed the page assuming a ' - + 'reader would see it. That meaning was never shown and is not shown now; only the author can ' - + 'say whether it matters and where it should live instead.', - acceptanceCriteria: 'No `record:highlights` field entry carries `icon`; the parse refuses it. The ' - + 'highlights strip renders the same chips, in the same order, with the same labels and values ' - + 'as before the upgrade. For each chip whose icon carried meaning, a reader who sees only the ' - + 'label and value can still tell what the icon was meant to say. Any tooling that generated ' - + 'highlight entries (a code generator, a template) no longer emits the key.', - }, - // #20295 (family `rest-api-retire`, rank 9 of the #18900 census; triage graded - // it RETIRE by the maintainer's criterion) — the D3 entry of the family (ruling - // B on #17152: one D3 entry per retirement family). Registered keys: - // `api/RestApiConfig:responseFormat` and `api/RestApiConfig:documentation.enabled` - // — four ledger keys, since `responseFormat` retires whole with its three - // members. No D2 conversion: a `RestServerConfig` is plugin TS configuration, - // never a stack collection member or a stored row (the - // `rest-server-config-dead-keys-retired` precedent on the four sibling - // sub-objects), so this entry is where the prescription reaches - // `os migrate meta`, the upgrade guide and `spec-changes.json`. - { - id: 'rest-api-config-dead-keys-retired', - surface: 'restServer.api.responseFormat / restServer.api.documentation.enabled', - replacement: - '(removed — delete each key; neither had an effect to preserve. Whether the server publishes its ' - + 'OpenAPI document and the docs viewer is `api.enableOpenApi`, the switch the mount already reads. ' - + 'Response shapes are fixed — each route answers in the response schema `@objectstack/spec/api` ' - + 'declares for it — and are not a server-wide option, so there is no replacement for `responseFormat`.)', - reason: - 'The `rest_api` liveness census found every member of these two keys `dead`: `normalizeConfig` ' - + 'parsed them, applied their defaults and copied them into the REST server\'s config, and no site ' - + 'ever read them back. So `responseFormat.envelope: false` unwrapped no response, ' - + '`includeMetadata` and `includePagination` gated nothing, and `documentation.enabled: false` ' - + 'turned no document off — the document\'s existence was, and is, decided by `api.enableOpenApi` at ' - + 'the mount. Enforce-or-remove (ADR-0049) resolved both to REMOVE: mainstream data APIs keep a ' - + 'fixed response envelope that no administrator toggles server-wide, a configurable envelope would ' - + 'fork the declared response shapes the client SDK parses and the served /openapi.json describes, ' - + 'and `documentation.enabled` duplicates a switch that is already enforced. `RestApiConfigSchema` ' - + 'and its inline `documentation` ' - + 'block are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row ' - + 'stays `dead` with a REMOVED note. No stored or built artifact carries either key, so no emitted ' - + 'default needs to be tolerated as residue: the config is a construction argument that is parsed and ' - + 'consumed in the same process. The consumer still owes the judgment because a host that WROTE ' - + '`envelope: false` or `documentation.enabled: false` believed its clients saw a different shape or ' - + 'no document, and only that host knows which clients were built on the belief.', - acceptanceCriteria: - 'No `RestServerConfig` value passed to the REST plugin carries `api.responseFormat` or ' - + '`api.documentation.enabled` — a config that does now fails `RestServer` construction (and so ' - + 'the REST plugin\'s `start`) with the retirement prescription, naming the key and ' - + '`RestApiConfigSchema`, instead of being accepted and ignored; `tsc` refuses the key at the ' - + 'authoring site (`never`). A host that meant "serve no OpenAPI document" sets `api.enableOpenApi: ' - + 'false` and sees `GET /openapi.json` and `GET /docs` unmounted. Every client that parses REST ' - + 'responses reads each route\'s declared response shape. Every LIVE key of the `api` block — ' - + 'including `documentation`\'s other members — parses byte-identically to before, and the mounted ' - + 'REST surface is unchanged: ' - + 'neither key ever reached it.', - }, - // #20294 (family `rest-api-documentation`, rank 8 of the #18900 census) — the - // D3 entry of the family's RETIRE half, under ruling B on #20359: the eight - // identity members of `api.documentation` are enforced (they overlay the served - // OpenAPI `info` on both doors) and `documentation.version` is retired. One D3 - // entry per retirement family (ruling B on #17152). Registered key: - // `api/RestApiConfig:documentation.version`. No D2 conversion: a - // `RestServerConfig` is plugin TS configuration, never a stack collection member - // or a stored row (the `rest-api-config-dead-keys-retired` precedent on this - // same block), so this entry is where the prescription reaches - // `os migrate meta`, the upgrade guide and `spec-changes.json`. - { - id: 'rest-api-documentation-version-retired', - surface: 'restServer.api.documentation.version', - replacement: - '(removed — delete the key. The served OpenAPI document\'s `info.version` is the protocol version, ' - + 'i.e. the version of the `@objectstack/spec` package that generated the document, with no configured ' - + 'override. An app that wants to publish ' - + 'its own release number writes it into `api.documentation.description`, which the served ' - + '`info.description` now carries.)', - reason: - 'The `rest_api` liveness census found `documentation.version` `dead`: `normalizeConfig` parsed it and ' - + 'copied it into the REST server\'s config, and no site read it back, so `version: \'2.3.0\'` never ' - + 'reached the served document. Enforce-or-remove (ADR-0049) split the `documentation` block by who ' - + 'owns each field. The title, description, terms of service, contact and license are the publisher\'s ' - + 'identity and are now enforced. `info.version` is a fact of the protocol: an earlier ruling made the ' - + 'served `info.version` equal the published artifact\'s, so an integrator can read which protocol ' - + 'version they are talking to, and it removed the serve-time override that had made the field mean the ' - + 'route identifier. A publisher-set version would give the field a third meaning, so the key is ' - + 'retired instead of enforced. `RestApiConfigSchema`\'s inline `documentation` block is a non-strict ' - + '`z.object()`, so the key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED ' - + 'note. The consumer still owes the judgment because a host that WROTE `documentation.version` believed ' - + 'its integrators read that number from the document, and only that host knows whether any client was ' - + 'built on the belief and where the number should be published instead.', - acceptanceCriteria: - 'No `RestServerConfig` value passed to the REST plugin carries `api.documentation.version` — a config ' - + 'that does now fails `RestServer` construction (and so the REST plugin\'s `start`) with the retirement ' - + 'prescription, naming the key and `RestApiConfigSchema`, instead of being accepted and ignored; `tsc` ' - + 'refuses the key at the authoring site (`never`). `GET {apiPath}/openapi.json` and its ' - + 'environment-scoped twin serve `info.version` equal to the one the bundled ' - + '`@objectstack/spec/openapi.json` carries, whatever the config says. A release number the host still ' - + 'wants published appears in the served `info.description` after it is written into ' - + '`api.documentation.description`.', - }, - { - id: 'rest-api-endpoint-handler-status-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'RestApiEndpoint.handlerStatus (the implemented / stub / planned marker an ' - + 'endpoint in a REST API plugin route registration could carry), the ' - + 'HandlerStatusSchema / HandlerStatus value def it was typed with, and the ' - + 'RouteCoverageEntrySchema / RouteCoverageReportSchema report shapes (with ' - + 'their RouteCoverageEntry / RouteCoverageReport types) that re-declared it', - replacement: - 'nothing declarative — the key never changed what the platform served, so ' - + 'there is no working configuration to migrate to. Delete the key; an ' - + 'endpoint that has no handler yet is simply not registered. Route ' - + 'readiness that IS measured is unchanged and lives elsewhere: the ' - + "discovery payload reports each service's status and handlerReady " - + '(api/discovery.zod.ts), and packages/runtime/src/route-ledger.ts asserts ' - + 'per-route coverage in CI. A declared-but-unbuilt route answering 501 ' - + 'instead of 404 is a new capability the ruling explicitly excluded (zero ' - + 'pull); if it is ever wanted it re-declares fresh under its own ruling, ' - + 'executor first', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling of 2026-09-01 on this key: ' - + 'remove it with a tombstone; enforce excluded. The key was DOCUMENTED to ' - + 'cause a specific runtime behaviour ' - + '— its docstring said a stub handler "returns 501 Not Implemented" — and ' - + 'that behaviour has a different cause: every ' - + 'DispatcherErrorCode.enum.NOT_IMPLEMENTED site (runtime/src/' - + 'endpoint-executor.ts ×3, runtime/src/api-mapping.ts, ' - + 'runtime/src/api-endpoint-step.ts) is the declarative-endpoint executor ' - + 'refusing a target or mapping it cannot serve, and none of them consults ' - + 'handlerStatus. Measured at the retirement base (origin/main a9b2be0b0, ' - + '2026-09-02, skills/** and tests excluded): the only identifier hits were ' - + 'the declaration on RestApiEndpointSchema, the re-declaration on ' - + 'RouteCoverageEntrySchema and a docblock saying adapters SHOULD warn on ' - + 'it; RouteCoverageReportSchema — the one shape that would have carried ' - + 'the status outward — had zero constructors in objectstack, objectui ' - + '(pinned sha) and cloud. So an author who wrote handlerStatus: \'stub\' ' - + 'expecting the dispatcher to answer 501 got an ordinarily served route, ' - + 'and the declaration reported progress to nobody — a declared ≠ enforced ' - + 'gap on the same endpoint vocabulary whose ApiEndpointSchema had already ' - + 'been closed strictly once `api` became a registered metadata type, and ' - + 'the surface a published skill had been teaching as working machinery ' - + '(this finding came out of correcting that skill sentence, in a factual ' - + 'sweep of the API skill). Bookkeeping: the KEY is tombstoned with ' - + 'retiredKey() on the ' - + 'non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in ' - + 'RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus ' - + '(orphan value enum once both carriers are gone — an exported value schema ' - + 'with no consumer reads as a capability, so it leaves with its key), ' - + 'api/RouteCoverageEntry and api/RouteCoverageReport (route 3: nobody ' - + 'ever parsed or constructed one) — all three in RETIRED_DEFS_BY_MAJOR[18]. ' - + 'It is a SEMANTIC entry rather than a D2 conversion because there is no ' - + 'source to rewrite: nothing in the tree parses RestApiEndpointSchema ' - + 'outside its own unit tests — a REST API plugin route registration is not ' - + 'a stack collection member and never a sys_metadata row — so the ' - + 'conversion chain has no seam that would ever see one (the ' - + 'kernel/Manifest:loading disposition). ENFORCE was excluded by the ruling: ' - + 'mounting a 501 stub for stub / planned endpoints is a zero-pull new ' - + 'capability, not a repair. The same ruling records the class direction ' - + 'for two sibling ADR-0049 findings (the unbound branded identifier schemas ' - + 'and the event-name schema no runtime reads; not ruled by it): ' - + 'a declared-but-unenforced key with no pull retires; enforce/bind only on ' - + 'a named consumer or measured pull. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No source writes handlerStatus on a RestApiEndpoint: authoring it is now a ' - + 'tsc error at the site (the tombstone types the key never) and a parse ' - + 'error carrying the prescription at path handlerStatus, for every former ' - + "value including the documented default 'implemented' (which was prose " - + 'only — the key never carried a Zod .default(), so no built artifact ' - + 'materialised it and there is no residue window). Pinned in ' - + 'api/plugin-rest-api.handler-status-retirement.test.ts. Concretely, check ' - + 'two places. (1) Every RestApiEndpoint literal — in a route registration ' - + 'passed to the REST API plugin, or standalone: delete the handlerStatus ' - + 'line; nothing served changes, because nothing ever read it. (2) Code ' - + 'importing HandlerStatusSchema, HandlerStatus, RouteCoverageEntrySchema, ' - + 'RouteCoverageEntry, RouteCoverageReportSchema or RouteCoverageReport from ' - + '@objectstack/spec or @objectstack/spec/api: every one is TS2305 after ' - + 'upgrade; no replacement exists to point at, because no producer ever ' - + 'emitted the report. Everything else on RestApiEndpointSchema — method, ' - + 'path, handler, category, public, permissions, the OpenAPI and ' - + 'performance keys — parses exactly as before, and the shipped default ' - + 'route registrations (getDefaultRouteRegistrations) never carried the ' - + 'key and still parse.', - }, - { - id: 'rest-api-plugin-durations-unit-in-key', - surface: 'the three REST-plugin durations whose name carried no unit: RestApiEndpoint.timeout, ' - + 'RestApiEndpoint.cacheTtl and RestApiPluginConfig.performance.defaultCacheTtl ' - + '(api/plugin-rest-api.zod.ts)', - replacement: 'timeoutMs (milliseconds), cacheTtlSeconds (seconds) and defaultCacheTtlSeconds ' - + '(seconds, default 300) — rename each key; every value is unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' - + 'RestApiEndpoint is this rule\'s clearest specimen after the founding one: `timeout` in ' - + 'MILLISECONDS and `cacheTtl` in SECONDS sat three lines apart on one shape, each unit named ' - + 'only in its describe, so the two numbers were indistinguishable at the authoring site and a ' - + 'value copied from one to the other was off by 1000x with no error anywhere. ' - + 'performance.defaultCacheTtl travels with them rather than in its own entry because it is ' - + 'the plugin-wide DEFAULT behind the per-endpoint override: renaming the override and leaving ' - + 'the default bare would have spelled one value two ways across one config. All three are ' - + 'retiredKey() tombstones — these shapes are not strict, so a bare deletion would strip the ' - + 'old key in silence, and defaultCacheTtl is a tombstone INSIDE the live `performance` block, ' - + 'whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a ' - + 'RestApiPluginConfig is the REST plugin\'s construction argument and a RestApiEndpoint is a ' - + 'route registration inside it — neither is a stack collection member or a stored row, so the ' - + 'chain has no seam that ever runs on them. That is the disposition ' - + 'api/RestApiEndpoint:handlerStatus already carries on this very shape ' - + '(rest-api-endpoint-handler-status-retired), and what ruling B prescribes for a key that is ' - + 'not authorable metadata. ADR-0087.', - acceptanceCriteria: - 'Every RestApiEndpointSchema.parse(…) and RestApiPluginConfigSchema.parse(…) site spells ' - + '`timeoutMs`, `cacheTtlSeconds` and `performance.defaultCacheTtlSeconds`; authoring any old ' - + 'spelling fails to compile (input type `never`) and fails to parse with the rename ' - + 'prescription naming the suffixed key. The built-in route tables shipped from this module ' - + '(DEFAULT_METADATA_ROUTES, DEFAULT_BATCH_ROUTES, DEFAULT_I18N_ROUTES, ' - + 'DEFAULT_ANALYTICS_ROUTES, DEFAULT_AUTOMATION_ROUTES, DEFAULT_DISCOVERY_ROUTES) author the ' - + 'new spellings at the same magnitudes they authored the old ones — a batch endpoint still ' - + 'gets 60000 ms and a discovery response is still cached for 3600 s.', - }, - { - id: 'rest-server-config-dead-keys-retired', - surface: 'restServer.crud.patterns / restServer.crud.objectParamStyle / restServer.metadata.cacheTtl / ' - + 'restServer.metadata.endpoints.schema / restServer.batch.operations.upsertMany / ' - + 'restServer.batch.defaultAtomic / restServer.routes.includeObjects / restServer.routes.excludeObjects / ' - + 'restServer.routes.nameTransform / restServer.routes.overrides', - replacement: - '(removed — delete each key; none had an effect to preserve. Per-object API exposure is declared on ' - + 'the object: `enable.apiEnabled: false` hides it from the REST data surface (404) and ' - + '`enable.apiMethods` whitelists its operations (405). The data base path is `crud.dataPrefix`, ' - + 'deployment-wide. An endpoint on a custom path or method — or one that needs its own `summary` / ' - + '`description` / `cacheTtl` — is a declarative `api` endpoint (`type: \'object_operation\'`). ' - + 'Batch atomicity is the per-request `options.atomic` (ADR-0119 D4); upsert is an operation type of ' - + 'the generic `POST /data/:object/batch` endpoint, gated by `batch.enableBatchEndpoint`.)', - reason: - 'The liveness census that enrolled the four `RestServerConfig` sub-objects found 15 of their ' - + '32 rows `dead`: parsed, defaulted and normalized into the REST server\'s config by `normalizeConfig` ' - + '(which parses them, rather than casting them, since an earlier fix) and never read back. ' - + '`crud.patterns` and `routes.overrides` described route customization ' - + 'the server mounts from fixed pairs; `routes.includeObjects` / `excludeObjects` and `overrides.enabled` ' - + '/ `operations` duplicated the object\'s own enforced exposure keys; `nameTransform` and ' - + '`objectParamStyle` were enums validated and then ignored; `metadata.endpoints.schema` and ' - + '`batch.operations.upsertMany` gated routes that were never built; `metadata.cacheTtl` fed no cache ' - + 'and no header; `batch.defaultAtomic` would have silently overridden a per-request contract ADR-0119 ' - + 'D4 had deliberately set. Enforce-or-remove (ADR-0049) resolved every family to REMOVE because each ' - + 'promised capability either already exists at its proper seat (the object, the declarative endpoint, ' - + 'the batch request) or would contradict a fixed contract (the client SDK, the discovery document and ' - + 'the served /openapi.json all describe the mounted CRUD paths; the object `name` is the REST path ' - + 'segment). All four schemas are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone ' - + 'and its ledger row stays `dead` with a REMOVED note; `api/CrudEndpointPattern`, the value def of ' - + '`crud.patterns`, leaves with it. No D2 conversion: a `RestServerConfig` is plugin TS configuration, ' - + 'never a stack collection member or a `sys_metadata` row (the `openApi31` precedent). A closed-set ' - + 'sweep of the cloud repository at 9b6abe0f2fd5: zero hits, structural — cloud never authors a ' - + '`RestServerConfig`.', - acceptanceCriteria: - 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries ' - + 'any of the ten keys — a config that does now fails `new RestServer(...)` / `createRestApiPlugin().start()` ' - + 'with the retirement prescription (naming the sub-object, the key and the declaring schema) instead of ' - + 'being accepted and ignored; `tsc` refuses the key at the authoring site (`never`). Every LIVE key of the ' - + 'four sub-objects parses byte-identically to before: `crud.operations.*`, `crud.dataPrefix`, ' - + '`metadata.prefix` / `enableCache` / `maskObjectFields` / `endpoints.types|items|item`, ' - + '`batch.maxBatchSize` / `enableBatchEndpoint` / `operations.createMany|updateMany|deleteMany` keep ' - + 'their defaults and their mounts. The mounted REST surface is byte-identical before and after — none ' - + 'of the ten keys ever reached it. No code imports `CrudEndpointPattern(Schema)` from ' - + '`@objectstack/spec/api` (TS2305 after upgrade).', - }, - // A row-level policy's `check` is refused where it can never run: on a policy - // whose `operation` is `select` or `delete`. Recorded as a semantic TODO, not a - // D2 conversion, because which rewrite is right depends on what the author meant - // the predicate to guard, and a rewrite made on their behalf would change which - // rows the policy admits. - { - id: 'rls-check-on-select-or-delete-policy-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'security.PermissionSet rowLevelSecurity[].check (RowLevelSecurityPolicySchema) on a policy whose ' - + 'operation is select or delete. A blank check (empty or whitespace only) declares nothing and is ' - + 'not refused', - replacement: - 'what the predicate was meant to guard, written where it runs. To limit which rows a select policy ' - + 'lets a caller read, or which rows a delete policy lets a caller delete, write the predicate as ' - + '`using` on that policy (remove `check`; if the policy already has a `using`, AND the two with &&). ' - + 'To validate rows as they are written, declare the `check` on a policy whose `operation` is ' - + '`insert`, `update` or `all` instead. The refusal lands at rowLevelSecurity[N].check, names the ' - + 'operation, and states both rewrites', - reason: - 'ADR-0049 enforce-or-remove and ADR-0058 D4. A `check` judges the post-image of a write: the new ' - + 'row of an insert, the changed row of an update. A select or delete writes no row, and the ' - + 'plugin-security write gate collects only the policies whose operation is the write\'s own or ' - + '`all`, so a `check` on a select or delete policy was accepted, stored and never evaluated. ' - + 'Measured on main before this change: a policy carrying only check record.status != ' - + '\'archived\' on select or delete admitted every insert and update of an archived row, and beside ' - + 'a USING-only `all` sibling it did not replace that sibling\'s `using` default the way a `check` ' - + 'on an insert, update or all policy does. An author (an AI author above all) who wrote a check ' - + 'on a delete policy believed deletes were guarded by it. The refusal is a non-transforming ' - + 'refinement on the policy schema, so it reaches every door that parses a permission set: ' - + 'defineStack, os validate, and the metadata save path, whose permission type validates ' - + 'against PermissionSetSchema. Metadata AT REST is not ' - + 'rewritten and this entry adds no D2 conversion: dropping the key would silently discard the ' - + 'predicate the author wrote, and moving it to `using` would start filtering reads or deletes ' - + 'the policy never filtered before — both change which rows the policy admits, which is the ' - + 'policy author\'s decision. Ships at once, no transition window and no advisory lint phase.', - acceptanceCriteria: - 'Search every authored and stored permission set for a rowLevelSecurity policy whose operation ' - + 'is select or delete and whose check is non-blank — metadata files, sys_metadata permission ' - + 'rows, and the row_level_security column of sys_permission_set. For authored metadata the sweep ' - + 'is mechanical: PermissionSetSchema.safeParse answers one custom issue at ' - + 'rowLevelSecurity[N].check whose message begins "`check` is never evaluated on a `select` ' - + 'policy" (or `delete`). For each, decide what the predicate was meant to guard: reads or ' - + 'deletes ⇒ express it in that policy\'s `using`; writes ⇒ move it to an insert, update or all ' - + 'policy. Then re-check the policy set\'s behaviour rather than assuming it is unchanged: the ' - + 'removed check never ran, so dropping it changes nothing, but a predicate moved into `using` ' - + 'now filters rows it never filtered, and a `check` moved onto an insert, update or all policy ' - + 'now replaces the `using` default of its USING-only siblings for that write. A policy with ' - + '`using` only, and a `check` on an insert, update or all policy, parse exactly as before. The ' - + 'repo, its example apps and the pinned console carried no such policy at the time of the ' - + 'change; two test fixtures that used select incidentally were moved to all and insert.', - }, - // The row-level-security face of ruling A on #19886 (stage 2a): the formula - // evaluator plugin-security runs a policy's check against. Recorded as its own - // entry because the surface an author rewrites is the RLS predicate, a CEL - // string, not a filter object they wrote by hand. - { - id: 'rls-predicate-array-comparand-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != ' - + 'or == against a list, ' - + 'a list literal or a current_user membership array, and the negation of such an ==. They ' - + 'lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which ' - + 'the @objectstack/formula evaluator matchesFilterCondition now refuses, together with ' - + '{ field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included', - replacement: - 'the list operator the comparison was standing in for. "One of these values" is in: ' - + 'record.status in ["open", "pending"]. "None of these values" is the negated in: ' - + '!(record.status in ["closed", "archived"]). Scalar != and ==, null, Date comparands, and ' - + '{ $field } references between single-valued columns evaluate exactly as before', - reason: - 'Ruling A of 2026-09-24 refuses an array comparand under $ne, and ruling 乙 of 2026-09-23 ' - + 'refuses one in the implicit-equality slot, each for every driver at once; this change ' - + 'lands both on the formula face, the evaluator ' - + 'plugin-security runs against the post-image of an insert or update to enforce a ' - + 'row-level check. It compared strictly, and no stored value ever equals an array, so a ' - + 'check written record.status != ["closed", "archived"], or != against a current_user ' - + 'membership array, matched EVERY post-image, and a check written ' - + '!(record.status == ["closed", "archived"]) did the same: every write such a policy was ' - + 'written to refuse was admitted and stored. The positive record.status == ["open", ' - + '"pending"] refused every write (403). The evaluator now refuses all of these shapes ' - + 'before any record is judged. The message withholds the field, the operator and the ' - + 'value. Metadata AT REST is not rewritten and this ' - + 'entry adds no D2 conversion: the platform cannot tell which list operator a list ' - + 'comparison was standing in for, and a policy rewritten on the author\'s behalf would ' - + 'change which writes it admits (the negated forms would start refusing writes they ' - + 'admitted, the positive form would start admitting writes it refused), which is the ' - + 'policy author\'s decision. ADR-0058 D4 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep the rowLevelSecurity check and using predicates of your permission sets for != or == ' - + 'whose right-hand side is a list literal or a current_user membership array, and for the ' - + 'negation of such an ==, then rewrite each with in or !(... in ...). ' - + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' - + 'admitted before were right: before this change a != or a negated == against a list ' - + 'admitted every write.', - }, - // The cross-field comparison-class family, both arms in one entry: #20347 - // refuses the comparison where it is authored (the lint rules behind - // os validate, and the permission save door), and #20355 refuses it where the - // row-level write check evaluates it. One classification decides both — - // crossFieldComparisonVerdict in @objectstack/spec/data, lifted from - // driver-sql's #5222 read boundary, which driver-sql now reads too. Recorded as - // its own entry because the surface an author rewrites is a CEL predicate string. - { - id: 'rls-predicate-cross-class-field-comparison-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a ' - + 'CEL predicate comparing a field with another field (==, !=, >, >=, <, <=) where the two ' - + 'declared columns share no comparison class: text against a number, a date against a ' - + 'datetime, a boolean against text, and any column against a file field (file, image, avatar, ' - + 'video, audio) or a formula field. In a filter passed to matchesFilterCondition together with ' - + 'the object\'s declared columns (options.fields), a { $field } comparison under $eq, $ne, $gt, ' - + '$gte, $lt or $lte between two such columns, and between a column and a json or multiple ' - + 'field', - replacement: - 'a comparison between two columns of one comparison class: a number with a number (number, ' - + 'currency, percent, rating, slider, progress, summary), text with text (the string types, ' - + 'autonumber, a single select or radio, a single lookup or user, a master_detail or a tree), a ' - + 'boolean with a boolean, a date with a date, a datetime with a datetime, a time of day with a ' - + 'time of day. ' - + 'A file field and a formula field cannot be compared with another column at all: compare the ' - + 'field with a literal or test it for null. If the two columns do hold comparable values, one ' - + 'of them is declared with the wrong type, so correct that declaration rather than the ' - + 'predicate. Comparisons between two columns of one class lower and evaluate exactly as before', - reason: - 'A column-to-column comparison has one meaning only within one comparison class: across ' - + 'classes SQLite orders every TEXT above every INTEGER while the in-process evaluator coerces ' - + '("open" > 5 is false). A formula field is virtual, with no stored column to reference. The ' - + 'file family is refused by name, whatever the deployment stores: during the ADR-0104 ' - + 'dual-encoding window one media column can hold a bare id and another the JSON-quoted form of ' - + 'the same id, so no comparison against the family is provably one answer on every path. ' - + 'driver-sql has refused such a comparison on the read since it first compiled a { $field } ' - + 'reference to a column-to-column comparison (a text column ordered against a number answered ' - + 'differently on SQLite than in memory, so the pushdown refused it), so a policy written ' - + 'record.status != record.amount (text and a number), record.status != record.photo (text and ' - + 'an image) or record.status != record.is_open (text and a formula field) got three answers, ' - + 'measured through the real plugin-security on driver-sql, on SQLite and PostgreSQL: ' - + 'os validate called it valid, every read it scoped answered INVALID_FILTER / 400 and every ' - + 'by-id update or delete it scoped 403, and an insert or update its check judged, or its using ' - + 'standing in as the check, was admitted and stored, because the write check compared the two ' - + 'raw values. The classification is now exported once from @objectstack/spec/data ' - + '(crossFieldComparisonVerdict) and read by every judge. The authoring arm: the ' - + 'rls-predicate-unenforceable rule refuses the comparison in using and check, on every ' - + 'operation, at os validate, build and lint and at the metadata save door for a permission ' - + 'set, and the sharing-rule-unlowerable-condition rule refuses it in a sharing-rule condition ' - + 'at os validate, build and lint. The write-check arm: the row-level write gate hands ' - + 'matchesFilterCondition the object\'s declared columns, and a comparison the classification ' - + 'does not define is refused INVALID_FILTER / 400 for every insert and update the check judges, ' - + 'before any record is read, with nothing stored; the message withholds the columns and the ' - + 'server log names the policy and both. A comparison against a json or multiple field is now ' - + 'refused by its declared type on the write too, where the earlier refusal of an array ' - + 'comparand under $ne judged it by the value each ' - + 'record held. driver-memory, a test driver with no field-reference arm, still reads such a ' - + 'comparison as a literal. Shipped producers were counted before the change: no shipped ' - + 'row-level policy or sharing-rule condition compares two fields of different classes. ' - + 'Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot ' - + 'tell which comparison the author meant, and rewriting it on the author\'s behalf would change ' - + 'which rows and writes the policy admits, which is the policy author\'s decision. ' - + 'ADR-0058 D4 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Run os validate over your stack: it names every row-level or sharing-rule predicate that ' - + 'compares two fields of different comparison classes, with both declarations. Rewrite each as ' - + 'the replacement says. A policy that never passed os validate (stored before the authoring ' - + 'arm, or written by another path) is refused at request time instead: every read it scopes ' - + 'answers 400 on the SQL drivers, and so does every insert or update its check judges, so ' - + 're-check what each such policy is meant to admit rather than assuming the writes it admitted ' - + 'before were right.', - }, - // Stage 2e of #19886: the mirror of stage 2d's ordering refusal, with the list on - // the RECORD's side. The shape is legal, a field ordered against one bound; what - // the write-check evaluator now refuses is the VALUE the record holds there, a - // list or an object, the way driver-sql's read already refuses the same - // comparison on a column it stores as JSON text. Recorded as its own entry - // because the surface an author rewrites is a CEL predicate string. - { - id: 'rls-predicate-stored-list-ordering-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'security.PermissionSet rowLevelSecurity[].check, and .using where it stands in as the check — ' - + 'a CEL predicate ordering a field against a bound (>, >=, <, <=) where the field holds a list ' - + 'or an object on the record being written, as a json column or a multiple lookup does, or as ' - + 'a list written into a text or number field does. In a filter passed to matchesFilterCondition, ' - + '$gt / $gte / $lt / $lte and $between on a field whose value on the record is a list or a plain ' - + 'object, whatever the comparand', - replacement: - 'a comparison that names one value. Order a single-valued column (record.priority > 2), or test ' - + 'membership in the list with in (record.status in ["open", "pending"]); a json or multiple ' - + 'field has no ordering. A record whose json column holds one scalar is compared exactly as ' - + 'before, and so are null and Date values, and every equality (==, !=, in) against a stored ' - + 'list', - reason: - 'The mirror, with the list on the record\'s side, of the earlier refusal of an ordering ' - + 'operator against an array comparand (one of the same-class leaks that followed the ' - + '2026-09-24 ruling refusing an array under $ne), measured ' - + 'through the real plugin-security on driver-sql and driver-memory. record.tags > "a", with ' - + 'tags a json column holding ["m"], lowered to { tags: { $gt: "a" } }, and the write-check ' - + 'evaluator compared the list\'s JavaScript string form ("m" > "a"), so the check admitted and ' - + 'stored the write; record.meta < "a" with meta holding { a: 1 } compared "[object Object]" and ' - + 'did the same, and so did a multiple lookup. driver-sql\'s read refuses every ordering ' - + 'comparison, and $between, on a column it stores as JSON text, by declared type (400), because ' - + 'such a comparison can never mean what the caller wrote; the in-process write check now ' - + 'follows it, per record: INVALID_FILTER / 400 and nothing stored, on an insert and on a by-id ' - + 'update, including one that edits another field of a row whose stored column holds a list. ' - + 'A list written into a text or number field under an ordering check, admitted before and ' - + 'stored as the text "[500]" by driver-sql, is refused the same way. driver-memory, a test ' - + 'driver, still compares a stored list element by element on a read, so there the write and ' - + 'the read part. Shipped producers were counted before the change: no shipped row-level or ' - + 'sharing-rule predicate orders a field at all. ' - + 'Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot ' - + 'tell which comparison an ordering over a list was standing in for, and rewriting it on the ' - + 'author\'s behalf would change which writes it admits, which is the policy author\'s decision. ' - + 'ADR-0058 D4 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep the rowLevelSecurity check predicates of your permission sets, and the using predicates ' - + 'of policies that declare no check, for >, >=, < or <= whose field is a json field or a ' - + 'multiple lookup, and rewrite each as the replacement says. Then write a record through each ' - + 'such policy: a write whose compared field holds a list now answers 400 rather than being ' - + 'admitted by string comparison, so re-check what the policy is supposed to admit.', - }, - // The saved-report stack and the `report` metadata kind shared a word and - // nothing else; this retires the stack and leaves the kind untouched. - { - id: 'saved-report-stack-retired', - surface: - 'the saved-report stack, whole: the `reports` platform capability token (`requires: ' - + '[\'reports\']`, its `PLATFORM_CAPABILITY_TOKENS` member and its ' - + '`PLATFORM_CAPABILITY_PROVIDERS` row); the saved-report service contract in ' - + '`@objectstack/spec/contracts` (`IReportService`, `SavedReport`, `ReportSchedule`, ' - + '`ReportQuery`, `ReportFormat`, `ReportRunResult`, `SaveReportInput`, ' - + '`ScheduleReportInput`); the ' - + '`sys_saved_report` and `sys_report_schedule` platform objects (`SysSavedReport` / ' - + '`SysReportSchedule` in `@objectstack/platform-objects/audit`) and their names in ' - + '`PLATFORM_PROVIDED_OBJECT_NAMES`; the eight `/api/v1/reports` routes (list, save, get, ' - + 'delete, run, schedule, list schedules, unschedule); the `reports` namespace of ' - + '`@objectstack/client`; and the `@objectstack/plugin-reports` package that served them. ' - + 'NOT the `report` metadata kind (`ReportSchema`, `/meta/report`, datasets, analytics), ' - + 'which is unchanged.', - replacement: - 'Delete `\'reports\'` from `requires` — `defineStack` now refuses it with this ' - + 'prescription. A report is `report` metadata: `ReportSchema` over a dataset (ADR-0021), ' - + 'served by the analytics service every server mounts. A saved ad-hoc object query — ' - + 'what a `sys_saved_report` row held — is a ListView on that object. Code that imported ' - + 'the contract types or called the client namespace deletes those lines; there is no ' - + 'successor API and no scheduled-delivery replacement.', - reason: - 'Maintainer ruling 2026-09-25 (verbatim: 「A. 退役」, then 「你直接派发处理这个退役任务。」). ' - + 'The stack persisted a raw object query (`object_name` plus `{filter, fields, orderBy, ' - + 'limit, groupBy}`) with a render format and an owner — the same object-plus-raw-query ' - + 'shape ADR-0021 removed from the `report` kind as its legacy inline query form, alive in ' - + 'a parallel table under the same word. Measured on the main branch of all three repos ' - + 'before removal: zero callers of the routes, the client namespace or the service ' - + 'contract outside their own tests, and no app declaring the capability. A declared ' - + 'capability with no consumer is a surface an author (most often a model) reaches for ' - + 'and confuses with the real report kind, so it is retired at once, with no ' - + 'deprecation window.', - acceptanceCriteria: - '`defineStack({ requires: [\'reports\'] })` throws `STACK_CAPABILITY_UNKNOWN` (422) whose ' - + 'message names the retirement and the replacement; `classifyRequiredCapability` answers ' - + '`unknown` for the token; every `/api/v1/reports` path answers the standard unmounted-route ' - + '404; nothing imports the retired contract types or objects (TS2305 after upgrade). ' - + 'Existing `sys_saved_report` / `sys_report_schedule` tables in deployed databases are left ' - + 'in place untouched — no backfill, no reaper, no drop (the platform never drops a table ' - + 'that metadata stops declaring); `os migrate plan` lists them in its informational ' - + 'unmanaged-tables section, and dropping them is the operator\'s decision.', - }, - { - id: 'schedule-flow-acting-organization-required', - surface: - 'The START NODE `config.organization` key of every time-triggered flow — a `type: ' - + "'schedule'` flow carrying a `config.schedule` cadence, and the `timeRelative` sweep " - + 'that carries its cadence in the same slot (`FlowTriggerKind` `schedule` / ' - + '`time_relative`) — TOGETHER WITH the deployment variable that decides whether such a ' - + 'flow arms at all, `OS_AUTOMATION_SCHEDULED_WORK_ENABLED`. Nothing is renamed, retired ' - + 'or re-typed: the start node\'s `config` is an OPEN record (ADR-0018), so the key is an ' - + 'ADDITION to a slot that already accepted it, and every flow that parses today parses ' - + 'byte-identically after the change. What narrows is the BIND-time accept set and the ' - + 'RUN-time data plane — and what the 2026-09-12 and 2026-09-16 amendments narrow further ' - + 'is WHERE that narrowing applies: the declaration is required under tenancy posture ' - + '`isolated` only, is OPTIONAL under `group` (where an undeclared run acts as the swept ' - + "record's own organization), is not read under `single`, and no time-triggered flow arms " - + 'anywhere until the deployment switches package-authored scheduled work on.', - replacement: - 'Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS ' - + 'PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` ' - + 'arms time-triggered flows and packaged `defineJob` cron jobs; unset — the global ' - + 'default, in every posture and every kernel — arms neither, and every such flow is ' - + 'listed by `getTriggerBindingAudit()` and the CLI startup summary as DISABLED BY ' - + 'DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' - + '(approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership ' - + 'backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on ' - + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS `isolated`, declare ' - + 'the organization each flow runs as, on the start node beside the cadence: ' - + "`config: { schedule: { … }, organization: '' }`. There is " - + 'deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per ' - + 'organization — and deliberately no fallback: nothing on this path ever chooses an ' - + 'organization, because a wrong `organization_id` is silently authoritative to every ' - + 'report, export and cleanup that filters by organization, while a refusal is visible ' - + 'at boot and names its flow. Under the `single` posture with the switch on, declare ' - + 'NOTHING: the run carries no organization and every tenant-scoped insert beneath it ' - + 'resolves the deployment\'s one organization through the guard that makes a ' - + 'system-context write resolve the install\'s organization. Under the `group` ' - + 'posture with the switch on, declaring is OPTIONAL and both shapes are supported: a ' - + 'declared flow behaves exactly as under `isolated` (the declaration bounds SELECTION and ' - + 'identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 ' - + 'makes inherent to the posture — and stamps each run it launches with the SWEPT ' - + "RECORD's own organization, the same subject-first order `sys_automation_run` already " - + 'uses. ⚠️ An undeclared `group` flow that reaches a tenant-scoped write with NO record ' - + 'to derive from — a record-less cron emitting a notification — is REFUSED at that write ' - + '(`walled-posture`, ADR-0112), loudly and by name; declare `config.organization` on that ' - + 'flow, which is the remedy the refusal itself prints. ⚠️ Three ' - + 'consequences apply to an `isolated` deployment that splits one flow into N, and each is ' - + 'deployment work: (1) rows whose tenant column is NULL stay visible to a scoped read ' - + '(`org = :tenant OR org IS NULL`), so after the split each such row is matched ONCE ' - + 'PER FLOW — N runs and N notifications for one row, each acting as a different ' - + 'organization; (2) the dispatch-claim key embeds the flow name ' - + '(`schedule::`, `time-relative:::`), so ' - + "renaming one flow into N abandons the current window's claims and a window already " - + 'delivered under the old name can deliver once more under the new ones; (3) a run ' - + 'SUSPENDED before the upgrade rehydrates its context from `context_json`, which ' - + 'carries no `tenantId`, so it resumes org-less — drain or accept in-flight suspended ' - + 'runs rather than assuming the upgrade confines them retroactively.', - reason: - 'Three maintainer rulings, all verbatim and untranslated, in the order they were given. ' - + '2026-09-08: ' - + '「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered ' - + 'run is launched from a job tick and a job tick carries no identity, so the run reached ' - + 'the tenancy guard with nothing to offer it: the notification wrote ' - + '`organization_id = NULL`, every tenant-scoped row beneath it was refused, and the tick ' - + 'still summarised itself as healthy. 2026-09-12, on the same surface: ' - + '「schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。' - + '对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,' - + '有没有可能定义为一个环境变量,根据环境变量控制?」 and 「group 默认也关,云端每库一租户全局默认关」. ' - + 'Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, ' - + 'its tenants, its budget — that no author can know and no metadata key should ask them ' - + 'for, so the gate is a deployment variable read at boot and the global default is OFF. ' - + '2026-09-16, reopening the `group` half of that amendment and nothing else: ' - + '「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ' - + 'ruled A′ the same day: under `group` with the switch on, a flow binds without a ' - + 'declaration. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and ' - + '`group` is not one: ADR-0105 D1 defines it as one legal group over one database with ' - + 'group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level ' - + 'batch job is a capability of the posture rather than the cross-organization task the ' - + 'ruling forbids. What was genuinely unanswered — recorded as unanswered by ruling G item ' - + '3 — was which organization such a run\'s inserts belong to, and the answer is the one ' - + '`sys_automation_run` was already ruled to use: the SUBJECT RECORD\'s organization, with ' - + 'the acting context as the fallback and never the primary. Filling the acting context ' - + 'the same way makes the inbox, delivery and history rows of one run agree about its ' - + 'owner; leaving them to disagree was the defect, not the fix. ⛔ The rejected arm is ' - + 'recorded too, because it is the one a later reader will re-propose: falling back to the ' - + "bootstrap organization (`slug='default'`) for a record-less run. Under a wall that " - + 'organization is minted ADMIN-KEYED by the enterprise organizations runtime and may not ' - + 'exist at all, and where it does it is whichever organization the platform owner ' - + 'registered under — plausibly one plant of many. That is the silently-authoritative ' - + 'wrong owner this entry already forbids, so a record-less undeclared run is refused ' - + 'instead. Where the switch is on, the 2026-09-08 ruling therefore stands unchanged under ' - + '`isolated`, is satisfied per-record under `group`, and is moot under `single`, which ' - + 'holds exactly one organization and therefore has no cross-organization task to forbid. ' - + '⛔ NOT losslessly convertible, and the reason is ' - + 'that both remedies are values only the deployment holds: an organization id is minted ' - + 'per install at runtime and the switch is an operator decision about cost, so there is ' - + 'no authored artifact and no stored representation a transform could rewrite — ' - + '`objectstack migrate meta` cannot know which organization a given sweep belongs to, ' - + 'nor whether this deployment wants scheduled work at all, and inventing either is ' - + 'precisely what the rulings forbid. Registered under ADR-0087 D3 rather than left ' - + 'silent because the change DOES carry a prescription — "decide the switch, then declare ' - + 'one flow per organization under a wall" is deployment work a human must do, which is ' - + 'what D3 says a structured TODO is for. The direct precedent is ' - + '`rest-requireauth-default-flip` (protocol 12): behaviour-only, no shape moved, a ' - + 'deployment judgement no transform can make, registered anyway.', - acceptanceCriteria: - 'The deployment has DECIDED the switch, and the decision is visible: `os doctor` prints ' - + 'the effective `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` value. A deployment that leaves ' - + 'it unset — the default — accepts that no packaged time-triggered flow and no packaged ' - + '`defineJob` runs, and confirms that every such flow appears in ' - + '`getTriggerBindingAudit()` and the CLI startup summary with the reason DISABLED BY ' - + 'DEPLOYMENT POLICY and NOT as "binding failed"; no boot line reads ' - + '`[schedule] NOT BOUND` / `[time-relative] NOT BOUND`, because nothing was refused for ' - + 'a declaration. A deployment that sets it to `true` under posture `single` confirms ' - + 'that its time-triggered flows are armed while declaring no `config.organization`, and ' - + 'that the runs they launch carry none. A deployment that sets it to `true` under posture ' - + '`group` confirms the shape it wants PER FLOW: for a flow it left undeclared, that boot ' - + 'logs the bind line naming per-record ownership, that a sweep tick launches runs stamped ' - + "with each swept record's own organization (NOT one organization for the batch), and " - + 'that any record-less cron among them either declares `config.organization` or is ' - + 'accepted to fail loudly at its first tenant-scoped write; for a flow it declared, the ' - + '`isolated` criteria below apply unchanged. ⚠️ The discriminating observation for the ' - + 'undeclared case is the SET of organizations across the runs one tick launched — a pin ' - + 'that reads only "a run was stamped" passes on the defect too, which stamped them all ' - + 'alike. A deployment that sets it to `true` under posture `isolated` ' - + 'confirms that every `schedule` / `time_relative` ' - + 'flow in the stack declares a non-empty `config.organization` on its start node, that ' - + 'boot logs no `NOT BOUND` line, that `getFlowRuntimeStates()` reports `bound: true` and ' - + 'that `getTriggerBindingAudit()` lists no time-triggered flow — and, where it ran ONE ' - + 'flow across all organizations, that it has split it into one flow per organization and ' - + 're-checked the three consequences above (NULL-tenant rows, abandoned dispatch claims, ' - + 'suspended runs). ⛔ There is NO authoring-time lint for the declaration: it was ' - + 'retired with this amendment because neither the switch nor the posture is knowable ' - + 'from a stack, so `os lint` reporting nothing is the criterion being met, not a check ' - + 'that was skipped. ⚠️ `@objectstack/driver-memory` has NO legal configuration for a ' - + 'time-triggered flow that touches per-organization data under a wall: it refuses any ' - + 'call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`), so a declared flow is ' - + 'refused per call, an undeclared `isolated` one is not armed at all, and an undeclared ' - + '`group` one arms and sweeps unscoped but is refused at the first write it derives an ' - + 'organization for. Multi-organization deployments use `@objectstack/driver-sql`.', - }, - { - id: 'scim-provider-object-retired', - surface: - 'the `sys_scim_provider` platform object (`SysScimProvider` in ' - + '`@objectstack/platform-objects/identity`, re-exported from the package ' - + 'root) and its name in `PLATFORM_PROVIDED_OBJECT_NAMES` ' - + '(`@objectstack/spec/system` constants). The rc.1-era `@better-auth/scim` ' - + 'connection row: one row per SCIM bearer connection, written only by the ' - + 'retired `/scim/generate-token` endpoint.', - replacement: - '(removed — no direct replacement row. The stable `@better-auth/scim` ' - + '1.7.x line, which the platform adopted as one whole-model migration, ' - + 'derives no `scimProvider` model: SCIM ' - + 'state lives in the seven stable platform objects ' - + '(`sys_scim_connection_binding`, `sys_scim_group`, ' - + '`sys_scim_group_member`, `sys_scim_identity_tombstone`, ' - + '`sys_scim_projection_grant`, `sys_scim_subject`, `sys_scim_user`) and ' - + 'connection credentials in the ObjectStack-owned ' - + '`sys_scim_connection_credential`, minted/verified by ' - + '`scim-connection-service.ts` behind the application-owned ' - + '`verifyBearerToken`. A SCIM-enabled deployment re-registers its ' - + 'connections on the stable surface; rc.1 token digests are not portable ' - + 'on any path, so the IdP reissues its token — a migration-day operator ' - + 'action, not a code rewrite.)', - reason: - 'Maintainer ruling 2026-08-24 on the disposition of `sys_scim_provider` ' - + '(verbatim, in part: 「不需要考虑历史数据」) — disposition A: retire, with no ' - + 'data-migration path owed for existing rows (reaffirmed 2026-08-25: SCIM ' - + 'has no real customers; the binding constraint is a smooth upgrade). ' - + 'Executed as a retirement of its own after the stable-1.7.1 migration ' - + 'landed: the installed library derives no `scimProvider` model, so ' - + 'the object backed nothing — nothing could write a row to it any more. ' - + 'Retiring it also removes its `provider_id` unique index, whose ' - + 'stricter-than-upstream uniqueness (one `provider_id` across every ' - + 'organization, where upstream scopes it per organization) was flagged ' - + 'while the SCIM upgrade was parked, and left pending exactly this ' - + 'retirement.', - acceptanceCriteria: - 'No code imports `SysScimProvider` from `@objectstack/platform-objects` ' - + '(TS2305 after upgrade); `isPlatformProvidedObjectName(\'sys_scim_provider\')` ' - + 'returns false, so a stack referencing the name is flagged as a probable ' - + 'typo rather than resolved; plugin-auth provisions no `sys_scim_provider` ' - + 'object and `AUTH_MODEL_TO_PROTOCOL` carries no `scimProvider` entry; the ' - + 'spec registry conformance test (`platform-object-names.test.ts`) pins ' - + 'the absence bidirectionally — re-adding either the object file or the ' - + 'registry name alone reds `registry group "platform-objects" is out of ' - + 'date` (measured both ways when the object was retired). Existing `sys_scim_provider` ' - + 'tables in deployed databases are left in place untouched, by ruling — ' - + 'no backfill, no reaper, no migrate command.', - }, - { - id: 'screen-field-lookup-reference-required', - surface: - "The `reference` key of a `type: 'lookup'` field on a `screen` node — " - + '`flows[].nodes[].config.fields[]` where the node `type` is `screen` and the field ' - + "`type` is `lookup` (`ScreenFieldConfigSchema`). Nothing is renamed, retired or " - + 're-typed and the key set does not move: `reference` was already declared and ' - + 'already optional in the shape. What narrows is the ACCEPT SET for one value of the ' - + "sibling `type` — a `lookup` field with no `reference`, or with a blank one, parsed " - + 'before this major and is refused now. Every other widget hint is untouched, and a ' - + '`lookup` field that already names its target parses byte-identically.', - replacement: - "Name the object whose records the picker offers, beside the type: " - + "`{ name: 'resolved_by_article', type: 'lookup', reference: 'crm_knowledge_article' }`. " - + 'The value is an object NAME (the canonical id — same string `FieldSchema.reference` ' - + 'carries), not a label and not a record id. ⚠️ There is deliberately no default and no ' - + 'inference: a picker pointed at the wrong object is worse than one that refuses to ' - + 'load, because it offers a human a plausible list of the wrong records and the flow ' - + 'stores the id it is given. Where the field genuinely has no target object — the ' - + 'author was using `lookup` to mean "type an id here" — the fix is the other ' - + "direction: change `type` to `'text'`, which is what that field actually was, and " - + 'keep the prose that asked for an id in `inlineHelpText`.', - reason: - 'Maintainer ruling A′, 2026-09-13, verbatim, ' - + 'untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and ' - + 'does nothing — and its own worked example of that state is a `lookup` with no ' - + '`reference`: the field renders a picker, the picker has no object to query, and ' - + 'nothing anywhere says so. The key shipped OPTIONAL on this surface one release ' - + 'earlier, on the argument that flows declaring a bare `lookup` already exist; the ' - + 'ruling reversed that, holding that a degraded shape which ships is not a reason to ' - + 'bend the contract to it. ⛔ NOT losslessly convertible, and the reason is the same ' - + 'one `schedule-flow-acting-organization-required` gives: the remedy is a value the ' - + 'artifact does not contain. A bare lookup records the field name and nothing about ' - + 'its intended object, so `objectstack migrate meta` can identify every site but can ' - + 'answer none of them — and a conversion that guessed (the first object with a ' - + 'matching-looking name, the flow\'s trigger object) would write an authoritative ' - + 'wrong answer into metadata a human then trusts. Registered under ADR-0087 D3 rather ' - + 'than left silent because the change DOES carry a prescription a human can execute, ' - + 'which is what D3 says a structured TODO is for.', - acceptanceCriteria: - "Every `type: 'lookup'` field on every `screen` node in the stack declares a non-empty " - + '`reference`, and the stack parses: `ScreenFieldConfigSchema` refuses the bare form ' - + 'with a message addressed to `reference` ' - + '(`SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED`), so a full metadata parse — `os lint`, or ' - + 'any publish — reports one issue per unfixed site and names the FLOW and the FIELD in ' - + 'its path. Work the list to empty rather than sampling it: a flow whose screen never ' - + 'reaches that node in testing is refused at publish just the same. For each site, ' - + 'answer which object the picker was meant to offer — the declaration is the answer, ' - + "and where there is no such object the field was never a lookup (retype it `'text'`). " - + '⚠️ Runs SUSPENDED at a screen before the upgrade rehydrate their `ScreenSpec` from ' - + 'stored context, so an in-flight run parked on an unfixed screen carries the old ' - + 'shape: drain or re-drive those rather than assuming the fix reaches them ' - + 'retroactively.', - }, - // ADR-0048 addendum N.3 — a runtime narrowing at boot with no authorable key: - // a cold boot refuses an environment-wide permission-set or position row over a - // name a configured package holds. Registered because an upgrading deployment - // can carry such rows and must clear them before its first boot on this major, - // with the offline step that exists for that (the migration prescription the - // ruling on the step orders onto this change's note). - { - id: 'security-catalog-environment-overlay-refused', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the cold boot of a deployment whose sys_metadata holds an active, environment-wide row of type ' - + 'permission or position (or the legacy plural permissions or positions) under the name of a ' - + 'permission set or position a configured package declares, the platform security plugin\'s ' - + 'shipped sets included', - replacement: - 'before the first boot on this major, run `os migrate security-catalog-overlays` with the ' - + 'flags and environment the deployment boots with (`--preset` and `--dev` mean what they mean ' - + 'to `os serve`): it lists exactly those rows, each with the package ' - + 'that holds its name. Then run `os migrate security-catalog-overlays --apply` to delete them ' - + 'through the metadata write path (a history tombstone per row). Or rename the item in the ' - + 'package. Nothing is adopted: an item the environment needs under its own name is re-created ' - + 'under a name no package holds', - reason: - 'Positions, permission sets and capabilities hold one name per deployment, and a package ' - + 'registering a name the environment catalog already holds was already refused on a hot ' - + 'install. A cold boot now refuses it too, right after the stored rows load: the stored row ' - + 'used to be served in place of the package\'s definition, with only a collision warning. ' - + 'Rows like this exist on deployments that saved over a package-held name before the packaged ' - + 'locks refused such saves, and over the security plugin\'s own sets, which it declares only ' - + 'where the boot composes it behind the auth gate (an auth secret set, or a development boot). ' - + 'The refused deployment cannot start, so no in-server action can clear the rows, and the ' - + 'metadata API reaches no legacy-plural row at all; the offline step can. No conversion ' - + 'applies: the rows are the environment\'s own work, and whether to drop or rename one is the ' - + 'operator\'s call, which the step\'s preview puts in front of them. ADR-0048, ADR-0087.', - acceptanceCriteria: - 'On the deployment\'s database and configuration, with its boot flags and environment, ' - + '`os migrate security-catalog-overlays` lists no row (exit 0). The listing covers permission ' - + 'sets and positions and the legacy plural spellings, and never an organization-scoped or a ' - + 'draft row. After `--apply`, the next boot of the same database and configuration comes up, ' - + 'where before it was refused with NAMESPACE_CONFLICT (422) naming the environment catalog as ' - + 'the holder.', - }, - { - id: 'send-template-input-org-retired', - surface: 'contracts.emailService.sendTemplate input.org', - replacement: - '(removed — never implemented; delete the key from the call. It is NOT replaced by ' - + '`organizationId`: that member is the delivery row\'s tenant stamp ' - + '(`sys_email.organization_id` pass-through, added so the email writer stamps a ' - + 'delivery row\'s organization at the source) and opts into no template overlay ' - + 'resolution)', - reason: - 'ADR-0049 enforce-or-remove. `SendTemplateInput.org` was declared as "Tenant id ' - + 'for org-overlay resolution (when supported)" and no implementation ever read it: ' - + '`@objectstack/plugin-email` — the only IEmailService implementation — resolves templates ' - + 'on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no ' - + 'error; the "(when supported)" hedge was the declaration admitting the gap. After the ' - + 'delivery-row stamp landed `organizationId` beside it, the input carried two org-shaped ' - + 'keys of which one did ' - + 'nothing — exactly the shape that invites an AI author to pick the wrong one. There is no ' - + 'behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a ' - + 'call-time input bag (the `data.engine.update options.upsert` precedent), which is why ' - + 'this is a D3 semantic entry with no D2 conversion — no metadata seam ever runs on it. ' - + 'Org-overlay template resolution, if it ever earns a measured business pull, is a new ' - + 'capability with its own ruling — not this key revived.', - acceptanceCriteria: - 'No caller passes `org` to `IEmailService.sendTemplate()`. The enforcement channel is the ' - + 'compiler: `SendTemplateInput` is a programmatic contracts interface with no Zod surface, ' - + 'so authoring `org` is an excess-property `tsc` error (pinned in ' - + '`packages/spec/src/contracts/email-service.test.ts`). Runtime behaviour is deliberately ' - + 'UNCHANGED: nothing ever read the member, so removing it removes no behaviour — a ' - + 'JavaScript caller still passing `org` keeps its exact pre-removal outcome (the key is ' - + 'carried inert and ignored). Template resolution still keys on `(name, locale)`, and ' - + '`organizationId` still stamps `sys_email.organization_id` without acquiring any overlay ' - + 'semantics.', - }, - { - id: 'session-payload-positions-security-axis', - surface: 'GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root `current_user.positions` bound from it)', - replacement: - 'the SAME key, carrying the SECURITY positions — the set `/auth/me/permissions` reports ' - + 'and `resolveUserAuthzGrants` resolves. A reader that wanted the better-auth role ' - + 'scalar reads `user.role`, which is unchanged and still published', - reason: - 'A MEANING change, not a rename: no key moved, so nothing in this entry can be found by ' - + 'grepping for a removed spelling — which is exactly why it needs a ledger row. ' - + '`customSession` built `user.positions` from the better-auth `sys_user.role` scalar ' - + 'split on commas, PLUS the active membership mapped to `org_*`, PLUS `platform_admin`, ' - + 'and read NOTHING from `sys_user_position` (ADR-0057 D4), the source of truth for ' - + 'custom positions. The Console binds that array straight through as the CEL root ' - + '`current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so ' - + 'an `action.visible` / `visibleWhen` / nav `visible` narrowed by a business position ' - + 'answered FALSE for EVERYONE, including the user who genuinely held it. ' - + '⭐ The failure was silent and in the invisible direction: the root was bound and the ' - + 'key was present, so `has(current_user.positions)` was true and CEL raised nothing — ' - + 'the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell ' - + 'and would have shown the button; a successful FALSE shows nothing and reports ' - + 'nothing. The documented example `\'org_admin\' in current_user.positions` kept working ' - + 'throughout, because `org_admin` is the one name that sits on BOTH axes — which is why ' - + 'no example, test or doc could reveal the split. ' - + 'This was a DECLARED contract being violated rather than an ambiguous name: ' - + '`EvalUserSchema` already specified `positions` as "built-in identity names + position ' - + 'names", exposed to "every predicate surface (server formula, server RLS, client UI ' - + 'gates) ... with an identical shape" so a predicate "evaluates identically wherever it ' - + 'is written". `/auth/me/permissions` and every server-side evaluator ' - + '(`ExecutionContext.positions`) already resolved the security axis; the session ' - + 'payload was the one producer that did not, because it derived the value itself ' - + 'instead of asking the authority `resolve-authz-context.ts` reserves that job for. ' - + '⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition ' - + 'rather than an omission: everything the old union contributed beyond the security ' - + 'axis was the `sys_user.role` scalar\'s own tokens, and that scalar is ALREADY ' - + 'published unchanged as `user.role` — the single exception ADR-0090 D3\'s "role" word ' - + 'ban carves out for third-party schema. Minting a `roles` array would revive the exact ' - + 'banned identifier `check:role-word` ratchets against, to publish information the ' - + 'payload already carries. Maintainer ruling 2026-09-05: option A, one name, one meaning ' - + '— `current_user.positions` means the security positions everywhere. ADR-0068 D1/D2, ' - + 'ADR-0090 D3/D5, ADR-0057 D4.', - acceptanceCriteria: - 'No predicate and no client reader treats `current_user.positions` / ' - + '`session.user.positions` as the better-auth role scalar. Audit every authored ' - + '`visible` / `visibleWhen` / RLS predicate that names `current_user.positions` and ' - + 'classify each comparand: a real `sys_position` name, a built-in identity name ' - + '(`platform_admin` / `org_owner` / `org_admin` / `org_member`), or `everyone` needs NO ' - + 'change and starts working where it silently answered FALSE before; a comparand that ' - + 'was only ever a `sys_user.role` token (`user`, and `admin` — note `admin` is NOT a ' - + 'built-in identity name; a membership `admin` is projected as `org_admin`) either ' - + 'moves to `user.role`, or — the supported route — becomes a real position assigned ' - + 'through `sys_user_position`, the governed ADR-0090 D12 channel. ' - + '⚠️ Verify against a REAL session rather than a fixture, and assert the axis by a name ' - + 'that exists on ONE side only: `org_admin` sits on both and cannot discriminate, which ' - + 'is precisely how this defect survived its own documented example. Sign in as a user ' - + 'holding a custom position, read `GET /api/v1/auth/get-session`, and assert that ' - + 'position is in `user.positions` and that the payload agrees set-for-set with `GET ' - + '/api/v1/auth/me/permissions`. ' - + 'Assert the absence of a role-scalar token by VALUE rather than by the predicate\'s ' - + 'verdict: a gate that reads `false` cannot tell "the name is gone" from "the name was ' - + 'never there", and both of those from a faulting predicate, which fails OPEN in the ' - + 'shell and renders anyway. A deployment that stored business role names in ' - + '`sys_user.role` instead of assigning positions is the one that must act; a name in ' - + '`sys_member.role` is still projected, so membership-derived names are unaffected.', - }, - { - id: 'session-user-language-retired', - surface: 'api.session.user.language', - replacement: - '`GET /auth/me/localization` → `locale` (the user\'s own `sys_user.locale` when set → ' - + 'the request\'s `Accept-Language` → the deployment default)', - reason: - '`SessionUserSchema.language` was declared with a permanent default of `\'en\'` and ' - + 'described as "Preferred language", and had no producer and no consumer anywhere: no ' - + 'session endpoint wrote it, no client read it (objectui measured zero readers at its ' - + 'pinned sha), so a reader trusting the published contract received a constant that was ' - + 'not the user\'s language. Meanwhile the user\'s real preference landed as the ' - + 'first-class column `sys_user.locale` (ruled 2026-09-01 once measured demand for a ' - + 'per-user notification locale arrived), which the session type could not see — ' - + 'three spellings of one concept on the published surface, none of them right. The ' - + 'maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 ' - + 'enforce-or-remove and make `GET /auth/me/localization` the ONE read face, with its ' - + '`locale` projecting the user column first. This is a RESPONSE surface — the server ' - + 'mints a `SessionUser` and nobody authors or persists one — so there is no source for ' - + 'the chain to rewrite; the schema tombstones the key via retiredKey() and consumers ' - + 'move their read to the endpoint. No replacement field joins the session contract ' - + 'until a session endpoint really produces one (no dual-spelling window, 不渐进). ' - + 'ADR-0049, ADR-0087.', - acceptanceCriteria: - 'No client reads `user.language` off a `SessionResponse` / `UserProfileResponse`; a ' - + 'client that seeded its UI language from it now reads `locale` off ' - + '`GET /auth/me/localization`, where a user who set `sys_user.locale` sees that value, ' - + 'a user who did not sees the request\'s `Accept-Language` preference, and a request ' - + 'expressing none sees the deployment default. Constructing a `SessionUser` with ' - + '`language` fails to parse with its own prescription instead of being silently ' - + 'stripped, and assigning it is a `tsc` error at the authoring site.', - }, - // One authoring shape for a stack config (the one-authoring-shape ruling). No D2 conversion: - // the change is to the config MODULE's shape (what its default export is), not - // to any metadata key, so there is nothing `objectstack migrate meta` could - // rewrite in a stored row or a parsed stack — the fix is one wrapping call in - // the author's source. - // - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span already, and a nested backtick would close it. - { - id: 'stack-config-default-export-unbuilt-refused', - surface: - 'the default export of objectstack.config.ts when it is not the value defineStack or composeStacks ' - + 'returned: a plain object literal, a spread or Object.assign copy of a built stack, a JSON copy of ' - + 'one, a module with no default export — and each input handed to composeStacks', - replacement: - 'export what the producer returned: `import { defineStack } from \'@objectstack/spec\'; export default ' - + 'defineStack({ … });` with every stack key inside the call (`api`, `plugins`, `requires`, …), or ' - + '`export default composeStacks([defineStack({ … }), …])`. Named exports beside it (`onEnable`, ' - + '`functions`) are unaffected. `defineStack(config, { strict: false })` also satisfies the doors — it ' - + 'is still the producer — but skips its judgement, so reserve it for sources a strict parse cannot yet read', - reason: - 'The stack family\'s cross-field refusals — unknown `requires` capability, cross-references to objects ' - + 'the stack does not define, the namespace prefix, one app per app package, the hierarchy-scope and ' - + 'trigger capability requirements — run inside `defineStack` and nowhere else. A config exporting a ' - + 'plain object skipped all of them: `objectstack validate` and `objectstack build` ran only the schema ' - + 'parse, answered success, and the build shipped the artifact, so the defect surfaced at deploy or ' - + 'never (a trigger flow that silently never fires). Judging the export at the door instead is not ' - + 'possible: a built stack carries each bound standalone action twice (top level and merged into its ' - + 'object), so re-running the family on `defineStack` output refuses every correct project with a bound ' - + 'action. So both producers stamp a non-enumerable provenance mark on what they return ' - + '(`hasStackProvenance`), and `objectstack validate` / `objectstack build` refuse an unmarked default ' - + 'export right after load with `STACK_PROVENANCE_MISSING` (exit 1), before any other judgement; ' - + '`composeStacks` refuses an unmarked input with the same code. A copy of a built stack is refused ' - + 'too, because the mark does not survive a spread or JSON round-trip — by design, since the copy is ' - + 'not what the producer judged. ⚠️ No D2 conversion: the module shape is source code, not metadata. ' - + '`objectstack serve`, `objectstack migrate` and `objectstack lint` load the config as before. ADR-0087.', - acceptanceCriteria: - 'Run `objectstack validate` (and `objectstack build`) in every project. A refusal prints ' - + '`objectstack.config.ts: the default export was not built by defineStack` and, under `--json`, carries ' - + '`code: STACK_PROVENANCE_MISSING`. Wrap the export in `defineStack({ … })`, move any key that was ' - + 'spread onto a copy inside the call, and re-run: the command either passes or now reports the stack ' - + 'family\'s own findings (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, …) that the plain ' - + 'export had been hiding — fix those as each message prescribes. A project already exporting ' - + '`defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected and passes ' - + 'byte-identically.', - }, - // Registered as D3 SEMANTIC and deliberately NOT as a D2 conversion, on the - // D2 scope guard (lossless only — the `owd-legacy-read-aliases` / `'full'` - // precedent): an authored theme has no lossless target. `app.branding` holds - // two colour strings scoped per APP, while a theme is a stack-scoped palette - // with modes and inheritance — a stack may declare N themes and M apps, so - // which palette entry becomes which app's `primaryColor` is a judgment the - // transform cannot make, and auto-DELETING the whole authored artifact would - // silently discard content the author may want to salvage. (Mechanically, a - // full-carrier `stripKeys` conversion also cannot coexist with the published - // `theme-inert-token-scales-removed` fixture: the fixture-disjointness - // contract replays the whole table over every fixture, and that entry's - // fixture necessarily authors `themes` — a secondary observation recorded for - // the next reader, not the reason.) - { - id: 'stack-themes-carrier-retired', - surface: 'stack `themes` (the carrier collection, and `ThemeSchema` with its sub-blocks)', - replacement: - 'delete the `themes:` key (and any `defineTheme` calls). To colour the shipped console, ' - + "set `app.branding.primaryColor` / `accentColor` — the one live colour surface (read by " - + 'objectui, driving `--primary`, `--accent` and their derived CSS variables). A palette ' - + 'value your own stylesheet consumed has no spec slot any more: move it into your own CSS.', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 (disposition B: ' - + '退役授权面 — objectui engine code and its unit tests are retained). The pipeline was ' - + 'live from the authoring gate (`ObjectStackDefinitionSchema.themes`, `defineTheme`) ' - + 'through artifact ingest (`ARTIFACT_FIELD_TO_TYPE.themes`) and stopped there, measured: ' - + 'zero non-test readers of `.themes` or stored `theme` items across ' - + 'core/runtime/rest/services/plugins; `theme` never in `MetadataTypeSchema`, ' - + '`DEFAULT_METADATA_TYPE_REGISTRY` or `BUILTIN_METADATA_TYPE_SCHEMAS`; the only mounted ' - + 'ThemeProvider is the app-shell chrome light/dark toggle, unrelated to `ThemeSchema`; ' - + 'and no key anywhere selected an active theme. So an author (human or AI) who wrote a ' - + 'theme shipped it through every green gate and saw nothing change — the ' - + 'declared-but-unenforced shape ADR-0049 exists to delete. What colours a console today ' - + 'is `app.branding`, and that path is live and untouched.', - acceptanceCriteria: - 'No stack source authors `themes:`; a stack that still does is refused at parse with the ' - + 'prescription (unrecognized_keys carrying the retirement\'s guidance — pinned in ' - + '`stack-top-level-strict.test.ts`). `PUT /meta/theme/:name` gets the unrecognised-type ' - + 'refusal (a `/meta` type name the platform does not have is refused, never minted as a ' - + 'namespace) instead of the store-anything branch ' - + 'it had before `theme` was validated at the `/meta` write door (pinned in ' - + '`protocol.unrecognised-meta-type.test.ts`). Legacy stored `theme` rows are untouched: ' - + '`applyConversionsToStoredItem` passes them through, reads still answer, and DELETE ' - + 'still works, so the residue is removable. ⚠️ On-screen behaviour is deliberately ' - + 'UNCHANGED and must be verified as such: nothing ever read an authored theme, so ' - + 'removing the surface removes no behaviour — `app.branding` colours the console before ' - + 'and after.', - }, - { - id: 'stack-top-level-unknown-keys-refused', - surface: 'top-level stack definition keys (`ObjectStackDefinitionSchema`) — undeclared keys', - replacement: 'the declared top-level surface. A key the schema does not declare is refused at ' - + 'parse with a prescriptive message naming the key, suggesting the closest declared key on ' - + 'a near miss (`objectz` → `objects`, `flow` → `flows`), and carrying a curated ' - + 'prescription for the known retirements (`approvals`/`approvalProcesses` → Approval-node ' - + 'flows per ADR-0019; `workflows` → `state_machine` validation rules per ADR-0020; ' - + '`portals` removed with the dead `PortalSchema`; `storage` is deployment config, ' - + 'OS_STORAGE_*; `onDisable` was never invoked, and left with the lifecycle-hook family ' - + 'the kernel never implemented). `onEnable` is now DECLARED rather than silently stripped — ' - + 'the runtime has always executed it off the authored bundle (a config-booted app keeps ' - + 'it too, since the fix that stopped the loader dropping it and every script action ' - + 'handler it registered)', - reason: - 'The outermost authoring door was the last strip-mode surface of the unknown-key ' - + 'strictness campaign: an ' - + 'unknown top-level stack key parsed green and its value was silently dropped. Measured on ' - + '17.0.0 GA: three injected bogus top-level keys added ZERO warnings to ' - + '`os validate` and exited 0 — even `--strict` could not catch them, because the ' - + '`defineStack:` naming diagnostic printed at load, outside the warning tally. The failure ' - + 'population is a typo or stale key (`flow` for `flows`, `approvalProcesses` after the 7.4 ' - + 'removal) shipping an artifact with a whole metadata family absent at runtime, debugged ' - + 'from the far end — the root of a downstream application\'s report of a top-level typo ' - + 'that shipped an artifact minus a whole family with `validate` and `build` both green. ' - + 'Unknown top-level keys are now refused at ' - + 'parse time, which fails `validate` (and every other path through this one parse) ' - + 'outright; the near-miss guidance that used to arrive as a load-time warning now rides ' - + 'the refusal itself.', - acceptanceCriteria: - 'A stack authoring only declared top-level keys parses byte-identically to before, ' - + '`onEnable`/`functions` included. Any undeclared top-level key fails the parse with ' - + '`unrecognized_keys` naming the key; a one-edit near miss carries a rename suggestion; ' - + 'the curated retirements answer with their prescriptions. `os validate` exits non-zero ' - + 'on a stack carrying any undeclared top-level key, with or without `--strict`.', - }, - { - id: 'standard-error-code-batch-members-retired', - surface: - '`error.code` values `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and ' - + '`TRANSACTION_FAILED` — three `StandardErrorCode` members retired from the closed ' - + 'catalog (ADR-0112 amendment 2026-08-18), so constructing or parsing an ApiError ' - + 'with any of them now refuses at the vocabulary boundary', - replacement: - 'branch on the codes the batch surface actually speaks: a rolled-back atomic batch ' - + 'marks each row `errors[0].code = ROLLED_BACK`, rows the abort never reached ' - + '`NOT_ATTEMPTED`, and the causal row keeps its own error — all per row, at HTTP ' - + '200, both codes ledger-registered. Delete any branch on the three retired ' - + 'spellings outright: it never fired, because nothing ever emitted them', - reason: - 'ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever ' - + 'emitted any of the three — measured when a sweep of the error catalogue found these ' - + 'three entries publishing no HTTP status: outside the enum declaration the ' - + 'only occurrences in the whole repo were two spec tests using them as arbitrary ' - + 'fixture strings, and `git log -S` shows they never had a producer since ADR-0112 ' - + 'introduced the vocabulary. A catalog member no producer can speak teaches an AI ' - + 'author a branch that can never fire; after removal the wrong spelling fails ' - + 'parse at authoring time instead. This is a WIRE vocabulary, not stored metadata ' - + '— no `sys_metadata` row exists for the D2 chain to rewrite, so (like ' - + '`driver-sql-upsert-cross-row-identity-merge-refused`) this entry is the ' - + 'notification channel. No mechanical rewrite exists: a dead branch has no ' - + 'correct mechanical target — the per-row codes carry strictly more information ' - + 'than the envelope code the branch expected. Maintainer ruling 2026-08-18: option A, ' - + 'retire all three from `StandardErrorCode`. ADR-0112, ADR-0049.', - acceptanceCriteria: - 'No consumer branches on the three retired spellings; batch failure handling reads ' - + 'the per-row `results[].errors[].code` (`ROLLED_BACK` / `NOT_ATTEMPTED`) instead ' - + 'of an envelope-level code; constructing an ApiError with a retired spelling ' - + 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.', - }, - { - id: 'standard-error-code-concurrent-limit-exceeded-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'error.code value CONCURRENT_LIMIT_EXCEEDED — a StandardErrorCode member retired from the ' - + 'closed catalogue, so constructing or parsing an error with it now refuses at every catalogue ' - + 'door (StandardErrorCode, ErrorCode / ApiErrorSchema.code, makeApiErrorSchema) with the ' - + 'removal prescription', - replacement: - 'delete any branch on `CONCURRENT_LIMIT_EXCEEDED` — a branch on a catalogue code no producer ' - + 'emits has nothing to match. For request pacing branch on `RATE_LIMIT_EXCEEDED` ' - + '(HTTP 429; wait `retryAfterSeconds` before retrying). A service that enforces its own ' - + 'concurrency limit registers a code for it in its own error-code ledger rather than reusing ' - + 'the retired spelling. `QUOTA_EXCEEDED`, its catalogue neighbour, is unchanged.', - reason: - 'ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A of ' - + '2026-09-13 (maintainer 「同意」) retired both producerless 429 members; the ' - + 'closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to ' - + 'this code alone after `QUOTA_EXCEEDED` was found emitted by a hosted AI agent route and read ' - + 'by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a ' - + 'producerless row with no card behind it as the registered-but-unemittable retirement class, ' - + 'and a catalogue member no producer speaks teaches an author a branch that cannot fire; after ' - + 'removal the stale spelling fails parse with its prescription instead. An error code is WIRE ' - + 'vocabulary, not a metadata key, so there is no authored source for a D2 conversion to ' - + 'rewrite and this entry is the notification channel, as it was for ' - + '`standard-error-code-batch-members-retired`. No mechanical rewrite exists: a dead branch has ' - + 'no correct mechanical target.', - acceptanceCriteria: - 'No consumer branches on `CONCURRENT_LIMIT_EXCEEDED`; request pacing is handled on ' - + '`RATE_LIMIT_EXCEEDED`. Constructing or parsing an error with the retired spelling fails ' - + '`StandardErrorCode`, `ErrorCode` / `ApiErrorSchema` and the `makeApiErrorSchema` envelope ' - + 'parse, and the failure message is the removal prescription rather than the bare enum listing. ' - + '`QUOTA_EXCEEDED` still parses at every door.', - }, - { - id: 'startup-orchestrator-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and ' - + 'contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: ' - + 'StartupOptionsSchema / StartupOptions / StartupOptionsParsed, ' - + 'HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / ' - + 'StartupOrchestrationResult, and the IStartupOrchestrator interface ' - + '(orchestrateStartup / rollback / checkHealth / startWithTimeout). The ' - + 'startup RESULT survives, re-declared: PluginStartupResultSchema and ' - + 'PluginStartupResult stay on both entries', - replacement: - '(removed — there is no declarative replacement, because nothing ever ' - + 'implemented the interface or parsed the schemas. Plugin startup is the ' - + 'kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() ' - + 'per plugin, which races that plugin start() against ' - + 'PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is ' - + 'set, destroys the already-started plugins and rethrows the original error ' - + 'as the new error cause. So: instead of StartupOptions.timeoutMs declare ' - + 'startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure ' - + 'set rollbackOnFailure on the kernel config; instead of ' - + 'StartupOrchestrationResult.results read the per-plugin durations through ' - + 'ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and ' - + 'HealthStatus have NO replacement at all — no startup probe system exists, ' - + 'and one returns only through the enforce route of ADR-0049 with a new ADR, ' - + 'the probe first and the vocabulary second. StartupOptions.parallel and ' - + 'StartupOptions.context likewise: the kernel starts plugins sequentially ' - + 'and passes its own PluginContext)', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a ' - + 'startup-result contract re-declared as the shape the kernel ships, and ' - + 'retire the rest. The module declared an orchestration ' - + 'design that never landed, and the spec and the kernel had already drifted ' - + 'into disagreement about the one shape that did: PluginStartupResultSchema ' - + 'described a plugin object, a required durationMs and a health member, ' - + 'while @objectstack/core shipped pluginName, an optional durationMs and ' - + 'timedOut. The ruling keeps a startup-result contract that describes what ' - + 'the kernel actually produces, and retires the rest. Re-measured on this ' - + 'card: zero implementers and zero consumers of the four retired surfaces in ' - + 'this repository and in the pinned objectui checkout, with lit same-corpus ' - + 'controls (defineStack, ManifestSchema); every remaining reference was a ' - + 'generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus ' - + 'are the sharpest of the four: they name a per-plugin health probe the ' - + 'runtime has never had, the shape of the plugin sandboxing / integrity / ' - + 'approval config that was never wired to anything, which an AI author (ADR-0033) ' - + 'reads as proof the capability exists. With no authored document carrying ' - + 'any of the three defs there is no seam for a D2 conversion and no author to ' - + 'tombstone for: route 3, the shape of the dynamic plugin-loading family\'s ' - + 'removal and the advanced plugin-lifecycle config\'s retirement — RETIRED_DEFS_BY_MAJOR ' - + 'plus this entry ARE the declaration. The two keys of the SURVIVING result ' - + 'schema that leave (plugin, health) are tombstoned instead, and registered ' - + 'in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is ' - + 'imported by @objectstack/core. A third key arrives on the spec surface only ' - + 'to leave it: core deprecated startTime alias, which held the same elapsed ' - + 'milliseconds as durationMs under a name that promises an instant. The ' - + 're-declaration had to either mirror it or tombstone it, and mirroring is ' - + 'refused by check:duration-unit-keys (ruling B on duration-shaped number keys: ' - + 'the unit lives in the key name) since it is an ' - + 'elapsed number whose key name carries no unit and matches neither of that ' - + 'rule two schema-declared exemptions. So the L1 window closes here and the ' - + 'kernel stops populating it in the same change.', - acceptanceCriteria: - 'No code imports any of the 8 retired names from @objectstack/spec, ' - + '@objectstack/spec/kernel or @objectstack/spec/contracts — every one is ' - + 'TS2305 after upgrade, pinned by resolved symbol identity in ' - + 'kernel/startup-orchestrator-retirement.test.ts. No metadata document needs ' - + 'editing: none of the three defs was reachable from a metadata-type ' - + 'binding, a stack collection or a manifest embed, so no authored document ' - + 'could ever carry one. PluginStartupResult SURVIVES on both entries with ' - + 'the shape the kernel ships — pluginName, success, optional durationMs, the ' - + 'serializable error projection, timedOut — and @objectstack/core now imports ' - + 'that type instead of declaring a twin, so the drift cannot recur. Writing ' - + 'plugin, health or startTime on a PluginStartupResult is a tsc error and a ' - + 'parse error carrying the rename or the deletion; a reader of the removed ' - + 'startTime alias reads durationMs, which has always carried the same value. ' - + 'Runtime behaviour is unchanged except for that one alias: nothing ever read ' - + 'the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and ' - + 'the only observable difference is that a startup result no longer carries ' - + 'startTime beside durationMs.', - }, - // #22443 (ADR-0049 enforce-or-remove) — `public` left `StorageScope` - // (`enumWithRetiredValues`, system/object-storage.zod.ts), and the - // `service-storage` upload doors refuse an upload naming that scope with - // `400 INVALID_REQUEST`. Semantic only: no metadata type carries either - // surface, so there is no authored source for a D2 conversion to rewrite — - // what moves is caller code and the intent behind it, which only the caller - // can judge. The `sys_file.scope` select retires the option too, and the rows - // already stored with it are rewritten to `user` by the operator sweep - // `@objectstack/service-storage` exports (`backfill-sys-file-public-scope.ts`). - { - id: 'storage-scope-public-retired', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span AND a table cell. - surface: - 'the storage scope public — the scope of an upload request, presigned or chunked, and ' - + 'ObjectStorageConfig.scope (StorageScope)', - replacement: - 'another scope, or none for the default (`user` on an upload, `global` on a storage ' - + "configuration), and `acl: 'public_read'` on the stored file record of each file that must " - + 'be readable before sign-in (ADR-0104)', - reason: - 'A storage scope never made a file publicly readable. The download doors judge a file by its ' - + '`acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public ' - + 'and the default acl was stored private and needs a signed-in caller, while its scope said ' - + 'otherwise. The value is retired rather than enforced: enforcing it would let any uploader make ' - + "a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for " - + 'anonymous download. Whether a given file must be readable before sign-in is the caller\'s ' - + 'call, so no rewrite can make it: an upload that meant public needs its stored file record ' - + 'marked, and one that did not needs only another scope. The upload request itself carries no ' - + 'acl, and every upload is stored private. The stored file record retires the value too: the ' - + 'scope select of sys_file no longer lists public, so a deployment that stored files with it runs ' - + 'the one-time operator sweep that @objectstack/service-storage exports ' - + '(`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), ' - + 'which rewrites each of those records to scope user. No access changes: no reader tells user ' - + 'apart from public, the storage key and the file bytes are not touched, and those files ' - + 'download exactly as before. Until the sweep has run, a record write that names such a file ' - + 'while another field already owns it is refused, because the copy it makes carries scope ' - + 'public. ADR-0049', - acceptanceCriteria: - 'No upload call names scope public and no ObjectStorageConfig declares it; each upload that did ' - + 'now names another scope or none and is answered 200. Each file that must render before ' - + "sign-in has acl 'public_read' on its stored file record, and fetching it with no session " - + 'serves it; fetching any other uploaded file with no session is answered 401. On each ' - + 'deployment, a dry run of the sweep scans zero sys_file records with scope public.', - }, - { - id: 'strategy-context-aggregation-method-narrowed', - surface: 'StrategyContext.executeAggregate aggregations[].method ' - + '(contracts/analytics-service.ts, exported from @objectstack/spec/contracts) ' - + '- the parameter type, declared as bare string', - replacement: 'AggregationFunction (count | sum | avg | min | max | count_distinct, ' - + 'data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already ' - + 'declares for the identical slot (AggregationNodeSchema.function; the analytics ' - + 'bridge renames method to function and forwards). A caller filling method from a ' - + 'string-typed value narrows the value to the enum - typing it ' - + 'AggregationFunction, or parsing with the spec\'s own AggregationFunction zod ' - + 'enum where the value enters from data. Values outside the six were never ' - + 'served: the bridge has parsed-and-refused them at runtime since it stopped ' - + 'declaring its own engine type and began parsing the method with the spec ' - + 'enum, and ' - + 'that refusal stays as defence in depth', - reason: - 'Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. ' - + 'Two spec-declared ' - + 'surfaces described the same value and disagreed about its type: ' - + 'IDataEngine.aggregate\'s aggregations[].function is the closed six-value ' - + 'AggregationFunction enum while StrategyContext.executeAggregate declared the ' - + 'same slot aggregations[].method: string, so nothing on the analytics side of ' - + 'that seam was compile-checked against the engine\'s vocabulary - an author, ' - + 'very often an AI (ADR-0033), writing an analytics strategy got no compile-time ' - + 'help and could carry any method name all the way to the bridge\'s runtime ' - + 'refusal. One slot now has one declaration. Bookkeeping: this is a TYPE ' - + 'narrowing on a runtime TS interface member - no authorable metadata key, no ' - + 'wire shape and no walked-shape def changed, so nothing lands in ' - + 'RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are ' - + 'expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion ' - + 'because there is no authored document or sys_metadata row for the chain to ' - + 'rewrite: the only consumers are TypeScript call sites, and the compile error ' - + 'is the channel that reaches them. In-repo census at the ruling (hard ' - + 'precondition, measured before the narrowing landed): every implementor and ' - + 'every call site filling method is legal under the enum - ' - + 'ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a ' - + 'custom-SQL measure up front, the two literal ' - + 'producers write count, and every test fixture is ' - + 'implementor-side and stays assignable by contravariance.', - acceptanceCriteria: - 'External implementors of StrategyContext stay source-compatible: a handler ' - + 'accepting method: string accepts a superset and remains assignable to the ' - + 'narrowed member. External callers filling method with a string-typed or ' - + 'out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; ' - + 'the fix is narrowing the value\'s type to AggregationFunction (parsing with ' - + 'the spec enum where it enters from data), never widening a local mirror of ' - + 'the contract. Runtime behaviour is unchanged: the bridge\'s ' - + 'parse-and-refuse accepts and rejects exactly the same sets before and after, ' - + 'and no stored metadata or document needs editing.', - }, - { - id: 'structured-region-body-pause-and-end-refused', - surface: - 'The BODY of every ADR-0031 structured region — `loop.config.body`, each ' - + '`parallel.config.branches[]`, and `try_catch.config.try` / `.catch` — at every depth the ' - + 'flow parse walks. Two node populations become undeclarable there: a node whose TYPE parks ' - + 'the run on EVERY execution (`screen`, `wait`, `approval`, `approval_revise`) and an ' - + '`end` node, whatever its `outcome`. ⛔ `subflow` and `map` are NOT in the population, ' - + 'although their executors also declare `supportsPause: true`: they pause exactly when the ' - + 'child flow their `config.flowName` names pauses, which is a DIFFERENT metadata record and ' - + 'is not in hand while this flow is parsed. Refusing them by type would also refuse ' - + '`loop { map(synchronous child) }`, a shape that runs correctly today, so a region-nested ' - + '`map` or `subflow` still parses and is met at RUN time instead.', - replacement: - 'Move the node onto the TOP-LEVEL graph and route the region\'s exit to it. For an `end`: ' - + 'delete it from the body, give the region a normal exit, and put the terminator (with its ' - + '`outcome` / `message`) on the top-level graph — `loop { body: [ …, end ] }` becomes ' - + '`loop { body: [ … ] } → end`. For a pausing node: hoist it out of the container — ' - + '`loop { body: [ try_catch { try: [ approval ] } ] }` becomes a top-level `approval` with ' - + 'the loop fanning out around it, or the pausing half of the branch is split into a ' - + '`subflow` the top-level graph calls; where the repetition is genuinely needed, make the ' - + 'TOP-LEVEL graph the repeating construct with the pause on it rather than nesting the ' - + 'pause inside a region. A `try_catch` whose only purpose was to contain the region\'s ' - + 'refusal has nothing left to contain and is deleted with it.', - reason: - 'Maintainer ruling of 2026-09-17, verbatim and untranslated: 「同意,其他也同' - + '意」, carrying the presented option C (a durable pause inside a structured region is ' - + 'refused at authoring time); extended the same day by a second ruling, which attached ' - + 'the `end` half (an `end` node inside a region body is refused as well) and ruled 禁 ' - + 'on building durable pause into structured regions — structured ' - + 'regions do not support durable pause and a region body cannot terminate the run, so this ' - + 'is that limit\'s authoring-time enforcement rather than an interim. The POPULATION was ' - + 'then fixed by the 2026-09-18 ruling, letter D (maintainer 「其他同意」): 「inside ' - + '`loop` / `parallel` branch / `try_catch` (try and catch) bodies at any depth, the node ' - + 'types `screen`, `wait`, `approval`, `approval_revise` and `end` are refused by ' - + '`FlowSchema.superRefine` … `map` and `subflow` are ⛔ not refused by type.」 A parse-time ' - + 'rule refuses what is STATICALLY wrong; refusing `map` / `subflow` by type would refuse a ' - + 'correct working shape on a guess about another record. The refusal already existed AT RUN ' - + 'TIME and said nothing an author could act on: the engine converts a suspension raised ' - + 'inside a region into an error at the region boundary, AFTER the executor has written its ' - + 'progress state into the enclosing scope, so a `try_catch` that contains that error leaves ' - + 'residue the next entry reads back as progress. Measured on a real `AutomationEngine`, ' - + '`loop { try_catch { map(pausing child) } }` over 3 iterations x 2 items: not one item\'s ' - + 'subflow ever completed, only two of three iterations reached the catch, and iteration 3 ' - + 'read `started === collection.length`, ran nothing, and returned SUCCESS with ' - + '`summary.failed = 0`. ⚠️ Read that measurement for the MECHANISM: the shape it was taken ' - + 'on is a `map`, which this parse rule deliberately does not reach — making the run-time ' - + 'refusal of a region-contained node that durably suspends LOUD is the second half of ' - + 'ruling D and ships as its own `domain:services` change. ⛔ NOT losslessly convertible: ' - + 'hoisting a node out of a region is a GRAPH REWRITE — new edges, a changed exit, sometimes ' - + 'a deleted container — and which of several shapes the author meant is an intent no ' - + 'artifact records, so a transform that picked one would be inventing the design. That ' - + 'leaves D3, a structured TODO naming each node to edit. ⚠️ Two further boundaries this ' - + 'refusal deliberately does NOT reach, because a parse cannot: a pausing node type ' - + 'contributed by a PLUGIN (ADR-0018 left the node-type namespace open and a parse has no ' - + 'registry), and a region nested past `MAX_REGION_DEPTH` (32), where the walk stops. For ' - + 'both, the engine\'s run-time refusal is still the only one — unchanged by this step, not ' - + 'fixed by it.', - acceptanceCriteria: - 'No `screen` / `wait` / `approval` / `approval_revise` node and no `end` node sits inside ' - + 'any `loop` body, `parallel` branch or `try_catch` try/catch region in the stack, at any ' - + 'depth. `FlowSchema.parse` (and therefore `defineFlow`, `registerFlow`, `os validate` and ' - + 'a Studio publish) accepts the stack: a node still nested is refused with the node AND the ' - + 'region named in one message ' - + "(`A \\`approval\\` node may not sit inside a structured region — \\`loop 'sweep' body → " - + "try_catch 'guard' try\\` is a region body …`), anchored at " - + '`nodes[i].config.body.nodes[j].type` so a designer can jump to it. ⛔ A region-nested ' - + '`map` or `subflow` is NOT part of this migration and needs no edit to load — if such a ' - + 'flow reports `success` having processed nothing, that is the run-time half of the same ' - + 'ruling and not a stack edit. Behaviour to re-check after editing, because the fix CHANGES ' - + 'IT deliberately: a region-nested `end` was a no-op, so moving it to the top level makes ' - + 'the run actually TERMINATE there — check that the nodes after the container were not ' - + 'relying on continuing past it. A hoisted `approval` / `wait` / `screen` now parks the run ' - + 'where the enclosing graph can see it, so anything that polled for the sweep to finish ' - + 'sees a suspended run instead of a green-but-empty one.', - }, - { - id: 'sys-account-issuer-retired', - surface: '`sys_account.issuer` — the column, its `{ fields: [\'issuer\', \'account_id\'], unique: ' - + 'true }` index, its label in the four generated translation bundles, and the ' - + '`@objectstack/plugin-auth` symbols that existed only to serve it ' - + '(`backfillAccountIssuer`, `CREDENTIAL_ISSUER`, `oauthIssuerFor`, `ResolvedSocialProvider`, ' - + '`BackfillAccountIssuerOptions`, `BackfillAccountIssuerResult`). The ' - + '`accounts.list()` client type loses `issuer` with the route that stopped returning it.', - replacement: 'nothing — account identity is `(provider_id, account_id)`, which `sys_account` has ' - + 'declared UNIQUE since the object was created. A caller that read `account.issuer` reads ' - + 'nothing in its place: the authority is `sys_sso_provider.issuer`, resolved through the ' - + 'account\'s `provider_id`, which is unique per environment. A host that called ' - + '`backfillAccountIssuer` on its own schedule deletes the call; there is no successor pass. ' - + 'Existing deployments run the ceremony below before the column is dropped.', - reason: - 'better-auth 1.7.3 removed the issuer-scoped account identity outright: ' - + '`createLocalAccountIssuer` is deleted, `accountSchema.issuer` ' - + 'is gone, `AccountKey` is `(providerId, accountId)` again, and the `account.issuer` column ' - + 'and its unique index are gone from `get-tables`. There is no drop-in replacement. ' - + 'Maintainer ruling 2026-09-10: adopt the rollback rather than own a fork of an ' - + 'identity model the vendor abandoned — a permanent fork on the authentication library was ' - + 'refused, and staying pinned was refused as the durable answer (the exact pin to 1.7.2, ' - + 'taken after a floating 1.7.3 broke a fresh seeded boot, was the stopgap and has done ' - + 'its job). The column was a net liability in its own right: a credential row whose ' - + '`issuer` was not the local credential issuer was invisible to `findAccountByKey`, so ' - + 'sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the ' - + '`sys_user` row rather than at the account — four checklist items rediscovered that ' - + 'independently. Its discriminating power here was near zero: `sys_sso_provider` declares ' - + '`{ fields: [\'provider_id\'], unique: \'global\' }`, so `provider_id → issuer` is a function ' - + 'within an environment.', - acceptanceCriteria: - 'BEFORE the column is dropped, `os migrate account-issuer` reads zero on the deployment: no ' - + '`(provider_id, account_id)` key is held by more than one row. That pre-flight reads ROWS, ' - + 'never the index declaration, because `syncDeclaredIndexes` logs a plain UNIQUE whose CREATE ' - + 'failed on existing duplicates and lets the boot continue (a plain unique over duplicate ' - + 'rows was made loud and non-fatal, the MySQL hash-shadow arm included) — so a database ' - + 'can carry the declaration without the constraint, and on such a database the drop degrades ' - + 'SILENTLY rather than failing. A dirty read refuses; so does a read that throws or a scan ' - + 'that truncates. `os migrate apply --allow-destructive` re-runs the same pre-flight and ' - + 'refuses the drop before writing any DDL; the boot refusal on unapplied destructive drift is ' - + 'unchanged, so a runtime never auto-migrates. Colliding rows are resolved by the operator — ' - + 'keep the row whose provider account is live, delete the rest so a fresh sign-in re-links — ' - + 'never merged or dropped by the platform. AFTER the drop, a fresh install and an ' - + 'existing-data upgrade both sign in over the real auth route. A `provider_id` re-pointed at a ' - + 'different IdP must have its account bindings REBUILT: no column records which IdP vouched ' - + 'for a row, so the key cannot separate the old IdP\'s subjects from the new one\'s, and the ' - + '`sys_sso_provider` update door refuses an issuer change while accounts are still bound to ' - + 'that provider.', - }, - // #15207 (ADR-0131 D7, C6 item 2) — the compliance ledger loses its injected - // organization column, and the organization a row is ABOUT stays in the - // existing attribution field tenant_id. A platform-object COLUMN retirement, - // not a spec-key retirement: no authorable spec key moves, so nothing lands in - // RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with. Existing - // rows keep the orphaned column until the v18 operator ceremony (ADR-0131 D14, - // D10 fate 1), which this entry does not perform. - { - id: 'sys-audit-log-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_audit_log.organization_id — the injected organization column left the compliance ledger ' - + '(packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, which now declares ' - + 'systemFields.tenant false); the organization a row is about stays in the attribution field ' - + 'tenant_id, and an organization reader is scoped on it by a platform row policy', - replacement: - '`sys_audit_log.tenant_id`, the attribution field every writer stamps. Rewrite any authored ' - + 'filter, list-view column, report grouping, formula or seed key that names `organization_id` on ' - + '`sys_audit_log` to name `tenant_id`. A row about a deployment-level action leaves it empty. ' - + 'Under an organization wall an organization reader is scoped to the rows about its active ' - + 'organization by the platform row policy `sys_audit_log_org`, and a platform administrator ' - + 'reads every row', - reason: - 'ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so the organization ' - + 'a row is about becomes a plain attribution field under a name the tenant-field resolver does not ' - + 'claim, never the tenancy anchor, and the object is governed by object permission, not by the ' - + 'wall. Writer census at commit 3ae59661dc of this repository\'s main branch: the record mirror, ' - + 'the record-view writer and the sign-in writer in plugin-audit, and the settings change writer in ' - + 'service-settings, stamp tenant_id and stamped the injected column with the same value; the ' - + 'platform-admin standing writer in plugin-security stamps both NULL, by ruling; the two ' - + 'administrative user writers in plugin-auth stamp neither. So the attribution field already ' - + 'carries every organization the column did. Under a walled posture the tenant wall compared the ' - + 'column to the caller organization, which hid every row about no organization from every reader, ' - + 'platform administrators included. The read scope moves to the security layer, where the ' - + 'engine computes it once: the platform row policy tenant_id equal to the caller organization, ' - + 'shipped in organization_admin, member_default and viewer_readonly and stripped when no wall is ' - + 'enforced, plus an explicit organization_admin entry for the ledger without viewAllRecords or ' - + 'modifyAllRecords, because the wildcard superuser bypass would otherwise skip the policy on an ' - + 'object with no tenant column and hand each organization administrator every organization\'s ' - + 'rows. Per-tenant retention windows partition on tenant_id. Existing databases: schema sync is ' - + 'additive, so the physical column stays and the boot drift report names it orphaned; once its ' - + 'values are confirmed equal to tenant_id, the operator drops it with os migrate apply ' - + '--allow-destructive, and any row where they differ is reported rather than dropped.', - acceptanceCriteria: - 'No authored metadata names `organization_id` on `sys_audit_log`: the field resolver (lint and the ' - + 'data door) answers it as an unknown field. A row about a deployment-level action is written ' - + 'with `tenant_id` empty and no refusal. Under an organization wall an organization administrator ' - + 'lists the rows whose `tenant_id` is its active organization and no other, and a platform ' - + 'administrator lists every row, the rows with no `tenant_id` included. Under `single` the policy ' - + 'is stripped and the organization administrator lists every row, as before.', - }, - // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card - // requires. A platform-object COLUMN retirement, not a spec-key retirement: no - // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no - // D2 conversion exists to pair with (the ups-delegated-from-column-retired - // shape). The writer census behind the verdict is cited in the reason. - { - id: 'sys-flow-dispatch-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_flow_dispatch.organization_id — the injected organization column left the flow trigger ' - + 'dispatch claim ledger (packages/services/service-automation/src/sys-flow-dispatch.object.ts, ' - + 'which now declares systemFields.tenant false), and reading the table now requires the ' - + 'manage_platform_settings capability', - replacement: - 'nothing on this table — `sys_flow_dispatch` is deployment-level state (ADR-0131 D7) and no ' - + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' - + 'formula or seed key that names `organization_id` on `sys_flow_dispatch`. A principal that ' - + 'must read the table needs the `manage_platform_settings` capability, which platform ' - + 'administrators hold', - reason: - 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' - + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' - + 'e67ba80049 of this repository\'s main branch: the sole writer is ObjectStoreFlowDispatchStore ' - + 'in @objectstack/service-automation: two write sites (the claim insert and the settle ' - + 'update), each under a system context whose row is a dispatch key and its outcome, naming no ' - + 'organization. So the injected column only ever held NULL. The census is the same procedure ' - + 'that reports the organization-stamping writers of sys_http_delivery, sys_secret and ' - + 'sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the ' - + 'caller organization and hid every row from every reader, platform administrators included; ' - + 'with no column there is no wall, and the table is governed by object permission instead ' - + '(D7). The capability gate is part of the same change, not a follow-up: the ' - + 'organization_admin grant carries the superuser bits on every object, so without it a walled ' - + 'deployment would hand each organization administrator every other organization\'s rows. ' - + 'Existing databases: schema sync is additive, so the physical column stays and the boot drift ' - + 'report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. ' - + 'The operator drops it with os migrate apply --allow-destructive, the remedy the drift report ' - + 'names.', - acceptanceCriteria: - 'No authored metadata names `organization_id` on `sys_flow_dispatch`: the field resolver ' - + '(lint and the data door) now answers it as an unknown field. On every tenancy posture a ' - + 'principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' - + '`sys_flow_dispatch`, and a platform administrator lists every row with no organization ' - + 'filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned ' - + 'column.', - }, - // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card - // requires. A platform-object COLUMN retirement, not a spec-key retirement: no - // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no - // D2 conversion exists to pair with (the ups-delegated-from-column-retired - // shape). The writer census behind the verdict is cited in the reason. - { - id: 'sys-job-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_job.organization_id — the injected organization column left the platform background-job ' - + 'catalogue (packages/platform-objects/src/audit/sys-job.object.ts, which now declares ' - + 'systemFields.tenant false), and reading the table now requires the manage_platform_settings ' - + 'capability', - replacement: - 'nothing on this table — `sys_job` is deployment-level state (ADR-0131 D7) and no ' - + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' - + 'formula or seed key that names `organization_id` on `sys_job`. A principal that must read ' - + 'the table needs the `manage_platform_settings` capability, which platform administrators ' - + 'hold', - reason: - 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' - + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' - + 'e67ba80049 of this repository\'s main branch: the sole writer is DbJobAdapter in ' - + '@objectstack/service-job: four write sites (the update and insert arms of the schedule ' - + 'upsert, the active toggle and the run summary bump), each under a system context whose row ' - + 'literal names no organization. So the injected column only ever held NULL. The census is the ' - + 'same procedure that reports the organization-stamping writers of sys_http_delivery, ' - + 'sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared ' - + 'that NULL to the caller organization and hid every row from every reader, platform ' - + 'administrators included; with no column there is no wall, and the table is governed by ' - + 'object permission instead (D7). The capability gate is part of the same change, not a ' - + 'follow-up: the organization_admin grant carries the superuser bits on every object, so ' - + 'without it a walled deployment would hand each organization administrator every other ' - + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' - + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' - + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' - + 'the remedy the drift report names.', - acceptanceCriteria: - 'No authored metadata names `organization_id` on `sys_job`: the field resolver (lint and the ' - + 'data door) now answers it as an unknown field. On every tenancy posture a principal without ' - + '`manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_job`, and a ' - + 'platform administrator lists every row with no organization filter. After os migrate apply ' - + '--allow-destructive the boot no longer reports the orphaned column.', - }, - // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card - // requires. A platform-object COLUMN retirement, not a spec-key retirement: no - // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no - // D2 conversion exists to pair with (the ups-delegated-from-column-retired - // shape). The writer census behind the verdict is cited in the reason. - { - id: 'sys-job-queue-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_job_queue.organization_id — the injected organization column left the durable job and ' - + 'message queue (packages/platform-objects/src/audit/sys-job-queue.object.ts, which now ' - + 'declares systemFields.tenant false), and reading the table now requires the ' - + 'manage_platform_settings capability', - replacement: - 'nothing on this table — `sys_job_queue` is deployment-level state (ADR-0131 D7) and no ' - + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' - + 'formula or seed key that names `organization_id` on `sys_job_queue`. A principal that must ' - + 'read the table needs the `manage_platform_settings` capability, which platform ' - + 'administrators hold', - reason: - 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' - + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' - + 'e67ba80049 of this repository\'s main branch: the sole writer is DbQueueAdapter in ' - + '@objectstack/service-queue: nine write sites (the publish insert and the worker update and ' - + 'delete paths), each under a system context whose row literal names no organization. So the ' - + 'injected column only ever held NULL. The census is the same procedure that reports the ' - + 'organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can ' - + 'fire. Under a walled posture the tenant wall compared that NULL to the caller organization ' - + 'and hid every row from every reader, platform administrators included; with no column there ' - + 'is no wall, and the table is governed by object permission instead (D7). The capability gate ' - + 'is part of the same change, not a follow-up: the organization_admin grant carries the ' - + 'superuser bits on every object, so without it a walled deployment would hand each ' - + 'organization administrator every other organization\'s rows. Existing databases: schema sync ' - + 'is additive, so the physical column stays and the boot drift report names it orphaned; by ' - + 'the census it holds only NULL, so dropping it loses nothing. The operator drops it with os ' - + 'migrate apply --allow-destructive, the remedy the drift report names.', - acceptanceCriteria: - 'No authored metadata names `organization_id` on `sys_job_queue`: the field resolver (lint ' - + 'and the data door) now answers it as an unknown field. On every tenancy posture a principal ' - + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' - + '`sys_job_queue`, and a platform administrator lists every row with no organization filter. ' - + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', - }, - // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card - // requires. A platform-object COLUMN retirement, not a spec-key retirement: no - // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no - // D2 conversion exists to pair with (the ups-delegated-from-column-retired - // shape). The writer census behind the verdict is cited in the reason. - { - id: 'sys-job-run-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_job_run.organization_id — the injected organization column left the platform job run ' - + 'history (packages/platform-objects/src/audit/sys-job-run.object.ts, which now declares ' - + 'systemFields.tenant false), and reading the table now requires the manage_platform_settings ' - + 'capability', - replacement: - 'nothing on this table — `sys_job_run` is deployment-level state (ADR-0131 D7) and no ' - + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' - + 'formula or seed key that names `organization_id` on `sys_job_run`. A principal that must ' - + 'read the table needs the `manage_platform_settings` capability, which platform ' - + 'administrators hold', - reason: - 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' - + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' - + 'e67ba80049 of this repository\'s main branch: the sole writer is DbJobAdapter in ' - + '@objectstack/service-job: two write sites (the run start insert and the run finish update), ' - + 'each under a system context whose row literal names no organization, including for a job ' - + 'that declares the organization it runs as, whose stamp reaches the job data writes and never ' - + 'this ledger. So the injected column only ever held NULL. The census is the same procedure ' - + 'that reports the organization-stamping writers of sys_http_delivery, sys_secret and ' - + 'sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the ' - + 'caller organization and hid every row from every reader, platform administrators included; ' - + 'with no column there is no wall, and the table is governed by object permission instead ' - + '(D7). The capability gate is part of the same change, not a follow-up: the ' - + 'organization_admin grant carries the superuser bits on every object, so without it a walled ' - + 'deployment would hand each organization administrator every other organization\'s rows. ' - + 'Existing databases: schema sync is additive, so the physical column stays and the boot drift ' - + 'report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. ' - + 'The operator drops it with os migrate apply --allow-destructive, the remedy the drift report ' - + 'names.', - acceptanceCriteria: - 'No authored metadata names `organization_id` on `sys_job_run`: the field resolver (lint and ' - + 'the data door) now answers it as an unknown field. On every tenancy posture a principal ' - + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' - + '`sys_job_run`, and a platform administrator lists every row with no organization filter. ' - + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', - }, - // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card - // requires. A platform-object COLUMN retirement, not a spec-key retirement: no - // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no - // D2 conversion exists to pair with (the ups-delegated-from-column-retired - // shape). The writer census behind the verdict is cited in the reason. - { - id: 'sys-migration-journal-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_migration_journal.organization_id — the injected organization column left the migration ' - + 'run journal (packages/platform-objects/src/system/sys-migration-journal.object.ts, which now ' - + 'declares systemFields.tenant false), and reading the table now requires the ' - + 'manage_platform_settings capability', - replacement: - 'nothing on this table — `sys_migration_journal` is deployment-level state (ADR-0131 D7) and ' - + 'no organization owns a row. Delete any authored filter, list-view column, report grouping, ' - + 'formula or seed key that names `organization_id` on `sys_migration_journal`. A principal ' - + 'that must read the table needs the `manage_platform_settings` capability, which platform ' - + 'administrators hold', - reason: - 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' - + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' - + 'e67ba80049 of this repository\'s main branch: the sole writer is the @objectstack/core ' - + 'migration runner: one append site, under a system context or under the transaction it opened ' - + 'with one, and the row contract MigrationJournalEventSchema has no organization field to ' - + 'carry. So the injected column only ever held NULL. The census is the same procedure that ' - + 'reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so ' - + 'it can fire. Under a walled posture the tenant wall compared that NULL to the caller ' - + 'organization and hid every row from every reader, platform administrators included; with no ' - + 'column there is no wall, and the table is governed by object permission instead (D7). The ' - + 'capability gate is part of the same change, not a follow-up: the organization_admin grant ' - + 'carries the superuser bits on every object, so without it a walled deployment would hand ' - + 'each organization administrator every other organization\'s rows. Existing databases: schema ' - + 'sync is additive, so the physical column stays and the boot drift report names it orphaned; ' - + 'by the census it holds only NULL, so dropping it loses nothing. The operator drops it with ' - + 'os migrate apply --allow-destructive, the remedy the drift report names.', - acceptanceCriteria: - 'No authored metadata names `organization_id` on `sys_migration_journal`: the field resolver ' - + '(lint and the data door) now answers it as an unknown field. On every tenancy posture a ' - + 'principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' - + '`sys_migration_journal`, and a platform administrator lists every row with no organization ' - + 'filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned ' - + 'column.', - }, - // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card - // requires. A platform-object COLUMN retirement, not a spec-key retirement: no - // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no - // D2 conversion exists to pair with (the ups-delegated-from-column-retired - // shape). The writer census behind the verdict is cited in the reason. - { - id: 'sys-migration-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_migration.organization_id — the injected organization column left the deployment ' - + 'data-migration flag ledger (packages/platform-objects/src/system/sys-migration.object.ts, ' - + 'which now declares systemFields.tenant false), and reading the table now requires the ' - + 'manage_platform_settings capability', - replacement: - 'nothing on this table — `sys_migration` is deployment-level state (ADR-0131 D7) and no ' - + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' - + 'formula or seed key that names `organization_id` on `sys_migration`. A principal that must ' - + 'read the table needs the `manage_platform_settings` capability, which platform ' - + 'administrators hold', - reason: - 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' - + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' - + 'e67ba80049 of this repository\'s main branch: eleven write sites in six files (the ' - + 'platform-objects migration flag helpers, the ObjectQL lax-deviation and boot-admission ' - + 'revocation writes, and the seed-tenancy, membership-backfill and flow-credential receipts), ' - + 'each under a system context, and the row contract DataMigrationFlagSchema has no ' - + 'organization field to carry. So the injected column only ever held NULL. The census is the ' - + 'same procedure that reports the organization-stamping writers of sys_http_delivery, ' - + 'sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared ' - + 'that NULL to the caller organization and hid every row from every reader, platform ' - + 'administrators included; with no column there is no wall, and the table is governed by ' - + 'object permission instead (D7). The capability gate is part of the same change, not a ' - + 'follow-up: the organization_admin grant carries the superuser bits on every object, so ' - + 'without it a walled deployment would hand each organization administrator every other ' - + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' - + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' - + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' - + 'the remedy the drift report names.', - acceptanceCriteria: - 'No authored metadata names `organization_id` on `sys_migration`: the field resolver (lint ' - + 'and the data door) now answers it as an unknown field. On every tenancy posture a principal ' - + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' - + '`sys_migration`, and a platform administrator lists every row with no organization filter. ' - + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', - }, - // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card - // requires. A platform-object COLUMN retirement, not a spec-key retirement: no - // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no - // D2 conversion exists to pair with (the ups-delegated-from-column-retired - // shape). The writer census behind the verdict is cited in the reason. - { - id: 'sys-presence-organization-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_presence.organization_id — the injected organization column left the realtime presence ' - + 'table (packages/services/service-realtime/src/objects/sys-presence.object.ts, which now ' - + 'declares systemFields.tenant false), and reading the table now requires the ' - + 'manage_platform_settings capability', - replacement: - 'nothing on this table — `sys_presence` is deployment-level state (ADR-0131 D7) and no ' - + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' - + 'formula or seed key that names `organization_id` on `sys_presence`. A principal that must ' - + 'read the table needs the `manage_platform_settings` capability, which platform ' - + 'administrators hold', - reason: - 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' - + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' - + 'e67ba80049 of this repository\'s main branch: nothing writes the table through ObjectQL at ' - + 'all (presence travels the realtime path, and the generic data door exposes reads only), and ' - + 'a person present in several organizations is one person. So the injected column only ever ' - + 'held NULL. The census is the same procedure that reports the organization-stamping writers ' - + 'of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the ' - + 'tenant wall compared that NULL to the caller organization and hid every row from every ' - + 'reader, platform administrators included; with no column there is no wall, and the table is ' - + 'governed by object permission instead (D7). The capability gate is part of the same change, ' - + 'not a follow-up: the organization_admin grant carries the superuser bits on every object, so ' - + 'without it a walled deployment would hand each organization administrator every other ' - + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' - + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' - + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' - + 'the remedy the drift report names.', - acceptanceCriteria: - 'No authored metadata names `organization_id` on `sys_presence`: the field resolver (lint and ' - + 'the data door) now answers it as an unknown field. On every tenancy posture a principal ' - + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' - + '`sys_presence`, and a platform administrator lists every row with no organization filter. ' - + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', - }, - // #15207 (ADR-0131 D7, C6 item 3) — the settings cascade's global rung leaves - // the tenant-scoped sys_setting for the tenant-less sys_platform_setting, and - // the `global` option of sys_setting.scope retires with it. A platform-object - // storage move, not a spec-key retirement: SpecifierScopeSchema and the - // `source: 'global'` resolution value name the cascade rung and stay, so - // nothing lands in RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair - // with. Existing rows move in the v18 operator ceremony (ADR-0131 D14), which - // this entry does not perform. - { - id: 'sys-setting-global-rung-moved', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_setting.scope global — the settings cascade global rung left the tenant-scoped settings ' - + 'table: a value for a key declared at global scope is stored in the new tenant-less object ' - + 'sys_platform_setting (packages/platform-objects/src/system/sys-platform-setting.object.ts), ' - + 'and the global option of sys_setting.scope is retired', - replacement: - '`sys_platform_setting`, one row per `(namespace, key)` for the deployment, with the same ' - + '`value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns and no ' - + '`scope`, `user_id` or `organization_id`. Write it only through the settings door ' - + '(`/api/settings/:namespace`), which routes a global-scope key there. Reading it through the ' - + 'generic data API requires the `manage_platform_settings` capability. Delete any authored ' - + 'filter, list-view column or seed that names `scope = global` on `sys_setting`', - reason: - 'ADR-0131 D7: deployment-level runtime settings leave the tenant-scoped table, and a tenant-less ' - + 'object holds the values an operator must change without a restart. A census of every ' - + 'manifest at commit 51290bca2c of this repository\'s main branch found seven namespaces whose ' - + 'keys sit at the global rung (ai, auth, knowledge, mail, sms, storage and the ObjectQL lifecycle ' - + 'defaults), every one edited live in Setup, so none of them moves to boot configuration. The ' - + 'settings service is the only writer of a global row, it writes under a system context, and the ' - + 'row names no organization, so on sys_setting the injected organization column only ever held ' - + 'NULL there and a walled posture hid the row from every reader. The resolver reads the rung ' - + 'from the new object alone and excludes scope global from its sys_setting reads, so a row a ' - + 'pre-v18 database still holds there is not a second source; no write path produces one any ' - + 'more, which is why the select option retires rather than staying a declared value no write can ' - + 'reach. The cascade order, the lock semantics, SpecifierScopeSchema and the global resolution ' - + 'source are unchanged. An encrypted value moves without re-encryption: the ADR-0128 AAD binds ' - + 'the settings scope, namespace and key, never the holder object or an organization, so a ' - + 'sys_secret handle copied into the new row opens as it did. Existing databases: nothing moves ' - + 'automatically (ADR-0131 D14). The v18 upgrade ceremony moves each sys_setting row at scope ' - + 'global into sys_platform_setting by namespace and key, value_enc handle included; until it ' - + 'runs, those values read as their next rung or the manifest default.', - acceptanceCriteria: - 'A write of a global-scope settings key creates or updates exactly one `sys_platform_setting` ' - + 'row for its `(namespace, key)` and no `sys_setting` row, and a read of the key answers that ' - + 'value with source `global`. A `sys_setting` row still at `scope = global` is not answered by ' - + 'any read. An encrypted global value keeps its `sys_secret` handle in ' - + '`sys_platform_setting.value_enc` and opens. On every tenancy posture a principal without ' - + '`manage_platform_settings` is refused 403 PERMISSION_DENIED on a generic data read of ' - + '`sys_platform_setting`, while the settings door keeps answering it for a holder of the ' - + 'manifest capability. After the v18 ceremony no `sys_setting` row is at `scope = global`.', - }, - // ADR-0131 D13 (C5, stage S1) — a platform-object RETIREMENT, not a spec-key - // retirement: no authorable spec key moves, so nothing lands in - // RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with (the - // scim-provider-object-retired shape). The writer/reader census behind the - // verdict is cited in the reason. - { - id: 'sys-view-definition-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the sys_view_definition platform object (SysViewDefinitionObject, exported by ' - + '@objectstack/metadata-core and re-exported by @objectstack/platform-objects and its ' - + 'metadata subpath), its registration by MetadataPlugin and by the metadata protocol ' - + 'assembly, its name in PLATFORM_OBJECTS_BY_PACKAGE (@objectstack/spec system constants), ' - + 'its kernel:ready active-row index migration and that migration\'s exports from ' - + '@objectstack/metadata-protocol (ensureViewDefinitionActiveIndex, resolveIndexExec, ' - + 'buildActiveIndexSql, VIEW_DEFINITION_TABLE, VIEW_ACTIVE_INDEX_NAME, ' - + 'VIEW_ACTIVE_PROBE_INDEX_NAME, VIEW_ACTIVE_INDEX_COLUMNS and the EnsureViewIndex types), ' - + 'and its idx_sys_view_def_active entry in the os migrate duplicates runtime-index pre-flight', - replacement: - 'nothing replaces the table — a runtime-authored view is a `view` metadata item in ' - + '`sys_metadata`, written through `PUT /api/v1/meta/view/` (the client\'s ' - + '`meta.saveItem` for type `view`), which is what every framework and Studio view door ' - + 'already does. Delete any import of the removed symbols; `classifyIndexFailure` and the ' - + '`IndexExec` type are still exported by `@objectstack/metadata-protocol`, from the ' - + 'shared index-migration module. A stack that names `sys_view_definition` (a lookup ' - + 'target, a flow trigger, a permission entry, a platform-global declaration) removes the ' - + 'reference: the name no longer resolves to a platform object', - reason: - 'ADR-0131 D13: an object no framework code writes or reads is inert and retires. Census at ' - + 'commit 41d0d4038c of this repository\'s main branch, run with the glob pathspec over ' - + 'packages/**/src (41 files; control word sys_metadata 769) and repo-wide (65 files): no ' - + 'framework writer of the table\'s rows and no reader of them — the only statements that ' - + 'touched its rows were the active-row index migration\'s own presence and duplicate ' - + 'probes and the os migrate duplicates pre-flight\'s copy of the latter. The sibling Studio ' - + 'repository never referenced it (0 hits against 93 for sys_metadata, at its main branch ' - + 'and at the pinned console commit): its view create, update and list doors write the ' - + 'ADR-0005 view overlay through the metadata API. The only way a row could ever have ' - + 'reached the table was a caller using the generic data door on the object by name. ' - + 'Keeping it registered kept an API-enabled table, a boot-time index migration and a ' - + 'pre-flight probe alive for no consumer, and kept the name resolving as a real platform ' - + 'object for authored metadata that named it.', - acceptanceCriteria: - 'No code imports SysViewDefinitionObject or the removed metadata-protocol exports (TS2305 ' - + 'after upgrade). isPlatformProvidedObjectName answers false for sys_view_definition, so a ' - + 'stack referencing the name is flagged as a probable typo rather than resolved. Neither ' - + 'MetadataPlugin nor the metadata protocol assembly registers the object, a serving boot ' - + 'issues no statement naming it, and os migrate duplicates reports three runtime-index ' - + 'pre-flight entries, none naming it. Existing databases: schema sync is additive and never ' - + 'drops a table, so a database an earlier release provisioned keeps sys_view_definition and ' - + 'any rows a caller wrote through the generic data door, and nothing reads them. os migrate ' - + 'apply --allow-destructive does not drop it either — it reconciles declared objects only ' - + '(measured: on one database it dropped an orphaned column of a declared table and left ' - + 'this table and its row in place). os migrate plan lists it among the platform-prefixed ' - + 'tables nothing declares when the project has a host config. Export any row worth keeping; ' - + 'dropping the table is the operator\'s call, by hand.', - }, - { - id: 'system-cache-durations-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the two cache durations whose name carried no unit: CacheTier.ttl and ' - + 'CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts)', - replacement: 'ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 ' - + 'TTL default and the 30 reset default are unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'These two are one entry because they are one file and one authoring session: a ' - + 'cache tier and the avalanche-prevention block that protects it. Each sat beside a ' - + 'number in a DIFFERENT unit with nothing at the authoring site to separate them — ' - + 'CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout ' - + '(seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That ' - + 'last pair is the sharpest case on this file: one shape already carried both ' - + 'conventions, and the suffixed one was the honest half. Both are retiredKey() ' - + 'tombstones; neither shape is strict, so a bare deletion would strip in silence and ' - + 'the unknown-key error could not carry the rename. Why a semantic entry and not a D2 ' - + 'conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor ' - + 'an avalanche-prevention block is a registered metadata kind stored as a sys_metadata ' - + 'row, so the conversion chain has no seam that would see one. ADR-0087.', - acceptanceCriteria: - 'Every author of a CacheTier spells ttlSeconds and every author of a ' - + 'CacheAvalanchePrevention spells circuitBreaker.resetTimeoutSeconds. Authoring either ' - + 'old spelling fails to compile (input type `never`) and fails to parse with the rename ' - + 'prescription rather than a bare unrecognized-key error. Behaviour is unchanged: a tier ' - + 'given ttlSeconds: 600 expires after ten minutes exactly as ttl: 600 did, an omitted ' - + 'key still defaults to 300, and resetTimeoutSeconds still defaults to 30. One thing ' - + 'this rename deliberately does NOT touch: lockout.lockTimeoutMs keeps its name and its ' - + 'MILLISECOND unit — the two timeouts on this schema were never the same unit and must ' - + 'not be migrated as if they were.', - }, - { - id: 'system-collaboration-durations-unit-in-key', - surface: 'the two collaboration-session durations whose name carried no unit: ' - + 'CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval ' - + '(system/collaboration.zod.ts)', - replacement: 'idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the ' - + '300000 idle-timeout default are unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'idleTimeout is the collision that got this whole population ruled rather than merely ' - + 'noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout ' - + 'in SECONDS at the same time — so the identical bare name meant five minutes on one ' - + 'shape and three and a half days on the other, a 1000x divergence no parse could catch ' - + 'because both readings are positive integers. The tenant half was already renamed, in ' - + 'the same change that landed the duration gate itself; this is the half that remained. ' - + 'snapshot.interval rides in the same entry ' - + 'because it is the same object graph and the same authoring session — leaving one bare ' - + 'beside the other would have preserved exactly the ambiguity the rename removes. Both ' - + 'are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip ' - + 'in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no ' - + 'collaboration collection, and a session config is a runtime call argument rather than ' - + 'a stored sys_metadata row, so the conversion chain has no seam that would see it. ' - + 'ADR-0087.', - acceptanceCriteria: - 'Every caller that opens a collaboration session spells idleTimeoutMs, and every snapshot ' - + 'block spells intervalMs. Authoring either old spelling fails to compile (input type ' - + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' - + 'idleTimeoutMs: 600000 idles out after ten minutes exactly as idleTimeout: 600000 did, ' - + 'an omitted key still defaults to 300000, and the positive-integer bounds ride along ' - + 'with the renamed keys so a zero or negative interval is still refused. The migration ' - + 'is proved correct when no source in the tree spells a bare idleTimeout on ANY shape — ' - + 'the seconds-valued tenant twin is already gone, so a surviving bare spelling is now ' - + 'unambiguously a missed edit rather than the other key.', - }, - { - id: 'system-failover-health-check-interval-unit-in-key', - surface: 'FailoverConfig.healthCheckInterval, the disaster-recovery health-check period ' - + 'whose name carried no unit (system/disaster-recovery.zod.ts)', - replacement: 'healthCheckIntervalSeconds — rename the key; the value and the 30 default ' - + 'are unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'It stands alone because its file has exactly one offender left — and because the key ' - + 'directly beside it is the counter-example that shows where the line falls. ' - + 'FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: ' - + 'it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL ' - + 'field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is ' - + 'forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this ' - + 'repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is ' - + 'not strict, so a bare deletion would strip in silence. Why a semantic entry and not a ' - + 'D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover ' - + 'config is host configuration, never a stored sys_metadata row. ADR-0087.', - acceptanceCriteria: - 'Every FailoverConfig author spells healthCheckIntervalSeconds; authoring ' - + 'healthCheckInterval fails to compile (input type `never`) and fails to parse with the ' - + 'rename prescription. Behaviour is unchanged: healthCheckIntervalSeconds: 30 probes ' - + 'every thirty seconds exactly as before, and an omitted key still defaults to 30. The ' - + 'migration is proved correct when dns.ttl is still spelled ttl — a sweep that renamed ' - + 'it too has over-applied the rule and stripped a declared exemption.', - }, - { - id: 'system-metrics-jsdoc-durations-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the five remaining metrics durations whose unit lived in a source JSDoc only: ' - + 'MetricDefinition.summary.maxAge, ServiceLevelObjective.errorBudget.burnRateWindows[].window, ' - + 'MetricExportConfig.interval, MetricsConfig.collectionInterval and ' - + 'MetricsConfig.retention.period (system/metrics.zod.ts)', - replacement: 'summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, ' - + 'intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each ' - + 'key; every value is unchanged', - reason: - 'This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, ' - + 'and the two are meant to be read as a sequence — this one does not amend that record, ' - + 'which stays a true account of what the system-directory duration round did. That round ' - + 'renamed the three metrics window ' - + 'and period lengths whose describe named no unit, and recorded that the error-budget ' - + 'burn-rate window was "outside this rename, not outside the gate population", naming the ' - + 'JSDoc-channel gap as where it would be settled. That gap is now ruled and this is ' - + 'its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer\'s 「同意」, ' - + 'which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, ' - + 'remediates the 21-row JSDoc-channel population per file, and lands that widened gate ' - + 'last, into a tree already clean. ⚠️ One consequence for ' - + 'readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its ' - + 'name and that a sweep renaming it has over-applied the rule. That sentence was true of ' - + 'that round and is superseded here, by the ruling it itself pointed at; the other key it ' - + 'names, the exporter batch size, is a COUNT of records and still does not move. All five ' - + 'keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a ' - + 'channel check:duration-unit-keys does not read — it reads .describe() and ' - + '.meta({ description }) — and four of the five carried no describe at all while the fifth ' - + 'read "Window size". So the reader who most needs the unit, the reader of the published ' - + 'reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible ' - + 'number of seconds and a plausible number of milliseconds, and nothing on the page decided ' - + 'it. Each key is renamed and its describe corrected in the same stroke, because under the ' - + 'duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the ' - + 'describe prose alone) moving the unit into the describe alone is itself a violation. ' - + 'Three of the ' - + 'five spellings are not the mechanical suffix, and each departure has a reason this file ' - + 'already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, ' - + 'because the enclosing array is already called burnRateWindows so the key would stutter — ' - + 'the objection the system-directory round recorded against window.windowSeconds — and ' - + 'because on this tree ' - + 'windowSeconds is not an authorable key at all, its only key-position occurrence being an ' - + 'alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; ' - + 'retention.period becomes durationSeconds, not periodSeconds, because period is calendar ' - + 'vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or ' - + 'calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds ' - + 'would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as ' - + 'collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds ' - + 'this same card creates one def over. The two mechanical spellings are attested: ' - + 'maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this ' - + 'same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the ' - + 'sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued ' - + 'cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, ' - + 'the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) ' - + 'and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a ' - + 'millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds ' - + 'to 45 at 9b62f54671. All five are ' - + 'retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion ' - + 'would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts ' - + 'declares no metrics collection, and none of a metric definition, an SLO, an export config ' - + 'or a metrics config is a registered metadata kind stored as a sys_metadata row — the same ' - + 'reading the system-directory round recorded for the three keys it renamed. Measured on ' - + 'fc28c1d38: no in-repo ' - + 'code consumer reads any of the five — outside packages/spec the only occurrences of every ' - + 'distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, ' - + 'collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the ' - + 'generated content/docs/references/system/metrics.mdx, which this rename regenerates, ' - + 'against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 ' - + '(1195 again at 9b62f54671); and the objectui ' - + 'checkout this repo builds against — this is the pin, ' - + '`.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — ' - + 'spells all six metrics def names and both distinctive keys 0 times across 8351 tracked ' - + 'files at that sha, against lit controls window 4470, timeout 1694, period 249, ' - + 'interval 213 and metrics 456 on that same corpus and sha (0 across 8281, against 4449 / ' - + '1674 / 249 / 213 / 455, at 47b1f0bb7, 0 across 8234, against 4430 / ' - + '1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / ' - + '1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / ' - + '1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / ' - + '1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / ' - + '1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / ' - + '1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / ' - + '1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / ' - + '1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / ' - + '1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / ' - + '1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / ' - + '1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / ' - + '324, at 62597c588), so no pin bump is owed. ' - + 'ADR-0087.', - acceptanceCriteria: - 'Every metric definition spells summary.maxAgeSeconds, every error-budget burn rate window ' - + 'spells durationSeconds, every metric export config spells intervalSeconds, and every ' - + 'metrics config spells collectionIntervalSeconds and retention.durationSeconds. Authoring ' - + 'any of the five old spellings fails to compile (input type `never`) and fails to parse ' - + 'with the rename prescription naming the suffixed key — not an unrecognized_keys issue. ' - + 'Behaviour is unchanged: collectionIntervalSeconds: 15 collects every fifteen seconds ' - + 'exactly as collectionInterval: 15 did, and every default (600, 60, 15, 604800) and ' - + 'positive-integer bound rides along with its renamed key. Each new describe names the unit, ' - + 'so the reference page carries it. Verify the same-named decoys on this one file apart: ' - + 'MetricAggregationConfig.window and ServiceLevelIndicator.window are objects that already ' - + 'hold a durationSeconds of their own, and ServiceLevelObjective.period is an object holding ' - + 'a durationSeconds and a calendar — none of the three moves, and a sweep that renamed any ' - + 'of them has over-applied this rule.', - }, - { - id: 'system-metrics-window-durations-unit-in-key', - surface: 'the three metrics window/period lengths whose name carried no unit: ' - + 'MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ' - + 'ServiceLevelObjective.period.duration (system/metrics.zod.ts)', - replacement: 'window.durationSeconds, window.durationSeconds and period.durationSeconds — ' - + 'rename each key; every value is unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'The three are one entry because they are one measurement expressed three times on one ' - + 'file: how long a window or period is. The new name is deliberately NOT the mechanical ' - + 'sizeSeconds the gate prints. size means a byte or row count everywhere else in this ' - + 'spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this ' - + 'very file spells a batch row count size — so sizeSeconds would have kept the ' - + 'misleading half of the name and bolted a unit onto it, leaving a reader to decide ' - + 'whether a window is measured in bytes-per-second or in time. windowSeconds was ' - + 'rejected for a plainer reason: the parent key is already window, so it would read ' - + 'window.windowSeconds. durationSeconds names what the number IS, and the file itself ' - + 'supplied the precedent — ServiceLevelObjective.period already called its length a ' - + 'duration, so after the rename all three read alike instead of one borrowing byte ' - + 'vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare ' - + 'deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' - + 'stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI ' - + 'or an SLO is a registered metadata kind stored as a sys_metadata row. ' - + 'ADR-0087.', - acceptanceCriteria: - 'Every aggregation window and SLI window spells durationSeconds, and every SLO period ' - + 'spells durationSeconds. Authoring window.size or period.duration fails to compile ' - + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' - + 'unchanged: window.durationSeconds: 300 aggregates over five minutes exactly as ' - + 'size: 300 did, and the positive-integer bounds ride along with the renamed keys. Two ' - + 'keys on this same file deliberately do NOT move, and a sweep that renamed either has ' - + 'over-applied the rule: the error-budget burn-rate window ' - + 'names its unit only in the JSDoc above it ("Window size in seconds"), a channel the ' - + 'gate does not read: it reads `.describe()` and `.meta({ description })`, and that ' - + 'key\'s describe ("Window size") names none. So the gate lists it among the ' - + 'duration-shaped keys without judging it — neither an offender nor an exemption — and ' - + 'it is outside this rename, not outside the gate population; that JSDoc-channel gap is ' - + 'filed as a finding of its own; ' - + 'and the exporter batch size is a COUNT of records, not a duration, so it has no unit ' - + 'to carry. Both keep their names. ' - // Pointer, not a rewrite (Prime Directive #13): the sentence above is #15679's, left - // word for word. The burn-rate window it names is renamed in this same, still-unreleased - // protocol-18 step, so a reader arriving here through `migrate meta` needs the successor. - + 'One of those two moves after all, in this same protocol step: the error-budget ' - + 'burn-rate window is renamed to durationSeconds by ' - + 'system-metrics-jsdoc-durations-unit-in-key, the remediation of the JSDoc-channel gap ' - + 'named just above (ruled: a duration key whose JSDoc names a unit its describe does not ' - + 'is refused). Read that entry with this one; the exporter batch size is ' - + 'still a COUNT of records and still does not move.', - }, - { - id: 'system-object-storage-durations-unit-in-key', - surface: 'the two object-storage durations whose name carried no unit: ' - + 'AccessControlConfig.maxAge and StorageConnection.timeout ' - + '(system/object-storage.zod.ts)', - replacement: 'maxAgeSeconds and timeoutMs — rename each key; both values are unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'AccessControlConfig.maxAge is the one key in this stack where the two structural ' - + 'exemptions and the rename look alike from a distance, so the reasoning is recorded ' - + 'rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on ' - + 'evidence: every bucket-CORS standard the value is forwarded to spells the field WITH ' - + 'its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it ' - + 'would have exempted a DEVIATION from the cited standard rather than a mirror of it, ' - + 'which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge ' - + 'DID get the marker and keeps its bare name, because the Fetch response header that one ' - + 'mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on ' - + 'opposite sides of the same line; the asymmetry is the point and must not be ' - + 'harmonised. StorageConnection.timeout rides along as the plain case on the same file. ' - + 'Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would ' - + 'strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' - + 'no objectStorage collection, and neither shape is a registered metadata kind stored as ' - + 'a sys_metadata row. ADR-0087.', - acceptanceCriteria: - 'Every bucket access-control block spells maxAgeSeconds and every storage connection ' - + 'spells timeoutMs. Authoring either old spelling fails to compile (input type `never`) ' - + 'and fails to parse with the rename prescription. Behaviour is unchanged: ' - + 'maxAgeSeconds: 3600 caches a preflight for an hour exactly as maxAge: 3600 did, and ' - + 'the non-negative bounds ride along with the renamed keys. The migration is proved ' - + 'correct when shared/CorsConfig.maxAge is STILL spelled maxAge — a find-and-replace ' - + 'that renamed both has destroyed a declared external-vocabulary mirror, and the gate ' - + 'will not catch it because the marker exempts the key either way.', - }, - { - id: 'system-registry-config-durations-unit-in-key', - surface: 'the three package-registry durations whose name carried no unit: ' - + 'RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl ' - + '(system/registry-config.zod.ts)', - replacement: 'syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every ' - + 'value, the 30000 timeout default and the 3600 TTL default are unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'The three are one entry because they are one file and, for the first two, one object: ' - + 'RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five ' - + 'lines apart, both bare. That pair carries the clearest demonstration in this card of ' - + 'why a bound is no substitute for a name — timeout is min(1000), which reads as one ' - + 'second under the right unit and as sixteen minutes under the wrong one, and both ' - + 'readings satisfy the validator. The cache TTL is the same defect one schema over, ' - + 'beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes ' - + 'are not strict, so a bare deletion would strip in silence. Why a semantic entry and ' - + 'not a D2 conversion: stack.zod.ts declares no registry collection, and a registry ' - + 'config is host configuration read at startup rather than a stored sys_metadata row, so ' - + 'the conversion chain has no seam that would see it. ADR-0087.', - acceptanceCriteria: - 'Every upstream declaration spells syncIntervalSeconds and timeoutMs, and every registry ' - + 'cache block spells ttlSeconds. Authoring any old spelling fails to compile (input type ' - + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' - + 'syncIntervalSeconds: 300 syncs every five minutes exactly as syncInterval: 300 did, an ' - + 'omitted timeoutMs still defaults to 30000, an omitted ttlSeconds still defaults to ' - + '3600, and the min-60 / min-1000 / min-0 bounds ride along with the renamed keys so a ' - + 'too-small interval or timeout is still refused. The pair on RegistryUpstream is the ' - + 'one to check by hand rather than by search-and-replace: after the migration a reader ' - + 'can tell at the authoring site that 300 and 30000 are not the same kind of number.', - }, - { - id: 'system-tracing-otel-exporter-durations-unit-in-key', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: 'the four tracing-configuration durations whose unit lived in a source JSDoc only: ' - + 'OpenTelemetryCompatibility.exporter.timeout, ' - + 'OpenTelemetryCompatibility.exporter.batch.exportTimeout, ' - + 'OpenTelemetryCompatibility.exporter.batch.scheduledDelay and ' - + 'TracingConfig.performance.exportInterval (system/tracing.zod.ts)', - replacement: 'timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each ' - + 'key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are ' - + 'unchanged', - reason: - 'Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the ' - + 'maintainer\'s 「同意」: a duration key whose JSDoc names a unit its describe does not is ' - + 'refused, and the keys in that shape are remediated per file before that refusal lands — ' - + 'the duration-unit rule (the unit lives in the key name or its value type, never in prose ' - + 'alone) executed file by file. It follows ' - + 'system-tracing-span-duration-unit-in-key on this same file and does not amend it: that ' - + 'entry retired Span.duration under ruling B, whose population was the describe channel, ' - + 'and these four keys were never in it — they are the JSDoc-only channel that finding ' - + 'opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — ' - + '"Timeout in milliseconds", "Export timeout in milliseconds", "Scheduled delay in ' - + 'milliseconds", "Background export interval in milliseconds" — and the JSDoc above a key ' - + 'is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: ' - + 'all four carried NO .describe() at all, so the published reference row for each was a ' - + 'bare integer with no unit anywhere on the page — a strictly worse channel than the ' - + 'unit-in-prose shape the duration-unit rule already refuses, since here the reference ' - + 'reader had no prose ' - + 'to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, ' - + '5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who ' - + 'reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, ' - + 'counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with ' - + 'git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of ' - + 'them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any ' - + 'name; the Delay-plus-Ms pairing is likewise already attested on that same ref ' - + '(maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, ' - + 'retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or ' - + 'exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout ' - + 'and its own landed precedent is ' - + 'Span.duration to durationMs, the opposite of the sibling metrics card whose rows were ' - + 'seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed ' - + 'to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is ' - + 'the OpenTelemetry batch span processor\'s own four knobs (max batch size, max queue ' - + 'size, scheduled delay, export timeout) beside the exporter\'s own request deadline — so ' - + 'renaming either to something more distinctive would depart from the vocabulary the shape ' - + 'mirrors, and the nesting already disambiguates every read point ' - + '(exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are ' - + 'retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema ' - + 'nor any object nested inside them is .strict(), so a bare deletion would be a SILENT ' - + 'STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded ' - + 'and the removed key was dropped without a word) — and the stripped value lands on an ' - + 'export deadline and a ' - + 'background export period. Why a semantic entry and not a D2 conversion: the conversion ' - + 'chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts ' - + 'declares no tracing collection, no metadata-type binding or manifest embed carries ' - + 'either, and a tracing configuration is never a stored sys_metadata row — so a conversion ' - + 'would be a transform with no seam that ever runs. That is the same disposition ' - + 'system-tracing-span-duration-unit-in-key recorded for the other key on this file. ' - + 'Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — ' - + 'OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times ' - + 'across the whole tree at that ref excluding packages/spec and content/docs/references, ' - + 'against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both ' - + 'counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a ' - + 'dark control of 0; inside packages/spec the ' - + 'only occurrences are tracing.zod.ts, its test, and the generated rows in ' - + 'content/docs/references/system/tracing.mdx, which this rename regenerates. And the ' - + 'pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names none of it: all 37 exports of ' - + 'tracing.zod.ts and each of the four key names occur 0 times across the 8351 files ' - + 'tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui\'s own HTML ' - + 'text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against ' - + 'two lit controls on that same corpus and sha: 18047 hits for the bare token objectstack, ' - + 'and 7545 for the package specifier @objectstack/spec (at 47b1f0bb7: 0 across 8281, Span 517, ' - + '17980 and 7523; at f0268ad78: 0 across 8234, Span 517, ' - + '17956 and 7522; at a58626c88: 0 across 7754, Span 509, ' - + '17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, ' - + '17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, ' - + '17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, ' - + '17227 and 7134; at ab1879721: 0 across 10267, Span 491, ' - + '16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, ' - + '16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, ' - + '15691 and 6206; at e420df310: 0 across 9800, Span 486, ' - + '15352 and 6024; at db11afd49: 0 across 9546, Span 486, ' - + '14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, ' - + 'Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043).', - acceptanceCriteria: - 'Every author and reader of an OpenTelemetryCompatibility spells exporter.timeoutMs, ' - + 'exporter.batch.exportTimeoutMs and exporter.batch.scheduledDelayMs, and every one of a ' - + 'TracingConfig spells performance.exportIntervalMs. Authoring any old spelling fails to ' - + 'compile (input type `never`) and fails to parse with the rename prescription naming the ' - + 'suffixed key — not with a generic unrecognized_keys issue, which these non-strict shapes ' - + 'could never have raised anyway. Behaviour is unchanged: the same milliseconds, the same ' - + '10000 / 30000 / 5000 / 5000 defaults and the same int().positive() bounds, and all four ' - + 'published describes now name milliseconds where before there was no describe at all. ' - + 'The authorable-surface and authorable-defaults ledgers move nothing: every one of the ' - + 'four is NESTED, and those artifacts record top-level keys per def only.', - }, - { - id: 'system-tracing-span-duration-unit-in-key', - surface: 'Span.duration, the emitted trace-span length whose name carried no unit ' - + '(system/tracing.zod.ts)', - replacement: 'durationMs — rename the key; the value is unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'It stands alone because it is the only offender on its file and the only one in this ' - + 'card that is a pure runtime-emitted measurement: a span is written by an exporter and ' - + 'read by a backend, never authored by hand. That is also why it is a rename and not an ' - + 'externalVocabulary mirror, which is the exemption a tracing shape would most plausibly ' - + 'claim: OpenTelemetry, whose model this schema follows, carries span length as a ' - + 'start/end nanosecond PAIR and declares no key named duration at all, so there is no ' - + 'external spelling for the marker to point at. The shape already spells its two ' - + 'instants startTime and endTime, so the bare duration was the one measurement on the ' - + 'span that did not say what it was. Tombstoned with retiredKey(); the shape is not ' - + 'strict, so a bare deletion would strip in silence and an exporter emitting the old ' - + 'spelling would lose the value without an error. Why a semantic entry and not a D2 ' - + 'conversion: an emitted span is never a stack collection member and never a stored ' - + 'sys_metadata row — the same disposition every runtime-emitted measurement in this ' - + 'stack has taken. ADR-0087.', - acceptanceCriteria: - 'Every exporter that BUILDS a Span spells durationMs, and every consumer that reads a ' - + 'span length reads durationMs. Authoring duration fails to compile (input type `never`) ' - + 'and fails to parse with the rename prescription rather than silently dropping the ' - + 'measurement. Behaviour is unchanged: durationMs: 150 is the same 150 milliseconds, and ' - + 'the non-negative bound rides along with the renamed key so a negative span length is ' - + 'still refused. Note the sibling instants startTime and endTime are ISO-8601 strings, ' - + 'not numbers, and are untouched by this rename.', - }, - { - id: 'system-worker-queue-rate-limit-duration-unit-in-key', - surface: 'QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried ' - + 'no unit (system/worker.zod.ts)', - replacement: 'durationMs — rename the key; the value is unchanged', - reason: - 'Maintainer ruling B on duration units (2026-09-02, its population widened on ' - + '2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions ' - + 'a schema declares on the key itself): the unit of a ' - + 'duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never ' - + 'only in the describe prose, and no existing offender is grandfathered. ' - + 'It stands alone because it is the only offender left on its file, and the file itself ' - + 'is what makes it a drift rather than a convention: TaskResult.durationMs, declared ' - + 'ninety lines earlier in the SAME source, already spelled the identical measurement ' - + 'with its unit. One file, one unit, two spellings, and the correct one was already ' - + 'there — so this rename removes an internal inconsistency rather than imposing an ' - + 'external one. Tombstoned with retiredKey(); the shape is not strict, so a bare ' - + 'deletion would strip in silence and a queue would fall back to no rate limit at all ' - + 'without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' - + 'jobs, not queues, so a QueueConfig is worker host configuration rather than a stack ' - + 'collection member or a stored sys_metadata row, and the conversion chain has no seam ' - + 'that would see it. ADR-0087.', - acceptanceCriteria: - 'Every queue declaration spells rateLimit.durationMs. Authoring rateLimit.duration fails ' - + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' - + 'than silently dropping the window and leaving the queue unthrottled. Behaviour is ' - + 'unchanged: { max: 100, durationMs: 60000 } is a hundred tasks a minute exactly as ' - + '{ max: 100, duration: 60000 } was, and the positive-integer bound rides along with the ' - + 'renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry.', - }, - { - id: 'tenant-schema-cache-ttl-unit-in-key', - surface: 'SchemaLevelIsolationStrategy `performance.schemaCacheTTL` (system/tenant.zod.ts)', - replacement: '`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value ' - + '(seconds) is unchanged', - reason: - 'Maintainer ruling A, 2026-09-11: the gate that reads a duration key\'s JSDoc lands last, ' - + 'after its offenders are fixed file by file — so this entry executes, per file, the rule ' - + 'that a duration number key carries its unit in ' - + 'its name. The key carried its unit (seconds) in a ' - + 'source JSDoc only — "Schema cache TTL in seconds" — while `.describe()`, the text ' - + '`content/docs/references/**` publishes, said "Schema cache TTL" and named no unit at all. ' - + 'So the reader who most needs the unit, the reader of the published reference page, was the ' - + 'only reader who never saw it: 3600 is a plausible number of seconds and a plausible number ' - + 'of milliseconds, and nothing on the page decided ' - + 'it. Under that rule\'s gate, moving the unit ' - + 'into the describe alone is itself a violation (unit in prose, none in the name), so the key ' - + 'is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: ' - + 'counted on this tree, the suffixed family already spells it that way in every member ' - + '(`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position ' - + '`TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested ' - + '`performance` object is not strict, so a bare deletion would silently strip the key. Why a ' - + 'semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a ' - + 'tenant isolation strategy is not a stored metadata row (it describes cloud tenancy ' - + 'configuration), so the chain has no seam that runs on it — the same reading ' - + '`tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on ' - + 'bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` ' - + 'and its test the only occurrences are the four generated rows in ' - + '`content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned ' - + 'objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells it 0 ' - + 'times across 8351 tracked files, against lit controls `TTL` 184 and `tenant` 1340 on the ' - + 'same corpus (0 across 8281, against 184 and 1338, at 47b1f0bb7; 0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 ' - + 'and 987, at 62597c588).', - acceptanceCriteria: - 'Every schema-level tenant isolation source spells `performance.schemaCacheTtlSeconds`; ' - + 'authoring `performance.schemaCacheTTL` fails to compile and fails to parse with the rename ' - + 'prescription naming the suffixed key; the parsed default is 3600 as before, and the ' - + 'published describe reads "Schema cache TTL in seconds".', - }, - { - id: 'tenant-timeouts-unit-in-key', - surface: 'DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy ' - + '`accessControl.sessionTimeout` (system/tenant.zod.ts)', - replacement: '`connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` ' - + '(default 3600) — rename each key; the values (seconds) are unchanged', - reason: - 'Maintainer ruling 2026-09-02, B: a duration number key carries its unit in its name, ' - + 'enforced by a gate with no grandfathered baseline — folding in the finding that these ' - + 'two descriptions named no unit. Both keys carried their ' - + 'unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` ' - + 'publishes — said "Idle pool timeout" and "Session timeout" with no unit at all. So the one ' - + 'reader who most needs the unit, the reader of the published reference page, was the only ' - + 'reader who never saw it: 300 is a plausible number of seconds and a plausible number of ' - + 'milliseconds, and nothing on the page decided it. ' - + 'That finding proposed adding the unit to the two ' - + 'descriptions; under the ruled gate that exact fix is a violation (unit in prose, none in ' - + 'the name), so the keys are renamed instead — one breaking change per key, and the tree ' - + 'never passes through a state the gate refuses. Both are retiredKey tombstones (the nested ' - + 'objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a ' - + 'stack collection member or a stored row (they describe cloud tenancy configuration), so the ' - + 'chain has no seam that runs on them (the `kernel/Manifest:loading` precedent). Measured on ' - + 'ca46f8f12: no in-repo runtime reads either key.', - acceptanceCriteria: - 'Every tenant isolation / security-policy source spells `idleTimeoutSeconds` and ' - + '`sessionTimeoutSeconds`; authoring `idleTimeout` or `sessionTimeout` fails to compile and ' - + 'fails to parse with the rename prescription naming the suffixed key; the parsed defaults ' - + 'are 300 and 3600 as before.', - }, - // The D3 entry of the `time-default-utc-suffix-dropped` family: the stored form - // of a `time` value (`ClockTimeValueSchema`) no longer admits a zone, so the - // literal defaults it judges move with it. - { - id: 'time-default-zone-refused', - surface: 'a literal `defaultValue` with a `Z` or a UTC offset on a `time` field, or on an ' - + 'action param typed `time`', - replacement: 'the wall clock itself, `HH:MM` or `HH:MM:SS` with no zone, or a `datetime` field ' - + 'when the value is an instant. The conversion drops a `Z` or a zero offset, which names the ' - + 'same wall clock. It does not touch a non-zero offset (`08:00+08:00`): whether that meant ' - + '08:00 or the UTC 00:00 only the author knows, so rewrite it by hand', - reason: - 'A `time` value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already ' - + 'refuses a zone-suffixed time of day on write. The stored form still admitted one, so a ' - + 'field default such as `10:00Z` parsed clean and every insert that fell back to it was then ' - + 'refused `invalid_time` on a field the caller never sent, and an action param default or ' - + 'submitted value passed the dispatcher. The stored form now refuses the zone, so the field ' - + 'and action-param default gates refuse it when it is authored and the dispatcher refuses it ' - + 'at submit.', - acceptanceCriteria: - 'No `time` field or `time` action param declares a literal default with a zone. Zone-less ' - + 'defaults, the `NOW()` token and expression defaults parse as before. A stored `sys_metadata` ' - + 'row whose default carried a `Z` or a zero offset loads with the zone dropped; one with a ' - + 'non-zero offset keeps loading as stored, is listed by `os migrate meta --stored` as a TODO ' - + 'naming the field or param, and fails the schema wherever it is parsed until it is rewritten.', - conversionIds: ['time-default-utc-suffix-dropped'], - }, - { - id: 'time-update-interval-sub-day-retired', - surface: - '`TimeUpdateInterval` — the `/analytics/query` body\'s `timeDimensions[].granularity` and an ' - + 'analytics cube dimension\'s `granularities[]`. The three sub-day members `second`, ' - + '`minute` and `hour` are retired; `day`, `week`, `month`, `quarter` and `year` are ' - + 'unchanged and parse byte-identically', - replacement: - 'the coarsest declared interval that still answers the question — `day` is the finest bucket ' - + 'the platform labels. A caller who wants raw per-instant rows drops `granularity` entirely, ' - + 'which groups on the unbucketed timestamp deliberately rather than by accident. There is no ' - + 'mechanical replacement that preserves a sub-day bucket, because no backend ever produced ' - + 'one', - reason: - 'ADR-0049 enforce-or-remove — the spec-side narrowing promised by the fix that made ' - + 'driver-memory\'s analytics face bucket by its declared granularity. The rest of the ' - + 'contract never carried these three: `DateGranularity` (`data/query.zod.ts`) — the ' - + 'vocabulary a `groupBy` entry and every driver\'s bucket expression are typed by — declares ' - + 'five, `@objectstack/core`\'s `BUCKET_GRANULARITIES` labels the same five, and ' - + '`DriverCapabilitiesSchema.supports.queryDateGranularity` is a `z.record(DateGranularity, ' - + 'boolean)`, so a driver could not advertise sub-day bucketing even if it had one. Measured ' - + 'on the shipped faces before the narrowing: `driver-memory`\'s analytics face answered ' - + 'NOT_IMPLEMENTED/501, `driver-mongodb`\'s bucket builder answered NOT_IMPLEMENTED/501, and ' - + 'the engine\'s in-memory aggregation — the fallback every SQL/ObjectQL analytics query ' - + 'carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity — ' - + 'answered 200 with one group per distinct timestamp, echoing the raw instant back as its ' - + 'own bucket label. Two honest refusals and one silently wrong answer, and no third ' - + 'behaviour anywhere. ⚠️ This retires the NAMES, not the idea: offering sub-day analytics ' - + 'means widening `DateGranularity`, the `queryDateGranularity` record, the canonical ' - + 'bucket-key vocabulary and every driver\'s bucket expression together — new capability, ' - + 'decided as such', - acceptanceCriteria: - 'No analytics request body carries `timeDimensions[].granularity` of `second`, `minute` or ' - + '`hour`, and no cube dimension offers one in `granularities[]` (the D2 conversion ' - + '`cube-sub-day-granularities-removed` strips them from sources, dropping the key entirely ' - + 'when nothing coarser remains). ⚠️ The conversion cannot decide what a dimension that ' - + 'offered ONLY sub-day intervals should offer instead — review each site the run reports ' - + 'and state the granularities that dimension actually serves.', - }, - { - id: 'training-deadline-keys-retired', - surface: - 'training duration and deadline keys: `TrainingCourse.durationMinutes` / `validityDays`, ' - + '`TrainingPlan.recertificationIntervalDays` / `gracePeriodDays` / `reminderDaysBefore`', - replacement: - 'nothing to re-declare — delete the keys. No training-management engine exists on the ' - + 'platform: nothing schedules or times a course, computes a certification expiry, ' - + 're-assigns training on an interval, escalates an expired certification or sends a ' - + 'reminder, so there is no live mechanism to declare a duration or deadline to', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys ' - + '(ruled A: retire per ' - + 'family). Five minute/day-shaped keys sat on the published authorable surface and in the ' - + 'generated reference docs — an author could write `validityDays: 365` and reasonably ' - + 'expect a certificate to expire — and read by NOTHING: the schemas are exported from ' - + '`@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, ' - + 'absent from the 2026-06 liveness ledgers, and the reader census over every package ' - + 'outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned ' - + 'sha returned zero hits for every key. Three of the five carried defaults (365, 30 and 14 ' - + 'days) that were materialized into every parsed plan without ever being consulted. Why ' - + 'D3 semantic and not a D2 conversion: the chain walks a normalized STACK and ' - + '`applyConversionsToStoredItem` maps a metadata type onto one of its collections; none of ' - + 'these schemas is either, so a conversion would be a transform with no seam that ever ' - + 'runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent).', - acceptanceCriteria: - 'No `TrainingCourse` or `TrainingPlan` literal — standalone or as a `courses[]` entry — ' - + 'carries `durationMinutes`, `validityDays`, `recertificationIntervalDays`, ' - + '`gracePeriodDays` or `reminderDaysBefore`. TypeScript authors get the refusal at compile ' - + 'time (each key is typed `never`); a value reaching the parse is refused with the ' - + 'prescription (`invalid_type` at the path of the key). Parsed plans no longer carry the ' - + 'three former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be ' - + 'verified as such: nothing ever read the keys, so removing them removes no behaviour.', - }, - { - id: 'training-family-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'the training family, retired whole: the five defs system/TrainingCategory, ' - + 'system/TrainingCompletionStatus, system/TrainingCourse, system/TrainingPlan and ' - + 'system/TrainingRecord, and every name system/training.zod.ts exported from ' - + '@objectstack/spec/system (the five *Schema consts, their z.input aliases and the two ' - + '*Parsed aliases)', - replacement: - 'nothing to re-declare — no training-management engine exists on the platform, so there ' - + 'is no working configuration to migrate to. Nothing assigned a course, tracked a ' - + 'completion, sent a reminder or expired a certification; a training record the ' - + 'organisation keeps is ordinary object data, declared as an object with its own fields ' - + 'and enforced by the object engine. If training management becomes a product capability ' - + 'it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the ' - + 'vocabulary second', - reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families\' remaining keys ' - + 'and defs (ruled A: retire the ' - + 'three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the ' - + 'integration/ErrorMappingConfig precedent; not roadmapped). Five defs and roughly ' - + 'twenty-five declared keys sat on the exported surface and in the generated reference ' - + 'docs, and were read by NOTHING: the schemas were exported from ' - + '`@objectstack/spec/system`, mounted by no `stack.zod.ts` key, registered as no metadata ' - + 'type, absent from the 2026-06 liveness ledgers, and the reader census over every ' - + 'package outside `packages/spec` (tests and changelogs excluded), over `examples/**` and ' - + '`skills/**`, and over objectui at the pinned sha returned zero hits for every exported ' - + 'name, with a lit control. `TrainingCourse.mandatory`, `TrainingPlan.trackCompletion` ' - + 'and `TrainingPlan.sendReminders` were boolean capability claims of exactly the shape ' - + 'ADR-0049 names: an author could write them, parse clean, and get no behaviour and no ' - + 'diagnostic. Tagging the family `[EXPERIMENTAL — not enforced]` was the fallback the ' - + 'ruling did not take (a human-only signal). The deadline-key tombstones of the 2026-09-02 ' - + 'per-family ruling (five ' - + 'sites, `RETIRED_KEYS_BY_MAJOR[18]`, D3 `training-deadline-keys-retired`) leave with ' - + 'their defs\' source; their registry entries stay as history. Why D3 semantic and not a ' - + 'D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` ' - + 'maps a metadata type onto one of its collections; none of these schemas is either, so ' - + 'a conversion would be a transform with no seam that ever runs (the ' - + '`kernel/MetadataPluginConfig:additionalTypes` precedent), and with no carrier key there ' - + 'is no shape on which a tombstone could sit.', - acceptanceCriteria: - 'No code imports TrainingCategorySchema, TrainingCompletionStatusSchema, ' - + 'TrainingCourseSchema, TrainingPlanSchema or TrainingRecordSchema — or any of their type ' - + 'aliases — from @objectstack/spec or @objectstack/spec/system: every such import is ' - + 'TS2305 after upgrade, and no working replacement exists to point at because the ' - + 'vocabulary described nothing real. The five defs are absent from ' - + '`json-schema.manifest/system.json`, the api-surface / declaration-map / export-origins ' - + 'shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED ' - + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' - + 'them removes no behaviour.', - }, - // The D3 entry of the `translation-component-submit-label-removed` family, - // which landed in commit d173125fb: the component-copy key left with its only - // declarer, `element:form`, rather than being re-anchored on `object-form`. - // One D3 entry per retirement family, even when D2 is lossless (ruling B on - // #17152). The strip deletes a string nothing has read since its carrier - // retired; where the translator's work should go instead is not something the - // conversion can decide. - { - id: 'translation-component-submit-label-retired', - surface: 'translation.pages..components..submitLabel — the component-copy key of the ' - + 'retired element:form', - replacement: 'The live form surface\'s submit copy: `submitText` on the `object-form` component, ' - + 'an I18nLabel localized at its own authoring site.', - reason: 'The D2 conversion `translation-component-submit-label-removed` deletes `submitLabel` from ' - + 'every translation bundle and stored translation item, and the delete is lossless: the key\'s ' - + 'only declarer, `element:form`, retired whole, so no resolver has overlaid the string since ' - + 'and it was read by nothing. What the delete drops is translation WORK. A translator who ' - + 'localized a submit button for each locale did so because a user was meant to read it; if ' - + 'the page\'s form now lives on `object-form`, its submit copy is `submitText`, and that key ' - + 'is not filled by moving the old strings mechanically — the component ids differ, and a ' - + 'retired `element:form` may have no successor on the page at all. Only the author can say ' - + 'which form each string belonged to and whether it still exists.', - acceptanceCriteria: 'No translation bundle or translation item carries a component ' - + '`submitLabel`; the parse refuses it. For each form a user still submits, the `object-form` ' - + 'component carries a `submitText` whose localized values cover the locales the dropped ' - + 'strings covered, and switching the UI locale shows the submit button in that locale — or ' - + 'the author has decided the default copy is acceptable.', - }, - // A translated flow screen heading is judged by the one text-slot judge, like - // the translated body text and refusal message beside it: the engine picks all - // three in the run's locale and renders them as double-brace templates. - // Semantic-only — a single-brace token in a translated heading always drew as - // literal text, so whether it was meant as a hole is the translator's call, - // and no D2 conversion rewrites it. - { - id: 'translation-flow-screen-title-text-slot-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'translation.flows..screens..title — a translated screen heading, in a stack\'s translations ' - + 'or a translation metadata item, carrying a single-brace token such as {name}, or a double-brace hole over a ' - + 'dollar-named variable the flow engine does not bind', - replacement: - 'the double-brace hole the refusal names, {{ name }}, where the heading should show the value — the holes of ' - + 'the heading it translates, as the translated description and refusal message already keep them; plain text ' - + 'where the braces were never meant as a hole. An empty string stays the untranslated slot.', - reason: - 'The flow engine picks a translated screen heading in the run\'s locale and renders it through the flow ' - + 'text-slot renderer, the double-brace dialect, exactly as it renders the translated body text and a refusing ' - + 'end node\'s message. Those two refused a single-brace token when the bundle was parsed; the heading accepted ' - + 'one and drew it as literal text, so a translator who wrote {name} by analogy with a messages entry shipped a ' - + 'heading that showed the braces. It is now refused at the same parse, with the words the source heading ' - + 'gets. No D2 conversion exists: a single-brace token in a translated heading drew as literal text on every ' - + 'route the heading has taken, the console overlay before the engine pick included, so only the translator ' - + 'can say whether it was meant as a hole.', - acceptanceCriteria: - 'Run objectstack validate, which parses the stack\'s translations, and parse each stored translation item: ' - + 'none is refused at a flows screens title. For each heading the refusal named, the translation now carries ' - + 'the holes of the heading it translates, or plain text; run the screen flow in that locale and confirm the ' - + 'heading shows the value, with no stray brace.', - }, - // The judgment half of `translation-per-app-settings-removed`. The D2 - // conversion deletes the group mechanically; what it cannot say in a - // `to: '(removed)'` notice is WHERE those strings were rendering and what - // renders once they are gone — and the answer differs by door. From a per-app - // bundle they only ever filled gaps the platform's own bundle left, and those - // gaps go back to the manifest's English literal. From a `translation` item - // they OVERRODE the platform's copy (the runtime-authored layer is read over - // the shipped bundles), and those keys go back to the platform's string. - // Extended from the bundle door to the item door by #19620 (ruling batch #210 - // item 2 letter B) rather than duplicated: one group, one ownership rule, one - // entry. - { - id: 'translation-per-app-settings-platform-only', - surface: - 'stack.translations[]..settings and translation.settings — the settings group on the ' - + 'per-app bundle and on the registered `translation` item', - // The group names are DERIVED from `TranslationDataSchema.shape`, never typed - // out beside it. A hand-maintained copy of a schema's key set is the construct - // that drifted to nine-of-ten in this very message, so the copy is deleted - // rather than pinned: there is one spelling of the set, and a group added to - // the per-app face reaches this sentence the day it is declared. - // `Object.keys` on a zod object shape yields the declaration order of the - // literal it was built from — the order this sentence promises the operator. - // The `translation` item declares the same groups (plus `locale` and its - // identity/envelope keys), so the one derived list answers both doors. - // A getter, not an eager template: importing the registry must not force the - // lazy translation schema at module load. - get replacement(): string { - const groups = Object.keys(TranslationDataSchema.shape); - return 'Delete the group from the per-app bundle and from every `translation` item. There is no ' - + 'application-side replacement key: settings copy is not application-authorable at either door. ' - + '`settings` is keyed by `SettingsManifest.namespace`, and only platform code declares a manifest ' - + '(`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces an ' - + 'application could ever address were the platform’s own. Platform settings copy is ' - + 'translated in the PLATFORM bundle — `@objectstack/service-settings`’s ' - + '`settingsBuiltinTranslations`, typed `PlatformTranslationData` — which is where a correction ' - + 'to a platform string belongs. An application’s own copy goes in the ' - + `${groups.length} groups the per-app bundle and the \`translation\` item still declare, in the ` - + 'order they declare them: ' - + groups.map((g) => `\`${g}\``).join(', ') - + '. Note `settingsCommon` among them: it IS on both faces, so the Settings UI shell strings an ' - + 'application may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT ' - + 'what is being removed here — only the per-namespace manifest copy under `settings` is.'; - }, - reason: - 'Not losslessly convertible, and NOT because the content was inert: what it did differs by door, ' - + 'and both effects are visible on screen. THE PER-APP BUNDLE — measured on this tree before the ' - + 'split: `AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to ' - + '`II18nService.loadTranslations`, the adapter deep-merges it into the one per-locale tree, and ' - + 'every platform plugin contributes into that same tree — so `settings` from an app bundle and ' - + '`settings` from `@objectstack/service-settings` land in one place. `resolveSettingsTitle` and ' - + 'the rest of the `resolveSettings*` family read it (`pickSettingsEntry` → ' - + "`pickData(bundle, locale)?.settings`), and so does the console's `useSettingsLabel`, which " - + 'scans every namespace carrying a `settings` branch. ORDER decides the rest, and it runs against ' - + 'the application: `AppPlugin` loads the app’s bundles in its own `start()` (kernel Phase 2), ' - + '`SettingsServicePlugin` contributes the platform’s settings translations from a `kernel:ready` ' - + 'hook (Phase 3), and `deepMerge` gives the LATER source the leaf — `AppPlugin`’s own comment ' - + 'says as much (“the platform bundles have not arrived yet at this point in the lifecycle”). So ' - + 'the platform won every key both bundles defined, and what a per-app bundle actually had was a ' - + 'GAP FILLER on a namespace it does not own: the entry rendered only where the platform bundle ' - + 'carried no string for that key and locale (the platform ships en / zh-CN / ja-JP / es-ES), ' - + 'silently, with no way for the author to tell a filled gap from an ignored override. Dropping ' - + 'it takes those gaps back to the manifest’s own literal — the `?? fallback` every ' - + '`resolveSettings*` helper ends in, which is English. THE `translation` ITEM went further: a ' - + 'stored item is not loaded into the static tree at all but into the runtime-authored layer ' - + '(`authored-translation-sync` → `replaceAuthoredTranslations`), and both i18n adapters read ' - + 'that layer OVER the shipped bundles (`deepMerge(static, authored)`), whatever order they loaded ' - + 'in. So an item’s `settings` OVERRODE the platform’s own copy for its locale — a published item ' - + 'could rewrite a platform Settings screen — which is exactly what the ownership ruling says an ' - + 'application must not do. Dropping it takes each overridden key back to the platform bundle’s ' - + 'string, and each key it had filled back to the manifest literal. A mechanical notice reading ' - + '"(removed)" conveys neither. The two bundles are separate namespaces from this major on ' - + '(ruling of 2026-09-13, letter ②: a platform bundle schema and a per-app bundle schema, ' - + '`settings` absent from the per-app one), and the item door follows the file door ' - + '(ruling of 2026-09-22, letter B: the file door and the item door are two ' - + 'authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of ' - + 'platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 ' - + 'enforce-or-remove supplied the question, not the answer — `settings` stays a LIVE platform ' - + 'key. No deprecation window: both doors refuse the key by name from this major, with the ' - + 'prescription on the rejection.', - acceptanceCriteria: - 'No application-authored face carries `settings`. `defineTranslationBundle({ : { ' - + 'settings: … } })`, a `defineStack({ translations: [...] })` entry carrying it, ' - + '`defineTranslation({ locale, settings: … })` and a `translation` item saved through the ' - + 'metadata API carrying it are all refused as an unrecognized key, and each refusal names the ' - + 'group as platform-only rather than suggesting a rename (pinned in ' - + '`packages/spec/src/system/translation.test.ts`; the metadata door answers `422 ' - + 'INVALID_METADATA`, pinned in `packages/metadata-protocol`). The platform face still accepts it: ' - + '`PlatformTranslationDataSchema.parse({ settings: … })` succeeds, `settingsBuiltinTranslations` ' - + 'still type-checks, and `GET /api/v1/i18n/translations/:locale` still declares `settings` on ' - + 'its response (`GetTranslationsResponseSchema`), because the served document is the merged ' - + 'tree. A `translation` row ALREADY STORED with `settings` is not refused — a stored row has no ' - + 'author to teach — it is converted: the runtime sync replays this conversion before merging the ' - + 'row, logs the conversion notice once, and loads the rest of the item, so its `settings` stops ' - + 'overriding at the next sync; `os migrate meta --stored --apply` persists the canonical row. ' - + 'For a deployment that WAS authoring settings copy, re-read the Settings screens in each locale ' - + 'it covered: where a `translation` item overrode a platform string, the platform’s string ' - + 'renders again; where either door filled a GAP — a namespace, key or locale the platform bundle ' - + 'does not translate — the manifest’s own literal renders, which is English. If a platform string ' - + 'is wrong or missing for your locale, correct it in the platform bundle ' - + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add ' - + 'app-side copy at either door, which is refused.', - }, - // The D3 entry of the `translation-widget-sub-caption-removed` family (#21257): - // the metric sub-caption retired at both ends by ruling C on objectui#11389, - // which reverses #5428 item 4. One D3 entry per retirement family, even when D2 - // is lossless (ruling B on #17152). The strip deletes a string nothing reads any - // more; whether that copy still belongs on the card, and where, is the author's - // call and not something the conversion can decide. - { - id: 'translation-widget-sub-caption-retired', - surface: 'translation.dashboards..widgets..subCaption — the metric sub-caption ' - + 'overlaid onto a widget\'s options.description', - replacement: 'The widget\'s one authored description, `widget.description`, rendered as the ' - + 'card-header subtitle and translated by `dashboards..widgets..description`.', - reason: 'The D2 conversion `translation-widget-sub-caption-removed` deletes `subCaption` from every ' - + 'translation bundle and stored translation item, and `translateDashboard` no longer overlays ' - + 'anything onto a widget\'s `options`. The sub-caption was the string under a metric\'s value; ' - + 'the dashboard schema never declared `options.description` and no authored widget wrote it, so ' - + 'a translated sub-caption existed only because this key put it there. What the delete drops is ' - + 'translation WORK: a translator who wrote a caption per locale meant a user to read it. The ' - + 'conversion cannot move those strings to `description`, because `description` already ' - + 'translates the card-header subtitle — a different string a widget may also carry — and only ' - + 'the author can say whether the caption\'s wording belongs in that subtitle or is no longer ' - + 'needed.', - acceptanceCriteria: 'No translation bundle or translation item carries a widget `subCaption`; the ' - + 'parse refuses it. For each metric widget whose caption a user still needs to read, the copy ' - + 'lives in the widget\'s `description` and its localized values sit under the widget\'s ' - + '`description` entry for every locale the dropped strings covered — or the author has decided ' - + 'the card-header subtitle alone is enough.', - }, - // #22343 — the D3 entry for closing the shared retry policy and, with it, for - // the build doors judging a `try_catch` node's config keys. `RetryPolicySchema` - // (`shared/retry-policy.zod.ts`) was a plain `z.object`, so it stripped a key it - // did not declare, on both of its parsers: `job.retryPolicy` and a `try_catch` - // node's `retry`. That strip was why `try_catch` was the one builtin - // `flow-builtin-node-config-undeclared-keys-refused` left on `registerFlow`'s - // descriptor walk. The schema is a `strictObject` now, and the spec's key arm - // judges `try_catch` like every other builtin. The census found no writer that - // relied on the strip. No key is removed, so there is no tombstone and no - // RETIRED_KEYS_BY_MAJOR row, and no D2 conversion exists: the platform cannot - // know what an undeclared key was meant to be. - // - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span and a table cell. - { - id: 'try-catch-and-retry-policy-undeclared-keys-refused', - surface: - 'a retry policy carrying a key it does not declare (a typo such as maxRetry, a key borrowed from another ' - + 'retry vocabulary such as baseDelayMs or maxAttempts, or a key nothing reads), wherever the policy is ' - + 'written: a job retryPolicy, and the retry block of a try_catch flow node; and a try_catch flow node whose ' - + 'config carries a key beside try, catch, errorVariable and retry that its executor contract does not ' - + 'declare. Never a key on the try or catch region object or on its nodes and edges (the region check at ' - + 'registration owns those). Reachable wherever a job or a flow is authored or stored: defineStack sources, ' - + 'defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from ' - + 'the Studio flow designer, and a flow row already sitting in sys_metadata', - replacement: - 'the key the policy declares, or no key: maxRetries (retries after the first attempt), backoffMs (the base ' - + 'delay), backoffMultiplier, maxRetryDelayMs and jitter. Rename a typo to the declared key the refusal\'s ' - + 'did-you-mean names; write a delay borrowed from another vocabulary as backoffMs or maxRetryDelayMs; ' - + 'write a count of total attempts as maxRetries one lower (maxAttempts 3 is maxRetries 2); delete a key ' - + 'nothing reads. A retryDelayMs is still answered by its own tombstone: rename it to backoffMs', - reason: - '`RetryPolicySchema` was a plain `z.object`, which strips a key it does not declare. Its defaults are ' - + 'opt-in (`maxRetries` 0, `backoffMultiplier` 1), so a stripped key falls back to "no retry" or to a flat ' - + 'delay: a `job.retryPolicy` with `maxRetry: 3` parsed, deployed and never retried, and nothing said so. On ' - + 'a `try_catch` node the strip also kept the flow parse from judging the node\'s keys: its descriptor ' - + 'closes `retry` to five keys, so `registerFlow`\'s undeclared-key walk (`validateNodeConfigKeys`) refused ' - + 'a key the contract would have accepted, and `flow-builtin-node-config-undeclared-keys-refused` left ' - + '`try_catch` to that walk. A `retry.maxRetry` typo or a `bogusKey` beside `try` therefore passed ' - + '`objectstack validate` and `objectstack compile` and was refused only when the flow registered. The ' - + 'policy is now a `strictObject`: an undeclared key is refused at parse, naming the key, with a ' - + 'did-you-mean for a near miss. Measured before closing it: every writer of either parser in this ' - + 'repository and in the pinned objectui writes only declared keys, so it is closed on the shared schema. ' - + 'The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), ' - + '`objectstack validate` and the metadata save door share (`flowNodeConfigRefusals`) now judges ' - + '`try_catch` keys like every other builtin\'s, as `node-config-refused-by-contract` anchored at the key ' - + '(`nodes.N.config.retry.maxRetry`), and the descriptor walk stands aside for it, so it keeps plugin node ' - + 'types only. The descriptor\'s declared key sets equal the contract\'s at every position the walk ' - + 'descends to, so registration refuses what it refused before. ⚠️ The one key the walk refused that the ' - + 'contract declares is the `retryDelayMs` tombstone. The `retry-policy-converged` conversion renames it ' - + 'before every door that converts first, but keeps it beside a `backoffMs` holding a different value, and ' - + 'leaves it when it is `null`. The key arm refuses what survives at `nodes.N.config.retry.retryDelayMs`, in ' - + 'the tombstone\'s own words, so registration widens nowhere. A `script` node\'s retired keys keep the ' - + 'scope they had. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ' - + '⚠️ Where such a node already sits, the whole flow is refused, as registration already refused it: from ' - + 'the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, while the flows ' - + 'beside it register; a `defineStack` source throws `StackSchemaInvalidError`; a save from Studio answers ' - + '422 naming the key. A job whose `retryPolicy` carries such a key is new to refusal (no door judged ' - + 'one before): its `defineStack` source throws `StackSchemaInvalidError` at `jobs.N.retryPolicy`, and an ' - + 'artifact carrying it is refused whole at load. ADR-0087, ADR-0031.', - acceptanceCriteria: - 'Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. ' - + 'Each refusal names the key: a job\'s at `jobs.N.retryPolicy` with the unrecognized key and its ' - + 'did-you-mean, a flow\'s at `nodes.N.config.retry.` or `nodes.N.config.` for a `try_catch` ' - + 'node, and `validateStackExpressions` phrases it as `node \'n\' (try_catch) config.retry.maxRetry`. For ' - + 'each hit rename or delete the key per the replacement. Two proofs. (1) For a stack authored in config ' - + 'files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to ' - + 'register flow` warn for it. A retry policy and a `try_catch` node whose keys the contract declares parse ' - + 'and register byte-identically to before.', - }, - // A forced local mode beside a remote to replicate from, refused at both doors - // together: the datasource contract (on mode) and the turso driver's - // constructor, in one message. The driver used to label it local and run it as - // an embedded replica anyway. The twin of - // turso-config-forced-replica-without-sync-url-refused, the other way round. A - // structured TODO, not a D2 conversion: whether the author meant an embedded - // replica of that remote or a plain local file is intent no artifact records. - { - id: 'turso-config-forced-local-with-sync-url-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of ' - + '@objectstack/driver-turso — mode local beside a non-empty syncUrl is now refused, on mode at ' - + 'authoring and at construction. The published TursoConfigSchema mirror of ' - + '@objectstack/driver-turso carries the same text for parity but declares no mode key and strips ' - + 'an authored one, so it cannot see a forced mode and still accepts the config as a replica', - replacement: - 'the configuration the author meant. An embedded replica drops mode: keep the file: url and ' - + 'syncUrl, for example url file:./data/replica.db with syncUrl naming the remote, and the url ' - + 'and syncUrl select the replica. A plain local database drops syncUrl and sync: a file: url ' - + '(or :memory:) with no syncUrl is a local database, with or without mode local', - reason: - 'A syncUrl names the remote an embedded replica syncs with, and the turso driver syncs whenever ' - + 'it is set on a local engine, whatever mode says. The triage ruling of 2026-09-29 weighed ' - + 'refusing this shape against honouring mode local by skipping the sync, and refused it: ' - + 'honouring it would ignore a declared syncUrl, the same defect with the keys swapped, and a ' - + 'loud contradiction is the author\'s to resolve. A forced mode local beside a syncUrl parsed ' - + 'clean at authoring, and the driver built it with a local transport label and then ran it as ' - + 'a replica: it synced on connect, started the sync interval and answered true to the ' - + 'sync-enabled check, exactly as the same config with no mode did (measured on the driver ' - + 'source). A declared mode the runtime ignores is the declared-but-not-enforced shape ADR-0049 ' - + 'does not ship, so the datasource contract and the constructor now refuse it together, with ' - + 'one message, which names both ways out. The sibling refusals keep their order: a forced ' - + 'local mode on a remote url or a bare path meets its url refusal first. An empty syncUrl is ' - + 'unset and is not refused. Stored datasource rows are not re-parsed when they load, so a ' - + 'stored row in this shape now fails when its driver is built: the connection service records ' - + 'it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot ' - + 'fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is ' - + 'set. Measured on this tree at the change: no example, template, published skill or ' - + 'hand-written doc authors the shape, and no host default or environment variable sets mode ' - + 'or syncUrl. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Validate every stack and re-save every turso datasource: os validate or defineStack, and a ' - + 'save or test connection through the datasource admin service, report a forced local mode ' - + 'beside a syncUrl at config.mode with both ways out. Decide per datasource whether it is an ' - + 'embedded replica (drop mode) or a local file (drop syncUrl and sync). Done when every turso ' - + 'datasource parses, the driver builds from it, and no datasource that declares mode local ' - + 'carries a syncUrl.', - }, - // A forced embedded replica with no remote to replicate from, refused at both - // doors together: the datasource contract (on mode) and the turso driver's - // constructor, in one message. The driver used to build it as a replica that - // never synced and run it as a plain local database. A structured TODO, not a - // D2 conversion: whether the author meant a replica of some remote (and which - // one) or a plain local file is intent no artifact records. - { - id: 'turso-config-forced-replica-without-sync-url-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of ' - + '@objectstack/driver-turso — mode replica with no syncUrl (or an empty one) is now refused, on ' - + 'mode at authoring and at construction. The published TursoConfigSchema mirror of ' - + '@objectstack/driver-turso carries the same text for parity but declares no mode key and strips ' - + 'an authored one, so it cannot see a forced mode and still accepts the config as a local file', - replacement: - 'the configuration the author meant. An embedded replica names the remote it replicates from: ' - + 'keep the file: url and set syncUrl to the libsql or https Turso endpoint, for example url ' - + 'file:./data/replica.db with syncUrl naming the remote. A plain local database drops mode: a ' - + 'file: url with no syncUrl and no mode is a local database', - reason: - 'An embedded replica is a local file kept in sync with the remote named in syncUrl, so a replica ' - + 'is defined by its remote. The ruling of 2026-09-28 weighed refusing this shape against ' - + 'documenting a replica with no remote as a local mode, and refused it: with no remote there is ' - + 'no replica mode to document, only a declaration nothing honours. A ' - + 'forced mode replica with no syncUrl parsed clean at authoring, and the turso driver built it as ' - + 'a replica that never synced: no sync client was created, no sync interval started, the sync ' - + 'call did nothing and the sync-enabled check answered false, while every read and write went ' - + 'to the local file (measured on the built driver). A declared mode the runtime never runs is ' - + 'the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the ' - + 'constructor now refuse it together, with one message, which names both ways out. The sibling ' - + 'refusals keep their order: a forced replica on a remote url, an in-memory url or a bare path ' - + 'meets its url refusal first, and one with sync meets the sync refusal first. Stored datasource ' - + 'rows are not re-parsed when they load, so a stored row in this shape now fails when its ' - + 'driver is built: the connection service records it as failed-degraded, a test connection ' - + 'answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that ' - + 'datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: ' - + 'no example, template, published skill or hand-written doc authors the shape, and no host ' - + 'default or environment variable sets mode. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Validate every stack and re-save every turso datasource: os validate or defineStack, and a ' - + 'save or test connection through the datasource admin service, report a forced replica with no ' - + 'syncUrl at config.mode with both ways out. Decide per datasource whether it is an embedded ' - + 'replica (set syncUrl) or a local file (drop mode). Done when every turso datasource parses, ' - + 'the driver builds from it, and every datasource that declares mode replica carries a syncUrl.', - }, - // #15680 / #15682 (stack card of #14478, maintainer ruling B: a duration key - // carries its unit in its NAME) — the D3 entry of the - // `turso-config-timeout-to-timeout-ms` family (ruling B on #17152: one D3 entry - // per retirement family, even when D2 is lossless). One conversion covers the - // authored surface of both declarations — the spec's turso contract and the - // driver package's own mirror schema, renamed in the same change. - { - id: 'turso-config-timeout-unit-in-key', - surface: 'datasource.config.timeout on the turso driver — the per-request time limit', - replacement: '`timeoutMs` — the same limit, in milliseconds, beside the sibling ' - + '`sync.intervalSeconds` that already spelled its unit.', - reason: 'The D2 conversion `turso-config-timeout-to-timeout-ms` renames `config.timeout` to ' - + '`config.timeoutMs` on every datasource whose driver resolves to turso (a stored `libsql` ' - + 'spelling included) and leaves every other driver\'s `timeout` alone; the rename is lossless ' - + 'because the key always meant milliseconds. The judgment is whether each value was written ' - + 'in that unit. Two keys above it, `sync.intervalSeconds` spelled SECONDS, so one config ' - + 'block carried both conventions, and the unit of `timeout` lived only in a description and ' - + 'a title no parse reads: `timeout: 30` meant as thirty seconds became a thirty-millisecond ' - + 'limit, short enough to fail a remote request, and the rename keeps 30. Only the author ' - + 'can say which unit they meant. Code that builds a turso driver config in TypeScript is ' - + 'outside the chain\'s reach.', - acceptanceCriteria: 'No turso datasource carries `config.timeout`; the spec contract refuses it ' - + 'with the rename. Every `timeoutMs` value is the limit the author intends in milliseconds — ' - + 'a datasource meant to allow thirty seconds ' - + 'reads `timeoutMs: 30000` — and queries against the remote database complete as they did ' - + 'before the upgrade. No code reads or writes `timeout` on a turso driver config.', - }, - // The AUTHORING half of the turso driver's constructor refusals. The driver - // refuses these configurations when it is built; this entry records that the - // datasource contract now refuses them where they are written, together with - // the one combination the driver used to build and then ignore (syncUrl under - // a forced remote mode), which the constructor refuses too since #20200. A - // structured TODO, not a D2 conversion: which way out an author wants — a - // remote database, an embedded replica on a local file, or a plain local - // file — is intent no artifact records. - { - id: 'turso-config-transport-mismatch-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'data.TursoConfig (a turso / libsql datasource.config) and the published TursoConfigSchema ' - + 'mirror of @objectstack/driver-turso — combinations of url, syncUrl, mode and timeoutMs that ' - + 'are now refused at parse: a remote url (libsql, https, http, wss, ws, any letter case) ' - + 'beside syncUrl or under a forced local or replica mode; in a local or replica mode, a url ' - + 'that is none of a file: url, :memory: or a remote url (a bare path, another scheme, ' - + ':MEMORY:, a blank url); a replica on an in-memory url; timeoutMs beside a wss or ws url in ' - + 'remote mode; and syncUrl under a forced remote mode. The driver mirror also refuses sync ' - + 'with no syncUrl, as the spec contract already did', - replacement: - 'the configuration the author meant, spelled the way the driver runs it. A remote database ' - + 'is the remote url alone (drop syncUrl and sync, and drop a forced local or replica mode or ' - + 'set it to remote). An embedded replica is a local file written as a file: url beside ' - + 'syncUrl, for example url file:./data/replica.db with syncUrl naming the remote. A local ' - + 'database is a file: url (file:./data/app.db, never the bare path ./data/app.db) or ' - + ':memory: for a throwaway one. A remote database that needs timeoutMs spells its url libsql ' - + 'or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or ' - + 'timeoutMs) and prints the spellings above', - reason: - 'Each key parsed on its own, so the contract accepted configurations the turso driver ' - + 'refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the fixes ' - + 'that stopped a remote url beside a syncUrl from writing to process memory and an ' - + 'unrecognised url scheme from falling through to an in-memory local engine) — a ' - + 'datasource published clean and then failed at boot or at test connection. One more it ' - + 'built and then ignored until the constructor was taught to refuse it as well: syncUrl ' - + 'under a forced remote ' - + 'mode, where the remote client was created without it, no sync ever ran and the sync call ' - + 'failed as not supported while the driver reported sync as enabled (measured on the built ' - + 'driver). Authoring now refuses exactly the constructor\'s refused set — the same predicates, ' - + 'a scheme matched in any letter case, the url read trimmed as both datasource loaders hand ' - + 'it over — plus that key, refused at authoring first as the declared-but-not-enforced shape ' - + 'ADR-0049 does not ship, and by the constructor too since that later fix. Nothing the ' - + 'constructor accepts is refused (when authoring first refused that key it was the one ' - + 'exception; since the constructor refuses it too there is none): ' - + 'a forced remote mode keeps its url unjudged, as the constructor does. Stored datasource ' - + 'rows are not re-parsed ' - + 'when they load, so a stored row still reaches the constructor as written; the constructor ' - + 'refuses the first four shapes there already and, since that later fix, also refuses ' - + 'syncUrl under ' - + 'a forced remote mode and sync with no syncUrl when the datasource boots. What changes here ' - + 'is that creating, testing or editing ' - + 'its config through the datasource admin service, defineStack or os validate is refused at ' - + 'the key. Measured on this tree at the change: no example, template, published skill or ' - + 'hand-written doc authors a refused combination. ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Validate every stack and re-save every turso datasource: os validate or defineStack, and a ' - + 'save or test connection through the datasource admin service, report each refused ' - + 'combination at config.url, config.syncUrl or config.timeoutMs with the ways out. Decide per ' - + 'datasource whether it is a remote database, an embedded replica on a local file, or a ' - + 'local file, and rewrite it to that spelling. Done when every turso datasource parses, the ' - + 'driver builds from it, and a replica datasource reports a file: url beside its syncUrl.', - }, - // #21855 — the rows' value ratchet for one member: an `action:group` / - // `action:menu` member's `params` takes the array form (`ActionParam[]`) only, - // unless the member's `type` is `api`, whose object `params` keeps the - // inline-action payload window (#5777) until 18. The maintainer's ruling A on - // objectui#10289 keeps `params` to one shape and declares no other value-bag - // key, and #21704's fork 5 A refused the member's `properties.params`, so a - // member has no static-values spelling and the container drops a non-array - // `params` on any other type at run time. D3 only: page-component `properties` - // is not parsed on the metadata save or load path, so a stored page is never - // refused; there is no D2 conversion, because the only home for static values - // is a different node (an `action:button`), which no rewrite can build in the - // author's place; and the census found no writer to respell. - { - id: 'ui-action-group-menu-member-params-array-only', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span. - surface: 'page action:group and action:menu components — a member of properties.actions whose type is not api, ' - + 'and whose params is not an array', - replacement: 'Write `params` as the list of inputs to collect from the user, an `ActionParam[]` array. To run an ' - + 'action with static parameter values, author it as its own `action:button` node, whose `params` object carries ' - + 'them; for a `type: \'api\'` member\'s request body write `bodyExtra`. A member that needs neither drops the key.', - reason: 'An `action:group` or `action:menu` runs each member itself and forwards an array `params` as the input ' - + 'list. It forwards any other `params` value only for a `type: \'api\'` member, as its request payload; for ' - + 'every other `type`, an absent one included, it drops the value, with a development-build warning only. The ' - + 'member declared `params` as any value, so an object `params` on such a member passed the component-props ' - + 'gate and then had no effect: no error and no static values. `params` carries one shape, the input list, and ' - + 'no second value-bag key is declared; a member\'s `properties.params` is already refused, so static parameter ' - + 'values are not part of the inline action vocabulary at all, and the action that needs them is its own ' - + '`action:button` node. The member now refuses a non-array `params` on a non-`api` type at the gate, at ' - + '`actions.N.params`, with that prescription. The `api` member\'s object `params` is unchanged. It is read ' - + 'where every page component\'s props are: the component-props gate reports the refusal as an advisory ' - + '`component-props-invalid` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a ' - + 'stored page still saves and loads, because a page component\'s `properties` is not parsed on the metadata ' - + 'save or load path. No conversion is registered: the static values belong on a different node, and the ' - + 'census found no writer. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `action:group` and `action:menu` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` finding at `properties.actions.N.params`. Each member whose action needs static ' - + 'parameter values is now its own `action:button` node, and pressing it hands the handler those values. ' - + 'Census at the time of the change: no `action:group` / `action:menu` member authors a non-array `params` on a ' - + 'non-`api` type in this repository, in objectui (at the pinned commit and on its main branch) or in the hotcrm ' - + 'application, outside objectui\'s own tests asserting that the container drops it; the cloud repository was not ' - + 'reachable.', - }, - // #21464 — each member of the `action:group` / `action:menu` page blocks' - // `actions` was an open record: the container draws and runs the member itself, - // and the spec declared none of its keys. The maintainer ruled on #21704 (fork - // 5, letter A): the measured read set, `action:button`'s keys keyed by `type`, - // with the rows' prescriptions; `outcomeMessages`, a member `className` and - // `properties.params` refused; `outcomeMessages` undeclared on all four action - // blocks. D3 only: page-component `properties` is not parsed on the metadata - // save or load path, so a stored page is never refused; and the authored census - // found no working member to respell — the refused values are objectui's probes - // of the very reads the ruling refuses (a member `className`, `outcomeMessages`, - // `properties.params`) and of the host's `autoTrigger` flag. - { - id: 'ui-action-group-menu-members-typed', - surface: 'page `action:group` and `action:menu` components — each member of `properties.actions` (whose keys ' - + 'used to pass unjudged)', - replacement: 'an inline action with `action:button`\'s keys, its executor spelled `type`: `{ name?, label?, ' - + 'icon?, type?, variant?, visible?, disabled?, tags?, params?, description?, target?, openIn?, method?, ' - + 'bodyExtra?, bodyShape?, operation?, patch?, confirmText?, successMessage?, errorMessage?, refreshAfter?, ' - + 'locations?, toast?, resultDialog?, onSuccess?, objectName? }`, plus `size?` on an `action:group` member. ' - + 'Write `actionType` as `type`, `endpoint` (and `url` / `path` / `href`) as `target`, `enabled` as `disabled` ' - + 'with the condition inverted, and `outcomeMessages` as one `successMessage`; drop a member `className`, ' - + '`properties`, `autoTrigger`, `undoable`, `recordIdField` and an `action:menu` member\'s `size`.', - reason: 'An `action:group` or `action:menu` draws and runs each member itself: it draws `label` (or `name`), ' - + '`icon`, `variant`, `tags` and, on a group\'s inline buttons, `size`; gates the member on `visible` and ' - + '`disabled`; places it by `locations`; and forwards its `type` and the rest of `action:button`\'s keys to the ' - + 'action runner. The page-component rows declared each member an open record, so a misspelled key, a ' - + 'node-style `actionType` or an `endpoint` no `api` handler reads passed the component-props gate, and the ' - + 'container drew and ran the member without it. The rows now take a closed member: `action:button`\'s keys ' - + 'by `type`, with the rows\' prescriptions; the keys the rows leave undecided — `outcomeMessages`, a member ' - + '`className`, a member `properties.params` — are refused, and `outcomeMessages` stays undeclared on all four ' - + 'action blocks as one decision. It is read where every page component\'s props are: the component-props ' - + 'gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' - + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves ' - + 'and loads, because a page component\'s `properties` is not parsed on the metadata save or load path. No ' - + 'conversion is registered: nothing on the load path refuses the shape, and the authored census found no ' - + 'working member to respell. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `action:group` and `action:menu` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.actions`. Each member ' - + 'is drawn with its label, icon and variant, and runs the executor its `type` names.', - }, - { - id: 'ui-action-undoable-unfulfillable-refused', - surface: '`action` documents declaring `undoable: true` on a shape no runtime fulfils — ' - + "`type: 'script'` (the default route) and `type: 'url'`, plus the dormant " - + "`type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update'`", - replacement: "either of the two fulfilled shapes — `operation: 'update'` with a `patch`, " - + 'where the framework runtime snapshots the prior value of every field in the merged ' - + "write bag, or `type: 'api'`, where the pinned console builds the undo envelope — or " - + 'no `undoable` at all. ⛔ NOT mechanically convertible: which of the two the author ' - + 'meant is an intent no artifact records (a `script` action with an inline handler and ' - + 'an api action calling an endpoint are different dispatches, not two spellings of ' - + 'one), and dropping the flag silently would remove an Undo the author asked for. The ' - + 'refusal names both shapes and the drop, and the author chooses', - reason: - 'The key was a plain optional boolean read by no refinement, so every combination ' - + 'parsed clean while only two of them ever produced an Undo — the declared-but-inert ' - + 'shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring ' - + "`operation: 'update'`, was MEASURED WRONG and is deliberately not what this entry " - + "records: the pinned console's two readers gate the undo envelope on " - + '`action.undoable` alone with zero reads of `action.operation`, and those same two ' - + "files are the entire recorded evidence for this package's own liveness verdict " - + '`action/undoable: live`. A blanket requirement would therefore have refused the ' - + 'published `ReassignLeadAction` skill example (`type: \'api\'` + `undoable: true`, no ' - + '`operation`) at import time, since `defineAction` IS `ActionSchema.parse`, and every ' - + 'console api action with undo along with it. So the accepted set is closed to the ' - + 'two shapes some runtime fulfils rather than to the one the framework runtime ' - + 'fulfils. Stating "`type: \'api\'` is fulfilled by the console" in the contract is ' - + 'the point, not a leak: the spec is the contract for every runtime including the ' - + 'console, and a closed table of fulfillable combinations is what the ' - + 'declared-is-delivered rule asks for.', - acceptanceCriteria: - "Every `action` document declaring `undoable: true` carries `operation: 'update'` or " - + "`type: 'api'`. Both fulfilled shapes parse byte-identically to before — the " - + 'published `ReassignLeadAction` example included — and an action with `undoable` ' - + 'absent or `false` is untouched on every type. An action declaring `undoable: true` ' - + 'on any other shape is refused with a per-key issue at `undoable` whose message names ' - + 'both fulfilling shapes and the runtime that fulfils each; the author adds the shape ' - + 'they meant or drops the flag.', - }, - // #21504 — the D3 record of the `ai:chat_window` retirement (ADR-0049 - // enforce-or-remove; triage ruling 5963897014: retire, refused BY NAME through - // `RETIRED_PAGE_COMPONENT_TYPES`, the `user:profile` precedent of #14159). There - // is no D2 half: the element's four keys left with its props def, so there is no - // key a walker could strip and leave a valid node behind, and deleting an - // authored page node is a layout decision a mechanical conversion must not make - // (the `element-filter-removed` docblock's rule). What the chain owes the - // upgrader is the delegation itself, in writing — the `element:filter` / - // `element:form` node entry's shape, for a node the parse now refuses by name. - // - // The replacement is not new prose: it is the node-level refusal message in - // `RETIRED_PAGE_COMPONENT_TYPES` (ui/page.zod.ts), so the door that refuses and - // the chain that prescribes carry one instruction. - { - id: 'ui-ai-chat-window-retired', - surface: - 'page.component.ai:chat_window — the component node, with every key its props bag ' - + 'declared (`mode`, `agentId`, `context`, `aria`), in regions, named slots and nested ' - + 'containers alike', - replacement: - 'Delete the component node and put nothing in its place: AI chat is not a page element, ' - + 'and the floating chat overlay the console mounts on every page is the supported entry ' - + 'point. To choose which platform agent the overlay answers with — what `agentId` reached ' - + 'for — set the app\'s `defaultAgent` (a platform agent: `ask`, or `build` on an authoring ' - + 'surface). `mode`, `context` and `aria` have no counterpart on the page: none of them was ' - + 'ever read, and the overlay is not configured per page', - reason: - 'No renderer for `ai:chat_window` ever shipped in objectui, framework or cloud, and none is ' - + 'wanted: the console leaves it unregistered on purpose so that a page naming it fails ' - + 'loudly, and Studio\'s page palette excludes it. So the element and its four keys were a ' - + 'capability claim nothing kept — a page that placed one validated clean and drew "Unknown ' - + 'component type" in front of an end user. Zero producers were measured in objectstack, ' - + 'cloud and hotcrm (one comment naming it as dropped). The name is now refused at ' - + '`PageComponentSchema.type`, its `ComponentPropsMap` row refuses every props bag with the ' - + 'same prescription, and the enum no longer lists it; `ai:suggestion` is unchanged. No ' - + 'conversion is registered, because the only edit is deleting the node, and which region ' - + 'closes up, holds something else, or keeps its slot is the author\'s judgment about a ' - + 'page they composed — this entry is that delegation', - acceptanceCriteria: - 'No `ai:chat_window` component remains in any page — regions, named slots and nested ' - + 'containers alike. `os validate` is clean: a remaining node is reported at its own `type` ' - + 'path with `params.retiredComponentType` naming `ai:chat_window`, so each one is named ' - + 'individually rather than as one page-level failure. An app whose removed node named an ' - + '`agentId` now names that platform agent in its `defaultAgent` instead, or leaves it unset ' - + 'for the default `ask`. Replaying the 17 → 18 chain over the edited source then reports the ' - + 'migrated stack schema-valid — `schemaValid: true` in `--json`', - }, - // The one key this close DECLARES rather than refuses is `dependsOn`, so an author - // who wrote it keeps working and now has a contract saying so. Everything else - // undeclared becomes a parse error. Registered as a structured TODO (ADR-0087 D3) - // rather than a conversion (D2) for the reason the majors-15/16/17 strictness - // entries give: an arbitrary unknown key has no mapping target, and deleting it - // automatically is the silent data loss ADR-0078 bans. - { - id: 'ui-bulk-action-param-unknown-keys-refused', - surface: - 'a list view\'s `bulkActionDefs[].params[]` entry (`BulkActionParamSchema`) — undeclared ' - + 'keys, which this shape accepted and forwarded while it was `.passthrough()`', - replacement: - 'the declared shape, now closed to match its single-record twin `ActionParamSchema`: ' - + '`{ name, type }` plus `label`, `help`, `required`, `default`, `options`, `object`, ' - + '`labelField`, `multiple`, `placeholder` and — new in this release — `dependsOn`. Every ' - + 'rejection names the surface, echoes the offending key and carries a rename or a ' - + 'prescription: the action-param spellings rename onto this surface\'s words (`helpText` → ' - + '`help`, `defaultValue` → `default`, `reference` → `object`, `displayField` → ' - + '`labelField`); the keys that belong one layer out are pointed there (`visible` and a ' - + 'capability gate belong on the DEF, `visibleWhen` belongs on an `options[]` entry, ' - + '`field` / `objectOverride` / `carryOver` / `defaultFromRow` / `requiresFeature` are ' - + 'field-backed ACTION-param contracts the bulk surface does not implement); and the ' - + 'widget-config family (`min`, `max`, `step`, `precision`, `scale`, `rows`, `accept`, ' - + '`maxSize`, and the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / ' - + '`descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate`) ' - + 'is answered with one prescription naming `FieldSchema` as the shape those keys are real ' - + 'on. `dependsOn` needs NO edit — it is declared, in the same shape the field-level key ' - + 'takes (`[\'parent\']`, or `[{ field, param }]` when the remote filter key differs).', - reason: - 'The accept was a NULL READING, and that is what makes this a contract fix rather than a ' - + 'preference. Measured against installed spec 17.4.0, three parses per schema in one ' - + 'process: `BulkActionParamSchema` accepted `zzz_nonsense_key_that_no_producer_emits_8755` ' - + 'in the SAME RUN that it accepted `dependsOn`, while `ActionParamSchema` one surface over ' - + 'refused both with `unrecognized_keys`. A shape that examines nothing cannot license ' - + 'anything — so "the bulk schema accepts it" was never evidence a key was authorable, and ' - + 'every misspelling and every invented key shipped silently. Not losslessly convertible ' - + 'for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has ' - + 'no mapping target and auto-deleting it would be the silent data loss ADR-0078 bans, so ' - + 'each occurrence needs an author\'s decision. ⚠️ Two halves of this are worth knowing ' - + 'before you upgrade. (1) `dependsOn` was already LIVE on this surface and is kept: ' - + '`bulkParamToField` does not destructure it out, so it rides the adapter\'s spread onto ' - + 'the field bag, where the option widgets read it through `useCascadingOptions` and the ' - + 'reference-bearing pickers lower it into a candidate filter; an ablation removing it from ' - + 'that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off ' - + 'the table. (2) the widget-config family rode the same spread and really was honoured by ' - + 'whichever widget read it — those keys are refused now rather than forwarded, which is the ' - + 'accepted cost of closing the shape (maintainer ruling, letter ' - + 'A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual ' - + 'spelling). ⛔ Do not read their rejection as "the renderer ignores them", and ⛔ do not ' - + 'answer it by declaring the key on the object\'s FIELD: the bulk surface has no ' - + 'field-backed param route, so that value does not reach this dialog either. A census of ' - + 'authored bulk params taken at registration time over the two repositories reachable from ' - + 'that session found ZERO carrying an undeclared key (objectstack@176b03582e: 7 param ' - + 'literals; objectui@3e4f6324f7: 3), so no in-corpus configuration is known to break. ' - + '⚠️ That census did NOT cover hotcrm, which was unreachable from the session that took it ' - + '— that leg is UNMEASURED, not clean, and an upgrader with their own metadata corpus ' - + 'should run the check below rather than inherit this result.', - acceptanceCriteria: - 'Every `bulkActionDefs[].params[]` entry in your stack parses with declared keys only — ' - + '`objectstack validate` (and `os lint` / `os build`) reports no `unrecognized_keys` under ' - + 'a `params` path. Each rejection carries its own fix; apply the rename it names, move the ' - + 'key to the layer the prescription points at, or delete metadata that was never read. ' - + '⚠️ Parsing clean is the weaker half here, because the widget-config keys were being ' - + 'HONOURED rather than dropped: for every param that carried one, re-open the bulk dialog ' - + 'and confirm the control still behaves as authored (a number param\'s bounds and step, a ' - + 'file param\'s accepted types and size cap, a picker\'s base filters and columns) — where ' - + 'it does not, the configuration is genuinely gone and the remedy is a spec issue asking ' - + 'for the key, not a local workaround. `dependsOn` needs no action: re-open one bulk dialog ' - + 'that declares it and confirm the dependent control is still gated until its parent param ' - + 'is filled, and that picking the parent still narrows the child.', - }, - { - id: 'ui-cloud-connection-widgets-unknown-keys-refused', - surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — ' - + '`properties` (any key at all: both widgets declare no props)', - replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. Neither ' - + 'widget reads any prop: the console registrations discard the schema node ' - + '(`() => `) and the components take no arguments, so there is no declared ' - + 'key to move to — a key authored on either widget configures nothing and is removed, ' - + 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component ' - + 'node, where the page runtime reads them.', - reason: - 'These were two more instances of the class already closed for `record:reference_rail` ' - + 'and then for `record:alert` / `record:quick_actions` / `record:history`, each by ' - + 'declaring a strict `ComponentPropsMap` row measured from the renderer\'s read points: ' - + 'console-registered widgets on ' - + '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the ' - + 'component type union\'s open string arm, with registered renderers but no ' - + '`ComponentPropsMap` row — so the props gate\'s dispatch (it parses `properties` ' - + 'against the type\'s row at publish and lint time, and skips a type with no row because ' - + 'the type union is open) skipped them as ' - + 'unregistered and any authored key rode through every validator in silence. The new ' - + 'rows are strict and EMPTY, measured from the renderers\' actual read points at the ' - + 'objectui pin (not from the registrations\' declared-input lists): both registrations ' - + 'ignore the component node entirely, so the widgets accept no configuration at all, ' - + 'and an authored key is now a publish-time refusal naming the surface instead of a ' - + 'silent no-op.', - acceptanceCriteria: - 'Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty ' - + '(or absent) `properties` bag and validates clean — the two plugin-shipped pages ' - + '(`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack ' - + 'validate` reports no `component-props-unknown-key` finding for these types. Any ' - + 'remaining authored key on either widget is deleted (it never configured anything), ' - + 'and behaviour that seems to need one is a renderer capability request against ' - + 'objectui, not a metadata key.', - }, - { - id: 'ui-form-field-length-malformed-refused', - surface: 'form-view field row `maxLength` / `minLength` declarations ' - + '(`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — ' - + '`0`, negative or non-integer values', - replacement: 'a positive-integer bound (>= 1), or no declaration at all ("no minimum" is ' - + 'expressed by OMITTING `minLength`, never by `minLength: 0`). The row-level key is a ' - + 'per-form override that can only NARROW what the referenced object field already ' - + 'declares (the object field surface tightened first, to a positive integer, by ' - + 'maintainer rulings) — so a malformed ' - + 'row value is deleted, and a bound that was actually wanted is re-declared as a ' - + 'positive integer, or dropped in favour of the object field\'s own authoritative ' - + 'declaration', - reason: - 'The form-field row still carried the object field\'s old shape — bare `z.number()` — ' - + 'after that surface converged (`maxLength` by the maintainer\'s 2026-08-24 ruling, ' - + '`minLength` by the 2026-08-25 one, which refused zero too: both ' - + '`z.number().int().min(1)`). The row keys are LIVE, measured in objectui: the spec ' - + 'bridge (`packages/react/src/spec-bridge/bridges/form-view.ts` mapField, which maps ' - + 'every spec key or explains why it does not, so none is dropped in silence) and ' - + 'plugin-form (`sectionFields.ts` normalizeSectionField) both copy ' - + 'them onto the runtime field, the console FormPage merges `override.maxLength ?? ' - + 'def.maxLength` onto the rendered input (fixed so that a form\'s own bound wins over ' - + 'the object\'s, as its docstring promised), and the fields package ' - + 'builds react-hook-form validation rules from `minLength`/`maxLength` — so ' - + '`maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and ' - + 'the public-form resolve route (`GET /forms/:slug`) serves the rows verbatim to ' - + 'anonymous renderers. The schema now refuses the malformed values at parse ' - + '(`z.number().int().min(1)`, ADR-0078 declared=enforced). Unlike the object-field ' - + 'twins there is NO type-conditional gate: a form row references its object field by ' - + 'name and usually omits `type`, so the referenced field\'s type is invisible at parse ' - + 'time — value shape is checkable on this surface, key placement is the object ' - + 'field\'s own schema\'s job.', - acceptanceCriteria: - 'Every form-view field row declaring `maxLength` or `minLength` carries a positive ' - + 'integer. Well-formed rows (a positive-integer bound) parse byte-identically to ' - + 'before; rows declaring neither key are untouched, and absence stays absence — no ' - + 'default materializes. A stored form view carrying a malformed row value is refused ' - + 'on its next authoring-path save with a prescriptive per-key issue; the author ' - + 'deletes the key (the object field\'s own declaration keeps governing the write ' - + 'seam) or re-declares the intended positive integer.', - }, - { - id: 'ui-form-field-precision-scale-integer-refused', - surface: 'form-view field row `precision` / `scale` declarations ' - + '(`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — ' - + 'non-integer or negative values (`scale: 2.5`, `precision: -1`)', - replacement: 'a non-negative integer digit count, or no declaration at all. The row-level ' - + 'key is a per-form override of the referenced object field\'s own declaration (that ' - + 'surface tightened first, to a non-negative integer) — a malformed row value is ' - + 'deleted, and a count ' - + 'that was actually wanted is re-declared as a non-negative integer (`scale: 2.5` was ' - + 'probably `2` or `3`)', - reason: - 'The form-field row still carried the object field\'s old shape — bare `z.number()` — ' - + 'after that surface converged on `z.number().int().min(0)` for both digit ' - + 'counts. The row keys are LIVE, measured in objectui: the spec bridge ' - + '(`form-view.ts` mapField, which maps every spec key or explains why it does not) and ' - + 'plugin-form (`sectionFields.ts`) copy ' - + 'them onto the runtime field, `ObjectForm` derives the number input\'s step from ' - + '`precision`, and the `NumberField` widget reads `scale` — so a malformed count ' - + 'flowed into rendering arithmetic (`Math.pow(10, -precision)`) with no defined ' - + 'meaning. The schema now refuses non-integer and negative values for both keys at ' - + 'parse time (ADR-0078 declared=enforced). Same no-type-gate rationale as the length ' - + 'pair entry (`ui-form-field-length-malformed-refused`): the row usually omits ' - + '`type`, so only value shape is checkable on this surface. ' - + '⚠️ The timeline view\'s `scale` enum (`TimelineConfigSchema.scale`) is a different ' - + 'surface and is unchanged; the gantt view has no `scale` key at all — its own ' - + 'granularity key is `viewMode`. ' - + '`CurrencyConfigSchema.precision` was also a different surface — retired in this ' - + 'same protocol major by `currency-config-precision-removed`, not enforced here.', - acceptanceCriteria: - 'Every form-view field row declaring `precision` or `scale` carries a non-negative ' - + 'integer. Well-formed rows (`0`, `2`, any non-negative integer) parse ' - + 'byte-identically to before; rows declaring neither key are untouched, and absence ' - + 'stays absence. A stored form view carrying a malformed row value is refused on its ' - + 'next authoring-path save with a prescriptive per-key issue; the author deletes the ' - + 'key or re-declares the integer they meant.', - }, - // The family's one D3 entry (the per-family house rule: a retirement family - // gets a semantic entry even when its D2 conversion is lossless). The D2 half - // is `form-layout-inline-grid-to-vertical`; this entry carries the one - // judgement the chain cannot make — whether a form that said `grid` wanted - // more than one column and never declared how many. - { - id: 'ui-form-layout-inline-grid-retired', - surface: - 'form `layout` — the `object-form` page component (`ObjectFormPropsSchema.layout`) and the ' - + 'form view (`FormViewSchema.layout`: `view.form`, `view.formViews.*`, a form view item\'s ' - + '`config`, a flattened form overlay): the `inline` and `grid` arms (REMOVED)', - replacement: - "`layout: 'vertical' | 'horizontal'`, or no `layout` at all ('vertical' is the renderer " - + 'default). A multi-column form is `columns` (e.g. `columns: 2`), which the renderer honours ' - + "under either layout — it was never a layout value. 'grid' → 'vertical' and 'inline' → " - + "'vertical', with any `columns` beside them kept as authored.", - reason: - 'Both surfaces declared `vertical | horizontal | inline | grid`, and no renderer ever gave ' - + '`inline` or `grid` a behaviour of its own. Measured at the objectui pin `f8a9d0fb0`: the ' - + 'simple `object-form` arm folds both to `vertical` under a comment saying exactly that, the ' - + 'drawer and modal arms pass only `vertical` / `horizontal` through, and the tabbed, split ' - + 'and wizard sub-forms hard-code `vertical` — so both values parsed green at the spec door ' - + 'and rendered as the default. The spec admitted them from two declarations (the designer ' - + 'palette and the registry inputs), never from a read. The maintainer\'s ADR-0049 family ' - + 'criterion asks whether mainstream platforms have the capability — if they do, build the ' - + 'consumer once, correctly; if they do not, retire the key — and not whether anything in this ' - + 'repository reads it. Multi-column, the capability `grid` names, is one they have, and this ' - + 'spec already carries it under another key, `columns`; `inline` is a toolbar / filter-row ' - + 'pattern, not a record-form layout. So the two arms are redundant vocabulary rather than a ' - + 'missing consumer, and are retired with no alias window. The mechanical ' - + 'rewrite is the ADR-0087 D2 conversion `form-layout-inline-grid-to-vertical` (retired from ' - + 'the load path — both enums refuse the two values at parse with a per-value prescription; ' - + 'stored rows and assembled artifacts replay clean). It is behaviour-preserving: the ' - + 'rewritten form renders exactly as before. What it cannot decide is whether an author who ' - + 'wrote `grid` without `columns` wanted a multi-column form they never got — that form ' - + 'always rendered single-column, and only the author knows whether that was the intent.', - acceptanceCriteria: - "No authored `object-form` component or form view carries `layout: 'inline' | 'grid'`; " - + '`objectstack validate` passes. For every form that was rewritten from `grid`, decide ' - + 'whether it should be multi-column: if so, author `columns` with the count you meant (the ' - + 'rewrite never invents one); if not, the rewritten `vertical` — or deleting `layout` — is ' - + 'already what the form rendered.', - }, - { - id: 'ui-form-view-predicate-features-root-refused', - surface: 'form-view predicates naming the `features.*` scope root — section-level ' - + '`visibleWhen`, field-level `visibleWhen` at any nesting depth, and per-option ' - + '`visibleWhen` authored inline in the form view (`FormViewSchema`, including the ' - + 'flattened runtime form overlay and the deprecated `visibleOn` alias spellings)', - replacement: 'gate by record state (`record.*` in runtime forms, `data.*` in metadata ' - + 'forms), or move the feature-gated surface onto an app page component or action — ' - + 'the predicate surfaces where `features.*` stays bound and stays legal. No rewrite ' - + 'is mechanical: a feature-flag gate and a record-state gate answer different ' - + 'questions, so the author chooses which surface the gate belongs on.', - reason: - 'Ruled by the maintainer on 2026-08-27 (option B — vocabulary narrowing: a form view may ' - + 'not name `features.*` in a predicate, and the authoring door refuses it loudly): one ' - + 'authored form view is served on two kinds of route, and a ' - + '`features.*` predicate got two verdicts from the same text. Inside an app ' - + '(`/apps/:appName/*`) the root resolves against the real auth-config flags; on the ' - + 'standalone form routes (`/forms/:name`, public `/f/:slug`) no app context exists, ' - + 'the root is UNBOUND, the predicate faults — and `visibleWhen`\'s fault fallback is ' - + 'visible, so the field or section a feature flag was meant to hide is shown to ' - + 'everyone (fail-open, on an access-shaped key). Measured before ruling and ' - + 're-verified at dispatch (2026-08-28): ZERO authored `features.*` form-view ' - + 'predicates exist across objectui apps/examples/content, against an 18-hit positive ' - + 'control on authored `visibleWhen` predicates — so the vocabulary is narrowed at ' - + 'the authoring door instead of building an auth-config fetch plus pre-load ' - + 'semantics on a route with zero consumers. App-context predicate surfaces ' - + '(page components, actions, bulk-action eligibility) keep `features.*` unchanged.', - acceptanceCriteria: - 'A form view carrying a predicate that names `features` in root position (dotted ' - + 'member access, index access, or the bare root — outside string literals) is ' - + 'refused at parse with a prescriptive issue naming the root, the surface, the ' - + 'fail-open reason and the ruling. Predicates on permitted roots parse unchanged, ' - + 'including member access on a record field that happens to be named `features` ' - + '(`record.features.x`). Stored form views are unaffected until their next ' - + 'authoring-path save (zero such documents were measured to exist); on refusal the ' - + 'author re-gates by record state or moves the gate to an app surface.', - }, - // #20592 — the D3 record of the accept-set narrowing the CLI's JSX page gate - // takes on when its console-manifest fallback becomes reachable (#19922): a - // project with no `sdui.manifest.json` of its own has its `kind:'html'` pages - // checked against the manifest `@objectstack/console` ships, which does not - // declare `div`. The vocabulary is ruling A on #20112 (record 5852014527): an - // html page may author the intrinsic tags its renderer registers, the published - // manifest declares that set, and `div` stays deprecated in favour of `box` — - // the console's own html compile refuses it too since objectui#10808. No D2 - // conversion and no tombstone: the tag sits inside an authored JSX string, which - // no conversion rewrites and no schema key can tombstone, so this entry is the - // migration channel beside the changeset's FROM → TO table. - { - id: 'ui-html-page-div-refused', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span. - surface: - "kind:'html' page source (and its deprecated kind:'jsx' alias) in a project with no " - + 'sdui.manifest.json of its own — the div tag, and any other tag or prop the SDUI ' - + 'component manifest shipped in @objectstack/console does not declare', - replacement: - '`box` for a plain wrapper — the one drop-in swap: the same element, your `className` ' - + 'verbatim, the same children, and no layout of its own. Reach for `card`, `flex`, ' - + '`container`, `stack` or `grid` only where you want their layout. For any other tag or ' - + 'prop the command names, a component and prop the manifest declares; the file is ' - + '`dist/sdui.manifest.json` inside `@objectstack/console`.', - reason: - 'An html page\'s source is a JSX string, not a keyed document: `objectstack migrate meta` ' - + 'rewrites stored metadata by key and cannot rewrite a tag inside authored source, so the ' - + 'move is by hand, and which wrapper keeps a page\'s layout is the author\'s call. ' - + '`objectstack validate`, `objectstack compile` (which `dev` and `start` run before they ' - + 'boot only when the artifact is missing or `--compile` is passed, and `dev`\'s watch mode ' - + 'when a watched file changes) and ' - + '`objectstack lint` check that source against an SDUI component manifest: the ' - + '`sdui.manifest.json` in the directory the command runs in, then the copy ' - + '`@objectstack/console` ships. The second lookup asked for a file the console package ' - + 'does not export, so it always failed, and a project without its own manifest had its ' - + 'html pages checked at parse level only — syntax and structure, never which components ' - + 'and props they use. It now reaches the shipped copy. That manifest declares the html ' - + 'tier\'s intrinsic tags but not `div`: the maintainer ruled (2026-09-27) that an html page ' - + 'may author the intrinsic tags its renderer registers and that the published manifest ' - + 'declares that set, while `div` stays deprecated in favour of `box`, and the console\'s ' - + 'own html-page compile refuses `div` the same way. A `div` in such a page, which used to ' - + 'pass unchecked, now fails the command with `jsx-forbidden-tag` and ' - + '`jsx-unknown-component`. A project that keeps its own `sdui.manifest.json` is checked ' - + 'against that file, as before. The runtime save door now holds pages to the same manifest: ' - + 'a server that `objectstack serve` runs (`dev` and `start` run it too) resolves the ' - + 'deployment\'s manifest the same way, from the `sdui.manifest.json` beside the served config ' - + 'and then the copy `@objectstack/console` ships, and the metadata save door compiles an html ' - + 'page\'s source against it on every publish. A `div` page saved from Studio or through the ' - + 'metadata API is refused with a `422` under the same rule ids, and a draft is stored as ' - + 'written and refused at its publish. A server that resolves no manifest says so once at boot ' - + 'and stores html pages unjudged, as before. Pages already stored are not rewritten; each is ' - + 'judged the next time it is saved.', - acceptanceCriteria: - '`objectstack validate` reports no `jsx-forbidden-tag`, `jsx-unknown-component` or ' - + '`jsx-unknown-prop` finding on any `kind:\'html\'` page and prints no ' - + '`sdui/jsx-parse-level-only` notice — that notice means the component check did not run, ' - + 'so a clean result beside it proves nothing; `objectstack compile` and `objectstack lint` ' - + 'agree. No html page source authors `div`, and in the console each rewritten page renders ' - + 'with no compile error. The platform\'s own reference is the showcase app\'s three html ' - + 'pages, which author their wrappers as `box` and pass with zero findings.', - }, - // The sibling axis of `ui-list-view-grouping-field-padded-refused` (#17360), - // which scoped this one out by name. Same defect, same refusal, one difference - // that changes the author's options: `kanban.groupByField` is REQUIRED, so a - // padded value there cannot be withdrawn by omitting the key. - { - id: 'ui-list-view-groupbyfield-padded-refused', - surface: 'list-view group-by field names — `kanban.groupByField` (`KanbanConfigSchema`, ' - + 'REQUIRED), `gantt.groupByField` and `timeline.groupByField` (`GanttConfigSchema` / ' - + '`TimelineConfigSchema`, both optional) — values carrying leading or trailing whitespace', - replacement: 'the field name written with no leading and no trailing whitespace — the same ' - + 'spelling the object declares and the server answers under. A padded value is RE-AUTHORED, ' - + 'never trimmed on the author\'s behalf: `\' stage\'` becomes `\'stage\'`. The refusal names ' - + 'the offending spelling verbatim, so the whitespace an author cannot see in an editor is ' - + 'visible in the message, next to the name to write instead.', - reason: - 'The same padded-name defect the grouping-level narrowing ' - + '(`ui-list-view-grouping-field-padded-refused`) refused, on the axis that one scoped out ' - + 'by name, and given the same refusal. All three keys were a bare `z.string()`, so a padded group-by name was valid ' - + 'authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, ' - + 'measured in objectui at `dda8f3815`: the kanban board resolves its lane as ' - + '`laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards ' - + 'by `card[laneField]`; `ObjectGantt`\'s `groupByAccessor` splits the name on `.` and walks ' - + 'the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same ' - + 'way. The server answers under the unpadded name, so every per-row lookup reads `undefined` ' - + 'and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ' - + 'ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true ' - + 'statement about the data: one giant bucket is indistinguishable from a dataset where the ' - + 'field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. ' - + '`packages/lint`\'s `validate-list-view-field-refs` already grades this position `error` for ' - + 'the same consequence, but it only runs where an app is validated against its object ' - + 'definitions; the producer accepted the value regardless. ⛔ NOT a `.trim()`: a trimming ' - + 'schema makes `\' stage\'` and `\'stage\'` silently equivalent, the consumer-tolerance ' - + 'direction the contract-first rule refuses (fix the metadata, not the renderer: ' - + 'off-spec metadata is refused where it is authored, never coerced into working) — and ' - + 'on the REQUIRED kanban key the author cannot ' - + 'withdraw the value by omitting the key, so a normalising producer would be their only ' - + 'feedback channel and it would say nothing. The narrowing is non-padded ONLY and ' - + 'deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package ' - + 'spells inline for object/field/tool NAMES: a `groupByField` is authored as a field ' - + 'REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. ' - + 'Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」).', - acceptanceCriteria: - 'Measured against the shipped schemas, not restated from the card. Every stored view whose ' - + '`kanban.groupByField`, `gantt.groupByField` or `timeline.groupByField` carries leading or ' - + 'trailing whitespace is refused on its next authoring-path save, with a `custom` issue at ' - + 'that key\'s own path (`groupByField`, or `kanban.groupByField` when the view is parsed ' - + 'whole) naming the offending spelling verbatim and the trimmed name to write instead; a ' - + 'value that is nothing but whitespace is refused with the remedy "Name the field to group ' - + 'by" rather than a trimmed name, since there is none. Refused: leading, trailing and both; ' - + 'a tab, a newline and a non-breaking space in those positions; whitespace-only. NOT refused, ' - + 'on purpose: whitespace INSIDE the name (`\'Group by field\'` parses), and the EMPTY string ' - + '(unchanged on all three keys, this narrowing covers the silent case only). Nothing is ' - + 'normalised on the way through — an accepted name arrives byte-identical, `\'owner.name\'` ' - + 'included — so a consumer proves the migration by re-saving each view and seeing either a ' - + 'refusal naming the field or a value it can compare byte-for-byte with what it wrote. Every ' - + '`groupByField` spelling in the repo at the time of the change parses unchanged: 14 distinct ' - + 'literals harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside ' - + '`node_modules`, zero of them padded, so no fixture had to be rewritten to keep the tree ' - + 'green.', - }, - { - id: 'ui-list-view-grouping-field-padded-refused', - surface: 'list-view grouping level names — `grouping.fields[].field` ' - + '(`GroupingFieldSchema`, the rows inside `ListView.grouping.fields[]`) — ' - + 'values carrying leading or trailing whitespace', - replacement: 'the field name written with no leading and no trailing whitespace — the ' - + 'same spelling the object declares and the server answers under. A padded value is ' - + 'RE-AUTHORED, never trimmed on the author\'s behalf: `\' business_unit \'` becomes ' - + '`\'business_unit\'`. The refusal names the offending spelling verbatim, so the ' - + 'whitespace an author cannot see in an editor is visible in the message.', - reason: - 'Ruled by the maintainer on 2026-09-10 (「其他同意」): ' - + 'refuse at the producer. `field` was a bare `z.string()`, so a padded grouping level ' - + 'was valid authored metadata all the way to the renderers. Measured on objectui ' - + '(M1-M11 with live controls): the projection harvester `collectGroupingFieldRefs` ' - + 'TRIMS the name when it builds `$select`, while THREE renderers bucket rows by the ' - + 'RAW name — plugin-grid `usableGroupingFields`, plugin-list ' - + '`ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. The server ' - + 'therefore answers under `business_unit` while every per-row lookup asks for ' - + '`\' business_unit \'`, reads `undefined`, and the view collapses into ONE `(empty)` ' - + 'group (grid, gallery) or ONE `Uncategorized` lane (kanban) holding every record — a ' - + 'silent wrong answer that reads as a true statement about the data, which is why ' - + 'nothing weaker than a parse refusal is honest here. ⛔ NOT a `.trim()`: a trimming ' - + 'schema makes `\' a \'` and `\'a\'` silently equivalent, the consumer-tolerance ' - + 'direction the contract-first rule refuses (fix the metadata, not the renderer: ' - + 'off-spec metadata is refused where it is authored, never coerced into working). ' - + 'objectui\'s harvester trim stays as ' - + 'defence-in-depth; nothing is removed there. The narrowing is non-padded ONLY and ' - + 'deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this ' - + 'package spells inline for object/field/tool NAMES: a grouping level is authored as ' - + 'a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree ' - + 'spelling of one. The blank name is unchanged here — it is already refused loudly ' - + 'one layer down by `compileListViewGroupQuery`\'s `grouping_field_blank`, and this ' - + 'narrowing exists for the SILENT case. Ships at once, no deprecation window ' - + '(2026-08-27 maintainer ruling 「短期不考虑渐进」).', - acceptanceCriteria: - 'Every stored list view whose `grouping.fields[].field` carries leading or trailing ' - + 'whitespace is refused on its next authoring-path save, with a per-element issue at ' - + '`grouping.fields[N].field` naming the offending spelling and the trimmed name to ' - + 'write instead. Names with no padding parse byte-identically to before — nothing is ' - + 'normalised on the way through, and a dotted relationship path stays valid. Views ' - + 'with no `grouping` block are untouched. Every `grouping.fields[].field` spelling ' - + 'in this repo at the time of the change parses unchanged: 50 literal occurrences ' - + 'under a `grouping:` key across 19 files, harvested with the TypeScript parser and ' - + 'cross-checked against 906 shape-exact `{ field, order?, collapsed? }` literals in ' - + '`packages/**`. The single harvested spelling this refuses — `\' \'` at ' - + '`view-grouping-query.test.ts` — is a NEGATIVE fixture handed straight to ' - + '`compileListViewGroupQuery` with no parse on its path, pinning that same ' - + '`grouping_field_blank` refusal; the producer now refuses it one layer earlier for ' - + 'the same reason.', - }, - { - id: 'ui-mcp-connect-agent-unknown-keys-refused', - surface: 'page `mcp:connect-agent` component — `properties` (any key at all: the widget ' - + 'declares no props)', - replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. The widget ' - + 'reads no prop: the console registration discards the schema node ' - + '(`() => `) and the component function takes no parameters — every ' - + 'value it renders comes from `/discovery`, i18n and its own state — so there is no ' - + 'declared key to move to; a key authored on it configures nothing and is removed, ' - + 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component ' - + 'node, where the page runtime reads them.', - reason: - 'This was a third instance of the class closed for the `record:*` blocks by declaring a ' - + 'strict `ComponentPropsMap` row measured from the renderer\'s read points (the strict, ' - + 'empty `cloud-connection:panel` / `marketplace:installed-list` rows closed the previous ' - + 'two): a ' - + 'console-registered widget on `@objectstack/mcp`\'s plugin-shipped Setup page, ' - + 'reachable through the component type union\'s open string arm, with a registered ' - + 'renderer but no `ComponentPropsMap` row — so the props gate\'s dispatch (it parses ' - + '`properties` against the type\'s row, and skips a type with no row because the type ' - + 'union is open) ' - + 'skipped it as unregistered, any authored key rode through every validator in ' - + 'silence, and door 3 of the canonical-envelope gate `@objectstack/mcp` was given for its ' - + 'shipped page had to carry a standing exemption for the type. The new row is strict and EMPTY, measured from the ' - + 'renderer\'s actual read points at the objectui pin (not from the registration\'s ' - + 'declared-input list): the registration ignores the component node entirely, so the ' - + 'widget accepts no configuration at all, and an authored key is now a publish-time ' - + 'refusal naming the surface instead of a silent no-op.', - acceptanceCriteria: - 'Every `mcp:connect-agent` node authors an empty (or absent) `properties` bag and ' - + 'validates clean — the plugin-shipped page (`connect_agent`) already does; ' - + '`objectstack validate` reports no `component-props-unknown-key` finding for the ' - + 'type. Any remaining authored key on the widget is deleted (it never configured ' - + 'anything), and behaviour that seems to need one is a renderer capability request ' - + 'against objectui, not a metadata key.', - }, - // The grouping prop on the two object blocks whose renderers read a grouping - // config. Typed by reference to the list view's own GroupingConfigSchema, so - // every door that carries a grouping judges it with the one schema. Deliberately - // NOT a D2 conversion: a padded field name is refused with guidance naming the - // field, never trimmed in silence. - { - id: 'ui-object-block-grouping-config-typed', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'the grouping property of the object-grid and object-kanban page blocks ' - + "(ComponentPropsMap['object-grid' | 'object-kanban'].grouping), which was z.unknown and " - + 'therefore accepted any value: a padded field name such as ' - + "{ fields: [{ field: ' business_unit ' }] }, a number, a bare field-name string, an " - + 'empty fields list, or keys the grouping config does not declare', - replacement: - 'the grouping config a list view carries, `GroupingConfigSchema` from ' - + '`@objectstack/spec/ui`: `{ fields: [{ field, order?, collapsed? }, ...] }` with at least ' - + 'one entry, each `field` naming the record field exactly as it is stored, with no leading ' - + 'or trailing whitespace, `order` one of `asc` / `desc` and `collapsed` a boolean. A padded ' - + "name is rewritten unpadded (`' business_unit '` becomes `'business_unit'`); a bare " - + "string `'business_unit'` becomes `{ fields: [{ field: 'business_unit' }] }`; an empty " - + '`fields` list, a number, or any other value is deleted, since it never grouped anything. ' - + 'On `object-kanban`, `swimlaneField` still wins when both are authored, and deleting ' - + '`grouping` is the whole migration there when `swimlaneField` is set', - reason: - 'Both blocks\' renderers read the list view\'s grouping shape and nothing else: the grid ' - + 'groups its rows by every `grouping.fields[i].field` (its server-side group header query ' - + 'and its row projection) and reads `order` and `collapsed` per level, and the kanban board ' - + 'takes `grouping.fields[0].field` as its swimlane field when no `swimlaneField` is ' - + 'authored, looking that raw name up on every card. The list view has refused a padded ' - + 'grouping field name since protocol 17.5, and a list view\'s `grouping` is a closed shape; ' - + 'these two doors declared the same prop as `z.unknown`, so the same value that list view ' - + 'refuses validated green here and rendered wrong with no error — the grid showed one ' - + '`(empty)` group holding every row, the board one swimlane holding every card. The prop is ' - + 'kept, not retired: the board\'s fallback is a live reader of the grouping config. ' - + 'The rewrite is left to the author on purpose: a trimming rule would make a padded and an ' - + 'unpadded name silently equivalent, which is the consumer tolerance the contract refuses, ' - + 'and a bare string, a number or an empty list has no mapping that says what grouping was ' - + 'meant. Metadata AT REST is left exactly as stored — `properties` on a page component is ' - + 'not parsed on the save path, so a stored page keeps loading and renders as it does today; ' - + 'the component-props gate reports such a value as an advisory `component-props-invalid` ' - + 'finding at the offending path on `os validate`, `os build` and `os lint`, a padded name ' - + 'with the received value and the unpadded name to write. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'Every `object-grid` and `object-kanban` node in your pages either omits `grouping` or ' - + 'carries `{ fields: [...] }` with at least one entry whose `field` is the unpadded stored ' - + 'name. `os validate` reports no `component-props-invalid` finding under ' - + '`properties.grouping` for these blocks. A well-formed grouping parses byte-identically to ' - + 'before when `order` and `collapsed` are spelled out; a short entry `{ field }` parses clean ' - + 'and gains the list view\'s defaults (`order: asc`, `collapsed: false`), which is how the ' - + 'grid already read it. After the rewrite, a grid that showed one `(empty)` group shows one ' - + 'group per value of that field, and a board that showed one swimlane shows one swimlane per value — ' - + 'check that this is the grouping you meant.', - }, - // #21464 — the `object-form` page block's `customFields` was `z.unknown()`: - // each member is objectui's runtime form field, which the spec did not declare, - // and objectui's own declaration is open and spells eight of its members in - // snake_case. The maintainer ruled on #21704 (fork 2, letter B) that the spec - // declares a closed runtime form field of the members the form draws, in - // camelCase, keyed by `name`. D3 only: page-component `properties` is not - // parsed on the metadata save or load path, so a stored page is never refused; - // and the authored census, with every inline option list evaluated, found no - // working member to respell — the refused values are a type-level test's - // `visibleOn` and a fixture pinning that an inline `defaultValue` seeds - // nothing. The shipped object-manager dialog's options (`{ label: 'Box', value: - // 'Box' }`, …) parse because an inline option's `value` is a runtime value, not - // a stored field's identifier; typed as the form view's option, they did not. - { - id: 'ui-object-form-custom-fields-typed', - surface: 'page `object-form` components — `properties.customFields` (which used to accept any value)', - replacement: 'a list of closed inline form fields `{ name, label?, type?, required?, options?, … }` — the ' - + 'members the form draws, in camelCase, each option `{ label, value, description?, visibleWhen? }` with ' - + '`value` a string, a number or a boolean. Write a `visibleOn` (or a legacy `condition`) as ' - + '`visibleWhen`, move a member\'s `defaultValue` into the block\'s `initialValues`, drop `id`, and leave ' - + 'the `grid` widget\'s snake_case keys (`min_rows`, `allow_add`, …) out until the widget reads a camelCase ' - + 'spelling.', - reason: 'The form merges `customFields` over the fields it generates from the object\'s metadata — a member ' - + 'naming a declared field replaces that field\'s whole definition, any other is added — and draws each ' - + 'member as it was written, handing it to the field widget as its metadata. The page-component row ' - + 'declared it `z.unknown()`, so `42`, a member with no `name`, or a misspelled member passed the ' - + 'component-props gate, and the form drew the field without it. The row now takes a closed runtime form ' - + 'field of the members the form draws, keyed by `name`, each typed to its read — by reference where this ' - + 'package already declares the member (the object field\'s metadata members, the evaluated predicates). ' - + 'An option is the runtime option the form\'s option controls draw — `label`, `value`, `description`, ' - + '`visibleWhen` — and its `value` is any string, number or boolean, kept as written: an inline field binds ' - + 'no object column, so a stored field\'s lowercase identifier rule does not apply to it. It is read where ' - + 'every page component\'s props are: the component-props gate ' - + 'reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' - + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' - + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' - + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, ' - + 'with every inline option list evaluated, found no working member to respell. Deployed metadata NOT ' - + 'MEASURED.', - acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.customFields`. ' - + 'Each form draws every inline field with the label, type and rules its member names.', - }, - // #21464 — the top-level `fields` of the `object-form` and - // `object-master-detail-form` page blocks was `z.array(z.unknown())`, held - // while the form drew a `{ name }` field entry its own page-builder guide - // taught. objectstack-ai/objectui#11550 retired that entry from every authoring - // face (the form still draws a STORED one, by its name, as tolerance), so both - // rows now take field names — objectui's own declaration of the member — and - // an object entry is refused with what to write instead. D3 only: page- - // component `properties` is not parsed on the metadata save or load path, so a - // stored page is never refused; and the authored census found no authored - // value to respell — the refused values are fixtures probing the stored read, - // the console warning and objectui's own refusal. - { - id: 'ui-object-form-fields-names-typed', - surface: 'page `object-form` and `object-master-detail-form` components — `properties.fields` (whose ' - + 'entries used to accept any value)', - replacement: 'a list of bare field names, in the order the form draws them. Write a `{ name: \'email\' }` ' - + 'entry as `\'email\'` — the form only ever drew its name — and move a `label` or `required` override ' - + 'onto a `sections[].fields` entry (`type` is always the object field\'s); write a `{ field: \'email\' }` ' - + 'entry as `\'email\'`, or move it into a section\'s `fields`, the vocabulary it belongs to.', - reason: 'The form reads its top-level `fields` as the names of the fields to draw, in order, selecting ' - + 'from the object\'s fields and from `customFields`; the master-detail form hands its own to the parent ' - + 'form verbatim. objectui declares the member `string[]`, but the page-component rows declared it ' - + '`z.array(z.unknown())` while the form drew a `{ name }` entry by that name — the shape objectui\'s ' - + 'page-builder guide taught, with a `label`, `type` and `required` the form silently dropped. ' - + 'objectui has since retired that entry from every authoring face — the guide and its fixtures name the ' - + 'fields — keeping only a STORED one readable; so both rows now take field names, and refuse an object entry with what to write instead: a ' - + '`{ name }` entry is its bare name, and a `{ field }` entry — the `sections[].fields` vocabulary, which ' - + 'the form skips at the top level with a console warning — is its bare name or belongs in a section. It is ' - + 'read where every page component\'s props are: the component-props gate reports a refused value as an ' - + 'advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and ' - + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s `properties` is ' - + 'not parsed on the metadata save or load path. No conversion is registered: nothing on the load path ' - + 'refuses the shape, the form already draws a stored `{ name }` entry by its name, and an override written ' - + 'beside it has no rewrite that keeps it — moving it onto a section is the judgment this entry leaves to ' - + 'the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-form` and `object-master-detail-form` node validates: `objectstack ' - + 'validate` reports no `component-props-invalid` finding under `properties.fields`. Each form draws the ' - + 'fields its list names, in that order, with any per-form label or required override taken from its ' - + 'section entry.', - }, - // #21464 — four members of the `object-form` page block were `z.unknown()` - // although the form reads each with a fixed shape, so an off-shape value passed - // the component-props gate and the form fell back or ignored it in silence. The - // row now takes the form view's own `submitBehavior` by reference and the - // measured shape for `contentLayout`, `navigateOnSuccess` and `mobile`. The - // form's `fields` and `sections` and the master-detail form's two are held at - // `z.unknown()` (the form draws a `{ name }` field entry and an inline runtime - // field inside a section, which the typed shapes would refuse), and - // `customFields` waits for the spec to declare objectui's runtime form field. - // D3 only: page-component `properties` is not parsed on the metadata save or - // load path, so a stored page is never refused; an off-shape value has no - // rewrite that says what the author meant; and the authored census found no - // authored value to respell — the refused values are fixtures probing that the - // form refuses them. - { - id: 'ui-object-form-members-typed', - surface: 'page `object-form` components — `properties.contentLayout`, `.submitBehavior`, ' - + '`.navigateOnSuccess` and `.mobile` (which used to accept any value)', - replacement: 'the shape the form reads: `contentLayout` `\'simple\'` or `\'tabbed\'`; `submitBehavior` the ' - + 'form view\'s own block — `{ kind: \'thank-you\', title?, message? }`, `{ kind: \'redirect\', url, ' - + 'delayMs? }` with a relative `url`, `{ kind: \'continue\' }` or `{ kind: \'next-record\' }`; ' - + '`navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, ' - + 'stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `\'auto\'` and the two ' - + 'counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` ' - + 'destination to a relative path; write `heading` as `title`.', - reason: 'The form reads these members with one shape, and the page-component row declared them ' - + '`z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one ' - + 'with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; ' - + 'a misspelled `contentLayout` such as `\'tabs\'` stacked the sections; a `navigateOnSuccess` that is not a ' - + 'string threw after the record was written, so the submit reported a failure; and a `mobile` member it ' - + 'does not read, or a `stepper` outside `true` / `false` / `\'auto\'`, was ignored. The row now takes ' - + 'the form view\'s own `submitBehavior` by reference — the block the renderers already judge a redirect ' - + '`url` through — so one value is judged the same way on the form view and the block, and the measured ' - + 'shape for the other three. The form\'s `fields` and `sections` and the master-detail form\'s two stay ' - + 'open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, ' - + 'which the typed shapes would refuse; and `customFields` stays open until the spec declares the ' - + 'runtime form field its entries are. It is read where every page component\'s props are: the ' - + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' - + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' - + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' - + '`properties` is not parsed on the metadata save or load path. No conversion is registered: nothing ' - + 'on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the ' - + 'form shows today and honours what the author wrote — which is the judgment this entry leaves to the ' - + 'upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under the four members\' paths. ' - + 'Each form that set one of them now shows it: the post-submit behaviour it names, the modal\'s tabbed ' - + 'sections, the navigation after a save, and the phone presentation.', - }, - // #21464 — the `sections` of the `object-form` and `object-master-detail-form` - // page blocks were `z.array(z.unknown())`: a section's `fields` draws an inline - // runtime form field beside a name and the form view's `{ field }` entry, which - // the stored form view's section refuses. The maintainer ruled on #21704 (fork - // 3, letter B) a page-block section shape of its own — the form view's section - // keys plus the three entry arms the form reads, canonical spellings only — and - // the stored form view is unchanged. D3 only: page-component `properties` is - // not parsed on the metadata save or load path, so a stored page is never - // refused; and the authored census, with every inline option list evaluated, - // found no working section to respell — the refused values are the two probes - // in objectui's `formSectionGroupReference-7051.test.tsx` (a section declaring - // neither `fields` nor `group`, and a group-owned `label` / `collapsible` - // beside `group`), shapes that test says this door refuses at parse. - { - id: 'ui-object-form-sections-typed', - surface: 'page `object-form` and `object-master-detail-form` components — `properties.sections` (whose ' - + 'entries used to accept any value)', - replacement: 'closed sections `{ name?, label?, description?, collapsible?, collapsed?, visibleWhen?, ' - + 'columns?, pane?, fields }` (or `{ group, columns?, pane? }`), each `fields` entry a field name, the form ' - + 'view\'s `{ field, … }` entry or an inline form field `{ name, type, … }`. Write a section or field ' - + '`visibleOn` as `visibleWhen`, a string `columns: \'2\'` as the number `2`, and a section `label` (or a ' - + 'field entry\'s `label` / `placeholder` / `helpText`) as a plain string.', - reason: 'The form reads a section\'s heading, collapse pair, `visibleWhen`, `columns`, `pane`, `group` and ' - + '`fields` — the key set of the form view\'s section — and draws three kinds of field entry: a name, the ' - + 'form view\'s `{ field }` entry overriding that object field, and an inline runtime form field drawn as ' - + 'it stands. The page-component rows declared each section `z.unknown()`, so a misspelled key passed the ' - + 'component-props gate and the form drew the section without it; a form view\'s deprecated `visibleOn` ' - + 'and string `columns`, which a form view folds at parse, reached the form raw — a page block\'s ' - + '`properties` is never parsed on the way — and were dropped. Both rows now take one section shape of ' - + 'their own, the stored form view unchanged: the form view\'s section keys plus the three entry arms, ' - + 'canonical spellings only, a label a plain string because the form draws it as it stands, and the form ' - + 'view\'s group-reference rule. It is read where every page component\'s props are: the component-props ' - + 'gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' - + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' - + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load ' - + 'path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, ' - + 'with every inline option list evaluated, found no working section to respell. Deployed metadata NOT ' - + 'MEASURED.', - acceptanceCriteria: 'Every `object-form` and `object-master-detail-form` node validates: `objectstack ' - + 'validate` reports no `component-props-invalid` / `component-props-unknown-key` finding under ' - + '`properties.sections`. Each form draws every section with the heading, visibility and columns it ' - + 'names, and every entry in it.', - }, - // #21464 — the `object-gantt` page block's `markers` was `z.array(z.unknown())`: - // its element contract lived only in objectui, so a marker with no `date`, a - // numeric `date` or a misspelled member passed the component-props gate, and - // the chart drew no line, or drew it unlabelled and in the default colour. The - // spec now declares objectui's own authoring declaration of a marker, - // `{ date, label?, color? }` with `date` a string, and the row takes it. D3 - // only: page-component `properties` is not parsed on the metadata save or load - // path, so a stored page is never refused; a misspelled member has no rewrite - // that says which of the three the author meant; and the authored census found - // no authored value to respell — the one refused value is objectui's own - // compile-time refusal probe. - { - id: 'ui-object-gantt-markers-typed', - surface: 'page `object-gantt` components — `properties.markers` (whose entries used to accept any value)', - replacement: 'a list of `{ date, label?, color? }`: `date` an ISO date or date-time string (required), `label` ' - + 'the text drawn against the line, `color` any CSS colour. Write a marker `title`, `text` or `name` as ' - + '`label`, and a `colour` as `color`; give every marker a string `date`.', - reason: 'The gantt reads each marker with one shape — `date` places the line, and a date that does not ' - + 'parse or falls outside the drawn range draws none; `label` is drawn against it; `color` paints it, the ' - + 'theme\'s primary colour when absent — and the page-component row declared the entries `z.unknown()`, ' - + 'because that contract was objectui\'s alone. So a marker with no `date`, a numeric `date` or a ' - + 'misspelled member passed the component-props gate, and the chart drew no line, or drew it with no label ' - + 'and in the default colour. The spec now declares objectui\'s own authoring declaration of a marker, ' - + '`{ date, label?, color? }` with `date` a string (authored metadata is JSON, which cannot carry a ' - + '`Date`), closed as every element shape on that map is. It is read where every page component\'s props ' - + 'are: the component-props gate reports a refused value as an advisory `component-props-invalid` / ' - + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack ' - + 'lint`, and a stored page still saves and loads, because a page component\'s `properties` is not parsed ' - + 'on the metadata save or load path. No conversion is registered: nothing on the load path refuses the ' - + 'shape, and a misspelled member has no rewrite that says which member the author meant — which is the ' - + 'judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-gantt` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.markers`. Each ' - + 'gantt that sets markers draws one line per marker whose date falls in the drawn range, with the label ' - + 'and colour written.', - }, - // #21464 — the `object-grid` page block's `columns` was `z.array(z.unknown())` - // although the grid reads it with one shape, so a column keyed `accessorKey` / - // `header` / `name`, a column with no `field`, a mixed list or a key the grid - // never reads off a column passed the component-props gate, and the grid drew no - // column or ignored the key, in silence. Stage 2 held it because the grid's group - // headers drew a column's `options`, which the list view's column entry does not - // declare; objectui retired that read (the group-header labels come from the - // object field's `options` only), so the member takes the list view's own - // `columns` by reference — the reference it was held from. D3 only: - // page-component `properties` is not parsed on the metadata save or load path, so - // a stored page is never refused; a refused column has no rewrite that both keeps - // what the grid draws today and honours what the author wrote; and the authored - // census found no authored value to respell — every refused value is a fixture - // whose refused key the grid does not draw. - { - id: 'ui-object-grid-columns-typed', - surface: 'page `object-grid` components — `properties.columns` (whose entries used to accept any value)', - replacement: 'the list view\'s own `columns`: all field-name strings, or all column entries `{ field, label?, ' - + 'width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }`. ' - + 'Respell a column keyed `accessorKey` / `header` or `name` as `field` / `label`; write a list as all strings ' - + 'or all entries, never a mix; delete a column key the entry does not declare (`editable`, `options`, ' - + '`reference`, `currency`, `precision`, …) — inline editing is the grid\'s own `editable`, and option ' - + 'labels, relational metadata and number formats are the object field\'s.', - reason: 'The grid reads `columns` with one shape — all field-name strings or all column entries, decided by ' - + 'the first entry, drawing only an entry with a string `field` and reading the column entry\'s own members ' - + 'off it — and the page-component row declared it `z.array(z.unknown())`, so any entry passed the ' - + 'component-props gate and the grid answered an off-shape one in silence: a column keyed `accessorKey` / ' - + '`header` or `name`, or one with no `field`, drew no column, a mixed list lost every entry the first one ' - + 'did not match, and a key the grid never reads off a column (`editable`, `options`, `reference`) was ' - + 'ignored. The member was held while the grid\'s group headers drew a column\'s `options` ahead of the ' - + 'field\'s; the renderer has since retired that read and takes the labels from the object field only, so ' - + 'the row takes the list view\'s own `columns` by reference — the column entry a list view already ' - + 'refuses an undeclared key on. It is read where every page component\'s props are: the component-props ' - + 'gate reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` ' - + 'finding on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' - + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save or load path. ' - + 'No conversion is registered: nothing on the load path refuses the shape, and a refused column has no ' - + 'rewrite that both keeps what the grid draws today and honours what the author wrote — which is the ' - + 'judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.columns`. Each grid ' - + 'draws every authored column: one per entry, headed by its `label` or the field\'s own, in the order ' - + 'written.', - }, - // #21229 — an `object-grid` page block's `exportOptions` was `z.unknown()`, so a - // bare format array (the list view's legacy spelling, which the list view lifts - // to `{ formats }`) was accepted on the grid, whose renderer reads - // `exportOptions.formats` and lifts nothing. The row now takes the list view's - // five-member export options OBJECT by identity, not the list view's union. D3 - // only: page-component `properties` is not parsed on the metadata save or load - // path, so a stored page is never refused and there is no load-path refusal for - // a conversion to pre-empt; the bare array never worked here, and lifting it - // would change the export menu a deployed grid shows today; the authored census - // found nothing to respell. - { - id: 'ui-object-grid-export-options-closed', - surface: 'page `object-grid` components — `properties.exportOptions` (which used to accept any value)', - replacement: 'the export options object a list view\'s `exportOptions` declares: `{ formats?, ' - + 'maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from ' - + '`csv`, `xlsx` and `json`, `maxRecords` a non-negative integer, and `includeHeaders` / ' - + '`streaming` booleans. Where a bare format array was written, write `{ formats: [...] }` to ' - + 'offer the formats you listed — the grid will now offer exactly those — or `{}` to keep the ' - + 'csv/json default the grid has been offering. Delete `pdf` from `formats`, and any key the ' - + 'object does not declare; delete an `exportOptions: null` (it never enabled the menu).', - reason: 'The grid reads one export options block — `exportOptions.formats`, `.maxRecords`, ' - + '`.includeHeaders`, `.fileNamePrefix` and `.streaming` — the block a list view declares, ' - + 'but the page-component row declared the key `z.unknown()`, so any value passed the ' - + 'component-props gate. The trap was the list view\'s legacy spelling: a bare format array is ' - + 'legal on a list view, which lifts it to `{ formats }` at parse, and was accepted on the grid, ' - + 'which lifts nothing — the export menu appeared, offering the csv/json default, and the ' - + 'author\'s list was dropped without a report. The row now takes the list view\'s export ' - + 'options object itself rather than its union, so a legacy spelling does not spread to a ' - + 'surface that never read it: a bare array is refused with the object form named, a format ' - + 'outside the enum is refused at its index (`pdf` with its retirement text), and a key the ' - + 'object does not declare is named. It is read where every page component\'s props are: the ' - + 'component-props gate reports these as an advisory `component-props-invalid` / ' - + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' - + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' - + '`properties` is not parsed on the metadata save or load path. No conversion is registered: ' - + 'nothing on the load path refuses the shape; a bare array has no rewrite that both keeps ' - + 'what the grid shows today and honours what the author wrote, which is the judgment this ' - + 'entry leaves to the upgrader; and the authored census found nothing to respell. Population ' - + 'measured at the change, on origin/main f148852752: zero `object-grid` blocks authoring ' - + '`exportOptions` in the examples, the package fixtures, the documentation and the published ' - + 'skills, against ten authored `object-grid` blocks through the same matcher (nine in ' - + 'TypeScript, one in a YAML documentation example) and four list-view `exportOptions` ' - + 'authorings as the key\'s control. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding on a ' - + '`properties.exportOptions` path. Every `exportOptions` on an `object-grid` is an object ' - + 'carrying only the five declared keys, with every `formats` entry `csv`, `xlsx` or `json`, ' - + 'and the grid\'s export menu offers the declared formats the active export path delivers ' - + '(`xlsx` on the server stream only).', - }, - // #21464 — eight list members of the `object-grid`, `object-kanban` and - // `object-calendar` page blocks were `z.unknown()` (an array of it for the - // lists) although each renderer reads them with a fixed shape, so an off-shape - // value passed the component-props gate and the block dropped or substituted it - // in silence. The rows now take the list view's own members by reference where - // a list view declares one, and the measured shape otherwise. The grid's - // `columns` is held at `z.unknown()`: the grid draws a column's `options`, - // which the list view's column entry does not declare. D3 only: - // page-component `properties` is not parsed on the metadata save or load path, - // so a stored page is never refused; an off-shape value has no rewrite that - // says what the author meant; and the authored census found no authored value - // to respell — the refused values are fixtures probing that the renderer drops - // them. - { - id: 'ui-object-grid-kanban-calendar-list-members-typed', - surface: 'page `object-grid` components — `properties.fields`, `.selection`, `.selectable`, ' - + '`.rowActions`, `.bulkActions` and `.batchActions`; page `object-kanban` components — ' - + '`properties.columns`; page `object-calendar` components — `properties.calendar` (which used to ' - + 'accept any value)', - replacement: 'the shape each block reads, the list view\'s own where it has one: `object-grid` `fields` ' - + 'field-name strings; `selection` `{ type }` with `none` / `single` / `multiple`; `selectable` `true`, ' - + '`false`, `\'single\'` or `\'multiple\'`; `rowActions`, `bulkActions` and `batchActions` action-name ' - + 'strings. `object-kanban` `columns` all lanes `{ id, title, cards?, limit?, className?, collapsed? }` ' - + 'or all bare value strings (never mixed), a lane `id` a string. `object-calendar` `calendar` ' - + '`{ startDateField, endDateField?, titleField?, colorField?, allDayField? }`. Move an object entry ' - + 'of `fields` to `columns`; move a `{ name }` entry of `bulkActions` to `bulkActionDefs` or write the ' - + 'bare name; style a lane with `className` instead of `color`; rename `dateField` / `endField` to ' - + '`startDateField` / `endDateField`.', - reason: 'Each renderer reads these members with one shape, and the page-component rows declared them ' - + '`z.unknown()`, so any value passed the component-props gate and the block answered an off-shape one ' - + 'with a silent default: an object entry of `fields` named no field; a `{ name }` entry of ' - + '`bulkActions` was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept ' - + 'its records into the trailing lane; and a calendar block without `startDateField` placed no event. The rows ' - + 'now take the list view\'s own `selection`, `rowActions`, `bulkActions` (for `batchActions` ' - + 'too, the spelling the grid reads first) and `calendar` members by reference, and the measured shape ' - + 'for the grid\'s `fields` and `selectable` and the kanban lane, so one value is judged the same way ' - + 'on every door that carries it. The grid\'s `columns` is not narrowed: its group-header labels read ' - + 'an authored column\'s `options`, which the list view\'s column entry does not declare, so it stays ' - + 'open until that read is ruled. It is read where every page component\'s props are: the ' - + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' - + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' - + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' - + '`properties` is not parsed on the metadata save or load path. No conversion is registered: nothing ' - + 'on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the ' - + 'block shows today and honours what the author wrote — which is the judgment this entry leaves to ' - + 'the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-grid`, `object-kanban` and `object-calendar` node validates: ' - + '`objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` ' - + 'finding under the eight members\' paths. Each block that set one of them now shows it: the grid\'s ' - + 'field fallback, the selection mode, the row and bulk actions, the kanban lanes with their records, and ' - + 'the calendar events placed by `startDateField`.', - }, - { - id: 'ui-object-grid-page-size-positive-integer-refused', - surface: '`object-grid` page-component page sizes ' - + "(`ComponentPropsMap['object-grid']` — `pagination.pageSize`, each " - + '`pagination.pageSizeOptions[]` entry, and the flat `pageSize` shorthand) — ' - + 'zero, negative and non-integer values (`pagination: { pageSize: 0 }`, ' - + '`pageSize: 25.5`)', - replacement: 'a positive integer, or no declaration at all. A page size of `0` has no ' - + 'defined meaning on this surface and never had one: delete the key to take the ' - + "renderer's own default, or write the page size that was meant (`pageSize: 0` " - + 'authored to mean "no paging" is `showPagination: false` with no `pagination` bag, ' - + "since the bag's PRESENCE is what enables paging)", - reason: - 'This door still carried the shape it was given when the `object-*` blocks first got ' - + '`ComponentPropsMap` rows measured from their read points — `pagination: z.unknown()` ' - + 'and `pageSize: z.number()` — after the view arm converged on ' - + '`z.number().int().positive()`. So the SAME authored member carried two accept sets ' - + 'and renderers read the looser one: `PaginationConfigSchema` (`view.zod.ts`) refuses ' - + '`pageSize: 0` and pins that refusal by name, and every other `pageSize` the package ' - + 'declares is bounded with its own throwing pin (`kernel/metadata-plugin.zod.ts`, ' - + '`marketplace/marketplace.zod.ts`) — the component arm was the only one that ' - + 'accepted `0`. The value is LIVE: an objectui grid measurement found that an authored ' - + '`pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as `$top: 0` ' - + 'and rendered ZERO ROWS, with no grouping needed to trigger it, and it reached the ' - + 'renderer through this arm. objectui\'s grid plugin repaired the consumer half — it ' - + 'now refuses a non-positive page size at all three read points (one resolver, ' - + 'fail-soft, one loud diagnostic); this is the declaration half, ' - + 'and it is not a prerequisite for that repair. ' - + '⚠️ The `pagination` bag itself stays OPEN (`z.looseObject`): only the two members ' - + 'whose value is a page size are bounded, and sibling keys parse and pass through ' - + 'exactly as before. `PaginationConfigSchema` on the view arm is a closed shape and ' - + 'is unchanged by this entry.', - acceptanceCriteria: - 'Every `object-grid` node declaring a page size — inside `pagination` or through the ' - + 'flat shorthand — carries a positive integer. Well-formed values (`10`, `25`, `50`) ' - + 'parse byte-identically to before, a `pagination` bag carrying sibling keys parses ' - + 'and keeps them, and absence stays absence. A stored page whose `object-grid` node ' - + 'carries `pagination: { pageSize: 0 }` still saves and loads — `properties` on a page ' - + 'component is not parsed on the metadata save path — and the component-props gate ' - + 'reports it as an advisory `component-props-invalid` finding at `pagination.pageSize` ' - + 'on `os validate`, `os build` and `os lint`; a `pageSizeOptions` entry and the flat ' - + '`pageSize` shorthand are reported the same way at their own paths. The author deletes ' - + 'the key or writes the page size they meant, and `os validate` then reports no ' - + '`component-props-invalid` finding for that node.', - }, - // #21445 — seven members of an `object-grid` page block's props were - // `z.unknown()` (`bulkActionDefs` an array of it) although the grid reads each - // with a fixed shape, so an off-shape value passed every door and the grid - // substituted a default or dropped it in silence. The row now takes the shape - // each read point takes. D3 only: page-component `properties` is not parsed on - // the metadata save or load path, so a stored page is never refused and there is - // no load-path refusal for a conversion to pre-empt; an off-shape value has no - // rewrite that says what the author meant; and the authored census found - // nothing in either repository's corpora to respell. - { - id: 'ui-object-grid-row-members-typed', - surface: 'page `object-grid` components — `properties.rowHeight`, `.rowColor`, `.navigation`, ' - + '`.conditionalFormatting`, `.bulkActionDefs`, `.aggregations` and `.operations` (which used to ' - + 'accept any value)', - replacement: 'the shape the grid reads, the list view\'s own where it has one: `rowHeight` one of ' - + '`compact` / `short` / `medium` / `tall` / `extra_tall`; `rowColor` `{ field, colors }`; ' - + '`navigation` `{ mode?, size?, openNewTab?, preventNavigation? }`; `conditionalFormatting` ' - + '`[{ condition, style }]` with a CEL `condition` and a CSS `style` map; `bulkActionDefs` the ' - + 'list view\'s bulk-action defs; `aggregations` `[{ field, type }]` with `type` one of `count`, ' - + '`sum`, `avg`, `min`, `max`, `count_distinct`; `operations` `{ create?, update?, delete?, ' - + 'export? }` booleans. Rewrite an objectui-native formatting rule `{ field, operator, value, ' - + 'backgroundColor }` as `{ condition: "record.FIELD == VALUE", style: { backgroundColor } }`; ' - + 'delete `operations.read` and `operations.import`, which nothing reads.', - reason: 'The grid reads each of these members with one shape, and the page-component row ' - + 'declared them `z.unknown()`, so any value passed the component-props gate and the grid ' - + 'answered an off-shape one with a silent default: an off-preset `rowHeight` such as `42` ' - + 'rendered as `compact`, a `rowColor` of the wrong shape coloured no row, a `navigation` written ' - + 'as a bare mode string opened the record page whatever it named, an aggregation with an ' - + 'unknown function drew a zero nothing computed or no number at all, and an `operations` ' - + 'toggle nothing reads toggled nothing. The row now takes the list view\'s own schemas for the ' - + 'five members a list view declares, and the measured shape for `aggregations` and ' - + '`operations`, so one value is judged the same way on both doors. It is read where every page ' - + 'component\'s props are: the component-props gate reports a refused value as an advisory ' - + '`component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, ' - + '`objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a ' - + 'page component\'s `properties` is not parsed on the metadata save or load path. No conversion ' - + 'is registered: nothing on the load path refuses the shape, and an off-shape value has no ' - + 'rewrite that both keeps what the grid shows today and honours what the author wrote — which ' - + 'is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under the seven members\' ' - + 'paths. Each grid that set one of them now shows it: the declared row height, the row colours ' - + 'its `colors` map names, the navigation mode on a row click, the conditional styles, the bulk ' - + 'actions, the group-header numbers and the affordances `operations` names.', - }, - // #21464 — the `object-kanban` page block's `conditionalFormatting` was - // `z.unknown()`, held while objectui's kanban authored a second rule dialect - // the list view's member refuses. objectstack-ai/objectui#11522 retired that - // dialect from objectui's authoring faces, so the row now takes the list view's - // own member, by reference, as `object-grid` does. D3 only: page-component - // `properties` is not parsed on the metadata save or load path, so a stored page - // is never refused; and the authored census found no working rule to respell — - // the refused values are objectui's own refusal probes and one no-predicate - // fixture in this package, respelled in the same change. - { - id: 'ui-object-kanban-conditional-formatting-typed', - surface: 'page `object-kanban` components — `properties.conditionalFormatting` (which used to accept any ' - + 'value)', - replacement: 'the list view\'s own rules, `[{ condition, style }]`: a non-blank CEL `condition` over the ' - + 'card\'s `record.*` and a CSS `style` map of string values. Rewrite a native rule `{ field, operator, ' - + 'value, backgroundColor }` as `{ condition: "record.FIELD == VALUE", style: { backgroundColor } }`, an ' - + '`expression` as `condition`, and move a colour written beside `condition` into `style`.', - reason: 'The board reads `conditionalFormatting` as an ordered list of `{ condition, style }` rules, through ' - + 'the evaluator the grid\'s rows use, and paints a card with the `style` of the first rule whose condition ' - + 'holds; objectui declares exactly the list view\'s rule as the member\'s only dialect. The page-component ' - + 'row declared it `z.unknown()`, so `42`, a bare string or a rule with no `style` passed the ' - + 'component-props gate and the board painted no card for it. The row now takes the list view\'s own ' - + 'member, by reference, as `object-grid` does, so one rule is judged the same way on every door. It is ' - + 'read where every page component\'s props are: the component-props gate reports a refused value as an ' - + 'advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, ' - + '`objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page ' - + 'component\'s `properties` is not parsed on the metadata save or load path. No conversion is ' - + 'registered: nothing on the load path refuses the shape, and the authored census found no working rule ' - + 'to respell. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-kanban` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under ' - + '`properties.conditionalFormatting`. Each board that sets rules paints the card each rule names with ' - + 'its `style`.', - }, - // #21464 — `navigation` on the `object-map`, `object-gantt` and `object-tree` - // page blocks was `z.unknown()` although each renderer hands it to the shared - // navigation hook, which reads `.mode` and types its mode union as the list - // view's `NavigationConfigSchema`; any value passed and an off-shape one opened - // the record page in silence. The three rows now take that schema by reference, - // the carrier `object-grid`, `object-kanban`, `object-calendar` and - // `object-timeline` already take. D3 only: page-component `properties` is not - // parsed on the metadata save or load path, so a stored page is never refused; - // an off-shape value has no rewrite that says what the author meant; and the - // authored census found nothing in either repository's corpora to respell. - { - id: 'ui-object-map-gantt-tree-navigation-typed', - surface: 'page `object-map`, `object-gantt` and `object-tree` components — `properties.navigation` ' - + '(which used to accept any value)', - replacement: 'the list view\'s navigation block `{ mode?, size?, openNewTab?, preventNavigation? }`, ' - + '`mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window`, `none` — the block ' - + '`object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take. Rewrite a bare ' - + 'mode string such as `navigation: \'drawer\'` as `navigation: { mode: \'drawer\' }`.', - reason: 'Each of the three renderers hands `navigation` to the shared navigation hook, which reads ' - + '`navigation.mode`, falls back to `page` when it finds none, and types its mode union as the list ' - + 'view\'s `NavigationConfigSchema`. The rows declared the member `z.unknown()`, so any value passed ' - + 'the component-props gate and an off-shape one was answered with a silent default: `navigation: 42` ' - + 'and a bare mode string such as `\'drawer\'` both opened the record page, whatever they named. The ' - + 'three rows now take the list view\'s schema by reference, so one value is judged the same way on ' - + 'every door that carries it. It is read where every page component\'s props are: the ' - + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' - + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' - + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' - + '`properties` is not parsed on the metadata save or load path. No conversion is registered: ' - + 'nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps ' - + 'what the block shows today (the record page) and honours what the author wrote — which is the ' - + 'judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-map`, `object-gantt` and `object-tree` node validates: ' - + '`objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` ' - + 'finding under `properties.navigation`. Each block that set `navigation` opens the mode it names ' - + 'on a marker, task or row click.', - }, - // #20928 — the third carrier of the inline grid column. An - // `object-master-detail-form` page block's `details` was `z.array(z.unknown())` - // while the other two carriers — a relationship field's `inlineColumns` and a - // form view's `subforms[].columns` (#20901) — were the strict - // `InlineGridColumnSchema`; the entry is now a strict shape of the twelve keys - // objectui's `MasterDetailForm` reads, and its `columns` REFERENCES that schema. - // D3 only: page-component `properties` is not parsed on the metadata save or - // load path, so a stored page is never refused and there is no load-path - // refusal for a conversion to pre-empt; the authored census found no `field` - // spelling to respell. `defineStack`'s identity-only check reaches this carrier - // too, the reach `inline-grid-column-identity-only-currency-scale-refused` - // records for the other two. - { - id: 'ui-object-master-detail-form-details-closed', - surface: 'page `object-master-detail-form` components — `properties.details[]` (each detail ' - + 'entry, which used to accept any value) and `properties.details[].columns[]` (its inline ' - + 'grid columns), including `scale` on a column that declares no `type` and whose `name` is a ' - + '`currency` field of the entry\'s `childObject`', - replacement: 'each entry is `{ childObject, relationshipField?, columns?, formFields?, ' - + 'inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' - + '— the keys the renderer reads — with `inlineMode` one of `grid` / `form`. Each column is the ' - + 'strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes — ' - + '`{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child ' - + 'object\'s field. Write `childObject` on every entry; write `name` where a column said ' - + '`field` (or `fieldName`, `key`) or was a bare field-name string; delete `scale` from a ' - + 'column that renders as a currency column, whether it declares `type: \'currency\'` or takes ' - + 'it from a `currency` child field — nothing replaces it, the currency\'s ISO 4217 minor unit ' - + 'decides; delete any key neither shape declares.', - reason: 'The block draws one inline grid per detail entry, hydrating an authored column list ' - + 'with the same rule and into the same grid as the other two carriers of the inline grid ' - + 'column, but nothing judged its entries: a key the renderer does not read was ignored in ' - + 'silence, and a column carrying a key the grid does not read, or `scale` on a currency ' - + 'column — refused on the other carriers under the maintainer\'s rulings of 2026-09-23 ' - + '(option B, `scale` retired from the currency type) and 2026-09-24 (option 乙 — a currency\'s ' - + 'ISO 4217 minor unit decides its display) — went through `objectstack validate` green. The ' - + 'entry is now a strict shape and its `columns` references the column schema, so every rule ' - + 'that schema holds applies here too, with its own prescription. The entry half is read where ' - + 'every page component\'s props are: the component-props gate reports a failing entry or ' - + 'column as an advisory `component-props-unknown-key` / `component-props-invalid` finding on ' - + '`objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still ' - + 'saves and loads, because a page component\'s `properties` is not parsed on the metadata save ' - + 'or load path. The identity-only half is `defineStack`\'s cross-reference check, which already ' - + 'judged the other two carriers: it now reaches the block wherever a page carries it and ' - + 'refuses an identity-only column over a `currency` child field that carries `scale`, with the ' - + 'column schema\'s own message; reach: the child object must be declared in the same stack, ' - + 'and a column the column schema refuses on its own is left to the component-props gate. No ' - + 'conversion is registered: nothing on the load path refuses the shape, and the authored ' - + 'census found nothing to respell. Population measured at the change, on origin/main ' - + 'ebdb6f2aca: one authored block in the examples (the showcase project workspace, one entry ' - + '`{ title, childObject, addLabel }`, no columns), one documentation example whose three ' - + 'columns were bare field-name strings (rewritten as `{ name }` columns in the same change), ' - + 'and zero `field`-keyed detail columns, against one authored `inlineColumns` block as the ' - + 'control. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-master-detail-form` node validates: `objectstack validate` ' - + 'reports no `component-props-unknown-key` / `component-props-invalid` finding on a ' - + '`properties.details` path and no cross-reference finding on a `details[].columns[].scale` ' - + 'path. Every detail entry carries `childObject` and only keys the entry shape declares; every ' - + 'column is an object carrying `name`, no column carries `field`, `fieldName` or `key`, and no ' - + 'column that renders as a currency column carries `scale`. The block\'s showcase entry ' - + '`{ title, childObject, addLabel }` parses unchanged, and the master-detail grid renders a ' - + 'value — not a blank cell — in each authored column for a row that has one.', - }, - // #21464 — two members of the `object-metric` page block were `z.unknown()` - // although the tile reads each with a fixed shape, so an off-shape value passed - // the component-props gate and the tile asked the server for a measure it could - // not answer or drew an empty badge, in silence. `aggregate` now takes the query - // AST's aggregation functions and the chart aggregate's `groupBy` union by - // reference (`groupBy` optional: a metric is one number), and `trend` the - // badge's measured shape. `drillDown` and `compareTo` are held at `z.unknown()`: each - // by-reference candidate disagrees with the tile's read (the chart's drill-down - // declares `filter`, which the tile never reads, and refuses `report`, which it - // draws; the dashboard widget's comparison declares `dimension`, never read on - // this path), so each waits on a ruling. D3 only: page-component `properties` - // is not parsed on the metadata save or load path, so a stored page is never - // refused; an off-shape value has no rewrite that says what the author meant; - // and the authored census found no authored value to respell — every refused - // value is a fixture probing that the tile does not draw it. - { - id: 'ui-object-metric-aggregate-trend-typed', - surface: 'page `object-metric` components — `properties.aggregate` and `.trend` (which used to accept any ' - + 'value)', - replacement: 'the shape the tile reads: `aggregate` `{ field?, function, groupBy? }`, with `function` one of ' - + 'the engine\'s `count`, `sum`, `avg`, `min`, `max` or `count_distinct`, a `field` for every function but ' - + '`count`, and `groupBy` the chart aggregate\'s own union — a field name or a `{ field, dateGranularity?, ' - + 'alias? }` date-bucket node — here optional; `trend` `{ value, label?, direction? }`, with `value` a number, `label` a string ' - + 'or an inline locale map and `direction` `up`, `down` or `neutral`. Write a string `aggregate` as an ' - + 'object (`\'count\'` → `{ function: \'count\' }`); move `dateGranularity` inside `groupBy`; write a bare ' - + 'trend direction as `{ value, direction }`.', - reason: 'The tile reads these members with one shape, and the page-component row declared them ' - + '`z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in ' - + 'silence: a string `aggregate` or a function the engine does not have asked the server for a measure it does not ' - + 'have, so the tile showed an error or, on the client-side fallback, a sum it was not asked for; ' - + '`groupby` for `groupBy` drew one ungrouped ' - + 'number; and a `trend` with no `value` painted a lone `%`, with a misspelled member or direction simply ' - + 'not drawn. The row now takes the query AST\'s own aggregation functions — the six the tile forwards to ' - + 'the engine — and the chart aggregate\'s `groupBy` union by reference, and the badge\'s measured shape for ' - + '`trend`. The aggregate is not the chart\'s whole: the chart requires `groupBy` and five functions, while ' - + 'a metric paints one number over every row and draws a `count_distinct` wherever the analytics service ' - + 'answers it. `drillDown` and `compareTo` stay open: the chart\'s drill-down ' - + 'declares a `filter` the tile never reads and refuses a `report` it draws, and the dashboard widget\'s ' - + 'comparison declares a `dimension` this path never reads, so each waits on a ruling between the ' - + 'reference and the read. It is read where every page component\'s props are: the component-props gate ' - + 'reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding ' - + 'on `objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and ' - + 'loads, because a page component\'s `properties` is not parsed on the metadata save or load path. No ' - + 'conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no ' - + 'rewrite that both keeps what the tile shows today and honours what the author wrote — which is the ' - + 'judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-metric` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under the two members\' paths. Each ' - + 'tile that set one of them now shows it: the number its aggregate names, grouped or bucketed as written, ' - + 'and the trend badge with its value, arrow and caption.', - }, - // #21464 — the `object-metric` page block's `compareTo` was `z.unknown()` - // although the tile reads it with one shape, so a bare kind string, a kind - // outside the two or a `dimension` passed the component-props gate, and the - // tile compared against the previous period, or shifted its own filter's window - // rather than the dimension named, in silence. It now takes the tile's read: - // `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by - // reference, and `dimension` refused by name with a prescription (the inline - // tile shifts the date macros in its own `filter` and never reads a dataset - // time dimension). D3 only: page-component `properties` is not parsed on the - // metadata save or load path, so a stored page is never refused; a `dimension` - // has no rewrite that keeps the window the author meant; and the authored census - // found no authored value to respell — the one refused value is a fixture - // probing that the tile does not read `dimension`. - { - id: 'ui-object-metric-compare-to-typed', - surface: 'page `object-metric` components — `properties.compareTo` (which used to accept any value)', - replacement: 'the shape the tile reads: `{ kind }`, with `kind` the dashboard widget comparison\'s own ' - + 'vocabulary, `previousPeriod` or `previousYear`. Write a bare kind string as an object ' - + '(`\'previousYear\'` → `{ kind: \'previousYear\' }`), and delete a `dimension`: the tile shifts the date ' - + 'macros in its own `filter`, so state the window there.', - reason: 'The tile reads `compareTo` with one shape — `kind` alone, dispatching on `previousYear` and treating ' - + 'every other value as `previousPeriod` — and the page-component row declared it `z.unknown()`, so any value ' - + 'passed the component-props gate and the tile answered an off-shape one in silence: a bare `\'previousYear\'` ' - + 'or a kind outside the two compared against the previous period, and a `dimension` was carried and never ' - + 'read, because this inline tile shifts the date macros in its own `filter` while only a dashboard widget\'s ' - + 'dataset path hands `dimension` to the analytics executor. The row now takes `{ kind }`, with `kind` the ' - + 'dashboard widget comparison\'s own member by reference, and refuses `dimension` by name with that ' - + 'prescription rather than accepting a key the tile ignores. It is read where every page component\'s props ' - + 'are: the component-props gate reports a refused value as an advisory `component-props-invalid` / ' - + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack ' - + 'lint`, and a stored page still saves and loads, because a page component\'s `properties` is not parsed on ' - + 'the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, ' - + 'and a `dimension` has no rewrite that keeps the window the author meant — which is the judgment this entry ' - + 'leaves to the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-metric` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.compareTo`. Each tile ' - + 'that sets a comparison shows its trend labelled for the kind it names, over the window its own `filter` ' - + 'resolves to.', - }, - // #21464 — the `object-metric` page block's `drillDown.report` was `z.unknown()`: - // the tile hands it to the shared drill drawer, which draws a dataset-bound - // report and lists the records for any other value, and no spec drill shape - // declared it. The maintainer ruled on #21704 (fork 1, letter B) that it is - // `ReportSchema` by reference, once a joined report refuses a block that binds - // no dataset (#21702), so a report the member admits is one the drawer draws. - // D3 only: page-component `properties` is not parsed on the metadata save or - // load path, so a stored page is never refused; and the authored census found - // no drawn report to respell — the refused values are objectui's probes of the - // values the drawer does NOT draw. - { - id: 'ui-object-metric-drill-down-report-typed', - surface: 'page `object-metric` components — `properties.drillDown.report` (which used to accept any value)', - replacement: 'a report definition, the same shape as `reports[]` (`ReportSchema`): `{ name, label, dataset, ' - + 'values, … }`, or a `joined` report whose every block binds a `dataset`. Write a bare report name, a ' - + '`{ name }` reference or the retired `objectName` / `columns` form as the dataset-bound report itself.', - reason: 'The metric tile hands `drillDown.report` to the shared drill drawer, which draws it as a report — with ' - + 'the metric\'s filter joined into the report\'s own `runtimeFilter` — when it is dataset-bound (a non-empty ' - + '`dataset`, or a `joined` report with a block that binds one), and lists the records for any other value. ' - + 'The page-component row declared it `z.unknown()`, so a report with no `dataset`, a misspelled report key, a ' - + 'bare report name or a `{ name }` reference passed the component-props gate, and the drawer quietly listed ' - + 'the records instead. The row now takes `ReportSchema` by reference — the declaration objectui already ' - + 'names for the member — and, since a joined report refuses a block that binds no `dataset`, every report it ' - + 'admits is one the drawer draws. It is read where every page component\'s props are: the component-props gate ' - + 'reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding on ' - + '`objectstack validate`, `objectstack build` and `objectstack lint`, and a stored page still saves and loads, ' - + 'because a page component\'s `properties` is not parsed on the metadata save or load path. No conversion is ' - + 'registered: nothing on the load path refuses the shape, and the authored census found no drawn report to ' - + 'respell. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-metric` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.drillDown.report`. Each ' - + 'tile whose drill names a report opens that report, scoped by the metric\'s filter, instead of the record list.', - }, - // #21464 — the `object-metric` page block's `drillDown` was `z.unknown()` - // although the tile reads it with one shape, so a drill `filter`, a `mode`, a - // misspelled member or a non-numeric page size passed the component-props gate - // and the tile ignored each, in silence. It now takes the tile's read: the five - // list members (`enabled`, `title`, `target`, `columns`, `maxRows`) are the chart - // drill-down's own by reference, and `filter` and `mode` are refused by name with - // the prescriptions the renderer's own type for this block carries (a metric has - // no click event for a drill filter to resolve against, and no row for `mode` to - // open). The drill `report` is held open, not typed: the tile draws a - // dataset-bound report, but the spec declares no drill report yet. D3 only: - // page-component `properties` is not parsed on the metadata save or load path, so - // a stored page is never refused; a drill `filter` has no rewrite that keeps the - // scope the author meant; and the authored census found no authored value to - // respell — the two refused values are fixtures probing the refusal. - { - id: 'ui-object-metric-drill-down-typed', - surface: 'page `object-metric` components — `properties.drillDown` (which used to accept any value)', - replacement: 'the shape the tile reads: `{ enabled?, title?, target?, columns?, maxRows?, report? }`, the first ' - + 'five the chart drill-down\'s own members — `enabled` a boolean, `title` a string, `target` `drawer`, ' - + '`dialog` or `navigate`, `columns` field names, `maxRows` a positive whole number — and `report` still open. ' - + 'Delete a drill `filter` and scope the metric with its own `filter`, one level up; delete a `mode`, since a ' - + 'metric always lists the records behind its number.', - reason: 'The tile reads `drillDown` with one shape — `enabled`, `title`, `target`, `columns`, `maxRows` and ' - + '`report`, scoping the drilled list by the metric\'s own `filter` — and the page-component row declared it ' - + '`z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in ' - + 'silence: a drill `filter` or a `mode` was carried and never read, a misspelled member was simply not ' - + 'applied, and a non-numeric page size reached the drilled list. The row now takes the five list members ' - + 'the chart drill-down declares, by reference, and refuses `filter` and `mode` by name: a metric tile has no ' - + 'click event for a drill filter to resolve against, and no row for `mode` to open as a record. The chart\'s ' - + 'shape is not taken whole, because it declares `filter`. The drill `report` stays open: the tile draws a ' - + 'dataset-bound report through the shared drawer, but no spec drill shape declares a `report` member yet. ' - + 'It is read where every page component\'s props are: the component-props gate reports a refused value as ' - + 'an advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, ' - + '`objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page ' - + 'component\'s `properties` is not parsed on the metadata save or load path. No conversion is registered: ' - + 'nothing on the load path refuses the shape, and a drill `filter` has no rewrite that keeps the scope the ' - + 'author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-metric` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.drillDown`. Each tile ' - + 'that sets a drill-down opens it as written: the panel shape `target` names, the heading `title` names, ' - + 'and the records behind the number, scoped by the metric\'s own `filter`, in the columns and page size ' - + 'written.', - }, - // #21464 — the `object-timeline` page block's `items` was `z.array(z.unknown())`: - // each entry is objectui's authored timeline element (`TimelineFeedItem` / - // `TimelineGanttItem`, ruled on objectui#6356), which the spec did not declare, - // and the arm an entry must be is chosen by the row's `variant`. The maintainer - // ruled on #21704 (fork 4, letter B): both arms closed, a feed entry's `content` - // opaque, a row refinement pairing each entry with the arm `variant` selects, - // and a gantt bar's dates a string or a number. D3 only: page-component - // `properties` is not parsed on the metadata save or load path, so a stored page - // is never refused; and the authored census found no drawn entry to respell — - // the refused values are objectui's probes of its render-time gantt date - // diagnostic. - { - id: 'ui-object-timeline-items-typed', - surface: 'page `object-timeline` components — `properties.items` (whose entries used to accept any value)', - replacement: 'the entry kind the block\'s `variant` selects: on `vertical` (the default) or `horizontal`, a feed ' - + 'entry `{ time?, title, description?, variant?, icon?, content?, className? }`; on `gantt`, a gantt row ' - + '`{ label, items? }` whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch ' - + 'milliseconds. Write a feed entry\'s `date` as `time` and its `color` as `variant` (`default`, `success`, ' - + '`warning`, `danger`, `info`); move a gantt row to `variant: \'gantt\'`, or a feed entry off it.', - reason: 'The timeline rail draws `items` as authored, ahead of every record source, and each branch of its ' - + 'renderer reads only its own kind of entry: the feed branches read `time`, `title`, `description`, `variant`, ' - + '`icon`, `content` and `className`; the gantt branch reads a row\'s `label` and its bars\' `title`, ' - + '`startDate`, `endDate` and `variant`. The page-component row declared each entry `z.unknown()`, so a ' - + 'misspelled key, a feed entry with no `title`, or a gantt row on a feed timeline passed the component-props ' - + 'gate, and the rail drew an empty, unlabelled entry. The row now takes objectui\'s two ruled kinds, closed, ' - + 'and pairs each entry with the kind its `variant` selects; a feed entry\'s `content` (child components) is ' - + 'held unjudged until a writer appears. It is read where every page component\'s props are: the ' - + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' - + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and `objectstack lint`, ' - + 'and a stored page still saves and loads, because a page component\'s `properties` is not parsed on the ' - + 'metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and ' - + 'the authored census found no drawn entry to respell. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-timeline` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.items`. Each timeline ' - + 'with authored entries draws every entry with its title (or row label), date and colour.', - }, - // #21464 — the `object-timeline` page block's `mapping` was `z.unknown()`: its - // contract lived only in objectui, so a bare field name, a non-string binding - // or a misspelled member (`titleField` inside `mapping`) passed the - // component-props gate, and the rail bound nothing for it and drew the default - // field. The spec now declares objectui's own declaration of the binding - // record, four optional field names, and the row takes it. D3 only: page- - // component `properties` is not parsed on the metadata save or load path, so a - // stored page is never refused; and the authored census found no refused - // authored value — every one parses. - { - id: 'ui-object-timeline-mapping-typed', - surface: 'page `object-timeline` components — `properties.mapping` (which used to accept any value)', - replacement: 'the binding record the rail reads: `{ title?, date?, description?, variant? }`, each a field ' - + 'name. Write `titleField`, `dateField` / `startDateField`, `descriptionField` and `variantField` inside ' - + '`mapping` as `title`, `date`, `description` and `variant`; write a bare field name as the member it ' - + 'binds (`mapping: { title: \'subject\' }`).', - reason: 'The timeline rail reads `mapping` as four field names — `title` and `date` between the `timeline` ' - + 'block\'s own member and the flat fallback, `description` ahead of `descriptionField`, and `variant`, ' - + 'the field whose value picks each entry\'s marker colour and the one binding with no other spelling — ' - + 'and the page-component row declared it `z.unknown()`, because that contract was objectui\'s alone. So a ' - + 'bare field name, a non-string binding or a misspelled member passed the component-props gate, and the ' - + 'rail bound nothing for it and drew the default field. The spec now declares objectui\'s own declaration ' - + 'of the binding record, four optional field names, closed as every element shape on that map is. It is ' - + 'read where every page component\'s props are: the component-props gate reports a refused value as an ' - + 'advisory `component-props-invalid` / `component-props-unknown-key` finding on `objectstack validate`, ' - + '`objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a page ' - + 'component\'s `properties` is not parsed on the metadata save or load path. No conversion is ' - + 'registered: nothing on the load path refuses the shape, and the authored census found nothing to ' - + 'respell. Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `object-timeline` node validates: `objectstack validate` reports no ' - + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.mapping`. Each ' - + 'timeline that sets a mapping draws its entries\' title, date, description and marker colour from the ' - + 'fields it names.', - }, - { - id: 'ui-react-list-view-binding-aliases-retired', - surface: '`kind:\'react\'` page source — `` and `` ' - + '(the react-tier overlay aliases published as deprecated when the react tier ' - + 'converged on the metadata-tier vocabulary)', - replacement: '`` — ListViewSchema\'s own ' - + '`data` data source and `type` view kind, the same two keys a metadata list view authors. ' - + '`objectName="x"` → `data={{ provider: \'object\', object: \'x\' }}`; `viewType="kanban"` → ' - + '`type="kanban"`. A `` with no `data` at all is refused too: on a react page no ' - + 'host stamps the object, so the data source is the required binding there.', - reason: - 'A react page\'s source is a JSX string, not a keyed document: `objectstack migrate meta` ' - + 'rewrites stored metadata by key and cannot rewrite props inside authored source, so the ' - + 'move is by hand. The contract deprecated both aliases in favour of the metadata-tier ' - + 'spelling (maintainer ruling 2026-08-23: the react tier converges on the metadata-tier ' - + 'vocabulary, deprecating first) while objectui\'s ListView still read only `objectName`, so the canonical ' - + 'spelling validated green and rendered an empty list. The consumer fold has landed (objectui ' - + '`normalizeListViewSchema`, console pin a472b071: `data.provider === \'object\'` → ' - + '`objectName`, and the author\'s `type` read for the view kind), and the maintainer ruled the ' - + 'aliases retired with no deprecation window (2026-09-07). Writing either alias is now ' - + 'a publish-time `react-prop-retired` error carrying this prescription — never a silent pass ' - + 'on a key the renderer happens to still read.', - acceptanceCriteria: - '`objectstack validate` reports no `react-prop-retired` and no `react-prop-missing-required` ' - + 'finding on any `kind:\'react\'` page; every `` carries `data={{ provider: ' - + '\'object\', object }}` (or another ViewData provider) and, where a view kind was chosen, ' - + '`type`; in the console each rewritten list renders the same rows and visualization it ' - + 'rendered under the alias spelling. The platform\'s own sites are the reference: the ' - + 'showcase `crm-workbench`, `renewals-pipeline` and `task-desk` pages pass with zero findings.', - }, - { - id: 'ui-record-blocks-unknown-keys-refused', - surface: 'page `record:alert` / `record:quick_actions` / `record:history` / ' - + '`record:discussion` components — `properties` (undeclared keys, notably a typo\'d ' - + '`severty`, quick_actions\' inline `actions`, history\'s host-channel `entries` / ' - + '`loading`, and any key at all on `record:discussion`)', - replacement: 'the declared shapes the renderers read. `record:alert`: `severity?`, `title?` / ' - + '`body?` (string or inline locale map), `visible?` (boolean | CEL string | `{ dialect, ' - + 'source }`), `icon?`, `action?` `{ actionName, label?, variant? }`, `dismissible?`, ' - + '`dismissKey?`. `record:quick_actions`: `actionNames?`, `requiredPermissions?`, ' - + '`location?` (the spec\'s own action-location vocabulary), `align?`, `inline?`, ' - + '`variant?` / `size?` (the Button primitive\'s vocabulary). `record:history`: `limit?`, ' - + '`emptyText?` / `unknownUserText?` (literal strings). `record:discussion`: ' - + '`record:chatter`\'s own row — one schema for the pair. Every rejection carries the ' - + 'surface, the offending key and a prescription (`actions` → `actionNames`; `entries` / ' - + '`loading` → omit, the block self-fetches `sys_activity`; `aria` on quick_actions → not ' - + 'declared until the renderer reads the contract spelling; `visibleWhen` / `visibility` ' - + 'on the alert → `visible`; a locale map as history text → a literal string)', - reason: - 'These were the four `record:*` components the component-props unknown-key gate (an ' - + 'authorable surface refuses a key it does not declare; for a component\'s `properties`, ' - + 'by parsing them against the type\'s `ComponentPropsMap` row) could not reach after ' - + 'the rail was given its strict row: each had a registered objectui renderer (and, bar ' - + '`record:discussion`, a `PageComponentType` entry and a console palette slot) but no ' - + '`ComponentPropsMap` row, so the props gate\'s dispatch skipped them as unregistered and ' - + 'every authored key rode through. A typo\'d `severty` on the platform\'s own banner ' - + 'surface parsed, typechecked, validated, built and shipped as a silent no-op while ' - + 'sibling components in the same file drew loud diagnostics. The rows declare the shapes ' - + 'the renderers actually read (measured from read points at the objectui pin, not from ' - + 'the registrations\' declared-input lists — quick_actions\' registration claims an ' - + 'empty-bar fallback the renderer does not implement, and omits the `aria.label` read ' - + 'that exists but under a spelling the shared ARIA shape refuses), so an undeclared key ' - + 'is now a publish-time refusal instead of a silent no-op.', - acceptanceCriteria: - 'Every `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` ' - + 'node validates with only declared keys, and declared keys parse byte-identically to ' - + 'before — the platform `sys_user` page\'s banner (inline locale maps, CEL `visible`, ' - + 'CTA) and self-service quick_actions bars, and the showcase task page\'s banner and bar, ' - + 'all pass with zero findings; `objectstack validate` reports no ' - + '`component-props-unknown-key` / `component-props-invalid` finding for these types. The ' - + 'one parse-time normalization is `ExpressionInputSchema`\'s own: a bare-string `visible` ' - + 'becomes the canonical `{ dialect: \'cel\', source }` envelope.', - }, - // #21142 — the fourth carrier of the inline grid column, and the last - // registered `record:*` renderer without a `ComponentPropsMap` row. The type sat - // on the string-arm registration ledger instead, so the component-props gate - // skipped its props bag: the showcase project page keyed all five of its - // columns `field` and published green over a grid of empty cells. The row - // declares the fifteen keys objectui's `LineItemsPanel` reads, and its - // `columns` REFERENCES `InlineGridColumnSchema`. D3 only: page-component - // `properties` is not parsed on the metadata save or load path, so a stored - // page is never refused and there is no load-path refusal for a conversion to - // pre-empt; the one `field`-keyed producer the census found is respelled in the - // same change. - { - id: 'ui-record-line-items-props-closed', - surface: 'page `record:line_items` components — `properties` (which used to accept any key) and ' - + '`properties.columns[]` (its inline grid columns)', - replacement: 'the declared shape the renderer reads: `{ childObject?, relationshipField, columns, ' - + 'parentObject?, parentId?, recordId?, amountField?, totalField?, title?, readonly?, minRows?, ' - + 'maxRows?, filter?, sort?, limit? }`, with `filter` the ViewFilterRule array, `sort` the SortItem ' - + 'array and `limit` a positive integer; `childObject` may come from the component-level ' - + '`dataSource` binding instead. `columns` is required and holds at least one column, each the ' - + 'strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes — ' - + '`{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or ' - + '`fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block ' - + 'draws a column exactly as declared and hydrates nothing from the child object\'s field; ' - + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `formFields` ' - + 'and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read ' - + 'here, `sortField`, which no block takes (the detail entry derives the line-position field from ' - + 'the child object), and any other key the shape does not declare.', - reason: 'The block draws one inline grid of the record\'s child rows, through the same objectui ' - + 'grid as the other three carriers of the inline grid column, but it had no `ComponentPropsMap` ' - + 'row: it was the one entry on the string-arm registration ledger, so the component-props gate ' - + 'skipped it as unregistered and every authored key rode through. The showcase project page ' - + 'keyed all five of its columns `field`, the spelling the grid retired, and published green; the ' - + 'grid binds a column by `name`, so every cell rendered empty. The row is measured from the ' - + 'renderer\'s read points at the objectui pin, not from the registration\'s declared-input list, ' - + 'and its `columns` references the column schema, so every rule that schema holds applies here ' - + 'too, with its own prescription. It is read where every page component\'s props are: the ' - + 'component-props gate reports a failing key or column as an advisory ' - + '`component-props-unknown-key` / `component-props-invalid` finding on `objectstack validate`, ' - + '`objectstack build` and `objectstack lint`, and a stored page still saves and loads, because a ' - + 'page component\'s `properties` is not parsed on the metadata save or load path. `defineStack`\'s ' - + 'identity-only column check does not reach this block: the panel hands its columns to the grid ' - + 'as authored, so there is no hydrated type to judge. No conversion is registered: nothing on the ' - + 'load path refuses the shape, and the one `field`-keyed producer was respelled in the same ' - + 'change. Population measured at the change, on origin/main 1ecb871beb: one authored block in ' - + 'the examples (the showcase project detail page, five `field`-keyed columns, respelled `name`), ' - + 'zero in the documentation, against eight authored `record:*` blocks of other types through ' - + 'the same matcher as the control. ' - + 'Deployed metadata NOT MEASURED.', - acceptanceCriteria: 'Every `record:line_items` node validates: `objectstack validate` reports no ' - + '`component-props-unknown-key` / `component-props-invalid` finding on its `properties` path. ' - + 'Every node carries `relationshipField` and at least one column, every column is an object ' - + 'carrying `name`, no column carries `field`, `fieldName` or `key`, and no key outside the ' - + 'declared shape is present. The showcase project detail page\'s block parses with its five ' - + '`name`-keyed columns, and its grid renders a value — not a blank cell — in each column for a ' - + 'row that has one.', - }, - { - id: 'ui-reference-rail-unknown-keys-refused', - surface: 'page `record:reference_rail` components — `properties` and each `entries[]` item: ' - + 'undeclared keys (notably a per-entry `filter`, an entry `icon`, and an inline locale map ' - + 'as `title`)', - replacement: 'the declared shape the renderer reads: `entries[]` of `{ objectName, ' - + 'relationshipField, title?, limit?, displayField? }` plus a component-level `hideEmpty`. ' - + 'Every rejection carries the surface, the offending key and a prescription (`filter` → ' - + 'remove it, or use `record:related_list` whose `filter` is real; `icon` → remove it, no ' - + 'render path reads it; entry-level `hideEmpty` → move it up beside `entries`; `items` / ' - + '`related` → `entries`; `object` → `objectName`; `label` → `title`; a `title` locale map ' - + '→ a literal string, or omit it to keep the localized object label)', - reason: - 'The rail was the `record:*` component the component-props unknown-key gate (an ' - + 'authorable surface refuses a key it does not declare; for a component\'s `properties`, ' - + 'by parsing them against the type\'s `ComponentPropsMap` row) could not reach: it had a ' - + 'registered renderer and a `PageComponentType` entry but no `ComponentPropsMap` row, so ' - + 'the props gate\'s dispatch skipped it as unregistered and every authored key rode ' - + 'through. Measured on 17.0.0 GA end to end: a planted entry `filter` passed tsc, ' - + '`objectstack validate` and `objectstack build`, shipped verbatim in the artifact, and ' - + 'the rendered rail kept counting and listing unfiltered rows — while the same build ' - + 'loudly reported `record:related_list` keys in the same file. The row declares the shape ' - + 'the renderer actually reads (measured from its read points, not its TS interface — the ' - + 'interface\'s `icon` is read by nothing and is refused, not declared), so an undeclared ' - + 'key is now a publish-time refusal instead of a silent no-op.', - acceptanceCriteria: - 'Every `record:reference_rail` node validates with only declared keys: `properties` carries ' - + '`entries` (≥ 1) and optionally `hideEmpty`; each entry carries `objectName` and ' - + '`relationshipField` and optionally `title` (literal string), `limit` (positive int) and ' - + '`displayField`. Declared keys parse byte-identically to before; `objectstack validate` ' - + 'reports no `component-props-unknown-key` / `component-props-invalid` finding for the ' - + 'rail.', - }, - // The D3 entry for the joined arm of `ReportSchema`'s refinement refusing a - // block that binds no `dataset`: the enforce arm of ADR-0049 enforce-or-remove, - // applied to the "each block dataset-bound" contract the schema comment and the - // reports guide already stated. It narrows a report's accept set; no key is - // removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is - // no D2 conversion either: which dataset a block shows is the author's - // decision, and no rewrite can name it. - { - id: 'ui-report-joined-block-dataset-required', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span. - surface: 'reports[].blocks[].dataset on a report whose type is joined: a block that binds no dataset ' - + '(the joined arm of the ReportSchema refinement)', - replacement: 'Bind the block to a dataset: set the block\'s `dataset` to the dataset whose measures ' - + '(`values`) and dimensions (`rows`) it shows. A block with nothing to show can be deleted ' - + 'instead, as long as the report keeps at least one block.', - reason: - 'ADR-0021 single-form, enforced (ADR-0049 enforce-or-remove, the enforce arm). A `joined` report ' - + 'carries its data on `blocks`, each an independent query over that block\'s own `dataset`, and the ' - + 'container selects nothing: a container `dataset` is refused. `ReportSchema`\'s refinement comment ' - + 'and the reports guide both said each block is dataset-bound, but the joined arm required only that ' - + '`blocks` be non-empty, and a block\'s `dataset` is optional on its shape, so a block with no `dataset` ' - + 'parsed, passed `objectstack validate` and every save door, and drew nothing. Measured at this repo\'s ' - + '`.objectui-sha` pin `ab187972159583b595facdcae3c73b50f6f312e9`: the joined renderer hands each ' - + 'block\'s `dataset` to its table, whose query hook goes idle on an empty name, so an unbound block ' - + 'draws an empty table and issues no query; a report whose blocks all lack one fails the ' - + 'dataset-report guard and falls through to the pre-9.0 presentation bridge, which issues no query ' - + 'either, and a dashboard drill-down that opens that report lists the records instead of drawing ' - + 'it. Studio\'s report inspector authors blocks through the spec form\'s `blocks` repeater, which ' - + 'adds a blank row and requires no column of it, so a block saved with only a name reached the store ' - + 'with no error. The joined arm now refuses each such block at `blocks[i].dataset`, naming the block, ' - + 'with the prescription to bind it to a dataset. `dataset` stays optional on the block shape itself: ' - + '`blocks` is read only on a `joined` report, and a block on any other report type is ignored, as ' - + 'before. Ships at once, with no deprecation window: there is no window in which an unbound block ' - + 'draws anything, and there is no mechanical rewrite, because only the author knows which dataset ' - + 'the block was meant to show.', - acceptanceCriteria: - 'WHICH DOOR: this is the spec schema\'s refusal, so it lands wherever a report is parsed through ' - + '`@objectstack/spec` — `defineReport`, `defineStack`, `os validate` / `os build`, and the metadata ' - + 'save door (the `report` entry of the metadata type registry) — as one `custom` issue per unbound ' - + 'block at `blocks.N.dataset`, naming the block. A stored `sys_metadata` report row is not rewritten: ' - + 'it carries the same issue in its read-side `_diagnostics` and is refused on its next save. Fix each ' - + 'by binding the block to the dataset it is meant to show, or by deleting the block, then check the ' - + 'rendered report: every block queries its dataset and draws its rows. A joined report whose blocks ' - + 'all bind a `dataset` parses byte-identically to before, and every non-joined report is untouched. ' - + 'Census at the time of the change: no joined report with an unbound block in this repository (one ' - + 'example-app report, one docs example and the test fixtures in `packages/lint`, ' - + '`packages/platform-objects` and `packages/spec` all bind every block; one metadata-door test ' - + 'fixture that left its block unbound on purpose was bound in the same change), in the hotcrm ' - + 'application (one joined report, every block bound) or in the cloud repository (no joined report ' - + 'exists).', - }, - // #20161 — the judgement half of `report-joined-chart-removed`, owed under - // #17152 ruling B (one D3 entry per retirement family, even when a lossless D2 - // exists). The D2 conversion strips a joined report's `chart` mechanically, at - // both coordinates, and that strip changes nothing that renders. What it cannot - // do is decide whether the author MEANT a chart: that intent has no place to go - // on a joined report, so moving it to a report that draws one is the author's - // call. Its own family, not an extension of - // `ui-report-joined-container-selection-refused`: that entry is the enforce arm - // for four selection keys that stay declared and has no D2 at all, while this one - // retires a declared block key, refuses a container key, and pairs with a D2. - { - id: 'ui-report-joined-chart-retired', - surface: - '`report.blocks[].chart` (REMOVED from the joined report block shape) and `report.chart` on ' - + 'a report whose `type` is `joined` (REFUSED by `ReportSchema`\'s refinement) — a chart ' - + 'anywhere on a joined report', - replacement: - 'nothing on the joined report: a joined report draws each block as a table and has no chart ' - + 'channel at either level. Delete the `chart`. If the chart was wanted, give the slice it ' - + 'was meant to plot a report of its own — `type` `tabular`, `summary` or `matrix`, binding ' - + 'the same `dataset` the block bound, selecting the dimension and measure the chart names ' - + 'in its `rows` and `values` — carry the `chart` over to that report\'s top level, where ' - + '`xAxis` names a dataset dimension and `yAxis` a measure exactly as before, and reach it ' - + 'from the app navigation beside the joined report.', - reason: - 'ADR-0049 enforce-or-remove. Nothing ever drew a chart on a joined report: the renderer\'s ' - + 'joined branch draws each block as a table and returns before its one read of the ' - + 'report\'s `chart`, and no renderer reads a block\'s `chart` at all — measured at this ' - + 'repo\'s `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52` ' - + '(`DatasetReportRenderer.tsx`, joined branch at lines 1462-1524, the only chart read at ' - + '1557). So both coordinates parsed, passed the `validate-chart-bindings` lint (which ' - + 'resolved their axes as if they would plot), and showed tables only. The D2 conversion ' - + '`report-joined-chart-removed` already REPAIRS THE DATA: it strips both from authored ' - + 'sources on a chain replay and from stored `sys_metadata` rows at rehydration, a lossless ' - + 'delete because neither value ever rendered. What it cannot repair is intent. Deleting ' - + 'the key leaves the report looking exactly as it always did — which is the problem when ' - + 'the author believed a chart was there: they were reading a chart that never existed, ' - + 'and only they know whether they wanted one. A walker cannot move it anywhere either: a ' - + 'joined report has no chart channel, and creating a new report, choosing its type and ' - + 'placing it in navigation are authoring decisions, not rewrites. The Studio report form ' - + 'offered a block chart input until this change, so a stored row carrying one is a real ' - + 'shape, not a hypothetical. Ships at once, no deprecation window: there is no window in ' - + 'which a key the renderer never reads does anything. `chart` on every non-joined report ' - + 'is unchanged — it is that report\'s live embedded chart.', - acceptanceCriteria: - 'WHICH DOOR: the refusal is the spec schema\'s, so it lands wherever a report is parsed ' - + 'through `@objectstack/spec` — `defineReport`, `os validate` / `os build`, and the metadata ' - + 'save door (the `report` entry of the metadata type registry, answered as ' - + '`INVALID_METADATA` with status 422). A block `chart` is refused as an unrecognized key ' - + 'on that block with the upgrade prescription; a container `chart` on a joined report is ' - + 'one `custom` issue at `chart`. (1) No joined report carries a `chart` at either level: the ' - + 'D2 strip covers existing sources on a chain replay, and the stored-row seams replay it for ' - + 'rows already at rest. (2) For every joined report ' - + 'that carried one, decide whether the chart was wanted; if it was, a non-joined report ' - + 'now binds that slice\'s dataset and carries the chart, and its `xAxis` / `yAxis` resolve ' - + '(`validate-chart-bindings` checks them there). (3) Check the rendered joined report: it ' - + 'renders exactly as before, because the chart was never drawn. A joined report with no ' - + '`chart` parses byte-identically to before, and every non-joined report is untouched. ' - + 'Census at the time of the change: zero joined reports with a chart in this repo\'s ' - + 'example apps and in the hotcrm reference app, against a lit control (non-joined reports ' - + 'carrying a chart: one and five). ' - + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - }, - { - id: 'ui-report-joined-container-selection-refused', - surface: 'report selection keys on a `joined` container — a top-level `dataset`, or a ' - + 'NON-EMPTY top-level `rows` / `columns` / `values` list, on a report whose `type` is ' - + '`joined` (`ReportSchema`\'s refinement)', - replacement: 'the same key on the `blocks[]` entries that need it — each block binds its own ' - + '`dataset` and selects its own `rows` / `columns` / `values` — or DELETE it. Deleting ' - + 'changes nothing that renders: the container value was never read. The refusal lands at ' - + 'the key\'s own path and says both, the way the container `order` refusal beside it ' - + 'always has, and that `order` refusal is unchanged.', - reason: - 'ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the ' - + 'selection of every non-joined report), and the one report type that never reads them ' - + 'now refuses them. A `joined` report selects nothing itself, and the refinement already ' - + 'said so for `order` alone — it refused a container `order` with a pointer onto ' - + '`blocks[]` while the four selection keys beside it parsed green. Measured at this ' - + 'repo\'s `.objectui-sha` pin `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`: ' - + '`DatasetReportRenderer`\'s joined branch (`DatasetReportRenderer.tsx:1462`) reads ' - + '`blocks`, plus the container `runtimeFilter` and `drilldown` resolved above it, and ' - + 'returns before the top-level reads of `columns` / `dataset` / `rows` / `values` begin ' - + '(line 1529 onward) — so each was accepted by the metadata layer and dropped by the ' - + 'renderer without a word. The alias tables made it reachable: `fields` / `measures` / ' - + '`metrics` route to `values`, `groupings` / `groupBy` / `dimensions` to `rows`, and ' - + '`objectName` / `object` / `dataSet` / `source` to `dataset`, on a joined report as on ' - + 'any other. Studio\'s report inspector hides the top-level binding for a joined report ' - + '(`ReportDefaultInspector.tsx:328`) but its type picker patches only `type`, so a ' - + 'report bound first and switched to `joined` second carries the keys invisibly. An ' - + 'empty list is NOT refused: it selects nothing, which is what a joined container ' - + 'selects — the container `order` refusal\'s own threshold. Ships at once, no ' - + 'deprecation window: there is no window in which a key the renderer never reads does ' - + 'anything.', - acceptanceCriteria: - 'WHICH DOOR: this is the spec schema\'s refusal, so it lands wherever a report is parsed ' - + 'through `@objectstack/spec` — `defineReport`, `os validate` / `os build`, and the ' - + 'metadata save door (the `report` entry of the metadata type registry) — as one ' - + '`custom` issue per key at `dataset` / `rows` / `columns` / `values`. A stored ' - + '`sys_metadata` report row is not rewritten: it carries the same issue in its read-side ' - + '`_diagnostics` and is refused on its next save. Fix each by moving the key onto the ' - + 'blocks that need it or deleting it, then check the rendered report: it renders exactly ' - + 'as before, because the container value was never read. A joined report that carries ' - + 'only `blocks`, `runtimeFilter`, `drilldown` and the identity / protection keys parses ' - + 'byte-identically to before, and every non-joined report is untouched. Census at the ' - + 'time of the change: zero joined reports carry any of the four at the container — in ' - + 'this repo one example-app report, one docs example and five test fixtures across ' - + '`packages/lint` and `packages/platform-objects`; in objectui every joined-report ' - + 'fixture and docs example at the pin above (13 occurrences); in the cloud repo none ' - + 'exist.', - }, - // #22470 — the upload requests' `scope` (presigned and chunked, - // api/storage.zod.ts) closed from an open string to the new `UploadScope` enum, - // the one list the `sys_file.scope` select is now built from, and the - // `service-storage` upload doors refuse any other value with - // `400 INVALID_REQUEST` naming the allowed values. Semantic only: no metadata - // type carries the upload request, so there is no authored source for a D2 - // conversion to rewrite — what moves is caller code, and which scope a file - // belongs under is a judgement only the caller can make. Nothing stored moves: - // no upload naming another scope ever reached a stored file record. - { - id: 'upload-request-scope-closed', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span AND a table cell. - surface: - 'the scope of an upload request, presigned or chunked (GetPresignedUrlRequest.scope and ' - + 'InitiateChunkedUploadRequest.scope) — an open string, now the UploadScope enum', - replacement: - 'one of the upload scopes `user`, `tenant`, `private`, `temp` or `attachments` (`UploadScope`, ' - + 'exported from `@objectstack/spec/api`), or no scope for the default `user`', - reason: - 'The upload requests declared scope an open string, while the stored file record only ever took ' - + 'the five values of the scope select of sys_file: any other value was refused by the data engine ' - + 'and answered 500 INTERNAL, with a message that sent the operator to restore the data engine. The ' - + 'request, the select and the upload doors now read one list. A literal outside it fails tsc, a ' - + 'parse of either request refuses it on the scope key, and both upload doors answer it with 400 ' - + 'INVALID_REQUEST naming the allowed values, before a file record, an upload URL or a backend ' - + 'upload exists. A scope is the lifecycle a file is filed under, not a free folder name, so a ' - + 'caller that sent one as a key prefix (avatars, logos, a record path) owes a choice no rewrite ' - + 'can make: `attachments` only for a file whose referrers are record attachment rows, since that ' - + 'scope is what orphan tombstoning reads; `temp` for a scratch file; otherwise `user` or `tenant`. ' - + 'No stored file record moves: no upload naming another scope ever succeeded.', - acceptanceCriteria: - 'Every upload call names one of the five upload scopes or none, and is answered 200; a caller ' - + 'that passes a scope through types it as UploadScope and compiles. An upload naming any other ' - + 'scope is answered 400 INVALID_REQUEST naming the five, and creates no sys_file record.', - }, - // The D3 entry for the list-view chart-binding check (#22491): the enforce arm - // of ADR-0049 enforce-or-remove, applied to the ADR-0021 single form the list - // chart block already required. It narrows a list view's accept set; no key is - // removed, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is - // no D2 conversion either: which dataset a chart plots is the author's - // decision, and a fabricated binding is the defect this closes. - { - id: 'view-chart-binding-dataset-required', - // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it - // inside a code span. - surface: - 'A list view whose type is chart and whose effective chart binding names no dataset: no chart block ' - + 'and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an ' - + 'options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every ' - + 'list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the ' - + 'flattened list overlay.', - replacement: - 'Bind the chart: declare a top-level `chart` block naming the ADR-0021 `dataset` to plot and at least one ' - + 'of its measures in `values` (`dimensions`, the X / group axis, stays optional, and `chartType` defaults ' - + 'to `bar`). A view whose only binding is the legacy `options.chart` bag completes the bag with `dataset` ' - + 'and `values`, or, preferred, moves the binding to the top-level `chart` block, which replaces the bag ' - + 'whole. A view that is not meant to be a chart takes another `type`.', - reason: - 'ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that ' - + 'renders nothing is refused rather than warned). A chart list view plots only the dataset its ' - + 'effective binding names, and the renderer reads that binding as the `chart` block, else the ' - + '`options.chart` bag, the block replacing the bag whole (objectui plugin-list `ListView`, ' - + '`resolveListChartBinding`, at this repo\'s `.objectui-sha` pin and at objectui main alike). The ' - + 'authoring `chart` block already required `dataset` and `values`, but a view with no block at all, or ' - + 'with only the bag, never met that schema. Measured on `origin/main` at `e148ca98`: the flattened ' - + 'overlay member accepted `type: \'chart\'` with no block and an `options.chart` bag holding only ' - + '`chartType`, and both authoring doors accepted the block-less view. What such a view rendered was a ' - + 'dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field ' - + 'named name and a measure named value), and objectui has since retired that floor, after which ' - + 'the chart component refuses on screen. Now refused at the view\'s own path, `chart`, or at ' - + '`options.chart.dataset` / `options.chart.values`, with the binding to declare. Ships at once, no ' - + 'grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: ' - + 'only the author knows which dataset a chart was meant to show.', - acceptanceCriteria: - 'WHICH DOOR: the spec schema\'s refusal, so it lands wherever a list view is parsed through ' - + '`@objectstack/spec` — `defineView`, `defineStack`, `os validate` / `os build`, and the metadata write ' - + 'door (`PUT /api/v1/meta/view/:name`, answering `422 INVALID_METADATA`) — as one `custom` issue at ' - + '`chart` for a view with no binding at all, or one per missing key at `options.chart.dataset` / ' - + '`options.chart.values` for an incomplete bag (overlay only; the authoring doors refuse `options` by ' - + 'name). A stored `sys_metadata` view row is neither rewritten nor refused on read: measured, the read ' - + 'door serves it as stored with the same issue in its `_diagnostics`, and it is refused on its next ' - + 'save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view ' - + 'that already declares a complete `chart` block parses byte-identically to before, and every view of ' - + 'another type is untouched, a grid that only offers a chart in `allowedVisualizations` included. ' - + 'Census at the time of the change: the two chart list views in `examples/app-showcase` and the chart ' - + 'list views in the `packages/lint` fixtures all bind a dataset and a measure; the only spec test that ' - + 'parsed a block-less chart view was a type-acceptance pin, re-judged in the same change.', - }, - // The absent-value half of the coupling #6227 declared, recorded beside its - // array half (`view-filter-rule-scalar-operator-array-refused`) rather than - // amended onto it: that entry's own replacement prose told an upgrading author - // "an omitted value is still an omitted value", and an upgrade guide that - // quietly rewrites a shipped prescription leaves the reader who followed it with - // no trace of why their metadata now fails. - { - id: 'view-filter-rule-absent-value-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, ' - + 'or present and undefined, on equals, not_equals, contains, not_contains, icontains, ' - + 'starts_with, ends_with, greater_than, less_than, greater_than_or_equal, ' - + 'less_than_or_equal, before or after (an alias spelling of any of them included), on ' - + 'every carrier of ViewFilterRuleSchema', - replacement: - 'the value the rule compares against — value: "open" on equals, value: "2026-01-01" on ' - + 'after. A rule that meant "the field has no value" becomes one of the four operators that ' - + 'take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction ' - + 'from their name and still parse with or without a value. A rule that was an unfinished ' - + 'row is deleted. The list operators (in / not_in) and the range operator (between) ' - + 'refused an absent value before this change and still do, in their own words', - reason: - 'The value key\'s own published description has declared, since the value was first shaped ' - + 'by its operator, that every ' - + 'operator outside the list, range and unary sets takes a scalar, and that only the unary ' - + 'operators ignore the key; the refinement implementing the coupling returned early on an ' - + 'absent value for every operator, so a rule with no value parsed green on all thirteen ' - + 'scalar operators. The query path refuses the same rule: both lowerings of a stored rule — ' - + 'the console\'s and the REST lookup-picker route\'s — emit it as the two-element ' - + '[field, operator] node, which the filter-AST lowering reads as an undefined comparand ' - + 'and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing ' - + 'between storage and the query drops the rule, so one such rule failed every query that ' - + 'read its view, the view\'s other rules included. The first-party producer does not write ' - + 'the shape: the console filter builder drops a row whose operator takes a value and whose ' - + 'value is missing before it saves, and the drill-down save-as-view path checks each rule ' - + 'against this schema before persisting it (read at the pinned objectui commit). ' - + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: ' - + 'there is no value to infer, and writing a value, switching to a unary operator and ' - + 'deleting the rule are three different predicates only the author can choose between. ' - + 'The read path does not re-validate stored rows (the reading the sibling entry ' - + 'view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading ' - + '— and keeps failing its queries, as it did before this change; what changes is that ' - + 'RE-SAVING it is refused at the value path, naming the operator and the field. ' - + 'ADR-0049 / ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep your authored views, pages and object-* blocks for a filter rule that has no value ' - + 'key and whose operator is none of the four unary operators, then decide per rule which ' - + 'of three things it meant: a comparison (write the value), a test for emptiness (switch ' - + 'to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). ' - + 'os validate reports each one by path with the operator and the field, so the sweep is ' - + 'mechanical rather than by eye. A view carrying one of these rules was refusing every ' - + 'query before this change, so re-check what it is supposed to show rather than assuming ' - + 'any earlier result set.', - }, - // A TYPE-surface narrowing registered here for the reason - // `spec-type-alias-input-suffix-retired` is: the surface is a TypeScript - // declaration, so there is no stored source for a D2 conversion to rewrite, and - // the compiler error it produces names the canonical vocabulary but not which - // member an alias spelling maps to. This guide is the channel that carries that - // second half. Nothing at rest moves and the runtime accept set is unchanged. - { - id: 'view-filter-rule-operator-input-canonical', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every ' - + 'carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the ' - + 'related-list, record-picker and object-* block filter doors)', - replacement: - 'the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A ' - + 'typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps ' - + 'to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to ' - + 'not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, ' - + 'isNull to is_null, and the rest). A value that is not yet known to be an operator — ' - + 'read from storage, a URL or user input — is typed unknown and handed to ' - + 'ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the ' - + 'schema stays the judge', - reason: - 'The operator key is a z.preprocess over the alias fold, and zod types a preprocess\'s ' - + 'INPUT from its function\'s parameter. That parameter was unknown, so ViewFilterRule (a ' - + 'z.input) typed operator as unknown: a rule with operator: 42, or any string at all, ' - + 'compiled on every carrier and was refused only when the door parsed it. The typed input ' - + 'is now the canonical ViewFilterOperator, the vocabulary the alias table\'s own contract ' - + 'says new producers emit. ' - + 'The RUNTIME does not move: the door still folds every spelling it folded before to ' - + 'canonical and still refuses a non-string with the enum\'s own ' - + 'issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS ' - + 'producer that carries an alias keep parsing exactly as before, and os validate answers ' - + 'as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 ' - + 'conversion: what narrows is only what TypeScript source may write. ' - + 'The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists ' - + 'to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ' - + 'ADR-0122.', - acceptanceCriteria: - 'Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a ' - + 'non-string, naming the canonical vocabulary. Rewrite each alias to the id ' - + 'VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the ' - + 'door already folded it to that id — and for a value typed string that is really ' - + 'unvalidated input, type it unknown and parse it rather than casting it. Stored views need ' - + 'no action: they load and parse as before.', - }, - // The scalar half of the coupling #6227 declared and did not judge. Recorded - // here rather than amended onto `view-filter-rule-value-shaped-by-operator` - // because that entry's own prose states the OPPOSITE reading as accepted, and - // an upgrade guide that quietly rewrites a shipped prescription leaves the - // reader who followed it with no trace of why their metadata now fails. - { - id: 'view-filter-rule-scalar-operator-array-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one ' - + 'value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, ' - + 'greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on ' - + 'every carrier of ViewFilterRuleSchema', - replacement: - 'one scalar — a string, number, boolean or null. A rule written ' - + 'value: ["won"] on equals becomes value: "won"; a rule that really did mean membership ' - + 'of a list becomes operator: "in" with the array unchanged. The list operators (in / ' - + 'not_in) and the range operator (between) are untouched and still take their arrays. ' - + 'The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched ' - + 'too: they take their direction from the operator NAME and their value position is ' - + 'discarded, so whatever sits there still parses, array included. An omitted value is ' - + 'still an omitted value', - reason: - 'Closing the protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' - + 'render-time filter converter — the protocol is the only refusal set, so a document it ' - + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' - + 'protocol\'s to close」. The value key\'s own published description has declared this rule ' - + 'since the value was first shaped by its operator — ' - + '「every other operator takes a scalar」 — and the refinement that implements the ' - + 'coupling returned early for every operator that is neither a list operator nor ' - + 'between, so the entire scalar class was declared and, from then until this change, ' - + 'not judged. ' - + '⚠️ This REVERSES a reading recorded in the sibling entry ' - + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' - + 'array as deliberately accepted because it 「lowers to a deep-equality comparand' - + '」. The backends a lowered view rule reaches ' - + 'at this release do not agree, so each is named rather than generalised. The SQL ' - + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' - + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' - + 'of the six accepted comparand types the platform declares in ' - + 'ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld ' - + 'INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm ' - + 'drivers built on driver-sql, and turso\'s remote transport. driver-memory REFUSES the ' - + 'same shape in the same envelope (its assertFilterConditionShape throws on an array in ' - + 'the implicit-equality position). ' - + 'driver-mongodb ANSWERS: its translateFilter passes ' - + 'the array through unchanged and the engine\'s shared comparand doors ' - + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' - + 'server applies MongoDB\'s equality rule for an array operand — a row matches when its ' - + 'stored array equals the value or holds the value as one of its elements, and a row ' - + 'storing the scalar does not match. That MongoDB reading is taken at the driver\'s ' - + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' - + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' - + 'driver-mongodb\'s translateFilter and mingo were each run on the ' - + 'lowered node beside a scalar and an $in control; ' - + 'MySQL and a live Turso server were NOT measured. ' - + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' - + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' - + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' - + 'two on equals has no honest single value, and picking the first is a different ' - + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' - + 'loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ' - + 'ADR-0087 / ADR-0112.', - acceptanceCriteria: - 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' - + 'none of in / not_in / between / the four unary operators and whose value is an array, ' - + 'then decide per rule which of the two things it meant: one value, or membership. ' - + 'os validate reports each one by path with the operator, the received shape ' - + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' - + 'Either way, re-check what the view is ' - + 'supposed to show rather than assuming the old result set was correct. A one-element ' - + 'array is the case to read closest: its two corrected spellings — value: "won" on ' - + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' - + 'set cannot tell you which the metadata meant, and only the author knows.', - }, - // Stage (iv) of ruling 甲 on #20051. A write-time narrowing of the ViewItem wire - // member: a stored record it newly refuses is read and served exactly as before - // and fails only on its next save, which is why this is a semantic entry and - // not a conversion — whether a record's top-level bag duplicates, contradicts - // or extends its `config` blocks is a fact only its author holds. - { - id: 'view-item-options-bag-refused', - surface: - 'A top-level `options` bag on a `view` item RECORD (`{ name, object, viewKind, config }`) saved through ' - + 'the metadata write door (`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, ' - + '`options.calendar`, `options.timeline` and every other key in it, on either `viewKind`.', - replacement: - 'The same per-kind blocks under the record\'s `config` — `options.kanban` becomes `config.kanban`, ' - + '`options.timeline` becomes `config.timeline` — where each is judged by its kind\'s own block schema, ' - + 'and a key that block does not declare is re-spelled or deleted as its refusal says. A key `config` ' - + 'already sets wins; the bag\'s copy is deleted. The flattened list overlay (no `config`) keeps its ' - + 'legacy `options` bag, judged key by key, as before.', - reason: - 'The record member re-opens its top level with a strip so the console\'s round-trip keys survive, and ' - + 'that strip dropped a top-level `options` bag from the parse without looking inside it, while the save ' - + 'stored the request body. The console reads a record\'s body from `config` on the object page but ' - + 'spreads the whole stored record on the interface page, so the same saved view rendered two ways. ' - + 'Now that the save stores the parsed body, the bag would instead vanish silently on the next save. ' - + 'The maintainer\'s ruling of 2026-09-30 refuses it by name with the prescription to ' - + 'write `config.KIND`; declaring it would have kept a second spelling of one block on a second member. ' - + 'No console write puts the bag on a record. Not convertible: which of two spellings of one block the ' - + 'author meant, where both are set, is the author\'s call.', - acceptanceCriteria: - 'Every stored `view` record carrying a top-level `options` saves again after its blocks move under ' - + '`config`. A record that still carries the bag is refused `422 INVALID_METADATA` on its next save, with ' - + 'an issue at `options` whose message prescribes `config.KIND`. Nothing is rewritten on read and nothing ' - + 'is refused on read: a record that fails is served exactly as stored until it is saved. Verify by ' - + 're-saving each stored record that carries `options` (a GET then a PUT of the same body) and reading ' - + 'a `200`.', - }, - // #20085 (ADR-0049 enforce-or-remove; triage direction 「retire both keys」) — - // the D3 entry of the `view-item-owner-hidden-removed` family (ruling B on - // #17152: one D3 entry per retirement family, even when D2 is lossless). - // Registered keys: `owner` / `hidden` on `ui/ViewItem` and on the wire member - // `ui/ViewItemWire`. The delete changes no render and no listing; what it - // leaves is a visibility belief — views marked private or hidden were listed - // to everyone who could read the object. - { - id: 'view-item-owner-hidden-retired', - surface: 'view.owner / view.hidden on the view item record — the per-user owner and the ' - + 'switcher-hidden flag', - replacement: '(removed — no per-user view scope and no switcher filter exists.) A view is listed ' - + 'to everyone who can read its object. A view that must not be listed is deleted; per-user ' - + 'view scoping is a parked direction, not a shipped mechanism.', - reason: 'The D2 conversion `view-item-owner-hidden-removed` deletes both keys from every view ' - + 'item RECORD — in `views` (stack sources and stored rows) and in the assembled-manifest view ' - + 'item channel (package export, environment artifacts) — and the delete is lossless: both ' - + 'switcher read paths filter on the view kind and object and sort on `order`, so ' - + '`hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure. ' - + 'A view an author marked as one user\'s, or hid from the switcher, has always been listed to ' - + 'every user who can read the object — its name, its columns, its filters and its sort. ' - + 'Whether anything in such a view was meant to stay private, and whether it should now be ' - + 'deleted rather than kept, is the author\'s call. A flattened view overlay\'s own `owner` and ' - + '`hidden` are a separate family on a different door, with their own D2 conversion ' - + '`view-overlay-owner-hidden-removed` and their own D3 entry `view-overlay-owner-hidden-retired`.', - acceptanceCriteria: 'No view item record in `views` or in an assembled artifact carries `owner` or ' - + '`hidden`; the parse refuses both by name, and an artifact assembled before the upgrade ' - + 'registers without a refusal over them. For every view that had carried either key, the ' - + 'author has confirmed that everything it shows may be listed to all readers of its object, ' - + 'or has deleted it. The view switcher lists the same views as before the upgrade.', - }, - // #20186, route C-prime (seat answers 5855433719 / 5855548706). A write-time - // re-routing of the flattened view overlays: each member judges only the - // `viewKind` it names. The stored rows it newly refuses are read and served - // exactly as before and fail only on their next save, which is why this is a - // semantic entry and not a conversion — which value a refused key meant is a - // fact only its author holds. - { - id: 'view-overlay-judged-by-viewkind-arm', - surface: - 'A flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the ' - + 'Studio / MCP save) whose `viewKind` names one family while the body was judged by the other: a ' - + 'column-less `viewKind: "list"` body, which only the form overlay member used to accept (its list keys ' - + '`sort`, `searchableFields`, `timeline`, `sharing` and the rest stripped unread), and a `viewKind: "form"` ' - + 'body carrying list `columns`, which only the list overlay member used to accept.', - replacement: - 'Each overlay is judged by the member its `viewKind` names. A column-less list overlay is a patch on the ' - + 'view it shadows and carries list keys the list view schema accepts: a `sort` array of `{ field, order }` ' - + '(the bare string clause was retired in 17.5.0), no `timeline.metaFields` (the timeline block has no such ' - + 'key), an array `searchableFields`, and the list `sharing` block (`{ type, lockedBy }`), not the form ' - + 'public-link block. A column-less list overlay names no `type`; one that does is a full inline config and ' - + 'lists its `columns`. A form overlay\'s `columns` is its body-column count (an integer); a field list ' - + 'means the body is a list view (`viewKind: "list"`) or belongs in `sections: [{ fields }]`.', - reason: - 'Both overlay members shared one `viewKind: list | form` enum. The list member required `columns`, so it ' - + 'refused the column-less list patch the console writes on every toolbar save (the ruled patch-only ' - + 'storage shape, maintainer ruling: 「`persistViewPatch` 只存 patch,不存 merged base」); the ' - + 'union then tried the form member, which requires no list key and strips every one, and accepted it — ' - + 'so a retired `sort` string or a `timeline.metaFields` the list schema refuses by name was saved with ' - + '`success: true` and stored as sent. Measured on `origin/main` @ `4df101c3` and again at `ce70876e` ' - + '(after the `options`-bag door landed) through the real save. Ruled route C-prime: each member admits one `viewKind`, the list ' - + 'member judges a column-less patch (`columns` optional there only; the authoring list view keeps it ' - + 'required), and a column-less body that names a `type` stays refused at `columns`. Not convertible: ' - + 'whether a refused value was a typo or a stale capability is the author\'s call.', - acceptanceCriteria: - 'Every stored flattened `view` overlay saves again unchanged. A row that does not is refused ' - + '`422 INVALID_METADATA` on its next save, with the issue located at the refused key (`sort`, `timeline`, ' - + '`searchableFields`, `sharing`, `columns`) and carrying that key\'s own message — for a column-less list ' - + 'overlay naming a `type`, the prescription at `columns`; for a field list on a form overlay, the ' - + 'body-column-count prescription at `columns`. Nothing is rewritten on read and nothing is refused on ' - + 'read: a row that fails is served exactly as stored until it is saved. Verify by re-saving each stored ' - + 'flattened overlay (a GET then a PUT of the same body) and reading a `200`.', - }, - // The door half of ruling A on objectui#10380 (#20051). A write-time narrowing - // of the flattened view overlays: the stored rows it newly refuses are read and - // served exactly as before and fail only on their next save, which is why this - // is a semantic entry and not a conversion — which key a row meant is a fact - // only its author holds. - { - id: 'view-overlay-options-bag-judged', - surface: - 'The legacy `options` bag on a flattened `view` overlay saved through the metadata write door ' - + '(`PUT /api/v1/meta/view/:name`, the Studio / MCP save): `options.kanban`, `options.calendar`, ' - + '`options.gantt`, `options.gallery`, `options.timeline`, `options.chart`, `options.map` and ' - + '`options.tree` on a list overlay, any other key in the bag, and the bag on a form overlay.', - replacement: - 'Each `options.KIND` block carrying only keys the top-level `KIND` block declares, with values that ' - + 'block accepts — or, preferred, the same keys moved to the top-level `KIND` block, which wins per key ' - + 'where both set one. A key the block does not declare is deleted or re-spelled to the declared key the ' - + 'refusal names (`options.kanban.groupField` becomes `groupByField`, `options.calendar.dateField` ' - + 'becomes `startDateField`); `options.timeline.metaFields` has no declared successor and is deleted. ' - + 'Any other key in the bag is deleted, and a form overlay carries no bag at all.', - reason: - 'The list overlay member re-opens its top level with a strip so the console\'s round-trip keys ' - + 'survive, and that strip dropped the `options` bag from the parse without looking inside it. The save ' - + 'stores the request body, not the parse output, and objectui\'s interface page forwards a stored ' - + 'view\'s `options` into the list renderer, which merges `options.KIND` under the top-level block — so a ' - + 'key the strict block refuses by name (`timeline.metaFields`) was saved and rendered when spelled ' - + '`options.timeline.metaFields`. Measured on `origin/main` @ `8d1f7ab` through the real save. Ruled ' - + 'direction A (the maintainer\'s ruling of 2026-09-24): judge each `options.KIND` with the kind\'s strict schema and ' - + 'refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled ' - + 'out because the legacy `options.map` path is live and pinned. Judged key by key, because the ' - + 'renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the ' - + 'keys the top-level block leaves to it is legal and stays accepted. Not convertible: whether a ' - + 'refused key was a typo of a declared one or a retired capability is the author\'s call.', - acceptanceCriteria: - 'Every stored `view` overlay carrying a top-level `options` saves again unchanged. A row that does not ' - + 'is refused `422 INVALID_METADATA` on its next save, with an `unrecognized_keys` issue at ' - + '`options.KIND` naming the key and carrying the same message the direct spelling gets at `KIND` — ' - + 'or at `options` for a key that is not a kind, or a form overlay\'s bag. Nothing is rewritten on ' - + 'read and nothing is refused on read: a row that fails is served exactly as stored until it is ' - + 'saved. Verify by re-saving each stored overlay that carries `options` (a GET then a PUT of the ' - + 'same body) and reading a `200`.', - }, - // #20230 (ADR-0049 enforce-or-remove; triage direction 「follow #20085's - // disposition for the same key pair」) — the D3 entry of the - // `view-overlay-owner-hidden-removed` family (ruling B on #17152: one D3 entry - // per retirement family, even when D2 is lossless). Registered keys: `owner` / - // `hidden` on `ui/ViewMetadata`, the door the two flattened overlay members are - // reached through. The same key pair on the view item RECORD is a separate - // family with its own conversion and its own D3 entry, disjoint from this one - // by `config`; the two share the prescription texts, not a conversion. - { - id: 'view-overlay-owner-hidden-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: - 'view.owner / view.hidden on a flattened view overlay — the lean PUT /api/v1/meta/view/:name body ' - + 'with no config that the console saves for a view it personalizes', - replacement: - '(removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone ' - + 'who can read its object. A view that must not be listed is deleted, or no longer shipped from ' - + 'source; per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism.', - reason: - 'The D2 conversion `view-overlay-owner-hidden-removed` deletes both keys from every flattened ' - + 'overlay (a view body with no `config` and no container slot) — in `views` (stack sources, and ' - + 'every stored row, replayed on each read before it is served or badged) and in the ' - + 'assembled-manifest view item channel — and the delete is lossless: both switcher read paths ' - + 'filter on the view kind and object and sort on `order`, so an overlay saved with ' - + '`hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure, ' - + 'the same one the view item record\'s retirement leaves. A view someone hid or marked as one ' - + 'user\'s through its overlay has always been listed to every user who can read the object. ' - + 'The delete is lossless but does not always close the row: an overlay row that held nothing ' - + 'but its identity and these keys is left identity-only, a body the view door refuses, so ' - + 'that row needs its author (see the acceptance criteria). ' - + 'Measured writers in this repository and its sibling UI: zero (no source, example or skill, ' - + 'and objectui at its pinned commit and at main writes neither key on an overlay; the HotCRM ' - + 'app writes neither). NOT MEASURED: clients outside this repository, and production stored ' - + 'rows — the write door accepted and stored both until this release, and no deployment store ' - + 'is reachable from here.', - acceptanceCriteria: - 'No flattened view overlay you save carries `owner` or `hidden`: the write door refuses either ' - + 'with 422 INVALID_METADATA, the issue located at the key and the retirement prescription as ' - + 'its message. A stored overlay row that held either is stripped of it on every read, and what ' - + 'follows depends on what else the row holds. (1) A row with any other view key (a column ' - + 'state, a sort, a default flag, an order) is served and badged valid without the keys, a GET ' - + 'then a PUT of the whole row answers 200 (if it was otherwise valid), and ' - + '`os migrate meta --stored --apply` rewrites it. (2) A hide-only row — nothing but its ' - + 'identity (name, object, viewKind, label) and `owner` / `hidden`, such as ' - + '`{ object, viewKind, hidden: true }` — is left with identity only, which the view door ' - + 'refuses ("only identity fields"): it is served badged invalid (it was badged valid before ' - + 'this release), a whole-row re-save or one that adds only identity answers 422 ' - + 'INVALID_METADATA, and `--apply` reports it `failed` and leaves it as stored. A write that ' - + 'adds a real view key, such as a toolbar toggle, saves. Resolve each such row: delete it (it ' - + 'never changed what anyone saw), or add the personalization setting its author meant and ' - + 'save that. For every view whose overlay had carried either key, its author has confirmed ' - + 'that the view may be listed to all readers of its object, or has deleted it. No switcher ' - + 'read path ever read either key, so which views the switcher lists does not change.', - }, - // The display page size a view gets when it declares none moved from 25 to 50 - // (maintainer ruling on objectui#9853). A default move reaches every silent - // document with no parse error and nothing in the author's diff, so the - // upgrade path carries it as a TODO: only the deployment can say whether a - // view that never declared a page size was relying on 25. - { - id: 'view-pagination-page-size-default-50', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell. - surface: 'ui.PaginationConfig.pageSize — an OMITTED page size on a view', - replacement: 'nothing, to take the platform display page size of 50. To keep the old 25 rows ' - + 'per page on a view, write it: `pagination: { pageSize: 25 }`', - reason: - 'A RULED behaviour change on a default, so there is nothing to rewrite and nothing to ' - + 'refuse: the maintainer\'s ruling of 2026-09-24 set the platform display page size to 50, ' - + 'declared once in the protocol, and the declared default of `PaginationConfigSchema.pageSize` moved ' - + 'from 25 to 50. A `pagination` block that omits `pageSize` now parses to 50 — 50 rows ' - + 'per page on a paged view, and a fetch ceiling of 50 on a view with no pager (kanban, ' - + 'gallery, timeline). A view with no `pagination` block at all parses with none on either ' - + 'side; its page size reaches it through the renderer, which is ruled to read the spec ' - + 'default rather than keep its own number (an earlier ruling on the grid\'s page size, which ' - + 'the page-size ruling restated). Not losslessly ' - + 'convertible because the question is intent, not text: a mechanical pass that wrote ' - + '`pageSize: 25` into every silent view would preserve the old number and defeat the ' - + 'ruling, and one that wrote 50 would add nothing the default does not already do. Only ' - + 'the deployment knows which silent views were relying on 25. The accept set is unchanged ' - + '— a positive integer — and every authored `pageSize` parses exactly as before.', - acceptanceCriteria: - 'An empty pagination configuration parses to a page size of 50, and a list view carrying ' - + '`pagination: {}` parses to `pagination.pageSize` 50; an authored ' - + '`pagination: { pageSize: 25 }` still parses to 25; `pageSize: 0`, a negative and a ' - + 'fraction are still refused. A view that must keep 25 rows per page declares ' - + '`pagination: { pageSize: 25 }` and shows 25 rows on its first page.', - }, - // A TS/API surface, never stored in stack metadata: there is no source for the - // chain to rewrite, so this entry is the whole ADR-0087 registration. - { - id: 'visibility-strict-options-unexported', - surface: - '`VISIBILITY_STRICT_OPTIONS` (const) on `@objectstack/spec/shared` — the shared ' - + '`strictObject` options of the visibility-carrying view/page shapes (ADR-0089 D3a)', - replacement: - '(removed from the public surface — no replacement export. It was an internal option bag ' - + 'for this package\'s own schemas; the visibility contract it configures is unchanged and ' - + 'still published through the schemas that use it — `FormFieldSchema`, `FormSectionSchema` ' - + 'and the page component — together with `normalizeVisibleWhen` and ' - + '`VISIBILITY_ALIAS_KEYS`, which stay exported.)', - reason: - 'ADR-0049 enforce-or-remove applied to an export. The const was barrel-exported while its ' - + 'type, `StrictObjectOptions`, is deliberately unpublished, so no consumer could annotate it, ' - + 'spread it into a typed option bag or name it in a parameter — a published value with no ' - + 'usable contract and zero measured pull outside this package. Publishing the type instead ' - + 'was weighed and not adopted: no consumer ever asked for it, and it would turn the ' - + 'strict-object template\'s internals into public API.', - acceptanceCriteria: - 'No code imports `VISIBILITY_STRICT_OPTIONS` from `@objectstack/spec`, `@objectstack/spec/shared` ' - + 'or any other entry (TS2305 after upgrade). Every visibility-carrying shape parses and ' - + 'refuses exactly as before — the options object is unchanged, only where it is exported ' - + 'from moved. No authored metadata document ever carried it, so `os migrate meta` has ' - + 'nothing to visit.', - }, - { - id: 'wait-node-event-config-required', - surface: - 'The `waitEventConfig` block of every `type: \'wait\'` flow node, and the ' - + '`boundaryConfig` block of every `type: \'boundary_event\'` node — the BLOCK, not a key ' - + 'inside it. `eventType` has been required INSIDE each block since protocol 17, so the ' - + 'contract already refused `waitEventConfig: {}`; what it also accepted was the block ' - + 'missing entirely, which is the state a freshly created node is in. Two documents, two ' - + 'verdicts, and the accepted one was the silent one. Also narrowed one level down: under ' - + '`eventType: \'timer\'`, `timerDuration` is now required and may not be blank. ⚠️ That ' - + 'second narrowing sits on the BLOCK and is NOT gated on `type: \'wait\'`, so it reaches ' - + 'any node type that carries a `waitEventConfig` at all — a `start` node spelled ' - + '`waitEventConfig: { eventType: \'timer\' }` parsed before and is refused now. Inert in ' - + 'practice, because no executor but the wait one reads the block, but a stack that spells ' - + 'it elsewhere must be edited too, so scan for the KEY and not only for the node type.', - replacement: - 'Declare what resumes the node, on the node: `waitEventConfig: { eventType: \'timer\', ' - + 'timerDuration: \'PT1H\' }` for a delay — QUOTE a bare number, the key is a string and a ' - + 'numeric string is read as milliseconds, so \'60000\' is the same 60s wait as \'PT1M\' — ' - + 'or `{ eventType: \'signal\' | \'webhook\' | \'manual\' | \'condition\', signalName: ' - + '\'\' }` when an external producer resumes the run. For `boundary_event`, ' - + '`boundaryConfig: { attachedToNodeId: \'\', eventType: \'error\' | \'timer\' | ' - + '\'signal\' | \'cancel\' }`. ⛔ There is deliberately NO default for either `eventType`: a ' - + 'required key has no "unset behaves as", and an indefinite park — if one is ever wanted — ' - + 'is its own declared `eventType`, never the absence of configuration. ⚠️ `boundary_event` ' - + 'has no executor in the runtime at all (a flow reaching one fails with NO_EXECUTOR), so a ' - + 'stored boundary node is an authoring-surface repair: the native construct for error ' - + 'handling is a `try_catch` region (ADR-0031).', - reason: - 'Maintainer ruling of 2026-09-13, the clause of the reply that covers this item, verbatim ' - + 'and untranslated: 「其他同意」 — carrying the presented option: the ' - + 'protocol is the source of truth; a designer never invents a default the protocol does ' - + 'not apply; a default the protocol should have is declared by the protocol; a required ' - + 'key has no "unset behaves as". ⛔ NOT losslessly convertible, and the reason is that the ' - + 'missing value is an INTENT no artifact records: a block-less wait node does not say ' - + 'whether its author meant a delay (and for how long) or a named signal (and which one), ' - + 'and a transform that picked one would be inventing the very default this ruling forbids. ' - + 'What the old runtime picked was \'timer\' with no duration, which is not a wait at all: ' - + 'measured through a real `engine.execute()` run, such a node answered `{ success: true, ' - + 'suspend: true }`, scheduled no wake-up job THOUGH A JOB SERVICE WAS ANSWERING, persisted ' - + 'no `waitUntil` for a later boot\'s re-arm pass, and emitted not one log line at any ' - + 'level — the run parked forever and reported success. So the conversion layer (D2) cannot ' - + 'hide this break and the tombstone channel cannot carry it either (nothing was renamed or ' - + 'retired; a key that was optional became required), which leaves D3: a structured TODO ' - + 'naming each node that must be edited. The alternative considered and NOT taken was to ' - + 'warn and keep parsing — a warning on the authoring path an AI agent drives is read by ' - + 'nobody, and the agent reports "done" over a flow that hangs.', - acceptanceCriteria: - 'Every `type: \'wait\'` node in the stack — at the top level AND inside every ADR-0031 ' - + 'region body — carries a `waitEventConfig` with an `eventType`, and every one whose ' - + '`eventType` is \'timer\' carries a non-blank `timerDuration`; every `type: ' - + '\'boundary_event\'` node carries a `boundaryConfig` with an `attachedToNodeId` and an ' - + '`eventType`. `FlowSchema.parse` (and therefore `registerFlow`, `os validate` and a ' - + 'Studio publish) accepts the stack: a node still missing its block is refused with the ' - + 'key named at `nodes[i].waitEventConfig` / `nodes[i].boundaryConfig` and the remedy in ' - + 'the message. ⚠️ A region body is checked through the REGION contract rather than the ' - + 'flow parse — `parseFlowNodeRegions` leaves a refused region raw — so a nested node is ' - + 'named by `LoopConfigSchema` / `ParallelConfigSchema` / `TryCatchConfigSchema` at ' - + '`body.nodes[i].waitEventConfig`, and at run time by the container node\'s own ' - + 'execute-time config parse; check the nested ones by parsing the container config, not ' - + 'only by parsing the flow. Behaviour to re-check after editing, because the fix CHANGES ' - + 'IT deliberately: a run that used to park forever on such a node now either waits the ' - + 'duration you declared or waits for the signal you named — anything that resumed those ' - + 'runs by hand (an operator calling `resume(runId)`, a nightly sweep) has less to do, and ' - + 'anything that COUNTED on the park is now on a timer.', - }, - { - id: 'websocket-durations-unit-in-key', - surface: 'the four WebSocket configuration durations whose name carried no unit: ' - + 'WebSocketConfig.reconnectInterval, WebSocketConfig.pingInterval, WebSocketConfig.timeout ' - + 'and WebSocketServerConfig.heartbeatInterval (api/websocket.zod.ts)', - replacement: 'reconnectIntervalMs, pingIntervalMs, timeoutMs and heartbeatIntervalMs — rename ' - + 'each key; every value is unchanged, and so is every default (1000, 30000, 5000, 30000)', - reason: - 'Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): ' - + 'the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying ' - + 'value, never only in the describe prose, and no existing offender is grandfathered. ' - + 'What makes this shape worth one entry rather than four is the neighbour: on both configs a ' - + 'bare duration sits directly beside a bare COUNT — maxReconnectAttempts on the client, ' - + 'reconnectAttempts on the server — so `reconnectInterval: 5` and `maxReconnectAttempts: 5` ' - + 'read as the same kind of number and are not. Suffixing the durations separates the two ' - + 'families at the authoring site; the counts keep their names, because a count has no unit to ' - + 'carry. All four are retiredKey() tombstones (neither shape is strict, so a bare deletion ' - + 'would strip in silence). Why a semantic entry and not a D2 conversion: a WebSocketConfig is ' - + 'a client CONNECTION argument and a WebSocketServerConfig is a server CONSTRUCTION argument ' - + '— neither is a stack collection member and neither is ever stored as a sys_metadata row, so ' - + 'the conversion chain has no seam that would see one. The same disposition the ' - + 'epoch-instant renames on this file took (epoch-instant-keys-renamed), and what ruling B ' - + 'prescribes for a key that is not authorable metadata. ADR-0087.', - acceptanceCriteria: - 'Every WebSocketConfigSchema.parse(…) / WebSocketServerConfigSchema.parse(…) site and every ' - + 'literal handed to a WebSocket client or server spells the suffixed keys; authoring any old ' - + 'spelling fails to compile (input type `never`) and fails to parse with the rename ' - + 'prescription. Behaviour is unchanged in every case: a client configured with ' - + '`reconnectIntervalMs: 2000` retries after two seconds exactly as `reconnectInterval: 2000` ' - + 'did, and a config that omits the keys still gets the same defaults. The positive-integer ' - + 'bounds ride along with the renamed keys, so a zero or negative interval is still refused.', - }, - // - ], -}; - -/** All migration steps, keyed by the major they migrate into. */ -export const MIGRATIONS_BY_MAJOR: Readonly> = { - 17: step17, - 18: step18, -}; - -/** The majors that have a step, ascending. */ -export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJOR) - .map(Number) - .sort((a, b) => a - b); - -/** - * Every authorable key TOMBSTONED at each protocol major, named EXACTLY. - * - * One entry per key, spelled `${defKey}:${name}` — the same string - * `packages/spec/authorable-surface.json` records, minus its `[RETIRED]` mark - * (`'automation/Event:type'`, `'ui/App:sharing'`). Not a surface path, not a - * prose clause, not a prefix: the literal key. - * - * ## What reads it - * - * Two gates in `scripts/build-schemas.ts` (`check:authorable-surface`), and - * nothing infers an entry for either — the author writes the key down or the - * gate stays red: - * - * - **Check (b)**, the retirement gate: a key that flips live → retired must - * appear here, by exact set membership, or the build fails. Two companions - * read the table in the other direction: check (b2) refuses an entry naming a - * key this build still emits as LIVE, and check (b3) — nested rows only — - * refuses a dotted entry whose def this build emits but whose path it does - * not (#17969). See "Lifecycle" below for what each of them means for a row - * already in the table. - * - **Check (c)**, the baseline-deletion gate (#4650): the major recorded here - * is what starts a tombstone's ~two-major aging clock, so it is also what - * eventually lets its `authorable-surface/` line be deleted. Since #5898 — - * before that, (c) dated the clock by matching the key's LEAF against the - * conversion registry, and took the `Math.min` of the matches. - * - * ## Why exact keys, and not the conversion `surface` - * - * Check (b) used to satisfy itself by matching the key's LEAF against every - * `surface` registered in {@link CONVERSIONS_BY_MAJOR} / {@link - * MIGRATIONS_BY_MAJOR} — `endsWith('.' + name)`, across all majors, ignoring - * which def the key belonged to. Any unrelated registration ending in the same - * leaf therefore registered a tombstone for free: measured in #4658, tombstoning - * `automation/Event:type` passed silently because protocol 11's - * `flow-node-http-callout-rename` had registered `flow.node.type`. The exposure - * grew with the vocabulary — `type`, `name`, `config`, `filter`, `schema`, - * `description` (#5509) are ordinary leaves on hundreds of authorable shapes — - * so the gate's whole guarantee had lapsed for the most common keys (#4659). - * - * A `surface` is deliberately PROSE: it addresses authors in the shape they - * write metadata (`flow.nodes[].outputSchema`), which no rule can map back onto - * a def key. So the registration moved here instead of being encoded into the - * surface — the conversion keeps its prose, and this table carries the machine - * fact. - * - * ## What it does NOT replace - * - * The D2 conversion (`src/conversions/registry.ts`) and D3 semantic entry stay - * the *documentation* channel: `spec-changes.json` (ADR-0087 D4), the generated - * upgrade guide and `os migrate meta` are projections of those, not of this - * table. An entry here is the *proof the retirement was declared*; the - * conversion is the *prescription a consumer follows*. A retirement needs both. - * - * ## Historical tombstones: absent, and therefore not deletable - * - * Check (b) fires only on a NEW live → retired transition — measured against the - * committed `authorable-surface.json` baseline, which already records every - * older tombstone as `[RETIRED]`. The 97 retirements that landed before this - * table existed therefore never re-trigger it and are deliberately absent: this - * reads "retirements registered under the exact-key gate", not "every retirement - * ever". - * - * #5898 measured what it would take to backfill them, and neither available - * source can date a row honestly: - * - * - **Leaf-matching the conversion registry** is precisely the inference #4659 - * removed. It is also demonstrably wrong in both directions on this very data: - * of the two rows it dated as aged-out, `data/Index:type` was dated by - * `flow.node.type` (an unrelated cluster) and `api/RestApiConfig:requireAuth` - * by `api.requireAuth` at major 12 — which is the secure-default FLIP, a - * different kind of change to the same surface, not its retirement. - * - **The git history of `authorable-surface.json`** begins at 17.0.0-rc.0, so - * every one of the 97 rows first carries `[RETIRED]` at major 17. That is an - * artifact of the baseline file's birth, not archaeology. - * - * So the rows stay undeclared, and check (c) is fail-closed about it: a - * tombstone with no entry here cannot prove its age and its baseline line may - * not be deleted. Deleting one is a deliberate, reviewable act — establish the - * true major for that key, add it below, and check (b2) verifies the entry still - * names a key this build tombstones. Do NOT add a row you cannot date; an - * estimate written down here reads as a fact to every later gate. - * - * ## Lifecycle - * - * A TRUE entry is permanent. A declared TOP-LEVEL tombstone ages out after ~two - * majors and its line may then leave `authorable-surface.json` (check (c)); its - * entry here stays, and then names a key the build no longer emits — the - * expected steady state, not an error. A NESTED row never reaches that file at - * all (0 dotted entries on the shipped surface), so it has no aging clock and - * "the build no longer emits it" is never that steady state for one. - * - * Two states are rejected. A row is read as a PATH only when its `name` half - * carries a dot AND this build emits no top-level property of that exact name, - * so a live dotted TOP-LEVEL key (`@odata.context`, a SCIM extension URN) stays - * on the map check (b2) has always judged it on: - * - * - **Still LIVE** (check (b2)): the entry names a key this build still emits as - * writable — a registration nothing consumed, pre-approving a retirement that - * has not happened. Remedy: tombstone the key, or delete the entry. - * - **Nested and unresolvable** (check (b3), #17969): the entry's def IS emitted - * and the dotted path is not in it. Because checks (a0)/(a)/(b)/(c) are - * structurally blind to a nested key, no gated route can produce this state — - * the row is wrong (a typo'd def, a typo'd path, a stale key name). Remedy: - * fix the spelling against the emitted schema, or delete the entry. A row - * whose DEF this build does not emit is NOT this state: that is the whole-def - * removal steady state, registered in {@link RETIRED_DEFS_BY_MAJOR}. - * - * ⛔ "Delete the entry" and "entries are permanent" do not conflict, and reading - * one as licence to drop the other is the way to lose a retirement. A row that - * was ever TRUE of some build is history and is never deleted; a row check (b2) - * or (b3) refuses was never true of any build, so deleting it removes a false - * claim rather than a record. Check (b3) can also not yet tell a wrong row apart - * from every truthful one — its refusal text enumerates the shapes it knows it - * cannot see — and for those the remedy is to teach the check the shape, ⛔ never - * to delete a row that is telling the truth. - * - * @see scripts/build-schemas.ts — checks (b)/(b2)/(b3)/(c), the only consumers - */ -export const RETIRED_KEYS_BY_MAJOR: Readonly> = { - // The first entries since #4659 built this table (#5552). ONE tombstone - // produced THREE keys: `transform` is declared on `shared/FieldMapping`, and - // every `.extend()` of the base copies the retired property into its own - // walked shape, which `authorable-surface/` marks `[RETIRED]` separately. - // Registered per key, as the gate reads them — nothing radiates from the - // base. Two of the three remain: `data/ExternalFieldMapping:transform` left - // when #8075 retired the whole external-lookup family (the def retirement - // subsumes the key entry, the WidgetManifest.performance way — see - // RETIRED_DEFS_BY_MAJOR below). - 17: [ - // One file per entry under `entries/retired-keys/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // #7481 — the sibling of `api/AuthFeaturesConfig:passkeys`, retired in the same - // maintainer ruling (2026-08-11) and for the same reason, but with one - // difference a reader of this table must not lose: what was inert here is the - // ADVERTISEMENT, not the capability. `AuthPluginConfig.plugins.magicLink` still - // wires better-auth's magic-link plugin and `/api/v1/auth/magic-link/{send,verify}` - // still answer — no client just renders anything off the served flag, so the - // flag alone was the false promise. The prescription says so explicitly rather - // than reusing its sibling's string. - // - // Same response-surface disposition as its sibling: no D2 conversion (nothing - // authors an `AuthFeaturesConfig`), prescription carried by this tombstone plus - // the D3 semantic entry `auth-config-unadvertised-reserved-features`. - 'api/AuthFeaturesConfig:magicLink', - // #7481 — the `/api/v1/auth/config` `features` payload stops advertising the - // two capabilities no client can act on (maintainer ruling 2026-08-11, - // declared = enforced: a deployer must not be able to flip a flag that does - // nothing anywhere). `passkeys` is the emptier of the pair — no login UI reads - // it AND no better-auth passkey plugin is wired behind it. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the same reason - // as the `api/ListNotifications{Request,Response}:cursor` pair: this is a - // RESPONSE surface — the server mints it on every `GET /auth/config` and - // nobody authors or persists an `AuthFeaturesConfig` — so there is no source - // for `os migrate meta` to rewrite. The prescription reaches consumers as the - // D3 semantic entry `auth-config-unadvertised-reserved-features` plus this - // tombstone, the `EnhancedApiError.fieldErrors` disposition. - // - // The withdrawal is conditional, not permanent: the flags return in the change - // that ships the login UI (objectui#4179). Until then the standing record is - // `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` in `kernel/public-auth-features.ts`. - 'api/AuthFeaturesConfig:passkeys', - // Commit 90bbf2510 — the notification-inbox pagination key, tombstoned on BOTH halves - // of `GET /api/v1/notifications` because one capability is never half- - // deleted (maintainer ruling 2026-08-07, ruled jointly with the `unreadCount` fix, commit 17d095413). Two - // keys, one prescription: `NOTIFICATIONS_CURSOR_REMOVED` in - // `api/protocol.zod.ts` is the single string both rejection sites raise. - // - // Registered here but NOT in `src/conversions/registry.ts`, and that - // asymmetry is the point rather than an omission: a D2 conversion rewrites - // an authored source or a stored `sys_metadata` row, and these two shapes - // are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing - // persists one. The prescription reaches consumers as the D3 semantic entry - // `notification-list-cursor-retired` plus this tombstone, which is the - // disposition `BatchOptions.validateOnly` and the `AnalyticsQueryRequest` - // envelope keys already take in this major ("a semantic TODO for API - // callers rather than a stack conversion"). - 'api/ListNotificationsRequest:cursor', - 'api/ListNotificationsResponse:cursor', - // #6748 — ADR-0049 enforce-or-remove on the action-descriptor capability - // block. `isAsync` was a second spelling of `supportsPause` with ZERO - // readers on a fresh three-repo measurement; its sibling took the enforce - // leg in #6667 and this one takes the remove leg. Descriptors are published - // from executor TypeScript, not from stack metadata, so the D2 side is a D3 - // `SemanticMigration` (`action-descriptor-is-async-retired`) rather than a - // MetadataConversion — there is no stored source for `os migrate meta` to - // rewrite. The `EnhancedApiError.fieldErrors` precedent. - 'automation/ActionDescriptor:isAsync', - // #6815 — the per-aggregation DISTINCT flag, retired under ADR-0049 by - // maintainer ruling 2026-08-09. ONE key, and one entry, because - // `AggregationNodeSchema` is reused BY REFERENCE rather than `.extend()`ed: - // `QuerySchema.aggregations` and `EngineAggregateOptionsSchema. - // aggregations` are both `z.array(AggregationNodeSchema)`, so the walked - // shape has a single `data/AggregationNode` def and the baseline marks one - // line `[RETIRED]`. Contrast the `shared/FieldMapping:transform` trio in - // this same table, where two `.extend()`s copied the property into three - // walked shapes and each needed its own registration. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the same - // reason as the `api/ListNotifications{Request,Response}:cursor` pair: - // `QueryAST` is a REQUEST surface — - // the client SDK builder's output and the `POST /data/:object/query` body - // — never stored in stack metadata, so there is no authored source or - // `sys_metadata` row for a D2 conversion to rewrite. The prescription - // reaches consumers as the D3 semantic entry - // `aggregation-node-distinct-retired` plus this tombstone, which is the - // disposition every other `data.query.*` retirement in this major already - // takes (`query-joins-retired` / `query-cursor-retired` / - // `query-distinct-retired` / `query-window-functions-retired`, #4286). - 'data/AggregationNode:distinct', - // #8057 — the deprecated legacy update-options schema re-declared the - // never-implemented `upsert` flag, so it is tombstoned with the SAME - // prescription as `data/EngineUpdateOptions:upsert` (one string, both - // schemas plus the engine's unknown-option gate): a capability is never - // half-deleted. See that entry's comment for the full disposition; no D2 - // conversion, since an engine option bag is call-time only. - 'data/DataEngineUpdateOptions:upsert', - // #8057 — the never-implemented upsert flag on `engine.update()`'s option - // surface, tombstoned on BOTH update-options schemas (this one and the - // deprecated `DataEngineUpdateOptions` sibling) with one prescription: - // `ENGINE_UPDATE_UPSERT_REMOVED` in `data/data-engine.zod.ts`, which the - // objectql engine's unknown-option gate quotes too. Declared-but-unenforced - // (ADR-0049): the key sat on the engine's update allowlist while no code path - // read it, so `{ upsert: true }` was accepted and silently dropped. - // - // Registered here but NOT in `src/conversions/registry.ts`, and that asymmetry - // is the point rather than an omission: a D2 conversion rewrites an authored - // source or a stored `sys_metadata` row, and an engine option bag is call-time - // only — nobody authors one and nothing persists one. The prescription reaches - // consumers as the D3 semantic entry `engine-update-upsert-retired` plus this - // tombstone (the `BatchOptions.validateOnly` / `ListNotificationsRequest:cursor` - // disposition). - 'data/EngineUpdateOptions:upsert', - // #7990 — sibling of `data/PostgresConfig:password`, same ruling, same - // disposition: tombstoned inline credential; the secret binder / - // `external.credentialsRef` is the mechanism. See that entry for the reasoning - // and the D3 semantic entry `datasource-config-inline-credential-refused` for - // the hand-migration prescription. - 'data/MongoConfig:password', - // #7990 — sibling of `data/PostgresConfig:password`, same ruling, same - // disposition: tombstoned inline credential; the secret binder / - // `external.credentialsRef` is the mechanism. See that entry for the reasoning - // and the D3 semantic entry `datasource-config-inline-credential-refused` for - // the hand-migration prescription. - 'data/MysqlConfig:password', - // #7990 (maintainer-ruled Option A, 2026-08-12) — inline datasource credentials - // refused at publish. The key is tombstoned (`z.never()` with the refusal - // prescription), not deleted: `sys_metadata` serves rows through the ordinary - // data API, so an inline `config.password` was cleartext at rest. The secret - // belongs to the datasource secret binder (`sys_secret` + - // `external.credentialsRef`), which already wins over an inline value at - // connect time. No D2 conversion: a stored cleartext credential cannot be - // mechanically rewritten into an encrypted `sys_secret` row at load — see the - // D3 semantic entry `datasource-config-inline-credential-refused`. - 'data/PostgresConfig:password', - // #7990 — the turso face of `data/PostgresConfig:password` (the credential key - // is `authToken`, a JWT, rather than a password — same inline-cleartext class, - // same ruling, same disposition). The standalone boot path is untouched: - // `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are resolved by the host and - // handed to the driver factory directly, never through the authoring schema. - // See the D3 semantic entry `datasource-config-inline-credential-refused`. - 'data/TursoConfig:authToken', - 'integration/ConnectorFieldMapping:transform', - // #4914 — ADR-0049 enforce-or-remove on the plugin manifest's whole - // `loading` block (maintainer ruling 2026-08-04). ONE tombstoned key here, - // because `loading` was the single carrier: every schema underneath it - // (`PluginLoadingConfig` and the ten members it combined) leaves the - // published set as a whole-def removal and is registered in - // `RETIRED_DEFS_BY_MAJOR` below, not as ~27 individual key entries. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `automation/ActionDescriptor:isAsync` gives: the conversion chain - // walks a normalized STACK (`mapCollection(stack, 'objects' | 'views' | …)`) - // and `applyConversionsToStoredItem` maps a metadata type onto one of those - // collections. A package manifest is neither — there is no `packages` / - // `plugins` entry in `PLURAL_TO_SINGULAR`, so a manifest is not a stack - // collection member and a stored manifest row passes that seam through - // unchanged. A MetadataConversion here would be a transform with no seam - // that ever runs. The prescription reaches authors instead through the - // tombstone at the one place a manifest is parsed with an author present - // (`os plugin build` → `ManifestSchema.safeParse`, which exits non-zero), - // and through the D3 semantic entry `plugin-manifest-loading-retired`. - 'kernel/Manifest:loading', - 'shared/FieldMapping:transform', - // #5775 — the SDUI component-props reconciliation. Three keys on the record - // picker (`displayField` was the REQUIRED one, and the synonym of the key - // the renderer actually reads) and the card's second spelling of the - // composition slot every other container calls `children`. - 'ui/ElementRecordPickerProps:displayField', - 'ui/ElementRecordPickerProps:multiple', - 'ui/ElementRecordPickerProps:searchFields', - // #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling - // 2026-08-10): every measured reader copied the key forward and the chain ends - // at ObjectGrid, which never spells it; the grid frame is the renderer's own - // constant (`borderless`), never this key. Distinct from the LIVE - // `ui/PageCardProps:bordered`, a different surface on the page-card container. - // Conversion `view-list-passthrough-keys-removed` strips it from sources. - 'ui/ListView:bordered', - // #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling - // 2026-08-10): every measured reader (react spec-bridge, plugin-list, - // plugin-view/app-shell) copied the key forward and the chain ends at - // ObjectGrid, which never spells it — copy-without-apply is dead in effect. - // Conversion `view-list-passthrough-keys-removed` strips it from sources. - 'ui/ListView:striped', - // #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling - // 2026-08-10): every measured reader copied the key forward and the chain ends - // at ObjectGrid, which never spells it. objectui's VirtualGrid genuinely - // virtualizes but is only exported, never instantiated by ObjectGrid, and its - // props carry no `virtualScroll` member — so no grid ever virtualized off this - // key. Conversion `view-list-passthrough-keys-removed` strips it from sources. - 'ui/ListView:virtualScroll', - // #7176 — the `ObjectListViewSchema` copy of `ui/ListView:bordered`: the def is - // `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands in - // this walked shape too and `authorable-surface/` marks it `[RETIRED]` - // separately. Registered per key, as gate (b) reads them — nothing radiates - // from the base (the `shared/FieldMapping:transform` precedent). - 'ui/ObjectListView:bordered', - // #7176 — the `ObjectListViewSchema` copy of `ui/ListView:striped`: the def is - // `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands in - // this walked shape too and `authorable-surface/` marks it `[RETIRED]` - // separately. Registered per key, as gate (b) reads them — nothing radiates - // from the base (the `shared/FieldMapping:transform` precedent). - 'ui/ObjectListView:striped', - // #7176 — the `ObjectListViewSchema` copy of `ui/ListView:virtualScroll`: the - // def is `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands - // in this walked shape too and `authorable-surface/` marks it `[RETIRED]` - // separately. Registered per key, as gate (b) reads them — nothing radiates - // from the base (the `shared/FieldMapping:transform` precedent). - 'ui/ObjectListView:virtualScroll', - // #6946 — three SDUI page-component props, retired by maintainer ruling - // 2026-08-09 (decision-inbox round, 「全部接受」): objectui#3829 route (c) - // for the first two, objectui#3818 for the third. Registered per key, as - // gate (b) reads them — nothing radiates from a neighbouring key, and this - // family needs that literally: `ui/PageHeaderProps:actions` and - // `ui/RecordHighlightsProps:layout` are LIVE keys sharing these leaf names. - // - // The first two are the B class — declared here, read NOWHERE in objectui - // (the header resolves icons per action; the card renders - // title/bordered/children/footer and has no actions area), and carried in - // that repo's own `UNPUBLISHED_EXEMPTIONS` map as exactly that. - 'ui/PageCardProps:actions', - 'ui/PageCardProps:body', - 'ui/PageHeaderProps:icon', - // #6776 — #5775's count was incomplete. The tab strip's visual style is the - // one prop whose declared spelling collides with the page component's own - // dispatch key, so `type` could never be authored in a flat or JSX carrier - // and was skipped unvalidated by `sdui-parser`'s `BASE_PROPS`. Renamed to - // the `tabStyle` every carrier can express and the renderer already reads. - 'ui/PageTabsProps:type', - // The third is a sharper shape: `layout` IS read, but only against - // `inline`/`compact` — values its `auto | custom` enum never permitted — so - // both legal values took the same branch. Declared on BOTH sides with the - // same enum, which is why the declaration-parity ratchet (two declarations, - // never a declaration vs an implementation) reported agreement over it. - 'ui/RecordDetailsProps:layout', - // - ], - // Post-cut retirements: v17.0.0 was cut before these landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription registers at the major boundary where `migrate meta` users - // look — the step18 discipline, applied to retired keys. - 18: [ - // One file per entry under `entries/retired-keys/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // #21320 — ADR-0049 enforce-or-remove, ruled D (retire) on - // objectstack-ai/cloud#2569: `agent.lifecycle`, the agent conversation state - // machine, was parsed and never read — no runtime in this repository or in - // cloud moved an agent through a declared state, and every enforcement design - // measured there was a subset statechart interpreter beside Flow (the - // two-engine shape ADR-0020 rejected). Tombstoned with `retiredKey()` on the - // strict `AgentSchema`; D2 conversion `agent-lifecycle-removed` (lossless - // delete, retired from the load path); D3 semantic entry - // `agent-lifecycle-retired`. Its value schema, `automation/StateMachine`, left - // with it (RETIRED_DEFS_BY_MAJOR). Registered under 18 for the launch-window - // reason its neighbours state. - 'ai/Agent:lifecycle', - // #20274 — ADR-0049 enforce-or-remove, ruling record 5950198150 (letter A′, - // maintainer 「同意」): the `agent.memory` contract states exactly what the - // runtime honours, and the memory store is platform infrastructure, not agent - // metadata. Retired as a WHOLE key, ⛔ not narrowed to a one-value enum: the - // cloud AI runtime keeps the notes in its own database store and refused the - // `vector` default and `redis` before an agent's first turn. Tombstoned with - // `retiredKey()` inside the live `longTerm` block; its old aliases (`backend` / - // `storage` / `provider`) became `guidance` entries carrying the same answer. - // D2 conversion `agent-memory-long-term-store-removed` (lossless delete, - // retired from the load path); D3 semantic entry - // `agent-memory-store-retired-and-limits-required`. Registered under 18 for the - // launch-window reason its neighbours state. - // - // ⚠️ The key was DEFAULTED (`'vector'`), so a released toolchain materialized - // it into every parsed agent that declared `longTerm`. The - // `acceptRetiredDefaultResidue` stage is deliberately NOT adopted: the producer - // census is zero (no agent outside `packages/spec` declares `longTerm`, measured - // in this repository; none of cloud's built-in agents does, per the cloud - // seat's reading), and the ruling names the tombstone's prescription as the - // backstop for the unmeasured tenant population. Stored rows and built - // artifacts are healed by the replayed D2 conversion; a pre-retirement compiled - // definition fed back through the authoring funnel meets the prescription. - // - // Nested key of an inline block — no `authorable-surface/` line of its own. - 'ai/Agent:memory.longTerm.store', - // #15680 (stack card 5/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): - // a duration-shaped `z.number()` key carries its unit in its NAME, and no - // existing offender is grandfathered. `ConversationAnalytics.duration` said - // "Session duration in seconds" in prose and nothing else, on a shape where - // every OTHER number is a count (messages, tokens, pruning events) and the two - // neighbouring instants already spell themselves `firstMessageAt` / - // `lastMessageAt`. Renamed to `durationSeconds`; the value is unchanged. - // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare - // deletion would strip the key in silence and the analytics row would lose the - // one measurement it carries, with no error anywhere. No D2 conversion: - // conversation analytics are computed and emitted at runtime, never authored - // and never a stored `sys_metadata` row, so the chain has no seam that sees - // one. See `ai-conversation-analytics-duration-unit-in-key`. - 'ai/ConversationAnalytics:duration', - // #15677 (stack card 2/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): - // a duration-shaped `z.number()` key carries its unit in its NAME, and no - // existing offender is grandfathered. `ApiEndpoint.cacheTtl` said "Response - // cache TTL in seconds" in prose and nothing else, on the same authorable - // surface where `rateLimit.windowMs` spells its unit. Renamed to - // `cacheTtlSeconds`; the value is unchanged and the key stays GET-only. - // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare - // deletion would strip the old key in silence, and the unknown-key error could - // not carry the rename. This is the ONE key of this card's twelve that also - // gets a D2 CONVERSION: `apis:` is a stack collection (`stack.zod.ts` — - // `apis: z.array(ApiEndpointSchema)`) and an `api` is a registered metadata - // kind stored as a row, so the conversion chain has a seam that sees it. - // `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it, retired from the - // load path (no alias window); the family's D3 entry is - // `api-endpoint-cache-ttl-unit-in-key`, because a rename that keeps the value - // cannot say whether the value was ever in seconds. Registered under 18 for - // the launch-window reason its neighbours state. - 'api/ApiEndpoint:cacheTtl', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `batch.defaultAtomic`: no batch handler consulted it. Atomicity is a - // per-request option (`BatchOptionsSchema.atomic`, ADR-0119 D4 — opt-in, - // default `false`, deliberately aligned to what every caller already got); a - // server-side default that flipped it silently would change the failure - // semantics of callers who send nothing, the exact move ADR-0119 D4 refused, - // so this is a remove, not an enforce. - 'api/BatchEndpointsConfig:defaultAtomic', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `batch.operations.upsertMany`: gated a route that was never built — no - // `POST /data/:object/upsertMany`, no protocol member behind it (the protocol - // carries createManyData / updateManyData / deleteManyData only). Upsert is an - // operation TYPE of the generic batch endpoint (`BatchOperationType` 'upsert'), - // gated by `enableBatchEndpoint`. Nested key of an inline block — no - // `authorable-surface/` line of its own. - 'api/BatchEndpointsConfig:operations.upsertMany', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `crud.objectParamStyle`: every CRUD route takes the object name as a PATH - // segment; `'query'` was validated against the enum and mounted exactly what - // `'path'` mounts. - 'api/CrudEndpointsConfig:objectParamStyle', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `crud.patterns`: every CRUD route is mounted from the fixed method/path - // pairs in `registerCrudEndpoints`; a custom pattern was validated and never - // read. Not enforced, because the mounted paths ARE the contract the client - // SDK, the discovery document and the served /openapi.json describe — a - // per-operation method/path knob would make every one of them lie. The live - // door for a custom path or method is a declarative `api` endpoint. Its value - // def `api/CrudEndpointPattern` leaves with it (RETIRED_DEFS_BY_MAJOR[18]); its - // four ledger child rows collapse into the one `patterns` row. Closes the open - // `z.partialRecord` question (commit f60ab90ae) — there is no record left to reshape. - 'api/CrudEndpointsConfig:patterns', - // #15677 (stack card 2/6 of #14478) — ruling B: the unit lives in the key NAME. - // `DataLoaderConfig.cacheTtl` named seconds only in its describe. Renamed to - // `cacheTtlSeconds`; the value is unchanged. Tombstoned with `retiredKey()` - // because the shape is not `.strict()`. No D2 conversion: a `DataLoaderConfig` - // is a per-request batch-loader construction argument, never a stack collection - // member or a stored row, so the chain has no seam (the `kernel/Manifest:loading` - // precedent); the semantic entry `api-runtime-config-durations-unit-in-key` - // carries the prescription. - 'api/DataLoaderConfig:cacheTtl', - // #15677 (stack card 2/6 of #14478) — ruling B: the unit lives in the key NAME. - // `DeviceRequestResponse.interval` named seconds only in its describe. Renamed - // to `intervalSeconds`; the value is unchanged. Checked against ruling B's - // SECOND exemption before renaming — a key mirroring a name fixed outside this - // repo carries `.meta({ externalVocabulary })` — and it does not qualify: - // `DeviceRequestResponseSchema` does not mirror RFC 8628 as a set (`code` is - // not `device_code`, `verificationUrl` is not `verification_uri`, `expiresAt` - // is not `expires_in` and holds an ISO-8601 string where the RFC has a relative - // lifetime), so a schema that already renames every RFC field it carries cannot - // claim the standard fixes this one. Tombstoned with `retiredKey()`. No D2 - // conversion: this is a RUNTIME-EMITTED device-flow response body, never a - // stored row; the semantic entry `device-request-response-interval-unit-in-key` - // carries the prescription. - 'api/DeviceRequestResponse:interval', - // #15677 (stack card 2/6 of #14478) — ruling B, which put this key explicitly - // IN scope with its own BREAKING note: the ~16 runtime-emitted measurements are - // read by humans and agents even if nobody authors them, `ApiError.retryAfter` - // on the wire envelope included. `retryAfter` bare, beside an HTTP `Retry-After` - // header that may carry EITHER delta-seconds OR an HTTP-date, is precisely the - // ambiguity the rule removes. Renamed to `retryAfterSeconds`; the value is - // unchanged. ⚠️ The HTTP `Retry-After` RESPONSE HEADER is a SEPARATE, UNCHANGED - // surface — its name is fixed by RFC 9110 §10.2.3 and nothing here touches it. - // Tombstoned with `retiredKey()`. No D2 conversion: an ADR-0112 error envelope - // is emitted on the wire, never stored as a metadata row, so the chain has no - // seam; the semantic entry `api-error-retry-after-unit-in-key` carries the - // prescription. - 'api/EnhancedApiError:retryAfter', - // #17667 — ADR-0049 enforce-or-remove (director seat, decision batch #126 - // item 1, maintainer 「同意」 2026-09-13, route 2). The other half of the - // pagination capability `GET /api/v1/packages` never had; see the sibling - // entry `api/ListInstalledPackagesRequest:limit` for the full record. Two - // keys, one prescription: `PACKAGES_LIST_PAGINATION_REMOVED` in - // `api/package-api.zod.ts` is the single string both rejection sites raise. - // - // `cursor` was the more inert of the two and the less forgiving to keep. No - // emit site has ever written the response half's `nextCursor`, so a caller - // looping "until the cursor runs out" would have re-read the first and only - // page forever, with no error and no 400 — and there is no ordering key on - // this collection a resume could have been built from, so the key had nothing - // to carry even if something had read it. - // - // Same registration shape as its sibling: major 18 (the removal ships on the - // 17.x line; the prescription lives at the major boundary), no D2 conversion - // because the shape is HTTP-only, and the D3 semantic entry - // `packages-list-pagination-retired` carries the prescription to - // `spec-changes.json`, the generated upgrade guide and `os migrate meta`. - 'api/ListInstalledPackagesRequest:cursor', - // #17667 — ADR-0049 enforce-or-remove (director seat, decision batch #126 - // item 1, maintainer 「同意」 2026-09-13, route 2). One capability, both - // halves, never half-deleted: `limit` and `cursor` retire together and share - // one prescription, `PACKAGES_LIST_PAGINATION_REMOVED` in - // `api/package-api.zod.ts`. - // - // `GET /api/v1/packages` declared a window it has never applied. The serving - // door filters on `status` / `type` / `enabled` and returns every remaining row, so a - // caller asking for one row was handed the whole table together with a - // `hasMore: false` that agreed with it — the silent-widening half of the - // ingress rule that names this exact parameter as the one whose drop is worst. - // - // This key is the sharper of the two because it carried `.default(50)`: a - // reader of the published schema — an SDK, codegen, an AI client — was - // entitled to believe an unparameterised list is capped at 50 rows. Nothing - // parses a query string through this schema, so that default has never been - // materialized anywhere, which is why the retirement needs no - // `acceptRetiredDefaultResidue` stage: there is no residue population. The - // `authorable-defaults/api.json` line goes with the key rather than through - // DEFAULT_CHANGES_BY_MAJOR, which excludes retirements by name. - // - // Registered under 18, not 17: v17.0.0 was cut long before this, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the `ui/ListView:pageName` - // precedent). Registered here but NOT in `src/conversions/registry.ts`, and - // that asymmetry is the point rather than an omission: a D2 conversion - // rewrites an authored source or a stored `sys_metadata` row, and this shape - // is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and nothing - // persists one. The prescription reaches consumers as the D3 semantic entry - // `packages-list-pagination-retired` plus this tombstone, the disposition - // `api/ListNotificationsRequest:cursor` (commit 90bbf2510) already took for the same - // shape one route over. - 'api/ListInstalledPackagesRequest:limit', - // #19543 — ADR-0049 enforce-or-remove (director seat, decision batch #204 - // item 2, maintainer 「204 同意」 2026-09-21, letter C for this door). The - // prescription is `RUNS_LIST_CURSOR_REMOVED` in `api/automation-api.zod.ts`. - // - // ⭐ `cursor` retires ALONE here, and the asymmetry with the sibling - // `/packages` retirement is the whole point of the ruling. On that door both - // `limit` and `cursor` were decorative, so both went (#17667, - // `api/ListInstalledPackagesRequest:limit` / `:cursor`). On THIS door `limit` - // is read end to end — boundary bounds check, service option, then the run - // store's history window — and the Console's flow-runs page sends it today, so - // retiring it would have been a regression rather than a narrowing. The card's - // own body called it "declared, never read"; that sentence is false and was - // measured false before this entry was written. - // - // What made `cursor` retirable is the response half: no emit site has ever - // written `nextCursor`, and the only ordering this door has is a required but - // non-unique `startedAt` timestamp that nothing ever minted a resume point - // from — so nothing could ever have minted a value for a caller to send back. - // A caller looping "until the cursor runs out" re-read the first and only - // window forever. - // - // Same registration shape as the `/packages` pair: major 18 (the removal ships - // on the 17.x line as a minor; the prescription lives at the major boundary - // where `migrate meta` users look), and NO D2 conversion, because a conversion - // rewrites an authored source or a stored `sys_metadata` row and this shape is - // HTTP-only — nobody authors a `ListRunsRequest` and nothing persists one. The - // D3 semantic entry `automation-runs-cursor-retired` carries the record to - // `spec-changes.json`, the generated upgrade guide and `os migrate meta`. - 'api/ListRunsRequest:cursor', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `metadata.cacheTtl`: `enableCache` selects the protocol's `getMetaItemCached` - // read path, which takes no TTL, and no Cache-Control / ETag / Last-Modified - // header was ever built from the value. Its negative-bound defect (no lower - // bound, `-1` accepted — #11984 pinned it as accepted because that was the - // contract) dies with the key. - 'api/MetadataEndpointsConfig:cacheTtl', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `metadata.endpoints.schema`: gated a route that does not exist — the REST - // server mounts no `GET /meta/:type/:name/schema` — so `false` removed nothing - // and `true` added nothing, while its three siblings each gate a real mount. - // A nested key of an inline block, so it has no line of its own in - // `authorable-surface/` (the `kernel/Manifest:contributes.routes` shape). - 'api/MetadataEndpointsConfig:endpoints.schema', - // #20295 — ADR-0049 enforce-or-remove on the `api` sub-object of - // `RestServerConfig`, executing the `rest_api` liveness census (#14640: 0 read - // sites outside `normalizeConfig` and the normalized-config type; re-measured on - // origin/main 4e0f72e8, objectui at its pin f8a9d0fb and cloud at 96eb092 — all - // clean against lit controls). Tombstoned with `retiredKey()` inside the live - // `documentation` block — a tombstone whose siblings keep parsing, because only - // this member retires here. No D2 conversion: a `RestServerConfig` is plugin TS - // configuration, never a stack collection member or a `sys_metadata` row. D3 - // semantic entry `rest-api-config-dead-keys-retired`. Registered under 18 for the - // launch-window reason its neighbours state. - // - // `documentation.enabled` was a second on/off switch for the OpenAPI document: - // `api.enableOpenApi` decides the mount, and this key was consulted nowhere. - // Nested key of an inline block — no `authorable-surface/` line of its own. - 'api/RestApiConfig:documentation.enabled', - // #20294 — ruling B on #20359 (family `rest-api-documentation`, rank 8 of the - // #18900 census): the eight identity members of `api.documentation` are - // ENFORCED (they overlay the served OpenAPI `info`) and `version` alone is - // RETIRED, because the served `info.version` is the protocol version and #11646 - // settled that nothing configured overrides it. Tombstoned with `retiredKey()` - // inside the live `documentation` block, next to the #20295 `enabled` - // tombstone. No D2 conversion: a `RestServerConfig` is plugin TS configuration, - // never a stack collection member or a `sys_metadata` row. D3 semantic entry - // `rest-api-documentation-version-retired`. Registered under 18 for the - // launch-window reason its neighbours state. - // - // Nested key of an inline block — no `authorable-surface/` line of its own. - 'api/RestApiConfig:documentation.version', - // #20295 — ADR-0049 enforce-or-remove on the `api` sub-object of - // `RestServerConfig`, executing the `rest_api` liveness census (#14640: every - // member of the block `dead`, 0 read sites outside `normalizeConfig` and the - // normalized-config type; re-measured on origin/main 4e0f72e8, objectui at its - // pin f8a9d0fb and cloud at 96eb092 — all clean against lit controls). - // `RestApiConfigSchema` is a non-strict `z.object()`, so the route is a - // `retiredKey()` tombstone (a bare deletion would strip the key silently), the - // ledger row stays `dead` with a REMOVED note, and there is no D2 conversion: a - // `RestServerConfig` is plugin TS configuration, never a stack collection member - // or a `sys_metadata` row — the `api/RestServerConfig:openApi31` precedent, and - // the `rest-server-config-dead-keys-retired` one on the four sibling - // sub-objects. D3 semantic entry `rest-api-config-dead-keys-retired`. Registered - // under 18 for the launch-window reason its neighbours state. - // - // `responseFormat` is retired WHOLE — `envelope`, `includeMetadata` and - // `includePagination` were its only members and none was ever read, so there is - // no live member left to hold the container open (the `crud.patterns` - // precedent). A response shape is a fixed contract — each route's declared - // response schema, which the client SDK parses — not a server-wide option. - 'api/RestApiConfig:responseFormat', - // #15677 (stack card 2/6 of #14478) — ruling B; the seconds half of the pair - // documented on `api/RestApiEndpoint:timeout`. Renamed to `cacheTtlSeconds`; - // the value is unchanged. Tombstoned with `retiredKey()`; disposition and - // reasoning are that entry's, and the prescription travels in the semantic - // entry `rest-api-plugin-durations-unit-in-key`. - 'api/RestApiEndpoint:cacheTtl', - // #13823 — ADR-0049 enforce-or-remove on `RestApiEndpointSchema.handlerStatus` - // (maintainer ruling 2026-09-01, director decision batch #27, verbatim - // 「同意」: remove). The key (`implemented` / `stub` / `planned`) was declared - // as a handler-readiness marker whose docstring promised that a `stub` handler - // "returns 501 Not Implemented", and NOTHING read it: the only identifier hits - // outside `skills/**` and tests were the declaration, its re-declaration on - // `RouteCoverageEntrySchema` and a docblock saying adapters SHOULD warn on it. - // The 501 it described has a different cause — every - // `DispatcherErrorCode.enum.NOT_IMPLEMENTED` site (`runtime/src/ - // endpoint-executor.ts` ×3, `runtime/src/api-mapping.ts`, - // `runtime/src/api-endpoint-step.ts`) is the declarative-endpoint executor - // refusing a target or mapping it cannot serve, none consulting the key — so - // `handlerStatus: 'stub'` got an ordinarily served route and reported its - // progress to nobody. Tombstoned with `retiredKey()`: `RestApiEndpointSchema` - // is a non-strict `z.object`, so a bare deletion would be a silent strip - // (#3733, ADR-0104). The value enum it was typed with and the two report - // shapes that re-declared it leave whole — `api/HandlerStatus`, - // `api/RouteCoverageEntry`, `api/RouteCoverageReport` in - // `RETIRED_DEFS_BY_MAJOR[18]`. - // - // Registered here but NOT in `src/conversions/registry.ts`, the - // `kernel/Manifest:loading` reasoning: nothing in the tree parses - // `RestApiEndpointSchema` outside its own unit tests — a REST API plugin's - // route registration is not a stack collection member (`PLURAL_TO_SINGULAR` - // has no entry for it) and nothing stores one as a `sys_metadata` row — so a - // MetadataConversion would be a transform with no seam that ever runs. The - // prescription reaches authors through the tombstone plus the D3 semantic - // entry `rest-api-endpoint-handler-status-retired`. ENFORCE (mounting a 501 - // stub for `stub` / `planned`) was excluded by the same ruling as a zero-pull - // new capability, not a repair. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the commit 0c2334f6c / #12428 grading). - 'api/RestApiEndpoint:handlerStatus', - // #15677 (stack card 2/6 of #14478) — ruling B. `RestApiEndpoint.timeout` - // (milliseconds) sat THREE LINES above `cacheTtl` (seconds), each unit named - // only in its describe: one shape, two units, no way to tell them apart at the - // authoring site. Renamed to `timeoutMs`; the value is unchanged. Tombstoned - // with `retiredKey()` on this non-strict shape, beside the `handlerStatus` - // tombstone already there. No D2 conversion: a `RestApiEndpoint` is REST-plugin - // route-registration configuration, never a stack collection member (the - // `rest-api-endpoint-handler-status-retired` precedent on this very shape); the - // semantic entry `rest-api-plugin-durations-unit-in-key` carries the - // prescription. - 'api/RestApiEndpoint:timeout', - // #15677 (stack card 2/6 of #14478) — ruling B. The plugin-wide default behind - // the per-endpoint `cacheTtl` this card also renames; leaving it bare would have - // left the DEFAULT spelled one way and the OVERRIDE another. Renamed to - // `defaultCacheTtlSeconds`; the value is unchanged. Tombstoned with - // `retiredKey()` inside the live `performance` block — a tombstone whose - // siblings must keep parsing. No D2 conversion: `RestApiPluginConfig` is the - // REST plugin's construction argument, never a stored row; the semantic entry - // `rest-api-plugin-durations-unit-in-key` carries the prescription. - 'api/RestApiPluginConfig:performance.defaultCacheTtl', - // #15677 (stack card 2/6 of #14478) — ruling B. `RouteDefinition.timeout` said - // "Execution timeout in ms" in prose and nothing else. Renamed to `timeoutMs`; - // the value is unchanged. Tombstoned with `retiredKey()`. No D2 conversion: a - // `RouteDefinition` is a router registration a host or plugin builds in code, - // never a stack collection member or a stored row; the semantic entry - // `api-runtime-config-durations-unit-in-key` carries the prescription. Note for - // anyone grepping: `packages/runtime/src/dispatcher-plugin.ts` declares its OWN - // local `RouteDefinition` interface for the `ai:routes` hook payload — a - // different type, with no duration key at all, and untouched by this rename. - 'api/RouteDefinition:timeout', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `routes.excludeObjects`: same shape as `includeObjects` — an excluded - // object was still mounted. The one customer-visible member of the family: - // `RestServerConfigSchema`'s own `@example` advertised - // `routes: { excludeObjects: ['system_log'] }`; corrected in the same change. - 'api/RouteGenerationConfig:excludeObjects', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `routes.includeObjects`: route registration iterates every registered - // object with no include filter. Per-object API exposure is declared ON the - // object and enforced by the REST data surface (`enable.apiEnabled` → 404, - // `enable.apiMethods` → 405), so the capability exists where the contract - // belongs and this key was a second, unread dialect of it. - 'api/RouteGenerationConfig:includeObjects', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `routes.nameTransform`: the enum was validated (since #11984) and every value - // mounted exactly what `'none'` mounts. Not enforced, because the object - // `name` is the canonical id on every surface including the REST path segment - // (Prime Directive #6) — a URL transform would contradict the one-name rule. - 'api/RouteGenerationConfig:nameTransform', - // Commit b3a63d32c — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, - // executing the liveness census commit a3d5724c8 recorded (15 `dead` rows across the `crud` / - // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` - // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 - // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a - // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict - // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion - // would strip the key silently), the ledger rows stay `dead` with a REMOVED - // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS - // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), - // never a stack collection member or a `sys_metadata` row — the - // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry - // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // `routes.overrides`: the per-object record (`enabled` / `basePath` / - // `operations`) was normalized and never read. `enabled` and `operations` - // duplicate the object's own enforced exposure keys (`enable.apiEnabled` / - // `enable.apiMethods`); `basePath` per object would contradict the one - // deployment-wide data base (`crud.dataPrefix`) the discovery document - // advertises. Its three ledger child rows collapse into the one `overrides` - // row. Closes the `z.partialRecord` question (commit f60ab90ae) on `overrides.*.operations` — no record left. - 'api/RouteGenerationConfig:overrides', - // #14788 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-03, option - // D). `SessionUserSchema.language` (`api/auth.zod.ts`) was declared with a - // permanent default of `'en'` and described as "Preferred language", and had - // no producer and no consumer anywhere: no session endpoint wrote it, no - // client read it (objectui measured at its pinned sha: zero readers of - // `SessionUser.language`; its only in-repo mentions were the schema's own - // unit test). A reader trusting the published contract received a constant - // that was not the user's language — the "declared ≠ honoured" shape, made - // worse by the fact that the user's real preference had just landed as a - // first-class column (`sys_user.locale`, #13881) the session type could not - // see. The ruling retires the dead key and makes `GET /auth/me/localization` - // the one read face for the signed-in user's language (`locale`: the user's - // own `sys_user.locale` when set → the request's `Accept-Language` → the - // deployment default); no replacement field joins the session contract until - // a session endpoint really produces one. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // The schema is a non-strict `z.object`, so the route is a `retiredKey()` - // tombstone (a bare delete would strip the key silently, ADR-0104). A RESPONSE - // surface — the server mints a `SessionUser` and nobody authors or persists - // one — so, like `api/AuthFeaturesConfig:passkeys`, there is no source for - // `os migrate meta` to rewrite and no D2 conversion; the prescription reaches - // consumers through this tombstone plus the D3 semantic entry - // `session-user-language-retired`. - 'api/SessionUser:language', - // #15676 — the epoch-instant half of #14478 ruling B. - // `SimplePresenceState.lastSeen` is an epoch INSTANT: it moved onto the shared - // `EpochMs` schema and was renamed `lastSeenAt`, joining this package's - // `lastAccessedAt` / `lastUsedAt` family. - // - // ⚠️ Not to be confused with `api/PresenceState:lastSeen` - // (`api/realtime-shared.zod.ts`), a DIFFERENT key of a different type — an - // ISO-8601 datetime string — which is untouched and stays live. - // - // Semantic entry rather than a D2 conversion, and registered under 18 rather - // than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry - // records: a presence payload is runtime-emitted, never a stored metadata row. - 'api/SimplePresenceState:lastSeen', - // #15677 (stack card 2/6 of #14478) — ruling B; documented with its two - // siblings on `api/WebSocketConfig:reconnectInterval`. Renamed to - // `pingIntervalMs`; the value is unchanged. Semantic entry - // `websocket-durations-unit-in-key`. - 'api/WebSocketConfig:pingInterval', - // #15677 (stack card 2/6 of #14478) — ruling B. Three durations on - // `WebSocketConfig` named their unit only in prose, interleaved with a - // `maxReconnectAttempts` that is a COUNT — so `reconnectInterval: 5` beside - // `maxReconnectAttempts: 5` read as one kind of number and was two. Renamed to - // `reconnectIntervalMs`; the value is unchanged. Tombstoned with `retiredKey()`. - // No D2 conversion: a `WebSocketConfig` is a client connection argument, never a - // stored row; the semantic entry `websocket-durations-unit-in-key` carries the - // prescription for all four of this shape's renames. - 'api/WebSocketConfig:reconnectInterval', - // #15677 (stack card 2/6 of #14478) — ruling B; documented with its two - // siblings on `api/WebSocketConfig:reconnectInterval`. Renamed to `timeoutMs`; - // the value is unchanged. Semantic entry `websocket-durations-unit-in-key`. - 'api/WebSocketConfig:timeout', - // #15676 — the epoch-instant half of #14478 ruling B. `WebSocketEvent.timestamp` - // is an epoch INSTANT, not a duration: it moved onto the shared `EpochMs` schema - // (which declares the millisecond unit) and was renamed `occurredAt`, because - // every `*Ms` key in this package is a duration and spelling an instant that way - // would put it in the family the rule exists to separate it from. - // - // Registered here but NOT in `src/conversions/registry.ts`, the - // `kernel/KernelContext:previewMode` reasoning: a WebSocket event is a RUNTIME - // wire payload emitted by the transport, never a stack collection member and - // never stored as a `sys_metadata` row, so a MetadataConversion would be a - // transform with no seam that ever runs. The prescription reaches consumers - // through the tombstone plus the D3 semantic entry `epoch-instant-keys-renamed` - // — which is exactly what ruling B prescribes for a runtime-emitted key. - // - // Registered under 18, not 17, for the reason the previewMode entry records: - // v17.0.0 was cut before this landed, so the change ships on the 17.x line and - // the prescription lives at the major boundary `migrate meta` users look at. - 'api/WebSocketEvent:timestamp', - // #15677 (stack card 2/6 of #14478) — ruling B. The server-side counterpart of - // the `WebSocketConfig` trio, with the same COUNT neighbour problem - // (`reconnectAttempts`). Renamed to `heartbeatIntervalMs`; the value is - // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: server - // construction configuration, never a stored row; the semantic entry - // `websocket-durations-unit-in-key` carries the prescription. - 'api/WebSocketServerConfig:heartbeatInterval', - // #15680 (stack card 5/6 of #14478) — ruling B, and the one key in this card - // that the gate did NOT list. It is here because it is not a second key: the - // `auto` persistence arm resolves to the same Node.js file adapter as the - // `file` arm, and this value is forwarded to the same - // `FileSystemPersistenceAdapter` field, in the same milliseconds, under the - // same `min(100)` bound. Its describe named no unit at all, which is why the - // predicate skipped it — and precisely why renaming only the `file` arm would - // have left ONE value with TWO spellings across sibling arms of one union, with - // the driver reading both. That is the consumer-side dialect Prime Directive - // #12 forbids, so the two arms move together. Renamed to `autoSaveIntervalMs` - // and its describe now names the unit too. Tombstoned with `retiredKey()`; - // covered by `memory-persistence-auto-save-interval-to-ms`, which converts both - // arms in one pass. - 'data/AutoPersistenceConfig:autoSaveInterval', - // #20637 — ADR-0049 enforce-or-remove (maintainer ruling, letter C: retire - // whole, no cache built). `Cube.refreshKey` — the refresh cadence `every` and - // the data-change probe `sql` — was read by nothing, and no analytics result - // is cached for it to key on. Measured: `git grep refreshKey` over the non-test - // sources of `packages/services`, `packages/drivers` and `packages/rest` - // answered 0 lines (4 for the neighbouring `.public` in the same pathspec). - // - // `retiredKey()` on a `strictObject`, for the prescription and the `tsc` - // channel (the `data/Metric:name` precedent). One row, not three: the nested - // `every` and `sql` left with the block, and a dotted row under a tombstoned - // parent names a path this build no longer emits (check (b3)). The D2 - // conversion `cube-refresh-key-removed` strips the block wherever the chain is - // replayed; the D3 record is `cube-refresh-key-retired`. - 'data/Cube:refreshKey', - // #18612 — ADR-0049 enforce-or-remove, the same ruling and the same diff as - // `data/CubeJoin:sql`. `CubeJoin.relationship` carried a - // `.default('many_to_one')` and nothing dispatched on the cardinality, so - // `one_to_many` parsed, changed no SQL, and the aggregate silently kept the - // many-to-one arithmetic. Two service-analytics fixtures authored - // `relationship: 'belongsTo'` — a value the enum never declared — which is its - // own evidence that nothing validated or read the key. - // - // Same route and same registration reasoning as the `sql` entry beside this - // one: strict deletion plus a `guidance` prescription on the `strictObject`, - // registered under 18. The D2 conversion - // `cube-join-sql-and-relationship-removed` strips this key too, and it is owed - // for the mirror-image reason: the key was DEFAULTED, so the value was - // MATERIALIZED into every cube artifact the old schema ever parsed, whether or - // not its author typed it. - 'data/CubeJoin:relationship', - // #18612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-18, director - // batch #154 item 4, letter 2). `CubeJoin.sql` was REQUIRED and described itself - // as the `ON` clause, and nothing ever read it: both analytics strategies - // SYNTHESISE the join, so an authored condition was not ignored but REPLACED by - // a foreign-key equality, returned under a 200 with a plausible number attached - // (the #10298 shape). Measured with a positive control — zero reads of a join's - // `sql` in any non-test source, against eight reads of the neighbouring - // `cube.joins?.[alias]?.name` in native-sql-strategy.ts, objectql-strategy.ts - // and analytics-service.ts. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the `data/Metric:filters` - // precedent, one shape over in the same file). `CubeJoinSchema` is a - // `strictObject`, so the route is strict deletion plus a `guidance` entry - // carrying the prescription — no `retiredKey()` tombstone, the key is out of - // the walked shape entirely, and its liveness-ledger row left with it. - // - // A D2 conversion DOES cover this surface: `cube-join-sql-and-relationship-removed` - // (`toMajor: 18`, `retiredFromLoadPath: true`) strips the key wherever the chain - // is replayed. It is owed because the key was REQUIRED, so every cube artifact - // ever written from the old schema's parse output carries it and would meet the - // boot door's refusal with no remedy short of hand-editing JSON. The guidance - // prescription therefore closes with the house `os migrate meta --from 17` - // sentence, and the D3 semantic entry `cube-join-sql-and-relationship-retired` - // carries the judgement the strip cannot: an author who wrote a non-FK - // condition wanted a join this runtime does not perform. - 'data/CubeJoin:sql', - // #19992 — ADR-0049 enforce-or-remove (triage direction REMOVE under ruling 乙 - // on #19910: 「a currency's decimal places are the currency's, not a - // setting」). `currencyConfig.precision` was declared and validated against - // ISO 4217 (#7918) but read by NOTHING — measured with a positive control - // (`currencyConfig.currencyMode` IS read) over objectstack, objectui at the - // `.objectui-sha` pin and at `main`, and cloud `main`. objectui's - // `CurrencyField` derives decimal places from the currency's ISO 4217 minor - // unit and never read the key; its own contradiction check was its only - // reader. The `decimals` / `scale` aliases that pointed authors at it went - // with it (they now answer with the same prescription). - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. `CurrencyConfigSchema` is - // `strictObject`, so the route is strict deletion + a `guidance` entry carrying - // the prescription (no retiredKey tombstone — the key is out of the walked - // shape entirely). Sources and stored rows are rewritten by the D2 conversion - // `currency-config-precision-removed`, which strips the key from every field's - // `currencyConfig` on objects and object extensions — including the `2` the - // old `.overwrite()` baked into parse output. - 'data/CurrencyConfig:precision', - // #20300 — the same ruling, the same diff and the same route as - // `data/Metric:name`: `Dimension.name` was REQUIRED and read by nothing, because - // `dimensions` is a record and every consumer resolves a dimension by its KEY - // (`getMeta` publishes `.`, `lookupMember` and the in-memory driver's - // `resolveDimension` index the bag by key). A `retiredKey()` tombstone on the - // `strictObject`; the D2 conversion `cube-member-inner-name-removed` strips it - // from stored and built cubes, and the D3 record is - // `cube-member-inner-name-retired`. - 'data/Dimension:name', - // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a - // duration-shaped `z.number()` key lives in the key name, and no existing - // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in - // prose and nothing else; renamed to `timeoutMs`, value unchanged. Tombstoned - // with `retiredKey()` because `DriverOptionsSchema` is not `.strict()` (a bare - // deletion would strip the old key in silence). No D2 conversion: a - // `DriverOptions` object is a per-call options argument to driver methods, - // never a stack collection member or a stored row, so the chain has no seam - // (the `kernel/Manifest:loading` precedent); the semantic entry - // `driver-options-timeout-to-timeout-ms` carries the prescription. Registered - // under 18 for the launch-window reason its neighbours state. - 'data/DriverOptions:timeout', - // #14477 — ADR-0049 enforce-or-remove. The 2026-09-02 ruling (ruled A: retire - // per family) held the `ESignatureConfig` pair on one condition — a roadmapped - // e-signature consumer would have earned an `[EXPERIMENTAL — not enforced]` tag - // instead — and the maintainer answered it on 2026-09-05 (decision batch #40: - // no roadmap), so the ruling's own branch resolves to retirement. A day-shaped - // deadline key on the published authorable surface (`data/ESignatureConfig`), - // read by NOTHING: no e-signature engine exists on the platform, no layer ever - // sent, expired or reminded a signature request, and the reader census over - // every package outside `packages/spec` (tests and changelogs excluded), over - // `examples/**` and `skills/**`, and over objectui at the pinned sha returned - // zero hits, with a lit control inside `packages/spec`. Its default of 30 days - // was materialized into every parsed configuration without ever being - // consulted. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: `DocumentSchema` is not - // a stack collection member and `document` is no metadata type, so a - // MetadataConversion would be a transform with no seam that ever runs. The - // prescription reaches authors through the tombstone (`tsc` + the parse) and - // the D3 semantic entry named below. - // D3 semantic entry: `esignature-config-deadline-keys-retired`. - 'data/ESignatureConfig:expirationDays', - // #14477 — ADR-0049 enforce-or-remove. The 2026-09-02 ruling (ruled A: retire - // per family) held the `ESignatureConfig` pair on one condition — a roadmapped - // e-signature consumer would have earned an `[EXPERIMENTAL — not enforced]` tag - // instead — and the maintainer answered it on 2026-09-05 (decision batch #40: - // no roadmap), so the ruling's own branch resolves to retirement. A day-shaped - // interval key on the published authorable surface (`data/ESignatureConfig`), - // read by NOTHING: no e-signature engine exists on the platform, no layer ever - // sent a reminder email, and the reader census over every package outside - // `packages/spec` (tests and changelogs excluded), over `examples/**` and - // `skills/**`, and over objectui at the pinned sha returned zero hits, with a - // lit control inside `packages/spec`. Its default of 7 days was materialized - // into every parsed configuration without ever being consulted. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: `DocumentSchema` is not - // a stack collection member and `document` is no metadata type, so a - // MetadataConversion would be a transform with no seam that ever runs. The - // prescription reaches authors through the tombstone (`tsc` + the parse) and - // the D3 semantic entry named below. - // D3 semantic entry: `esignature-config-deadline-keys-retired`. - 'data/ESignatureConfig:reminderDays', - // #15680 (stack card 5/6 of #14478) — ruling B. - // `FilePersistenceConfig.autoSaveInterval` said "Auto-save interval in ms" in - // prose and nothing else. Its `min(100)` bound is what made the bare name - // dangerous rather than merely untidy: 100 reads as a plausible number of - // SECONDS, so an author who guessed the unit wrong cleared the bound, was - // refused nowhere, and saved a thousand times more often than intended. - // Renamed to `autoSaveIntervalMs`; the value and the 2000 default are - // unchanged. Tombstoned with `retiredKey()` — this shape IS `strictObject`, so - // a bare deletion is not silent, but an unknown-key rejection cannot carry the - // FROM → TO mapping, which is the whole payload of a rename. Covered by the D2 - // conversion `memory-persistence-auto-save-interval-to-ms`: a memory datasource - // is a `datasources[]` stack collection member whose `config` is stored whole in - // `sys_metadata`, so the chain has a seam that sees it. - 'data/FilePersistenceConfig:autoSaveInterval', - // #18669 — maintainer ruling A (2026-09-17, decision batch #151 item 4): - // `FileValue.duration` names a media length and carried its unit in no channel - // at all — no `.describe()`, no JSDoc, no unit token in the key. Renamed to - // `durationSeconds`; the value is unchanged and DELIBERATELY not narrowed — a - // fractional second is a legal media length, so the ruling refused both a - // closed `DurationSeconds` type and an `.int()` floor. The name is - // `durationSeconds` rather than the gate's mechanical `durationSec`/`lengthSeconds` - // because `durationSeconds` is the spelling this spec already landed on for a - // length of time in SIX places, ENUMERATED rather than counted because a bare - // number in shipped prose cannot be re-checked against the tree: - // `ai/conversation.zod.ts` `ConversationAnalytics.durationSeconds`; and on - // `system/metrics.zod.ts` `MetricAggregationConfig.window.durationSeconds`, - // `ServiceLevelIndicator.window.durationSeconds`, - // `ServiceLevelObjective.period.durationSeconds`, - // `ServiceLevelObjective.errorBudget.burnRateWindows[].durationSeconds` and - // `MetricsConfig.retention.durationSeconds`. The media length is therefore the - // SEVENTH spelling of one vocabulary, not the first of a second one. The - // semantic entry `data-file-value-duration-unit-in-key` carries the same six keys - // in the same order, so the two surfaces that state one fact cannot drift apart. - // ⚠️ Read `metrics.zod.ts`'s own `burnRateWindows` JSDoc beside this list: it - // calls itself "The fourth window length on this file", which is the sentence - // that falsifies any shorter count of that file. - // Tombstoned with `retiredKey()`: `FileValueSchema` - // is the one deliberate `z.looseObject` in this file, so a bare deletion would - // wave the old spelling through as an unrecognised extra rather than prescribe - // the rename. No D2 conversion: `FileValueSchema` is the ADR-0104 D3 wave-2 - // EXPANDED READ form, derived at read time from a `sys_file` id — the stored - // form is `FileReferenceIdValueSchema`, an opaque string — so it is never - // authored, never a stored `sys_metadata` row, and the chain has no seam that - // would ever see one. See `data-file-value-duration-unit-in-key`. - 'data/FileValue:duration', - // #10414 — ADR-0049 enforce-or-remove (triage routed REMOVE; the #10298 shape - // one level up). `filters` was a declared, authorable per-metric raw-SQL - // filter (`filters: [{ sql: string }]`) with ZERO consumers, measured with a - // positive control: no `.filters` read in `service-analytics` or any driver's - // non-test code while the neighbouring `format` key IS read - // (`analytics-service.ts`, `dataset-compiler.ts`). - // `NativeSQLStrategy.resolveMeasureSql` and - // `ObjectQLStrategy.resolveMeasureAggregation` both wrap the metric's `sql` in - // the aggregate and never look at `filters` — so a hand-authored cube's - // `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and - // silently returned the UNFILTERED aggregate under the author's metric name. - // The raw-SQL fragment also ran against the platform's structured - // FilterCondition direction (it cannot be parameterized, re-targeted per - // driver dialect, or walked by `packages/lint/src/filter-walk.ts`); the - // dataset path was repaired separately through its own structured channel - // (#10411), which left this key inert with the dataset half fixed around it. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // `MetricSchema` is `strictObject`, so the route is strict deletion + a - // `guidance` entry carrying the prescription (no retiredKey tombstone — the - // key is out of the walked shape entirely). Sources are rewritten by the D2 - // conversion `metric-filters-removed`, which strips the key from every metric - // in `analyticsCubes[].measures`. - 'data/Metric:filters', - // #20300 — ADR-0049 enforce-or-remove (triage verdict RETIRE by the - // maintainer's criterion for declared-but-unenforced families: Cube.dev and - // LookML key a member by its declared name, with no second inner name that can - // disagree). `Metric.name` was REQUIRED and read by nothing: `measures` is a - // record, and every consumer resolves a metric by its KEY — `getMeta` - // publishes `.`, `NativeSQLStrategy#lookupMember` and the in-memory - // driver index the bag by key. Measured with a lit control: zero reads of a - // member's inner `name` in non-test source, four reads of the neighbouring - // `measure.label` / `dimension.label` in the same `getMeta` projections. - // - // `retiredKey()` on a `strictObject`, for the prescription and the `tsc` - // channel (the `ui/Action:aria` precedent). Registered under 18, not 17: the - // tombstone ships on the 17.x line (launch-window convention — accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. The D2 conversion - // `cube-member-inner-name-removed` strips the key wherever the chain is - // replayed; it is owed because the key was REQUIRED, so every stored or built - // cube carries it. The D3 record is `cube-member-inner-name-retired`. - 'data/Metric:name', - // #15680 (stack card 5/6 of #14478) — ruling B. `NoSQLQueryOptions.timeout` - // said "Query timeout (ms)" in prose and nothing else, directly beside - // `batchSize`, a plain row COUNT: two bare numbers side by side, one carrying a - // unit and one not, with nothing at the call site to tell them apart. Renamed - // to `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the - // shape is not `.strict()`, so a bare deletion would strip in silence and the - // query would run without the limit its author set. No D2 conversion: query - // options are a per-call driver argument reached only through - // `AggregationPipeline.options`, which no `stack.zod.ts` collection declares - // and no `sys_metadata` row stores. See `data-nosql-query-options-timeout-unit-in-key`. - 'data/NoSQLQueryOptions:timeout', - // #19054 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, verbatim - // and untranslated: 「organizationField 撤出可授权面 同意你的建议」). - // `TenancyConfig.organizationField` named the column a platform row is STAMPED - // from, as opposed to the column the object is WALLED by (`tenantField`). On an - // ordinary object those are the same column, and the whole protocol declared it - // exactly once — on `sys_api_key`, a table this platform ships and no - // application authors. The `tenancy` block is `.strict()`, so the key is - // removed from the shape and its prescription is served from - // `TENANCY_RETIRED_KEY_GUIDANCE`. The divergence itself is unchanged: it moves - // to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, read - // by the three sanctioned platform-row writers alone. D2: - // `object-tenancy-organization-field-removed`. - 'data/TenancyConfig:organizationField', - // #15680 (stack card 5/6 of #14478) — ruling B. `TursoConfig.timeout` said - // "Operation timeout in milliseconds" in prose and carried a `.meta({ title: - // 'Timeout (ms)' })` no parse reads — and sat two keys below - // `sync.intervalSeconds`, which already spelled ITS unit. One shape carrying - // both conventions, and the suffixed one was the honest half. Renamed to - // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the - // shape IS `strictObject`, so the tombstone is here for the prescription an - // unknown-key rejection cannot carry. Covered by the D2 conversion - // `turso-config-timeout-to-timeout-ms`: a turso datasource is a `datasources[]` - // stack collection member whose `config` is stored whole in `sys_metadata`. - // ⚠️ This is the SPEC's turso contract (`packages/spec/src/data/driver/turso.zod.ts`). - // The driver package ships its own parallel `turso.zod.ts` whose `timeout` is - // outside this card's declared population and is renamed by the card that - // widens that population. - 'data/TursoConfig:timeout', - // #15680 (stack card 5/6 of #14478) — ruling B. - // `CircuitBreakerConfig.monitoringWindow` said "Rolling window for failure - // count in ms" in prose and nothing else — ONE key below `resetTimeoutMs`, - // which already spelled its unit, on the same six-key shape. That is the - // sharpest case in this card: a single schema already carried both - // conventions, so a reader had no rule to apply, only two examples that - // disagreed. Renamed to `monitoringWindowMs`; the value and the 60000 default - // are unchanged. Tombstoned with `retiredKey()` — the shape is not `.strict()`, - // so a bare deletion would strip in silence and the breaker would fall back to - // its default window while the author believed they had widened it. Covered by - // the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: - // `connectors:` is a stack collection and a published connector row lands whole - // in `sys_metadata`, so the chain has a seam that sees it. - // - // ⚠️ Superseded in the same unreleased step: the whole `health` block was then - // retired under ADR-0049 (the connector resilience family), so - // `integration/CircuitBreakerConfig` left whole (`RETIRED_DEFS_BY_MAJOR[18]`) and - // this tombstone left with it. The row STAYS — the whole-def removal steady - // state gate (b3) exempts — because it is still the record that the bare - // `monitoringWindow` spelling was retired. The rename's breaker half was absorbed - // by `connector-resilience-keys-removed`, which strips the block an author - // holding either spelling still carries; the `health` tombstone's prescription - // names both spellings. (Its trigger half was absorbed later in the same step by - // `connector-triggers-removed`, so the rename conversion itself left the table.) - 'integration/CircuitBreakerConfig:monitoringWindow', - // ADR-0049 enforce-or-remove on `ConnectorSchema.connectionTimeoutMs` - // (maintainer ruling 2026-09-22, letter A — the narrower SECOND decision this - // key was owed, after the ruling that made its nine ledger siblings live left - // this one dead on a stated reason rather than by oversight). The key was - // bounded (`min(1000).max(300000)`), defaulted (`30000`), `.describe()`d and - // served back by `/meta/connector`: every signal an authoring surface can give - // said it worked. - // - // ⚠️ This is NOT the zero-mention retirement shape, and reading it as one loses - // the finding. Measured with `git grep -n connectionTimeoutMs SHA -- . - // ':!packages/spec'` at `origin/main`: THIRTEEN non-test source occurrences over - // seven files in five packages — SIX READS (openapi-connector.ts:242, - // openapi-provider.ts:193, rest-connector.ts:134, rest-provider.ts:64, - // plugin.ts:307, plugin.ts:1589), FOUR TYPE DECLARATIONS - // (openapi-connector.ts:135, rest-connector.ts:47, plugin.ts:291, - // plugin.ts:339), and THREE surviving hardcoded `30000` writes - // (mcp-connector.ts:247, slack-connector.ts:94, plugin.ts:1782). - // - // ⭐ The card's own Leg-2 table — "five non-spec mentions, all writes of a - // hardcoded 30000" — was CORRECT at the SHA it cited and dated (0870fb5418: - // exactly those five, line for line). It is STALE, not false; b929e0a662, the - // PR the card itself flagged as pending, is what moved it. ⛔ Do not re-cite - // either reading without its tree: a census is a count plus the commit it was - // taken against. - // - // Measured across all six reads, every one is a pass-through: the value's only - // termini are the def `GET /connectors` echoes and the fingerprint that decides - // whether to re-materialize. Never a deadline. - // `connectorFetchOptions()` (`integration/connector-fetch-policy.ts`) is the one - // mapping from authored policy onto the platform's outbound `fetch`, and it was - // handed `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not - // honouring it — ADR-0049 forbids the parsed-unmarked-unenforced state whether - // the inert value travels or sits still. - // - // The `实现` arm was unavailable, which is why the second decision came out - // `retire` rather than `enforce`: a connector's outbound call is a WHATWG - // `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the - // whole operation, so nothing there observes the connect phase. Bounding - // "time until the response arrives" with this key would kill a slow-but- - // connected upstream the author meant to allow with a large `requestTimeoutMs` - // — breaking the very promise the key makes. (undici's `connectTimeout` needs a - // custom dispatcher: Node-only, and a new subsystem underneath every connector.) - // `requestTimeoutMs` — the lit control for every census above — is the bound - // the platform can keep. Its live record is cited as the code rather than as a - // card number on purpose: the PR that made it live has been DELETED from the - // board (probed: minted, absent, and the web endpoint 404s), so the only - // durable anchor is `integration/connector-fetch-policy.ts`, where - // `connectorFetchOptions()` maps the key onto `resilientFetch`'s per-attempt - // `timeoutMs`, pinned by `connector-fetch-policy.test.ts`. - // - // Tombstoned with `retiredKey()`: `ConnectorSchema` is a non-strict `z.object`, - // so a bare deletion would be a silent strip (ADR-0104). No def leaves with it — - // the key was a bare `z.number()`, not a `ConfigSchema` shape, so - // `RETIRED_DEFS_BY_MAJOR[18]` gains nothing. Authored sources and stored rows are - // rewritten by the D2 conversion `connector-connection-timeout-ms-removed`; the - // withdrawn `ConnectorProviderContext` member, which is code with no authored - // source, leaves via the D3 semantic entry - // `connector-provider-context-connection-timeout-ms-retired`. - // - // ⭐ RETIRED-DEFAULT RESIDUE: the `acceptRetiredDefaultResidue` stage IS owed - // here, and is adopted — `{ connectionTimeoutMs: 30000 }` on both carriers - // (#12840, maintainer ruling 2026-08-28; the class rule lives in - // `shared/retired-key.ts`). The discriminator across the three siblings is - // whether a released toolchain MATERIALIZED the default into something that is - // later re-parsed — `security/ObjectPermission:allowPurge` adopted the stage - // for exactly that reason, `kernel/PluginQualityMetrics:securityScan` did not - // because the key carried no default of its own, and - // `api/ListInstalledPackagesRequest:limit` did not because nothing ever parses - // through that schema so its `.default(50)` was never materialized anywhere. - // - // This key is in the FIRST bucket, measured across two builds rather than - // argued: on the base build `ObjectStackSchema.parse({ connectors: [{ name, - // label, type }] })` returns an entry carrying `connectionTimeoutMs: 30000` - // (emitted keys: authentication, connectionTimeoutMs, enabled, label, name, - // requestTimeoutMs, status, type — the author typed three), and feeding that - // exact object back to the tombstoned build is refused at - // `connectors.0.connectionTimeoutMs`. - // - // ⛔ The presence of a D2 conversion does NOT discharge this obligation, and - // reading it that way is the trap: `security/ObjectPermission:allowPurge` has - // BOTH a D2 (`permission-allow-restore-purge-removed`, in the same step-18 - // chain) AND the residue stage. The reason is the SECOND door — the fact - // `liveness/connector.json` opens its `_note` with: besides the authoring - // doors, `AutomationEngine.registerConnector` parses `ConnectorSchema` for a def - // a PLUGIN or an ADR-0097 provider factory builds IN CODE. No conversion runs - // there. A connector package still compiled against 17.x carries the - // materialized `30000` in that def literal — all four shipped connectors did, - // which is what the card counted as its hardcoded writes — so without this - // stage a 17.x plugin fails registration on a value its author never typed. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look — the disposition - // `18.integration__Connector__errorMapping.ts` records for the same schema. - 'integration/Connector:connectionTimeoutMs', - // Commit 13c48c2a5 — ADR-0049 enforce-or-remove on `ConnectorSchema.errorMapping` (triage - // ruling 2026-09-02: removal via the `spec-property-retirement` playbook; the - // split condition — a downstream consumer in objectui or a customer stack — - // measured empty at objectui `0d8fd7c`, hotcrm not measurable). The key carried - // `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys), and - // NOTHING read them: outside the declaring file and its unit test the only - // reference in the tree was a type-identity pin. No provider, dispatcher or - // materializer ever mapped an external error through the rules, so - // `unmappedBehavior` configured nothing and a rule's `userMessage` was never - // shown to anyone — and that spelling is the name of the LIVE API-error channel - // (`ApiError.userMessage`), so an author who had read that documentation and - // wrote a rule here reasonably believed they were marking a refusal for an end - // user; the failure was silent in both directions (it validated, it published, - // nothing was shown). Removal resolves the collision by deletion. Tombstoned - // with `retiredKey()`: `ConnectorSchema` is a non-strict `z.object`, so a bare - // deletion would be a silent strip (#3733, ADR-0104). The def shapes leave - // whole — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and - // the orphaned `integration/ConnectorErrorCategory` enum, all in - // `RETIRED_DEFS_BY_MAJOR[18]`. Sources are rewritten by the D2 conversion - // `connector-error-mapping-removed` (one strip per `connectors[]` entry; the - // eleven nested keys leave with the block). - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the #12497 / #13823 grading). - 'integration/Connector:errorMapping', - // The second key of the connector-attached sync family (see - // `18.integration__Connector__syncConfig.ts` for the retirement record): - // `ConnectorSchema.fieldMappings`, whose six live keys (`source`, `target`, - // `defaultValue`, `dataType`, `required`, `syncMode`) no engine ever read — - // the word appeared nowhere outside `packages/spec`. The target-side field map - // is `mapping.fieldMapping`, which the REST import path executes. The orphaned - // `integration/ConnectorFieldMapping` leaves via `RETIRED_DEFS_BY_MAJOR[18]`; - // the same D2 conversion strips the key. - 'integration/Connector:fieldMappings', - // ADR-0049 enforce-or-remove on `ConnectorSchema.health` — the connector - // resilience family (one batch with `status` and the nested `webhooks`), retired - // by the maintainer's criterion for a declared-but-unenforced family: does the - // mainstream platform offer the capability? Author-configured health probes and - // circuit breakers are not connector metadata anywhere in the mainstream - // (Salesforce Named Credentials, Power Platform custom connectors, Retool / - // Appsmith resources); breakers live in API-gateway infrastructure. - // - // Measured on `origin/main` before the removal: the fourteen keys under the block - // — `healthCheck.{enabled, intervalMs, timeoutMs, endpoint, method, - // expectedStatus, unhealthyThreshold, healthyThreshold}` and - // `circuitBreaker.{enabled, failureThreshold, resetTimeoutMs, - // halfOpenMaxRequests, monitoringWindowMs, fallbackStrategy}` — have ZERO reads - // outside `packages/spec` (lit control in the same scan: `retryConfig`, the - // executed sibling policy, read 17 times in `packages/connectors` and - // `packages/services/service-automation`). Nothing polled, counted or tripped. - // - // Tombstoned with `retiredKey()`: `ConnectorSchema` is a non-strict `z.object`, - // so a bare deletion would be a silent strip (ADR-0104). The shapes behind it - // leave whole — `integration/ConnectorHealth`, `integration/HealthCheckConfig`, - // `integration/CircuitBreakerConfig` in `RETIRED_DEFS_BY_MAJOR[18]`. The key - // carried no default of its own, so there is no residue window for it (its - // sub-keys' defaults were only materialized inside an authored block). Sources - // and stored rows are rewritten by the D2 conversion - // `connector-resilience-keys-removed`; the family's judgement is the D3 entry - // `connector-resilience-keys-retired`. - // - // Registered under 18, not 17: the removal ships on the 17.x line - // (launch-window convention: accept-set narrowings ride minor releases) and the - // prescription lives at the major boundary where `migrate meta` users look — the - // disposition `18.integration__Connector__errorMapping.ts` records for the same - // schema. - 'integration/Connector:health', - // ADR-0049 enforce-or-remove on `ConnectorSchema.status` — part of the connector - // resilience family batch (see `18.integration__Connector__health.ts`). The key - // was `ConnectorStatusSchema` (`active` / `inactive` / `error` / `configuring`) - // with a `.default('inactive')`, and NOTHING read it. Measured at `origin/main` - // 3f86dc52f2 with one member-read pattern over the connector packages, the - // automation service, rest, runtime, metadata and objectql: 38 `.status` reads, - // every one on an HTTP answer, an error case or a flow-run entry, none on a - // connector def — while the same pattern finds `requestTimeoutMs`, the lit - // control, read off a connector entry or provider context five times. - // The runtime's dispatchability answer is a DIFFERENT field: the computed - // `state` (`ready` / `degraded`) that `GET /api/v1/automation/connectors` - // publishes and no authored value can set. Participation is `enabled` (and - // `provider` on a declarative instance). The only non-spec occurrences were - // WRITES — `status: 'active'` in the four shipped connector packages and - // `status: 'error'` on the automation service's degraded husk — read back by - // nothing; they were deleted in the same change. - // - // Tombstoned with `retiredKey()` (non-strict schema, ADR-0104); the orphaned - // `integration/ConnectorStatus` enum leaves via `RETIRED_DEFS_BY_MAJOR[18]`. - // - // ⭐ RETIRED-DEFAULT RESIDUE: owed and adopted — `{ status: 'inactive' }` joins - // `{ connectionTimeoutMs: 30000 }` in `CONNECTOR_RETIRED_KEY_RESIDUE` on both - // carriers (#12840's class rule, `shared/retired-key.ts`). The discriminator is - // whether a released toolchain MATERIALIZED the default into something that is - // later re-parsed, and it did: every 17.x parse emitted `status: 'inactive'` - // into every connector — authored or not — and `registerConnector` re-parses a - // def built in code, where no conversion runs. Any other value keeps the - // refusal. Sources and stored rows are rewritten by the D2 conversion - // `connector-resilience-keys-removed`. - 'integration/Connector:status', - // ADR-0049 on `ConnectorSchema.syncConfig` — connector-attached sync, ruled - // ENFORCE on the maintainer's criterion for a declared-but-unenforced family - // (the mainstream has the capability) with the definition MOVED to the target - // side: a `mapping` whose `connectorSource` names the connector it pulls from, - // with a `job` for the cadence. The eight `DataSyncConfig` keys (`strategy`, - // `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, - // `batchSize`, `deleteMode`, `filters`) were read by NOTHING. Measured on - // `origin/main` before the removal: outside `packages/spec` the word - // `syncConfig` appeared only in two comments; the automation service's - // declared-connector item and its re-materialization fingerprint carry - // neither sync key, and the def a provider registers is the provider's own, - // so the key never reached the connector registry — while `retryConfig`, the - // lit control on the same def, is read by the connector fetch policy. - // - // Tombstoned with `retiredKey()` (non-strict schema, ADR-0104); the orphaned - // `integration/DataSyncConfig`, `integration/SyncStrategy` and - // `integration/ConnectorConflictResolution` leave via - // `RETIRED_DEFS_BY_MAJOR[18]`. The key carried no default of its own, so no - // retired-default residue is owed. Sources and stored rows are rewritten by - // the D2 conversion `connector-sync-keys-removed`, which STRIPS the key and - // never writes a `mapping` — that is the author's decision, carried by the D3 - // entry `connector-sync-keys-retired`. - 'integration/Connector:syncConfig', - // ADR-0049 enforce-or-remove on `ConnectorSchema.triggers` — the connector - // triggers family, ruled RETIRE on the maintainer's criterion for a - // declared-but-unenforced family, with ADR-0041 left as it is (connector-event - // triggers stay in its third tier, as their own trigger package, promoted only - // when real projects ask for them). The `ConnectorTrigger` array (`key`, - // `label`, `description`, `type: 'polling' | 'webhook'`, `intervalSeconds`) was - // read by NOTHING. Measured on `origin/main` before the removal: - // `AutomationEngine.registerConnector` walks `parsed.actions` only and stores the - // rest of the def unread; the engine's trigger registry is keyed by FLOW trigger - // kind (`record_change`, `time_relative`, `schedule`, `api` — a closed set) and - // no connector trigger ever entered it; no polling loop read `intervalSeconds`; - // no receiver was driven by a `webhook` trigger; and across every `.ts`, `.tsx` - // and `.json` file outside `packages/spec` (tests excluded) at `288611e3e5`, the - // 15 authorings of a `triggers:` key were all webhook `triggers`, a plugin - // grouping or form-label translations — none a connector trigger — while - // `actions`, the lit control on the same def, is walked by `registerConnector` - // and handler-checked there. - // - // The one runtime touch was a REFUSAL of `triggers` on a provider-bound - // declarative instance, reasoned "the provider derives them from the upstream at - // boot" — untrue, since no provider ever derived a trigger. The tombstone makes - // that rule unreachable (every carrier refuses every value), so the rule left - // with it rather than being re-reasoned. - // - // Tombstoned with `retiredKey()` (non-strict schema, ADR-0104); the orphaned - // `integration/ConnectorTrigger` leaves via `RETIRED_DEFS_BY_MAJOR[18]`. The key - // carried no default, so no retired-default residue is owed. Sources and stored - // rows are rewritten by the D2 conversion `connector-triggers-removed`, which - // STRIPS the array and never turns a trigger into a flow — that is the author's - // decision, carried by the D3 entry `connector-triggers-retired`. - 'integration/Connector:triggers', - // ADR-0049 enforce-or-remove on `ConnectorSchema.webhooks` — part of the - // connector resilience family batch (see `18.integration__Connector__health.ts`). - // A connector's NESTED webhook array is not the collection anything delivers: - // the stack decomposition registers a `connectors:` entry WHOLE, so a webhook - // nested in it never becomes a `webhook` metadata item, and - // `@objectstack/plugin-webhooks` materializes `sys_webhook` rows only from those - // items (the top-level `webhooks:` collection). Measured on `origin/main`: zero - // reads of a connector's own `webhooks` outside `packages/spec`, while - // `stack.webhooks` — the lit control, same scan — is read five times; the one - // test that authors a nested array - // (`bootstrap-declared-webhooks.connector-nested.test.ts`) exists to pin that it - // is NOT hoisted. And no code path emits a connector lifecycle event - // (`sync.completed`, `auth.expired`, …) for its `events` to subscribe to. - // - // Tombstoned with `retiredKey()` (non-strict schema, ADR-0104). The nested shape - // leaves whole — `integration/WebhookConfig`, `integration/WebhookEvent`, - // `integration/WebhookSignatureAlgorithm` in `RETIRED_DEFS_BY_MAJOR[18]`. The key - // carried no default, so no residue window. The D2 conversion - // `connector-resilience-keys-removed` STRIPS the array and never moves it to the - // top-level collection: that would start deliveries the connector never made — - // the author's decision, carried by the D3 entry `connector-resilience-keys-retired`. - 'integration/Connector:webhooks', - // #15680 (stack card 5/6 of #14478) — ruling B. `ConnectorTrigger.interval` - // said "Polling interval in seconds" in prose and nothing else. A polling - // cadence is exactly the number a reader guesses at, and the bare name `interval` - // means MILLISECONDS elsewhere in this same spec — the identical spelling - // carrying two units a thousandfold apart is the collision that got this whole - // population ruled rather than merely noted. Renamed to `intervalSeconds`; the - // value is unchanged. Tombstoned with `retiredKey()`; the shape is not - // `.strict()`, so a bare deletion would strip in silence. Covered by the D2 - // conversion `connector-health-and-trigger-durations-unit-in-key`. - // - // ⚠️ Superseded in the same unreleased step: the trigger shape was declared but - // never read (no polling loop was driven by it), and the whole `triggers` array - // was then retired under ADR-0049 — `integration/ConnectorTrigger` left whole - // (`RETIRED_DEFS_BY_MAJOR[18]`) and this tombstone left with it. The row STAYS — - // the whole-def removal steady state gate (b3) exempts — because it is still the - // record that the bare `interval` spelling was retired (the - // `integration/CircuitBreakerConfig:monitoringWindow` precedent). The rename - // conversion left the table, both of its halves absorbed; the trigger half by - // `connector-triggers-removed`, which strips the array an author holding either - // spelling still carries, and the `triggers` tombstone's prescription names both. - 'integration/ConnectorTrigger:interval', - // The same tombstone seen through the second carrier. - // `DeclarativeConnectorEntrySchema` and `ConnectorSchema` are now SIBLINGS, not - // parent and child: each wraps the shared private `ConnectorBaseSchema` in the - // retired-default residue stage, the entry schema adding the ADR-0097 - // cross-field rules on the base before wrapping. (Until this retirement the - // entry schema was literally `ConnectorSchema.superRefine(...)`; that spelling - // is gone with the pipe.) So the `connectionTimeoutMs` tombstone is carried by - // the shape that - // `stack.connectors[]` (`stack.zod.ts`) and the `PUT /meta/connector/:name` door - // (`kernel/metadata-type-schemas.ts`) actually parse, and the authorable-surface - // walk publishes the `[RETIRED]` row under this def key as well. One tombstone, - // two registered keys: gate (b) of `scripts/build-schemas.ts` reads EXACT - // `${defKey}:${name}` membership per def, never by radiating from a neighbour. - // - // This carrier is also what made the D2 conversion owed rather than optional: - // the door persists what it parses, so a stored `sys_metadata` connector row can - // carry the key — measured, not assumed, before the tombstone landed. - // See `18.integration__Connector__connectionTimeoutMs.ts` for the retirement record. - 'integration/DeclarativeConnectorEntry:connectionTimeoutMs', - // Commit 13c48c2a5 — the same tombstone seen through the second carrier. - // `DeclarativeConnectorEntrySchema` and `ConnectorSchema` both wrap the shared - // private `ConnectorBaseSchema` in the retired-default residue stage, the entry - // schema adding the ADR-0097 cross-field rules on the base before wrapping. - // (When this entry was written the two were parent and child — - // `ConnectorSchema.superRefine(...)` — and the `connectionTimeoutMs` retirement - // replaced that with the sibling shape.) Either way the `errorMapping` - // tombstone on the base is carried by the shape that - // `stack.connectors[]` (`stack.zod.ts`) and the `PUT /meta/connector/:name` door - // (`kernel/metadata-type-schemas.ts`) actually parse, and the authorable-surface - // walk publishes the `[RETIRED]` row under this def key as well. One tombstone, - // two registered keys: gate (b) of `scripts/build-schemas.ts` reads EXACT - // `${defKey}:${name}` membership per def, never by radiating from a neighbour. - // See `18.integration__Connector__errorMapping.ts` for the retirement record. - 'integration/DeclarativeConnectorEntry:errorMapping', - // The same `fieldMappings` tombstone seen through the second carrier — the - // shape `stack.connectors[]` and the `PUT /meta/connector/:name` door parse. - // See `18.integration__Connector__syncConfig.ts` for the retirement record. - 'integration/DeclarativeConnectorEntry:fieldMappings', - // The same `health` tombstone seen through the second carrier. - // `DeclarativeConnectorEntrySchema` and `ConnectorSchema` are SIBLINGS: each - // wraps the shared private `ConnectorBaseSchema` in the retired-default residue - // stage, so the tombstone is carried by the shape that `stack.connectors[]` - // (`stack.zod.ts`) and the `PUT /meta/connector/:name` door - // (`kernel/metadata-type-schemas.ts`) actually parse, and the authorable-surface - // walk publishes the `[RETIRED]` row under this def key as well. One tombstone, - // two registered keys: gate (b) of `scripts/build-schemas.ts` reads EXACT - // `${defKey}:${name}` membership per def. See - // `18.integration__Connector__health.ts` for the retirement record. - 'integration/DeclarativeConnectorEntry:health', - // The same `status` tombstone seen through the second carrier — the shape - // `stack.connectors[]` and the `PUT /meta/connector/:name` door parse, which - // also carries the `'inactive'` residue stage (both carriers wrap - // `ConnectorBaseSchema` with the same `CONNECTOR_RETIRED_KEY_RESIDUE`). One - // tombstone, two registered keys, EXACT per-def membership (gate (b)). See - // `18.integration__Connector__status.ts` for the retirement record. - 'integration/DeclarativeConnectorEntry:status', - // The same `syncConfig` tombstone seen through the second carrier — the shape - // `stack.connectors[]` and the `PUT /meta/connector/:name` door parse. One - // tombstone, two registered keys, EXACT per-def membership (gate (b)). See - // `18.integration__Connector__syncConfig.ts` for the retirement record. - 'integration/DeclarativeConnectorEntry:syncConfig', - // The same `triggers` tombstone seen through the second carrier — the shape - // `stack.connectors[]` and the `PUT /meta/connector/:name` door parse, where the - // provider-bound refusal the tombstone replaced used to live. One tombstone, two - // registered keys, EXACT per-def membership (gate (b)). See - // `18.integration__Connector__triggers.ts` for the retirement record. - 'integration/DeclarativeConnectorEntry:triggers', - // The same `webhooks` tombstone seen through the second carrier — the shape - // `stack.connectors[]` and the `PUT /meta/connector/:name` door parse. One - // tombstone, two registered keys, EXACT per-def membership (gate (b)). See - // `18.integration__Connector__webhooks.ts` for the retirement record. - 'integration/DeclarativeConnectorEntry:webhooks', - // #18669 — maintainer ruling A (2026-09-17, decision batch #151 item 4): - // `CompatibilityMatrixEntry.estimatedMigrationTime` said "Estimated migration - // time in hours" in a source JSDoc and carried no `.describe()` at all, so the - // published reference page rendered a bare number beside `migrationComplexity`'s - // named scale — the JSDoc-channel shape #15939 was filed on, one def over. - // Renamed to `estimatedMigrationTimeHours` AND given the describe: the rename - // alone would have left the two channels that name the unit (the key name and a - // source comment) agreeing about something `content/docs/references/**` does not - // print, which `check:duration-unit-keys` refuses as - // `unit-in-jsdoc-not-in-describe` (ruled an offence 2026-09-18, decision batch - // #158 item 5). HOURS is the unit the JSDoc named, kept rather than converted to - // seconds: the value is unchanged and the ruling forbade narrowing. Tombstoned - // with `retiredKey()`: `CompatibilityMatrixEntrySchema` is a plain `z.object`, - // not strict, so a bare deletion would strip the old spelling in silence. No D2 - // conversion: a compatibility matrix is a plugin-published version manifest — - // `stack.zod.ts` declares no collection of them and it is not a registered - // metadata kind stored as a `sys_metadata` row. - // See `kernel-compatibility-matrix-estimated-migration-time-unit-in-key`. - 'kernel/CompatibilityMatrixEntry:estimatedMigrationTime', - // #15678 (stack card 3/6 of #14478) — ruling B. `EventPersistence.retention` - // said "Days to retain persisted events" in prose and nothing else. Renamed to - // `retentionDays`; the value is unchanged. Tombstoned with `retiredKey()`. No - // D2 conversion: an `EventPersistence` hangs off `EventBusConfig`, the event - // bus's construction argument — never a stack collection member (`stack.zod.ts` - // declares no `eventBus` key) and never a stored sys_metadata row, so the - // conversion chain has no seam that would see one. The semantic entry - // `kernel-event-bus-retention-unit-in-key` carries the prescription. - 'kernel/EventPersistence:retention', - // #15678 (stack card 3/6 of #14478) — ruling B. `EventSourcingConfig.retention` - // said "Days to retain events" in prose and nothing else — two keys above the - // count-valued `snapshotRetention`, so `retention: 365` and - // `snapshotRetention: 10` read as the same kind of number and are not. Renamed - // to `retentionDays`; the value is unchanged, and `snapshotRetention` keeps its - // name because a count has no unit to carry. Tombstoned with `retiredKey()`. - // No D2 conversion, for the reason the sibling `EventPersistence:retention` - // entry records; `kernel-event-bus-retention-unit-in-key` is the prescription. - 'kernel/EventSourcingConfig:retention', - // #15676 — the epoch-instant half of #14478 ruling B. `HealthStatus.timestamp` - // is the instant the health check RAN: it moved onto the shared `EpochMs` schema - // and was renamed `checkedAt`, which also states what the instant marks. - // - // Semantic entry rather than a D2 conversion, and registered under 18 rather - // than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry - // records: a health report is emitted by the startup orchestrator at runtime, - // never authored into a metadata document. - 'kernel/HealthStatus:timestamp', - // #15939 ruling A (per-file remediation of #14478). - // `HotReloadConfig.debounceDelay` said "Debounce delay before reloading - // (milliseconds)" in a source JSDoc and "Wait time after change detection before - // reload" in the `.describe()` the reference pages publish, so the published - // channel named no unit at all and the reference-page reader got a bare 1000. - // Renamed to `debounceDelayMs`, the plain suffix rather than a shortened form: - // this is the only debounce-shaped key spelling in the repo (5 key-position - // occurrences, all this key and its fixtures; no `debounceMs` variant anywhere), - // while the Delay-plus-Ms pairing is already attested (`maxDelayMs`, - // `initialDelayMs`, `retryDelayMs`, `delayMs`) — so there was no competing family - // spelling to choose between. The value and the 1000 default are unchanged. - // Tombstoned with `retiredKey()`: `HotReloadConfigSchema` is not `.strict()`, so - // a bare deletion would silently strip the key and hand `setTimeout` no delay. - // No D2 conversion: not a stack collection member, not a stored row — - // `HotReloadConfig` is a library parameter a host passes to `HotReloadManager` in - // TypeScript, the same reading `hot-reload-watch-placeholder-retired` recorded - // for this def. See `kernel-health-check-and-hot-reload-durations-unit-in-key`. - 'kernel/HotReloadConfig:debounceDelay', - // #12428 — ADR-0049 enforce-or-remove, one symbol over from #12340 (PR #12425) - // in the same file and on the same per-key test. `HotReloadManager.startWatching` - // contained NO watcher: a guard plus `logger.info('File watching started', - // { patterns })` above an in-source note saying real watching "would require - // chokidar or similar / This is a placeholder for the integration point". - // `watchHandles` was only ever read, deleted, iterated and cleared and NEVER - // set, so `stopWatching`'s cleanup branch and the teardown loop over its keys - // were structurally UNREACHABLE rather than merely untaken — measured with a - // firing positive control (`reloadTimers.set` resolves a real writer in the - // same file and the same scan; `watchHandles.set` resolves nothing anywhere). - // `watchPatterns` therefore had no reader that ACTED on it, and #12340's - // silence was at least at DEBUG level where this one claimed success at INFO. - // - // Neither of ADR-0049's other two states was available: ENFORCE would build for - // a caller that does not exist (no runtime composes `HotReloadManager` — only - // its own unit test and `core/examples/phase2-integration.ts`), and - // EXPERIMENTAL requires a roadmap, where a scan of every planning doc returned - // ZERO hits for hot-reload file watching against 145 control hits in the same - // files. Real watching lives where it is implemented: `chokidar` is a - // dependency of `@objectstack/metadata`, `@objectstack/metadata-fs` and - // `@objectstack/cli`, never of `@objectstack/core`. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look — the same grading #12340 used one - // day earlier in this module. - // - // Tombstoned with `retiredKey()` in `HotReloadConfigSchema` (the surface - // baseline line carries `[RETIRED]`). Deliberately NO D2 conversion: the chain - // walks a normalized STACK, and `HotReloadConfig` is not an authorable surface - // — no metadata-type binding, stack collection or manifest embed ever carried - // it, and nothing in the tree parses `HotReloadConfigSchema` outside its own - // unit test — so a conversion would be a transform with no seam that ever - // runs. The D3 semantic entry `hot-reload-watch-placeholder-retired` is the - // declaration, and the registration-time refusal in - // `HotReloadManager.registerPlugin` is the door for the audience that exists. - 'kernel/HotReloadConfig:watchPatterns', - // Commit 0c2334f6c — ADR-0049 enforce-or-remove on the preview-mode block (maintainer - // ruling 2026-08-27, Option A: remove). The key was declared as an auth bypass - // — its docstring promised auto-login as a simulated admin and named a - // production guard "the runtime must enforce" — and NOTHING implemented any of - // it: zero consumers measured in objectstack, objectui and cloud (cloud#1651, - // 2026-08-26, with positive controls; `ArtifactKernelFactory` — where serve.ts - // predicted preview auto-login would live if anywhere — never touches it). An - // author could write the six-key block today, parse cleanly, and get no - // behaviour and no diagnostic, while the reference docs said the capability - // existed. Tombstoned with `retiredKey()` — `KernelContextSchema` is not - // `.strict()`, so a bare deletion would be a silent strip (#3733, ADR-0104). - // - // Registered here but NOT in `src/conversions/registry.ts`, the - // `kernel/Manifest:loading` reasoning: a kernel context is constructed by HOST - // CODE at boot — it is not a stack collection member (`PLURAL_TO_SINGULAR` has - // no entry for it) and nothing stores one as a `sys_metadata` row, so a - // MetadataConversion would be a transform with no seam that ever runs. The - // prescription reaches authors through the tombstone plus the D3 semantic - // entry `kernel-context-preview-mode-retired`. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - 'kernel/KernelContext:previewMode', - // #15676 — the epoch-instant half of #14478 ruling B. `KernelContext.startTime` - // is the boot INSTANT: it moved onto the shared `EpochMs` schema and was renamed - // `startedAt`. - // - // Semantic entry rather than a D2 conversion, the same disposition - // `kernel/KernelContext:previewMode` already carries on this very def: a kernel - // context is constructed by HOST CODE at boot — not a stack collection member - // (`PLURAL_TO_SINGULAR` has no entry for it), never stored as a `sys_metadata` - // row — so the conversion chain has no seam that would ever see one. - // - // Registered under 18, not 17, for the reason that sibling entry records. - 'kernel/KernelContext:startTime', - // #15678 (stack card 3/6 of #14478) — ruling B. - // `KernelSecurityPolicy.auditLog.retention` said "Log retention in days" in - // prose and nothing else. Renamed to `retentionDays`; the value is unchanged. - // Tombstoned with `retiredKey()`. This is the THIRD bare `retention` this card - // renames and the second unit-bearing one to land on `retentionDays` — the - // spelling is now uniform across the kernel. No D2 conversion; see - // `kernel-plugin-security-durations-unit-in-key`. - 'kernel/KernelSecurityPolicy:auditLog.retention', - // #15678 (stack card 3/6 of #14478) — ruling B. - // `KernelSecurityPolicy.authentication.tokenExpiration` said "Token expiration - // in seconds" in prose and nothing else — on a policy whose rate-limit window - // two blocks above was ALREADY spelled `windowMs`, so one policy document - // carried both conventions. Renamed to `tokenExpirationSeconds`; the value is - // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: a - // `KernelSecurityPolicy` is a plugin security manifest's policy block, never a - // stack collection member. See `kernel-plugin-security-durations-unit-in-key`. - 'kernel/KernelSecurityPolicy:authentication.tokenExpiration', - // Commit dce5cd4f0 — ADR-0049 enforce-or-remove on the plugin manifest's three dead - // top-level containers (triage graded 2026-08-23; cloud leg measured clean - // 2026-08-29 on #12400 with positive controls). The census found ZERO reads - // of the `capabilities` container itself in objectstack, objectui and cloud, - // which settles all five keys beneath it (`implements`, `provides`, - // `requires`, `extensionPoints`, `extensions`) at once — a key cannot be read - // if the object holding it never is. Its describe() sold "interoperability - // and automatic discovery"; no discovery path consulted it, and real - // dependency resolution runs off top-level `manifest.dependencies`. ONE - // tombstoned key, because `capabilities` was the single carrier (the - // `kernel/Manifest:loading` shape); `PluginCapabilityManifestSchema` itself - // stays published — the plugin-registry surface still declares it — so - // nothing lands in `RETIRED_DEFS_BY_MAJOR`. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/Manifest:loading` gives: a package manifest is not a stack - // collection member (`PLURAL_TO_SINGULAR` has no `packages` / `plugins` - // entry — re-verified at claim; the map's `capabilities` entry is the - // unrelated ADR-0066 stack-level collection), so a D2 conversion would be a - // transform with no seam that ever runs. The prescription reaches authors - // through the tombstone at `os plugin build` → `ManifestSchema.safeParse` - // and through the D3 semantic entry - // `plugin-manifest-dead-containers-retired`. - 'kernel/Manifest:capabilities', - // Commit dce5cd4f0 — ADR-0049 enforce-or-remove on the plugin manifest's three dead - // top-level containers; census and registration major recorded once in the - // sibling entry `kernel/Manifest:capabilities`, the why-no-D2-conversion - // reasoning in `kernel/Manifest:loading` (the precedent); the D3 semantic - // entry is `plugin-manifest-dead-containers-retired`. - // - // `configuration` declared a per-plugin settings surface (`{ title, - // properties }`, a simplified JSON-Schema map) that no settings UI rendered - // and no loader resolved. Its `properties.*.secret` flag is the - // false-compliance shape ADR-0049 exists for: the describe() promised "value - // is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or - // even parsed the flag — `secret: true` next to an API key got exactly the - // same handling as `secret: false`. The enforced channel is host - // composition: the options object passed to the plugin's constructor in - // `defineStack({ plugins: [new MyPlugin({ … })] })`. - 'kernel/Manifest:configuration', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block; one of NINE members tombstoned together. Census, registration major, - // and the why-no-D2-conversion reasoning are recorded once in the sibling - // entry `kernel/Manifest:contributes.events` (this family) and in - // `kernel/Manifest:loading` (the precedent); the D3 semantic entry is - // `plugin-manifest-contributes-dead-members-retired`. - // - // `actions` declared invocable server actions nothing ever registered. The - // working surfaces are the stack `actions` collection (METADATA_ARRAY_KEYS, - // registered by the engine) and `engine.registerAction`. - 'kernel/Manifest:contributes.actions', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block; one of NINE members tombstoned together. Census, registration major, - // and the why-no-D2-conversion reasoning are recorded once in the sibling - // entry `kernel/Manifest:contributes.events` (this family) and in - // `kernel/Manifest:loading` (the precedent); the D3 semantic entry is - // `plugin-manifest-contributes-dead-members-retired`. - // - // `commands` documented Commander.js runtime resolution as current behaviour; - // the CLI never resolved commands from it. Commands are auto-discovered via - // oclif's native plugin system (`cli-extension.zod.ts` records the migration: - // "The `objectstack.config.ts` plugins array no longer determines CLI - // commands"). - 'kernel/Manifest:contributes.commands', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block; one of NINE members tombstoned together. Census, registration major, - // and the why-no-D2-conversion reasoning are recorded once in the sibling - // entry `kernel/Manifest:contributes.events` (this family) and in - // `kernel/Manifest:loading` (the precedent); the D3 semantic entry is - // `plugin-manifest-contributes-dead-members-retired`. - // - // `drivers` declared storage drivers nothing ever read. A driver is wired by - // registering a kernel SERVICE named `driver.*` (objectql's plugin calls - // `registerDriver` on it); the only in-repo author (driver-memory) was - // registered that way, not by its declaration. - 'kernel/Manifest:contributes.drivers', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block (triage graded 2026-08-21; cloud leg measured clean 2026-08-24). One - // of NINE members tombstoned together: that commit's census measured exactly ONE non-test - // read of `manifest.contributes` monorepo-wide (engine.ts, member `kinds`), - // with controls, re-verified across objectstack + objectui + cloud at claim - // time. `events` in particular was decorative twice over: its only in-repo - // author (plugin-hono-server) already subscribed to the same events - // imperatively in plugin code, so the declaration drove nothing even for the - // one package that wrote it. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/Manifest:loading` gives: a package manifest is not a stack - // collection member (`PLURAL_TO_SINGULAR` has no `packages` / `plugins` - // entry), so a D2 conversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone at - // `os plugin build` → `ManifestSchema.safeParse` and through the D3 semantic - // entry `plugin-manifest-contributes-dead-members-retired`. - 'kernel/Manifest:contributes.events', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block; one of NINE members tombstoned together. Census, registration major, - // and the why-no-D2-conversion reasoning are recorded once in the sibling - // entry `kernel/Manifest:contributes.events` (this family) and in - // `kernel/Manifest:loading` (the precedent); the D3 semantic entry is - // `plugin-manifest-contributes-dead-members-retired`. - // - // `fieldTypes` advertised an extension point the platform does not have: - // there is no `registerFieldType` seam anywhere — zero hits monorepo-wide. - // The field-type vocabulary is the spec `FieldType` enum. - 'kernel/Manifest:contributes.fieldTypes', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block; one of NINE members tombstoned together. Census, registration major, - // and the why-no-D2-conversion reasoning are recorded once in the sibling - // entry `kernel/Manifest:contributes.events` (this family) and in - // `kernel/Manifest:loading` (the precedent); the D3 semantic entry is - // `plugin-manifest-contributes-dead-members-retired`. - // - // `functions` declared ObjectQL functions nothing ever registered from here. - // The working surface is `defineStack({ functions })` → hook-binder → - // `engine.registerFunction`. - 'kernel/Manifest:contributes.functions', - // #11169 — ADR-0049 enforce-or-remove on `contributes.kinds[].globs` - // (maintainer ruling 2026-08-24, 「接受你的建议。」: remove via the full - // ceremony). The sub-field promised glob-driven file-type discovery the - // platform performs somewhere else: real artifact discovery globs - // `filePatterns` off the metadata type registry, and - // `metadata-plugin.zod.ts` states outright that `contributes.kinds` does not - // extend it. Measured (PR #11168, re-run with positive control at claim - // time): zero consumers — the only non-test occurrences of the path are the - // schema declaration and two type positions (`registerKind`'s parameter, - // `getAllKinds`' return), and nothing reads the value — so an authored - // `globs` was stored, served back through `GET /metadata/kind`, and never - // consulted. The `kind` bucket itself and its `id` are NOT touched: the - // bucket is live (engine → `registerKind`) and reachable via the generic - // `GET /metadata/:type` passthrough. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/Manifest:loading` gives: a package manifest is not a stack - // collection member, so a D2 conversion would be a transform with no seam - // that ever runs. The prescription reaches authors through the tombstone at - // `os plugin build` → `ManifestSchema.safeParse` and through the D3 semantic - // entry `plugin-manifest-kind-globs-retired`. - 'kernel/Manifest:contributes.kinds.globs', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block; one of NINE members tombstoned together. Census, registration major, - // and the why-no-D2-conversion reasoning are recorded once in the sibling - // entry `kernel/Manifest:contributes.events` (this family) and in - // `kernel/Manifest:loading` (the precedent); the D3 semantic entry is - // `plugin-manifest-contributes-dead-members-retired`. - // - // `menus` had the tightest control in the census: the bare word has only three - // non-test hits monorepo-wide — this declaration plus two alias maps that - // redirect the spelling to `navigation`. The working surface is app - // `navigation` / `manifest.navigationContributions` (ADR-0029 D7), which the - // engine registers. - 'kernel/Manifest:contributes.menus', - // Commit bc56e1881 — ADR-0049 enforce-or-remove fork on `contributes.routes`, the ONE - // `contributes` member deliberately excluded from commit be21955ba's nine-member - // retirement because removing it needed a ruling, not a tombstone: the key - // was the only DECLARED channel for a real capability (serving a code-handler - // endpoint), and four published surfaces — a customer-published skill among - // them — taught it as working machinery. Maintainer ruled Option B 2026-08-22 - // (「接受所有」 on the decision batch carrying the four-axis analysis): remove - // the key; author-facing materials redirect to the imperative `http.server` - // mount, the form that actually works. The ruling's cloud precondition was - // discharged 2026-08-24 (the cloud census: cloud @ 5b5925a, zero `manifest.contributes` - // reads, controls green), completing commit be21955ba's three-repo census at exactly - // one live read (engine.ts, member `kinds` — now the block's sole survivor). - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/Manifest:loading` gives: a package manifest is not a stack - // collection member (`PLURAL_TO_SINGULAR` has no `packages` / `plugins` - // entry), so a D2 conversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone at - // `os plugin build` → `ManifestSchema.safeParse` and through the D3 semantic - // entry `plugin-manifest-contributes-routes-retired`. - 'kernel/Manifest:contributes.routes', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block; one of NINE members tombstoned together. Census, registration major, - // and the why-no-D2-conversion reasoning are recorded once in the sibling - // entry `kernel/Manifest:contributes.events` (this family) and in - // `kernel/Manifest:loading` (the precedent); the D3 semantic entry is - // `plugin-manifest-contributes-dead-members-retired`. - // - // `themes` here was a `{ id, label, path }` shape with no reader anywhere. It - // is UNRELATED to the stack-level `themes` collection (a `ThemeSchema` - // surface, itself retired as a carrier by `stack-themes-carrier-retired`): - // stack-level theme hits reach the registry through top-level metadata - // collections, never through `contributes.themes`. - 'kernel/Manifest:contributes.themes', - // Commit be21955ba — ADR-0049 enforce-or-remove on the plugin manifest's `contributes` - // block; one of NINE members tombstoned together. Census, registration major, - // and the why-no-D2-conversion reasoning are recorded once in the sibling - // entry `kernel/Manifest:contributes.events` (this family) and in - // `kernel/Manifest:loading` (the precedent); the D3 semantic entry is - // `plugin-manifest-contributes-dead-members-retired`. - // - // `translations` promised `{ locale, path }` file registration no loader ever - // performed. The working surface is the `translation` metadata type — stack - // `translations` collection (`defineTranslationBundle`), governed by - // `packages/spec/liveness/translation.json`. - 'kernel/Manifest:contributes.translations', - // Commit dce5cd4f0 — ADR-0049 enforce-or-remove on the plugin manifest's three dead - // top-level containers; census and registration major recorded once in the - // sibling entry `kernel/Manifest:capabilities`, the why-no-D2-conversion - // reasoning in `kernel/Manifest:loading` (the precedent); the D3 semantic - // entry is `plugin-manifest-dead-containers-retired`. - // - // `extensions` was an untyped escape hatch — `z.record(z.string(), - // z.unknown())` — with zero readers, so whatever an author parked here was - // stored and never consulted. Because the value type is `unknown`, this key - // is where anything the platform does not yet model would get parked; its - // measured emptiness is therefore its own evidence — authors are not parking - // things here either, making an untyped catch-all with no users the cheapest - // removal in the family. The enforced extension channels are - // `contributes.kinds` (metadata kinds), `navigationContributions` - // (ADR-0029 D7), and plugin code itself (`init`/`start`). - 'kernel/Manifest:extensions', - // #15624 — ADR-0049 enforce-or-remove (PM ruling on the card, conditioned on - // the measurement it carries). `MetadataManagerConfig.cache.enabled` was - // declared, defaulted (`true`), documented on the published reference page — - // and read by nothing: the only runtime consumer of the `cache` block is - // `MetadataManager`, which hands `cache.databaseLoader` (and only that) to - // `new DatabaseLoader({ cache })`. `enabled: false` switched nothing off. - // Tombstoned with `retiredKey()` (the nested object is not strict; a bare - // deletion would strip the key in silence — the same no-op one layer down). - // The switch that is honoured is `cache.databaseLoader.enabled`. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts` — the - // `kernel/MetadataManagerConfig:persistence.overlayWritable` reasoning: a - // metadata-manager config is not a stack collection member, so a - // MetadataConversion would be a transform with no seam that ever runs. The - // prescription reaches authors through the tombstone (`tsc` + the parse) and - // the D3 semantic entry `metadata-manager-config-inert-cache-keys-retired`. - 'kernel/MetadataManagerConfig:cache.enabled', - // #15624 — ADR-0049 enforce-or-remove. `MetadataManagerConfig.cache.maxSize` - // ("Max cache size in bytes") was declared and documented, and read by nothing - // — the outer `cache` block has no runtime consumer; only - // `cache.databaseLoader` reaches `DatabaseLoader`, whose own `maxSize` is an - // ENTRY COUNT (default 500), not bytes. Tombstoned with `retiredKey()`. The - // cap that is honoured is `cache.databaseLoader.maxSize`. - // - // No D2 conversion, the `persistence.overlayWritable` reasoning: a - // metadata-manager config is not a stack collection member. D3 semantic entry: - // `metadata-manager-config-inert-cache-keys-retired`. - 'kernel/MetadataManagerConfig:cache.maxSize', - // #14478 tombstoned `MetadataManagerConfig.cache.ttl` as a RENAME to - // `ttlSeconds` (D3 `metadata-manager-config-cache-ttl-unit-in-key`) without - // registering the key here — a nested key the authorable-surface walk does not - // reach, so gate (b) never asked for it. #15624 then retired `ttlSeconds` - // itself before the rename ever shipped (ADR-0049 enforce-or-remove: the - // outer `cache` block was read by nothing), so the rename is folded into the - // removal and `cache.ttl`'s tombstone now prescribes deletion — an author - // upgrading from a published 17.x sees one hop, not two. Registered as the - // deletion it now is. The TTL that is honoured is - // `cache.databaseLoader.ttlMs`. - // - // No D2 conversion (a metadata-manager config is not a stack collection - // member). D3 semantic entry: `metadata-manager-config-inert-cache-keys-retired`. - 'kernel/MetadataManagerConfig:cache.ttl', - // #15624 — ADR-0049 enforce-or-remove. `MetadataManagerConfig.cache.ttlSeconds` - // (the #14478 respelling of `cache.ttl`, registered under this same major and - // never shipped) was declared, defaulted (3600) and documented, and read by - // nothing — the outer `cache` block has no runtime consumer; only - // `cache.databaseLoader` reaches `DatabaseLoader`. Tombstoned with - // `retiredKey()`; the rename is folded into the removal (`cache.ttl`'s own - // tombstone now prescribes deletion too — see the sibling entry). The TTL that - // is honoured is `cache.databaseLoader.ttlMs` (milliseconds). - // - // No D2 conversion, the `persistence.overlayWritable` reasoning: a - // metadata-manager config is not a stack collection member. D3 semantic entry: - // `metadata-manager-config-inert-cache-keys-retired`. - 'kernel/MetadataManagerConfig:cache.ttlSeconds', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057, adopting retirement; re-chartered, that commit executes the widened - // surface). `persistence.overlayWritable` gated exactly one method — - // `MetadataManager.saveOverlay()` — which belonged to the paper - // metadata-customization protocol removed whole in the same change: no route - // ever served the paper `…/overlay` endpoints, no UI called the method, and - // its only callers were `packages/metadata`'s own unit tests. With the limb - // gone the flag gates nothing. The base write gate `persistence.writable` - // stays; the real org-overlay writes (ADR-0005) ride the REST meta write - // doors' `manage_metadata` permission gate, not this flag. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look - // (the #8586 precedent). - // - // Registered here but NOT in `src/conversions/registry.ts` — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning: a - // metadata-manager config is not a stack collection member, so a - // MetadataConversion would be a transform with no seam that ever runs. The - // prescription reaches authors through the tombstone (`tsc` + the parse) and - // the D3 semantic entry `metadata-customization-protocol-retired`. - 'kernel/MetadataManagerConfig:persistence.overlayWritable', - // #8586 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-14, ruled - // REMOVE). `additionalTypes` was declared, authorable, and documented on four - // docs pages as THE way a plugin registers a custom metadata type — and read - // by NOTHING: the only production writer of the manager's type registry is - // `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)`, called exactly once, and - // it replaces the array outright. Measured: declared count == live count - // (27 == 27). The #4212 `onInstall` silence trap one level down. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/Manifest:loading` gives: the conversion chain walks a normalized - // STACK (`mapCollection(stack, 'objects' | 'views' | …)`) and - // `applyConversionsToStoredItem` maps a metadata type onto one of those - // collections. A metadata-plugin config is neither — there is no `plugins` - // entry in `PLURAL_TO_SINGULAR`, so it is not a stack collection member and a - // MetadataConversion here would be a transform with no seam that ever runs. - // The prescription reaches authors through the tombstone (`tsc` + the parse) - // and the D3 semantic entry `metadata-plugin-additional-types-retired`. - 'kernel/MetadataPluginConfig:additionalTypes', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057, adopting retirement; re-chartered, that commit executes the widened - // surface). `customizationPolicies` embedded the paper metadata-customization - // protocol's `CustomizationPolicySchema` (lockedFields / customizableFields - // whitelists) and was read by NOTHING: no code ever consulted a policy before - // accepting or refusing a customization, and the protocol it configured — - // the three-layer overlay of `kernel/metadata-customization.zod.ts`, removed - // whole in the same change (see `RETIRED_DEFS_BY_MAJOR[18]`) — was itself - // unreachable from any served surface. ADR-0126 §6 wall 4 supersedes the - // protocol on the record ("nothing may build against it"). What a - // customization may touch is governed by ADR-0005's org overlay - // (`allowOrgOverride`) and ADR-0126's packaged-metadata model. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the #8586 precedent). - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // the sibling `kernel/MetadataPluginConfig:additionalTypes` entry gives: the - // conversion chain walks a normalized STACK and - // `applyConversionsToStoredItem` maps a metadata type onto one of its - // collections. A metadata-plugin config is neither — there is no `plugins` - // entry in `PLURAL_TO_SINGULAR`, so a MetadataConversion here would be a - // transform with no seam that ever runs. The prescription reaches authors - // through the tombstone (`tsc` + the parse) and the D3 semantic entry - // `metadata-customization-protocol-retired`. - 'kernel/MetadataPluginConfig:customizationPolicies', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057, adopting retirement; re-chartered, that commit executes the widened - // surface). `mergeStrategy` embedded the paper protocol's - // `MergeStrategyConfigSchema` (keep-custom / accept-incoming / - // three-way-merge) and was read by NOTHING: no 3-way merge engine ever - // existed, and package upgrades do not merge customizations — ADR-0126 §6 - // wall 3 separates the packaged BASE (upgrades rewrite it) from the - // customer's recorded choices (never touched by an upgrade). The value - // schema leaves with its module (`kernel/metadata-customization.zod.ts`, - // `RETIRED_DEFS_BY_MAJOR[18]`). NOT the same surface as the inline - // three-value `mergeStrategy` vocabulary on `api/PackageUpgradeRequest` / - // `kernel/UpgradePackageRequest` — those are separately declared twins that - // never imported the module and are deliberately untouched here. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look - // (the #8586 precedent). - // - // Registered here but NOT in `src/conversions/registry.ts` — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning: a metadata-plugin - // config is not a stack collection member, so a MetadataConversion would be - // a transform with no seam that ever runs. The prescription reaches authors - // through the tombstone (`tsc` + the parse) and the D3 semantic entry - // `metadata-customization-protocol-retired`. - 'kernel/MetadataPluginConfig:mergeStrategy', - // #15678 (stack card 3/6 of #14478) — ruling B. - // `MultiVersionSupport.rollout.duration` said "Rollout duration in - // milliseconds" in prose and nothing else, directly beside the unit-less - // `percentage` — two bare numbers on one block, one a proportion and one a - // span. Renamed to `durationMs`; the value is unchanged, and `percentage` - // keeps its name because a proportion has no time unit to carry. Tombstoned - // with `retiredKey()`. No D2 conversion: `MultiVersionSupport` is a plugin - // version-routing configuration a host constructs, never a stack collection - // member. See `kernel-package-lifecycle-durations-unit-in-key`. - 'kernel/MultiVersionSupport:rollout.duration', - // #15678 (stack card 3/6 of #14478) — ruling B. - // `PackageDependencyResolutionResult.resolvedIn` said "Time taken to resolve - // dependencies in milliseconds" in prose and nothing else. Renamed to - // `resolvedInMs`; the value is unchanged. Tombstoned with `retiredKey()`. No - // D2 conversion: the result is EMITTED by a dependency resolution run, never - // authored into a metadata document. See - // `kernel-package-lifecycle-durations-unit-in-key`. - 'kernel/PackageDependencyResolutionResult:resolvedIn', - // #12032 — ADR-0049 enforce-or-remove, one class over from #12428 (PR #12571) - // and #12340 (PR #12425) in the same host-driven lifecycle library, and for a - // sharper reason than either: this key HAD a reader that acted, and what it did - // was not what the key declared. - // - // `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped. - // The comment above the call read "Call destroy and init to restart"; `init` - // appeared in `health-monitor.ts` ONLY inside that comment. What a plugin got - // was destroy -> `logger.info('Plugin restarted')` -> status `recovering` -> - // periodic checks continuing against the destroyed instance, which the default - // check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no - // `checkMethod` resolves) passes forever. So the TERMINAL report on a - // destroyed, never-re-initialised plugin was `healthy` — reproduced before - // anything was changed, at ee3595cefd with `successThreshold: 3`: - // - // round 1 (failing): status=failed destroyed=0 alive=true - // after backoff: status=recovering destroyed=1 alive=false - // recovery round 3: status=healthy destroyed=1 alive=false - // - // #11955 made that MORE convincing rather than less: reaching `healthy` now - // costs `successThreshold` consecutive passing rounds. - // - // Neither of ADR-0049's other two states was available. ENFORCE would have to - // BUILD the restart, and the class cannot host one: `Plugin.init(ctx)` needs a - // `PluginContext`, and the only two `plugin.init(...)` call sites in the tree - // are the kernel's own boot loops (`kernel-base.ts:202`, `kernel.ts:607`), both - // over the full plugin list with a context that is `private` on `ObjectKernel` - // and `protected` on `KernelBase` — no host can obtain one (positive control: - // the same scan resolves five real non-test `plugin.destroy()` call sites, so - // it sees lifecycle drivers). Building a per-plugin re-init API plus a host - // callback for a caller that does not exist is the speculation ADR-0049's - // staged decision names as the wrong default at this milestone. EXPERIMENTAL - // requires a roadmap, and a scan of the whole `docs/` planning + ADR corpus - // returned ZERO mentions of plugin auto-restart against 118 control hits for - // "health" and 13 for "hot reload" in the same corpus. - // - // The three keys retire together: with no restart, "Maximum restart attempts - // before giving up" and "Backoff strategy for restart delays" have nothing left - // to be the vocabulary OF — the same test that took `distributedConfig` out - // with the `stateStrategy` value it was documented as being required for. - // - // Tombstoned with `retiredKey()` rather than deleted, for #12428's reason: a - // key leaving a SURVIVING def has no route-3 exit, and `PluginHealthCheckSchema` - // is not `.strict()`, so a bare deletion would be a SILENT STRIP (#3733, - // ADR-0104). Deliberately NO D2 conversion: the chain walks a normalized STACK, - // and `PluginHealthCheck` is not an authorable surface — no metadata-type - // binding, stack collection or manifest embed ever carried it, and nothing in - // the tree parses `PluginHealthCheckSchema` outside its own unit test — so a - // conversion would be a transform with no seam that ever runs. The D3 semantic - // entry `plugin-auto-restart-never-reinitialised` is the declaration, and the - // registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door - // for the audience that exists. - 'kernel/PluginHealthCheck:autoRestart', - // #15939 ruling A (per-file remediation of #14478). `PluginHealthCheck.interval` - // said "Health check interval in milliseconds" in a source JSDoc, and the - // `.describe()` the reference pages publish said "How often to perform health - // checks (default: 30s)" — its one unit-shaped token naming SECONDS for a value - // the schema bounds at min 1000 and defaults to 30000 MILLISECONDS. Measured by - // the gate's own census, the key read [name: -] [prose: -]: no unit in the name, - // and none the gate recognises in the prose either. Renamed to `intervalMs` — - // the family's own spelling on this tree (100 key-position `*Ms` declarations in - // packages/spec, `intervalMs` 3 of them). The value and the 30000 default are - // unchanged. Tombstoned with `retiredKey()`: `PluginHealthCheckSchema` is not - // `.strict()`, so a bare deletion would silently strip the key and hand - // `setInterval` no period at all. No D2 conversion: not a stack collection - // member, not a stored row — `PluginHealthCheck` is a library parameter a host - // passes to `PluginHealthMonitor` in TypeScript, the same reading - // `plugin-auto-restart-never-reinitialised` recorded for this def. See - // `kernel-health-check-and-hot-reload-durations-unit-in-key`. - 'kernel/PluginHealthCheck:interval', - // #12032 — ADR-0049 enforce-or-remove, one class over from #12428 (PR #12571) - // and #12340 (PR #12425) in the same host-driven lifecycle library, and for a - // sharper reason than either: this key HAD a reader that acted, and what it did - // was not what the key declared. - // - // `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped. - // The comment above the call read "Call destroy and init to restart"; `init` - // appeared in `health-monitor.ts` ONLY inside that comment. What a plugin got - // was destroy -> `logger.info('Plugin restarted')` -> status `recovering` -> - // periodic checks continuing against the destroyed instance, which the default - // check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no - // `checkMethod` resolves) passes forever. So the TERMINAL report on a - // destroyed, never-re-initialised plugin was `healthy` — reproduced before - // anything was changed, at ee3595cefd with `successThreshold: 3`: - // - // round 1 (failing): status=failed destroyed=0 alive=true - // after backoff: status=recovering destroyed=1 alive=false - // recovery round 3: status=healthy destroyed=1 alive=false - // - // #11955 made that MORE convincing rather than less: reaching `healthy` now - // costs `successThreshold` consecutive passing rounds. - // - // Neither of ADR-0049's other two states was available. ENFORCE would have to - // BUILD the restart, and the class cannot host one: `Plugin.init(ctx)` needs a - // `PluginContext`, and the only two `plugin.init(...)` call sites in the tree - // are the kernel's own boot loops (`kernel-base.ts:202`, `kernel.ts:607`), both - // over the full plugin list with a context that is `private` on `ObjectKernel` - // and `protected` on `KernelBase` — no host can obtain one (positive control: - // the same scan resolves five real non-test `plugin.destroy()` call sites, so - // it sees lifecycle drivers). Building a per-plugin re-init API plus a host - // callback for a caller that does not exist is the speculation ADR-0049's - // staged decision names as the wrong default at this milestone. EXPERIMENTAL - // requires a roadmap, and a scan of the whole `docs/` planning + ADR corpus - // returned ZERO mentions of plugin auto-restart against 118 control hits for - // "health" and 13 for "hot reload" in the same corpus. - // - // The three keys retire together: with no restart, "Maximum restart attempts - // before giving up" and "Backoff strategy for restart delays" have nothing left - // to be the vocabulary OF — the same test that took `distributedConfig` out - // with the `stateStrategy` value it was documented as being required for. - // - // Tombstoned with `retiredKey()` rather than deleted, for #12428's reason: a - // key leaving a SURVIVING def has no route-3 exit, and `PluginHealthCheckSchema` - // is not `.strict()`, so a bare deletion would be a SILENT STRIP (#3733, - // ADR-0104). Deliberately NO D2 conversion: the chain walks a normalized STACK, - // and `PluginHealthCheck` is not an authorable surface — no metadata-type - // binding, stack collection or manifest embed ever carried it, and nothing in - // the tree parses `PluginHealthCheckSchema` outside its own unit test — so a - // conversion would be a transform with no seam that ever runs. The D3 semantic - // entry `plugin-auto-restart-never-reinitialised` is the declaration, and the - // registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door - // for the audience that exists. - 'kernel/PluginHealthCheck:maxRestartAttempts', - // #12032 — ADR-0049 enforce-or-remove, one class over from #12428 (PR #12571) - // and #12340 (PR #12425) in the same host-driven lifecycle library, and for a - // sharper reason than either: this key HAD a reader that acted, and what it did - // was not what the key declared. - // - // `PluginHealthMonitor.attemptRestart` called `plugin.destroy()` and stopped. - // The comment above the call read "Call destroy and init to restart"; `init` - // appeared in `health-monitor.ts` ONLY inside that comment. What a plugin got - // was destroy -> `logger.info('Plugin restarted')` -> status `recovering` -> - // periodic checks continuing against the destroyed instance, which the default - // check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no - // `checkMethod` resolves) passes forever. So the TERMINAL report on a - // destroyed, never-re-initialised plugin was `healthy` — reproduced before - // anything was changed, at ee3595cefd with `successThreshold: 3`: - // - // round 1 (failing): status=failed destroyed=0 alive=true - // after backoff: status=recovering destroyed=1 alive=false - // recovery round 3: status=healthy destroyed=1 alive=false - // - // #11955 made that MORE convincing rather than less: reaching `healthy` now - // costs `successThreshold` consecutive passing rounds. - // - // Neither of ADR-0049's other two states was available. ENFORCE would have to - // BUILD the restart, and the class cannot host one: `Plugin.init(ctx)` needs a - // `PluginContext`, and the only two `plugin.init(...)` call sites in the tree - // are the kernel's own boot loops (`kernel-base.ts:202`, `kernel.ts:607`), both - // over the full plugin list with a context that is `private` on `ObjectKernel` - // and `protected` on `KernelBase` — no host can obtain one (positive control: - // the same scan resolves five real non-test `plugin.destroy()` call sites, so - // it sees lifecycle drivers). Building a per-plugin re-init API plus a host - // callback for a caller that does not exist is the speculation ADR-0049's - // staged decision names as the wrong default at this milestone. EXPERIMENTAL - // requires a roadmap, and a scan of the whole `docs/` planning + ADR corpus - // returned ZERO mentions of plugin auto-restart against 118 control hits for - // "health" and 13 for "hot reload" in the same corpus. - // - // The three keys retire together: with no restart, "Maximum restart attempts - // before giving up" and "Backoff strategy for restart delays" have nothing left - // to be the vocabulary OF — the same test that took `distributedConfig` out - // with the `stateStrategy` value it was documented as being required for. - // - // Tombstoned with `retiredKey()` rather than deleted, for #12428's reason: a - // key leaving a SURVIVING def has no route-3 exit, and `PluginHealthCheckSchema` - // is not `.strict()`, so a bare deletion would be a SILENT STRIP (#3733, - // ADR-0104). Deliberately NO D2 conversion: the chain walks a normalized STACK, - // and `PluginHealthCheck` is not an authorable surface — no metadata-type - // binding, stack collection or manifest embed ever carried it, and nothing in - // the tree parses `PluginHealthCheckSchema` outside its own unit test — so a - // conversion would be a transform with no seam that ever runs. The D3 semantic - // entry `plugin-auto-restart-never-reinitialised` is the declaration, and the - // registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door - // for the audience that exists. - 'kernel/PluginHealthCheck:restartBackoff', - // #15939 ruling A (per-file remediation of #14478). `PluginHealthCheck.timeout` - // said "Timeout for health check in milliseconds" in a source JSDoc and - // "Maximum time to wait for health check response" in the `.describe()` the - // reference pages publish, so the published channel named no unit at all and the - // reference-page reader got a bare 5000. Renamed to `timeoutMs` — the family's - // most attested spelling on this tree (29 key-position `timeoutMs` declarations - // in packages/spec). The value and the 5000 default are unchanged. Tombstoned - // with `retiredKey()`: `PluginHealthCheckSchema` is not `.strict()`, so a bare - // deletion would silently strip the key and race the health check against no - // deadline. No D2 conversion: not a stack collection member, not a stored row — - // `PluginHealthCheck` is a library parameter a host passes to - // `PluginHealthMonitor` in TypeScript, the same reading - // `plugin-auto-restart-never-reinitialised` recorded for this def. See - // `kernel-health-check-and-hot-reload-durations-unit-in-key`. - 'kernel/PluginHealthCheck:timeout', - // #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.responseTime` - // said "Average response time in ms" in prose and nothing else. Renamed to - // `responseTimeMs`; the value is unchanged. Tombstoned with `retiredKey()`. - // ⚠️ Not to be confused with `PluginSecurityManifest.vulnerabilityDisclosure.responseTime`, - // the identically-named key this same card renames to `responseTimeHours` — - // same bare name, different unit, which is the confusion ruling B removes. No - // D2 conversion; `kernel-plugin-health-report-durations-unit-in-key` carries - // the prescription. - 'kernel/PluginHealthReport:metrics.responseTime', - // #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.uptime` - // said "Plugin uptime in milliseconds" in prose and nothing else, while this - // same platform serves a SECONDS-valued `uptime` on `GET /health` (the protocol - // lifecycle page had to spend a paragraph telling the two apart). Renamed to - // `uptimeMs`; the value is unchanged. Tombstoned with `retiredKey()` inside the - // live `metrics` block — a tombstone whose siblings must keep parsing. No D2 - // conversion: a health report is emitted by the monitor at runtime - // (`packages/core/src/health-monitor.ts`), never authored. See - // `kernel-plugin-health-report-durations-unit-in-key`. - 'kernel/PluginHealthReport:metrics.uptime', - // #15932 — ADR-0049 enforce-or-remove (director seat, decision batch #65, - // 2026-09-07, maintainer verbatim 「同意」). `PluginQualityMetrics.securityScan` is - // the scan-result family's sibling on the plugin registry entry, named by the - // ruling alongside it: a last-scan date, per-severity vulnerability counts and a - // `passed` boolean, read by no scanner, registry, installer or UI. The census put - // every reference in `packages/spec/src/kernel/plugin-registry.test.ts` — the - // spec's own self-test and nothing else. - // - // It is the sharper half of the family for an author: `scanResults` published a - // report, but `securityScan.passed` published a VERDICT, so a plugin could ship - // `passed: true` with nothing at all behind it and a consumer reading the - // registry entry had no way to tell that from a real result. - // - // Tombstoned with `retiredKey()`: `PluginQualityMetricsSchema` is a plain - // `z.object`, so a bare deletion would strip an authored block in silence - // (ADR-0104). The key carried NO default of its own — the defaults inside it - // (`critical`/`high`/`medium`/`low = 0`, `passed = false`) fired only for an - // author who wrote the block — so `acceptRetiredDefaultResidue` is not owed: - // there is no value a released toolchain materialized into an artifact whose - // author never typed the key. - // - // No D2 conversion: a plugin registry entry is a published package artifact, not - // a stack collection member or a stored `sys_metadata` row. The ledger channel is - // `plugin-security-scan-result-surface-retired`. - 'kernel/PluginQualityMetrics:securityScan', - // #15932 — ADR-0049 enforce-or-remove (director seat, decision batch #65, - // 2026-09-07, maintainer verbatim 「同意」). `PluginSecurityManifest.scanResults` - // published an array of `KernelSecurityScanResult` on the authorable surface with - // zero authors and zero parsers: no `.parse`/`.safeParse` site existed anywhere - // against the scan-result schemas, so a publisher could declare a clean scan on a - // plugin manifest, be accepted, and get nothing — the declared-not-enforced shape - // Prime Directive #10 names, one layer out from the runtime scanner that PR - // #15930 removed for the same reason (that PR closed issue 14919, a number since - // deleted from the board and no longer resolving). - // - // Tombstoned with `retiredKey()`, not deleted: `PluginSecurityManifestSchema` is a - // plain `z.object`, not `.strict()`, so a bare deletion would strip an authored - // key in silence (ADR-0104) — swapping an inert declaration for an invisible one. - // The value type leaves this build entirely (`RETIRED_DEFS_BY_MAJOR[18]`, - // `kernel/KernelSecurityScanResult`). - // - // No D2 conversion: a security manifest is a package artifact a publisher ships, - // never a stack collection member and never a stored `sys_metadata` row, so the - // chain has no seam that would see one — the disposition the sibling - // `vulnerabilityDisclosure.responseTime` entry on this same schema already - // records. The prescription an author meets is the tombstone itself; the ledger - // channel is `plugin-security-scan-result-surface-retired`. - 'kernel/PluginSecurityManifest:scanResults', - // #15932 — ADR-0049 enforce-or-remove, decision batch #65. - // `PluginSecurityManifest.vulnerabilities` was an array of - // `KernelSecurityVulnerability` and is this retirement's FORCED CONSEQUENCE - // rather than a name the ruling listed: it was the last authorable referent of a - // def the ruling retires by name, so it cannot survive the def, and keeping the - // def alive only to carry it would be keeping the retired family alive under a - // second name. It is inert on its own terms too — nothing ever wrote the list and - // nothing ever read it, so declaring a known vulnerability against a plugin - // warned nobody and blocked no install. - // - // ⚠️ This is the ONE key outside the four names the #15932 dispatch fenced - // (`KernelSecurityScanResult`, `KernelSecurityVulnerability`, - // `PluginSecurityManifest.scanResults`, `PluginQualityMetrics.securityScan`), and - // it is reported as such on the card and in the landing PR rather than absorbed - // silently. It is not a neighbour retired by proximity — the fence's stated - // concern — it is a referent of a named retiree. - // - // Tombstoned with `retiredKey()` for the reason its `scanResults` sibling records - // (non-strict shape, ADR-0104 silent strip). No D2 conversion, same reasoning. - 'kernel/PluginSecurityManifest:vulnerabilities', - // #15678 (stack card 3/6 of #14478) — ruling B. - // `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` said "Expected - // response time in hours" in prose and nothing else. Renamed to - // `responseTimeHours`; the value is unchanged. This is the card's sharpest - // case: `PluginHealthReport.metrics.responseTime` carried the SAME bare name - // for a MILLISECOND value, so `responseTime: 24` meant a day on one kernel - // shape and 24ms on another. Tombstoned with `retiredKey()`. No D2 conversion: - // a security manifest is a package artifact a publisher ships, never a stack - // collection member. See `kernel-plugin-security-durations-unit-in-key`. - 'kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime', - // #15678 (stack card 3/6 of #14478) — ruling B. `PluginStartupResult.duration` - // said "Time taken to start the plugin in milliseconds" in prose and nothing - // else. Renamed to `durationMs`; the value is unchanged. Tombstoned with - // `retiredKey()`. No D2 conversion: the result is EMITTED by the orchestrator - // per plugin at boot, never authored. - // - // ⚠️ Note for anyone grepping: `packages/core/src/plugin-loader.ts` declares - // its OWN local `PluginStartupResult` interface — a DIFFERENT type - // (`{ success, pluginName, startTime?, error?, timedOut? }`) with no - // `duration` key at all. It is not a reader of this schema, it is untouched by - // this rename, and the divergence between the two shapes is filed separately. - // See `kernel-startup-orchestrator-durations-unit-in-key`. - 'kernel/PluginStartupResult:duration', - // #16059 — `PluginStartupResult.health` held the `HealthStatus` a startup - // health check would have produced. The health-check option, the orchestrator - // interface that declared the check and the `HealthStatus` vocabulary itself - // all leave in this major (`kernel/HealthStatus` in RETIRED_DEFS_BY_MAJOR[18]), - // and nothing ever filled the key. Tombstoned rather than dropped because the - // carrying def survives — see `18.kernel__PluginStartupResult__plugin.ts` for - // why that matters here. - 'kernel/PluginStartupResult:health', - // #16059 — `PluginStartupResult.plugin` carried a nested - // `{ name, version } & Record` plugin object. The kernel has - // never built one: `ObjectKernel.startPluginWithTimeout()` has always returned - // the plugin NAME, and the re-declaration of this schema against the shipped - // shape replaces the key with `pluginName`. Tombstoned rather than dropped - // because this def keeps emitting and its type is imported by - // `@objectstack/core`, so a construction site still writing `plugin` meets the - // prescription through tsc as well as through a parse. - 'kernel/PluginStartupResult:plugin', - // #16059 — `PluginStartupResult.startTime` was `@objectstack/core`'s own - // ADR-0087 L1 alias: the kernel set it to the SAME elapsed milliseconds as - // `durationMs`, under a name that promises an instant. It arrives on the spec's - // surface only to leave it, because the re-declaration of this schema against - // the shipped shape had to choose between mirroring the member and tombstoning - // it, and mirroring is refused by `check:duration-unit-keys` (ruling B on - // #14478): an elapsed number whose key name carries no unit, matching neither - // of that rule's two schema-declared exemptions — not an `EpochMs` instant, not - // an external-standard mirror. Renaming it to `startTimeMs` would mint a - // spelling nothing ever produced for a member already slated for removal, so - // the alias ends here and the kernel stops populating it in the same change. - 'kernel/PluginStartupResult:startTime', - // #15939 ruling A (per-file remediation of #14478 ruling B). This is the fifth - // duration on `kernel/plugin-security-advanced.zod.ts` and the one #15678 - // deliberately left alone: `resourceLimits.timeout` said "Execution timeout in - // milliseconds" in a source JSDoc and "Maximum execution time" in the - // `.describe()` the reference pages publish, so the published channel named no - // unit at all and the gate listed the key in its census without judging it. - // Renamed to `timeoutMs`, the same token `SandboxConfig.process.timeoutMs` on - // this file already carries. The value is unchanged. Tombstoned with - // `retiredKey()`: the nested `resourceLimits` object is not strict, so a bare - // deletion would silently strip the key. No D2 conversion: a `RuntimeConfig` is - // the engine block of the `SandboxConfig` a host or a plugin security manifest - // constructs, never a stack collection member or a stored row — the same - // reading `kernel-plugin-security-durations-unit-in-key` recorded for the four - // keys it renamed. See `kernel-runtime-config-timeout-unit-in-key`. - 'kernel/RuntimeConfig:resourceLimits.timeout', - // #15678 (stack card 3/6 of #14478) — ruling B. `SandboxConfig.process.timeout` - // said "Process timeout in ms" in prose and nothing else. Renamed to - // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()` inside - // the live `process` block. ⚠️ Note for anyone grepping this file: the - // neighbouring `RuntimeConfig.resourceLimits.timeout` is a DIFFERENT key and is - // not covered by this entry. It was never "outside the gate's population", the - // reason this note gave until #15939: its unit lived in a source JSDoc only - // ("Execution timeout in milliseconds") while the `.describe()` the reference - // pages publish read "Maximum execution time" and named none, so - // `check:duration-unit-keys` listed the key in its census and never judged it. - // Ruling A on #15939 remediated that JSDoc-channel population per file, so that - // key is renamed to `timeoutMs` as well — landed, not pending — under its own - // entry `kernel/RuntimeConfig:resourceLimits.timeout`; see - // `kernel-runtime-config-timeout-unit-in-key` for its record. - // No D2 conversion: a `SandboxConfig` is the isolation argument a host or a - // plugin security manifest constructs, never a stack collection member or a - // stored row. See - // `kernel-plugin-security-durations-unit-in-key`. - 'kernel/SandboxConfig:process.timeout', - // #15678 (stack card 3/6 of #14478) — ruling B. `StartupOptions.timeout` said - // "Maximum time in milliseconds to wait for each plugin to start" in prose and - // nothing else — while the very contract that consumes it, - // `IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs)`, already - // named its own parameter `timeoutMs`. One boundary, two spellings. Renamed to - // `timeoutMs`; the value and the 30000 default are unchanged. Tombstoned with - // `retiredKey()`. No D2 conversion: `StartupOptions` is the argument a host - // passes to `orchestrateStartup()` at boot, never a stack collection member or - // a stored row. See `kernel-startup-orchestrator-durations-unit-in-key`. - 'kernel/StartupOptions:timeout', - // #15678 (stack card 3/6 of #14478) — ruling B. - // `StartupOrchestrationResult.totalDuration` said "Total time taken for all - // plugins in milliseconds" in prose and nothing else. Renamed to - // `totalDurationMs`; the value is unchanged, and it now agrees with the - // per-plugin `durationMs` it sums. Tombstoned with `retiredKey()`. No D2 - // conversion: the result is EMITTED at the end of a boot, never authored. See - // `kernel-startup-orchestrator-durations-unit-in-key`. - 'kernel/StartupOrchestrationResult:totalDuration', - // Commit 0c2334f6c — the `TenantRuntimeContextSchema` copy of - // `kernel/KernelContext:previewMode`: the def is `KernelContextSchema.extend(…)`, - // so the tombstone lands in this walked shape too and `authorable-surface/` - // marks it `[RETIRED]` separately. Registered per key, as gate (b) reads them — - // nothing radiates from the base (the `shared/FieldMapping:transform` - // precedent). See the base entry for the full record and the - // no-D2-conversion reasoning. - 'kernel/TenantRuntimeContext:previewMode', - // #15676 — the walked-shape copy of `kernel/KernelContext:startTime`. - // `TenantRuntimeContextSchema` extends `KernelContextSchema`, so it inherits - // both the renamed `startedAt` key and the tombstone; the authorable-surface - // ratchet records the two copies separately, so both are declared here. The - // `previewMode` retirement registered its two copies the same way. - 'kernel/TenantRuntimeContext:startTime', - // #15678 (stack card 3/6 of #14478) — ruling B. `UpgradePlan.estimatedDuration` - // said "Estimated upgrade duration in seconds" in prose and nothing else — and - // SECONDS is the minority unit in this package, which is exactly why the bare - // name misleads. Renamed to `estimatedDurationSeconds`; the value is unchanged. - // Tombstoned with `retiredKey()`. No D2 conversion: an `UpgradePlan` is - // GENERATED by `IPackageService.planUpgrade()` before an upgrade runs and - // carried on the `UpgradeResult`, never authored into a metadata document. The - // semantic entry `kernel-package-lifecycle-durations-unit-in-key` carries it. - 'kernel/UpgradePlan:estimatedDuration', - // #20289 (family `qa-runner`, verdict ENFORCE; the `requires` key ruled B) — - // `requires.plugins` could never be judged: `os test` reaches its target over - // HTTP and no served surface lists the loaded plugins. It retires into - // `requires.services`, judged against the services the target's discovery - // document declares enabled and available. Tombstoned with `retiredKey()` inside - // the live `requires` block, whose `params` and `services` keep parsing. No D2 - // conversion: a QA suite is a loose JSON file `os test` loads, never a stack - // collection member or a stored row. D3 semantic entry - // `qa-scenario-requires-plugins-retired`. Registered under 18 for the - // launch-window reason its neighbours state. - // - // Nested key of an inline block — no `authorable-surface/` line of its own. - 'qa/TestScenario:requires.plugins', - // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowPurge` - // (see that entry for the full rationale: ADR-0049 enforce-or-remove, - // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key - // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema` - // extends the same closed base shape (`.extend({ apiOperations }).strip()`, - // both faces behind the #12840 residue stage since 2026-08-28) — the clone - // shares the authoring shape's per-property schema instances, so the - // `retiredKey()` tombstone rides into the effective surface and this def's - // walked shape carries the same `[RETIRED]` row, while the retired default - // (`false`) an older published-toolchain server still emits on the wire is - // accepted as inert residue and stripped. Registered so the aging clock - // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The - // effective surface is server-resolved, never authored, so no D2 conversion - // clause targets it — the authoring-side strip in - // `permission-allow-restore-purge-removed` is the only source rewrite that - // exists to do. - 'security/EffectiveObjectPermission:allowPurge', - // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowRestore` - // (see that entry for the full rationale: ADR-0049 enforce-or-remove, - // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key - // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema` - // extends the same closed base shape (`.extend({ apiOperations }).strip()`, - // both faces behind the #12840 residue stage since 2026-08-28) — the clone - // shares the authoring shape's per-property schema instances, so the - // `retiredKey()` tombstone rides into the effective surface and this def's - // walked shape carries the same `[RETIRED]` row, while the retired default - // (`false`) an older published-toolchain server still emits on the wire is - // accepted as inert residue and stripped. Registered so the aging clock - // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The - // effective surface is server-resolved, never authored, so no D2 conversion - // clause targets it — the authoring-side strip in - // `permission-allow-restore-purge-removed` is the only source rewrite that - // exists to do. - 'security/EffectiveObjectPermission:allowRestore', - // #12497 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-26, decision- - // inbox batch 5, accepting #1883's recommendation B). `allowPurge` claimed to - // gate a `purge` (hard-delete / GDPR erase) ObjectQL operation that has never - // existed: no destructive lifecycle verb is in the engine's dispatch - // vocabulary (pinned by objectql's - // `engine-middleware-operation-vocabulary.test.ts`, #8106). This was ADR-0049's - // worst false-compliance shape — an admin who set `allowPurge: false` believed - // a lock on permanent deletion existed, when the operation itself did not. - // The permission evaluator's pre-mapping row (`OPERATION_TO_PERMISSION` - // purge→allowPurge) retired in the same batch: with the bit unwritable, a - // mapping onto it was a claim about a surface that rejects authoring. A - // dispatched `purge` stays denied fail-closed via the evaluator's - // `DESTRUCTIVE_OPERATIONS` backstop — there is no ungated window in either - // direction. THE KEY RETURNS with the M2 lifecycle initiative (feature + RBAC - // in one batch, maintainer 2026-08-03); anchor card #1883 stays open. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // ObjectPermissionSchema is `strictObject` but the def is reachable from the - // `permission` metadata root, so the route is the `retiredKey()` tombstone - // (the `rls.priority` posture) — the key stays in the walked shape as - // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the - // prescription — with ONE ruled exception (#12840, maintainer 2026-08-28): - // the key's own retired default (`false`), which the published 17.x toolchain - // materialized into every built artifact's entries, parses as inert residue - // and is stripped by the `acceptRetiredDefaultResidue` stage ahead of the - // shape; every other value keeps this refusal. Sources are rewritten by the - // D2 conversion `permission-allow-restore-purge-removed`, which strips the - // key from every object grant in `permissions[].objects`. - 'security/ObjectPermission:allowPurge', - // #12497 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-26, decision- - // inbox batch 5, accepting #1883's recommendation B). `allowRestore` claimed to - // gate a `restore` (undelete) ObjectQL operation that has never existed: no - // destructive lifecycle verb is in the engine's dispatch vocabulary (pinned by - // objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106), so - // authoring the bit granted nothing — an AI author who declared it believed a - // recycle-bin capability boundary existed, and the failure was silent. The - // permission evaluator's pre-mapping row (`OPERATION_TO_PERMISSION` - // restore→allowRestore) retired in the same batch: with the bit unwritable, a - // mapping onto it was a claim about a surface that rejects authoring. A - // dispatched `restore` stays denied fail-closed via the evaluator's - // `DESTRUCTIVE_OPERATIONS` backstop — there is no ungated window in either - // direction. THE KEY RETURNS with the M2 lifecycle initiative (feature + RBAC - // in one batch, maintainer 2026-08-03); anchor card #1883 stays open. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // ObjectPermissionSchema is `strictObject` but the def is reachable from the - // `permission` metadata root, so the route is the `retiredKey()` tombstone - // (the `rls.priority` posture) — the key stays in the walked shape as - // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the - // prescription — with ONE ruled exception (#12840, maintainer 2026-08-28): - // the key's own retired default (`false`), which the published 17.x toolchain - // materialized into every built artifact's entries, parses as inert residue - // and is stripped by the `acceptRetiredDefaultResidue` stage ahead of the - // shape; every other value keeps this refusal. Sources are rewritten by the - // D2 conversion `permission-allow-restore-purge-removed`, which strips the - // key from every object grant in `permissions[].objects`. - 'security/ObjectPermission:allowRestore', - // #20321 (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's - // criterion for declared-but-unenforced families — does a mainstream platform - // have the capability?). `RowLevelSecurityPolicy.tags` promised categorization - // and reporting for governance and compliance, and nothing ever read it: the - // RLS compiler never consults it, objectui's permission preview renders only - // the policy count and its policy editor neither seeds nor reads the key, and - // cloud has no reader. No mainstream platform tags a row-level policy. The - // policy shape is `strictObject`, but the def is reachable from the - // `permission` metadata root, so the route is the `retiredKey()` tombstone - // (the `rls.priority` posture one key over): the key stays in the walked shape - // as `[RETIRED]`, and authoring it is a tsc error and a parse error carrying - // the prescription. D2: `permission-rls-tags-removed`; D3: - // `permission-rls-tags-retired`. - 'security/RowLevelSecurityPolicy:tags', - // #15679 (stack card 4/6 of #14478) — ruling B. `AccessControlConfig.maxAge` said - // "CORS preflight cache duration in seconds" in prose and nothing else. - // ⚠️ This key is deliberately a RENAME and not an `externalVocabulary` marker, - // and the asymmetry with its twin is load-bearing: every bucket-CORS standard - // this value is forwarded to spells the field WITH its unit (S3 `MaxAgeSeconds`, - // GCS `maxAgeSeconds`, Azure `MaxAgeInSeconds`), so marking it would have - // exempted a DEVIATION from the cited standard rather than a mirror of it. The - // twin `shared/CorsConfig.maxAge` DID get the marker, because the Fetch response - // header it mirrors — `Access-Control-Max-Age` — genuinely carries no unit token. - // Two `maxAge` keys, opposite sides of the line; do not harmonise them. Renamed - // to `maxAgeSeconds`; the value is unchanged. Tombstoned with `retiredKey()`. No - // D2 conversion: not a stack collection member, not a stored row. - // See `system-object-storage-durations-unit-in-key`. - 'system/AccessControlConfig:maxAge', - // #15679 (stack card 4/6 of #14478) — ruling B. `circuitBreaker.resetTimeout` - // said "Seconds before half-open state" in prose only, while the `lockout` block - // three lines down on the SAME schema already spelled `lockTimeoutMs`. One shape, - // two conventions, and the two are not even the same unit. Renamed to - // `resetTimeoutSeconds`; the value and the 30 default are unchanged. Tombstoned - // with `retiredKey()`. No D2 conversion: not a stack collection member, not a - // stored row. See `system-cache-durations-unit-in-key`. - 'system/CacheAvalanchePrevention:circuitBreaker.resetTimeout', - // #15679 (stack card 4/6 of #14478) — ruling B. `CacheTier.ttl` said "Default TTL - // in seconds" in prose and nothing else, on a tier whose sibling `maxSize` is a - // size in MB: two bare numbers side by side, neither naming its unit at the - // authoring site. Renamed to `ttlSeconds`; the value and the 300 default are - // unchanged. Tombstoned with `retiredKey()` — `CacheTierSchema` is a plain - // `z.object()`, so a bare deletion would strip the old key in silence. No D2 - // conversion: `stack.zod.ts` declares no `cache` collection and a cache tier is - // never a stored metadata row, so the conversion chain has no seam that sees it. - // See `system-cache-durations-unit-in-key`. - 'system/CacheTier:ttl', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // - // A NESTED site: the authorable-surface ratchet walks top-level def - // properties only, so no `[RETIRED]` row exists for it and gate (b) of - // `build-schemas.ts` neither demands nor refuses this entry — it is here for - // the spec-changes / upgrade-guide projection, spelled the way - // `kernel/Manifest:contributes.actions` and - // `api/BatchEndpointsConfig:operations.upsertMany` are. - // D3 semantic entry: `change-management-duration-keys-retired`. - 'system/ChangeImpact:downtime.durationMinutes', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // - // A NESTED site: the authorable-surface ratchet walks top-level def - // properties only, so no `[RETIRED]` row exists for it and gate (b) of - // `build-schemas.ts` neither demands nor refuses this entry — it is here for - // the spec-changes / upgrade-guide projection, spelled the way - // `kernel/Manifest:contributes.actions` and - // `api/BatchEndpointsConfig:operations.upsertMany` are. - // D3 semantic entry: `change-management-duration-keys-retired`. - 'system/ChangeRequest:implementation.steps.estimatedMinutes', - // #15679 (stack card 4/6 of #14478) — ruling B. This is the live 1000x collision - // that got the whole population ruled: `CollaborationSessionConfig.idleTimeout` - // is MILLISECONDS while the tenant surface carried its own `idleTimeout` in - // SECONDS, so `idleTimeout: 300000` meant five minutes here and three and a half - // days there, with nothing at either authoring site to tell them apart. Renamed - // to `idleTimeoutMs`; the value and the 300000 default are unchanged. Tombstoned - // with `retiredKey()`. No D2 conversion: `stack.zod.ts` declares no - // `collaboration` collection and a session config is a runtime call argument, - // not a stored metadata row. See `system-collaboration-durations-unit-in-key`. - 'system/CollaborationSessionConfig:idleTimeout', - // #15679 (stack card 4/6 of #14478) — ruling B. `snapshot.interval` said - // "Snapshot interval in milliseconds" in prose and nothing else. It moves in the - // same stroke as its parent's `idleTimeout`: both are session-lifetime durations - // on one config object, and leaving one bare would have kept exactly the - // ambiguity the rename removes. Renamed to `intervalMs`; the value is unchanged. - // Tombstoned with `retiredKey()`. No D2 conversion, for its parent's reason. - // See `system-collaboration-durations-unit-in-key`. - 'system/CollaborationSessionConfig:snapshot.interval', - // #15679 (stack card 4/6 of #14478) — ruling B. `FailoverConfig.healthCheckInterval` - // said "Health check interval in seconds" in prose and nothing else. Renamed to - // `healthCheckIntervalSeconds`; the value and the 30 default are unchanged. - // ⚠️ Its neighbour `FailoverConfig.dns.ttl` on this same schema keeps its bare - // name and is NOT part of this rename — that key carries an `externalVocabulary` - // marker because it mirrors the DNS resource-record TTL field (RFC 1035 §4.1.3), - // spelled `ttl` by every provider API it is forwarded to. This one mirrors - // nothing outside the repo. Tombstoned with `retiredKey()`. No D2 conversion: - // not a stack collection member, not a stored row. - // See `system-failover-health-check-interval-unit-in-key`. - 'system/FailoverConfig:healthCheckInterval', - // #15939 ruling A (per-file remediation of #14478). `batch.flushInterval` said - // "Flush interval in milliseconds" in a source JSDoc and carried no - // `.describe()` at all, so the reference page published a bare 5000. Renamed to - // `flushIntervalMs` — the family's own spelling, 272 key-position `*Ms:` - // declarations in `packages/spec/src` and `flushIntervalMs` already declared on - // `kernel/events/integrations.zod.ts`. The value and the 5000 default are - // unchanged. Tombstoned with `retiredKey()`: the nested `batch` object is not - // strict, so a bare deletion would silently strip the key. ⚠️ Not the same key - // as `system/LoggingConfig:buffer.flushInterval`, which defaults to 1000 and - // has its own row. No D2 conversion: no logging collection on `stack.zod.ts`, - // not a stored row. See `logging-durations-unit-in-key`. - 'system/HttpDestinationConfig:batch.flushInterval', - // #15939 ruling A (per-file remediation of #14478). `retry.initialDelay` said - // "Initial retry delay in milliseconds" in a source JSDoc and carried no - // `.describe()` at all, so the reference page published a bare 1000. Renamed to - // `initialDelayMs` — already attested as a key-position declaration 5 times on - // this tree. The value and the 1000 default are unchanged. Tombstoned with - // `retiredKey()`: the nested `retry` object is not strict, so a bare deletion - // would silently strip the key. No D2 conversion: no logging collection on - // `stack.zod.ts`, not a stored row. See `logging-durations-unit-in-key`. - 'system/HttpDestinationConfig:retry.initialDelay', - // #15939 ruling A (per-file remediation of #14478). `timeout` said "Timeout in - // milliseconds" in a source JSDoc and carried no `.describe()` at all, so the - // reference page published a bare 30000. Renamed to `timeoutMs` — already - // attested as a key-position declaration 30 times on this tree. The value and - // the 30000 default are unchanged. Tombstoned with `retiredKey()`: - // `HttpDestinationConfig` is not strict, so a bare deletion would silently - // strip the key. ⚠️ The one TOP-LEVEL key of this card's four, so this is the - // one whose `authorable-surface/` and `authorable-defaults/` rows move — that - // ratchet records `schema.properties` one level deep. No D2 conversion: no - // logging collection on `stack.zod.ts`, not a stored row. See - // `logging-durations-unit-in-key`. - 'system/HttpDestinationConfig:timeout', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `incident-response-deadline-keys-retired`. - 'system/IncidentNotificationMatrix:escalationTimeoutMinutes', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `incident-response-deadline-keys-retired`. - 'system/IncidentNotificationRule:regulatorDeadlineHours', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `incident-response-deadline-keys-retired`. - 'system/IncidentNotificationRule:withinMinutes', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `incident-response-deadline-keys-retired`. - 'system/IncidentResponsePhase:targetHours', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `incident-response-deadline-keys-retired`. - 'system/IncidentResponsePolicy:retentionDays', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `incident-response-deadline-keys-retired`. - 'system/IncidentResponsePolicy:triageDeadlineHours', - // #14478 — maintainer ruling 2026-09-02 (recorded on the card as "ruled B"): - // a duration-shaped `z.number()` key carries its unit in its NAME, never only - // in its `.describe()` prose, and no existing offender is grandfathered. - // `job.timeout` said "in milliseconds" in prose while its sibling - // `retryPolicy.backoffMs` spelled its unit — one surface, two conventions, and - // a seconds value copied in became a limit 1000× too short with no error. - // Renamed to `timeoutMs`; the value is unchanged. Tombstoned with - // `retiredKey()` on the strict `JobSchema` (the `ObjectGridProps:defaultSort` - // route — the baseline line carries `[RETIRED]`, and the tombstone carries the - // rename where a bare unknown-key error would only carry the key); sources - // are rewritten by the D2 conversion `job-timeout-to-timeout-ms`, retired from - // the load path (no alias window). Registered under 18, not 17: v17.0.0 was - // cut before this landed, so the rename ships on the 17.x line - // (launch-window convention) and the prescription lives at the major boundary - // where `migrate meta` users look. - 'system/Job:timeout', - // #15939 ruling A (per-file remediation of #14478). `buffer.flushInterval` said - // "Flush interval in milliseconds" in a source JSDoc and carried no - // `.describe()` at all, so the reference page published a bare 1000. Renamed to - // `flushIntervalMs`. The value and the 1000 default are unchanged. Tombstoned - // with `retiredKey()`: the nested `buffer` object is not strict, so a bare - // deletion would silently strip the key. ⚠️ Not the same key as - // `system/HttpDestinationConfig:batch.flushInterval`, which defaults to 5000 - // and has its own row — the two spellings were identical and the defaults never - // were. No D2 conversion: no logging collection on `stack.zod.ts`, not a stored - // row. See `logging-durations-unit-in-key`. - 'system/LoggingConfig:buffer.flushInterval', - // #15679 (stack card 4/6 of #14478) — ruling B. `MetricAggregationConfig.window.size` - // said "Window size in seconds" in prose and nothing else. Renamed to - // `durationSeconds`, NOT to the gate's mechanical `sizeSeconds`: `size` is - // byte/row-count vocabulary everywhere else in this spec (`CacheTier.maxSize` is - // MB, `RegistryConfig.cache.maxSize` is bytes, this file's own `batch.size` is a - // row count), so `sizeSeconds` would have preserved the misleading half of the - // name and bolted a unit onto it. `windowSeconds` was rejected too — the parent - // key is already `window`, so it would read `window.windowSeconds`. The value is - // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: `stack.zod.ts` - // declares no `metrics` collection and an aggregation config is not a stored - // metadata row. See `system-metrics-window-durations-unit-in-key`. - 'system/MetricAggregationConfig:window.size', - // #15939 ruling A (per-file remediation of #14478). `summary.maxAge` said "Max - // age of observations in seconds" in a source JSDoc and carried no - // `.describe()` at all, so the reference page published a bare 600 and the - // gate listed the key among the duration-shaped ones without judging it. - // Renamed to `maxAgeSeconds`, the same token `AccessControlConfig.maxAgeSeconds` - // on `system/object-storage.zod.ts` already carries after this same rule renamed - // it — NOT `durationSeconds`, the spelling the three window lengths on this file - // take, because the sibling key `ageBuckets` counts buckets of this very age and - // dropping the `age` stem would orphan the pair. The value is unchanged. - // Tombstoned with `retiredKey()`: the nested `summary` object is not strict, so - // a bare deletion would silently strip the key. A NESTED site: the - // authorable-surface ratchet walks top-level def properties only, so no - // `[RETIRED]` row exists for it and gate (b) of `build-schemas.ts` neither - // demands nor refuses this entry — it is here for the spec-changes / - // upgrade-guide projection. No D2 conversion: `stack.zod.ts` declares no metrics - // collection and a metric definition is not a stored metadata row. - // See `system-metrics-jsdoc-durations-unit-in-key`. - 'system/MetricDefinition:summary.maxAge', - // #15939 ruling A (per-file remediation of #14478). `MetricExportConfig.interval` - // said "Export interval in seconds" in a source JSDoc and carried no - // `.describe()` at all — one of the three sites the #15939 filing named — so the - // reference page published a bare 60, a plausible number of seconds and a - // plausible number of milliseconds. Renamed to `intervalSeconds`, the token - // every seconds-valued cadence in this spec already carries (`intervalSeconds` - // on `ai/model-registry`, `api/auth-endpoints`, `data/driver/turso` and - // `integration/connector`); no competing `intervalSec` or `intervalS` spelling - // exists. The 60 default is unchanged. Tombstoned with `retiredKey()`: this - // object is not strict, so a bare deletion would silently strip the key. A - // TOP-LEVEL site, so the authorable-surface ratchet moves: the row becomes - // `system/MetricExportConfig:interval [RETIRED]` beside a new - // `system/MetricExportConfig:intervalSeconds`, and the authorable-defaults row - // moves with it. No D2 conversion: `stack.zod.ts` declares no metrics collection - // and an export config is not a stored metadata row. - // See `system-metrics-jsdoc-durations-unit-in-key`. - 'system/MetricExportConfig:interval', - // #15939 ruling A (per-file remediation of #14478). `MetricsConfig.collectionInterval` - // said "Collection interval in seconds" in a source JSDoc and carried no - // `.describe()` at all, so the reference page published a bare 15. Renamed to - // `collectionIntervalSeconds` and not to a bare `intervalSeconds`: the qualifier - // distinguishes it from `MetricExportConfig.intervalSeconds`, a different cadence - // one def over that this same card renames, and the qualifier-plus-IntervalSeconds - // compound is the attested form (`syncIntervalSeconds`, `refreshIntervalSeconds`, - // `healthCheckIntervalSeconds`). The 15 default is unchanged. Tombstoned with - // `retiredKey()`: this object is not strict, so a bare deletion would silently - // strip the key. A TOP-LEVEL site, so the authorable-surface ratchet moves: the - // row becomes `system/MetricsConfig:collectionInterval [RETIRED]` beside a new - // `system/MetricsConfig:collectionIntervalSeconds`, and the authorable-defaults - // row moves with it. No D2 conversion: `stack.zod.ts` declares no metrics - // collection and a metrics config is not a stored metadata row. - // See `system-metrics-jsdoc-durations-unit-in-key`. - 'system/MetricsConfig:collectionInterval', - // #15939 ruling A (per-file remediation of #14478). `MetricsConfig.retention.period` - // said "Retention period in seconds" in a source JSDoc and carried no - // `.describe()` at all, so the reference page published a bare 604800. Renamed to - // `durationSeconds`, not to the mechanical `periodSeconds`: `period` is calendar - // vocabulary elsewhere in this spec — `ServiceLevelObjective.period.type` selects - // rolling or calendar and `PluginRegistryEntry.pricing.billingPeriod` is monthly - // or yearly — so `periodSeconds` would have kept the ambiguous half of the name - // and bolted a unit onto it, the same objection #15679 raised against - // `sizeSeconds`. `durationSeconds` is what this file already calls a length of - // time, in three places. The 604800 (7 day) default is unchanged. Tombstoned with - // `retiredKey()`: the nested `retention` object is not strict, so a bare deletion - // would silently strip the key. A NESTED site: the authorable-surface ratchet - // walks top-level def properties only — the top-level row is - // `system/MetricsConfig:retention` and it does not move — so gate (b) of - // `build-schemas.ts` neither demands nor refuses this entry. No D2 conversion: - // `stack.zod.ts` declares no metrics collection and a metrics config is not a - // stored metadata row. - // See `system-metrics-jsdoc-durations-unit-in-key`. - 'system/MetricsConfig:retention.period', - // #15939 ruling A (per-file remediation of #14478). `exporter.batch.exportTimeout` - // said "Export timeout in milliseconds" in a source JSDoc and carried NO - // `.describe()` at all, so the published reference page showed a bare 30000. - // Renamed to `exportTimeoutMs`; the value and the 30000 default are unchanged. - // Tombstoned with `retiredKey()`: the nested `batch` object is not strict, so a - // bare deletion would silently strip the key. No D2 conversion: not a stack - // collection member, not a stored row. See - // `system-tracing-otel-exporter-durations-unit-in-key`. - 'system/OpenTelemetryCompatibility:exporter.batch.exportTimeout', - // #15939 ruling A (per-file remediation of #14478). `exporter.batch.scheduledDelay` - // said "Scheduled delay in milliseconds" in a source JSDoc and carried NO - // `.describe()` at all, so the published reference page showed a bare 5000. - // Renamed to `scheduledDelayMs`, the plain suffix: counted in key position across - // `packages/spec/src` at `98bd7986fe`, the Delay-plus-Ms pairing is already the - // family spelling (`maxDelayMs` 9, `initialDelayMs` 9, `maxRetryDelayMs` 5, - // `debounceDelayMs` 2, `delayMs` 2, `retryDelayMs` 1) and neither - // `scheduledDelayMs` nor `scheduledDelaySeconds` occurs there at all. - // The value and the 5000 default are unchanged. Tombstoned with `retiredKey()`: - // the nested `batch` object is not strict, so a bare deletion would silently - // strip the key. No D2 conversion: not a stack collection member, not a stored - // row. See `system-tracing-otel-exporter-durations-unit-in-key`. - 'system/OpenTelemetryCompatibility:exporter.batch.scheduledDelay', - // #15939 ruling A (per-file remediation of #14478). `exporter.timeout` said - // "Timeout in milliseconds" in a source JSDoc and carried NO `.describe()` at - // all, so the `content/docs/references/**` page published a bare 10000 with no - // unit anywhere on it. Renamed to `timeoutMs`; the value and the 10000 default - // are unchanged. It keeps the plain suffix even though its `exporter.batch` - // sibling becomes `exportTimeoutMs`: the two are the OpenTelemetry exporter's - // own request deadline and the batch processor's export deadline, two distinct - // knobs the shape already spelled apart, and the nesting keeps every read point - // unambiguous. Tombstoned with `retiredKey()`: the nested `exporter` object is - // not strict, so a bare deletion would silently strip the key and let a 10000 ms - // default land on an exporter deadline the operator had deliberately changed. - // No D2 conversion: not a stack collection member, not a stored row. See - // `system-tracing-otel-exporter-durations-unit-in-key`. - 'system/OpenTelemetryCompatibility:exporter.timeout', - // #15679 (stack card 4/6 of #14478) — ruling B. `QueueConfig.rateLimit.duration` - // said "Duration in milliseconds" in prose and nothing else — while - // `TaskResult.durationMs`, ninety lines earlier in the SAME file, already spelled - // the identical measurement correctly. The counter-example was in the file, which - // is what makes this one a drift rather than a convention. Renamed to - // `durationMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 - // conversion: `stack.zod.ts` declares `jobs`, not `queues`, and a queue config is - // worker host configuration rather than a stored metadata row. - // See `system-worker-queue-rate-limit-duration-unit-in-key`. - 'system/QueueConfig:rateLimit.duration', - // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryConfig.cache.ttl` said - // "Cache TTL in seconds" in prose and nothing else, next to a `maxSize` in the - // same cache block measured in BYTES. Renamed to `ttlSeconds`; the value and the - // 3600 default are unchanged. Tombstoned with `retiredKey()`. No D2 conversion: - // not a stack collection member, not a stored row. - // See `system-registry-config-durations-unit-in-key`. - 'system/RegistryConfig:cache.ttl', - // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.syncInterval` - // said "Auto-sync interval in seconds" in prose and nothing else, on a block - // whose `timeout` two keys down was MILLISECONDS: one upstream declaration, two - // units, neither spelled at the authoring site. Renamed to `syncIntervalSeconds`; - // the value and the min-60 bound are unchanged. Tombstoned with `retiredKey()`. - // No D2 conversion: `stack.zod.ts` declares no `registry` collection and a - // registry config is host configuration, not a stored metadata row. - // See `system-registry-config-durations-unit-in-key`. - 'system/RegistryUpstream:syncInterval', - // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.timeout` said - // "Request timeout in milliseconds" in prose and nothing else, beside a - // seconds-valued `syncInterval` on the same block. Its `min(1000)` bound is the - // sharpest reading of why the rule exists: under the wrong unit that floor reads - // as sixteen minutes rather than one second, and no parse can catch the mistake - // because both readings are in range. Renamed to `timeoutMs`; the value, the - // 30000 default and the min-1000 bound are unchanged. Tombstoned with - // `retiredKey()`. No D2 conversion, for its sibling's reason. - // See `system-registry-config-durations-unit-in-key`. - 'system/RegistryUpstream:timeout', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // - // A NESTED site: the authorable-surface ratchet walks top-level def - // properties only, so no `[RETIRED]` row exists for it and gate (b) of - // `build-schemas.ts` neither demands nor refuses this entry — it is here for - // the spec-changes / upgrade-guide projection, spelled the way - // `kernel/Manifest:contributes.actions` and - // `api/BatchEndpointsConfig:operations.upsertMany` are. - // D3 semantic entry: `change-management-duration-keys-retired`. - 'system/RollbackPlan:steps.estimatedMinutes', - // #15939 ruling A (per-file remediation of #14478). `performance.schemaCacheTTL` - // said "Schema cache TTL in seconds" in a source JSDoc and "Schema cache TTL" in - // the `.describe()` the reference pages publish, so the published channel named - // no unit at all. Renamed to `schemaCacheTtlSeconds` — `Ttl`, not `TTL`, because - // that is how every member of the suffixed family on this tree already spells it - // (`cacheTtlSeconds`, `ttlSeconds`, `defaultCacheTtlSeconds`). The value and the - // 3600 default are unchanged. Tombstoned with `retiredKey()`: the nested - // `performance` object is not strict, so a bare deletion would silently strip the - // key. No D2 conversion: not a stack collection member, not a stored row. See - // `tenant-schema-cache-ttl-unit-in-key`. - 'system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL', - // #15679 (stack card 4/6 of #14478) — ruling B. The second of the two - // byte-identical `window.size` declarations in `metrics.zod.ts`; it carries the - // same prose and takes the same new name, `durationSeconds`, for the reason - // recorded on its twin (`system/MetricAggregationConfig:window.size`). Registered - // as its own row because the authorable surface is per DEF, not per source line: - // an author migrating an SLI never reads the aggregation-config entry. The value - // is unchanged. Tombstoned with `retiredKey()`; no D2 conversion. - // See `system-metrics-window-durations-unit-in-key`. - 'system/ServiceLevelIndicator:window.size', - // #15939 ruling A (per-file remediation of #14478). The error-budget burn-rate - // `window` said "Window size in seconds" in a source JSDoc while its - // `.describe()` read "Window size" and named no unit — the exact site #15939 - // was filed on. Renamed to `durationSeconds`: it is the fourth window length on - // this file, and #15679 already settled that a window length here is spelled - // `durationSeconds` so the measurements read alike. `windowSeconds` is rejected - // for the reason #15679 recorded against `window.windowSeconds` — the enclosing - // array is already called `burnRateWindows`, so the key would stutter — and - // because on this tree `windowSeconds` is not an authorable key at all: its only - // key-position occurrence is an entry in `ServerRateLimitConfigSchema`'s alias - // map that maps the spelling AWAY to `windowMs`. The value is unchanged. - // Tombstoned with `retiredKey()`: this array-element object is not strict, so a - // bare deletion would silently strip the key. - // - // The path crosses an ARRAY (`errorBudget.burnRateWindows` is a `z.array`) and - // is spelled with plain dots, no bracket token, because that is the only - // notation this table uses: of its rows none carries a bracket, and the two - // historical rows that crossed an array — `system/RollbackPlan:steps.…` and - // `system/ChangeRequest:implementation.steps.…` — spelled it this way (their - // schemas have since been retired, so the precedent cannot be re-read on this - // tree; flagged for the contract review). A NESTED site: the authorable-surface - // ratchet walks top-level def properties only, so no `[RETIRED]` row exists for - // it and gate (b) of `build-schemas.ts` neither demands nor refuses this entry. - // No D2 conversion: an SLO is not a stack collection member and not a stored - // metadata row — the reading `system-metrics-window-durations-unit-in-key` - // already recorded for this same def. - // See `system-metrics-jsdoc-durations-unit-in-key`. - 'system/ServiceLevelObjective:errorBudget.burnRateWindows.window', - // #15679 (stack card 4/6 of #14478) — ruling B. `ServiceLevelObjective.period.duration` - // said "Duration in seconds" in prose and nothing else. Renamed to - // `durationSeconds`; the value is unchanged. This key is why the two `window.size` - // keys above land on `durationSeconds` rather than `sizeSeconds`: the file already - // spelled a window length as a `duration` one schema down, so the three - // measurements now read alike instead of one of them borrowing byte vocabulary. - // Tombstoned with `retiredKey()`. No D2 conversion: an SLO is not a stack - // collection member and not a stored metadata row. - // See `system-metrics-window-durations-unit-in-key`. - 'system/ServiceLevelObjective:period.duration', - // #15679 (stack card 4/6 of #14478) — ruling B. `Span.duration` said "Duration in - // milliseconds" in prose and nothing else, on a shape that already spells its two - // instants `startTime` / `endTime`. Renamed to `durationMs`; the value is - // unchanged. Not an `externalVocabulary` mirror: OpenTelemetry, which this shape - // follows, carries span length as a start/end nanosecond PAIR and declares no key - // named `duration` at all, so there is no external spelling to mirror here. - // Tombstoned with `retiredKey()`. No D2 conversion: a span is a runtime-emitted - // measurement, never authored metadata and never a stored `sys_metadata` row. - // See `system-tracing-span-duration-unit-in-key`. - 'system/Span:duration', - // #15679 (stack card 4/6 of #14478) — ruling B. `StorageConnection.timeout` said - // "Connection timeout in milliseconds" in prose and nothing else. Renamed to - // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 - // conversion: `stack.zod.ts` declares no `objectStorage` collection and a storage - // connection is host configuration, not a stored metadata row. - // See `system-object-storage-durations-unit-in-key`. - 'system/StorageConnection:timeout', - // #15939 ruling A (per-file remediation of #14478). `performance.exportInterval` - // said "Background export interval in milliseconds" in a source JSDoc and - // carried NO `.describe()` at all, so the published reference page showed a bare - // 5000. Renamed to `exportIntervalMs`; the value and the 5000 default are - // unchanged. `intervalMs` is the family spelling already attested 14 times in key - // position across `packages/spec/src` at `98bd7986fe` (0 `exportIntervalMs` and 0 - // `exportIntervalSeconds` there, so nothing competes), so the suffix lands on a - // name the surface already uses. - // Tombstoned with `retiredKey()`: the nested `performance` object is not strict, - // so a bare deletion would silently strip the key and hand a background exporter - // its default period. No D2 conversion: not a stack collection member, not a - // stored row. See `system-tracing-otel-exporter-durations-unit-in-key`. - 'system/TracingConfig:performance.exportInterval', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `training-deadline-keys-retired`. - 'system/TrainingCourse:durationMinutes', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `training-deadline-keys-retired`. - 'system/TrainingCourse:validityDays', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `training-deadline-keys-retired`. - 'system/TrainingPlan:gracePeriodDays', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `training-deadline-keys-retired`. - 'system/TrainingPlan:recertificationIntervalDays', - // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: - // retire per family). One of the hour/minute/day-shaped deadline keys of the - // incident-response / training / change-management families: declared on the - // published authorable surface, read by NOTHING — the schemas are mounted by - // no stack key and registered as no metadata type, and the reader census over - // every package outside `packages/spec` (and objectui at the pinned sha) - // returned zero hits — so an author who wrote it held a deadline the platform - // never kept. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention) and the - // prescription lives at the major boundary where `migrate meta` users look. - // - // Registered here but NOT in `src/conversions/registry.ts`, for the reason - // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain - // walks a normalized STACK and none of these schemas is a stack collection - // member, so a MetadataConversion would be a transform with no seam that ever - // runs. The prescription reaches authors through the tombstone (`tsc` + the - // parse) and the D3 semantic entry named below. - // D3 semantic entry: `training-deadline-keys-retired`. - 'system/TrainingPlan:reminderDaysBefore', - // #20323 — ADR-0049 enforce-or-remove (triage record 5860351140, following the - // `ChartConfig.aria` retirement `2bf6ef18d`). The liveness ledger graded this - // key `live` on an uncited "PARTIAL — honored by a few objectui renderers" - // note, and no reader stood behind it: measured at the `.objectui-sha` pin - // `f8a9d0fb05`, none of the surfaces that render an action (`action:button`, - // `action:icon`, `action:menu`, `action:group`, `action:bar`, the grid's row - // and bulk action menus, `record:quick_actions`, the declared-actions bar) - // reads an action's `aria`. Every one of them derives the accessible name from - // the action's REQUIRED `label` — visible text, or `aria-label` on the - // icon-only renderer and the overflow trigger — and the node that PLACES the - // actions carries the node-level `ariaLabel` / `ariaDescribedBy` / `role` - // (`page.components[].aria`, the list view `aria`). A per-action block was a - // second spelling of both. - // - // `retiredKey()` on a `strictObject`, for the prescription (the - // `aria-carrier-tombstones.test.ts` family). Sources are rewritten by the D2 - // conversion `action-aria-removed`; the D3 record is `action-aria-retired`. - // - // Registered under 18, not 17: the tombstone ships on the 17.x line - // (launch-window convention — accept-set narrowings ride minor releases) and - // the prescription lives at the major boundary where `migrate meta` users look. - 'ui/Action:aria', - // #17751 — ADR-0049 enforce-or-remove (maintainer decision batch #118 item 2, - // 2026-09-12: recommendation C, judge the protocol wrong for this one key). - // The third and last member of the `aria` family retired on the same measured - // evidence: `dashboard.aria` at the #3896 close-out, `dashboard.widgets[].aria` - // at #5010, and now the block-local spelling one level further in, inside the - // widget's `chartConfig`. It outlived the first two sweeps by DEPTH, not by - // evidence — `widgets.chartConfig` was an undrilled container, so no key inside - // it had ever been classified until the per-key pass recorded in - // `liveness/dashboard.json` at the `.objectui-sha` pin `53ded82bf7a4`. That pass - // found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER - // face: `AdvancedChartImpl` declares no `aria` prop, `chartConfigPresentation` - // names it nowhere (its docblock calls it "the one declared key with no reader - // at all"), `SchemaRenderer`'s ARIA injection reads flat node props and never a - // nested `aria` object, and `ui/react-blocks.ts` omits it from ``'s - // thirteen `dataProps` — the one key of this shape missing from that list. - // Pinned as a negative from both spellings in objectui ("ignores - // chartConfig.aria", "ignores aria — nested and flattened"). - // - // REMOVE rather than ENFORCE, which is the less usual ADR-0049 answer and is - // the whole of the ruling: this same chart config already carries a WORKING - // accessible-name channel in `description`, lowered onto the chart graphic as - // `role="img"` + `aria-label` and pinned in the DOM. Wiring `aria` too would - // put two accessible-name sources on one element and demand a precedence rule - // nobody has written. One node, one accessibility vocabulary — which on the - // surfaces that really render DOM is the shared `AriaProps` block, untouched - // and still live on `page.aria`, `page.components[].aria` and the list view - // `aria`. - // - // `retiredKey()` rather than a bare deletion even though `ChartConfigSchema` IS - // a `strictObject`: a bare delete would still be loud, but as a generic - // unrecognized-key rejection that cannot carry the prescription — the exact - // distinction `aria-carrier-tombstones.test.ts` asserts by name for the widget - // twin. Sources are rewritten by the D2 conversion `chart-config-aria-removed`. - // - // Registered under 18, not 17: v17 was cut before this landed, so the tombstone - // ships on the 17.x line (launch-window convention — accept-set narrowings ride - // minor releases) and the prescription lives at the major boundary where - // `migrate meta` users look. - 'ui/ChartConfig:aria', - // #15680 (stack card 5/6 of #14478) — ruling B. `dashboard.refreshInterval` - // said "Auto-refresh interval in seconds" in prose and nothing else. The three - // rename-hint aliases beside it — `refresh`, `autoRefresh`, `pollInterval` — - // measure how many spellings authors actually reach for, and not one of them - // named a unit either, so every door into this key left the cadence ambiguous. - // All three were repointed to the new spelling in the same edit. Renamed to - // `refreshIntervalSeconds`; the value is unchanged. Tombstoned with - // `retiredKey()`; the shape IS `strictObject`, so the tombstone is here for the - // prescription an unknown-key rejection cannot carry. Covered by the D2 - // conversion `dashboard-refresh-interval-to-refresh-interval-seconds`: - // `dashboards:` is a stack collection and a dashboard is a registered metadata - // kind stored as a row. - // ⚠️ Unique in this stack: the consumer is in ANOTHER REPOSITORY. objectui's - // dashboard renderer reads this key and multiplies by 1000, and publishes it as - // a registry input, so its reader could not move in this PR the way every other - // reader in this card did. Sequenced as a follow-up card behind a release that - // actually ships the rename; until then the renderer sees an absent key and - // does not start its timer. - 'ui/Dashboard:refreshInterval', - // ADR-0021 (the dataset is the single author-facing analytics shape on the - // dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, - // decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the - // dataset decides which series exist and which column each one reads, and - // `chartConfig` carries appearance — so this structure key is tombstoned on the - // widget carrier. `series[].name` named which dataset measure a series read — - // series MEMBERSHIP, which follows from `values` and the split in `dimensions` - // — and an entry naming a measure outside the selection was ignored, so the - // declaration and what rendered could differ silently. - // Select the measures and the split instead. - // ⚠️ Scoped to THIS carrier. The same key stays authorable on the base - // `ChartConfigSchema` (the react `` tier publishes it in - // that block's `dataProps`, and an inline-data chart has no dataset to derive - // it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The - // tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. - // D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: - // `dashboard-widget-chart-config-structure-refused`. - 'ui/DashboardWidgetChartConfig:series', - // ADR-0021 (the dataset is the single author-facing analytics shape on the - // dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, - // decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the - // dataset decides which series exist and which column each one reads, and - // `chartConfig` carries appearance — so this structure key is tombstoned on the - // widget carrier. Nothing on this face ever read it: the dashboard renderer - // maps the WIDGET's own `type` to the chart family, and the presentation - // lowering carries no `type` branch at all — so an author who wrote a family - // here got the widget's. - // Write the family on the widget instead. - // ⚠️ Scoped to THIS carrier. The same key stays authorable on the base - // `ChartConfigSchema` (the react `` tier publishes it in - // that block's `dataProps`, and an inline-data chart has no dataset to derive - // it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The - // tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. - // D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: - // `dashboard-widget-chart-config-structure-refused`. - 'ui/DashboardWidgetChartConfig:type', - // ADR-0021 (the dataset is the single author-facing analytics shape on the - // dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, - // decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the - // dataset decides which series exist and which column each one reads, and - // `chartConfig` carries appearance — so this structure key is tombstoned on the - // widget carrier. `xAxis.field` named the plotted column, which the widget's - // `dimensions` selection already names — and it was a LIVE membership channel: - // the renderer synthesised a series from an authored axis when the chart - // declared none, so one authored field could silently re-point a dataset-bound - // series at another column. - // Select the dimension instead. - // ⚠️ Scoped to THIS carrier. The same key stays authorable on the base - // `ChartConfigSchema` (the react `` tier publishes it in - // that block's `dataProps`, and an inline-data chart has no dataset to derive - // it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The - // tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. - // D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: - // `dashboard-widget-chart-config-structure-refused`. - 'ui/DashboardWidgetChartConfig:xAxis', - // ADR-0021 (the dataset is the single author-facing analytics shape on the - // dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, - // decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the - // dataset decides which series exist and which column each one reads, and - // `chartConfig` carries appearance — so this structure key is tombstoned on the - // widget carrier. Each `yAxis[].field` named a plotted measure, which the - // widget's `values` selection already names; the array length also declared - // the secondary axis, which follows from the measures selected. - // Select the measures instead. - // ⚠️ Scoped to THIS carrier. The same key stays authorable on the base - // `ChartConfigSchema` (the react `` tier publishes it in - // that block's `dataProps`, and an inline-data chart has no dataset to derive - // it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The - // tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. - // D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: - // `dashboard-widget-chart-config-structure-refused`. - 'ui/DashboardWidgetChartConfig:yAxis', - // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never - // had a renderer or reader anywhere: objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering - // (userFilters / filter builder)"), and the 2026-06 page-liveness audit - // recorded it rendering "Unknown component type". Measured at retirement time - // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded - // sweep): zero production readers of any `element:filter` key — so every key, - // this one included, was a capability claim nothing kept. Per-key retirement - // would have been the wrong grain (the #9198 lesson, one element over); all - // six authorable keys are tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFilterProps:aria', - // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never - // had a renderer or reader anywhere: objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering - // (userFilters / filter builder)"), and the 2026-06 page-liveness audit - // recorded it rendering "Unknown component type". Measured at retirement time - // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded - // sweep): zero production readers of any `element:filter` key — so every key, - // this one included, was a capability claim nothing kept. Per-key retirement - // would have been the wrong grain (the #9198 lesson, one element over); all - // six authorable keys are tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFilterProps:fields', - // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never - // had a renderer or reader anywhere: objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering - // (userFilters / filter builder)"), and the 2026-06 page-liveness audit - // recorded it rendering "Unknown component type". Measured at retirement time - // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded - // sweep): zero production readers of any `element:filter` key — so every key, - // this one included, was a capability claim nothing kept. Per-key retirement - // would have been the wrong grain (the #9198 lesson, one element over); all - // six authorable keys are tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFilterProps:layout', - // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never - // had a renderer or reader anywhere: objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering - // (userFilters / filter builder)"), and the 2026-06 page-liveness audit - // recorded it rendering "Unknown component type". Measured at retirement time - // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded - // sweep): zero production readers of any `element:filter` key — so every key, - // this one included, was a capability claim nothing kept. Per-key retirement - // would have been the wrong grain (the #9198 lesson, one element over); all - // six authorable keys are tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFilterProps:object', - // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never - // had a renderer or reader anywhere: objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering - // (userFilters / filter builder)"), and the 2026-06 page-liveness audit - // recorded it rendering "Unknown component type". Measured at retirement time - // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded - // sweep): zero production readers of any `element:filter` key — so every key, - // this one included, was a capability claim nothing kept. Per-key retirement - // would have been the wrong grain (the #9198 lesson, one element over); all - // six authorable keys are tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFilterProps:showSearch', - // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never - // had a renderer or reader anywhere: objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering - // (userFilters / filter builder)"), and the 2026-06 page-liveness audit - // recorded it rendering "Unknown component type". Measured at retirement time - // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded - // sweep): zero production readers of any `element:filter` key — so every key, - // this one included, was a capability claim nothing kept. Per-key retirement - // would have been the wrong grain (the #9198 lesson, one element over); all - // six authorable keys are tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFilterProps:targetVariable', - // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never - // had a renderer or reader anywhere — the #9220 shape one element over, - // recorded by that card's own verdict sweep. objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry naming the live replacement ("no - // renderer — use the object-bound `object-form` block"), and the 2026-06 - // page-liveness audit recorded it rendering "Unknown component type". Measured - // at retirement time (objectstack @dd84ddd796, objectui @3ece13e33; cloud per - // the card's two recorded readings @5f1bf23f / @a11458b): zero production - // readers of any `element:form` key — so every key, this one included, was a - // capability claim nothing kept. Per-key retirement would have been the wrong - // grain (the #9198 lesson, two elements over); all six authorable keys are - // tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFormProps:aria', - // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never - // had a renderer or reader anywhere — the #9220 shape one element over, - // recorded by that card's own verdict sweep. objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry naming the live replacement ("no - // renderer — use the object-bound `object-form` block"), and the 2026-06 - // page-liveness audit recorded it rendering "Unknown component type". Measured - // at retirement time (objectstack @dd84ddd796, objectui @3ece13e33; cloud per - // the card's two recorded readings @5f1bf23f / @a11458b): zero production - // readers of any `element:form` key — so every key, this one included, was a - // capability claim nothing kept. Per-key retirement would have been the wrong - // grain (the #9198 lesson, two elements over); all six authorable keys are - // tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFormProps:fields', - // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never - // had a renderer or reader anywhere — the #9220 shape one element over, - // recorded by that card's own verdict sweep. objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry naming the live replacement ("no - // renderer — use the object-bound `object-form` block"), and the 2026-06 - // page-liveness audit recorded it rendering "Unknown component type". Measured - // at retirement time (objectstack @dd84ddd796, objectui @3ece13e33; cloud per - // the card's two recorded readings @5f1bf23f / @a11458b): zero production - // readers of any `element:form` key — so every key, this one included, was a - // capability claim nothing kept. Per-key retirement would have been the wrong - // grain (the #9198 lesson, two elements over); all six authorable keys are - // tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFormProps:mode', - // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never - // had a renderer or reader anywhere — the #9220 shape one element over, - // recorded by that card's own verdict sweep. objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry naming the live replacement ("no - // renderer — use the object-bound `object-form` block"), and the 2026-06 - // page-liveness audit recorded it rendering "Unknown component type". Measured - // at retirement time (objectstack @dd84ddd796, objectui @3ece13e33; cloud per - // the card's two recorded readings @5f1bf23f / @a11458b): zero production - // readers of any `element:form` key — so every key, this one included, was a - // capability claim nothing kept. Per-key retirement would have been the wrong - // grain (the #9198 lesson, two elements over); all six authorable keys are - // tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFormProps:object', - // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never - // had a renderer or reader anywhere — the #9220 shape one element over, - // recorded by that card's own verdict sweep. objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry naming the live replacement ("no - // renderer — use the object-bound `object-form` block"), and the 2026-06 - // page-liveness audit recorded it rendering "Unknown component type". Measured - // at retirement time (objectstack @dd84ddd796, objectui @3ece13e33; cloud per - // the card's two recorded readings @5f1bf23f / @a11458b): zero production - // readers of any `element:form` key — so every key, this one included, was a - // capability claim nothing kept. Per-key retirement would have been the wrong - // grain (the #9198 lesson, two elements over); all six authorable keys are - // tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFormProps:onSubmit', - // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never - // had a renderer or reader anywhere — the #9220 shape one element over, - // recorded by that card's own verdict sweep. objectui registers none (its - // renderers/basic/elements.tsx header deferred the element to "owning plugins" - // that never materialized), Studio's designer palette carries it as a - // no-renderer PALETTE_EXCLUSIONS entry naming the live replacement ("no - // renderer — use the object-bound `object-form` block"), and the 2026-06 - // page-liveness audit recorded it rendering "Unknown component type". Measured - // at retirement time (objectstack @dd84ddd796, objectui @3ece13e33; cloud per - // the card's two recorded readings @5f1bf23f / @a11458b): zero production - // readers of any `element:form` key — so every key, this one included, was a - // capability claim nothing kept. Per-key retirement would have been the wrong - // grain (the #9198 lesson, two elements over); all six authorable keys are - // tombstoned together. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstones ship on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — which the parse then refuses - // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the - // component. - 'ui/ElementFormProps:submitLabel', - // #11509 (v18, ruling A-narrow) — `filter` on `element:number` was a flat second - // spelling of the node-level `dataSource.filter` binding (the element resolved `object` binding-first and AND-combined the two filters). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementNumberProps:filter', - // #11509 (v18, ruling A-narrow) — `object` on `element:number` was a flat second - // spelling of the node-level `dataSource.object` binding (the element resolved `object` binding-first and AND-combined the two filters). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementNumberProps:object', - // #11509 (v18, ruling A-narrow) — `filter` on `element:record_picker` was a flat second - // spelling of the node-level `dataSource.filter` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementRecordPickerProps:filter', - // #11509 (v18, ruling A-narrow) — `limit` on `element:record_picker` was a flat second - // spelling of the node-level `dataSource.limit` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementRecordPickerProps:limit', - // #11509 (v18, ruling A-narrow) — `object` on `element:record_picker` was a flat second - // spelling of the node-level `dataSource.object` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementRecordPickerProps:object', - // #11509 (v18, ruling A-narrow) — `sort` on `element:record_picker` was a flat second - // spelling of the node-level `dataSource.sort` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementRecordPickerProps:sort', - // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on - // `element:record_picker` was a declarative hint with zero readers: the picker - // writes the selected record id through the reverse binding — the page - // variable whose `source` names this component's `id` (PageVariableSchema; - // `usePageVariableBinding(schema?.id)` in objectui's console renderer) — and - // nothing anywhere read this key. Measured (objectstack-ai/objectui#3834, - // re-verified at retirement time): zero production readers in objectui, - // framework and cloud; the only repo-wide hits were the reverse-parity gate's - // exemption block and spec's own accept tests. Same silent-no-op hazard and - // same disposition as the `element:text_input` twin registered beside this - // entry, and as the #5775 record-picker inert keys one shape over. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion - // `element-input-target-variable-removed`. - 'ui/ElementRecordPickerProps:targetVariable', - // #11509 (v18, ruling A-narrow) — `filter` on `element:repeater` was a flat second - // spelling of the node-level `dataSource.filter` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementRepeaterProps:filter', - // #11509 (v18, ruling A-narrow) — `limit` on `element:repeater` was a flat second - // spelling of the node-level `dataSource.limit` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementRepeaterProps:limit', - // #11509 (v18, ruling A-narrow) — `object` on `element:repeater` was a flat second - // spelling of the node-level `dataSource.object` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementRepeaterProps:object', - // #11509 (v18, ruling A-narrow) — `sort` on `element:repeater` was a flat second - // spelling of the node-level `dataSource.sort` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). - // In v18 an element binds data through `dataSource` only: one node, one - // door, one precedence. Sources are rewritten by the D2 conversion - // `element-flat-data-binding-to-data-source`; the judgment an upgrader - // still owes is the D3 entry `element-flat-data-binding-retired`. - 'ui/ElementRepeaterProps:sort', - // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on `element:text_input` - // was a declarative hint with zero readers: its own describe text said the - // live binding "resolves via the variable whose `source` equals this component - // id" (PageVariableSchema), and that reverse lookup - // (`usePageVariableBinding(schema?.id)` in objectui's console renderer) is the - // only binding mechanism that exists. Measured (objectstack-ai/objectui#3834, - // re-verified at retirement time): no production reader in objectui, framework - // or cloud — the only repo-wide hits were the reverse-parity gate's exemption - // block (which cites the origin card) and spec's own accept tests. An author - // who wrote `targetVariable` and skipped the variable's `source` got an input - // that wrote nothing, with a success receipt — the ADR-0078 silent-no-op - // shape, on the exact surface AI authors write from. Same disposition as its - // sibling inert hint (objectui#3829, settled by retirement in objectui - // PR #4794). - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion - // `element-input-target-variable-removed` (a page component IS a stack - // collection member, unlike the `kernel/Manifest:loading` family). - 'ui/ElementTextInputProps:targetVariable', - // #21180 — ADR-0087 D2, immediate retirement, by the maintainer's ruling E on - // #21079 (comment 5933054144), which reverses the #7467 ruling that had - // declared the key. The block opted a lookup / `master_detail` / `user` field - // on an ANONYMOUS public form into a record-search picker served by an - // unauthenticated route (`GET /forms/:slug/lookup/:field`). The ruling retired - // the capability: the route is deleted, and the public-form resolve route now - // leaves those three field types off the anonymous rendering unconditionally. - // Zero producers measured before the ruling — no example, template, plugin or - // first-party UI caller declared one. - // - // `retiredKey()` on the form field's shape, for the prescription. Sources are - // rewritten by the D2 conversion `form-field-public-picker-removed`; the D3 - // record is `form-field-public-picker-retired`. - // - // Registered under 18, not 17: the tombstone ships on the 17.x line - // (launch-window convention — accept-set narrowings ride minor releases) and - // the prescription lives at the major boundary where `migrate meta` users look. - 'ui/FormField:publicPicker', - // #20161 (ADR-0049 enforce-or-remove). `JoinedReportBlock.chart` declared an - // inline chart on one block of a `joined` report, and no renderer ever drew it: - // at the `.objectui-sha` pin `f8a9d0fb0596`, `DatasetReportRenderer`'s joined - // branch draws each block as a table and has no read of a block's `chart` at - // all, so the chart parsed, passed `validate-chart-bindings`, and plotted - // nothing. The block shape is `.strict()`, so the key is removed from it and - // its prescription is served from the block schema's `guidance` table. A - // joined report's container `chart` is refused by `ReportSchema`'s refinement - // in the same change; `chart` stays live on every non-joined report. D2: - // `report-joined-chart-removed`. - 'ui/JoinedReportBlock:chart', - // #17063 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision - // batch #107 item 1, verbatim 「撤」). `ListView.pageName` named the published - // page a `type: 'page'` view was to mount. Only the spec half of #13216 ever - // landed: no renderer read the key, so the named page was never reached and the - // view drew an empty grid. Tombstoned with `retiredKey()` beside the - // `virtualScroll` tombstone already on this shape; the enum VALUE `'page'` went - // with it, carrying its own prescription on the `type` enum's error map. The - // surviving page mount is the app navigation item (`PageNavItem.pageName`), - // which is a different key on a different surface and has always rendered. D2: - // `view-page-mount-removed`. - 'ui/ListView:pageName', - // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the - // maintainer's #18900 criterion). `ListView.tabs` declared tab definitions for - // a multi-tab view interface, and no renderer ever drew them: a list view's own - // `tabs` has no reader, and objectui's `TabBar`, the one component that would - // draw it, has no production mount. The tab strip above an object's records is - // the saved-view switcher (`ViewTabBar`), which renders one tab per `listViews` - // entry and reads no `tabs` key. `userFilters.tabs`, a different key of the - // same element type, is read and rendered, and stays. Tombstoned with - // `retiredKey()` beside the `pageName` tombstone already on this shape; - // `ViewTabSchema` stays, reused by the page-only `userFilters.tabs` preset bar. - // D2: `view-list-tabs-removed`. - 'ui/ListView:tabs', - // #16885 — the list view's `navigation.view` binding, retired under ADR-0049 - // enforce-or-remove by maintainer ruling 2026-09-13 (director decision batch - // #126 item 4, verbatim 「同意」, option B). The key's describe promised "the - // form view to use for details" and no layer from spec to console ever - // resolved a view BY NAME: its one read in the shipped console passed the - // value into the SECOND argument of `onNavigate` — the slot that otherwise - // carries the navigation-mode token — so an authored name substituted for the - // mode rather than selecting a view, and a consumer reading that argument - // against its closed `edit`/`view` vocabulary matched neither branch. Zero - // authored instances in this repo; the one external author deleted its - // occurrence. ONE key and one entry: `NavigationConfigSchema` is reused BY - // REFERENCE (`ListViewSchema.navigation` is its only referent), so the walked - // shape has a single `ui/NavigationConfig` def and the baseline marks one line - // `[RETIRED]`. - // - // Registered here but NOT in `src/conversions/registry.ts`, and deliberately: - // the ruling's disposition is a D3 SEMANTIC entry - // (`list-view-navigation-view-retired`). A mechanical strip would delete the - // key without telling anyone which list view lost it, and an author who wrote - // it wanted a named detail layout — a want that page assignment serves and a - // stripped key does not record. So the prescription reaches consumers as that - // semantic TODO plus this tombstone. - 'ui/NavigationConfig:view', - // #11509 (v18, ruling A-narrow, sub-question 1) — `defaultFilters` on - // `object-grid` was the legacy second spelling of `filter`, read only when - // `filter` lowered to nothing; #19514 narrowed it to the rule array and left - // the removal to its own ruling, which this is. Retired in the shape - // `defaultSort`'s took (`ui/ObjectGridProps:defaultSort`, beside this entry): - // the D2 conversion `object-grid-default-filters-removed` moves the rules onto - // an empty `filter` and deletes the key beside a `filter` that has content. - 'ui/ObjectGridProps:defaultFilters', - // #11805 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-25, - // decision-inbox batch 4: 「#11805 退役 defaultSort,不需要major」; the producer - // half of objectui#5861, under the objectui#4869 「接受所有」 direction). - // `defaultSort` was the legacy second spelling of `object-grid`'s `sort`: a - // single `{ field, order }` pair the renderer read only when `sort` was absent - // (measured at the `.objectui-sha` pin `190fbd01d`, - // `plugin-grid/src/ObjectGrid.tsx:1244-1246` fetch fallback and `:2847`, which - // wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One - // intent, two spellings; objectui's mirror schema is parity-test-only and - // parses nothing at runtime, so only this repo's strictObject can refuse the - // key. Zero authored occurrences in either repo's corpora (the card's - // measurement, re-run here at dispatch). - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666, - // as `data/Metric:filters` before it). Tombstoned with `retiredKey()` in - // `ObjectGridPropsSchema` (the surface baseline line carries `[RETIRED]`); - // sources are rewritten by the D2 conversion `object-grid-default-sort-removed` - // (wrap-and-rename to `sort: [pair]` when `sort` is absent; a pure lossless - // delete when `sort` is present, since the fallback was never read then). - 'ui/ObjectGridProps:defaultSort', - // #21445 — ADR-0049 enforce-or-remove (objectui#6152 ruling A: `resizable` is - // canonical; the startup rule of immediate retirement — zero writers in either - // repository, so no window). `resizableColumns` was the legacy second spelling - // of `object-grid`'s `resizable`, read only as - // `schema.resizable ?? schema.resizableColumns` (measured at the - // `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). - // One switch, two spellings; a grid authoring both silently ignored this one. - // - // Registered under 18 for the reason `ui/ObjectGridProps:defaultSort` is: the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. Tombstoned with `retiredKey()` in - // `ObjectGridPropsSchema` (the surface baseline line carries `[RETIRED]`); - // sources are rewritten by the D2 conversion - // `object-grid-resizable-columns-removed` (renamed to `resizable` when that is - // absent; a pure lossless delete when it is present, since the legacy key was - // never read then). - 'ui/ObjectGridProps:resizableColumns', - // #17260 — ADR-0049 enforce-or-remove, executing the objectui#8285 - // director-seat ruling (comment 5583979207, decision batch #91, 2026-09-08, - // standing maintainer delegation): ruled option B — `quickAdd` is retired from - // the `object-kanban` board. The ruling kept the control on the `kanban-ui` - // block, where a React host can supply the runtime function it needs; objectui - // has since retired that block (objectui#8257), so `object-kanban` offers no - // quick-add control and no block a document can name offers one either. - // The board FORWARDED the key but never honoured it: measured at the - // `.objectui-sha` pin `53ded82bf`, `ObjectKanban.tsx:931` spreads the authored - // bag into `KanbanRenderer` (`plugin-kanban/src/index.tsx:196` passes both - // `quickAdd` and `onQuickAdd`), and `KanbanImpl` gates the affordance on BOTH - // (`:355`, `:368`) — while `onQuickAdd` is a host-supplied FUNCTION that JSON - // cannot carry and no producer puts on an `object-kanban` node - // (`ObjectKanban.tsx` names neither half: 0 each, against 6 for the sibling - // `onCardClick` in the same file). The drop was not silent, which is the sharp - // edge: objectui's html tier reported the published key as `unknown-prop` — - // the SAME diagnostic a typo gets — and its registry-spec ledger records it as - // `ESCALATED (object-kanban.quickAdd — measured NOT honoured)`, so the author - // met a tool contradicting the contract with no way to tell which side was - // wrong. Tombstoned with `retiredKey()` in `ObjectKanbanPropsSchema` (the - // surface baseline line carries `[RETIRED]`); sources are stripped by the D2 - // conversion `object-kanban-quick-add-removed`, a pure lossless delete scoped - // by component `type` so the same key on any other component type is untouched. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look — the `ui/ObjectGridProps:defaultSort` - // precedent one entry over. - 'ui/ObjectKanbanProps:quickAdd', - // #17063 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision - // batch #107 item 1, verbatim 「撤」). `ObjectListView.pageName` named the published - // page a `type: 'page'` view was to mount. Only the spec half of #13216 ever - // landed: no renderer read the key, so the named page was never reached and the - // view drew an empty grid. Tombstoned with `retiredKey()` beside the - // `virtualScroll` tombstone already on this shape; the enum VALUE `'page'` went - // with it, carrying its own prescription on the `type` enum's error map. The - // surviving page mount is the app navigation item (`PageNavItem.pageName`), - // which is a different key on a different surface and has always rendered. D2: - // `view-page-mount-removed`. - 'ui/ObjectListView:pageName', - // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the - // maintainer's #18900 criterion). `ObjectListView.tabs` declared tab - // definitions for a multi-tab view interface, and no renderer ever drew them: a - // list view's own `tabs` has no reader, and objectui's `TabBar`, the one - // component that would draw it, has no production mount. The tab strip above an - // object's records is the saved-view switcher (`ViewTabBar`), which renders one - // tab per `listViews` entry and reads no `tabs` key. `userFilters.tabs`, a - // different key of the same element type, is read and rendered, and stays. - // Tombstoned with `retiredKey()` beside the `pageName` tombstone already on - // this shape; `ViewTabSchema` stays, reused by the page-only `userFilters.tabs` - // preset bar. D2: `view-list-tabs-removed`. - 'ui/ObjectListView:tabs', - // #21589 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec - // half of objectui#11070 round 9 (the direction recorded on #21220's landing - // and mirrored on objectui#11396 ③: tombstone plus ADR-0087). The console - // retired the authored override at objectui `0a3e5409f`, and the - // `.objectui-sha` pin `89cad75d5570` is past it: `MasterDetailDetailConfig` - // has no `sortField` member (`plugin-form/src/MasterDetailForm.tsx:83`), and - // the field the line grid stamps with each line's position is the one - // `deriveDetail` derives from the child object (`deriveMasterDetail.ts:540`), - // handed to the grid as `sort_field` (`:874`). Tombstoned with `retiredKey()` - // in the strict detail entry; a NESTED row (an array member, spelled without - // its `[]`), so it has no `authorable-surface/` line and checks (b2)/(b3) - // resolve it against the emitted schema. Stored and built pages are stripped - // by the D2 conversion `object-master-detail-form-detail-sort-field-removed`, - // a pure lossless delete scoped by component `type` and position; its D3 - // record is `object-master-detail-form-detail-sort-field-retired`. - // - // Registered under 18, not 17: the removal ships on the 17.x line - // (launch-window convention: accept-set narrowings ride minor releases) and - // the prescription lives at the major boundary where `migrate meta` users - // look — the `ui/PageHeaderProps:breadcrumb` precedent. - 'ui/ObjectMasterDetailFormProps:details.sortField', - // ADR-0090 D2 (no Profile concept) + ADR-0049 enforce-or-remove; maintainer - // ruling 2026-09-12, decision batch #121 item 2, verbatim 「同意」. - // `Page.assignedProfiles` was an authorable key named for the concept ADR-0090 D2 - // deleted, and it gated nothing: measured across this repository and objectui, - // every hit was a declaration, a generated artifact, prose or a round-trip test — - // no renderer, route or metadata read door ever read it, so a page that "assigned - // profiles" stayed open to every caller who could reach it. `PageSchema` is reachable - // from the `page` metadata-type root, so the key is NOT deleted from the shape: it - // stays as a `retiredKey()` tombstone that carries the prescription, which is why the - // key keeps its authorable-surface line (marked `[RETIRED]`) and its liveness row (as - // `dead`). Authoring it is a `tsc` error and a parse error; there is no `guidance` - // entry for it, because a guidance entry only ever runs from the - // `unrecognized_keys` path and the shape still declares this key. The two alias - // entries that steered an - // authored `profiles:` / `assignedTo:` INTO this retired vocabulary became - // refusals naming the permission-set route in the same change. Page audience is - // the permission set's: the object's permission sets gate the DATA, and positions - // bind those sets to people. D2: `page-assigned-profiles-removed`; D3 semantic: - // `page-assigned-profiles-audience-to-permission-set`. - 'ui/Page:assignedProfiles', - // #11027 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-22, ruled B: - // retire + repair the redirect texts in the same change). The LAST carrier of - // the `ResponsiveConfig` layout block, and the destination the - // `dashboard.widgets[].responsive` tombstone (#4876) prescribed as the live - // alternative. Measured across objectstack + objectui with the tsc-probe - // methodology (positive and negative controls): objectui's two implementations - // of the contract (`useResponsiveConfig` in `@object-ui/mobile`, - // `ResponsiveProtocol` in `@object-ui/core`) had ZERO callers, nothing read - // `.responsive` off a page component, and objectui's own node interface never - // declared the key — so an author following the shipped prescription moved an - // inert key to an inert key and was told it now works. Same retirement the - // family already took on identical evidence: `view.responsive` (#3896), - // `dashboard.widgets[].responsive` (#4876). It survived the sweeps through an - // instrument gap, not on evidence: `page/regions` is an undrilled container, - // so no component-level key has ever been classified (#4956's page-side - // instance). The shape leaves with its last carrier — see - // `RETIRED_DEFS_BY_MAJOR[18]` (`ui/ResponsiveConfig` and its breakpoint maps). - // The live per-breakpoint channel on the same component is `responsiveStyles` - // (ADR-0065). - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // tombstone ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // Sources are rewritten by the D2 conversion `page-component-responsive-removed`. - 'ui/PageComponent:responsive', - // #20758 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec - // half of objectui#11166 (triage ruling on the card: RETIRE, no consumer). - // `PageHeaderProps.breadcrumb` switched a trail that never existed: objectui's - // `PageHeaderRenderer` draws an EMPTY `div[data-page-breadcrumb-slot]` unless - // the key is `false`, nothing fills it, and the console draws the navigation - // trail once, in the shell (`AppHeader`). Tombstoned with `retiredKey()` in the - // `strictObject`, beside the `icon` this row lost at 17 (the surface baseline - // line carries `[RETIRED]`); stored and built pages are stripped of both values - // by the D2 conversion `page-header-breadcrumb-removed`, whose D3 record is - // `page-header-breadcrumb-retired`. - // - // ⭐ RETIRED-DEFAULT RESIDUE: not owed, although the key carried - // `.default(true)`. The default was never materialized: a page parses its - // component `properties` as an open bag, and this row is parsed only by the - // advisory props lint, which writes nothing back — so no released toolchain - // emitted `breadcrumb: true` into an artifact nobody authored. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look — the - // `ui/ObjectKanbanProps:quickAdd` precedent. - 'ui/PageHeaderProps:breadcrumb', - // #10054 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-21, executing - // the 2026-08-20 census verdict). `icon` on the object arm of - // `RecordHighlightsField` was declared, described (`Icon name (lucide icon - // key)`), and advertised on six author-facing surfaces — with ZERO read - // points, measured in every direction: objectui's renderer normalizes the - // authored object and carries `icon: f?.icon` into `HeaderHighlight`, whose - // chip has NO icon slot (its only `icon` occurrence is a button - // `size="icon"`); the key is structurally unable to travel - // `useRegisterHighlightFields`, which registers `names: string[]`; the Studio - // block designer publishes the field list as a `string[]` input, so the key - // was never designer-publishable; and all in-tree `record:highlights` - // producers author bare string arrays. The exact shape #8691 recorded for the - // reference-rail `icon`: declared and normalized, drawn by nothing — an - // authored value parsed clean and cost the author silently. The neighbouring - // `readonly` key is LIVE (#5176, HeaderHighlight's inline-edit gate) and is - // untouched. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). - // The object arm is `strictObject`, so the route is strict deletion + a - // `guidance` entry carrying the prescription (no retiredKey tombstone — the - // key is out of the walked shape entirely, and the refusal is the arm's own - // named `unrecognized_keys`, unpacked through the zod-4 union collapse by - // `packages/lint/src/zod-issue-format.ts`; the `data/Metric:filters` route). - // Sources are rewritten by the D2 conversion - // `record-highlights-field-icon-removed`, which strips the key from the object - // entries of every `record:highlights` `fields[]` (pure lossless delete — the - // chip renders label and value only, so the key never had an effect to lose). - 'ui/RecordHighlightsField:icon', - // #17751 — the SECOND key one tombstone produced. `ReportChartSchema` is a - // `ChartConfigSchema.extend(...)`, and an extension copies the retired property - // into its own walked shape, which `authorable-surface/` marks `[RETIRED]` - // separately. Registered per key, as the gate reads them — nothing radiates - // from the base (the `shared/FieldMapping:transform` precedent). See - // `18.ui__ChartConfig__aria.ts` for the evidence and the ruling. - // - // The report face is where this key was authorable at two depths — - // `reports[].chart.aria` and `reports[].blocks[].chart.aria`, a `joined` report - // carrying both — and the D2 conversion `chart-config-aria-removed` strips all - // of them together with the dashboard site. - 'ui/ReportChart:aria', - // #20085 (ADR-0049 enforce-or-remove; triage direction 「retire both keys」). - // `ViewItem.hidden` promised to hide a view item from the switcher, and nothing - // ever read it: no writer and no reader of the view-item key in the framework, - // in objectui at its pin and at `main`, or in cloud, and both switcher read - // paths filter on `viewKind` + `object` only — `hidden: true` hid nothing. - // Tombstoned with `retiredKey()` on the shared `viewItemBaseShape()`, because - // that shape also feeds the `.strip()` wire member (`ui/ViewItemWire`, - // registered beside this row), where a bare deletion would strip in silence. - // The flattened-overlay members declare their own `hidden` on a different door, - // untouched. ⚠️ No gate below can JUDGE this row: `ui/ViewItem` is a - // discriminated union, whose emitted JSON Schema has no top-level - // `properties`, so `authorable-surface/` carries no `ui/ViewItem:*` line and - // check (b) never sees the tombstone — the row is declared, not checked. - // D2: `view-item-owner-hidden-removed`. - 'ui/ViewItem:hidden', - // #20085 (ADR-0049 enforce-or-remove; triage direction 「retire both keys」). - // `ViewItem.owner` named the user a `personal` view item belonged to, and - // nothing ever read it: no writer and no reader of the view-item key in the - // framework, in objectui at its pin and at `main`, or in cloud, and both - // switcher read paths filter on `viewKind` + `object` only — so a view marked - // as one user's was listed for everyone who can read the object. Per-user view - // scoping is a parked direction (ADR-0017, amended 2026-09-04). Tombstoned with - // `retiredKey()` on the shared `viewItemBaseShape()`, because that shape also - // feeds the `.strip()` wire member (`ui/ViewItemWire`, registered beside this - // row), where a bare deletion would strip in silence. ⚠️ No gate below can - // JUDGE this row: `ui/ViewItem` is a discriminated union, whose emitted JSON - // Schema has no top-level `properties`, so `authorable-surface/` carries no - // `ui/ViewItem:*` line and check (b) never sees the tombstone — the row is - // declared, not checked. D2: `view-item-owner-hidden-removed`. - 'ui/ViewItem:owner', - // #20085 — the wire carrier of `ui/ViewItem:hidden` (see that row for the - // measurement). `ViewItemWireSchema` is member 1 of the `view` union - // `saveMetaItem` validates; it is built from the same `viewItemBaseShape()`, so - // the one tombstone refuses the key there too instead of letting `.strip()` - // drop it in silence — registered under both def keys, the - // `integration/DeclarativeConnectorEntry:connectionTimeoutMs` precedent. Same - // blind spot as its sibling: a discriminated-union def emits no top-level - // `properties`, so no gate judges this row. - // D2: `view-item-owner-hidden-removed`. - 'ui/ViewItemWire:hidden', - // #20085 — the wire carrier of `ui/ViewItem:owner` (see that row for the - // measurement). `ViewItemWireSchema` is member 1 of the `view` union - // `saveMetaItem` validates; it is built from the same `viewItemBaseShape()`, so - // the one tombstone refuses the key there too instead of letting `.strip()` - // drop it in silence — registered under both def keys, the - // `integration/DeclarativeConnectorEntry:connectionTimeoutMs` precedent. Same - // blind spot as its sibling: a discriminated-union def emits no top-level - // `properties`, so no gate judges this row. - // D2: `view-item-owner-hidden-removed`. - 'ui/ViewItemWire:owner', - // #20230 (ADR-0049 enforce-or-remove; triage direction 「follow #20085's - // disposition for the same key pair」). The flattened view overlay's `hidden` — - // the lean personalization PUT with no `config`, members 3 and 4 of the `view` - // union `saveMetaItem` validates — was declared by `flattenedViewOverlayFields()` - // separately from the view item's, accepted, stored verbatim, and read by - // nothing: both switcher read paths filter on `viewKind` + `object` only, so a - // `hidden: true` overlay hid no view. No writer in this framework, its examples, - // objectui at its pin and at `main`, or the HotCRM app (cloud not reachable). - // Tombstoned with `retiredKey()` on both overlay members, with the view item's - // own prescription text; `.strip()` members, so a bare deletion would have - // dropped the key in silence. Registered under `ui/ViewMetadata`, the exported - // door the overlay members are reached through (the members themselves are not - // exported). ⚠️ No gate below can JUDGE this row: `ui/ViewMetadata` is in - // `unemitted-schemas.baseline.json` (its `config: z.undefined()` guards have no - // JSON Schema form), so `authorable-surface/` carries no `ui/ViewMetadata:*` - // line and check (b) never sees the tombstone — the row is declared, not - // checked. D2: `view-overlay-owner-hidden-removed`. - 'ui/ViewMetadata:hidden', - // #20230 — the overlay door's `owner`, the sibling of `ui/ViewMetadata:hidden` - // (see that row for the measurement and the registration's def key). It named - // a user nothing ever read: no per-user scope exists for a view (ADR-0017, - // parked), so an overlay marked as one user's changed nothing for anyone. - // Tombstoned with `retiredKey()` on both overlay members, with the view item's - // own prescription text. Same blind spot as its sibling: the def is unemitted, - // so no gate judges this row. D2: `view-overlay-owner-hidden-removed`. - 'ui/ViewMetadata:owner', - // - ], -}; - -/** - * Every whole JSON Schema **def** UNPUBLISHED at each protocol major, named - * EXACTLY. - * - * One entry per def, spelled `${category}/${SchemaName}` — the same string - * `packages/spec/json-schema.manifest.json` records (`'identity/Session'`, - * `'data/ValidationRule'`). The sibling of {@link RETIRED_KEYS_BY_MAJOR} one - * level up: that table registers a single authorable KEY being tombstoned, this - * one registers a whole SCHEMA leaving the published set. - * - * ## What reads it - * - * The manifest deletion gate in `scripts/build-schemas.ts` - * (`check:authorable-surface`): a def listed in `json-schema.manifest.json` at - * the merge base with origin/main that this build no longer emits must appear - * here, by exact set membership, or the build fails. Nothing else consumes the - * table, and nothing infers an entry. - * - * ## Why the manifest line alone was not a declaration - * - * The #2978 ratchet asks for one, in a comment: "remove a key ONLY for a - * deliberate retirement". Nothing checked it. The ratchet's `missing` set is - * `manifest − emitted` with the manifest read from the SAME commit, so a PR that - * deleted the export and the manifest line together produced an empty `missing` - * and a silent gate — the exact shape #4650 had just closed one level down, for - * authorable keys. Worse, the two gates deferred to each other: the #4650 - * deletion gate waives every baseline key under a def the build stopped emitting - * ("adjudicated by json-schema.manifest.json"), and the manifest said nothing. - * Measured on #4725 by dropping one barrel re-export: 7 defs and 116 authorable - * keys left the contract with `gen:schema`, `check:authorable-surface` and - * `check:api-surface` all green. - * - * The fix is the #4650 structure: the removal is judged against the manifest at - * the merge base — which the commit under test cannot rewrite — and the deleted - * line has to be answered by a declaration written down HERE, under a major. - * - * ## Why not reachability - * - * #4650's per-key gate lets a deletion prove itself by showing the def is - * unreachable from the metadata-type roots, computed by BFS over the build's own - * Zod graph. That proof is unavailable one level up, and unavailable in the - * dangerous direction: reachability is keyed by `zodByDefKey`, which only holds - * defs this build EMITS, so a def that just stopped being emitted answers - * "unreachable" — a waiver for exactly the removals the gate exists to catch. - * Declaration is therefore the only honest proof at def granularity. - * - * ## What it does NOT replace - * - * A rename is not a retirement: a def published under a new name is declared in - * `scripts/lib/renamed-defs.ts` (`RENAMED_DEFS`), which the gate consults first, - * and an entry here would be a false claim that the contract shrank. And as with - * {@link RETIRED_KEYS_BY_MAJOR}, the D2 conversion (`src/conversions/registry.ts`) - * plus its D3 chain step stay the *documentation* channel that `spec-changes.json`, - * the upgrade guide and `os migrate meta` project; an entry here is the *proof - * the removal was declared*. A retirement needs both. - * - * ## Not a backfill of history - * - * The gate fires only on a def that leaves the published set relative to the - * merge base, so removals that landed before this table existed never re-trigger - * it and are deliberately absent. This reads "whole-schema removals registered - * under the manifest deletion gate", not "every schema ever unpublished". - * - * ## Lifecycle - * - * Entries are permanent, and after the removal merges they name a def no build - * emits — the expected steady state. The one state the gate rejects is an entry - * naming a def this build STILL publishes: a registration nothing consumed, - * pre-approving a removal that has not happened. - * - * @see scripts/build-schemas.ts — the manifest deletion gate, the only consumer - */ -export const RETIRED_DEFS_BY_MAJOR: Readonly> = { - // The first entry since #4725 built this table (#5552). The `transform` key's - // value schema had no other consumer, so it goes with the key rather than - // surviving as an exported union nothing references — an exported schema with - // no consumer reads as a capability to whoever finds it (#3950). - // - // The ten that follow are #5055 (ADR-0049 enforce-or-remove, maintainer ruling - // 2026-08-06, window moved to protocol 17 on 2026-08-07): `ui/widget.zod.ts`'s - // widget-registration vocabulary and the five doorless shapes of - // `ui/i18n.zod.ts`. None had a carrier key, none was reachable from the - // metadata-type roots, and none was ever parsed in objectstack / objectui / - // cloud — so there is no tombstone and no D2 conversion (route 3 of the - // retirement playbook), and this table plus the D3 `SemanticMigration` - // `ui-widget-i18n-family-retired` IS the declaration. Same shape as #4988. - // - // ⚠️ `ui/FieldWidgetProps` is deliberately NOT here — it was never in the - // manifest to begin with (its `onChange` is a `z.function()`, so no JSON - // Schema is emitted) and it survives the retirement: it is a React props - // contract, not authorable metadata, and it has a live compile-time consumer - // in objectui (`packages/fields/src/__tests__/spec-symbol-batch7.test.ts`, - // landed by objectui PR #3289). - // - // The 2026-08-08 ADR-0049 sweep (#6486) adds twenty-three more across three - // members (4 + 10 + 9), all route 3 and all whole-def: `system/http-server.zod.ts`'s - // runtime vocabulary (#5295, D3 `http-server-runtime-vocabulary-retired`), - // `api/protocol.zod.ts`'s viewId-addressed view CRUD (commit f549a0d4a, D3 - // `view-management-protocol-retired`) and the whole L2 ETL layer (#6414, D3 - // `etl-pipeline-layer-retired`). None had a carrier key and none was ever - // parsed outside its own unit tests, so again there is no tombstone and no D2 - // conversion — this table plus those three entries ARE the declaration. - // - // ⚠️ `system/ServerRateLimitConfig` is deliberately NOT here. It sits four - // lines from the retired `system/ServerCapabilities` in the same manifest and - // shares its prefix, but it belongs to `StackServerSecurity.rateLimit` — the - // LIVE server surface #5006 admitted, with an executor. Prefix adjacency is - // not evidence. - 17: [ - // One file per entry under `entries/retired-defs/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // Commit f549a0d4a — api/protocol.zod.ts view-management operations - 'api/CreateViewRequest', - 'api/CreateViewResponse', - 'api/DeleteViewRequest', - 'api/DeleteViewResponse', - 'api/GetViewRequest', - 'api/GetViewResponse', - 'api/ListViewsRequest', - 'api/ListViewsResponse', - 'api/UpdateViewRequest', - 'api/UpdateViewResponse', - // #6414 — automation/etl.zod.ts, the whole L2 layer - 'automation/ETLDestination', - 'automation/ETLEndpointType', - 'automation/ETLPipeline', - 'automation/ETLPipelineRun', - 'automation/ETLRunStatus', - 'automation/ETLSource', - 'automation/ETLSyncMode', - 'automation/ETLTransformation', - 'automation/ETLTransformationType', - // #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049 - // enforce-or-remove; fork (b) of the #8075 census, accepted 2026-08-12). The - // external-data-source shape whose `authentication.config` record accepted - // inline OAuth client secrets and API keys — its own docblock example wrote - // `"clientSecret": "..."`. Zero consumers: no metadata-type binding, no stack - // collection, no object/field embedding (`object.external` binds - // `ObjectExternalBindingSchema`, which routes credentials through datasource - // config per ADR-0015/0062), and its only in-module consumer - // (`ExternalLookupSchema.dataSource`) was itself consumed by nothing. Route 3: - // no tombstone, no D2 conversion — this table plus the D3 - // `external-lookup-message-queue-families-retired` are the declaration. - 'data/ExternalDataSource', - // #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049). Extended - // `shared/FieldMapping` and was embedded only by `ExternalLookupSchema`, which - // nothing consumed. Its `transform` key's #5552 `retiredKey()` tombstone — and - // the `data/ExternalFieldMapping:transform` RETIRED_KEYS entry that registered - // it — are SUBSUMED here, the WidgetManifest.performance way: they go with the - // shape that carried them, which is strictly stronger, because there is no - // longer a mapping shape to author the key INTO. The base tombstone on - // `shared/FieldMapping` and the `integration/ConnectorFieldMapping` spelling - // are untouched and still reject the key with the #5552 prescription. - 'data/ExternalFieldMapping', - // #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049). The root of - // the module's dependency shape: it embedded `ExternalDataSource` (the inline - // credential sink) and `ExternalFieldMapping`, and was itself referenced by no - // stack collection, metadata type or import outside packages/spec — the - // #5552 conversion's docblock had already recorded that no external-lookup - // document exists for the conversion walker to visit. Real-time external - // lookup returns via the enforce route of ADR-0049: the executor first, the - // vocabulary second. - 'data/ExternalLookup', - // #4914 — the plugin manifest's `loading` block (ADR-0049 enforce-or-remove, - // maintainer ruling 2026-08-04). `PluginLoadingConfig` was reachable from - // authored metadata ONLY through `Manifest.loading`, and the ten members - // registered with it were embedded only by it, so retiring the carrier key - // unpublishes the whole closure. The carrier itself is a `retiredKey()` tombstone - // registered one level up in `RETIRED_KEYS_BY_MAJOR`. - // - // ⚠️ `kernel/PluginLoadingEvent` and `kernel/PluginLoadingState` are - // deliberately NOT here. They live in the same module and share its prefix, - // but neither was ever embedded in `PluginLoadingConfig` — they are the - // observational half (a lifecycle event and a per-plugin state), they are - // not authorable, and they still emit. Module adjacency is not evidence, - // the `system/ServerRateLimitConfig` note above applies verbatim. - 'kernel/PluginCaching', - 'kernel/PluginCodeSplitting', - 'kernel/PluginDependencyResolution', - 'kernel/PluginDynamicImport', - 'kernel/PluginHotReload', - 'kernel/PluginInitialization', - 'kernel/PluginLoadingConfig', - 'kernel/PluginLoadingStrategy', - 'kernel/PluginPerformanceMonitoring', - 'kernel/PluginPreloadConfig', - 'kernel/PluginSandboxing', - 'shared/FieldMappingTransform', - // #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). Consumer - // group config (groupId / offset reset / auto-commit / poll size), embedded - // only by `system/MessageQueueConfig` (retired in the same change) and - // consumed by nothing. - 'system/ConsumerConfig', - // #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). DLQ config, - // embedded only by `system/MessageQueueConfig` (retired in the same change) - // and consumed by nothing. ⚠️ `kernel/DeadLetterQueueEntry` is a different - // declaration (the event bus's per-event DLQ record) and is untouched — - // name adjacency is not evidence, the `system/ServerRateLimitConfig` note - // applies verbatim. - 'system/DeadLetterQueue', - // #8075 — system/message-queue.zod.ts, retired whole (ADR-0049 - // enforce-or-remove; fork (b) of the #8075 census, accepted 2026-08-12). The - // top-level broker-administration config, carrying a required inline - // `sasl.password` credential when `sasl` was present. Zero consumers outside - // packages/spec repo-wide; no `message_queue` metadata type; the connector - // 'message_queue' ConnectorType enum value never referenced this schema. The - // consumed near-namesake `kernel/EventMessageQueueConfig` - // (`EventBusConfigSchema.messageQueue`) deliberately carries NO credential - // field and is untouched. Route 3: no tombstone, no D2 conversion — this - // table plus the D3 `external-lookup-message-queue-families-retired` are the - // declaration. - 'system/MessageQueueConfig', - // #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). The - // six-value broker provider enum, embedded only by - // `system/MessageQueueConfig` (retired in the same change). Not the same - // declaration as `kernel/EventMessageQueueConfig.provider` — that is its own - // INLINE `z.enum` (same six values, never a reference to this def) and is - // untouched. - 'system/MessageQueueProvider', - // #5295 — system/http-server.zod.ts runtime vocabulary - 'system/ServerCapabilities', - 'system/ServerEvent', - 'system/ServerEventType', - 'system/ServerStatus', - // #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). Topic - // partitions/replication/retention/compression config, embedded only by - // `system/MessageQueueConfig` (retired in the same change) and consumed by - // nothing — leaving it behind would strand an exported schema with no - // consumer (#3950). - 'system/TopicConfig', - 'ui/DateFormat', - 'ui/I18nObject', - 'ui/LocaleConfig', - 'ui/NumberFormat', - 'ui/PluralRule', - 'ui/WidgetEvent', - 'ui/WidgetLifecycle', - 'ui/WidgetManifest', - 'ui/WidgetProperty', - 'ui/WidgetSource', - // - ], - // Post-cut retirements: v17.0.0 was cut before these landed, so the - // removal ships on the 17.x line (launch-window convention) and the - // prescription registers at the major boundary where `migrate meta` users - // look — the step18 discipline the retired-key table above already applies, - // extended to whole defs. - 18: [ - // One file per entry under `entries/retired-defs/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // #17158 — `api/CreateExportJobRequest`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the request body of `POST /api/v1/data/:object/export` as an asynchronous job. - // That method and path are served by nothing; the served export is the - // synchronous `GET` on the same path. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/CreateExportJobRequest', - // #17158 — `api/CreateExportJobResponse`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the `{ jobId, status, estimatedRecords, createdAt }` answer of the unserved - // asynchronous export-job create. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/CreateExportJobResponse', - // Commit b3a63d32c — `api/CrudEndpointPattern` (the `{ method, path, summary, description }` - // value shape of `crud.patterns`) leaves with its carrier key: its ONLY consumer - // was `CrudEndpointsConfigSchema.patterns`, tombstoned in the same change under - // ADR-0049 enforce-or-remove, and an exported value schema with no consumer - // reads as a capability (#3950). `api/CrudOperation` stays — - // `GeneratedEndpointSchema.operation` still reads it. See - // `retired-keys/18.api__CrudEndpointsConfig__patterns.ts` for the retirement record. - 'api/CrudEndpointPattern', - // #17158 — `api/ExportJobProgress`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the progress body of `GET /api/v1/data/export/:jobId`, a route no package - // mounts. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/ExportJobProgress', - // #17158 — `api/ExportJobStatus`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the job-status enum (`pending` … `expired`) of the export-job API family. No - // job store, worker or route ever set or read one. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/ExportJobStatus', - // #17158 — `api/ExportJobSummary`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the row shape of the unserved export-job list. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/ExportJobSummary', - // #19543 (door ④) — `api/FlowSummary` left with its only reader, - // `api/ListFlowsResponse` (above). No producer ever built one: the retired - // list route answered bare names, so the summary's `label` / `type` / - // `status` / `version` / `enabled` / `nodeCount` / `lastRunAt` were a shape - // with no emitter, and an exported schema with no consumer reads as a - // capability (#3950, the `ui/ThemeMode` rule). Measured before removal: zero - // readers in objectstack, objectui (pinned sha and main) or cloud. A flow's - // runtime enablement is served by `GET /api/v1/automation/_status`; its - // definition by `GET /api/v1/meta/flow`. - 'api/FlowSummary', - // #17158 — `api/GetExportJobDownloadRequest`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the request of `GET /api/v1/data/export/:jobId/download`, a route no package - // mounts. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/GetExportJobDownloadRequest', - // #17158 — `api/GetExportJobDownloadResponse`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the presigned-link answer of `GET /api/v1/data/export/:jobId/download`, a - // route no package mounts. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/GetExportJobDownloadResponse', - // #13823 — `api/HandlerStatus` (the `implemented` / `stub` / `planned` enum) - // left with its two carriers: `RestApiEndpoint.handlerStatus` is tombstoned - // in this same major (`RETIRED_KEYS_BY_MAJOR[18]`) and - // `RouteCoverageEntry.handlerStatus` left with that def (`api/RouteCoverageEntry` - // below), so the enum had no remaining consumer — and an exported value - // schema with no consumer reads as a capability (#3950, the `ui/ThemeMode` - // rule). Measured before removal: zero readers of the enum or the key in - // objectstack, objectui (pinned sha) or cloud. See - // `18.api__RestApiEndpoint__handlerStatus.ts` for the retirement record. - 'api/HandlerStatus', - // #17158 — `api/ListExportJobsRequest`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the query of `GET /api/v1/data/export` (`object`, `status`, `limit` default - // 20, `cursor`), a route no package mounts; the `limit` / `cursor` pair (#19543 - // door ②, absorbed into #17158) had no reader to spend it. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/ListExportJobsRequest', - // #17158 — `api/ListExportJobsResponse`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the `{ jobs, nextCursor, hasMore }` answer of `GET /api/v1/data/export`, a - // route no package mounts. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/ListExportJobsResponse', - // #19543 (door ④) — `api/ListFlowsRequest`, the query of the retired - // `GET /api/v1/automation` flow list (maintainer ruling on #19543: - // 「退役,统一走 /meta/flow」). It declared `status` / `type` / `limit` - // (default 50) / `cursor`, and the route read none of them: it called - // `listFlows()` with no arguments. Retired whole with the route and its - // `AutomationApiContracts.listFlows` entry; flows are metadata (ADR-0106) and - // the list is `GET /api/v1/meta/flow`. Zero readers measured before removal in - // objectstack, objectui (pinned sha and main) and cloud. No carrier key and no - // authored document, so no tombstone and no D2 conversion — this table plus - // the D3 semantic entry `automation-flow-list-route-retired` ARE the - // declaration — the whole-def route-3 shape, as the precedent entry - // `package-rollback-response-retired` (and its `api/PackageRollbackResponse` - // row) recorded it. - 'api/ListFlowsRequest', - // #19543 (door ④) — `api/ListFlowsResponse`, the answer of the retired - // `GET /api/v1/automation` flow list. It declared `FlowSummary[]`, `total`, - // `nextCursor` and `hasMore`, while the route answered bare flow NAMES with a - // literal `hasMore: false` and never a `nextCursor` — a declaration no build - // ever served. Retired whole with the route; the list is `GET /api/v1/meta/flow`. - // See `18.api__ListFlowsRequest.ts` and the D3 semantic entry - // `automation-flow-list-route-retired` for the record. - 'api/ListFlowsResponse', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the `GET /api/meta/:type/:name/effective` response contract. No adapter - // ever served that path; the layered read that actually ships is - // `getMetaItemLayered` (ADR-0005 org overlay, `code`/`overlay`/`effective` - // layers) with its own contracts. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'api/MetadataEffectiveResponse', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the `GET /api/meta/:type/:name/overlay` response contract. No adapter ever - // served that path (measured: no route spelling in packages/rest or - // packages/metadata). - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'api/MetadataOverlayResponse', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the `PUT /api/meta/:type/:name/overlay` request contract (a bare - // `MetadataOverlaySchema`). No adapter ever served that path. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'api/MetadataOverlaySaveRequest', - // #12038 — `api/package-api.zod.ts` `PackageRollbackResponseSchema`, retired - // whole together with the `PackageApiContracts.rollbackPackage` entry that - // bound it (maintainer ruling 2026-08-27, sub-question 3A). The schema - // declared a VERSION rollback — `{ success, restoredVersion?, message? }` — - // while the live `POST /api/v1/packages/:packageId/rollback` route posts - // `{ commitId }` and the dispatcher serves it with `rollbackToPackageCommit`, - // the ADR-0067 COMMIT rollback: a wrong-operation declaration bound to the - // exact live path, which a future sweep would have read as authoritative. - // Zero consumers measured across objectstack/objectui/cloud (#12038 survey - // §5.2): only its own unit test and the #11925 negative guard, both updated - // in the retiring PR. No carrier key, no authored document, so no tombstone - // and no D2 conversion — this table plus the D3 semantic entry - // `package-rollback-response-retired` ARE the declaration (commit 2c86fe3ea's route-3 - // shape). The live route's true contract is - // `RollbackToPackageCommitResponseSchema` (`api/package-lifecycle.zod.ts`), - // authored in the same PR AFTER this retirement per the ruling's sequencing. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the #8586 / commit 2c86fe3ea precedent). - 'api/PackageRollbackResponse', - // #13823 — `api/RouteCoverageEntry` (one declared endpoint's coverage row: - // `path` / `method` / `category` / `handlerStatus` / `service` / - // `healthCheckPassed`) left whole with `api/RouteCoverageReport`, the only - // shape that embedded it. Nothing ever constructed or parsed one — zero - // constructors in objectstack, objectui (pinned sha) or cloud — so the - // `handlerStatus` it re-declared was carried outward by nobody. Route 3 - // (whole-def removal, no carrier key, no D2 conversion); see - // `18.api__RestApiEndpoint__handlerStatus.ts` for the retirement record. - 'api/RouteCoverageEntry', - // #13823 — `api/RouteCoverageReport` (the aggregated startup report — `timestamp` - // / `adapter` / `summary { total, implemented, stub, planned }` / `entries[]`) - // whose docblock said adapters SHOULD emit it as startup health diagnostics and - // warn on every endpoint with `handlerStatus !== 'implemented'`. No adapter, - // dispatcher or registrar ever constructed one — zero constructors in - // objectstack, objectui (pinned sha) or cloud — so it was a shape with no - // producer, and the status it aggregated had no reader. Route 3 (whole-def - // removal, no carrier key, no D2 conversion). Route readiness that IS measured - // is unchanged: the discovery payload's per-service `status` / `handlerReady` - // (`api/discovery.zod.ts`) and the CI-asserted route ledger - // (`packages/runtime/src/route-ledger.ts`). See - // `18.api__RestApiEndpoint__handlerStatus.ts` for the retirement record. - 'api/RouteCoverageReport', - // #17158 — `api/ScheduleExportRequest`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the request body of `POST /api/v1/data/export/schedules`, a route no package - // mounts; its `schedule` block held only `timezone` after #16320. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/ScheduleExportRequest', - // #17158 — `api/ScheduleExportResponse`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the answer of `POST /api/v1/data/export/schedules`, a route no package mounts. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/ScheduleExportResponse', - // #17158 — `api/ScheduledExport`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // a recurring export definition (`schedule`, `delivery`, `nextRunAt`). #16320 - // had already deleted its `schedule.cronExpression`, leaving a required - // `schedule` block that could hold no schedule; no scheduler ever read it. - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'api/ScheduledExport', - // #21320 — `automation/ActionRef` (a named side effect, by name or parameterised) left with `automation/StateMachine`: - // every consumer it had was inside the retired state-machine family (the - // #3950 rule — an exported value schema with no consumer reads as a - // capability). See `18.automation__StateMachine.ts` for the retirement - // record and the ruling. - 'automation/ActionRef', - // #21320 — `automation/GuardRef` (a named transition condition) left with `automation/StateMachine`: - // every consumer it had was inside the retired state-machine family (the - // #3950 rule — an exported value schema with no consumer reads as a - // capability). See `18.automation__StateMachine.ts` for the retirement - // record and the ruling. - 'automation/GuardRef', - // #17158 — `automation/ScheduleState`, retired whole with the export-job API family - // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui - // retired its side first in objectui#10247). It declared - // the runtime state of a scheduled flow (`timezone`, `status`, `nextRunAt`, run - // counters). No scheduler ever wrote or read one, and after #16320 deleted its - // required cron it no longer declared a cadence. Retired with the family under - // ruling item 2 ("unless a live consumer is measured" — none was, in - // objectstack, objectui at the pin, or cloud). - // Zero readers in objectstack, in objectui at the pinned sha, and in cloud. - // No carrier key and no authored document, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `export-job-family-retired` are the declaration. - 'automation/ScheduleState', - // #21320 — `automation/state-machine.zod.ts` `StateMachineSchema`, the - // XState-style machine (hierarchical and parallel states, guarded transitions, - // entry/exit actions), retired whole with its last authorable door, the - // tombstoned `agent.lifecycle` (ADR-0049 enforce-or-remove, ruled D on - // objectstack-ai/cloud#2569). ADR-0020 had already retired it as a - // record-lifecycle declaration — the `workflow` type and `object.stateMachines` - // went, and a record's legal transitions are the `state_machine` validation - // rule — and kept the file only because the agent door still imported it - // (ADR-0020 implementation note 1). The rest of the family — `StateNode`, - // `Transition`, `ActionRef`, `GuardRef` — left with it, each registered in its - // own entry file beside this one. Upgraders get the D3 semantic entry - // `agent-lifecycle-retired`. Registered under 18 for the launch-window reason - // its neighbours state. - 'automation/StateMachine', - // #21320 — `automation/StateNode` (one state of a machine, recursive) left with `automation/StateMachine`: - // every consumer it had was inside the retired state-machine family (the - // #3950 rule — an exported value schema with no consumer reads as a - // capability). See `18.automation__StateMachine.ts` for the retirement - // record and the ruling. - 'automation/StateNode', - // #21320 — `automation/Transition` (a guarded transition between states) left with `automation/StateMachine`: - // every consumer it had was inside the retired state-machine family (the - // #3950 rule — an exported value schema with no consumer reads as a - // capability). See `18.automation__StateMachine.ts` for the retirement - // record and the ruling. - 'automation/Transition', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/AnalyticsTimeRange', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/AppDiscoveryRequest', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/AppDiscoveryResponse', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/AppSubscription', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/CreateListingRequest', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/CuratedCollection', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/Environment', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentCredential', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentCredentialStatus', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentDriver', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentMember', - // #16325 — `cloud/environment-package.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentPackageInstallation', - // #16325 — `cloud/environment-package.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentPackageStatus', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentRole', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentStatus', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/EnvironmentVisibility', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/FeaturedListing', - // #16325 — `cloud/environment-package.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/InstallPackageToEnvironmentRequest', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/InstalledAppSummary', - // #16325 — `cloud/environment-package.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ListEnvironmentPackagesResponse', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ListInstalledAppsRequest', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ListInstalledAppsResponse', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ListReviewsRequest', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ListReviewsResponse', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ListingActionRequest', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/MarketplaceHealthMetrics', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/PackageInstallation', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/PackageInstallationStatus', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/PolicyAction', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/PolicyViolationType', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ProvisionEnvironmentRequest', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ProvisionEnvironmentResponse', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ProvisionOrganizationRequest', - // #16325 — `cloud/environment.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ProvisionOrganizationResponse', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ProvisionTenantRequest', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ProvisionTenantResponse', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/PublisherProfile', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/PublishingAnalyticsRequest', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/PublishingAnalyticsResponse', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/RecommendationReason', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/RecommendedApp', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/RejectionReason', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ReleaseChannel', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ReviewCriterion', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ReviewDecision', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/ReviewModerationStatus', - // #16325 — `cloud/environment-package.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/RollbackEnvironmentPackageRequest', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/SubmissionReview', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/SubmitReviewRequest', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/SubscriptionStatus', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/TenantContext', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/TenantDatabase', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/TenantDatabaseStatus', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/TenantIdentificationSource', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/TenantPlan', - // #16325 — `cloud/tenant.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/TenantRoutingConfig', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/TimeSeriesPoint', - // #16325 — `cloud/marketplace-admin.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/TrendingListing', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/UpdateListingRequest', - // #16325 — `cloud/environment-package.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/UpgradeEnvironmentPackageRequest', - // #16325 — `cloud/app-store.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/UserReview', - // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath - // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are - // the cloud repo's own declarations, not an open-source protocol). Prescription: the - // `cloud-subpath-retired` semantic entry of this major. - 'cloud/VersionRelease', - // #22158 — `data/Document` — a stored document with versioning, a template, - // an e-signature block and access control, which no document store ever kept — - // leaves whole with the document family under ADR-0049 enforce-or-remove, by - // the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): - // "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and - // `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, - // so that 'template' means one thing" — a printable document is a page that - // declares `print`. It was exported from `@objectstack/spec/data` - // (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no - // metadata type, absent from every liveness ledger, and read by NOTHING: on - // objectstack `fec87e7e0` every hit for the family's exported names outside - // `packages/spec` was generated reference docs, release notes or a changelog; - // objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm - // (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier - // key, so no `retiredKey()` tombstone and no D2 conversion (none of these - // schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR - // plus the D3 semantic entry `document-schemas-retired` ARE the declaration. - 'data/Document', - // #22158 — `data/DocumentTemplate` — a docx template URL with typed - // placeholders (`fileUrl` / `fileType` / `placeholders[]`), which no template - // engine ever merged — - // leaves whole with the document family under ADR-0049 enforce-or-remove, by - // the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): - // "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and - // `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, - // so that 'template' means one thing" — a printable document is a page that - // declares `print`. It was exported from `@objectstack/spec/data` - // (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no - // metadata type, absent from every liveness ledger, and read by NOTHING: on - // objectstack `fec87e7e0` every hit for the family's exported names outside - // `packages/spec` was generated reference docs, release notes or a changelog; - // objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm - // (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier - // key, so no `retiredKey()` tombstone and no D2 conversion (none of these - // schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR - // plus the D3 semantic entry `document-schemas-retired` ARE the declaration. - 'data/DocumentTemplate', - // #22158 — `data/DocumentVersion` — one immutable version of a stored - // document, whose only carrier was `DocumentSchema.versioning.versions` (the - // orphan-value-schema rule takes it with its carrier) — - // leaves whole with the document family under ADR-0049 enforce-or-remove, by - // the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): - // "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and - // `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, - // so that 'template' means one thing" — a printable document is a page that - // declares `print`. It was exported from `@objectstack/spec/data` - // (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no - // metadata type, absent from every liveness ledger, and read by NOTHING: on - // objectstack `fec87e7e0` every hit for the family's exported names outside - // `packages/spec` was generated reference docs, release notes or a changelog; - // objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm - // (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier - // key, so no `retiredKey()` tombstone and no D2 conversion (none of these - // schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR - // plus the D3 semantic entry `document-schemas-retired` ARE the declaration. - 'data/DocumentVersion', - // #22158 — `data/ESignatureConfig` — an e-signature workflow (`provider` / - // `enabled` / `signers[]`), which no e-signature integration ever sent. Its - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries (`expirationDays` / - // `reminderDays`, the #14477 tombstones) stay as history — gate (b2) of - // build-schemas.ts accepts an entry naming a key the build no longer emits — - // leaves whole with the document family under ADR-0049 enforce-or-remove, by - // the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): - // "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and - // `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, - // so that 'template' means one thing" — a printable document is a page that - // declares `print`. It was exported from `@objectstack/spec/data` - // (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no - // metadata type, absent from every liveness ledger, and read by NOTHING: on - // objectstack `fec87e7e0` every hit for the family's exported names outside - // `packages/spec` was generated reference docs, release notes or a changelog; - // objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm - // (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier - // key, so no `retiredKey()` tombstone and no D2 conversion (none of these - // schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR - // plus the D3 semantic entry `document-schemas-retired` ARE the declaration. - 'data/ESignatureConfig', - // Commit 2c86fe3ea — identity/identity.zod.ts `ApiKeySchema`, retired whole (ADR-0049 - // enforce-or-remove; maintainer ruling 2026-08-15, disposition B: delete). - // The schema documented better-auth's `apiKey` PLUGIN shape — a plugin this - // platform does not load: `start`, `lastRefetchAt`, `enabled` (the real - // column is `revoked`, opposite polarity), the four per-key rate-limit keys - // (no such surface exists anywhere), `permissions`, `metadata`, camelCase - // `organizationId`. Zero consumers in framework, cloud or objectui outside - // its own unit test; the live `sys_api_key` table is declared by - // `@objectstack/platform-objects` (`identity/sys-api-key.object.ts`) and - // read by `core/src/security/api-key.ts` / `runtime/src/domains/keys.ts` in - // snake_case, never through this schema — one table had two declarations and - // the published one was fiction. Route 3: no carrier key, no authored - // document for a D2 conversion to rewrite, so no tombstone and no - // conversion — this table plus the D3 semantic entry - // `identity-api-key-schema-retired` ARE the declaration. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the #8586 / PR #8702 precedent). - 'identity/ApiKey', - // `integration/CircuitBreakerConfig` (`enabled`, `failureThreshold`, - // `resetTimeoutMs`, `halfOpenMaxRequests`, `monitoringWindowMs`, - // `fallbackStrategy`, and the `monitoringWindow` rename tombstone) leaves with - // `integration/ConnectorHealth`, whose `circuitBreaker` was its only carrier. No - // state machine ever opened, half-opened or closed a breaker, and none of the - // four `fallbackStrategy` behaviours was implemented. Its `monitoringWindow` - // tombstone leaves with it: the `RETIRED_KEYS_BY_MAJOR[18]` row - // `integration/CircuitBreakerConfig:monitoringWindow` stays, which is the - // whole-def removal steady state gate (b3) of `scripts/build-schemas.ts` - // deliberately exempts. See `retired-keys/18.integration__Connector__health.ts` - // for the retirement record. - 'integration/CircuitBreakerConfig', - // `integration/ConnectorConflictResolution` (`source_wins` / `target_wins` / - // `latest_wins` / `manual`) was the value vocabulary of - // `syncConfig.conflictResolution` alone, and leaves with it. Its default, - // `latest_wins`, read as a configured policy and resolved nothing; the pull - // binding that replaces the family claims no conflict policy (version 1 writes - // through the mapping's `mode` / `upsertKey`). See - // `retired-keys/18.integration__Connector__syncConfig.ts`. - 'integration/ConnectorConflictResolution', - // Commit 13c48c2a5 — `integration/ConnectorErrorCategory` (the 8-value connector-side - // error category enum) left with its two carriers: `ErrorMappingRule.targetCategory` - // and `ErrorMappingConfig.defaultCategory`, both retired in this same major - // (`RETIRED_DEFS_BY_MAJOR[18]`). Measured before removal: outside the declaring - // file its only references were a value round-trip in `connector.test.ts` and a - // type-identity pin — no runtime reader — so the enum had no remaining consumer, - // and an exported value schema with no consumer reads as a capability (#3950, - // the `api/HandlerStatus` precedent). `api/ErrorCategory` — the HTTP-response - // vocabulary this enum was deliberately kept from sharing a name with - // (ADR-0112 D9a) — is unaffected. See - // `retired-keys/18.integration__Connector__errorMapping.ts` for the retirement - // record. - 'integration/ConnectorErrorCategory', - // `integration/ConnectorFieldMapping` (the shared base `FieldMapping` extended - // with `dataType`, `required` and `syncMode`) leaves with its only carrier, - // `ConnectorSchema.fieldMappings`, tombstoned in this same major under - // ADR-0049. Nothing moved a value through it; the target-side field map is - // `mapping.fieldMapping`. The `integration/ConnectorFieldMapping:transform` - // retired-key row of the earlier `transform` retirement stays as its record. - // See `retired-keys/18.integration__Connector__fieldMappings.ts`. - 'integration/ConnectorFieldMapping', - // `integration/ConnectorHealth` (`healthCheck`, `circuitBreaker`) leaves with its - // only carrier, `ConnectorSchema.health`, tombstoned in this same major under - // ADR-0049 enforce-or-remove (`RETIRED_KEYS_BY_MAJOR[18]`). Nothing outside the - // declaring file ever parsed or constructed one, and an exported value schema - // with no consumer reads as a capability (#3950). See - // `retired-keys/18.integration__Connector__health.ts` for the retirement record. - 'integration/ConnectorHealth', - // `integration/ConnectorStatus` (`active` / `inactive` / `error` / - // `configuring`) leaves with its only carrier, `ConnectorSchema.status`, - // tombstoned in this same major under ADR-0049 enforce-or-remove. Nothing read a - // connector's `status`; the runtime's dispatchability answer is the computed - // `ConnectorState` (`ready` / `degraded`, `integration/connector-descriptor.ts`), - // which is a TypeScript type and not a published def, so nothing replaces this - // one. See `retired-keys/18.integration__Connector__status.ts` for the - // retirement record. - 'integration/ConnectorStatus', - // `integration/ConnectorTrigger` (`key`, `label`, `description`, - // `type: 'polling' | 'webhook'`, `intervalSeconds`, and the `interval` tombstone - // of its unit rename) leaves with its only carrier, `ConnectorSchema.triggers`, - // tombstoned in this same major under ADR-0049 enforce-or-remove. Nothing read a - // connector trigger, so nothing replaces the shape: work starts from a flow that - // calls the connector's action. See - // `retired-keys/18.integration__Connector__triggers.ts` for the retirement - // record. - 'integration/ConnectorTrigger', - // `integration/DataSyncConfig` (`strategy`, `direction`, `realtimeSync`, - // `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, - // `filters`) leaves with its only carrier, `ConnectorSchema.syncConfig`, - // tombstoned in this same major under ADR-0049. No engine ever ran a - // connector-attached sync; a sync is defined on its target `mapping` - // (`connectorSource`), with a `job` for the cadence. See - // `retired-keys/18.integration__Connector__syncConfig.ts` for the retirement - // record. - 'integration/DataSyncConfig', - // Commit 13c48c2a5 — `integration/ErrorMappingConfig` (`rules`, `defaultCategory`, - // `unmappedBehavior`, `logUnmapped`) leaves with its only carrier: - // `ConnectorSchema.errorMapping`, tombstoned in this same major under ADR-0049 - // enforce-or-remove (`RETIRED_KEYS_BY_MAJOR[18]`). Nothing outside the declaring - // file ever parsed or constructed one, and an exported value schema with no - // consumer reads as a capability (#3950). Its two `.default()`s - // (`defaultCategory: 'integration_error'`, `logUnmapped: true`) were only ever - // materialized INSIDE an authored `errorMapping` block, so there is no - // residue window on the carrier (#12840 does not apply: the key itself carried - // no default). See `retired-keys/18.integration__Connector__errorMapping.ts` - // for the retirement record. - 'integration/ErrorMappingConfig', - // Commit 13c48c2a5 — `integration/ErrorMappingRule` (`sourceCode`, `sourceMessage`, - // `targetCode`, `targetCategory`, `severity`, `retryable`, `userMessage`) leaves - // with `integration/ErrorMappingConfig`, whose `rules[]` was its only carrier. - // The `userMessage` member is the reason the census filed the card: its - // `describe` read, to the letter, like the LIVE `ApiError.userMessage` channel - // (the user-facing refusal text a thrown HTTP error declares), while no code - // path ever read this one — two keys meaning different things under one - // spelling in the same spec package. Deletion resolves the collision without a - // rename. See `retired-keys/18.integration__Connector__errorMapping.ts` for the - // retirement record. - 'integration/ErrorMappingRule', - // `integration/HealthCheckConfig` (`enabled`, `intervalMs`, `timeoutMs`, - // `endpoint`, `method`, `expectedStatus`, `unhealthyThreshold`, - // `healthyThreshold`) leaves with `integration/ConnectorHealth`, whose - // `healthCheck` was its only carrier. No loop ever polled a connector endpoint: - // the only `healthCheck` code outside `packages/spec` is the kernel's PLUGIN - // health contract — a different shape on a different subject. Its four - // `.default()`s were only ever materialized INSIDE an authored block, so there - // is no residue window on the carrier. See - // `retired-keys/18.integration__Connector__health.ts` for the retirement record. - 'integration/HealthCheckConfig', - // `integration/SyncStrategy` (`full` / `incremental` / `upsert` / - // `append_only`) was the value vocabulary of `syncConfig.strategy` alone, and - // leaves with it. On the target-side binding, full vs incremental is whether - // `connectorSource.watermark` is set, and upsert is the mapping's own `mode`. - // See `retired-keys/18.integration__Connector__syncConfig.ts`. - 'integration/SyncStrategy', - // `integration/WebhookConfig` — the canonical `webhook` shape `.extend()`ed with - // `events` and `signatureAlgorithm` — leaves with its only carrier, - // `ConnectorSchema.webhooks`, tombstoned in this same major under ADR-0049 - // enforce-or-remove. A webhook nested in a connector was never registered, - // materialized or delivered; the delivered shape is `automation/Webhook`, which - // is unaffected. See `retired-keys/18.integration__Connector__webhooks.ts` for - // the retirement record. - 'integration/WebhookConfig', - // `integration/WebhookEvent` (`record.created` / `record.updated` / - // `record.deleted` / `sync.started` / `sync.completed` / `sync.failed` / - // `auth.expired` / `rate_limit.exceeded`) leaves with `integration/WebhookConfig`, - // whose `events` was its only carrier. No code path emits any of the connector - // lifecycle events it names. See - // `retired-keys/18.integration__Connector__webhooks.ts` for the retirement record. - 'integration/WebhookEvent', - // `integration/WebhookSignatureAlgorithm` (`hmac_sha256` / `hmac_sha512` / - // `none`) leaves with `integration/WebhookConfig`, whose `signatureAlgorithm` was - // its only carrier. A delivered webhook (the top-level `webhooks:` collection) is - // signed by the messaging outbox from its `secret`; nothing ever read this - // choice. See `retired-keys/18.integration__Connector__webhooks.ts` for the - // retirement record. - 'integration/WebhookSignatureAlgorithm', - // #11825 — kernel/plugin-lifecycle-advanced.zod.ts - // `AdvancedPluginLifecycleConfigSchema`, retired whole (ADR-0049 - // enforce-or-remove; maintainer ruling 2026-08-25, route 2). The aggregating - // `{ health, hotReload, degradation, updates, resources, observability }` - // lifecycle-config container had ZERO runtime readers, re-measured per group - // at the retirement's base commit with positive controls: the kernel never - // parses, stores or forwards it; no manifest, stack collection or - // metadata-type binding embeds it (no authored document could carry it); and - // a scan of objectstack + objectui put every reference inside `packages/spec` - // itself. The classes with implementation bodies — `PluginHealthMonitor` and - // `HotReloadManager` in `@objectstack/core` — are KEPT as host-driven library - // (the #11811 lifecycle.mdx examples are the supported usage), and their - // input vocabularies (`PluginHealthCheck`, `PluginHealthStatus`, - // `PluginHealthReport`, `HotReloadConfig`, `DistributedStateConfig`, - // `PluginStateSnapshot`) survive in the same module; but neither class is - // composed by any runtime, and both take their config DIRECTLY — never - // through this container. An author declaring `health: {...}` or a rollback - // policy here got a clean parse and NOTHING — the #3950 shape at container - // scale. Route 3: no carrier key, no authored document for a D2 conversion to - // rewrite, so no tombstone and no conversion — this table plus the D3 - // semantic entry `advanced-plugin-lifecycle-config-retired` ARE the - // declaration. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the #8586 / PR #8702 precedent). - 'kernel/AdvancedPluginLifecycleConfig', - // #12007 — kernel/cli-extension.zod.ts - // `CLICommandContributionSchema` / `CLICommandContribution`, retired whole - // (ADR-0049 enforce-or-remove; triage graded 2026-08-25, the exported - // orphan-value-schema class — #3950). The pair described a "CLI Command - // Contribution declaration in the manifest" and claimed retention "for - // describing command metadata in plugin manifests" — but after commit be21955ba - // tombstoned `manifest.contributes.commands`, no manifest surface could - // legally carry these entries: the exported schema advertised a shape whose - // only declared carrier rejects it. The manifest never referenced this schema - // even before the tombstone (its inline `commands` item schema was an - // independent duplicate). Zero consumers outside spec's own test and - // generated artifacts, measured at the retirement's base commit (146f448a5) - // with positive controls in objectstack, objectui (pinned sha) and cloud. - // What ACTUALLY registers CLI commands is oclif's native plugin discovery — - // `OclifPluginConfigSchema` (same module, live `package.json` `oclif` - // section) SURVIVES, as does the module docblock's Commander.js migration - // prose, which the `contributes.commands` tombstone cites. Route 3: no - // carrier key, no authored document for a D2 conversion to rewrite, so no - // tombstone and no conversion — this table plus the D3 semantic entry - // `cli-command-contribution-retired` ARE the declaration. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the #8586 / PR #8702 precedent). - 'kernel/CLICommandContribution', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the who-customized enum (`package`/`admin`/`user`/`migration`/`api`). - // Nothing ever wrote or read an origin. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'kernel/CustomizationOrigin', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the vendor customization-boundary policy (lockedFields / - // customizableFields whitelists), embedded by the retired authorable key - // `MetadataPluginConfig.customizationPolicies` (see - // `RETIRED_KEYS_BY_MAJOR[18]`). No code ever consulted a policy. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'kernel/CustomizationPolicy', - // #12340 — kernel/plugin-lifecycle-advanced.zod.ts - // `DistributedStateConfigSchema`, retired whole (ADR-0049 enforce-or-remove). - // - // The orphan value schema of `HotReloadConfig.distributedConfig`, which had - // ZERO readers — measured at cdbd9204b6 with a firing positive control - // (`stateStrategy` resolves to real readers in `core/src/hot-reload.ts`, so - // the scan sees readers; `distributedConfig` resolved to nothing outside - // `packages/spec` itself in objectstack, and to nothing in objectui). An - // author could name a Redis or etcd endpoint, a key prefix, a TTL, auth - // credentials and a replication factor, and NOTHING ever opened a connection - // — the #3950 shape, sharpened by cluster-persistence vocabulary an AI author - // (ADR-0033) reads as proof the capability exists. - // - // It leaves with the enum value it existed for. `HotReloadConfig.stateStrategy` - // narrowed to ['memory','none'] in the same card, because the 'disk' and - // 'distributed' arms of `PluginStateManager.saveState` both wrote to the same - // in-memory Map as 'memory' and said so at DEBUG level only. With - // 'distributed' gone, a key whose own doc comment called it "required when - // stateStrategy is 'distributed'" names a value that no longer exists — it - // could not honestly outlive the narrowing. - // - // This is the #11825 keep, narrowed from inside, NOT reversed: that ruling - // kept `HotReloadConfigSchema` and `HotReloadManager` as a host-driven library - // and they both stand here. What it also listed among the survivors was - // `DistributedStateConfig` — a line this card reverses on new evidence, since - // #11825 measured the CONTAINER's six groups and never this key's own readers. - // The survival pin in `plugin-lifecycle-advanced-retirement.test.ts` moves in - // the same commit, deliberately and with the reasoning recorded there. - // - // Route 3: `HotReloadConfig` is not an authorable surface — no metadata-type - // binding, stack collection or manifest embed ever carried it, and nothing in - // the tree parses `HotReloadConfigSchema` outside its own unit test — so there - // is no authored document for a D2 conversion to rewrite and nobody who could - // receive a parse-time tombstone. This table plus the D3 semantic entry - // `hot-reload-inert-state-strategies-retired` ARE the declaration. - 'kernel/DistributedStateConfig', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the per-field change-tracking record - // (`path`/`originalValue`/`currentValue`) `MetadataOverlay.changes` embedded - // for upgrade conflict detection that never ran. objectui's - // `packages/types/src/views.ts` names it in a comment that itself says 'Do - // not re-point this at it'. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'kernel/FieldChange', - // #11825 — `kernel/GracefulDegradation` left with - // `kernel/AdvancedPluginLifecycleConfig`: its ONLY consumer was the retired - // container's `degradation` key (the #3950 rule — an exported value schema - // with no consumer reads as a capability). Unlike the `health` / `hotReload` - // vocabularies, which survive as the input types of the kept host-driven - // classes, NO implementation body exists for any of its keys — `fallbackMode`, - // `criticalDependencies`, `optionalDependencies`, `degradedFeatures`, - // `autoRecovery` were never read by anything in objectstack or objectui - // (measured with positive controls; the bare-name collisions — - // plugin-ordering's `optionalDependencies`, auth-manager's private - // `degradedFeatures` map — are different surfaces, verified structurally). - // An author declaring a degraded-mode contract got a clean parse and no - // degradation behaviour of any kind. The vocabulary returns only via the - // ENFORCE route of ADR-0049 through a new ADR — the implementation first. - // See `18.kernel__AdvancedPluginLifecycleConfig.ts` for the family record. - 'kernel/GracefulDegradation', - // #16059 — `HealthStatusSchema` (`healthy`, `checkedAt`, `details`, `message`) - // was the return vocabulary of `IStartupOrchestrator.checkHealth(plugin)` and - // the value of `PluginStartupResult.health`. Both carriers left in this same - // major, and no probe system was ever built behind either: the kernel does not - // check a plugin's health at startup, so nothing ever produced a HealthStatus. - // An exported health vocabulary with no producer reads as proof the platform - // health-checks plugins (#3950, ADR-0033). The `health` member of the surviving - // `PluginStartupResult` is tombstoned rather than dropped, because that def - // keeps emitting — see `18.kernel__PluginStartupResult__health.ts`. Route 3; - // the D3 semantic entry `startup-orchestrator-retired` carries the record. - 'kernel/HealthStatus', - // #15932 — ADR-0049 enforce-or-remove (director seat, decision batch #65, - // 2026-09-07, maintainer verbatim 「同意」). `KernelSecurityScanResult` declared a - // complete scan report — timestamp, scanner name/version, a passed/failed/warning - // status, vulnerability and code-issue lists, dependency findings, license - // compliance and a six-number summary — and no layer ever emitted, stored, parsed - // or read one. Its only importer of any kind was `PluginSecurityScanner`, a - // type-only import from `packages/core/src/security/security-scanner.ts`, and - // PR #15930 deleted that file (it closed issue 14919, a number since deleted - // from the board and no longer resolving); the census after it landed put every remaining - // reference inside the declaring module itself, against a lit control (five hits - // for `PluginSecurityManifest` in the same file), so the zero is a reading. - // - // Whole-def retirement, not a tombstone: nothing parses this def, so there is no - // author to hand a prescription to and no `${defKey}:${name}` key leaving a live - // shape. `RETIRED_DEFS_BY_MAJOR[18]` plus the semantic entry - // `plugin-security-scan-result-surface-retired` ARE the declaration — the - // #11825 shape (its sibling in the pair this tree usually cites, issue 8715, is - // a number deleted from the board; 11825 is the half that still resolves). - // The two authorable carriers that pointed here, - // `PluginSecurityManifest.scanResults` and `.vulnerabilities`, are separately - // tombstoned and registered in `RETIRED_KEYS_BY_MAJOR[18]`. - // - // No D2 conversion: a plugin security manifest is a package artifact a publisher - // ships, never a stack collection member and never a stored `sys_metadata` row, - // so the conversion chain has no seam that would see one (the sibling - // `kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime` entry - // records the same reasoning for the same schema). - 'kernel/KernelSecurityScanResult', - // #15932 — ADR-0049 enforce-or-remove (director seat, decision batch #65, - // 2026-09-07, maintainer verbatim 「同意」). The other half of the scan-result - // family: a CVE-shaped vulnerability record (severity, CVSS score, affected and - // fixed versions, exploit/patch availability, remediation, disclosure dates) that - // nothing ever constructed, validated or consulted. It reached this build through - // exactly three referents, all now gone — `KernelSecurityScanResult.vulnerabilities` - // and `.dependencyVulnerabilities[].vulnerability` (that def leaves in the same - // change) and `PluginSecurityManifest.vulnerabilities` (tombstoned). Retired - // together with `KernelSecurityScanResult` because declaring a vulnerability - // vocabulary with no scan to carry it reads as a capability, which is the - // ADR-0049 shape. - // - // Whole-def retirement for the reason its sibling entry records, and the same - // disposition: `RETIRED_DEFS_BY_MAJOR[18]` plus - // `plugin-security-scan-result-surface-retired`, no D2 conversion, no tombstone - // of its own. Its two authorable defaults — - // `KernelSecurityVulnerability:exploitAvailable = false` and - // `:patchAvailable = false` — leave `authorable-defaults/kernel.json` with the - // def; nothing ever parsed this schema, so no released toolchain ever - // materialized either value into an artifact and there is no residue to accept. - // - // ⛔ NOT in scope, and deliberately untouched: the unprefixed - // `SecurityVulnerabilitySchema` / `SecurityScanResultSchema` pair in the sibling - // module `kernel/plugin-security.zod.ts`. Those are separate defs with their own - // self-test and are outside this ruling. - 'kernel/KernelSecurityVulnerability', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the 3-way-merge conflict record (`baseValue`/`incomingValue`/`customValue` - // + suggested resolution). No merge engine ever existed to produce one. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'kernel/MergeConflict', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the 3-way-merge outcome record (merged payload, conflicts, auto-resolved - // stats). No merge engine ever existed to produce one. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'kernel/MergeResult', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the merge-strategy config (`keep-custom`/`accept-incoming`/`three-way- - // merge` + path rules), embedded by the retired authorable key - // `MetadataPluginConfig.mergeStrategy` (see `RETIRED_KEYS_BY_MAJOR[18]`). - // NOT the inline three-value `mergeStrategy` vocabulary on - // `api/PackageUpgradeRequest` / `kernel/UpgradePackageRequest` — separately - // declared twins, untouched. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'kernel/MergeStrategyConfig', - // #14180 — kernel/cluster.zod.ts `MetadataChangeOperationSchema` / - // `MetadataChangeOperation` (the `create` / `update` / `delete` / `publish` - // enum), the orphan value schema of the retired - // `kernel/MetadataChangedEventPayload` def registered beside it: it existed - // only to type that payload's `operation` field and had no other consumer - // anywhere — measured at the retirement's base commit 2cc461030, every hit - // was the payload itself, its own isomorphic alias pin and the generated - // artifacts; nothing in objectui at the pinned sha. An exported value schema - // with no consumer reads as a capability to whoever finds it (#3950), so it - // leaves with the payload (the playbook's orphan-value-schema rule, the - // `kernel/DistributedStateConfig` precedent). Unlike the payload this enum - // serializes, so it WAS in `json-schema.manifest/kernel.json` and the - // manifest deletion gate adjudicates its removal against this entry. Route 3, - // same declaration: this table plus the D3 semantic entry - // `metadata-changed-event-payload-retired`. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. - 'kernel/MetadataChangeOperation', - // #14180 — kernel/cluster.zod.ts `MetadataChangedEventPayloadSchema` / - // `MetadataChangedEventPayload`, retired whole (ADR-0049 enforce-or-remove; - // triage ruling 2026-09-02: remove via the ADR-0087 route, ⛔ not "make a - // consumer" — that is contract growth with no pull). The docblock declared a - // MUST-emit / MUST-subscribe contract for a `metadata:changed` event — `type` - // / `name` / `tenantId` / `version: z.bigint()` / `operation` / - // `correlationId`, readers comparing `version` before invalidating — that - // nothing in the tree ever produced or consumed: zero runtime emitters, zero - // subscribers, zero imports outside `packages/spec` (its own unit test, the - // isomorphic alias pin and the generated artifacts), measured at the - // retirement's base commit 2cc461030 with positive controls in objectstack - // and objectui (pinned sha). It was unenforceable by construction: the - // `bigint` version field cannot cross a JSON transport (the standard - // serializer throws on it) without a codec no pubsub driver ships, so no - // conforming emitter could ever have existed. The three cluster channels that - // DO run — `metadata.changed` (`ClusterMetadataChangedPayload`, - // `@objectstack/metadata`), `metadata.mutated` - // (`ClusterMetadataMutationPayload`, `@objectstack/metadata-protocol`) and - // `datasource.mutated` — all carry an address-only signal whose receiver - // re-reads its own store (ruled 2026-09-01 for the registry lane), the - // opposite of the declared version-compare receipt, so the one plausible - // future consumer was decided against. Never in `json-schema.manifest/` (the - // JSON Schema build skips `bigint`), so the manifest deletion gate has nothing - // to adjudicate for this def; the entry is the declaration the retirement - // route requires. Route 3: not an authorable surface — no metadata-type - // binding, stack collection or manifest embed ever carried it — so no - // tombstone and no D2 conversion; this table plus the D3 semantic entry - // `metadata-changed-event-payload-retired` ARE the declaration. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. - 'kernel/MetadataChangedEventPayload', - // Commit 9e0ba21a1 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on - // #12057: retirement adopted, re-scope rejected; re-chartered, that commit executes - // the widened surface). Part of the whole-module removal of - // `kernel/metadata-customization.zod.ts` — the paper three-layer - // customization protocol ADR-0126 §6 wall 4 supersedes on the record - // ("nothing may build against it"). Zero reachable consumers, re-verified - // at the retirement's base commit with positive controls (#12057 fork - // report): the only implementation was `packages/metadata`'s manager limb, - // served by no route and called only by its own unit tests; the real - // mechanisms are ADR-0005's org overlay and ADR-0126's packaged-metadata - // model. This def: the protocol's core record: a platform/user-scoped JSON-merge-patch delta - // (`baseType`/`baseName`/`scope`/`patch`) with field-level `changes` - // tracking. Only importers were `api/metadata.zod.ts` section 5, - // `contracts/metadata-service.ts`'s optional members, and - // `packages/metadata`'s unreachable manager limb — all removed in the same - // change. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. No carrier key survives for - // these defs and no authored document embedded them, so no tombstone and no - // D2 conversion — this table plus the D3 semantic entry - // `metadata-customization-protocol-retired` ARE the declaration (commit 2c86fe3ea's - // route-3 shape). - 'kernel/MetadataOverlay', - // #11825 — `kernel/PluginUpdateStrategy` left with - // `kernel/AdvancedPluginLifecycleConfig`: its ONLY consumer was the retired - // container's `updates` key (the #3950 rule — an exported value schema with - // no consumer reads as a capability). NO implementation body exists for any - // of its keys — `mode`, `autoUpdateConstraints`, `schedule`, `rollback`, - // `validation` were never read by anything in objectstack or objectui - // (measured with positive controls; `checkCompatibility` on - // `AppLifecycleService` is a different surface, verified structurally). The - // sharpest face: `rollback: { automatic: true, keepVersions: 3 }` promised - // zero-downtime rolling updates and automatic rollback-on-failure that - // nothing implements — production-safety vocabulary an author (very often an - // AI, ADR-0033) reads as proof the capability exists. The vocabulary returns - // only via the ENFORCE route of ADR-0049 through a new ADR — the - // implementation first. See `18.kernel__AdvancedPluginLifecycleConfig.ts` - // for the family record. - 'kernel/PluginUpdateStrategy', - // Commit 0c2334f6c — `kernel/PreviewModeConfig` (the six-key preview/demo config block: - // `autoLogin` default true, `simulatedRole` default 'admin', - // `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`). Its - // only carrier key, `KernelContext.previewMode`, is tombstoned in this same - // major (both walked-shape copies — see the two `…:previewMode` entries in - // RETIRED_KEYS_BY_MAJOR[18]), and an exported value schema with no consumer - // reads as a capability (#3950, the `PerformanceConfigSchema` rule) — so the - // def leaves the emitted set whole, with its `PreviewModeConfig` / - // `PreviewModeConfigParsed` types. Measured before removal: zero consumers of - // the schema or any of its six keys in objectstack, objectui or cloud - // (cloud#1651, 2026-08-26, positive controls on record). The declared - // behaviour (auto-login as a simulated admin, a read-only demo session) was - // never implemented by any layer; preview DEPLOYMENTS belong to the - // deployment layer, whose `OS_PREVIEW_MODE` is routing-only and stays. If a - // preview experience becomes a product capability it re-declares fresh, with - // the production-posture hard-refusal as the first-landed half (commit 0c2334f6c's ruling - // record). - 'kernel/PreviewModeConfig', - // #16059 — `StartupOptionsSchema` (`timeoutMs`, `rollbackOnFailure`, - // `healthCheck`, `parallel`, `context`) left with the `IStartupOrchestrator` - // contract it was the argument of: it was only ever the `options` parameter of - // `orchestrateStartup(plugins, options)`, and nothing in any repository - // implemented or called that method. The kernel's own boot loop reads its - // timeout from `PluginMetadata.startupTimeout` and its rollback policy from - // `KernelConfig.rollbackOnFailure`, never from this object. `healthCheck` named - // a startup probe system that does not exist. Route 3 (no authored document - // carries the def, so no tombstone and no D2 conversion): this table plus the - // D3 semantic entry `startup-orchestrator-retired` ARE the declaration. - 'kernel/StartupOptions', - // #16059 — `StartupOrchestrationResultSchema` (`results`, `totalDurationMs`, - // `allSuccessful`, `rolledBack`) was the aggregate `orchestrateStartup()` - // returned, and it left with that method: no implementation ever existed, so no - // aggregate was ever built. The kernel starts plugins one at a time and returns - // a `PluginStartupResult` per plugin; the per-plugin durations it does keep are - // reachable through `ObjectKernel.getPluginStartupDurations()`. Route 3; the D3 - // semantic entry `startup-orchestrator-retired` carries the record. - 'kernel/StartupOrchestrationResult', - // #13612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-01, director - // batch C: retire; binding was weighed and not adopted). One of the six - // branded identifier schemas of `shared/branded-types.zod.ts`, removed whole - // with the module. No schema in either repo ever composed `AppNameSchema`, - // so the promised brand safety was unobtainable. The real app-name contract - // is the bare `SnakeCaseIdentifierSchema` at `ui/app.zod.ts` — untouched by - // this retirement. No tombstone and no D2 conversion (no authored document - // ever embedded a branded value); this table plus the D3 semantic entry - // `branded-identifier-schemas-retired` are the declaration. - 'shared/AppName', - // #13613 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-01, director - // batch C: retire). `EventNameSchema` presented itself as the platform's - // event-name grammar while nothing that runs consumed it: its only three - // binding schemas (`EventTypeDefinitionSchema.name`, `EventSchema.name`, - // `EventMessageSchema.eventName`) have zero runtime consumers, and the - // vocabulary the platform actually checks is the closed literal enums - // `DataEventType` / `BulkDataEventType` (`api/events.zod.ts`), which never - // referenced it. The three fields stay as plain `z.string()`; the enums are - // the only event-name contract. `WebSocketEventSchema.channel` stays a - // deliberate `z.string()` — the ruling adds no constraint there. No authored - // document is invalidated (the accept set at the three fields widens), so no - // tombstone and no D2 conversion — this table plus the D3 semantic entry - // `event-name-schema-retired` are the declaration (commit 2c86fe3ea's route-3 shape). - 'shared/EventName', - // #13612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-01, director - // batch C: retire; binding was weighed and not adopted). One of the six - // branded identifier schemas of `shared/branded-types.zod.ts`, removed whole - // with the module. No schema in either repo ever composed `FieldNameSchema`, - // so the promised brand safety was unobtainable. The real field-name contract - // is the inline `z.string().regex(/^[a-z_][a-z0-9_]*$/)` at - // `data/field.zod.ts` — untouched by this retirement. No tombstone and no D2 - // conversion (no authored document ever embedded a branded value); this table - // plus the D3 semantic entry `branded-identifier-schemas-retired` are the - // declaration. - 'shared/FieldName', - // #13612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-01, director - // batch C: retire; binding was weighed and not adopted). One of the six - // branded identifier schemas of `shared/branded-types.zod.ts`, removed whole - // with the module. No schema in either repo ever composed `FlowNameSchema`, - // so the promised brand safety was unobtainable. The real flow-name contract - // is the inline `z.string().regex(/^[a-z_][a-z0-9_]*$/)` at - // `automation/flow.zod.ts` — untouched by this retirement. No tombstone and - // no D2 conversion (no authored document ever embedded a branded value); this - // table plus the D3 semantic entry `branded-identifier-schemas-retired` are - // the declaration. - 'shared/FlowName', - // #13612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-01, director - // batch C: retire; binding was weighed and not adopted). One of the six - // branded identifier schemas of `shared/branded-types.zod.ts`, removed whole - // with the module. The brand promised compile-time safety no consumer could - // obtain: no schema in either repo ever composed `ObjectNameSchema`, so - // nothing produced or accepted a branded value. The real object-name contract - // is the inline `z.string().regex(/^[a-z_][a-z0-9_]*$/)` at - // `data/object.zod.ts` — untouched by this retirement (the ruling keeps the - // five surfaces' real validators as the contract of record). No authored - // document ever embedded a branded value, so no tombstone and no D2 - // conversion — this table plus the D3 semantic entry - // `branded-identifier-schemas-retired` are the declaration (commit 2c86fe3ea's - // route-3 shape). - 'shared/ObjectName', - // #13612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-01, director - // batch C: retire; binding was weighed and not adopted). One of the six - // branded identifier schemas of `shared/branded-types.zod.ts`, removed whole - // with the module. No schema in either repo ever composed `RoleNameSchema`, - // so the promised brand safety was unobtainable. The real role/position-name - // contract is the bare `SnakeCaseIdentifierSchema` at - // `identity/position.zod.ts` — untouched by this retirement. No tombstone and - // no D2 conversion (no authored document ever embedded a branded value); this - // table plus the D3 semantic entry `branded-identifier-schemas-retired` are - // the declaration. - 'shared/RoleName', - // #13612 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-01, director - // batch C: retire; binding was weighed and not adopted). One of the six - // branded identifier schemas of `shared/branded-types.zod.ts`, removed whole - // with the module. No schema in either repo ever composed `ViewNameSchema`, - // so the promised brand safety was unobtainable. View names are validated - // where views are declared, not through a brand. No tombstone and no D2 - // conversion (no authored document ever embedded a branded value); this table - // plus the D3 semantic entry `branded-identifier-schemas-retired` are the - // declaration. - 'shared/ViewName', - // #15513 — `system/ChangeImpact` — the impact assessment (`level` / - // `affectedSystems` / `downtime` …, plus the #14477-retired nested - // `downtime.durationMinutes`) — leaves whole with the change-management family - // under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-05 on #15513, - // ruled A: retire the three compliance-shaped families whole; not roadmapped). - // It was exported from `@objectstack/spec/system` - // (`system/change-management.zod.ts`), mounted by no `stack.zod.ts` key, - // registered as no metadata type, absent from the 2026-06 liveness ledgers, and - // read by NOTHING: the reader census over every package outside `packages/spec` - // (tests and changelogs excluded), over `examples/**` and `skills/**`, and over - // objectui at the pinned sha returned zero hits for every one of the family's - // exported names, with a lit control on the same pattern. No change-management - // engine exists on the platform: nothing routed a change request for approval, - // walked its implementation steps, honoured a rollback plan or gated on - // `securityImpact.requiresSecurityApproval` / `approval.required` — both of - // which read as gates the platform enforced, and neither ever did. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `change-management-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/ChangeImpact', - // #15513 — `system/ChangePriority` — the 4-value priority enum — leaves whole - // with the change-management family under ADR-0049 enforce-or-remove (maintainer - // ruling 2026-09-05 on #15513, ruled A: retire the three compliance-shaped - // families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/change-management.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No change-management engine exists on the platform: nothing routed a - // change request for approval, walked its implementation steps, honoured a - // rollback plan or gated on `securityImpact.requiresSecurityApproval` / - // `approval.required` — both of which read as gates the platform enforced, and - // neither ever did. An exported value schema with no consumer reads as a - // capability (#3950); the generated reference docs advertised a compliance - // subsystem that does not exist. No carrier key, so no `retiredKey()` tombstone - // and no D2 conversion (none of these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `change-management-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/ChangePriority', - // #15513 — `system/ChangeRequest` — the change request itself (`id` / `title` / - // `type` / `priority` / `status` / `impact` / `approval` / `implementation` / - // `rollbackPlan` / `securityImpact` …, plus the #14477-retired nested - // `implementation.steps[].estimatedMinutes`) — leaves whole with the - // change-management family under ADR-0049 enforce-or-remove (maintainer ruling - // 2026-09-05 on #15513, ruled A: retire the three compliance-shaped families - // whole; not roadmapped). It was exported from `@objectstack/spec/system` - // (`system/change-management.zod.ts`), mounted by no `stack.zod.ts` key, - // registered as no metadata type, absent from the 2026-06 liveness ledgers, and - // read by NOTHING: the reader census over every package outside `packages/spec` - // (tests and changelogs excluded), over `examples/**` and `skills/**`, and over - // objectui at the pinned sha returned zero hits for every one of the family's - // exported names, with a lit control on the same pattern. No change-management - // engine exists on the platform: nothing routed a change request for approval, - // walked its implementation steps, honoured a rollback plan or gated on - // `securityImpact.requiresSecurityApproval` / `approval.required` — both of - // which read as gates the platform enforced, and neither ever did. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `change-management-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/ChangeRequest', - // #15513 — `system/ChangeStatus` — the 10-value change status enum — leaves - // whole with the change-management family under ADR-0049 enforce-or-remove - // (maintainer ruling 2026-09-05 on #15513, ruled A: retire the three - // compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/change-management.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No change-management engine exists on the platform: nothing routed a - // change request for approval, walked its implementation steps, honoured a - // rollback plan or gated on `securityImpact.requiresSecurityApproval` / - // `approval.required` — both of which read as gates the platform enforced, and - // neither ever did. An exported value schema with no consumer reads as a - // capability (#3950); the generated reference docs advertised a compliance - // subsystem that does not exist. No carrier key, so no `retiredKey()` tombstone - // and no D2 conversion (none of these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `change-management-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/ChangeStatus', - // #15513 — `system/ChangeType` — the 4-value ITIL change type enum — leaves - // whole with the change-management family under ADR-0049 enforce-or-remove - // (maintainer ruling 2026-09-05 on #15513, ruled A: retire the three - // compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/change-management.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No change-management engine exists on the platform: nothing routed a - // change request for approval, walked its implementation steps, honoured a - // rollback plan or gated on `securityImpact.requiresSecurityApproval` / - // `approval.required` — both of which read as gates the platform enforced, and - // neither ever did. An exported value schema with no consumer reads as a - // capability (#3950); the generated reference docs advertised a compliance - // subsystem that does not exist. No carrier key, so no `retiredKey()` tombstone - // and no D2 conversion (none of these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `change-management-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/ChangeType', - // #15513 — `system/Incident` — the incident record itself (id / title / severity - // / category / status / reportedBy / affectedSystems / dataClassification / - // responsePhases / lessonsLearned …) — leaves whole with the incident-response - // family under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-05 on - // #15513, ruled A: retire the three compliance-shaped families whole; not - // roadmapped). It was exported from `@objectstack/spec/system` - // (`system/incident-response.zod.ts`), mounted by no `stack.zod.ts` key, - // registered as no metadata type, absent from the 2026-06 liveness ledgers, and - // read by NOTHING: the reader census over every package outside `packages/spec` - // (tests and changelogs excluded), over `examples/**` and `skills/**`, and over - // objectui at the pinned sha returned zero hits for every one of the family's - // exported names, with a lit control on the same pattern. No incident-response - // engine exists on the platform: nothing classified, tracked, escalated or - // notified an incident, and nothing notified a regulator — an author writing - // `notifyRegulators: true` held a compliance promise the platform never kept, - // with no error and no feedback. An exported value schema with no consumer reads - // as a capability (#3950); the generated reference docs advertised a compliance - // subsystem that does not exist. No carrier key, so no `retiredKey()` tombstone - // and no D2 conversion (none of these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `incident-response-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/Incident', - // #15513 — `system/IncidentCategory` — the 10-value incident category enum — - // leaves whole with the incident-response family under ADR-0049 - // enforce-or-remove (maintainer ruling 2026-09-05 on #15513, ruled A: retire the - // three compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/incident-response.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No incident-response engine exists on the platform: nothing - // classified, tracked, escalated or notified an incident, and nothing notified a - // regulator — an author writing `notifyRegulators: true` held a compliance - // promise the platform never kept, with no error and no feedback. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `incident-response-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/IncidentCategory', - // #15513 — `system/IncidentNotificationMatrix` — the notification-rule matrix - // (`rules[]`, plus the #14477-retired `escalationTimeoutMinutes`) — leaves whole - // with the incident-response family under ADR-0049 enforce-or-remove (maintainer - // ruling 2026-09-05 on #15513, ruled A: retire the three compliance-shaped - // families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/incident-response.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No incident-response engine exists on the platform: nothing - // classified, tracked, escalated or notified an incident, and nothing notified a - // regulator — an author writing `notifyRegulators: true` held a compliance - // promise the platform never kept, with no error and no feedback. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `incident-response-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/IncidentNotificationMatrix', - // #15513 — `system/IncidentNotificationRule` — one severity → channels / - // recipients / `notifyRegulators` rule (plus the #14477-retired `withinMinutes` - // / `regulatorDeadlineHours`) — leaves whole with the incident-response family - // under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-05 on #15513, - // ruled A: retire the three compliance-shaped families whole; not roadmapped). - // It was exported from `@objectstack/spec/system` - // (`system/incident-response.zod.ts`), mounted by no `stack.zod.ts` key, - // registered as no metadata type, absent from the 2026-06 liveness ledgers, and - // read by NOTHING: the reader census over every package outside `packages/spec` - // (tests and changelogs excluded), over `examples/**` and `skills/**`, and over - // objectui at the pinned sha returned zero hits for every one of the family's - // exported names, with a lit control on the same pattern. No incident-response - // engine exists on the platform: nothing classified, tracked, escalated or - // notified an incident, and nothing notified a regulator — an author writing - // `notifyRegulators: true` held a compliance promise the platform never kept, - // with no error and no feedback. An exported value schema with no consumer reads - // as a capability (#3950); the generated reference docs advertised a compliance - // subsystem that does not exist. No carrier key, so no `retiredKey()` tombstone - // and no D2 conversion (none of these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `incident-response-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/IncidentNotificationRule', - // #15513 — `system/IncidentResponsePhase` — one response phase (`phase` / - // `description` / `assignedTo`, plus the #14477-retired `targetHours`) — leaves - // whole with the incident-response family under ADR-0049 enforce-or-remove - // (maintainer ruling 2026-09-05 on #15513, ruled A: retire the three - // compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/incident-response.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No incident-response engine exists on the platform: nothing - // classified, tracked, escalated or notified an incident, and nothing notified a - // regulator — an author writing `notifyRegulators: true` held a compliance - // promise the platform never kept, with no error and no feedback. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `incident-response-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/IncidentResponsePhase', - // #15513 — `system/IncidentResponsePolicy` — the policy document - // (`notificationMatrix` / `defaultResponseTeam` / `requirePostIncidentReview` …, - // plus the #14477-retired `triageDeadlineHours` / `retentionDays`) — leaves - // whole with the incident-response family under ADR-0049 enforce-or-remove - // (maintainer ruling 2026-09-05 on #15513, ruled A: retire the three - // compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/incident-response.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No incident-response engine exists on the platform: nothing - // classified, tracked, escalated or notified an incident, and nothing notified a - // regulator — an author writing `notifyRegulators: true` held a compliance - // promise the platform never kept, with no error and no feedback. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `incident-response-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/IncidentResponsePolicy', - // #15513 — `system/IncidentSeverity` — the 4-value severity enum — leaves whole - // with the incident-response family under ADR-0049 enforce-or-remove (maintainer - // ruling 2026-09-05 on #15513, ruled A: retire the three compliance-shaped - // families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/incident-response.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No incident-response engine exists on the platform: nothing - // classified, tracked, escalated or notified an incident, and nothing notified a - // regulator — an author writing `notifyRegulators: true` held a compliance - // promise the platform never kept, with no error and no feedback. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `incident-response-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/IncidentSeverity', - // #15513 — `system/IncidentStatus` — the 7-value incident status enum — leaves - // whole with the incident-response family under ADR-0049 enforce-or-remove - // (maintainer ruling 2026-09-05 on #15513, ruled A: retire the three - // compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/incident-response.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No incident-response engine exists on the platform: nothing - // classified, tracked, escalated or notified an incident, and nothing notified a - // regulator — an author writing `notifyRegulators: true` held a compliance - // promise the platform never kept, with no error and no feedback. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `incident-response-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/IncidentStatus', - // #15513 — `system/RollbackPlan` — the rollback plan (`description` / `steps[]` - // / `testProcedure` …, plus the #14477-retired nested - // `steps[].estimatedMinutes`) — leaves whole with the change-management family - // under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-05 on #15513, - // ruled A: retire the three compliance-shaped families whole; not roadmapped). - // It was exported from `@objectstack/spec/system` - // (`system/change-management.zod.ts`), mounted by no `stack.zod.ts` key, - // registered as no metadata type, absent from the 2026-06 liveness ledgers, and - // read by NOTHING: the reader census over every package outside `packages/spec` - // (tests and changelogs excluded), over `examples/**` and `skills/**`, and over - // objectui at the pinned sha returned zero hits for every one of the family's - // exported names, with a lit control on the same pattern. No change-management - // engine exists on the platform: nothing routed a change request for approval, - // walked its implementation steps, honoured a rollback plan or gated on - // `securityImpact.requiresSecurityApproval` / `approval.required` — both of - // which read as gates the platform enforced, and neither ever did. An exported - // value schema with no consumer reads as a capability (#3950); the generated - // reference docs advertised a compliance subsystem that does not exist. No - // carrier key, so no `retiredKey()` tombstone and no D2 conversion (none of - // these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `change-management-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/RollbackPlan', - // #15513 — `system/TrainingCategory` — the 8-value training category enum — - // leaves whole with the training family under ADR-0049 enforce-or-remove - // (maintainer ruling 2026-09-05 on #15513, ruled A: retire the three - // compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/training.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No training-management engine exists on the platform: nothing - // assigned a course, tracked a completion, sent a reminder or expired a - // certification — `mandatory: true`, `trackCompletion: true` and `sendReminders: - // true` were declarations nothing ever read. An exported value schema with no - // consumer reads as a capability (#3950); the generated reference docs - // advertised a compliance subsystem that does not exist. No carrier key, so no - // `retiredKey()` tombstone and no D2 conversion (none of these schemas is a - // stack collection member — the `kernel/MetadataPluginConfig:additionalTypes` - // reasoning): RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `training-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/TrainingCategory', - // #15513 — `system/TrainingCompletionStatus` — the 5-value completion status - // enum — leaves whole with the training family under ADR-0049 enforce-or-remove - // (maintainer ruling 2026-09-05 on #15513, ruled A: retire the three - // compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/training.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No training-management engine exists on the platform: nothing - // assigned a course, tracked a completion, sent a reminder or expired a - // certification — `mandatory: true`, `trackCompletion: true` and `sendReminders: - // true` were declarations nothing ever read. An exported value schema with no - // consumer reads as a capability (#3950); the generated reference docs - // advertised a compliance subsystem that does not exist. No carrier key, so no - // `retiredKey()` tombstone and no D2 conversion (none of these schemas is a - // stack collection member — the `kernel/MetadataPluginConfig:additionalTypes` - // reasoning): RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `training-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/TrainingCompletionStatus', - // #15513 — `system/TrainingCourse` — a course (`id` / `title` / `category` / - // `mandatory` / `targetRoles` / `passingScore` …, plus the #14477-retired - // `durationMinutes` / `validityDays`) — leaves whole with the training family - // under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-05 on #15513, - // ruled A: retire the three compliance-shaped families whole; not roadmapped). - // It was exported from `@objectstack/spec/system` (`system/training.zod.ts`), - // mounted by no `stack.zod.ts` key, registered as no metadata type, absent from - // the 2026-06 liveness ledgers, and read by NOTHING: the reader census over - // every package outside `packages/spec` (tests and changelogs excluded), over - // `examples/**` and `skills/**`, and over objectui at the pinned sha returned - // zero hits for every one of the family's exported names, with a lit control on - // the same pattern. No training-management engine exists on the platform: - // nothing assigned a course, tracked a completion, sent a reminder or expired a - // certification — `mandatory: true`, `trackCompletion: true` and `sendReminders: - // true` were declarations nothing ever read. An exported value schema with no - // consumer reads as a capability (#3950); the generated reference docs - // advertised a compliance subsystem that does not exist. No carrier key, so no - // `retiredKey()` tombstone and no D2 conversion (none of these schemas is a - // stack collection member — the `kernel/MetadataPluginConfig:additionalTypes` - // reasoning): RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `training-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/TrainingCourse', - // #15513 — `system/TrainingPlan` — the organisational plan (`courses[]` / - // `trackCompletion` / `sendReminders` …, plus the #14477-retired - // `recertificationIntervalDays` / `gracePeriodDays` / `reminderDaysBefore`) — - // leaves whole with the training family under ADR-0049 enforce-or-remove - // (maintainer ruling 2026-09-05 on #15513, ruled A: retire the three - // compliance-shaped families whole; not roadmapped). It was exported from - // `@objectstack/spec/system` (`system/training.zod.ts`), mounted by no - // `stack.zod.ts` key, registered as no metadata type, absent from the 2026-06 - // liveness ledgers, and read by NOTHING: the reader census over every package - // outside `packages/spec` (tests and changelogs excluded), over `examples/**` - // and `skills/**`, and over objectui at the pinned sha returned zero hits for - // every one of the family's exported names, with a lit control on the same - // pattern. No training-management engine exists on the platform: nothing - // assigned a course, tracked a completion, sent a reminder or expired a - // certification — `mandatory: true`, `trackCompletion: true` and `sendReminders: - // true` were declarations nothing ever read. An exported value schema with no - // consumer reads as a capability (#3950); the generated reference docs - // advertised a compliance subsystem that does not exist. No carrier key, so no - // `retiredKey()` tombstone and no D2 conversion (none of these schemas is a - // stack collection member — the `kernel/MetadataPluginConfig:additionalTypes` - // reasoning): RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry - // `training-family-retired` ARE the declaration. The family's #14477 - // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries stay as history — gate (b2) - // of build-schemas.ts accepts an entry naming a key the build no longer emits. - 'system/TrainingPlan', - // #15513 — `system/TrainingRecord` — one completion record (`courseId` / - // `userId` / `status` / `score` / `completedAt` …) — leaves whole with the - // training family under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-05 - // on #15513, ruled A: retire the three compliance-shaped families whole; not - // roadmapped). It was exported from `@objectstack/spec/system` - // (`system/training.zod.ts`), mounted by no `stack.zod.ts` key, registered as no - // metadata type, absent from the 2026-06 liveness ledgers, and read by NOTHING: - // the reader census over every package outside `packages/spec` (tests and - // changelogs excluded), over `examples/**` and `skills/**`, and over objectui at - // the pinned sha returned zero hits for every one of the family's exported - // names, with a lit control on the same pattern. No training-management engine - // exists on the platform: nothing assigned a course, tracked a completion, sent - // a reminder or expired a certification — `mandatory: true`, `trackCompletion: - // true` and `sendReminders: true` were declarations nothing ever read. An - // exported value schema with no consumer reads as a capability (#3950); the - // generated reference docs advertised a compliance subsystem that does not - // exist. No carrier key, so no `retiredKey()` tombstone and no D2 conversion - // (none of these schemas is a stack collection member — the - // `kernel/MetadataPluginConfig:additionalTypes` reasoning): - // RETIRED_DEFS_BY_MAJOR plus the D3 semantic entry `training-family-retired` ARE - // the declaration. The family's #14477 `RETIRED_KEYS_BY_MAJOR[18]` deadline-key - // entries stay as history — gate (b2) of build-schemas.ts accepts an entry - // naming a key the build no longer emits. - 'system/TrainingRecord', - // #21504 — `ui/AIChatWindowProps` (`mode`, `agentId`, `context`, `aria`) leaves - // with the `ai:chat_window` page element it described (ADR-0049 - // enforce-or-remove; triage ruling 5963897014, the `user:profile` precedent of - // #14159). No renderer for the element ever shipped, so none of the four keys - // was read anywhere. Its only carrier, the `ComponentPropsMap['ai:chat_window']` - // row, is kept as a whole-bag refusal (`retiredComponentProps`) that answers - // with the element's retirement prescription, and the node itself is refused by - // name at `PageComponentSchema.type`. Upgraders get the D3 semantic entry - // `ui-ai-chat-window-retired`. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look. - 'ui/AIChatWindowProps', - // Commit 35ad101bc — `ui/BorderRadius` (the border-radius scale sub-block) left with `ui/Theme`: - // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an - // exported value schema with no consumer reads as a capability). See - // `18.ui__Theme.ts` for the retirement record and the ruling. - 'ui/BorderRadius', - // #11027 — `ui/BreakpointColumnMap` (breakpoint → grid column count, 1-12) - // left with `ui/ResponsiveConfig`: its ONLY consumer was the retired - // `ResponsiveConfigSchema.columns` (the #3950 rule — an exported value schema - // with no consumer reads as a capability). See `18.ui__ResponsiveConfig.ts` - // for the retirement record and the measurement. - 'ui/BreakpointColumnMap', - // #11027 — `ui/BreakpointName` (the Tailwind-style `xs…2xl` breakpoint-name - // enum) left with `ui/ResponsiveConfig`: its only consumers were the retired - // `ResponsiveConfigSchema` (`breakpoint`, `hiddenOn`) and the two retired - // breakpoint maps' key sets (the #3950 rule — an exported value schema with - // no consumer reads as a capability). The surviving `ResponsiveStyles` - // vocabulary is the ADR-0065 max-width buckets (`large`/`medium`/`small`/ - // `xsmall`), a different axis by design. See `18.ui__ResponsiveConfig.ts` for - // the retirement record and the measurement. - 'ui/BreakpointName', - // #11027 — `ui/BreakpointOrderMap` (breakpoint → display order) left with - // `ui/ResponsiveConfig`: its ONLY consumer was the retired - // `ResponsiveConfigSchema.order` (the #3950 rule — an exported value schema - // with no consumer reads as a capability). See `18.ui__ResponsiveConfig.ts` - // for the retirement record and the measurement. - 'ui/BreakpointOrderMap', - // Commit 35ad101bc — `ui/ColorPalette` (the colour palette sub-block) left with `ui/Theme`: - // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an - // exported value schema with no consumer reads as a capability). See - // `18.ui__Theme.ts` for the retirement record and the ruling. - 'ui/ColorPalette', - // `ui/FormFieldPublicPicker` (`displayFields`, `maxResults`, `filter`, - // `object`) leaves with its only carrier, `FormFieldBaseSchema.publicPicker`, - // tombstoned in this same major under ADR-0087 D2 by the maintainer's ruling E - // on #21079: anonymous public forms no longer offer record search, so nothing - // replaces the shape — a fixed choice is a `select` field with static - // `options`, and a record choice belongs on a form behind sign-in. See - // `retired-keys/18.ui__FormField__publicPicker.ts` for the retirement record. - 'ui/FormFieldPublicPicker', - // #11027 — `ui/ResponsiveConfig` (the per-breakpoint LAYOUT block: grid - // columns / visibility / display order on the Tailwind `xs…2xl` axis). Its - // last authorable carrier, `page.components[].responsive`, is tombstoned in - // this same major (ADR-0049 D2; the widget embed went in #4876, the view - // embed in #3896), and an exported value schema with no consumer reads as a - // capability (#3950 rule — the `PerformanceConfigSchema` precedent from the - // very same file). Measured before removal: zero callers of either objectui - // consumer implementation, zero authored instances in either repo. The live - // per-breakpoint channel is `responsiveStyles` (ADR-0065, `ResponsiveStyles`), - // which stays. The Tailwind-style layout vocabulary returns if and when a - // renderer implements it — in one change, with the engine (the #4834 rule). - 'ui/ResponsiveConfig', - // Commit 35ad101bc — `ui/Shadow` (the shadow scale sub-block) left with `ui/Theme`: - // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an - // exported value schema with no consumer reads as a capability). See - // `18.ui__Theme.ts` for the retirement record and the ruling. - 'ui/Shadow', - // Commit 35ad101bc — `ui/theme.zod.ts` `ThemeSchema`, retired whole with its - // `defineStack({ themes })` carrier key (ADR-0049 enforce-or-remove; - // maintainer ruling 2026-08-21, disposition B: 退役授权面 — `app.branding` - // stays the one colour surface; objectui's ThemeEngine/ThemeContext and their - // unit tests are retained). The pipeline was live from the authoring gate - // through artifact ingest and stopped there: zero non-test readers of the - // stored items across core/runtime/rest/services/plugins, `theme` never a - // registered metadata type, no first-party app mounting the spec-aware - // provider, and nothing selecting an active theme — an authored theme shipped - // green and changed nothing on screen. The carrier's strict-parse rejection - // carries the prescription (stack.zod.ts `guidance`); upgraders get the D3 - // semantic entry `stack-themes-carrier-retired`. - // - // Registered under 18, not 17: v17.0.0 was cut before this landed, so the - // removal ships on the 17.x line (launch-window convention: accept-set - // narrowings ride minor releases) and the prescription lives at the major - // boundary where `migrate meta` users look (the #8586 / commit 2c86fe3ea precedent). - 'ui/Theme', - // Commit 35ad101bc — `ui/ThemeMode` (the theme mode enum (light/dark/auto)) left with `ui/Theme`: - // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an - // exported value schema with no consumer reads as a capability). See - // `18.ui__Theme.ts` for the retirement record and the ruling. - 'ui/ThemeMode', - // Commit 35ad101bc — `ui/Typography` (the typography sub-block) left with `ui/Theme`: - // its ONLY consumer was the retired `ThemeSchema` (the #3950 rule — an - // exported value schema with no consumer reads as a capability). See - // `18.ui__Theme.ts` for the retirement record and the ruling. - 'ui/Typography', - // - ], -}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template new file mode 100644 index 00000000000..bd49126d8c6 --- /dev/null +++ b/packages/spec/src/migrations/registry.ts.template @@ -0,0 +1,3450 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The permanent metadata migration chain (ADR-0087 D3). + * + * One {@link MigrationStep} per protocol major that carried a break. Each step's + * mechanical transforms are the D2 conversions that graduated into it (referenced + * by id, so the transform + fixture pair are never duplicated), and its + * `semantic` list carries one entry per retirement family, even when a lossless + * D2 conversion also exists for it — D2 carries the mechanical data repair only. + * + * The chain is a **forever artifact**: every step back to + * {@link MIGRATION_SUPPORT_FLOOR} stays replayable, and CI replays the full chain + * from the oldest supported major's fixtures to current on every release + * (`migrations.test.ts`). The support floor is an explicit release-policy knob — + * how far back `migrate meta --from N` reaches — revisitable per major, never an + * accident of deletion. + * + * ## ⚠️ `registry.ts` is GENERATED WHOLE and git-ignored (#7297, #22554) + * + * `gen:migration-registry` writes it from `./registry.ts.template` (everything + * outside the `` markers) and `./entries/` (each step's + * `semantic` list and the two RETIRED tables: one file per entry, sorted by id), + * on install and as the first step of `build`. Add an entry by adding a FILE + * (`./entries/README.md`); edit anything else in the TEMPLATE. An edit to + * `registry.ts` itself is never committed, and the next generation overwrites it. + * + * They were three hand-authored APPEND tables, and every retirement card + * appended to the same tail line of the same two of them: `step17`'s semantic + * list and `RETIRED_KEYS_BY_MAJOR[17]` conflicted in **6 of 11** contended + * re-merge laps over 2026-08-06..10, for 613 hand-resolved lines of conflict + * markers in four days (#6957's measurement, ruling adopted 2026-08-10). The + * danger was never the wall-clock: **both tables are consumed as sets**, so a + * resolution that drops a sibling's entry produces no error anywhere and the + * retirement it declared silently stops being declared. + * + * Everything OUTSIDE the markers — this header, each step's `rationale` and + * `conversionIds`, and the two tables' load-bearing doc comments — is + * hand-written in the template and merges there as text. Step 18 is shaped for + * that merge (#20535): its `rationale` is fragments kept sorted by key, so two + * retirements insert at different lines ({@link STEP18_RATIONALE}), and its + * `conversionIds` is derived rather than kept as a second copy. + */ + +// A VALUE import for the generated regions: an entry literal below derives its +// group enumeration from this schema's own keys rather than restating them +// (`translation-per-app-settings-platform-only`). Entry files carry their own +// copy of this import, but the generator treats a file's imports as scaffolding +// and concatenates only the literal — so an entry that references a value needs +// that value in scope HERE, hand-written, outside the generated regions. +import { TranslationDataSchema } from '../system/translation.zod.js'; +// The other VALUE import: step 18's `conversionIds` is read off the list the +// loader applies instead of being restated here (see `step18`). +import { CONVERSIONS_BY_MAJOR } from '../conversions/registry.js'; +import type { MigrationStep } from './types.js'; + +/** + * The oldest protocol major the chain guarantees a replayable path from. + * `objectstack migrate meta --from N` supports any `N >= MIGRATION_SUPPORT_FLOOR`. + * A release-policy decision (ADR-0087 D3), not an accident of what still exists. + * + * Raised 10 → 16 by maintainer ruling, 2026-09-18 (#19056), verbatim and + * untranslated: + * + * > 升级只需要支持从 16.0版本开始。 + * + * Raising it is what RETIRES the steps below it: `step11`–`step16` went with + * this move, because a step the floor no longer reaches is replayed by nothing + * and CI stops proving it still works — dead code that reads as a promise. + * `applyMetaMigrations(doc, N)` now throws `MigrationFloorError` for + * N ∈ 10..15; those consumers reach 16 by another path first. + * + * ⚠️ The floor scopes the CHAIN and nothing else. {@link RETIRED_KEYS_BY_MAJOR} + * and {@link RETIRED_DEFS_BY_MAJOR} are keyed by major but are NOT floor-scoped: + * their only consumer (`scripts/build-schemas.ts`) folds every major into one + * set, so a row below the floor is still the live proof that its retirement was + * declared. ⛔ Never drop rows from either table when this constant moves. + */ +export const MIGRATION_SUPPORT_FLOOR = 16; + +/** + * Protocol 17 step. + * + * Mechanical: the three deprecated aliases that a schema transform used to + * fold into a canonical key and drop from the parsed output (`action.execute`, + * `field.conditionalRequired`, `agent.knowledge.topics`) are removed from the + * spec. Each is a pure key rename with an unchanged value, so each replays + * losslessly as a D2 conversion. None of the three carries a D3 entry: this + * step shipped before every retirement family was required to carry one + * (`SemanticMigration`, `./types.ts`), and it was not back-filled when that + * rule landed. The step's `semantic` list is NOT empty — its later retirements + * carry their D3 entries in the generated region below. + * + * The three conversions are `retiredFromLoadPath` from the day they land: 17 + * gives the aliases no acceptance window at all, and each schema tombstones its + * key with a fix-it error instead. This step is what makes that affordable — + * `os migrate meta --from ` lists the consumer's edits site by site rather + * than leaving them to hand-edit against a changelog. + */ +const step17: MigrationStep = { + toMajor: 17, + rationale: + [ + 'Protocol 17 removes the last three deprecated authorable aliases: action ', + '`execute` (use `target`), field `conditionalRequired` (use `requiredWhen`), and ', + 'agent `knowledge.topics` (use `knowledge.sources`). Each was already lowered into ', + 'its canonical key at parse time and dropped from the parsed output, so no runtime ', + 'behaviour changes — only the authorable surface shrinks to one spelling per slot. ', + 'All three are pure key renames with unchanged values and replay losslessly; the ', + 'schemas reject the removed spellings with a fix-it error naming the replacement.\n\n', + 'It also removes the sharing-rule access level `full`: declared as ', + '"Full Access (Transfer, Share, Delete)" but never enforced as anything but ', + '`edit` — both gates matched `edit`/`full` alike, so Setup promised admins a ', + 'delete grant it never issued (ADR-0078). Unlike the OWD `sharingModel: \'full\'` ', + 'alias retired at step 13, this one HAS a lossless target precisely because it ', + 'was inert — old and new shapes are behaviourally identical — so it converts ', + 'mechanically and leaves no semantic residue. It is the one protocol-17 ', + 'conversion that keeps a load-path acceptance window: it had no prior ', + 'deprecation, and a removed enum value cannot carry the fix-it error the three ', + 'key renames tombstone theirs with.\n\n', + 'Finally it removes agent `tools`: the legacy inline ', + '`{type,name,description}[]` fallback, which the runtime resolved against the ', + 'FULL tool registry with no surface check — the one seam that broke ADR-0064\'s ', + '"an agent reaches exactly its surface-compatible skills\' tools, nothing falls ', + 'through to the global registry". Unlike the renames above this has NO lossless ', + 'target: each entry has to become a reference inside a skill, which is a human ', + 'decision about which skill. The conversion therefore drops the dead key (the ', + 'cloud runtime had stopped reading it, so it already contributes nothing) and ', + 'emits a notice per agent so the author knows where capability must be ', + 're-declared; the schema tombstones the key with a fix-it error naming `skills`.\n\n', + 'Beyond those spec-surface removals, it graduates the seven flow-node config key ', + 'aliases the executors still ', + 'tolerated: the CRUD nodes\' `object` (use `objectName`) — the last tenant ', + 'of the `readAliasedConfig` executor shim, which is deleted with it — plus the six ', + 'open-coded fallbacks that never went through that shim: notify `to`/`subject`/', + '`body`/`url` (use `recipients`/`title`/`message`/`actionUrl`) and script ', + '`functionName`/`input` (use `function`/`inputs`). All are pure key renames with ', + 'unchanged values and replay losslessly. Like the sharing-rule access level above ', + 'they keep a load-path acceptance window: none carried a prior deprecation ', + 'warning, and `FlowNodeSchema.config` is an unconstrained record, so no schema ', + 'tombstone can reject them — the conversion layer is the only seam that can ', + 'declare, convert, and retire them.\n\n', + 'The same graduation covers `wait`, whose fallback was not a config-to-config ', + 'rename. `wait` keeps its contract in the declared `waitEventConfig` ', + 'block, not in `config` at all — yet the executor also read six loose `config` ', + 'keys, two of them (`duration`, `signal`) spellings the spec never declared. ', + 'The conversion lifts them onto the declared block in the executor\'s own `??` ', + 'precedence, so a value already declared wins and its loose counterpart is left ', + 'shadowed. One wrinkle makes this a rewrite rather than a delete: ', + '`waitEventConfig.eventType` is required once the block exists, and the loader ', + 'parses the CONVERTED flow — so a source carrying only `config: { duration }` ', + 'is stamped with `eventType: \'timer\'`, the exact default the executor applied ', + 'to that shape. Behaviour-preserving in both directions.\n\n', + '`connector_action` gets the same lift for the opposite reason. Its ', + 'contract also lives in a declared sibling block (`connectorConfig`), and the ', + 'executor never read `config` at all — but the node\'s descriptor published a ', + '`configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys, and ', + 'the Studio inspector derives its form from a published schema, so schema-driven ', + 'authoring wrote the trio to the wrong place and produced nodes that refused to ', + 'dispatch. The conversion lifts the trio onto the declared block (declared keys ', + 'win; a lift that cannot complete the required connectorId+actionId pair leaves ', + 'the node untouched rather than turning a step-time refusal into a load ', + 'failure), and the descriptor stops publishing the mis-rooted schema.\n\n', + 'The reconciliation that found those also found `map`, whose executor read a ', + 'bare `cfg.flowName ?? cfg.flow` for an undeclared `flow` spelling no schema ', + 'ever described. A pure rename, graduated the same way, so the ', + 'executor reads only the canonical `flowName`.\n\n', + 'And it removes the RLS-policy key `priority` (found by the liveness ledger\'s security re-verification): promised ', + '"conflict resolution" that cannot exist, because no outcome depends on an order: ', + 'applicable policies OR-combine on reads, and a write\'s check is chosen once per ', + 'operation across the applicable policies, then OR-combined (see ', + '`RowLevelSecurityPolicySchema.check`) — there is never a conflict to order, and nothing ever ', + 'read the key (call graph closed across the collection site, the projection ', + 'round-trip and the compiler). A pure lossless delete: outcomes are identical ', + 'with or without it; the schema tombstones the key with the same prescription.\n\n', + 'The same close-out retires the four inert tool authoring keys (`category`, ', + '`permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no ', + 'execution path read them. Two were misleading in the dangerous direction — ', + '`permissions` promised an invocation gate nothing enforced, and `active: false` ', + 'read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless ', + 'deletes; the strict ToolSchema rejects each with its prescription.\n\n', + 'The AppSchema sheds its seven dead authoring keys (2026-06 liveness audit, ', + 'the unknown-key strictness wave\'s app step): `version` (apps are versioned by manifest.version), `aria`, ', + '`objects`/`apis` (the self-described "config file convenience" — nothing read ', + 'them; the chatbot derives an app\'s objects from its nav items), `sharing`/', + '`embed` (a declared-but-unenforced public surface — the only live path is ', + 'FormView.sharing; ADR-0049), and `mobileNavigation` (fully unimplemented). ', + 'Pure lossless deletes — none ever had a runtime effect; each key is ', + 'tombstoned with its prescription.\n\n', + 'ADR-0113 splits the `required` tri-binding: post-17, `required` is ONLY the ', + 'write-time contract (insert must provide; update may not null out; legacy null ', + 'rows rest), and the physical NOT NULL is the explicit `storage.notNull`. ', + '⚠️ NOTHING converts the column half for you, and nothing tightens a column you ', + 'already have. A `field-required-notnull-explicit` conversion did stamp ', + '`storage.notNull: true` onto every `required: true` field; it was WITHDRAWN ', + '(maintainer ruling 2026-09-08), because stamping the constraint wherever ', + '`required: true` appears is exactly the implication the ADR abolished — and ', + 'because the artifact-ingestion door replays retired conversions, so the LOADER ', + 'applied it to 17-authored sources that deliberately omit it and then told their ', + 'authors to write the same tightening into the source, which on a populated ', + 'database is a destructive `tighten_not_null` migration prescribed as the remedy ', + 'for a deprecation notice. Post-17 a column is NOT NULL because its author wrote ', + '`storage: { notNull: true }`, and for no other reason. If you are upgrading a ', + 'pre-17 source whose columns ARE NOT NULL and you want them to stay that way, ', + 'add `storage: { notNull: true }` to those fields yourself — deliberately, and ', + 'knowing that doing it to a field whose column is currently nullable is a ', + 'destructive migration with a backfill ceremony.\n\n', + 'On the wire contract it also retires the `/analytics/query` request ENVELOPE ', + 'outright: `AnalyticsQueryRequestSchema` used to describe `{ cube, query: {...}, ', + 'format }` — the dialect of the degraded analytics shim (retired for serving unscoped aggregates) that the ', + 'real engine never understood (an envelope body inferred a column-less cube and ', + 'died as an SQL syntax error). The canonical request body is now the BARE ', + 'AnalyticsQuery — `cube` + `measures` at the top level — which is what every ', + 'real caller already sends; the schema tombstones `query`/`format`, and the ', + 'dispatcher entry validates bodies and answers 400 with the prescription. No ', + 'stored metadata carries this shape (it was HTTP-only), so the change is two ', + 'semantic TODOs for API callers rather than a stack conversion.\n\n', + 'The close-out sweep finishes the enforce-or-remove worklist across the ', + 'remaining types: action `shortcut`/`bulkEnabled` (no keydown path; the ', + "multi-select toolbar reads the view's bulkActions), flow `active`/`template`/", + 'node `outputSchema`/errorHandling `fallbackNodeId` (`active: false` never ', + 'stopped a flow — `status` is the enforced lifecycle; faults route via ', + 'per-node fault edges), the inert view keys (list `responsive`/`performance`, ', + 'form `data`/`defaultSort`/`aria` — list aria/data stay live), dashboard and ', + 'widget `aria`/`performance`, `agent.knowledge` (declaring sources never ', + 'scoped retrieval — absorbs the former topics→sources rename), and ', + "`skill.triggerPhrases` (phrases were never matched; routing is ", + 'triggerConditions + the agent allowlist). All pure lossless deletes, each ', + 'tombstoned at its schema with the prescription.\n\n', + 'One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` ', + '— it carried two defaults: `.default(0)` in FlowSchema and ', + "`maxRetries ?? 3` in the engine's retryExecution — and because `??` fires ", + 'only on `undefined`, an unstated count meant 0 retries for a flow parsed by ', + 'the schema and 3 for a definition handed to the engine directly: the retry ', + "count was a function of the route in, not of the authored flow. The engine's ", + 'copy is deleted (it reads the parsed block, no fallback), which makes an ', + "unstated count unambiguously 0 — and `strategy: 'retry'` that retries zero ", + "times is `strategy: 'fail'` under another name, the declared-not-delivered ", + 'shape ADR-0049 exists to close. The schema therefore requires `maxRetries` ', + "at least 1 under `'retry'`, in both spellings (omitted, and an explicit 0). ", + 'This is the one v17 flow change the chain cannot apply for you: choosing the ', + 'count is a judgment about re-running the WHOLE flow with its side effects, ', + 'so it is a semantic TODO rather than a rewrite.\n\n', + 'It also retires `api.requireAuth`: the deployment-wide opt-out that let a ', + 'stack serve its ENTIRE data plane anonymously with one boolean. Auth is a kernel ', + 'concern, not a deployment posture — anonymous access to object data is now denied ', + 'unconditionally on every HTTP surface. Every surface that legitimately serves a ', + 'session-less caller derives its own narrow authorization from a DECLARATION instead: ', + 'the control-plane allowlist, `publicFormGrant` (public form views), share-link tokens ', + "(read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped ", + 'with a notice rather than mapped — there is no replacement value, only a different ', + 'way to publish (by declaration). A stack that mounts no auth at all now fails at boot ', + 'when it would serve a data API, instead of receiving an implicit fail-open.\n\n', + 'The same major retires `BatchOptions.validateOnly`: a batch "dry-run" flag that ', + 'was declared but never implemented — every batch surface (`updateManyData` / ', + '`deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a ', + 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag ', + 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the ', + 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the ', + 'change is one semantic TODO for API callers rather than a stack conversion.\n\n', + 'The batch response rows converge on their declared schema in the same window: ', + 'the per-row results of `/batch`, `/updateMany` and `/deleteMany` used to carry a legacy ', + 'implementation shape — `error: string`, `record`, no `index` — while ', + '`BatchOperationResultSchema`, the published client SDK type and the reference docs all ', + 'declared `errors: ApiError[]` / `data` / `index`. A consumer written against the ', + 'declaration read `row.errors` and got `undefined` at runtime — the exact "photographed ', + 'from the schema, dead on the wire" failure the enhanced-api-error rename above records. ', + 'The wire now delivers the declared shape, and the ADR-0119 D4 rollback marking is ', + 'structured with it: `ROLLED_BACK:` / `NOT_ATTEMPTED:` message prefixes become ', + 'first-class `ApiError.code` values, so clients branch on `errors[0].code` instead of ', + 'regexing message strings. A RESPONSE surface, never stored in stack metadata, so there ', + 'is no source for the chain to rewrite — one semantic TODO for readers of the old row ', + 'keys.\n\n', + 'It also narrows `QueryAST.fields` to field names: the `FieldNode` union carried a ', + 'second `{ field, fields, alias }` nested-select member that nothing produced and nothing ', + 'consumed — every reader on the path treats the list as `string[]`, so the object form was ', + 'dropped by the SQL and memory drivers, projected as a column named "[object Object]" by ', + 'MongoDB, and refused by the REST ingress as an unknown field of that name. `expand` is the ', + 'one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one ', + 'capability, one contract). Like the two above it is a request shape, never stored, so the ', + 'chain has no source to rewrite.\n\n', + 'The `QueryAST` liveness sweep applies the same method to the rest of the request surface: `query.joins` ', + 'and `query.windowFunctions` are tombstoned — no engine or driver ever read either on the ', + 'query path, so every join and OVER clause a caller declared was silently dropped. Joins ', + 'were the second, broken spelling of related-record retrieval (`expand` is the live one; ', + 'the whole JoinNode cluster goes with the key), and window functions only ever ran behind ', + '`SqlDriver.findWithWindowFunctions()`, a driver-level door whose flat input shape the ', + 'spec vocabulary never matched (it declared `field`/`over`/`frame` members the door never ', + 'read — that cluster goes too). Request shapes again: two semantic TODOs, no source ', + 'rewrite.\n\n', + 'That sweep\'s close-out settles the remaining three. `having` is ENFORCED, not removed — ', + 'the engine applies it after aggregation on both paths, so the clause every SQL-literate ', + 'author expects now works (no migration; queries that carried it were silently returning ', + 'every group and now filter as written). `cursor` and `distinct` are tombstoned WITH ', + 'their shipped SDK producers (`QueryBuilder.cursor()` / `.distinct()` are deleted): no ', + 'driver ever implemented keyset pagination or SELECT DISTINCT, `cursor` re-served page 1 ', + "forever, and `distinct`'s only observable effect was mis-wired — it suppressed the REST ", + 'list count, which is now truthful again. Both are request shapes; two more semantic ', + 'TODOs, no source rewrite.\n\n', + 'The same kind of retirement covers `wait`\'s timeout pair. `waitEventConfig.onTimeout` ', + 'had ZERO readers — no path ever inspected it, so neither `fail` nor `continue` ever ', + 'happened, while its `.default(\'fail\')` stamped a decision nothing made onto every wait ', + 'node. `waitEventConfig.timeoutMs` said "maximum wait time before timeout" and its only ', + 'reader used it as the timer DURATION when `timerDuration` was absent: it did something, ', + 'just not what it said. Together they declared a timeout `wait` does not have — the run ', + 'resumes when its timer elapses or its signal arrives, never on a deadline. Rather than ', + 'retrofit an implementation to fit two keys that happened to be declared, the pair is ', + 'retired and real timeout semantics are left to be built to a requirement. `timeoutMs` ', + 'converts to `timerDuration` (stringified — the target is `z.string()` and ', + '`parseIsoDuration` reads a bare numeric string as milliseconds, so the wait is unchanged); ', + 'with `timerDuration` already set it is dropped, having been dead metadata. Like the other ', + 'keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the ', + 'load path: absorbing them silently would let an author keep believing they configured a ', + 'timeout.\n\n', + 'Closing the same audit on the data side, `datasource.readReplicas` is removed. ', + 'It described replica connections nothing ever opened: `ConnectableDatasource` and ', + '`DatasourceConnectionSpec` carry no replicas field, the driver factory never reads the ', + 'key, and no query path distinguishes a read from a write — read/write splitting does not ', + 'exist in the platform, so every statement always went to the primary. A lossless delete ', + 'with no target to move to; front replicas behind one endpoint (pgpool, ProxySQL, an RDS ', + 'reader endpoint) and point `config` at it. Notable as the case that shows how a key gets ', + 'MORE convincing as it stays dead: the change that closed the datasource-config gap taught the ', + 'schema to validate each replica entry against the declared driver\'s config contract, so ', + 'sources written in between carry replica blocks that were genuinely checked — precise ', + 'hosts, correct port types, typos rejected. Precision applied to an inert slot reads as ', + 'evidence the slot is live, which is why ADR-0049 asks for a consumer rather than for ', + 'rigor. Retired from the load path with the rest of the keys that misdescribed themselves.\n\n', + 'The datasource close-out also graduates the four legacy `datasource.config` spellings the ', + 'shared driver factory still tolerated via undeclared read-side `??` fallbacks (a follow-up to that ', + 'validation change): sqlite `file`/`database` (use `filename`), postgres/mysql ', + '`connectionString` (use `url`) and `user` (use `username`), and mongo `uri` (use `url`) ', + 'and `user` (use `username`). That validation change made the authoring gate reject each with a rename ', + 'hint, but a runtime datasource persisted in `sys_metadata` before the gate kept working ', + 'only because the factory read leniently — and deleting that tolerance without a ', + 'conversion would have silently moved data (a stored sqlite `file:` row falls back to ', + '`:memory:`). The `datasource-config-driver-key-aliases` conversion rewrites the stored ', + 'shape to the canonical keys at every rehydration seam, the factory now reads exactly one ', + 'spelling per key, and the four `??` chains are deleted. Driver-aware by construction: ', + '`database` renames only under sqlite, where it aliased the file path — for every other ', + 'driver it is a canonical key and is untouched. Retired from the load path not for lying ', + 'but because the authoring gate already rejects the spellings loudly; the chain and the ', + 'stored-row replay are the seams that accept them.\n\n', + 'Finishing the same datasource surface, the canonical driver id `mongo` is renamed to ', + '`mongodb`. The two spellings have both been accepted since `datasource.config` was first ', + 'parsed against its driver\'s own contract, and both still ', + 'are, so no boot breaks and no data moves — what changed is which one is CANONICAL, and ', + 'that string is published as `DRIVER_CATALOG.id` and is what the Studio connection form ', + 'writes into `datasource.driver`. Every row written before the rename therefore carries ', + '`mongo` while the form now emits `mongodb`, leaving one deployment with two spellings of ', + 'one driver and any reader that matches a stored driver against the published catalog id ', + 'silently missing the older rows. The `datasource-driver-mongo-to-mongodb` conversion ', + 'converges the stored value at every rehydration seam; it stays on the LIVE load path ', + '(unlike the config-key aliases beside it) precisely because `mongo` is still legal — ', + 'there is no loud rejection for it to pre-empt, and nothing to lose by converging early. ', + 'The rename is what let the driver-selection id and the config-contract id become one ', + 'string: `packages/spec`\'s driver vocabulary is now a single table both boot hosts read, ', + 'which closed the last fork where `OS_DATABASE_DRIVER=pg` booted under `os start` and was ', + 'refused by `os migrate`. `turso`/libSQL joins the same table with a real config contract, ', + 'so a libSQL `config` is validated instead of waved through.\n\n', + 'The `script` flow node converges on its one real path. It had four ways to name ', + 'what it ran and only one of them ran anything: `config.actionType: \'email\' | \'slack\'` ', + 'were logger-backed stubs that wrote a line, reported success and delivered nothing under ', + 'any configuration — with `config.template` / `.recipients` / `.variables` feeding a ', + 'message no channel ever sent; inline `config.script` was recognized and never executed ', + '(the built-in runtime has no server-side JS sandbox), so the node warned and no-op\'d; and ', + 'every other `actionType` value was shorthand for a registered-function name, a second ', + 'spelling of `config.function`. All five keys are retired and `function` becomes required, ', + 'which is also what finally made the contract PARSEABLE: while the legal key set depended ', + 'on `actionType`, a flat parse would either reject valid shapes or wave everything through, ', + 'so `script` (with `subflow`) now runs through the same execute-time config `parse()` the executor wiring ', + 'gave the flat builtins. A shorthand `actionType` CONVERTS into `function` — that is what ', + 'it meant — unless `function` is already set, in which case it was dead metadata the ', + 'executor never reached. The other four are dropped outright: nothing read them, so there ', + 'is no value to preserve, and rebuilding the intent is an authoring decision the tombstones ', + 'prescribe per branch (a `notify` node for mail — it delivers through the messaging ', + 'service, the in-app inbox by default and real email once `@objectstack/plugin-email` is ', + 'installed; a `connector_action` with the Slack connector, or an `http` node posting to a ', + 'webhook, for Slack; a registered function for an inline body). Retired from the load path ', + 'for the same reason as the rest: absorbing `actionType: \'email\'` silently would let an ', + 'author keep believing the flow sends mail.\n\n', + 'The same audit reaches the driver contract itself: `IDataDriver.findStream` is removed ', + 'from the contract. It was REQUIRED — every driver and every test double had to implement it — and ', + 'documented as the read "optimized for large datasets to avoid memory overflow", while ', + 'two of its three implementations awaited `find()` for the whole result set and then ', + 'yielded it row by row, reaching exactly the peak it promised to avoid; the third ', + 'streamed for real but was the one read in that driver that skipped `buildFindOptions`, ', + 'so it dropped `query.fields`. Nothing anywhere called it, which is why a contract ', + 'method could carry an inverted guarantee for this long and why ~20 test doubles could ', + 'satisfy it by throwing `not implemented`. Paged `find()` is the read that exists and is ', + 'enforced (its total-order guarantee is checked by the shared pagination-conformance ', + 'cases); a cursor-based read is worth building when a caller asks for one, which is the ', + 'honest order. A TS/API surface, never stored — one semantic TODO for driver authors, no ', + 'source rewrite, and no tombstone: `DriverInterfaceSchema` describes a contract that ', + 'code IMPLEMENTS and nothing ever `.parse()`d a driver, so tsc is the only channel that ', + 'could carry the prescription, and it carries it where it matters — at a call site.\n\n', + 'Separately, `object.managedBy: \'system\'` is retired in favour of `\'system-data\'`, ', + 'finishing the split ADR-0103 began in v16. That split was deliberately ADDITIVE: the 20 ', + 'engine-owned objects moved to the new explicit `engine-owned`, and the 8 admin/user-', + 'writable ones — the RBAC link tables, `sys_user_preference`, the three messaging config ', + 'grids — stayed behind on `system`. What was left is a value whose name describes the half ', + 'that had already moved out: "system" sitting on precisely the objects a user writes. That ', + 'is not a cosmetic complaint. An author choosing between `system` and `engine-owned` had ', + 'nothing in the vocabulary to choose on, so the bucket was re-overloadable by anyone ', + 'reading the name in good faith — a model author most of all. `system-data` states both ', + 'boundaries: the SCHEMA is the platform\'s (versus `platform`, which is tenant-modelled), ', + 'the DATA is the admin\'s or the user\'s (versus `engine-owned`, where the engine owns both). ', + 'Reusing `config` was considered and rejected — `sys_user_preference` is user-owned rather ', + 'than admin-authored, and `config` suppresses CSV import — as was `platform-data`, which ', + 'sits one word away from the unrelated `platform` in the same closed enum and would ', + 'reintroduce the confusion at the point of choosing. Because v16 already drained the ', + 'engine side, the conversion is a ONE-TO-ONE mechanical value rename with no judgement ', + 'call. One deliberate consequence: `system` defaulted LOCKED and each object re-opened its ', + 'writes through `userActions`, while `system-data` defaults WRITABLE on create, edit, ', + 'delete and exportCsv, so those blocks become redundant and are deleted (keep ', + '`userActions` only to NARROW). CSV `import` is the one verb that default deliberately ', + 'withholds (maintainer ruling 2026-08-03): it stays opt-in per object via `userActions: { import: true }`, so a ', + 'v16 `system` object — which resolved `import: false`, because the re-open blocks only ', + 'ever named create/edit/delete — keeps resolving `import: false` after the rename. The ', + 'reason is leverage, not authorization: three of the eight members are the RBAC link ', + 'tables, and a bulk-grant entry point on the permission model\'s grant surface should be ', + 'a per-object declaration rather than something inherited by being filed in the right ', + 'bucket. No enforcement ', + 'moves — the engine write guard, the DelegatedAdminGate, RLS and permission sets all ', + 'adjudicate off resolved affordances and the principal, never off the bucket name; ', + '`system-data` simply joins `platform`/`config` as a bucket the guard does not cover, ', + 'because a writable default has nothing to fail closed on. Retired from the load path: ', + 'the enum rejection is what teaches the new spelling, and absorbing `\'system\'` silently at ', + 'load would leave every author writing the name this rename exists to retire.\n\n', + 'Finally, five keys retire because the advisory lint could never have warned about them ', + '— mapping `extractQuery` / `errorPolicy` / `batchSize`, and app ', + '`contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, ', + 'and a default materialises at parse time — so the liveness lint cannot tell a value the ', + 'author wrote from one the schema supplied, and marking them would have warned on every ', + 'mapping and every selector in existence. For a key in that state removal is not the ', + 'escalation after a warning; it is the only channel that ever reaches the author, which ', + 'is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What ', + 'they claimed: `extractQuery` promised an export path no exporter implements (exports go ', + 'through the ordinary query API); `errorPolicy` offered skip/abort/retry where error ', + 'handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes ', + 'itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth ', + 'reading twice — it was not unread but deliberately DISOBEYED, because context selectors ', + 'are mandatory-scope and an "All" row would clear the scope: on Studio\'s package selector ', + 'that means listing the platform\'s own system/cloud kernel packages to a developer who ', + 'scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ', + 'ignored it. The mapping prescription for `batchSize` deliberately offers no rename: ', + 'bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all ', + 'live, but each is a different key sizing its own path — the same trap `datasource.', + 'retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.\n\n', + 'The sharpest removal in this step is two keys wide: `app.areas[].visible` and ', + '`app.areas[].requiredPermissions`. Read the class before the count — these were ', + 'not inert authoring keys but FAIL-OPEN access gates. At the time of the retirement the ', + 'server-side authority (`filterAppForUser`) checked the app\'s `requiredPermissions` and ', + 'then walked ONLY the top-level `navigation` tree; it never read `item.areas` at all, and ', + 'the client rendered every area in the switcher. So an author writing ', + '`requiredPermissions: [\'sales.admin\']` on an area got a clean parse, a stored value, and ', + 'an area visible to everybody — and had every reason to believe otherwise, because the SAME ', + 'key names are genuinely enforced one ', + 'level up and one level down: app-level `requiredPermissions` drops the whole app ', + 'server-side, and a navigation ITEM\'s `requiredPermissions` / `requiresService` are ', + 'stripped server-side and re-checked in the shell, whose item-level `visible` is a real ', + 'CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was ', + 'weighed and deliberately not taken here: it needs semantics decided first (does ', + 'filtering an area remove its items everywhere? does the server bind `user` for area ', + 'CEL?), and a retirement must not invent an authorization mechanism — while shipping a ', + 'major with the gate still declared would have kept authors writing it for all of 17.x. ', + 'The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading ', + 'author has to re-decide is only where the gate really goes: onto the items inside the ', + 'area, or onto the app — and BOTH of those destinations are server-enforced. The caveat ', + 'this prescription used to carry, that per-item gating INSIDE an area was enforced by the ', + 'shell only because the server did not walk `areas`, was CLOSED by a server-side fix inside this same ', + '17.0.0 window: `filterAppForUser` now runs the SAME `filterNav` over every ', + '`areas[].navigation`, so an ITEM\'s `requiredPermissions` / `requiresService` is stripped ', + 'server-side in BOTH trees and a gated entry never ships in the `/meta` body at all. Read ', + 'that as the boundary closing, NOT as the area-LEVEL keys coming back: those stay retired ', + 'and that fix gave an area no gate of its own — what it enforces are the items inside one. ', + 'The other half of the asymmetry is unchanged and is why `requiredPermissions` is the key ', + 'to reach for: `visible` (CEL) and `requiresObject` are still evaluated client-side ONLY ', + 'at every level, because server-side CEL needs a bound `user` context the read layer does ', + 'not have — so anything that must never reach the browser goes in `requiredPermissions`, ', + 'never in `visible`.\n\n', + 'The same window converges the retry policy. `@objectstack/spec/automation` and ', + '`@objectstack/spec/system` each exported a `RetryPolicy`/`RetryPolicySchema` resolving ', + 'to a DIFFERENT declaration, so which shape a consumer got depended only on the import ', + 'path — yet both computed `delay = base * multiplier^(retry-1)` and both ', + 'executors implemented that same formula. One declaration now serves both entries with ', + 'the union of their capabilities, so `job.retryPolicy` gains the `maxRetryDelayMs` ceiling ', + 'and `jitter` (both enforced in `runWithPolicy`, not merely declared — jitter is what stops ', + 'a fleet of jobs that failed on one outage from retrying in lockstep). The single ', + 'authorable casualty is the automation spelling of the base delay: `retryDelayMs` → ', + '`backoffMs`, a pure rename that replays losslessly and is what the already-enforced ', + 'retry policies (`job.retryPolicy`, `hook.retryPolicy`) call it.\n\n', + 'The subtle half is the defaults, and it is worth stating because no gate can see it: ', + '`job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` while the automation ', + 'shape defaulted 0 / 1, and the authorable-surface gate compares KEY SETS — a changed ', + 'default is invisible to it, to the tombstone mechanism and to `spec_changes` alike. The ', + 'merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in — the same ', + 'reading already recorded in `flow-retry-max-retries-required`), and the conversion writes ', + 'the pre-17 numbers into every existing `job.retryPolicy` that omitted them. Deployed ', + 'stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored ', + 'omission means.\n\n', + 'That convergence then had to be finished twice more, and WHY it was incomplete is the ', + 'part worth carrying forward. It was driven by the dual-source instrument, ', + 'which asks "how many declarations publish the same exported NAME?" — so it could not see ', + 'the two encodings of the identical policy that have no exported name at all, being ', + 'anonymous inline blocks nested in a bigger schema: `flow.errorHandling` and ', + '`ETLPipeline.retry`. The instrument was not broken and answered its own question exactly; ', + 'that question was simply not "how many shapes does this ONE concept have?", which is what ', + 'everybody read off it. The cost of the gap is concrete and falls on the author who did the ', + 'right thing: `shared/retry-policy.zod.ts` tombstoned `retryDelayMs` and told them to write ', + '`backoffMs`, and `flow.errorHandling` then rejected `backoffMs` and demanded `retryDelayMs` ', + '— reading the newer file was punished. Both blocks now build from one shared shape. ', + '`flow.errorHandling` costs nothing beyond the same `retryDelayMs` → `backoffMs` rename ', + '(every other key, bound and default already matched, which is exactly why it looked ', + 'reviewed), and the conversion covers it. `ETLPipeline.retry` costs a rename of the COUNT ', + '— `maxAttempts` → `maxRetries`, same number, do NOT subtract one: that adjustment belongs ', + "to `integration/connector.zod.ts`'s identically-spelled `RetryConfig.maxAttempts`, which ", + 'INCLUDES the first attempt — plus the same default flip (3 → 0) and three keys it never ', + 'had (`backoffMultiplier` / `maxRetryDelayMs` / `jitter`, so a nightly warehouse pipeline ', + 'can stop retrying flat, uncapped and unjittered every 60s). The ETL half gets a tombstone ', + 'and no conversion step, deliberately: an ETL pipeline is not a `defineStack` collection ', + 'and `etl.zod.ts` has no parse site in any of the three repos, so there is no stored ', + 'document to walk and a step for it would advertise coverage it does not have. Nothing ', + 'deployed moves; the migration surface is empty and this is the cheapest this convergence ', + 'will ever be.\n\n', + 'The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops ', + '`data.field.changed`. It had no producer anywhere — the engine emits ', + '`data.record.{created,updated,deleted}` and, since predicate writes got their own bulk event, `data.records.{updated,', + 'deleted}` — so a subscriber switching on it held a branch that could never run, and ', + 'the `switch` still compiled, which is why an empty member could sit in a public enum ', + 'this long. It could not have been implemented against this contract as written: ', + '`DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, ', + 'so the member advertised a granularity the payload has no room for. Nothing is lost — ', + 'per-field detail already rides on `data.record.updated` as `changes` (with `before` / ', + '`after`), one event per write instead of N on a wide table. Like the driver contract ', + 'above it is a runtime surface, never stored in stack metadata, so it is one semantic ', + 'TODO for event consumers rather than a source rewrite, and it carries no tombstone: a ', + 'removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` ', + 'retirement noted. Should a real per-field stream ever be wanted, it earns its own ', + 'contract, as the bulk `data.records.*` events did, rather than reclaiming this slot.\n\n', + 'The object capability block closes out the same ADR-0049 pass: `enable.trash` and ', + '`enable.mru` left the schema in the 16.x line (the close-out of the 11.0 dead-property removals — every ', + 'delete has always been a hard delete and MRU tracking was never implemented, so both ', + 'default-true flags gated nothing), and the `.strict()` capabilities block rejects them ', + 'with the prescription. This step registers the migration surface that removal was ', + 'missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, ', + 'and `os migrate meta --from 16` lists the mechanical edits for authored sources. Soft delete stays parked at ', + 'the proposal stage; if built it returns as a live enforced flag rather than by reviving these keys.\n\n', + 'The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block: ', + '`OpenApi31ExtensionsSchema` (`webhooks` / `callbacks` / `jsonSchemaDialect` / ', + '`pathItemReferences`) with `OpenApiWebhookEventSchema` and `CallbackSchema` under it. ', + "Declared-but-unenforced end to end: the REST server's `normalizeConfig` forwards only ", + '`api`/`crud`/`metadata`/`batch`/`routes`, the served /openapi.json is the pre-generated ', + 'contract enriched with the live server URL and registered objects, and `gen:openapi` ', + 'never read a webhook or callback — so a definition authored under `openApi31.webhooks` ', + 'never appeared in any served document, and zero import-level consumers existed across ', + 'objectstack / cloud / objectui. `RestServerConfig` is plugin TS configuration (the REST ', + 'plugin constructor / `plugin-hono-server` `restConfig`), never a stored metadata shape: ', + "the stack tree's own `api` block declares only its four scoping/auth knobs, so no ", + '`sys_metadata` row can carry `openApi31` and there is no source for the chain to ', + 'rewrite — one semantic TODO for config authors rather than a stack conversion, the ', + '`validateOnly` shape. The key itself is tombstoned (the schema is not `.strict()`; a ', + 'plain delete would strip it silently), and a config-driven webhooks/callbacks synthesis, ', + 'if ever wanted, returns via the enforce route of ADR-0049 through a new ADR.\n\n', + 'The same pass closes `activationEvents`: both keys that carried it — ', + '`DynamicLoadRequest.activationEvents` on the kernel side and ', + '`StudioPluginManifest.activationEvents` on the studio side — declared lazy plugin ', + 'activation ("plugins remain dormant until an activation event fires") that no runtime ', + 'in any repo ever implemented: every plugin has always activated immediately on ', + "load/registration, and cloud-v1's own ROADMAP recorded the capability as ", + 'unimplemented, planned for v0.4.0. A dual-source cleanup had just converged the two ', + '`ActivationEventSchema` declarations onto one structured `{ type, pattern }` ', + "vocabulary in this same unreleased major; with the maintainer's enforce-or-remove ", + 'ruling landing on REMOVE, that converged vocabulary retires before ever shipping — ', + 'composed across the two changes, a v16 author simply deletes the key in whichever ', + 'form they carried. Neither parent is stored metadata (`StudioPluginManifest` is TS ', + 'configuration parsed by `defineStudioPlugin`; `DynamicLoadRequest` is a runtime ', + 'request shape with no caller in any repo), so there is no source for the chain to ', + 'rewrite — one semantic TODO, the `validateOnly` shape. The kernel key is tombstoned ', + '(its schema is not `.strict()`; a plain delete would strip it silently), the studio ', + 'key is rejected by the strict manifest parse with its own guidance prescription, and ', + 'the orphaned `ActivationEventSchema` def is removed with them. Behaviour is ', + 'byte-identical: eager activation was always the only behaviour.\n\n', + 'That kernel-side tombstone was then SUPERSEDED inside the same unreleased major by ', + 'a removal that finished the enforce-or-remove pass one level up: the entire ', + '`plugin-runtime.zod` family — `DynamicLoadRequest`, `DynamicUnloadRequest`, ', + '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation` — is removed, because ', + 'the "Dynamic Loading" capability it described (runtime load / unload / reload without ', + 'a kernel restart, with sandboxing, integrity hashes, drain strategies and ', + 'dependent-cascade policy) has no server anywhere: no runtime in objectstack, cloud or ', + 'objectui ever received one of these requests or produced one of these results. An earlier removal from that module ', + 'had suspended the call on these five deliberately — "operation contracts, not security ', + 'promises" — in a changeset paragraph no issue carried; this removal is that decision, ', + 'answered REMOVE. So a v16 author who wrote `activationEvents` inside a ', + '`DynamicLoadRequest` value does not delete a key: the whole value has no shape and no ', + 'recipient, and importing `DynamicLoadRequestSchema` at all is TS2305 in v17. The ', + 'studio half of the `activationEvents` retirement is untouched and still rejects the ', + 'key with its own prescription — `defineStudioPlugin` remains a live authoring surface. ', + 'Behaviour is again byte-identical: nothing ever executed a dynamic plugin operation.\n\n', + "Finally it removes the script-body capability token 'crypto.hash'. Four layers ", + 'declared it — the `HookBodyCapability` enum, the doc table beside it, the CLI extractor ', + 'and `ScriptContext.crypto.hash` — and none implemented it: `installCtx` wired only ', + '`randomUUID`, so the one call the token authorised threw inside the VM every time. The ', + 'build-time inference made it worse than an ordinary declared-but-unenforced key: writing ', + '`ctx.crypto.hash(...)` made the CLI ADD the capability for you, so `os build` went green ', + 'on the body that was guaranteed to fail at the first record write. Removed rather than ', + 'implemented (ADR-0049) — hashing inside the sandbox widens its capability and ', + 'security-review surface, and a capability that throws on every use yet drew zero ', + 'complaints in its whole life is its own liveness verdict. This is an enum VALUE, not a ', + 'key, so there is no `retiredKey()` tombstone: the enum error map carries the ', + 'prescription, keyed on the received value so that only the spelling which used to be ', + 'legal is told it "was removed". The conversion strips the dead token from ', + '`body.capabilities` on hooks and actions; it deliberately does NOT touch the ', + '`ctx.crypto.hash(...)` call the body made under it, which never returned a value and ', + 'which the author must delete. Hashing returns only WITH an implementation, through the ', + 'capability admission process.\n\n', + "It also removes `connector.rateLimitConfig` and its whole shape. This one is not ", + '"declared but unread" — it is declared but UNIMPLEMENTED, one step worse. The only token ', + "bucket the platform owns (runtime `security/rate-limit.ts`) is INBOUND: the dispatcher ", + 'calls `consume(key)` on a request fingerprint and answers 429. Nothing anywhere throttles ', + 'the calls a connector makes OUT, and no provider — `connector-rest`, `connector-openapi`, ', + '`connector-mcp`, `connector-slack` — reads the key or has a seam that could. So ', + '`strategy`, `maxRequests`, `windowSeconds`, `burstCapacity`, `respectUpstreamLimits` and ', + '`rateLimitHeaders` parsed cleanly and capped nothing, on a surface where the author ', + "believed they had bounded their spend against a third party's quota. `ConnectorRateLimit", + 'Config` and the `RateLimitStrategy` enum it embedded had no other consumer and are removed ', + 'with the key, so importing either is TS2305 in v17 — the `plugin-runtime.zod` shape, and the same ', + 'implementation-first ruling: the vocabulary comes back WITH the engine, in one change. ', + 'It is deliberately NOT converted to `shared` `RateLimitConfig`, which limits the calls ', + 'others make to US; the dual-source cleanup split their names for precisely this confusion, and rewriting an ', + 'outbound cap into an inbound one would throttle the wrong direction. Delete the key and ', + 'rate-limit where the calls are actually made — the connector provider or upstream gateway.\n\n', + 'Last, it removes `dashboard.widgets[].responsive` — the straggler of the close-out ', + 'sweep above, which retired the literally same-named `view.responsive` on the same ', + 'evidence four days earlier. Re-measured before removal: no objectui code reads ', + '`widget.responsive` (DashboardRenderer, DashboardEditor and plugin-designer name it only ', + 'in comments), and there are zero authored instances repo-wide, so the conversion is ', + 'expected to be a no-op on every real source — it exists so that a stored dashboard ', + 'carrying the key is cleaned deterministically rather than meeting the tombstone at load. ', + 'What kept it alive was not evidence but a hole in the instrument: the liveness ledger ', + 'declares no `children` on `dashboard.widgets`, and the walk only drills one level through ', + 'an explicit `children`, so no widget-level key has ever been classified at all (filed as ', + 'its own finding, fixed separately). The removal was deliberately narrow — it took the widget EMBED, ', + 'not the shape, which at the time was believed live on `page.components[].responsive` via ', + 'objectui `useResponsiveConfig`. CORRECTED in 2026-08: that belief measured false ', + '— the hook had zero callers, nothing read the page key either — so protocol 18 retires ', + '`page.components[].responsive` and the `ResponsiveConfig` shape with it (see the ', + '`page-component-responsive-removed` step-18 entry). Authors who need breakpoint behaviour ', + 'use `responsiveStyles` (ADR-0065), the channel objectui really compiles. Per-widget ', + 'responsive layout returns if and when a renderer implements it.\n\n', + 'Finally it CONVERGES `dashboard.widgets[].compareTo` — the one entry in this step ', + 'that is not a removal but a vocabulary merge, and the one whose defect was worst-shaped. ', + 'The widget declared three arms with confident TSDoc; the analytics executor implements one ', + 'contract, `DatasetSelection.compareTo` = `{ kind, dimension? }`, which has no `offset` in ', + 'it. On the ADR-0021 dataset path the two string arms were DROPPED by the renderer (a ', + 'comparison silently absent from a widget whose author asked for one) and `{ offset }` was ', + 'forwarded into that contract with no dimension, so the executor threw ', + '`compareTo requires a timeDimension "undefined"` and errored the whole widget. All three ', + 'arms worked on the legacy inline chart path. Same key, two fates — and the failing one was ', + 'the path the spec itself calls canonical, which is why this ranks above an ordinary ', + 'declared-but-unread key: the documentation was actively teaching a shape that crashes. ', + 'The widget now declares the executor\'s own words, so `declared = enforced` holds by ', + 'construction with no second vocabulary left to drift. `dimension` is optional and resolved ', + 'by the EXECUTOR (one dated time dimension → that one; zero or several → a loud error ', + 'naming the candidates), which is a producer-side resolution rule, not the consumer-side ', + 'tolerance PD #12 forbids. The bare strings and `{ offset: \'1y\' }` replay mechanically; ', + 'every other `{ offset }` duration is a semantic TODO below, because `previousPeriod` ', + 'shifts by the resolved window\'s own length and rewriting `7d` into it would change which ', + 'rows the comparison counts. The converged slot is also union-free, which is not cosmetic: ', + 'zod collapses a failed union into one bare `Invalid input`, and a top-level-only error mapper showed that curated ', + 'guidance inside a union arm never reaches the author at all.\n\n', + 'The same widget drill retires four more keys: the action trio ', + '`actionUrl`/`actionType`/`actionIcon`, and `aria`. The trio described a per-widget action ', + 'BUTTON that no renderer in either repo has ever drawn — all 14 `actionUrl` reads in ', + 'DashboardRenderer are scoped to `header.actions[]`, a different schema — and `actionIcon` ', + 'had zero references anywhere outside its own declaration. `aria` is the dashboard-level ', + '`aria` removed by the close-out sweep, one level down: declared ARIA attributes that never ', + 'reached the DOM, i.e. an accessibility guarantee an author could state and nothing ', + 'honoured. It survived that sweep for the same reason `responsive` did — `widgets` had no ', + 'ledger drill until the instrument hole above was fixed — not on evidence. This removal also settles a second-order cost ', + 'the trio was carrying: `packages/lint`\'s dashboard action-ref rule enforced ', + 'ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key ', + '"the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so ', + 'an author could FAIL A BUILD because a control that cannot render pointed at an action ', + 'that also did not. That widget branch is deleted with the keys. Lossless deletes in every ', + 'case — the keys contributed nothing to any rendered output — and the shared `AriaProps` ', + 'shape is untouched, staying live on `page.aria` / `page.components[].aria` and the list ', + 'view `aria` — not on `app.aria`, which this same major retires (see ', + '`app-dead-authoring-keys-removed`, which strips it). Move a ', + 'dashboard-wide affordance to `header.actions[]` (where `icon` is the header spelling of ', + '`actionIcon`); for per-row click-through use a dataset-bound `table`/`pivot`, whose rows ', + 'drill through the semantic layer already.\n\n', + '⚠️ One protocol-17 change turns metadata ON rather than off, and it is the one to read ', + 'first: declarative `apis:` endpoints EXECUTE from 17. The surface used to be inert ', + 'end to end — no route mounted, no matcher, every key including `authRequired` parsed and ', + 'enforced nothing — which is why publish at first refused a non-empty `apis:` outright. 17 ships the ', + 'executor and narrows that refusal to a per-endpoint publish gate, so an endpoint that ', + 'passes the gate is MOUNTED and serves traffic the moment it is published. Any historical ', + '`apis:` block therefore changes meaning without changing a byte. Review every entry before ', + 'upgrading, and pay particular attention to an explicit `authRequired: false`: the schema ', + 'default is `true`, so an omission is safe, and only that explicit `false` opens anonymous ', + 'access — which ADR-0121 D6 now pairs with a mandatory armed `rateLimit` ', + '(`enabled: true`; the key defaults to `false`, so a budget written without it meters ', + 'nothing). Paths also move under the namespace carve-out `/api/v1/apps//', + '` (ADR-0121 D1/D2). The full checklist is the `declarative-apis-endpoints-live` ', + 'semantic entry below; it is a security review, not a rename, so nothing about it is ', + 'applied for you.\n\n', + 'Finally, the theme token scales retire (ADR-0049): `typography.fontSize`, ', + '`typography.fontWeight`, `typography.lineHeight`, `typography.letterSpacing`, ', + '`typography.fontFamily.heading`, `typography.fontFamily.mono`, `animation` and `zIndex`. ', + 'These are the reverse of the usual inert key and the distinction is the point: the theme ', + 'engine DID emit them — `--font-size-*`, `--font-weight-*`, `--line-height-*`, ', + '`--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, `--font-heading`, ', + '`--font-mono` all reached the document exactly as authored — and no first-party component ', + 'or stylesheet has ever read one, so a declared type scale was real CSS that styled ', + 'nothing. That is why the earlier theme sweep left them standing: its criterion was ', + '"never emitted", and these are emitted. `colors`, `borderRadius`, `shadows` and ', + '`typography.fontFamily.base` have live consumers and are untouched. The prescription is ', + '`customVars`, which emits `--: ` verbatim — so a tenant stylesheet that really ', + 'was reading `--z-modal` reproduces it byte for byte and loses no capability. The ', + 'conversion DELETES the keys and emits a notice per key rather than auto-populating ', + '`customVars`: a rewrite would hand back two dozen variables that still nothing reads, ', + 'turning a dead semantic slot into a dead literal one. Deciding which of them you actually ', + 'consume is yours to make; the notice names each one. Retired from the load path with the ', + 'other keys that misdescribed themselves.\n\n', + 'It closes the enforce-or-remove line with two `./ui` vocabulary shapes that never ', + 'had a key to be written into: `NotificationActionSchema` / `NotificationAction` ', + 'and `EmbedConfigSchema` / `EmbedConfig`. This is the class BELOW a declared-but-unread ', + 'key — there was no key at all. No schema anywhere declared a carrier, a BFS from all 24 ', + 'metadata-type roots plus `ObjectStackSchema` reached neither (with `Page` / `Action` / ', + '`DashboardWidget` / `Webhook` / `SharingConfig` as positive controls in the same run, and ', + 'a synthetic carrier flipping both), and no repo parsed either outside its own unit test. ', + 'Each was left behind by an earlier retirement one level up: the notification action by ', + 'the dual-source cleanup that deleted the two wrapper shapes that could have carried it, and the embed ', + 'config by 17.0.0\'s own `App.embed` tombstone. The strictness wave\'s 批 14 measured both and deliberately ', + 'declined to close them with `.strict()`, because strictness on a shape nothing parses ', + 'enforces nothing and only makes a dead slot look load-bearing; this removal is that ', + 'deferred call, answered REMOVE. Nothing is applied for you and nothing needs to be — ', + 'there is no key in any source to rewrite; the change is visible only as TS2305 on an ', + 'import. ⚠️ Read the scope precisely, because one of the two modules SPLITS: ', + '`ui/sharing.zod` keeps `SharingConfigSchema` and it stays LIVE — `FormView.sharing` ', + 'carries it and `rest-server.ts` mounts the anonymous form routes on ', + '`allowAnonymous` + `publicLink`, so public form sharing is untouched — and ', + '`ui/notification.zod` keeps `NotificationType` / `NotificationSeverity` / ', + '`NotificationPosition`. Only the two named shapes go.\n\n', + 'The same is true of the protocol-17 retirement that closes this list, and the pair is ', + 'worth reading together (ADR-0049): the five `@objectstack/spec/ui` interaction-config ', + 'modules — `touch.zod.ts`, `dnd.zod.ts`, `keyboard.zod.ts`, `animation.zod.ts` and ', + '`offline.zod.ts`, 22 `z.object` sites and 64 exported names — are deleted whole, with ', + 'their reference docs. They were never reachable: no schema in the protocol declared a ', + '`touch:` / `dnd:` / `keyboard:` / `animation:` / `offline:` key, so no metadata document ', + 'could carry one and none needs rewriting now. The defect was on the DOCUMENTATION side, ', + 'which is the half that made it urgent — `authorable-surface.json` carried 109 keys under ', + 'these defs and the generated `references/ui/*` pages rendered them as authoring tables, ', + 'so an AI author reading `dnd.mdx` wrote a `dnd:` block that `PageComponentSchema` then ', + 'rejected as an unknown key. That is a published capability the runtime does not deliver ', + '(Prime Directive #10), not a strictness gap: closing the shapes would have validated a ', + 'slot nobody can reach. Business reading behind the ruling: these five are RENDERER ', + 'BUILT-IN behaviour, decided by the component library rather than authored per page; ', + 'offline is a platform capability whose vocabulary belongs on a sync engine that has not ', + 'been built. ⚠️ The `animation` here is `ui/animation.zod.ts` ', + '(`ComponentAnimation` / `MotionConfig` / `PageTransition` / `AnimationTrigger`), a ', + 'DIFFERENT surface from the theme `animation` block retired above with the token scales — that one had ', + 'a carrier key and got a tombstone; this one had none and gets deletion. The one name ', + 'worth checking on upgrade is the bare `ConflictResolution` type: it left with ', + '`ui/offline.zod.ts` and is now published by nobody. `ConnectorConflictResolution` ', + '(`@objectstack/spec/integration`, connector sync) and `ConflictResolutionStrategy` ', + '(`@objectstack/spec/api`, route merge policy) are different concepts under their own ', + 'names and are untouched.\n\n', + 'The last enforce-or-remove entry of this step is on the RUNTIME context rather than on ', + 'anything authorable: `HookContext.session.roles`. It was declared in ', + '`data/hook.zod.ts`, read by exactly two consumers — the approvals record lock and the ', + 'delegation write guard, each opening with `session.roles?.includes(\'admin\')` — and ', + 'produced by nobody on the hook path: ObjectQL\'s `buildSession()` writes the session ', + 'field by field (`userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the skip ', + 'flags) and has no `roles` write, here or in `cloud`, whose hook consumers read ', + '`hookContext?.session?.userId` and nothing else (an ACTION body\'s `ctx.session` is a ', + 'different untyped object that does carry one, tracked apart). So both branches were dead ', + 'on every real ', + 'engine path: an authorization decision in shape only, and — worse for a reader — a SECOND ', + 'admin dialect competing with the one ADR-0095 D3 sanctions. The approvals plugin deleted the ', + 'two readers on the maintainer\'s ruling; this step removes the declaration that outlived ', + 'them, which is what ADR-0049 asks for once a key has neither end. Nothing observable ', + 'changes: a key nobody wrote and nothing read cannot alter a single decision. It is ', + 'tombstoned rather than deleted because `HookContextSchema` is deliberately NOT `.strict()` ', + '(strictness there would make an engine-internal enrichment a breaking change for anyone ', + 'parsing a context they were handed, as `provenance` was for schedule-triggered runs), so a plain delete would ', + 'strip the key in silence — the ADR-0104 failure this whole pass exists to end. ', + 'There is NO conversion and no source rewrite: a HookContext is built per operation by the ', + 'engine and never stored, so no `sys_metadata` row, example or template can carry the key ', + '— the `openApi31` / `activationEvents` shape, one semantic TODO for hook authors. The ', + 'live vocabulary is untouched and deliberately elsewhere: gate on `session.userId` / ', + '`session.isSystem` in the hook, and judge PRIVILEGE through the security service, which ', + 'reads capability grants (`permissions`), placements (`positions`) and the derived posture ', + 'off the execution context.\n\n', + 'The same enforce-or-remove reading reaches the storage contract: ', + '`IStorageService.list(prefix)` is removed. It had no ', + 'consumer — the only in-repo call site was a proxy pass-through — and the two shipped ', + 'adapters answered it with two different, silently incomplete semantics: the local ', + 'adapter listed a single level and reported directories as files, the S3 adapter ', + 'recursed and stopped at 1000 objects without reading `IsTruncated` / ', + '`ContinuationToken`. Enumerating a prefix without a cursor is the wrong signature to ', + 'inherit, so nothing replaces it in place: query the file records you wrote, and let a ', + 'real caller bring back a cursor-shaped `list(prefix, { cursor, limit })` with ', + 'adapter-conformance cases behind it. Same shape and same disposition as the ', + '`findStream` retirement above — a TS/API contract, no stored source, no tombstone, ', + 'tsc at the call site.\n\n', + 'Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and ', + '`indexes[].partial`. Neither ever had a DDL consumer: ', + '`SqlDriver.syncDeclaredIndexes` creates declared indexes through knex\'s `table.index()` / ', + '`table.unique()`, and the drift differ\'s `DeclaredIndexInput` carries only ', + '`name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no access ', + 'method and an authored `partial` produced a FULL index with its predicate discarded. ', + '`partial` was the more damaging of the two because it read as a correctness control: the ', + 'platform\'s own `sys_metadata` declared it for overlay uniqueness, and what the ', + 'declaration alone materialized was an unrestricted unique index (the active-row scoping ', + 'is delivered by a runtime migration, `metadata-protocol`\'s `ensureOverlayIndex`, not by ', + 'the key). `type` was the louder: its `.default(\'btree\')` put an inert knob into every ', + 'parse output, so it read as live configuration — the ADR-0078 no-silently-inert shape. ', + 'Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing needs ', + 'per-dialect algorithm mapping (`gin`/`gist` Postgres-only, `fulltext` MySQL-family), ', + 'raw-SQL `CREATE INDEX … WHERE` on the dialects that have partial indexes at all (MySQL ', + 'does not), and a redesign of how `isSyncReproducibleIndex` excludes partial indexes from ', + 'incremental sync — design cost for a capability nothing has asked for. Both are lossless ', + 'deletes: no DDL changes, because no DDL ever depended on them. Drift detection is ', + 'untouched — the `partial` flag it consumes is parsed back out of the database\'s OWN ', + '`CREATE INDEX` DDL and never came from this key.\n\n', + 'It also retires the field-mapping `transform` key and the whole five-member ', + '`FieldMappingTransform` union behind it: `constant` / `cast` / `lookup` / ', + '`javascript` / `map`, declared on `shared/FieldMapping` and inherited by ', + '`integration/ConnectorFieldMapping` and `data/ExternalFieldMapping`. Nothing ever ', + 'executed one. `fieldMappings` is spelled only inside `packages/spec` itself — the ', + 'connector packages, the automation engine, REST and objectui never read it, and no ', + 'code anywhere switches on `transform.type` — so all five members were ', + 'declared-but-unenforced together, not just the one that got the bug filed. That one ', + 'is the sharpest evidence though: `javascript`\'s `.describe()` recommended the ', + 'dialect `js`, which `ExpressionDialect` had already retired (ADR-0058 addendum), so the ', + 'envelope the documentation taught was rejected by the enum; the only spelling that ', + 'parsed was the bare string, which `ExpressionInputSchema` wraps as `cel`; and the CEL ', + 'that resulted could not evaluate the `value.toUpperCase()` the same line offered as ', + 'its example. Three surfaces disagreeing about a capability with no implementation ', + 'under any of them. Fixing the sentence alone was rejected (maintainer, 2026-08-06) as ', + 'gilding a member that cannot run. The key is tombstoned rather than deleted because ', + 'the schema and both extenders are plain `z.object`s and `ConnectorSchema.parse` is a ', + 'live receiver, so a bare deletion would strip silently. What is NOT affected, despite ', + 'the shared word: the import mapping\'s `mapping.fieldMapping[].transform`, a flat ', + 'string enum applied row by row by the REST import path and live in the liveness ', + 'ledger — including its own `javascript` value, which that path rejects with a 400 ', + 'rather than pretending to run.\n\n', + 'The last of the strictness wave\'s enforce-or-remove batches lands on two more `ui/` files (', + 'ADR-0049 — read it next to the interaction-config modules above, it is the same shape one batch later). ', + '`ui/widget.zod.ts` published a whole widget-REGISTRATION vocabulary — `WidgetManifest` ', + 'with `WidgetLifecycle` hooks, `WidgetEvent`s, `WidgetProperty` knobs and a ', + '`WidgetSource` npm/remote/inline implementation union — and `ui/i18n.zod.ts` published ', + '`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat` and `LocaleConfig`. Ten defs, ', + 'twenty exported names, and not one carrier key between them: nothing under ', + '`packages/spec/src` imported `widget.zod` at all, the only live imports of `i18n.zod` ', + 'name `I18nLabelSchema` / `AriaPropsSchema`, the BFS from all 24 metadata-type roots ', + 'plus `defineStack` reached none of them, and no repo ever parsed one. So again nothing ', + 'is applied for you and nothing needs to be — the change is TS2305 on an import, and a ', + '`field.widget: "my_picker"` string is untouched, because that key names a component the ', + 'RENDERER registered and never referenced `WidgetManifest`. ⚠️ Read this scope precisely ', + 'too, because BOTH files split. `ui/i18n.zod.ts` keeps `I18nLabelSchema` (the label ', + 'primitive the whole `ui/` tree imports) and `AriaPropsSchema` — a REAL door, carried as ', + '`aria:` on ~30 live shapes and closed by 批 16, untouched here. And `ui/widget.zod.ts` ', + 'keeps `FieldWidgetPropsSchema`, the one site of the nine whose evidence differs: it is ', + 'a React props contract rather than authorable metadata (it never appeared in the ', + 'authorable surface or the schema manifest — `onChange` is a `z.function()`), so having ', + 'no parse is its design; and an objectui change (2026-08-03) made it a live compile-time ', + 'consumer, renaming `@object-ui/fields`\' validation slot onto this contract\'s `error` ', + 'with no alias and pinning it as a deliberate tripwire. Retiring it would have broken ', + 'the one consumer the batch had, one day after it appeared. The measurement that decides ', + 'a site is the CURRENT one, not the one in the issue body.\n\n', + 'Last, it reconciles the SDUI component-props surface with the renderers that serve it ', + '— wiring the first parse gate `ComponentPropsMap` ever had found that the corpus it ', + 'landed on diverged in BOTH directions: keys objectui honours that the schema never ', + 'declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. ', + 'The maintainer ruled direction A (2026-08-06), the `record:details` sections rule again: the delivered and ', + 'authorized shape is the contract. So the honoured keys are declared ', + '(`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` ', + '`stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, ', + 'and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared ', + '`EmptyProps` while their renderers rendered a child list), and four keys retire. Two are ', + 'synonym renames: `element:record_picker.displayField` → `labelField` (the required key no ', + 'renderer read, while `labelField ?? \'name\'` is what actually renders the row — so an ', + 'author who followed the schema got a picker listing `name` with no diagnostic, the ', + 'ADR-0078 shape), and `page:card.body` → `children` (one composition key across every ', + 'container; the card renderer already reads both, and the showcase authors `children`). ', + 'Two are enforce-or-remove deletions: `element:record_picker.searchFields` and ', + '`.multiple` — the control is a shadcn single-select with no search input, binding ONE ', + 'record id into a page variable, so `searchFields` narrowed nothing and `multiple: true` ', + 'selected nothing extra while reporting success. Either returns the day the capability ', + 'is implemented. Not in scope, and deliberately: `page:card.visible` is a ', + 'component-level visibility predicate written into `properties` and hoisted by the ', + 'renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.\n\n', + 'That count turned out to be incomplete, and a second pass finishes it: five more keys the ', + 'renderers read were still undeclared. Four are plain additions with no behaviour change ', + '(`page:header` `recordChrome`/`showStar`/`showCopyId`, which select between the ', + 'record-chip header and the bare heading a dashboard wants, and `page:accordion.variant`, ', + 'which decides whether the accordion draws its own dividers or leaves the border to each ', + 'panel). The fifth is a rename, and the only one in the family whose defect is structural ', + 'rather than an oversight: the tab strip\'s visual style was declared as ', + '`page:tabs.type`, which collides with the page component\'s OWN dispatch key. objectui\'s ', + '`SchemaRenderer` refuses to hoist `properties.type` for exactly that reason, ', + '`sdui-parser`\'s `BASE_PROPS` contains `type` and skips it before any validation runs, ', + 'and in a flat or JSX carrier the node reads `{ type: \'page:tabs\', … }` so the name is ', + 'already taken. The key was therefore unauthorable in every carrier but the nested ', + '`properties` object, and unvalidated even there. It becomes `tabStyle` — the spelling ', + 'objectui publishes and the renderer already reads first in the flat carriers — which is ', + '`displayField` → `labelField` again: converge on the spelling that works, not the one ', + 'that declares well, and keep one spelling rather than two (Prime Directive #12).\n\n', + 'A third pass closes that reconciliation from the other side, on three keys the two earlier ', + 'passes left standing (maintainer ruling 2026-08-09, decision-inbox round: retire the header icon and ', + 'card actions upstream, and the details layout). Two are the plain B class — declared here, read NOWHERE. ', + '`page:header.icon` is resolved by objectui only per header ACTION (`action.icon`); the ', + "header's own props bag is never asked for one, and `@object-ui/layout`'s `` ", + 'takes an `icon` React prop from a host with no schema fallback beside the ', + '`schema?.actions ?? schema?.properties?.actions` fallback four lines away. ', + '`page:card.actions` has no actions area to render into at all: the card renderer builds ', + 'its `` from `title`, `bordered`, `children` and `footer`, full stop. Both sat in ', + "objectui's own unpublished-exemption map as \"spec declares it, NO renderer read point\", ", + 'which is what put the contract decision — wire it, publish it with a KNOWN GAP marker, or ', + 'retire it — in front of the maintainer; the ruling retired it. Neither has a lossless ', + 'rewrite target (a header has no second icon slot, and moving a card\'s action ids into ', + '`children` as components is a page rewrite, not a mechanical one), so both are pure ', + 'strips. ⚠️ `page:header.actions` is LIVE and untouched — the strip is scoped by component ', + 'type, never by key name.\n\n', + 'The third, `record:details.layout`, is a sharper shape and the one worth reading twice: ', + 'it IS read. The renderer computes ', + "`schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical'`, ", + 'while the declared enum is `auto | custom` — so neither legal value can match, both take ', + 'the same branch, and a key that was accepted and read still selected nothing, under a ', + '`.describe()` promising "auto uses object highlightFields, custom uses explicit sections". ', + 'The behaviour that prose describes is real, but the renderer keys it off whether ', + '`sections` was authored, never off this flag. Every gate stayed green because ', + '`check:react-declaration-parity` compares two DECLARATIONS and objectui declared the same ', + '`auto | custom` enum — perfect agreement over a key nothing honoured — while a THIRD ', + "spelling (`stacked | inline | compact`) sat in `@object-ui/types`' mirror. A pure strip ", + 'for the same reason: `auto`, `custom` and omission were behaviourally identical, so there ', + 'is no value to carry. ⚠️ `record:highlights.layout` is a different, live, honoured key ', + 'and is untouched. objectui\'s side of both rulings drops the exemptions, the input and the ', + 'dead branch on the next pin bump.\n\n', + 'Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave ', + '`AggregationFunction` (ADR-0049). The enum declared eight functions and the SQL ', + 'family compiles five — `SqlDriver.mapAggregateFunc` and the Turso ', + '`RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest ', + 'to one refusal — so three were declared-but-unenforced against the backends this platform ', + 'targets. What makes these two worse than an ordinary inert declaration is that another ', + 'package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and ', + '`string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they ', + "reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of ", + 'the requested value, with no error and no log. The maintainer SPLIT the three rather than ', + 'retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS ', + 'and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard ', + 'staple, already lowered by `service-analytics` — with its SQL implementation following on ', + 'its own card, so that declaration leads its implementation by decision rather than by ', + 'drift. These two take the remove leg: display conveniences with no measured pull, and ', + '`string_agg` never had one shape to lower to (the delimiter is a second argument in ', + 'PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). ', + 'This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no ', + '`retiredKey()` tombstone: the enum error map carries the prescription, keyed on the ', + 'received value so only the two spellings that used to be legal are told they "were ', + 'removed". Of the two authoring surfaces only one is stored metadata: the conversion ', + 'rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with ', + 'neither `aggregate` nor `derived` fails the dataset\'s own refinement, so stripping just ', + 'the key would emit an item that cannot parse) plus any derived measure the drop strands, ', + 'with a notice each. Nothing is lost: `compileDataset` refused both by name already, so ', + 'such a measure never produced a number. `QueryAST.aggregations[].function` is a request ', + 'surface with no stored source — one semantic TODO below. The mongodb and in-memory ', + 'backends that implemented these two were inside the maintainer\'s driver freeze when this was decided ', + '(it was lifted on 2026-08-11) and are untouched; their code is simply no longer reachable ', + 'through a spec-valid request.\n\n', + 'The same aggregation node loses one more member, and it is the sharper class of the two: ', + '`aggregations[].distinct` is removed (ADR-0049, maintainer ruling 2026-08-09). ', + 'The functions above were declared and UNLOWERED — a caller on a SQL datasource got a ', + 'refusal. This flag was declared and lowered by exactly ONE of the six faces that read an ', + 'aggregation: the engine\'s in-memory fallback deduplicated the values before applying the ', + 'function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, ', + '`driver-mongodb`\'s `buildAggregationStage`, `driver-memory`\'s `computeAggregate` and ', + 'service-analytics\' `AGGREGATE_SQL` all ignored it. So the same query answered a ', + 'deduplicated `sum` on the fallback path and an ordinary `sum` on every SQL datasource, ', + 'with the engine choosing between the two per query — by driver, by a non-UTC date bucket, ', + 'by whether the driver aggregates natively at all. That is the divergence class closed on this axis when ', + 'every SQL face got one aggregate spelling and one refusal, still open on this key, and it is worse to sit on because ', + 'the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing ', + 'for a dashboard author to notice. It survived the `QueryAST` sweep of this very schema because ', + 'that sweep asked which members no executor reads, and this one had a reader — the wrong ', + 'question for a key whose defect is WHICH executor reads it. Remove rather than enforce, ', + 'per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL ', + 'lowering has since landed) already covers the only deduplicating spelling with measured ', + 'demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling ', + 'mistake and would have to be lowered across five faces, two of them then frozen under ', + 'the driver freeze (lifted 2026-08-11, after this ruling), to buy it. The blast radius inside the ', + 'fallback is narrower than the key suggests and was ', + 'measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned ', + 'from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe ', + 'does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is ', + '`retiredKey()`-tombstoned rather than bare-deleted: a plain deletion would have made zod ', + 'silently STRIP what callers still send, trading a divergent flag for an ignored one ', + '(ADR-0104). One tombstone covers every aggregation door, because ', + '`QuerySchema.aggregations` and `EngineAggregateOptionsSchema.aggregations` reuse that one ', + 'schema by reference. No conversion: a request surface with no stored source — one ', + 'semantic TODO below, the disposition every other `data.query.*` retirement in this major ', + 'already takes.\n\n', + 'One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape ', + "protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` ", + "resolves to `'service'` instead of `'any'`, so a pausing node type that never states who ", + 'may continue its pauses is refused on the generic resume route rather than open to it ', + '(ADR-0044\'s 2026-07-28 amendment). Nothing is removed and no metadata shape ', + 'changes — the field has been optional since step one of the same issue — so tsc reports ', + 'nothing and only the MEANING of silence moved. That is exactly why it needs a ledger ', + 'entry: a third-party plugin author has no compile error to discover it with, and the ', + 'one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before ', + 'a user meets a run that will not continue.\n\n', + 'The same descriptor loses a key in this step, and the pairing is the point (', + 'ADR-0049). `ActionDescriptor.isAsync` and `ActionDescriptor.supportsPause` were two ', + 'spellings of one capability — "this node type can suspend the run" — and enforcing pauses split ', + 'them by evidence rather than by preference: `supportsPause` took the ENFORCE leg (the ', + 'engine now refuses a suspension the descriptor never declared, at the one seam every ', + 'suspension passes through), and `isAsync` takes the REMOVE leg, because a fresh ', + 'three-repo measurement found zero readers and no consumer it could grow into. What ', + 'makes the duplicate worse than an ordinary inert key is that five shipped descriptors ', + 'WROTE it, so the platform itself modelled a declaration that decided nothing — and a ', + 'plugin author copying `screen` (which declared BOTH) had no way to tell which of the ', + 'two the runtime honoured. It is tombstoned rather than deleted, so the answer arrives ', + 'as a rejection carrying the fix; and because a descriptor lives in executor TypeScript ', + 'rather than in stored metadata, its prescription is a semantic entry below rather than ', + 'a conversion `os migrate meta` could replay.\n\n', + 'The plugin manifest loses its whole `loading` block in this step (ADR-0049, ', + 'maintainer ruling 2026-08-04) — the same enforce-or-remove question asked of a block ', + 'rather than a key, and answered REMOVE on measurement: every reference to ', + '`manifest.loading.*` in objectstack, cloud and objectui lived inside `packages/spec` ', + 'itself, so a full loading policy parsed, entered the manifest, and configured nothing. ', + 'The reason it outranked ordinary inert-key cleanup is that one of its members was ', + '`sandboxing`, declaring process / vm / iframe / web-worker isolation and a service ACL: ', + 'an inert SECURITY control is worse than an absent one, because an author (very often an ', + 'AI, ADR-0033) reads the vocabulary as proof the isolation exists and stops looking. Hot ', + 'reload was a two-source defect on top of that — the retired `PluginHotReloadSchema` was ', + 'the dead one of two vocabularies, and the ruling converges on the live one, ', + '`HotReloadConfigSchema`, which `HotReloadManager` actually reads and which is KEPT ', + 'unenforced as the starting point for a separate future decision. Like `isAsync`, its ', + 'prescription is a semantic entry rather than a conversion: a manifest is not a stack ', + 'collection, so `os migrate meta` has no seam at which to rewrite one.\n\n', + 'The action LOCATION vocabulary loses `global_nav` in this step (ADR-0049, ', + 'maintainer ruling 2026-08-09). It was declared from the day `ACTION_LOCATIONS` was ', + 'written and no product surface ever served it: the console command palette composes its ', + 'groups from nav items, objects, dashboards, pages, reports, recent items and record ', + 'search, and reads no action metadata at all — so an action declaring this location never ', + 'reached a user. What lifts it above ordinary inert-declaration cleanup is that the ', + 'authoring tool PROMISED the surface: the Studio designer previewed a mock ', + '`⌘K · Command palette` frame for exactly this value, so an author (very often an AI, ', + 'ADR-0033) declared it, watched it "render", shipped it, and got nothing — the ADR-0078 ', + 'shape arriving through a location vocabulary rather than through a missing key. It was ', + 'retired rather than implemented because the demand evidence is empty: no user has asked ', + 'for command-palette actions and the only two declarers were our own showcase corpus, so ', + 'wiring the palette would have been capability expansion with no pull. This is an enum ', + 'VALUE, not a key, so — as with `crypto.hash` and the two aggregate functions above — ', + 'there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed ', + 'on the received value so only the spelling that used to be legal is told it "was ', + 'removed". The conversion strips the value from `action.locations` and KEEPS the key even ', + 'when the array empties, because on this surface `locations: []` and an absent ', + '`locations` are different declarations: the empty array is the documented headless shape ', + '(callable over REST/MCP/AI, capability gate and audit trail intact), while an absent key ', + "means nobody placed the action — which is what `packages/lint`'s `action-no-placement` ", + 'warns about. An object-less action, whose only reason for declaring `global_nav` was that ', + 'it has no row and no record header to render on, is therefore migrated to the ', + 'declaration it always meant.\n\n', + 'It also removes the three pass-through-only list-view display keys `striped` / ', + '`bordered` / `virtualScroll` (ADR-0049 enforce-or-remove, maintainer ruling ', + '2026-08-10). All three were graded live on reads that turned out to be forwarding ', + 'copies: the react spec-bridge, plugin-list and plugin-view/app-shell each copy the ', + 'key onto the next node, and the chain ends at ObjectGrid, which never spells any of ', + 'the three — so an author who wrote `striped: true` got a parse-clean no-op, the exact ', + 'silent-no-op shape enforce-or-remove exists to end. Copy-without-apply is dead in ', + 'effect; per the ruling, if objectui wants one of these as real behavior, that is an ', + 'implementation card filed first, and the key stays retired pending it.\n\n', + "Finally it removes the 'pdf' member of `view.exportOptions` formats (maintainer ", + 'ruling 2026-08-12). PDF export was declined platform-side as not planned, so the ', + "member was declared-but-unrenderable: ObjectGrid dropped the format from the export menu ", + "with only a runtime console.warn, so `exportOptions: ['xlsx', 'pdf']` type-checked, ", + 'validated, and silently rendered a menu without PDF. The same ruling adopted the OBJECT ', + 'form for `exportOptions` — `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, ', + 'streaming? }`, exactly the key set the renderer reads, ending the state where no ', + 'declaration was both type-legal and functional — with the legacy bare array still ', + 'accepted and lifted to `{ formats: [...] }` at parse, which is why the conversion strips ', + "only 'pdf' and does not rewrite the array spelling. This is an enum VALUE, not a key, so ", + '— as with `crypto.hash` above — there is no `retiredKey()` tombstone: the format enum\'s ', + 'error map carries the prescription, keyed on the received value so only the spelling ', + 'that used to be legal is told it "was removed", plus a union-level dispatch so the ', + 'refusal is the top-level message in either authored form. The strip keeps an emptied ', + '`formats` array rather than deleting the declaration.', + ].join(''), + conversionIds: [ + 'action-execute-to-target', + 'field-conditionalRequired-to-requiredWhen', + 'agent-tools-to-skills', + 'sharing-rule-access-level-full-to-edit', + 'flow-node-crud-object-alias', + 'flow-node-notify-config-aliases', + 'flow-node-wait-event-config-lift', + 'flow-node-connector-config-lift', + 'flow-node-map-flow-alias', + 'flow-node-subflow-flow-alias', + 'flow-node-script-config-aliases', + 'permission-rls-priority-removed', + 'tool-inert-authoring-keys-removed', + 'app-dead-authoring-keys-removed', + 'app-area-fail-open-gates-removed', + 'action-inert-keys-removed', + 'flow-inert-keys-removed', + 'view-inert-keys-removed', + 'view-list-passthrough-keys-removed', + 'view-export-options-pdf-removed', + 'dashboard-inert-keys-removed', + 'agent-knowledge-removed', + 'skill-trigger-phrases-removed', + 'stack-api-require-auth-removed', + 'datasource-capabilities-removed', + 'datasource-inert-blocks-removed', + 'mapping-inert-keys-removed', + 'book-translations-removed', + 'job-id-removed', + 'translation-validation-messages-removed', + 'flow-node-wait-timeout-keys-removed', + 'datasource-read-replicas-removed', + 'datasource-config-driver-key-aliases', + 'datasource-driver-mongo-to-mongodb', + 'flow-node-script-branch-keys-removed', + 'object-managed-by-system-to-system-data', + 'retry-policy-converged', + 'object-enable-trash-mru-removed', + 'hook-body-crypto-hash-removed', + 'connector-rate-limit-config-removed', + 'dashboard-widget-responsive-removed', + 'dashboard-widget-action-aria-removed', + 'dashboard-widget-compareto-converged', + 'theme-inert-token-scales-removed', + 'page-header-subtitle-alias', + 'object-index-type-partial-removed', + 'field-mapping-transform-removed', + 'record-picker-display-field-to-label-field', + 'record-picker-inert-keys-removed', + 'page-card-body-to-children', + 'dataset-measure-array-string-agg-removed', + 'inline-action-api-params-to-body-extra', + 'page-tabs-type-to-tab-style', + 'page-structure-inert-keys-removed', + 'record-details-layout-removed', + 'app-hidden-to-unpublished', + 'action-global-nav-location-removed', + ], + semantic: [ + // One file per entry under `entries/semantic/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], +}; + +/** + * One retirement's sentences in a step's `rationale` (#20535). + * + * The rationale renders as ONE paragraph — the upgrade guide prints it and + * `migrate meta` shows it per hop — but an open major's is authored as + * fragments, because every retirement of that major adds to it and one shared + * string does not merge: each retirement PR rewrote its closing line. + */ +interface RationaleFragment { + /** + * Names the retirement: kebab-case, unique in its list, and its own D3 + * `semantic` entry id when it has one. The list is kept SORTED by this key. + */ + readonly id: string; + /** Where the fragment renders: ascending `order`, ties broken by `id`. */ + readonly order: number; + /** The sentences, with no leading or trailing space: fragments join with one. */ + readonly text: string; +} + +/** A rationale from its fragments: ascending `order` (ties by `id`), joined with one space. */ +function joinRationale(fragments: readonly RationaleFragment[]): string { + return [...fragments] + .sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : 1)) + .map((f) => f.text) + .join(' '); +} + +/** + * Step 18's `rationale`, one fragment per retirement. + * + * ⚠️ SORTED BY `id`, NOT BY LANDING ORDER — that is the point of the shape. + * Git reports a conflict whenever two branches insert into the SAME gap between + * two unchanged lines, whatever they insert, and GitHub's server-side merge runs + * no driver that could say otherwise. A list appended at its end is one gap, so + * any two retirements in flight conflicted here, exactly as they did when this + * was one `+` chain whose closing line each of them rewrote. Kept sorted by + * key, two retirements insert into different gaps and merge clean — one + * existing fragment between them is enough. `order`, not the position in this + * list, says where a fragment renders. + * + * To add one: `id` is your retirement's D3 semantic entry id; insert the + * fragment where that id sorts; `order` is one more than the highest here. Two + * retirements in flight may both take the same number, and then render in `id` + * order. `scripts/step18-rationale-merge.test.ts` holds the list sorted and + * proves the merge. + */ +const STEP18_RATIONALE: readonly RationaleFragment[] = [ + { + id: 'action-aria-retired', + order: 43, + text: + 'Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth ' + + 'member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the ' + + 'chart config\'s, and retired for the same measured reason: an ARIA block an author can ' + + 'declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an ' + + 'uncited "partial" note with no reader behind it; at the pinned renderer, none of the ' + + 'surfaces that render an action — button, icon, menu, group and bar, the row and bulk ' + + 'action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, ' + + 'because every one of them already takes the accessible name from the action\'s required ' + + '`label` (visible text, or `aria-label` on an icon-only action), and the node that places ' + + 'the actions carries the node-level `aria` block — a per-action block would be a second ' + + 'spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack ' + + 'actions and object-nested actions as a pure lossless delete, retired from the load path ' + + 'so authors are refused at parse; its D3 record is the semantic entry ' + + '`action-aria-retired`.', + }, + { + id: 'action-block-endpoint-spelling-retired', + order: 55, + text: + 'It also gives the executor target of an action one spelling on the page blocks that run ' + + 'one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the ' + + '`action:button` and `action:icon` component rows declared `endpoint` as a key of their own, ' + + 'and the console\'s `api` handler reads `target` only — so an `api` button authored with ' + + '`endpoint` was accepted by the props gate and called nothing. The rows now refuse it with ' + + 'the same rename, read from the one alias table both share. The D2 conversion ' + + '`action-block-endpoint-to-target` renames the key on an `api` action, where the rename is ' + + 'lossless, retired from the load path so authors are refused at the door while stored rows ' + + 'and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another ' + + 'one is left as stored and reported as a TODO. Its D3 record is the semantic entry ' + + '`action-block-endpoint-spelling-retired`.', + }, + { + id: 'admin-export-wildcard-removed', + order: 4, + text: + 'Finally, it removes the `objects["*"].allowExport` grant from the shipped admin ' + + 'permission sets — `admin_full_access`, `organization_admin` and the derived ' + + '`organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the ' + + 'export axis undeniable for an org admin: an application could declare an object ' + + 'exportable by nobody and the platform exported it anyway, with no supported opt-out, ' + + 'because a code-package set cannot be edited (`403 [not_overridable]`) and the admin ' + + 'held no app-authored set in which to write the per-object `false` that would have ' + + 'won. It is the earlier removal of `member_default`\'s CRUD wildcard applied to the export axis, ' + + 'which had kept its wildcard by omission rather than by decision. From 18 an admin ' + + 'exports exactly what an app-authored set grants — a posture the same run measured ' + + 'to be already precise. Unlike everything else in this step it changes no schema, so ' + + 'nothing refuses at publish: the upgrade signal is behavioural and belongs here.', + }, + { + id: 'agent-lifecycle-retired', + order: 62, + text: + 'It also retires an agent\'s conversation state machine, `agent.lifecycle` (ADR-0049 ' + + 'enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared ' + + 'state or refused an undeclared transition, and enforcing it would have meant a statechart ' + + 'interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served ' + + 'elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step ' + + 'process is a Flow, a record\'s status transitions are the `state_machine` validation rule — so ' + + 'authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` ' + + 'deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, ' + + 'kept by ADR-0020 only for this door, left the package with it.', + }, + { + id: 'agent-memory-store-retired-and-limits-required', + order: 61, + text: + 'It also makes an agent\'s memory contract state exactly what the runtime honours (ADR-0049 ' + + 'enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest ' + + '`maxEntries` long-term notes before the first round, writes one every `reflectionInterval` ' + + 'delivered interactions, and keeps them in its own database store; before an agent\'s first ' + + 'turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` ' + + 'missing either number, and a `reflectionInterval` without one. So `longTerm.store` is ' + + 'retired as a whole key — the memory store is platform infrastructure, not agent metadata — ' + + 'and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired ' + + 'from the load path; and with long-term memory enabled both numbers are required at ' + + 'authoring, with no default declared, so an upgrading author chooses them.', + }, + { + id: 'agent-structured-output-refused-members-retired', + order: 60, + text: + 'It also makes an agent\'s structured output JSON-only (ADR-0049 enforce-or-remove). The ' + + 'cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer ' + + 'and refused four of its members before an agent\'s first turn: the `regex`, `grammar` and ' + + '`xml` formats — no key ever carried a pattern or grammar to check against, and an answer is ' + + 'checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All ' + + 'four are refused at parse with a prescription, and the D2 conversion ' + + '`agent-structured-output-refused-members-removed` deletes a block whose `format` was ' + + 'retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, ' + + 'retired from the load path.', + }, + { + id: 'chart-config-aria-retired', + order: 31, + text: + 'Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer ' + + 'decision of 2026-09-12 — judge the protocol wrong for ' + + 'this one key). It is the last member of the `aria` family retired for the same measured ' + + 'reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an ' + + 'author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — ' + + 'it sits inside the widget’s `chartConfig` bag, which no drill had reached until the ' + + 'per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one ' + + '`ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares ' + + 'no `aria` prop, the presentation lowering names it nowhere, and the react block omits it ' + + 'from ``’s `dataProps`. Remove rather than enforce, because the same chart ' + + 'config already carries a WORKING accessible-name channel in `description` (lowered as ' + + '`role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name ' + + 'sources on one element behind a precedence rule nobody has written — one node, one ' + + 'accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies ' + + 'into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it ' + + 'from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, ' + + '`reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. ' + + 'The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became ' + + 'refusals carrying the same prescription rather than renames onto a tombstone.', + }, + { + id: 'compliance-deadline-keys-retired', + order: 23, + text: + 'It also retires the fourteen hour/minute/day-shaped deadline keys of the ' + + 'incident-response, training and change-management families (ADR-0049 ' + + 'enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response ' + + 'schemas, five on the training schemas and three nested in the change-management ' + + 'schemas, every one on the published surface and read by nothing — the schemas are ' + + 'mounted by no stack key and registered as no metadata type — so a compliance author ' + + 'who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All ' + + 'fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion ' + + 'would be a silent strip) with no D2 conversion, for the additionalTypes reason: ' + + 'none of these schemas is a stack collection member, so the chain has no seam. ' + + 'It then retires those three compliance-shaped families WHOLE (ADR-0049 ' + + 'enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen ' + + 'defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and ' + + '`system/change-management.zod.ts` — roughly a hundred declared keys, exported from ' + + '`@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, ' + + 'absent from the liveness ledgers, read by nothing repo-wide (examples, skills and ' + + 'objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 ' + + 'semantic entry per family; the fourteen deadline-key tombstones leave with their ' + + 'defs\' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean ' + + 'capability claims such as `notifyRegulators`, `requirePostIncidentReview`, ' + + '`trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape ' + + 'left: an author writing `notifyRegulators: true` held a compliance promise the platform ' + + 'never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped ' + + 'e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` ' + + '(`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey ' + + 'tombstones with no D2 conversion (`document` is no stack collection member), ' + + 'registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry.', + }, + { + id: 'connector-error-mapping-retired', + order: 22, + text: + 'It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ' + + 'ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) ' + + 'were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, ' + + 'through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no ' + + 'provider, dispatcher or materializer ever mapped an external error through the rules, so ' + + '`unmappedBehavior` configured nothing and a rule\'s `userMessage` was never shown to ' + + 'anyone. That spelling is the live API-error channel\'s (`ApiError.userMessage`), so an ' + + 'author who wrote a rule here reasonably believed they were marking a refusal for an end ' + + 'user; the failure was silent in both directions. The carrier key is a retiredKey ' + + 'tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), ' + + 'the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the ' + + 'orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and ' + + 'the mechanical conversion strips the block from `connectors[]` (pure lossless delete; ' + + 'it never had an effect to lose).', + }, + { + id: 'connector-provider-context-connection-timeout-ms-retired', + order: 35, + text: + 'It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; ' + + 'maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was ' + + 'owed after the ruling that made its nine ledger siblings live deliberately left this ' + + 'one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, ' + + 'so an author had every signal it worked — and no site ever applied it as a deadline. ' + + 'This retirement is NOT the zero-mention shape: five sites outside `packages/spec` ' + + 'read the key (the materialization fingerprint and the provider-context build in the ' + + 'automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider ' + + 'factories, and the `?? 30000` fallbacks that put it back on the reported def), but ' + + 'every one is a pass-through whose only termini are the def `GET /connectors` echoes ' + + 'and the fingerprint that decides whether to re-materialize. The one mapping from ' + + 'authored policy onto the platform\'s outbound `fetch` was handed `retryConfig` and ' + + '`requestTimeoutMs` only, so the key was carried and never honoured — the same ' + + 'parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was ' + + 'the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole ' + + 'operation and never the connect phase, so bounding time-to-response with it would ' + + 'kill a slow-but-connected upstream the author meant to allow with a large ' + + '`requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform ' + + 'can keep. The carrier key is a retiredKey tombstone on the non-strict ' + + '`ConnectorSchema` (a bare deletion would be a silent strip), registered under both ' + + 'def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers ' + + 'wrapping the same private `ConnectorBaseSchema`; the D2 conversion ' + + 'strips it from `connectors[]` as a pure lossless delete — it never had an effect to ' + + 'lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` ' + + 'door persists the authored value and the stored-row rehydration seam is live for ' + + 'this type, both measured); and the withdrawn `ConnectorProviderContext` member, ' + + 'which is code and has no authored source to rewrite, leaves via the paired semantic ' + + 'entry instead.', + }, + { + id: 'connector-resilience-keys-retired', + order: 44, + text: + 'It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): ' + + '`connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — ' + + '`connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no ' + + 'reader outside the spec package. No loop ever polled a connector endpoint or tripped a ' + + 'breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and ' + + 'participation is `enabled`); and a webhook nested in a connector was never registered as a ' + + '`webhook` item, so it was never materialized or delivered — the top-level `webhooks:` ' + + 'collection is the delivered one. The three carrier keys are retiredKey tombstones on ' + + '`ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted ' + + '`\'inactive\'`, joins `connectionTimeoutMs` in the retired-default residue stage, because ' + + 'every 17.x parse emitted it into every connector. Seven defs leave whole — ' + + '`ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, ' + + '`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion ' + + '`connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored ' + + 'rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them ' + + 'would start deliveries that never happened). It ABSORBS the breaker half of the duration ' + + 'rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer ' + + 'converted, because the whole block it lived in is now removed.', + }, + { + id: 'connector-sync-keys-retired', + order: 52, + text: + 'It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ' + + 'ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, ' + + '`timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and ' + + '`connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, ' + + '`syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` ' + + 'defaults read as configured policy and did nothing. The capability is mainstream, so the ' + + 'definition moves rather than lapses: every mainstream platform binds a sync to its ' + + 'TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls ' + + 'from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence ' + + '(no schedule key returns to the connector). That binding is declared in this step and ' + + 'executed in a later one. Both connector keys are retiredKey tombstones on ' + + '`ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, ' + + '`SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; ' + + 'and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` ' + + 'and stored rows as a pure lossless delete — never writing a `mapping`, which would start ' + + 'writes that never happened.', + }, + { + id: 'connector-triggers-retired', + order: 48, + text: + 'It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps ' + + 'connector-event triggers in its third tier, as their own trigger package): the ' + + '`ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and ' + + '`intervalSeconds` — was read by nothing. The automation engine registered a connector\'s ' + + 'actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever ' + + 'entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, ' + + 'so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on ' + + '`ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of ' + + 'the key, whose reason (the provider derives triggers) was untrue, is gone with it, since ' + + 'the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and ' + + 'the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and ' + + 'stored rows as a pure lossless delete — never turning a trigger into a flow, which is the ' + + 'author\'s decision (an `api` flow for an external event, a `schedule` flow for a scheduled ' + + 'pull, each calling the connector\'s action). It ABSORBS the trigger half of the connector ' + + 'duration rename (its breaker half went with `health` above), so ' + + '`connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer ' + + 'in this step.', + }, + { + id: 'cron-positions-deleted', + order: 26, + text: + 'It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ' + + 'ruling retired each family rather than marking it experimental): the two export-schedule crons, ' + + '`ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and ' + + 'the two disaster-recovery crons were parsed into the cron envelope and read by nothing ' + + '(the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no ' + + 'retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 ' + + 'semantic entry — so this step replays nothing for them and `migrate meta` lists no ' + + 'edit: the keys simply stop existing. That the chain is silent does NOT make the ' + + 'deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but ' + + 'above it `lintUnknownAuthoringKeys` names the dropped key for the one ' + + 'position a stack manifest reaches — `os validate` and `os build` both print ' + + '`connectors..syncConfig.schedule: \'schedule\' is not a declared connector ' + + 'key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that ' + + 'warning. The other six positions are unreachable from a manifest, so for those the ' + + 'parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 ' + + 'on the retirement PR, taken over the seat recommendation to keep the connector D2, on ' + + 'the reading that customers do not upgrade major by major in order.', + }, + { + id: 'cube-member-inner-name-retired', + order: 47, + text: + 'It retires the inner `name` on cube members — `measures..name` and ' + + '`dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: ' + + 'Cube.dev and LookML key a member by its declared name, with no second inner name that can ' + + 'disagree. Both member bags are records, and every consumer already resolved a member by its ' + + 'record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by ' + + 'nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey ' + + 'tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every ' + + 'stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it ' + + 'from every member of every cube, retired from the load path, and its notice prints a ' + + 'disagreeing value beside the key that stays. Its D3 record is the semantic entry ' + + '`cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling ' + + 'they meant.', + }, + { + id: 'cube-member-sql-expression-retired', + order: 53, + text: + 'It also narrows an analytics cube member\'s `sql` — `measures..sql` and ' + + '`dimensions..sql` — to a column reference: a field of the cube\'s object, a ' + + 'relationship path ending in one, or `\'*\'` (maintainer ruling D, ADR-0021 "zero raw ' + + 'SQL / zero raw expressions" carried from the dataset layer to the cube members it ' + + 'compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single ' + + 'field, so no platform check could judge which fields it reads, and the two analytics ' + + 'strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path ' + + 'refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset ' + + 'form — a measure with its own structured `filter` for a conditional count or sum, and ' + + '`derived: { op, of: [...] }` over named measures for a ratio, sum, difference or ' + + 'product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so ' + + 'the semantic entry `cube-member-sql-expression-retired` carries the move, including ' + + 'the scale change a ratio makes (a `derived` ratio is a 0–1 fraction).', + }, + { + id: 'cube-metric-expression-types-retired', + order: 62, + text: + 'It also retires a cube measure\'s custom-SQL-expression types — `number`, `string` and ' + + '`boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 ' + + 'enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with ' + + 'that `sql` now a column reference they had nothing left to compute: the raw-SQL path ' + + 'returned the column unaggregated and the ObjectQL path refused the measure. Each is refused ' + + 'at parse with a prescription naming the six aggregates. No D2 conversion: the column alone ' + + 'does not say which aggregate the author meant, so the semantic entry ' + + '`cube-metric-expression-types-retired` carries the choice, and a stored cube that still ' + + 'carries one is refused rather than rewritten.', + }, + { + id: 'cube-metric-filters-retired', + order: 10, + text: + 'It also retires `measures..filters` on analytics cubes (ADR-0049 ' + + 'enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both ' + + 'SQL strategies aggregate the metric\'s `sql` and never read `filters`, so a ' + + 'hand-authored `filters: [{ sql: "stage = \'closed_won\'" }]` parsed, registered, and ' + + 'silently returned the UNFILTERED aggregate under the author\'s metric name (the ' + + 'same defect the dataset path had, on a hand-authored cube; the dataset half was repaired ' + + 'through its own structured channel when the analytics strategy began compiling each ' + + 'dataset measure\'s `filter`). The raw-SQL fragment also ran against the ' + + 'platform\'s structured-FilterCondition direction — it cannot be parameterized, ' + + 're-targeted per driver dialect, or walked by the lint filter rules. The mechanical ' + + 'conversion strips the key from old sources (pure lossless delete — it never had an ' + + 'effect to lose); filter at query time with `where`, or use an ADR-0021 dataset ' + + 'measure\'s structured `filter` (a metric\'s own `sql` is a column reference, see ' + + '`cube-member-sql-expression-retired`).', + }, + { + id: 'cube-refresh-key-retired', + order: 49, + text: + 'It also retires a cube\'s `refreshKey` whole — the refresh cadence `every` and the ' + + 'data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no ' + + 'analytics result is cached, so a declared cadence refreshed nothing and every query was ' + + 'computed when it was asked, as it still is. The key is a retiredKey tombstone on ' + + '`CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from ' + + 'every cube as a pure lossless delete, retired from the load path. Its D3 record is the ' + + 'semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a ' + + 'result cache exists.', + }, + { + id: 'currency-config-precision-retired', + order: 41, + text: + 'It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): ' + + 'declared and validated against ISO 4217, read by no renderer or runtime — a currency ' + + 'amount\'s decimal places are its currency\'s ISO 4217 minor unit, derived from the ' + + 'currency itself. The D2 conversion `currency-config-precision-removed` strips it from ' + + 'every field\'s `currencyConfig` as a pure lossless delete, which matters most at rest: ' + + 'the schema used to bake `precision: 2` into parse output, so stored object rows and ' + + 'built artifacts carry it without anyone having written it. Retired from the load path; ' + + 'an authored key is refused with the prescription.', + }, + { + id: 'dashboard-widget-chart-config-structure-refused', + order: 32, + text: + 'It also states, and enforces, who owns a dataset-bound chart\'s STRUCTURE ' + + '(ADR-0021; maintainer ruling 2026-09-12): the dataset ' + + 'decides which series exist and which column each one reads, `chartConfig` carries ' + + 'appearance, and `dashboard.widgets[].chartConfig`\'s `type`, `xAxis`, `yAxis` and ' + + '`series` are refused by name on that carrier — the widget\'s own `type` is the chart ' + + 'family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a ' + + 'live membership channel: the renderer synthesised a series from it when the chart ' + + 'declared none, so one authored axis could silently re-point a dataset-bound series at ' + + 'another column and the chart still drew. The D2 conversion strips the four keys from ' + + 'dashboard widgets only — `ReportChartSchema` and the inline-data react `` ' + + 'tier keep their own axes — and the paired semantic entry carries what the stripped ' + + 'keys were saying, because an authored axis field may name a column the widget never ' + + 'selected and no walker can move that intent into the dataset.', + }, + { + id: 'dataset-member-field-expression-refused', + order: 58, + text: + 'It also holds an ADR-0021 dataset\'s `field` — `dimensions[].field` and `measures[].field` — to ' + + 'the accept set the cube members it compiles to already hold, from one shared declaration: a ' + + 'field of the dataset\'s object, a relationship path ending in one, and on a measure also ' + + '`\'*\'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The ' + + 'slot was a bare string that parsed any expression, while the analytics dataset door already ' + + 'refused one on every query, so an expression could be saved and never answered. It is now ' + + 'refused at parse with a prescription naming the ADR-0021 form — a measure with its own ' + + 'structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an ' + + 'empty string (a count omits `field` instead) and `\'*\'` on a dimension, which names no ' + + 'axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a ' + + '`count` measure\'s empty `field`, which still counts rows. An expression has no mechanical ' + + 'rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` ' + + 'carries the rest.', + }, + { + id: 'declared-index-bare-unique-true-retired', + order: 86, + text: + 'It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this ' + + 'protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was ' + + 'positional: it built the index over exactly `fields`, one holder across the whole ' + + 'installation, while reading like "unique per organization" to an author who knew the ' + + 'field-level meaning. The parse now refuses it with a prescription naming both words — ' + + '`\'global\'` (installation-wide, the index bare `true` built) and `\'organization\'` (one ' + + 'holder per organization). Field-level `unique: true` is untouched. The D2 conversion ' + + '`declared-index-unique-scope` rewrites a declared index\'s bare `true` to `\'global\'`, ' + + 'which is lossless and drift-free by construction, retired from the load path so authors ' + + 'are refused at the door while stored rows, built artifacts and `os migrate meta` replay ' + + 'it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: ' + + 'whether each respelled index was really meant installation-wide is the author\'s call.', + }, + { + id: 'deployment-plumbing-organization-columns-retired', + order: 86, + text: + 'It also takes the injected organization column off seven deployment-level platform tables — ' + + '`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, ' + + '`sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that ' + + 'attributes a row of any of them to an organization, so the column only ever held NULL, and under ' + + 'a walled posture the tenant wall hid every row from every reader. Each now declares ' + + '`systemFields: { tenant: false }` and the object-level capability gate ' + + '`requiredPermissions: [\'manage_platform_settings\']`: with no column there is no wall, so reads ' + + 'are governed by object permission, and the gate keeps one organization\'s administrator off ' + + 'another organization\'s rows. Nothing in stack metadata is rewritten; an existing database keeps ' + + 'the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` ' + + 'drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries.', + }, + { + id: 'document-schemas-retired', + order: 88, + text: + 'It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on ' + + 'PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print ' + + 'declaration; no new template type"): the four defs of `data/document.zod.ts` — ' + + '`data/DocumentTemplate` (a docx template with placeholders), `data/Document`, ' + + '`data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from ' + + '`@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by ' + + 'nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 ' + + 'semantic entry, so that "template" means one thing: a printable document is a page that ' + + 'declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def\'s source ' + + 'and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history.', + }, + { + id: 'duration-keys-unit-in-key', + order: 24, + text: + 'Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived ' + + 'only in its description into the key name (maintainer ruling 2026-09-02, ' + + 'no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` ' + + '(mechanical rename, retired from the load path), and the five keys with no stack ' + + 'seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and ' + + 'milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the ' + + 'tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the ' + + 'reference pages never published — are retiredKey tombstones with a ' + + 'semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and ' + + '`integration` remainder closes the same sweep: `dashboard.refreshInterval` → ' + + '`refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` ' + + '→ `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later ' + + 'absorbed by the removal of the block each key lived in — see the connector retirements ' + + 'below), and the two ' + + 'datasource config keys `memory config.persistence.autoSaveInterval` → ' + + '`autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the ' + + 'same file adapter, so splitting them would have left one value with two spellings) ' + + 'and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a ' + + 'connector and a datasource are stack collection members stored as rows; the two with ' + + 'no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, ' + + 'and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey ' + + 'tombstones with a semantic entry each. That remainder is what takes ' + + '`check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate ' + + 'goes red again by design when its declared population widens beyond that subtree.', + }, + { + id: 'element-filter-retired', + order: 7, + text: + 'Finally, it retires the whole `element:filter` element (ADR-0049 ' + + 'enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` ' + + 'retirement recorded and left for ' + + 'its own card): no renderer for the element ever shipped in any repo — objectui ' + + 'registers none, Studio\'s designer palette lists it as a no-renderer exclusion, and ' + + 'the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so ' + + 'every one of its six authorable keys was a capability claim nothing kept. All six ' + + 'are retiredKey tombstones; the mechanical conversion strips them from old sources ' + + '(pure lossless deletes) and leaves the bare node, which the parse then refuses by ' + + 'name — delete the component. List ' + + 'surfaces own their filtering: a view\'s `userFilters` quick-filter bar / the list ' + + 'toolbar\'s filter builder.', + }, + { + id: 'element-flat-data-binding-retired', + order: 90, + text: + 'It also retires the element layer\'s second data door (#11509, ruling A-narrow): the flat ' + + '`object` / `filter` / `sort` / `limit` keys of `element:record_picker` and ' + + '`element:repeater` and the flat `object` / `filter` of `element:number`, each the same query ' + + 'as a key of the node-level `dataSource` binding, resolved per element by three contradictory ' + + 'rules. The console moved all three elements onto the binding first, so from this major an ' + + 'element binds data through `dataSource` only; the keys are retiredKey tombstones, and the ' + + 'component-props gate requires `dataSource.object` on the three instead of waiving the flat ' + + 'key for them — which also closes the repeater that passed validation bound only through a ' + + 'binding it did not read. The D2 conversion `element-flat-data-binding-to-data-source` follows ' + + 'each element\'s old rule (move where the binding lacks the key, delete where the binding won, ' + + 'append `element:number`\'s filter, which AND-combined) and runs before the record-form filter ' + + 'conversion, which then converts a moved record form at `dataSource.filter`; what the old rule ' + + 'leaves undecided is a TODO, judged by the D3 entry.', + }, + { + id: 'element-form-retired', + order: 8, + text: + 'It also retires the whole `element:form` element (ADR-0049 enforce-or-remove ' + + 'at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement\'s own ' + + 'verdict sweep): no renderer for the element ever shipped in any repo — objectui ' + + 'registers none, Studio\'s designer palette lists it as a no-renderer exclusion ' + + 'naming the live replacement, and the 2026-06 page-liveness audit recorded it ' + + 'rendering "Unknown component type" — so every one of its six authorable keys was a ' + + 'capability claim nothing kept. All six are retiredKey tombstones; the mechanical ' + + 'conversion strips them from old sources (pure lossless deletes) and leaves the bare ' + + 'node, which the parse then refuses by name — delete the component. ' + + 'Use the object-bound `object-form` block instead ' + + '— rendered, designer-publishable, its props declared for the component-props gate, and ' + + 'carrying the same intent ' + + '(`objectName`, `fields`, `mode`, `submitText`).', + }, + { + id: 'element-input-target-variable-retired', + order: 6, + text: + 'It also retires `targetVariable` on `element:text_input` and ' + + '`element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint ' + + 'with zero readers in any repo — the live binding runs the other direction, ' + + 'resolved from the page variable whose `source` names the component\'s `id` ' + + '(PageVariableSchema) — so an author who wrote only `targetVariable` got an input ' + + 'that wrote nothing, with a success receipt. The mechanical conversion strips the ' + + 'key from old sources (pure lossless delete — it never had an effect to lose); ' + + 'the tombstone\'s prescription says how to declare the binding that works.', + }, + { + id: 'element-text-variant-heading-subheading-retired', + order: 68, + text: + 'It also completes the `element:text` `variant` convergence (the second release of ' + + 'the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, ' + + '`body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, ' + + 'which every release since the nine were added still accepted, are refused by name with a ' + + 'prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` ' + + 'rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — ' + + 'the heading element each one always rendered, so the outline is unchanged and the heading ' + + 'takes that level\'s style. The `body` default for an absent `variant` is unchanged.', + }, + { + id: 'field-inline-and-related-list-columns-closed', + order: 9, + text: + 'It also closes the two explicit column lists on relationship fields: ' + + '`field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema ' + + '(mirroring the objectui grid renderer\'s measured reads — objectui aligned the ' + + 'widget to `name` and retired the `field` spelling with no tolerant alias), and ' + + '`field.relatedListColumns` entries are child field-name strings (the only form the ' + + 'related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed ' + + 'column published clean and rendered as blank cells with the right row count. The ' + + 'mechanical conversion respells inline `{ field }` entries as `{ name }` and folds ' + + 'related-list column objects to their identity string; unknown keys are named ' + + 'rejections at publish from this major.', + }, + { + id: 'field-reference-to-spelling-retired', + order: 21, + text: + 'Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → ' + + '`reference` on lookup/master_detail fields (the server half of the maintainer\'s 2026-08-31 ' + + 'ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` ' + + 'has always refused `reference_to` by name, but stored `sys_metadata` rows written by ' + + 'seams that bypass the parse still carry it, held up today only by objectui\'s ' + + '`reference ?? reference_to` fallback arms — which the ruling\'s objectui half deletes. The ' + + 'mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical ' + + '`reference` wins, a disagreeing pair is kept for the author), replays on every ' + + 'stored-row rehydration so the serve face only ever emits the canonical spelling, ' + + 'and `os migrate meta` rewrites old sources; the authoring-surface rejection with ' + + 'its rename prescription is unchanged.', + }, + { + id: 'field-scale-precision-integer-refused', + order: 3, + text: + 'It also refuses malformed field `scale`/' + + '`precision` declarations: both are digit counts, so a non-integer or ' + + 'negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time ' + + '`scale` check, which refuses an over-scale value rather than rounding it, deliberately ' + + 'left it unenforced rather than invent ' + + 'floor/round semantics, which made the declaration silently inert. The schema now ' + + 'refuses both at parse (`z.number().int().min(0)`); the mechanical conversion ' + + 'deletes a malformed value from old sources and stored rows (behaviour-preserving), ' + + 'and the semantic entry tells the author to re-declare the count they meant.', + }, + { + id: 'flow-approval-node-config-contract-refused', + order: 84, + text: + 'It also judges an `approval` flow node\'s `config` at parse against the contract the spec ' + + 'declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any ' + + 'issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an ' + + 'undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the ' + + 'artifact. The approval node now joins a declared contract map beside the builtin executor ' + + 'contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin ' + + 'loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the ' + + 'contract\'s own words, its did-you-mean included, and a key left out as before. The builtin arm ' + + 'stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: ' + + 'the platform cannot know what the author meant. Its D3 record is the semantic entry ' + + '`flow-approval-node-config-contract-refused`.', + }, + { + id: 'flow-binding-variable-dollar-name-refused', + order: 92, + text: + 'The binding keys follow the same rule, so a flow cannot bind a `$` name it is then refused to read: ' + + 'a node\'s `outputVariable` (`get_record`, `create_record`, `map`, `script`, `subflow`) refuses a name ' + + 'that starts with `$`, and a `try_catch` `errorVariable` refuses every one but the engine\'s own ' + + '`$error`, its default. The remedy is the same name without the `$`, read as `{{ name }}`. Each key ' + + 'states the rule as a `pattern`, so the published JSON Schema refuses what the parse refuses, and the ' + + 'node contract, `registerFlow`, `objectstack validate` and the run itself refuse such a name at the ' + + 'key. No D2 conversion exists: the bare name may already be bound in the flow, and the reads of the ' + + 'old name sit in every dialect a flow string speaks, so the rename is the author\'s. Its D3 record is ' + + 'the semantic entry `flow-binding-variable-dollar-name-refused`.', + }, + { + id: 'flow-builtin-node-config-undeclared-keys-refused', + order: 89, + text: + 'Then the builtin key arm covers every builtin whose contract registration could judge: a key the ' + + 'executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, ' + + '`http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at ' + + '`nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the ' + + 'rename-or-remove remedy. Registration\'s descriptor walk refused those keys already, after ' + + '`objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those ' + + 'types, so each has one judge; the declared key sets were measured equal first, so registration ' + + 'refuses what it refused before. `try_catch` waits for its contract\'s `retry` to close (below): it ' + + 'stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 ' + + 'conversion exists. Its D3 record is the semantic entry ' + + '`flow-builtin-node-config-undeclared-keys-refused`.', + }, + { + id: 'flow-builtin-node-config-values-refused', + order: 85, + text: + 'Then the builtin arm stops being presence-only: a present value a builtin node\'s executor ' + + 'contract refuses is refused at parse, at `nodes.N.config.`, with the same ' + + '`node-config-refused-by-contract` code. Every builtin executor parses its config against that ' + + 'contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: \'1\'` ' + + 'used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that ' + + 'reached the node. The arm judges only what the build can know the run will parse: never a value ' + + 'carrying a `{token}`, whatever its slot\'s type (held back by ruling, not admitted: outside `http` ' + + 'such a token in a number or boolean slot still fails at its first run, so those slots take a ' + + 'literal); on `http`, which parses after interpolating, only ' + + 'token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a ' + + '`body`; on the region containers, never the region slots. Key membership is untouched. No key ' + + 'is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the ' + + 'value the author meant. Its D3 record is the semantic entry ' + + '`flow-builtin-node-config-values-refused`.', + }, + { + id: 'flow-cel-unbound-root-refused', + order: 92, + text: + 'It also refuses, at `objectstack validate`, a flow CEL root the flow does not bind — in a trigger gate, an ' + + 'edge, a `decision` branch, a screen field `visibleWhen` or a CEL value envelope. Such a root fails every run ' + + 'that reaches it with an unknown-variable fault, and the build used to pass it. The run-user spellings ' + + 'formulas, row-level security and the client accept (`user`, `ctx.user`, `os.user`) are the reachable case: ' + + 'flow CEL binds the run\'s user as `current_user` only, and the refusal names it. A flow whose run can bind a ' + + 'name the reader cannot see is left unjudged — through a node, or through an entrance the stack declares that ' + + 'hands it a record whose keys are not in hand — and so, for now, is every flow write at the runtime publish ' + + 'gate, whose snapshot carries no actions or other flows. No D2 conversion exists: none of these roots ever ' + + 'evaluated in a flow. Its D3 record is the semantic entry `flow-cel-unbound-root-refused`.', + }, + { + id: 'flow-decision-edge-branching-first-match', + order: 45, + text: + 'Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, ' + + '「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the ' + + 'branch, and taking every true branch is the declared `mode: \'inclusive\'`. The D2 ' + + 'conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision ' + + 'with two or more conditioned out-edges and no `conditions` list, so a flow written while ' + + 'every true branch ran keeps its behaviour; it is a default flip, so it is retired from ' + + 'the load path AND refused by the flow rehydration seam and the artifact-ingestion door, ' + + 'and replays only here — the paired semantic entry carries the judgment the diff then ' + + 'asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a ' + + 'decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites ' + + 'it; `os migrate meta --stored` lists each one for review, and `mode: \'inclusive\'` is ' + + 'the one-line fix where a node meant every branch.', + }, + { + id: 'flow-edge-unresolved-or-repeated-refused', + order: 86, + text: + 'It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats ' + + 'an earlier one. An edge\'s `source` and `target` must name nodes of the graph that declares ' + + 'it — the flow\'s own nodes, or the region body\'s for an edge inside a region — because the ' + + 'engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and ' + + 'an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier ' + + 'edge of that graph is refused, because the engine runs a target once per out-edge it selects ' + + 'and a copy ran it again. Both are judged in the region walk the node-id rule uses, so ' + + '`objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, ' + + 'so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent ' + + 'a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record ' + + 'is the semantic entry `flow-edge-unresolved-or-repeated-refused`.', + }, + { + id: 'flow-script-subflow-config-undeclared-keys-refused', + order: 87, + text: + 'And the builtin arm judges key membership where no other door does: a key a `script` or ' + + '`subflow` node\'s executor contract does not declare is refused at parse, at ' + + '`nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two ' + + 'descriptors publish no `configSchema`, so `registerFlow`\'s undeclared-key check skipped them, ' + + 'while their executors parse the strict contract and refuse the node on an undeclared key: a ' + + '`script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration ' + + 'and fail every run that reached the node. Every other builtin keeps its undeclared keys at ' + + 'registration, against its descriptor; a retired `script` key keeps its tombstone. No key is ' + + 'removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what ' + + 'an undeclared key was meant to be. Its D3 record is the semantic entry ' + + '`flow-script-subflow-config-undeclared-keys-refused`.', + }, + { + id: 'flow-text-slot-single-brace-refused', + order: 89, + text: + 'It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node\'s `title` and `message`, ' + + 'a `screen` node\'s `title` and `description`, a refusing `end` node\'s `message`: they render ' + + 'through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with ' + + 'an optional formatter (the engine\'s hole grammar now admits a `$`-named variable, so ' + + '`{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, ' + + '`registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for ' + + 'arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a ' + + 'variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently ' + + '(JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so ' + + 'the rewrite is the author\'s to check. Every other flow string keeps the single-brace dialect. Its ' + + 'D3 record is the semantic entry `flow-text-slot-single-brace-refused`.', + }, + { + id: 'flow-text-slot-unbound-dollar-root-refused', + order: 91, + text: + 'In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine ' + + 'binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`\'s ' + + '`$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a ' + + 'plain string and which renders nothing under the template engine, is refused by the node contract, ' + + '`registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the ' + + 'value with an `assignment` node, then write the variable as a hole. The template engine binds no new ' + + 'variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 ' + + 'record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`.', + }, + { + id: 'flow-value-slot-template-dialect-refused', + order: 88, + text: + 'It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the ' + + 'maintainer\'s ruling D on the flow expression dialects): the `assignment` node\'s values, in all ' + + 'three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value ' + + 'envelope is already the expression form. A string there is now the literal ' + + 'text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, ' + + '`registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. ' + + 'No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing ' + + 'under the template and fails under CEL; CEL divides two integers as integers), so which value an ' + + 'absent key should write is the author\'s judgment. The `$User` paths are refused too: the flow CEL ' + + 'scope binds `current_user`, the run\'s user or `null` in a run with none, so `{$User.Id}` is ' + + '`current_user.id`, guarded where a flow can run without a user, and the other `$User` paths, which ' + + 'never resolved, name a read of the user record. The date macros are refused too, with their CEL ' + + 'string form (`{TODAY() + 7}` is `isoDate(daysFromNow(7))`, `{NOW()}` is `isoDatetime(now())`), so no ' + + 'single-brace spelling is kept. Its D3 record is the semantic entry ' + + '`flow-value-slot-template-dialect-refused`.', + }, + { + id: 'flow-write-node-stored-metadata-target-refused', + order: 74, + text: + 'It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose ' + + '`objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of ' + + '2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only ' + + 'writer is the metadata protocol). The runtime already refused such a node before any write, at its ' + + 'first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at ' + + '`nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, ' + + 'with the runtime\'s prescription to change metadata through the metadata API: one of those three ' + + 'write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic ' + + 'target stay outside it: the run judges the name it hands the data engine. No key is removed, so there ' + + 'is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. ' + + 'Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`.', + }, + { + id: 'form-field-public-picker-retired', + order: 56, + text: + 'Finally, it retires the form field\'s `publicPicker` block (ADR-0087 D2, immediate — the ' + + 'maintainer\'s ruling E, which reverses the earlier ruling that had declared it): an ' + + 'anonymous public form no longer offers record search. The block opted a lookup, ' + + '`master_detail` or `user` field on a public form into a picker served by an ' + + 'unauthenticated route; that route is deleted, and the public-form resolve route now ' + + 'leaves those three field types off the anonymous rendering unconditionally. The schema ' + + 'refuses the key with the prescription; the mechanical conversion ' + + '`form-field-public-picker-removed` strips it from old sources and stored rows (lossless ' + + 'in effect — its only reader was the deleted route), and the semantic entry asks the ' + + 'author how a visitor should now choose: a `select` field with static `options`, or a ' + + 'form behind sign-in.', + }, + { + id: 'form-view-option-default-retired', + order: 19, + text: + 'Finally, it narrows the per-option `default` key OUT of the form-view options ' + + 'vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 ' + + 'on the console form renderer\'s analysis, disposition 甲): `SelectOptionSchema` serves ' + + 'two surfaces and only the OBJECT-field face reads `default` (enforced there by a ' + + 'maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked ' + + '`default: true`; that face, its alias rows and its precedence pin are untouched). On a ' + + 'form-view field\'s option list ' + + 'the key parsed clean and nothing read it — the insert-path fallback consults the ' + + 'object definition\'s options, never a form view\'s, and no form renderer seeds a value ' + + 'from it (measured against the console\'s form controls, none of which reads the key; ' + + 'the ruled census found ZERO authored occurrences ' + + 'across the tree, the example apps and the published *.form.ts corpus). The FormView ' + + 'vocabulary\'s own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses ' + + 'the key with the prescription; the mechanical conversion strips it from stored ' + + 'sources (pure lossless delete — it never had an effect on this surface to lose).', + }, + { + id: 'form-view-subform-columns-closed', + order: 54, + text: + 'It also closes the form view\'s inline grid columns: `subforms[].columns`, on `view.form` ' + + 'and on `formViews` entries, was `z.array(z.any())` while a relationship field\'s ' + + '`inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column ' + + 'published clean and drew a blank grid column, and `scale` on a currency column, which the ' + + 'other carrier refuses under the maintainer\'s rulings of 2026-09-23 (option B) and ' + + '2026-09-24 (option 乙), published green. The carrier now references that schema, so both ' + + 'carriers are judged by it, with its own prescriptions. The D2 conversion ' + + '`form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the ' + + 'respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored ' + + 'rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired ' + + 'from the load path, so an author writing `field` meets the refusal. A view saved with a ' + + 'failing column is refused with the column schema\'s prescription, and a stored row carrying ' + + 'one is diagnosed at rehydration; neither is stripped, because which column an unknown key ' + + 'or a mixed `field`/`name` entry meant is the author\'s call, and a conversion that dropped ' + + 'the key would accept at load what the parse now refuses. Its D3 record is the semantic ' + + 'entry `form-view-subform-columns-closed`. On both carriers, the reach of ' + + '`inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: ' + + 'such a column takes its type from the child field, which the column schema cannot see, ' + + 'when the console hydrates it, so `defineStack`\'s cross-reference check re-parses a column ' + + 'whose `name` is a `currency` field of the child object as the type it renders as, and the ' + + 'refusal of its `scale` is the column schema\'s own. Reach: the child object must be ' + + 'declared in the same stack; a column naming no field of it, or a subform whose child ' + + 'object comes from another package, is not judged there. It has no D2 conversion, for the ' + + 'declared-type entry\'s reason: deleting the key is the migration, and a conversion that ' + + 'dropped it would accept it at load, the grace window ruling B refused. Its D3 record is ' + + 'the semantic entry `inline-grid-column-identity-only-currency-scale-refused`.', + }, + { + id: 'hook-body-stored-metadata-target-refused', + order: 67, + text: + 'It also refuses, at parse, a hook whose `body` targets a table of stored metadata, ' + + '`sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an ' + + 'app-authored body may not touch those tables, whose only writer for a body is the metadata ' + + 'protocol). The runtime already refused such a hook where a body becomes a handler, so it ' + + 'never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the ' + + 'same set at `object`, or at the list member, with the runtime\'s prescription to change ' + + 'metadata through the metadata API, judged by the one predicate the runtime uses: a hook ' + + 'with a `body` in any form whose target names either table. A code `handler` and the ' + + 'wildcard `\'*\'` stay outside it, as they are at registration. No key is removed, so there is ' + + 'no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could ' + + 'keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`.', + }, + { + id: 'list-view-page-mount-retired', + order: 27, + text: + 'It also retires the `type: \'page\'` LIST-VIEW mount and its `pageName` binding (' + + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a ' + + 'view could render nothing of its own and delegate to an already-published page, but only ' + + 'the spec half landed: no renderer ever routed it — objectui\'s list-view switch shares its ' + + 'default arm with `grid` — so a page view drew an empty table where the page belonged, and ' + + 'the three parse refusals policing the binding policed a mount that never mounted anything. ' + + 'The enum VALUE carries its prescription on the `type` enum\'s own error map (an enum-value ' + + 'narrowing has no tombstone to hang one on, the `exportOptions` \'pdf\' precedent); ' + + '`pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS ' + + 'both keys rather than rewriting `type` to `\'grid\'`: `type` defaults to `grid` in the ' + + 'schema, so deleting it lands the row on exactly what it already rendered without this ' + + 'registry guessing a view type. The surviving page mount is the app navigation item ' + + '(`PageNavItem.pageName`), untouched.', + }, + { + id: 'list-view-sort-string-clause-retired', + order: 29, + text: + 'It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy ' + + 'string clause is retired, one spelling, the array). This is the PRODUCER ' + + 'half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now ' + + 'refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer ' + + 'rejects — a document that validated upstream failed downstream, and the author was told off ' + + 'by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to ' + + 'hang a prescription on, so the surviving array member\'s own error map carries it, keyed on ' + + '`issue.input` being a string. The D2 conversion REWRITES rather than strips, because the ' + + 'clause is losslessly mechanical: `\'created_at desc\'` is the tuple `{ field, order }`, a bare ' + + 'field name meant ascending and is written out as `order: \'asc\'`, and the comma-separated ' + + 'multi-key form becomes one entry per key in the same order. A string that does not parse as ' + + 'that grammar — the `\'-field\'` dialect above all — is left alone and meets the door instead: ' + + 'that dialect belongs to `RecordRelatedListProps.sort`, never reaches ' + + '`convertSortToQueryParams`, and retiring it was NOT ruled.', + }, + { + id: 'list-view-tabs-retired', + order: 46, + text: + 'It also retires the list view\'s own `tabs` (ADR-0049 enforce-or-remove). The ' + + 'key parsed and was stored at every list-view door and drew nothing: a list view\'s own ' + + '`tabs` has no reader, the one component that would draw it has no production mount, ' + + 'and the tab strip above an object\'s records is the saved-view switcher, which renders ' + + 'one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different ' + + 'key of the same element type, is read and rendered, and stays). The key is a ' + + '`retiredKey()` tombstone on the list-view shape (its ' + + 'prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` ' + + 'itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. ' + + 'The D2 conversion `view-list-tabs-removed` strips the key from every list payload in ' + + '`stack.views[]` as a lossless delete, and is retired from the load path, so authors are ' + + 'refused at parse rather than rewritten.', + }, + { + id: 'manifest-permissions-string-list-retired', + order: 85, + text: + 'It also retires the flat-list form of a package manifest\'s `permissions` (ADR-0049 ' + + 'enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings ' + + 'and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted ' + + 'on the list — the loader registers the consented grant set, never the manifest\'s request — ' + + 'so the block is now the only form. A list is refused at parse with its prescription, and the ' + + 'D2 conversion `manifest-permissions-string-list-removed` strips it from the stack\'s manifest ' + + 'and every `packages[].manifest` as a lossless delete, retired from the load path; translating ' + + 'what each dropped string meant into the four lists is the author\'s judgement, not a rewrite.', + }, + { + id: 'mapping-lookup-params-retired', + order: 13, + text: + 'It also retires the import mapping `lookup` transform\'s steering params (ADR-0049 ' + + 'enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired ' + + '`extractQuery` / `errorPolicy` / `batchSize`): ' + + '`fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a ' + + 'per-entry reference-resolution dialect the import path never implemented — `lookup` ' + + 'copies the cell through and resolution runs off the target field\'s own metadata — ' + + 'and `autoCreate` read as create-if-missing while an unresolved reference actually ' + + 'fails the row (`import_reference_not_found`), with or without the key. The eleven ' + + 'alias spellings convert to guidance so every spelling lands on the prescription; the ' + + 'mechanical conversion strips the four keys from stored sources (pure lossless ' + + 'deletes — none ever had an effect to lose).', + }, + { + id: 'memory-persistence-placeholder-refused', + order: 1, + text: + 'Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol ' + + '17 applied to datasource connection config, to the memory ' + + 'driver\'s config-material persistence keys: `persistence.path` (file persistence ' + + 'and the `auto` override) and `persistence.key` (localStorage and the `auto` ' + + 'override) refuse `${…}` placeholder syntax at publish. Nothing resolves a ' + + 'placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or ' + + 'write under the literal localStorage key — the same authored-under-a-false-belief ' + + 'shape, one surface over. The memory driver\'s `initialData` stays deliberately ' + + 'unjudged: it carries arbitrary record values, where a literal `${…}` may be ' + + 'legitimate data.', + }, + { + id: 'metadata-customization-protocol-retired', + order: 20, + text: + 'It also retires the paper metadata-customization protocol whole (ADR-0049 ' + + 'enforce-or-remove, maintainer ruling 2026-08-29): ' + + '`kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay ' + + 'model with field-level change tracking and a 3-way-merge story — was exported, ' + + 'documented as the customization architecture, and implemented ONLY by an unreachable ' + + '`packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` ' + + 'endpoints; the four optional service members were called only by their own unit ' + + 'tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against ' + + 'it"). The module\'s seven defs and the three section-5 API contracts leave via ' + + 'RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.' + + 'customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.' + + 'overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager ' + + 'configs are not stack collection members, the additionalTypes reasoning). The ' + + 'customization that actually ships: ADR-0005\'s org overlay and ADR-0126\'s ' + + 'packaged-metadata model.', + }, + { + id: 'metadata-manager-config-inert-cache-keys-retired', + order: 25, + text: + 'It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, ' + + '`ttlSeconds` (the duration rename\'s respelling of `ttl`, never shipped) and `maxSize` — that the ' + + 'rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and ' + + 'published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the ' + + 'loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey ' + + 'tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 ' + + 'conversion (a manager config is no stack collection member); the rename is folded into ' + + 'the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key.', + }, + { + id: 'metadata-plugin-additional-types-retired', + order: 2, + text: + 'It also retires `MetadataPluginConfig.additionalTypes` (' + + 'ADR-0049 enforce-or-remove): the key was documented as THE plugin ' + + 'kind-declaration channel and read by nothing — the manager\'s type registry is ' + + 'seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so ' + + 'authoring it configured nothing. A kind enters the live set as a side effect of ' + + 'registering an item of that kind.', + }, + { + id: 'object-grid-default-filters-retired', + order: 90, + text: + 'It also retires `object-grid`\'s `defaultFilters` (#11509, ruling A-narrow, in the shape ' + + 'of the `defaultSort` retirement): the legacy second spelling of `filter`, read only when ' + + '`filter` lowered to nothing, which an earlier narrowing in this major had shaped as the rule ' + + 'array and this retirement absorbs. The mechanical conversion moves the rules onto an empty ' + + '`filter` and deletes the key beside a `filter` with content (the renderer never read it ' + + 'there); it runs before the record-form filter conversion, which then converts a moved record ' + + 'form at `filter`.', + }, + { + id: 'object-grid-default-sort-retired', + order: 17, + text: + 'Finally, it retires `object-grid`\'s `defaultSort` (ADR-0049 enforce-or-remove; ' + + 'maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of ' + + 'objectui\'s `table.defaultSort` retirement, which the maintainer\'s 2026-08-22 「接受所有」 ruling ' + + 'on objectui\'s sort sink ordered): the legacy second ' + + 'spelling of `sort`, a single `{ field, order }` pair the renderer read only when ' + + '`sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, ' + + '`plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it ' + + '`[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two ' + + 'spellings; objectui\'s mirror schema is parity-test-only and parses nothing at ' + + 'runtime, so only the spec strictObject can refuse the key. The mechanical ' + + 'conversion carries the pair over — renamed to `sort` and wrapped in the array ' + + 'shape — when `sort` is absent, and strips it as a pure lossless delete when ' + + '`sort` is present (the renderer\'s own precedence made it unread then).', + }, + { + id: 'object-grid-resizable-columns-retired', + order: 62, + text: + 'It also retires `object-grid`\'s `resizableColumns` (ADR-0049 enforce-or-remove; ' + + 'objectui\'s ruling that `resizable` is canonical, under the startup rule of immediate ' + + 'retirement): the legacy second spelling of `resizable`, read only as ' + + '`schema.resizable ?? schema.resizableColumns` (measured at the ' + + '`.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two ' + + 'spellings, and zero ' + + 'writers in either repository, so there is no window. A retiredKey tombstone on ' + + '`ObjectGridPropsSchema` with one D2 conversion that follows the renderer\'s precedence: the ' + + 'value moves to `resizable` when that is absent, and strips as a lossless delete when it is ' + + 'present (it was never read then). Its D3 record is the semantic entry ' + + '`object-grid-resizable-columns-retired`.', + }, + { + id: 'object-kanban-quick-add-retired', + order: 28, + text: + 'It also retires `object-kanban`\'s `quickAdd` (ADR-0049 enforce-or-remove; the spec ' + + 'half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation ' + + 'path and retires the key). The board FORWARDED the key into the shared renderer but the ' + + 'affordance is gated ' + + 'on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON ' + + 'cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently ' + + 'false. The drop was NOT silent, and that is what made it worse than silence: objectui\'s ' + + 'html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so ' + + 'an author following the contract met a tool contradicting it with no way to tell which side ' + + 'was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that ' + + 'is a pure lossless DELETE (the key never had an effect to preserve) scoped by component ' + + '`type`. Delete the key; `object-kanban` offers no quick-add control.', + }, + { + id: 'object-master-detail-form-detail-sort-field-retired', + order: 70, + text: + 'It also retires an `object-master-detail-form` detail entry\'s `sortField` (ADR-0049 ' + + 'enforce-or-remove; the spec half of objectui\'s own retirement of the override). The console ' + + 'stopped reading the ' + + 'authored override: the field its line grid stamps with each line\'s position on ' + + 'drag-reorder is derived from the child object — its first field named `position`, ' + + '`sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had ' + + 'crossed that change while the spec still declared the key, so an authored value published ' + + 'green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 ' + + 'conversion that is a pure lossless DELETE scoped by component `type` and by position ' + + '(`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` ' + + 'carries the one judgment left, whether the child object declares the field the line order ' + + 'is kept in.', + }, + { + id: 'object-tenancy-organization-field-retired', + order: 34, + text: + 'Finally it retires object `tenancy.organizationField` (ADR-0049 ' + + 'enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as ' + + 'opposed to the column the object is WALLED by (`tenantField`); on an ordinary object ' + + 'those are the same column, and the entire protocol declared it exactly once — on ' + + '`sys_api_key`, a better-auth-managed credential table this platform ships and no ' + + 'application authors. Its three readers were all platform-row writers, scope-pinned by ' + + 'name, so an application declaration was inert by construction while still forcing ' + + 'every future piece of organization logic to ask "what if somebody set this?". The ' + + 'divergence is NOT retired, only its authorability: it moves to ' + + '`PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object ' + + 'name and read by the stamp face alone, so audit stamping, the approval-row writer and ' + + 'the automation-run recorder keep their behaviour with no authorable input. The ' + + 'conversion is a lossless delete, and a lossless delete still leaves the author a ' + + 'judgment, which the family\'s D3 entry `object-tenancy-organization-field-retired` ' + + 'carries — an application whose tenant column genuinely is not `organization_id` ' + + 'declares `tenancy.tenantField`, which both walls the object and stamps its platform ' + + 'rows.', + }, + { + id: 'page-assigned-profiles-audience-to-permission-set', + order: 30, + text: + 'It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; ' + + 'maintainer ruling 2026-09-12 \u300c\u540c\u610f\u300d). The key was authorable on the published ' + + '`PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema\'s own ' + + 'alias table CORRECTED an authored `profiles:` into it — two files from ' + + '`security/permission.zod.ts` answering the same word with "no Profile concept". Measured ' + + 'across this repository and objectui it had zero readers, so a page that "assigned ' + + 'profiles" was open to every caller who could reach it. It is a retiredKey tombstone on ' + + '`PageSchema` — the def is still parsed from the `page` root, so there is an author to ' + + 'teach — and the two alias entries became refusals naming the permission-set route. The ' + + 'D2 conversion STRIPS the key — there is no lossless target, because which permission set ' + + 'a given profile name corresponds to is a judgement no walker can make, which is what the ' + + 'paired D3 semantic entry is for.', + }, + { + id: 'page-component-filter-record-to-rule-array', + order: 36, + text: + 'Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter ' + + 'spelling platform-wide, the rule array) its ' + + 'mechanical half at rest (ruled 2026-09-12): the D2 conversion ' + + '`page-component-filter-record-to-rule-array` rewrites a record-form or single-level ' + + 'AST `filter` at the converged rule-array doors — `dataSource.filter` and the ' + + '`object-*` `filter` props — to the rule array wherever the mapping is lossless, ' + + 'and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless ' + + 'rule spelling) exactly as stored, because flattening a combinator changes which rows ' + + 'a page selects. It is retired from the load path, so authors are still refused at ' + + 'the door and taught the array; the stored-row seams and this chain replay it.', + }, + { + id: 'page-component-responsive-retired', + order: 15, + text: + 'Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` ' + + 'layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the ' + + 'key was the destination the `dashboard.widgets[].responsive` tombstone ' + + 'prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology ' + + 'with positive and negative controls) found the claim false — objectui\'s two ' + + 'implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero ' + + 'callers and nothing read `.responsive` off a page component, so the prescribed ' + + 'migration moved an inert key to an inert key while the platform\'s own error message ' + + 'vouched for it. The same change repairs every shipped text that carried that redirect. ' + + '`ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no ' + + 'other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live ' + + 'per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which ' + + 'objectui really compiles. The mechanical conversion strips the key from stored pages ' + + '(pure lossless delete — it never had an effect to lose).', + }, + { + id: 'page-header-breadcrumb-retired', + order: 51, + text: + 'It also retires the page header\'s `breadcrumb` switch (ADR-0049 enforce-or-remove): no ' + + 'renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and ' + + 'the navigation trail is drawn once, by the app shell\'s header. The key is a retiredKey ' + + 'tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 ' + + 'conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and ' + + '`false` alike, retired from the load path. Its D3 record is the semantic entry ' + + '`page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: ' + + 'the Studio page palette still offers it.', + }, + { + id: 'page-requires-non-compiled-kind-refused', + order: 66, + text: + 'It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; ' + + 'maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html ' + + 'page\'s source when the page is saved, while on `react`, `full` and `slotted` pages nothing ' + + 'derived it, the Studio page editor dropped it on every save, and a load-time warning was its ' + + 'one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated ' + + 'alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` ' + + 'included, naming the key, the page\'s kind and the compiled kinds. The key stays live on html ' + + 'pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` ' + + 'deletes the key from those pages, retired from the load path, so stored rows and artifacts ' + + 'replay clean while authored sources are refused until edited; the delete is lossless. Its D3 ' + + 'record is the semantic entry `page-requires-non-compiled-kind-refused`.', + }, + { + id: 'page-slots-details-beside-tabs-refused', + order: 92, + text: + 'It also refuses a page that authors both `slots.details` and `slots.tabs`. The two slots read ' + + 'as independent and are not: `details` is the body of the Details tab, that tab lives inside ' + + 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip, so the console\'s ' + + 'synthesizer never read a `details` authored beside it and its sections and hidden fields ' + + 'silently never applied. `PageSchema` now refuses the pair at `slots.details`, naming both ' + + 'slots and the fix — the `record:details` component as the `children` of a `tabs` item — so ' + + '`objectstack validate` and the metadata save door agree; the platform\'s own `sys_user_detail` ' + + 'page moved its details body into its first tab. No key is removed, so there is no tombstone, ' + + 'and no D2 conversion exists: which tab carries the body is the author\'s decision. Its D3 ' + + 'record is the semantic entry `page-slots-details-beside-tabs-refused`.', + }, + { + id: 'permission-restore-purge-bits-retired', + order: 18, + text: + 'Finally, it retires the object-permission lifecycle bits `allowRestore` and ' + + '`allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, ' + + 'decision-inbox batch 5, which chose retiring the two bits over gating operations that do ' + + 'not exist): the `restore` / ' + + '`purge` ObjectQL operations the bits claimed to gate have never existed — no ' + + 'destructive lifecycle verb is in the engine\'s dispatch vocabulary, which a test pins — ' + + 'so granting the bits delivered nothing, and an author who declared ' + + '`allowPurge: false` believed a lock on GDPR hard-deletion existed when the ' + + 'operation itself did not. Both keys are retiredKey tombstones; the evaluator\'s ' + + 'pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays ' + + 'denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ' + + 'ungated window), and the mechanical conversion strips the keys from every object ' + + 'grant in `permissions[].objects` (pure lossless delete — they never had an ' + + 'effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record\'s ' + + 'owner — and stays. The keys return ' + + 'with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their ' + + 'anchor.', + }, + { + id: 'permission-rls-tags-retired', + order: 42, + text: + 'It also retires the RLS policy\'s `tags` (ADR-0049 enforce-or-remove; graded ' + + 'RETIRE by the maintainer\'s criterion — no mainstream platform tags a row-level policy): ' + + 'the key promised categorization and reporting for governance and compliance, and nothing ' + + 'ever read it — the RLS compiler never consulted it and no preview rendered it. It is a ' + + '`retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key ' + + 'over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in ' + + '`permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that ' + + 'still carries it replays clean. It is retired from the load path, so authors are refused ' + + 'at parse rather than rewritten. Its D3 record is the semantic entry ' + + '`permission-rls-tags-retired`.', + }, + { + id: 'platform-global-object-organization-column-retired', + order: 89, + text: + 'It also makes the deployment\'s platform-global declaration total (ADR-0131 D7): an object a deployment ' + + 'declares platform-global in its `org-scoping` service\'s `platformGlobalObjects` gets no ' + + 'organization column on that deployment, because the injected-columns plan reads the declaration, ' + + 'so the organization wall and the driver agree by having nothing to scope. The engine reads it at ' + + 'its plugin start, before the first schema sync, once every plugin init has run, and re-plans the ' + + 'objects registered before it; the security layer\'s stand-down for such an object retires with ' + + 'it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. ' + + 'Nothing moves automatically: a declaring deployment\'s existing table keeps the column as an ' + + 'orphan the boot drift report names. The D3 record is the ' + + '`platform-global-object-organization-column-retired` semantic entry.', + }, + { + id: 'plugin-manifest-contributes-dead-members-retired', + order: 16, + text: + 'Finally, it retires nine of the eleven members of the plugin manifest\'s ' + + '`contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, ' + + 'cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, ' + + '`translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. ' + + 'A census of all three repos, with controls, measured that the whole monorepo ' + + 'contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; ' + + 'the other nine ' + + 'members parsed, entered the manifest, and changed nothing, while published docs and ' + + 'the schema\'s own JSDoc kept teaching them (`commands` documented Commander.js ' + + 'resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam ' + + 'that never existed). All nine are retiredKey tombstones mirroring `loading`; ' + + '`kinds` survives (live reader), and `routes` was left to a ruling of its own, which ' + + 'retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 ' + + 'semantic, no D2 conversion: a manifest is not a stack collection ' + + 'member, so a conversion would be a transform with no seam that ever runs. ' + + 'On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 ' + + 'enforce-or-remove; maintainer ruling 2026-08-24): the schema promised ' + + 'that declaring `globs` enables file-type discovery, but discovery globs ' + + '`filePatterns` off the metadata type registry — which `contributes.kinds` does ' + + 'not extend, as `metadata-plugin.zod.ts` records outright — so an authored ' + + '`globs` was accepted, stored, served back through `GET /metadata/kind`, and ' + + 'never consulted (zero value reads; the only non-test occurrences were the ' + + 'schema declaration and two type positions). The `kind` bucket itself and its ' + + '`id` are untouched; file-type discovery stays single-channel on `filePatterns`. ' + + 'D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning.', + }, + { + id: 'record-chatter-position-vocabulary-converged', + order: 5, + text: + 'Finally, it converges `record:chatter` / `record:discussion` `position` on the ' + + 'renderer\'s vocabulary (maintainer ruling 2026-08-15): the schema declared ' + + '`sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the ' + + 'schema\'s own `sidebar` default silently rendered in flow while the value that ' + + 'actually docks the panel (`right`) was refused at publish. The row now speaks ' + + '`bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings ' + + '(`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three ' + + 'schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per ' + + 'the `maxVisible` principle — renderer fallbacks stay the renderer\'s facts.', + }, + { + id: 'record-highlights-field-icon-retired', + order: 12, + text: + 'It also retires the `record:highlights` highlight-field `icon` (ADR-0049 ' + + 'enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census ' + + 'verdict): a declared key with zero read points in any direction — objectui\'s renderer ' + + 'normalized the authored object and carried `icon` into a highlight chip with no icon ' + + 'slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to ' + + 'carry it), and the Studio designer publishes the field list as plain strings — while ' + + 'six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, ' + + 'on the highlight chip). The mechanical conversion strips the key from the object ' + + 'entries of every `record:highlights` `fields[]` (pure lossless delete — the chip ' + + 'renders label and value only, so it never had an effect to lose); there is no ' + + 'replacement, and the live neighbour `readonly`, declared because the chip\'s read-only gate ' + + 'reads it, is untouched.', + }, + { + id: 'stack-themes-carrier-retired', + order: 11, + text: + 'Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 ' + + 'enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): ' + + 'the pipeline was live from the authoring gate through artifact ingest and stopped ' + + 'there — zero non-test readers of stored `theme` items, `theme` never a registered ' + + 'metadata type, no first-party app mounting the spec-aware provider, nothing ' + + 'selecting an active theme — so an authored theme shipped through every green gate ' + + 'and changed nothing on screen. `app.branding` stays the one colour surface; ' + + 'objectui\'s ThemeEngine/ThemeContext and their unit tests are retained. Semantic ' + + 'rather than mechanical: an authored palette has no lossless target (N themes vs ' + + 'M apps is a judgment), so the entry prescribes the hand move instead of deleting ' + + 'authored content silently.', + }, + { + id: 'sys-audit-log-organization-column-retired', + order: 88, + text: + 'It also takes the injected organization column off the compliance ledger, `sys_audit_log` ' + + '(ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the ' + + 'organization a row is about stays in the attribution field `tenant_id`, which every writer ' + + 'already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a ' + + 'platform administrator now reads the rows about no organization too; an organization reader is ' + + 'scoped to the rows about its active organization by the platform row policy ' + + '`sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ' + + 'ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant ' + + 'retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps ' + + 'the column as an orphan the boot drift report names, for the v18 ceremony to drop once its ' + + 'values are confirmed in `tenant_id`. The D3 record is the ' + + '`sys-audit-log-organization-column-retired` semantic entry.', + }, + { + id: 'sys-setting-global-rung-moved', + order: 87, + text: + 'It also moves the settings cascade\'s global rung out of the tenant-scoped `sys_setting` into ' + + 'the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the ' + + 'deployment, no organization column, reads governed by the `manage_platform_settings` ' + + 'capability. The settings service writes a global-scope key there and reads the rung from there ' + + 'alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The ' + + 'cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the ' + + 'v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open ' + + 'unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is ' + + 'the `sys-setting-global-rung-moved` semantic entry.', + }, + { + id: 'sys-view-definition-retired', + order: 90, + text: + 'It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework ' + + 'code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. ' + + 'The object, its two registrations, its `kernel:ready` active-row index migration and that ' + + 'migration\'s exports leave, and its name leaves the platform-object registry. Nothing in stack ' + + 'metadata is rewritten; an existing database keeps the table, which no platform path drops. ' + + 'The D3 record is the `sys-view-definition-retired` semantic entry.', + }, + { + id: 'time-default-zone-refused', + order: 50, + text: + 'It also narrows the `time` stored form to the zone-less wall clock the record validator ' + + 'already enforces (ADR-0053 D-C1), so a field default or an action param default or value ' + + 'with a `Z` or a UTC offset is refused when it is authored or submitted rather than on ' + + 'every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` ' + + 'drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero ' + + 'offset as stored for its author to rewrite; its D3 record is the semantic entry ' + + '`time-default-zone-refused`.', + }, + { + id: 'translation-component-submit-label-retired', + order: 14, + text: + 'Finally, it retires the component-translation copy key ' + + '`pages..components..submitLabel` and its `submit` alias (ADR-0049; ' + + 'maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key ' + + 'exists because some component in `ComponentPropsMap` declares it — and ' + + '`submitLabel`\'s only declarer was `element:form`, retired whole above, so ' + + 'the key had no declared component left to translate and the resolver overlay was ' + + 'its only reader. Retire won over re-anchor because the live form surface ' + + '(`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own ' + + 'authoring site; re-anchoring would have widened the face for one word. The ' + + 'mechanical conversion strips the key from stored bundles and items (pure lossless ' + + 'delete — nothing read it once `element:form` was retired), at the acknowledged cost ' + + 'of dropping the ' + + 'bespoke-component route for that one word.', + }, + { + id: 'translation-per-app-settings-platform-only', + order: 33, + text: + 'Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings ' + + 'copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle ' + + '(`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is ' + + 'keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both ' + + 'bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — ' + + 'but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` ' + + '(Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later ' + + 'source the leaf, so what an application had was a GAP FILLER on a namespace it does not own ' + + '— rendering only where the platform bundle carried no string for that key and locale. The ' + + 'registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app ' + + 'metadata type, two authoring doors, one accepted shape) and no longer declares ' + + '`settings` either; there the group had been STRONGER, because the runtime-authored layer is ' + + 'read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 ' + + 'conversion strips the group from per-app bundle entries and from bare items alike — the ' + + 'runtime translation sync replays it over every stored row before merging — and the paired ' + + 'semantic entry says what the strip means at each door, because a notice reading "(removed)" ' + + 'says neither that an item’s overrides give way to the platform’s string nor that a gap falls ' + + "back to the manifest's own English literal.", + }, + { + id: 'translation-widget-sub-caption-retired', + order: 60, + text: + 'It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses ' + + 'the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget ' + + 'translation key ' + + '`dashboards..widgets..subCaption` overlaid a widget\'s `options.description`, a key ' + + 'the dashboard schema never declared and no authored widget wrote, so the overlay in ' + + '`translateDashboard` was its only writer. The overlay is removed, `subCaption` is a ' + + '`retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias ' + + 'now carries the retirement instead of a rename onto a key that accepts nothing. A widget ' + + 'keeps one authored description, `widget.description`, which renders as the card-header ' + + 'subtitle and is translated by the widget\'s `description` key. The D2 conversion ' + + '`translation-widget-sub-caption-removed` strips the key from bundle entries and stored ' + + 'translation items as a lossless delete of what is served, retired from the load path so ' + + 'authors are refused at parse; its D3 record is the semantic entry ' + + '`translation-widget-sub-caption-retired`.', + }, + { + id: 'try-catch-and-retry-policy-undeclared-keys-refused', + order: 90, + text: + 'Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one ' + + 'declaration behind `job.retryPolicy` and a `try_catch` node\'s `retry`, refuses a key it does not ' + + 'declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a ' + + 'stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the ' + + 'five keys the descriptor declares, a key a `try_catch` node\'s contract does not declare is refused at ' + + 'parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the ' + + 'descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a ' + + 'different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no ' + + 'new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry ' + + '`try-catch-and-retry-policy-undeclared-keys-refused`.', + }, + { + id: 'ui-action-group-menu-member-params-array-only', + order: 83, + text: + 'It then closes the one static-values spelling those members still accepted and the containers drop: an ' + + '`action:group` or `action:menu` member\'s `params` takes the input list, an `ActionParam[]` array, only, ' + + 'unless the member\'s `type` is `api`, whose object `params` keeps its request-payload window. `params` ' + + 'carries one shape and no second value-bag key is declared, so an object `params` on any other member, which ' + + 'parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action ' + + 'with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a ' + + 'stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' + + '`ui-action-group-menu-member-params-array-only`.', + }, + { + id: 'ui-action-group-menu-members-typed', + order: 82, + text: + 'And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks ' + + '(the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so ' + + 'a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A ' + + 'member now takes `action:button`\'s keys with its executor spelled `type`, measured from the containers\' ' + + 'reads — an `action:menu` item reads no `size` and declares none — with the rows\' prescriptions; ' + + '`outcomeMessages`, a member `className` and a member `properties.params` are refused, and ' + + '`outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the ' + + 'component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. ' + + 'Its D3 record is the semantic entry `ui-action-group-menu-members-typed`.', + }, + { + id: 'ui-ai-chat-window-retired', + order: 65, + text: + 'It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), ' + + 'the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is ' + + 'wanted — the console leaves it unregistered on purpose, because the floating chat overlay ' + + 'it mounts on every page is the supported AI chat entry point — so a page that placed one ' + + 'validated clean and drew "Unknown component type", and its four props configured nothing. ' + + 'The name leaves `PageComponentType` and is refused by name at the node, its ' + + '`ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and ' + + 'the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only ' + + 'edit is deleting the node, a layout decision that is the author\'s. Its D3 record is the ' + + 'semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged.', + }, + { + id: 'ui-form-layout-inline-grid-retired', + order: 40, + text: + 'It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that ' + + 'declared the four-arm enum — the `object-form` page component and the form view ' + + '(ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a ' + + 'behaviour of its own: every form presentation folded both to `vertical`, multi-column ' + + 'is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row ' + + 'pattern rather than a record-form layout — redundant vocabulary under the maintainer\'s family ' + + 'criterion (a capability mainstream platforms have is served once, here by `columns`), retired ' + + 'with no alias window. Both enums refuse the two values with a ' + + 'per-value prescription naming `columns`; the D2 conversion ' + + '`form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, ' + + '`columns` untouched) on `object-form` page components, on every form payload a view ' + + 'carries, and on the assembled-manifest `viewItems` channel.', + }, + { + id: 'ui-object-form-custom-fields-typed', + order: 78, + text: + 'It also types the `object-form` page block\'s `customFields`, one of the two contracts the ' + + '`ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each ' + + 'member is the runtime form field the form draws, which the spec did not declare, so a member with no ' + + '`name` or a misspelled member passed every door and the form drew the field without it. The spec now ' + + 'declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — ' + + 'the `grid` widget\'s snake_case keys stay out until the widget reads a camelCase spelling — and the ' + + 'row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and ' + + 'loads, so no conversion is registered. Its D3 record is the semantic entry ' + + '`ui-object-form-custom-fields-typed`.', + }, + { + id: 'ui-object-form-fields-names-typed', + order: 74, + text: + 'It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page ' + + 'blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third ' + + 'stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, ' + + '`type` and `required` it silently dropped, and objectui has since retired that entry from every ' + + 'authoring face, drawing only a stored one by its name. So both rows take field names, objectui\'s own ' + + 'declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry ' + + 'is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate ' + + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the ' + + 'semantic entry `ui-object-form-fields-names-typed`.', + }, + { + id: 'ui-object-form-members-typed', + order: 67, + text: + 'It also types four members of the `object-form` page block (the third stage of the ' + + '`ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, ' + + '`navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, ' + + 'so a `submitBehavior` `kind` the form does not know passed every door and fell through to the ' + + 'thank-you panel. `submitBehavior` takes the form view\'s own block by reference; the other three ' + + 'take the measured shape. The form\'s `fields` and `sections` and the master-detail form\'s two ' + + 'stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, ' + + 'which the typed shapes would refuse — and `customFields` stays open until the spec declares the ' + + 'runtime form field its entries are. Read by the component-props gate (advisory); a stored page ' + + 'still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' + + '`ui-object-form-members-typed`.', + }, + { + id: 'ui-object-form-sections-typed', + order: 79, + text: + 'It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the ' + + 'other ruled fork: a section\'s `fields` draws an inline runtime form field beside a name and the form ' + + 'view\'s `{ field }` entry, which the stored form view\'s section refuses, so the sections stayed ' + + '`z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape ' + + 'of their own — the form view\'s section keys plus those three entry arms, the inline arm the runtime ' + + 'form field — in canonical spellings only: a page block\'s `properties` is never parsed on the way to ' + + 'the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at ' + + 'parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. ' + + 'Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' + + 'registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`.', + }, + { + id: 'ui-object-gantt-markers-typed', + order: 75, + text: + 'It also types the `object-gantt` page block\'s `markers` (the same stage): its entries were ' + + '`z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric ' + + '`date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The ' + + 'spec now declares objectui\'s own authoring declaration of a marker, `{ date, label?, color? }` with ' + + '`date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still ' + + 'saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' + + '`ui-object-gantt-markers-typed`.', + }, + { + id: 'ui-object-grid-columns-typed', + order: 73, + text: + 'It also types the `object-grid` page block\'s `columns` (the fifth stage of the ' + + '`ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid\'s ' + + 'group headers drew a column\'s `options`, which the list view\'s column entry does not declare, and ' + + 'objectui has since retired that read and takes the labels from the object field only. So the member ' + + 'takes the list view\'s own `columns` by reference — all field names or all column entries — and a ' + + 'column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, ' + + '`options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by ' + + 'the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' + + 'registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`.', + }, + { + id: 'ui-object-grid-export-options-closed', + order: 59, + text: + 'It also closes the export options of an `object-grid` page block. `exportOptions` ' + + 'was `z.unknown()`, so a bare format array — the list view\'s legacy spelling, which the list ' + + 'view lifts to `{ formats }` — was accepted on the grid, whose renderer reads ' + + '`exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and ' + + 'the author\'s list was dropped. The row now takes the list view\'s five-member export ' + + 'options object by identity, not the list view\'s union, and refuses a bare array with the ' + + 'object form named, a format outside the enum and an undeclared key. Page-component ' + + '`properties` is read by the component-props gate, which reports these as advisory ' + + 'findings, and is not parsed on the metadata save or load path, so a stored page still saves ' + + 'and loads and no conversion is registered: the bare array never worked here, and lifting it ' + + 'would change the menu a deployed grid shows. The authored census found nothing to respell. ' + + 'Its D3 record is the semantic entry `ui-object-grid-export-options-closed`.', + }, + { + id: 'ui-object-grid-kanban-calendar-list-members-typed', + order: 66, + text: + 'It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page ' + + 'blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid\'s ' + + '`fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban\'s ' + + '`columns` and the calendar\'s `calendar` were `z.unknown()` (an array of it for the lists), although ' + + 'each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door ' + + 'and was skipped. The members a list view declares take the list view\'s own by reference ' + + '(`batchActions`, the spelling the grid reads first, takes `bulkActions`\'s); the grid\'s `fields` ' + + 'and `selectable` and the kanban lane take the measured shape. The grid\'s `columns` stays open: its ' + + 'group headers draw an authored column\'s `options`, which the list view\'s column entry does not ' + + 'declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no ' + + 'conversion is registered. Its D3 record is the semantic entry ' + + '`ui-object-grid-kanban-calendar-list-members-typed`.', + }, + { + id: 'ui-object-grid-row-members-typed', + order: 63, + text: + 'It also types seven members of an `object-grid` page block: `rowHeight`, ' + + '`rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and ' + + '`operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid ' + + 'reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. ' + + 'The five a list view also declares take the list view\'s own schemas by reference; ' + + '`aggregations` takes the measured `[{ field, type }]` with the query AST\'s aggregation ' + + 'functions, and `operations` the four booleans a grid read point names (`create`, ' + + '`update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by ' + + 'the component-props gate (advisory); a stored page still saves and loads, so no conversion ' + + 'is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`.', + }, + { + id: 'ui-object-kanban-conditional-formatting-typed', + order: 77, + text: + 'It also types the `object-kanban` page block\'s `conditionalFormatting`, the one member the ' + + '`ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui\'s ' + + 'kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` ' + + 'passed every door and the board painted no card for it. objectui has since made the list view\'s ' + + '`{ condition, style }` rule the member\'s only authoring dialect, and the board evaluates it with the ' + + 'grid\'s evaluator, so the row takes the list view\'s own member by reference, as `object-grid` does. ' + + 'Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' + + 'registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`.', + }, + { + id: 'ui-object-map-gantt-tree-navigation-typed', + order: 64, + text: + 'It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks ' + + '(the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer ' + + 'hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, ' + + 'so `navigation: 42` and a bare mode string passed every door and opened the record page. The ' + + 'three rows now take the list view\'s `NavigationConfigSchema` by reference, the carrier the grid, ' + + 'kanban, calendar and timeline blocks already take. Read by the component-props gate ' + + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record ' + + 'is the semantic entry `ui-object-map-gantt-tree-navigation-typed`.', + }, + { + id: 'ui-object-master-detail-form-details-closed', + order: 56, + text: + 'It also closes the third carrier of the inline grid column: an ' + + '`object-master-detail-form` page block\'s `details` was `z.array(z.unknown())`, so a key ' + + 'its renderer does not read and `scale` on a currency column, which the other two carriers ' + + 'refuse under the maintainer\'s rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), ' + + 'went through `objectstack validate` green. ' + + 'Each detail entry is now a strict shape of the twelve keys the renderer reads, and its ' + + '`columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the ' + + 'component-props gate, which reports a failing entry or column as an advisory finding, ' + + 'and is not parsed on the metadata save or load path, so a stored page still saves and ' + + 'loads and no conversion is registered; the authored census found nothing to respell. ' + + '`defineStack`\'s identity-only check reaches the block wherever a page carries it, with ' + + 'the reach `inline-grid-column-identity-only-currency-scale-refused` records for the ' + + 'other two carriers. Its D3 record is the semantic entry ' + + '`ui-object-master-detail-form-details-closed`.', + }, + { + id: 'ui-object-metric-aggregate-trend-typed', + order: 69, + text: + 'It also types two members of the `object-metric` page block (the fourth stage of the ' + + '`ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although ' + + 'the tile reads each with one shape, so `aggregate: \'count\'` and a trend with no `value` passed ' + + 'every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. ' + + '`aggregate` takes the query AST\'s aggregation functions and the chart aggregate\'s `groupBy` ' + + 'union by reference, with `groupBy` optional because a metric is one number; `trend` takes the ' + + 'badge\'s measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key ' + + 'the tile never reads (the chart drill-down\'s `filter`, the dashboard comparison\'s `dimension`), and ' + + 'the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the ' + + 'component-props gate (advisory); a stored page still saves and loads, so no conversion is ' + + 'registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`.', + }, + { + id: 'ui-object-metric-compare-to-typed', + order: 71, + text: + 'It also types the `object-metric` page block\'s `compareTo` (the fifth stage of the ' + + '`ComponentPropsMap` `z.unknown()` close-out) to the tile\'s read, per the ruling between the ' + + 'reference and the read: `{ kind }`, with `kind` the dashboard widget comparison\'s own vocabulary by ' + + 'reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own ' + + '`filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a ' + + '`dimension` passed every door and compared the wrong window. Read by the component-props gate ' + + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the ' + + 'semantic entry `ui-object-metric-compare-to-typed`.', + }, + { + id: 'ui-object-metric-drill-down-report-typed', + order: 80, + text: + 'It then types the three members the stages above held open, as the maintainer ruled them on the ' + + 'decision card for those forks. The `object-metric` drill-down\'s `report` is `ReportSchema`, by ' + + 'reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, ' + + 'and since then every report the member admits is one the drill drawer draws — a report with no ' + + '`dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the ' + + 'records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, ' + + 'so no conversion is registered. Its D3 record is the semantic entry ' + + '`ui-object-metric-drill-down-report-typed`.', + }, + { + id: 'ui-object-metric-drill-down-typed', + order: 72, + text: + 'It also types the `object-metric` page block\'s `drillDown` to the tile\'s read (the same ' + + 'stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are ' + + 'the chart drill-down\'s own by reference, and `filter` and `mode` are refused by name, because a ' + + 'metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; ' + + 'both passed every door and were ignored. The drill `report` stays open: the tile draws a ' + + 'dataset-bound report, but the spec declares no drill report yet, and declares that contract first. ' + + 'Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is ' + + 'registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`.', + }, + { + id: 'ui-object-timeline-items-typed', + order: 81, + text: + 'It types the `object-timeline` page block\'s `items` (fork 4, letter B): each entry is one of objectui\'s ' + + 'two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` ' + + 'or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or ' + + 'epoch milliseconds — and a row refinement pairs each entry with the kind the block\'s `variant` selects, ' + + 'so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. ' + + 'A feed entry\'s `content` (child components) is held unjudged until a writer appears. Read by the ' + + 'component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. ' + + 'Its D3 record is the semantic entry `ui-object-timeline-items-typed`.', + }, + { + id: 'ui-object-timeline-mapping-typed', + order: 76, + text: + 'It also types the `object-timeline` page block\'s `mapping` (the same stage): the binding record — ' + + 'four optional field names for an entry\'s title, date, description and marker colour — was ' + + '`z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member ' + + 'passed every door and the rail drew the default field. The spec now declares objectui\'s own ' + + 'declaration of it, and the row takes it. The stage\'s other members — the metric drill-down\'s ' + + '`report`, the form\'s `customFields` and both forms\' `sections`, the timeline\'s `items` and the ' + + 'action containers\' members — stay open: each contract has more than one viable shape that no ruling ' + + 'decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no ' + + 'conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`.', + }, + { + id: 'ui-record-line-items-props-closed', + order: 57, + text: + 'It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` ' + + 'row — it was the one entry on the string-arm registration ledger — so the component-props ' + + 'gate skipped its props, and the showcase project page\'s five `field`-keyed columns published ' + + 'green over a grid of empty cells. The row declares the fifteen keys the renderer ' + + 'reads, requires `relationshipField` and at least one column, and its `columns` references ' + + '`InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The ' + + 'panel draws its columns as authored, with no hydration from the child object\'s field, so ' + + '`defineStack`\'s identity-only check does not reach it. Its D3 record is the semantic entry ' + + '`ui-record-line-items-props-closed`.', + }, + { + id: 'ui-report-joined-block-dataset-required', + order: 77, + text: + 'It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, ' + + 'enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each ' + + 'block is dataset-bound, but the joined arm of `ReportSchema`\'s refinement required only a non-empty ' + + '`blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, ' + + 'and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one ' + + 'falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each ' + + 'such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; ' + + '`dataset` stays optional on the block shape, which is read only on a `joined` report. No key is ' + + 'removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset ' + + 'a block was meant to show. Its D3 record is the semantic entry ' + + '`ui-report-joined-block-dataset-required`.', + }, + { + id: 'ui-report-joined-chart-retired', + order: 38, + text: + 'It also retires a `joined` report\'s `chart` at both coordinates (ADR-0049 ' + + 'enforce-or-remove): the joined renderer draws each block as a table and returns before ' + + 'the one container `chart` read, and no renderer reads a block\'s `chart` at all, so a ' + + 'chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. ' + + 'The key leaves `JoinedReportBlockSchema`\'s closed shape (its `guidance` table carries ' + + 'the prescription) and the joined arm of `ReportSchema`\'s refinement refuses a ' + + 'container `chart`; `chart` stays live on every non-joined report. The D2 conversion ' + + '`report-joined-chart-removed` strips both as a pure lossless delete — neither ever had ' + + 'an effect to lose — because a stored report row CAN carry them (the Studio report form ' + + 'offered a block `chart` input until this change); it is retired from the load path, so ' + + 'authors are refused at parse rather than rewritten.', + }, + { + id: 'view-item-owner-hidden-retired', + order: 37, + text: + 'It also retires the view item\'s `owner` and `hidden` (ADR-0049 ' + + 'enforce-or-remove). Both sat on the view-item identity layer, were accepted by the ' + + 'strict authoring door and by the wire member the `view` write door validates, and were ' + + 'stored verbatim — and nothing read either: both switcher read paths filter on ' + + '`viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no ' + + 'per-user scope ever read `owner`, so a view marked as one user\'s was listed for ' + + 'everyone who can read the object. Per-user view scoping is a parked direction ' + + '(ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` ' + + 'tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire ' + + 'member, where a bare deletion would be a silent strip. The D2 conversion ' + + '`view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, ' + + 'as a lossless delete, in both collections a record travels in — `views` (stack sources ' + + 'and stored rows) and the assembled-manifest `viewItems` channel (package export, ' + + 'environment artifacts), whose registration parse would otherwise refuse an artifact ' + + 'assembled before this release.', + }, + { + id: 'view-overlay-owner-hidden-retired', + order: 39, + text: + 'It retires the view item\'s `owner` / `hidden` pair on the flattened overlay door too ' + + '(ADR-0049; the view item\'s disposition for the same key pair, followed here as triage ' + + 'directed): the lean personalization ' + + 'PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and ' + + 'nothing read either. Both are `retiredKey()` tombstones on the two overlay members with ' + + 'the view item\'s own prescription texts, and the D2 conversion ' + + '`view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, ' + + 'no container slot) in `views` and `viewItems`, so a stored overlay row is served without ' + + 'them. A row that held other view keys is then valid again and re-saves; a row that held ' + + 'nothing but its identity and the two keys is left identity-only, which the door refuses, ' + + 'so it is badged invalid, refused on a whole-row re-save and reported `failed` by ' + + '`os migrate meta --stored --apply` until it is deleted or given the setting its author ' + + 'meant. Its D3 record is the semantic entry ' + + '`view-overlay-owner-hidden-retired`.', + }, +]; + +/** + * Protocol 18 step — accumulating, uncut. + * + * v17.0.0 was cut before these narrowings landed, so their migration + * prescriptions belong to the NEXT major: `composeMigrationChain` filters + * `m <= toMajor` (default `PROTOCOL_MAJOR`), so this step is inert for every + * default caller until the protocol major reaches 18. The enforcement itself + * ships earlier on the 17.x line (launch-window convention: accept-set + * narrowings ride minor releases); this step is where `migrate meta` users + * are told, at the major boundary where they look. + * + * Mechanical: the malformed field `scale`/`precision` delete (#8321) — a + * non-integer or negative digit count declared nothing #7501's enforcement + * could act on, so removing it is behaviour-preserving; retired from the + * load path (the schema refuses live authors), replayed by `migrate meta` + * and the stored-row rehydration seam. Semantic: the memory-driver + * persistence placeholder refusal (commit 4bfe1a539, PR #8666: `${…}` refused + * at publish in `persistence.path` / `persistence.key`) — the #8336 parent + * adjudication applied to the two config-material memory keys its deliberate + * `initialData` exclusion never covered — the + * `MetadataPluginConfig.additionalTypes` retirement (#8586, ADR-0049): the + * inert plugin kind-declaration key, tombstoned on the 17.x line, with no D2 + * conversion because a plugin config is not a stack collection member (the + * `kernel/Manifest:loading` precedent) — the field `scale`/`precision` + * integer refusal (#8321), whose non-mechanical half is that only the author + * knows the digit count they meant — the removal of the `'*'` export grant + * from the shipped admin permission sets (#8681), the one entry here that + * narrows a CAPABILITY rather than an accept set, and so the one whose silence + * on upgrade is a working export turning into a 403 rather than a publish + * refusal — and the `record:chatter`/`record:discussion` `position` vocabulary + * convergence (#8762): the row's old `sidebar`/`inline`/`drawer` set is + * respelled to the renderer's `bottom`/`right`/`left` and the row's three + * schema defaults are dropped. + */ +const step18: MigrationStep = { + toMajor: 18, + rationale: joinRationale(STEP18_RATIONALE), + // Derived, not listed: every major-18 conversion graduates into this step, in + // the order `CONVERSIONS_BY_MAJOR[18]` applies them — the order that list + // already curates. A hand-kept copy took an append from every retirement (its + // tail conflicted like the rationale's) and can fall out of step with the + // list it copies: step 17's names the same ids in a different order. Add a + // conversion there, never here. + conversionIds: CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id), + semantic: [ + // One file per entry under `entries/semantic/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], +}; + +/** All migration steps, keyed by the major they migrate into. */ +export const MIGRATIONS_BY_MAJOR: Readonly> = { + 17: step17, + 18: step18, +}; + +/** The majors that have a step, ascending. */ +export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJOR) + .map(Number) + .sort((a, b) => a - b); + +/** + * Every authorable key TOMBSTONED at each protocol major, named EXACTLY. + * + * One entry per key, spelled `${defKey}:${name}` — the same string + * `packages/spec/authorable-surface.json` records, minus its `[RETIRED]` mark + * (`'automation/Event:type'`, `'ui/App:sharing'`). Not a surface path, not a + * prose clause, not a prefix: the literal key. + * + * ## What reads it + * + * Two gates in `scripts/build-schemas.ts` (`check:authorable-surface`), and + * nothing infers an entry for either — the author writes the key down or the + * gate stays red: + * + * - **Check (b)**, the retirement gate: a key that flips live → retired must + * appear here, by exact set membership, or the build fails. Two companions + * read the table in the other direction: check (b2) refuses an entry naming a + * key this build still emits as LIVE, and check (b3) — nested rows only — + * refuses a dotted entry whose def this build emits but whose path it does + * not (#17969). See "Lifecycle" below for what each of them means for a row + * already in the table. + * - **Check (c)**, the baseline-deletion gate (#4650): the major recorded here + * is what starts a tombstone's ~two-major aging clock, so it is also what + * eventually lets its `authorable-surface/` line be deleted. Since #5898 — + * before that, (c) dated the clock by matching the key's LEAF against the + * conversion registry, and took the `Math.min` of the matches. + * + * ## Why exact keys, and not the conversion `surface` + * + * Check (b) used to satisfy itself by matching the key's LEAF against every + * `surface` registered in {@link CONVERSIONS_BY_MAJOR} / {@link + * MIGRATIONS_BY_MAJOR} — `endsWith('.' + name)`, across all majors, ignoring + * which def the key belonged to. Any unrelated registration ending in the same + * leaf therefore registered a tombstone for free: measured in #4658, tombstoning + * `automation/Event:type` passed silently because protocol 11's + * `flow-node-http-callout-rename` had registered `flow.node.type`. The exposure + * grew with the vocabulary — `type`, `name`, `config`, `filter`, `schema`, + * `description` (#5509) are ordinary leaves on hundreds of authorable shapes — + * so the gate's whole guarantee had lapsed for the most common keys (#4659). + * + * A `surface` is deliberately PROSE: it addresses authors in the shape they + * write metadata (`flow.nodes[].outputSchema`), which no rule can map back onto + * a def key. So the registration moved here instead of being encoded into the + * surface — the conversion keeps its prose, and this table carries the machine + * fact. + * + * ## What it does NOT replace + * + * The D2 conversion (`src/conversions/registry.ts`) and D3 semantic entry stay + * the *documentation* channel: `spec-changes.json` (ADR-0087 D4), the generated + * upgrade guide and `os migrate meta` are projections of those, not of this + * table. An entry here is the *proof the retirement was declared*; the + * conversion is the *prescription a consumer follows*. A retirement needs both. + * + * ## Historical tombstones: absent, and therefore not deletable + * + * Check (b) fires only on a NEW live → retired transition — measured against the + * committed `authorable-surface.json` baseline, which already records every + * older tombstone as `[RETIRED]`. The 97 retirements that landed before this + * table existed therefore never re-trigger it and are deliberately absent: this + * reads "retirements registered under the exact-key gate", not "every retirement + * ever". + * + * #5898 measured what it would take to backfill them, and neither available + * source can date a row honestly: + * + * - **Leaf-matching the conversion registry** is precisely the inference #4659 + * removed. It is also demonstrably wrong in both directions on this very data: + * of the two rows it dated as aged-out, `data/Index:type` was dated by + * `flow.node.type` (an unrelated cluster) and `api/RestApiConfig:requireAuth` + * by `api.requireAuth` at major 12 — which is the secure-default FLIP, a + * different kind of change to the same surface, not its retirement. + * - **The git history of `authorable-surface.json`** begins at 17.0.0-rc.0, so + * every one of the 97 rows first carries `[RETIRED]` at major 17. That is an + * artifact of the baseline file's birth, not archaeology. + * + * So the rows stay undeclared, and check (c) is fail-closed about it: a + * tombstone with no entry here cannot prove its age and its baseline line may + * not be deleted. Deleting one is a deliberate, reviewable act — establish the + * true major for that key, add it below, and check (b2) verifies the entry still + * names a key this build tombstones. Do NOT add a row you cannot date; an + * estimate written down here reads as a fact to every later gate. + * + * ## Lifecycle + * + * A TRUE entry is permanent. A declared TOP-LEVEL tombstone ages out after ~two + * majors and its line may then leave `authorable-surface.json` (check (c)); its + * entry here stays, and then names a key the build no longer emits — the + * expected steady state, not an error. A NESTED row never reaches that file at + * all (0 dotted entries on the shipped surface), so it has no aging clock and + * "the build no longer emits it" is never that steady state for one. + * + * Two states are rejected. A row is read as a PATH only when its `name` half + * carries a dot AND this build emits no top-level property of that exact name, + * so a live dotted TOP-LEVEL key (`@odata.context`, a SCIM extension URN) stays + * on the map check (b2) has always judged it on: + * + * - **Still LIVE** (check (b2)): the entry names a key this build still emits as + * writable — a registration nothing consumed, pre-approving a retirement that + * has not happened. Remedy: tombstone the key, or delete the entry. + * - **Nested and unresolvable** (check (b3), #17969): the entry's def IS emitted + * and the dotted path is not in it. Because checks (a0)/(a)/(b)/(c) are + * structurally blind to a nested key, no gated route can produce this state — + * the row is wrong (a typo'd def, a typo'd path, a stale key name). Remedy: + * fix the spelling against the emitted schema, or delete the entry. A row + * whose DEF this build does not emit is NOT this state: that is the whole-def + * removal steady state, registered in {@link RETIRED_DEFS_BY_MAJOR}. + * + * ⛔ "Delete the entry" and "entries are permanent" do not conflict, and reading + * one as licence to drop the other is the way to lose a retirement. A row that + * was ever TRUE of some build is history and is never deleted; a row check (b2) + * or (b3) refuses was never true of any build, so deleting it removes a false + * claim rather than a record. Check (b3) can also not yet tell a wrong row apart + * from every truthful one — its refusal text enumerates the shapes it knows it + * cannot see — and for those the remedy is to teach the check the shape, ⛔ never + * to delete a row that is telling the truth. + * + * @see scripts/build-schemas.ts — checks (b)/(b2)/(b3)/(c), the only consumers + */ +export const RETIRED_KEYS_BY_MAJOR: Readonly> = { + // The first entries since #4659 built this table (#5552). ONE tombstone + // produced THREE keys: `transform` is declared on `shared/FieldMapping`, and + // every `.extend()` of the base copies the retired property into its own + // walked shape, which `authorable-surface/` marks `[RETIRED]` separately. + // Registered per key, as the gate reads them — nothing radiates from the + // base. Two of the three remain: `data/ExternalFieldMapping:transform` left + // when #8075 retired the whole external-lookup family (the def retirement + // subsumes the key entry, the WidgetManifest.performance way — see + // RETIRED_DEFS_BY_MAJOR below). + 17: [ + // One file per entry under `entries/retired-keys/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], + // Post-cut retirements: v17.0.0 was cut before these landed, so the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription registers at the major boundary where `migrate meta` users + // look — the step18 discipline, applied to retired keys. + 18: [ + // One file per entry under `entries/retired-keys/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], +}; + +/** + * Every whole JSON Schema **def** UNPUBLISHED at each protocol major, named + * EXACTLY. + * + * One entry per def, spelled `${category}/${SchemaName}` — the same string + * `packages/spec/json-schema.manifest.json` records (`'identity/Session'`, + * `'data/ValidationRule'`). The sibling of {@link RETIRED_KEYS_BY_MAJOR} one + * level up: that table registers a single authorable KEY being tombstoned, this + * one registers a whole SCHEMA leaving the published set. + * + * ## What reads it + * + * The manifest deletion gate in `scripts/build-schemas.ts` + * (`check:authorable-surface`): a def listed in `json-schema.manifest.json` at + * the merge base with origin/main that this build no longer emits must appear + * here, by exact set membership, or the build fails. Nothing else consumes the + * table, and nothing infers an entry. + * + * ## Why the manifest line alone was not a declaration + * + * The #2978 ratchet asks for one, in a comment: "remove a key ONLY for a + * deliberate retirement". Nothing checked it. The ratchet's `missing` set is + * `manifest − emitted` with the manifest read from the SAME commit, so a PR that + * deleted the export and the manifest line together produced an empty `missing` + * and a silent gate — the exact shape #4650 had just closed one level down, for + * authorable keys. Worse, the two gates deferred to each other: the #4650 + * deletion gate waives every baseline key under a def the build stopped emitting + * ("adjudicated by json-schema.manifest.json"), and the manifest said nothing. + * Measured on #4725 by dropping one barrel re-export: 7 defs and 116 authorable + * keys left the contract with `gen:schema`, `check:authorable-surface` and + * `check:api-surface` all green. + * + * The fix is the #4650 structure: the removal is judged against the manifest at + * the merge base — which the commit under test cannot rewrite — and the deleted + * line has to be answered by a declaration written down HERE, under a major. + * + * ## Why not reachability + * + * #4650's per-key gate lets a deletion prove itself by showing the def is + * unreachable from the metadata-type roots, computed by BFS over the build's own + * Zod graph. That proof is unavailable one level up, and unavailable in the + * dangerous direction: reachability is keyed by `zodByDefKey`, which only holds + * defs this build EMITS, so a def that just stopped being emitted answers + * "unreachable" — a waiver for exactly the removals the gate exists to catch. + * Declaration is therefore the only honest proof at def granularity. + * + * ## What it does NOT replace + * + * A rename is not a retirement: a def published under a new name is declared in + * `scripts/lib/renamed-defs.ts` (`RENAMED_DEFS`), which the gate consults first, + * and an entry here would be a false claim that the contract shrank. And as with + * {@link RETIRED_KEYS_BY_MAJOR}, the D2 conversion (`src/conversions/registry.ts`) + * plus its D3 chain step stay the *documentation* channel that `spec-changes.json`, + * the upgrade guide and `os migrate meta` project; an entry here is the *proof + * the removal was declared*. A retirement needs both. + * + * ## Not a backfill of history + * + * The gate fires only on a def that leaves the published set relative to the + * merge base, so removals that landed before this table existed never re-trigger + * it and are deliberately absent. This reads "whole-schema removals registered + * under the manifest deletion gate", not "every schema ever unpublished". + * + * ## Lifecycle + * + * Entries are permanent, and after the removal merges they name a def no build + * emits — the expected steady state. The one state the gate rejects is an entry + * naming a def this build STILL publishes: a registration nothing consumed, + * pre-approving a removal that has not happened. + * + * @see scripts/build-schemas.ts — the manifest deletion gate, the only consumer + */ +export const RETIRED_DEFS_BY_MAJOR: Readonly> = { + // The first entry since #4725 built this table (#5552). The `transform` key's + // value schema had no other consumer, so it goes with the key rather than + // surviving as an exported union nothing references — an exported schema with + // no consumer reads as a capability to whoever finds it (#3950). + // + // The ten that follow are #5055 (ADR-0049 enforce-or-remove, maintainer ruling + // 2026-08-06, window moved to protocol 17 on 2026-08-07): `ui/widget.zod.ts`'s + // widget-registration vocabulary and the five doorless shapes of + // `ui/i18n.zod.ts`. None had a carrier key, none was reachable from the + // metadata-type roots, and none was ever parsed in objectstack / objectui / + // cloud — so there is no tombstone and no D2 conversion (route 3 of the + // retirement playbook), and this table plus the D3 `SemanticMigration` + // `ui-widget-i18n-family-retired` IS the declaration. Same shape as #4988. + // + // ⚠️ `ui/FieldWidgetProps` is deliberately NOT here — it was never in the + // manifest to begin with (its `onChange` is a `z.function()`, so no JSON + // Schema is emitted) and it survives the retirement: it is a React props + // contract, not authorable metadata, and it has a live compile-time consumer + // in objectui (`packages/fields/src/__tests__/spec-symbol-batch7.test.ts`, + // landed by objectui PR #3289). + // + // The 2026-08-08 ADR-0049 sweep (#6486) adds twenty-three more across three + // members (4 + 10 + 9), all route 3 and all whole-def: `system/http-server.zod.ts`'s + // runtime vocabulary (#5295, D3 `http-server-runtime-vocabulary-retired`), + // `api/protocol.zod.ts`'s viewId-addressed view CRUD (commit f549a0d4a, D3 + // `view-management-protocol-retired`) and the whole L2 ETL layer (#6414, D3 + // `etl-pipeline-layer-retired`). None had a carrier key and none was ever + // parsed outside its own unit tests, so again there is no tombstone and no D2 + // conversion — this table plus those three entries ARE the declaration. + // + // ⚠️ `system/ServerRateLimitConfig` is deliberately NOT here. It sits four + // lines from the retired `system/ServerCapabilities` in the same manifest and + // shares its prefix, but it belongs to `StackServerSecurity.rateLimit` — the + // LIVE server surface #5006 admitted, with an executor. Prefix adjacency is + // not evidence. + 17: [ + // One file per entry under `entries/retired-defs/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], + // Post-cut retirements: v17.0.0 was cut before these landed, so the + // removal ships on the 17.x line (launch-window convention) and the + // prescription registers at the major boundary where `migrate meta` users + // look — the step18 discipline the retired-key table above already applies, + // extended to whole defs. + 18: [ + // One file per entry under `entries/retired-defs/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], +}; diff --git a/turbo.json b/turbo.json index c68f3beb138..8589631248a 100644 --- a/turbo.json +++ b/turbo.json @@ -39,9 +39,19 @@ "outputs": ["dist/**", "json-schema/**", ".next/**", "!.next/cache/**"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/content/**"] }, + "@objectstack/spec#gen:migration-registry": { + "dependsOn": [], + "outputs": ["src/migrations/registry.ts"], + "inputs": [ + "package.json", + "scripts/build-migration-registry.ts", + "src/migrations/registry.ts.template", + "src/migrations/entries/**" + ] + }, "@objectstack/spec#build": { "dependsOn": ["^build"], - "outputs": ["dist/**", "json-schema/**", ".next/**", "!.next/cache/**"], + "outputs": ["dist/**", "json-schema/**", "src/migrations/registry.ts", ".next/**", "!.next/cache/**"], "inputs": [ "$TURBO_DEFAULT$", "$TURBO_ROOT$/scripts/tsup-drop-sources-content.mjs", @@ -140,8 +150,13 @@ "$TURBO_ROOT$/scripts/js-comment-mask.d.mts" ] }, + "@objectstack/spec#typecheck": { + "dependsOn": ["^build", "gen:migration-registry"], + "outputs": [], + "inputs": ["$TURBO_DEFAULT$", "!dist/**", "!coverage/**", "!.turbo/**"] + }, "@objectstack/spec#test": { - "dependsOn": ["^build"], + "dependsOn": ["^build", "gen:migration-registry"], "outputs": [], "env": ["OS_TEST_TIERS"], "inputs": [ @@ -634,7 +649,7 @@ "dependsOn": ["^build"] }, "@objectstack/spec#test:repo": { - "dependsOn": ["^build"], + "dependsOn": ["^build", "gen:migration-registry"], "outputs": [], "inputs": [ "$TURBO_DEFAULT$", From 21a37b202f4981ad543b0f47388739f5df843517 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:52:24 +0000 Subject: [PATCH 2/7] build(spec): retire check:migration-registry and teach its readers the generated registry The registry's committed copy is gone, so every reader of it moves to its two committed sources: - check-adr-0087-registration reads a rev's migration ledger from the template and the entry files when the rev tracks the template (the committed registry otherwise, for merge bases from before), and its witness runs the rev's own registry generator before projecting. A new GR battery covers the generated era, including the transition merge. - check-future-spec-major drops its exemption row for the registry; the entry file that carries the sentence keeps its own. - check-generated records gen:migration-registry as an ungated generator of git-ignored build output; regen-artifacts marks the registry row untracked. - The lint.yml step and the migration_registry gate family retire, with the selector's self-test pins for that family (its floor drops to the measured 59 cases / 304 checks). - build-schemas remedies, the entries README, .gitattributes and test comments stop naming the retired check; step18-rationale-merge merges the template, the file git now merges. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- .gitattributes | 9 +- .github/workflows/lint.yml | 30 +-- .../build-migration-registry-entry.test.ts | 10 +- .../scripts/build-schemas-check-mode.test.ts | 5 +- packages/spec/scripts/build-schemas.ts | 14 +- packages/spec/scripts/check-generated.ts | 23 +- .../scripts/step18-rationale-merge.test.ts | 7 +- .../spec/src/migrations/entries/README.md | 26 ++- .../spec/src/migrations/migrations.test.ts | 6 +- scripts/check-adr-0087-registration.mjs | 199 +++++++++++++++++- scripts/check-future-spec-major.mjs | 14 +- scripts/ci/select-gate-families.selftest.sh | 131 ++---------- scripts/ci/select-gate-families.sh | 43 +--- scripts/regen-artifacts.mjs | 30 +-- 14 files changed, 300 insertions(+), 247 deletions(-) diff --git a/.gitattributes b/.gitattributes index f2736eec163..7cc23e8822b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -64,11 +64,10 @@ # The migrations registry is the interesting one since #7297: its three append # tables now come from `packages/spec/src/migrations/entries/` (one file per # entry, the `.changeset/*.md` shape), so the conflict two retirement cards used -# to have is gone at the SOURCE — different entries are different files. The -# file stays out of this list anyway, because it is now MIXED: a driver that -# deferred it whole would resolve its still-hand-written prose by regenerating, -# which loses an edit rather than a merge. `check:migration-registry` is what -# guards the generated half instead. +# to have is gone at the SOURCE — different entries are different files. Since +# #22554 the registry itself is git-ignored and generated whole at install and +# build, so git never merges it; its hand-written prose lives in +# `registry.ts.template`, which is plain source and text-merges like any other. # # The strictness ledger's COUNTS file joined at #5107 — the ledger's numbers were # the repo's hottest conflict surface and merged in the one way that hides: two diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a789427245a..d45e9074561 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -283,11 +283,11 @@ jobs: # over the ten merge-group runs measured for #16496; the PM dispatch-gates # self-test alone 597 s), and none of its expensive steps read a # merge group's file surface: a docs-only group paid the full battery. - # So on `merge_group` and `pull_request` the TEN scoped families below + # So on `merge_group` and `pull_request` the NINE scoped families below # -- each step carrying `if: steps.gate-families.outputs. != 'skip'` # -- run only when the changed paths touch the files that family reads. - # One of the ten, `migration_registry` (#19753), is a gate this job did - # not run before at all; the other nine were scoped away from it. + # (A tenth, `migration_registry` (#19753), retired with its gate in + # #22554: the registry it compared is no longer committed.) # `push` on main and the scheduled full run keep the whole battery: the # script runs everything for any event it does not scope. # @@ -399,24 +399,12 @@ jobs: - name: ADR anchors + number uniqueness (governed code names its decision) run: pnpm check:adr-anchors - # Migration registry matches its entry files (#19753). `registry.ts` is - # the generated concatenation of `packages/spec/src/migrations/entries/`, - # it ships in the spec package's `dist`, and this check is the only - # proof the two agree -- `check:generated --reconcile-only` (in the - # `Type Check · source gates` job) proves the script is classified, - # never that the artifact is current. It ran - # in no CI step, so an entry edited without `gen:migration-registry` - # shipped the old text with every required check green. - # - # Scoped, on the maintainer's ruling for that card: a step in THIS - # required job, run when the change touches what the check reads - # (`migration_registry` in the selector above). ⛔ Not a separate - # `paths:`-filtered job: one that never reports on an unrelated PR - # leaves a required context pending forever. Cheap -- tsx over one - # directory, no build (~1.4 s measured locally) -- so it sits early. - - name: Migration registry matches its entry files - if: steps.gate-families.outputs.migration_registry != 'skip' - run: pnpm --filter @objectstack/spec check:migration-registry + # No "migration registry matches its entry files" step since #22554: + # `packages/spec/src/migrations/registry.ts` is git-ignored and generated + # whole from `registry.ts.template` + `entries/` by `gen:migration-registry`, + # which `pnpm install` above already ran (spec's `prepare`, self-test + # included). There is no committed copy left to compare, and an entry the + # generator refuses fails that install step, loudly, in every job. # Enforces the no-restricted-imports guard against @objectstack/spec root # namespace imports (the dormant rule was never run in CI). Syntactic diff --git a/packages/spec/scripts/build-migration-registry-entry.test.ts b/packages/spec/scripts/build-migration-registry-entry.test.ts index 0882cf49c26..1212f23249e 100644 --- a/packages/spec/scripts/build-migration-registry-entry.test.ts +++ b/packages/spec/scripts/build-migration-registry-entry.test.ts @@ -3,8 +3,9 @@ // `build-migration-registry.ts` has TWO kinds of caller now, and its entry guard // is the only thing telling them apart: // -// - pnpm RUNS it (`gen:migration-registry`, `check:migration-registry`), and -// write mode rewrites `src/migrations/registry.ts`; +// - pnpm RUNS it (`gen:migration-registry`, on install, in `build`, before +// `typecheck` and `test`), and every run rewrites `src/migrations/registry.ts` +// from the template and the entries; // - `build-schemas.ts` IMPORTS it, for `shardNameFor` — so the entry file each // registration remedy names is the one name the generator accepts, read from // the rule `entries/README.md` documents instead of restated beside it. @@ -66,8 +67,9 @@ describe('build-migration-registry.ts — run, it speaks; import, it runs nothin ); const r = tsx([probe]); expect(r.status, r.stderr).toBe(0); - // Write mode would have printed `✓ wrote src/migrations/registry.ts (…)`, and - // check mode `✓ … is current` — any line beyond the probe's own is the defect. + // A run would have printed its self-test line and then `✓ wrote + // src/migrations/registry.ts (…)` or `✓ … is current` — any line beyond the + // probe's own is the defect. expect(r.stdout).toBe('17.data__AggregationNode__distinct.ts\n'); expect(r.stderr).toBe(''); }); diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 76e2f9c8565..47f1e5b20b6 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -3525,8 +3525,9 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its // The prescription IS the contract: the entry FILE to add per key, named // by the generator's own rule, and the generator command. RETIRED_KEYS_BY_MAJOR // is generated from those files, so a remedy that pastes lines into - // src/migrations/registry.ts sends the author to an edit the next run - // reverts and check:migration-registry fails. + // src/migrations/registry.ts sends the author to an edit that is never + // committed (the file is generated whole and git-ignored) and the next run + // overwrites. const remedy = output.slice(output.indexOf(`2 ${CHECK_B}`)); for (const key of [LEAF_COLLIDER_A, LEAF_COLLIDER_B]) { expect(remedy).toContain(entryFile('retired-keys', key)); diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 1fbb6bbcaf2..b04092ea74c 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -771,9 +771,9 @@ const CURRENT_MAJOR = Number.parseInt(SPEC_VERSION, 10); // list are GENERATED into src/migrations/registry.ts from one file per entry // under src/migrations/entries/. So every remedy in this file that asks for a // registration — or for one to be undone — names the entry FILE and the -// generator command, never the table: an entry typed between the generated -// markers is reverted by the next `gen:migration-registry` run and failed by -// `check:migration-registry` before that. The file name comes from the +// generator command, never the table: registry.ts is generated whole and +// git-ignored (#22554), so an entry typed into it is never committed and the +// next `gen:migration-registry` run overwrites it. The file name comes from the // generator's own `shardNameFor`, the one naming rule entries/README.md // documents, so a gate never prints a name the generator would refuse. @@ -1193,8 +1193,8 @@ if (surfaceDoc) { `\n — with a \`//\` comment above the \`export\` saying why the key was retired (it\n` + ` lands above the entry in the table), then run\n` + ` \`${MIGRATION_REGISTRY_COMMAND}\`. ⛔ Never type an entry into the\n` + - ` generated table by hand: the next run reverts it, and check:migration-registry\n` + - ` fails it first. Nothing is inferred here: a leaf name matched against unrelated\n` + + ` generated table by hand: the registry is generated whole and never committed,\n` + + ` so the edit is lost. Nothing is inferred here: a leaf name matched against unrelated\n` + ` conversion surfaces is what let tombstones register themselves by coincidence\n` + ` (#4659).\n\n` + ` 2. Add a D2 conversion in src/conversions/registry.ts naming the surface (and a D3\n` + @@ -2716,8 +2716,8 @@ function checkManifestRemovals(git: GitRun, baseRev: string | null): void { ` ${MIGRATION_ENTRIES_README}; these names are the generator's own) —\n\n` + renderNewEntryFiles('retired-def', unregistered) + `\n — then run \`${MIGRATION_REGISTRY_COMMAND}\`. ⛔ Never type an\n` + - ` entry into the generated table by hand: the next run reverts it, and\n` + - ` check:migration-registry fails it first.\n\n` + + ` entry into the generated table by hand: the registry is generated whole and\n` + + ` never committed, so the edit is lost.\n\n` + ` 2. Add a D2 conversion in src/conversions/registry.ts naming the surface (and a D3\n` + ` chain step referencing it) plus a \`major\` changeset, so the removal reaches\n` + ` spec-changes.json, the upgrade guide and \`os migrate meta\` — the table is the\n` + diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index efa49a8f333..87ecb6ac88f 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -76,16 +76,6 @@ const GATED: ReadonlyArray<{ readsSchemaTree?: string; ratchet?: true; }> = [ - // First, because it is UPSTREAM of the two below and the cheapest thing in the - // list: it reads a directory and splices text, with no schema build. Both - // `spec-changes.json` and the upgrade guide are projections of the migration - // registry (#7297), so a registry left stale after an entry file was added - // reports as THREE stale artifacts, of which only this one names the cause. - { - check: 'check:migration-registry', - gen: 'gen:migration-registry', - artifact: 'src/migrations/registry.ts — its generated regions, from src/migrations/entries/', - }, // Its committed copy is gone too (#22485): the publish lane writes the shipped one // into the package, `gen:spec-changes` writes the same gitignored file locally, // and the gate generates in memory, so it never reports stale. @@ -438,6 +428,19 @@ const UNGATED_GENERATORS: ReadonlyArray<{ gen: string; why: string }> = [ '`pnpm build` — green by construction, the #4177/#4232 class.', }, { gen: 'gen:sbom', why: 'the SBOM is a release artifact, regenerated at publish time rather than checked in' }, + // Its gate, `check:migration-registry`, retired with the committed copy (#22554): + // there is nothing left to compare. Not a hole, for the reason `gen:openapi`'s + // entry gives, and more strongly — this output is never read stale by a build. + { + gen: 'gen:migration-registry', + why: + 'src/migrations/registry.ts is GITIGNORED build output since #22554, generated whole from ' + + 'src/migrations/registry.ts.template and src/migrations/entries/. There is no committed copy for a ' + + 'gate to compare: the generator runs on `pnpm install` (`prepare`), as the first step of `build`, and ' + + 'before `typecheck`, `test` and `test:repo` (their scripts, and the turbo task they depend on, whose ' + + 'output is the file), and its --self-test (splicing, ordering, determinism) runs inside every ' + + 'generation. A malformed entry fails the generation itself, loudly, before anything reads the file.', + }, ]; /** diff --git a/packages/spec/scripts/step18-rationale-merge.test.ts b/packages/spec/scripts/step18-rationale-merge.test.ts index 7c295214873..c335a99a8fc 100644 --- a/packages/spec/scripts/step18-rationale-merge.test.ts +++ b/packages/spec/scripts/step18-rationale-merge.test.ts @@ -34,9 +34,12 @@ import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; import { MIGRATIONS_BY_MAJOR } from '../src/migrations/registry'; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const REGISTRY_PATH = path.resolve(HERE, '../src/migrations/registry.ts'); +// The file git merges: since #22554 `registry.ts` is generated whole and never +// committed, so the hand-written step-18 rationale lives in the template, and the +// template is what two retirement PRs edit, and what GitHub merges. +const REGISTRY_PATH = path.resolve(HERE, '../src/migrations/registry.ts.template'); /** Where the file sits in the fixture repo — the real relative path, so a failure names it. */ -const REL = 'packages/spec/src/migrations/registry.ts'; +const REL = 'packages/spec/src/migrations/registry.ts.template'; const OPEN = 'const STEP18_RATIONALE: readonly RationaleFragment[] = [\n'; const CLOSE = '];\n'; diff --git a/packages/spec/src/migrations/entries/README.md b/packages/spec/src/migrations/entries/README.md index 874560bcb2a..5f04e3b1179 100644 --- a/packages/spec/src/migrations/entries/README.md +++ b/packages/spec/src/migrations/entries/README.md @@ -2,8 +2,10 @@ One TypeScript file per entry of `../registry.ts`'s three append tables (#7297, the registry half of #6957's ruling). `pnpm --filter @objectstack/spec gen:migration-registry` -concatenates them into that file's marked regions, sorted by entry id; -`check:migration-registry` proves the regions still match this directory. +writes `../registry.ts` whole: the hand-written skeleton `../registry.ts.template` with +these files concatenated into its marked regions, sorted by entry id. It runs on +`pnpm install`, as the first step of `build`, and before `typecheck` and `test`, and +`registry.ts` is git-ignored (#22554), so there is no committed copy to keep in step. | directory | table | id | | ---------------- | ------------------------------------ | -------------------------------------- | @@ -65,9 +67,10 @@ into the registry; the run of `//` comments immediately above `export const entr ## Four rules that are not style -- **Touch no other file, and never edit inside the markers.** `registry.ts`'s - `` regions are output. A hand edit there is reverted by the next - `gen:` run and reported by `check:migration-registry` before that. +- **Touch no other file for the entry, and never edit `registry.ts`.** It is output, + generated whole and git-ignored: a hand edit there is never committed, and the next + `gen:` run overwrites it. Prose outside the tables (a step's `rationale`) lives in + `../registry.ts.template`. - **There is no index, deliberately.** An index is itself a single append-only file every card must edit, which is the exact conflict this directory removes (PM decision on #6957). The directory listing is the index, and order is derived from the id. @@ -96,9 +99,11 @@ into the registry; the run of `//` comments immediately above `export const entr ## What this does not fix -The `registry.ts` lap. An entry lands with its generated regions in `registry.ts`, so -`gen:migration-registry` regenerates that one file and the pull request commits it; -`check:migration-registry` is its gate. Nothing else is regenerated for an entry. +Not the template's prose. A step-18 retirement still adds its `STEP18_RATIONALE` +fragment to `../registry.ts.template`, which is hand-written and merges as text; the +fragments are kept sorted by key so two retirements insert at different lines (#20535). +The `registry.ts` lap is gone (#22554): the file is generated at install and build and +never committed, so an entry's pull request regenerates nothing for it. `spec-changes.json` and the protocol upgrade guide are projections of this registry, but neither is committed any more (#22449 B′: the guide's copy left git at #22483, `spec-changes.json`'s at #22485). The publish lane generates both into the package @@ -113,8 +118,9 @@ generates. A retirement card is not faster for this file, only harder to lose. History, kept for its `registry.ts` conclusion. This section measured the two projections while they were committed and routed `merge=os-regen`; since #22485 -they are neither, so there is nothing of theirs left to merge. What still holds is the -residue paragraph below: every adjacent-id collision conflicts in `registry.ts`. +they are neither, so there is nothing of theirs left to merge. Since #22554 the residue +paragraph's `registry.ts` conflict is history too: the registry is no longer committed, +so an adjacent-id pair of entries no longer meets anywhere. `.gitattributes` routes both through `merge=os-regen`, and that driver is a **local** git facility: the GitHub merge queue rebuilds each PR server-side, where no custom diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 634bc3cb4f1..5b79a2413ef 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -42,9 +42,9 @@ const SEMANTIC_ENTRIES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), 'e * The full text — leading comment AND literal — of every `semantic/` entry * file registered under `major`, keyed by filename. The files, not the parsed * objects: an entry may name its family's conversion in the comment the - * generator carries into `registry.ts` rather than in a field, and - * `check:migration-registry` already proves the files and the generated - * region are the same set. + * generator carries into `registry.ts` rather than in a field, and the + * generator writes the region from exactly these files on every build (the + * registry is never committed, so there is no second copy to drift). */ function semanticEntrySources(major: number): Map { const out = new Map(); diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index 7e383d48ea9..af8e43e5353 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -446,6 +446,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'I2d: the synthetic convention-rot controls run on a repo whose stock DOES': 4, 'V3: the real repository is the case that matters -- this gate\'s own vocabulary': 10, 'W1-W6 (#22485): the parser-rot witness is GENERATED at head, never read from a committed copy': 9, + 'GR1-GR7 (#22554): the migration ledger is GENERATED at build, read from its template and entry files': 13, 'Unit pins on the two pattern-shaped judgements': 29, 'the floors: what the new vocabulary must refuse': 9, 'the floors: labels that are NOT mentions, and mentions that ARE evidenced': 9, @@ -467,7 +468,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 50; +const SELF_TEST_BATTERY_FLOOR = 51; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -483,6 +484,24 @@ export const LEDGER_SOURCES = [ 'packages/spec/src/conversions/registry.ts', ]; +/** + * The two committed sources of `LEDGER_SOURCES[0]` since #22554, which took the + * migration registry out of git: it is GENERATED at build, whole, from the + * hand-written template and one file per entry under the three entry directories + * (the generator's `KINDS`, `packages/spec/scripts/build-migration-registry.ts`). + * A rev of that era tracks these and not the registry; every earlier rev tracks the + * registry and not the template. A merge base can sit in either era, so the era is + * read off the rev itself — see `migrationLedgerTextAt`. + */ +export const MIGRATION_LEDGER_TEMPLATE = 'packages/spec/src/migrations/registry.ts.template'; +/** What writes `LEDGER_SOURCES[0]` from those two in the generated era; the witness runs it first. */ +const MIGRATION_LEDGER_GENERATOR = 'packages/spec/scripts/build-migration-registry.ts'; +export const MIGRATION_LEDGER_ENTRY_DIRS = [ + 'packages/spec/src/migrations/entries/semantic', + 'packages/spec/src/migrations/entries/retired-keys', + 'packages/spec/src/migrations/entries/retired-defs', +]; + /** * The generator whose output proves the source parser still matches (#4690), and * the inputs it reads, relative to the tree root. Since #22485 there is no @@ -2081,6 +2100,34 @@ export function generatedWitness({ cwd, head, toolRoot = REPO_ROOT }) { for (const rel of ['node_modules', join('packages', 'spec', 'node_modules')]) { if (existsSync(join(toolRoot, rel)) && !existsSync(join(tree, rel))) symlinkSync(join(toolRoot, rel), join(tree, rel)); } + // The generated era (#22554): the archive carries the template and the entry + // files but no registry, exactly as a fresh checkout does, so the rev's OWN + // registry generator runs first -- the step `build` and `pnpm install` take. A + // template with no generator beside it is a red, never a witness of nothing. + if (existsSync(join(tree, MIGRATION_LEDGER_TEMPLATE))) { + const registryGenerator = join(tree, MIGRATION_LEDGER_GENERATOR); + if (!existsSync(registryGenerator)) { + return fail( + `${MIGRATION_LEDGER_TEMPLATE} is tracked at ${head} but ${MIGRATION_LEDGER_GENERATOR} is not, so the\n` + + ' migration registry the witness generator imports cannot be generated. If the generator moved,\n' + + ' update MIGRATION_LEDGER_GENERATOR in this script.', + ); + } + const generated = spawnSync(process.execPath, [tsxCli, registryGenerator], { + cwd: join(tree, 'packages', 'spec'), + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (generated.status !== 0) { + const said = `${generated.stdout ?? ''}${generated.stderr ?? ''}${generated.error ? String(generated.error) : ''}`.trim(); + return fail( + `${MIGRATION_LEDGER_GENERATOR} exited ${generated.status ?? 'without a status'} at ${head}, so the migration\n` + + ' registry the witness generator imports does not exist:\n' + + ` ${said.split('\n').slice(0, 15).join('\n ')}`, + ); + } + } const generator = join(tree, WITNESS_GENERATOR); const out = join(dir, 'spec-changes.json'); const run = spawnSync(process.execPath, [tsxCli, generator, '--out', out], { @@ -2223,7 +2270,7 @@ export function ledgerAt(rev, cwd) { const bySource = {}; const missingSources = []; for (const path of LEDGER_SOURCES) { - const src = showOrNull(rev, path, cwd); + const src = path === LEDGER_SOURCES[0] ? migrationLedgerTextAt(rev, cwd) : showOrNull(rev, path, cwd); if (src === null) { missingSources.push(path); continue; } const found = extractIds(src); bySource[path] = found.length; @@ -2232,6 +2279,37 @@ export function ledgerAt(rev, cwd) { return { ids, bySource, missingSources }; } +/** + * The text of the migration ledger at a rev: what that rev's BUILD reads. + * + * In the generated era (#22554: the rev tracks `MIGRATION_LEDGER_TEMPLATE`) that is + * the template followed by every `.ts` entry file the generator concatenates into + * it — the generated registry's lines, in another order, which `extractIds` reads as + * a set. A non-`.ts` file there (the directory's README, whose examples spell + * id-shaped lines) is not an entry: the generator refuses it, so it is not read. A + * registry file a rev of this era still tracks is a stale copy, and the template + * wins over it. In the committed era the registry itself is the text. `null` when a + * rev has neither: a red in `assertInputs`, never an empty ledger. + * + * Exported for the self-test's GR battery. + * + * @returns {string | null} + */ +export function migrationLedgerTextAt(rev, cwd) { + const template = showOrNull(rev, MIGRATION_LEDGER_TEMPLATE, cwd); + if (template === null) return showOrNull(rev, LEDGER_SOURCES[0], cwd); + let listed; + try { + listed = git(['ls-tree', '-r', '--name-only', rev, '--', ...MIGRATION_LEDGER_ENTRY_DIRS], cwd); + } catch { + return null; + } + const entries = listed.split('\n').map((p) => p.trim()).filter((p) => p.endsWith('.ts')).sort(); + const texts = showManyOrNull(rev, entries, cwd); + if (texts.size !== entries.length) return null; + return [template, ...entries.map((p) => texts.get(p))].join('\n'); +} + // --------------------------------------------------------------------------- // Input assertions -- absence is never a pass (#4690) // --------------------------------------------------------------------------- @@ -2274,7 +2352,8 @@ export function assertInputs({ cwd, head, witness = generatedWitness }) { const { ids, bySource, missingSources } = ledgerAt(head, cwd); for (const path of missingSources) { problems.push( - `ADR-0087 ledger source not found at HEAD: ${path}\n` + + `ADR-0087 ledger source not found at HEAD: ${path}` + + (path === LEDGER_SOURCES[0] ? ` (nor its generated-era source, ${MIGRATION_LEDGER_TEMPLATE})` : '') + '\n' + ' fix: if the ledger moved, update LEDGER_SOURCES in this script. A missing ledger is a\n' + ' red, never a silent skip -- every `registered` claim would otherwise be unverifiable.', ); @@ -4322,7 +4401,9 @@ function ledgerTouchingCommits(path, head, cwd) { for (const sha of shas) { let names; try { names = git(['show', '--name-only', '--format=', sha], cwd); } catch { continue; } - if (LEDGER_SOURCES.some((src) => names.includes(src))) hits.push(sha); + // The generated era (#22554): a registration commit adds an entry file and + // touches no registry, so the ledger's two committed sources count too. + if ([...LEDGER_SOURCES, MIGRATION_LEDGER_TEMPLATE, ...MIGRATION_LEDGER_ENTRY_DIRS].some((src) => names.includes(src))) hits.push(sha); } return { available: true, shas: hits }; } @@ -6475,6 +6556,116 @@ function selfTest() { ); } + // ---- GR1-GR7 (#22554): the migration ledger is GENERATED at build --------- + // + // A generated-era rev tracks `MIGRATION_LEDGER_TEMPLATE` and the entry files and + // NOT the registry, exactly as `main` does after #22554. These fixtures carry the + // REAL registry generator, copied from this repository, so the witness runs the + // step a fresh checkout's install and build run -- never a model of it. + battery('GR1-GR7 (#22554): the migration ledger is GENERATED at build, read from its template and entry files'); + { + const realGenerator = readFileSync(join(REPO_ROOT, MIGRATION_LEDGER_GENERATOR), 'utf8'); + const TEMPLATE = 'export const X = {\n semantic: [\n // \n // \n ],\n};\n'; + const ENTRY = (id) => `export const entry = {\n id: '${id}',\n surface: 's',\n};\n`; + const entryPath = (id) => `${MIGRATION_LEDGER_ENTRY_DIRS[0]}/17.${id}.ts`; + // Every entry directory carries a README, the generator's one declared + // non-entry file: git keeps no empty directory, and the generator refuses a + // missing one. The semantic README spells an id-shaped example line (GR2). + const generatedEra = (entryIds) => ({ + [MIGRATION_LEDGER_TEMPLATE]: TEMPLATE, + [MIGRATION_LEDGER_GENERATOR]: realGenerator, + [`${MIGRATION_LEDGER_ENTRY_DIRS[0]}/README.md`]: "An entry looks like this:\n\n id: 'readme-example',\n", + [`${MIGRATION_LEDGER_ENTRY_DIRS[1]}/README.md`]: 'retired keys\n', + [`${MIGRATION_LEDGER_ENTRY_DIRS[2]}/README.md`]: 'retired defs\n', + ...Object.fromEntries(entryIds.map((id) => [entryPath(id), ENTRY(id)])), + }); + const generatedRepo = (files) => { + const dir = mkdtempSync(join(tmpdir(), 'adr0087-generated-era-')); + cleanup.push(dir); + const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), text); }; + git(['init', '-q', '-b', 'main'], dir); + git(['config', 'user.email', 't@t'], dir); + git(['config', 'user.name', 't'], dir); + w(LEDGER_SOURCES[1], CONV(['a-conversion'])); + w(WITNESS_GENERATOR, WITNESS_GEN()); + w(ADR_0087, ADR_DOC()); + w('packages/spec/package.json', JSON.stringify({ name: '@objectstack/spec', version: '1.0.0' })); + w('.changeset/stock-breaking.md', CS({ body: 'stock\n\n**BREAKING** something\n' })); + for (const [rel, text] of Object.entries(files)) w(rel, text); + git(['add', '-A'], dir); + git(['commit', '-qm', 'generated era'], dir); + return dir; + }; + + // GR1: the ledger of a generated-era rev is read from its sources, with the + // registry ABSENT from the tree -- asserted, so the green cannot be a fixture + // that quietly committed one. + const g1 = generatedRepo(generatedEra(['alpha', 'beta'])); + assert(showOrNull('HEAD', LEDGER_SOURCES[0], g1) === null, 'GR1 control: the generated-era fixture must not track the registry'); + const l1 = ledgerAt('HEAD', g1); + assert(l1.missingSources.length === 0, `GR1: a generated-era rev has a ledger -- got missing ${JSON.stringify(l1.missingSources)}`); + assert(l1.ids.has('alpha') && l1.ids.has('beta'), `GR1: both entry ids are read -- got ${JSON.stringify([...l1.ids])}`); + assert(l1.bySource[LEDGER_SOURCES[0]] === 2, `GR1: exactly the two entries, counted under the registry's name -- got ${JSON.stringify(l1.bySource)}`); + + // GR2: a non-entry file in an entry directory is not an entry. The README's + // id-shaped example line is NOT an id, because the generator would not read it. + assert(!l1.ids.has('readme-example'), 'GR2: an id-shaped line in entries/**/README.md must not be read as a ledger id'); + + // GR3: the witness runs the rev's own registry generator first, so a fresh + // tree with no registry still projects -- and agrees with the parser. + let used = null; + const p3 = assertInputs({ cwd: g1, head: 'HEAD', witness: (o) => (used = generatedWitness(o)) }); + assert(p3.length === 0, `GR3: a generated-era HEAD has every input -- got: ${p3.join('|')}`); + assert( + JSON.stringify(used?.json ? projectedMigrationIds(used.json) : null) === JSON.stringify(['alpha', 'beta']), + `GR3: the witness generated the registry and projected both entries -- got ${JSON.stringify(used)}`, + ); + + // GR4: a template with no generator beside it is red, naming what is missing. + const g4files = generatedEra(['alpha']); + delete g4files[MIGRATION_LEDGER_GENERATOR]; + const p4 = assertInputs({ cwd: generatedRepo(g4files), head: 'HEAD' }); + assert( + p4.some((p) => /no parser-rot witness/.test(p) && p.includes(`${MIGRATION_LEDGER_GENERATOR} is not`)), + `GR4: a generated-era rev with no registry generator must be RED -- got: ${p4.join('|')}`, + ); + + // GR5: a generation that refuses is red in the generator's own words -- here + // the filename/id correspondence it enforces. + const g5files = generatedEra(['alpha']); + g5files[`${MIGRATION_LEDGER_ENTRY_DIRS[0]}/17.misnamed.ts`] = ENTRY('gamma'); + const p5 = assertInputs({ cwd: generatedRepo(g5files), head: 'HEAD' }); + assert( + p5.some((p) => /no parser-rot witness/.test(p) && /exited 1/.test(p) && /filename does not match/.test(p)), + `GR5: a generator refusal must be RED, quoting it -- got: ${p5.join('|')}`, + ); + + // GR6: a rev with neither the registry nor its template has no ledger -- red, + // naming both, never an empty set. + const g6files = generatedEra(['alpha']); + delete g6files[MIGRATION_LEDGER_TEMPLATE]; + const p6 = assertInputs({ cwd: generatedRepo(g6files), head: 'HEAD', witness: parserWitness }); + assert( + p6.some((p) => p.includes(`ledger source not found at HEAD: ${LEDGER_SOURCES[0]}`) && p.includes(MIGRATION_LEDGER_TEMPLATE)), + `GR6: no registry and no template must be RED, naming both -- got: ${p6.join('|')}`, + ); + + // GR7: THE TRANSITION. The merge base tracks the registry (committed era) and + // HEAD replaces it with its sources (generated era) -- the shape of the PR that + // made the change, and of every branch forked before it. An entry carried + // across is NOT new; an entry added beside it is. + const transition = (claim) => run(mk({ + baseIds: ['alpha'], + files: { + [LEDGER_SOURCES[0]]: null, + ...generatedEra(['alpha', 'beta']), + '.changeset/x.md': CS({ body: `**BREAKING** x\n\n\n` }), + }, + })); + green('GR7 transition: an entry added in the generated era is a new registration', transition('beta')); + red('GR7 transition: an entry carried over from the committed era is not new', transition('alpha'), [/none of those ids is NEW in this diff/]); + } + // ---- Unit pins on the two pattern-shaped judgements ---------------------- // // P1-P3 are the LABEL branch, unchanged since #6148 -- the template placeholder diff --git a/scripts/check-future-spec-major.mjs b/scripts/check-future-spec-major.mjs index 9fe210b8312..5cd87e70c58 100644 --- a/scripts/check-future-spec-major.mjs +++ b/scripts/check-future-spec-major.mjs @@ -337,16 +337,6 @@ const QUOTATION_EXEMPTIONS = Object.freeze([ + 'rule can tell a count from a version when both sit one space after ' + 'the name.', }, - { - file: 'packages/spec/src/migrations/registry.ts', - major: 4997, - kind: 'measurement', - covers: 1, - witness: /controls objectstack 12966 and @objectstack\/spec 4997 on the same corpus/, - why: - 'The same recorded evidence sentence, in the registry that carries the ' - + 'semantic entry above. A count, not a version.', - }, { file: 'docs/adr/0021-analytics-dataset-semantic-layer.md', major: 6526, @@ -726,9 +716,9 @@ function selfTest() { files: F( " + 'the string debounceDelay each occur 0 times across its 8228 tracked files, against lit '\n" + " + 'controls objectstack 12966 and @objectstack/spec 4997 on the same corpus.',\n", - 'packages/spec/src/migrations/registry.ts', + 'packages/spec/src/migrations/entries/semantic/18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts', ), - ledger: [QUOTATION_EXEMPTIONS[4]], + ledger: [QUOTATION_EXEMPTIONS[3]], expect: 'green', }, { diff --git a/scripts/ci/select-gate-families.selftest.sh b/scripts/ci/select-gate-families.selftest.sh index 7422005c5f0..2ba748c1e1b 100644 --- a/scripts/ci/select-gate-families.selftest.sh +++ b/scripts/ci/select-gate-families.selftest.sh @@ -30,11 +30,8 @@ # self-test, and `schedule` and `push` still run it over the very changes the # PR path skips. # -# Since #19753 it covers migration_registry, the one family that ADDS a gate -# to the PR path: an entry, the generated registry, the generator and the two -# package files that decide how it runs must each select it; a spec source -# outside the migration tree, a sibling spec script and a lookalike directory -# must not; and a deletion, an empty diff or an unresolvable base still run it. +# It covered a tenth family, migration_registry (#19753), until #22554 retired +# it with its gate: the migration registry it compared is no longer committed. # # The last section reads the REAL lint.yml and pins the YAML half of the # contract: the selector step exists under the id the `if:` lines name, every @@ -73,7 +70,7 @@ git_q() { git -c user.name=selftest -c user.email=selftest@example.invalid -c commit.gpgsign=false "$@" } -ALL='migration_registry slot_lookup query_options_erasure entry_guard comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands verify_lock' +ALL='slot_lookup query_options_erasure entry_guard comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands verify_lock' # ── Fixture repositories ──────────────────────────────────────────────────── # C0 carries one representative file of every class the classifier names, so @@ -84,7 +81,7 @@ git_q -C "$UP" init -q git_q -C "$UP" symbolic-ref HEAD refs/heads/main mkdir -p "$UP/packages/a/scripts" "$UP/packages/a/src" "$UP/apps/site/src" "$UP/docs" "$UP/content/docs" \ "$UP/scripts/pm" "$UP/scripts/ci" "$UP/.github/workflows" "$UP/.claude/agents" "$UP/skills/x" "$UP/.changeset" \ - "$UP/packages/spec/src/migrations/entries/semantic" "$UP/packages/spec/scripts" + "$UP/packages/spec/src" "$UP/packages/spec/scripts" printf '{"name":"fixture","private":true}\n' > "$UP/package.json" printf 'packages:\n - packages/*\n' > "$UP/pnpm-workspace.yaml" printf '{"name":"a","scripts":{"check:gate":"vitest run scripts/gate.test.ts"}}\n' > "$UP/packages/a/package.json" @@ -126,13 +123,10 @@ printf '# rules\n' > "$UP/AGENTS.md" printf '# claude\n' > "$UP/CLAUDE.md" printf '# readme\n' > "$UP/README.md" printf -- '---\n"a": patch\n---\nchange\n' > "$UP/.changeset/first.md" -# The migration_registry read-set (#19753), and its nearest neighbours outside it. +# A spec package: its manifest, a source and package-local scripts the cases edit. printf '{"name":"spec"}\n' > "$UP/packages/spec/package.json" printf '{}\n' > "$UP/packages/spec/tsconfig.json" printf 'export const index = 1;\n' > "$UP/packages/spec/src/index.ts" -printf 'export const registry = [];\n' > "$UP/packages/spec/src/migrations/registry.ts" -printf 'export const entry = 1;\n' > "$UP/packages/spec/src/migrations/entries/semantic/17.x.ts" -printf 'export const gen = 1;\n' > "$UP/packages/spec/scripts/build-migration-registry.ts" printf 'export const schemas = 1;\n' > "$UP/packages/spec/scripts/build-schemas.ts" printf 'export const merge = 1;\n' > "$UP/packages/spec/scripts/conversions-merge.test.ts" git_q -C "$UP" add -A @@ -308,8 +302,7 @@ expect_all_run expect_reason pm_dispatch_gates "event 'push' is not scoped" expect_reason entry_guard "event 'push' is not scoped" expect_reason bare_root_worklist "event 'push' is not scoped" -expect_reason migration_registry "event 'push' is not scoped" -expect_line 'Gate families: 10 run, 0 skipped' +expect_line 'Gate families: 9 run, 0 skipped' run_case 'schedule: every family runs (the hourly full run keeps the battery)' "$REPO" schedule '' '' expect_rc 0 @@ -356,7 +349,7 @@ expect_reason pm_dispatch_gates 'scripts/pm/tool.mjs (M, scripts)' expect_reason self_test_workflow_commands 'scripts/pm/tool.mjs (M, scripts)' expect_changed 'one M' "M scripts/pm/tool.mjs" expect_line "Gate-family diff base: $C0 (the merge group's base_sha)" -expect_line 'Gate families: 6 run, 4 skipped' +expect_line 'Gate families: 6 run, 3 skipped' S=$(scenario A:scripts/pm/new-tool.mjs) run_case 'merge_group: an ADDED file inside the read-set still runs it -- the narrowing is about the class, never the status' "$REPO" merge_group '' "$C0" @@ -370,7 +363,7 @@ run_case 'merge_group: a scripts/pm prose change runs the tooling self-tests, an expect_rc 0 expect_warnings '' '' expect_verdicts entry_guard pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands -expect_line 'Gate families: 5 run, 5 skipped' +expect_line 'Gate families: 5 run, 4 skipped' S=$(scenario M:docs/guide.md M:content/docs/page.mdx M:.changeset/first.md M:README.md) run_case 'merge_group: a docs-only group skips every family, and prints it' "$REPO" merge_group '' "$C0" @@ -379,13 +372,11 @@ expect_warnings '' '' expect_verdicts expect_reason pm_dispatch_gates 'no changed path is in its read-set' expect_reason entry_guard 'no changed path is in its read-set' -expect_reason migration_registry 'no changed path is in its read-set' -expect_line 'Gate families: 0 run, 10 skipped' +expect_line 'Gate families: 0 run, 9 skipped' expect_line 'skip pm_dispatch_gates' expect_line 'skip comment_mask_corpus' expect_line 'skip self_test_workflow_commands' -expect_line 'skip migration_registry' -if grep -q '^| `pm_dispatch_gates` | skip |' "$RT/step-summary" && grep -q '^## Gate families: 0 run, 10 skipped' "$RT/step-summary"; then +if grep -q '^| `pm_dispatch_gates` | skip |' "$RT/step-summary" && grep -q '^## Gate families: 0 run, 9 skipped' "$RT/step-summary"; then ok 'the step summary lists the skipped families' else bad 'the step summary lists the skipped families' "$(tr '\n' ' ' < "$RT/step-summary")" @@ -447,7 +438,6 @@ run_case 'merge_group: an EMPTY diff runs every family rather than selecting not expect_rc 0 expect_warnings '#10057' "::warning::The diff against $C0 listed no changed files; every gate family runs rather than selecting nothing (#10057)." expect_all_run -expect_reason migration_registry "the diff against $C0 listed no changed files" expect_changed 'empty' '' S=$(scenario M:docs/guide.md) @@ -456,14 +446,12 @@ expect_rc 0 expect_warnings 'no base' '::warning::This merge_group event carries no base_sha, so the gate-family diff base cannot be computed; every family runs.' expect_all_run expect_reason pm_dispatch_gates 'no base_sha in the merge_group event' -expect_reason migration_registry 'no base_sha in the merge_group event' run_case 'merge_group: an unfetchable base_sha warns twice and runs everything' "$REPO" merge_group '' "$ZEROS" expect_rc 0 expect_warnings 'fetch + resolve' "::warning::Could not fetch the merge group's base $ZEROS; the resolution below will decide. ::warning::Could not resolve the merge group's base '$ZEROS' in this checkout; every gate family runs rather than guessing which paths changed (#16453)." expect_all_run -expect_reason migration_registry "merge group base '$ZEROS' unresolvable" if git_q -C "$SHALLOW" cat-file -e "$C0^{commit}" 2>/dev/null; then bad 'precondition: the shallow clone lacks C0 before the fetch case' @@ -512,8 +500,7 @@ expect_reason declared_population_live 'no changed path is in its read-set' expect_reason bare_root_worklist 'no changed path is in its read-set' expect_reason self_test_workflow_commands 'no changed path is in its read-set' expect_reason entry_guard 'no changed path is in its read-set' -expect_reason migration_registry 'no changed path is in its read-set' -expect_line 'Gate families: 3 run, 7 skipped' +expect_line 'Gate families: 3 run, 6 skipped' S=$(scenario M:apps/site/src/page.tsx) run_case 'merge_group: an apps TSX edit is outside the ratchets (packages/** only) and inside the corpus alone' "$REPO" merge_group '' "$C0" @@ -572,11 +559,10 @@ expect_verdicts entry_guard pm_dispatch_gates declared_population_live bare_root expect_reason verify_lock '(M, verify-lock)' S=$(scenario M:scripts/helper.mjs) -run_case 'merge_group: a top-level scripts module is imported by the ratchets AND the lock preflight, and is a masked source -- every family but the migration registry, which reads no repo-level script' "$REPO" merge_group '' "$C0" +run_case 'merge_group: a top-level scripts module is imported by the ratchets AND the lock preflight, and is a masked source -- every family runs' "$REPO" merge_group '' "$C0" expect_rc 0 expect_verdicts slot_lookup query_options_erasure entry_guard comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands verify_lock expect_reason verify_lock scripts/helper.mjs -expect_reason migration_registry 'no changed path is in its read-set' S=$(scenario M:scripts/slot-lookup-baseline.json) run_case 'merge_group: a ratchet baseline runs the ratchets and the families that walk scripts/, and not the PM self-test -- the battery opens no data file (#22076)' "$REPO" merge_group '' "$C0" @@ -658,81 +644,7 @@ S=$(scenario M:docs/guide.md M:packages/a/src/index.test.ts M:.github/workflows/ run_case 'merge_group: a mixed group runs the union of what its paths reach' "$REPO" merge_group '' "$C0" expect_rc 0 expect_verdicts slot_lookup query_options_erasure comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands -expect_line 'Gate families: 7 run, 3 skipped' - -# ── merge_group: the migration registry family (#19753) ──────────────────── -# The #19523 shape first: an entry edited and the registry NOT regenerated. -# The entry is TypeScript under packages/, so the two ratchets and the corpus -# walk ride along; what these cases pin is the migration_registry verdict. -S=$(scenario M:packages/spec/src/migrations/entries/semantic/17.x.ts) -run_case 'merge_group: an entry edited without the registry (the #19523 shape) runs the migration registry' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_warnings '' '' -expect_verdicts migration_registry slot_lookup query_options_erasure comment_mask_corpus -expect_reason migration_registry packages/spec/src/migrations/entries/semantic/17.x.ts -expect_reason migration_registry '(M, workspace)' -expect_line 'Gate families: 4 run, 6 skipped' - -S=$(scenario A:packages/spec/src/migrations/entries/retired-keys/17.ui__X__y.ts) -run_case 'merge_group: an ADDED entry runs it -- a new directory under the tree is still the tree' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_warnings '' '' -expect_verdicts migration_registry slot_lookup query_options_erasure comment_mask_corpus -expect_reason migration_registry '(A, workspace)' - -S=$(scenario A:packages/spec/src/migrations/entries/semantic/notes.txt) -run_case 'merge_group: a non-TypeScript file in an entry directory runs it and nothing else -- the gate refuses a stray file there' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_verdicts migration_registry -expect_line 'Gate families: 1 run, 9 skipped' - -S=$(scenario M:packages/spec/src/migrations/registry.ts) -run_case 'merge_group: the generated registry edited alone runs it -- a hand edit is the other half of the drift' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_verdicts migration_registry slot_lookup query_options_erasure comment_mask_corpus - -S=$(scenario M:packages/spec/scripts/build-migration-registry.ts) -run_case 'merge_group: the generator runs it, and every family that reads a package-local script' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_verdicts migration_registry slot_lookup query_options_erasure comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands -expect_reason migration_registry packages/spec/scripts/build-migration-registry.ts - -S=$(scenario M:packages/spec/scripts/build-schemas.ts) -run_case 'merge_group: a SIBLING spec script is not the generator -- the arm names one file' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_verdicts slot_lookup query_options_erasure comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands -expect_reason migration_registry 'no changed path is in its read-set' - -S=$(scenario M:packages/spec/package.json) -run_case 'merge_group: the spec manifest runs it -- pnpm resolves the check script through it' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_verdicts migration_registry pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands verify_lock -expect_reason migration_registry '(M, workspace)' - -S=$(scenario M:packages/spec/tsconfig.json) -run_case 'merge_group: the spec tsconfig runs it alone -- tsx loads it from the package directory' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_verdicts migration_registry -expect_line 'Gate families: 1 run, 9 skipped' - -S=$(scenario M:packages/spec/src/index.ts) -run_case 'merge_group: a spec source OUTSIDE the migration tree skips it' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_warnings '' '' -expect_verdicts slot_lookup query_options_erasure comment_mask_corpus -expect_reason migration_registry 'no changed path is in its read-set' - -S=$(scenario A:packages/spec/src/migrations-notes/README.md) -run_case 'merge_group: a lookalike directory beside the tree skips it -- the arm is anchored on the separator' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_verdicts -expect_reason migration_registry 'no changed path is in its read-set' - -S=$(scenario D:packages/spec/src/migrations/entries/semantic/17.x.ts) -run_case 'merge_group: a DELETED entry runs it, with everything else (structural)' "$REPO" merge_group '' "$C0" -expect_rc 0 -expect_all_run -expect_reason migration_registry 'structural change (D packages/spec/src/migrations/entries/semantic/17.x.ts)' +expect_line 'Gate families: 7 run, 2 skipped' # ── pull_request ──────────────────────────────────────────────────────────── git_q -C "$REPO" checkout -q -B feature "$C0" @@ -743,7 +655,6 @@ run_case 'pull_request: decided against merge-base(origin/main, HEAD)' "$REPO" p expect_rc 0 expect_warnings '' '' expect_verdicts -expect_reason migration_registry 'no changed path is in its read-set' expect_line "Gate-family diff base: $C0 (merge-base of origin/main and HEAD)" expect_changed 'the feature edit only, not C1' "M docs/guide.md" @@ -759,7 +670,7 @@ expect_rc 0 expect_warnings '' '' expect_verdicts slot_lookup query_options_erasure comment_mask_corpus expect_reason pm_dispatch_gates 'no changed path is in its read-set' -expect_line 'Gate families: 3 run, 7 skipped' +expect_line 'Gate families: 3 run, 6 skipped' # The card's pin and triage's (#22076): product code alone runs no PM # self-test; scripts/pm/** runs it, prose included. @@ -797,16 +708,6 @@ expect_rc 0 expect_warnings '' '' expect_verdicts slot_lookup query_options_erasure comment_mask_corpus declared_population_live bare_root_worklist self_test_workflow_commands expect_reason pm_dispatch_gates 'no changed path is in its read-set' - -git_q -C "$REPO" checkout -q -B feature-19753 "$C0" -printf 'export const entry = 2;\n' > "$REPO/packages/spec/src/migrations/entries/semantic/17.x.ts" -git_q -C "$REPO" commit -q -am 'F3: an entry edited on a feature branch, registry not regenerated' -run_case 'pull_request: an entry edited on a feature branch runs the migration registry, decided against the merge base' "$REPO" pull_request main '' -expect_rc 0 -expect_warnings '' '' -expect_verdicts migration_registry slot_lookup query_options_erasure comment_mask_corpus -expect_reason migration_registry '(M, workspace)' -expect_line "Gate-family diff base: $C0 (merge-base of origin/main and HEAD)" git_q -C "$REPO" checkout -q -B feature "$F1" run_case 'pull_request: no base branch in the payload' "$REPO" pull_request '' '' @@ -834,7 +735,6 @@ expect_rc 0 expect_warnings 'fetch + #6195' '::warning::Could not fetch origin/nope; the merge-base resolution below will decide. ::warning::Could not resolve merge-base(origin/nope, HEAD); every gate family runs rather than guessing which paths changed (#6195).' expect_all_run -expect_reason migration_registry 'merge-base(origin/nope, HEAD) unresolvable' # ── The YAML half of the contract ─────────────────────────────────────────── # Read from the real workflow: the selector step, the `if:` spellings, and @@ -898,11 +798,10 @@ pin_step self_test_workflow_commands 'node scripts/check-self-test-workflow-comm # that no workflow passes. pin_step verify_lock 'bash scripts/pm/os-verify-lock.sh' pin_step comment_mask_corpus 'node scripts/check-comment-mask-corpus.mjs' -pin_step migration_registry 'pnpm --filter @objectstack/spec check:migration-registry' # ── Verdict ───────────────────────────────────────────────────────────────── # #4690: a battery that ran nothing is a failure, never a pass. -if [ "$cases" -lt 71 ] || [ "$checks" -lt 369 ]; then +if [ "$cases" -lt 59 ] || [ "$checks" -lt 304 ]; then echo "SELFTEST FAILED: only $cases case(s) / $checks check(s) ran -- the battery is short" exit 1 fi diff --git a/scripts/ci/select-gate-families.sh b/scripts/ci/select-gate-families.sh index 76f368b76de..ab5e541d810 100644 --- a/scripts/ci/select-gate-families.sh +++ b/scripts/ci/select-gate-families.sh @@ -136,18 +136,6 @@ # as its population, reads the workflow tree and # .github/actions to learn which self-tests CI runs, # and spawns those self-tests. -# migration_registry `pnpm --filter @objectstack/spec -# check:migration-registry` (#19753): tsx runs -# packages/spec/scripts/build-migration-registry.ts, -# its own self-test and then its check. It reads the -# generated registry and the entry directories it -# concatenates -- both under -# packages/spec/src/migrations/ -- plus its own -# source, the package manifest pnpm resolves the -# script through, and the package tsconfig.json tsx -# loads from that directory (which extends the root -# one, and root configuration runs every family). -# Nothing else in the workspace, nothing outside it. # # Root configuration (`package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, # `turbo.json`, `tsconfig.json`, `eslint.config.mjs`, .gitignore, @@ -227,16 +215,12 @@ # every `.claude/**` code file (the hooks, which the shell-mask census reads), # the workflow tree and every package manifest. # -# ## migration_registry ADDS a gate to the PR path; it scopes nothing away +# ## A tenth family, `migration_registry`, retired (#22554) # -# The other nine families were steps that ran on every PR before the selector -# existed. `check:migration-registry` ran in no CI step at all, so a PR that -# edited a migration entry without regenerating registry.ts shipped a stale, -# published registry with every required check green (#19753). The maintainer -# ruled it onto the required job as a scoped step, verbatim 「同意 A′」 -# (#19753): every PR is judged, and the step runs when a change touches the -# read-set declared above. Its skip is a RATCHET-grade claim -- no changed path -# is one the gate reads -- not the weaker tooling-self-test claim of #19498. +# It scheduled `check:migration-registry` (#19753), which compared the committed +# migration registry of the spec package against its entry files. That file is +# no longer committed: it is generated whole at install and at build, so the +# gate and its family retired together and nothing here selects for it. # # ## The interface # @@ -256,7 +240,7 @@ set -euo pipefail # The family ids, in the order the job runs them. `--families` prints them so # the self-test can pin the workflow's `if:` set against this list without a # second transcription. -FAMILIES='migration_registry slot_lookup query_options_erasure entry_guard comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands verify_lock' +FAMILIES='slot_lookup query_options_erasure entry_guard comment_mask_corpus pm_dispatch_gates declared_population_live bare_root_worklist self_test_workflow_commands verify_lock' if [ "${1:-}" = '--families' ]; then for id in $FAMILIES; do echo "$id"; done @@ -629,21 +613,6 @@ family_reads() { *) return 0 ;; esac ;; - migration_registry) - # The read-set declared in the header, named path by path: the migration - # tree (the entries AND the generated registry -- an edit to either side - # alone is exactly the drift this gate reports), the generator, and the - # two package files that decide how it runs. Every other spec file skips. - case "$path" in - packages/spec/src/migrations/*) return 0 ;; - packages/spec/scripts/build-migration-registry.ts) return 0 ;; - packages/spec/package.json|packages/spec/tsconfig.json) return 0 ;; - esac - case "$class" in - docs|changeset|workflow|agent-config|scripts|verify-lock|workspace) return 1 ;; - *) return 0 ;; - esac - ;; *) return 0 ;; esac } diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index db4f13d6c27..f0491b34bf6 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -47,9 +47,9 @@ export const ROOT_OWNER = '@objectstack/spec-monorepo'; * a merge that keeps one side whole loses nothing a regeneration cannot restore. * That property is what makes "defer and regenerate" a merge SEMANTICS rather than * a coin flip, and it is why `NOT_DRIVER_MANAGED` turns MIXED files away - * (`skills/README.md`, `content/docs/ai/skills-reference.mdx`, - * `src/migrations/registry.ts` — each in its own words: "a deferral would launder - * the prose"). + * (`skills/README.md`, `content/docs/ai/skills-reference.mdx` — each in its own + * words: "a deferral would launder the prose"; the migration registry was the third + * until #22554 made it git-ignored build output). * * `mixed` is the third answer those two lists could not express. A file can be * mixed AND still belong here, when the generated half is unreachable by any text @@ -242,8 +242,8 @@ export const REGEN_ARTIFACTS = Object.freeze([ // is a whole generated tree; `content/docs/permissions/` is 22 hand-written prose // pages with ONE generated page among them, so the directory glob would hand 21 // prose files to a driver that resolves to OURS — laundering away a sibling's - // prose edit, which is the exact trade `migrations/registry.ts` is kept out of - // this table for. The glob is recorded in NOT_DRIVER_MANAGED below. + // prose edit, which is the exact trade the migration registry was kept out of + // this table for while it was committed. The glob is recorded in NOT_DRIVER_MANAGED below. // // Why it was routed here (#13646, while the page carried `file:line` anchors): two // PRs that each ran `--fix` against their own tree wrote correct-for-themselves @@ -477,16 +477,18 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ { path: 'packages/spec/src/migrations/registry.ts', gen: 'gen:migration-registry', + untracked: true, why: - 'a MIXED file since #7297, and the mix is exactly why the driver must not own it. Its three ' - + 'append tables are now generated into marked regions from `src/migrations/entries/` (one file ' - + 'per entry), so a conflict INSIDE a region is resolved by `gen:migration-registry` and nothing ' - + 'else — `check:migration-registry` fails if it was resolved any other way, which is what stops ' - + "a resolution from silently dropping one side's retirement (#6957). But everything OUTSIDE the " - + 'markers — the tables\' load-bearing doc comments and each step\'s `rationale` — is still ' - + 'hand-written, and the driver defers the WHOLE file to one side. Routing it here would let a ' - + "regeneration launder away a sibling's prose edit, trading the silent drop this change removed " - + 'for a quieter one. So the prose conflict stays a human\'s, as it always was.', + 'GITIGNORED since #22554 (ruling B on that card, completing #22449 B′): generated WHOLE by ' + + '`gen:migration-registry` on `pnpm install` and as the first step of `build`, from two committed ' + + 'sources — `src/migrations/registry.ts.template`, the hand-written skeleton outside the ' + + '`` markers, and `src/migrations/entries/`, one file per entry. Git never merges ' + + 'it. It was a MIXED committed file from #7297 until then, kept out of the driver because a ' + + "regeneration would launder a sibling's prose edit; that prose now lives in the template, which " + + 'is plain hand-written source and text-merges like any other, its conflicts a human\'s. A branch ' + + 'that still carries the committed registry meets a modify/delete conflict on its merge of main, ' + + 'which takes no driver: the deletion is the answer. Same expiry clause as the untracked entries ' + + 'above: committing it again turns this entry red.', }, { path: 'packages/spec/src/conversions/registry.ts', From 765ae6bde4c9595422d545d521744585fd2f33d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:55:34 +0000 Subject: [PATCH 3/7] chore(changeset): the migration registry is generated at build Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- ...4-migration-registry-generated-at-build.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/22554-migration-registry-generated-at-build.md diff --git a/.changeset/22554-migration-registry-generated-at-build.md b/.changeset/22554-migration-registry-generated-at-build.md new file mode 100644 index 00000000000..8c044d43caf --- /dev/null +++ b/.changeset/22554-migration-registry-generated-at-build.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': patch +--- + +The migration registry `src/migrations/registry.ts` is generated at build and no longer committed + +Clause-②: no + +`src/migrations/registry.ts` keeps its path and its exports, and the built package carries the same +`./migrations` entry as before. What changes is where the file comes from in this repository: it is +git-ignored and written whole by `gen:migration-registry` from two committed sources, +`src/migrations/registry.ts.template` (the hand-written step rationale, doc comments and table +skeleton) and `src/migrations/entries/` (one file per entry). The generator runs on `pnpm install` +(the package's new `prepare` script), as the first step of `build`, and before `typecheck`, `test` +and `test:repo`; its self-test runs inside every generation. `check:migration-registry` is removed, +since there is no committed copy left to compare. + +For a contributor: add a migration entry as a file under `src/migrations/entries/` and edit any other +registry prose in `registry.ts.template`. An edit to `registry.ts` itself is never committed, and the +next install or build overwrites it. + +Measured with `pnpm pack` against the previous build: 2,070 files each, and 4 differ. Those are +`package.json`'s `scripts` (`build`, `typecheck`, `test` and `test:repo` now start with the generation, +and `check:migration-registry` is gone; `pnpm pack` leaves `prepare` out of the packed manifest) and the +three `.build-input-hash` stamps that record the build's inputs. Every `dist` file, declaration, source +map and JSON Schema is byte-identical. No schema, type, export or runtime behaviour changes. From b5940d0910e95b5d545a9d983a6c95f1e55d5268 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:16:03 +0000 Subject: [PATCH 4/7] fix(pm): the two PM tools read the migration registry as generated The registry left git in this PR, which reddened two scripts/pm self-tests on Lint & Repo Gates. Co-landed here, declared to the domain:skills seat: - dispatch-gates: an import hint on a generated, git-ignored module reaches that module's committed sources. GENERATED_MODULE_SOURCES declares one row, the registry and its template plus entries/, because only its turbo task names the sources and those inputs also name the manifest and the generator. The self-test pins the mapping with a control, proves the row against the tree (module untracked and ignored by a tracked ignore file, sources tracked), and keeps such hints out of the extension-narrowing agreement population. - os-regen-merge self-test case 9c pins skills/README.md and gen:skill-docs: the registry row it pinned is untracked now, and the reader skips those. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 56 ++++++++++++++++++++++++- scripts/pm/dispatch-gates.self-test.mjs | 56 ++++++++++++++++++++++++- scripts/pm/os-regen-merge.sh | 18 ++++---- 3 files changed, 121 insertions(+), 9 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index d785474cfc8..b70f38430ec 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -6966,6 +6966,56 @@ export function collapseHint(hint) { return hint.replace(/\*\*?/g, '').replace(/\/+$/, ''); } +/** + * ## A GENERATED, git-ignored module is reached through its committed SOURCES (#22554) + * + * `packages/spec/src/migrations/registry.ts` left git under ruling B on #22554: + * it is generated whole, on install and at build, from `registry.ts.template` + * and the per-entry files under `entries/`, and git never sees it. So the hint a + * gate's import yields — `packages/spec/src/migrations/registry`, or the + * module's full path where a gate spells it — names a file no card can touch. + * A card that changes what the module CONTAINS edits its sources instead, and + * with nothing here such a card derived none of the families that import the + * registry: the silent under-derivation #12514 repaired, back by another door. + * + * Each row names a generated module and its committed sources. The module's + * hint, spelled with or without a `MODULE_SPECIFIER_EXTENSIONS` extension, + * covers a source FILE or anything under a source DIRECTORY, and nothing more: + * not a sibling, not the generator, not the package manifest. + * + * DECLARED rather than derived, and why. That a module is generated and + * git-ignored IS derivable from the tree (an untracked path `git check-ignore` + * covers). WHICH files it is generated from is written in one place only, its + * turbo task's `inputs` — and those also name the package manifest and the + * generator script, so reading them would hand every card that touches the spec + * manifest the registry's families: a widening this file prices per pair, and + * not the mapping ruled. So the sources are a declared row, and the self-test + * proves every row against the tree on each run — the module untracked and + * ignored by a TRACKED ignore file, every source tracked — so a row that stops + * being true reds instead of quietly matching nothing. + */ +export const GENERATED_MODULE_SOURCES = Object.freeze([ + Object.freeze({ + module: 'packages/spec/src/migrations/registry.ts', + sources: Object.freeze([ + 'packages/spec/src/migrations/registry.ts.template', + 'packages/spec/src/migrations/entries', + ]), + }), +]); + +/** + * The declared committed sources a collapsed hint reaches because it names a + * `GENERATED_MODULE_SOURCES` module — by its full path, or as its module + * specifier with the extension dropped — or `null` for every other hint. + */ +export function generatedModuleSources(plain) { + for (const row of GENERATED_MODULE_SOURCES) { + if (plain === row.module || MODULE_SPECIFIER_EXTENSIONS.some((ext) => plain + ext === row.module)) return row.sources; + } + return null; +} + /** * ## A glob in a NON-FINAL segment is not collapsible, and must not be collapsed * @@ -7197,7 +7247,11 @@ export function hintCovers(hint, inputPath) { // the specifier resolves to, and the extension list is the shared // `MODULE_SPECIFIER_EXTENSIONS` rather than a second copy of it. See the // section above for the price and for what this deliberately cannot do. - MODULE_SPECIFIER_EXTENSIONS.some((ext) => inputPath === plain + ext) + MODULE_SPECIFIER_EXTENSIONS.some((ext) => inputPath === plain + ext) || + // A generated, git-ignored module is reached through its committed sources, + // declared in `GENERATED_MODULE_SOURCES` (#22554): a source file, or a path + // under a source directory. + (generatedModuleSources(plain)?.some((source) => inputPath === source || inputPath.startsWith(`${source}/`)) ?? false) ); } diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index d16e93b8f08..aa4ba2ccebd 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -66,6 +66,7 @@ import { DEFAULT_BASE_REF, DEFAULT_BASE_REMOTE, ESCAPABLE_LITERAL_LEDGER, + GENERATED_MODULE_SOURCES, HARVEST_SNIPPET, INHERITED_POPULATION_MARKER, MANDATORY_TIER_GLOBS, @@ -158,6 +159,7 @@ import { firstPartyImportTargets, followCompositeActions, gateFamilyFiles, + generatedModuleSources, globCarriesLiteralSuffix, globInNonFinalSegment, governedReadCensus, @@ -165,6 +167,7 @@ import { hashCommentProgram, hintCovers, hintReachesTree, + ignoreVerdicts, i18nBundlePackageDirs, importBindsNoPopulation, isGateScriptPath, @@ -2917,7 +2920,14 @@ export function selfTest({ tier = 'full' } = {}) { // Both `looseTarget` and `extensionlessModuleTarget` already refuse a hint // whose collapsed form the tree HAS as a path, so widening the population // adds no candidate; measured, it still selects the same 38. - const agreeCandidates = [...agreeHints]; + // + // A hint on a GENERATED, git-ignored module (#22554) is out of this population + // by construction: the module is not a tracked file, so the strict rule names + // nothing, while the loose one names the tracked `registry.ts.template` beside + // it — not a module, and not what the import means. Such a hint reaches the + // tree through its declared committed sources (`GENERATED_MODULE_SOURCES`), + // pinned in its own case below, never through an extension. + const agreeCandidates = [...agreeHints].filter((h) => generatedModuleSources(collapseHint(h)) === null); const strictOf = (h) => extensionlessModuleTarget(h, agreeFileSet, agreePrefixes); const refusedByNarrowing = agreeCandidates.filter((h) => !strictOf(h) && looseTarget(h)); t( @@ -2986,6 +2996,50 @@ export function selfTest({ tier = 'full' } = {}) { // that would have broken it: `.base.json` is not a module extension. t('the sibling-file refusal survives the extension follow', !hintCovers('packages/spec/authorable-surface', 'packages/spec/authorable-surface.base.json')); + // ── A GENERATED, git-ignored module is reached through its SOURCES (#22554) ── + // + // The migration registry left git: its hint names a file no card can touch, + // and a card that changes what it contains edits the template or an entry. + // ARRIVAL pins on both kinds of source and both spellings of the hint; the + // CONTROL is the same hint against a migrations sibling and a lookalike of the + // source directory, and a NON-generated module's hint against an entry — the + // mapping belongs to the declared row and to nothing else. + const genHint = 'packages/spec/src/migrations/registry'; + t( + "a generated module's hint reaches its committed sources: the template, and a file under entries/, by either spelling", + hintCovers(genHint, 'packages/spec/src/migrations/registry.ts.template') && + hintCovers(genHint, 'packages/spec/src/migrations/entries/semantic/18.x.ts') && + hintCovers(`${genHint}.ts`, 'packages/spec/src/migrations/entries/retired-keys/17.x.ts'), + ); + t( + "CONTROL: and nothing else — not a sibling module, not a lookalike of the source directory, not another module's hint", + !hintCovers(genHint, 'packages/spec/src/migrations/chain.ts') && + !hintCovers(genHint, 'packages/spec/src/migrations/entries-notes/x.ts') && + !hintCovers('packages/spec/src/migrations/chain', 'packages/spec/src/migrations/entries/semantic/18.x.ts'), + ); + // The row is DECLARED, so it is proven against the real tree on every run: + // the module untracked and ignored by a TRACKED ignore file, every source + // tracked. Committing the module again, or moving a source, reds here. + { + const genFiles = trackedFiles(); + const genTracked = new Set(genFiles); + const genPrefixes = trackedPrefixes(genFiles); + const genIgnored = ignoreVerdicts(GENERATED_MODULE_SOURCES.map((row) => row.module)); + const genBroken = GENERATED_MODULE_SOURCES.filter((row) => { + const verdict = genIgnored.get(row.module); + return ( + genTracked.has(row.module) || + verdict?.covered !== true || + !genTracked.has(verdict.source) || + !row.sources.every((source) => genPrefixes.has(source)) + ); + }); + t( + `every GENERATED_MODULE_SOURCES row holds in this tree: module untracked and ignored by a tracked ignore file, each source tracked (broken: ${genBroken.map((row) => row.module).join(', ') || 'none'})`, + GENERATED_MODULE_SOURCES.length > 0 && genBroken.length === 0, + ); + } + // COHERENCE: the matcher and the residue cannot disagree about a file the // tree HAS. `extensionlessModuleTarget` exists to say "the tree has this // file" about a hint the sweep calls dead; after this change no hint that diff --git a/scripts/pm/os-regen-merge.sh b/scripts/pm/os-regen-merge.sh index e3e7103d154..94764d87916 100644 --- a/scripts/pm/os-regen-merge.sh +++ b/scripts/pm/os-regen-merge.sh @@ -2005,20 +2005,24 @@ mode_self_test() { # the surface it reads still has the shape and still declares the path # the card is about. If a later change routes that file or drops its # entry, this reddens and the classification gets revisited instead of - # silently reverting to the old label. + # silently reverting to the old label. The pinned path is + # `skills/README.md` since #22554: the migration registry it first + # pinned became git-ignored build output, an `untracked` row the + # reader skips, and `skills/README.md` is the same shape — tracked, + # MIXED, marked regions, a generator. ndm_real="$(read_not_driver_managed "$(dirname "$SELF")/../regen-artifacts.mjs")" || ndm_real='' st_case 'the real ledger is readable and non-empty' \ "$([ "$(printf '%s' "$ndm_real" | grep -c . || true)" -gt 0 ] && echo yes || echo no)" yes - st_case 'and still declares the migrations registry (the card s path)' \ - "$(printf '%s\n' "$ndm_real" | awk -F'\t' '$1 == "packages/spec/src/migrations/registry.ts"' | grep -c . || true)" 1 + st_case 'and still declares a MIXED marked-region file (skills/README.md)' \ + "$(printf '%s\n' "$ndm_real" | awk -F'\t' '$1 == "skills/README.md"' | grep -c . || true)" 1 st_case 'and records the generator that resolves its regions' \ - "$(printf '%s\n' "$ndm_real" | awk -F'\t' '$1 == "packages/spec/src/migrations/registry.ts" { print $2 }' \ - | grep -c 'gen:migration-registry' || true)" 1 + "$(printf '%s\n' "$ndm_real" | awk -F'\t' '$1 == "skills/README.md" { print $2 }' \ + | grep -c 'gen:skill-docs' || true)" 1 st_case 'control: a fabricated path is not in it' \ "$(printf '%s\n' "$ndm_real" | awk -F'\t' '$1 == "packages/spec/no-such-ledger-entry.json"' | grep -c . || true)" 0 st_case 'control: that file really is unrouted, so it can only reach class 3' \ - "$(cd "$(dirname "$SELF")/../.." && git check-attr merge -- packages/spec/src/migrations/registry.ts)" \ - 'packages/spec/src/migrations/registry.ts: merge: unspecified' + "$(cd "$(dirname "$SELF")/../.." && git check-attr merge -- skills/README.md)" \ + 'skills/README.md: merge: unspecified' # --- 9d. AN UNREADABLE LEDGER IS LOUD, never a silent class-1 sweep. This is # the failure mode that would restore the defect with the evidence From de55c4fa38f73146b231d3dc17f5852c8998e818 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:11:23 +0000 Subject: [PATCH 5/7] refactor(pm): the generated-module row lives in dispatch-gates.data.mjs dispatch-gates.mjs is frozen (ruling 208 R6), and this PR touches it only under that ruling's exception: the workflow it feeds broke. As the W3 split did, the declared row GENERATED_MODULE_SOURCES and its rationale move to the data file, and the engine keeps only what matches it. That is the import and re-export, a five-line lookup and the hintCovers clause: +11/-1 against 765ae6bde4. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- scripts/pm/dispatch-gates.data.mjs | 45 ++++++++++++++++++++++-- scripts/pm/dispatch-gates.mjs | 56 ++++-------------------------- 2 files changed, 49 insertions(+), 52 deletions(-) diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index d4d42b9b956..bd2a139bb83 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -8,8 +8,9 @@ * gate scripts' own sources, on every run of the engine beside this file. What IS hand-written is * below: the marker grammar vocabularies, the ledgers (whole-tree residue, compound anchors, governed * reads, escapable literals), the change-kind roster (convention-triggered gates, named by the KIND - * of change rather than by a path), the model-tier globs and the tier ladder words. Each table keeps - * the docblock that governs it; a row's reason is part of the row. + * of change rather than by a path), the model-tier globs, the tier ladder words and the generated-module + * sources (a git-ignored module's committed inputs). Each table keeps the docblock that governs it; a + * row's reason is part of the row. * * ⛔ No logic. A row that needs a function names it — a predicate by its key (`matches`, `except`), * a tier by its name (`tier`) — and the engine resolves the name at load, refusing one it does not @@ -1256,3 +1257,43 @@ export const TIER_DEFAULT = 'opus'; * — so the green above it is measured rather than vacuous. */ export const RETIRED_TIER_WORDS = Object.freeze([]); + +/** + * ## A GENERATED, git-ignored module is reached through its committed SOURCES (#22554) + * + * `packages/spec/src/migrations/registry.ts` left git under ruling B on #22554: + * it is generated whole, on install and at build, from `registry.ts.template` + * and the per-entry files under `entries/`, and git never sees it. So the hint a + * gate's import yields — `packages/spec/src/migrations/registry`, or the + * module's full path where a gate spells it — names a file no card can touch. + * A card that changes what the module CONTAINS edits its sources instead, and + * with nothing here such a card derived none of the families that import the + * registry: the silent under-derivation #12514 repaired, back by another door. + * + * Each row names a generated module and its committed sources. The module's + * hint, spelled with or without a `MODULE_SPECIFIER_EXTENSIONS` extension, + * covers a source FILE or anything under a source DIRECTORY, and nothing more: + * not a sibling, not the generator, not the package manifest. The engine's + * `generatedModuleSources` finds a row and `hintCovers` applies it; the rows + * themselves are data, here, like every other declared table the engine reads. + * + * DECLARED rather than derived, and why. That a module is generated and + * git-ignored IS derivable from the tree (an untracked path `git check-ignore` + * covers). WHICH files it is generated from is written in one place only, its + * turbo task's `inputs` — and those also name the package manifest and the + * generator script, so reading them would hand every card that touches the spec + * manifest the registry's families: a widening this file prices per pair, and + * not the mapping ruled. So the sources are a declared row, and the self-test + * proves every row against the tree on each run — the module untracked and + * ignored by a TRACKED ignore file, every source tracked — so a row that stops + * being true reds instead of quietly matching nothing. + */ +export const GENERATED_MODULE_SOURCES = Object.freeze([ + Object.freeze({ + module: 'packages/spec/src/migrations/registry.ts', + sources: Object.freeze([ + 'packages/spec/src/migrations/registry.ts.template', + 'packages/spec/src/migrations/entries', + ]), + }), +]); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index b70f38430ec..72aa08d0b2b 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -475,6 +475,7 @@ import { CHANGE_KIND_ROWS, MANDATORY_TIER_GLOB_ROWS, SUSPECT_TIER_GLOB_ROWS, + GENERATED_MODULE_SOURCES, } from './dispatch-gates.data.mjs'; export { @@ -489,6 +490,7 @@ export { TIER_FLOOR, TIER_DEFAULT, RETIRED_TIER_WORDS, + GENERATED_MODULE_SOURCES, }; // Re-exported so this tool's self-test drives the SAME predicates the gate @@ -6966,54 +6968,10 @@ export function collapseHint(hint) { return hint.replace(/\*\*?/g, '').replace(/\/+$/, ''); } -/** - * ## A GENERATED, git-ignored module is reached through its committed SOURCES (#22554) - * - * `packages/spec/src/migrations/registry.ts` left git under ruling B on #22554: - * it is generated whole, on install and at build, from `registry.ts.template` - * and the per-entry files under `entries/`, and git never sees it. So the hint a - * gate's import yields — `packages/spec/src/migrations/registry`, or the - * module's full path where a gate spells it — names a file no card can touch. - * A card that changes what the module CONTAINS edits its sources instead, and - * with nothing here such a card derived none of the families that import the - * registry: the silent under-derivation #12514 repaired, back by another door. - * - * Each row names a generated module and its committed sources. The module's - * hint, spelled with or without a `MODULE_SPECIFIER_EXTENSIONS` extension, - * covers a source FILE or anything under a source DIRECTORY, and nothing more: - * not a sibling, not the generator, not the package manifest. - * - * DECLARED rather than derived, and why. That a module is generated and - * git-ignored IS derivable from the tree (an untracked path `git check-ignore` - * covers). WHICH files it is generated from is written in one place only, its - * turbo task's `inputs` — and those also name the package manifest and the - * generator script, so reading them would hand every card that touches the spec - * manifest the registry's families: a widening this file prices per pair, and - * not the mapping ruled. So the sources are a declared row, and the self-test - * proves every row against the tree on each run — the module untracked and - * ignored by a TRACKED ignore file, every source tracked — so a row that stops - * being true reds instead of quietly matching nothing. - */ -export const GENERATED_MODULE_SOURCES = Object.freeze([ - Object.freeze({ - module: 'packages/spec/src/migrations/registry.ts', - sources: Object.freeze([ - 'packages/spec/src/migrations/registry.ts.template', - 'packages/spec/src/migrations/entries', - ]), - }), -]); - -/** - * The declared committed sources a collapsed hint reaches because it names a - * `GENERATED_MODULE_SOURCES` module — by its full path, or as its module - * specifier with the extension dropped — or `null` for every other hint. - */ +/** The committed sources a collapsed hint reaches as a `GENERATED_MODULE_SOURCES` module, by either spelling, or `null`. */ export function generatedModuleSources(plain) { - for (const row of GENERATED_MODULE_SOURCES) { - if (plain === row.module || MODULE_SPECIFIER_EXTENSIONS.some((ext) => plain + ext === row.module)) return row.sources; - } - return null; + const row = GENERATED_MODULE_SOURCES.find((r) => plain === r.module || MODULE_SPECIFIER_EXTENSIONS.some((ext) => plain + ext === r.module)); + return row ? row.sources : null; } /** @@ -7248,9 +7206,7 @@ export function hintCovers(hint, inputPath) { // `MODULE_SPECIFIER_EXTENSIONS` rather than a second copy of it. See the // section above for the price and for what this deliberately cannot do. MODULE_SPECIFIER_EXTENSIONS.some((ext) => inputPath === plain + ext) || - // A generated, git-ignored module is reached through its committed sources, - // declared in `GENERATED_MODULE_SOURCES` (#22554): a source file, or a path - // under a source directory. + // A generated, git-ignored module reaches its committed sources (`GENERATED_MODULE_SOURCES`, #22554). (generatedModuleSources(plain)?.some((source) => inputPath === source || inputPath.startsWith(`${source}/`)) ?? false) ); } From e5ca95636ee7ef138a9dc0d7eea59574a0068023 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:18:30 +0000 Subject: [PATCH 6/7] test(pm): re-measure the census pin the data-file import moved The engine's inherited-population marker sits two lines lower (734 -> 736) because GENERATED_MODULE_SOURCES joined the data import and the re-export above it. Same marker, same text. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- scripts/pm/dispatch-gates.self-test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index aa4ba2ccebd..a9bf2bb3db6 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -7290,7 +7290,7 @@ export function selfTest({ tier = 'full' } = {}) { 'scripts/check-issue-citations.mjs:204 local-env', 'scripts/cli-build-prerequisite.mjs:111 inherited-population', 'scripts/pm/check-expected-skips.mjs:131 self-test-reads', - 'scripts/pm/dispatch-gates.mjs:734 inherited-population', + 'scripts/pm/dispatch-gates.mjs:736 inherited-population', ].join(' · '), censusRows.join(' · '), ); From 81ae60b20a023a45b0489373110624310a5d2e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:05:20 +0000 Subject: [PATCH 7/7] docs(pm): os-regen-merge's class-3 roster names two MIXED rows The migration registry was the third until this PR made it git-ignored build output, an untracked row the reader skips. Comment text only. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- scripts/pm/os-regen-merge.sh | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/pm/os-regen-merge.sh b/scripts/pm/os-regen-merge.sh index 94764d87916..6f8bf8dc218 100644 --- a/scripts/pm/os-regen-merge.sh +++ b/scripts/pm/os-regen-merge.sh @@ -356,15 +356,16 @@ # # ## Class 3 is not ONE instruction, and the ledger is what shows it # -# "Resolve by regeneration" is correct for three of the ledger's thirty tracked +# "Resolve by regeneration" is correct for two of the ledger's thirty tracked # entries and WRONG for the rest, so membership alone cannot carry the message: # -# `packages/spec/src/migrations/registry.ts`, `skills/README.md` and -# `content/docs/ai/skills-reference.mdx` are MIXED — a generator owns the text -# between a marker pair, a human owns everything outside it. A conflict INSIDE -# a region is resolved by rerunning that generator and by nothing else; the -# prose outside it is a hand merge. The module's own header names these three -# as the files `NOT_DRIVER_MANAGED` "turns away", and one more of that shape +# `skills/README.md` and `content/docs/ai/skills-reference.mdx` are MIXED (the +# migration registry was the third until #22554 made it git-ignored build +# output) — a generator owns the text between a marker pair, a human owns +# everything outside it. A conflict INSIDE a region is resolved by rerunning +# that generator and by nothing else; the prose outside it is a hand merge. +# The module's own header names these two as the files `NOT_DRIVER_MANAGED` +# "turns away", and one more of that shape # (`content/docs/permissions/tenant-audit-census.mdx`) is reached through a # directory entry rather than named outright. #