diff --git a/.changeset/11183-action-node-properties-bag.md b/.changeset/11183-action-node-properties-bag.md
new file mode 100644
index 0000000000..c204f453fa
--- /dev/null
+++ b/.changeset/11183-action-node-properties-bag.md
@@ -0,0 +1,15 @@
+---
+---
+
+Docs and pins only, no package released: every taught `action:*` node under `AGENTS.md`,
+`skills/objectui/**` and `content/docs/guide/**` writes the block's props in the spec's
+`properties` bag, as the `@objectstack/spec` `ComponentPropsMap` row declares them, instead of
+flat on the node. The spec's `PageComponentSchema` is `.strict()` (ADR-0089 D3a) and refuses a
+node-level `actionType` / `target` as mis-layered; objectui's strict authoring face refuses the
+same two keys, and PR objectui#11069 moves `objectui validate` onto that face, so the taught node
+had to move first. Same move for the `record:activity` example in the plugin-detail guide
+(`limit` / `showCompleted`) and the page-builder skill's `object-form` example, which `objectui
+validate` already refuses flat by name. The runtime reads both spellings through the
+`SchemaRenderer` `properties` hoist, so no behaviour changes. The two ledgered pins that read the
+quick-start's taught node move with it and keep a derived control that the pre-move flat spelling
+is still refused by exactly `actionType` and `target` (objectui#11183).
diff --git a/AGENTS.md b/AGENTS.md
index 534a8ee514..57af347790 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,9 +93,9 @@ interface BaseSchema {
- **#1 — Protocol-agnostic.** Never hardcode `objectql.find()`. Use the DataSource interface; inject `dataSource` via ``.
- **#2 — Docs-driven.** For every feature/refactor, update package `README.md` **and** `content/docs/guide/*.md`. Not done until docs reflect the code.
- **#3 — "Shadcn-native" aesthetics.** We are "serializable Shadcn". Follow Shadcn's DOM structure (`CardHeader`/`CardTitle`/`CardContent`). Always expose `className` in schema props so users can override via JSON.
-- **#4 — Action system (objectui#7898, objectui#6497).** Actions are **data, not functions**, and a control that RUNS something is its own NODE TYPE — `action:button` — never a handler key or an event bag on an ordinary node. `actionType` names the executor the action runner dispatches to; the node's own keys carry that executor's arguments:
+- **#4 — Action system (objectui#7898, objectui#6497, objectui#11183).** Actions are **data, not functions**, and a control that RUNS something is its own NODE TYPE — `action:button` — never a handler key or an event bag on an ordinary node. The block's props live in the node's `properties` bag, which is the spec's `ComponentPropsMap` row for it: `actionType` names the executor the action runner dispatches to, and the row's other keys carry that executor's arguments. ⛔ Never write them flat on the node: the spec's `PageComponentSchema` is `.strict()` (ADR-0089 D3a) and refuses a node-level `actionType` / `target` as mis-layered, and objectui's strict authoring face refuses the same two keys. The runtime reads both spellings through the `SchemaRenderer` `properties` hoist, so the bag costs nothing at render:
```json
- { "type": "action:button", "label": "Open details", "actionType": "url", "target": "/users/ada" }
+ { "type": "action:button", "properties": { "label": "Open details", "actionType": "url", "target": "/users/ada" } }
```
⛔ Never author an `events` bag: `BaseSchema` declares no `events` member and no renderer reads `schema.events` — the node is `.passthrough()`, so one authored there is kept, judged by nothing and run by nothing. `ButtonSchema.onClick` is a runtime slot for a host-supplied function and is refused by name for the same reason.
- **#5 — Layout as components.** Treat `Grid`/`Stack`/`Container` as first-class. Layout schemas declare responsive columns on the node as `columns` — a number, or a breakpoint object (`columns: { xs: 1, md: 2, lg: 4 }`); never `cols`, which nothing reads (objectui#4001).
diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md
index 62215ec366..ef9919128d 100644
--- a/content/docs/guide/layout.md
+++ b/content/docs/guide/layout.md
@@ -546,19 +546,23 @@ Same rule as above, and it governs the trail too: the breadcrumb and the buttons
"children": [
{
"type": "action:button",
- "name": "edit_record",
- "label": "Edit",
- "variant": "default",
- "icon": "pencil",
- "actionType": "editRecord"
+ "properties": {
+ "name": "edit_record",
+ "label": "Edit",
+ "variant": "default",
+ "icon": "pencil",
+ "actionType": "editRecord"
+ }
},
{
"type": "action:button",
- "name": "delete_record",
- "label": "Delete",
- "variant": "destructive",
- "icon": "trash",
- "actionType": "deleteRecord"
+ "properties": {
+ "name": "delete_record",
+ "label": "Delete",
+ "variant": "destructive",
+ "icon": "trash",
+ "actionType": "deleteRecord"
+ }
}
]
},
@@ -573,7 +577,10 @@ Same rule as above, and it governs the trail too: the breadcrumb and the buttons
```
A button that RUNS something is an `action:button` node, not a `button` carrying an
-`onClick`. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied
+`onClick`. Its props — `actionType` and the rest of the block's row — go in the node's
+`properties` bag, as `@objectstack/spec` declares them; the spec's strict page component
+refuses them written flat on the node, and `SchemaRenderer` hoists the bag onto the node
+before the renderer reads it. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied
function and the zod mirror refuses it BY NAME — JSON has no function value, and no
handler key consumes a declarative action object. The refusal is not the whole cost:
`onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`, so an authored string or object is
diff --git a/content/docs/guide/quick-start.md b/content/docs/guide/quick-start.md
index 9fca28bf4e..ca70f902fc 100644
--- a/content/docs/guide/quick-start.md
+++ b/content/docs/guide/quick-start.md
@@ -143,16 +143,20 @@ Open [http://localhost:5173](http://localhost:5173). You should see a card and d
### Add Actions
Actions are data, not inline functions. Declare one as an `action:button`
-node: `actionType` names the built-in executor the action runner dispatches to,
-and the action's own keys carry that executor's arguments — `target` is the
-location a `url` action navigates to:
+node whose `properties` bag carries the block's props, as `@objectstack/spec`
+declares them: `actionType` names the built-in executor the action runner
+dispatches to, and the bag's other keys carry that executor's arguments —
+`target` is the location a `url` action navigates to. Written flat on the node,
+`actionType` and `target` are refused by the spec's strict page component:
```json
{
"type": "action:button",
- "label": "Open details",
- "actionType": "url",
- "target": "/users/ada"
+ "properties": {
+ "label": "Open details",
+ "actionType": "url",
+ "target": "/users/ada"
+ }
}
```
diff --git a/content/docs/guide/record-edit-modes.md b/content/docs/guide/record-edit-modes.md
index 94e404f15b..1d2f42819d 100644
--- a/content/docs/guide/record-edit-modes.md
+++ b/content/docs/guide/record-edit-modes.md
@@ -63,19 +63,20 @@ refresh the page mid-edit (the form rehydrates from the URL `:recordId`).
In addition to the implicit "click create/edit on a list" entry point,
two declarative actions let you open the page-mode routes from an
-`action:button` in metadata. The handler name goes in `actionType`: that
-is the key the button renderer forwards to the action runner as the
-action's type, and the runner dispatches to the handler registered under
-it. Arguments are static values, and they go in the node's `properties`
-bag as `properties.params`:
+`action:button` in metadata. Every prop of the block lives in the node's
+`properties` bag, as `@objectstack/spec` declares it. The handler name goes
+in `properties.actionType`: that is the key the button renderer forwards to
+the action runner as the action's type, and the runner dispatches to the
+handler registered under it. Arguments are static values, and they go in
+the same bag as `properties.params`:
```jsonc
{
"type": "action:button",
- "label": "New Account",
- "icon": "plus",
- "actionType": "navigate_create",
"properties": {
+ "label": "New Account",
+ "icon": "plus",
+ "actionType": "navigate_create",
"params": { "objectName": "account" }
}
}
@@ -89,10 +90,10 @@ names the record it sits on with `${record.id}`:
```jsonc
{
"type": "action:button",
- "label": "Edit",
- "icon": "pencil",
- "actionType": "navigate_edit",
"properties": {
+ "label": "Edit",
+ "icon": "pencil",
+ "actionType": "navigate_edit",
"params": {
"objectName": "account",
"recordId": "${record.id}"
@@ -126,8 +127,10 @@ entirely:
```jsonc
{
"type": "action:button",
- "label": "New",
- "actionType": "navigate_create"
+ "properties": {
+ "label": "New",
+ "actionType": "navigate_create"
+ }
}
```
diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md
index 65da7d502a..1b695520c4 100644
--- a/content/docs/guide/schema-rendering.md
+++ b/content/docs/guide/schema-rendering.md
@@ -307,16 +307,20 @@ Components can emit events that you handle in React:
/>
```
-Reference actions in schemas:
+Reference actions in schemas — the block's props go in its `properties` bag, as
+`@objectstack/spec` declares them; the spec's strict page component refuses them
+written flat on the node:
```json
{
"type": "action:button",
- "name": "call_api",
- "label": "Click Me",
- "actionType": "api",
- "endpoint": "/api/action",
- "method": "POST"
+ "properties": {
+ "name": "call_api",
+ "label": "Click Me",
+ "actionType": "api",
+ "endpoint": "/api/action",
+ "method": "POST"
+ }
}
```
diff --git a/content/docs/plugins/plugin-detail.mdx b/content/docs/plugins/plugin-detail.mdx
index 02eb931e2f..1f6a018f03 100644
--- a/content/docs/plugins/plugin-detail.mdx
+++ b/content/docs/plugins/plugin-detail.mdx
@@ -184,7 +184,9 @@ declare const handleAddComment: (text: string) => void;
`RecordActivityTimeline` is a presenter: you hand it `items`. The SDUI block
`record:activity` is the schema-authored form of the same panel, and it finds
-its own feed. Drop it anywhere inside a record page:
+its own feed. Drop it anywhere inside a record page; its inputs go in the node's
+`properties` bag, as `@objectstack/spec` declares them (the strict page component
+refuses them written flat on the node):
```json
{
@@ -195,7 +197,7 @@ its own feed. Drop it anywhere inside a record page:
"label": "Activity",
"value": "activity",
"children": [
- { "type": "record:activity", "limit": 20, "showCompleted": false }
+ { "type": "record:activity", "properties": { "limit": 20, "showCompleted": false } }
]
}
]
diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts
index c8fa47ba07..476180d005 100644
--- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts
+++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts
@@ -371,13 +371,17 @@ describe('objectui validate — a page built from ADR-0080 public blocks (object
it('validates a page with the taught `action:button` node (objectui#10872 batch 4)', async () => {
// The node the quick-start's "Add Actions" section teaches — the one
// AGENTS.md #4 and the handler-key refusals' own remedy point at — read
- // from the page that teaches it, beside a `page:header`.
+ // from the page that teaches it, beside a `page:header`. Since
+ // objectui#11183 the page writes it in the spec's spelling, the block's
+ // props in `properties`, so this row passes on the strict face as well.
const quickStart = readFileSync(QUICK_START, 'utf8');
const fence = /```json\n([\s\S]*?)\n```/.exec(quickStart.slice(quickStart.indexOf('### Add Actions')));
expect(fence, 'no ```json fence under "### Add Actions" in the quick-start').not.toBeNull();
const taught = JSON.parse((fence as RegExpExecArray)[1]);
- // Lit control on the extraction: it is the taught node.
+ // Lit control on the extraction: it is the taught node, in the bag spelling.
expect(taught.type).toBe('action:button');
+ expect(taught.properties?.actionType).toBe('url');
+ expect(taught).not.toHaveProperty('actionType');
const page = {
type: 'page',
title: 'Users',
diff --git a/packages/types/src/__tests__/held-public-block-arms-10872.test.ts b/packages/types/src/__tests__/held-public-block-arms-10872.test.ts
index 409caf98ef..02590d6265 100644
--- a/packages/types/src/__tests__/held-public-block-arms-10872.test.ts
+++ b/packages/types/src/__tests__/held-public-block-arms-10872.test.ts
@@ -75,6 +75,12 @@ const bagOf = (arm: z.ZodObject): z.ZodType => (arm.shape.properties as z.ZodOpt
const keysOf = (schema: z.ZodType): string[] =>
Object.keys((schema as unknown as { shape?: Record }).shape ?? {}).sort();
+/** The taught node with its bag hoisted flat — the spelling the page taught before objectui#11183. */
+const flattened = (node: Record): Record => {
+ const { properties, ...rest } = node;
+ return { ...rest, ...(properties as Record) };
+};
+
/** Every issue, union branches unfolded and paths made absolute. */
const allIssues = (issues: Issue[] | undefined, prefix: PropertyKey[] = []): Issue[] =>
(issues ?? []).flatMap((issue) => {
@@ -223,7 +229,11 @@ describe('objectui#10872 batch 4 — the two handler keys the action renderers r
const HERE = dirname(fileURLToPath(import.meta.url));
const QUICK_START = join(HERE, '..', '..', '..', '..', 'content', 'docs', 'guide', 'quick-start.md');
-/** The quick-start's "Add Actions" node — the one AGENTS.md #4 teaches, read from the page that teaches it. */
+/**
+ * The quick-start's "Add Actions" node — the one AGENTS.md #4 teaches, read from
+ * the page that teaches it. Since objectui#11183 the page writes it in the spec's
+ * spelling: the block's props in `properties`, nothing flat on the node.
+ */
function taughtActionButton(): Record {
const doc = readFileSync(QUICK_START, 'utf8');
const section = doc.slice(doc.indexOf('### Add Actions'));
@@ -233,10 +243,15 @@ function taughtActionButton(): Record {
}
describe('objectui#10872 batch 4 — a page with an `action:button` validates', () => {
- it('reads the taught node (non-vacuity)', () => {
+ it('reads the taught node (non-vacuity) — and it is in the bag spelling', () => {
const node = taughtActionButton();
expect(node.type).toBe('action:button');
- expect(node.actionType).toBe('url');
+ const bag = node.properties as Record;
+ expect(bag.actionType).toBe('url');
+ expect(bag.target).toBe('/users/ada');
+ // Nothing executor-shaped is left flat on the node (objectui#11183).
+ expect(node).not.toHaveProperty('actionType');
+ expect(node).not.toHaveProperty('target');
});
it('the taught node, in a page, passes the tolerant face `objectui validate` runs', () => {
@@ -245,23 +260,34 @@ describe('objectui#10872 batch 4 — a page with an `action:button` validates',
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
});
- it.each(FACES)('%s face: the same node in the spec\'s spelling — its props in `properties` — validates', (_face, judge) => {
- const { type, ...props } = taughtActionButton();
- const page = { type: 'page', children: [{ type: 'page:header', properties: { title: 'Users' } }, { type, properties: props }] };
+ it.each(FACES)('%s face: the taught node AS WRITTEN — its props in `properties` — validates, in a page and alone', (_face, judge) => {
+ const page = { type: 'page', children: [{ type: 'page:header', properties: { title: 'Users' } }, taughtActionButton()] };
const result = judge(page);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
+ expect(judge(taughtActionButton()).success).toBe(true);
+ });
+
+ it('the spec\'s own `PageComponentSchema` accepts the taught node as written', () => {
+ expect(PageComponentSchema.safeParse(taughtActionButton()).success).toBe(true);
});
- it('⚠️ the strict face refuses the taught node\'s FLAT props — the flat-props question objectui#10872 holds', () => {
- // A prop written flat on the node is not judged against the row: `label` is
- // a `BaseSchema` key, and `actionType` / `target` are undeclared there, so
- // the strict face refuses exactly those two, as the spec's own
- // `PageComponentSchema` does. Whether the flat spelling becomes an
- // authoring channel for `action:*` is that card's call; this reading moves
- // with it.
- const result = StrictAnyComponentSchema.safeParse(taughtActionButton());
+ it('CONTROL — the pre-objectui#11183 FLAT spelling of the same node is refused, by exactly its two executor keys', () => {
+ // objectui#10872's fork, ruled A on objectui#11183: the `properties` bag is
+ // the contract. A prop written flat on the node is not judged against the
+ // row — `label` is a `BaseSchema` key, `actionType` / `target` are
+ // undeclared there — so the strict face refuses exactly those two, as the
+ // spec's `PageComponentSchema` does. Derived from the taught node rather
+ // than transcribed, so the control moves with the page.
+ const flat = flattened(taughtActionButton());
+ expect(flat.actionType).toBe('url');
+ const result = StrictAnyComponentSchema.safeParse(flat);
expect(result.success).toBe(false);
expect(refusedKeys(result).sort()).toEqual(['actionType', 'target']);
- expect(PageComponentSchema.safeParse(taughtActionButton()).success).toBe(false);
+ const spec = PageComponentSchema.safeParse(flat);
+ expect(spec.success).toBe(false);
+ expect(refusedKeys(spec as Result).sort()).toEqual(['actionType', 'target']);
+ // The tolerant face still keeps the flat node, which is why the page had to move
+ // before `objectui validate` reaches the strict face (objectui#5250).
+ expect(safeValidateSchema(flat).success).toBe(true);
});
});
diff --git a/skills/objectui/SKILL.md b/skills/objectui/SKILL.md
index 15730680a8..0e8a1d02dc 100644
--- a/skills/objectui/SKILL.md
+++ b/skills/objectui/SKILL.md
@@ -83,16 +83,21 @@ styles can be overridden from JSON.
### 4. The action system (interactivity)
Actions are data, not functions — and a control that RUNS something is its own
-node type, `action:button`. `actionType` names the executor the action runner
-dispatches to; the node's own keys carry that executor's arguments:
+node type, `action:button`. Its props go in the node's `properties` bag, the spec's
+row for the block: `actionType` names the executor the action runner dispatches
+to, and the row's other keys carry that executor's arguments. ⛔ Never flat on the
+node — the spec's strict page component refuses a node-level `actionType` /
+`target` as mis-layered (ADR-0089 D3a), and so does objectui's strict authoring face:
```json
{
"type": "action:button",
- "label": "Open details",
- "actionType": "url",
- "target": "/users/ada"
+ "properties": {
+ "label": "Open details",
+ "actionType": "url",
+ "target": "/users/ada"
+ }
}
```
diff --git a/skills/objectui/guides/page-builder.md b/skills/objectui/guides/page-builder.md
index e9cc92fe7a..a9c817bc06 100644
--- a/skills/objectui/guides/page-builder.md
+++ b/skills/objectui/guides/page-builder.md
@@ -122,15 +122,19 @@ Keep custom component registrations namespaced to avoid collisions.
### 5. Use action data, not inline callback spaghetti
Represent interactions as data: a control that runs something is an `action:button`
-node, and `actionType` names the executor the action runner dispatches to.
+node. Its props go in the `properties` bag — `actionType` names the executor the
+action runner dispatches to — never flat on the node, which the spec's strict page
+component refuses (ADR-0089 D3a).
```json
{
"type": "action:button",
- "label": "Save customer",
- "actionType": "url",
- "target": "/customers"
+ "properties": {
+ "label": "Save customer",
+ "actionType": "url",
+ "target": "/customers"
+ }
}
```
@@ -251,17 +255,20 @@ renderers do (`schema.objectName`, `schema.columns`, `schema.fields`,
> names the field a form input writes. A grid column written as `{ "name": ... }`
> names no field, so `ObjectGrid` drops it.
-**Form plugin example:**
+**Form plugin example** — an `object-form` takes its props in the spec's `properties`
+bag, and `objectui validate` refuses the flat spelling by name:
```json
{
"type": "object-form",
- "objectName": "customer",
- "mode": "edit",
- "fields": [
- { "name": "name", "label": "Name", "type": "text", "required": true },
- { "name": "email", "label": "Email", "type": "text" }
- ]
+ "properties": {
+ "objectName": "customer",
+ "mode": "edit",
+ "fields": [
+ { "name": "name", "label": "Name", "type": "text", "required": true },
+ { "name": "email", "label": "Email", "type": "text" }
+ ]
+ }
}
```
diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md
index 644c8247e8..d7f1815656 100644
--- a/skills/objectui/rules/protocol.md
+++ b/skills/objectui/rules/protocol.md
@@ -253,17 +253,23 @@ channel is objectui#4795's open question, not a taught surface.
## Rule: Actions Are Node Types, Not An Event Bag
-A control that RUNS something MUST be its own node — `action:button` — with
-`actionType` naming the executor the action runner dispatches to and the node's
-own keys carrying that executor's arguments:
+A control that RUNS something MUST be its own node — `action:button` — and its
+props live in the node's `properties` bag (the spec's row for the block, hoisted
+onto the node by `SchemaRenderer` as the rule above describes): `actionType`
+names the executor the action runner dispatches to, and the row's other keys carry
+that executor's arguments. ⛔ Not flat on the node: the spec's strict page
+component refuses a node-level `actionType` / `target` as mis-layered
+(ADR-0089 D3a), and objectui's strict authoring face refuses the same two keys.
```json
{
"type": "action:button",
- "label": "Submit",
- "actionType": "url",
- "target": "/success"
+ "properties": {
+ "label": "Submit",
+ "actionType": "url",
+ "target": "/success"
+ }
}
```