From 0c5dbbff2c7ae9601fe6c9fe38df027c2618e179 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:58:15 +0000 Subject: [PATCH 1/9] =?UTF-8?q?spec:=20retire=20action.aria=20=E2=80=94=20?= =?UTF-8?q?no=20action=20surface=20ever=20applied=20it=20(WIP:=20sources,?= =?UTF-8?q?=20ledger,=20tests,=20changeset)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .changeset/20323-action-aria-removed.md | 44 +++++++++ content/docs/protocol/objectui/actions.mdx | 8 +- .../protocol/objectui/widget-contract.mdx | 7 +- packages/spec/liveness/action.json | 5 +- .../conversions/action-aria-removed.test.ts | 98 +++++++++++++++++++ packages/spec/src/conversions/registry.ts | 88 +++++++++++++++++ .../retired-keys/18.ui__Action__aria.ts | 24 +++++ .../semantic/18.action-aria-retired.ts | 33 +++++++ packages/spec/src/migrations/registry.ts | 17 +++- packages/spec/src/ui/action.test.ts | 26 ++++- packages/spec/src/ui/action.zod.ts | 43 +++++++- .../src/ui/aria-carrier-tombstones.test.ts | 75 ++++++++++++++ 12 files changed, 453 insertions(+), 15 deletions(-) create mode 100644 .changeset/20323-action-aria-removed.md create mode 100644 packages/spec/src/conversions/action-aria-removed.test.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__Action__aria.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.action-aria-retired.ts diff --git a/.changeset/20323-action-aria-removed.md b/.changeset/20323-action-aria-removed.md new file mode 100644 index 00000000000..529b304a514 --- /dev/null +++ b/.changeset/20323-action-aria-removed.md @@ -0,0 +1,44 @@ +--- +'@objectstack/spec': minor +--- + +**BREAKING** — remove `aria` from the action (`ActionSchema`), the ARIA block no action surface ever applied. + +Clause-②: yes + +`ActionSchema` declared a per-action ARIA block, and the liveness ledger graded it `live` on an uncited note — 「PARTIAL — honored by a few objectui renderers, not the core action buttons/menus」 — with no reader behind it. Re-measured at this checkout's own `.objectui-sha` pin `f8a9d0fb05`: none of the surfaces that render an action reads an action's `aria` — not `action:button`, `action:icon`, `action:menu`, `action:group` or `action:bar`, not the grid's row and bulk action menus, not `record:quick_actions`, not the declared-actions bar. The only `schema.aria` readers there are the placing nodes' own blocks (the `record:*` page components, the list view, `element:button`'s props), none of which looks inside an action. So an author — or an AI — who filled in `aria` got no accessible name on the rendered button, and nothing said so. + +It is the fourth member of the `aria` family retired for exactly this, after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's `aria`. + +**Removed rather than enforced** (ADR-0049 enforce-or-remove; the triage direction on the card, following the chart config retirement `2bf6ef18d`). The capability is already delivered under another key. Every one of those surfaces derives the accessible name from the action's **required** `label` — the visible button or menu-item text, and the `aria-label` of the icon-only `action:icon` and of the overflow-menu trigger — and the node that places the actions carries the node-level `ariaLabel` / `ariaDescribedBy` / `role`. The reversal condition the triage named (an icon-only action rendered with no accessible name at all) was measured and does not hold on any of them. A per-action block would be a second spelling of both, behind a precedence rule nobody has written. + +## FROM → TO + +| you wrote (17.4 and earlier) | write instead | +| --- | --- | +| `aria: { ariaLabel: 'Escalate this case' }` on an action, top-level or under `objects[].actions[]` | the name in the action's `label` — it is what every action renderer announces | +| `aria: { ariaDescribedBy: … }` / `aria: { role: … }` on an action | delete it; to describe or role the toolbar or list the actions sit in, put it in the `aria` block of the node that places them — `page.components[].aria` or the list view `aria` | +| `ariaLabel` / `ariaDescribedBy` / `role` on a page, page component or list view | unchanged — the shared `AriaProps` block stays live there | + +**The one-line fix:** delete `aria` from the action; put the accessible name in its `label`. + +`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + +## The retirement kit + +- **A `retiredKey()` tombstone, not a bare deletion** — even though `ActionSchema` is a `strictObject`. A bare delete would still be loud, but only as a generic unrecognized-key report that cannot carry the prescription; the tombstone types the key `never` for `tsc` and raises the upgrade text at parse. The key therefore stays in the walked shape: its liveness row stays (regraded `live` → `dead` with a `REMOVED` note that records the uncited 「PARTIAL」 claim it replaces) and the authorable-surface baseline marks `ui/Action:aria` `[RETIRED]`. +- **The D2 conversion `action-aria-removed`** (protocol 18, retired from the load path) strips the key from stack `actions[]` and from `objects[].actions[]` as a pure lossless delete — it never had an effect to lose. Its D3 record is the semantic entry `action-aria-retired`: its own family, not a member of the chart config's. +- **`AriaPropsSchema` is untouched** — a key retirement, not a def retirement; it stays live on pages, page components, the list view and the element props. +- **No form input and no locale bundle move.** The key never reached `action.form.ts`. The Studio action inspector's "More fields" section is derived from the served schema, where a tombstone node is dropped from the payload, so the served `aria` column goes with this release. + +## Reach, measured + +- This repository: **0** authors of `aria` on an action in `examples/**`, `packages/**` fixtures or the published skills (control: 15 `variant:` lines in `examples/**`). Two hand-written docs pages taught the key and are corrected here. +- HotCRM at `origin/main` `2f7b2326`: **0** on an action; its 6 `aria:` blocks are all page-level `page.aria`, which stays live (control: HotCRM authors actions — 5 action files declare `locations:`). +- Other out-of-repo authors: NOT MEASURED. + +## What an operator with a STORED action sees + +A `sys_metadata` `action` or `object` row written before this release can carry the key. Nothing breaks at read: the conversion replays on rehydration and strips it, so the row is served canonical and parses. `os migrate meta --stored --apply` rewrites the rows. + + diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 55581edf2a6..11dda54afd0 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -244,12 +244,14 @@ interface Action { // AI (ADR-0011) ai?: ActionAi; // Opt-in AI tool exposure - - // Misc - aria?: AriaProps; // Accessibility attributes } ``` +An action has no `aria` block. Its accessible name is its required `label`: the +visible button or menu-item text, and the `aria-label` of an icon-only action. +To name the toolbar or list the actions sit in, author `aria` on the node that +places them (`page.components[].aria` or the list view's `aria`). + Action `name` is the configuration ID and **must** be lowercase `snake_case` (`approve_request`, not `approveRequest` or `Approve Request`). JavaScript function names referenced by `body`/`target` may still use camelCase. diff --git a/content/docs/protocol/objectui/widget-contract.mdx b/content/docs/protocol/objectui/widget-contract.mdx index 669658a7bd3..42a600ea7f4 100644 --- a/content/docs/protocol/objectui/widget-contract.mdx +++ b/content/docs/protocol/objectui/widget-contract.mdx @@ -223,8 +223,11 @@ declare the registry. ## Accessibility `AriaProps` (`packages/spec/src/ui/i18n.zod.ts`) is the shared ARIA shape carried -by the live UI schemas — views, pages, page components, charts and actions all -declare an `aria:` block. The supported attributes are intentionally minimal: +by the live UI schemas — list views, pages and page components declare an `aria:` +block. A chart config and an action do not: both `aria` keys were retired because +no renderer applied them. A chart's accessible name is its `description`, an +action's is its `label`, and the region that places either is named by its own +`aria:` block. The supported attributes are intentionally minimal: {/* os:check */} ```typescript diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 756f2e7b0b5..b1a5cf1200f 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -217,8 +217,9 @@ } }, "aria": { - "status": "live", - "note": "PARTIAL — honored by a few objectui renderers, not the core action buttons/menus." + "status": "dead", + "verifiedAt": "2026-09-28", + "note": "REMOVED 2026-09-28 (#20323, ADR-0049 enforce-or-remove) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 conversion action-aria-removed. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); write the accessible name as the action's required `label`, and put a region name in the placing node's `aria` block (`page.components[].aria` or the list view `aria`). The `live` grade this row replaces rested on an uncited note — 「PARTIAL — honored by a few objectui renderers, not the core action buttons/menus」 — with no reader behind it: re-measured at the `.objectui-sha` pin f8a9d0fb05, none of the action surfaces (action:button/icon/menu/group/bar, the grid row and bulk action menus, record:quick_actions, the declared-actions bar) reads an action's `aria`; the only `schema.aria` readers there are the placing nodes' own blocks (the record:* page components, the list view, element:button's props)." }, "shortcut": { "status": "dead", diff --git a/packages/spec/src/conversions/action-aria-removed.test.ts b/packages/spec/src/conversions/action-aria-removed.test.ts new file mode 100644 index 00000000000..4fe67c84e8b --- /dev/null +++ b/packages/spec/src/conversions/action-aria-removed.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { ActionSchema } from '../ui/action.zod.js'; +import { applyConversions, collectConversionNotices } from './apply.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import type { ConversionNotice } from './types.js'; + +/** + * [#20323] `action-aria-removed` — the D2 half of the `action.aria` retirement. + * + * The fixture pair in `conversions.test.ts` already proves before → after over + * the whole table. What it cannot express, and what this file pins: + * + * - FIRING on both authored coordinates (`actions[]` and + * `objects[].actions[]`), each with its own notice path; + * - the CONTROL: an action that carries no `aria` comes back as the SAME + * reference — the strip touches nothing else, so the rendered behaviour + * (which never read the key) is preserved exactly; + * - IDEMPOTENCE: the converted result replays to itself with no second + * notice (`stripKeys` skips an absent key, by construction — asserted here + * rather than assumed); + * - that the strip lands where a consumer reads it: the result parses + * against `ActionSchema`, and the stored-row seam replays it (the entry is + * `retiredFromLoadPath`, so only data-at-rest seams apply it). + */ +describe('[#20323] action-aria-removed (ADR-0087 D2)', () => { + const ARIA = { ariaLabel: 'Escalate this case', role: 'button' } as const; + + it('is registered for protocol 18 and retired from the authoring load path', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === 'action-aria-removed'); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + }); + + it('fires on a stack action and on an object-nested action, one notice per site', () => { + const { stack, notices } = collectConversionNotices( + { + actions: [{ name: 'escalate_case', label: 'Escalate', type: 'script', aria: ARIA }], + objects: [{ + name: 'support_case', + label: 'Case', + actions: [{ name: 'reopen_case', label: 'Reopen', type: 'script', aria: { ariaDescribedBy: 'x' } }], + }], + }, + { includeRetired: true }, + ); + const mine = notices.filter((n) => n.conversionId === 'action-aria-removed'); + expect(mine.map((n) => n.path).sort()).toEqual([ + 'actions[0].aria', + 'objects[0].actions[0].aria', + ]); + for (const n of mine) { + expect(n.from).toBe('aria'); + expect(n.to).toBe('(removed)'); + } + const s = stack as { actions: Record[]; objects: { actions: Record[] }[] }; + expect(s.actions[0]).not.toHaveProperty('aria'); + expect(s.objects[0].actions[0]).not.toHaveProperty('aria'); + // Everything else on the action survives verbatim. + expect(s.actions[0]).toEqual({ name: 'escalate_case', label: 'Escalate', type: 'script' }); + }); + + it('control: an action without `aria` is returned as the same reference, with no notice', () => { + const clean = { + actions: [{ name: 'close_case', label: 'Close', type: 'script', icon: 'check' }], + objects: [{ name: 'support_case', label: 'Case', actions: [{ name: 'reopen_case', label: 'Reopen' }] }], + }; + const notices: ConversionNotice[] = []; + const out = applyConversions(clean, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(out, 'nothing to strip ⇒ copy-on-write returns the input').toBe(clean); + expect(notices.filter((n) => n.conversionId === 'action-aria-removed')).toEqual([]); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions( + { actions: [{ name: 'escalate_case', label: 'Escalate', type: 'script', aria: ARIA }] }, + { includeRetired: true }, + ); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); + + it('the stored-row seam replays it, and the result parses against `ActionSchema`', () => { + const stored = { name: 'escalate_case', label: 'Escalate', type: 'script', aria: ARIA }; + // Before: the retired key is refused at parse — the row a pre-retirement + // author left behind would be badged invalid without the replay. + expect(ActionSchema.safeParse(stored).success).toBe(false); + const converted = applyConversionsToStoredItem('action', stored); + expect(converted).not.toHaveProperty('aria'); + expect(ActionSchema.safeParse(converted).success).toBe(true); + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 922080b53ca..b01781a7155 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10116,6 +10116,93 @@ const chartConfigAriaRemoved: MetadataConversion = { }, }; +/** + * `action.aria` removed (ADR-0049 enforce-or-remove; triage record 5860351140 + * on #20323, following the `ChartConfig.aria` retirement `2bf6ef18d`). + * + * A pure lossless delete. Measured at the `.objectui-sha` pin `f8a9d0fb05`, no + * surface that renders an action reads the action's `aria` — every one of them + * derives the accessible name from the action's required `label` (visible + * text, or `aria-label` on the icon-only renderer and the overflow trigger), so + * the rendered DOM is byte-for-byte the same with or without the block. What + * the author meant by it is the paired D3 entry `action-aria-retired`'s + * business; this entry only removes the key. + * + * ⚠️ Coverage boundary — TWO authored sites, because `ActionSchema` is authored + * both as a stack collection and nested under its object (the walk + * `action-execute-to-target` established): + * + * - `actions[]` + * - `objects[].actions[]` + * + * Both are registered metadata kinds stored as `sys_metadata` rows, so the + * stored-row seams replay this entry too. A plugin's type-level + * `MetadataTypeRegistryEntry.actions` is code, not a stack source, and no + * walker reaches it; the tombstone refuses it at parse instead. + * + * A SEPARATE family rather than more coordinates on `chart-config-aria-removed`: + * that entry's identity is the chart config's measurement, and folding a + * differently-evidenced removal into it would misattribute this one in + * `spec-changes.json` and the upgrade guide — the reason that entry itself gave + * for not joining `dashboard-widget-action-aria-removed`. + */ +const actionAriaRemoved: MetadataConversion = { + id: 'action-aria-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'action.aria / object.actions[].aria', + summary: + "action key 'aria' removed (ADR-0049 enforce-or-remove — no action surface ever applied it; " + + "every renderer takes the accessible name from the action's required 'label', and the " + + "placing node's own 'aria' block names the region)", + apply(stack, emit) { + const strip = (action: Dict, path: string): Dict => stripKeys(action, ['aria'], emit, path); + const withTopLevel = mapCollection(stack, 'actions', strip); + return mapCollection(withTopLevel, 'objects', (obj, path) => + mapCollection(obj, 'actions', (action, actionPath) => strip(action, `${path}.${actionPath}`)), + ); + }, + fixture: { + before: { + actions: [ + { + name: 'escalate_case', + label: 'Escalate', + type: 'script', + icon: 'arrow-up', + aria: { ariaLabel: 'Escalate this case', role: 'button' }, + }, + // An action without the key passes through untouched. + { name: 'close_case', label: 'Close', type: 'script' }, + ], + objects: [{ + name: 'support_case', + label: 'Case', + actions: [{ + name: 'reopen_case', + label: 'Reopen', + type: 'script', + aria: { ariaDescribedBy: 'reopen_help' }, + }], + }], + }, + after: { + actions: [ + { name: 'escalate_case', label: 'Escalate', type: 'script', icon: 'arrow-up' }, + { name: 'close_case', label: 'Close', type: 'script' }, + ], + objects: [{ + name: 'support_case', + label: 'Case', + actions: [{ name: 'reopen_case', label: 'Reopen', type: 'script' }], + }], + }, + // One notice per stripped action — top-level and object-nested — and none + // for `close_case`. + expectedNotices: 2, + }, +}; + /** * `dashboard.widgets[].chartConfig` loses its four STRUCTURE keys (ADR-0021; * maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」). @@ -11446,6 +11533,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { }); }); -describe('Action ARIA Integration', () => { - it('should accept action with ARIA attributes', () => { - expect(() => ActionSchema.parse({ +// #20323 — inverted in place, not respelled: this block pinned exactly the +// acceptance the retirement removes, and there is no spelling of `aria` the +// action shape accepts any more. The prescription itself, and the carriers it +// names, are pinned with the rest of the family in `aria-carrier-tombstones.test.ts`. +describe('Action ARIA Integration (retired — #20323)', () => { + it('refuses an action carrying `aria`, with the tombstone prescription', () => { + const result = ActionSchema.safeParse({ name: 'accessible_action', label: 'Delete', target: 'noop', aria: { ariaLabel: 'Delete this record permanently', role: 'button' }, - })).not.toThrow(); + }); + expect(result.success, 'an authored `action.aria` must be refused').toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'aria'); + expect(issue, 'the refusal sits at the retired key').toBeDefined(); + expect(issue!.message).toMatch(/`action\.aria` was removed.*Delete the key\./s); + }); + + it('control: the same action without `aria` parses, and keeps its accessible name in `label`', () => { + const parsed = ActionSchema.parse({ + name: 'accessible_action', + label: 'Delete', + target: 'noop', + }); + expect(parsed.label).toBe('Delete'); + expect(parsed).not.toHaveProperty('aria'); }); }); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index e1c4193898f..002061f1e60 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -23,7 +23,7 @@ import { BulkActionExecutionSchema } from './bulk-action.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union'; -import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { I18nLabelSchema } from './i18n.zod'; import { HookBodySchema } from '../data/hook-body.zod'; // Imported file-directly (not via the kernel barrel): the module is // deliberately import-free, so this cannot introduce a cycle. @@ -1655,8 +1655,45 @@ const actionObject = () => strictObject({ }).default('self').describe("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."), }).optional().describe("Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response); `openIn` defaults 'self'. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior."), - /** ARIA accessibility attributes */ - aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), + // `aria` REMOVED (ADR-0049 enforce-or-remove; the triage record on the card, + // comment 5860351140 on #20323, follows the `ChartConfig.aria` retirement + // `2bf6ef18d`). The ledger graded this key `live` on an uncited "PARTIAL — + // honored by a few objectui renderers" note, and no reader stood behind it. + // Measured at this checkout's `.objectui-sha` pin `f8a9d0fb05`: none of the + // surfaces that render an action — `action:button`, `action:icon`, + // `action:menu`, `action:group`, `action:bar`, the grid's row and bulk + // action menus, `record:quick_actions`, the declared-actions bar — reads an + // action's `aria`. The only `schema.aria` readers there are the PLACING + // nodes' own blocks (the `record:*` page components, the list view, + // `element:button`'s props), which never look inside an action. + // + // Remove rather than enforce: every one of those surfaces already derives the + // accessible name from the action's REQUIRED `label` — as the visible button + // or menu-item text, or as `aria-label` on the icon-only `action:icon` and + // the overflow-menu trigger — and the region that places the actions carries + // the node-level `ariaLabel` / `ariaDescribedBy` / `role`. A per-action ARIA + // block would be a second spelling of both, needing a precedence rule nobody + // has written. One node, one accessibility vocabulary. + // + // `retiredKey()` rather than a bare deletion although this is a + // `strictObject`: a bare delete is loud only as a generic unrecognized-key + // report, which cannot carry the prescription (`aria-carrier-tombstones.test.ts` + // pins the family). `AriaPropsSchema` is untouched — a key retirement, not a + // def retirement. Sources are stripped by the D2 conversion + // `action-aria-removed`. + aria: retiredKey( + '`action.aria` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no action ' + + 'surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk ' + + 'action menus and the record quick-actions toolbar all take the accessible name from the ' + + "action's `label` and never read this block, so ARIA attributes declared here parsed and then " + + 'silently did not reach the DOM. Delete the key. The accessible name that IS applied is the ' + + "action's required `label` — the visible button or menu-item text, and the `aria-label` of an " + + 'icon-only action — so write the name you meant there. To name the region that PLACES the ' + + 'actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing ' + + 'node: `page.components[].aria` (the component that renders the actions) or the list view ' + + '`aria`. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), // ADR-0010 — runtime protection envelope (internal — set by the loader). // `action` is a registered metadata type, so `MetadataPlugin`'s loader stamps diff --git a/packages/spec/src/ui/aria-carrier-tombstones.test.ts b/packages/spec/src/ui/aria-carrier-tombstones.test.ts index a6308f932c5..6e5cff62985 100644 --- a/packages/spec/src/ui/aria-carrier-tombstones.test.ts +++ b/packages/spec/src/ui/aria-carrier-tombstones.test.ts @@ -7,6 +7,8 @@ import { ChartConfigSchema } from './chart.zod'; import { PageSchema, PageComponentSchema } from './page.zod'; import { ReportChartSchema } from './report.zod'; import { ListViewSchema } from './view.zod'; +import { ActionSchema } from './action.zod'; +import { ObjectSchema } from '../data/object.zod'; /** * The two `aria` tombstones must not point at each other (#6756). @@ -244,6 +246,79 @@ describe('the `aria` tombstones name only live `AriaProps` carriers (#6756)', () expect(message).toContain('`report.blocks[].chart.aria`'); }); + // ───────────────────────────────────────────────────────────────────────── + // The fourth member (#20323): `action.aria`. Graded `live` in + // `liveness/action.json` on an uncited "PARTIAL" note with no reader behind + // it — at the `.objectui-sha` pin `f8a9d0fb05` no surface that renders an + // action reads the action's `aria`. Joined here for the reason the chart + // member gave: every new `aria` tombstone is one more prescription that can + // go stale at its siblings. + // + // Its replacement channel differs from the chart's, and that difference is + // what is pinned: the accessible name that IS applied is the action's own + // required `label` (visible text, or `aria-label` on the icon-only renderer), + // and a REGION name belongs to the node that places the actions — the page + // component or the list view, both asserted live in the ground truth above. + // ───────────────────────────────────────────────────────────────────────── + + const actionWithAria = { name: 'escalate_case', label: 'Escalate', target: 'noop', aria: ARIA }; + + it('the action tombstone fires and prescribes the label and the placing node, never a retired carrier', () => { + const message = messageOf(ActionSchema.safeParse(actionWithAria)); + + // Anti-vacuity: `ActionSchema` is a `strictObject`, so a deleted tombstone + // still refuses the key — as a generic unrecognized-key rejection that + // cannot carry this opening clause. DISAPPEARANCE fails here. + expect( + message, + 'the action `aria` prescription must still be reachable through the parse', + ).toContain('`action.aria` was removed'); + + // The channel that IS applied on the action itself — naming it is the + // reason the key could be REMOVED rather than enforced. + expect(message, 'the prescription must name the action\'s own accessible name').toContain('`label`'); + + // The placing node's vocabulary, and the two live nodes that carry it. + for (const key of ['`ariaLabel`', '`ariaDescribedBy`', '`role`']) { + expect(message, `the prescription must name ${key}`).toContain(key); + } + for (const live of ['`page.components[].aria`', 'list view `aria`']) { + expect(message, `the prescription must name ${live}`).toContain(live); + } + + // ...and nothing retired, by exact spelling. + expect(message, 'must not point at `app.aria`').not.toMatch(/app\.aria/i); + expect(message, 'must not point at `dashboard.widgets[].aria`').not.toMatch(/dashboard\.widgets\[\]\.aria/i); + expect(message, 'must not point at the retired chart config block').not.toMatch(/chartConfig\.aria|ChartConfig\.aria/); + + // None of the above bought by weakening the prescription itself. + expect(message).toContain('Delete the key.'); + expect(message).toContain('os migrate meta --from 17'); + expect(message).toMatch(/to list the mechanical edits for existing sources; apply them by hand\.$/); + }); + + it('the action tombstone refuses on the object-nested coordinate too', () => { + // `ObjectSchema.actions` is `z.array(ActionSchema)`, so the same tombstone + // answers there — the second site the D2 conversion walks. + const result = ObjectSchema.safeParse({ name: 'support_case', label: 'Case', actions: [actionWithAria] }); + const message = messageOf(result); + expect(message).toContain('`action.aria` was removed'); + }); + + it('control: the same action without `aria` parses, and a sibling node\'s `aria` still does', () => { + const withoutAria = { name: 'escalate_case', label: 'Escalate', target: 'noop' }; + expect(ActionSchema.safeParse(withoutAria).success, 'an aria-less action').toBe(true); + // The sibling node the prescription sends the author to, with the SAME + // block — the retirement is a key retirement on one schema, never a + // withdrawal of the shared `AriaProps` shape. + expect(PageComponentSchema.safeParse({ + type: 'record:quick_actions', properties: {}, aria: ARIA, + }).success, '`page.components[].aria` on the node that places record actions').toBe(true); + expect(ListViewSchema.safeParse({ + type: 'grid', columns: ['name'], aria: { ariaLabel: 'Open cases', ariaDescribedBy: 'cases_help', role: 'region' }, + }).success, 'the list view `aria`, all three keys').toBe(true); + }); + it('the two former alias spellings refuse instead of renaming onto the tombstone', () => { // `accessibility` and `ariaProps` were `aliases` FOR `aria`. Left as // aliases they would answer "did you mean `aria`?" — the one key this shape From 6334c137d2f6e946ebb1d5b0fe38ac1fdbc3d020 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:07:46 +0000 Subject: [PATCH 2/9] spec: regenerate artifacts for the action.aria retirement Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- content/docs/references/data/object.mdx | 2 +- .../references/kernel/metadata-plugin.mdx | 2 +- content/docs/references/ui/action.mdx | 10 +--- packages/spec/authorable-surface/ui.json | 2 +- packages/spec/liveness/state-counts.md | 4 +- packages/spec/src/migrations/registry.ts | 51 +++++++++++++++++++ 6 files changed, 57 insertions(+), 14 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index dff789d10e8..11b79f648ff 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -478,7 +478,7 @@ const result = ApiMethod.parse(data); | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | -| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index fd593b3b26b..50c06c8e969 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -348,7 +348,7 @@ const result = MetadataBulkResultSchema.parse(data); | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | -| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index ab49551947c..ffe42cb57d4 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -67,7 +67,7 @@ const result = ActionSchema.parse(data); | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | -| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | @@ -148,14 +148,6 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **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}`). Relative = SPA route hop; 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. | -### Nested Shape: `Action.aria` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **ariaLabel** | `string \| Record` | optional | Accessible label for screen readers (WAI-ARIA aria-label). Plain string, or an inline locale map — no translation-bundle slot addresses this key, so a plain string is announced in the source language. | -| **ariaDescribedBy** | `string` | optional | ID of element providing additional description (WAI-ARIA aria-describedby) | -| **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | - --- diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 5228c0ba78d..60774929718 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -14,7 +14,7 @@ "ui/Action:_packageVersion", "ui/Action:_provenance", "ui/Action:ai", - "ui/Action:aria", + "ui/Action:aria [RETIRED]", "ui/Action:body", "ui/Action:bodyExtra", "ui/Action:bodyShape", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 1400db3e142..ae71503c350 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -30,7 +30,7 @@ for both corollaries. | `object` | 50 | 0 | 0 | 0 | 1 | 51 | | `field` | 91 | 0 | 0 | 1 | 1 | 93 | | `flow` | 34 | 0 | 0 | 6 | 0 | 40 | -| `action` | 46 | 0 | 0 | 3 | 0 | 49 | +| `action` | 45 | 0 | 0 | 4 | 0 | 49 | | `hook` | 19 | 0 | 0 | 3 | 0 | 22 | | `permission` | 36 | 0 | 0 | 6 | 0 | 42 | | `position` | 12 | 0 | 0 | 0 | 0 | 12 | @@ -67,4 +67,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 44 | 1 | 74 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **936** | **5** | **1** | **168** | **9** | **1119** | +| **total** | **935** | **5** | **1** | **169** | **9** | **1119** | diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8f08d861722..35ad56ef957 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5446,6 +5446,35 @@ const step18: MigrationStep = { // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #20323 — ADR-0049 enforce-or-remove (triage record 5860351140) — the D3 entry + // of the `action-aria-removed` family (ruling B on #17152: one D3 entry per + // retirement family, even when D2 is lossless). Its OWN family, not a member of + // `chart-config-aria-retired`: a different schema, a different measurement and + // a different replacement channel (the action's `label`, not a chart + // `description`). Registered key: `ui/Action:aria`, over two authored sites. + // The strip changes nothing a screen reader hears; the name the author wrote + // was never announced, and moving it is the author's edit. + { + id: 'action-aria-retired', + surface: 'action.aria / object.actions[].aria — the ARIA block on an action', + replacement: "The action's required `label`, which every action renderer uses as the accessible " + + 'name (the visible button or menu-item text, and the `aria-label` of an icon-only action). ' + + 'To name the region that places the actions, the `aria` block of the placing node — ' + + '`page.components[].aria` or the list view `aria`.', + reason: 'The D2 conversion `action-aria-removed` deletes `aria` from every stack action and every ' + + 'object-nested action, and the delete is lossless: no surface that renders an action ever ' + + 'read the block, so the ARIA attributes it declared never reached the DOM. The residue is ' + + 'accessibility work the author did that no user benefited from. An author who wrote ' + + '`aria.ariaLabel` believed screen-reader users heard that name; they heard the `label`. The ' + + 'strip deletes the text along with the key, and only the author can say whether it should ' + + 'become the `label` — which sighted users read too — or whether it described the toolbar ' + + 'or list the action sits in, and belongs in that node\'s `aria` block instead.', + acceptanceCriteria: 'No action, top-level or nested under an object, carries `aria`; the parse ' + + 'refuses it. Every action that had carried an `aria.ariaLabel` has a `label` conveying what ' + + 'that name was meant to announce, or the author has moved the text to the placing ' + + "component's or list view's `aria` block, or confirmed the existing label already says it. " + + 'With a screen reader, focusing an icon-only action announces its label.', + }, { id: 'action-bulk-dispatch-contract-undeclared', surface: '`action.execution` — the bulk dispatch contract an action’s body is written for', @@ -19484,6 +19513,28 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // parse) and the D3 semantic entry named below. // D3 semantic entry: `training-deadline-keys-retired`. 'system/TrainingPlan:reminderDaysBefore', + // #20323 — ADR-0049 enforce-or-remove (triage record 5860351140, following the + // `ChartConfig.aria` retirement `2bf6ef18d`). The liveness ledger graded this + // key `live` on an uncited "PARTIAL — honored by a few objectui renderers" + // note, and no reader stood behind it: measured at the `.objectui-sha` pin + // `f8a9d0fb05`, none of the surfaces that render an action (`action:button`, + // `action:icon`, `action:menu`, `action:group`, `action:bar`, the grid's row + // and bulk action menus, `record:quick_actions`, the declared-actions bar) + // reads an action's `aria`. Every one of them derives the accessible name from + // the action's REQUIRED `label` — visible text, or `aria-label` on the + // icon-only renderer and the overflow trigger — and the node that PLACES the + // actions carries the node-level `ariaLabel` / `ariaDescribedBy` / `role` + // (`page.components[].aria`, the list view `aria`). A per-action block was a + // second spelling of both. + // + // `retiredKey()` on a `strictObject`, for the prescription (the + // `aria-carrier-tombstones.test.ts` family). Sources are rewritten by the D2 + // conversion `action-aria-removed`; the D3 record is `action-aria-retired`. + // + // Registered under 18, not 17: the tombstone ships on the 17.x line + // (launch-window convention — accept-set narrowings ride minor releases) and + // the prescription lives at the major boundary where `migrate meta` users look. + 'ui/Action:aria', // #17751 — ADR-0049 enforce-or-remove (maintainer decision batch #118 item 2, // 2026-09-12: recommendation C, judge the protocol wrong for this one key). // The third and last member of the `aria` family retired on the same measured From bfc86375c0f6c157f38ebcd96d3471a9fe17cf7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:08:54 +0000 Subject: [PATCH 3/9] spec: the stored-row pin uses a parseable script action Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/conversions/action-aria-removed.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/conversions/action-aria-removed.test.ts b/packages/spec/src/conversions/action-aria-removed.test.ts index 4fe67c84e8b..84b5faaa73f 100644 --- a/packages/spec/src/conversions/action-aria-removed.test.ts +++ b/packages/spec/src/conversions/action-aria-removed.test.ts @@ -87,7 +87,7 @@ describe('[#20323] action-aria-removed (ADR-0087 D2)', () => { }); it('the stored-row seam replays it, and the result parses against `ActionSchema`', () => { - const stored = { name: 'escalate_case', label: 'Escalate', type: 'script', aria: ARIA }; + const stored = { name: 'escalate_case', label: 'Escalate', type: 'script', target: 'escalateCase', aria: ARIA }; // Before: the retired key is refused at parse — the row a pre-retirement // author left behind would be badged invalid without the replay. expect(ActionSchema.safeParse(stored).success).toBe(false); From 0f20c62fd97b5b5c82dfbdd2f46fbd15efaa214f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:13:54 +0000 Subject: [PATCH 4/9] spec: the action/aria undrilled-container row goes with the retirement check:liveness names it stale: the tombstone is no longer a container, so the recorded gap no longer exists. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../spec/scripts/liveness/undrilled-containers.baseline.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index d7ff9e15b8e..bdba239d1e5 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -58,7 +58,6 @@ ], "containers": [ "action/ai", - "action/aria", "action/params", "action/resultDialog", "agent/guardrails", From 90b8fcdcd29f5050b471e701a58c2e1b4d4fcd1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:24:39 +0000 Subject: [PATCH 5/9] spec: regenerate liveness state counts over the merged tree Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/liveness/state-counts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index ae71503c350..76efc093d52 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -62,9 +62,9 @@ for both corollaries. | `metadata_endpoints` | 7 | 0 | 0 | 2 | 0 | 9 | | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | -| `rest_api` | 12 | 0 | 0 | 14 | 0 | 26 | +| `rest_api` | 12 | 0 | 0 | 12 | 0 | 24 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 44 | 1 | 74 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **935** | **5** | **1** | **169** | **9** | **1119** | +| **total** | **935** | **5** | **1** | **167** | **9** | **1117** | From 2b15085d506bb72b9ae6e0c36ddc6beab96a7ee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:24:55 +0000 Subject: [PATCH 6/9] spec: regenerate the artifacts the origin/main merge deferred state-counts.md, and the three reference pages that render ActionSchema, regenerated from the merged tree (gen:migration-registry, gen:liveness-counts, gen:docs over a fresh spec build); check:generated reads all 15 up to date. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- content/docs/references/data/object.mdx | 2 +- content/docs/references/kernel/metadata-plugin.mdx | 2 +- content/docs/references/ui/action.mdx | 2 +- packages/spec/liveness/state-counts.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 11b79f648ff..d7c00807c76 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -1,6 +1,6 @@ --- title: Object -description: Object protocol schemas +description: "Object schemas of the ObjectStack Data Protocol: ApiMethod, ApiOperation, Index and 13 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 50c06c8e969..2a5837058c0 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -1,6 +1,6 @@ --- title: Metadata Plugin -description: Metadata Plugin protocol schemas +description: "Defines the specification for the Metadata Plugin — the central authority responsible for managing ALL metadata across the ObjectStack platform." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index ffe42cb57d4..c2e99d96d62 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -1,6 +1,6 @@ --- title: Action -description: Action protocol schemas +description: "Action schemas of the ObjectStack UI Protocol: Action, ActionAi, ActionLocation and 3 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 76efc093d52..211cf79d7a9 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -56,7 +56,7 @@ for both corollaries. | `validation` | 18 | 0 | 0 | 0 | 0 | 18 | | `api` | 25 | 0 | 0 | 1 | 2 | 28 | | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | -| `qa` | 4 | 0 | 0 | 5 | 0 | 9 | +| `qa` | 8 | 0 | 0 | 1 | 0 | 9 | | `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | | `crud_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | | `metadata_endpoints` | 7 | 0 | 0 | 2 | 0 | 9 | @@ -67,4 +67,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 44 | 1 | 74 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **935** | **5** | **1** | **167** | **9** | **1117** | +| **total** | **939** | **5** | **1** | **163** | **9** | **1117** | From 68b038ef7c85ed9431229c91467e7a6e09133690 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:29:27 +0000 Subject: [PATCH 7/9] spec: action.aria tombstone names 17.5.0; refusal pin asserts the never issue; object-row replay pinned - The prescription names the release that ships the refusal (17.5.0), the house spelling of every sibling retirement on this line. - action.test.ts: the refusal pin asserts the tombstone's own issue kind (invalid_type at `aria`, and no root unrecognized_keys), which is what a bare deletion would answer instead. - action-aria-removed.test.ts: the stored-row seam reaches an `object` row's nested action, the changeset's second at-rest coordinate. - The changeset's BREAKING sentence names the replacement; its HotCRM control is re-measured at 2f7b2326. - liveness/README.md: the action row's "dead set three" becomes four. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .changeset/20323-action-aria-removed.md | 4 ++-- packages/spec/liveness/README.md | 2 +- .../conversions/action-aria-removed.test.ts | 20 +++++++++++++++++++ packages/spec/src/ui/action.test.ts | 7 ++++++- packages/spec/src/ui/action.zod.ts | 2 +- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.changeset/20323-action-aria-removed.md b/.changeset/20323-action-aria-removed.md index 529b304a514..c578e67c6f0 100644 --- a/.changeset/20323-action-aria-removed.md +++ b/.changeset/20323-action-aria-removed.md @@ -2,7 +2,7 @@ '@objectstack/spec': minor --- -**BREAKING** — remove `aria` from the action (`ActionSchema`), the ARIA block no action surface ever applied. +**BREAKING** — `aria` on an action (`ActionSchema`, top-level `actions[]` and `objects[].actions[]`) is now refused at parse: no action surface ever applied it. Write the accessible name in the action's rendered `label`, and name the region that places the actions with `ariaLabel` / `ariaDescribedBy` / `role` in the placing node's `aria` block (`page.components[].aria` or the list view `aria`). Clause-②: yes @@ -34,7 +34,7 @@ It is the fourth member of the `aria` family retired for exactly this, after `da ## Reach, measured - This repository: **0** authors of `aria` on an action in `examples/**`, `packages/**` fixtures or the published skills (control: 15 `variant:` lines in `examples/**`). Two hand-written docs pages taught the key and are corrected here. -- HotCRM at `origin/main` `2f7b2326`: **0** on an action; its 6 `aria:` blocks are all page-level `page.aria`, which stays live (control: HotCRM authors actions — 5 action files declare `locations:`). +- HotCRM at `origin/main` `2f7b2326`: **0** on an action; its 6 `aria:` blocks are all page-level `page.aria`, which stays live (control: HotCRM authors actions — 7 files under `src/**/actions/` declare `locations:`, 17 times). - Other out-of-repo authors: NOT MEASURED. ## What an operator with a STORED action sees diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 560b83313a8..7a00d3e01d7 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -904,7 +904,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | object | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763). **#19054** REMOVES `tenancy.organizationField` at protocol 18 (ADR-0049 enforce-or-remove) — the STRICT-deletion route, so the row leaves this ledger with the key rather than staying as a tombstone: the `tenancy` block is a `strictObject`, the key is gone from the walked shape, and a surviving row would read as an ORPHAN. It was classified `live` on one real consumer (`resolveRecordOrganizationField`'s limb 0) and one real declaration, both of them ours — the key was authorable by every application and declared, repo-wide, only on `sys_api_key`. ⛔ Not a correction of that `live` verdict: the consumer still reads the same column for the same table, now from `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, which is not an authorable surface and therefore has no row here | | field | full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers. **#13043** ends the empty dead column this type had carried since that sweep — the reason the cell said "healthy" until 2026-08-29: `conditionalRequired` is re-classified `live` → `dead` with no key added or removed. It has been a `retiredKey` tombstone since 2026-07-28 (protocol 17, #3855), so the row stays (the `rls.priority` precedent) while the verdict does not. BOTH halves of its evidence were falsified, not just the citation: the `.transform` lowering `conditionalRequired` → `requiredWhen` that the row credited does not exist (field.zod.ts has zero `.transform` calls), and the objectql rule-validator `requiredWhen ?? conditionalRequired` fallback its note leaned on was retired by #3903, which replays the ADR-0087 conversion chain at rehydration instead — so a stored pre-17 row reaches the validator already lowered. The rot was invisible to every citation check (pointer in range, right file, file names the key) and the entry carried no `verifiedAt`, so nothing ever re-asked — the #12516 class, the same shape `action.execute` turned out to have. It was also the ledger's LAST `path:NNN` citation, so retiring it took #13003's line-citation counter to zero **#19187** flips `relatedListFilter` `planned` → `live` 2026-09-20, the fourth member of the related-list family joining its three siblings. ⛔ NOT this type's first flip of that direction, which is what an earlier draft of this cell claimed: `valueDomain` went `planned` → `live` in `fa125f3bfe` (#15316) once the record validator's call into `isValueDomainMember` landed, and it stands `live` with `verifiedAt` 2026-09-04. The correction is kept rather than quietly deleted because the false clause was the same species as the row it was describing — a confident sentence in the file whose job is to say true things about the ledger, falsified by one `git log -p` over this file. The row is the clean case the `app.navigation.runAction` (#10068) and `list.map` (#11442) flips established: #8704 seeded it contract-first with `authorWarn` and wrote the flip condition into its own note, objectui#4664 satisfied that condition, and the flip was taken by re-measuring at the `.objectui-sha` pin rather than on objectui main — `deriveRelatedLists` puts the authored value on the derived descriptor and `RecordDetailView` writes it onto the synthesized `record:related_list` node, so the rows and the tab badge answer one composed question. What makes it a DEFECT rather than bookkeeping is the direction a stale `planned` row fails in: its `authorHint` was a sentence `packages/lint` repeated at every compile — 「the auto-derived related list does not apply this filter yet」 — about a key the pinned console applies, so the ledger was steering authors off a working key rather than merely lagging it. It is also the direction no citation check can see: a `planned` row cites nothing, so nothing rots, and only the consumer landing falsifies it. With it, `field` carries NO `authorWarn` row at any depth, which gates the lint's field walk off entirely (`if (fieldWarn.size > 0)`) — recorded because the next warned field row re-opens that walk, and the #11385 field-walk pin in `packages/lint` is narrowed until one does | | flow | dead count = **5 tombstone entries** + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges), plus errorHandling.`retryDelayMs` RENAMED to `backoffMs` 2026-08-04 (#4964). The rename is why the dead column moved while live did not: a rename is a removal on this ledger, so the old spelling is tombstoned (`retiredKey` keeps it in the walked shape) and the new spelling enters as its own `live` row. Read it beside the four above as the one entry here that cost an author nothing — the block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name, and #4964 spelled its base delay `backoffMs` to match `job.retryPolicy` and a `try_catch` node's `retry`. Remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | -| action | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions). **#7367** (PR #7430) adds `description` as an authorable key, `live` on arrival — the only row this type has gained since that sweep. **#13036** makes the dead set three: `execute` joins it, re-classified `live` → `dead` 2026-08-29 with no key added or removed. Its `live` verdict rested on a `.transform` lowering `execute` → `target` that protocol 17 (#3855) removed along with the alias; the key has been a `retiredKey` tombstone since 2026-07-28, so the row stays (the `rls.priority` precedent) while the verdict does not. The rot was invisible to every citation check — the pointer was in range, in the right file, and the file names the key — and the entry carried no `verifiedAt`, so nothing ever re-asked | +| action | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions). **#7367** (PR #7430) adds `description` as an authorable key, `live` on arrival — the only row this type has gained since that sweep. **#13036** makes the dead set three: `execute` joins it, re-classified `live` → `dead` 2026-08-29 with no key added or removed. Its `live` verdict rested on a `.transform` lowering `execute` → `target` that protocol 17 (#3855) removed along with the alias; the key has been a `retiredKey` tombstone since 2026-07-28, so the row stays (the `rls.priority` precedent) while the verdict does not. The rot was invisible to every citation check — the pointer was in range, in the right file, and the file names the key — and the entry carried no `verifiedAt`, so nothing ever re-asked. **#20323** makes the dead set four: `aria` re-classified `live` → `dead` 2026-09-28 and tombstoned (the `rls.priority` precedent again). Its `live` verdict rested on an uncited 「PARTIAL — honored by a few objectui renderers」 note; re-measured at the `.objectui-sha` pin, no surface that renders an action reads an action's `aria`, and each takes the accessible name from the required `label` | | hook | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove | | permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection). `objects.allowRestore`/`allowPurge` REMOVED 2026-08-26 (#12497, ADR-0049 — the `restore`/`purge` ops never existed; the 2026-07-30 'live' verdict cited only the evaluator pre-mapping, retired in the same batch; `retiredKey` tombstones, keys return with M2 per the #1883 ruling) | | position | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | diff --git a/packages/spec/src/conversions/action-aria-removed.test.ts b/packages/spec/src/conversions/action-aria-removed.test.ts index 84b5faaa73f..e9010b1e849 100644 --- a/packages/spec/src/conversions/action-aria-removed.test.ts +++ b/packages/spec/src/conversions/action-aria-removed.test.ts @@ -95,4 +95,24 @@ describe('[#20323] action-aria-removed (ADR-0087 D2)', () => { expect(converted).not.toHaveProperty('aria'); expect(ActionSchema.safeParse(converted).success).toBe(true); }); + + it('the stored-row seam reaches an `object` row\'s nested action too', () => { + // The second authored coordinate at rest: an `object` row wraps as + // `objects[0]`, so the walk reaches `objects[].actions[]`. Everything but + // the retired key survives, and the untouched sibling action is the SAME + // reference (copy-on-write). + const untouched = { name: 'close_case', label: 'Close', type: 'script', target: 'closeCase' }; + const stored = { + name: 'support_case', + label: 'Case', + actions: [ + { name: 'reopen_case', label: 'Reopen', type: 'script', target: 'reopenCase', aria: ARIA }, + untouched, + ], + }; + const converted = applyConversionsToStoredItem('object', stored) as typeof stored; + expect(converted.actions[0]).toEqual({ name: 'reopen_case', label: 'Reopen', type: 'script', target: 'reopenCase' }); + expect(converted.actions[1]).toBe(untouched); + expect(converted.name).toBe('support_case'); + }); }); diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 0aa3edd1c4b..039e63fdfeb 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -1124,7 +1124,12 @@ describe('Action ARIA Integration (retired — #20323)', () => { expect(result.success, 'an authored `action.aria` must be refused').toBe(false); const issue = result.error!.issues.find((i) => i.path.join('.') === 'aria'); expect(issue, 'the refusal sits at the retired key').toBeDefined(); - expect(issue!.message).toMatch(/`action\.aria` was removed.*Delete the key\./s); + // The tombstone's own issue kind — a `never` slot at `aria` — not the + // strict shape's generic `unrecognized_keys` at the root, which is what a + // bare deletion would answer, without the prescription below. + expect(issue!.code).toBe('invalid_type'); + expect(result.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(false); + expect(issue!.message).toMatch(/`action\.aria` was removed in @objectstack\/spec 17\.5\.0.*Delete the key\./s); }); it('control: the same action without `aria` parses, and keeps its accessible name in `label`', () => { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 002061f1e60..2e0a17b14b0 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1682,7 +1682,7 @@ const actionObject = () => strictObject({ // def retirement. Sources are stripped by the D2 conversion // `action-aria-removed`. aria: retiredKey( - '`action.aria` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no action ' + + '`action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action ' + 'surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk ' + 'action menus and the record quick-actions toolbar all take the accessible name from the ' + "action's `label` and never read this block, so ARIA attributes declared here parsed and then " + From 8dd3a2cec5df7445ccaeffeefd510cbe7d15211a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:34:14 +0000 Subject: [PATCH 8/9] spec: regenerate the reference pages for the 17.5.0 prescription Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- content/docs/references/data/object.mdx | 2 +- content/docs/references/kernel/metadata-plugin.mdx | 2 +- content/docs/references/ui/action.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index d7c00807c76..48450ce7706 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -478,7 +478,7 @@ const result = ApiMethod.parse(data); | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | -| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 2a5837058c0..0558bbeb9b1 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -348,7 +348,7 @@ const result = MetadataBulkResultSchema.parse(data); | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | -| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index c2e99d96d62..b6fea8725e7 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -67,7 +67,7 @@ const result = ActionSchema.parse(data); | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | -| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | From 7faf0e9f63c7a37478b86af29f713f5697ea1970 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:20:02 +0000 Subject: [PATCH 9/9] spec: regenerate the reference page the second origin/main merge deferred content/docs/references/data/object.mdx regenerated from the merged tree over a fresh spec build; gen:migration-registry and gen:liveness-counts reproduce the committed bytes, and check:generated reads all 15 up to date. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- content/docs/references/data/object.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 48450ce7706..f17f4d4aaef 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -260,7 +260,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -592,7 +592,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |