Skip to content

Commit 39224eb

Browse files
committed
fix(spec): prescribe per action type when object-form params is refused (#6828)
`params` has always been `z.array(ActionParamSchema)`, so the object form has always been refused. #5777 replaced the unactionable "expected array, received object" with a message naming `bodyExtra` — right for a `type:'api'` action's static request body, and wrong for every other type. On a `type:'url'` action the object form meant a third thing: objectui's ActionRunner read a non-array `params` as the `${param.X}` interpolation scope for `target`, and `params.newTab` as a legacy new-tab flag. Neither is a request-body key, which is why the `inline-action-api-params-to-body-extra` conversion guards on `type === 'api'` (rewriting a url action's object `params` would be lossy; ADR-0087 D2 requires losslessness). The maintainer's 2026-08-10 ruling retires the url meaning rather than giving it a key — both halves already have sanctioned spellings: put static values in the `target` string, and open a new tab with the declared `openIn: 'new-tab'`. The refusal message now carries both arms, and the authoring docs state the refusal where inline and url actions are described. No acceptance-face movement: `params` is still `z.array(ActionParamSchema)`, the object form is still refused with `invalid_type` at path `params`, and the array form still parses on every action type. Message and docs only. Pinned on both arms and on both the inline and registered surfaces (`inline-action.test.ts`). Reverse-verified: reverting the message turns the three url-arm pins RED on message content. Closes #6828 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vsi2JQ41Z9ct1z8jC4MjVW
1 parent f188ed6 commit 39224eb

5 files changed

Lines changed: 221 additions & 2 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the object-form `params` refusal prescribes per action type — `bodyExtra` for `api`, `target` interpolation + `openIn` for `url` (#6828)
6+
7+
`params` has always been `z.array(ActionParamSchema)`, so writing it as an object
8+
has always been refused. What changed in #5777 is the *message*: it stopped being
9+
the unactionable "expected array, received object" and started naming
10+
`bodyExtra`, the key the maintainer's 2026-08-06 ruling created for a
11+
`type:'api'` action's static request body.
12+
13+
That prescription is right for exactly one action type. On a `type:'url'` action
14+
the object form meant something else entirely — objectui's `ActionRunner` read a
15+
non-array `params` as the `${param.X}` interpolation scope for `target`, and
16+
`params.newTab` as a legacy new-tab flag. Telling that author to use `bodyExtra`
17+
sent them to an api request-body key that is neither an interpolation scope nor a
18+
new-tab control. (The same asymmetry is why the
19+
`inline-action-api-params-to-body-extra` conversion guards on `type === 'api'`:
20+
rewriting a url action's object `params` into `bodyExtra` would be lossy, and
21+
ADR-0087 D2 requires losslessness.)
22+
23+
The maintainer's 2026-08-10 ruling on #6828 **retired the url meaning** rather
24+
than giving it a key — a key with three meanings and no authorized spelling for
25+
the third is the de-facto-contract shape AGENTS.md #0.1 forbids, the schema
26+
already refuses it, and nothing in the reachable corpus authors it. Both halves
27+
already have sanctioned spellings:
28+
29+
| Retired reading | Sanctioned spelling |
30+
|:---|:---|
31+
| statically authored `${param.X}` scope | put the value in the `target` string itself (`${param.X}` interpolates what the params **dialog** collected; `${ctx.X}` the action context) |
32+
| `params.newTab` | `openIn: 'new-tab'` (declared, and already read with priority by the runner) |
33+
34+
So the refusal message now carries both arms, and the authoring docs
35+
(`ui/actions`, `protocol/objectui/actions`) state the refusal where inline and
36+
url actions are described.
37+
38+
**No acceptance-face movement**: `params` is still `z.array(ActionParamSchema)`,
39+
the object form is still refused with `invalid_type` at path `params`, and the
40+
array form still parses on every action type. This is a message-and-docs change —
41+
hence `patch` — pinned on both arms and on both the inline and registered
42+
surfaces.
43+
44+
The two objectui reads this ruling makes dead vocabulary (`interpolateTarget`'s
45+
non-array `params` scope, and the `params.newTab` escape hatch) are objectui's
46+
card, filed contract-first behind this one.

content/docs/protocol/objectui/actions.mdx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,26 @@ type: url
9292
target: '/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account'
9393
```
9494

95-
To open the result in a new tab, set `opensInNewTab: true` (the renderer pre-opens the tab synchronously so popup blockers don't fire). `newTabUrl` provides a zero-roundtrip new-tab target template supporting the `{recordId}` placeholder.
95+
For a **static** url, `openIn: 'new-tab'` opens `target` in a new tab and `openIn: 'self'` navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an **async handler that redirects**, set `opensInNewTab: true` instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); `newTabUrl` provides a zero-roundtrip new-tab target template supporting the `{recordId}` placeholder.
96+
97+
<Callout type="warn">
98+
**The interpolation scope is not authorable, and `params` is not it.** `params`
99+
is the parameter DEFINITION array — `ActionParam[]`, the dialog shown before
100+
the action runs — on every action type, so `params: { id: 'abc' }` on a url
101+
action is rejected at parse time. `${param.X}` resolves against the values that
102+
dialog **collected**; a value you already know at authoring time belongs
103+
literally in the `target` string.
104+
105+
Two url-side readings of an object-form `params` existed in the renderer and
106+
were **retired** by the 2026-08-10 ruling on
107+
[#6828](https://github.com/objectstack-ai/objectstack/issues/6828) rather than
108+
given a key: a statically authored `${param.X}` scope (say it in `target`), and
109+
`params.newTab` (say it with `openIn: 'new-tab'`). The refusal message names
110+
both replacements. Note the asymmetry with `type: 'api'`, where the object form
111+
*did* get a key — `bodyExtra`, per
112+
[#5777](https://github.com/objectstack-ai/objectstack/issues/5777) — because a
113+
request payload has no other spelling; a url interpolation scope does.
114+
</Callout>
96115

97116
### Flow Actions
98117

content/docs/ui/actions.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,26 @@ construction — exist at all.
249249
(generated tokens, export links).
250250
- **`variant` / `icon` / `order`** — presentation and sort position.
251251

252+
<Callout type="warn">
253+
**`params` is an array of parameter *definitions*, never a map of values.**
254+
Writing `params: { … }` is rejected at authoring time, and what to write
255+
instead depends on the action's `type`:
256+
257+
| You wanted | Write this instead |
258+
|:---|:---|
259+
| A static request body for `type: 'api'` | `bodyExtra: { name: '{{page.inquiryName}}' }` — merged last, `{{page.<var>}}` tokens resolved by the runtime |
260+
| A value to interpolate into a `type: 'url'` `target` | Put it in the `target` string itself. `${param.X}` interpolates a value the params **dialog** collected; `${ctx.X}` one from the action context |
261+
| A new tab for `type: 'url'` | `openIn: 'new-tab'` (for an *async* handler that redirects, use `opensInNewTab` instead) |
262+
263+
There is no object form of `params` on a url action to migrate to: the two
264+
things it used to mean in the renderer — a statically authored `${param.X}`
265+
scope, and a `params.newTab` flag — were **retired**, not renamed
266+
([#6828](https://github.com/objectstack-ai/objectstack/issues/6828)). Both are
267+
already expressible with the keys above, so a third meaning of `params` earns
268+
nothing; if you have a case the `target` string genuinely cannot express, that
269+
is a spec proposal for a properly named key, not a values map under this one.
270+
</Callout>
271+
252272
## Permissions and visibility
253273

254274
- **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate**

packages/spec/src/ui/action.zod.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,15 +903,44 @@ const actionObject = () => strictObject({
903903
* bare "expected array, received object" an author cannot act on. Sources
904904
* still carrying the object form are rewritten at load by the
905905
* `inline-action-api-params-to-body-extra` conversion (ADR-0087 D2).
906+
*
907+
* **The api prescription is not universal, which #6828 measured and the
908+
* maintainer's 2026-08-10 ruling closed.** On a `type:'url'` action the
909+
* object form meant a THIRD thing again — objectui's `ActionRunner` read a
910+
* non-array `params` as the `${param.X}` interpolation scope for `target`,
911+
* and `params.newTab` as a legacy new-tab flag. Sending that author to
912+
* `bodyExtra` is a wrong instruction: an api request-body key is not an
913+
* interpolation scope (the same asymmetry is why the conversion above guards
914+
* on `type === 'api'` — rewriting a url action's object `params` would be
915+
* lossy, and ADR-0087 D2 requires losslessness). The ruling **retired** the
916+
* url meaning rather than giving it a key: the scope is already expressible
917+
* as `target`-string interpolation, and the flag is already {@link openIn}.
918+
* So the refusal below prescribes per action type — `bodyExtra` for `api`,
919+
* the sanctioned url spellings for `url` — and nothing new enters the
920+
* vocabulary. A future authorable interpolation-scope key needs a spec
921+
* proposal that demonstrates pull, not a third arm of this one.
922+
*
923+
* The branch is stated IN THE TEXT rather than selected at runtime because
924+
* zod cannot see a sibling from a property-level error map: the map receives
925+
* only `{ code, expected, input, inst, path }` for the offending value, and
926+
* an object-level `.check()`/`.superRefine()` — which would see `type` — is
927+
* skipped once a property has already failed (probed on zod 4.4.3). Reading
928+
* `type` here would mean restructuring `ActionSchema` behind a
929+
* `z.preprocess`, which erases `z.input<typeof ActionSchema>` (the authoring
930+
* type `defineAction` publishes) — a far larger change than the guidance
931+
* defect warrants, and one that moves surfaces this issue must not move.
906932
*/
907933
params: z.array(ActionParamSchema, {
908934
error: (iss) => (
909935
iss.code === 'invalid_type'
910936
&& iss.input !== null
911937
&& typeof iss.input === 'object'
912938
&& !Array.isArray(iss.input)
913-
? "`params` is the parameter DEFINITION array (fields collected from the user before the action runs), not the request payload. "
939+
? "`params` is the parameter DEFINITION array (fields collected from the user before the action runs), not a values map. "
914940
+ "For a `type:'api'` action's static request body — including `{{page.<var>}}` tokens — use `bodyExtra: { … }` instead (#5777). "
941+
+ "For a `type:'url'` action there is nowhere to move it to, by decision: put static values straight into the `target` string "
942+
+ "(`${param.X}` interpolates a value collected by the params dialog, `${ctx.X}` one from the action context), and open a new tab with "
943+
+ "`openIn: 'new-tab'`. The url-side readings of an object `params` — a static `${param.X}` scope, and `params.newTab` — are RETIRED, not renamed (#6828). "
915944
+ 'Expected an array of ActionParam, received an object.'
916945
: undefined
917946
),

packages/spec/src/ui/inline-action.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,111 @@ describe('InlineActionSchema — `bodyExtra` is the payload key, `params` is not
205205
});
206206
});
207207

208+
/**
209+
* #6828 — the THIRD meaning of `params`, and why one prescription is not enough.
210+
*
211+
* #5777 settled the `type:'api'` half: the object form means the request
212+
* payload, and the payload moves to `bodyExtra`. It did not settle the
213+
* `type:'url'` half, where objectui's `ActionRunner` read a non-array `params`
214+
* as the `${param.X}` interpolation scope for `target` and `params.newTab` as a
215+
* legacy new-tab flag. So between #5777 and this card the refusal told a url
216+
* author to use `bodyExtra` — an api request-body key, which is not an
217+
* interpolation scope and cannot carry a new-tab flag. Wrong instruction, and
218+
* the exact one the maintainer's 2026-08-10 ruling on #6828 names when it
219+
* retires the url meaning instead of giving it a key.
220+
*
221+
* Both arms are pinned, on both surfaces, by path + code + message content: a
222+
* regression that drops either arm is a wrong instruction again, and the bare
223+
* "expected array, received object" would keep a `toThrow()`-shaped test green.
224+
*/
225+
describe('object-form `params` prescribes per action type (#6828)', () => {
226+
/** The `params` issue a parse produced, or a readable failure if it produced none. */
227+
const paramsIssue = (r: { success: boolean; error?: z.ZodError }) => {
228+
expect(r.success).toBe(false);
229+
const issues = r.error!.issues;
230+
const issue = issues.find(i => i.path.join('.') === 'params');
231+
expect(issue, JSON.stringify(issues)).toBeDefined();
232+
return issue!;
233+
};
234+
235+
it("sends a `type:'url'` action to `target` interpolation and `openIn`, NOT to `bodyExtra`", () => {
236+
// The shape the issue measured: the interpolation scope half. `target`
237+
// already carries the token, and the scope is the only thing missing a
238+
// spelling — which the ruling declines to add.
239+
const r = InlineActionSchema.safeParse({
240+
type: 'url',
241+
target: '/x?id=${param.id}',
242+
params: { id: 'abc' },
243+
}) as { success: boolean; error?: z.ZodError };
244+
const issue = paramsIssue(r);
245+
expect(issue.code).toBe('invalid_type');
246+
expect((issue as unknown as { expected: string }).expected).toBe('array');
247+
expect(issue.message).toContain('`target` string');
248+
expect(issue.message).toContain("openIn: 'new-tab'");
249+
expect(issue.message).toContain('RETIRED, not renamed (#6828)');
250+
});
251+
252+
it('names the retired `params.newTab` escape hatch, so the flag half has an answer too', () => {
253+
// The second url reading: `executeUrl` read `params.newTab` below `openIn`
254+
// in priority. `openIn` is the declared key, so this half is a plain
255+
// deprecation — but only if the message says so where the author lands.
256+
const r = InlineActionSchema.safeParse({
257+
type: 'url',
258+
target: 'https://example.com/pricing',
259+
params: { newTab: true },
260+
}) as { success: boolean; error?: z.ZodError };
261+
const issue = paramsIssue(r);
262+
expect(issue.code).toBe('invalid_type');
263+
expect(issue.message).toContain('params.newTab');
264+
expect(issue.message).toContain("openIn: 'new-tab'");
265+
});
266+
267+
it("keeps the `type:'api'` arm intact — `bodyExtra` is still the payload prescription", () => {
268+
// The url arm is additive. #5777's answer must not be displaced by it:
269+
// the api author reaching for a payload still gets the key that holds one.
270+
const r = InlineActionSchema.safeParse({
271+
type: 'api',
272+
target: '/api/v1/forms/contact-us/submit',
273+
params: { name: '{{page.inquiryName}}' },
274+
}) as { success: boolean; error?: z.ZodError };
275+
const issue = paramsIssue(r);
276+
expect(issue.code).toBe('invalid_type');
277+
expect(issue.message).toContain('bodyExtra: { … }');
278+
expect(issue.message).toContain('(#5777)');
279+
});
280+
281+
it('guides the REGISTERED action surface identically — the field factory is shared', () => {
282+
// `params` is declared once on `actionObject()`, so a registered `type:'url'`
283+
// action must land on the same guidance. Pinned because a future fix that
284+
// moved the branch onto `InlineActionSchema` alone would silently leave the
285+
// registry surface on the api-only prescription.
286+
const r = ActionSchema.safeParse({
287+
name: 'open_pricing',
288+
label: 'Pricing',
289+
type: 'url',
290+
target: '/pricing?plan=${param.plan}',
291+
params: { plan: 'pro' },
292+
}) as { success: boolean; error?: z.ZodError };
293+
const issue = paramsIssue(r);
294+
expect(issue.code).toBe('invalid_type');
295+
expect(issue.message).toContain('RETIRED, not renamed (#6828)');
296+
expect(issue.message).toContain('bodyExtra: { … }');
297+
});
298+
299+
it('leaves the definition-array meaning of `params` accepted on a url action', () => {
300+
// The acceptance face does not move: the array form was always the one
301+
// meaning, and a url action collecting input still interpolates `${param.X}`
302+
// from the dialog. This is the sanctioned spelling the message points at.
303+
const r = InlineActionSchema.safeParse({
304+
type: 'url',
305+
target: '/api/v1/auth/sign-in/social?provider=${param.provider}',
306+
params: [{ name: 'provider', label: 'Provider', type: 'text' }],
307+
openIn: 'new-tab',
308+
});
309+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
310+
});
311+
});
312+
208313
describe('normalizeInlineAction — the legacy spellings cloud actually writes', () => {
209314
it('folds the exact shape in cloud service-tenant pages', () => {
210315
// packages/service-tenant/src/pages/{pricing,welcome,billing-cancel,billing-success}

0 commit comments

Comments
 (0)