Skip to content

Commit 387e231

Browse files
os-warrenclaude
andauthored
feat(spec,runtime): refuse the doubled post-success navigation channel on script actions (#11841)
* feat(spec,runtime): refuse the doubled post-success navigation channel (#11519) A type:'script' action could carry two post-success destinations — the declared ActionSchema.onSuccess block and the handler-returned { redirectUrl } — with the spec ruling neither, leaving renderer-side precedence to decide silently (interim: declared wins, objectui#5933). Maintainer ruling 2026-08-24: refuse the doubled channel; no precedence field. Measured static knowability partitions the fix: - Statically knowable half: opensInNewTab: true is the schema-visible marker of the handler-redirect channel, so onSuccess beside it on a script action is refused at authoring time by a new refine, with guidance naming both channels and the remedy. - Runtime-only remainder: a handler that returns redirectUrl with no marker is diagnosed loudly at the dispatch seam (doubledPostSuccessNavigationWarning), wired at both surfaces that hold the declaration and the handler result — the REST /actions route and the MCP run_action bridge. Observe-only: the wire is untouched and the interim renderer precedence stays the decider until the author takes the remedy. Single-channel cases (only onSuccess, only opensInNewTab, opensInNewTab + newTabUrl) stay accepted byte-identically, pinned; the corpus was measured at zero doubled producers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * chore: changeset for the doubled post-success navigation refusal (#11519) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * test(runtime): typecheck-clean annotations for the doubled-redirect pins The runtime TEST_DEBT ledger entry is frozen at 227 raw tsc errors with the test exclusion removed; the new test file initially owed 8 (TS18048 x2, TS7006 x6). Annotated the spy-call lambdas and optional-chained the dispatcher response so the scoped re-measure reads exactly 227 with zero attributed to this file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b706af9 commit 387e231

6 files changed

Lines changed: 475 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(spec,runtime): refuse the doubled post-success navigation channel on a `type: 'script'` action (#11519)
7+
8+
**BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under
9+
the repo's launch-window convention for breaking changes.
10+
11+
Two independent channels could name a post-success destination for one
12+
`type: 'script'` action: the declared `onSuccess` block (`{ navigate, openIn }`,
13+
validated and visible in metadata) and the handler-returned `{ redirectUrl }`
14+
convention (runtime-only). The spec ruled each surface's default in isolation
15+
and said nothing about an action carrying both — so the renderer had to pick,
16+
and the pick lived only in one renderer's implementation (declared `onSuccess`
17+
wins, objectstack-ai/objectui#5933). Maintainer ruling 2026-08-24: refuse the
18+
doubled channel; ⛔ no `precedence` contract field.
19+
20+
The measured static-knowability partition:
21+
22+
- **Authoring-time refine (spec):** "the handler can return `redirectUrl`" is
23+
runtime-only in general (`target` names an opaque registry entry;
24+
`HookBodySchema` declares no return contract) — but `opensInNewTab: true` is
25+
a schema-visible declaration of the handler-redirect channel (its contract is
26+
"pre-open a tab, then drive it to the handler's returned `redirectUrl`").
27+
A `type: 'script'` action declaring `onSuccess` beside `opensInNewTab: true`
28+
is now **rejected at parse time**, with guidance naming both channels and the
29+
remedy. Previously the pair parsed clean and one declaration was silently
30+
dead at render.
31+
- **Dispatch-seam diagnostic (runtime):** the runtime-only remainder — a
32+
handler that actually returns `{ redirectUrl }` while the action declares
33+
`onSuccess` — now logs a loud `[action-contract]` warning at both dispatch
34+
surfaces (the REST `/actions` route and the MCP `run_action` bridge), naming
35+
the action, both channels, the interim winner and the remedy. Observe-only:
36+
the wire is untouched and the interim renderer precedence stands until the
37+
author takes the remedy.
38+
39+
Single-channel declarations are untouched and pinned byte-identically: only
40+
`onSuccess`, only `opensInNewTab` (with or without `newTabUrl`), and
41+
`opensInNewTab: false` beside `onSuccess` all parse exactly as before. The
42+
corpus was measured at zero doubled producers (this repo's examples and
43+
platform metadata, objectui metadata, and the cloud SSO handoff producers per
44+
the #11519 measurement), so no shipped metadata is affected.
45+
46+
**Migration.** An action refused by the new refine must pick its one
47+
destination: keep `onSuccess` and drop `opensInNewTab` (and stop returning
48+
`redirectUrl` from the handler), or keep `opensInNewTab` + the handler
49+
redirect and drop `onSuccess`. Which channel is right is an authoring decision
50+
the metadata cannot make for you, and zero such actions exist in any measured
51+
corpus.
52+
53+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over a pair of existing keys: no key is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. The refusal is the channel that reaches an affected author, at the parse site, carrying the remedy; choosing which of the two declared destinations to keep is an authoring decision no migration entry can perform on an upgrader's behalf — and the measured population of affected sources is zero in every corpus. -->

packages/runtime/src/action-execution.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,6 +1244,11 @@ export async function invokeBusinessAction(deps: ActionExecutionDeps,
12441244
if (!dispatch.dispatched) {
12451245
throw new Error(`No handler registered for action '${name}' on '${objectName}'`);
12461246
}
1247+
// [#11519] Same doubled post-success-navigation diagnostic as the REST
1248+
// seam — the defect is a property of the authored action + handler pair,
1249+
// observable wherever the two meet. Observe-only; the result is untouched.
1250+
const doubled = doubledPostSuccessNavigationWarning(deps, action, dispatch.result, objectName);
1251+
if (doubled) console.warn(doubled);
12471252
return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), result: dispatch.result ?? null };
12481253
}
12491254

@@ -1358,6 +1363,59 @@ export function isActionNotRegisteredError(err: any): boolean {
13581363
}
13591364

13601365

1366+
/**
1367+
* [#11519] The DOUBLED post-success-navigation diagnostic — the runtime half
1368+
* of the maintainer's 2026-08-24 ruling (refuse the doubled channel; ⛔ no
1369+
* `precedence` contract field).
1370+
*
1371+
* Two channels can name a post-success destination for one `type: 'script'`
1372+
* action: the declared `ActionSchema.onSuccess` block, and the
1373+
* handler-returned `{ redirectUrl }` convention. The statically-knowable half
1374+
* (`onSuccess` beside `opensInNewTab: true`, the schema-visible marker of the
1375+
* handler-redirect channel) is refused at parse time by `@objectstack/spec`.
1376+
* This helper covers the remainder no schema can see — "the handler returns
1377+
* `redirectUrl`" is runtime-only knowledge (`target` names an opaque registry
1378+
* entry; `HookBodySchema` declares no return contract) — at the one seam
1379+
* where both channels are finally in hand: the script dispatch, holding the
1380+
* resolved declaration AND the handler's return value.
1381+
*
1382+
* Returns the warning text on the doubled case, `null` otherwise; the caller
1383+
* logs it (the `actionPermissionError` string-or-null convention). It only
1384+
* OBSERVES — the result still reaches the client intact, and the interim
1385+
* renderer precedence (declared `onSuccess` wins, objectui#5933) still
1386+
* decides the navigation until the author takes the remedy the warning
1387+
* names. `warn`, not `error`, by the degradation-log-level rule: nothing
1388+
* claimed-persisted is lost, and the system is visibly navigating — to the
1389+
* declared destination.
1390+
*
1391+
* Both dispatch surfaces call it — the REST `/actions` route and the MCP
1392+
* `run_action` bridge — because the defect it names is a property of the
1393+
* AUTHORED action + handler pair, observable wherever the two meet, not of
1394+
* whichever caller happened to invoke it.
1395+
*/
1396+
export function doubledPostSuccessNavigationWarning(
1397+
_deps: ActionExecutionDeps,
1398+
actionDef: any,
1399+
result: unknown,
1400+
objectName?: string,
1401+
): string | null {
1402+
const navigate: unknown = actionDef?.onSuccess?.navigate;
1403+
if (typeof navigate !== 'string' || navigate.length === 0) return null;
1404+
if (!result || typeof result !== 'object' || Array.isArray(result)) return null;
1405+
const redirectUrl: unknown = (result as Record<string, unknown>).redirectUrl;
1406+
if (typeof redirectUrl !== 'string' || redirectUrl.length === 0) return null;
1407+
const where = objectName ? `${objectName}/${actionDef?.name ?? '<unnamed>'}` : String(actionDef?.name ?? '<unnamed>');
1408+
return (
1409+
`[action-contract] Action '${where}': the handler returned \`redirectUrl\` while the action `
1410+
+ 'also declares `onSuccess.navigate` — two post-success destinations for one success '
1411+
+ '(#11519). The DECLARED `onSuccess` wins and the handler\'s `redirectUrl` is ignored '
1412+
+ '(interim renderer precedence, objectui#5933). Fix the action, not the renderer: keep '
1413+
+ '`onSuccess` and stop returning `redirectUrl` from the handler, or drop `onSuccess` and '
1414+
+ 'let the handler return drive the navigation. There is no `precedence` field, by ruling.'
1415+
);
1416+
}
1417+
1418+
13611419
/**
13621420
* [ADR-0110 D2] Run a script/body action through the engine's handler
13631421
* registry: rotate the derived key candidates across the object-key rotation

packages/runtime/src/domains/actions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,13 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string
432432
response: deps.error(`Action '${actionName}' on object '${objectName}' not found`, 404),
433433
};
434434
}
435+
// [#11519] Doubled post-success navigation — the handler returned
436+
// `redirectUrl` while the declaration carries `onSuccess`. The one
437+
// seam holding both channels; observe LOUDLY, never rewrite the wire
438+
// (the interim renderer precedence, declared wins per objectui#5933,
439+
// stays the decider until the author takes the remedy).
440+
const doubled = actionExec.doubledPostSuccessNavigationWarning(deps, actionDef, result, objectName);
441+
if (doubled) console.warn(doubled);
435442
// [#3962] Single wrap: `data` is the handler's return value, exactly as
436443
// every other domain serializes. The former inner `{success, data}`
437444
// envelope existed only to carry a failure signal at HTTP 200; failures
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* REST `/actions/:object/:action` — the DOUBLED post-success-navigation
5+
* diagnostic (#11519, maintainer ruling 2026-08-24).
6+
*
7+
* Two channels can name a post-success destination for one `type: 'script'`
8+
* action: the declared `ActionSchema.onSuccess` block, and the
9+
* handler-returned `{ redirectUrl }`. The statically-knowable half
10+
* (`onSuccess` + `opensInNewTab: true`) is refused at parse time by
11+
* `@objectstack/spec`; this file pins the RUNTIME half — the case no schema
12+
* can see, because "the handler returns `redirectUrl`" is runtime-only
13+
* knowledge (`target` names an opaque registry entry, `HookBodySchema`
14+
* declares no return contract). The seam where the two channels finally meet
15+
* is the script dispatch: the resolved declaration (carrying `onSuccess`) and
16+
* the handler's return value are both in hand, so the doubled case is
17+
* diagnosed LOUDLY there instead of being resolved silently by renderer-side
18+
* precedence.
19+
*
20+
* The diagnostic never alters the wire: the handler's return value still
21+
* reaches the client intact, and the interim renderer precedence (declared
22+
* `onSuccess` wins, objectui#5933) still decides the navigation until the
23+
* author takes the remedy the warning names.
24+
*/
25+
26+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27+
import { HttpDispatcher } from './http-dispatcher.js';
28+
import { doubledPostSuccessNavigationWarning } from './action-execution.js';
29+
30+
const scriptAction = {
31+
name: 'open_portal',
32+
label: 'Open portal',
33+
objectName: 'crm_lead',
34+
type: 'script',
35+
target: 'openPortal',
36+
onSuccess: { navigate: '/apps/crm/leads/${result.id}', openIn: 'self' },
37+
};
38+
39+
function makeDispatcher(opts: { objectDef?: any; handlerResult?: unknown } = {}) {
40+
const executeAction = vi.fn(async () => opts.handlerResult ?? { ran: 'script' });
41+
const objectDef = opts.objectDef ?? { name: 'crm_lead', actions: [scriptAction] };
42+
const ql: any = {
43+
executeAction,
44+
getSchema: (name: string) => (name === objectDef.name ? objectDef : undefined),
45+
registry: {
46+
getObject: (name: string) => (name === objectDef.name ? objectDef : undefined),
47+
getItem: () => undefined,
48+
},
49+
find: vi.fn(async () => []),
50+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
51+
};
52+
const metadata: any = {
53+
load: vi.fn(async () => null),
54+
loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })),
55+
listObjects: vi.fn(async () => [objectDef]),
56+
getObject: vi.fn(async () => objectDef),
57+
};
58+
const kernel: any = {
59+
context: {
60+
getService: (n: string) =>
61+
n === 'objectql' || n === 'data' ? ql
62+
: n === 'metadata' ? metadata
63+
: null,
64+
},
65+
};
66+
return { dispatcher: new HttpDispatcher(kernel), executeAction };
67+
}
68+
69+
const ctxFor = (): any => ({
70+
request: {},
71+
environmentId: 'platform',
72+
executionContext: { userId: 'u1', systemPermissions: [] },
73+
});
74+
75+
describe('REST /actions — doubled post-success navigation diagnostic (#11519)', () => {
76+
let warnSpy: ReturnType<typeof vi.spyOn>;
77+
beforeEach(() => {
78+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
79+
});
80+
afterEach(() => {
81+
warnSpy.mockRestore();
82+
});
83+
84+
it('warns LOUDLY when the handler returns redirectUrl while the action declares onSuccess', async () => {
85+
const { dispatcher } = makeDispatcher({
86+
handlerResult: { redirectUrl: 'https://idp.example.com/handoff' },
87+
});
88+
89+
const res = await dispatcher.handleActions('/crm_lead/open_portal', 'POST', {}, ctxFor());
90+
91+
expect(res.response?.status).toBe(200);
92+
const doubled = warnSpy.mock.calls
93+
.map((c: unknown[]) => c.join(' '))
94+
.filter((line: string) => line.includes('[action-contract]'));
95+
expect(doubled).toHaveLength(1);
96+
// The warning names the action, BOTH channels, the interim winner and
97+
// the remedy — that is what "loud" means here.
98+
expect(doubled[0]).toContain("'crm_lead/open_portal'");
99+
expect(doubled[0]).toContain('onSuccess');
100+
expect(doubled[0]).toContain('redirectUrl');
101+
expect(doubled[0]).toContain('objectui#5933');
102+
expect(doubled[0]).toContain('#11519');
103+
});
104+
105+
it('does NOT alter the wire — the handler return value still reaches the client intact', async () => {
106+
const { dispatcher } = makeDispatcher({
107+
handlerResult: { redirectUrl: 'https://idp.example.com/handoff', ticket: 't_1' },
108+
});
109+
110+
const res = await dispatcher.handleActions('/crm_lead/open_portal', 'POST', {}, ctxFor());
111+
112+
// Single wrap (#3962): `data` IS the handler's return value. The
113+
// diagnostic observes; the interim renderer precedence (declared wins,
114+
// objectui#5933) stays the decider until the remedy is taken.
115+
expect(res.response?.body.data).toEqual({
116+
redirectUrl: 'https://idp.example.com/handoff',
117+
ticket: 't_1',
118+
});
119+
});
120+
121+
it('stays SILENT when only onSuccess is declared (handler returns no redirectUrl)', async () => {
122+
const { dispatcher } = makeDispatcher({ handlerResult: { ok: true } });
123+
124+
await dispatcher.handleActions('/crm_lead/open_portal', 'POST', {}, ctxFor());
125+
126+
expect(warnSpy.mock.calls.map((c: unknown[]) => c.join(' '))
127+
.filter((line: string) => line.includes('[action-contract]'))).toHaveLength(0);
128+
});
129+
130+
it('stays SILENT when only the handler-redirect channel is used (no onSuccess declared)', async () => {
131+
const single = { ...scriptAction, onSuccess: undefined };
132+
const { dispatcher } = makeDispatcher({
133+
objectDef: { name: 'crm_lead', actions: [single] },
134+
handlerResult: { redirectUrl: 'https://idp.example.com/handoff' },
135+
});
136+
137+
await dispatcher.handleActions('/crm_lead/open_portal', 'POST', {}, ctxFor());
138+
139+
expect(warnSpy.mock.calls.map((c: unknown[]) => c.join(' '))
140+
.filter((line: string) => line.includes('[action-contract]'))).toHaveLength(0);
141+
});
142+
});
143+
144+
describe('doubledPostSuccessNavigationWarning — predicate pins (#11519)', () => {
145+
const deps: any = {};
146+
const decl = { name: 'open_portal', onSuccess: { navigate: '/x', openIn: 'self' } };
147+
148+
it('fires exactly on the doubled pair', () => {
149+
const msg = doubledPostSuccessNavigationWarning(deps, decl, { redirectUrl: '/y' }, 'crm_lead');
150+
expect(msg).toBeTruthy();
151+
expect(msg).toContain('[action-contract]');
152+
});
153+
154+
it.each([
155+
['no declaration', undefined, { redirectUrl: '/y' }],
156+
['declaration without onSuccess', { name: 'a' }, { redirectUrl: '/y' }],
157+
['onSuccess without navigate', { onSuccess: {} }, { redirectUrl: '/y' }],
158+
['non-object result', decl, 'https://x'],
159+
['array result', decl, [{ redirectUrl: '/y' }]],
160+
['result without redirectUrl', decl, { ok: true }],
161+
['empty redirectUrl', decl, { redirectUrl: '' }],
162+
['non-string redirectUrl', decl, { redirectUrl: 42 }],
163+
['null result', decl, null],
164+
])('stays null on %s', (_label, actionDef, result) => {
165+
expect(doubledPostSuccessNavigationWarning(deps, actionDef, result, 'crm_lead')).toBeNull();
166+
});
167+
});

0 commit comments

Comments
 (0)