From 20d7166b8749376a21304d817ce8851bbb6d719e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 12:06:52 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20ActionSchema.onSuccess=20?= =?UTF-8?q?=E2=80=94=20post-success=20navigation=20with=20${result.*}=20sc?= =?UTF-8?q?ope=20(#9566,=20#9474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- .changeset/action-onsuccess-navigation.md | 38 ++++ packages/spec/authorable-surface/ui.json | 1 + packages/spec/liveness/action.json | 6 + .../spec/src/ui/action-on-success.test.ts | 193 ++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 120 +++++++++++ 5 files changed, 358 insertions(+) create mode 100644 .changeset/action-onsuccess-navigation.md create mode 100644 packages/spec/src/ui/action-on-success.test.ts diff --git a/.changeset/action-onsuccess-navigation.md b/.changeset/action-onsuccess-navigation.md new file mode 100644 index 0000000000..a7f4d80a6b --- /dev/null +++ b/.changeset/action-onsuccess-navigation.md @@ -0,0 +1,38 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `ActionSchema.onSuccess` — post-success navigation for `api`/`script` actions, with `${result.*}` joining the navigate template's interpolation scope (#9566, #9474) + + + +The maintainer's 2026-08-18 ruling (recorded on #9566, mirrored on #9474) +declares ONE post-success navigation contract for both server-executing action +types instead of two per-type conventions: + +- `onSuccess: { navigate, openIn? }` — a strict object, read for + `type: 'api'` and `type: 'script'` only (a refinement refuses it on + `url`/`modal`/`flow`/`form`, where no success event exists for it to ride — + the ADR-0078 posture, same enforcement shape as the `body`-on-non-script + refinement). +- `navigate` is a route/URL template. Its documented interpolation scope is + `${param.*}` + `${ctx.*}` (existing) + **`${result.*}` — NEW: the action's + server response payload** (an `api` action's response body, a `script` + handler's return value), which is what makes "server clones a record → jump + to the new record" declarable: `navigate: '/apps/crm/tasks/${result.id}'`. + The interpolation ENGINE stays the renderer's (objectui `interpolateTarget`); + the spec records the contract. +- `openIn` is the closed enum `'self' | 'newTab'`, defaulting **`'self'`** + (materialized, the file's default convention) — no general navigation DSL. +- The shipped handler-return convention (`{ redirectUrl, openIn? }`, + objectui#2967/#2904) keeps its 17.0.0 semantics: absent `openIn` still means + new-tab (no silent behavior flip for existing handlers); a handler may return + `openIn: 'self'` explicitly. + +The console consumer is the downstream objectui half (SPA navigation branch, +`executeAPI` navigation handling, `${result.*}` interpolation), filed +Blocked-by these cards; the liveness ledger records the key at `planned` +strength with the amend-on-landing instruction. diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index d7e6091158..acf4fb89f1 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -33,6 +33,7 @@ "ui/Action:name", "ui/Action:newTabUrl", "ui/Action:objectName", + "ui/Action:onSuccess", "ui/Action:openIn", "ui/Action:opensInNewTab", "ui/Action:order", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index ee4a817cb3..faf2dae065 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -158,6 +158,12 @@ "evidence": "objectui ActionRunner.executeUrl (objectui issue #2043)", "note": "Declarative new-tab control for STATIC type:'url' targets. ActionRunner.executeUrl reads action.openIn with priority over the legacy params.newTab/external-URL heuristic; action-button/icon/menu/group + basic/elements renderers forward it. Distinct from opensInNewTab/newTabUrl (async SSO pre-open)." }, + "onSuccess": { + "status": "planned", + "verifiedAt": "2026-08-18", + "evidenceScope": "cross-repo", + "note": "PLANNED, deliberately not `live` — the #9340 map / #9463 viewMode convention for a spec-first contract-split key. Declared by the #9566/#9474 maintainer ruling (2026-08-18, one navigation contract for both cards): `{ navigate, openIn: 'self'|'newTab' }` post-success navigation for type:'api'/'script' actions, with `${result.*}` (the server response) joining the navigate template's interpolation scope. No console consumer reads it yet: objectui's consoleServerAction.ts drives only the handler-return `{ redirectUrl }` convention (new-tab, objectui#2967/#2904), executeAPI returns {success,data} and never navigates, and interpolateTarget's scope has no `result` member — the SPA-navigation branch, executeAPI navigation handling and result-scope interpolation are the downstream objectui card(s) filed Blocked-by #9566/#9474 at this key's landing. Amend to `live` citing the consoleServerAction/executeAPI read and the interpolateTarget result member when that half lands — measured objectui per the #9566 issue audit at spec/console 17.0.0, 2026-08-18." + }, "aria": { "status": "live", "note": "PARTIAL — honored by a few objectui renderers, not the core action buttons/menus." diff --git a/packages/spec/src/ui/action-on-success.test.ts b/packages/spec/src/ui/action-on-success.test.ts new file mode 100644 index 0000000000..5c22d532b2 --- /dev/null +++ b/packages/spec/src/ui/action-on-success.test.ts @@ -0,0 +1,193 @@ +// #9566 / #9474 — `onSuccess` post-success navigation (maintainer ruling +// 2026-08-18, recorded on #9566): one CLOSED key covering both server-executing +// action types, `navigate` (route/URL template whose scope gains `${result.*}`, +// the server response) + `openIn: 'self' | 'newTab'` defaulting `'self'`. +// These pins hold the ruled shape: the accept set, the materialized default, +// the closed enum, the strict inner object, and the api/script type scope. +import { describe, it, expect } from 'vitest'; +import { ActionSchema, InlineActionSchema } from './action.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +const base = { name: 'copy_as_new_version', label: 'Copy as new version' }; + +describe('ActionSchema.onSuccess (#9566/#9474)', () => { + describe('accept pins', () => { + it('accepts the full shape on a type:api action', () => { + const r = ActionSchema.safeParse({ + ...base, + type: 'api', + target: '/api/v1/actions/task_version/clone', + onSuccess: { navigate: '/apps/mfg/task_version/${result.id}', openIn: 'newTab' }, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect((r.data as { onSuccess: unknown }).onSuccess) + .toEqual({ navigate: '/apps/mfg/task_version/${result.id}', openIn: 'newTab' }); + }); + + it('accepts the minimal shape on a type:script action', () => { + const r = ActionSchema.safeParse({ + ...base, + type: 'script', + target: 'cloneVersion', + onSuccess: { navigate: '/apps/mfg/task_version/${result.id}' }, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + + it('reaches the same shape through the registered `action` metadata schema (the parsing door)', () => { + const schema = getMetadataTypeSchema('action'); + expect(schema).toBeDefined(); + const r = schema!.safeParse({ + ...base, + type: 'api', + target: '/api/v1/actions/task_version/clone', + onSuccess: { navigate: '/apps/mfg/task_version/${result.id}' }, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + }); + + describe('default pin — openIn materializes to self', () => { + it('parse output carries openIn "self" when the author omits it', () => { + // The ruled default is MATERIALIZED (`.default('self')`), so a consumer + // reads the resolved member off the parse output and never needs its own + // fallback — declared = enforced. This is the observable being pinned. + const out = ActionSchema.parse({ + ...base, + type: 'api', + target: '/api/v1/actions/task_version/clone', + onSuccess: { navigate: '/x/${result.id}' }, + }) as { onSuccess?: { navigate: string; openIn: string } }; + expect(out.onSuccess?.openIn).toBe('self'); + }); + + it('an explicit openIn survives untouched', () => { + const out = ActionSchema.parse({ + ...base, + type: 'script', + target: 'cloneVersion', + onSuccess: { navigate: '/x', openIn: 'newTab' }, + }) as { onSuccess?: { openIn: string } }; + expect(out.onSuccess?.openIn).toBe('newTab'); + }); + }); + + describe('refusal pins — the closed enum', () => { + it('rejects an out-of-vocabulary openIn', () => { + const r = ActionSchema.safeParse({ + ...base, + type: 'api', + target: '/t', + onSuccess: { navigate: '/x', openIn: 'modal' }, + }); + expect(r.success).toBe(false); + }); + + it("names the camelCase member when the author writes the sibling key's kebab spelling", () => { + // The top-level `openIn` (type:'url') spells its member 'new-tab'; the + // handler-return convention and this key spell it 'newTab'. The enum's + // error map catches exactly the crossover spelling (the + // ActionLocationSchema issue.input precedent) — every other wrong value + // keeps zod's own enum error. + const r = ActionSchema.safeParse({ + ...base, + type: 'api', + target: '/t', + onSuccess: { navigate: '/x', openIn: 'new-tab' }, + }); + expect(r.success).toBe(false); + const msg = r.error!.issues.map((i) => i.message).join('\n'); + expect(msg).toContain("'newTab'"); + expect(msg).toContain('new-tab'); + }); + }); + + describe('refusal pins — the strict inner object', () => { + const innerIssue = (onSuccess: Record) => { + const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', onSuccess }); + expect(r.success).toBe(false); + return r.error!.issues.find((i) => i.code === 'unrecognized_keys'); + }; + + it('rejects an undeclared key instead of silently dropping it', () => { + const issue = innerIssue({ navigate: '/x', notAKey: 1 }); + expect(issue).toBeDefined(); + expect(issue!.message).toContain('`notAKey`'); + }); + + it("points the handler-return spelling `redirectUrl` at `navigate`", () => { + expect(innerIssue({ redirectUrl: '/x' })!.message) + .toContain('`redirectUrl` → `navigate`'); + }); + + it('points the generic destination spellings at `navigate`', () => { + for (const key of ['url', 'to', 'route', 'path', 'target']) { + expect(innerIssue({ [key]: '/x' })!.message) + .toContain(`\`${key}\` → \`navigate\``); + } + }); + + it('tells an author reaching for `opensInNewTab` that the tab choice here is openIn', () => { + expect(innerIssue({ navigate: '/x', opensInNewTab: true })!.message) + .toContain("openIn: 'newTab'"); + }); + + it('requires `navigate` — an empty onSuccess block is not a declaration', () => { + const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', onSuccess: {} }); + expect(r.success).toBe(false); + }); + }); + + describe('type scope — api and script only (the #4352 enforcement shape)', () => { + it.each(['url', 'modal', 'flow', 'form'] as const)('refuses onSuccess on a type:%s action', (type) => { + const r = ActionSchema.safeParse({ + ...base, + type, + target: type === 'form' ? 'edit_form' : '/t', + onSuccess: { navigate: '/x' }, + }); + expect(r.success).toBe(false); + const msg = r.error!.issues.map((i) => i.message).join('\n'); + expect(msg).toContain('onSuccess'); + expect(msg).toContain("'api'"); + expect(msg).toContain("'script'"); + }); + }); + + describe('the pre-existing probes now land on prescriptions, not bare rejections (#9474)', () => { + it('a top-level `redirect` names the onSuccess shape', () => { + const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', redirect: '/x' }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue!.message).toContain('onSuccess'); + expect(issue!.message).toContain('navigate'); + }); + + it('a top-level `redirectUrl` says it is the handler-return convention, not an authorable key', () => { + const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', redirectUrl: '/x' }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue!.message).toContain('HANDLER-RETURN'); + expect(issue!.message).toContain('onSuccess'); + }); + }); + + describe('strictness posture unchanged elsewhere', () => { + it('InlineActionSchema does not pick onSuccess — unknown key inline', () => { + // The inline surface widens when a renderer widens (the file's own rule); + // element:button's forward list has no onSuccess hop, so the key is + // registered-actions-only until the objectui half lands. + const r = InlineActionSchema.safeParse({ + type: 'api', + target: '/t', + onSuccess: { navigate: '/x' }, + }); + expect(r.success).toBe(false); + }); + + it('an action WITHOUT onSuccess still parses exactly as before', () => { + const out = ActionSchema.parse({ ...base, type: 'api', target: '/t' }) as Record; + expect('onSuccess' in out && out.onSuccess !== undefined).toBe(false); + }); + }); +}); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 94fbb77f29..37ac5435b8 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -811,6 +811,25 @@ const actionObject = () => strictObject({ requiresConfirmation: 'the AI human-in-the-loop override lives under `ai` — write ' + '`ai: { requiresConfirmation: true }`. `confirmText` is the separate UI confirm prompt.', + // The three spellings #9474 measured authors probing for post-success + // navigation before `onSuccess` existed. Each is a top-level string where + // the declared shape is a nested object, so an alias rename would produce + // a second, worse error (`invalid_type` at `onSuccess`) — a guidance + // pointer carries the whole rewrite instead. + redirect: + "post-success navigation is declared under `onSuccess` — write `onSuccess: { navigate: " + + "'' }` (interpolates ${param.*}, ${ctx.*} and ${result.*}, the server " + + "response). Read for `type: 'api'` and `type: 'script'` actions; `openIn: 'self' | 'newTab'` " + + "picks the tab (default 'self').", + navigate: + '`navigate` is not a top-level key — it lives inside `onSuccess`: write ' + + "`onSuccess: { navigate: '' }`. The template interpolates ${param.*}, " + + '${ctx.*} and ${result.*} (the server response payload, e.g. ${result.id}).', + redirectUrl: + '`redirectUrl` is the HANDLER-RETURN convention (a server handler returns ' + + '`{ redirectUrl, openIn? }`), not an authorable action key. To declare the destination in ' + + "metadata, write `onSuccess: { navigate: '' }` — ${result.*} " + + 'interpolates the server response.', }, }, { /** Machine name of the action */ @@ -1281,6 +1300,86 @@ const actionObject = () => strictObject({ */ newTabUrl: z.string().optional().describe('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.'), + /** + * Post-success navigation (#9566 / #9474, maintainer ruling 2026-08-18) — + * where to take the user after a server-executing action succeeds. Read for + * `type: 'api'` and `type: 'script'` only (the refinement below refuses it on + * other types: a `type:'url'` action already navigates via `target` + + * `openIn`, and `modal`/`flow`/`form` dispatch on `target` with no success + * event this key could ride — a declared key those paths never read is the + * ADR-0078 silently-inert shape). + * + * **`navigate`** is a route/URL template. Its interpolation scope is the + * CONTRACT recorded here; the interpolation ENGINE is the renderer's + * (objectui `interpolateTarget`) and the scope members are measured from it: + * + * - `${param.X}` — value collected by the action's params dialog. + * - `${ctx.X}` — the action context: `ctx.origin`, `ctx.apiBase`, + * `ctx.user.*`, `ctx.org.*`, `ctx.recordId`, `ctx.selection` (the same + * scope `target` interpolates today). + * - `${result.X}` — **NEW with this key**: the action's server response + * payload. For a `type:'api'` action, the response body of the `target` + * call; for a `type:'script'` action, the handler's return value. This is + * what makes "server clones a record → jump to the new record" declarable: + * `navigate: '/apps/crm/tasks/${result.id}'`. + * + * Renderers MUST `encodeURIComponent` interpolated values in URL query + * positions (same rule as `target`). A relative template is an SPA route + * hop (no full page load, immune to popup blocking); an absolute URL is a + * document navigation. + * + * **`openIn`** is a CLOSED two-member enum — `'self'` (default: navigate in + * place) or `'newTab'` — deliberately not a general navigation DSL. + * The default is MATERIALIZED (`.default('self')`, the file's convention — + * `type`, `refreshAfter`, `ai.exposed`): declared navigation is an explicit + * author choice, so parse output always carries the resolved member and no + * consumer needs its own fallback. Note the spelling: `'newTab'` here + * (matching the handler-return convention below), while the top-level + * `openIn` for `type:'url'` spells it `'new-tab'` — the enum's error map + * catches the crossover. + * + * **Relation to the handler-return convention** (objectui + * `consoleServerAction.ts`, the `{ redirectUrl, openIn? }` shape fixed by + * objectui#2967/#2904): that runtime surface keeps its shipped 17.0.0 + * semantics — a handler returning `{ redirectUrl }` WITHOUT `openIn` still + * opens a NEW TAB (no silent behavior flip for existing handlers); a handler + * may return `openIn: 'self'` explicitly to opt into the same-tab jump. The + * defaults are deliberately split by surface: this schema key is a NEW + * surface and defaults `'self'`; the handler convention is a shipped surface + * and keeps new-tab. + * + * The console consumer is not wired yet — the SPA navigation branch, + * `executeAPI` navigation handling and `${result.*}` interpolation are the + * downstream objectui half (Blocked-by #9566/#9474; tracked in the liveness + * ledger at `planned` strength with the amend-on-landing instruction). + */ + onSuccess: strictObject({ + surface: "this action's onSuccess block", + history: ACTION_HISTORY, + aliases: { + // The handler-return convention's spelling, and the generic reaches. + redirectUrl: 'navigate', url: 'navigate', to: 'navigate', route: 'navigate', + path: 'navigate', target: 'navigate', navigateTo: 'navigate', href: 'navigate', + open: 'openIn', tab: 'openIn', + }, + guidance: { + opensInNewTab: + '`opensInNewTab` is the top-level pre-opened-tab flag for ASYNC redirect handlers, not an ' + + "`onSuccess` key — here the tab choice is `openIn: 'newTab'` (or `'self'`, the default).", + newTab: + "the tab choice is the closed enum `openIn: 'self' | 'newTab'` — write `openIn: 'newTab'`.", + }, + }, { + navigate: z.string().describe("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: z.enum(['self', 'newTab'], { + error: (issue) => (issue.input === 'new-tab' + ? "`onSuccess.openIn` spells the new-tab member `'newTab'` (matching the handler-return " + + "convention `{ redirectUrl, openIn }`), not `'new-tab'` — that kebab spelling belongs " + + "to the top-level `openIn` key for `type:'url'` actions. Write `openIn: 'newTab'`." + : undefined), + }).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 (#9566/#9474). `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'), @@ -1400,6 +1499,27 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { + 'correct for a param-LESS action, where the confirm IS the only dialog, and for a view\'s ' + '`bulkActionDefs`, where the pair renders one dialog by that schema\'s own contract.', path: ['confirmText'], +}).refine((data) => { + // #9566/#9474 — `onSuccess` is scoped to the two action types that HAVE a + // success event carrying a server response for `${result.*}` to read: + // `api` (the `target` call's response body) and `script` (the handler's + // return value). Same enforcement shape as the `body`-on-non-script + // refinement above (#4352): a declared key the dispatch path never reads is + // the invisible-failure class this file rejects at author time. + if (data.onSuccess && data.type !== 'api' && data.type !== 'script') { + return false; + } + return true; +}, { + message: + "`onSuccess` declares POST-SUCCESS navigation for a server-executing action, and only " + + "`type: 'api'` and `type: 'script'` have a success event (a server response) it can ride — " + + "on any other type the key would parse clean and never run (ADR-0078). For a `type:'url'` " + + "action the navigation IS the action: put the destination in `target` and pick the tab with " + + "the top-level `openIn`. For `modal`/`flow`/`form`, the action dispatches on `target` and no " + + 'renderer reads a post-success hop today — if that capability is needed, it is a spec ' + + 'proposal, not a silent key.', + path: ['onSuccess'], }).transform((data, ctx) => lowerRequiresFeature(data, ctx))); export type Action = z.input; From b53b4fb706bdf52571eba2e197dfd10909207614 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 12:26:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore(spec):=20regenerate=20docs/ledger=20a?= =?UTF-8?q?rtifacts;=20drill=20onSuccess=20liveness=20container;=20move=20?= =?UTF-8?q?the=20action.zod=20site-count=20pin=208=E2=86=929?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- content/docs/references/ui/action.mdx | 1 + ...-07-unknown-key-strictness-ledger.counts.md | 14 +++++++------- packages/spec/liveness/action.json | 18 ++++++++++++++---- packages/spec/liveness/state-counts.md | 4 ++-- .../spec/scripts/strictness-ledger.test.ts | 5 ++++- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 44d6cf7d52..672b42b0e7 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -96,6 +96,7 @@ const result = ActionSchema.parse(data); | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **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 (#9566/#9474). `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 | | **_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. | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index f10002b97a..2f0ba98f8b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 443 | +| Object sites in them | 444 | | Still-open (strip) sites | 123 | | Files carrying at least one | 22 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 175 | 164 | 5 | 0 | 6 | +| `ui/` | 176 | 165 | 5 | 0 | 6 | | `data/` | 156 | 74 | 1 | 0 | 81 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **443** | **314** | **6** | **0** | **123** | +| **total** | **444** | **315** | **6** | **0** | **123** | ## File-level triage — site counts @@ -62,7 +62,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | File | Sites | |---|---| | `action-params.zod.ts` | 1 | -| `action.zod.ts` | 8 | +| `action.zod.ts` | 9 | | `app.zod.ts` | 18 | | `bulk-action.zod.ts` | 3 | | `chart.zod.ts` | 8 | @@ -77,7 +77,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `theme.zod.ts` | 6 | | `view.zod.ts` | 56 | | `widget.zod.ts` | 1 | -| **total** | **175** | +| **total** | **176** | ### `data/` — sites @@ -156,7 +156,7 @@ over it is here. ### `ui/` — open -**6 strip of 175**, in 4 file(s). +**6 strip of 176**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -164,7 +164,7 @@ over it is here. | `app.zod.ts` | 1 | 18 | | `view.zod.ts` | 3 | 56 | | `widget.zod.ts` | 1 | 1 | -| **total** | **6** | **175** | +| **total** | **6** | **176** | | Bucket | Sites | |---|---| diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index faf2dae065..55ccb36319 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -159,10 +159,20 @@ "note": "Declarative new-tab control for STATIC type:'url' targets. ActionRunner.executeUrl reads action.openIn with priority over the legacy params.newTab/external-URL heuristic; action-button/icon/menu/group + basic/elements renderers forward it. Distinct from opensInNewTab/newTabUrl (async SSO pre-open)." }, "onSuccess": { - "status": "planned", - "verifiedAt": "2026-08-18", - "evidenceScope": "cross-repo", - "note": "PLANNED, deliberately not `live` — the #9340 map / #9463 viewMode convention for a spec-first contract-split key. Declared by the #9566/#9474 maintainer ruling (2026-08-18, one navigation contract for both cards): `{ navigate, openIn: 'self'|'newTab' }` post-success navigation for type:'api'/'script' actions, with `${result.*}` (the server response) joining the navigate template's interpolation scope. No console consumer reads it yet: objectui's consoleServerAction.ts drives only the handler-return `{ redirectUrl }` convention (new-tab, objectui#2967/#2904), executeAPI returns {success,data} and never navigates, and interpolateTarget's scope has no `result` member — the SPA-navigation branch, executeAPI navigation handling and result-scope interpolation are the downstream objectui card(s) filed Blocked-by #9566/#9474 at this key's landing. Amend to `live` citing the consoleServerAction/executeAPI read and the interpolateTarget result member when that half lands — measured objectui per the #9566 issue audit at spec/console 17.0.0, 2026-08-18." + "children": { + "navigate": { + "status": "planned", + "verifiedAt": "2026-08-18", + "evidenceScope": "cross-repo", + "note": "PLANNED, deliberately not `live` — the #9340 map / #9463 viewMode convention for a spec-first contract-split key. Declared by the #9566/#9474 maintainer ruling (2026-08-18, one navigation contract for both cards): a post-success route/URL template for type:'api'/'script' actions whose interpolation scope gains `${result.*}` (the server response) beside `${param.*}`/`${ctx.*}`. No console consumer reads it yet: objectui's consoleServerAction.ts drives only the handler-return `{ redirectUrl }` convention (new-tab, objectui#2967/#2904), executeAPI returns {success,data} and never navigates, and interpolateTarget's scope has no `result` member — the SPA-navigation branch, executeAPI navigation handling and result-scope interpolation are the downstream objectui card(s) filed Blocked-by #9566/#9474 at this key's landing. Amend to `live` citing the consoleServerAction/executeAPI read and the interpolateTarget result member when that half lands — measured objectui per the #9566 issue audit at spec/console 17.0.0, 2026-08-18." + }, + "openIn": { + "status": "planned", + "verifiedAt": "2026-08-18", + "evidenceScope": "cross-repo", + "note": "PLANNED with its sibling `navigate` (see that entry for the full cross-repo measurement). The closed enum 'self'|'newTab' with a MATERIALIZED .default('self') — parse output always carries the resolved member, so the future console branch reads it with no fallback of its own. The shipped handler-return surface `{ redirectUrl, openIn? }` keeps 17.0.0 semantics (absent openIn ⇒ new-tab); only this schema key defaults 'self'. Amend to `live` together with `navigate` when the objectui half (Blocked-by #9566/#9474) lands." + } + } }, "aria": { "status": "live", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 0d42b4bd0f..1d82eccb72 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 | 1 | 51 | | `field` | 88 | 0 | 0 | 2 | 90 | | `flow` | 34 | 0 | 6 | 0 | 40 | -| `action` | 42 | 0 | 2 | 0 | 44 | +| `action` | 42 | 0 | 2 | 2 | 46 | | `hook` | 18 | 0 | 2 | 0 | 20 | | `permission` | 38 | 0 | 4 | 0 | 42 | | `position` | 12 | 0 | 0 | 0 | 12 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **796** | **6** | **55** | **10** | **867** | +| **total** | **796** | **6** | **55** | **12** | **869** | diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index 3ea8acf9dc..c890182b0e 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -100,7 +100,10 @@ describe('site counting reads the AST, not the source text', () => { expect(countSites(at('kernel/metadata-protection.zod.ts'))).toBe(0); expect(countSites(at('shared/suggestions.zod.ts'))).toBe(0); // And the case that mattered, because this file IS triaged: 9 → 8. - expect(countSites(at('ui/action.zod.ts'))).toBe(8); + // (8 → 9 at #9566, which ADDED the `onSuccess` strictObject site — the + // count is incidental; what this case pins is that JSDoc examples are + // not counted.) + expect(countSites(at('ui/action.zod.ts'))).toBe(9); }); it('counts a call the source wraps across lines (`z\\n .object({`)', () => {