Skip to content

Commit 0e4e51b

Browse files
os-zhuangclaude
andauthored
feat(spec): ActionParamSchema.carryOver — seeded from the row, rendered read-only, submitted verbatim (#11992) (#12614)
Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d7b3963 commit 0e4e51b

7 files changed

Lines changed: 332 additions & 5 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
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)
7+
8+
<!-- adr-0087: not-required (accept-set expansion) One new CLOSED optional key
9+
on an existing shape; nothing authorable is renamed, retired or tombstoned, so
10+
there is no conversion to register. Previously-refused spellings stay refused —
11+
`readonly` and `disabled` now carry alias guidance pointing at the new key. -->
12+
13+
The maintainer's 2026-08-25 ruling on #11753 (recommendation A) declares ONE
14+
carry-over contract instead of a rendering convention: a param may state, in
15+
metadata, that its value is carried through the action dialog rather than
16+
collected from the user.
17+
18+
- `carryOver: true` — seed from the current row (`defaultFromRow: true` is
19+
required alongside, enforced at parse time), render as a NON-EDITABLE
20+
summary, submit VERBATIM. Unlike `visible: false` — the measured non-answer,
21+
which omits the param from the submission entirely — a carry-over param is
22+
always sent.
23+
- Aliases: `readonly` / `disabled` are refused with guidance naming
24+
`carryOver` (a field's `readonly` means write-path strip, which is exactly
25+
the wrong half here).
26+
- Exemplar (`@objectstack/plugin-security`): the five `clone_permission_set`
27+
JSON facet params (`object_permissions`, `field_permissions`,
28+
`system_permissions`, `row_level_security`, `tab_permissions`) declare it,
29+
so the sanctioned clone path stops offering five prefilled raw-JSON
30+
textareas an admin could hand-mangle into a clone that grants MORE than its
31+
base. `description` stays an ordinary editable param. The send-side contract
32+
is unchanged (#11703 pin 6 stays green).
33+
34+
The objectui renderer leg (honouring the declaration in `ActionParamDialog`)
35+
is the downstream card tracked on #11753.

content/docs/references/ui/action.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
146146
| **maxSize** | `integer` | optional | Max upload size in bytes for file/image params. |
147147
| **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. |
148148
| **defaultFromRow** | `boolean` | optional | |
149+
| **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. |
149150
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. |
150151
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. |
151152

@@ -239,6 +240,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
239240
| **maxSize** | `integer` | optional | Max upload size in bytes for file/image params. |
240241
| **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. |
241242
| **defaultFromRow** | `boolean` | optional | |
243+
| **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. |
242244
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. |
243245
| **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. |
244246

@@ -358,6 +360,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
358360
| **maxSize** | `integer` | optional | Max upload size in bytes for file/image params. |
359361
| **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. |
360362
| **defaultFromRow** | `boolean` | optional | |
363+
| **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. |
361364
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. |
362365
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. |
363366

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// #11992 — the exemplar half of the #11753 ruling (recommendation A,
2+
// maintainer 2026-08-25): the five `clone_permission_set` facet params DECLARE
3+
// the spec's `carryOver` key, so the clone dialog's JSON facets are copied
4+
// verbatim, shown read-only, and never offered as prefilled textareas an admin
5+
// could hand-mangle into a clone that grants MORE than its base.
6+
//
7+
// ⭐ IDENTITIES, NOT COUNTS (same discipline as the #11703 pins one file over):
8+
// "five params declare it" holds constant while two of them swap. Every facet
9+
// is asserted by NAME, and the deliberate non-member (`description` — prose,
10+
// not a permission facet) is asserted NOT to carry the key, so the boundary of
11+
// the declaration is pinned from both sides.
12+
//
13+
// The SEND side is deliberately not restated here — that is
14+
// `packaged-permission-set-lock.test.ts`'s clone-payload suite (#11703 pin 6),
15+
// which reads the params list and must stay green under this declaration
16+
// precisely because `carryOver` changes what the dialog RENDERS, never what it
17+
// SENDS.
18+
import { describe, it, expect } from 'vitest';
19+
import { ActionParamSchema } from '@objectstack/spec/ui';
20+
import { SysPermissionSet } from './sys-permission-set.object.js';
21+
22+
/** The five JSON-serialized definition facets the clone carries (#11703). */
23+
const CARRIED_FACETS = [
24+
'object_permissions',
25+
'field_permissions',
26+
'system_permissions',
27+
'row_level_security',
28+
'tab_permissions',
29+
] as const;
30+
31+
const cloneParams = (): any[] => {
32+
const action = (SysPermissionSet.actions ?? []).find(
33+
(a: any) => a.name === 'clone_permission_set',
34+
);
35+
if (!action) throw new Error('clone_permission_set is missing from SysPermissionSet.actions');
36+
return (action as any).params ?? [];
37+
};
38+
39+
describe('clone_permission_set carry-over declaration (#11992)', () => {
40+
it.each(CARRIED_FACETS)('%s declares carryOver: true alongside its row seed', (facet) => {
41+
const p = cloneParams().find((x) => x.field === facet);
42+
expect(p, `param { field: '${facet}' } is missing from clone_permission_set`).toBeDefined();
43+
expect(p.carryOver).toBe(true);
44+
// The co-requirement the spec enforces at parse time — asserted here too
45+
// so a future edit that drops the seed fails THIS suite by name instead of
46+
// only tripping a schema refusal somewhere in a stack build.
47+
expect(p.defaultFromRow).toBe(true);
48+
});
49+
50+
it('description stays an ordinary editable param (no carryOver)', () => {
51+
const p = cloneParams().find((x) => x.field === 'description');
52+
expect(p, 'param { field: "description" } is missing').toBeDefined();
53+
expect(p.carryOver).toBeUndefined();
54+
});
55+
56+
it('every clone param parses under ActionParamSchema (the declaration is spec-legal, not local dialect)', () => {
57+
for (const p of cloneParams()) {
58+
const r = ActionParamSchema.safeParse(p);
59+
expect(
60+
r.success,
61+
`param ${JSON.stringify(p)} refused: ${JSON.stringify((r as { error?: unknown }).error)}`,
62+
).toBe(true);
63+
}
64+
});
65+
});

packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,16 +140,22 @@ export const SysPermissionSet = ObjectSchema.create({
140140
// name, so the scope has to be right here — a bare "Unique" tells the
141141
// author the name is taken installation-wide when it is not.
142142
{ name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'snake_case machine name, unique per organization' },
143+
// `description` is prose, not a permission facet: it stays editable
144+
// (renaming a clone's description is legitimate), while the five JSON
145+
// facets below are declared `carryOver` — the #11753 ruling's
146+
// non-editable carry-over. Copied verbatim, shown read-only, never
147+
// offered as a prefilled JSON textarea an admin could hand-mangle into
148+
// a clone that grants MORE than its base.
143149
{ field: 'description', defaultFromRow: true },
144-
{ field: 'object_permissions', defaultFromRow: true },
145-
{ field: 'field_permissions', defaultFromRow: true },
150+
{ field: 'object_permissions', defaultFromRow: true, carryOver: true },
151+
{ field: 'field_permissions', defaultFromRow: true, carryOver: true },
146152
// [#11703] The three facets the clone silently dropped. Same
147153
// JSON-string shape as the two above: `permissionSetRowFields()`
148154
// writes all five with `JSON.stringify`, and the data door parses all
149155
// five back — the accept surface did not move, only what is SENT.
150-
{ field: 'system_permissions', defaultFromRow: true },
151-
{ field: 'row_level_security', defaultFromRow: true },
152-
{ field: 'tab_permissions', defaultFromRow: true },
156+
{ field: 'system_permissions', defaultFromRow: true, carryOver: true },
157+
{ field: 'row_level_security', defaultFromRow: true, carryOver: true },
158+
{ field: 'tab_permissions', defaultFromRow: true, carryOver: true },
153159
// ⛔ `admin_scope` is deliberately absent — see `description` above.
154160
],
155161
},

packages/spec/authorable-surface/ui.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"ui/ActionNavItem:type",
7171
"ui/ActionNavItem:visible",
7272
"ui/ActionParam:accept",
73+
"ui/ActionParam:carryOver",
7374
"ui/ActionParam:defaultFromRow",
7475
"ui/ActionParam:defaultValue",
7576
"ui/ActionParam:field",
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// #11992 — `ActionParamSchema.carryOver`, the #11753 ruling's spec half
2+
// (maintainer 2026-08-25, recommendation A): a declared carry-over param is
3+
// seeded from the row, rendered as a NON-EDITABLE summary, and submitted
4+
// VERBATIM. These pins hold the ruled shape: the accept set (key + parsed
5+
// output), the parse-time `defaultFromRow` co-requirement, the alias
6+
// prescriptions for the words authors will actually try (`readonly` /
7+
// `disabled`), and the describe() contract the renderer leg and the docs are
8+
// generated from.
9+
//
10+
// Measured constraint restated from the parent card, because it is the reason
11+
// the key exists at all: `visible: false` is NOT this contract — it omits the
12+
// param from the dialog AND from the submission, which is the #11703
13+
// silent-drop shape. `carryOver` must keep the param in the submission.
14+
import { describe, it, expect } from 'vitest';
15+
import { ActionParamSchema } from './action.zod';
16+
17+
describe('ActionParamSchema.carryOver (#11992, #11753 ruling)', () => {
18+
describe('accept pins', () => {
19+
it('accepts carryOver on a field-backed defaultFromRow param and carries it in the parse output', () => {
20+
const r = ActionParamSchema.safeParse({
21+
field: 'row_level_security',
22+
defaultFromRow: true,
23+
carryOver: true,
24+
});
25+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
26+
// The renderer leg reads this member off the parsed shape; nothing may
27+
// strip or rename it on the way through (contrast `requiresFeature`,
28+
// which IS lowered away — this key is not sugar, it is the contract).
29+
expect((r.data as { carryOver?: boolean }).carryOver).toBe(true);
30+
});
31+
32+
it('accepts carryOver: false as an explicit no-op', () => {
33+
const r = ActionParamSchema.safeParse({
34+
field: 'description',
35+
defaultFromRow: true,
36+
carryOver: false,
37+
});
38+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
39+
});
40+
41+
it('accepts carryOver on an inline param when the row seed is declared', () => {
42+
const r = ActionParamSchema.safeParse({
43+
name: 'tab_permissions',
44+
type: 'textarea',
45+
defaultFromRow: true,
46+
carryOver: true,
47+
});
48+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
49+
});
50+
});
51+
52+
describe('co-requirement pin — carryOver without its row seed is an authoring error', () => {
53+
it('refuses carryOver: true without defaultFromRow, on the carryOver path, naming the missing seed', () => {
54+
const r = ActionParamSchema.safeParse({
55+
field: 'row_level_security',
56+
carryOver: true,
57+
});
58+
expect(r.success).toBe(false);
59+
if (r.success) return;
60+
const issue = r.error.issues.find((i) => i.path.join('.') === 'carryOver');
61+
expect(issue, JSON.stringify(r.error.issues)).toBeDefined();
62+
// The message must carry the repair (`defaultFromRow: true`) and the
63+
// fixed-value alternative (`bodyExtra`) — the refusal is the docs at the
64+
// moment of the mistake.
65+
expect(issue!.message).toContain('defaultFromRow: true');
66+
expect(issue!.message).toContain('bodyExtra');
67+
});
68+
69+
it('refuses carryOver: true with defaultFromRow explicitly false', () => {
70+
const r = ActionParamSchema.safeParse({
71+
field: 'row_level_security',
72+
defaultFromRow: false,
73+
carryOver: true,
74+
});
75+
expect(r.success).toBe(false);
76+
});
77+
78+
it('a carryOver: false param does NOT require the seed (no phantom check on the disabled spelling)', () => {
79+
const r = ActionParamSchema.safeParse({
80+
name: 'note',
81+
type: 'text',
82+
carryOver: false,
83+
});
84+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
85+
});
86+
});
87+
88+
describe('alias pins — the borrowed words point at the declared key', () => {
89+
// The parent card's option A was literally titled "a readonly / carryOver
90+
// flag", and `FieldSchema.readonly` / widget `disabled` are the spellings
91+
// an author will reach for first. Both must land on the strict-unknown-key
92+
// path with a suggestion naming `carryOver` — never parse clean (this
93+
// schema is strict) and never dead-end without a pointer.
94+
it.each(['readonly', 'disabled'] as const)('rejects %s with a suggestion naming carryOver', (word) => {
95+
const r = ActionParamSchema.safeParse({
96+
field: 'row_level_security',
97+
defaultFromRow: true,
98+
[word]: true,
99+
});
100+
expect(r.success).toBe(false);
101+
if (r.success) return;
102+
const text = JSON.stringify(r.error.issues);
103+
expect(text).toContain(word);
104+
expect(text).toContain('carryOver');
105+
});
106+
});
107+
108+
describe('describe pin — the three ruled semantics are stated on the key', () => {
109+
it('the .describe() text states seed-from-row, non-editable render, and verbatim submission', () => {
110+
// The describe string is what the generated reference docs and the
111+
// authorable-surface baseline carry — an author (or an AI writing
112+
// metadata in bulk) reads THIS, so all three halves of the ruled
113+
// contract must be in it, including the contrast with `visible: false`
114+
// (the measured non-answer).
115+
const shape = (ActionParamSchema as unknown as {
116+
def: { getter?: () => unknown };
117+
});
118+
// `lazySchema` wraps the pipeline; walk to the inner object's shape via
119+
// a parse-independent probe: JSON-schema-free, so just read the
120+
// description off a parsed-known-good source — the schema graph.
121+
const description = findCarryOverDescription(shape);
122+
expect(description).toBeTruthy();
123+
expect(description).toContain('seed the value from the current row');
124+
expect(description).toContain('non-editable summary');
125+
expect(description).toContain('submit it verbatim');
126+
expect(description).toContain('visible: false');
127+
});
128+
});
129+
});
130+
131+
/**
132+
* Walk the (lazy, refined, transformed) schema graph down to the strict object
133+
* and read `carryOver`'s description. Kept structural rather than importing
134+
* zod internals: every wrapper layer exposes its inner schema on `def`
135+
* (`innerType` / `schema` / `getter()`), and the object layer exposes `shape`.
136+
*/
137+
function findCarryOverDescription(node: unknown, depth = 0): string | undefined {
138+
// `lazySchema` returns a Proxy over a FUNCTION target (structurally a
139+
// ZodType, `typeof` says 'function'), so both object and function nodes are
140+
// walkable — an object-only guard silently skips the schema root.
141+
if (!node || (typeof node !== 'object' && typeof node !== 'function') || depth > 12) return undefined;
142+
const n = node as Record<string, any>;
143+
const shape = typeof n.shape === 'object' ? n.shape : n.def?.shape;
144+
if (shape?.carryOver) {
145+
const co = shape.carryOver as Record<string, any>;
146+
return co.description ?? co.def?.description ?? co.meta?.()?.description;
147+
}
148+
const d = n.def ?? {};
149+
for (const next of [
150+
typeof d.getter === 'function' ? d.getter() : undefined,
151+
d.innerType,
152+
d.schema,
153+
d.in,
154+
n.innerType,
155+
]) {
156+
const found = findCarryOverDescription(next, depth + 1);
157+
if (found) return found;
158+
}
159+
return undefined;
160+
}

0 commit comments

Comments
 (0)