From 37f7b1fbf0cc5c9f3fa561fc66a447ac91fb830a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:45:20 +0000 Subject: [PATCH 01/10] feat(spec,metadata-core): retiredAfter on every retired conversion; the artifact window decides per entry MetadataConversion is now live-or-retired: a retired entry must carry retiredAfter, the last published spec version whose authoring surface still accepted its old shape. The 73 published retired entries are backfilled from the published tarballs (census committed beside the registry, re-derivable by scripts/build-retired-after-census.ts); the 20 unreleased ones carry 17.4.0. applyArtifactForwardConversions replays entry E when the artifact floor is below the runtime label OR at or below E.retiredAfter, so an artifact built by the last release boots on a main that enforces the next release's retirements. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../src/artifact-forward-conversion.ts | 92 +++- .../scripts/build-retired-after-census.ts | 202 +++++++++ packages/spec/src/conversions/apply.ts | 5 +- packages/spec/src/conversions/registry.ts | 93 ++++ .../src/conversions/retired-after.census.json | 401 ++++++++++++++++++ .../conversions/retired-after.census.test.ts | 142 +++++++ packages/spec/src/conversions/types.ts | 105 +++-- 7 files changed, 995 insertions(+), 45 deletions(-) create mode 100644 packages/spec/scripts/build-retired-after-census.ts create mode 100644 packages/spec/src/conversions/retired-after.census.json create mode 100644 packages/spec/src/conversions/retired-after.census.test.ts diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index 309b84443aa..b95c2f32a81 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -31,20 +31,34 @@ * * Let `floor` be the lowest version the artifact's declared protocol range * admits (the leading version token of `engines.protocol`), and `runtime` the - * `@objectstack/spec` version this process actually runs. + * `@objectstack/spec` version this process actually runs — its package label. + * The window is decided PER ENTRY: a registry entry E is replayed when + * + * floor < runtime OR floor ≤ E.retiredAfter + * + * where `retiredAfter` is the version the registry stamps on every retired + * entry — the last published spec whose authoring surface still accepted the + * old shape (`MetadataConversion`, `@objectstack/spec`). Spelled out: * * - **`floor < runtime`** → the artifact predates this runtime's authoring * surface. Replay the full conversion chain (retired entries included) * before the strict parse — the artifact is the "consumer arriving late" * ADR-0087 D3 keeps every conversion around for. * - **`floor >= runtime`** → the artifact claims the current (or a newer) - * surface. Nothing is replayed; the strict parse — tombstones included — - * is the authority. This is what keeps the conversion **versioned rather - * than a blanket amnesty**: a key retired at version V stays a loud refusal - * for anything authored at ≥ V, and when a retired key later returns to the - * spec (the roadmap-M2 shape: `allowRestore`/`allowPurge` come back with the - * lifecycle operations they gate), artifacts authored against that surface - * are never stripped by history. + * surface as the label spells it. Only the retired entries whose + * `retiredAfter` the floor does not exceed are replayed — retirements the + * running spec enforces although its label has not moved past the release + * the artifact was built by. `main` is exactly that runtime between two + * releases: it refuses keys the next release retires while still carrying + * the last release's label, so a label-only comparison read an artifact + * built by that last release as "current" and refused it outright. When no + * entry is that recent, nothing is replayed and the strict parse — + * tombstones included — is the authority. This is what keeps the conversion + * **versioned rather than a blanket amnesty**: a key retired at version V + * stays a loud refusal for anything authored at ≥ V, and when a retired key + * later returns to the spec (the roadmap-M2 shape: `allowRestore`/`allowPurge` + * come back with the lifecycle operations they gate), artifacts authored + * against that surface are never stripped by history. * - **No declared range** → replay the full chain. Same posture as the * protocol handshake (which grandfathers range-less packages with a warning, * ADR-0087 "never false-reject") and as the stored-row pass (whose rows @@ -57,6 +71,11 @@ * tombstone's prescription). Unreachable in practice — `@objectstack/spec` * is a hard dependency — and injectable for tests either way. * + * Below the label the label still stands in for every entry's own version, so + * an artifact authored between an entry's retirement and the running release + * is replayed rather than refused; narrowing that to the per-entry version + * alone is a separate decision, not taken here. + * * The comparison uses the full `x.y.z`, not the major: within-line * retirements (17.1 → 17.2) are exactly the case that created this module. * Cross-major gaps are the protocol *handshake*'s jurisdiction @@ -88,9 +107,10 @@ * ## What this deliberately is NOT * * - Not a second conversion table: the ADR-0087 registry in - * `@objectstack/spec` stays the single authority on *what* converts; this - * module only decides *whether the retired window opens* for one artifact — - * now per entry for the one named class above, rather than all-or-nothing. + * `@objectstack/spec` stays the single authority on *what* converts — and, + * through each retired entry's `retiredAfter`, on *since when* it stopped + * being authorable; this module only decides *whether the retired window + * opens* for one artifact, reading those facts off the registry per entry. * - Not a validator: like `applyConversions` itself, this never throws and * never gates. Gating stays at the caller's schema parse. * - Not the flow-specific seam: flows convert here too (context-less, exactly @@ -116,7 +136,7 @@ import { createRequire } from 'node:module'; // to declaration emit once no exported type references the root — the public // surface speaks {@link ArtifactConversionNotice}, a structural mirror pinned // against the real thing in this module's test. -import { applyConversions } from '@objectstack/spec'; +import { ALL_CONVERSIONS, applyConversions } from '@objectstack/spec'; import { resolveDeclaredRange, type ProtocolHandshakeManifest } from './protocol-handshake.js'; /** @@ -152,9 +172,18 @@ export interface ArtifactConversionNotice { export type ArtifactForwardConversionVerdict = /** Declared floor predates the runtime spec — full chain replayed. */ | 'converted-forward' + /** + * Declared floor is at or above the runtime spec's label, but at or below the + * `retiredAfter` of one or more retired entries this runtime enforces — only + * those entries replayed (see the module doc's per-entry rule). + */ + | 'converted-retired-after' /** No declared range — treated as old data at rest, full chain replayed. */ | 'converted-undeclared' - /** Declared floor is current-or-newer — nothing replayed, the strict parse decides. */ + /** + * Declared floor is current-or-newer, and newer than every retirement's + * `retiredAfter` — nothing replayed, the strict parse decides. + */ | 'authored-current' /** Runtime spec version unresolvable — nothing replayed (see module doc). */ | 'runtime-version-unknown' @@ -290,6 +319,28 @@ export function resolveInstalledSpecVersion(): string | null { */ const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = ['app-hidden-to-unpublished']; +/** + * The per-entry half of the window, for a floor at or above the runtime label: + * the ids the door must NOT replay — {@link DEFAULT_FLIPS_NOT_REPLAYED_HERE} + * (read first, whatever an entry's version says), every live entry, and every + * retired entry whose `retiredAfter` the floor exceeds. `null` when that is + * every entry, i.e. the floor predates no retirement the runtime enforces. + * + * A `retiredAfter` this cannot read closes its entry: the strict parse and its + * tombstone stay the authority, which is the loud direction. + */ +function idsTheFloorPostdates(floor: [number, number, number]): string[] | null { + const closed = [...DEFAULT_FLIPS_NOT_REPLAYED_HERE]; + let open = 0; + for (const conversion of ALL_CONVERSIONS) { + if (DEFAULT_FLIPS_NOT_REPLAYED_HERE.includes(conversion.id)) continue; + const retiredAfter = conversion.retiredFromLoadPath === true ? parseVersion(conversion.retiredAfter) : null; + if (retiredAfter && compareTriples(floor, retiredAfter) <= 0) open += 1; + else closed.push(conversion.id); + } + return open > 0 ? closed : null; +} + /** * Apply the versioned forward conversion to one compiled-artifact definition. * @@ -325,18 +376,29 @@ export function applyArtifactForwardConversions( } let verdict: ArtifactForwardConversionVerdict; + let excludeConversionIds: readonly string[] = DEFAULT_FLIPS_NOT_REPLAYED_HERE; if (!floor) { verdict = 'converted-undeclared'; } else if (compareTriples(floor, runtime) < 0) { verdict = 'converted-forward'; } else { - return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [] }; + const closed = idsTheFloorPostdates(floor); + if (closed === null) { + return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [] }; + } + verdict = 'converted-retired-after'; + // The per-entry half of the window: every entry the floor does NOT predate + // stays with the strict parse. Each id's reason is the same, read off the + // registry rather than written here — the floor is at or above the runtime + // label AND above the entry's own `retiredAfter` (or the entry is live, and + // a live entry has no retirement for the floor to predate). + excludeConversionIds = closed; } const notices: ArtifactConversionNotice[] = []; const converted = applyConversions(definition as Record, { includeRetired: true, - excludeConversionIds: DEFAULT_FLIPS_NOT_REPLAYED_HERE, + excludeConversionIds, onNotice: (n) => { notices.push(n); options.onNotice?.(n); diff --git a/packages/spec/scripts/build-retired-after-census.ts b/packages/spec/scripts/build-retired-after-census.ts new file mode 100644 index 00000000000..16329382132 --- /dev/null +++ b/packages/spec/scripts/build-retired-after-census.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * build-retired-after-census.ts — derive, from the PUBLISHED `@objectstack/spec` + * tarballs, which ADR-0087 conversion entries each stable release carried as + * retired, and write that census to `src/conversions/retired-after.census.json`. + * + * pnpm --filter @objectstack/spec exec tsx scripts/build-retired-after-census.ts # re-derive + write + * pnpm --filter @objectstack/spec exec tsx scripts/build-retired-after-census.ts --check # re-derive + compare + * + * Behind a proxy, Node's `fetch` needs `NODE_USE_ENV_PROXY=1` to use it. + * + * ## Why a committed census, and why this is not a CI step + * + * `MetadataConversion.retiredAfter` is the last published spec version whose + * authoring surface still accepted a retired entry's old shape. For every entry + * a release has already shipped, that is a FACT about the published tarballs: + * the stable release just before the first tarball that carries the entry + * retired. `src/conversions/retired-after.census.test.ts` pins every entry's + * value against those facts, offline, on every run. The facts themselves come + * from here, because reading them means downloading every stable tarball since + * the registry first shipped (hundreds of MB), which no unit suite should do per + * run and no suite here does. + * + * The census holds RAW facts only — per stable release: its version, the + * tarball's `dist.integrity`, and the sorted ids its `ALL_CONVERSIONS` marks + * `retiredFromLoadPath`. Every derived value (which release first retired an + * entry, what its `retiredAfter` must be) is the test's, so a wrong derivation + * cannot hide inside the committed file. + * + * ## When to run it + * + * After every stable `@objectstack/spec` publish. Until then the census's last + * release trails the published line, and the test's rule for entries the census + * never saw retired stays the release-pending tolerance (`[last censused + * release, package.json label]`) instead of the exact label — see the test's + * docblock. `--check` names a missing stable release as drift, so running it is + * how you find out. + * + * ## How a release is read + * + * Stable releases only (no prerelease segment): an `engines.protocol` caret + * floor never names a prerelease, and the artifact door compares `x.y.z` + * triples. The census starts at the first stable release whose root entry + * exports `ALL_CONVERSIONS` — and proves the release before it exports none, so + * no retirement can predate the census. Each tarball is verified against the + * packument's `dist.integrity` before it is read; its `dist/` is unpacked under + * `node_modules/.cache/` (so its bare imports resolve through this package's own + * `node_modules`) and its `dist/index.mjs` is imported to read `ALL_CONVERSIONS` + * as that release built it. Any failure — offline, a 404, an integrity mismatch, + * an unreadable export — exits non-zero, loudly: ⛔ never a partial census. + */ + +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG_DIR = resolve(HERE, '..'); +const CENSUS_PATH = join(PKG_DIR, 'src/conversions/retired-after.census.json'); +const CACHE_DIR = join(PKG_DIR, 'node_modules/.cache/retired-after-census'); +const PACKAGE = '@objectstack/spec'; +const REGISTRY = 'https://registry.npmjs.org'; + +interface CensusRelease { + version: string; + integrity: string; + retired: string[]; +} + +interface RetiredAfterCensus { + $comment: string[]; + package: string; + registry: string; + /** The stable release just before `releases[0]`, proven to export no `ALL_CONVERSIONS`. */ + precedingRelease: string; + releases: CensusRelease[]; +} + +const COMMENT = [ + 'GENERATED by scripts/build-retired-after-census.ts from the published @objectstack/spec tarballs.', + 'Do not edit by hand. Re-run the script after every stable publish; see its header.', + 'Per stable release: the tarball integrity and the ids its ALL_CONVERSIONS marks retiredFromLoadPath.', + 'Pinned against the registry by src/conversions/retired-after.census.test.ts.', +]; + +type Triple = [number, number, number]; + +function parseStable(v: string): Triple | null { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +function compare(a: Triple, b: Triple): number { + for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! < b[i]! ? -1 : 1; + return 0; +} + +async function fetchOk(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`GET ${url} answered ${res.status} ${res.statusText}`); + return res; +} + +interface PackumentVersion { + dist: { tarball: string; integrity: string }; +} + +async function readRelease(version: string, meta: PackumentVersion): Promise { + const tgz = Buffer.from(await (await fetchOk(meta.dist.tarball)).arrayBuffer()); + const [algo, expected] = meta.dist.integrity.split('-', 2) as [string, string]; + const actual = createHash(algo).update(tgz).digest('base64'); + if (actual !== expected) { + throw new Error(`${PACKAGE}@${version}: tarball integrity mismatch (${algo} ${actual} != ${expected})`); + } + const dir = join(CACHE_DIR, version); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const tgzPath = join(dir, 'package.tgz'); + writeFileSync(tgzPath, tgz); + const tar = spawnSync('tar', ['xzf', tgzPath, '-C', dir, 'package/package.json', 'package/dist'], { + encoding: 'utf8', + }); + if (tar.status !== 0) throw new Error(`${PACKAGE}@${version}: tar failed: ${tar.stderr || tar.error}`); + const entry = join(dir, 'package/dist/index.mjs'); + if (!existsSync(entry)) throw new Error(`${PACKAGE}@${version}: no dist/index.mjs in the tarball`); + const mod = (await import(pathToFileURL(entry).href)) as { ALL_CONVERSIONS?: unknown }; + const all = mod.ALL_CONVERSIONS; + if (all === undefined) return null; + if (!Array.isArray(all)) throw new Error(`${PACKAGE}@${version}: ALL_CONVERSIONS is not an array`); + return all + .filter((c: { retiredFromLoadPath?: unknown }) => c.retiredFromLoadPath === true) + .map((c: { id: unknown }) => { + if (typeof c.id !== 'string') throw new Error(`${PACKAGE}@${version}: a conversion has no string id`); + return c.id; + }) + .sort(); +} + +async function deriveCensus(): Promise { + const packument = (await (await fetchOk(`${REGISTRY}/${PACKAGE.replace('/', '%2F')}`)).json()) as { + versions: Record; + }; + const stable = Object.keys(packument.versions) + .map((v) => ({ v, t: parseStable(v) })) + .filter((x): x is { v: string; t: Triple } => x.t !== null) + .sort((a, b) => compare(a.t, b.t)) + .map((x) => x.v); + + // Walk DOWN from the newest stable release until one exports no registry. + const releases: CensusRelease[] = []; + let precedingRelease: string | null = null; + for (let i = stable.length - 1; i >= 0; i--) { + const version = stable[i]!; + const retired = await readRelease(version, packument.versions[version]!); + if (retired === null) { + precedingRelease = version; + break; + } + releases.unshift({ version, integrity: packument.versions[version]!.dist.integrity, retired }); + process.stdout.write(` ${version}: ${retired.length} retired\n`); + } + if (precedingRelease === null) throw new Error('every stable release exports ALL_CONVERSIONS — no census floor'); + if (releases.length === 0) throw new Error('the newest stable release exports no ALL_CONVERSIONS'); + return { $comment: COMMENT, package: PACKAGE, registry: REGISTRY, precedingRelease, releases }; +} + +function serialize(census: RetiredAfterCensus): string { + return JSON.stringify(census, null, 2) + '\n'; +} + +async function main(): Promise { + const check = process.argv.includes('--check'); + try { + const census = await deriveCensus(); + const next = serialize(census); + if (check) { + const committed = existsSync(CENSUS_PATH) ? readFileSync(CENSUS_PATH, 'utf8') : ''; + if (committed !== next) { + console.error( + `retired-after census drift: ${CENSUS_PATH} is not what the published tarballs say ` + + `(newest stable release on npm: ${census.releases.at(-1)!.version}). ` + + 'Re-run without --check and commit the result.', + ); + process.exit(1); + } + console.log(`retired-after census matches the published tarballs (${census.releases.length} releases) ✓`); + return; + } + writeFileSync(CENSUS_PATH, next); + console.log(`wrote ${CENSUS_PATH} (${census.releases.length} releases, from ${census.releases[0]!.version})`); + } finally { + rmSync(CACHE_DIR, { recursive: true, force: true }); + } +} + +main().catch((err: unknown) => { + console.error(`build-retired-after-census: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/packages/spec/src/conversions/apply.ts b/packages/spec/src/conversions/apply.ts index 14be0a2a329..239d77452a5 100644 --- a/packages/spec/src/conversions/apply.ts +++ b/packages/spec/src/conversions/apply.ts @@ -47,8 +47,9 @@ export interface ApplyConversionsOptions { * can turn it off); flow rehydration in the automation engine; and the * artifact-ingestion door (`applyArtifactForwardConversions` in * `@objectstack/metadata-core`, reached from two callers), which opens the - * window by comparing the artifact's declared `engines.protocol` floor with - * the running spec version. A stored row, a stored flow and a built artifact + * window per entry by comparing the artifact's declared `engines.protocol` + * floor with the running spec version and with the entry's own + * `retiredAfter`. A stored row, a stored flow and a built artifact * have no author to teach, so each replays the FULL chain, retired entries * included — ADR-0087's `## Addendum (2026-07-31)` for the first two, the * #12772 ruling for the third. The fixture CI sets it as well, so graduated diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index e8d00f813df..d1a183e353f 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -224,6 +224,7 @@ const objectCompactLayoutRename: MetadataConversion = { id: 'object-compactLayout-to-highlightFields', toMajor: 11, retiredFromLoadPath: true, + retiredAfter: '15.0.0', surface: 'object.compactLayout', summary: "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)", apply(stack, emit) { @@ -262,6 +263,7 @@ const stackRolesToPositions: MetadataConversion = { id: 'stack-roles-to-positions', toMajor: 13, retiredFromLoadPath: true, + retiredAfter: '15.0.0', surface: 'stack.roles', summary: "stack collection key 'roles' → 'positions' (ADR-0090 D3)", apply(stack, emit) { @@ -297,6 +299,7 @@ const owdLegacyReadAliases: MetadataConversion = { id: 'owd-legacy-read-aliases', toMajor: 13, retiredFromLoadPath: true, + retiredAfter: '15.0.0', surface: 'object.sharingModel', summary: "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)", apply(stack, emit) { @@ -353,6 +356,7 @@ const sharingRecipientRoleToPosition: MetadataConversion = { id: 'sharing-recipient-role-to-position', toMajor: 13, retiredFromLoadPath: true, + retiredAfter: '15.0.0', surface: 'sharingRule.sharedWith.type', summary: "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)", apply(stack, emit) { @@ -413,6 +417,7 @@ const bookAudienceProfileToPermissionSet: MetadataConversion = { id: 'book-audience-profile-to-permission-set', toMajor: 14, retiredFromLoadPath: true, + retiredAfter: '15.0.0', surface: 'book.audience', summary: "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)", apply(stack, emit) { @@ -715,6 +720,7 @@ const actionExecuteToTarget: MetadataConversion = { id: 'action-execute-to-target', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'action.execute', summary: "action key 'execute' → 'target' (the deprecated handler alias, #3713)", apply(stack, emit) { @@ -754,6 +760,7 @@ const fieldConditionalRequiredToRequiredWhen: MetadataConversion = { id: 'field-conditionalRequired-to-requiredWhen', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'field.conditionalRequired', summary: "field key 'conditionalRequired' → 'requiredWhen' (the deprecated predicate alias, #3754)", apply(stack, emit) { @@ -808,6 +815,7 @@ const agentToolsToSkills: MetadataConversion = { id: 'agent-tools-to-skills', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'agent.tools', summary: "agent key 'tools' removed — declare capability in a skill (ADR-0064, #3894)", apply(stack, emit) { @@ -1722,6 +1730,7 @@ const appDeadAuthoringKeysRemoved: MetadataConversion = { id: 'app-dead-authoring-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / ' + 'app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / ' @@ -1835,6 +1844,7 @@ const appAreaFailOpenGatesRemoved: MetadataConversion = { id: 'app-area-fail-open-gates-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'app.areas.visible / app.areas.requiredPermissions', summary: "navigation-area keys 'visible'/'requiredPermissions' removed (#4651, ADR-0049 — FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user, while the identically named keys on a navigation ITEM and on the APP are enforced; gate the items inside the area, or gate the app)", apply(stack, emit) { @@ -1936,6 +1946,7 @@ const permissionRlsPriorityRemoved: MetadataConversion = { id: 'permission-rls-priority-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'permission.rowLevelSecurity.priority', summary: "RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)", apply(stack, emit) { @@ -2004,6 +2015,7 @@ const toolInertAuthoringKeysRemoved: MetadataConversion = { id: 'tool-inert-authoring-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'tool.category / tool.permissions / tool.active / tool.builtIn', summary: "tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing)", apply(stack, emit) { @@ -2115,6 +2127,7 @@ const actionInertKeysRemoved: MetadataConversion = { id: 'action-inert-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'action.shortcut / action.bulkEnabled', summary: "action keys 'shortcut'/'bulkEnabled' removed (#3896 close-out — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions)", apply(stack, emit) { @@ -2139,6 +2152,7 @@ const flowInertKeysRemoved: MetadataConversion = { id: 'flow-inert-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId', summary: "flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle)", apply(stack, emit) { @@ -2197,6 +2211,7 @@ const viewInertKeysRemoved: MetadataConversion = { id: 'view-inert-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria', summary: "view keys removed (#3896 close-out): list 'responsive'/'performance', form 'defaultSort'/'aria' — no renderer read them (list aria/data and form data stay live)", apply(stack, emit) { @@ -2242,6 +2257,7 @@ const viewListPassthroughKeysRemoved: MetadataConversion = { id: 'view-list-passthrough-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'view.list.striped / view.list.bordered / view.list.virtualScroll', summary: "view list keys removed (#7176): 'striped'/'bordered'/'virtualScroll' — every measured reader copied the key forward and none applied it (pass-through-only; ADR-0049 enforce-or-remove)", apply(stack, emit) { @@ -2308,6 +2324,7 @@ const viewExportOptionsPdfRemoved: MetadataConversion = { id: 'view-export-options-pdf-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'view.list.exportOptions / view.listViews.*.exportOptions', summary: "list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; " @@ -2377,6 +2394,7 @@ const dashboardInertKeysRemoved: MetadataConversion = { id: 'dashboard-inert-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'dashboard.aria / dashboard.performance / dashboard.widgets[].performance', summary: "dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them)", apply(stack, emit) { @@ -2446,6 +2464,7 @@ const dashboardWidgetResponsiveRemoved: MetadataConversion = { id: 'dashboard-widget-responsive-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'dashboard.widgets[].responsive', summary: "dashboard widget key 'responsive' removed (#4876 — no renderer ever applied per-widget breakpoint overrides; the page.components[].responsive key this entry once deferred to was itself retired at protocol 18, #11027)", apply(stack, emit) { @@ -2523,6 +2542,7 @@ const dashboardWidgetActionAriaRemoved: MetadataConversion = { id: 'dashboard-widget-action-aria-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / ' + 'dashboard.widgets[].actionIcon / dashboard.widgets[].aria', @@ -2621,6 +2641,7 @@ const dashboardWidgetCompareToConverged: MetadataConversion = { id: 'dashboard-widget-compareto-converged', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'dashboard.widgets[].compareTo', summary: "dashboard widget 'compareTo' converged on the executor's { kind, dimension? } contract " @@ -2695,6 +2716,7 @@ const agentKnowledgeRemoved: MetadataConversion = { id: 'agent-knowledge-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'agent.knowledge', summary: "agent key 'knowledge' removed (#3896 close-out — declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level)", apply(stack, emit) { @@ -2712,6 +2734,7 @@ const skillTriggerPhrasesRemoved: MetadataConversion = { id: 'skill-trigger-phrases-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'skill.triggerPhrases', summary: "skill key 'triggerPhrases' removed (#3896 close-out — activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection)", apply(stack, emit) { @@ -2749,6 +2772,7 @@ const stackApiRequireAuthRemoved: MetadataConversion = { id: 'stack-api-require-auth-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'stack.api.requireAuth', summary: "stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963)", apply(stack, emit) { @@ -2829,6 +2853,7 @@ const flowNodeWaitTimeoutKeysRemoved: MetadataConversion = { id: 'flow-node-wait-timeout-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'flow.node.waitEventConfig', summary: "waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) " + @@ -2909,6 +2934,7 @@ const datasourceInertBlocksRemoved: MetadataConversion = { id: 'datasource-inert-blocks-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission', summary: "datasource keys 'retryPolicy'/'healthCheck' and external 'label'/'requirePermission' removed (#4583 — nothing retried, nothing probed on a schedule, and the federation label/permission were read by nobody)", apply(stack, emit) { @@ -2996,6 +3022,7 @@ const mappingInertKeysRemoved: MetadataConversion = { id: 'mapping-inert-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'mapping.extractQuery / mapping.errorPolicy / mapping.batchSize', summary: "mapping keys 'extractQuery'/'errorPolicy'/'batchSize' removed (#4509 — no exporter reads a mapping, error handling belongs to the import request, and the write path sizes its own batches)", apply(stack, emit) { @@ -3050,6 +3077,7 @@ const bookTranslationsRemoved: MetadataConversion = { id: 'book-translations-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'book.translations / book.groups.translations', summary: "book keys 'translations' (book-level and group-level) removed (#4667 — no resolver read them; the tree endpoint and portal render labels verbatim, so a localized book served its authoring locale to everyone). Localize the docs instead: `doc.translations` is live", apply(stack, emit) { @@ -3111,6 +3139,7 @@ const jobIdRemoved: MetadataConversion = { id: 'job-id-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'job.id', summary: "job key 'id' removed (#4667 — nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist)", apply(stack, emit) { @@ -3167,6 +3196,7 @@ const translationValidationMessagesRemoved: MetadataConversion = { id: 'translation-validation-messages-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'translation.validationMessages', summary: "translation key 'validationMessages' removed (#4667 — no resolver read it, so a translated rule message was stored and never shown; #3778's migration table had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`), and translate it under the object-scoped group `objects.._validations..message`, which the write path resolves (17.3.0, #14381)", apply(stack, emit) { @@ -3224,6 +3254,7 @@ const datasourceCapabilitiesRemoved: MetadataConversion = { id: 'datasource-capabilities-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'datasource.capabilities', summary: "datasource key 'capabilities' removed (#4583 — eleven flags no code read; pushdown comes from the driver's own supports.*, and `readOnly` never made anything read-only)", apply(stack, emit) { @@ -3278,6 +3309,7 @@ const datasourceReadReplicasRemoved: MetadataConversion = { id: 'datasource-read-replicas-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'datasource.readReplicas', summary: "datasource key 'readReplicas' removed (#4468 — no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it)", apply(stack, emit) { @@ -3376,6 +3408,7 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = { id: 'datasource-config-driver-key-aliases', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'datasource.config', summary: "datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', " @@ -3617,6 +3650,7 @@ const flowNodeScriptBranchKeysRemoved: MetadataConversion = { id: 'flow-node-script-branch-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'flow.node.script.config.actionType / flow.node.script.config.template / ' + 'flow.node.script.config.recipients / flow.node.script.config.variables / ' @@ -3716,6 +3750,7 @@ const objectManagedBySystemToSystemData: MetadataConversion = { id: 'object-managed-by-system-to-system-data', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'object.managedBy', summary: "object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the " @@ -3782,6 +3817,7 @@ const objectEnableTrashMruRemoved: MetadataConversion = { id: 'object-enable-trash-mru-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'object.enable.trash / object.enable.mru', summary: "object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no " @@ -3864,6 +3900,7 @@ const objectIndexTypePartialRemoved: MetadataConversion = { id: 'object-index-type-partial-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'object.indexes[].type / object.indexes[].partial', summary: "object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver " @@ -4200,6 +4237,7 @@ const hookBodyCryptoHashRemoved: MetadataConversion = { id: 'hook-body-crypto-hash-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'hook.body.capabilities / action.body.capabilities', summary: "script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed " @@ -4369,6 +4407,7 @@ const datasetMeasureAggRemoved: MetadataConversion = { id: 'dataset-measure-array-string-agg-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'dataset.measures[].aggregate', summary: "dataset measure aggregates 'array_agg' / 'string_agg' removed (#6188 — no SQL backend " @@ -4493,6 +4532,7 @@ const connectorRateLimitConfigRemoved: MetadataConversion = { id: 'connector-rate-limit-config-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'connector.rateLimitConfig', summary: "connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; " @@ -4617,6 +4657,7 @@ const fieldMappingTransformRemoved: MetadataConversion = { id: 'field-mapping-transform-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'connector.fieldMappings[].transform / externalLookup.fieldMappings[].transform', summary: "field-mapping key 'transform' removed (#5552 — the whole five-member " @@ -4730,6 +4771,7 @@ const themeInertTokenScalesRemoved: MetadataConversion = { id: 'theme-inert-token-scales-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight' + ' / theme.typography.letterSpacing / theme.typography.fontFamily.heading' @@ -5111,6 +5153,7 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { id: 'record-picker-display-field-to-label-field', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'page.component.element:record_picker.displayField', summary: "record-picker component prop 'displayField' → 'labelField' (#5775 — the required key no renderer read; `labelField ?? 'name'` is what renders the row)", @@ -5232,6 +5275,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { id: 'record-picker-inert-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple', summary: "record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader)", @@ -5358,6 +5402,7 @@ const pageCardBodyToChildren: MetadataConversion = { id: 'page-card-body-to-children', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'page.component.page:card.body', summary: "page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both)", @@ -5790,6 +5835,7 @@ const pageTabsTypeToTabStyle: MetadataConversion = { id: 'page-tabs-type-to-tab-style', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'page.component.page:tabs.type', summary: "page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them)", @@ -5977,6 +6023,7 @@ const pageStructureInertKeysRemoved: MetadataConversion = { id: 'page-structure-inert-keys-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'page.component.page:header.icon / page.component.page:card.actions', summary: "page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer " @@ -6142,6 +6189,7 @@ const recordDetailsLayoutRemoved: MetadataConversion = { id: 'record-details-layout-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'page.component.record:details.layout', summary: "record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were " @@ -6283,6 +6331,7 @@ const appHiddenToUnpublished: MetadataConversion = { id: 'app-hidden-to-unpublished', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'app.hidden', summary: "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", @@ -6395,6 +6444,7 @@ const actionGlobalNavLocationRemoved: MetadataConversion = { id: 'action-global-nav-location-removed', toMajor: 17, retiredFromLoadPath: true, + retiredAfter: '16.1.0', surface: 'action.locations[]', summary: "action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K " @@ -6488,6 +6538,7 @@ const fieldMalformedScalePrecisionRemoved: MetadataConversion = { id: 'field-malformed-scale-precision-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.0.0', surface: 'object.fields.*.scale / object.fields.*.precision', summary: "malformed field 'scale'/'precision' declarations (non-integer or negative) are removed — " @@ -6595,6 +6646,7 @@ const recordChatterPositionVocabulary: MetadataConversion = { id: 'record-chatter-position-vocabulary', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.0.0', surface: 'page.component.record:chatter.position / page.component.record:discussion.position', summary: "record:chatter / record:discussion 'position' respelled to the renderer's vocabulary — " @@ -6712,6 +6764,7 @@ const elementInputTargetVariableRemoved: MetadataConversion = { id: 'element-input-target-variable-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.0.0', surface: 'page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable', summary: @@ -6866,6 +6919,7 @@ const elementFilterRemoved: MetadataConversion = { id: 'element-filter-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.0.0', surface: 'page.component.element:filter.object / page.component.element:filter.fields / ' + 'page.component.element:filter.targetVariable / page.component.element:filter.layout / ' @@ -7018,6 +7072,7 @@ const elementFormRemoved: MetadataConversion = { id: 'element-form-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.2.0', surface: 'page.component.element:form.object / page.component.element:form.fields / ' + 'page.component.element:form.mode / page.component.element:form.submitLabel / ' @@ -7198,6 +7253,7 @@ const translationPerAppSettingsRemoved: MetadataConversion = { id: 'translation-per-app-settings-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'stack.translations[]..settings / translation.settings', summary: "translation group 'settings' removed from both application-authored faces, the per-app bundle " @@ -7305,6 +7361,7 @@ const translationComponentSubmitLabelRemoved: MetadataConversion = { id: 'translation-component-submit-label-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.2.0', surface: 'translation.pages.components.submitLabel', summary: "translation component-copy key 'submitLabel' removed (#10926 — its only declared carrier, " @@ -7446,6 +7503,7 @@ const fieldColumnListsCanonicalized: MetadataConversion = { id: 'field-column-lists-canonicalized', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.0.0', surface: 'field.inlineColumns[].field / field.relatedListColumns[] object entries', summary: "inline-grid column entries respelled 'field' → 'name' (objectui#3951's name-keyed GridColumn) " @@ -7580,6 +7638,7 @@ const metricFiltersRemoved: MetadataConversion = { id: 'metric-filters-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.1.0', surface: 'analyticsCubes[].measures..filters', summary: "cube metric key 'filters' removed (#10414, ADR-0049 — no strategy ever read it: the " @@ -7671,6 +7730,7 @@ const cubeSubDayGranularitiesRemoved: MetadataConversion = { id: 'cube-sub-day-granularities-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'analyticsCubes[].dimensions..granularities', summary: "cube dimension granularities 'second' / 'minute' / 'hour' removed (#17296, ADR-0049 — no " @@ -7803,6 +7863,7 @@ const cubeJoinSqlAndRelationshipRemoved: MetadataConversion = { id: 'cube-join-sql-and-relationship-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'analyticsCubes[].joins..sql / analyticsCubes[].joins..relationship', summary: "cube join keys 'sql' and 'relationship' removed (#18612, ADR-0049 — neither was ever read: " @@ -7932,6 +7993,7 @@ const recordHighlightsFieldIconRemoved: MetadataConversion = { id: 'record-highlights-field-icon-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.1.0', surface: 'page.component.record:highlights.fields[].icon', summary: "record:highlights highlight-field key 'icon' removed (#10054, ADR-0049 — no render path: " @@ -8077,6 +8139,7 @@ const mappingLookupParamsRemoved: MetadataConversion = { id: 'mapping-lookup-params-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.2.0', surface: 'mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate', summary: "mapping lookup params 'object'/'fromField'/'toField'/'autoCreate' removed (#10329, " @@ -8186,6 +8249,7 @@ const pageComponentResponsiveRemoved: MetadataConversion = { id: 'page-component-responsive-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.2.0', surface: 'page.components[].responsive', summary: "page component key 'responsive' removed (#11027 — no renderer ever applied per-component " @@ -8286,6 +8350,7 @@ const objectGridDefaultSortRemoved: MetadataConversion = { id: 'object-grid-default-sort-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.2.0', surface: 'page.component.object-grid.defaultSort', summary: "object-grid component prop 'defaultSort' removed (#11805 — the legacy single-sort second " @@ -8489,6 +8554,7 @@ const objectKanbanQuickAddRemoved: MetadataConversion = { id: 'object-kanban-quick-add-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'page.component.object-kanban.quickAdd', summary: "object-kanban component prop 'quickAdd' removed (#17260 — the affordance is gated on a " @@ -8663,6 +8729,7 @@ const permissionAllowRestorePurgeRemoved: MetadataConversion = { id: 'permission-allow-restore-purge-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.2.0', surface: 'permission.objects..allowRestore / permission.objects..allowPurge', summary: "object-permission keys 'allowRestore' and 'allowPurge' removed (#12497, ADR-0049 — the " @@ -8759,6 +8826,7 @@ const formViewOptionDefaultRemoved: MetadataConversion = { id: 'form-view-option-default-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.2.0', surface: 'view.form.sections[].fields[].options[].default', summary: "form-view per-option 'default' removed from the FormView vocabulary (ADR-0049 " @@ -8979,6 +9047,7 @@ const fieldReferenceToAlias: MetadataConversion = { id: 'field-reference-to-alias', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.2.0', surface: 'field.reference_to', summary: "field key 'reference_to' → 'reference' (the legacy objectql runtime dialect for a " @@ -9079,6 +9148,7 @@ const connectorErrorMappingRemoved: MetadataConversion = { id: 'connector-error-mapping-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.3.0', surface: 'connector.errorMapping', summary: "connector key 'errorMapping' removed (#14676, ADR-0049 — no engine ever mapped an external " @@ -9181,6 +9251,7 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = { id: 'connector-connection-timeout-ms-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'connector.connectionTimeoutMs', summary: "connector key 'connectionTimeoutMs' removed (ADR-0049 — the platform never applied it as a " @@ -9239,6 +9310,7 @@ const hookTimeoutToTimeoutMs: MetadataConversion = { id: 'hook-timeout-to-timeout-ms', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.3.0', surface: 'hook.timeout', summary: "hook key 'timeout' → 'timeoutMs' (#14478 — the unit lived only in the description; the value, milliseconds, is unchanged)", apply(stack, emit) { @@ -9279,6 +9351,7 @@ const jobTimeoutToTimeoutMs: MetadataConversion = { id: 'job-timeout-to-timeout-ms', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.3.0', surface: 'job.timeout', summary: "job key 'timeout' → 'timeoutMs' (#14478 — the unit lived only in the description; the value, milliseconds, is unchanged)", apply(stack, emit) { @@ -9332,6 +9405,7 @@ const apiEndpointCacheTtlToCacheTtlSeconds: MetadataConversion = { id: 'api-endpoint-cache-ttl-to-cache-ttl-seconds', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.3.0', surface: 'apis[].cacheTtl', summary: "api endpoint key 'cacheTtl' \u2192 'cacheTtlSeconds' (#14478 \u2014 the unit lived only in the description; the value, seconds, is unchanged, and the key stays GET-only)", apply(stack, emit) { @@ -9400,6 +9474,7 @@ const dashboardRefreshIntervalToRefreshIntervalSeconds: MetadataConversion = { id: 'dashboard-refresh-interval-to-refresh-interval-seconds', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.3.0', surface: 'dashboard.refreshInterval', summary: "dashboard key 'refreshInterval' → 'refreshIntervalSeconds' (#14478 — the unit lived only in the description; the value, seconds, is unchanged)", apply(stack, emit) { @@ -9458,6 +9533,7 @@ const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { id: 'connector-health-and-trigger-durations-unit-in-key', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.3.0', surface: 'connector.triggers[].interval', summary: "connector key 'triggers[].interval' → 'intervalSeconds' (#14478 — the unit lived only in the description; the value, seconds, is unchanged. The breaker half, 'health.circuitBreaker.monitoringWindow' → 'monitoringWindowMs', was absorbed by the removal of the whole 'health' block)", apply(stack, emit) { @@ -9559,6 +9635,7 @@ const connectorResilienceKeysRemoved: MetadataConversion = { id: 'connector-resilience-keys-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'connector.health / connector.status / connector.webhooks', summary: "connector keys 'health', 'status' and 'webhooks' removed (ADR-0049 — no connector health " @@ -9639,6 +9716,7 @@ const memoryPersistenceAutoSaveIntervalToMs: MetadataConversion = { id: 'memory-persistence-auto-save-interval-to-ms', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.3.0', surface: 'datasource.config.persistence.autoSaveInterval', summary: "memory datasource key 'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both the file and auto arms (#14478 — the unit lived only in the description; the value, milliseconds, is unchanged)", apply(stack, emit) { @@ -9724,6 +9802,7 @@ const tursoConfigTimeoutToTimeoutMs: MetadataConversion = { id: 'turso-config-timeout-to-timeout-ms', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.3.0', surface: 'datasource.config.timeout (turso)', summary: "turso datasource key 'config.timeout' → 'config.timeoutMs' (#14478 — the unit lived only in the description and a .meta() title no parse reads; the value, milliseconds, is unchanged)", apply(stack, emit) { @@ -9812,6 +9891,7 @@ const viewPageMountRemoved: MetadataConversion = { id: 'view-page-mount-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: "view.list / view.listViews.* — the list-view type 'page' and its pageName binding", summary: "list-view type 'page' and its `pageName` binding removed (#17063 — the delegating render half " @@ -9916,6 +9996,7 @@ const listViewSortStringClauseToArray: MetadataConversion = { id: 'list-view-sort-string-clause-to-array', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'view.list.sort / view.listViews.*.sort — the bare string sort clause', summary: 'the bare string list-view `sort` clause becomes the `{ field, order }[]` array (#17053 — ' @@ -10015,6 +10096,7 @@ const pageAssignedProfilesRemoved: MetadataConversion = { id: 'page-assigned-profiles-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'page.assignedProfiles', summary: "page key 'assignedProfiles' removed (ADR-0090 D2 deleted the Profile concept it was named " @@ -10090,6 +10172,7 @@ const chartConfigAriaRemoved: MetadataConversion = { id: 'chart-config-aria-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria', summary: @@ -10226,6 +10309,7 @@ const actionAriaRemoved: MetadataConversion = { id: 'action-aria-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'action.aria / object.actions[].aria', summary: "action key 'aria' removed (ADR-0049 enforce-or-remove — no action surface ever applied it; " @@ -10301,6 +10385,7 @@ const dashboardWidgetChartConfigStructureRemoved: MetadataConversion = { id: 'dashboard-widget-chart-config-structure-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'dashboard.widgets[].chartConfig.type / dashboard.widgets[].chartConfig.xAxis / ' + 'dashboard.widgets[].chartConfig.yAxis / dashboard.widgets[].chartConfig.series', @@ -10411,6 +10496,7 @@ const objectTenancyOrganizationFieldRemoved: MetadataConversion = { id: 'object-tenancy-organization-field-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'object.tenancy.organizationField', summary: 'object `tenancy.organizationField` removed (#19054, ADR-0049 — the stamp-only column ' @@ -10520,6 +10606,7 @@ const viewItemOwnerHiddenRemoved: MetadataConversion = { id: 'view-item-owner-hidden-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'view.owner / view.hidden — on the view item record ({ name, object, viewKind, config })', summary: "view item keys 'owner'/'hidden' removed (#20085, ADR-0049 — declared on the view item record " @@ -10663,6 +10750,7 @@ const viewOverlayOwnerHiddenRemoved: MetadataConversion = { id: 'view-overlay-owner-hidden-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'view.owner / view.hidden — on a flattened view overlay ({ name, object, viewKind, …, no config })', summary: "flattened view overlay keys 'owner'/'hidden' removed (#20230, ADR-0049 — the view item's pair on " @@ -11150,6 +11238,7 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { id: 'page-component-filter-record-to-rule-array', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, ' + 'element:number, element:record_picker) / page.component.properties.defaultFilters ' @@ -11421,6 +11510,7 @@ const reportJoinedChartRemoved: MetadataConversion = { id: 'report-joined-chart-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'report.blocks[].chart / report.chart on a joined report', summary: "a joined report's 'chart' removed from its blocks and refused on the container (#20161 — " @@ -11548,6 +11638,7 @@ const formLayoutInlineGridToVertical: MetadataConversion = { id: 'form-layout-inline-grid-to-vertical', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'page.component.object-form.layout / view.form.layout / view.formViews.*.layout', summary: "form 'layout' arms 'inline' and 'grid' rewritten to 'vertical' (#20221, ADR-0049 — no renderer " @@ -11703,6 +11794,7 @@ const currencyConfigPrecisionRemoved: MetadataConversion = { id: 'currency-config-precision-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'object.fields.*.currencyConfig.precision', summary: "currency field key 'currencyConfig.precision' removed (#19992, ADR-0049 — no renderer or " @@ -11806,6 +11898,7 @@ const permissionRlsTagsRemoved: MetadataConversion = { id: 'permission-rls-tags-removed', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'permission.rowLevelSecurity[].tags', summary: "RLS-policy key 'tags' removed (#20321, ADR-0049 — nothing ever read a policy's tags and no " diff --git a/packages/spec/src/conversions/retired-after.census.json b/packages/spec/src/conversions/retired-after.census.json new file mode 100644 index 00000000000..792d9369824 --- /dev/null +++ b/packages/spec/src/conversions/retired-after.census.json @@ -0,0 +1,401 @@ +{ + "$comment": [ + "GENERATED by scripts/build-retired-after-census.ts from the published @objectstack/spec tarballs.", + "Do not edit by hand. Re-run the script after every stable publish; see its header.", + "Per stable release: the tarball integrity and the ids its ALL_CONVERSIONS marks retiredFromLoadPath.", + "Pinned against the registry by src/conversions/retired-after.census.test.ts." + ], + "package": "@objectstack/spec", + "registry": "https://registry.npmjs.org", + "precedingRelease": "14.7.0", + "releases": [ + { + "version": "14.8.0", + "integrity": "sha512-/kOdvbVf2MSwTrCnRe7mxSUqmnU00TUrOQ5pNdR/u9kqz4pBcbFUbVEt1fSZ4EKzB/N56Z4HijEP5lME38s+UQ==", + "retired": [] + }, + { + "version": "15.0.0", + "integrity": "sha512-ZK9iExKLnZnIKd01/YaHzE7y8WihHZiQO+O+dVgkDuvH6Us50NVC6J0yHVpxSN3cCBkO9Xlj68wJ+2GQQpW6fg==", + "retired": [] + }, + { + "version": "15.1.0", + "integrity": "sha512-gL5toOEKTZ2whRJqQYfSa2G3D72jQJd4in3NtHlI02zR7olKzLx7z6+FbbcmI7cienYSxHMl3oDnRV/xFp2+aA==", + "retired": [ + "book-audience-profile-to-permission-set", + "object-compactLayout-to-highlightFields", + "owd-legacy-read-aliases", + "sharing-recipient-role-to-position", + "stack-roles-to-positions" + ] + }, + { + "version": "15.1.1", + "integrity": "sha512-Eto1iLtzmnh3kSzIVGPUQF9DZGE1dY3YWFm+3qRpNBFB8iSvOilLwNYVSF64D5sTobp0CZSzB1/GalzFAR1icA==", + "retired": [ + "book-audience-profile-to-permission-set", + "object-compactLayout-to-highlightFields", + "owd-legacy-read-aliases", + "sharing-recipient-role-to-position", + "stack-roles-to-positions" + ] + }, + { + "version": "16.0.0", + "integrity": "sha512-hjUScIt3dw/MVMOiAJ8XyKVFUv2R46Djxz+itibb3QJ9wApbPyaRKBVNDsYnbKz0v9PJTVSPUjJ4nPad/M/zdA==", + "retired": [ + "book-audience-profile-to-permission-set", + "object-compactLayout-to-highlightFields", + "owd-legacy-read-aliases", + "sharing-recipient-role-to-position", + "stack-roles-to-positions" + ] + }, + { + "version": "16.1.0", + "integrity": "sha512-JoDl1ZC4yTxy85/9OoMlOwMTa0I31Jlg5h3lBFSXabZQUclVoexHvF49G4df3xcYyu9r339dskl3F9vwN3kwWA==", + "retired": [ + "book-audience-profile-to-permission-set", + "object-compactLayout-to-highlightFields", + "owd-legacy-read-aliases", + "sharing-recipient-role-to-position", + "stack-roles-to-positions" + ] + }, + { + "version": "17.0.0", + "integrity": "sha512-65rmDnj6WKnIATvEg9/coDYSVUgBTtym/FysEApaK2W/k4ub0DP7dQ9VUmLB4HEbJxo0ahzD1tFObkXYEIT7Fw==", + "retired": [ + "action-execute-to-target", + "action-global-nav-location-removed", + "action-inert-keys-removed", + "agent-knowledge-removed", + "agent-tools-to-skills", + "app-area-fail-open-gates-removed", + "app-dead-authoring-keys-removed", + "app-hidden-to-unpublished", + "book-audience-profile-to-permission-set", + "book-translations-removed", + "connector-rate-limit-config-removed", + "dashboard-inert-keys-removed", + "dashboard-widget-action-aria-removed", + "dashboard-widget-compareto-converged", + "dashboard-widget-responsive-removed", + "dataset-measure-array-string-agg-removed", + "datasource-capabilities-removed", + "datasource-config-driver-key-aliases", + "datasource-inert-blocks-removed", + "datasource-read-replicas-removed", + "field-conditionalRequired-to-requiredWhen", + "field-mapping-transform-removed", + "field-required-notnull-explicit", + "flow-inert-keys-removed", + "flow-node-script-branch-keys-removed", + "flow-node-wait-timeout-keys-removed", + "hook-body-crypto-hash-removed", + "job-id-removed", + "mapping-inert-keys-removed", + "object-compactLayout-to-highlightFields", + "object-enable-trash-mru-removed", + "object-index-type-partial-removed", + "object-managed-by-system-to-system-data", + "owd-legacy-read-aliases", + "page-card-body-to-children", + "page-structure-inert-keys-removed", + "page-tabs-type-to-tab-style", + "permission-rls-priority-removed", + "record-details-layout-removed", + "record-picker-display-field-to-label-field", + "record-picker-inert-keys-removed", + "sharing-recipient-role-to-position", + "skill-trigger-phrases-removed", + "stack-api-require-auth-removed", + "stack-roles-to-positions", + "theme-inert-token-scales-removed", + "tool-inert-authoring-keys-removed", + "translation-validation-messages-removed", + "view-export-options-pdf-removed", + "view-inert-keys-removed", + "view-list-passthrough-keys-removed" + ] + }, + { + "version": "17.1.0", + "integrity": "sha512-gyF7knX3mqTEZSYeFIW/HBP0yFcjEzyXCS/p5pwH4yuJ4R2uw7mE3ItJs187CxZIFhFC2PTluRri3C/1Yaa9Og==", + "retired": [ + "action-execute-to-target", + "action-global-nav-location-removed", + "action-inert-keys-removed", + "agent-knowledge-removed", + "agent-tools-to-skills", + "app-area-fail-open-gates-removed", + "app-dead-authoring-keys-removed", + "app-hidden-to-unpublished", + "book-audience-profile-to-permission-set", + "book-translations-removed", + "connector-rate-limit-config-removed", + "dashboard-inert-keys-removed", + "dashboard-widget-action-aria-removed", + "dashboard-widget-compareto-converged", + "dashboard-widget-responsive-removed", + "dataset-measure-array-string-agg-removed", + "datasource-capabilities-removed", + "datasource-config-driver-key-aliases", + "datasource-inert-blocks-removed", + "datasource-read-replicas-removed", + "element-filter-removed", + "element-input-target-variable-removed", + "field-column-lists-canonicalized", + "field-conditionalRequired-to-requiredWhen", + "field-malformed-scale-precision-removed", + "field-mapping-transform-removed", + "field-required-notnull-explicit", + "flow-inert-keys-removed", + "flow-node-script-branch-keys-removed", + "flow-node-wait-timeout-keys-removed", + "hook-body-crypto-hash-removed", + "job-id-removed", + "mapping-inert-keys-removed", + "object-compactLayout-to-highlightFields", + "object-enable-trash-mru-removed", + "object-index-type-partial-removed", + "object-managed-by-system-to-system-data", + "owd-legacy-read-aliases", + "page-card-body-to-children", + "page-structure-inert-keys-removed", + "page-tabs-type-to-tab-style", + "permission-rls-priority-removed", + "record-chatter-position-vocabulary", + "record-details-layout-removed", + "record-picker-display-field-to-label-field", + "record-picker-inert-keys-removed", + "sharing-recipient-role-to-position", + "skill-trigger-phrases-removed", + "stack-api-require-auth-removed", + "stack-roles-to-positions", + "theme-inert-token-scales-removed", + "tool-inert-authoring-keys-removed", + "translation-validation-messages-removed", + "view-export-options-pdf-removed", + "view-inert-keys-removed", + "view-list-passthrough-keys-removed" + ] + }, + { + "version": "17.2.0", + "integrity": "sha512-nQzyR9+9JQEtLzFastQbj0ETLLbOm4sSY+sNUEX2k0QgZThCsMB1rKPv100c6GQZYg1BaLfz5xc8tv9oH3cKqg==", + "retired": [ + "action-execute-to-target", + "action-global-nav-location-removed", + "action-inert-keys-removed", + "agent-knowledge-removed", + "agent-tools-to-skills", + "app-area-fail-open-gates-removed", + "app-dead-authoring-keys-removed", + "app-hidden-to-unpublished", + "book-audience-profile-to-permission-set", + "book-translations-removed", + "connector-rate-limit-config-removed", + "dashboard-inert-keys-removed", + "dashboard-widget-action-aria-removed", + "dashboard-widget-compareto-converged", + "dashboard-widget-responsive-removed", + "dataset-measure-array-string-agg-removed", + "datasource-capabilities-removed", + "datasource-config-driver-key-aliases", + "datasource-inert-blocks-removed", + "datasource-read-replicas-removed", + "element-filter-removed", + "element-input-target-variable-removed", + "field-column-lists-canonicalized", + "field-conditionalRequired-to-requiredWhen", + "field-malformed-scale-precision-removed", + "field-mapping-transform-removed", + "field-required-notnull-explicit", + "flow-inert-keys-removed", + "flow-node-script-branch-keys-removed", + "flow-node-wait-timeout-keys-removed", + "hook-body-crypto-hash-removed", + "job-id-removed", + "mapping-inert-keys-removed", + "metric-filters-removed", + "object-compactLayout-to-highlightFields", + "object-enable-trash-mru-removed", + "object-index-type-partial-removed", + "object-managed-by-system-to-system-data", + "owd-legacy-read-aliases", + "page-card-body-to-children", + "page-structure-inert-keys-removed", + "page-tabs-type-to-tab-style", + "permission-rls-priority-removed", + "record-chatter-position-vocabulary", + "record-details-layout-removed", + "record-highlights-field-icon-removed", + "record-picker-display-field-to-label-field", + "record-picker-inert-keys-removed", + "sharing-recipient-role-to-position", + "skill-trigger-phrases-removed", + "stack-api-require-auth-removed", + "stack-roles-to-positions", + "theme-inert-token-scales-removed", + "tool-inert-authoring-keys-removed", + "translation-validation-messages-removed", + "view-export-options-pdf-removed", + "view-inert-keys-removed", + "view-list-passthrough-keys-removed" + ] + }, + { + "version": "17.3.0", + "integrity": "sha512-mceFp9swHHISMycZk8TjdqjBhi0GboQoapRSNeFyaI8GBIpw5cRc37GrWrIFVTZLaVCc7TF3XHSi1qqjaBNmzA==", + "retired": [ + "action-execute-to-target", + "action-global-nav-location-removed", + "action-inert-keys-removed", + "agent-knowledge-removed", + "agent-tools-to-skills", + "app-area-fail-open-gates-removed", + "app-dead-authoring-keys-removed", + "app-hidden-to-unpublished", + "book-audience-profile-to-permission-set", + "book-translations-removed", + "connector-rate-limit-config-removed", + "dashboard-inert-keys-removed", + "dashboard-widget-action-aria-removed", + "dashboard-widget-compareto-converged", + "dashboard-widget-responsive-removed", + "dataset-measure-array-string-agg-removed", + "datasource-capabilities-removed", + "datasource-config-driver-key-aliases", + "datasource-inert-blocks-removed", + "datasource-read-replicas-removed", + "element-filter-removed", + "element-form-removed", + "element-input-target-variable-removed", + "field-column-lists-canonicalized", + "field-conditionalRequired-to-requiredWhen", + "field-malformed-scale-precision-removed", + "field-mapping-transform-removed", + "field-reference-to-alias", + "field-required-notnull-explicit", + "flow-inert-keys-removed", + "flow-node-script-branch-keys-removed", + "flow-node-wait-timeout-keys-removed", + "form-view-option-default-removed", + "hook-body-crypto-hash-removed", + "job-id-removed", + "mapping-inert-keys-removed", + "mapping-lookup-params-removed", + "metric-filters-removed", + "object-compactLayout-to-highlightFields", + "object-enable-trash-mru-removed", + "object-grid-default-sort-removed", + "object-index-type-partial-removed", + "object-managed-by-system-to-system-data", + "owd-legacy-read-aliases", + "page-card-body-to-children", + "page-component-responsive-removed", + "page-structure-inert-keys-removed", + "page-tabs-type-to-tab-style", + "permission-allow-restore-purge-removed", + "permission-rls-priority-removed", + "record-chatter-position-vocabulary", + "record-details-layout-removed", + "record-highlights-field-icon-removed", + "record-picker-display-field-to-label-field", + "record-picker-inert-keys-removed", + "sharing-recipient-role-to-position", + "skill-trigger-phrases-removed", + "stack-api-require-auth-removed", + "stack-roles-to-positions", + "theme-inert-token-scales-removed", + "tool-inert-authoring-keys-removed", + "translation-component-submit-label-removed", + "translation-validation-messages-removed", + "view-export-options-pdf-removed", + "view-inert-keys-removed", + "view-list-passthrough-keys-removed" + ] + }, + { + "version": "17.4.0", + "integrity": "sha512-bOtSFih0Ed67JZMGuLmlHuIv2zPN62W8NL2qENQCYjZB174NMlIIvHC6Jlz2SIy5EwsTnTGBF5EPF6C2/TmqoA==", + "retired": [ + "action-execute-to-target", + "action-global-nav-location-removed", + "action-inert-keys-removed", + "agent-knowledge-removed", + "agent-tools-to-skills", + "api-endpoint-cache-ttl-to-cache-ttl-seconds", + "app-area-fail-open-gates-removed", + "app-dead-authoring-keys-removed", + "app-hidden-to-unpublished", + "book-audience-profile-to-permission-set", + "book-translations-removed", + "connector-error-mapping-removed", + "connector-health-and-trigger-durations-unit-in-key", + "connector-rate-limit-config-removed", + "dashboard-inert-keys-removed", + "dashboard-refresh-interval-to-refresh-interval-seconds", + "dashboard-widget-action-aria-removed", + "dashboard-widget-compareto-converged", + "dashboard-widget-responsive-removed", + "dataset-measure-array-string-agg-removed", + "datasource-capabilities-removed", + "datasource-config-driver-key-aliases", + "datasource-inert-blocks-removed", + "datasource-read-replicas-removed", + "element-filter-removed", + "element-form-removed", + "element-input-target-variable-removed", + "field-column-lists-canonicalized", + "field-conditionalRequired-to-requiredWhen", + "field-malformed-scale-precision-removed", + "field-mapping-transform-removed", + "field-reference-to-alias", + "flow-inert-keys-removed", + "flow-node-script-branch-keys-removed", + "flow-node-wait-timeout-keys-removed", + "form-view-option-default-removed", + "hook-body-crypto-hash-removed", + "hook-timeout-to-timeout-ms", + "job-id-removed", + "job-timeout-to-timeout-ms", + "mapping-inert-keys-removed", + "mapping-lookup-params-removed", + "memory-persistence-auto-save-interval-to-ms", + "metric-filters-removed", + "object-compactLayout-to-highlightFields", + "object-enable-trash-mru-removed", + "object-grid-default-sort-removed", + "object-index-type-partial-removed", + "object-managed-by-system-to-system-data", + "owd-legacy-read-aliases", + "page-card-body-to-children", + "page-component-responsive-removed", + "page-structure-inert-keys-removed", + "page-tabs-type-to-tab-style", + "permission-allow-restore-purge-removed", + "permission-rls-priority-removed", + "record-chatter-position-vocabulary", + "record-details-layout-removed", + "record-highlights-field-icon-removed", + "record-picker-display-field-to-label-field", + "record-picker-inert-keys-removed", + "sharing-recipient-role-to-position", + "skill-trigger-phrases-removed", + "stack-api-require-auth-removed", + "stack-roles-to-positions", + "theme-inert-token-scales-removed", + "tool-inert-authoring-keys-removed", + "translation-component-submit-label-removed", + "translation-validation-messages-removed", + "turso-config-timeout-to-timeout-ms", + "view-export-options-pdf-removed", + "view-inert-keys-removed", + "view-list-passthrough-keys-removed" + ] + } + ] +} diff --git a/packages/spec/src/conversions/retired-after.census.test.ts b/packages/spec/src/conversions/retired-after.census.test.ts new file mode 100644 index 00000000000..7e8ed77a042 --- /dev/null +++ b/packages/spec/src/conversions/retired-after.census.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { ALL_CONVERSIONS } from './registry.js'; + +/** + * [#20390] `MetadataConversion.retiredAfter` — the census pin. + * + * Every retired entry carries `retiredAfter`: the last published + * `@objectstack/spec` version whose authoring surface still accepted its old + * shape. The artifact-ingestion door reads it per entry, so a wrong value is a + * wrong boot verdict — too low and an artifact built by the last release is + * refused by the unreleased `main` that retired its key; too high and the door + * converts an artifact authored after the retirement instead of refusing it. + * tsc makes the field REQUIRED; this file pins each VALUE. + * + * The facts come from `retired-after.census.json`: for every stable release + * since the registry first shipped, the ids its published tarball marks + * retired, re-derived from npm by `scripts/build-retired-after-census.ts` + * (tarball integrity checked). Two rules, both the ruling's: + * + * - PUBLISHED entry (retired in some censused tarball): the value is the + * stable release just before the FIRST tarball that carries it retired. + * - UNPUBLISHED entry (retired in no censused tarball): the value is the + * package's own version label — the label at the moment it lands, which is + * the last release — so a retirement landing after a release bump cannot be + * stamped low. One tolerance, and only one: while the label is AHEAD of the + * census's last release (the release PR bumped it and the census has not + * yet recorded that release's tarball), an unpublished entry may carry any + * version from the census's last release up to the label — it landed either + * before the bump or after it, and only the new tarball can say which. + * Refreshing the census after the publish closes the tolerance again. + */ + +type Triple = [number, number, number]; + +interface CensusRelease { + version: string; + integrity: string; + retired: string[]; +} + +interface Census { + precedingRelease: string; + releases: CensusRelease[]; +} + +const CENSUS = JSON.parse( + readFileSync(new URL('./retired-after.census.json', import.meta.url), 'utf8'), +) as Census; +const LABEL = ( + JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version: string } +).version; + +const REGEN = 'pnpm --filter @objectstack/spec exec tsx scripts/build-retired-after-census.ts'; + +function triple(v: string): Triple { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v); + if (!m) throw new Error(`not a stable x.y.z version: '${v}'`); + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function cmp(a: string, b: string): number { + const x = triple(a); + const y = triple(b); + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1; + return 0; +} + +const LAST_CENSUSED = CENSUS.releases.at(-1)!.version; + +/** + * The stable release just before the first censused tarball that carries `id` + * retired — or `null` when no published tarball does (an unpublished entry). + */ +function publishedRetiredAfter(id: string): string | null { + const k = CENSUS.releases.findIndex((r) => r.retired.includes(id)); + if (k < 0) return null; + return k === 0 ? CENSUS.precedingRelease : CENSUS.releases[k - 1]!.version; +} + +const RETIRED = ALL_CONVERSIONS.filter((c) => c.retiredFromLoadPath === true); +const LIVE = ALL_CONVERSIONS.filter((c) => c.retiredFromLoadPath !== true); + +describe('[#20390] retiredAfter census — every retired entry stamped from the published tarballs', () => { + it('the census is well-formed: stable releases in ascending order, never ahead of the package label', () => { + expect(CENSUS.releases.length).toBeGreaterThan(0); + const versions = [CENSUS.precedingRelease, ...CENSUS.releases.map((r) => r.version)]; + for (let i = 1; i < versions.length; i++) { + expect(cmp(versions[i - 1]!, versions[i]!), `${versions[i - 1]} < ${versions[i]}`).toBe(-1); + } + expect(cmp(LAST_CENSUSED, LABEL), `census (${LAST_CENSUSED}) is ahead of the label (${LABEL})`).toBeLessThanOrEqual(0); + for (const r of CENSUS.releases) { + expect(r.integrity).toMatch(/^sha512-/); + expect([...r.retired].sort()).toEqual(r.retired); + } + }); + + it('every retired entry carries a stable x.y.z retiredAfter, never above the package label', () => { + expect(RETIRED.length).toBeGreaterThan(0); + for (const c of RETIRED) { + expect(c.retiredAfter, c.id).toMatch(/^\d+\.\d+\.\d+$/); + expect(cmp(c.retiredAfter!, LABEL), `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL}`).toBeLessThanOrEqual(0); + } + }); + + it('no live entry carries retiredAfter (it is set together with retiredFromLoadPath)', () => { + for (const c of LIVE) expect(c.retiredAfter, c.id).toBeUndefined(); + }); + + it('every PUBLISHED entry carries the stable release before the first tarball that retired it', () => { + const wrong: string[] = []; + let published = 0; + for (const c of RETIRED) { + const want = publishedRetiredAfter(c.id); + if (want === null) continue; + published += 1; + if (c.retiredAfter !== want) wrong.push(`${c.id}: retiredAfter '${c.retiredAfter}', the tarballs say '${want}'`); + } + expect(wrong, `stamp these from the census (${REGEN} re-derives it)`).toEqual([]); + // The census reaches at least one published retirement — a census that + // matched nothing would pass the loop above vacuously. + expect(published).toBeGreaterThan(0); + }); + + it('every UNPUBLISHED entry carries the package label (the release-pending range while the census trails it)', () => { + const pending = cmp(LABEL, LAST_CENSUSED) > 0; + const wrong: string[] = []; + for (const c of RETIRED) { + if (publishedRetiredAfter(c.id) !== null) continue; + const v = c.retiredAfter!; + if (!pending && v !== LABEL) { + wrong.push(`${c.id}: retiredAfter '${v}', but no published tarball retires it, so it takes the label '${LABEL}'`); + } else if (pending && (cmp(v, LAST_CENSUSED) < 0 || cmp(v, LABEL) > 0)) { + wrong.push(`${c.id}: retiredAfter '${v}' is outside [${LAST_CENSUSED}, ${LABEL}] — refresh the census (${REGEN})`); + } + } + expect(wrong).toEqual([]); + }); +}); diff --git a/packages/spec/src/conversions/types.ts b/packages/spec/src/conversions/types.ts index 339adf4026f..383004851d8 100644 --- a/packages/spec/src/conversions/types.ts +++ b/packages/spec/src/conversions/types.ts @@ -189,19 +189,47 @@ export interface ConversionFixture { } /** - * A single declarative, lossless metadata conversion. - * - * `apply` is a **pure, immutable** transform: it returns a stack with the old - * shape rewritten to the canonical one (copy-on-write — untouched branches are - * shared, so `plugins` and other non-clonable values are never touched), and - * reports each rewrite via `emit`. Registry glue turns each - * {@link ConversionApplication} into a full {@link ConversionNotice}. + * The members every conversion carries, whatever its retirement state. Not + * exported: {@link MetadataConversion} is the one public name; this body and + * the two retirement states below are how it is spelled. */ -export interface MetadataConversion { +interface MetadataConversionBody { /** Stable, kebab-case id; also the migration-chain step id when this graduates (P2). */ id: string; /** The protocol major that introduced the canonical shape. */ toMajor: number; + /** Dotted surface, e.g. `flow.node.type`, `page.kind`, `flow.node.config`. */ + surface: string; + /** One-line human summary of the rename/move (the load-bearing prose, kept to one field). */ + summary: string; + /** + * Apply the conversion to a normalized stack, immutably. Returns the (possibly + * new) stack and calls `emit` once per rewritten site. A conversion over an + * open namespace consults `context` (when supplied) to refuse — and report via + * `context.reportConflict` — a rewrite whose old token is a live name; a + * conversion over a closed surface ignores `context`. A conversion that + * recognises a pre-protocol shape on its surface but has no lossless rewrite + * for it leaves the site unchanged and reports it via `context.reportTodo`. + */ + apply( + stack: Record, + emit: (detail: ConversionApplication) => void, + context?: ConversionContext, + ): Record; + /** Old→new fixture pair driving the CI check. */ + fixture: ConversionFixture; +} + +/** A conversion the authoring funnel still replays: no retirement, so no retirement version. */ +interface LiveConversionState { + /** Absent (or `false`): the authoring funnel replays this entry. See the retired state. */ + retiredFromLoadPath?: false; + /** Absent: a live entry has no retirement version. Set it together with `retiredFromLoadPath`. */ + retiredAfter?: undefined; +} + +/** A conversion retired from the authoring surface, stamped with the version it retired after. */ +interface RetiredConversionState { /** * When `true`, this conversion is **retired from the AUTHORING surface**: the * authoring funnel (`normalizeStackInput` — `defineStack`, `validate`, @@ -217,7 +245,8 @@ export interface MetadataConversion { * stored-row rehydration (`applyConversionsToStoredItem`, which pins * `includeRetired: true` rather than offering it), flow rehydration in the * automation engine, and the artifact-ingestion door - * (`applyArtifactForwardConversions`, inside its declared-floor window). A + * (`applyArtifactForwardConversions`, inside its declared-floor window, which + * it decides per entry with {@link RetiredConversionState.retiredAfter}). A * row, a stored flow or a built artifact has no author for a tombstone to * teach, and refusing a shape that once worked would only break data — see * ADR-0087's `## Addendum (2026-07-31)` and the #12772 ruling. `objectstack @@ -228,25 +257,45 @@ export interface MetadataConversion { * whose old and new shapes are both legal and mean different things (a * default flip, not a rename), the data-at-rest seams will still apply it. */ - retiredFromLoadPath?: boolean; - /** Dotted surface, e.g. `flow.node.type`, `page.kind`, `flow.node.config`. */ - surface: string; - /** One-line human summary of the rename/move (the load-bearing prose, kept to one field). */ - summary: string; + retiredFromLoadPath: true; /** - * Apply the conversion to a normalized stack, immutably. Returns the (possibly - * new) stack and calls `emit` once per rewritten site. A conversion over an - * open namespace consults `context` (when supplied) to refuse — and report via - * `context.reportConflict` — a rewrite whose old token is a live name; a - * conversion over a closed surface ignores `context`. A conversion that - * recognises a pre-protocol shape on its surface but has no lossless rewrite - * for it leaves the site unchanged and reports it via `context.reportTodo`. + * The last published `@objectstack/spec` version whose authoring surface + * still accepted the old shape, as a stable `x.y.z`. REQUIRED on every + * retired entry, so tsc refuses a retirement that omits it. + * + * It is a FACT when the entry is written, never a guess at the next release + * number: it is the package's version label at the moment the retirement + * lands — `main` carries the last release's label until the next release is + * cut, and that release is the first one to refuse the old shape. For an + * entry already published, it is the stable release just before the first + * published tarball that carries the entry retired. + * + * Read by the artifact-ingestion door (`applyArtifactForwardConversions`, + * `@objectstack/metadata-core`): an artifact whose declared `engines.protocol` + * floor is at or below this version predates the retirement, so the door + * replays this entry even when that floor is not below the runtime's own + * version label — the gap a `main` that enforces a retirement the label has + * not caught up with would otherwise leave. A floor above it still meets the + * strict parse and its tombstone. + * + * Pinned against the published tarballs by `retired-after.census.test.ts` + * (the committed census beside it, re-derived from npm by + * `scripts/build-retired-after-census.ts`). */ - apply( - stack: Record, - emit: (detail: ConversionApplication) => void, - context?: ConversionContext, - ): Record; - /** Old→new fixture pair driving the CI check. */ - fixture: ConversionFixture; + retiredAfter: `${number}.${number}.${number}`; } + +/** + * A single declarative, lossless metadata conversion. + * + * `apply` is a **pure, immutable** transform: it returns a stack with the old + * shape rewritten to the canonical one (copy-on-write — untouched branches are + * shared, so `plugins` and other non-clonable values are never touched), and + * reports each rewrite via `emit`. Registry glue turns each + * {@link ConversionApplication} into a full {@link ConversionNotice}. + * + * Either live or retired: a retired entry (`retiredFromLoadPath: true`) must + * also carry `retiredAfter`, the version it retired after; a live entry carries + * neither. + */ +export type MetadataConversion = MetadataConversionBody & (LiveConversionState | RetiredConversionState); From e621fa01bc15d5a8af6a1ff6987a5f760853f7a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:59:23 +0000 Subject: [PATCH 02/10] test(metadata,metadata-core): pin the per-entry window on a real 17.4.0-built artifact Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../src/artifact-forward-conversion.test.ts | 147 +++++++++++++++- .../forward-probe-17.4-built.artifact.json | 138 +++++++++++++++ ...t-forward-conversion-retired-after.test.ts | 163 ++++++++++++++++++ 3 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json create mode 100644 packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 207575e6717..913d5a6bba4 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -98,8 +98,12 @@ describe('applyArtifactForwardConversions — the versioned window (#12772)', () }); it('REFUSES the amnesty for an artifact authored at the current spec version — no blanket strip', () => { - const def = legacyPermissionDefinition('^17.2.0'); - const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); + // "Current" for THIS registry: every retirement it carries is stamped + // `retiredAfter` 17.4.0 or earlier, so a 17.5.0 floor on a 17.5.0 runtime + // predates none of them. (A floor at the label that DOES predate one opens + // the per-entry window instead — the #20390 block below.) + const def = legacyPermissionDefinition('^17.5.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' }); expect(result.verdict).toBe('authored-current'); expect(result.notices).toEqual([]); @@ -401,6 +405,145 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub }); }); +/** + * [#20390] The per-entry window — `retiredAfter` (ruling 5865890672, letter A). + * + * Between two releases `main` refuses keys the NEXT release retires while its + * package label still reads the LAST release. A label-only window therefore + * read an artifact built by that last release as "authored current" and let + * the strict parse refuse it — the measured cloud re-cut: a 17.4.0-built + * artifact with dashboard charts and page `assignedProfiles` could not boot on + * a runtime built from `main` (label 17.4.0, retirements stamped for 17.5.0). + * + * The rule: entry E replays when `floor < runtime` OR `floor <= E.retiredAfter`. + * The runtime label is injected so each leg names the release it models; the + * registry is always this tree's real one, whose 17.5.0 retirements carry + * `retiredAfter: '17.4.0'` (pinned against the tarballs in spec's census test). + */ +describe('[#20390] the per-entry window — an artifact built by the last release boots on unreleased main', () => { + /** The shape the published 17.4.0 CLI emits for a chart widget and an assigned page. */ + const builtBy174 = (protocolRange: string) => ({ + manifest: { + id: 'com.example.forward_probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app', + engines: { protocol: protocolRange }, + }, + objects: [{ + name: 'fwd_deal', label: 'Deal', sharingModel: 'private', + fields: { stage: { type: 'text', label: 'Stage' }, amount: { type: 'number', label: 'Amount' } }, + }], + datasets: [{ + name: 'fwd_deal_metrics', label: 'Deal metrics', object: 'fwd_deal', + dimensions: [{ name: 'stage', field: 'stage' }], + measures: [{ name: 'amount', aggregate: 'sum', field: 'amount' }], + }], + dashboards: [{ + name: 'fwd_pipeline', label: 'Pipeline', + widgets: [{ + id: 'amount_by_stage', title: 'Amount by stage', type: 'bar', + dataset: 'fwd_deal_metrics', dimensions: ['stage'], values: ['amount'], + chartConfig: { + type: 'bar', + xAxis: { field: 'stage', showGridLines: true, logarithmic: false }, + yAxis: [{ field: 'amount', showGridLines: true, logarithmic: false }], + showLegend: true, showDataLabels: false, + }, + layout: { x: 0, y: 0, w: 6, h: 4 }, + }], + }], + pages: [{ + name: 'fwd_deal_desk', label: 'Deal Desk', type: 'app', template: 'default', regions: [], + isDefault: false, assignedProfiles: ['sales_manager'], kind: 'full', + }], + }); + + /** The retired-key sites the 17.5.0 cohort refuses in {@link builtBy174}. */ + const RETIRED_SITES = [ + 'dashboards.0.widgets.0.chartConfig.type', + 'dashboards.0.widgets.0.chartConfig.xAxis', + 'dashboards.0.widgets.0.chartConfig.yAxis', + 'pages.0.assignedProfiles', + ]; + + const issuePaths = (value: unknown): string[] => { + const parsed = ObjectStackDefinitionSchema.safeParse(value); + return parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.')).sort(); + }; + + const byConversion = (notices: readonly ArtifactConversionNotice[]) => { + const counts: Record = {}; + for (const n of notices) counts[n.conversionId] = (counts[n.conversionId] ?? 0) + 1; + return counts; + }; + + it('premise: unconverted, this tree refuses the 17.4.0-built shape at exactly the retired sites', () => { + expect(issuePaths(builtBy174('^17.4.0'))).toEqual(RETIRED_SITES); + }); + + // Pin (4): the regression case from the card's acceptance. + it('unreleased main (label 17.4.0), artifact at the last release (^17.4.0): the 17.5.0 retirements replay and the parse passes', () => { + const def = builtBy174('^17.4.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + + expect(result.verdict).toBe('converted-retired-after'); + expect(result.authoredFloor).toBe('17.4.0'); + expect(byConversion(result.notices)).toEqual({ + 'page-assigned-profiles-removed': 1, + 'dashboard-widget-chart-config-structure-removed': 3, + }); + expect(result.notices.map((n) => n.path).sort()).toEqual([ + 'dashboards[0].widgets[0].chartConfig.type', + 'dashboards[0].widgets[0].chartConfig.xAxis', + 'dashboards[0].widgets[0].chartConfig.yAxis', + 'pages[0].assignedProfiles', + ]); + // What the door hands the strict parse now boots. + expect(issuePaths(result.definition)).toEqual([]); + }); + + // Pin (3): the boundary the per-entry rule must keep. + it('an artifact whose floor is exactly 17.5.0 on a 17.5.0-labelled runtime is refused, not converted', () => { + const def = builtBy174('^17.5.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' }); + + expect(result.verdict).toBe('authored-current'); + expect(result.notices).toEqual([]); + expect(result.definition).toBe(def); + // The strict parse the door feeds refuses every retired site, tombstones included. + expect(issuePaths(result.definition)).toEqual(RETIRED_SITES); + }); + + it('after the release (label 17.5.0) the same ^17.4.0 artifact converts through the label half — the rule reduces to the old one', () => { + const result = applyArtifactForwardConversions(builtBy174('^17.4.0'), { runtimeSpecVersion: '17.5.0' }); + expect(result.verdict).toBe('converted-forward'); + expect(byConversion(result.notices)).toEqual({ + 'page-assigned-profiles-removed': 1, + 'dashboard-widget-chart-config-structure-removed': 3, + }); + expect(issuePaths(result.definition)).toEqual([]); + }); + + /** + * Inside the open per-entry window, an entry the floor post-dates still + * refuses: `permission-allow-restore-purge-removed` shipped retired in 17.2.0 + * (`retiredAfter` 17.1.0), so a ^17.4.0 artifact carrying `allowRestore: true` + * meets its tombstone although the 17.5.0 entries replay beside it. A key + * retired at V stays a loud refusal for anything authored at >= V. + */ + it('replays only the entries the floor predates — an older retirement still meets its tombstone', () => { + const def = { + ...builtBy174('^17.4.0'), + permissions: [{ name: 'fwd_agent', label: 'Agent', objects: { fwd_deal: { allowRead: true, allowRestore: true } } }], + }; + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + + expect(result.verdict).toBe('converted-retired-after'); + expect(result.notices.map((n) => n.conversionId)).not.toContain('permission-allow-restore-purge-removed'); + const grant = (result.definition as typeof def).permissions[0]!.objects.fwd_deal; + expect(grant.allowRestore, 'the 17.2.0 retirement is not replayed for a 17.4.0 floor').toBe(true); + expect(issuePaths(result.definition)).toEqual(['permissions.0.objects.fwd_deal.allowRestore']); + }); +}); + describe('parseRangeFloor — the range spellings artifacts actually carry', () => { it.each([ ['^17.1.0', [17, 1, 0]], diff --git a/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json b/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json new file mode 100644 index 00000000000..b3da3fcab7d --- /dev/null +++ b/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json @@ -0,0 +1,138 @@ +{ + "manifest": { + "id": "com.example.forward_probe", + "namespace": "fwd", + "defaultDatasource": "default", + "version": "1.0.0", + "type": "app", + "scope": "project", + "name": "forward_probe", + "engines": { + "protocol": "^17.4.0" + } + }, + "objects": [ + { + "name": "fwd_deal", + "label": "Deal", + "isSystem": false, + "datasource": "default", + "fields": { + "name": { + "label": "Name", + "type": "text", + "required": false, + "searchable": false, + "multiple": false, + "unique": false, + "hidden": false, + "readonly": false, + "sortable": true, + "externalId": false + }, + "stage": { + "label": "Stage", + "type": "text", + "required": false, + "searchable": false, + "multiple": false, + "unique": false, + "hidden": false, + "readonly": false, + "sortable": true, + "externalId": false + }, + "amount": { + "label": "Amount", + "type": "number", + "required": false, + "searchable": false, + "multiple": false, + "unique": false, + "hidden": false, + "readonly": false, + "sortable": true, + "externalId": false + } + }, + "sharingModel": "private" + } + ], + "pages": [ + { + "name": "fwd_deal_desk", + "label": "Deal Desk", + "type": "app", + "template": "default", + "regions": [], + "isDefault": false, + "assignedProfiles": [ + "sales_manager" + ], + "kind": "full" + } + ], + "dashboards": [ + { + "name": "fwd_pipeline", + "label": "Pipeline", + "widgets": [ + { + "id": "amount_by_stage", + "title": "Amount by stage", + "type": "bar", + "chartConfig": { + "type": "bar", + "xAxis": { + "field": "stage", + "showGridLines": true, + "logarithmic": false + }, + "yAxis": [ + { + "field": "amount", + "showGridLines": true, + "logarithmic": false + } + ], + "showLegend": true, + "showDataLabels": false + }, + "dataset": "fwd_deal_metrics", + "dimensions": [ + "stage" + ], + "values": [ + "amount" + ], + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + } + } + ] + } + ], + "datasets": [ + { + "name": "fwd_deal_metrics", + "label": "Deal metrics", + "object": "fwd_deal", + "dimensions": [ + { + "name": "stage", + "field": "stage" + } + ], + "measures": [ + { + "name": "amount", + "aggregate": "sum", + "field": "amount" + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts new file mode 100644 index 00000000000..4a6f222f2aa --- /dev/null +++ b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20390] An artifact built by the LAST release boots on unreleased `main`. + * + * `__fixtures__/forward-probe-17.4-built.artifact.json` is `dist/objectstack.json` + * verbatim, as built by the published `@objectstack/cli` 17.4.0 (`os build`, + * resolving `@objectstack/spec` 17.4.0 from npm) from a one-object source: a + * dataset-bound bar-chart widget whose `chartConfig` carries `type`, `xAxis` + * and `yAxis` — `type` was REQUIRED at 17.4.0 — and a page with + * `assignedProfiles`, declaring `engines.protocol: '^17.4.0'`. + * + * `main` retires all four keys for 17.5.0 while `packages/spec` still carries + * the 17.4.0 label, so before this fix the door's label-only window read the + * artifact as "authored current", replayed nothing, and the strict parse in + * `_parseAndRegisterArtifact` refused the boot. The registry now stamps each of + * those retirements `retiredAfter: '17.4.0'`, and the door replays an entry + * whenever the artifact's floor is at or below it. + * + * Two acceptance pins live here, on the real door: (1) the built artifact + * boots and the conversion notices are logged; (2) a newly AUTHORED source + * using the same retired keys is still refused loudly by the authoring funnel. + * The window's two version boundaries — floor 17.5.0 on a 17.5.0 runtime + * refused, and the label-17.4.0 regression case with an injected label — are + * pinned in `@objectstack/metadata-core`'s `artifact-forward-conversion.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineStack } from '@objectstack/spec'; +import { MetadataPlugin } from './plugin.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(HERE, '__fixtures__/forward-probe-17.4-built.artifact.json'); + +/** Fresh parse per test — `_parseAndRegisterArtifact` mutates items in place. */ +function loadFixture(): any { + return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')); +} + +function fakeCtx() { + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn(() => undefined), + trigger: vi.fn(), + } as any; +} + +function newPlugin(): any { + return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); +} + +/** The conversion summary lines the door logged, keyed by conversion id. */ +function conversionWarns(ctx: any): Map { + const byId = new Map(); + for (const [line] of ctx.logger.warn.mock.calls as [string][]) { + const id = /ADR-0087 conversion '([a-z0-9-]+)'/.exec(String(line))?.[1]; + if (!id) continue; + byId.set(id, [...(byId.get(id) ?? []), String(line)]); + } + return byId; +} + +describe('[#20390] artifact door — a 17.4.0-built artifact boots on unreleased main', () => { + it('the fixture carries the shape the published 17.4.0 CLI emitted (premise guard)', () => { + const fixture = loadFixture(); + expect(fixture.manifest.engines.protocol).toBe('^17.4.0'); + const chartConfig = fixture.dashboards[0].widgets[0].chartConfig; + expect(Object.keys(chartConfig)).toEqual(expect.arrayContaining(['type', 'xAxis', 'yAxis'])); + expect(fixture.pages[0].assignedProfiles).toEqual(['sales_manager']); + }); + + // Pin (1). + it('boots: the dashboard and the page register with the retired keys converted away', async () => { + const plugin = newPlugin(); + const total = await plugin._parseAndRegisterArtifact(fakeCtx(), loadFixture(), 'forward-probe-17.4'); + expect(total).toBeGreaterThan(0); + + const dashboard = await plugin.manager.get('dashboard', 'fwd_pipeline'); + expect(dashboard, 'the dashboard registers').toBeDefined(); + const chartConfig = (dashboard as any).widgets[0].chartConfig ?? {}; + for (const key of ['type', 'xAxis', 'yAxis']) expect(chartConfig).not.toHaveProperty(key); + // The widget keeps its dataset binding — the selection the chart renders from. + expect((dashboard as any).widgets[0]).toMatchObject({ type: 'bar', dataset: 'fwd_deal_metrics', dimensions: ['stage'], values: ['amount'] }); + + const page = await plugin.manager.get('page', 'fwd_deal_desk'); + expect(page, 'the page registers').toBeDefined(); + expect(page).not.toHaveProperty('assignedProfiles'); + }); + + // Pin (1), the other half: loud, once per conversion per artifact. + it('logs one conversion summary per retired entry it replayed, naming the site count', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + await plugin._parseAndRegisterArtifact(ctx, loadFixture(), 'forward-probe-17.4'); + + const warns = conversionWarns(ctx); + expect([...warns.keys()].sort()).toEqual([ + 'dashboard-widget-chart-config-structure-removed', + 'page-assigned-profiles-removed', + ]); + expect(warns.get('dashboard-widget-chart-config-structure-removed')).toHaveLength(1); + expect(warns.get('dashboard-widget-chart-config-structure-removed')![0]).toContain('3 site(s)'); + expect(warns.get('page-assigned-profiles-removed')).toHaveLength(1); + expect(warns.get('page-assigned-profiles-removed')![0]).toContain('1 site(s)'); + }); +}); + +describe('[#20390] authoring funnel — a NEW source using the retired keys is still refused loudly', () => { + // Pin (2). The source the fixture was built from, verbatim, authored today: + // the authoring funnel never replays a retired entry, whatever the floor says. + const source = () => ({ + manifest: { + id: 'com.example.forward_probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app', + engines: { protocol: '^17.4.0' }, + }, + objects: [{ + name: 'fwd_deal', label: 'Deal', sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + amount: { type: 'number', label: 'Amount' }, + }, + }], + datasets: [{ + name: 'fwd_deal_metrics', label: 'Deal metrics', object: 'fwd_deal', + dimensions: [{ name: 'stage', field: 'stage' }], + measures: [{ name: 'amount', field: 'amount', aggregate: 'sum' }], + }], + dashboards: [{ + name: 'fwd_pipeline', label: 'Pipeline', + widgets: [{ + id: 'amount_by_stage', title: 'Amount by stage', type: 'bar', + dataset: 'fwd_deal_metrics', dimensions: ['stage'], values: ['amount'], + chartConfig: { type: 'bar', xAxis: { field: 'stage' }, yAxis: [{ field: 'amount' }] }, + layout: { x: 0, y: 0, w: 6, h: 4 }, + }], + }], + pages: [{ name: 'fwd_deal_desk', label: 'Deal Desk', type: 'app', assignedProfiles: ['sales_manager'], regions: [] }], + }); + + it('defineStack refuses with STACK_SCHEMA_INVALID / 422, one issue per retired site', () => { + let refused: any = null; + try { + defineStack(source() as never); + } catch (e) { + refused = e; + } + expect(refused, 'defineStack must refuse').not.toBeNull(); + expect(refused.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused.status).toBe(422); + const paths = (refused.issues as { path: PropertyKey[] }[]).map((i) => i.path.join('.')).sort(); + expect(paths).toEqual([ + 'dashboards.0.widgets.0.chartConfig.type', + 'dashboards.0.widgets.0.chartConfig.xAxis', + 'dashboards.0.widgets.0.chartConfig.yAxis', + 'pages.0.assignedProfiles', + ]); + }); +}); From 404f17b7f089a0d0d5341b1843d65adce8197a34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:11:44 +0000 Subject: [PATCH 03/10] test(metadata): rebuild the 17.4.0 probe artifact with a reverse-domain manifest id Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- packages/metadata-core/src/artifact-forward-conversion.test.ts | 2 +- .../src/__fixtures__/forward-probe-17.4-built.artifact.json | 2 +- .../plugin-artifact-forward-conversion-retired-after.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 913d5a6bba4..6a57cc6a6ff 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -424,7 +424,7 @@ describe('[#20390] the per-entry window — an artifact built by the last releas /** The shape the published 17.4.0 CLI emits for a chart widget and an assigned page. */ const builtBy174 = (protocolRange: string) => ({ manifest: { - id: 'com.example.forward_probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app', + id: 'com.example.forward-probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app', engines: { protocol: protocolRange }, }, objects: [{ diff --git a/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json b/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json index b3da3fcab7d..015ceca9bb1 100644 --- a/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json +++ b/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json @@ -1,6 +1,6 @@ { "manifest": { - "id": "com.example.forward_probe", + "id": "com.example.forward-probe", "namespace": "fwd", "defaultDatasource": "default", "version": "1.0.0", diff --git a/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts index 4a6f222f2aa..9c3ca684a64 100644 --- a/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts +++ b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts @@ -114,7 +114,7 @@ describe('[#20390] authoring funnel — a NEW source using the retired keys is s // the authoring funnel never replays a retired entry, whatever the floor says. const source = () => ({ manifest: { - id: 'com.example.forward_probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app', + id: 'com.example.forward-probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app', engines: { protocol: '^17.4.0' }, }, objects: [{ From 2e1fd0e45e47fa50be74ec80e2355b6b308435f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:22:59 +0000 Subject: [PATCH 04/10] =?UTF-8?q?chore(spec):=20regenerate=20api-surface?= =?UTF-8?q?=20and=20export-origins=20=E2=80=94=20MetadataConversion=20is?= =?UTF-8?q?=20now=20a=20type=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- packages/spec/api-surface/root.json | 2 +- packages/spec/export-origins/root.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 6d5d38d750c..857281527a8 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -101,7 +101,7 @@ "MIGRATION_SUPPORT_FLOOR (const)", "MapSupportedField (type)", "MetadataCollectionInput (type)", - "MetadataConversion (interface)", + "MetadataConversion (type)", "MigrationApplication (interface)", "MigrationChainResult (interface)", "MigrationFloorError (class)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 11aa5bb3cf6..226e9d0d75a 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -100,7 +100,7 @@ "MIGRATION_SUPPORT_FLOOR": "src/migrations/registry.ts#MIGRATION_SUPPORT_FLOOR (const)", "MapSupportedField": "src/shared/metadata-collection.zod.ts#MapSupportedField (type)", "MetadataCollectionInput": "src/shared/metadata-collection.zod.ts#MetadataCollectionInput (type)", - "MetadataConversion": "src/conversions/types.ts#MetadataConversion (interface)", + "MetadataConversion": "src/conversions/types.ts#MetadataConversion (type)", "MigrationApplication": "src/migrations/types.ts#MigrationApplication (interface)", "MigrationChainResult": "src/migrations/types.ts#MigrationChainResult (interface)", "MigrationFloorError": "src/migrations/chain.ts#MigrationFloorError (class)", From ed521aa8269b97e352f9674868b589701237410a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:24:21 +0000 Subject: [PATCH 05/10] =?UTF-8?q?chore(changeset):=20spec=20+=20metadata-c?= =?UTF-8?q?ore=20minor,=20Clause-=E2=91=A1=20yes,=20runtime-interface-only?= =?UTF-8?q?=20disposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../20390-conversion-retired-after-window.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/20390-conversion-retired-after-window.md diff --git a/.changeset/20390-conversion-retired-after-window.md b/.changeset/20390-conversion-retired-after-window.md new file mode 100644 index 00000000000..afcbfd1493e --- /dev/null +++ b/.changeset/20390-conversion-retired-after-window.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-core': minor +--- + +feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390) + +Clause-②: yes + + + +**BREAKING** for code that implements `MetadataConversion` itself — shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above). `MetadataConversion` is now a type alias of a live-or-retired union: an entry with `retiredFromLoadPath: true` must also carry `retiredAfter`, a stable `x.y.z` string, and a live entry carries neither. tsc names the missing member (`Property 'retiredAfter' is missing`). No in-repo conversion is left unstamped, and no metadata an author writes changes. + +**What the field means.** `retiredAfter` is the last published `@objectstack/spec` version whose authoring surface still accepted the entry's old shape. It is a fact when the entry lands: the package's own version label at that moment, because `main` carries the last release's label until the next release is cut. Every published retired entry is stamped from the published tarballs — the stable release just before the first tarball that carries it retired — and each entry not yet in any published tarball carries the current label, `17.4.0`. + +**Why the artifact door needed it.** Between two releases, `main` refuses keys that the next release retires while its label still reads the last release. The artifact-ingestion door (`applyArtifactForwardConversions`) compared an artifact's `engines.protocol` floor with that label alone, so an artifact built by the last published CLI — floor `^17.4.0`, dashboard `chartConfig.type`/`xAxis`/`yAxis` and page `assignedProfiles` — read as "authored current": nothing was converted and the strict parse refused the boot. The door now replays a registry entry when the floor is below the runtime label, **or** at or below that entry's `retiredAfter`. After a release the rule reduces to the old one, and an artifact whose floor is above an entry's `retiredAfter` still meets that entry's tombstone — a floor of `^17.5.0` on a 17.5.0 runtime is refused, not converted. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. + +**`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. A consumer that switches exhaustively over the verdict adds that arm. The door's operator log lines are unchanged: one summary per conversion per artifact, naming the site count. + +**Census.** 93 retired entries on this tree: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 20 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish. From 07572aa56a0a874edf1deb8975dad2e533c2327e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:00:34 +0000 Subject: [PATCH 06/10] feat(spec): stamp flow-decision-mode-inclusive-explicit retiredAfter 17.4.0; pin the refusal list read before it The entry landed with #15429 after this branch forked; it is in no published tarball, so it carries the package label. The door still refuses it inside its own per-entry window, because DEFAULT_FLIPS_NOT_REPLAYED_HERE is read first. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../src/artifact-forward-conversion.test.ts | 16 ++++++++++++++++ packages/spec/src/conversions/registry.ts | 1 + 2 files changed, 17 insertions(+) diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 8291e03ec3d..3eb2755909d 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -619,6 +619,22 @@ describe('the artifact door never writes `mode: inclusive` onto an authored excl expect(Object.keys(registered.config ?? {}), 'what registration receives').not.toContain('mode'); }); + /** + * [#20390] The per-entry window does not reopen it either. The entry is + * stamped `retiredAfter: '17.4.0'`, so a ^17.4.0 floor on a runtime still + * labelled 17.4.0 is inside ITS per-entry window — and the door's refusal + * list is still read first, before any version is. + */ + it('stays refused inside the per-entry window too — the refusal list is read before retiredAfter', () => { + const def = twoBranchDecisionDefinition('^17.4.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + + // ⭐ ANTI-VACUITY: the per-entry window really is open on this input. + expect(result.verdict).toBe('converted-retired-after'); + expect(verdictNodeOf(result.definition).config).toBeUndefined(); + expect(result.notices.map((n) => n.conversionId)).not.toContain(ID); + }); + it('floor ^99.0.0 — the window is shut and nothing is replayed at all', () => { const def = twoBranchDecisionDefinition('^99.0.0'); const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 49a0b6b6dc3..3f9d1ee47b2 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -12038,6 +12038,7 @@ const flowDecisionModeInclusiveExplicit: MetadataConversion = { id: 'flow-decision-mode-inclusive-explicit', toMajor: 18, retiredFromLoadPath: true, + retiredAfter: '17.4.0', surface: 'flow.nodes[].config.mode (decision)', summary: "edge-branched decision with two or more conditioned out-edges and no `mode`: `mode: 'inclusive'` written " From b9a07ea3f7dc84ad96aacc59ea75b07ae685b039 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:00:57 +0000 Subject: [PATCH 07/10] chore(changeset): census counts after the #15429 merge Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .changeset/20390-conversion-retired-after-window.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20390-conversion-retired-after-window.md b/.changeset/20390-conversion-retired-after-window.md index afcbfd1493e..e9c1b552666 100644 --- a/.changeset/20390-conversion-retired-after-window.md +++ b/.changeset/20390-conversion-retired-after-window.md @@ -17,4 +17,4 @@ Clause-②: yes **`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. A consumer that switches exhaustively over the verdict adds that arm. The door's operator log lines are unchanged: one summary per conversion per artifact, naming the site count. -**Census.** 93 retired entries on this tree: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 20 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish. +**Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish. From 874a4e742dcaf06c0735ea240cdbca778dc905db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:20:39 +0000 Subject: [PATCH 08/10] fix(metadata): the #12915 notice and the conversion summary follow the per-entry window MetadataPlugin reads which forward-conversion verdicts open the window from one total table over ArtifactForwardConversionVerdict, with 'converted-retired-after' on the open side, so the unbound form-predicate notice no longer waits for the package label to move. Under that verdict the conversion summary names the retirement this runtime enforces past the artifact's floor, carried on the result as replayedRetirements. The census refresh joins the GA release flow in docs/releases-maintenance.md. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../20390-conversion-retired-after-window.md | 7 +- docs/releases-maintenance.md | 2 + .../src/artifact-forward-conversion.test.ts | 10 ++ .../src/artifact-forward-conversion.ts | 56 +++++++---- ...t-forward-conversion-retired-after.test.ts | 46 +++++++++ ...lugin-unbound-form-predicate-roots.test.ts | 23 ++++- packages/metadata/src/plugin.ts | 96 +++++++++++++++---- 7 files changed, 202 insertions(+), 38 deletions(-) diff --git a/.changeset/20390-conversion-retired-after-window.md b/.changeset/20390-conversion-retired-after-window.md index e9c1b552666..c96c4700994 100644 --- a/.changeset/20390-conversion-retired-after-window.md +++ b/.changeset/20390-conversion-retired-after-window.md @@ -1,6 +1,7 @@ --- '@objectstack/spec': minor '@objectstack/metadata-core': minor +'@objectstack/metadata': patch --- feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390) @@ -15,6 +16,8 @@ Clause-②: yes **Why the artifact door needed it.** Between two releases, `main` refuses keys that the next release retires while its label still reads the last release. The artifact-ingestion door (`applyArtifactForwardConversions`) compared an artifact's `engines.protocol` floor with that label alone, so an artifact built by the last published CLI — floor `^17.4.0`, dashboard `chartConfig.type`/`xAxis`/`yAxis` and page `assignedProfiles` — read as "authored current": nothing was converted and the strict parse refused the boot. The door now replays a registry entry when the floor is below the runtime label, **or** at or below that entry's `retiredAfter`. After a release the rule reduces to the old one, and an artifact whose floor is above an entry's `retiredAfter` still meets that entry's tombstone — a floor of `^17.5.0` on a 17.5.0 runtime is refused, not converted. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. -**`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. A consumer that switches exhaustively over the verdict adds that arm. The door's operator log lines are unchanged: one summary per conversion per artifact, naming the site count. +**`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. `ArtifactForwardConversionResult` gains `replayedRetirements` (exported element type `ArtifactReplayedRetirement`): under that verdict, each retirement this runtime enforces past the artifact's floor, with its `retiredAfter`; empty for every other verdict. A consumer that switches exhaustively over the verdict adds that arm. -**Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish. +**`@objectstack/metadata`, the artifact door — the arm added.** `MetadataPlugin` now reads which verdicts open the window from one total table over `ArtifactForwardConversionVerdict`, with `'converted-retired-after'` on the open side. The #12915 unbound form-predicate notice rides that same reading, so a 17.4.0-built artifact carrying a bare-root form predicate on `main` is announced now, rather than only once the package label moves past 17.4.0. A verdict added later fails to compile until it is placed on one side of the window. Under the new verdict the conversion summary no longer says the artifact "predates this runtime's spec" beside a runtime version equal to its floor: it names the retirement this runtime enforces past the artifact's floor, with the release that last accepted the shape, and says the artifact converts again on every boot until it is rebuilt with tooling from a release that ships the retirement. Summaries are still one per conversion per artifact, naming the site count. + +**Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish; `docs/releases-maintenance.md` lists that step in the GA release flow. diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index 563195acb2b..a2b11361f00 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -447,6 +447,8 @@ Wait for the refreshed PR's CI, then merge it. That merge is still the decision release, and the `release` environment approval is still the authorisation — neither is changed by where the refresh came from. +**After a stable `@objectstack/spec` publish, refresh the retired-after census** (#20390): run `pnpm --filter @objectstack/spec exec tsx scripts/build-retired-after-census.ts` (prefix `NODE_USE_ENV_PROXY=1` behind a proxy) and commit the rewritten `packages/spec/src/conversions/retired-after.census.json` in an ordinary PR — until it lands, the census test holds an unpublished entry's `retiredAfter` only to the range from the last censused release to the label, not to the label exactly. + ## Drift guard `scripts/check-release-notes.mjs` (run in CI as `pnpm check:release-notes`) fails the diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 3eb2755909d..d700c536fb2 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -498,6 +498,13 @@ describe('[#20390] the per-entry window — an artifact built by the last releas ]); // What the door hands the strict parse now boots. expect(issuePaths(result.definition)).toEqual([]); + // The door names what opened it: each retirement this runtime enforces past + // the floor, with the release it retired after — never a default flip. + const replayed = new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter])); + expect(replayed.get('page-assigned-profiles-removed')).toBe('17.4.0'); + expect(replayed.get('dashboard-widget-chart-config-structure-removed')).toBe('17.4.0'); + expect(replayed.has('flow-decision-mode-inclusive-explicit')).toBe(false); + expect([...new Set(replayed.values())]).toEqual(['17.4.0']); }); // Pin (3): the boundary the per-entry rule must keep. @@ -507,6 +514,7 @@ describe('[#20390] the per-entry window — an artifact built by the last releas expect(result.verdict).toBe('authored-current'); expect(result.notices).toEqual([]); + expect(result.replayedRetirements).toEqual([]); expect(result.definition).toBe(def); // The strict parse the door feeds refuses every retired site, tombstones included. expect(issuePaths(result.definition)).toEqual(RETIRED_SITES); @@ -515,6 +523,8 @@ describe('[#20390] the per-entry window — an artifact built by the last releas it('after the release (label 17.5.0) the same ^17.4.0 artifact converts through the label half — the rule reduces to the old one', () => { const result = applyArtifactForwardConversions(builtBy174('^17.4.0'), { runtimeSpecVersion: '17.5.0' }); expect(result.verdict).toBe('converted-forward'); + // The label half names no per-entry reason: the whole chain replays on one. + expect(result.replayedRetirements).toEqual([]); expect(byConversion(result.notices)).toEqual({ 'page-assigned-profiles-removed': 1, 'dashboard-widget-chart-config-structure-removed': 3, diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index 5daaa45cae1..bd42214b795 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -218,6 +218,22 @@ export interface ArtifactForwardConversionResult { runtimeSpecVersion: string | null; /** Every notice the replay emitted (empty when nothing converted). */ notices: ArtifactConversionNotice[]; + /** + * Under `'converted-retired-after'` only: the retirements this runtime + * enforces past the artifact's floor, which the per-entry half of the window + * replayed — each with the `retiredAfter` the floor is at or below. Empty for + * every other verdict: the label half replays the whole chain on one reason + * for all of it, and a closed window replays nothing. + */ + replayedRetirements: ArtifactReplayedRetirement[]; +} + +/** One retirement the per-entry half of the window replayed (see `'converted-retired-after'`). */ +export interface ArtifactReplayedRetirement { + /** The conversion id (`MetadataConversion.id`). */ + conversionId: string; + /** Its `retiredAfter`: the last spec release whose authoring surface still accepted the old shape. */ + retiredAfter: string; } /** @@ -342,25 +358,31 @@ const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = [ ]; /** - * The per-entry half of the window, for a floor at or above the runtime label: - * the ids the door must NOT replay — {@link DEFAULT_FLIPS_NOT_REPLAYED_HERE} + * The per-entry half of the window, for a floor at or above the runtime label. + * `closed` is the ids the door must NOT replay — {@link DEFAULT_FLIPS_NOT_REPLAYED_HERE} * (read first, whatever an entry's version says), every live entry, and every - * retired entry whose `retiredAfter` the floor exceeds. `null` when that is - * every entry, i.e. the floor predates no retirement the runtime enforces. + * retired entry whose `retiredAfter` the floor exceeds; `opened` is the rest, + * each a retirement this runtime enforces past the floor. `null` when nothing + * opens, i.e. the floor predates no retirement the runtime enforces. * * A `retiredAfter` this cannot read closes its entry: the strict parse and its * tombstone stay the authority, which is the loud direction. */ -function idsTheFloorPostdates(floor: [number, number, number]): string[] | null { +function idsTheFloorPostdates( + floor: [number, number, number], +): { closed: string[]; opened: ArtifactReplayedRetirement[] } | null { const closed = [...DEFAULT_FLIPS_NOT_REPLAYED_HERE]; - let open = 0; + const opened: ArtifactReplayedRetirement[] = []; for (const conversion of ALL_CONVERSIONS) { if (DEFAULT_FLIPS_NOT_REPLAYED_HERE.includes(conversion.id)) continue; const retiredAfter = conversion.retiredFromLoadPath === true ? parseVersion(conversion.retiredAfter) : null; - if (retiredAfter && compareTriples(floor, retiredAfter) <= 0) open += 1; - else closed.push(conversion.id); + if (retiredAfter && compareTriples(floor, retiredAfter) <= 0) { + opened.push({ conversionId: conversion.id, retiredAfter: retiredAfter.join('.') }); + } else { + closed.push(conversion.id); + } } - return open > 0 ? closed : null; + return opened.length > 0 ? { closed, opened } : null; } /** @@ -381,7 +403,7 @@ export function applyArtifactForwardConversions( : resolveInstalledSpecVersion(); if (definition === null || typeof definition !== 'object' || Array.isArray(definition)) { - return { definition, verdict: 'not-an-object', authoredFloor: null, runtimeSpecVersion, notices: [] }; + return { definition, verdict: 'not-an-object', authoredFloor: null, runtimeSpecVersion, notices: [], replayedRetirements: [] }; } const manifest = (definition as { manifest?: unknown }).manifest; @@ -394,27 +416,29 @@ export function applyArtifactForwardConversions( const runtime = runtimeSpecVersion ? parseVersion(runtimeSpecVersion) : null; if (!runtime) { - return { definition, verdict: 'runtime-version-unknown', authoredFloor, runtimeSpecVersion, notices: [] }; + return { definition, verdict: 'runtime-version-unknown', authoredFloor, runtimeSpecVersion, notices: [], replayedRetirements: [] }; } let verdict: ArtifactForwardConversionVerdict; let excludeConversionIds: readonly string[] = DEFAULT_FLIPS_NOT_REPLAYED_HERE; + let replayedRetirements: ArtifactReplayedRetirement[] = []; if (!floor) { verdict = 'converted-undeclared'; } else if (compareTriples(floor, runtime) < 0) { verdict = 'converted-forward'; } else { - const closed = idsTheFloorPostdates(floor); - if (closed === null) { - return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [] }; + const perEntry = idsTheFloorPostdates(floor); + if (perEntry === null) { + return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [], replayedRetirements: [] }; } verdict = 'converted-retired-after'; + replayedRetirements = perEntry.opened; // The per-entry half of the window: every entry the floor does NOT predate // stays with the strict parse. Each id's reason is the same, read off the // registry rather than written here — the floor is at or above the runtime // label AND above the entry's own `retiredAfter` (or the entry is live, and // a live entry has no retirement for the floor to predate). - excludeConversionIds = closed; + excludeConversionIds = perEntry.closed; } const notices: ArtifactConversionNotice[] = []; @@ -427,5 +451,5 @@ export function applyArtifactForwardConversions( }, }) as T; - return { definition: converted, verdict, authoredFloor, runtimeSpecVersion, notices }; + return { definition: converted, verdict, authoredFloor, runtimeSpecVersion, notices, replayedRetirements }; } diff --git a/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts index 9c3ca684a64..be1ca4f222b 100644 --- a/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts +++ b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts @@ -40,6 +40,11 @@ function loadFixture(): any { return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')); } +/** A fresh fixture carrying a fresh copy of the given `views`. */ +function loadFixtureWith(views: unknown): any { + return { ...loadFixture(), views: JSON.parse(JSON.stringify(views)) }; +} + function fakeCtx() { return { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, @@ -106,6 +111,47 @@ describe('[#20390] artifact door — a 17.4.0-built artifact boots on unreleased expect(warns.get('dashboard-widget-chart-config-structure-removed')![0]).toContain('3 site(s)'); expect(warns.get('page-assigned-profiles-removed')).toHaveLength(1); expect(warns.get('page-assigned-profiles-removed')![0]).toContain('1 site(s)'); + // Under the per-entry half the floor is not below the runtime's label, + // so the line must not claim the artifact "predates" a runtime printed + // at the same version — it names the retirement that opened it instead. + for (const line of [...warns.values()].flat()) expect(line).not.toContain("predates this runtime's spec"); + }); + + /** + * #12915 scope C rides the same window. The unbound-root notice is read off + * the forward-conversion pass's own verdict, so on unreleased `main` a + * 17.4.0-built artifact is "old" for it exactly as it is for the replay — + * the notice must not wait for the package label to move. + */ + it('announces a bare-root form predicate once — the #12915 notice follows the per-entry window', async () => { + const views = [{ + form: { + type: 'simple', + data: { provider: 'object', object: 'fwd_deal' }, + sections: [{ + name: 'deal', + fields: [ + { field: 'stage' }, + // Bare root: `stage`, not `record.stage` — unbound where it evaluates. + { field: 'amount', required: true, visibleWhen: { dialect: 'cel', source: 'stage == "won"' } }, + ], + }], + }, + }]; + expect(loadFixtureWith(views).manifest.engines.protocol).toBe('^17.4.0'); + + const plugin = newPlugin(); + const ctx = fakeCtx(); + await plugin._parseAndRegisterArtifact(ctx, loadFixtureWith(views), 'forward-probe-17.4-bare-root'); + // The HMR watcher replays the same artifact: still once. + await plugin._parseAndRegisterArtifact(ctx, loadFixtureWith(views), 'forward-probe-17.4-bare-root'); + + const unbound = (ctx.logger.warn.mock.calls as [string][]) + .map(([line]) => String(line)) + .filter((line) => line.includes('root identifier is NOT bound')); + expect(unbound).toHaveLength(1); + expect(unbound[0]).toContain("'stage'"); + expect(unbound[0]).toContain('1 view(s): fwd_deal'); }); }); diff --git a/packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts b/packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts index ff7d68c2f4a..e90cecc861a 100644 --- a/packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts +++ b/packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts @@ -25,6 +25,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveInstalledSpecVersion } from '@objectstack/metadata-core'; +import { ALL_CONVERSIONS } from '@objectstack/spec'; import { MetadataPlugin } from './plugin.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -48,6 +49,21 @@ function newPlugin(): any { return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); } +/** + * The first `x.y.z` past both the installed spec's label and every retired + * entry's `retiredAfter` — the floor of an artifact authored against the + * surface this runtime actually enforces, which no window opens for. + */ +function currentSurfaceFloor(): string { + const installed = resolveInstalledSpecVersion(); + if (!installed) return ''; + const triples = [installed, ...ALL_CONVERSIONS.flatMap((c) => (c.retiredFromLoadPath === true ? [c.retiredAfter] : []))] + .map((v) => v.split('.').slice(0, 3).map((n) => Number.parseInt(n, 10)) as [number, number, number]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]); + const [major, minor, patch] = triples.at(-1)!; + return `${major}.${minor}.${patch + 1}`; +} + /** Just the notices this feature emits — never the #12772 conversion summaries. */ function unboundRootWarnings(ctx: any): string[] { return (ctx.logger.warn.mock.calls as any[]) @@ -137,7 +153,12 @@ describe('artifact door — unbound form-predicate roots are announced to the op // declaring the current floor gets zero notices even carrying the very // same bare-root predicates. Derived from the installed spec rather than // hardcoded, so the pin cannot rot into vacuity on the next spec bump. - const current = resolveInstalledSpecVersion(); + // + // "Current" is past BOTH the package label and every retirement the + // registry enforces (#20390): while `main` carries retirements its label + // has not moved past, `^