Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/action-onsuccess-navigation.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (accept-set expansion) One new CLOSED optional key
on an existing shape; nothing authorable is renamed, retired or tombstoned, so
there is no conversion to register. Previously-refused spellings stay refused —
three of them now carry guidance pointing at the new key. -->

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.
1 change: 1 addition & 0 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; 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. |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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

Expand All @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -156,15 +156,15 @@ 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 |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 18 |
| `view.zod.ts` | 3 | 56 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **6** | **175** |
| **total** | **6** | **176** |

| Bucket | Sites |
|---|---|
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions packages/spec/liveness/action.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@
"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": {
"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",
"note": "PARTIAL — honored by a few objectui renderers, not the core action buttons/menus."
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/state-counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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** |
5 changes: 4 additions & 1 deletion packages/spec/scripts/strictness-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({`)', () => {
Expand Down
193 changes: 193 additions & 0 deletions packages/spec/src/ui/action-on-success.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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<string, unknown>;
expect('onSuccess' in out && out.onSuccess !== undefined).toBe(false);
});
});
});
Loading
Loading