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
47 changes: 47 additions & 0 deletions .changeset/builtin-node-config-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
---

Reconcile the remaining flat builtins' declared config against what their
executors read (#4045 — the CRUD / screen / map step, after notify / http /
connector in #4210).

**Six executor-derived Zod contracts.** `GetRecordConfigSchema`,
`CreateRecordConfigSchema`, `UpdateRecordConfigSchema`,
`DeleteRecordConfigSchema`, `ScreenConfigSchema` (+ `ScreenFieldConfigSchema`)
and `MapConfigSchema` in `automation/builtin-node-config.zod.ts`, each written
by reading the executor rather than transcribing the descriptor literal, so the
new bidirectional ledger test is evidence rather than a tautology. Contract
exports only — nothing parses with them yet (#4045 step 3b, gated on the #4059
warning data).

**Seven capabilities the executors honour are now authorable.** Each was read
by the executor and offered by no form — online or offline — so it was reachable
only by hand-written metadata:

- `get_record.fields` — the query projection, passed straight into
`find`/`findOne`;
- `screen.recordId` — the record `mode: 'edit'` opens; the form declared the
edit mode while offering no way to name 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. Same nested repeater position as the
`visibleWhen` gap #3528 was filed for;
- `map.indexVariable` and `map.input` — the index binding and the per-item
subflow params.

**`map`'s undeclared `flow` alias graduates to the conversion layer.** The
executor carried `cfg.flowName ?? cfg.flow` for a spelling no schema ever
described — the `notify.source` shape (Prime Directive #12). The bare fallback
is deleted and `flow-node-map-flow-alias` (protocol 17, retires at 18) renames
it at load, including the `AutomationEngine.registerFlow` rehydration seam.

**`assignment` is pinned as deliberately un-reconcilable**, with the reason on
record: with no `assignments` wrapper its top-level config keys ARE the author's
variable names, so no fixed key set can describe it and a catchall Zod would
reconcile vacuously. What the ledger pins instead is that the form offers
exactly the canonical `assignments` map and that the map stays open.

With this, every builtin that publishes a `configSchema` is reconciled against
its executor, and the ones that publish none each have a recorded reason.
194 changes: 194 additions & 0 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
---
title: Builtin Node Config
description: Builtin Node Config protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

@module automation/builtin-node-config

Config contracts for the remaining flat builtins — the CRUD quartet

(`get_record` / `create_record` / `update_record` / `delete_record`),

`screen`, and `map` (#4045). Sibling of `io-node-config.zod.ts`

(notify / http) and `control-flow.zod.ts` (loop / parallel / try_catch).

## Provenance — written from the executors, not from the forms

Each schema was derived by reading what the executor actually does with

`node.config` (`service-automation/builtin/crud-nodes.ts`,

`screen-nodes.ts`, `map-node.ts`), **not** by transcribing the hand-written

`configSchema` literal on the node's descriptor. The two artifacts are

reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` in

`service-automation`; a Zod copied from the form would make that

reconciliation a tautology (#4045).

Writing these against the executors is what surfaced the drift the

reconciliation exists to catch — keys the executors read that no form

offered anywhere (`get_record.fields`, `screen.recordId`, the screen

field-item keys `options`/`defaultValue`/`placeholder`, `map.indexVariable`,

`map.input`), and the undeclared `map.flow` alias, which graduated into the

ADR-0087 D2 conversion layer like `notify.source` before it.

## What these schemas are (and are not) wired to

Contract exports only — no engine path `parse()`s a node config with them

today, so registering a flow behaves exactly as before. Wiring the executors

to parse is deliberately deferred until the #4059 undeclared-key warning has

measured a release's worth of real metadata (#4045 step 3b).

Deliberately absent:

- `assignment` — its config cannot be described by a fixed key set: with no

`assignments` wrapper the TOP-LEVEL config keys ARE the author's variable

names (logic-nodes.ts). The ledger test pins that exemption with its

reason instead of pretending a shape.

- `decision` / `script` / `subflow` / `wait` / `connector_action` — the

deliberately-schemaless class (config-schemas.test.ts); their contracts

live elsewhere (edges, sibling blocks, conditional forms).

<Callout type="info">
**Source:** `packages/spec/src/automation/builtin-node-config.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

// Validate data
const result = CreateRecordConfig.parse(data);
```

---

## CreateRecordConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | ✅ | Object to insert into |
| **fields** | `Record<string, any>` | optional | Field values to write on the new record |
| **outputVariable** | `string` | optional | Flow variable bound to the created record |


---

## DeleteRecordConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | ✅ | Object to delete from |
| **filter** | `Record<string, any>` | optional | Field/value pairs identifying the record(s) to delete |


---

## GetRecordConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | ✅ | Object to query |
| **filter** | `Record<string, any>` | optional | Field/value pairs to match (operator values like `{"$ne": null}` are preserved) |
| **fields** | `string[]` | optional | Field projection — only these fields are read (default: all) |
| **limit** | `number` | optional | Max records to return; >1 switches to a multi-record query |
| **outputVariable** | `string` | optional | Flow variable the result is bound to |


---

## MapConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **collection** | `string \| any[]` | ✅ | Template/variable resolving to the array to process (an inline array is accepted) |
| **flowName** | `string` | ✅ | Subflow run for each item — it may pause (e.g. an approval) |
| **iteratorVariable** | `string` | ✅ | Variable holding the current item |
| **indexVariable** | `string` | optional | Optional variable holding the current index |
| **itemObject** | `string` | optional | When items are records, the object they belong to (exposes each item as the child's record) |
| **input** | `Record<string, any>` | optional | Params passed to each item's subflow (interpolated per item) |
| **outputVariable** | `string` | optional | Each item's subflow output, collected in order |


---

## ScreenConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **title** | `string` | optional | Heading shown above the screen |
| **description** | `string` | optional | Body text shown under the heading |
| **fields** | `{ name: string; label?: string; type?: string; required?: boolean; … }[]` | optional | Input fields collected on this screen |
| **waitForInput** | `boolean` | optional | Pause to show the screen even with no fields; false forces a server pass-through |
| **objectName** | `string` | optional | Render this object's full create/edit form instead of a flat field list |
| **idVariable** | `string` | optional | Object form only: variable bound to the saved record's id |
| **mode** | `Enum<'create' \| 'edit'>` | optional | Object form only: create or edit |
| **recordId** | `string` | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) |
| **defaults** | `Record<string, any>` | optional | Object form only: prefilled values |


---

## ScreenFieldConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field |
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
| **placeholder** | `string` | optional | Input placeholder text |
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |


---

## UpdateRecordConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | ✅ | Object to update |
| **filter** | `Record<string, any>` | optional | Field/value pairs identifying the record(s) to update |
| **fields** | `Record<string, any>` | optional | Field values to write |


---

1 change: 1 addition & 0 deletions content/docs/references/automation/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
<Cards>
<Card href="/docs/references/automation/approval" title="Approval" description="Source: packages/spec/src/automation/approval.zod.ts" />
<Card href="/docs/references/automation/bpmn-interop" title="Bpmn Interop" description="Source: packages/spec/src/automation/bpmn-interop.zod.ts" />
<Card href="/docs/references/automation/builtin-node-config" title="Builtin Node Config" description="Source: packages/spec/src/automation/builtin-node-config.zod.ts" />
<Card href="/docs/references/automation/control-flow" title="Control Flow" description="Source: packages/spec/src/automation/control-flow.zod.ts" />
<Card href="/docs/references/automation/etl" title="Etl" description="Source: packages/spec/src/automation/etl.zod.ts" />
<Card href="/docs/references/automation/execution" title="Execution" description="Source: packages/spec/src/automation/execution.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"approval",
"job",
"---More---",
"builtin-node-config",
"io-node-config"
]
}
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ The same graduation covers `wait`, whose fallback was not a config-to-config ren

`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.

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`.

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.

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.
Expand All @@ -152,6 +154,7 @@ The close-out sweep finishes the enforce-or-remove worklist across the remaining
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
Expand Down
Loading
Loading