Skip to content

Commit 07e630e

Browse files
os-steveclaude
andauthored
feat(spec): ActionSchema.onSuccess — post-success navigation with ${result.*} scope (#9566, #9474) (#9601)
* feat(spec): ActionSchema.onSuccess — post-success navigation with ${result.*} scope (#9566, #9474) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP * chore(spec): regenerate docs/ledger artifacts; drill onSuccess liveness container; move the action.zod site-count pin 8→9 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f25bb18 commit 07e630e

9 files changed

Lines changed: 382 additions & 10 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `ActionSchema.onSuccess` — post-success navigation for `api`/`script` actions, with `${result.*}` joining the navigate template's interpolation scope (#9566, #9474)
6+
7+
<!-- adr-0087: not-required (accept-set expansion) One new CLOSED optional key
8+
on an existing shape; nothing authorable is renamed, retired or tombstoned, so
9+
there is no conversion to register. Previously-refused spellings stay refused —
10+
three of them now carry guidance pointing at the new key. -->
11+
12+
The maintainer's 2026-08-18 ruling (recorded on #9566, mirrored on #9474)
13+
declares ONE post-success navigation contract for both server-executing action
14+
types instead of two per-type conventions:
15+
16+
- `onSuccess: { navigate, openIn? }` — a strict object, read for
17+
`type: 'api'` and `type: 'script'` only (a refinement refuses it on
18+
`url`/`modal`/`flow`/`form`, where no success event exists for it to ride —
19+
the ADR-0078 posture, same enforcement shape as the `body`-on-non-script
20+
refinement).
21+
- `navigate` is a route/URL template. Its documented interpolation scope is
22+
`${param.*}` + `${ctx.*}` (existing) + **`${result.*}` — NEW: the action's
23+
server response payload** (an `api` action's response body, a `script`
24+
handler's return value), which is what makes "server clones a record → jump
25+
to the new record" declarable: `navigate: '/apps/crm/tasks/${result.id}'`.
26+
The interpolation ENGINE stays the renderer's (objectui `interpolateTarget`);
27+
the spec records the contract.
28+
- `openIn` is the closed enum `'self' | 'newTab'`, defaulting **`'self'`**
29+
(materialized, the file's default convention) — no general navigation DSL.
30+
- The shipped handler-return convention (`{ redirectUrl, openIn? }`,
31+
objectui#2967/#2904) keeps its 17.0.0 semantics: absent `openIn` still means
32+
new-tab (no silent behavior flip for existing handlers); a handler may return
33+
`openIn: 'self'` explicitly.
34+
35+
The console consumer is the downstream objectui half (SPA navigation branch,
36+
`executeAPI` navigation handling, `${result.*}` interpolation), filed
37+
Blocked-by these cards; the liveness ledger records the key at `planned`
38+
strength with the amend-on-landing instruction.

‎content/docs/references/ui/action.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const result = ActionSchema.parse(data);
9696
| **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. |
9797
| **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. |
9898
| **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. |
99+
| **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. |
99100
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
100101
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
101102
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 443 |
24+
| Object sites in them | 444 |
2525
| Still-open (strip) sites | 123 |
2626
| Files carrying at least one | 22 |
2727

@@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
4444

4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
47-
| `ui/` | 175 | 164 | 5 | 0 | 6 |
47+
| `ui/` | 176 | 165 | 5 | 0 | 6 |
4848
| `data/` | 156 | 74 | 1 | 0 | 81 |
4949
| `automation/` | 65 | 42 | 0 | 0 | 23 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **443** | **314** | **6** | **0** | **123** |
52+
| **total** | **444** | **315** | **6** | **0** | **123** |
5353

5454
## File-level triage — site counts
5555

@@ -62,7 +62,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
6262
| File | Sites |
6363
|---|---|
6464
| `action-params.zod.ts` | 1 |
65-
| `action.zod.ts` | 8 |
65+
| `action.zod.ts` | 9 |
6666
| `app.zod.ts` | 18 |
6767
| `bulk-action.zod.ts` | 3 |
6868
| `chart.zod.ts` | 8 |
@@ -77,7 +77,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
7777
| `theme.zod.ts` | 6 |
7878
| `view.zod.ts` | 56 |
7979
| `widget.zod.ts` | 1 |
80-
| **total** | **175** |
80+
| **total** | **176** |
8181

8282
### `data/` — sites
8383

@@ -156,15 +156,15 @@ over it is here.
156156

157157
### `ui/` — open
158158

159-
**6 strip of 175**, in 4 file(s).
159+
**6 strip of 176**, in 4 file(s).
160160

161161
| File | Strip | Sites |
162162
|---|---|---|
163163
| `action-params.zod.ts` | 1 | 1 |
164164
| `app.zod.ts` | 1 | 18 |
165165
| `view.zod.ts` | 3 | 56 |
166166
| `widget.zod.ts` | 1 | 1 |
167-
| **total** | **6** | **175** |
167+
| **total** | **6** | **176** |
168168

169169
| Bucket | Sites |
170170
|---|---|

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"ui/Action:name",
3434
"ui/Action:newTabUrl",
3535
"ui/Action:objectName",
36+
"ui/Action:onSuccess",
3637
"ui/Action:openIn",
3738
"ui/Action:opensInNewTab",
3839
"ui/Action:order",

‎packages/spec/liveness/action.json‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,22 @@
158158
"evidence": "objectui ActionRunner.executeUrl (objectui issue #2043)",
159159
"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)."
160160
},
161+
"onSuccess": {
162+
"children": {
163+
"navigate": {
164+
"status": "planned",
165+
"verifiedAt": "2026-08-18",
166+
"evidenceScope": "cross-repo",
167+
"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."
168+
},
169+
"openIn": {
170+
"status": "planned",
171+
"verifiedAt": "2026-08-18",
172+
"evidenceScope": "cross-repo",
173+
"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."
174+
}
175+
}
176+
},
161177
"aria": {
162178
"status": "live",
163179
"note": "PARTIAL — honored by a few objectui renderers, not the core action buttons/menus."

‎packages/spec/liveness/state-counts.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ for both corollaries.
3030
| `object` | 50 | 0 | 0 | 1 | 51 |
3131
| `field` | 88 | 0 | 0 | 2 | 90 |
3232
| `flow` | 34 | 0 | 6 | 0 | 40 |
33-
| `action` | 42 | 0 | 2 | 0 | 44 |
33+
| `action` | 42 | 0 | 2 | 2 | 46 |
3434
| `hook` | 18 | 0 | 2 | 0 | 20 |
3535
| `permission` | 38 | 0 | 4 | 0 | 42 |
3636
| `position` | 12 | 0 | 0 | 0 | 12 |
@@ -57,4 +57,4 @@ for both corollaries.
5757
| `api` | 25 | 0 | 0 | 2 | 27 |
5858
| `capability` | 12 | 0 | 0 | 0 | 12 |
5959
| `qa` | 4 | 0 | 5 | 0 | 9 |
60-
| **total** | **796** | **6** | **55** | **10** | **867** |
60+
| **total** | **796** | **6** | **55** | **12** | **869** |

‎packages/spec/scripts/strictness-ledger.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ describe('site counting reads the AST, not the source text', () => {
100100
expect(countSites(at('kernel/metadata-protection.zod.ts'))).toBe(0);
101101
expect(countSites(at('shared/suggestions.zod.ts'))).toBe(0);
102102
// And the case that mattered, because this file IS triaged: 9 → 8.
103-
expect(countSites(at('ui/action.zod.ts'))).toBe(8);
103+
// (8 → 9 at #9566, which ADDED the `onSuccess` strictObject site — the
104+
// count is incidental; what this case pins is that JSDoc examples are
105+
// not counted.)
106+
expect(countSites(at('ui/action.zod.ts'))).toBe(9);
104107
});
105108

106109
it('counts a call the source wraps across lines (`z\\n .object({`)', () => {
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// #9566 / #9474 — `onSuccess` post-success navigation (maintainer ruling
2+
// 2026-08-18, recorded on #9566): one CLOSED key covering both server-executing
3+
// action types, `navigate` (route/URL template whose scope gains `${result.*}`,
4+
// the server response) + `openIn: 'self' | 'newTab'` defaulting `'self'`.
5+
// These pins hold the ruled shape: the accept set, the materialized default,
6+
// the closed enum, the strict inner object, and the api/script type scope.
7+
import { describe, it, expect } from 'vitest';
8+
import { ActionSchema, InlineActionSchema } from './action.zod';
9+
import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas';
10+
11+
const base = { name: 'copy_as_new_version', label: 'Copy as new version' };
12+
13+
describe('ActionSchema.onSuccess (#9566/#9474)', () => {
14+
describe('accept pins', () => {
15+
it('accepts the full shape on a type:api action', () => {
16+
const r = ActionSchema.safeParse({
17+
...base,
18+
type: 'api',
19+
target: '/api/v1/actions/task_version/clone',
20+
onSuccess: { navigate: '/apps/mfg/task_version/${result.id}', openIn: 'newTab' },
21+
});
22+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
23+
expect((r.data as { onSuccess: unknown }).onSuccess)
24+
.toEqual({ navigate: '/apps/mfg/task_version/${result.id}', openIn: 'newTab' });
25+
});
26+
27+
it('accepts the minimal shape on a type:script action', () => {
28+
const r = ActionSchema.safeParse({
29+
...base,
30+
type: 'script',
31+
target: 'cloneVersion',
32+
onSuccess: { navigate: '/apps/mfg/task_version/${result.id}' },
33+
});
34+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
35+
});
36+
37+
it('reaches the same shape through the registered `action` metadata schema (the parsing door)', () => {
38+
const schema = getMetadataTypeSchema('action');
39+
expect(schema).toBeDefined();
40+
const r = schema!.safeParse({
41+
...base,
42+
type: 'api',
43+
target: '/api/v1/actions/task_version/clone',
44+
onSuccess: { navigate: '/apps/mfg/task_version/${result.id}' },
45+
});
46+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
47+
});
48+
});
49+
50+
describe('default pin — openIn materializes to self', () => {
51+
it('parse output carries openIn "self" when the author omits it', () => {
52+
// The ruled default is MATERIALIZED (`.default('self')`), so a consumer
53+
// reads the resolved member off the parse output and never needs its own
54+
// fallback — declared = enforced. This is the observable being pinned.
55+
const out = ActionSchema.parse({
56+
...base,
57+
type: 'api',
58+
target: '/api/v1/actions/task_version/clone',
59+
onSuccess: { navigate: '/x/${result.id}' },
60+
}) as { onSuccess?: { navigate: string; openIn: string } };
61+
expect(out.onSuccess?.openIn).toBe('self');
62+
});
63+
64+
it('an explicit openIn survives untouched', () => {
65+
const out = ActionSchema.parse({
66+
...base,
67+
type: 'script',
68+
target: 'cloneVersion',
69+
onSuccess: { navigate: '/x', openIn: 'newTab' },
70+
}) as { onSuccess?: { openIn: string } };
71+
expect(out.onSuccess?.openIn).toBe('newTab');
72+
});
73+
});
74+
75+
describe('refusal pins — the closed enum', () => {
76+
it('rejects an out-of-vocabulary openIn', () => {
77+
const r = ActionSchema.safeParse({
78+
...base,
79+
type: 'api',
80+
target: '/t',
81+
onSuccess: { navigate: '/x', openIn: 'modal' },
82+
});
83+
expect(r.success).toBe(false);
84+
});
85+
86+
it("names the camelCase member when the author writes the sibling key's kebab spelling", () => {
87+
// The top-level `openIn` (type:'url') spells its member 'new-tab'; the
88+
// handler-return convention and this key spell it 'newTab'. The enum's
89+
// error map catches exactly the crossover spelling (the
90+
// ActionLocationSchema issue.input precedent) — every other wrong value
91+
// keeps zod's own enum error.
92+
const r = ActionSchema.safeParse({
93+
...base,
94+
type: 'api',
95+
target: '/t',
96+
onSuccess: { navigate: '/x', openIn: 'new-tab' },
97+
});
98+
expect(r.success).toBe(false);
99+
const msg = r.error!.issues.map((i) => i.message).join('\n');
100+
expect(msg).toContain("'newTab'");
101+
expect(msg).toContain('new-tab');
102+
});
103+
});
104+
105+
describe('refusal pins — the strict inner object', () => {
106+
const innerIssue = (onSuccess: Record<string, unknown>) => {
107+
const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', onSuccess });
108+
expect(r.success).toBe(false);
109+
return r.error!.issues.find((i) => i.code === 'unrecognized_keys');
110+
};
111+
112+
it('rejects an undeclared key instead of silently dropping it', () => {
113+
const issue = innerIssue({ navigate: '/x', notAKey: 1 });
114+
expect(issue).toBeDefined();
115+
expect(issue!.message).toContain('`notAKey`');
116+
});
117+
118+
it("points the handler-return spelling `redirectUrl` at `navigate`", () => {
119+
expect(innerIssue({ redirectUrl: '/x' })!.message)
120+
.toContain('`redirectUrl` → `navigate`');
121+
});
122+
123+
it('points the generic destination spellings at `navigate`', () => {
124+
for (const key of ['url', 'to', 'route', 'path', 'target']) {
125+
expect(innerIssue({ [key]: '/x' })!.message)
126+
.toContain(`\`${key}\` → \`navigate\``);
127+
}
128+
});
129+
130+
it('tells an author reaching for `opensInNewTab` that the tab choice here is openIn', () => {
131+
expect(innerIssue({ navigate: '/x', opensInNewTab: true })!.message)
132+
.toContain("openIn: 'newTab'");
133+
});
134+
135+
it('requires `navigate` — an empty onSuccess block is not a declaration', () => {
136+
const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', onSuccess: {} });
137+
expect(r.success).toBe(false);
138+
});
139+
});
140+
141+
describe('type scope — api and script only (the #4352 enforcement shape)', () => {
142+
it.each(['url', 'modal', 'flow', 'form'] as const)('refuses onSuccess on a type:%s action', (type) => {
143+
const r = ActionSchema.safeParse({
144+
...base,
145+
type,
146+
target: type === 'form' ? 'edit_form' : '/t',
147+
onSuccess: { navigate: '/x' },
148+
});
149+
expect(r.success).toBe(false);
150+
const msg = r.error!.issues.map((i) => i.message).join('\n');
151+
expect(msg).toContain('onSuccess');
152+
expect(msg).toContain("'api'");
153+
expect(msg).toContain("'script'");
154+
});
155+
});
156+
157+
describe('the pre-existing probes now land on prescriptions, not bare rejections (#9474)', () => {
158+
it('a top-level `redirect` names the onSuccess shape', () => {
159+
const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', redirect: '/x' });
160+
expect(r.success).toBe(false);
161+
const issue = r.error!.issues.find((i) => i.code === 'unrecognized_keys');
162+
expect(issue!.message).toContain('onSuccess');
163+
expect(issue!.message).toContain('navigate');
164+
});
165+
166+
it('a top-level `redirectUrl` says it is the handler-return convention, not an authorable key', () => {
167+
const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', redirectUrl: '/x' });
168+
expect(r.success).toBe(false);
169+
const issue = r.error!.issues.find((i) => i.code === 'unrecognized_keys');
170+
expect(issue!.message).toContain('HANDLER-RETURN');
171+
expect(issue!.message).toContain('onSuccess');
172+
});
173+
});
174+
175+
describe('strictness posture unchanged elsewhere', () => {
176+
it('InlineActionSchema does not pick onSuccess — unknown key inline', () => {
177+
// The inline surface widens when a renderer widens (the file's own rule);
178+
// element:button's forward list has no onSuccess hop, so the key is
179+
// registered-actions-only until the objectui half lands.
180+
const r = InlineActionSchema.safeParse({
181+
type: 'api',
182+
target: '/t',
183+
onSuccess: { navigate: '/x' },
184+
});
185+
expect(r.success).toBe(false);
186+
});
187+
188+
it('an action WITHOUT onSuccess still parses exactly as before', () => {
189+
const out = ActionSchema.parse({ ...base, type: 'api', target: '/t' }) as Record<string, unknown>;
190+
expect('onSuccess' in out && out.onSuccess !== undefined).toBe(false);
191+
});
192+
});
193+
});

0 commit comments

Comments
 (0)