From 39224eb562a98b5662b3e2b4d00ca9e3776ec701 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:08:07 +0000 Subject: [PATCH] fix(spec): prescribe per action type when object-form `params` is refused (#6828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Vsi2JQ41Z9ct1z8jC4MjVW --- .../action-params-url-refusal-guidance.md | 46 ++++++++ content/docs/protocol/objectui/actions.mdx | 21 +++- content/docs/ui/actions.mdx | 20 ++++ packages/spec/src/ui/action.zod.ts | 31 +++++- packages/spec/src/ui/inline-action.test.ts | 105 ++++++++++++++++++ 5 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 .changeset/action-params-url-refusal-guidance.md diff --git a/.changeset/action-params-url-refusal-guidance.md b/.changeset/action-params-url-refusal-guidance.md new file mode 100644 index 0000000000..5226b4d11a --- /dev/null +++ b/.changeset/action-params-url-refusal-guidance.md @@ -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. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index c0abeaf4f0..d8299fb2ec 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -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. + + +**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. + ### Flow Actions diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index 3c6a276b95..1ec116f90d 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -249,6 +249,26 @@ construction — exist at all. (generated tokens, export links). - **`variant` / `icon` / `order`** — presentation and sort position. + +**`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.}}` 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. + + ## Permissions and visibility - **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate** diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 14836efc71..0ba4daa1d6 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -903,6 +903,32 @@ 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` (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) => ( @@ -910,8 +936,11 @@ const actionObject = () => strictObject({ && 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.}}` 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 ), diff --git a/packages/spec/src/ui/inline-action.test.ts b/packages/spec/src/ui/inline-action.test.ts index 5e7d5e7836..7c7ff0c25b 100644 --- a/packages/spec/src/ui/inline-action.test.ts +++ b/packages/spec/src/ui/inline-action.test.ts @@ -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}