Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': major
'@objectstack/service-automation': major
Expand Down Expand Up @@ -30,7 +30,7 @@
| you wrote | write instead | what changes |
|:--|:--|:--|
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
| `'{list.0}'` | `{ dialect: 'cel', source: 'list[0]' }` | an empty list fails the run |
| `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run |
| `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` |
| `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` |
| `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |
Expand Down
13 changes: 13 additions & 0 deletions .changeset/22290-value-slot-remedy-cel-claimed-head.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': patch
---

A value-slot `{…}` refusal now names a CEL spelling that evaluates when the path's head variable is named like a CEL type or keyword (`{list.0}` → `vars["list"][0]`)

Clause-②: no

The refusal for a `{…}` path token in a flow value slot (`valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals`, shown by `objectstack validate`, `registerFlow` and the executors) printed the path as bare CEL. For a head variable named `list`, that remedy was `list[0]`, and CEL read `list` as its own type, not the variable: `objectstack validate` refused the remedy it had just suggested (`Cannot index type 'type' with type 'int'`), and the bare `{list}` remedy `list` passed validation and evaluated to the type instead of the variable's value. A head that CEL claims for itself is now read through the flow scope's `vars` map, the route a `$`-named head already took: `{list.0}` → `vars["list"][0]`, `{list}` → `vars["list"]`.

The claimed names are read off the CEL implementation the formula engine builds (cel-js 8.0.0): the type identifiers `bool`, `bytes`, `double`, `int`, `list`, `map`, `null_type`, `string`, `type` and `uint`; the namespaces `cel`, `google` and `optional`; the reserved words, such as `for`, `if` and `var`; and the keywords `true`, `false`, `null` and `in`. `timestamp`, `duration` and `dyn` are functions there, not bindings, so a variable with one of those names already read correctly and is unchanged. A later path segment that is a keyword is indexed by name (`{record.in}` → `record["in"]`). The `has()` guard the refusal suggests is now printed only where `has()` accepts it. CEL refuses `has()` over an index at run time (`has(rows[0].name)`, `has(vars["list"].tags)`), so a path with an index gets no guard, and a claimed head is guarded as `has(vars.list.tags) ? vars.list.tags : null`.

Ordinary heads (`record.owner`, `items[0]`) print the same remedy as before. Which strings are refused and which are kept is unchanged; only the remedy text changes.
2 changes: 1 addition & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ for some input, so the rewrite is yours to judge.
| you wrote | write instead | what changes |
|:---|:---|:---|
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
| `'{list.0}'` | `source: 'list[0]'` | an empty list fails the run |
| `'{items.0}'` | `source: 'items[0]'` | an empty list fails the run |
| `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` |
| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division |
| `'Follow up on {record.name}'` | `source: "'Follow up on ' + record.name"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |
Expand Down
2 changes: 1 addition & 1 deletion docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,7 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`)
- **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent
- Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged.
- Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null).
- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, list[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal
- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal
- Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it.
- Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote.
- **`flow-write-node-stored-metadata-target-refused`** — `a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@
* - every REFUSED spelling resolves through the variables (a path), computes
* (an expression), or resolves to nothing — and the judge refuses each,
* including the dispatch-order edges (`{$User}` with no path, `{NOW}` with
* no call, a padded `{ amount }`).
* no call, a padded `{ amount }`);
* - for a refused PATH, the remedy the judge prints reads the same value
* through this package's CEL evaluator (`evaluateValueEnvelope`: the
* author-time envelope check, then the built `@objectstack/formula` engine
* over the flow's real CEL scope) as the interpolator read from the
* template — including a head variable named like an identifier CEL claims
* for itself (`{list.0}` → `vars["list"][0]`, #22290).
*/

import { describe, expect, it } from 'vitest';
import { valueSlotTemplateRefusals } from '@objectstack/spec/automation';
import { AutomationEngine } from '../engine.js';
import { interpolateString } from './template.js';

const VARIABLES = new Map<string, unknown>([
Expand Down Expand Up @@ -100,3 +107,73 @@ describe('a spelling the retirement REFUSES is one the interpolator reads as tem
expect(valueSlotTemplateRefusals('a {} b')).toEqual([]);
});
});

describe('the remedy a refused path prints reads, through CEL, the value the interpolator read', () => {
const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => quiet } as never;
const engine = new AutomationEngine(quiet);

/** The CEL spellings the one refusal of `token` names: the envelope's source, and the `has()` guard when it prints one. */
function printedSpellings(token: string): string[] {
const refusals = valueSlotTemplateRefusals(token);
expect(refusals, `exactly one refusal for ${token}`).toHaveLength(1);
const message = refusals[0]!.message;
const envelope = /Write `[^`]+` as \{ dialect: 'cel', source: (?:'([^']*)'|("(?:[^"\\]|\\.)*")) \}/.exec(message);
expect(envelope, `an envelope remedy in: ${message}`).not.toBeNull();
const guard = /: `(has\([^`]*)` \(the guarded form writes `null`\)/.exec(message);
return [envelope![1] ?? (JSON.parse(envelope![2]!) as string), ...(guard ? [guard[1]!] : [])];
}

/** Both readings of `token` over `variables`, and every printed spelling evaluated to the interpolator's value. */
function expectReadingsAgree(token: string, variables: Map<string, unknown>, value: unknown): void {
expect(interpolateString(token, variables, CONTEXT)).toEqual(value);
for (const source of printedSpellings(token)) {
expect(engine.evaluateValueEnvelope({ dialect: 'cel', source }, variables, token), source).toEqual(value);
}
}

// Every identifier CEL claims before a flow variable can (`flow-template-token.ts`
// in the spec reads them off cel-js 8.0.0): the type identifiers, the namespace
// constants, the reserved words and the keywords. `__proto__` is claimed there
// too, and is left out of this list: the flow CEL scope is a plain object, where
// `__proto__` names the prototype rather than a key, so no CEL spelling reads a
// variable of that name.
it.each([
'bool', 'bytes', 'double', 'int', 'list', 'map', 'null_type', 'string', 'type', 'uint',
'cel', 'google', 'optional',
'as', 'break', 'const', 'continue', 'else', 'for', 'function', 'if', 'import', 'let', 'loop', 'namespace',
'package', 'return', 'var', 'void', 'while', 'prototype',
'true', 'false', 'null', 'in',
])('a head variable named `%s`', (name) => {
const value = ['first', { key: 'second' }];
const variables = new Map<string, unknown>([[name, value]]);
expectReadingsAgree(`{${name}}`, variables, value);
expectReadingsAgree(`{${name}.0}`, variables, 'first');
expectReadingsAgree(`{${name}.1.key}`, variables, 'second');
expectReadingsAgree(`{${name}.tags}`, new Map<string, unknown>([[name, { tags: 'T' }]]), 'T');
});

it('the guard is read off the refusal too, so each row above evaluates it where one is printed', () => {
expect(printedSpellings('{list.tags}')).toEqual(['vars["list"].tags', 'has(vars.list.tags) ? vars.list.tags : null']);
expect(printedSpellings('{list}')).toEqual(['vars["list"]', 'has(vars.list) ? vars.list : null']);
expect(printedSpellings('{null.tags}')).toEqual(['vars["null"].tags']);
});

it('a later keyword segment, and an index in the middle of a path', () => {
const variables = new Map<string, unknown>([['record', { in: 'x', tags: { null: 'y' } }], ['rows', [{ name: 'r0' }]]]);
expectReadingsAgree('{record.in}', variables, 'x');
expectReadingsAgree('{record.tags.null}', variables, 'y');
expectReadingsAgree('{rows.0.name}', variables, 'r0');
});

it.each(['items', 'timestamp', 'duration', 'dyn'])('control: an ordinary head `%s` is read bare, unchanged', (name) => {
const variables = new Map<string, unknown>([[name, ['first', { key: 'second' }]]]);
expect(printedSpellings(`{${name}.0}`)).toEqual([`${name}[0]`]);
expectReadingsAgree(`{${name}.0}`, variables, 'first');
expectReadingsAgree(`{${name}.1.key}`, variables, 'second');
});

it('control: a `$`-named head is still read through `vars`', () => {
expect(printedSpellings('{$error.message}')).toEqual(['vars["$error"].message']);
expectReadingsAgree('{$error.message}', VARIABLES, 'boom');
});
});
Loading
Loading