Merged
42 changes: 42 additions & 0 deletions .changeset/assignment-value-role-cel-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
---

feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role

An assignment's whole job is to compute a value into a variable, yet CEL was
reachable from flow metadata only where the answer had to be a boolean
(`condition`, `decision.conditions[].expression`, a screen field's
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
and `flow-template`, and the `assignment` node's values were `{token}`
interpolation only. So the stdlib the platform already declares, documents and
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
called from metadata, and the commonest outbound shape a business application
has (one digest message listing a recipient's N records) needed a `script`
node.

Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.

- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
engine to the value the variable takes — not a predicate, not a template.
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
accept a `*` segment ("every key of this object", the sibling of `[]`), and
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
slot — a plain string there stays `{token}` interpolation. Every entry that
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
the exported recognizer both halves discriminate on.
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
`AssignmentExpressionValueSchema` declare the `assignment` node's value
contract: a string (`{token}` interpolation), a CEL value envelope (the
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
literal. Every value that parsed before still parses; the one newly refused
shape is a malformed envelope (no `source`, an empty or non-string `source`,
a `template` / `cron` / unknown dialect), refused at the variable's path with
a fixed leading sentence. The map value carries `.meta({ xExpression:
'value' })`, the declaration channel the ledger reads for this slot.

The executor half is a separate change in `@objectstack/service-automation`:
until it lands, the built-in `assignment` executor still writes an envelope
object into the variable verbatim and `notify` renders it as JSON, and the
ledger's reconciliation ratchet there does not yet know the `value` marker.
41 changes: 41 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```

**Assignment (set variables):**

```typescript
{
id: 'build_digest',
type: 'assignment',
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
},
},
}
```

A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
objects are assigned as literals. A later `notify` node renders the variable as
any other: `message: '{digest}'`.

<Callout type="warn" title="The contract landed first; the executor half follows">

The envelope form is what `@objectstack/spec` declares — the expression
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
[#14149]. Until the matching `@objectstack/service-automation` change lands,
the built-in `assignment` executor still writes an envelope object into the
variable verbatim and `notify` renders it as JSON. For a digest body today,
call a registered function from a `script` node.

[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149

</Callout>

**Create Record:**

```typescript
Expand Down
57 changes: 48 additions & 9 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas

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).
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
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

Expand DownExpand Up@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.

`assignment` is described by its VALUES, not by a key set (#14149): the
canonical `assignments` map's keys are the author's variable names, and with
no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
`AssignmentConfigSchema` below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form `assignments` map as the openness it is; that pin and
this contract describe the same surface from the two sides.

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
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
Expand All@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

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

---

## AssignmentConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **assignments** | `Record<string, any>` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |


---

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dialect** | `'cel'` | ✅ | |
| **source** | `string` | optional | |
| **ast** | `any` | optional | |
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |


---

## AssignmentValue

Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## CreateRecordConfig
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1589** | 14 protocol modules |
| **Total** | **199** | **1592** | 14 protocol modules |

---

Expand DownExpand Up@@ -103,15 +103,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 439 |
| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |

Expand All@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
| `automation/` | 66 | 42 | 0 | 0 | 24 |
| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **439** | **309** | **6** | **0** | **124** |
| **total** | **440** | **309** | **6** | **1** | **124** |

## File-level triage — site counts

Expand DownExpand Up@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
| `builtin-node-config.zod.ts` | 8 |
| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
Expand All@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
| **total** | **66** |
| **total** | **67** |

### `security/` — sites

Expand DownExpand Up@@ -204,7 +204,7 @@ over it is here.

### `automation/` — open

**24 strip of 66**, in 5 file(s).
**24 strip of 67**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **24** | **66** |
| **total** | **24** | **67** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
/** `true` = an open map with untyped values; an object = the schema every value takes. */
additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}

Expand All@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
*
* `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
* value may be a `{ dialect: 'cel', source }` envelope evaluated by the
* expression engine to a value — the `assignment` node's `assignments.*`. It
* IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
* it a distinct ledger role is the slot's shape rule: only an envelope-shaped
* object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
expression: 'predicate',
template: 'flow-template',
value: 'value',
};

/**
* Collect every `xExpression`-marked property in a configSchema, as the same
* `key[].nested` path syntax the ledger uses.
* `key[].nested` path syntax the ledger uses — and, since #14149, the same
* `key.*` syntax: an object-valued `additionalProperties` is the schema every
* value of an open map takes, so a marker there declares "every authored key
* of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
Expand All@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}

// An open map whose VALUES carry a schema contributes `key.*` — the ledger's
// spelling for "every authored key of this map" (`assignments.*`). A bare
// `additionalProperties: true` (untyped values — the `assignment`
// DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
// the spec channel cannot double-declare the same slot.
const values = schema.additionalProperties;
if (values && typeof values === 'object') {
const here = prefix ? `${prefix}.*` : '*';
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
out.push(...collectExpressionProps(values, here));
}
return out;
}

Expand DownExpand Up@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});

it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
);
expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
expect(assignment!.role).toBe('value');
// The descriptor declares `assignments` as `additionalProperties: true`
// (the openness IS its contract, pinned by the form↔Zod ledger), so the
// marker rides the spec Zod's map value and reaches this ratchet through
// the JSON map — never through the descriptor channel.
expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
// And the marker is mapped, not merely tolerated: an unknown marker still
// fails `roleOf`, and a ledger entry with no declaration still reads stale.
expect(ROLE_BY_MARKER.value).toBe('value');
});

it('screen.fields[].visibleWhen is covered — the #3528 regression', () => {
const screen = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen',
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/assignment-value-role-cel-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
---

feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role

An assignment's whole job is to compute a value into a variable, yet CEL was
reachable from flow metadata only where the answer had to be a boolean
(`condition`, `decision.conditions[].expression`, a screen field's
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
and `flow-template`, and the `assignment` node's values were `{token}`
interpolation only. So the stdlib the platform already declares, documents and
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
called from metadata, and the commonest outbound shape a business application
has (one digest message listing a recipient's N records) needed a `script`
node.

Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.

- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
engine to the value the variable takes — not a predicate, not a template.
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
accept a `*` segment ("every key of this object", the sibling of `[]`), and
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
slot — a plain string there stays `{token}` interpolation. Every entry that
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
the exported recognizer both halves discriminate on.
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
`AssignmentExpressionValueSchema` declare the `assignment` node's value
contract: a string (`{token}` interpolation), a CEL value envelope (the
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
literal. Every value that parsed before still parses; the one newly refused
shape is a malformed envelope (no `source`, an empty or non-string `source`,
a `template` / `cron` / unknown dialect), refused at the variable's path with
a fixed leading sentence. The map value carries `.meta({ xExpression:
'value' })`, the declaration channel the ledger reads for this slot.

The executor half is a separate change in `@objectstack/service-automation`:
until it lands, the built-in `assignment` executor still writes an envelope
object into the variable verbatim and `notify` renders it as JSON, and the
ledger's reconciliation ratchet there does not yet know the `value` marker.
41 changes: 41 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```

**Assignment (set variables):**

```typescript
{
id: 'build_digest',
type: 'assignment',
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
},
},
}
```

A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
objects are assigned as literals. A later `notify` node renders the variable as
any other: `message: '{digest}'`.

<Callout type="warn" title="The contract landed first; the executor half follows">

The envelope form is what `@objectstack/spec` declares — the expression
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
[#14149]. Until the matching `@objectstack/service-automation` change lands,
the built-in `assignment` executor still writes an envelope object into the
variable verbatim and `notify` renders it as JSON. For a digest body today,
call a registered function from a `script` node.

[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149

</Callout>

**Create Record:**

```typescript
Expand Down
57 changes: 48 additions & 9 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas

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).
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
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

Expand DownExpand Up@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.

`assignment` is described by its VALUES, not by a key set (#14149): the
canonical `assignments` map's keys are the author's variable names, and with
no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
`AssignmentConfigSchema` below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form `assignments` map as the openness it is; that pin and
this contract describe the same surface from the two sides.

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
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
Expand All@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

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

---

## AssignmentConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **assignments** | `Record<string, any>` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |


---

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dialect** | `'cel'` | ✅ | |
| **source** | `string` | optional | |
| **ast** | `any` | optional | |
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |


---

## AssignmentValue

Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## CreateRecordConfig
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1589** | 14 protocol modules |
| **Total** | **199** | **1592** | 14 protocol modules |

---

Expand DownExpand Up@@ -103,15 +103,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 439 |
| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |

Expand All@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
| `automation/` | 66 | 42 | 0 | 0 | 24 |
| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **439** | **309** | **6** | **0** | **124** |
| **total** | **440** | **309** | **6** | **1** | **124** |

## File-level triage — site counts

Expand DownExpand Up@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
| `builtin-node-config.zod.ts` | 8 |
| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
Expand All@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
| **total** | **66** |
| **total** | **67** |

### `security/` — sites

Expand DownExpand Up@@ -204,7 +204,7 @@ over it is here.

### `automation/` — open

**24 strip of 66**, in 5 file(s).
**24 strip of 67**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **24** | **66** |
| **total** | **24** | **67** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
/** `true` = an open map with untyped values; an object = the schema every value takes. */
additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}

Expand All@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
*
* `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
* value may be a `{ dialect: 'cel', source }` envelope evaluated by the
* expression engine to a value — the `assignment` node's `assignments.*`. It
* IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
* it a distinct ledger role is the slot's shape rule: only an envelope-shaped
* object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
expression: 'predicate',
template: 'flow-template',
value: 'value',
};

/**
* Collect every `xExpression`-marked property in a configSchema, as the same
* `key[].nested` path syntax the ledger uses.
* `key[].nested` path syntax the ledger uses — and, since #14149, the same
* `key.*` syntax: an object-valued `additionalProperties` is the schema every
* value of an open map takes, so a marker there declares "every authored key
* of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
Expand All@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}

// An open map whose VALUES carry a schema contributes `key.*` — the ledger's
// spelling for "every authored key of this map" (`assignments.*`). A bare
// `additionalProperties: true` (untyped values — the `assignment`
// DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
// the spec channel cannot double-declare the same slot.
const values = schema.additionalProperties;
if (values && typeof values === 'object') {
const here = prefix ? `${prefix}.*` : '*';
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
out.push(...collectExpressionProps(values, here));
}
return out;
}

Expand DownExpand Up@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});

it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
);
expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
expect(assignment!.role).toBe('value');
// The descriptor declares `assignments` as `additionalProperties: true`
// (the openness IS its contract, pinned by the form↔Zod ledger), so the
// marker rides the spec Zod's map value and reaches this ratchet through
// the JSON map — never through the descriptor channel.
expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
// And the marker is mapped, not merely tolerated: an unknown marker still
// fails `roleOf`, and a ledger entry with no declaration still reads stale.
expect(ROLE_BY_MARKER.value).toBe('value');
});

it('screen.fields[].visibleWhen is covered — the #3528 regression', () => {
const screen = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen',
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/assignment-value-role-cel-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
---

feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role

An assignment's whole job is to compute a value into a variable, yet CEL was
reachable from flow metadata only where the answer had to be a boolean
(`condition`, `decision.conditions[].expression`, a screen field's
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
and `flow-template`, and the `assignment` node's values were `{token}`
interpolation only. So the stdlib the platform already declares, documents and
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
called from metadata, and the commonest outbound shape a business application
has (one digest message listing a recipient's N records) needed a `script`
node.

Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.

- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
engine to the value the variable takes — not a predicate, not a template.
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
accept a `*` segment ("every key of this object", the sibling of `[]`), and
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
slot — a plain string there stays `{token}` interpolation. Every entry that
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
the exported recognizer both halves discriminate on.
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
`AssignmentExpressionValueSchema` declare the `assignment` node's value
contract: a string (`{token}` interpolation), a CEL value envelope (the
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
literal. Every value that parsed before still parses; the one newly refused
shape is a malformed envelope (no `source`, an empty or non-string `source`,
a `template` / `cron` / unknown dialect), refused at the variable's path with
a fixed leading sentence. The map value carries `.meta({ xExpression:
'value' })`, the declaration channel the ledger reads for this slot.

The executor half is a separate change in `@objectstack/service-automation`:
until it lands, the built-in `assignment` executor still writes an envelope
object into the variable verbatim and `notify` renders it as JSON, and the
ledger's reconciliation ratchet there does not yet know the `value` marker.
41 changes: 41 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```

**Assignment (set variables):**

```typescript
{
id: 'build_digest',
type: 'assignment',
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
},
},
}
```

A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
objects are assigned as literals. A later `notify` node renders the variable as
any other: `message: '{digest}'`.

<Callout type="warn" title="The contract landed first; the executor half follows">

The envelope form is what `@objectstack/spec` declares — the expression
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
[#14149]. Until the matching `@objectstack/service-automation` change lands,
the built-in `assignment` executor still writes an envelope object into the
variable verbatim and `notify` renders it as JSON. For a digest body today,
call a registered function from a `script` node.

[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149

</Callout>

**Create Record:**

```typescript
Expand Down
57 changes: 48 additions & 9 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas

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).
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
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

Expand DownExpand Up@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.

`assignment` is described by its VALUES, not by a key set (#14149): the
canonical `assignments` map's keys are the author's variable names, and with
no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
`AssignmentConfigSchema` below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form `assignments` map as the openness it is; that pin and
this contract describe the same surface from the two sides.

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
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
Expand All@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

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

---

## AssignmentConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **assignments** | `Record<string, any>` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |


---

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dialect** | `'cel'` | ✅ | |
| **source** | `string` | optional | |
| **ast** | `any` | optional | |
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |


---

## AssignmentValue

Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## CreateRecordConfig
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1589** | 14 protocol modules |
| **Total** | **199** | **1592** | 14 protocol modules |

---

Expand DownExpand Up@@ -103,15 +103,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 439 |
| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |

Expand All@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
| `automation/` | 66 | 42 | 0 | 0 | 24 |
| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **439** | **309** | **6** | **0** | **124** |
| **total** | **440** | **309** | **6** | **1** | **124** |

## File-level triage — site counts

Expand DownExpand Up@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
| `builtin-node-config.zod.ts` | 8 |
| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
Expand All@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
| **total** | **66** |
| **total** | **67** |

### `security/` — sites

Expand DownExpand Up@@ -204,7 +204,7 @@ over it is here.

### `automation/` — open

**24 strip of 66**, in 5 file(s).
**24 strip of 67**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **24** | **66** |
| **total** | **24** | **67** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
/** `true` = an open map with untyped values; an object = the schema every value takes. */
additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}

Expand All@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
*
* `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
* value may be a `{ dialect: 'cel', source }` envelope evaluated by the
* expression engine to a value — the `assignment` node's `assignments.*`. It
* IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
* it a distinct ledger role is the slot's shape rule: only an envelope-shaped
* object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
expression: 'predicate',
template: 'flow-template',
value: 'value',
};

/**
* Collect every `xExpression`-marked property in a configSchema, as the same
* `key[].nested` path syntax the ledger uses.
* `key[].nested` path syntax the ledger uses — and, since #14149, the same
* `key.*` syntax: an object-valued `additionalProperties` is the schema every
* value of an open map takes, so a marker there declares "every authored key
* of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
Expand All@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}

// An open map whose VALUES carry a schema contributes `key.*` — the ledger's
// spelling for "every authored key of this map" (`assignments.*`). A bare
// `additionalProperties: true` (untyped values — the `assignment`
// DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
// the spec channel cannot double-declare the same slot.
const values = schema.additionalProperties;
if (values && typeof values === 'object') {
const here = prefix ? `${prefix}.*` : '*';
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
out.push(...collectExpressionProps(values, here));
}
return out;
}

Expand DownExpand Up@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});

it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
);
expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
expect(assignment!.role).toBe('value');
// The descriptor declares `assignments` as `additionalProperties: true`
// (the openness IS its contract, pinned by the form↔Zod ledger), so the
// marker rides the spec Zod's map value and reaches this ratchet through
// the JSON map — never through the descriptor channel.
expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
// And the marker is mapped, not merely tolerated: an unknown marker still
// fails `roleOf`, and a ledger entry with no declaration still reads stale.
expect(ROLE_BY_MARKER.value).toBe('value');
});

it('screen.fields[].visibleWhen is covered — the #3528 regression', () => {
const screen = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen',
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/assignment-value-role-cel-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
---

feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role

An assignment's whole job is to compute a value into a variable, yet CEL was
reachable from flow metadata only where the answer had to be a boolean
(`condition`, `decision.conditions[].expression`, a screen field's
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
and `flow-template`, and the `assignment` node's values were `{token}`
interpolation only. So the stdlib the platform already declares, documents and
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
called from metadata, and the commonest outbound shape a business application
has (one digest message listing a recipient's N records) needed a `script`
node.

Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.

- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
engine to the value the variable takes — not a predicate, not a template.
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
accept a `*` segment ("every key of this object", the sibling of `[]`), and
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
slot — a plain string there stays `{token}` interpolation. Every entry that
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
the exported recognizer both halves discriminate on.
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
`AssignmentExpressionValueSchema` declare the `assignment` node's value
contract: a string (`{token}` interpolation), a CEL value envelope (the
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
literal. Every value that parsed before still parses; the one newly refused
shape is a malformed envelope (no `source`, an empty or non-string `source`,
a `template` / `cron` / unknown dialect), refused at the variable's path with
a fixed leading sentence. The map value carries `.meta({ xExpression:
'value' })`, the declaration channel the ledger reads for this slot.

The executor half is a separate change in `@objectstack/service-automation`:
until it lands, the built-in `assignment` executor still writes an envelope
object into the variable verbatim and `notify` renders it as JSON, and the
ledger's reconciliation ratchet there does not yet know the `value` marker.
41 changes: 41 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```

**Assignment (set variables):**

```typescript
{
id: 'build_digest',
type: 'assignment',
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
},
},
}
```

A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
objects are assigned as literals. A later `notify` node renders the variable as
any other: `message: '{digest}'`.

<Callout type="warn" title="The contract landed first; the executor half follows">

The envelope form is what `@objectstack/spec` declares — the expression
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
[#14149]. Until the matching `@objectstack/service-automation` change lands,
the built-in `assignment` executor still writes an envelope object into the
variable verbatim and `notify` renders it as JSON. For a digest body today,
call a registered function from a `script` node.

[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149

</Callout>

**Create Record:**

```typescript
Expand Down
57 changes: 48 additions & 9 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas

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).
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
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

Expand DownExpand Up@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.

`assignment` is described by its VALUES, not by a key set (#14149): the
canonical `assignments` map's keys are the author's variable names, and with
no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
`AssignmentConfigSchema` below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form `assignments` map as the openness it is; that pin and
this contract describe the same surface from the two sides.

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
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
Expand All@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

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

---

## AssignmentConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **assignments** | `Record<string, any>` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |


---

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dialect** | `'cel'` | ✅ | |
| **source** | `string` | optional | |
| **ast** | `any` | optional | |
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |


---

## AssignmentValue

Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## CreateRecordConfig
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1589** | 14 protocol modules |
| **Total** | **199** | **1592** | 14 protocol modules |

---

Expand DownExpand Up@@ -103,15 +103,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 439 |
| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |

Expand All@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
| `automation/` | 66 | 42 | 0 | 0 | 24 |
| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **439** | **309** | **6** | **0** | **124** |
| **total** | **440** | **309** | **6** | **1** | **124** |

## File-level triage — site counts

Expand DownExpand Up@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
| `builtin-node-config.zod.ts` | 8 |
| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
Expand All@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
| **total** | **66** |
| **total** | **67** |

### `security/` — sites

Expand DownExpand Up@@ -204,7 +204,7 @@ over it is here.

### `automation/` — open

**24 strip of 66**, in 5 file(s).
**24 strip of 67**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **24** | **66** |
| **total** | **24** | **67** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
/** `true` = an open map with untyped values; an object = the schema every value takes. */
additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}

Expand All@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
*
* `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
* value may be a `{ dialect: 'cel', source }` envelope evaluated by the
* expression engine to a value — the `assignment` node's `assignments.*`. It
* IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
* it a distinct ledger role is the slot's shape rule: only an envelope-shaped
* object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
expression: 'predicate',
template: 'flow-template',
value: 'value',
};

/**
* Collect every `xExpression`-marked property in a configSchema, as the same
* `key[].nested` path syntax the ledger uses.
* `key[].nested` path syntax the ledger uses — and, since #14149, the same
* `key.*` syntax: an object-valued `additionalProperties` is the schema every
* value of an open map takes, so a marker there declares "every authored key
* of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
Expand All@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}

// An open map whose VALUES carry a schema contributes `key.*` — the ledger's
// spelling for "every authored key of this map" (`assignments.*`). A bare
// `additionalProperties: true` (untyped values — the `assignment`
// DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
// the spec channel cannot double-declare the same slot.
const values = schema.additionalProperties;
if (values && typeof values === 'object') {
const here = prefix ? `${prefix}.*` : '*';
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
out.push(...collectExpressionProps(values, here));
}
return out;
}

Expand DownExpand Up@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});

it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
);
expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
expect(assignment!.role).toBe('value');
// The descriptor declares `assignments` as `additionalProperties: true`
// (the openness IS its contract, pinned by the form↔Zod ledger), so the
// marker rides the spec Zod's map value and reaches this ratchet through
// the JSON map — never through the descriptor channel.
expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
// And the marker is mapped, not merely tolerated: an unknown marker still
// fails `roleOf`, and a ledger entry with no declaration still reads stale.
expect(ROLE_BY_MARKER.value).toBe('value');
});

it('screen.fields[].visibleWhen is covered — the #3528 regression', () => {
const screen = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen',
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/assignment-value-role-cel-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
---

feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role

An assignment's whole job is to compute a value into a variable, yet CEL was
reachable from flow metadata only where the answer had to be a boolean
(`condition`, `decision.conditions[].expression`, a screen field's
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
and `flow-template`, and the `assignment` node's values were `{token}`
interpolation only. So the stdlib the platform already declares, documents and
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
called from metadata, and the commonest outbound shape a business application
has (one digest message listing a recipient's N records) needed a `script`
node.

Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.

- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
engine to the value the variable takes — not a predicate, not a template.
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
accept a `*` segment ("every key of this object", the sibling of `[]`), and
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
slot — a plain string there stays `{token}` interpolation. Every entry that
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
the exported recognizer both halves discriminate on.
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
`AssignmentExpressionValueSchema` declare the `assignment` node's value
contract: a string (`{token}` interpolation), a CEL value envelope (the
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
literal. Every value that parsed before still parses; the one newly refused
shape is a malformed envelope (no `source`, an empty or non-string `source`,
a `template` / `cron` / unknown dialect), refused at the variable's path with
a fixed leading sentence. The map value carries `.meta({ xExpression:
'value' })`, the declaration channel the ledger reads for this slot.

The executor half is a separate change in `@objectstack/service-automation`:
until it lands, the built-in `assignment` executor still writes an envelope
object into the variable verbatim and `notify` renders it as JSON, and the
ledger's reconciliation ratchet there does not yet know the `value` marker.
41 changes: 41 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```

**Assignment (set variables):**

```typescript
{
id: 'build_digest',
type: 'assignment',
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
},
},
}
```

A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
objects are assigned as literals. A later `notify` node renders the variable as
any other: `message: '{digest}'`.

<Callout type="warn" title="The contract landed first; the executor half follows">

The envelope form is what `@objectstack/spec` declares — the expression
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
[#14149]. Until the matching `@objectstack/service-automation` change lands,
the built-in `assignment` executor still writes an envelope object into the
variable verbatim and `notify` renders it as JSON. For a digest body today,
call a registered function from a `script` node.

[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149

</Callout>

**Create Record:**

```typescript
Expand Down
57 changes: 48 additions & 9 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas

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).
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
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

Expand DownExpand Up@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.

`assignment` is described by its VALUES, not by a key set (#14149): the
canonical `assignments` map's keys are the author's variable names, and with
no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
`AssignmentConfigSchema` below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form `assignments` map as the openness it is; that pin and
this contract describe the same surface from the two sides.

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
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
Expand All@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

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

---

## AssignmentConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **assignments** | `Record<string, any>` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |


---

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dialect** | `'cel'` | ✅ | |
| **source** | `string` | optional | |
| **ast** | `any` | optional | |
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |


---

## AssignmentValue

Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## CreateRecordConfig
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1589** | 14 protocol modules |
| **Total** | **199** | **1592** | 14 protocol modules |

---

Expand DownExpand Up@@ -103,15 +103,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 439 |
| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |

Expand All@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
| `automation/` | 66 | 42 | 0 | 0 | 24 |
| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **439** | **309** | **6** | **0** | **124** |
| **total** | **440** | **309** | **6** | **1** | **124** |

## File-level triage — site counts

Expand DownExpand Up@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
| `builtin-node-config.zod.ts` | 8 |
| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
Expand All@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
| **total** | **66** |
| **total** | **67** |

### `security/` — sites

Expand DownExpand Up@@ -204,7 +204,7 @@ over it is here.

### `automation/` — open

**24 strip of 66**, in 5 file(s).
**24 strip of 67**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **24** | **66** |
| **total** | **24** | **67** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
/** `true` = an open map with untyped values; an object = the schema every value takes. */
additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}

Expand All@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
*
* `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
* value may be a `{ dialect: 'cel', source }` envelope evaluated by the
* expression engine to a value — the `assignment` node's `assignments.*`. It
* IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
* it a distinct ledger role is the slot's shape rule: only an envelope-shaped
* object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
expression: 'predicate',
template: 'flow-template',
value: 'value',
};

/**
* Collect every `xExpression`-marked property in a configSchema, as the same
* `key[].nested` path syntax the ledger uses.
* `key[].nested` path syntax the ledger uses — and, since #14149, the same
* `key.*` syntax: an object-valued `additionalProperties` is the schema every
* value of an open map takes, so a marker there declares "every authored key
* of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
Expand All@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}

// An open map whose VALUES carry a schema contributes `key.*` — the ledger's
// spelling for "every authored key of this map" (`assignments.*`). A bare
// `additionalProperties: true` (untyped values — the `assignment`
// DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
// the spec channel cannot double-declare the same slot.
const values = schema.additionalProperties;
if (values && typeof values === 'object') {
const here = prefix ? `${prefix}.*` : '*';
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
out.push(...collectExpressionProps(values, here));
}
return out;
}

Expand DownExpand Up@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});

it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
);
expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
expect(assignment!.role).toBe('value');
// The descriptor declares `assignments` as `additionalProperties: true`
// (the openness IS its contract, pinned by the form↔Zod ledger), so the
// marker rides the spec Zod's map value and reaches this ratchet through
// the JSON map — never through the descriptor channel.
expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
// And the marker is mapped, not merely tolerated: an unknown marker still
// fails `roleOf`, and a ledger entry with no declaration still reads stale.
expect(ROLE_BY_MARKER.value).toBe('value');
});

it('screen.fields[].visibleWhen is covered — the #3528 regression', () => {
const screen = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen',
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/assignment-value-role-cel-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
---

feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role

An assignment's whole job is to compute a value into a variable, yet CEL was
reachable from flow metadata only where the answer had to be a boolean
(`condition`, `decision.conditions[].expression`, a screen field's
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
and `flow-template`, and the `assignment` node's values were `{token}`
interpolation only. So the stdlib the platform already declares, documents and
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
called from metadata, and the commonest outbound shape a business application
has (one digest message listing a recipient's N records) needed a `script`
node.

Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.

- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
engine to the value the variable takes — not a predicate, not a template.
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
accept a `*` segment ("every key of this object", the sibling of `[]`), and
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
slot — a plain string there stays `{token}` interpolation. Every entry that
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
the exported recognizer both halves discriminate on.
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
`AssignmentExpressionValueSchema` declare the `assignment` node's value
contract: a string (`{token}` interpolation), a CEL value envelope (the
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
literal. Every value that parsed before still parses; the one newly refused
shape is a malformed envelope (no `source`, an empty or non-string `source`,
a `template` / `cron` / unknown dialect), refused at the variable's path with
a fixed leading sentence. The map value carries `.meta({ xExpression:
'value' })`, the declaration channel the ledger reads for this slot.

The executor half is a separate change in `@objectstack/service-automation`:
until it lands, the built-in `assignment` executor still writes an envelope
object into the variable verbatim and `notify` renders it as JSON, and the
ledger's reconciliation ratchet there does not yet know the `value` marker.
41 changes: 41 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```

**Assignment (set variables):**

```typescript
{
id: 'build_digest',
type: 'assignment',
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
},
},
}
```

A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
objects are assigned as literals. A later `notify` node renders the variable as
any other: `message: '{digest}'`.

<Callout type="warn" title="The contract landed first; the executor half follows">

The envelope form is what `@objectstack/spec` declares — the expression
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
[#14149]. Until the matching `@objectstack/service-automation` change lands,
the built-in `assignment` executor still writes an envelope object into the
variable verbatim and `notify` renders it as JSON. For a digest body today,
call a registered function from a `script` node.

[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149

</Callout>

**Create Record:**

```typescript
Expand Down
57 changes: 48 additions & 9 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas

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).
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
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

Expand DownExpand Up@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.

`assignment` is described by its VALUES, not by a key set (#14149): the
canonical `assignments` map's keys are the author's variable names, and with
no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
`AssignmentConfigSchema` below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form `assignments` map as the openness it is; that pin and
this contract describe the same surface from the two sides.

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
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
Expand All@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

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

---

## AssignmentConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **assignments** | `Record<string, any>` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |


---

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dialect** | `'cel'` | ✅ | |
| **source** | `string` | optional | |
| **ast** | `any` | optional | |
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |


---

## AssignmentValue

Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## CreateRecordConfig
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1589** | 14 protocol modules |
| **Total** | **199** | **1592** | 14 protocol modules |

---

Expand DownExpand Up@@ -103,15 +103,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 439 |
| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |

Expand All@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
| `automation/` | 66 | 42 | 0 | 0 | 24 |
| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **439** | **309** | **6** | **0** | **124** |
| **total** | **440** | **309** | **6** | **1** | **124** |

## File-level triage — site counts

Expand DownExpand Up@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
| `builtin-node-config.zod.ts` | 8 |
| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
Expand All@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
| **total** | **66** |
| **total** | **67** |

### `security/` — sites

Expand DownExpand Up@@ -204,7 +204,7 @@ over it is here.

### `automation/` — open

**24 strip of 66**, in 5 file(s).
**24 strip of 67**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **24** | **66** |
| **total** | **24** | **67** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
/** `true` = an open map with untyped values; an object = the schema every value takes. */
additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}

Expand All@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
*
* `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
* value may be a `{ dialect: 'cel', source }` envelope evaluated by the
* expression engine to a value — the `assignment` node's `assignments.*`. It
* IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
* it a distinct ledger role is the slot's shape rule: only an envelope-shaped
* object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
expression: 'predicate',
template: 'flow-template',
value: 'value',
};

/**
* Collect every `xExpression`-marked property in a configSchema, as the same
* `key[].nested` path syntax the ledger uses.
* `key[].nested` path syntax the ledger uses — and, since #14149, the same
* `key.*` syntax: an object-valued `additionalProperties` is the schema every
* value of an open map takes, so a marker there declares "every authored key
* of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
Expand All@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}

// An open map whose VALUES carry a schema contributes `key.*` — the ledger's
// spelling for "every authored key of this map" (`assignments.*`). A bare
// `additionalProperties: true` (untyped values — the `assignment`
// DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
// the spec channel cannot double-declare the same slot.
const values = schema.additionalProperties;
if (values && typeof values === 'object') {
const here = prefix ? `${prefix}.*` : '*';
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
out.push(...collectExpressionProps(values, here));
}
return out;
}

Expand DownExpand Up@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});

it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
);
expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
expect(assignment!.role).toBe('value');
// The descriptor declares `assignments` as `additionalProperties: true`
// (the openness IS its contract, pinned by the form↔Zod ledger), so the
// marker rides the spec Zod's map value and reaches this ratchet through
// the JSON map — never through the descriptor channel.
expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
// And the marker is mapped, not merely tolerated: an unknown marker still
// fails `roleOf`, and a ledger entry with no declaration still reads stale.
expect(ROLE_BY_MARKER.value).toBe('value');
});

it('screen.fields[].visibleWhen is covered — the #3528 regression', () => {
const screen = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen',
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/assignment-value-role-cel-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
---

feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role

An assignment's whole job is to compute a value into a variable, yet CEL was
reachable from flow metadata only where the answer had to be a boolean
(`condition`, `decision.conditions[].expression`, a screen field's
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
and `flow-template`, and the `assignment` node's values were `{token}`
interpolation only. So the stdlib the platform already declares, documents and
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
called from metadata, and the commonest outbound shape a business application
has (one digest message listing a recipient's N records) needed a `script`
node.

Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.

- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
engine to the value the variable takes — not a predicate, not a template.
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
accept a `*` segment ("every key of this object", the sibling of `[]`), and
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
slot — a plain string there stays `{token}` interpolation. Every entry that
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
the exported recognizer both halves discriminate on.
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
`AssignmentExpressionValueSchema` declare the `assignment` node's value
contract: a string (`{token}` interpolation), a CEL value envelope (the
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
literal. Every value that parsed before still parses; the one newly refused
shape is a malformed envelope (no `source`, an empty or non-string `source`,
a `template` / `cron` / unknown dialect), refused at the variable's path with
a fixed leading sentence. The map value carries `.meta({ xExpression:
'value' })`, the declaration channel the ledger reads for this slot.

The executor half is a separate change in `@objectstack/service-automation`:
until it lands, the built-in `assignment` executor still writes an envelope
object into the variable verbatim and `notify` renders it as JSON, and the
ledger's reconciliation ratchet there does not yet know the `value` marker.
41 changes: 41 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```

**Assignment (set variables):**

```typescript
{
id: 'build_digest',
type: 'assignment',
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
},
},
}
```

A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
objects are assigned as literals. A later `notify` node renders the variable as
any other: `message: '{digest}'`.

<Callout type="warn" title="The contract landed first; the executor half follows">

The envelope form is what `@objectstack/spec` declares — the expression
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
[#14149]. Until the matching `@objectstack/service-automation` change lands,
the built-in `assignment` executor still writes an envelope object into the
variable verbatim and `notify` renders it as JSON. For a digest body today,
call a registered function from a `script` node.

[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149

</Callout>

**Create Record:**

```typescript
Expand Down
57 changes: 48 additions & 9 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas

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).
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
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

Expand DownExpand Up@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.

`assignment` is described by its VALUES, not by a key set (#14149): the
canonical `assignments` map's keys are the author's variable names, and with
no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
`AssignmentConfigSchema` below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form `assignments` map as the openness it is; that pin and
this contract describe the same surface from the two sides.

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
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
Expand All@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

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

---

## AssignmentConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **assignments** | `Record<string, any>` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |


---

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dialect** | `'cel'` | ✅ | |
| **source** | `string` | optional | |
| **ast** | `any` | optional | |
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |


---

## AssignmentValue

Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## CreateRecordConfig
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1589** | 14 protocol modules |
| **Total** | **199** | **1592** | 14 protocol modules |

---

Expand DownExpand Up@@ -103,15 +103,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 439 |
| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |

Expand All@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
| `automation/` | 66 | 42 | 0 | 0 | 24 |
| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **439** | **309** | **6** | **0** | **124** |
| **total** | **440** | **309** | **6** | **1** | **124** |

## File-level triage — site counts

Expand DownExpand Up@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
| `builtin-node-config.zod.ts` | 8 |
| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
Expand All@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
| **total** | **66** |
| **total** | **67** |

### `security/` — sites

Expand DownExpand Up@@ -204,7 +204,7 @@ over it is here.

### `automation/` — open

**24 strip of 66**, in 5 file(s).
**24 strip of 67**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **24** | **66** |
| **total** | **24** | **67** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
/** `true` = an open map with untyped values; an object = the schema every value takes. */
additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}

Expand All@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
*
* `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
* value may be a `{ dialect: 'cel', source }` envelope evaluated by the
* expression engine to a value — the `assignment` node's `assignments.*`. It
* IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
* it a distinct ledger role is the slot's shape rule: only an envelope-shaped
* object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
expression: 'predicate',
template: 'flow-template',
value: 'value',
};

/**
* Collect every `xExpression`-marked property in a configSchema, as the same
* `key[].nested` path syntax the ledger uses.
* `key[].nested` path syntax the ledger uses — and, since #14149, the same
* `key.*` syntax: an object-valued `additionalProperties` is the schema every
* value of an open map takes, so a marker there declares "every authored key
* of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
Expand All@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}

// An open map whose VALUES carry a schema contributes `key.*` — the ledger's
// spelling for "every authored key of this map" (`assignments.*`). A bare
// `additionalProperties: true` (untyped values — the `assignment`
// DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
// the spec channel cannot double-declare the same slot.
const values = schema.additionalProperties;
if (values && typeof values === 'object') {
const here = prefix ? `${prefix}.*` : '*';
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
out.push(...collectExpressionProps(values, here));
}
return out;
}

Expand DownExpand Up@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});

it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
);
expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
expect(assignment!.role).toBe('value');
// The descriptor declares `assignments` as `additionalProperties: true`
// (the openness IS its contract, pinned by the form↔Zod ledger), so the
// marker rides the spec Zod's map value and reaches this ratchet through
// the JSON map — never through the descriptor channel.
expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
// And the marker is mapped, not merely tolerated: an unknown marker still
// fails `roleOf`, and a ledger entry with no declaration still reads stale.
expect(ROLE_BY_MARKER.value).toBe('value');
});

it('screen.fields[].visibleWhen is covered — the #3528 regression', () => {
const screen = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen',
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/assignment-value-role-cel-envelope.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": minor
---

feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role

An assignment's whole job is to compute a value into a variable, yet CEL was
reachable from flow metadata only where the answer had to be a boolean
(`condition`, `decision.conditions[].expression`, a screen field's
`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
and `flow-template`, and the `assignment` node's values were `{token}`
interpolation only. So the stdlib the platform already declares, documents and
tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
called from metadata, and the commonest outbound shape a business application
has (one digest message listing a recipient's N records) needed a `script`
node.

Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.

- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
`{ dialect: 'cel', source }` expression envelope evaluated by the expression
engine to the value the variable takes — not a predicate, not a template.
The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
accept a `*` segment ("every key of this object", the sibling of `[]`), and
`resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
slot — a plain string there stays `{token}` interpolation. Every entry that
existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
the exported recognizer both halves discriminate on.
- `AssignmentConfigSchema` / `AssignmentValueSchema` /
`AssignmentExpressionValueSchema` declare the `assignment` node's value
contract: a string (`{token}` interpolation), a CEL value envelope (the
`ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
literal. Every value that parsed before still parses; the one newly refused
shape is a malformed envelope (no `source`, an empty or non-string `source`,
a `template` / `cron` / unknown dialect), refused at the variable's path with
a fixed leading sentence. The map value carries `.meta({ xExpression:
'value' })`, the declaration channel the ledger reads for this slot.

The executor half is a separate change in `@objectstack/service-automation`:
until it lands, the built-in `assignment` executor still writes an envelope
object into the variable verbatim and `notify` renders it as JSON, and the
ledger's reconciliation ratchet there does not yet know the `value` marker.
41 changes: 41 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```

**Assignment (set variables):**

```typescript
{
id: 'build_digest',
type: 'assignment',
label: 'Build digest',
config: {
assignments: {
// `{token}` flow interpolation — a sole token keeps the token's type,
// text with holes stays text
owner_name: '{manager.name}',
// CEL value envelope — evaluated by the expression engine to a value, so
// the declared CEL stdlib is reachable from metadata: one line per task
digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
},
},
}
```

A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
objects are assigned as literals. A later `notify` node renders the variable as
any other: `message: '{digest}'`.

<Callout type="warn" title="The contract landed first; the executor half follows">

The envelope form is what `@objectstack/spec` declares — the expression
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
[#14149]. Until the matching `@objectstack/service-automation` change lands,
the built-in `assignment` executor still writes an envelope object into the
variable verbatim and `notify` renders it as JSON. For a digest body today,
call a registered function from a `script` node.

[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149

</Callout>

**Create Record:**

```typescript
Expand Down
57 changes: 48 additions & 9 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas

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).
`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
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

Expand DownExpand Up@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.

`assignment` is described by its VALUES, not by a key set (#14149): the
canonical `assignments` map's keys are the author's variable names, and with
no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
`AssignmentConfigSchema` below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form `assignments` map as the openness it is; that pin and
this contract describe the same surface from the two sides.

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
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
Expand All@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';

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

---

## AssignmentConfig

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **assignments** | `Record<string, any>` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |


---

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dialect** | `'cel'` | ✅ | |
| **source** | `string` | optional | |
| **ast** | `any` | optional | |
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |


---

## AssignmentValue

Value the variable takes: a string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## CreateRecordConfig
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All@@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **199** | **1589** | 14 protocol modules |
| **Total** | **199** | **1592** | 14 protocol modules |

---

Expand DownExpand Up@@ -103,15 +103,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
14 changes: 7 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 439 |
| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |

Expand All@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
| `automation/` | 66 | 42 | 0 | 0 | 24 |
| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **439** | **309** | **6** | **0** | **124** |
| **total** | **440** | **309** | **6** | **1** | **124** |

## File-level triage — site counts

Expand DownExpand Up@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
| `builtin-node-config.zod.ts` | 8 |
| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
Expand All@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
| **total** | **66** |
| **total** | **67** |

### `security/` — sites

Expand DownExpand Up@@ -204,7 +204,7 @@ over it is here.

### `automation/` — open

**24 strip of 66**, in 5 file(s).
**24 strip of 67**, in 5 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
| **total** | **24** | **66** |
| **total** | **24** | **67** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
/** `true` = an open map with untyped values; an object = the schema every value takes. */
additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}

Expand All@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
*
* `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
* value may be a `{ dialect: 'cel', source }` envelope evaluated by the
* expression engine to a value — the `assignment` node's `assignments.*`. It
* IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
* it a distinct ledger role is the slot's shape rule: only an envelope-shaped
* object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
expression: 'predicate',
template: 'flow-template',
value: 'value',
};

/**
* Collect every `xExpression`-marked property in a configSchema, as the same
* `key[].nested` path syntax the ledger uses.
* `key[].nested` path syntax the ledger uses — and, since #14149, the same
* `key.*` syntax: an object-valued `additionalProperties` is the schema every
* value of an open map takes, so a marker there declares "every authored key
* of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
Expand All@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}

// An open map whose VALUES carry a schema contributes `key.*` — the ledger's
// spelling for "every authored key of this map" (`assignments.*`). A bare
// `additionalProperties: true` (untyped values — the `assignment`
// DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
// the spec channel cannot double-declare the same slot.
const values = schema.additionalProperties;
if (values && typeof values === 'object') {
const here = prefix ? `${prefix}.*` : '*';
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
out.push(...collectExpressionProps(values, here));
}
return out;
}

Expand DownExpand Up@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});

it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
);
expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
expect(assignment!.role).toBe('value');
// The descriptor declares `assignments` as `additionalProperties: true`
// (the openness IS its contract, pinned by the form↔Zod ledger), so the
// marker rides the spec Zod's map value and reaches this ratchet through
// the JSON map — never through the descriptor channel.
expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
// And the marker is mapped, not merely tolerated: an unknown marker still
// fails `roleOf`, and a ledger entry with no declaration still reads stale.
expect(ROLE_BY_MARKER.value).toBe('value');
});

it('screen.fields[].visibleWhen is covered — the #3528 regression', () => {
const screen = FLOW_NODE_EXPRESSION_PATHS.find(
(e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen',
Expand Down
Loading
Loading