Skip to content

Commit 62a789b

Browse files
os-zhuangclaude
andauthored
fix(spec,service-automation): reconcile the CRUD/screen/map declared config with what the executors read (#4045) (#4228)
Completes the declared-vs-read reconciliation across the flat builtins, after notify/http/connector in #4210. Six executor-derived Zod contracts land in automation/builtin-node-config.zod.ts (get/create/update/delete_record, screen + its field item, map), each written by READING the executor rather than transcribing the descriptor literal — so the new bidirectional ledger test is evidence, not a tautology. Contract exports only; nothing parses with them yet (step 3b, gated on the #4059 warning data). Writing them against the executors surfaced seven live capabilities that no form offered, online or offline — authorable only by hand-written metadata: get_record.fields (the projection passed into find/findOne), screen.recordId (the record `mode: 'edit'` opens — the form declared the mode but not its target), screen.fields[].options/defaultValue/placeholder (all three forwarded into the ScreenSpec the client renders, so a select field's choices could not be authored in Studio at all — the same nested repeater position as #3528), and map.indexVariable/map.input. All are declared on their descriptors now. map's undeclared `flow` alias went the other route: the executor carried `cfg.flowName ?? cfg.flow` for a spelling no schema ever described — the notify.source shape (PD #12). The bare fallback is deleted and flow-node-map-flow-alias (protocol 17, retires at 18) renames it at load, including the registerFlow rehydration seam. assignment is pinned as deliberately un-reconcilable with its reason on record: with no `assignments` wrapper its top-level config keys ARE the author's variable names, so no fixed key set describes it and a catchall Zod would reconcile vacuously. The ledger pins what CAN be pinned — the form offers exactly the canonical assignments map, and that map stays open. Every builtin publishing a configSchema is now reconciled against its executor; those publishing none each have a recorded reason. Claude-Session: https://claude.ai/code/session_01UDhMtxPLLoFaGtdpNA7xTU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40e420f commit 62a789b

19 files changed

Lines changed: 841 additions & 3 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
---
5+
6+
Reconcile the remaining flat builtins' declared config against what their
7+
executors read (#4045 — the CRUD / screen / map step, after notify / http /
8+
connector in #4210).
9+
10+
**Six executor-derived Zod contracts.** `GetRecordConfigSchema`,
11+
`CreateRecordConfigSchema`, `UpdateRecordConfigSchema`,
12+
`DeleteRecordConfigSchema`, `ScreenConfigSchema` (+ `ScreenFieldConfigSchema`)
13+
and `MapConfigSchema` in `automation/builtin-node-config.zod.ts`, each written
14+
by reading the executor rather than transcribing the descriptor literal, so the
15+
new bidirectional ledger test is evidence rather than a tautology. Contract
16+
exports only — nothing parses with them yet (#4045 step 3b, gated on the #4059
17+
warning data).
18+
19+
**Seven capabilities the executors honour are now authorable.** Each was read
20+
by the executor and offered by no form — online or offline — so it was reachable
21+
only by hand-written metadata:
22+
23+
- `get_record.fields` — the query projection, passed straight into
24+
`find`/`findOne`;
25+
- `screen.recordId` — the record `mode: 'edit'` opens; the form declared the
26+
edit mode while offering no way to name its target;
27+
- `screen.fields[].options` / `defaultValue` / `placeholder` — all three
28+
forwarded into the ScreenSpec the client renders, so a select field's choices
29+
could not be authored in Studio at all. Same nested repeater position as the
30+
`visibleWhen` gap #3528 was filed for;
31+
- `map.indexVariable` and `map.input` — the index binding and the per-item
32+
subflow params.
33+
34+
**`map`'s undeclared `flow` alias graduates to the conversion layer.** The
35+
executor carried `cfg.flowName ?? cfg.flow` for a spelling no schema ever
36+
described — the `notify.source` shape (Prime Directive #12). The bare fallback
37+
is deleted and `flow-node-map-flow-alias` (protocol 17, retires at 18) renames
38+
it at load, including the `AutomationEngine.registerFlow` rehydration seam.
39+
40+
**`assignment` is pinned as deliberately un-reconcilable**, with the reason on
41+
record: with no `assignments` wrapper its top-level config keys ARE the author's
42+
variable names, so no fixed key set can describe it and a catchall Zod would
43+
reconcile vacuously. What the ledger pins instead is that the form offers
44+
exactly the canonical `assignments` map and that the map stays open.
45+
46+
With this, every builtin that publishes a `configSchema` is reconciled against
47+
its executor, and the ones that publish none each have a recorded reason.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
---
2+
title: Builtin Node Config
3+
description: Builtin Node Config protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
@module automation/builtin-node-config
9+
10+
Config contracts for the remaining flat builtins — the CRUD quartet
11+
12+
(`get_record` / `create_record` / `update_record` / `delete_record`),
13+
14+
`screen`, and `map` (#4045). Sibling of `io-node-config.zod.ts`
15+
16+
(notify / http) and `control-flow.zod.ts` (loop / parallel / try_catch).
17+
18+
## Provenance — written from the executors, not from the forms
19+
20+
Each schema was derived by reading what the executor actually does with
21+
22+
`node.config` (`service-automation/builtin/crud-nodes.ts`,
23+
24+
`screen-nodes.ts`, `map-node.ts`), **not** by transcribing the hand-written
25+
26+
`configSchema` literal on the node's descriptor. The two artifacts are
27+
28+
reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` in
29+
30+
`service-automation`; a Zod copied from the form would make that
31+
32+
reconciliation a tautology (#4045).
33+
34+
Writing these against the executors is what surfaced the drift the
35+
36+
reconciliation exists to catch — keys the executors read that no form
37+
38+
offered anywhere (`get_record.fields`, `screen.recordId`, the screen
39+
40+
field-item keys `options`/`defaultValue`/`placeholder`, `map.indexVariable`,
41+
42+
`map.input`), and the undeclared `map.flow` alias, which graduated into the
43+
44+
ADR-0087 D2 conversion layer like `notify.source` before it.
45+
46+
## What these schemas are (and are not) wired to
47+
48+
Contract exports only — no engine path `parse()`s a node config with them
49+
50+
today, so registering a flow behaves exactly as before. Wiring the executors
51+
52+
to parse is deliberately deferred until the #4059 undeclared-key warning has
53+
54+
measured a release's worth of real metadata (#4045 step 3b).
55+
56+
Deliberately absent:
57+
58+
- `assignment` — its config cannot be described by a fixed key set: with no
59+
60+
`assignments` wrapper the TOP-LEVEL config keys ARE the author's variable
61+
62+
names (logic-nodes.ts). The ledger test pins that exemption with its
63+
64+
reason instead of pretending a shape.
65+
66+
- `decision` / `script` / `subflow` / `wait` / `connector_action` — the
67+
68+
deliberately-schemaless class (config-schemas.test.ts); their contracts
69+
70+
live elsewhere (edges, sibling blocks, conditional forms).
71+
72+
<Callout type="info">
73+
**Source:** `packages/spec/src/automation/builtin-node-config.zod.ts`
74+
</Callout>
75+
76+
## TypeScript Usage
77+
78+
```typescript
79+
import { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
80+
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
81+
82+
// Validate data
83+
const result = CreateRecordConfig.parse(data);
84+
```
85+
86+
---
87+
88+
## CreateRecordConfig
89+
90+
### Properties
91+
92+
| Property | Type | Required | Description |
93+
| :--- | :--- | :--- | :--- |
94+
| **objectName** | `string` || Object to insert into |
95+
| **fields** | `Record<string, any>` | optional | Field values to write on the new record |
96+
| **outputVariable** | `string` | optional | Flow variable bound to the created record |
97+
98+
99+
---
100+
101+
## DeleteRecordConfig
102+
103+
### Properties
104+
105+
| Property | Type | Required | Description |
106+
| :--- | :--- | :--- | :--- |
107+
| **objectName** | `string` || Object to delete from |
108+
| **filter** | `Record<string, any>` | optional | Field/value pairs identifying the record(s) to delete |
109+
110+
111+
---
112+
113+
## GetRecordConfig
114+
115+
### Properties
116+
117+
| Property | Type | Required | Description |
118+
| :--- | :--- | :--- | :--- |
119+
| **objectName** | `string` || Object to query |
120+
| **filter** | `Record<string, any>` | optional | Field/value pairs to match (operator values like `{"$ne": null}` are preserved) |
121+
| **fields** | `string[]` | optional | Field projection — only these fields are read (default: all) |
122+
| **limit** | `number` | optional | Max records to return; >1 switches to a multi-record query |
123+
| **outputVariable** | `string` | optional | Flow variable the result is bound to |
124+
125+
126+
---
127+
128+
## MapConfig
129+
130+
### Properties
131+
132+
| Property | Type | Required | Description |
133+
| :--- | :--- | :--- | :--- |
134+
| **collection** | `string \| any[]` || Template/variable resolving to the array to process (an inline array is accepted) |
135+
| **flowName** | `string` || Subflow run for each item — it may pause (e.g. an approval) |
136+
| **iteratorVariable** | `string` || Variable holding the current item |
137+
| **indexVariable** | `string` | optional | Optional variable holding the current index |
138+
| **itemObject** | `string` | optional | When items are records, the object they belong to (exposes each item as the child's record) |
139+
| **input** | `Record<string, any>` | optional | Params passed to each item's subflow (interpolated per item) |
140+
| **outputVariable** | `string` | optional | Each item's subflow output, collected in order |
141+
142+
143+
---
144+
145+
## ScreenConfig
146+
147+
### Properties
148+
149+
| Property | Type | Required | Description |
150+
| :--- | :--- | :--- | :--- |
151+
| **title** | `string` | optional | Heading shown above the screen |
152+
| **description** | `string` | optional | Body text shown under the heading |
153+
| **fields** | `{ name: string; label?: string; type?: string; required?: boolean; … }[]` | optional | Input fields collected on this screen |
154+
| **waitForInput** | `boolean` | optional | Pause to show the screen even with no fields; false forces a server pass-through |
155+
| **objectName** | `string` | optional | Render this object's full create/edit form instead of a flat field list |
156+
| **idVariable** | `string` | optional | Object form only: variable bound to the saved record's id |
157+
| **mode** | `Enum<'create' \| 'edit'>` | optional | Object form only: create or edit |
158+
| **recordId** | `string` | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) |
159+
| **defaults** | `Record<string, any>` | optional | Object form only: prefilled values |
160+
161+
162+
---
163+
164+
## ScreenFieldConfig
165+
166+
### Properties
167+
168+
| Property | Type | Required | Description |
169+
| :--- | :--- | :--- | :--- |
170+
| **name** | `string` || Field name (the flow variable the value binds to) |
171+
| **label** | `string` | optional | Display label |
172+
| **type** | `string` | optional | Input type |
173+
| **required** | `boolean` | optional | Whether a value is required to submit |
174+
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field |
175+
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
176+
| **placeholder** | `string` | optional | Input placeholder text |
177+
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
178+
179+
180+
---
181+
182+
## UpdateRecordConfig
183+
184+
### Properties
185+
186+
| Property | Type | Required | Description |
187+
| :--- | :--- | :--- | :--- |
188+
| **objectName** | `string` || Object to update |
189+
| **filter** | `Record<string, any>` | optional | Field/value pairs identifying the record(s) to update |
190+
| **fields** | `Record<string, any>` | optional | Field values to write |
191+
192+
193+
---
194+

content/docs/references/automation/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
88
<Cards>
99
<Card href="/docs/references/automation/approval" title="Approval" description="Source: packages/spec/src/automation/approval.zod.ts" />
1010
<Card href="/docs/references/automation/bpmn-interop" title="Bpmn Interop" description="Source: packages/spec/src/automation/bpmn-interop.zod.ts" />
11+
<Card href="/docs/references/automation/builtin-node-config" title="Builtin Node Config" description="Source: packages/spec/src/automation/builtin-node-config.zod.ts" />
1112
<Card href="/docs/references/automation/control-flow" title="Control Flow" description="Source: packages/spec/src/automation/control-flow.zod.ts" />
1213
<Card href="/docs/references/automation/etl" title="Etl" description="Source: packages/spec/src/automation/etl.zod.ts" />
1314
<Card href="/docs/references/automation/execution" title="Execution" description="Source: packages/spec/src/automation/execution.zod.ts" />

content/docs/references/automation/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"approval",
2121
"job",
2222
"---More---",
23+
"builtin-node-config",
2324
"io-node-config"
2425
]
2526
}

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ The same graduation covers `wait`, whose fallback was not a config-to-config ren
128128

129129
`connector_action` gets the same lift for the opposite reason (#4045). Its contract also lives in a declared sibling block (`connectorConfig`), and the executor never read `config` at all — but the node's descriptor published a `configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys, and the Studio inspector derives its form from a published schema, so schema-driven authoring wrote the trio to the wrong place and produced nodes that refused to dispatch. The conversion lifts the trio onto the declared block (declared keys win; a lift that cannot complete the required connectorId+actionId pair leaves the node untouched rather than turning a step-time refusal into a load failure), and the descriptor stops publishing the mis-rooted schema.
130130

131+
The reconciliation that found those also found `map`, whose executor read a bare `cfg.flowName ?? cfg.flow` for an undeclared `flow` spelling no schema ever described (#4045). A pure rename, graduated the same way, so the executor reads only the canonical `flowName`.
132+
131133
And it removes the RLS-policy key `priority` (#3896 security audit): promised "conflict resolution" that cannot exist, because applicable policies OR-combine (most permissive wins) — there is never a conflict to order, and nothing ever read the key (call graph closed across the collection site, the projection round-trip and the compiler). A pure lossless delete: outcomes are identical with or without it; the schema tombstones the key with the same prescription.
132134

133135
The same close-out retires the four inert tool authoring keys (`category`, `permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no execution path read them. Two were misleading in the dangerous direction — `permissions` promised an invocation gate nothing enforced, and `active: false` read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless deletes; the strict ToolSchema rejects each with its prescription.
@@ -152,6 +154,7 @@ The close-out sweep finishes the enforce-or-remove worklist across the remaining
152154
| `flow-node-notify-config-aliases` | `flow.node.notify.config` | notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796), and nested 'source: {object, id}' → 'sourceObject' / 'sourceId' (#4045) | live — protocol 17 loader accepts the old shape |
153155
| `flow-node-wait-event-config-lift` | `flow.node.wait.waitEventConfig` | wait flow-node loose config keys → the declared `waitEventConfig` block: 'eventType', 'timerDuration'/'duration' → 'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (#4045) | live — protocol 17 loader accepts the old shape |
154156
| `flow-node-connector-config-lift` | `flow.node.connector_action.connectorConfig` | connector_action flow-node loose config keys 'connectorId' / 'actionId' / 'input' → the declared `connectorConfig` block (#4045) | live — protocol 17 loader accepts the old shape |
157+
| `flow-node-map-flow-alias` | `flow.node.map.config.flowName` | map flow-node config key 'flow' → 'flowName' (#4045 — undeclared executor fallback graduation) | live — protocol 17 loader accepts the old shape |
155158
| `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape |
156159
| `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only |
157160
| `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only |

0 commit comments

Comments
 (0)