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
46 changes: 46 additions & 0 deletions .changeset/action-params-url-refusal-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@objectstack/spec": patch
---

fix(spec): the object-form `params` refusal prescribes per action type — `bodyExtra` for `api`, `target` interpolation + `openIn` for `url` (#6828)

`params` has always been `z.array(ActionParamSchema)`, so writing it as an object
has always been refused. What changed in #5777 is the *message*: it stopped being
the unactionable "expected array, received object" and started naming
`bodyExtra`, the key the maintainer's 2026-08-06 ruling created for a
`type:'api'` action's static request body.

That prescription is right for exactly one action type. On a `type:'url'` action
the object form meant something else entirely — 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. Telling that author to use `bodyExtra`
sent them to an api request-body key that is neither an interpolation scope nor a
new-tab control. (The same asymmetry is why the
`inline-action-api-params-to-body-extra` conversion guards on `type === 'api'`:
rewriting a url action's object `params` into `bodyExtra` would be lossy, and
ADR-0087 D2 requires losslessness.)

The maintainer's 2026-08-10 ruling on #6828 **retired the url meaning** rather
than giving it a key — a key with three meanings and no authorized spelling for
the third is the de-facto-contract shape AGENTS.md #0.1 forbids, the schema
already refuses it, and nothing in the reachable corpus authors it. Both halves
already have sanctioned spellings:

| Retired reading | Sanctioned spelling |
|:---|:---|
| 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) |
| `params.newTab` | `openIn: 'new-tab'` (declared, and already read with priority by the runner) |

So the refusal message now carries both arms, and the authoring docs
(`ui/actions`, `protocol/objectui/actions`) 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. This is a message-and-docs change —
hence `patch` — pinned on both arms and on both the inline and registered
surfaces.

The two objectui reads this ruling makes dead vocabulary (`interpolateTarget`'s
non-array `params` scope, and the `params.newTab` escape hatch) are objectui's
card, filed contract-first behind this one.
21 changes: 20 additions & 1 deletion content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,26 @@ type: url
target: '/api/v1/auth/sign-in/social?provider=${param.provider}&callbackURL=${ctx.origin}/_console/apps/account/sys_account'
```

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.
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.

<Callout type="warn">
**The interpolation scope is not authorable, and `params` is not it.** `params`
is the parameter DEFINITION array — `ActionParam[]`, the dialog shown before
the action runs — on every action type, so `params: { id: 'abc' }` on a url
action is rejected at parse time. `${param.X}` resolves against the values that
dialog **collected**; a value you already know at authoring time belongs
literally in the `target` string.

Two url-side readings of an object-form `params` existed in the renderer and
were **retired** by the 2026-08-10 ruling on
[#6828](https://github.com/objectstack-ai/objectstack/issues/6828) rather than
given a key: a statically authored `${param.X}` scope (say it in `target`), and
`params.newTab` (say it with `openIn: 'new-tab'`). The refusal message names
both replacements. Note the asymmetry with `type: 'api'`, where the object form
*did* get a key — `bodyExtra`, per
[#5777](https://github.com/objectstack-ai/objectstack/issues/5777) — because a
request payload has no other spelling; a url interpolation scope does.
</Callout>

### Flow Actions

Expand Down
20 changes: 20 additions & 0 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,26 @@ construction — exist at all.
(generated tokens, export links).
- **`variant` / `icon` / `order`** — presentation and sort position.

<Callout type="warn">
**`params` is an array of parameter *definitions*, never a map of values.**
Writing `params: { … }` is rejected at authoring time, and what to write
instead depends on the action's `type`:

| You wanted | Write this instead |
|:---|:---|
| A static request body for `type: 'api'` | `bodyExtra: { name: '{{page.inquiryName}}' }` — merged last, `{{page.<var>}}` tokens resolved by the runtime |
| 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 |
| A new tab for `type: 'url'` | `openIn: 'new-tab'` (for an *async* handler that redirects, use `opensInNewTab` instead) |

There is no object form of `params` on a url action to migrate to: the two
things it used to mean in the renderer — a statically authored `${param.X}`
scope, and a `params.newTab` flag — were **retired**, not renamed
([#6828](https://github.com/objectstack-ai/objectstack/issues/6828)). Both are
already expressible with the keys above, so a third meaning of `params` earns
nothing; if you have a case the `target` string genuinely cannot express, that
is a spec proposal for a properly named key, not a values map under this one.
</Callout>

## Permissions and visibility

- **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate**
Expand Down
31 changes: 30 additions & 1 deletion packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,15 +903,44 @@ const actionObject = () => strictObject({
* bare "expected array, received object" an author cannot act on. Sources
* still carrying the object form are rewritten at load by the
* `inline-action-api-params-to-body-extra` conversion (ADR-0087 D2).
*
* **The api prescription is not universal, which #6828 measured and the
* maintainer's 2026-08-10 ruling closed.** On a `type:'url'` action the
* object form meant a THIRD thing again — 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. Sending that author to
* `bodyExtra` is a wrong instruction: an api request-body key is not an
* interpolation scope (the same asymmetry is why the conversion above guards
* on `type === 'api'` — rewriting a url action's object `params` would be
* lossy, and ADR-0087 D2 requires losslessness). The ruling **retired** the
* url meaning rather than giving it a key: the scope is already expressible
* as `target`-string interpolation, and the flag is already {@link openIn}.
* So the refusal below prescribes per action type — `bodyExtra` for `api`,
* the sanctioned url spellings for `url` — and nothing new enters the
* vocabulary. A future authorable interpolation-scope key needs a spec
* proposal that demonstrates pull, not a third arm of this one.
*
* The branch is stated IN THE TEXT rather than selected at runtime because
* zod cannot see a sibling from a property-level error map: the map receives
* only `{ code, expected, input, inst, path }` for the offending value, and
* an object-level `.check()`/`.superRefine()` — which would see `type` — is
* skipped once a property has already failed (probed on zod 4.4.3). Reading
* `type` here would mean restructuring `ActionSchema` behind a
* `z.preprocess`, which erases `z.input<typeof ActionSchema>` (the authoring
* type `defineAction` publishes) — a far larger change than the guidance
* defect warrants, and one that moves surfaces this issue must not move.
*/
params: z.array(ActionParamSchema, {
error: (iss) => (
iss.code === 'invalid_type'
&& iss.input !== null
&& typeof iss.input === 'object'
&& !Array.isArray(iss.input)
? "`params` is the parameter DEFINITION array (fields collected from the user before the action runs), not the request payload. "
? "`params` is the parameter DEFINITION array (fields collected from the user before the action runs), not a values map. "
+ "For a `type:'api'` action's static request body — including `{{page.<var>}}` tokens — use `bodyExtra: { … }` instead (#5777). "
+ "For a `type:'url'` action there is nowhere to move it to, by decision: put static values straight into the `target` string "
+ "(`${param.X}` interpolates a value collected by the params dialog, `${ctx.X}` one from the action context), and open a new tab with "
+ "`openIn: 'new-tab'`. The url-side readings of an object `params` — a static `${param.X}` scope, and `params.newTab` — are RETIRED, not renamed (#6828). "
+ 'Expected an array of ActionParam, received an object.'
: undefined
),
Expand Down
105 changes: 105 additions & 0 deletions packages/spec/src/ui/inline-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,111 @@ describe('InlineActionSchema — `bodyExtra` is the payload key, `params` is not
});
});

/**
* #6828 — the THIRD meaning of `params`, and why one prescription is not enough.
*
* #5777 settled the `type:'api'` half: the object form means the request
* payload, and the payload moves to `bodyExtra`. It did not settle the
* `type:'url'` half, where 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. So between #5777 and this card the refusal told a url
* author to use `bodyExtra` — an api request-body key, which is not an
* interpolation scope and cannot carry a new-tab flag. Wrong instruction, and
* the exact one the maintainer's 2026-08-10 ruling on #6828 names when it
* retires the url meaning instead of giving it a key.
*
* Both arms are pinned, on both surfaces, by path + code + message content: a
* regression that drops either arm is a wrong instruction again, and the bare
* "expected array, received object" would keep a `toThrow()`-shaped test green.
*/
describe('object-form `params` prescribes per action type (#6828)', () => {
/** The `params` issue a parse produced, or a readable failure if it produced none. */
const paramsIssue = (r: { success: boolean; error?: z.ZodError }) => {
expect(r.success).toBe(false);
const issues = r.error!.issues;
const issue = issues.find(i => i.path.join('.') === 'params');
expect(issue, JSON.stringify(issues)).toBeDefined();
return issue!;
};

it("sends a `type:'url'` action to `target` interpolation and `openIn`, NOT to `bodyExtra`", () => {
// The shape the issue measured: the interpolation scope half. `target`
// already carries the token, and the scope is the only thing missing a
// spelling — which the ruling declines to add.
const r = InlineActionSchema.safeParse({
type: 'url',
target: '/x?id=${param.id}',
params: { id: 'abc' },
}) as { success: boolean; error?: z.ZodError };
const issue = paramsIssue(r);
expect(issue.code).toBe('invalid_type');
expect((issue as unknown as { expected: string }).expected).toBe('array');
expect(issue.message).toContain('`target` string');
expect(issue.message).toContain("openIn: 'new-tab'");
expect(issue.message).toContain('RETIRED, not renamed (#6828)');
});

it('names the retired `params.newTab` escape hatch, so the flag half has an answer too', () => {
// The second url reading: `executeUrl` read `params.newTab` below `openIn`
// in priority. `openIn` is the declared key, so this half is a plain
// deprecation — but only if the message says so where the author lands.
const r = InlineActionSchema.safeParse({
type: 'url',
target: 'https://example.com/pricing',
params: { newTab: true },
}) as { success: boolean; error?: z.ZodError };
const issue = paramsIssue(r);
expect(issue.code).toBe('invalid_type');
expect(issue.message).toContain('params.newTab');
expect(issue.message).toContain("openIn: 'new-tab'");
});

it("keeps the `type:'api'` arm intact — `bodyExtra` is still the payload prescription", () => {
// The url arm is additive. #5777's answer must not be displaced by it:
// the api author reaching for a payload still gets the key that holds one.
const r = InlineActionSchema.safeParse({
type: 'api',
target: '/api/v1/forms/contact-us/submit',
params: { name: '{{page.inquiryName}}' },
}) as { success: boolean; error?: z.ZodError };
const issue = paramsIssue(r);
expect(issue.code).toBe('invalid_type');
expect(issue.message).toContain('bodyExtra: { … }');
expect(issue.message).toContain('(#5777)');
});

it('guides the REGISTERED action surface identically — the field factory is shared', () => {
// `params` is declared once on `actionObject()`, so a registered `type:'url'`
// action must land on the same guidance. Pinned because a future fix that
// moved the branch onto `InlineActionSchema` alone would silently leave the
// registry surface on the api-only prescription.
const r = ActionSchema.safeParse({
name: 'open_pricing',
label: 'Pricing',
type: 'url',
target: '/pricing?plan=${param.plan}',
params: { plan: 'pro' },
}) as { success: boolean; error?: z.ZodError };
const issue = paramsIssue(r);
expect(issue.code).toBe('invalid_type');
expect(issue.message).toContain('RETIRED, not renamed (#6828)');
expect(issue.message).toContain('bodyExtra: { … }');
});

it('leaves the definition-array meaning of `params` accepted on a url action', () => {
// The acceptance face does not move: the array form was always the one
// meaning, and a url action collecting input still interpolates `${param.X}`
// from the dialog. This is the sanctioned spelling the message points at.
const r = InlineActionSchema.safeParse({
type: 'url',
target: '/api/v1/auth/sign-in/social?provider=${param.provider}',
params: [{ name: 'provider', label: 'Provider', type: 'text' }],
openIn: 'new-tab',
});
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
});
});

describe('normalizeInlineAction — the legacy spellings cloud actually writes', () => {
it('folds the exact shape in cloud service-tenant pages', () => {
// packages/service-tenant/src/pages/{pricing,welcome,billing-cancel,billing-success}
Expand Down
Loading