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}