From 8e60576e128099bbfadb82a99d87351318dde122 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:30:29 +0000 Subject: [PATCH 01/11] fix(spec): tombstones for keys live in @objectstack/spec 17.7.0 name 18 as their removal version The v18 train runs in Changesets pre mode, so the first release that refuses a retirement stamped `retiredAfter: '17.7.0'` is 18.0.0, not a 17.x minor. Seven prescriptions named 17 (or 17.8.0, or no version) for shapes the 17.7.0 tarball still accepts: the ten element flat-binding keys (one shared string), object-grid `defaultFilters`, email_template `category` / `isSystem`, the package-manifest permission-string list, StorageScope `public`, and the `allowOrgOverride` rename. Tests pinning the literal follow. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- packages/lint/src/lint-liveness-properties.test.ts | 2 +- packages/lint/src/runtime-gate.inert-type-writes.test.ts | 2 +- packages/lint/src/validate-component-props.test.ts | 4 ++-- .../spec/src/kernel/manifest-permissions-string-list.test.ts | 2 +- packages/spec/src/kernel/manifest.zod.ts | 2 +- packages/spec/src/kernel/metadata-plugin.zod.ts | 2 +- ...tadata-type-registry-allow-org-override-retirement.test.ts | 2 +- .../email-template-category-is-system-retirement.test.ts | 4 ++-- packages/spec/src/system/email-template.zod.ts | 4 ++-- packages/spec/src/system/object-storage.zod.ts | 2 +- packages/spec/src/ui/component.zod.ts | 4 ++-- packages/spec/src/ui/element-flat-binding-retirement.test.ts | 4 ++-- 12 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 3e0cbaed042..8b427ca05cd 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -440,7 +440,7 @@ describe('lintLivenessProperties', () => { expect(refused.success, key).toBe(false); expect(refused.error?.issues[0]?.path, key).toEqual([key]); expect(refused.error?.issues[0]?.message, key).toMatch( - new RegExp(`^\`email_template\\.${key}\` was removed in @objectstack/spec 17`), + new RegExp(`^\`email_template\\.${key}\` was removed in @objectstack/spec 18`), ); } }); diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index 95c902351de..2436995203d 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -758,7 +758,7 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem const refused = door!.safeParse({ ...item, category: 'workflow' }); expect(refused.success).toBe(false); expect(refused.error?.issues[0]?.path).toEqual(['category']); - expect(refused.error?.issues[0]?.message).toMatch(/^`email_template\.category` was removed in @objectstack\/spec 17/); + expect(refused.error?.issues[0]?.message).toMatch(/^`email_template\.category` was removed in @objectstack\/spec 18/); }); it('[#21127] a `mapping` write that DOES author `connectorSource` is judged silent — not thrown on', () => { diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 5ecf6ddf37c..3315e8caf44 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -379,7 +379,7 @@ describe('validateComponentProps — value verdicts', () => { 'pages[0].regions[0].components[0].properties.object', ]); const tombstone = invalid(flat).find((f) => f.path.endsWith('.properties.object'))!; - expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 17.*`dataSource\.object`/s); + expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 18.*`dataSource\.object`/s); }); /** @@ -483,7 +483,7 @@ describe('validateComponentProps — value verdicts', () => { for (const key of ['object', 'filter', 'sort', 'limit']) { const at = invalid(findings).find((f) => f.path === `${base}.properties.${key}`)!; expect(at.message).toMatch( - new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 17.*\`dataSource\\.${key}\``, 's'), + new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 18.*\`dataSource\\.${key}\``, 's'), ); } expect(unknownKeys(findings)).toEqual([]); diff --git a/packages/spec/src/kernel/manifest-permissions-string-list.test.ts b/packages/spec/src/kernel/manifest-permissions-string-list.test.ts index c53d6e5d9d4..9d74f6255d0 100644 --- a/packages/spec/src/kernel/manifest-permissions-string-list.test.ts +++ b/packages/spec/src/kernel/manifest-permissions-string-list.test.ts @@ -59,7 +59,7 @@ const legal = () => ({ * two-clause `os migrate meta` sentence naming the case the conversion covers. */ const PRESCRIPTION = - /^Expected the plugin permission block `\{ services\?, hooks\?, network\?, fs\? \}`, received a flat list\..*removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\).*translate each one by hand, or delete `permissions` when the plugin needs none\. Run `os migrate meta --from 17` to list the mechanical edits for the package manifest case; `--write` applies the ones it can prove, and a granted-permission record is not a source it reads\.$/s; + /^Expected the plugin permission block `\{ services\?, hooks\?, network\?, fs\? \}`, received a flat list\..*removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\).*translate each one by hand, or delete `permissions` when the plugin needs none\. Run `os migrate meta --from 17` to list the mechanical edits for the package manifest case; `--write` applies the ones it can prove, and a granted-permission record is not a source it reads\.$/s; const permissionsIssue = (input: unknown) => { const result = ManifestSchema.safeParse(input); diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 9c54d65a51c..06fbf3e2771 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -38,7 +38,7 @@ import { NavigationContributionSchema } from '../ui/app.zod'; const PLUGIN_PERMISSIONS_LIST_FORM = 'Expected the plugin permission block `{ services?, hooks?, network?, fs? }`, received a flat list. ' + 'A list of permission strings was the legacy form of a package manifest\'s `permissions`, removed in ' - + '@objectstack/spec 17 (ADR-0049 enforce-or-remove) — no loader ever read it: what a package is ' + + '@objectstack/spec 18 (ADR-0049 enforce-or-remove) — no loader ever read it: what a package is ' + 'granted at load is the consented grant set, never the manifest\'s list. Name what the plugin may touch ' + 'in the four lists instead — platform services, lifecycle hooks, network hosts and filesystem paths, ' + 'e.g. `{ services: [\'object\'], network: [\'api.acme.com\'] }`. A permission string has no mechanical ' diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 86961718d34..b6f2a5ab24a 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -315,7 +315,7 @@ const MetadataTypeRegistryEntryBaseSchema = z.object({ */ allowOrgOverride: retiredKey( '`MetadataTypeRegistryEntry.allowOrgOverride` was renamed to `allowEnvironmentOverlay` ' - + '(ADR-0131 D6) — the per-organization overlay axis was retired, and ' + + 'in @objectstack/spec 18 (ADR-0131 D6) — the per-organization overlay axis was retired, and ' + 'the key now names the one scope it governs: whether an environment may overlay an item ' + 'of this type that a managed package ships. Rename the key to `allowEnvironmentOverlay`; ' + 'the value is unchanged.', diff --git a/packages/spec/src/kernel/metadata-type-registry-allow-org-override-retirement.test.ts b/packages/spec/src/kernel/metadata-type-registry-allow-org-override-retirement.test.ts index 64bc0561213..11cf08b4080 100644 --- a/packages/spec/src/kernel/metadata-type-registry-allow-org-override-retirement.test.ts +++ b/packages/spec/src/kernel/metadata-type-registry-allow-org-override-retirement.test.ts @@ -56,7 +56,7 @@ describe('MetadataTypeRegistryEntry.allowOrgOverride is renamed to allowEnvironm expect(issue!.path).toEqual(['allowOrgOverride']); // The prescription IS the migration doc for whoever hits it. expect(issue!.message).toMatch( - /`MetadataTypeRegistryEntry\.allowOrgOverride` was renamed to `allowEnvironmentOverlay` \(ADR-0131 D6\)/s, + /`MetadataTypeRegistryEntry\.allowOrgOverride` was renamed to `allowEnvironmentOverlay` in @objectstack\/spec 18 \(ADR-0131 D6\)/s, ); expect(issue!.message).toMatch(/Rename the key to `allowEnvironmentOverlay`; the value is unchanged/s); // `false` is refused exactly like `true`: the key is gone, not one value of it. diff --git a/packages/spec/src/system/email-template-category-is-system-retirement.test.ts b/packages/spec/src/system/email-template-category-is-system-retirement.test.ts index 0faf811b5bc..0c0e0cc564c 100644 --- a/packages/spec/src/system/email-template-category-is-system-retirement.test.ts +++ b/packages/spec/src/system/email-template-category-is-system-retirement.test.ts @@ -68,9 +68,9 @@ const TEMPLATE = { // Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; // the key-first house convention is asserted on the issue message itself below. const CATEGORY_PRESCRIPTION = - /`email_template\.category` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing read it.*Delete the key\. A template is found by its `name`, so group templates with a dotted namespace prefix instead.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; + /`email_template\.category` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\) — nothing read it.*Delete the key\. A template is found by its `name`, so group templates with a dotted namespace prefix instead.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; const IS_SYSTEM_PRESCRIPTION = - /`email_template\.isSystem` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing enforced it.*Delete the key\. A built-in template is a code package's declaration.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; + /`email_template\.isSystem` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\) — nothing enforced it.*Delete the key\. A built-in template is a code package's declaration.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; const RETIRED = [ { key: 'category', prescription: CATEGORY_PRESCRIPTION, values: ['auth', 'notification', 'workflow', 'marketing', 'custom'] }, diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index d72fca56e99..2df82881ed1 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -80,7 +80,7 @@ export const EMAIL_TEMPLATE_FLOOR_LOCALE = 'en-US'; * still owes. */ const EMAIL_TEMPLATE_CATEGORY_REMOVED = - '`email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + '`email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — ' + 'nothing read it: the Studio listing never offered the filter facet it promised, and delivery ' + 'never depended on it. Delete the key. A template is found by its `name`, so group templates ' + 'with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). ' @@ -88,7 +88,7 @@ const EMAIL_TEMPLATE_CATEGORY_REMOVED = + 'applies the ones it can prove, and you apply the rest by hand.'; const EMAIL_TEMPLATE_IS_SYSTEM_REMOVED = - '`email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + '`email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — ' + 'nothing enforced it: no door refused deleting a template that set it. Delete the key. A ' + "built-in template is a code package's declaration: a Studio save overrides it for the " + "environment, and discarding that save restores the package's copy. " diff --git a/packages/spec/src/system/object-storage.zod.ts b/packages/spec/src/system/object-storage.zod.ts index 1d6355eea64..9d117fd66c5 100644 --- a/packages/spec/src/system/object-storage.zod.ts +++ b/packages/spec/src/system/object-storage.zod.ts @@ -44,7 +44,7 @@ import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; // no authored source to rewrite. Module-private and written with `//`, never // `/** */`: prose an enum's error map consumes, not documented surface. const STORAGE_SCOPE_PUBLIC_RETIRED = - '`public` was removed from `StorageScope` in @objectstack/spec 17.8.0 (ADR-0049 ' + '`public` was removed from `StorageScope` in @objectstack/spec 18 (ADR-0049 ' + 'enforce-or-remove) — no storage scope ever made a file publicly readable. A file is ' + 'served without sign-in only when its stored file record carries `acl: \'public_read\'` ' + '(ADR-0104), the one opt-in for anonymous download. Name another scope, or omit `scope` ' diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index eb783670960..a5514057aad 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3042,7 +3042,7 @@ const elementFlatBindingRetired = ( : type === 'element:number' && key === 'filter' ? 'where `dataSource.filter` already has rules, append these to it, since the two always AND-combined' : 'where `dataSource` already sets it, delete this one, since the binding\'s value always won'; - return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 17 (ADR-0087 D2) — ` + return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — ` + `${why}. Use \`dataSource.${key}\` on the component ` + 'node, a sibling of `type` rather than a key inside `properties`. Move the key; the value ' + `(${ELEMENT_FLAT_BINDING_VALUE[key]}) is unchanged, and ${both}. ` @@ -5304,7 +5304,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * a record-form value it moves is converted at `filter` like any other. */ defaultFilters: retiredKey( - '`object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — ' + '`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + 'it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to ' + 'nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use ' + '`filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, ' diff --git a/packages/spec/src/ui/element-flat-binding-retirement.test.ts b/packages/spec/src/ui/element-flat-binding-retirement.test.ts index 8bc46e60f32..a53e9c3dfc1 100644 --- a/packages/spec/src/ui/element-flat-binding-retirement.test.ts +++ b/packages/spec/src/ui/element-flat-binding-retirement.test.ts @@ -124,7 +124,7 @@ describe('the element tombstones — refused at the key, with the prescription', expect(issues[0]!.path).toEqual([key]); const message = issues[0]!.message; // House convention 1 + 2: the qualified key opens it, then the release and ADR. - expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 17 (ADR-0087 D2) — `)).toBe(true); + expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — `)).toBe(true); // The live mechanism, named at its own key on the node. expect(message).toContain(`Use \`dataSource.${key}\` on the component node, a sibling of \`type\``); expect(message.endsWith(MIGRATE_SENTENCE)).toBe(true); @@ -217,7 +217,7 @@ describe('`object-grid.defaultFilters` — refused at the key, with the prescrip expect(at).toHaveLength(1); expect(at[0]!.code).toBe('invalid_type'); expect(at[0]!.path).toEqual(['defaultFilters']); - expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — ')).toBe(true); + expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ')).toBe(true); expect(at[0]!.message).toContain('Use `filter`.'); expect(at[0]!.message.endsWith(MIGRATE_SENTENCE)).toBe(true); }); From 8d3a9c21b0740c4666aa6526a9cb414075e15864 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:33:05 +0000 Subject: [PATCH 02/11] test(spec): pin a retirement prescription's removal version to the one its registry entry derives The pin reads `retiredAfter` off each joined ADR-0087 conversion, the newest published stable release off the tree (the census, every stamp, a stable label) and the next release's major off PROTOCOL_MAJOR, and asserts every tombstone or refusal for that surface names that major, and that its `os migrate meta --from N` is toMajor - 1. Every entry retired after the newest published stable release must be joined, so a future retirement cannot ship its prescription unjudged. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../retired-key-removal-version.pin.test.ts | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 packages/spec/src/shared/retired-key-removal-version.pin.test.ts diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts new file mode 100644 index 00000000000..9633362282b --- /dev/null +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -0,0 +1,306 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Class pin: a retirement prescription names the release that removed it, and + * the pin DERIVES that release from the ADR-0087 conversion entry instead of + * trusting the sentence. + * + * Governing text, `retiredKey()`'s `@param guidance` (`./retired-key.ts`): + * "State what replaced the key, the version that removed it, and the one-line + * fix — this string IS the migration doc for anyone who hits it." A wrong + * version there is wrong release text: it ships in every refusal, in every + * JSON Schema `describe`, and in what `os validate` prints. + * + * ## The derivation + * + * `MetadataConversion.retiredAfter` is the last published `@objectstack/spec` + * release whose authoring surface still accepted the old shape + * (`../conversions/types.ts`). So the release that removed it is the first + * stable release after `retiredAfter`, and the prescription names that + * release's major: + * + * - `retiredAfter` is the newest published stable release ⇒ nothing published + * refuses the shape yet, and the release `main` cuts next does. Its major is + * `PROTOCOL_MAJOR`, which `../kernel/protocol-version.ts` keeps in lockstep + * with the package major and, in Changesets pre mode with a pending major, + * lets name the major that release is about to publish. On the v18 train + * that is 18. On the 17.x line, where a retirement shipped as a minor, + * the same rule gave 17. + * - `retiredAfter` is older ⇒ a published release refuses it: the next one on + * the same line when the tree records one, else the first release of the + * next line. + * + * "The newest published stable release" is read off the tree, with no version + * written here. It is the highest stable version the tree records: every + * census release, every `retiredAfter` stamp, and the package label when the + * label is stable. Under a stable label that is the label: `main` carries the + * last release's label until the next one is cut, and + * `retired-after.census.test.ts` holds every stamp and every census release at + * or below it. Under a prerelease label (`18.0.0-next.N`) it is the highest + * stamp: every retirement that lands then stamps the newest published stable + * release (`../conversions/types.ts`). The committed census alone cannot + * answer it, because the census trails the published line until it is + * refreshed after a stable publish. + * + * ## The join + * + * `JOIN` maps a retired conversion id to the prescriptions an author meets for + * its surface. A tombstone row reads the key's parse refusal and its + * `[REMOVED]` description, which are the two channels. A refusal row reads the + * refusal the surface answers, for a value or union-arm retirement that has no + * key to tombstone. Two guards keep the join honest: + * + * - every retired entry whose `retiredAfter` is the newest published stable + * release must have a row, so a future retirement cannot ship its + * prescription unjudged; + * - the rows must discriminate: at least one derives `PROTOCOL_MAJOR` and at + * least one derives an older major, so the derivation is exercised in both + * directions on real entries, never vacuously. + * + * The three older rows are entries a 17.x minor shipped. Each is `toMajor: 18` + * like the rest, so `toMajor` cannot be what tells them apart. + * + * Out of reach by construction: a retirement with no D2 conversion carries no + * `retiredAfter`, so nothing here can derive its release (a retired-key-only + * tombstone, or a value retired with only a D3 entry). + * + * Each prescription's `os migrate meta --from N` sentence is derived the same + * way: `composeMigrationChain` keeps the majors above N, so the step that + * replays a `toMajor: T` entry is reached by `--from T-1`. + */ + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { IndexSchema } from '../data/object.zod'; +import { ManifestPermissionsSchema } from '../kernel/manifest.zod'; +import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; +import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; +import { + ElementNumberPropsSchema, + ElementRecordPickerPropsSchema, + ElementRepeaterPropsSchema, + ObjectGridPropsSchema, + ObjectKanbanPropsSchema, + PageHeaderProps, +} from '../ui/component.zod'; + +// --------------------------------------------------------------------------- +// Versions +// --------------------------------------------------------------------------- + +type Triple = [number, number, number]; + +const STABLE = /^(\d+)\.(\d+)\.(\d+)$/; + +function triple(v: string): Triple { + const m = STABLE.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 major = (v: string): number => triple(v)[0]; + +/** What the tree records about the published line, and the major `main` publishes next. */ +interface ReleaseFacts { + /** Every stable version the tree records: census releases, stamps, a stable label. */ + recorded: readonly string[]; + protocolMajor: number; +} + +function newestStable(facts: ReleaseFacts): string { + return facts.recorded.reduce((a, b) => (cmp(a, b) >= 0 ? a : b)); +} + +/** The major of the first stable release that refuses a shape last accepted by `retiredAfter`. */ +function removalMajor(retiredAfter: string, facts: ReleaseFacts): number { + if (cmp(retiredAfter, newestStable(facts)) >= 0) return facts.protocolMajor; + const line = major(retiredAfter); + const laterOnLine = facts.recorded.some((v) => major(v) === line && cmp(v, retiredAfter) > 0); + return laterOnLine ? line : line + 1; +} + +const LABEL = ( + JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version: string } +).version; + +const CENSUS = JSON.parse( + readFileSync(new URL('../conversions/retired-after.census.json', import.meta.url), 'utf8'), +) as { precedingRelease: string; releases: { version: string }[] }; + +const RETIRED = ALL_CONVERSIONS.filter((c) => c.retiredFromLoadPath === true); + +const FACTS: ReleaseFacts = { + recorded: [ + CENSUS.precedingRelease, + ...CENSUS.releases.map((r) => r.version), + ...RETIRED.map((c) => c.retiredAfter!), + ...(STABLE.test(LABEL) ? [LABEL] : []), + ], + protocolMajor: PROTOCOL_MAJOR, +}; + +const NEWEST_STABLE = newestStable(FACTS); + +// --------------------------------------------------------------------------- +// Prescriptions +// --------------------------------------------------------------------------- + +interface Prescription { + where: string; + text: string; +} + +type Renderer = () => Prescription[]; + +type Parse = { success: boolean; error?: { issues: { message: string }[] } }; +type Shaped = { shape: Record Parse; description?: string }> }; + +/** A `retiredKey()` tombstone: its parse refusal and its `[REMOVED]` description. */ +const tombstone = (schema: unknown, surface: string, key: string): Renderer => () => { + const field = (schema as Shaped).shape[key]; + if (!field) throw new Error(`${surface}: no key \`${key}\` on the schema`); + const refused = field.safeParse(1); + expect(refused.success, `${surface}.${key} must refuse every value`).toBe(false); + const description = field.description ?? ''; + expect(description.startsWith('[REMOVED] '), `${surface}.${key}: the description is a tombstone's`).toBe(true); + return [ + { where: `${surface}.${key} (parse)`, text: refused.error!.issues[0]!.message }, + { where: `${surface}.${key} (describe)`, text: description.slice('[REMOVED] '.length) }, + ]; +}; + +/** The refusal a surface answers for a retired value or union arm. */ +const refusal = (where: string, parse: () => Parse): Renderer => () => { + const refused = parse(); + expect(refused.success, `${where} must be refused`).toBe(false); + return refused.error!.issues.map((issue) => ({ where, text: issue.message })); +}; + +const ELEMENTS: readonly (readonly [unknown, string, readonly string[]])[] = [ + [ElementNumberPropsSchema, 'element:number', ['object', 'filter']], + [ElementRecordPickerPropsSchema, 'element:record_picker', ['object', 'filter', 'sort', 'limit']], + [ElementRepeaterPropsSchema, 'element:repeater', ['object', 'filter', 'sort', 'limit']], +]; + +/** Retired conversion id → the prescriptions an author meets for its surface. */ +const JOIN: Readonly> = { + 'element-flat-data-binding-to-data-source': ELEMENTS.flatMap(([schema, surface, keys]) => + keys.map((key) => tombstone(schema, surface, key)), + ), + 'object-grid-default-filters-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultFilters')], + 'email-template-category-is-system-removed': [ + tombstone(EmailTemplateDefinitionSchema, 'email_template', 'category'), + tombstone(EmailTemplateDefinitionSchema, 'email_template', 'isSystem'), + ], + 'manifest-permissions-string-list-removed': [ + refusal('manifest.permissions as a list of strings', () => ManifestPermissionsSchema.safeParse(['system.user.read'])), + ], + 'declared-index-unique-scope': [ + refusal('indexes[].unique: true', () => IndexSchema.safeParse({ fields: ['code'], unique: true })), + ], + // Shipped by a 17.x minor; `toMajor: 18` like the rows above. + 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], + 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')], + 'object-kanban-quick-add-removed': [tombstone(ObjectKanbanPropsSchema, 'object-kanban', 'quickAdd')], +}; + +/** Every major a prescription names as a release: `@objectstack/spec N[.x.y]` or `protocol N`. */ +function majorsNamed(text: string): number[] { + return [...text.matchAll(/@objectstack\/spec (\d+)(?:\.\d+\.\d+)?\b|\bprotocol (\d+)\b/g)].map((m) => + Number(m[1] ?? m[2]), + ); +} + +const byId = new Map(RETIRED.map((c) => [c.id, c])); + +// --------------------------------------------------------------------------- + +describe('a retirement prescription names the release the registry entry derives', () => { + it('every joined entry is a retired conversion', () => { + const stale = Object.keys(JOIN).filter((id) => !byId.has(id)); + expect(stale, 'JOIN names an id that is not a retired conversion').toEqual([]); + }); + + it('every entry retired after the newest published stable release is joined to its prescriptions', () => { + const unjoined = RETIRED.filter((c) => cmp(c.retiredAfter!, NEWEST_STABLE) >= 0 && !(c.id in JOIN)).map( + (c) => `${c.id} (retiredAfter ${c.retiredAfter})`, + ); + expect( + unjoined, + `retired after ${NEWEST_STABLE}, the newest published stable release: add each to JOIN in this file, ` + + 'with the tombstones or refusals an author meets for its surface', + ).toEqual([]); + }); + + it('the joined rows exercise the derivation in both directions', () => { + const derived = Object.keys(JOIN).map((id) => removalMajor(byId.get(id)!.retiredAfter!, FACTS)); + expect(derived).toContain(PROTOCOL_MAJOR); + expect(derived.some((m) => m < PROTOCOL_MAJOR)).toBe(true); + }); + + it.each(Object.keys(JOIN))('%s', (id) => { + const entry = byId.get(id)!; + const want = removalMajor(entry.retiredAfter!, FACTS); + const prescriptions = JOIN[id]!.flatMap((render) => render()); + expect(prescriptions.length).toBeGreaterThan(0); + for (const { where, text } of prescriptions) { + const named = majorsNamed(text); + expect(named.length, `${where}: names no release — ${text}`).toBeGreaterThan(0); + expect( + named.every((m) => m === want), + `${where}: retiredAfter ${entry.retiredAfter} (newest published stable ${NEWEST_STABLE}) derives ` + + `@objectstack/spec ${want}, but the prescription names ${named.join(', ')} — ${text}`, + ).toBe(true); + for (const [, from] of text.matchAll(/os migrate meta --from (\d+)/g)) { + expect(Number(from), `${where}: toMajor ${entry.toMajor} is replayed by --from ${entry.toMajor - 1}`).toBe( + entry.toMajor - 1, + ); + } + } + }); +}); + +describe('the derivation under simulated release facts', () => { + const at = (recorded: string[], protocolMajor: number): ReleaseFacts => ({ recorded, protocolMajor }); + + it('a stable 17.x label on the v18 train: the newest stable is the next major\'s, older stamps keep 17', () => { + const facts = at(['17.4.0', '17.5.0', '17.7.0', '17.7.0'], 18); + expect(removalMajor('17.7.0', facts)).toBe(18); + expect(removalMajor('17.5.0', facts)).toBe(17); + expect(removalMajor('17.2.0', facts)).toBe(17); + }); + + it('a prerelease label records no label, and the highest stamp answers the same way', () => { + const facts = at(['17.4.0', '17.5.0', '17.7.0'], 18); + expect(newestStable(facts)).toBe('17.7.0'); + expect(removalMajor('17.7.0', facts)).toBe(18); + expect(removalMajor('17.5.0', facts)).toBe(17); + }); + + it('once 18.0.0 is the label, a 17.7.0 stamp still derives 18, and an 18.x stamp stays on 18', () => { + const facts = at(['17.4.0', '17.5.0', '17.7.0', '18.0.0', '18.1.0'], 18); + expect(removalMajor('17.7.0', facts)).toBe(18); + expect(removalMajor('17.5.0', facts)).toBe(17); + expect(removalMajor('18.0.0', facts)).toBe(18); + expect(removalMajor('18.1.0', facts)).toBe(18); + }); + + it('on the 17.x line, where a retirement shipped as a minor, the newest stamp derives 17', () => { + const facts = at(['17.4.0', '17.5.0'], 17); + expect(removalMajor('17.5.0', facts)).toBe(17); + }); + + it('the tree\'s own reading: the newest published stable is at least every census release', () => { + for (const r of CENSUS.releases) expect(cmp(NEWEST_STABLE, r.version)).toBeGreaterThanOrEqual(0); + }); +}); From 68f8c5582daf764c56fd019d0b5edd6e34cec649 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:35:02 +0000 Subject: [PATCH 03/11] chore(changeset): spec patch, retirement prescriptions name 18 for shapes 17.7.0 still accepts Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .changeset/23145-tombstone-removal-version.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/23145-tombstone-removal-version.md diff --git a/.changeset/23145-tombstone-removal-version.md b/.changeset/23145-tombstone-removal-version.md new file mode 100644 index 00000000000..699e272158f --- /dev/null +++ b/.changeset/23145-tombstone-removal-version.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': patch +--- + +Retirement prescriptions for shapes `@objectstack/spec` 17.7.0 still accepts now name 18 as the release that removed them + +Clause-②: no + +17.7.0, the newest published release, still accepts every shape below. No 17.x release removes them: the 18 line does. Their refusals, and their `[REMOVED]` JSON Schema descriptions, named a different release: + +| Surface | Was | Now | +| --- | --- | --- | +| `element:number` `object` / `filter`; `element:record_picker` and `element:repeater` `object` / `filter` / `sort` / `limit` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| `object-grid` `defaultFilters` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| `email_template.category`, `email_template.isSystem` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| a package manifest's `permissions` written as a list of permission strings | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| `public` in `StorageScope` | removed in @objectstack/spec 17.8.0 | removed in @objectstack/spec 18 | +| `MetadataTypeRegistryEntry.allowOrgOverride` → `allowEnvironmentOverlay` | renamed (no release named) | renamed in @objectstack/spec 18 | + +The `os migrate meta --from 17` sentence in each prescription is unchanged and was already right: `--from 17` replays the protocol-18 conversions. A prescription for a key that a 17.x release already refuses keeps naming 17. Text only: no key, value or shape is accepted or refused differently. From 93675095007f191db8ac0e4edb85d68033e41c72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:42:40 +0000 Subject: [PATCH 04/11] docs(spec): regenerate the references for the corrected removal versions Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../references/kernel/metadata-plugin.mdx | 2 +- .../docs/references/system/email-template.mdx | 4 ++-- content/docs/references/ui/component.mdx | 22 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index c6ad54d1042..631e9603a4e 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -269,7 +269,7 @@ const result = MetadataBulkResultSchema.parse(data); | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | | **supportsOverlay** | `boolean` | optional (default: `true`) | Whether a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for permission alone; it opens no save and it decides no read | | **allowEnvironmentOverlay** | `boolean` | optional (default: `false`) | Whether an environment may overlay an item of this type that a managed package ships (ADR-0131 D6 regime O). Decides the save door, the managed-content seal on write and on read, and Studio's write gate; the OS_METADATA_WRITABLE hatch does not reach a managed item. Default false. | -| **allowOrgOverride** | `never` | optional | [REMOVED] `MetadataTypeRegistryEntry.allowOrgOverride` was renamed to `allowEnvironmentOverlay` (ADR-0131 D6) — the per-organization overlay axis was retired, and the key now names the one scope it governs: whether an environment may overlay an item of this type that a managed package ships. Rename the key to `allowEnvironmentOverlay`; the value is unchanged. | +| **allowOrgOverride** | `never` | optional | [REMOVED] `MetadataTypeRegistryEntry.allowOrgOverride` was renamed to `allowEnvironmentOverlay` in @objectstack/spec 18 (ADR-0131 D6) — the per-organization overlay axis was retired, and the key now names the one scope it governs: whether an environment may overlay an item of this type that a managed package ships. Rename the key to `allowEnvironmentOverlay`; the value is unchanged. | | **allowRuntimeCreate** | `boolean` | optional (default: `true`) | Allow runtime creation via API | | **supportsVersioning** | `boolean` | optional (default: `false`) | Whether version history is tracked | | **executionPinned** | `boolean` | optional (default: `false`) | Transaction rows reference a specific version_hash; history GC is disabled and getByHash() MUST resolve old hashes (ADR-0009) | diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index 196bffc8d42..2aa1fa74d21 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -47,7 +47,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Template identifier (dotted snake_case) | | **label** | `string` | ✅ | Display label | -| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY. A call that NAMES a locale gets exactly one retry rung, the literal `en-US`, with no language-subtag folding: `en` and `en-US` are different ROWS of one bundle and neither reaches the other, so a bundle with no `en-US` row has no fallback floor for those calls and every recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale is the other case and does not dead-letter: it drops to the bundle's lowest locale tag and renders that silently. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. | | **subject** | `string` | ✅ | Subject template | | **bodyHtml** | `string` | ✅ | HTML body template | @@ -56,7 +56,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **fromOverride** | `{ name?: string; address: string }` | optional | | | **replyTo** | `string` | optional | | | **active** | `boolean` | optional (default: `true`) | | -| **isSystem** | `never` | optional | [REMOVED] `email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **isSystem** | `never` | optional | [REMOVED] `email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string` | optional | | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this email template. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 419714591b6..4e802d7e8d2 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -341,10 +341,10 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **field** | `string` | optional | Field to aggregate | | **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function | -| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **format** | `Enum<'number' \| 'currency' \| 'percent'>` | optional | Number display format | | **prefix** | `string` | optional | Prefix text (e.g. "$") | | **suffix** | `string` | optional | Suffix text (e.g. "%") | @@ -367,13 +367,13 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **labelField** | `string` | optional | Field rendered as each row's text (default `name`) | | **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) | | **label** | `string \| Record` | optional | Control label rendered above the select | -| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | @@ -399,12 +399,12 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **titleField** | `string` | optional | Field shown first on each line, emphasized | | **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | -| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | | **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | @@ -797,7 +797,7 @@ Sort field and direction pair | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | -| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | From 15ff350f37733f2d0d37cfe059eb81a2f7678492 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:09:36 +0000 Subject: [PATCH 05/11] fix(spec): the sub-day time-interval refusal names 17.5.0, the release that removed it `TimeUpdateInterval` accepted `second` / `minute` / `hour` through the 17.4.0 tarball and refuses them from 17.5.0 (the D2 entry `cube-sub-day-granularities-removed` is `retiredAfter: '17.4.0'`), but the refusal said "retired in protocol 18". It now says "was removed in @objectstack/spec 17.5.0", and the derived removal-version pin joins the entry, so it guards the 17.x-shipped direction too. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../src/memory-analytics-time-granularity.test.ts | 2 +- packages/spec/src/data/analytics.test.ts | 2 +- packages/spec/src/data/analytics.zod.ts | 2 +- .../src/shared/retired-key-removal-version.pin.test.ts | 10 ++++++++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts index 3c7bd4709d7..8c95ba7f7a6 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts @@ -309,7 +309,7 @@ describe('[#16178] a sub-day granularity is refused, not dropped', () => { expect(parsed.success, granularity).toBe(false); if (parsed.success) return; const message = parsed.error.issues.map((i) => i.message).join(' '); - expect(message, granularity).toContain('retired in protocol 18'); + expect(message, granularity).toContain('removed in @objectstack/spec 17.5.0'); expect(message, granularity).toContain('os migrate meta --from 17'); }, ); diff --git a/packages/spec/src/data/analytics.test.ts b/packages/spec/src/data/analytics.test.ts index 214b37950d5..487d24bb65c 100644 --- a/packages/spec/src/data/analytics.test.ts +++ b/packages/spec/src/data/analytics.test.ts @@ -80,7 +80,7 @@ describe('TimeUpdateInterval', () => { if (parsed.success) continue; const [issue] = parsed.error.issues; expect(issue.message, retired).toContain(`'${retired}'`); - expect(issue.message, retired).toContain('retired in protocol 18'); + expect(issue.message, retired).toContain('removed in @objectstack/spec 17.5.0'); expect(issue.message, retired).toContain('day, week, month, quarter, year'); expect(issue.message, retired).toContain('os migrate meta --from 17'); } diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 2343320739a..b7e1a037def 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -166,7 +166,7 @@ function timeUpdateIntervalRefusalMessage(input: unknown): string { const declared = DateGranularity.options.join(', '); if (typeof input === 'string' && (RETIRED_SUB_DAY_INTERVALS as readonly string[]).includes(input)) { return ( - `Time interval ${received} was retired in protocol 18 (ADR-0049 enforce-or-remove). ` + `Time interval ${received} was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove). ` + 'No backend ever bucketed it and none could advertise it: the canonical bucket-key ' + 'vocabulary and `supports.queryDateGranularity` both stop at ' + `${declared}, so the name resolved to a refusal or to one group per distinct timestamp. ` diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 9633362282b..e69b3bdaf0e 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -57,8 +57,10 @@ * least one derives an older major, so the derivation is exercised in both * directions on real entries, never vacuously. * - * The three older rows are entries a 17.x minor shipped. Each is `toMajor: 18` - * like the rest, so `toMajor` cannot be what tells them apart. + * The four older rows are entries a 17.x minor shipped. Each is `toMajor: 18` + * like the rest, so `toMajor` cannot be what tells them apart. One of them, + * the sub-day time interval, guards the opposite direction: a prescription for + * a shape a 17.x minor already refused must not name the next major. * * Out of reach by construction: a retirement with no D2 conversion carries no * `retiredAfter`, so nothing here can derive its release (a retired-key-only @@ -74,6 +76,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { ALL_CONVERSIONS } from '../conversions/registry'; +import { TimeUpdateInterval } from '../data/analytics.zod'; import { IndexSchema } from '../data/object.zod'; import { ManifestPermissionsSchema } from '../kernel/manifest.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; @@ -212,6 +215,9 @@ const JOIN: Readonly> = { 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')], 'object-kanban-quick-add-removed': [tombstone(ObjectKanbanPropsSchema, 'object-kanban', 'quickAdd')], + 'cube-sub-day-granularities-removed': [ + refusal('a sub-day TimeUpdateInterval member', () => TimeUpdateInterval.safeParse('minute')), + ], }; /** Every major a prescription names as a release: `@objectstack/spec N[.x.y]` or `protocol N`. */ From 1ee37155de1bd87611a7ff763c0874c9fe110768 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:11:12 +0000 Subject: [PATCH 06/11] docs(changeset): the pending email-template changeset quotes its prescriptions as they now read (deliberate correction) `.changeset/22876-email-template-category-is-system-retired.md` lines 29 and 31 quote the two `email_template` prescriptions, which now name @objectstack/spec 18; the quotes said 17. Nothing else in that file changes. This PR's own changeset records the sub-day time-interval correction too. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .changeset/22876-email-template-category-is-system-retired.md | 4 ++-- .changeset/23145-tombstone-removal-version.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/22876-email-template-category-is-system-retired.md b/.changeset/22876-email-template-category-is-system-retired.md index 06df26f98bb..ad063561b35 100644 --- a/.changeset/22876-email-template-category-is-system-retired.md +++ b/.changeset/22876-email-template-category-is-system-retired.md @@ -26,9 +26,9 @@ Measured before removal: no reader of either key in `@objectstack/plugin-email`, **What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: -> `email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. +> `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. -> `email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. +> `email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. ## The retirement kit diff --git a/.changeset/23145-tombstone-removal-version.md b/.changeset/23145-tombstone-removal-version.md index 699e272158f..ab4d8396d3d 100644 --- a/.changeset/23145-tombstone-removal-version.md +++ b/.changeset/23145-tombstone-removal-version.md @@ -17,4 +17,6 @@ Clause-②: no | `public` in `StorageScope` | removed in @objectstack/spec 17.8.0 | removed in @objectstack/spec 18 | | `MetadataTypeRegistryEntry.allowOrgOverride` → `allowEnvironmentOverlay` | renamed (no release named) | renamed in @objectstack/spec 18 | +One prescription was wrong the other way. 17.4.0 accepts the sub-day `TimeUpdateInterval` members `second`, `minute` and `hour`, and 17.5.0 refuses them, but the refusal said "retired in protocol 18". It now says "removed in @objectstack/spec 17.5.0". + The `os migrate meta --from 17` sentence in each prescription is unchanged and was already right: `--from 17` replays the protocol-18 conversions. A prescription for a key that a 17.x release already refuses keeps naming 17. Text only: no key, value or shape is accepted or refused differently. From 90cfa7acec87ddf9daced1613a28da60babb8020 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:15:02 +0000 Subject: [PATCH 07/11] fix(spec): the sub-day interval docblock names the releases that declared and removed them `RETIRED_SUB_DAY_INTERVALS`' TSDoc, which ships in the `.d.ts`, said the enum declared the three names "until protocol 18". The 17.4.0 tarball is the last release declaring them, and 17.5.0 removed them. This PR's changeset also notes that the 18.0.0-next.0 release notes quoted the two email_template prescriptions with the earlier version. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .changeset/23145-tombstone-removal-version.md | 2 ++ packages/spec/src/data/analytics.zod.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/23145-tombstone-removal-version.md b/.changeset/23145-tombstone-removal-version.md index ab4d8396d3d..dfb4a36621c 100644 --- a/.changeset/23145-tombstone-removal-version.md +++ b/.changeset/23145-tombstone-removal-version.md @@ -17,6 +17,8 @@ Clause-②: no | `public` in `StorageScope` | removed in @objectstack/spec 17.8.0 | removed in @objectstack/spec 18 | | `MetadataTypeRegistryEntry.allowOrgOverride` → `allowEnvironmentOverlay` | renamed (no release named) | renamed in @objectstack/spec 18 | +The `18.0.0-next.0` release notes quoted the two `email_template` prescriptions with the earlier version, 17; from this release on they name 18. + One prescription was wrong the other way. 17.4.0 accepts the sub-day `TimeUpdateInterval` members `second`, `minute` and `hour`, and 17.5.0 refuses them, but the refusal said "retired in protocol 18". It now says "removed in @objectstack/spec 17.5.0". The `os migrate meta --from 17` sentence in each prescription is unchanged and was already right: `--from 17` replays the protocol-18 conversions. A prescription for a key that a 17.x release already refuses keeps naming 17. Text only: no key, value or shape is accepted or refused differently. diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index b7e1a037def..be86656c847 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -101,7 +101,8 @@ export const DimensionType = z.enum([ export type DimensionType = z.input; /** - * The three sub-day names this enum declared until protocol 18 (#17296). + * The three sub-day names this enum declared through @objectstack/spec 17.4.0; + * 17.5.0 removed them (#17296). * * They were never a capability any backend could offer, and not because the * backends lag the contract — the rest of the contract never declared them: From 56d63b4e79e18dbb7311d97dda88ab2c54b906e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:34:29 +0000 Subject: [PATCH 08/11] docs(changeset): name 17.7.0 the newest 17.x release and 18.0.0-next.0 the first that refuses these shapes `@objectstack/spec@18.0.0-next.0` was published after this changeset was written, so "17.7.0, the newest published release" stopped being true where the sentence is read. Every shape in the table is refused by the published 18.0.0-next.0 tarball and accepted by 17.7.0. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .changeset/23145-tombstone-removal-version.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/23145-tombstone-removal-version.md b/.changeset/23145-tombstone-removal-version.md index dfb4a36621c..9fd610c4c3e 100644 --- a/.changeset/23145-tombstone-removal-version.md +++ b/.changeset/23145-tombstone-removal-version.md @@ -6,7 +6,7 @@ Retirement prescriptions for shapes `@objectstack/spec` 17.7.0 still accepts now Clause-②: no -17.7.0, the newest published release, still accepts every shape below. No 17.x release removes them: the 18 line does. Their refusals, and their `[REMOVED]` JSON Schema descriptions, named a different release: +17.7.0, the newest 17.x release (npm `latest`), still accepts every shape below, and `18.0.0-next.0` is the first release that refuses them. No 17.x release removes them: the 18 line does. Their refusals, and their `[REMOVED]` JSON Schema descriptions, named a different release: | Surface | Was | Now | | --- | --- | --- | From d0b4cfd1a5983d0e80294ba59ab71cff1612fc6e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:17:59 +0000 Subject: [PATCH 09/11] docs(spec): regenerate the component references from the merged tree `content/docs/references/ui/component.mdx` merged without a text merge (both sides changed it); regenerated with gen:schema + gen:docs from the merged source. It differs from origin/main by exactly this branch's eleven corrected rows. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 85 +++++++++++------------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 4e802d7e8d2..23be42dbce1 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -31,7 +31,7 @@ const result = ActionButtonPropsSchema.parse(data); | **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone | | **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map | | **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | -| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type`: on a page component `type` is the component itself | +| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary, plus `primary`, which renders as `default` (renderer default: `default`) | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Button size — the Button primitive's vocabulary, plus `md`, which renders as `default` (renderer default: `default`) | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the button is not rendered when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | @@ -54,9 +54,16 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | +### Nested Shape: `ActionButtonProps.onSuccess` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | + --- @@ -82,7 +89,7 @@ const result = ActionButtonPropsSchema.parse(data); | **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | -| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | +| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | @@ -103,7 +110,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Inline button size — the Button primitive's vocabulary, plus `md` (drawn as `default`), falling back to the group's `size`. A dropdown item reads no size | @@ -119,7 +126,7 @@ const result = ActionButtonPropsSchema.parse(data); | **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button` | | **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) | -| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type` | +| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type` | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary, plus `primary`, which renders as `default` (renderer default: `ghost`) | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is FALSE. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled | @@ -139,9 +146,16 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | +### Nested Shape: `ActionIconProps.onSuccess` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | + --- @@ -165,7 +179,7 @@ const result = ActionButtonPropsSchema.parse(data); | **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | -| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | +| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | @@ -186,7 +200,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | @@ -661,7 +675,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this gantt binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | @@ -685,14 +699,6 @@ Sort field and direction pair | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectGanttProps.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectGanttProps.data[provider='value']` | Property | Type | Required | Description | @@ -752,7 +758,7 @@ Sort field and direction pair | **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | | **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | | **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | -| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | | **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | | **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | | **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | @@ -827,7 +833,7 @@ Sort field and direction pair | **showColumnTypeIcons** | `boolean` | optional | Show field-type icons in column headers | | **exportOptions** | `{ formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export config — the object `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, the same block a list view's `exportOptions` declares, with `formats` drawn from `csv`, `xlsx` and `json`. A bare format array is refused: the grid reads `exportOptions.formats`, so write `{ formats: ['csv', 'xlsx'] }` | | **operations** | `{ create?: boolean; update?: boolean; delete?: boolean; export?: boolean }` | optional | Built-in affordance toggles `{ create?, update?, delete?, export? }` (booleans). A declared block replaces the grid's default: `update` / `delete` not named are withheld from the row menu, `create` enables the add-row affordance, and `export: false` hides the export menu | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | | **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` | ### Nested Shape: `ObjectGridProps.emptyState` @@ -966,14 +972,6 @@ Sort field and direction pair | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectGridProps.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectGridProps.data[provider='value']` | Property | Type | Required | Description | @@ -1069,7 +1067,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this map binds to — the THIRD record source `getDataConfig` resolves, after `data` and `staticData`. Optional because the component-level `dataSource` binding can supply the object instead | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `getDataConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `getDataConfig`, wrapped into a `{ provider: 'value' }` config | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | @@ -1085,14 +1083,6 @@ View filter rule | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectMapProps.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectMapProps.data[provider='value']` | Property | Type | Required | Description | @@ -1377,7 +1367,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this tree binds to — the THIRD record source `resolveRecordSourceConfig` resolves, after `data` and `staticData`. Optional because either of the first two rungs resolves the source without it: `data` can name the object itself (`{ provider: 'object', object }`) and `staticData` needs none. ⚠️ NOT supplied by the component-level `dataSource` binding the sibling blocks name — this renderer registers no such gate | -| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | +| **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| value \| schema), read FIRST by `resolveRecordSourceConfig`. Static inline rows live at `{ provider: 'value', items: [...] }` or at `staticData`; the bare-array shortcut is refused | | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted | @@ -1390,14 +1380,6 @@ Sort field and direction pair | **provider** | `'object'` | ✅ | | | **object** | `string` | ✅ | Target object name | -### Nested Shape: `ObjectTreeProps.data[provider='api']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | - ### Nested Shape: `ObjectTreeProps.data[provider='value']` | Property | Type | Required | Description | @@ -1561,7 +1543,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **label** | `string \| Record` | ✅ | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **icon** | `string` | optional | Lucide icon name rendered in the tab trigger, left of the label. Read on this component — the renderer draws it via `LazyIcon`; contrast the item `key` beside it, which no read point takes and which the alias table answers with `value`. | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface's own evaluator binds `data` to the record ROW (not the data-source adapter) and also spreads the row's bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface's own evaluator binds `data` to the record ROW (the component-node evaluator mounts no `data` root) and also spreads the row's bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key. | | **value** | `string` | optional | Stable `?tab=` URL token for this tab (default: index-derived `tab-`, which is not durable across item-list changes) | | **count** | `integer` | optional | Badge count shown next to the tab label (default: derived from `record:related_list` descendants) | | **children** | `any[]` | ✅ | Child components | @@ -1956,6 +1938,7 @@ Sort field and direction pair | **objectName** | `string` | ✅ | Related object name (e.g., "task", "opportunity") | | **relationshipField** | `string` | ✅ | Field on related object that points to this record (e.g., "account_id") | | **relationshipValueField** | `string` | optional (default: `"id"`) | Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions). | +| **parentDataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | [PLANNED — not yet rendered] The PARENT record this related list hangs off, bound by a query of its own: an object binding `{ object, view?, filter?, sort?, limit? }`, the shape `dataSource` takes. Read by the single-record rule: the FIRST row of the bound set by its `sort` is the parent (declare `limit: 1` to say one row is meant), and when no row answers, the block renders a loud absence, never an empty list, which would read as "this record has no related records". `relationshipValueField` is read on that row. When this key is absent, the record page's record context remains the parent. The node-level `dataSource` on this block keeps its published meaning, the CHILD rows the list draws (it fills `objectName`, `columns`, `filter`, `sort` and `limit`), and is never read as the parent. | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Fields to display in the related list — either plain field-name strings, or the same per-column entries a saved list view declares (`ListColumn`: `field`, plus `label`, `width`, `align`, `hidden`, `sortable`, `summary`, …). A view-supplied list may arrive in the `ListColumn` spelling: objectui composes a saved view's `columns` onto this block verbatim, and this key declares the SAME union as `listViews[].columns`. One spelling per list — the two arms are exclusive, so an array mixing strings and column objects is refused. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns (field-name strings only) → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | @@ -1969,6 +1952,16 @@ Sort field and direction pair | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +### Nested Shape: `RecordRelatedListProps.parentDataSource` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Object to query | +| **view** | `string` | optional | Named view to apply | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | +| **limit** | `integer` | optional | Max records to display | + ### Nested Shape: `RecordRelatedListProps.columns[number]` | Property | Type | Required | Description | From 6e4a1485cc35054c4178ff8d4ad17c42d6ce6b07 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:27:37 +0000 Subject: [PATCH 10/11] fix(spec): three more prescriptions for shapes 17.7.0 accepts name 18; the pin joins two new retirements Two retirements reached main with `retiredAfter: '17.7.0'` (`page-action-row-navigation-executor-to-url`, `cbp-master-detail-required-defaulted`), and the derived removal-version pin refused them as unjoined. They are joined now. Their prescriptions named no release: the page action rows' `'navigation'` refusal now says the alias is "refused on page action rows since @objectstack/spec 18", and the controlled-by-parent refusal says the one-required-master rule holds "since @objectstack/spec 18". `view.data.provider: 'api'` (D3-only, outside the pin) said "removed in @objectstack/spec 17" for a shape 17.7.0 accepts; it now says 18. 17.7.0 and 18.0.0-next.0 both accept all three shapes. The literal pins follow; `view.data.provider: 'api'` and `StorageScope.public` gain a one-line release pin. The pending changesets 23082 and 23103 quote the corrected text as it now reads (deliberate correction). Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../23082-view-data-api-provider-retired.md | 2 +- ...page-action-row-on-success-and-executor.md | 2 +- ...ate-component-props-row-on-success.test.ts | 2 +- packages/spec/src/data/object.zod.ts | 5 ++- .../retired-key-removal-version.pin.test.ts | 38 ++++++++++++++++--- .../spec/src/system/object-storage.test.ts | 1 + ...ent-action-row-on-success-executor.test.ts | 2 +- packages/spec/src/ui/component.zod.ts | 2 +- .../view-data-api-provider-retirement.test.ts | 2 +- packages/spec/src/ui/view.test.ts | 1 + packages/spec/src/ui/view.zod.ts | 2 +- 11 files changed, 45 insertions(+), 14 deletions(-) diff --git a/.changeset/23082-view-data-api-provider-retired.md b/.changeset/23082-view-data-api-provider-retired.md index 577a50fed2b..2f7dbaba967 100644 --- a/.changeset/23082-view-data-api-provider-retired.md +++ b/.changeset/23082-view-data-api-provider-retired.md @@ -22,7 +22,7 @@ Measured before removal: no producer wrote `provider: 'api'` on a view outside d **What an author who still writes it sees.** `tsc` fails at the authoring site (`ViewData` no longer has the arm), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `data.provider` with the prescription: -> `view.data.provider: 'api'` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch. +> `view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch. ## The retirement kit diff --git a/.changeset/23103-page-action-row-on-success-and-executor.md b/.changeset/23103-page-action-row-on-success-and-executor.md index e965ac35926..5a0fd28f215 100644 --- a/.changeset/23103-page-action-row-on-success-and-executor.md +++ b/.changeset/23103-page-action-row-on-success-and-executor.md @@ -33,6 +33,6 @@ A page's `properties` bag is outside the stack parse. `os validate` judges it th **The one-line fix: give every page action row's `onSuccess` the action's block, `{ navigate, openIn? }`, with a `navigate` that is an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — and spell the url executor `'url'`.** The rename from `'navigation'` is lossless on a row: the runner sends both spellings to one navigator, and the alias's own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. The executor rename is mechanical: `os migrate meta --from 17 --write` performs it through the ADR-0087 D2 conversion `page-action-row-navigation-executor-to-url`, which is retired from the load path, so an author meets the refusal while stored rows and migrated sources are rewritten. It lists the D3 entries `ui-page-action-row-navigation-executor-refused` (which names that conversion) and `ui-page-action-row-on-success-shape-closed`. An `onSuccess` rewrite stays by hand, because which address a refused `navigate` meant is the author's call. -**What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way. +**What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way. **Measured producers: none refused.** No page action row in this repository or in objectui (at its pin `1fb1ced3`) writes an `onSuccess` outside the block or the executor `'navigation'`. objectui's row tests write `{ navigate: '/app/records/${result.id}' }`, admitted, and every `type: 'navigation'` there is an `element:button` inline action or a runner test, not a page action row. Host-registered executor names are kept: this package's own released guidance prescribes `actionType: 'navigate_edit'` on an `action:button`. diff --git a/packages/lint/src/validate-component-props-row-on-success.test.ts b/packages/lint/src/validate-component-props-row-on-success.test.ts index b0e3547925f..1d479116114 100644 --- a/packages/lint/src/validate-component-props-row-on-success.test.ts +++ b/packages/lint/src/validate-component-props-row-on-success.test.ts @@ -67,7 +67,7 @@ describe("the props gate refuses `'navigation'` at a page action row's executor const findings = validateComponentProps(stackWith(node({ [key]: 'navigation', target: '/apps/crm/tasks' }))); expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.${key}`]]); expect(findings[0].message).toContain( - `${subject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling.`, + `${subject} is the console runner's local alias of \`'url'\`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling.`, ); }); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 0a2b751a751..6347359316f 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2019,8 +2019,9 @@ function refuseControlledByParentWithoutOneMaster( path: ['sharingModel'], message: `Object '${name}' declares sharingModel 'controlled_by_parent' but no required \`master_detail\` field. ` - + 'ADR-0055 §1: a controlled-by-parent object derives its master from exactly one `master_detail` field ' - + 'declared `required: true`, and from nothing else, so as declared the platform derives no master ' + + 'ADR-0055 §1: since @objectstack/spec 18, a controlled-by-parent object derives its master from exactly ' + + 'one `master_detail` field declared `required: true`, and from nothing else, so as declared the ' + + 'platform derives no master ' + '(every read is denied, every write refused). ' + (found.length > 0 ? `${found.join('; ')}. ` : '') + 'If the rows must outlive their parent, this object is not a detail: give it its own sharingModel ' diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index e69b3bdaf0e..a29e4a763db 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -77,11 +77,15 @@ import { describe, expect, it } from 'vitest'; import { ALL_CONVERSIONS } from '../conversions/registry'; import { TimeUpdateInterval } from '../data/analytics.zod'; -import { IndexSchema } from '../data/object.zod'; +import { IndexSchema, ObjectSchema } from '../data/object.zod'; import { ManifestPermissionsSchema } from '../kernel/manifest.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; import { + ActionButtonPropsSchema, + ActionGroupPropsSchema, + ActionIconPropsSchema, + ActionMenuPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementRepeaterPropsSchema, @@ -165,7 +169,7 @@ interface Prescription { type Renderer = () => Prescription[]; -type Parse = { success: boolean; error?: { issues: { message: string }[] } }; +type Parse = { success: boolean; error?: { issues: { message: string; path?: readonly PropertyKey[] }[] } }; type Shaped = { shape: Record Parse; description?: string }> }; /** A `retiredKey()` tombstone: its parse refusal and its `[REMOVED]` description. */ @@ -182,11 +186,17 @@ const tombstone = (schema: unknown, surface: string, key: string): Renderer => ( ]; }; -/** The refusal a surface answers for a retired value or union arm. */ -const refusal = (where: string, parse: () => Parse): Renderer => () => { +/** + * The refusal a surface answers for a retired value or union arm. `at` keeps + * only the issues at that first path segment, for a surface whose parse can + * report other, unrelated issues. + */ +const refusal = (where: string, parse: () => Parse, at?: PropertyKey): Renderer => () => { const refused = parse(); expect(refused.success, `${where} must be refused`).toBe(false); - return refused.error!.issues.map((issue) => ({ where, text: issue.message })); + const issues = refused.error!.issues.filter((issue) => at === undefined || issue.path?.[0] === at); + expect(issues.length, `${where}: no issue at ${String(at)}`).toBeGreaterThan(0); + return issues.map((issue) => ({ where, text: issue.message })); }; const ELEMENTS: readonly (readonly [unknown, string, readonly string[]])[] = [ @@ -211,6 +221,24 @@ const JOIN: Readonly> = { 'declared-index-unique-scope': [ refusal('indexes[].unique: true', () => IndexSchema.safeParse({ fields: ['code'], unique: true })), ], + 'page-action-row-navigation-executor-to-url': [ + refusal("action:button actionType 'navigation'", () => ActionButtonPropsSchema.safeParse({ label: 'Clone', actionType: 'navigation', target: '/apps/crm/tasks' })), + refusal("action:icon actionType 'navigation'", () => ActionIconPropsSchema.safeParse({ icon: 'copy', label: 'Clone', actionType: 'navigation', target: '/apps/crm/tasks' })), + refusal("action:group member type 'navigation'", () => ActionGroupPropsSchema.safeParse({ actions: [{ label: 'Clone', type: 'navigation', target: '/apps/crm/tasks' }] })), + refusal("action:menu member type 'navigation'", () => ActionMenuPropsSchema.safeParse({ actions: [{ label: 'Clone', type: 'navigation', target: '/apps/crm/tasks' }] })), + ], + 'cbp-master-detail-required-defaulted': [ + refusal( + 'a controlled_by_parent master_detail without `required`', + () => ObjectSchema.safeParse({ + name: 'cbp_line', + label: 'Line', + sharingModel: 'controlled_by_parent', + fields: { order: { type: 'master_detail', label: 'Order', reference: 'cbp_order' } }, + }), + 'sharingModel', + ), + ], // Shipped by a 17.x minor; `toMajor: 18` like the rows above. 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')], diff --git a/packages/spec/src/system/object-storage.test.ts b/packages/spec/src/system/object-storage.test.ts index e6aef1edce8..e92f40f5123 100644 --- a/packages/spec/src/system/object-storage.test.ts +++ b/packages/spec/src/system/object-storage.test.ts @@ -57,6 +57,7 @@ describe('StorageScopeSchema', () => { expect(result.success).toBe(false); const message = result.error!.issues[0]!.message; expect(message).toMatch(/`public` was removed from `StorageScope`.*ADR-0049/s); + expect(message).toContain('in @objectstack/spec 18 (ADR-0049'); expect(message).toContain("acl: 'public_read'"); expect(message).toContain('ADR-0104'); }); diff --git a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts index e2084ef0129..dd572cd52fa 100644 --- a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts +++ b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts @@ -185,7 +185,7 @@ describe("a page action row's executor key refuses `'navigation'`, the runner's const issues = issuesOf(row.parse({ [row.executorKey]: 'navigation', target: '/apps/crm/tasks' })); expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, row.executorKey]]]); expect(firstSentence(issues[0].message)).toBe( - `${row.navigationSubject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling.`, + `${row.navigationSubject} is the console runner's local alias of \`'url'\`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling.`, ); expect(issues[0].message).toContain(`Write \`${row.executorKey}: 'url'\``); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 6313e639c19..fb3ad73747a 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -4088,7 +4088,7 @@ function refuseRowUrlExecutorAlias(executorKey: 'actionType' | 'type', subject: code: 'custom', path: [executorKey], message: - `${subject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling. ` + `${subject} is the console runner's local alias of \`'url'\`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling. ` + `Write \`${executorKey}: 'url'\`: on a page action row the alias runs the same navigator on the same ` + '`target` and `openIn`, so nothing else changes, and a `\'url\'` row\'s `target` is judged by the url ' + 'scheme set.', diff --git a/packages/spec/src/ui/view-data-api-provider-retirement.test.ts b/packages/spec/src/ui/view-data-api-provider-retirement.test.ts index 5dd253265be..943bf800d58 100644 --- a/packages/spec/src/ui/view-data-api-provider-retirement.test.ts +++ b/packages/spec/src/ui/view-data-api-provider-retirement.test.ts @@ -56,7 +56,7 @@ import { FormViewSchema, ListViewSchema, ViewDataSchema, ViewSchema } from './vi // The bare major, the house form of every prescription added since the v18 // line opened (Changesets pre mode: the first release carrying this removal // is `18.0.0-next.N`, so a minted minor would name a version that never ships). -const PRESCRIPTION = /`view\.data\.provider: 'api'` was removed in @objectstack\/spec 17 \(ADR-0049/; +const PRESCRIPTION = /`view\.data\.provider: 'api'` was removed in @objectstack\/spec 18 \(ADR-0049/; /** The spelling that used to be legal, in its fullest form. */ const RETIRED_SOURCE = { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index d91bfa369ae..07909fa7455 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -128,6 +128,7 @@ describe('ViewDataSchema', () => { const issue = result.error!.issues.find((i) => i.path.join('.') === 'provider'); expect(issue, JSON.stringify(result.error!.issues)).toBeDefined(); expect(issue!.message).toMatch(/`view\.data\.provider: 'api'` was removed/); + expect(issue!.message).toContain('was removed in @objectstack/spec 18 ('); }); it('should accept value provider with static items', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 31f279c275e..1c8a4fbbb9a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -190,7 +190,7 @@ export type { HttpMethodSubset } from '../shared/http.zod'; * `view-data-api-provider-retired` carries the judgement an upgrader owes. */ const VIEW_DATA_API_PROVIDER_RETIRED = - "`view.data.provider: 'api'` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — " + "`view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — " + 'a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call ' + 'written there was stored with the view, served to every client that read it and sent by the ' + "console as written; a metadata-declared API call now has one door, the page element's binding, " From 38728b3e1b37ed00bed9da54be08198fd7fe1bd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:33:09 +0000 Subject: [PATCH 11/11] docs(changeset): name 18.0.0-next.0 only for the shapes it refuses; a second table for the three it still accepts `view.data.provider: 'api'`, the page action rows' `'navigation'` executor and the controlled-by-parent one-required-master rule are accepted by both 17.7.0 and the published 18.0.0-next.0, so the first release that refuses them is the one after next.0. They get their own table, and the first table's sentence names next.0 for its own rows only. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .changeset/23145-tombstone-removal-version.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.changeset/23145-tombstone-removal-version.md b/.changeset/23145-tombstone-removal-version.md index 9fd610c4c3e..24f85895fb3 100644 --- a/.changeset/23145-tombstone-removal-version.md +++ b/.changeset/23145-tombstone-removal-version.md @@ -6,7 +6,7 @@ Retirement prescriptions for shapes `@objectstack/spec` 17.7.0 still accepts now Clause-②: no -17.7.0, the newest 17.x release (npm `latest`), still accepts every shape below, and `18.0.0-next.0` is the first release that refuses them. No 17.x release removes them: the 18 line does. Their refusals, and their `[REMOVED]` JSON Schema descriptions, named a different release: +17.7.0, the newest 17.x release (npm `latest`), still accepts every shape in the two tables below. No 17.x release removes them: the 18 line does. `18.0.0-next.0` is the first release that refuses the shapes in this first table. Their refusals, and their `[REMOVED]` JSON Schema descriptions, named a different release: | Surface | Was | Now | | --- | --- | --- | @@ -19,6 +19,14 @@ Clause-②: no The `18.0.0-next.0` release notes quoted the two `email_template` prescriptions with the earlier version, 17; from this release on they name 18. +`18.0.0-next.0` still accepts the shapes in this second table, so the first release that refuses them is the one after it. Their refusals named 17, or no release at all: + +| Surface | Was | Now | +| --- | --- | --- | +| `view.data.provider: 'api'` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | +| `'navigation'` as a page action row's executor (`actionType` on `action:button` / `action:icon`, member `type` on `action:group` / `action:menu`) | no release named | refused on page action rows since @objectstack/spec 18 | +| a `controlled_by_parent` object with no `master_detail` declared `required: true` | no release named | the one-required-master rule holds since @objectstack/spec 18 | + One prescription was wrong the other way. 17.4.0 accepts the sub-day `TimeUpdateInterval` members `second`, `minute` and `hour`, and 17.5.0 refuses them, but the refusal said "retired in protocol 18". It now says "removed in @objectstack/spec 17.5.0". The `os migrate meta --from 17` sentence in each prescription is unchanged and was already right: `--from 17` replays the protocol-18 conversions. A prescription for a key that a 17.x release already refuses keeps naming 17. Text only: no key, value or shape is accepted or refused differently.