Repository navigation
fix(service-automation)!: a whitespace-only config.condition is refused at registerFlow, the rule the edge door already carries (#17322) - #17491
Conversation
…used at `registerFlow` (#17322) A flow node's `config.condition` — a `decision` node's predicate, and on a `start` node the trigger gate — accepted a whitespace-only string, passed it to `AutomationEngine.evaluateCondition`, and landed on the empty-source arm (`exprStr.trim() === ''`), which returns `false` under a comment naming that arm as being for an UNAUTHORED condition. The value was authored: the branch never ran, forever, with nothing said at any layer. Since #15807 the EDGE door refuses exactly this value at `FlowSchema.parse` (`FlowEdgeSchema.condition` composes `EvaluatedExpressionInputSchema`). One operation, two implementations, inconsistent behaviour: the governed side wins and the other rebinds. A node's `config` is an open `z.record`, so `registerFlow`'s structural pass is the only producer-side gate it has, and the rule is applied there — by importing `EvaluatedExpressionInputSchema` and running the condition's source through it, so the two doors cannot drift into two notions of "blank" or two sentences for it. Applied to the SOURCE rather than to the whole value: the union would also refuse an envelope with no `dialect` or with a dialect outside its enum, both of which this slot admits, and refusing them would widen the narrowing past what was ruled. `evaluateCondition` and `structuralConditionRefusal` are unchanged. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
…ank-condition-node-door
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 80006201bc5ac3d06405d21b832dddb16fdeba6c && git checkout 80006201bc5ac3d06405d21b832dddb16fdeba6c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 113050e2c2bc92502895a5a174cdd1e8aef0f4ae aefbb07b26bc37abbf3147f71870e364b0092721 && git checkout -B drift-repro 113050e2c2bc92502895a5a174cdd1e8aef0f4ae && git merge --no-ff aefbb07b26bc37abbf3147f71870e364b0092721
node scripts/docs-audit/affected-docs.mjs --json 113050e2c2bc92502895a5a174cdd1e8aef0f4ae |
|
ACCEPT — 2026-09-10T17:47Z, head Checks on this head, taken from the check-run list and not from an aggregate roll-up:
What I checked beyond green:
The four acceptance notes are correctly reported rather than taken: notes 1 and 3 land in Flipped ready for review and armed for the merge queue. Not approved and not merged by this seat. Generated by Claude Code |
Fixes #17322
Clause-②: no
A flow node's
config.condition— adecisionnode's predicate, and on astartnode the trigger gate — accepted a whitespace-only string, passed it toAutomationEngine.evaluateCondition, and landed on the empty-source arm (exprStr.trim() === ''), which returnsfalseunder a comment that names that arm as being for an unauthored condition. The value was authored. The branch never ran, forever, with nothing said at any layer; on astartnode it gated the whole flow shut in silence.Since #15807 the edge door refuses exactly this value at
FlowSchema.parse(FlowEdgeSchema.conditioncomposesEvaluatedExpressionInputSchema). Triage's ruling on this card:What moved
One gate, in
registerFlow's structural-condition pass — the only producer-side gateconfig.conditionhas, itsconfigbeing an openz.record(z.string(), z.unknown())with no schema in front of it. It sits afterstructuralConditionRefusal(a string is still a well-shaped condition) and before the CEL check.The rule is imported, never re-derived: the condition's source is run through
EvaluatedExpressionInputSchemaitself, so the two doors cannot drift into two notions of "blank" or two sentences for it — the property the #15662 campaign built the shared refusal for. Both the verdict and the published sentence (EVALUATED_EXPRESSION_SOURCE_REQUIRED) come from spec's own export; nothing is re-spelled here.Applied to the source, not to the whole value, deliberately: the union would also refuse an envelope with no
dialector with a dialect outside its enum, and this slot admits both (structuralConditionRefusal's own docblock, and #4336). Refusing them would widen the narrowing past what was ruled.evaluateConditionandstructuralConditionRefusalare unchanged.evaluateConditionstill answersfalsefor a blank string: it is the shared evaluator and a public method on an exported class, so its throw behaviour is itself a contract, and a stored flow reaches it whatever the producer refuses. This change is at the producer only.The measurement triage asked for
Two probes over this branch's tree, each with its own positive control, because a zero without a control is not a measurement.
.ts.tsx.js.mjs.json.yaml.mdx)packages/lint/src/lint-flow-patterns.test.ts:1279,packages/lint/src/validate-expressions-nonstring-source.test.ts:118— the last a hook condition, not a flow node)condition:string literals found by the same probeconditionvalue as bare text or as an envelopesource)Probe A's zero is reported as unmeasured on purpose: its control is zero, so it proves nothing about its own reach. Its diagnostic reading is separate and is the useful one — only 3 tracked JSON files carry a
nodesarray at all (docs/qa/platform-checklist/areas/automation.json,packages/spec/liveness/flow.json,scripts/fixtures/i18n-walk-parity/every-group.stack.json) and only 2 carry aconditionkey, both of them liveness-ledger rows describing the key rather than authoring one. This repository does not author flows as JSON data, so probe B carries the measurement.Conclusion: no automation checked into this repository carries a blank
config.condition. Nothing here becomes a dead branch made loud. For a deployment that does carry one, the consequence is stated in the changeset and is wider than the branch: stored flows canonicalize atregisterFlow, and each of the three boot paths inplugin.tswraps that call intry/catch, logs onewarnnaming the flow, and continues — so the whole flow stops registering, its trigger is never armed, and the warn line is the only announcement. It is also the locator: the refusal names the node and the slot, e.g.node 'gate' (start) condition.Reproduction: RED before, GREEN after
packages/services/service-automation/src/structural-condition-shape.test.ts, run before any engine change:All six failures are the new blank-condition assertions; every control was green. After the fix, the same file:
Two pins in that file were flipped, not deleted — both pinned the ruling this card supersedes, and both are re-judged with the reason recorded in place:
CONTROL — the whitespace-only STRING ruling on this slot is untouched (#15662)→ now asserts the refusal at registration. service-automation:evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662 ruled the blank string correct on the ground that it was 「consistent on both sides」; spec/automation:FlowEdgeSchema.conditionstill accepts an envelope the engine cannot evaluate (ast-only, whitespace-onlysource) — the evaluated-slot rule of #15430 has not reached the edge condition #15807 removed that ground.a whitespace-only STRING still registers and still evaluates false→ same flip, and it now also pins that the two refusals stayed distinct: the answer is the evaluated-slot sentence, notSTRUCTURAL_CONDITION_SHAPE_REFUSAL.The evaluator half of both pins is kept and still asserts
false.Reverse verification
From the committed state: the gate was removed, the mutation was proved to have reached disk, and the same file was re-run.
Direction: turns red, as predicted. Restored from
HEAD(never a baregit checkout --, which restores the mutation out of the index) and proved by state, not by an exit code:The mutation is resolved from
src(the test imports./engine.jsrelatively, within the package), so nodistsits between the edit and the run — which the red itself demonstrates.Clause-② limbs, measured
Limb A — widen or narrow? A 20-shape census of
registerFlow's accept set forconfig.condition, run twice on one tree: once at HEAD, once with the new gate ablated by the mechanism above.'',' ','\t\n ',{ source: ' ' },{ source: '' },{ dialect: 'cel', source: ' ' }.absent/null(still accepted), the brace trap, thecronandtemplatedialects, theast-only envelope,42/true/['a']/{}(all still refused by their pre-existing verdicts).A pure narrowing. ⇒
Clause-②: no— breaking, but not widening.Limb B — public surface? Export declaration lines moved in
packages/services/service-automation/between the merge base and HEAD: 0 added, 0 removed (git diff MERGE_BASE HEAD -- packages/services/service-automation/matched againstexport const|function|class|type|interface|enum|default). The change is entirely inside a closure local toregisterFlow; the one new import is a consumption, not a re-export. No symbol is named because there is none.Verification
Base
origin/maine758131b3, merged in viascripts/pm/os-regen-merge.sh; every reading below is from the merged tree ataefbb07b2. Heavy runs went throughscripts/pm/os-verify-lock.shand the verdict quoted is the lock's ownVERDICT command-exitline.pnpm --filter @objectstack/service-automation test— 129 files / 1539 tests passed,VERDICT command-exit 0.pnpm --filter @objectstack/service-automation typecheck—VERDICT command-exit 0;check:test-typecheck: OK, 0 files / 0 errors.pnpm lint(eslint . --no-inline-config, the whole repository, not a narrowed run) —VERDICT command-exit 0, 84s.pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'— 72/72 tasks successful.pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date (run becausepackages/specmoved on main's side of the merge; this branch edits none of it).node scripts/pm/dispatch-gates.mjsderived 61 gate families for this diff. All 61 were run on the merged tree and all 61 exited 0.--ranreconciliation:61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded rather than claimed. Two of them (check:dual-build-cjs-loads,check:type-check-debt) exited 3 — PREREQUISITE NOT MET on the first pass and were re-run to a real 0 after the full build; neither zero is rounded up from a 3.node scripts/check-adr-0087-registration.mjs— green on the disposition below.Acceptance notes
Noted while measuring, not fixed here and not filed by this branch — each is outside this card's package, and
packages/specis this lane's declared red line.packages/specthat says the opposite.packages/spec/src/automation/flow-node-expression-paths.ts:424–:427, the docblock ofstructuralConditionRefusal, still admits "every string, including a whitespace-only one … a whitespace-only condition meaningfalseis consistent on both sides and is ruled correct, not a defect". Its stated ground was falsified by spec/automation:FlowEdgeSchema.conditionstill accepts an envelope the engine cannot evaluate (ast-only, whitespace-onlysource) — the evaluated-slot rule of #15430 has not reached the edge condition #15807 and superseded by this card's triage ruling.domain:spec.objectstack validateandregisterFlownow disagree.packages/lint/src/validate-expressions.ts:1209(checkStructuralCondition) applies onlystructuralConditionRefusal, so a blankconfig.conditionthat this PR refuses at registration is still reported as nothing by the linter — measured, andpackages/lint/src/lint-flow-patterns.test.ts:1279pins that silence withtoHaveLength(0). The third door needs the same rebinding.flow-edge-condition-evaluated-slot-source-requiredregisters the decision this change is a second face of, but itssurfaceandacceptanceCriterianame onlyedges[].condition; a consumer replaying the chain is not told to sweep the node key. The changeset's disposition says so in writing rather than claiming coverage it does not have.domain:spec.config.conditions[].expression(adecisionnode's branch list) and atscreen.fields[].visibleWhenstill registers clean and still lands on the same silent-falsearm —packages/services/service-automation/src/decision-predicate-envelope.test.ts:113–:117pins it, on service-automation: adecisioncondition accepts a CEL envelope that neither validator can see — a malformed one evaluates tofalseSILENTLY at run time and takes the wrong branch #15572's ruling, whose ground is the same 「consistent on both sides」 that spec/automation:FlowEdgeSchema.conditionstill accepts an envelope the engine cannot evaluate (ast-only, whitespace-onlysource) — the evaluated-slot rule of #15430 has not reached the edge condition #15807 removed. Whether that slot follows this one is a ruling, not a refactor, so it is reported rather than taken.Generated by Claude Code