Skip to content

service-automation: refuse a structural flow condition that is neither CEL text nor an expression - #15792

Merged
os-warren merged 5 commits into
mainfrom
claude/issue-15662-structural-condition-refusal
Sep 5, 2026
Merged

service-automation: refuse a structural flow condition that is neither CEL text nor an expression#15792
os-warren merged 5 commits into
mainfrom
claude/issue-15662-structural-condition-refusal

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #15662

evaluateCondition derives its source as typeof expression === 'string' ? expression : (expression?.source ?? ''). For a value that is neither a string nor envelope-shaped the read yields undefined, the ?? supplies '', and the empty-source arm returns false — the documented "an unauthored branch must not open" rule, applied to a value that was very much authored. config.condition is also the key a start node's trigger gate is read from, so a value like 42 there could gate a whole flow shut forever with nothing said at any layer.

1. Blast radius — measured first, before any refusal was written

The card named this as the trap and it is the deliverable that decides the rule. Driven on this branch's base (AutomationEngine constructed directly; FlowSchema.parse, canonicalizeStoredFlow/graftConditionEnvelopes, registerFlow and evaluateCondition all exercised), not inferred from the code:

surface authored after FlowSchema.parse verdict
edge.condition "record.status == 'x'" {"dialect":"cel","source":"record.status == 'x'"} always an envelope
edge.condition 42, ['a'] already REFUSED by the edge schema
node config.condition "record.rating >= 4" "record.rating >= 4" left a string (open z.record)
node config.condition {dialect:'cel',source:'…'} passed through verbatim ACCEPTED, and evaluated correctly
canonicalizeStoredFlow node config.condition unchanged the graft never lowers it

evaluateCondition, same run: {dialect:'cel',source:'1 == 1'}true; {source:'1 == 1'} (no dialect) → true; 42false; truefalse; ['a']false; {source:1}TypeError: exprStr.trim is not a function. Through registerFlow, a decision node with config: { condition: 42 | true | ['a'] }REGISTERED, and the same values on the start node → REGISTERED.

Two shapes are legitimate on this arm: bare CEL text, and the expression envelope. The envelope is not a tolerated accident — after the parse it is the only shape an edge condition ever has, and at config.condition it is accepted by the schema and evaluated correctly by design (#4336: the dialect is decided by the source, so both spellings evaluate the same).

2. The refusal, and why it is not the ledger arm's rule

STRUCTURAL_CONDITION_SHAPE_REFUSAL / structuralConditionRefusal, new in @objectstack/spec/automation beside predicateSlotRefusal. One notion, derived once, read by registerFlow and by objectstack validate so build time and author time cannot disagree about the shape.

  • Admitted: every string (including whitespace-only); absent / null; any object carrying a string source or an astExpressionSchema's own source-or-ast rule, read rather than re-derived, with dialect optional because evaluateCondition already treats a dialect-less envelope as CEL.
  • Refused: a number, a boolean, an array, or an object that is neither — { source: 1 }, { dialect: 'cel' } with no source and no ast, {}.

PREDICATE_SLOT_STRING_REFUSAL (#15572) was not copied onto this arm. That rule refuses every non-string because those slots are declared z.string(); FlowEdgeSchema.condition is ExpressionInputSchema and FlowNodeSchema.config is an open z.record, so applying it here would refuse every conditional edge in every flow. That is not an argument — it is mutation M4 below, which drives exactly that substitution and turns the envelope controls red in both consumers.

Two boundaries held, deliberately:

Secondary fix in the same class: { source: 1 } used to reach exprStr.trim() and throw a bare TypeError out of the validator — a refusal by accident, with no location and no rule. It is now the located refusal, pinned.

3. Trigger gate

Covered. The gate is startNode.config.condition, read by resolveTriggerBinding; it is the same key the node arm walks, so the refusal reaches it before a binding is ever built. Pinned separately from the decision-node case at both registerFlow and objectstack validate, and mutation M2 shows those start-node pins go red on their own.

4. Tests, and the mutation that proves each one discriminates

New: packages/services/service-automation/src/structural-condition-shape.test.ts (13), a structuralConditionRefusal block in packages/spec/src/automation/flow-node-expression-paths.test.ts (6), and a structural condition shape (#15662) block in packages/lint/src/validate-expressions.test.ts (11) — each with a RED CONTROL (the brace-trap string on the same slot, through the same call) so a zero elsewhere in the block cannot be a harness that reached nothing.

@objectstack/spec is consumed through its exports by both other packages (neither aliases it in vitest.config), so every cross-package leg rebuilds and proves the marker in dist/ via scripts/ablation-dist-preflight.mjs before its colour is read. Each mutation was proved on disk first (injected-text and removed-anchor grep -c, not a bare git diff --stat), carried a trap … EXIT INT TERM, and its restore was proved by a whole-tree git status --porcelain plus a git hash-object vs HEAD blob comparison.

mutation what it changes dist proof observed
M1 structuralConditionRefusal returns undefined always marker present in 2 built files spec 2 red, service-automation 9 red, lint 5 red — every control still green
M4 the arm uses predicateSlotRefusal (the ledger rule) marker present in 2 built files spec 5 red, service-automation 9 red, lint 6 red — the envelope controls go red, in both consumers
M2 engine node call site un-wired source-local (suite imports ./engine.js) service-automation 9 red; lint untouched
M3a lint node call site un-wired source-local lint 4 red — the edge pin stays green
M3b lint edge call site un-wired source-local lint exactly 1 red — the edge pin

A first attempt at M1 was recorded as void, not re-rolled quietly: the marker was a comment, tsup stripped it, and ablation-dist-preflight refused the run ("marker found ONLY in 2 sourcemap files … treat this run as void"). The marker was moved into executable code and the leg re-run.

Restore leg: spec rebuilt from HEAD and ablation-dist-preflight … --absent verified for both markers (✓ marker absent from all 217 built files, ✓ working tree clean against HEAD).

All at head 77142e3fa, every heavy run serialized through scripts/pm/os-verify-lock.sh (verdict line read, never a bare $?):

  • pnpm --filter @objectstack/spec test → 473 files, 12717 passed, exit 0
  • pnpm --filter @objectstack/service-automation test → 110 files, 1316 passed, exit 0
  • pnpm --filter @objectstack/lint test → 97 files, 3337 passed, exit 0
  • pnpm --filter … typecheck for all three → exit 0. Not a vacuous green: service-automation's tsc --noEmit did compile the new test file — it caught a TS7030 in it, which is fixed in the last commit.
  • pnpm lint (eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 0

5. Gates

Family re-derived from the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (the --repo assertion holds against this checkout's origin), not from a hand-written list — and re-run on the final head 77142e3fa after the last commit, so the ratchet readings are about the tree that is actually here. 53 gates + check:nul-bytes + the ADR-0087 control; exit codes captured by redirect, never through a pipe. 48 exit 0. The other 7 are prerequisite/wiring states, each recorded as what it is rather than as a pass:

  • check:test-completenessexit 3 = NOT MEASURED (no turbo run test log to parse; its own text: "not a red, and there is nothing here to fix").
  • pm/check-half-statesexit 3 = NOT MEASURED (unread instrument, not a quiet board).
  • check:partof-closing-keyword, check:single-claim-pathsexit 2 = NOT WIRED (no PR_BODY/PR_NUMBER locally). The first was then re-run with PR_BODY set to this text: exit 0, "this PR carries no Part-of/closing-keyword contradiction".
  • check:react-declaration-parityNOT MEASURED: MANIFEST is not set — this gate did NOT run; the registry side is objectui's browser-produced sdui.manifest.json, which CI supplies.
  • check-dev-prereqs → exit 1 reporting 46 of 67 workspace packages have no dist/ on disk. That is a property of a fresh per-task worktree in which only this change's closures were built, not of this diff.
  • pr-labels.mjs → needs PR_NUMBER; its --self-test control passed.

Controls checked rather than assumed: check-adr-0087-registration --self-test → exit 0 (and the gate itself → exit 0), pr-labels --self-test → PASSED.

Generated artifacts regenerated with the gen:* commands the gates name, never hand-edited: packages/spec/api-surface/automation.json and packages/spec/export-origins/automation.json (+2 exports each, additive). check:api-surface, check:export-origins, check:generated, check:liveness, check:spec-changes all exit 0.


🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/service-automation, @objectstack/spec, touching 4 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5315098dfb55d19ea79bd27c28b3864f32f8fad9packageMentionDocs.

Which tree this was computed on

This run read content/docs from 71b45f6bfc17658ba5f13dc92a777332437d20bf — the merge of head 77142e3fa40a1357830e8cc496c55c0e558d2629 into base 5315098dfb55d19ea79bd27c28b3864f32f8fad9, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 71b45f6bfc17658ba5f13dc92a777332437d20bf && git checkout 71b45f6bfc17658ba5f13dc92a777332437d20bf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5315098dfb55d19ea79bd27c28b3864f32f8fad9 77142e3fa40a1357830e8cc496c55c0e558d2629 && git checkout -B drift-repro 5315098dfb55d19ea79bd27c28b3864f32f8fad9 && git merge --no-ff 77142e3fa40a1357830e8cc496c55c0e558d2629

node scripts/docs-audit/affected-docs.mjs --json 5315098dfb55d19ea79bd27c28b3864f32f8fad9

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5315098dfb55d19ea79bd27c28b3864f32f8fad9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Clause-② contract review — PR #15792 (card #15662), head 77142e3fa

Tier: CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:9429). Evidence: override + self-report — the PM seat attests this Agent call carried an explicit model: fable override, and this reviewer's own system-prompt identity is claude-fable-5-1. No get_session read; not claimed as an exact match.

Verdict: PASS, with four notes below (none makes a published statement false in a way that matters, none makes the fix wrong).

Setup: dedicated worktree detached at 77142e3fa, primary checkout untouched, no stash, no push. Closure built for both consumers (spec with 34/34 declaration files; service-automation and lint consume @objectstack/spec through dist/service-automation's only vitest alias is an anchored platform-objects one, lint has none — so the dist preflight is the right instrument). Recorded so nobody reads it as a diff property: the first turbo run build attempt was OOM-killed (exit 137) at default concurrency on this shared 16 GB box; rebuilt at --concurrency=1.

1. Blast radius — re-driven; holds. One layer named more precisely

Driven at head through FlowSchema.parse, AutomationEngine.evaluateCondition and registerFlow (not read off the code):

surface authored post-parse reaches the engine arm as
top-level edge.condition "record.status == 'x'" {dialect:'cel', source:…} envelope — every authored string
loop.config.body.edges[].condition 'it.x == 1' {dialect:'cel', source:…} envelope (region edges are z.array(z.lazy(() => FlowEdgeSchema)), control-flow.zod.ts:144,243)
top-level edge.condition 42, true, ['a'], '', {}, {dialect:'cel'}, {source:1}, {source:'x'} (no dialect), {dialect:'cel', source:''}, {dialect:'js', source:'x'} all refused by ExpressionInputSchema at parse
node config.condition (top-level and loop-body node) string / envelope / 42 / true / ['a'] / {source:1} / {source:'x'} / ast-only verbatim the open z.record lowers nothing
evaluateCondition {dialect:'cel', source:'1 == 1'}true; {source:'1 == 1'}true; 42/true/['a']/' '/{dialect:'cel', source:' '}false; {source:1}TypeError: exprStr.trim is not a function (pre-fix path, now intercepted)

⇒ Two shapes are legitimate on the arm; the PR's admit-set follows from the measurement, not the other way round. I tried to find an authored shape the measurement missed and found two adjacent facts, neither of which changes the admit-set:

  • Region edges survive FlowSchema.parse raw. parseFlowNodeRegions is lenient (slot.schema.safeParse(slot.raw); if (!parsed.success) continue;), so a loop-body edge.condition: 42 is not refused by the parse — it is refused one step later by validateControlFlow's re-parse (loop 'lp' body: invalid region — Invalid input), which canonicalizeStoredFlow runs before validateFlowExpressions. So the engine's edge arm stays unreachable through registerFlow, but the PR's sentence "already refused by ExpressionInputSchema at FlowSchema.parse" is the top-level story; for a region the refusing layer is validateControlFlow (note 3).
  • {dialect:'cron'|'template', source} passes the parse at an edge and the z.record at a node, is admitted by the structural refusal (string source), and is refused by the pre-existing check() pass — measured: edge 'e1' (start→branch) condition: expected a CEL expression but got a \cron` dialect. The silent false evaluateCondition` answers for a cron envelope is therefore unreachable through registration. Not this card's class.

2. M4 — reproduces, and it is the measurement that matters

Each spec-side leg: mutate → tsup (JS pass) → ablation-dist-preflight present-mode → suites → restore. Marker proved in 2 built files (dist/automation/index.js, index.mjs; 2 sourcemap hits not counted) on both legs.

leg spec service-automation lint what went red
M1 (structuralConditionRefusal → always undefined) 2 9 5 only refusal pins; every control green (envelope, no-dialect, whitespace, brace-trap RED CONTROL)
M4 (arm := predicateSlotRefusal, the ledger rule) 5 9 6 the envelope controls, in both consumers: automation "an envelope on an EDGE still registers" + "an expression ENVELOPE at config.condition still registers"; lint "an envelope on an EDGE is legitimate" + "an expression ENVELOPE on a node condition is legitimate here"

Numbers match the PR's table exactly, and the M4 reds are the right ones: the automation edge control is fed a plain string '1 == 1' and still goes red, because post-parse it is an envelope — that is "every conditional edge in every flow", shown rather than argued. The two rules are not interchangeable; the separate refusal is not duplication.

Restore proven: source blob 5e34032f4 == HEAD, full spec rebuild (34/34 declarations back), --absent preflight for all three markers → "absent from all 217 built files", whole-tree git status --porcelain empty.

3. Admit-set boundaries — hold; the refine was read, not re-derived

Grid driven through ExpressionSchema.safeParse, ExpressionInputSchema.safeParse, structuralConditionRefusal, predicateSlotRefusal:

  • Admitted here: 'x', '', ' ', undefined, null, {dialect:'cel', source:'x'}, {source:'x'}, {dialect:'cel', ast:{}}, {ast:{}}, {dialect:'cel', ast:null}, {dialect:'cel', source:''}, {dialect:'cel', source:' '}, {dialect:'js'|'cron', source:'x'}.
  • Refused here (attribution source: '' on every one): 42, 0, NaN, true, false, ['a'], [], new Date(), a function, a symbol, a bigint, {dialect:'cel'}, {}, {source:1}, {source:['x']}, {dialect:'cel', ast:undefined}.
  • expression.zod.ts:88 refine is exactly e.source !== undefined || e.ast !== undefined; the refusal's typeof rec.source === 'string' || rec.ast !== undefined is that refine with the field's own string-ness folded in — read, not re-derived. ast: null is admitted by both (null !== undefined), as the refine says.
  • The only deltas from ExpressionSchema are (i) source: '' — admitted here as a string, refused there by min(1); consistent with the card's "strings untouched" ruling, and at an edge unreachable anyway (parse refuses '', lowers ' '); (ii) dialect missing/invalid — the deliberate deference, and the invalid case is caught by the existing check() pass (above).
  • {source:1} at registerFlow → the located structural refusal, no TypeError (verified through the engine, not only the unit).

Dialect precision (note 2): "an envelope without one is CEL, which is what evaluateCondition already does with it" is slightly stronger than the code. isEnvelope requires a dialect key, so {source} is read exactly as the bare string is — sniffed for {var} holes. Measured: {source:'{x} == 1'} took the legacy template path (\{x}` did not resolve), {dialect:'cel', source:'{x} == 1'} took CEL (Expected COLON, got RBRACE`). Same outcome as the bare-string spelling (#4336), both loud; no silent case is introduced. The docblock could say "read as the bare string is" rather than "is CEL".

4. The ast admission — reasoning holds, datum verified, no duplicate card

5. Reachable vs defensive — both halves verified

  • Engine edge arm — defensive, unpinned, and that reading is right. validateFlowExpressions has exactly one caller (registerFlow, on parsed). Top-level non-envelope values are refused by ExpressionInputSchema; region edges by validateControlFlow (§1). Extra leg M2e (un-wire the engine edge call site) → 13/13 green — the unpinned state made concrete. Engine node arm: M2 → 9 red, matches.
  • Lint edge arm — reachable, pinned, discriminates. validateStackExpressions iterates recordsOf(stack.flows) with no FlowSchema.parse on the way — the raw authored stack. M3bexactly 1 red (the edge pin); M3a → 4 red with the edge pin green. Both match.
  • Bonus reach not claimed by the PR: a loop-body node config.condition: 42 → the structural refusal, scope-labelled, at registerFlow. Not pinned for a region in either consumer; minor, since the walk is collectFlowGraphs's and applyConversionsToFlow does not recurse into loop bodies — conditions inside a loop are never converted to CEL and the gate silently never opens #4347 pins that walk.

6. Void leg — the safety net really refuses it

ablation-dist-preflight --self-test → all cases pass (incl. "sourcemap-only hit is RED" and the filesystem leg). Real reproduction: comment-only marker → tsup → preflight exit 1: "marker found ONLY in 2 sourcemap files and in no executable output … Treat this run as void." Marker moved into executable code → exit 0, 2 built files. Other cards in this lane can lean on it.

7. Generated baselines — genuine

gen:api-surface ("17 entries, 5276 exports") and gen:export-origins re-run; both files hash identical to HEAD (3a9077df7, d1c390eac), porcelain clean. For the record: the generator refuses to write against a declaration-less dist ("holds no .d.ts declarations") — a cache-restored spec dist had none; after a real build the output was byte-identical.

8. Gate accounting — reproduced; none masks a failure

gate my exit the script's own words
check:test-completeness 3 "NOT MEASURED … not a red, and there is nothing here to fix" (EXIT_PREREQUISITE_NOT_MET = 3)
pm/check-half-states 3 "unread instrument, never a quiet board"
check:partof-closing-keyword 2 → 0 with PR_BODY "NOT WIRED … judged nothing" → "carries no Part-of/closing-keyword contradiction" (body reconstructed from the API read — gh is absent in this sandbox — content-equivalent for what the gate reads: Fixes #15662, no Part-of)
check:single-claim-paths 2 "NOT WIRED — PR_NUMBER is not set"
check:react-declaration-parity 1 cannotRun: "MANIFEST is not set … This gate did NOT run. That is a failure, not a skip (#4690)" — NOT MEASURED is right about what it says of this diff; the script itself insists the reading stays red until CI's manifest supplies the registry side
check-dev-prereqs 1 "65 of 67 workspace packages declare an entry point under dist/ that is not on disk" in my fresh tree (dev: 46/67) — a property of the worktree
pr-labels.mjs 1 (usage) / 0 --self-test "VERDICT: pr-labels self-test PASSED"
check:nul-bytes 0 7621 files scanned

Head baselines: spec targeted file 25/25, structural-condition-shape.test.ts 13/13, validate-expressions.test.ts 282/282.

Notes (PASS with it noted)

  1. PR body §4: the lint block is 10 cases by vitest's own count (PR says 11); spec 6 and service-automation 13 are right.
  2. flow-node-expression-paths.ts docblock / PR §2: a dialect-less envelope is read as the bare string is (template-sniffed), not unconditionally as CEL — same behaviour, both loud.
  3. PR §"defensive only": for region edges the refusing layer is validateControlFlow, not FlowSchema.parse.
  4. Boundary for the record: {dialect:'cel', source:''} at config.condition registers and answers false — the whitespace-string ruling applied; ExpressionSchema would refuse it (min(1)). If spec/formula: ExpressionSchema accepts an ast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430's disposition 1 narrows blank sources on evaluated slots, this is the second line to revisit beside the ast clause.

NOT MEASURED by this review: the full three-package suites (the 12717 / 1316 / 3337 totals are the dev's; I ran the three targeted files, the lint file whole), typecheck, repo-wide pnpm lint, and the ~44 gates outside the nine above (ADR-0087 was PM-verified). Limit: a sibling agent held the heavy-verify lock throughout; my single-file vitest runs and builds were unlocked.


Generated by Claude Code


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 5, 2026 08:54
@os-warren
os-warren enabled auto-merge September 5, 2026 08:54

Copy link
Copy Markdown
Collaborator Author

PM note on landing — four precision corrections, none blocking

Posted by the domain:services PM seat. From the Clause-② review (comment 5550711650), verdict PASS. ⛔ None of these changes behaviour or any test; all four are statements a future reader would otherwise take at face value.

  1. The PR body's lint block is 10 cases, not 11 (vitest count).
  2. The docblock's "a dialect-less envelope is CEL" is imprecise. It is really "read as the bare string is" — template-sniffed: {source:'{x} == 1'} took the legacy path while {dialect:'cel', …} produced a CEL parse error. ⭐ Both outcomes are loud, so there is no silent case hiding here; only the description is narrower than the behaviour.
  3. "Defensive only" credits the wrong enforcer for region edges. They survive FlowSchema.parse raw (parseFlowNodeRegions is safeParse + continue) and are refused by validateControlFlow"loop 'lp' body: invalid region — Invalid input" — before validateFlowExpressions ever runs. The conclusion (the engine's edge arm is unreachable) is correct; the mechanism named is not.
  4. Flag for spec/formula: ExpressionSchema accepts an ast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430: {dialect:'cel', source:''} at config.condition registers and answers false. Consistent with the whitespace-string ruling this card preserves, but it is a second line to revisit if spec/formula: ExpressionSchema accepts an ast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430 ever narrows blank sources.

What the review added beyond confirming the PR

M2e — a mutation the reviewer invented to test the PR's own claim. The PR declares the engine's edge arm defensive only and therefore deliberately unpinned. Rather than accept that, the reviewer un-wired the arm and got 13/13 green — confirming it is unpinned exactly as declared — and separately confirmed it is unreachable through registerFlow, the only caller of validateFlowExpressions. A claim of "unreachable, so unpinned" is usually where a hole hides; here it was checked both ways.

M4 reproduced exactly — spec 5 / automation 9 / lint 6, with the envelope controls red in both consumers. One detail is the blast-radius finding demonstrating itself: the automation edge control fed a plain '1 == 1' goes red under M4 because post-parse it is an envelope. That is precisely why the ledger arm's z.string() rule could not be copied here.

The void-leg safety net was reproduced from both ends: the preflight's own self-test asserts "a sourcemap-only hit is RED", and the real case reproduces — a comment-only marker survives only into 2 sourcemap files after tsup, and the preflight exits 1 with "Treat this run as void"; moved into executable code, exit 0.

Baselines regenerate byte-identical (gen:api-surface 17 entries / 5276 exports, gen:export-origins), with a real gotcha surfaced: the generator refuses a declaration-less dist, which is what a turbo cache-restored packages/spec/dist can be.

NOT MEASURED by the review (the dev's own numbers stand for these): the full three-package suites, typecheck, repo-wide lint, and ~44 of the 53 gates — nine plus nul-bytes were reproduced, and ADR-0087 was PM-verified. A sibling agent held the heavy-verify lock, so the review's runs were unlocked single-file vitest.


Generated by Claude Code

@os-warren
os-warren added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 9408b7f Sep 5, 2026
36 checks passed
@os-warren
os-warren deleted the claude/issue-15662-structural-condition-refusal branch September 5, 2026 09:42
baozhoutao pushed a commit that referenced this pull request Sep 5, 2026
…cord-field (resolve validate-expressions.test.ts with #15792)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: evaluateCondition answers a silent false for a non-string predicate, and a non-string config.condition registers clean

2 participants