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
43 changes: 43 additions & 0 deletions .changeset/22477-flow-text-slot-dollar-root-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/spec': minor
'@objectstack/lint': patch
---

A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`.

Clause-②: no (narrowing)

<!-- adr-0087: registered flow-text-slot-unbound-dollar-root-refused -->

**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.

**Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one.

**What is refused.**

- A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`.
- `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key.
- `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it.
- `objectstack validate` reports `expression-invalid` at `error`.
- The remedy for `{{ $User.<path> }}` is the sentence `{$User.<path>}` gets: compute the value into a variable with an `assignment` node, whose value slot still reads that spelling, then write the variable as a hole. Any other root is named in the refusal, beside the variables the engine does bind.
- A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy.

**Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable.

**`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: '{$User.Id}' }`, then `message: 'By {{ by }}'` |
| `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` |

**The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.**

**Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured.

### The kit

- **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one.
- **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow.
5 changes: 4 additions & 1 deletion docs/protocol-upgrade-guide.md

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion packages/lint/src/lint-flow-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema } from '@objectstack/spec/automation';
import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, TryCatchConfigSchema, HttpConfigSchema, FlowSchema, NotifyConfigSchema, textSlotTemplateRefusal } from '@objectstack/spec/automation';
import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
// [#5659] The shared identity reduction, asserted beside the rule that consumes
// it — the rule's verdict and the drivers' verdict are one object now.
Expand Down Expand Up @@ -2533,6 +2533,34 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule
expect(fnds[0].message).toContain('notify title');
expect(fnds[0].hint).toContain('{{ $error.message }}');
});

// [#22477] The hole a bare `$User.Id` would become is refused by the
// spec's text-slot judge (the engine binds no `$User`), so the hint gives
// the judge's own remedy — asked of the judge, not re-listed here.
it('gives the judge\'s remedy, not a refused hole, for a bare `$User.Id`', () => {
const fnds = notifyText('Closed by $User.Id');
expect(fnds.map((f) => f.rule)).toEqual([FLOW_BARE_DOLLAR_REF]);
const refusal = textSlotTemplateRefusal('{{ $User.Id }}');
expect(refusal).toBeDefined();
expect(fnds[0].hint).toContain(refusal!);
expect(fnds[0].hint).toContain("assignments: { v: '{$User.Id}' }");
expect(fnds[0].hint).not.toContain('{{ $User.Id }}');
});

it('control: a bare engine-bound `$error.message` keeps the hole prescription and no remedy', () => {
const fnds = notifyText('Failed: $error.message');
expect(fnds.map((f) => f.rule)).toEqual([FLOW_BARE_DOLLAR_REF]);
expect(fnds[0].hint).toContain('`{{ $error.message }}`');
expect(fnds[0].hint).not.toContain('assignments:');
});

it('answers each bare reference on its own when one slot carries both kinds', () => {
const fnds = notifyText('Failed: $error.message, closed by $User.Id');
expect(fnds).toHaveLength(1);
expect(fnds[0].hint).toContain('`{{ $error.message }}`');
expect(fnds[0].hint).toContain(textSlotTemplateRefusal('{{ $User.Id }}')!);
expect(fnds[0].hint).not.toContain('{{ $User.Id }}');
});
});
});

Expand Down
36 changes: 32 additions & 4 deletions packages/lint/src/lint-flow-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ import {
collectFlowGraphs,
FLOW_NODE_TEXT_SLOTS,
flowNodeTextSlotSources,
textSlotTemplateRefusal,
} from '@objectstack/spec/automation';
import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation';
// [#15429] The decision's `mode` contract, parsed here so `os validate` and
Expand Down Expand Up @@ -669,6 +670,33 @@ const DOUBLE_BRACE = /\{\{\s*[\w$][\w$.\s]*\}\}/;
// A `$Ident.field` not immediately inside a `{` (so `{$User.Id}` is NOT flagged).
// Require a letter/_ after `$` so currency like `$5.00` is never matched.
const BARE_DOLLAR_REF = /(?:^|[^{])\$[A-Za-z_]\w*\.[A-Za-z_]/;
// Every such reference, whole (`$error.message`), at the same anchor — what a
// text slot's hint names, one hole or one remedy per reference.
const BARE_DOLLAR_REFS = /(?:^|[^{])(\$[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)/g;

/**
* [#22477] The hint for bare `$name.path` references in a text slot's text
* outside its holes. A reference whose hole the spec's text-slot judge admits
* is prescribed that hole; one whose `$` root the flow engine does not bind
* (`$User.Id`) would be refused as a hole too, so it gets the judge's own
* refusal and remedy instead. The judge is ASKED, never re-listed here: which
* `$` roots the engine binds is answered in one place
* (`flow-text-slot-template.ts`), and a second list would drift from it.
*/
function textSlotBareDollarHint(outsideHoles: string): string {
const refs = [...new Set([...outsideHoles.matchAll(BARE_DOLLAR_REFS)].map((m) => m[1]!))];
const holes: string[] = [];
const refusals: string[] = [];
for (const ref of refs) {
const refusal = textSlotTemplateRefusal(`{{ ${ref} }}`);
if (refusal === undefined) holes.push(`\`{{ ${ref} }}\``);
else refusals.push(`\`${ref}\` has no hole either: ${refusal}`);
}
const parts = ['A text slot renders only `{{ }}` holes, and all other text is literal.'];
if (holes.length > 0) parts.push(`Write it as a hole: ${holes.join(', ')}.`);
parts.push(...refusals);
return parts.join(' ');
}

/** Config keys whose string values are CEL predicates, not interpolated templates. */
const CEL_KEYS = new Set(['condition', 'expression', 'conditions']);
Expand Down Expand Up @@ -1759,16 +1787,16 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
}
}
// [#22110] The text slots' own bare-`$` check: read OUTSIDE their
// `{{ }}` holes, where a `$name.path` is the hole's correct content.
// `{{ }}` holes, where a `$name.path` is the hole's correct content —
// [#22477] when the engine binds its root; otherwise the hint carries
// the judge's remedy, never a hole the judge refuses.
for (const slot of flowNodeTextSlotSources(String(node.type), node.config)) {
const outsideHoles = slot.source.replace(/\{\{[^}]*\}\}/g, '');
if (BARE_DOLLAR_REF.test(outsideHoles)) {
findings.push({
where: nodeWhere,
message: `\`${slot.source.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` in the ${slot.label} is NOT rendered.`,
hint:
`Write it as a hole: \`{{ $ref.field }}\` (e.g. \`{{ $error.message }}\`) — a text slot renders only ` +
`\`{{ }}\` holes, and all other text is literal.`,
hint: textSlotBareDollarHint(outsideHoles),
rule: FLOW_BARE_DOLLAR_REF,
});
}
Expand Down
20 changes: 20 additions & 0 deletions packages/lint/src/validate-expressions.text-slot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
* 2. a slot with none is compiled as a `template` — a hole holding logic or an
* unknown formatter is an `error` too.
*
* And #22477: a `{{ }}` hole whose root is a `$` name the flow engine does not
* bind (`{{ $User.Id }}`, which rendered a blank fragment) is refused by the
* same judge — same door, same finding — with the remedy `{$User.Id}` gets.
*
* Every other notify / screen string keeps the single-brace dialect and gets
* nothing here. The `end` message is refused one door earlier, at the flow
* parse (`EndConfigSchema`), so it is pinned on `validateStackExpressions`
Expand Down Expand Up @@ -117,6 +121,22 @@ describe('`objectstack validate` — a flow text slot reads `{{ }}` holes (#2211
expect(validate('screen', { objectName: 'deal', mode: 'edit', recordId: '{record.id}', title: 'Edit {{ record.name }}' })).toEqual([]);
});

it('refuses `By {{ $User.Id }}` in a text slot at `error`, with the remedy `{$User.Id}` gets (#22477)', () => {
for (const [nodeType, config, where] of [
['notify', { recipients: ['u1'], title: 'Closed', message: 'By {{ $User.Id }}' }, 'notify message at config.message'],
['screen', { waitForInput: true, title: 'By {{ $User.Id }}' }, 'screen title at config.title'],
] as const) {
const findings = validate(nodeType, config);
expect(findings, JSON.stringify(config)).toHaveLength(1);
expect(findings[0]!.severity).toBe('error');
expect(findings[0]!.where).toContain(where);
expect(findings[0]!.message).toContain("assignments: { v: '{$User.Id}' }");
expect(findings[0]!.message.startsWith(TEXT_SLOT_TEMPLATE_REFUSAL)).toBe(false);
}
// Control: the engine-bound `$error` and an ordinary hole stay clean at the same door.
expect(validate('notify', { recipients: ['u1'], title: 'Deal {{ record.name }}', message: 'Failed: {{ $error.message }}' })).toEqual([]);
});

it('judges an `end` message too, for a stack handed to `validateStackExpressions` with no parse in front of it', () => {
const issues = validateStackExpressions(stackWith('end', { outcome: 'refused', message: 'No: {record.name}' }) as never)
.filter((i) => i.where.includes("node 'w'"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@
* `Date` rendered JSON-quoted, a whole-slot object `[object Object]`), which
* the renderer pins below hold.
*
* The card's three pins are the first describe block.
* The card's three pins are the first describe block. #22477's — a hole over
* a `$` root the engine does not bind is refused, and the spec judge's list of
* the roots it does bind misses none of them — are the last.
*/

import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { AutomationContext } from '@objectstack/spec/contracts';
import { TEXT_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation';
import { TEXT_SLOT_TEMPLATE_REFUSAL, textSlotTemplateRefusal } from '@objectstack/spec/automation';

import { AutomationEngine } from '../engine.js';
import { InMemorySuspendedRunStore } from '../suspended-run-store.js';
Expand Down Expand Up @@ -262,3 +267,70 @@ describe('#22110 — renderTextSlot, the one text renderer', () => {
expect(renderTextSlot('Hello {record.name}', vars({ record: ACME }))).toBe('Hello {record.name}');
});
});

/** This package's `src` — the runtime whose `$` variables the spec judge lists. */
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..');

/** A `$`-named variable bound by its literal name: `variables.set('$error', …)`. */
const DOLLAR_BINDING = /\.set\(\s*(['"`])(\$[A-Za-z_][\w$]*)\1/g;

/** Every `$`-named variable this package's runtime sources bind by literal name, with the file binding it. */
function engineBoundDollarVariables(): Map<string, string> {
const out = new Map<string, string>();
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path);
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) {
for (const match of readFileSync(path, 'utf8').matchAll(DOLLAR_BINDING)) {
if (!out.has(match[2]!)) out.set(match[2]!, path.slice(SRC.length + 1));
}
}
}
};
walk(SRC);
return out;
}

describe('#22477 — a text-slot hole may root only at a `$` variable the engine binds', () => {
const bound = engineBoundDollarVariables();

// A `$`-named variable this runtime starts binding must be admitted by the
// spec's one list (`FLOW_ENGINE_VARIABLES` in `@objectstack/spec`'s
// `flow-text-slot-template.ts`), or a text slot could not name it — add it
// THERE. And one it stops binding must leave that list too, or a hole over
// it would be admitted and render blank: that is what the floor below is
// for — it fails on a removal, so delete the name from both places.
it('the scan is not vacuous: it finds every `$` variable bound today', () => {
expect([...bound.keys()].sort()).toEqual(
expect.arrayContaining(['$error', '$flowLabel', '$flowName', '$loopIndex', '$loopItems', '$record', '$runId']),
);
});

it('the spec judge admits a hole over every `$` variable this runtime binds — its list misses none', () => {
for (const [name, file] of bound) {
expect(textSlotTemplateRefusal(`{{ ${name} }}`), `${name}, bound in ${file}`).toBeUndefined();
}
// Control: the judge is not admitting every `$` hole.
expect(bound.has('$User')).toBe(false);
expect(textSlotTemplateRefusal('{{ $User.Id }}')).toBeDefined();
});

it('an admitted root renders its value — `$flowName`, `$flowLabel`, `$record` are bound for every run', async () => {
const { engine, emitted } = harness();
engine.registerFlow('roots', notifyFlow('roots', { title: '{{ $flowName }} / {{ $flowLabel }} / {{ $record.name }}' }) as never);
const result = await engine.execute('roots', ctx());
expect(result.success, JSON.stringify(result)).toBe(true);
expect(emitted[0]!.payload).toMatchObject({ title: 'roots / roots / Acme Corp' });
});

it('registerFlow refuses `By {{ $User.Id }}` in a text slot with the remedy `{$User.Id}` gets — it would render `By `', () => {
const { engine } = harness();
const refusal = registrationRefusal(engine, 'by_user', notifyFlow('by_user', { title: 'Closed', message: 'By {{ $User.Id }}' }));
expect(refusal).toBeDefined();
expect(refusal).toContain("node 'notify' (notify) notify message at config.message");
expect(refusal).toContain("assignments: { v: '{$User.Id}' }");
// The renderer it no longer reaches: the hole resolves to nothing.
expect(renderTextSlot('By {{ $User.Id }}', new Map([['userId', 'usr_7']]))).toBe('By ');
});
});
Loading
Loading