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
35 changes: 35 additions & 0 deletions .changeset/action-param-carryover-declaration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-security': patch
---

feat(spec): `ActionParamSchema.carryOver` — the declared carry-over param: seeded from the row, rendered as a non-editable summary, submitted verbatim (#11753 ruling, spec half; #11992)

<!-- adr-0087: not-required (accept-set expansion) One new CLOSED optional key
on an existing shape; nothing authorable is renamed, retired or tombstoned, so
there is no conversion to register. Previously-refused spellings stay refused —
`readonly` and `disabled` now carry alias guidance pointing at the new key. -->

The maintainer's 2026-08-25 ruling on #11753 (recommendation A) declares ONE
carry-over contract instead of a rendering convention: a param may state, in
metadata, that its value is carried through the action dialog rather than
collected from the user.

- `carryOver: true` — seed from the current row (`defaultFromRow: true` is
required alongside, enforced at parse time), render as a NON-EDITABLE
summary, submit VERBATIM. Unlike `visible: false` — the measured non-answer,
which omits the param from the submission entirely — a carry-over param is
always sent.
- Aliases: `readonly` / `disabled` are refused with guidance naming
`carryOver` (a field's `readonly` means write-path strip, which is exactly
the wrong half here).
- Exemplar (`@objectstack/plugin-security`): the five `clone_permission_set`
JSON facet params (`object_permissions`, `field_permissions`,
`system_permissions`, `row_level_security`, `tab_permissions`) declare it,
so the sanctioned clone path stops offering five prefilled raw-JSON
textareas an admin could hand-mangle into a clone that grants MORE than its
base. `description` stays an ordinary editable param. The send-side contract
is unchanged (#11703 pin 6 stays green).

The objectui renderer leg (honouring the declaration in `ActionParamDialog`)
is the downstream card tracked on #11753.
3 changes: 3 additions & 0 deletions content/docs/references/ui/action.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,6 +146,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
| **maxSize** | `integer` | optional | Max upload size in bytes for file/image params. |
| **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. |
| **defaultFromRow** | `boolean` | optional | |
| **carryOver** | `boolean` | optional | Carry-over param: seed the value from the current row (requires defaultFromRow: true), render it as a non-editable summary in the dialog, and submit it verbatim in the request body. Unlike `visible: false` (which omits the param from the submission entirely), a carry-over param is always sent. |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. |

Expand DownExpand Up@@ -239,6 +240,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
| **maxSize** | `integer` | optional | Max upload size in bytes for file/image params. |
| **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. |
| **defaultFromRow** | `boolean` | optional | |
| **carryOver** | `boolean` | optional | Carry-over param: seed the value from the current row (requires defaultFromRow: true), render it as a non-editable summary in the dialog, and submit it verbatim in the request body. Unlike `visible: false` (which omits the param from the submission entirely), a carry-over param is always sent. |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. |

Expand DownExpand Up@@ -358,6 +360,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
| **maxSize** | `integer` | optional | Max upload size in bytes for file/image params. |
| **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. |
| **defaultFromRow** | `boolean` | optional | |
| **carryOver** | `boolean` | optional | Carry-over param: seed the value from the current row (requires defaultFromRow: true), render it as a non-editable summary in the dialog, and submit it verbatim in the request body. Unlike `visible: false` (which omits the param from the submission entirely), a carry-over param is always sent. |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. |
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. |

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
// #11992 — the exemplar half of the #11753 ruling (recommendation A,
// maintainer 2026-08-25): the five `clone_permission_set` facet params DECLARE
// the spec's `carryOver` key, so the clone dialog's JSON facets are copied
// verbatim, shown read-only, and never offered as prefilled textareas an admin
// could hand-mangle into a clone that grants MORE than its base.
//
// ⭐ IDENTITIES, NOT COUNTS (same discipline as the #11703 pins one file over):
// "five params declare it" holds constant while two of them swap. Every facet
// is asserted by NAME, and the deliberate non-member (`description` — prose,
// not a permission facet) is asserted NOT to carry the key, so the boundary of
// the declaration is pinned from both sides.
//
// The SEND side is deliberately not restated here — that is
// `packaged-permission-set-lock.test.ts`'s clone-payload suite (#11703 pin 6),
// which reads the params list and must stay green under this declaration
// precisely because `carryOver` changes what the dialog RENDERS, never what it
// SENDS.
import { describe, it, expect } from 'vitest';
import { ActionParamSchema } from '@objectstack/spec/ui';
import { SysPermissionSet } from './sys-permission-set.object.js';

/** The five JSON-serialized definition facets the clone carries (#11703). */
const CARRIED_FACETS = [
'object_permissions',
'field_permissions',
'system_permissions',
'row_level_security',
'tab_permissions',
] as const;

const cloneParams = (): any[] => {
const action = (SysPermissionSet.actions ?? []).find(
(a: any) => a.name === 'clone_permission_set',
);
if (!action) throw new Error('clone_permission_set is missing from SysPermissionSet.actions');
return (action as any).params ?? [];
};

describe('clone_permission_set carry-over declaration (#11992)', () => {
it.each(CARRIED_FACETS)('%s declares carryOver: true alongside its row seed', (facet) => {
const p = cloneParams().find((x) => x.field === facet);
expect(p, `param { field: '${facet}' } is missing from clone_permission_set`).toBeDefined();
expect(p.carryOver).toBe(true);
// The co-requirement the spec enforces at parse time — asserted here too
// so a future edit that drops the seed fails THIS suite by name instead of
// only tripping a schema refusal somewhere in a stack build.
expect(p.defaultFromRow).toBe(true);
});

it('description stays an ordinary editable param (no carryOver)', () => {
const p = cloneParams().find((x) => x.field === 'description');
expect(p, 'param { field: "description" } is missing').toBeDefined();
expect(p.carryOver).toBeUndefined();
});

it('every clone param parses under ActionParamSchema (the declaration is spec-legal, not local dialect)', () => {
for (const p of cloneParams()) {
const r = ActionParamSchema.safeParse(p);
expect(
r.success,
`param ${JSON.stringify(p)} refused: ${JSON.stringify((r as { error?: unknown }).error)}`,
).toBe(true);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,16 +140,22 @@ export const SysPermissionSet = ObjectSchema.create({
// name, so the scope has to be right here — a bare "Unique" tells the
// author the name is taken installation-wide when it is not.
{ name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'snake_case machine name, unique per organization' },
// `description` is prose, not a permission facet: it stays editable
// (renaming a clone's description is legitimate), while the five JSON
// facets below are declared `carryOver` — the #11753 ruling's
// non-editable carry-over. Copied verbatim, shown read-only, never
// offered as a prefilled JSON textarea an admin could hand-mangle into
// a clone that grants MORE than its base.
{ field: 'description', defaultFromRow: true },
{ field: 'object_permissions', defaultFromRow: true },
{ field: 'field_permissions', defaultFromRow: true },
{ field: 'object_permissions', defaultFromRow: true, carryOver: true },
{ field: 'field_permissions', defaultFromRow: true, carryOver: true },
// [#11703] The three facets the clone silently dropped. Same
// JSON-string shape as the two above: `permissionSetRowFields()`
// writes all five with `JSON.stringify`, and the data door parses all
// five back — the accept surface did not move, only what is SENT.
{ field: 'system_permissions', defaultFromRow: true },
{ field: 'row_level_security', defaultFromRow: true },
{ field: 'tab_permissions', defaultFromRow: true },
{ field: 'system_permissions', defaultFromRow: true, carryOver: true },
{ field: 'row_level_security', defaultFromRow: true, carryOver: true },
{ field: 'tab_permissions', defaultFromRow: true, carryOver: true },
// ⛔ `admin_scope` is deliberately absent — see `description` above.
],
},
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,7 @@
"ui/ActionNavItem:type",
"ui/ActionNavItem:visible",
"ui/ActionParam:accept",
"ui/ActionParam:carryOver",
"ui/ActionParam:defaultFromRow",
"ui/ActionParam:defaultValue",
"ui/ActionParam:field",
Expand Down
160 changes: 160 additions & 0 deletions packages/spec/src/ui/action-param-carryover.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
// #11992 — `ActionParamSchema.carryOver`, the #11753 ruling's spec half
// (maintainer 2026-08-25, recommendation A): a declared carry-over param is
// seeded from the row, rendered as a NON-EDITABLE summary, and submitted
// VERBATIM. These pins hold the ruled shape: the accept set (key + parsed
// output), the parse-time `defaultFromRow` co-requirement, the alias
// prescriptions for the words authors will actually try (`readonly` /
// `disabled`), and the describe() contract the renderer leg and the docs are
// generated from.
//
// Measured constraint restated from the parent card, because it is the reason
// the key exists at all: `visible: false` is NOT this contract — it omits the
// param from the dialog AND from the submission, which is the #11703
// silent-drop shape. `carryOver` must keep the param in the submission.
import { describe, it, expect } from 'vitest';
import { ActionParamSchema } from './action.zod';

describe('ActionParamSchema.carryOver (#11992, #11753 ruling)', () => {
describe('accept pins', () => {
it('accepts carryOver on a field-backed defaultFromRow param and carries it in the parse output', () => {
const r = ActionParamSchema.safeParse({
field: 'row_level_security',
defaultFromRow: true,
carryOver: true,
});
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
// The renderer leg reads this member off the parsed shape; nothing may
// strip or rename it on the way through (contrast `requiresFeature`,
// which IS lowered away — this key is not sugar, it is the contract).
expect((r.data as { carryOver?: boolean }).carryOver).toBe(true);
});

it('accepts carryOver: false as an explicit no-op', () => {
const r = ActionParamSchema.safeParse({
field: 'description',
defaultFromRow: true,
carryOver: false,
});
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
});

it('accepts carryOver on an inline param when the row seed is declared', () => {
const r = ActionParamSchema.safeParse({
name: 'tab_permissions',
type: 'textarea',
defaultFromRow: true,
carryOver: true,
});
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
});
});

describe('co-requirement pin — carryOver without its row seed is an authoring error', () => {
it('refuses carryOver: true without defaultFromRow, on the carryOver path, naming the missing seed', () => {
const r = ActionParamSchema.safeParse({
field: 'row_level_security',
carryOver: true,
});
expect(r.success).toBe(false);
if (r.success) return;
const issue = r.error.issues.find((i) => i.path.join('.') === 'carryOver');
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
// The message must carry the repair (`defaultFromRow: true`) and the
// fixed-value alternative (`bodyExtra`) — the refusal is the docs at the
// moment of the mistake.
expect(issue!.message).toContain('defaultFromRow: true');
expect(issue!.message).toContain('bodyExtra');
});

it('refuses carryOver: true with defaultFromRow explicitly false', () => {
const r = ActionParamSchema.safeParse({
field: 'row_level_security',
defaultFromRow: false,
carryOver: true,
});
expect(r.success).toBe(false);
});

it('a carryOver: false param does NOT require the seed (no phantom check on the disabled spelling)', () => {
const r = ActionParamSchema.safeParse({
name: 'note',
type: 'text',
carryOver: false,
});
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
});
});

describe('alias pins — the borrowed words point at the declared key', () => {
// The parent card's option A was literally titled "a readonly / carryOver
// flag", and `FieldSchema.readonly` / widget `disabled` are the spellings
// an author will reach for first. Both must land on the strict-unknown-key
// path with a suggestion naming `carryOver` — never parse clean (this
// schema is strict) and never dead-end without a pointer.
it.each(['readonly', 'disabled'] as const)('rejects %s with a suggestion naming carryOver', (word) => {
const r = ActionParamSchema.safeParse({
field: 'row_level_security',
defaultFromRow: true,
[word]: true,
});
expect(r.success).toBe(false);
if (r.success) return;
const text = JSON.stringify(r.error.issues);
expect(text).toContain(word);
expect(text).toContain('carryOver');
});
});

describe('describe pin — the three ruled semantics are stated on the key', () => {
it('the .describe() text states seed-from-row, non-editable render, and verbatim submission', () => {
// The describe string is what the generated reference docs and the
// authorable-surface baseline carry — an author (or an AI writing
// metadata in bulk) reads THIS, so all three halves of the ruled
// contract must be in it, including the contrast with `visible: false`
// (the measured non-answer).
const shape = (ActionParamSchema as unknown as {
def: { getter?: () => unknown };
});
// `lazySchema` wraps the pipeline; walk to the inner object's shape via
// a parse-independent probe: JSON-schema-free, so just read the
// description off a parsed-known-good source — the schema graph.
const description = findCarryOverDescription(shape);
expect(description).toBeTruthy();
expect(description).toContain('seed the value from the current row');
expect(description).toContain('non-editable summary');
expect(description).toContain('submit it verbatim');
expect(description).toContain('visible: false');
});
});
});

/**
* Walk the (lazy, refined, transformed) schema graph down to the strict object
* and read `carryOver`'s description. Kept structural rather than importing
* zod internals: every wrapper layer exposes its inner schema on `def`
* (`innerType` / `schema` / `getter()`), and the object layer exposes `shape`.
*/
function findCarryOverDescription(node: unknown, depth = 0): string | undefined {
// `lazySchema` returns a Proxy over a FUNCTION target (structurally a
// ZodType, `typeof` says 'function'), so both object and function nodes are
// walkable — an object-only guard silently skips the schema root.
if (!node || (typeof node !== 'object' && typeof node !== 'function') || depth > 12) return undefined;
const n = node as Record<string, any>;
const shape = typeof n.shape === 'object' ? n.shape : n.def?.shape;
if (shape?.carryOver) {
const co = shape.carryOver as Record<string, any>;
return co.description ?? co.def?.description ?? co.meta?.()?.description;
}
const d = n.def ?? {};
for (const next of [
typeof d.getter === 'function' ? d.getter() : undefined,
d.innerType,
d.schema,
d.in,
n.innerType,
]) {
const found = findCarryOverDescription(next, depth + 1);
if (found) return found;
}
return undefined;
}
Loading
Loading