Skip to content

Commit 16c5473

Browse files
fix(spec)!: refuse a decision branch with no expression — absent or null — at all three doors (#19961) (#20315)
Fixes #19961 Clause-②: no (narrowing) A `decision` branch with no `expression` (the key absent, or `expression: null`) is now refused at all three doors: `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`. It goes through the same walk, the same function and the same lead sentence that already refuse a blank branch predicate (#17493 / PR #19960). ## What was wrong, measured on `origin/main` `a9fb83ef` `DecisionConditionSchema` declares a branch `{ label, expression }` with `expression` a required `z.string()`. Nothing parses a decision node's open `config` against that schema. The expression ledger's resolver also skipped an absent value as "not authored". So the build accepted a branch that the run refuses. | branch | `FlowSchema.parse` | `registerFlow` | `objectstack validate --json` | |:--|:--|:--|:--| | `{ label: 'y' }` (the card's shape) | accepted | registered | `valid: true`, exit 0 | | `{ label: 'y', expression: null }` | accepted | registered | `valid: true`, exit 0 | | `{ label: 'y', condition: 'true' }` (the edge's spelling) | accepted | registered | `valid: true`, exit 0 | | `{ label: 'y', expression: ' ' }` (control, #19960) | refused, `custom` at `nodes.1.config.conditions.0.expression` | refused, same issue | `valid: false`, exit 1, same path | | `{ label: 'y', expression: 'true' }` (control) | accepted | registered | `valid: true`, exit 0 | What the run did with it: `evaluateCondition({ dialect: 'cel', source: undefined })` and `source: null` both throw `condition evaluation error: A structural condition …`. On the real decision executor, a run that reaches such a branch ends `success: false` at the branch (pinned below). ## The fix: one walk, one judge - `packages/spec/src/automation/flow-node-expression-paths.ts` - `FlowNodeExpressionPath` gains `required?: true`. It is set on `decision` `conditions[].expression` and on nothing else. - For a `required` predicate slot, `resolveFlowNodeExpressions` now emits the absent or `null` value on a branch that exists. It still skips a decision with no `conditions`, an empty list, and an absent screen `visibleWhen`. - `predicateSlotRefusal(undefined | null)` now has its own detail sentence and prescription, under the unchanged `PREDICATE_SLOT_STRING_REFUSAL` lead. - `packages/spec/src/automation/flow.zod.ts`: the `FlowSchema` predicate-slot refinement now admits the absent or `null` value of a `required` slot, next to strings. Every other non-string keeps its #15572 scope, so it is still not refused at this door. - `packages/lint/src/validate-expressions.ts`: `checkDeclaredPredicate` dropped its `raw == null` early return. Whether an absent value is a finding is the resolver's call. The early return answered "valid" for the exact value `FlowSchema.parse` refuses, for any caller of `validateStackExpressions` that does not parse first. This site is outside the claim's file surface. The measurement put the third door's refusal there (ablation B below). - `engine.ts`: no change. Its ledger pass already calls `predicateSlotRefusal` on everything the resolver emits, and `registerFlow` parses first, so the parse answers first. That is the same two-layer shape the blank has. **The route choice (Zone 2 item 3).** I chose (a), the predicate-slot walk treating an absent `expression` as a refused slot. I did not choose (b), parsing each branch against `DecisionConditionSchema`. Reasons, per axis: - Business need, measured: the only named producer is objectui's `rowsToList`, which writes `{ label }`. The absent key is the whole defect. - Long-term design: (a) keeps one judge (`predicateSlotRefusal`) and one walk for the three doors. (b) would be a second judge with Zod's own messages, a different prescription at each door, and a key-set closure on branches that nobody ruled. - Guarding AI authors: both refuse loudly. (a) also names the `condition` alias mistake in the same prescription. - No scope growth: (a) touches one ledger entry. (b) would narrow a much wider accept set, including unknown branch keys and the whole branch shape. `DecisionConditionSchema` and the fenced `DecisionConfigSchema` / `mode` region are untouched. #20168's PR #20279 landed while this was in flight, and this branch is merged over it (`7534fd7e`). Its refusal lives in `DecisionConfigSchema`, which no door parses a node's config against, so it and this walk do not meet. Its suite is green here (in the `src/automation` run below). **Prescription wording.** Triage (`5811370954`) says PR #19960's decision-branch prescription is "删掉这个分支" (delete the branch). The landed #19960 text says something else: write the predicate, or `expression: 'false'` to keep what the blank ran, and ⚠️ **not** by dropping a decision's only branch. That clause is pinned by `predicate-slot-blank.test.ts`. This PR follows the landed wording. It drops the "keep what ran" half, because an absent predicate never ran: it failed the run at the branch. So `'false'` is offered as "keep the branch and its label, never take it", and nothing is claimed to be preserved. ### The refusal text, quoted (`predicateSlotRefusal(undefined)`, byte for byte what all three doors print) ```text A predicate slot holds BARE CEL TEXT that states a rule — it is declared `z.string()` — so an expression envelope, any other non-string, or a string that is blank after trimming is not authorable there. Found nothing — the key is absent where the slot is required: a decision branch is `{ label, expression }` and its `expression` is not optional, so a branch without one states no rule. Write the predicate the branch was meant to test (e.g. `record.rating >= 4`); a predicate written under another key — `condition` is the edge's spelling — belongs in `expression`. There is no run to keep: the executor evaluates every branch it reaches, and a branch with no `expression` failed the run there. To keep the branch and its label but never take it, write `expression: 'false'`. Not by dropping a decision's only branch: the node then routes by its out-edges alone, and the out-edge that branch labelled is no longer held back. ``` For `null`, `Found nothing — the key is absent` reads `Found` followed by the code-spelled `null`. The lead sentence (`PREDICATE_SLOT_STRING_REFUSAL`) is unchanged, byte for byte. **After, measured on `e702ebd4` (the real CLI door, spec rebuilt; no file of this diff changed after that).** `{ label: 'y' }`, `expression: null` and `condition: 'true'` all give `objectstack validate --json` `valid: false`, exit 1, one `custom` error at `flows.0.nodes.1.config.conditions.0.expression`. The absent and alias messages are byte-identical. `registerFlow` refuses the same three with a `custom` issue at `nodes.1.config.conditions.0.expression`. `expression: 'true'` still validates and registers. The blank keeps its own message. ## Pins: one table per door, the same five rows Every refused row asserts the issue `code`, the `path`, and the full message equal to the spec's own `predicateSlotRefusal(value).message`. - `packages/spec/src/automation/flow-decision-branch-expression-absent.test.ts`: `FlowSchema.parse`. - The five rows: absent, `null`, `condition` alias, blank control, real accept control. - Branch index 1 is anchored. The ADR-0031 region body is anchored. - Controls: a decision with no `conditions` or `[]` still parses; an absent screen `visibleWhen` still parses. - `packages/services/service-automation/src/decision-branch-expression-absent.test.ts`: `registerFlow`. - The same table. `getFlow` is `null` after each refusal. - Region body. - On the real decision executor: the absent branch failed the run (`success: false`, `condition evaluation error`, ran `['start']`); `expression: 'false'` routes to the fallback. - `packages/lint/src/validate-expressions.test.ts` `describe('a decision branch with no expression (#19961)')`: `validateStackExpressions`, with the same table, the exact `where` string, branch index 1, and controls. - `packages/spec/src/automation/flow-node-expression-paths.test.ts`: - The resolver emits `undefined` or `null` for the decision slot and skips everything else. - `predicateSlotRefusal(undefined | null)` prescription clauses are pinned by name. - The `required` set is pinned to exactly `decision.conditions[].expression (predicate)`, because the absent arm's wording is decision-specific. - `packages/services/service-automation/src/builtin/config-expression-ledger.test.ts`: the reconciliation ratchet now reads each channel's JSON-Schema `required` list. It asserts that the ledger's `required` flags equal the channel's, in both directions, over the `predicate` role. It derives, not assumes, that `visibleWhen` is optional. It asserts that `required` is never set on another role. The channels do require `loop.collection` / `map.collection`, but no door refuses their absence (reported to the seat as an out-of-scope finding). **Pin sweep.** One published pin flipped: `decision-predicate-envelope.test.ts` asserted `decisionFlow('str_absent', undefined)` registers. It was re-judged in place, and the reason is written beside it. It now asserts the throw carries `PREDICATE_SLOT_STRING_REFUSAL` and `Found nothing — the key is absent where the slot is required`. Repo sweep for other branches without an `expression`: a bracket-balanced scan of every `.ts` / `.json` / `.yaml` file that mentions both `decision` and `conditions` found only this PR's own fixtures. A grep of helper-built branches (`{ label: …, expression }` shorthand) found 4 sites, all in suites run below. No other package's test builds a `decision` with `conditions`. ## Ablation: the pins can fail Both ablations were run on committed state through `scripts/ablation-replace.mjs`, which wraps the change, verifies it on disk and restores it with a trap. Both proved restore by blob hash equal to HEAD and an empty `git diff HEAD`. - **A: the `required` flag neutralised.** `required: true,` was replaced by a spread that is `{}` unless a `globalThis` flag named `ABLATION_19961` is set. - `ablation-dist-preflight.mjs @objectstack/spec ABLATION_19961` found the marker present in 20 built files. - Red, in the expected direction: - spec: 7 failed (the absent, `null` and alias rows, index 1, region, the `required`-set pin, the resolver pin); - service-automation: 6 failed (the three rows, region, the re-judged envelope pin, the ratchet); - lint: 4 failed. - The blank and real controls stayed green at every door. - Restore leg: rebuild, `--absent` marker gone from all 222 built files, whole-tree `git status` clean. spec 52/52, service-automation 34/34 and lint 344/344 green. - The first attempt was a no-op and its reading was discarded: my replacement was not valid TypeScript, so the transform failed and the build never ran. - **B: the lint early return put back** (`if (raw == null) return { refused: false };`): lint showed 4 failed (absent, `null`, alias, index 1), and the blank and real rows stayed green. Restored by blob hash. The first attempt was refused by the tool before running anything, because the anchor matched its own replacement. ## Producer census (Zone 2 item 4): authored count 0 - `examples/**` at `e702ebd4`: 3 flows carry `decision` nodes (app-crm `convert-lead`, app-showcase `needs_exec` / `triage`, app-todo `check_recurring`). All of them branch on out-edges and declare no `conditions`, so 0 branches lack an `expression`. - `packages/**` non-test: no default flow carries a `decision` node. The `content/docs/automation/flows.mdx` examples: 3 `conditions` lists, all with `expression`. - cloud `origin/main` `96eb092f`: 0 `decision` nodes. `service-ai-studio`'s authoring whitelist names `decision` as an authorable node type, so AI-authored flows now meet this refusal. - objectui at the pin `f8a9d0fb` (`.objectui-sha`): `FlowObjectListField` `rowsToList` still drops a blank cell, so a branch row with an empty expression cell is written as `{ label }`. That is the known writer. Triage accepted that its save now fails loudly, so it is not fixed here. ## ADR-0087 and changeset - New semantic entry `flow-decision-branch-expression-absent-refused` (major 18) and a regenerated `registry.ts`. - There is no D2 conversion: the platform cannot know the rule the author left out, and `'false'` would change behaviour rather than keep it. - The changeset `.changeset/19961-decision-branch-expression-absent-refused.md`: `@objectstack/spec` and `@objectstack/lint` `minor`, BREAKING, with the FROM → TO table. - `service-automation` gets no changeset: its diff is test files only, and those are not in `files[]`. ## Verification (final head `7534fd7e`, which is `origin/main` `6a6a17b6` merged, #20279 included) - Tests (`os-verify-lock`, spec rebuilt on this head): - spec `src/automation` + `src/migrations`: 1005/1005, including #20279's `schemaless-node-config.test.ts`. - service-automation: the 4 predicate-slot / ledger files, 50/50. - lint `validate-expressions.test.ts`: 344/344. - Full suites, run on the first merge head `266cd043`: spec 16855 passed (583 files), service-automation 1767/1767, lint 4269/4269. - Typecheck, including the test layers, on `266cd043`: spec, lint and service-automation all exit 0. No file of this diff changed after that. - Gates: `dispatch-gates.mjs --commands` re-derived on `7534fd7e` gives 90 families. 89 ran with exit 0. `dispatch-gates --ran` answers "90 derived famil(ies) accounted for — 89 run, 1 NOT-MEASURED". - NOT MEASURED: `check:type-check-debt`. Its `--re-measure` runs a whole-tree `turbo run build --filter=./packages/*` outside `os-verify-lock`. This diff touches no DEBT-ledger package. - On earlier heads, two gates needed their prerequisites built first, and both then exited 0. `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET because 12 unrelated packages were unbuilt. `check:dts-closure` went red on local state: 6 packages lost their `.d.ts` to my own interrupted `--re-measure` build. That is not this diff. - `eslint --no-inline-config --format json` over the 12 changed `.ts` files on `7534fd7e`: 12 files, 0 errors, 0 warnings. - Population: `eslint.config.mjs` `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, and no file here is ignored. - Invariance: the config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. - Declared narrowing: after the second and third `origin/main` merges, I re-ran the targeted suites above and every gate, not the full package suites. The incoming commits touch other surfaces (rls, date comparands, report charts, cli generate, pm scripts, and #20279's `DecisionConfigSchema` `mode`), not the flow predicate walk. ## Acceptance notes (observed, not filed) - `service-automation` `engine.ts` `evaluateCondition` still has an inline comment saying the empty-source arm is where "a `decision` node whose `conditions[]` entry has no `expression`" lands and answers `false`. Since #16038 the shape gate throws first, and since this PR the shape cannot register. The comment is stale; no behaviour follows from it. Carrier: whoever next edits `evaluateCondition`, else none. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc40033 commit 16c5473

13 files changed

Lines changed: 777 additions & 23 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(spec)!: a `decision` branch with no `expression` — the key absent, or `null` — is refused at authoring (#19961)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered flow-decision-branch-expression-absent-refused -->
11+
12+
**BREAKING** — an accept-set narrowing on one authored flow-node slot, shipped as
13+
`minor` under the launch-window convention (`check-changeset-no-major` refuses
14+
`major` until GA; breaking-ness is carried by this banner and the ADR-0087
15+
disposition above, not by the level).
16+
17+
**What changed.** `DecisionConditionSchema` declares a branch `{ label, expression }`
18+
with `expression` a required `z.string()`. Nothing enforced that: a decision node's
19+
`config` is an open record no schema is parsed against, and the expression ledger's
20+
resolver skipped an absent value as "not authored". So `conditions: [{ label: 'y' }]`
21+
passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`,
22+
and the run then failed at that branch — the executor evaluates every branch it
23+
reaches, and a branch with no `expression` is a condition with no `source`, which
24+
`evaluateCondition` refuses. The ledger now marks the slot `required` (reconciled
25+
against the schema's own `required` list), and the branch is refused at all three
26+
doors through the walk and the function that already refuse a blank one — by
27+
`FlowSchema.parse` with a `custom` issue anchored at the slot (for example
28+
`nodes.1.config.conditions.0.expression`), by `registerFlow` and `objectstack validate`
29+
through that same parse, and by `validateStackExpressions` for a stack handed to it
30+
directly — with one message, led by the published `PREDICATE_SLOT_STRING_REFUSAL`
31+
sentence. `expression: null` is refused the same way, and so is a branch that wrote
32+
its predicate under `condition` (the edge's spelling), which has no `expression`
33+
either. The Studio flow designer writes the refused shape when a branch row's
34+
expression cell is left empty. Where such a branch already sits, the whole flow is
35+
refused: registered from the metadata
36+
registry or `sys_metadata` at boot, it is skipped with a `failed to register flow`
37+
warn naming it while the flows beside it register; a `defineStack({ flows })` source
38+
throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused
39+
whole at load.
40+
41+
## FROM → TO
42+
43+
| you wrote | write instead |
44+
|:--|:--|
45+
| `conditions: [{ label: 'high' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` |
46+
| `conditions: [{ label: 'high', condition: 'record.amount > 10000' }]` | the same predicate under `expression` |
47+
| `conditions: [{ label: 'high', expression: null }]` | the predicate you meant, or `expression: 'false'` to keep the branch and never take it |
48+
49+
**One-line fix:** write the predicate under `expression`. `expression: 'false'` keeps
50+
the branch and its label and never takes it — a change of behaviour, not a preserved
51+
one: a run that reached the branch used to FAIL there, and now routes on to the next
52+
branch or the declared fallback. ⚠️ Do not drop a decision's only branch: the node
53+
then routes by its out-edges alone, and the out-edge that branch labelled is no
54+
longer held back.
55+
56+
**Unchanged.** A branch carrying a non-blank predicate parses, registers and
57+
validates as before; a blank one keeps its refusal and its own prescription
58+
(`flow-predicate-slot-blank-string-refused`); a `decision` with no `conditions`, or
59+
an empty list, still routes by its out-edges; an absent screen field `visibleWhen`
60+
is still legal (that slot is not required); and `PREDICATE_SLOT_STRING_REFUSAL`
61+
keeps its name and its text.

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ASSIGNMENT_VALUE_ENVELOPE_REFUSAL,
1616
PREDICATE_SLOT_STRING_REFUSAL,
1717
STRUCTURAL_CONDITION_SHAPE_REFUSAL,
18+
predicateSlotRefusal,
1819
} from '@objectstack/spec/automation';
1920

2021
import {
@@ -4398,6 +4399,75 @@ describe('a blank string in a ledger predicate slot (#17493)', () => {
43984399
});
43994400
});
44004401

4402+
/**
4403+
* [#19961] A `decision` branch with no `expression`, at the THIRD door:
4404+
* `objectstack validate`'s expression pass.
4405+
*
4406+
* `DecisionConditionSchema` declares `expression` `z.string()`, not optional,
4407+
* but `conditions: [{ label: 'y' }]` reported NOTHING here: the resolver skipped
4408+
* the absent value as "not authored", and `checkDeclaredPredicate` returned
4409+
* early on `null` / absent besides. The run then failed at the branch. The
4410+
* ledger now marks the slot `required`, the resolver emits the absent value
4411+
* there, and this pass refuses it through `predicateSlotRefusal` — the same
4412+
* call, the same message, as `FlowSchema.parse` and `registerFlow`.
4413+
*
4414+
* The table is the one those two doors run (in `spec` and `service-automation`):
4415+
* nothing, a blank string, a real predicate — asserted by `where`, severity and
4416+
* the full message, read off the spec's own function.
4417+
*
4418+
* ⚠️ Through the CLI, `objectstack validate` meets the absent value first at
4419+
* its schema step (`FlowSchema.parse` refuses it there, with the same message).
4420+
* This pass is what answers for a stack handed to `validateStackExpressions`
4421+
* directly, and it is what these pins drive.
4422+
*/
4423+
describe('a decision branch with no `expression` (#19961)', () => {
4424+
const flowStack = (...branches: Record<string, unknown>[]) => ({
4425+
flows: [{
4426+
name: 'absent_flow',
4427+
nodes: [{ id: 'start', type: 'start' }, { id: 'check', type: 'decision', config: { conditions: branches } }],
4428+
edges: [],
4429+
}],
4430+
});
4431+
const errorsOf = (stack: unknown) =>
4432+
validateStackExpressions(stack as never).filter((i) => (i.severity ?? 'error') === 'error');
4433+
const WHERE_0 = "flow 'absent_flow' · node 'check' (decision) decision branch expression at config.conditions[0].expression";
4434+
4435+
it.each([
4436+
{ name: 'no `expression` key — the #19961 shape', branch: { label: 'y' }, refused: true, refusedWith: undefined },
4437+
{ name: '`expression: null`', branch: { label: 'y', expression: null }, refused: true, refusedWith: null },
4438+
{ name: 'the predicate under the edge\'s spelling `condition`', branch: { label: 'y', condition: 'true' }, refused: true, refusedWith: undefined },
4439+
{ name: 'a blank string — the #17493 control', branch: { label: 'y', expression: ' ' }, refused: true, refusedWith: ' ' },
4440+
{ name: 'a real predicate — the accept control', branch: { label: 'y', expression: 'true' }, refused: false, refusedWith: undefined },
4441+
] as Array<{ name: string; branch: Record<string, unknown>; refused: boolean; refusedWith: unknown }>)('$name', ({ branch, refused, refusedWith }) => {
4442+
const found = errorsOf(flowStack(branch));
4443+
if (!refused) {
4444+
expect(found).toHaveLength(0);
4445+
return;
4446+
}
4447+
expect(found).toHaveLength(1);
4448+
expect(found[0].severity).toBe('error');
4449+
expect(found[0].where).toBe(WHERE_0);
4450+
expect(found[0].message).toBe(predicateSlotRefusal(refusedWith)!.message);
4451+
expect(found[0].message.startsWith(PREDICATE_SLOT_STRING_REFUSAL)).toBe(true);
4452+
});
4453+
4454+
it('names WHICH branch: an absent second branch is located at index 1, the valid first one is not', () => {
4455+
const found = errorsOf(flowStack({ label: 'a', expression: 'amount > 1' }, { label: 'b' }));
4456+
expect(found.map((i) => i.where)).toEqual([
4457+
"flow 'absent_flow' · node 'check' (decision) decision branch expression at config.conditions[1].expression",
4458+
]);
4459+
expect(found[0].source).toBe('');
4460+
});
4461+
4462+
it('CONTROL — a decision with no branch, and a screen field with no `visibleWhen`, report nothing', () => {
4463+
expect(errorsOf({ flows: [{ name: 'f', nodes: [{ id: 'check', type: 'decision', config: {} }], edges: [] }] })).toHaveLength(0);
4464+
expect(errorsOf(flowStack())).toHaveLength(0);
4465+
expect(errorsOf({
4466+
flows: [{ name: 'f', nodes: [{ id: 'form', type: 'screen', config: { fields: [{ name: 'amount', type: 'number' }] } }], edges: [] }],
4467+
})).toHaveLength(0);
4468+
});
4469+
});
4470+
44014471
/**
44024472
* [#20078] A field-level predicate that reads THROUGH a reference field is
44034473
* refused at authoring, with the repair that is true for the root it reads.

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1401,7 +1401,14 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
14011401
* a field-existence pass would report every field name as unknown.
14021402
*/
14031403
const checkDeclaredPredicate = (where: string, raw: unknown): { refused: boolean } => {
1404-
if (raw == null) return { refused: false };
1404+
// [#19961] No `raw == null` early return: whether an absent value is a
1405+
// finding is the resolver's call, not this pass's. It emits absent / `null`
1406+
// only for a `required` ledger slot (a `decision` branch's `expression`),
1407+
// and there it is a refusal — the one `predicateSlotRefusal` gives the
1408+
// other two doors. An early return here answered "valid" for the very
1409+
// value `FlowSchema.parse` refuses, for any caller of
1410+
// `validateStackExpressions` that did not parse first.
1411+
//
14051412
// [#15572] The slot is declared bare CEL TEXT, so a non-string — the
14061413
// `{ dialect, source }` envelope above all — is refused on SHAPE before
14071414
// anything tries to read a source out of it. The refusal is the spec's,

‎packages/services/service-automation/src/builtin/config-expression-ledger.test.ts‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ interface SchemaNode {
4848
items?: SchemaNode;
4949
/** `true` = an open map with untyped values; an object = the schema every value takes. */
5050
additionalProperties?: boolean | SchemaNode;
51+
/** The keys of `properties` this object requires (JSON Schema `required`). */
52+
required?: string[];
5153
xExpression?: string;
5254
}
5355

@@ -83,9 +85,9 @@ const ROLE_BY_MARKER: Record<string, FlowNodeExpressionRole> = {
8385
function collectExpressionProps(
8486
schema: SchemaNode | undefined,
8587
prefix = '',
86-
): { path: string; marker: string }[] {
88+
): { path: string; marker: string; required: boolean }[] {
8789
if (!schema || typeof schema !== 'object') return [];
88-
const out: { path: string; marker: string }[] = [];
90+
const out: { path: string; marker: string; required: boolean }[] = [];
8991

9092
if (schema.properties) {
9193
for (const [key, prop] of Object.entries(schema.properties)) {
@@ -96,7 +98,11 @@ function collectExpressionProps(
9698
const here = prefix
9799
? `${prefix}.${key}${isObjectArray ? '[]' : ''}`
98100
: `${key}${isObjectArray ? '[]' : ''}`;
99-
if (typeof prop.xExpression === 'string') out.push({ path: here, marker: prop.xExpression });
101+
// [#19961] Whether the declaring object REQUIRES the slot — read off the
102+
// same schema the marker is, so the ledger's `required` flag is
103+
// reconciled against the contract rather than restated beside it.
104+
const required = Array.isArray(schema.required) && schema.required.includes(key);
105+
if (typeof prop.xExpression === 'string') out.push({ path: here, marker: prop.xExpression, required });
100106
if (isObjectArray) out.push(...collectExpressionProps(prop.items, here));
101107
else out.push(...collectExpressionProps(prop, here));
102108
}
@@ -110,7 +116,8 @@ function collectExpressionProps(
110116
const values = schema.additionalProperties;
111117
if (values && typeof values === 'object') {
112118
const here = prefix ? `${prefix}.*` : '*';
113-
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
119+
// A map value is never "required": the map's keys are the author's own.
120+
if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression, required: false });
114121
out.push(...collectExpressionProps(values, here));
115122
}
116123
return out;
@@ -119,7 +126,7 @@ function collectExpressionProps(
119126
const engine = new AutomationEngine(silentLogger());
120127
installBuiltinNodes(engine, ctx());
121128

122-
type DeclaredSlot = { nodeType: string; path: string; role: FlowNodeExpressionRole };
129+
type DeclaredSlot = { nodeType: string; path: string; role: FlowNodeExpressionRole; required: boolean };
123130

124131
/** Resolve an `xExpression` marker to its ledger role, failing loudly on an unknown one. */
125132
function roleOf(nodeType: string, path: string, marker: string): FlowNodeExpressionRole {
@@ -137,8 +144,8 @@ function declaredFromDescriptors(): DeclaredSlot[] {
137144
const found: DeclaredSlot[] = [];
138145
for (const descriptor of engine.getActionDescriptors()) {
139146
const schema = descriptor.configSchema as SchemaNode | undefined;
140-
for (const { path, marker } of collectExpressionProps(schema)) {
141-
found.push({ nodeType: descriptor.type, path, role: roleOf(descriptor.type, path, marker) });
147+
for (const { path, marker, required } of collectExpressionProps(schema)) {
148+
found.push({ nodeType: descriptor.type, path, role: roleOf(descriptor.type, path, marker), required });
142149
}
143150
}
144151
return found;
@@ -163,8 +170,8 @@ function declaredFromDescriptors(): DeclaredSlot[] {
163170
function declaredFromSchemalessConfigs(): DeclaredSlot[] {
164171
const found: DeclaredSlot[] = [];
165172
for (const [nodeType, json] of Object.entries(getSchemalessNodeConfigJsonSchemas())) {
166-
for (const { path, marker } of collectExpressionProps(json as SchemaNode)) {
167-
found.push({ nodeType, path, role: roleOf(nodeType, path, marker) });
173+
for (const { path, marker, required } of collectExpressionProps(json as SchemaNode)) {
174+
found.push({ nodeType, path, role: roleOf(nodeType, path, marker), required });
168175
}
169176
}
170177
return found;
@@ -213,6 +220,33 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
213220
expect(stale, 'stale ledger entries — no descriptor or schemaless schema declares these').toEqual([]);
214221
});
215222

223+
/**
224+
* [#19961] The ledger's `required` flag is what makes the resolver emit an
225+
* ABSENT value for the doors to refuse — so it must say exactly what the
226+
* declaring channel's `required` list says, in both directions. A flag the
227+
* contract does not back would refuse a legal omission (an absent
228+
* `visibleWhen` shows the field); a requirement the ledger misses is the
229+
* #19961 shape again — declared required, admitted absent at every door.
230+
*
231+
* Reconciled over the `predicate` role, the one role the flag acts on: the
232+
* resolver emits an absent value only for a required PREDICATE slot. The
233+
* channels require two `flow-template` slots too (`loop.collection`,
234+
* `map.collection`), and no door refuses their absence — their executors
235+
* parse their own config — so the flag stays off there, and the second
236+
* assertion pins that it is never set on another role.
237+
*/
238+
it('the ledger marks `required` exactly the predicate slots the declaring channel requires (#19961)', () => {
239+
const declared = declaredEverywhere().filter((d) => d.role === 'predicate');
240+
const requiredByChannel = declared.filter((d) => d.required).map(key).sort();
241+
const requiredByLedger = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'predicate' && e.required).map(key).sort();
242+
expect(requiredByLedger, 'ledger `required` flags disagree with the declaring channel').toEqual(requiredByChannel);
243+
expect(FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role !== 'predicate' && e.required).map(key), '`required` acts on the predicate role only').toEqual([]);
244+
// Non-vacuous: the one required predicate slot there is today is derived,
245+
// not assumed — and the optional one (`visibleWhen`) is derived as optional.
246+
expect(requiredByChannel).toEqual(['decision.conditions[].expression (predicate)']);
247+
expect(declared.filter((d) => !d.required).map(key)).toEqual(['screen.fields[].visibleWhen (predicate)']);
248+
});
249+
216250
it('decision.conditions[].expression is covered — the #4439 hole', () => {
217251
const decision = FLOW_NODE_EXPRESSION_PATHS.find(
218252
(e) => e.nodeType === 'decision' && e.path === 'conditions[].expression',

0 commit comments

Comments
 (0)