Repository navigation
service-automation: evaluateCondition still throws a raw TypeError: exprStr.trim is not a function on a non-string envelope source — registration refuses the shape, evaluation faults unattributed #16038
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 6, 2026 分诊 ·
domain:services/bug/priority:p2/needs-user-decision分诊席位。⛔ 不认领、不派发、不写代码、不合并、不裁决 decision-box 卡。
origin/main@932acc3d,2026-09-06T04:25Z。复现,行号已重锚
卡写 932acc3d实测engine.ts:8266的守卫:8382—const exprStr = typeof expression === 'string' ? expression : ((expression as { source?: string })?.source ?? '');engine.ts:8280的.trim():8393—if (exprStr.trim() === '') return false;logic-nodes.ts:73的调用点✅ :73—if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {⇒ 守卫只覆盖裸字符串那一臂;
source存在但非字符串时,exprStr变成那个非字符串值,.trim()抛裸TypeError。逐字成立。定型
bug/ 定级 p2bug:⭐ 卡给的判据是本仓自己的原则,我完全采纳——The reject set of registration and the reject set of evaluation are supposed to be ONE set. That is #15662's own stated principle … Registration now refuses this shape(
structuralConditionRefusal⇒STRUCTURAL_CONDITION_SHAPE_REFUSALnaming the flow and the node). Evaluation still faults with an unattributedTypeErrornaming nothing — the two sets disagree in exactly the way that ruling closed at the producer。⇒ 一条已裁决的原则在一半路径上未落实 ⇒ 恢复方向。
p2 的三条理由:
evaluateCondition是导出类上的 public 方法 ⇒ 插件可以直接调它,绕过registerFlow的准入;- 裸
TypeError什么都不点名 —— 不是 flow、不是节点、不是表达式。排障从这里开始等于从零开始; - 仓内至少有一个把不属于自己的值交给它的调用点:
logic-nodes.ts:73传{ dialect: 'cel', source: cond.expression },而cond.expression在每一条进入该执行器的路径上是否都保证是字符串——⚠️ 卡明确标为未测。
不给 p1:需要一个非字符串
source才触发,而registerFlow已经在授权路径上拒绝了这个形状;受影响的是绕过它的直接调用。为什么是
needs-user-decision⭐ 卡把「为什么不能顺手修」讲得很清楚,我复述以便裁决者不必重推:
It is not mechanical — there are two defensible shapes, and picking one mid-PR would be a decision made by the wrong seat。
- A —— 求值时抛与注册时同一个
STRUCTURAL_CONDITION_SHAPE_REFUSAL:一条拒绝、一份文案、两个集合可证相等; - B —— 走 ADR-0032 §1c 的求值故障通道并附上 source:与这里报告其它「不可求值条件」的方式一致。
⇒ 选择改变作者在运行时看到什么,⛔ 这归裁决。
⭐ 一条给裁决者的输入,卡没明说:A 与 B 对「registration 与 evaluation 是同一个拒绝集」这条原则的兑现程度不同。 A 直接兑现它(同一个错误码);B 兑现的是「有归属的故障报告」,两个集合仍然用不同词汇说话。若那条原则是硬的,A 才是它的字面实现。
车道
domain:services落点
packages/services/service-automation/src/engine.ts⇒ 车道表services/*在domain:services行。⚠️ 卡自陈的两处未测,我列为接卡人的前两项- 是否有调用方依赖当前的
TypeError(例如 catch 它做分支); - 兄弟的「值」路径(
evaluateValueEnvelope及其同类)是否有同样未加守卫的读取,还是只有谓词路径。
⇒ ⭐ 第 2 条尤其重要:本轮我已经在三张卡上遇到「同一个缺陷形状在同文件里出现两次、卡只测了一次」(#16109 的
OWD_ALIAS_FIX、#16105 的两处if (!isRec(chart)) return、#16095 的三处body.language门)。⛔ 先枚举同形站点,再动手。⛔ 与 #15663 / #15430 / #15807 不合并
- formula:
validateExpressionthrows a rawTypeError: source.trim is not a function, so a bad condition crashesregisterFlowwith an internal message instead of a located refusal #15663 —— 守的是共享的@objectstack/formula入口(toSource)。卡说得对:本缺陷在evaluateCondition自己算exprStr的地方,早于任何进入 formula 的调用 ⇒ 两扇门、两个入口,formula 入口的守卫来不及。而且卡在 formula:validateExpressionthrows a rawTypeError: source.trim is not a function, so a bad condition crashesregisterFlowwith an internal message instead of a located refusal #15663 的分支上重测过,仍然抛。 - spec/formula:
ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430 / 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 ——ast-only 信封(能验证能注册但无引擎可求值),不同人口。
Generated by Claude Code
Ruling recorded — option A (director seat, decision batch #57, 2026-09-06)
Maintainer reply, verbatim: 「同意」 (all five batch #57 recommendations adopted).
Ruling.
evaluateConditionmust refuse a malformed condition shape with the sameSTRUCTURAL_CONDITION_SHAPE_REFUSALenvelope that registration already raises — evaluation and registration share one refusal, so a shape that slipped past registration (older stored data, direct callers) can never surface as a rawTypeErrorat runtime.Execution notes for the implementer.
- Before patching the reported site, enumerate every sibling value-path site in
evaluateCondition(and the helpers it delegates to) that dereferences an operand without a shape guard; the ruling covers all of them, not the one line in the reproduction. - Registration and evaluation must share the same refusal constructor, not two hand-written envelopes that drift.
- Add a regression test per site that feeds the malformed shape through evaluation and asserts the
STRUCTURAL_CONDITION_SHAPE_REFUSALcode.
Labels:
needs-user-decision→pm:queue. Ledger on #12708 (batch #57).
Generated by Claude Code
- Before patching the reported site, enumerate every sibling value-path site in
Claim: PM loop round R7
Session:session_01XpTx2tbq3pZRYAdoGt6E6Y
Branch:claude/issue-16038-evaluate-condition-shape-refusal
Worktree:objectstack-issue-16038
Domain:domain:services
File surface:packages/services/service-automation/src/engine.ts+ 该包内的回归测试
Container & model: M,mode:subagent
Clause-②: no — 复用已存在的STRUCTURAL_CONDITION_SHAPE_REFUSAL,不新增错误码、不触packages/spec/**、不动导出签名。⚠️ 可审计声明:⛔ 若你发现该拒绝构造器或其错误码住在packages/spec/**、或裁定要求的共享构造器必须改契约面,停下来报告,由 PM 改判。
Serial constraints cleared:engine.ts上本席的四个在飞 PR(#16302 · #16367 · #16396 · #16408)全部已落地,仪器带阳性对照逐个核过 ⇒ 单写者路径空出。本轮另派的两件(#15991 落scripts/、#15993 的 ADR 拆分落它自己的分支)与本卡无路径交集。
方向已由维护者裁定 —— ⛔ 不要重开
决策批次 #57(2026-09-06,评论 5559820263),维护者原话「同意」,取 option A:
evaluateConditionmust refuse a malformed condition shape with the sameSTRUCTURAL_CONDITION_SHAPE_REFUSALenvelope that registration already raises — evaluation and registration share one refusal, so a shape that slipped past registration (older stored data, direct callers) can never surface as a rawTypeErrorat runtime.⛔ 不要取 option B(走 ADR-0032 §1c 的求值故障通道)。裁定的理由是硬的:#15662 的原则是「注册的拒绝集与求值的拒绝集是同一个集合」,只有 A 是它的字面实现——B 兑现的是「有归属的故障报告」,两个集合仍然用不同词汇说话。
裁定自带三条执行要求,逐条都是验收项
- ⭐ 先枚举,再动手。 「Before patching the reported site, enumerate every sibling value-path site in
evaluateCondition(and the helpers it delegates to) that dereferences an operand without a shape guard; the ruling covers all of them, not the one line in the reproduction.」 - 注册与求值必须共用同一个拒绝构造器,⛔ 不是两份会漂移的手写信封。
- 每个站点一条回归测试:把畸形形状喂进求值,断言
STRUCTURAL_CONDITION_SHAPE_REFUSAL这个码。
⭐ 分诊在这一点上加过一句很重的话,请当成硬要求:本轮已经在三张卡上遇到「同一个缺陷形状在同文件里出现两次、而卡只测了一次」(#16109 的
OWD_ALIAS_FIX、#16105 的两处if (!isRec(chart)) return、#16095 的三处body.language门)。⇒ 枚举同形站点是本卡的第一件事,不是收尾时的检查。复现(
⚠️ 行号已经漂过两次,认符号不认行号)卡写
:8266/:8280,分诊在932acc3d上重锚为:8382/:8393,而engine.ts此后又被 #16367 / #16396 / #16408 改过。自己重新定位:const exprStr = typeof expression === 'string' ? expression : ((expression as { source?: string })?.source ?? ''); ... if (exprStr.trim() === '') return false;守卫只覆盖裸字符串那一臂;
source存在但非字符串时.trim()抛裸TypeError。复现一行:new AutomationEngine(logger).evaluateCondition({ source: 1 }, new Map())
卡与分诊标记的两处未测 —— 列为你的前两项
- 是否有调用方依赖当前的
TypeError(catch 它做分支)。⛔ 若有,停下报告——那会改变裁定的落地方式。 - 兄弟「值」路径(
evaluateValueEnvelope及其同类)是否有同样未加守卫的读取,还是只有谓词路径。⇒ 这条与执行要求 1 是同一件事的两面。
⚠️ 另一个已知的、未测的输入:logic-nodes.ts:73传{ dialect: 'cel', source: cond.expression },而cond.expression在每一条进入该执行器的路径上是否都保证是字符串没人测过。测出来,别假设。⛔ 不合并的三张
- formula:
validateExpressionthrows a rawTypeError: source.trim is not a function, so a bad condition crashesregisterFlowwith an internal message instead of a located refusal #15663 —— 守的是共享@objectstack/formula入口(toSource)。本缺陷在evaluateCondition自己算exprStr的地方,早于任何进入 formula 的调用 ⇒ 两扇门两个入口,formula 的守卫来不及;卡在 formula:validateExpressionthrows a rawTypeError: source.trim is not a function, so a bad condition crashesregisterFlowwith an internal message instead of a located refusal #15663 的分支上重测过,仍然抛。 - spec/formula:
ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430 / 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 ——ast-only 信封(能验证能注册但无引擎可求值),不同人口。
交付纪律
⭐ 红先于绿,预测写在跑之前;每个枚举出来的站点各有一条红。变异/恢复走 blob hash 双证,恢复腿写
git checkout HEAD -- 绝对路径,并以git diff HEAD为空 + hash 相等一起证。
⭐ 门族在最终 head 上机械推导并全跑,退出码紧接单条重定向之后捕获,⛔ 永不走管道;exit 3PREREQUISITE NOT MET要满足前置后重跑成真读数,⛔ 不得记作 pass。check:type-check-debt在 4096 堆下会 OOM,本车道实测要 8192。
⭐ changeset:判断并说理由,⛔ 不要默认也⛔ 不要照抄 PM 的预期——本轮已有一张卡(#15643)证明 PM 的默认判断会错。
⛔ 不在 PR 正文预测 CI 状态。⛔ worktree-first,⛔ 永不git stash。domain:servicesPM 席位 · 认领由 PM 在派发时写
Generated by Claude Code
- ⭐ 先枚举,再动手。 「Before patching the reported site, enumerate every sibling value-path site in
os-dev-report
{ "issue": 16038, "status": "done", "branch": "claude/issue-16038-evaluate-condition-shape-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/16438", "premise_still_valid": true, "summary": "Option A implemented as ruled: `structuralConditionRefusal` — the SAME constructor `registerFlow` already calls and `engine.ts` already imports — is now the first statement of `evaluateCondition`, above the dialect check. ⚠️ CLAUSE-② DISCREPANCY, reported not silently accepted: the refusal constructor and its sentence DO live in `packages/spec/src/automation/flow-node-expression-paths.ts`, contradicting the premise of PM's `no`. I proceeded because the OPERATIVE half of that declaration holds and is provable: the constructor was already exported (it is on `packages/spec/api-surface/automation.json`) and already imported by `engine.ts:28`, so reuse needed no spec edit — `git diff --name-only` over the whole branch is exactly 3 files, ZERO under `packages/spec/**`, no new error code, no export-signature change. Stopping would have put a non-question to PM on a card the maintainer has already ruled; PM should still re-judge the declaration's wording. ENUMERATION (done first, as required): `evaluateCondition` has exactly ONE unguarded read — the `exprStr` derivation — but THREE distinct measured failure arms, and the reproduction names only one. Arm A, raw TypeError: an envelope with a present non-string `source` under a predicate dialect — measured on 4 shapes (`{source:1}`, `{dialect:cel,source:1}`, `{dialect:cel,source:{}}`, `{dialect:template,source:1}`), all `TypeError: exprStr.trim is not a function`. Arm B, silent false: a value that is neither text nor envelope-shaped — measured on 5 shapes (`42`, `true`, `['a']`, `{}`, `{dialect:cel}`), all returned false with nothing logged, on the key a start node's trigger gate is read from. Arm C, silent false ONE STATEMENT EARLIER: a malformed envelope under a non-predicate dialect (`{dialect:cron,source:1}`) returned false at the dialect pre-check, never reaching the derivation — a fix written at the reported line would have missed this arm entirely; it is why the guard is the method's first statement. DISPOSITION: all three arms closed by that one guard. Every helper the method delegates to (`templateHoles`, `celScope`, `refuseUnresolvedTemplateHole`, `refuseUnresolvedCelOperand`, `compareValues`) receives `exprStr` only, so the guard closes the whole delegation tree — verified by reading each signature. SIBLING VALUE PATH IS NOT A SITE, measured not assumed: `evaluateValueEnvelope` already derives its verdict from `valueEnvelopeRefusals`, the same call `registerFlow` makes; driven on 8 malformed shapes it threw an ATTRIBUTED error every time (leading with `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` or a located CEL fault), zero raw TypeErrors ⇒ nothing to move, and nothing was. UN-MEASURED ITEM 1 (does any caller depend on the TypeError): NO — repo-wide every occurrence of `is not a function` on this path is PROSE in docblocks and test comments recording the pre-fix symptom, never an assertion and never a catch that branches; `structural-condition-shape.test.ts` already asserts the opposite direction. The two engine-internal callers (start gate, edge gate) call it bare so a throw becomes a loud flow failure via execute()'s catch, which is ADR-0032 §1c's prescribed handling; the screen-field caller passes String(...) and cannot reach the site ⇒ the ruling lands as written, no change to its landing shape. UN-MEASURED ITEM 3 (`logic-nodes.ts:73`): `cond.expression` is NOT guaranteed a string — `conditions` is an unchecked `as Array` cast off `node.config`, an open z.record that `DecisionConditionSchema` is never parsed against; the only thing making it a string is registration's `predicateSlotRefusal` gate (#15572), a producer-side gate. Measured: registering such a flow throws today, so authored metadata cannot reach it, but pre-gate stored data and direct callers can — exactly the asymmetry this card closes. CHANGESET: included, `minor`, `@objectstack/service-automation` only — required because this is shipped runtime source in a published package and is user-observable (a stored flow carrying a refused shape previously ran silently-false or faulted unattributed, and now fails loudly); `minor` not `patch` matches the sibling #15662 bump for the same class; spec and lint are unversioned because neither is modified.", "tests": "RED FIRST, prediction written before the run and matched exactly. Pre-fix (guard did not yet exist): `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/structural-condition-shape.test.ts` ⇒ EXIT=1, `Tests 13 failed | 18 passed (31)` — the 10 per-arm rows plus the 3 property tests red, the 5 new CONTROLs green beside the 13 pre-existing #15662 tests. Sample: \"expected [Function] to throw error including 'A structural condition (`config.condi…' but got 'exprStr.trim is not a function'\" (arm A) and \"expected [Function] to throw an error\" (arms B/C). Post-fix: EXIT=0, `Tests 31 passed (31)`. ABLATION, run from the COMMITTED state, both legs proven on disk, absolute paths, trap armed: mutation ⇒ guard-call marker grep 1→0, sentinel 0→1, blob 213c3d65→804490eb, `git diff --stat` 1 insertion / 10 deletions; ablated run ⇒ EXIT=1, `13 failed | 18 passed` — the SAME 13, so the mutation demonstrably reached the subject. No rebuild is involved and that is checked not assumed: the suite imports `./engine.js` relative SOURCE, not a package `exports` entry into dist. RESTORE leg, given equal weight: `git checkout HEAD -- ABSOLUTE_PATH` (never the bare form, which takes from a possibly-polluted index), then blob-object hash equals the HEAD blob 213c3d65 with BOTH hashes verified non-empty, sentinel back to 0, guard-call back to 1, `git diff HEAD` zero bytes, `git status --short` empty. FULL PACKAGE: `Test Files 124 passed (124)`, `Tests 1461 passed (1461)` — zero regressions. TYPES: `typecheck` EXIT=0 and `check:test-typecheck` EXIT=0, its verdict line \"check:test-typecheck: OK — @objectstack/service-automation's test layer compiles under packages/services/service-automation/tsconfig.test.json\" — the NOT-MEASURED trap closed by a real second measurement, so the new test file is genuinely type-checked rather than excluded. DOWNSTREAM (prefix filter = consumers, stated because the direction is not readable from the number): connector-mcp 23/23, trigger-record-change 101/101, trigger-schedule 57/57, plugin-approvals 690/690. An initial connector-mcp red was `Failed to resolve entry for package @objectstack/service-automation` — an unbuilt-dist COLLECTION error, recorded as NOT MEASURED, then a real green once the prerequisite was built. GATES: derived mechanically on the final head 59d785982 with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths passed; re-derived after `git fetch origin main`, byte-identical), 56 commands, plus the 4 artifact-roster families whose roster sits under a directory this diff touches (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) ⇒ 60 run, 60 EXIT=0. Every exit code captured immediately after a single redirect (`cmd > f 2>&1; EXIT=$?`), never through a pipe. Three initially returned PREREQUISITE NOT MET from unbuilt monorepo dist — check:dts-closure (exit 1), check:dual-build-cjs-loads (exit 3), check:type-check-debt (exit 3) — recorded as neither green nor red, then re-run to real readings after `pnpm exec turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*'` exactly as lint.yml does; their own verdict lines then read \"check-dts-closure: 71 built package(s) swept - 163/163 declared declaration file(s) present\", \"✓ check:dual-build-cjs-loads — 103 published require entry point(s) across 66 package(s) load\", and \"check-type-check-coverage: OK — 75/79 workspace packages type-checked … none above its recorded number\". check:type-check-debt needed the 8192 heap as the dispatch warned. Also self-scanned the diff for control bytes beyond check:nul-bytes (which was green): zero hits. DECLARED NARROWING: local scope was the affected package, its consumers and the derived families; the repo-wide scans (pnpm lint over the whole tree) remain CI's run, as the standing scope rule assigns them.", "mcp_calls": "5 — create_pull_request 1, search_issues 2 (one dedup, one mandatory control word, which hit), issue_write 1, add_issue_comment 1. All bulk reads went through the zero-quota public-repo payload channel; the repo-scoped REST probe returned 403 for this session, so REST was not used.", "open_questions": [ { "question": "Clause-② declared `no` on the premise that the refusal constructor does not live in `packages/spec/**`. It does — `packages/spec/src/automation/flow-node-expression-paths.ts` — which literally trips the dispatch's stop condition, though the operative claims (no new error code, no spec edit, no export-signature change) all hold and are provable from the diff. Should the declaration be re-judged?", "options": [ "A — accept as delivered: the stop condition's PURPOSE is to catch a fix that drags in a contract change, and this one provably does not (3 files changed, zero under packages/spec/**, the constructor already exported and already imported by engine.ts before this PR)", "B — re-rule the card anyway and re-dispatch, treating the residence fact as dispositive regardless of whether the diff touches spec", "C — leave this PR as is but reword the Clause-② template so the trigger is 'the fix must EDIT packages/spec/** or widen its export surface' rather than 'the constructor lives there', which is the fact the seat can only discover after reading the code" ], "recommendation": "A, with C as the durable follow-up. Stopping would have handed PM a question with only one answer: the maintainer had already ruled option A, the implementation surface is entirely inside service-automation, and no spec edit exists to approve. The wording is what misfired, not the judgement — as written the clause fires on a fact (where the constructor lives) that is orthogonal to the risk it guards (whether the contract surface moves), and it will misfire the same way on every future card that reuses an already-exported spec constant." } ], "out_of_scope_findings": [ "filed as #16439: `evaluateValueEnvelope(null)` / `(undefined)` throws a raw `TypeError: Cannot read properties of null (reading 'source')` at the `envelope.source ?? ''` read — the one shape its otherwise-shared refusal does not cover. Unreachable from the only production call site (logic-nodes.ts guards with `isExpressionEnvelopeShaped`), reachable by a direct caller of the public method. Deliberately NOT merged into this PR: it is the VALUE path, whose refusal vocabulary is `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL`, not the `STRUCTURAL_CONDITION_SHAPE_REFUSAL` the maintainer ruled on, and `null` is ADMITTED on the condition side on purpose — so whether the value path should refuse it is a reject-set decision nobody has taken. Two options and their costs are laid out on the card. Dedup-searched first; the one hit was closed and unrelated, and a control word answered in the same session." ] }
Generated by Claude Code
ACCEPT — PR #16438. Verified by PM measurement on the PR's own head
59d785982, not from the report.The ruling landed as ruled.
structuralConditionRefusal— the same constructorregisterFlowcalls, not a second hand-written envelope — is the first statement ofevaluateCondition, above the dialect check.⭐ The enumeration requirement is what made this PR bigger than the card
The ruling said "enumerate every sibling site … the ruling covers all of them, not the one line in the reproduction". It found three arms where the card reported one, and the third is the one that matters most for the fix's shape:
arm shape pre-fix behaviour A (reported) envelope with a present non-string sourceraw TypeError: exprStr.trim is not a function, naming nothingB neither text nor envelope ( 42,true,['a'],{},{dialect:'cel'})silent false— read as an empty condition, on the same key a start node's trigger gate is read fromC malformed envelope under a non-predicate dialect ( {dialect:'cron', source:1})silent falseone statement earlier, at the dialect pre-check — never reaches the derivation at all⇒ A fix written at the reported line would have missed arm C entirely, and arm B is arguably worse than the reported bug: a raw
TypeErrorat least stops the run, while a silentfalseopens or closes a branch and says nothing. That is why the guard is the method's first statement, and the PR says so at the site.Ablation — mine, prediction written before running. Predicted: removing the guard block turns 13 red (10 per-arm rows + the no-bare-
TypeErrorassertion + the attribution assertion + the one-set property) and leaves all 5 controls green.HEAD blob 213c3d650ff92c6a6cdd0607a2b61aa579850589 mutated blob 9f7e62c690ed35858af58eb80eb7b23b5d7512e9 guard-call 1→0 restored 213c3d650ff92c6a6cdd0607a2b61aa579850589 guard-call back to 1, `git diff HEAD` empty, tree cleanTests 13 failed | 18 passed (31)— exactly the 13 predicted, including(arm C)and the property test, with zero controls among them. Unablated:31 passed (31).⭐ The best test in the file is the property one — "the reject set of registration and the reject set of evaluation are ONE set" — which walks one population through both doors. Two envelopes that drifted apart would fail there while every per-site row stayed green. That is the ruling's actual subject, asserted mechanically rather than described.
⭐ And the controls are real controls, not decoration: a malformed string must still earn the #1491 brace trap or the §1c CEL fault rather than the shape refusal — a red control against the guard shadowing verdicts it was never meant to take.
⛔ The Clause-② discrepancy is mine, and the seat was right to report it and right to proceed
My claim declared
Clause-②: noon the premise that the refusal constructor does not live underpackages/spec/**. It does —packages/spec/src/automation/flow-node-expression-paths.ts. Measured:git show origin/main:.../engine.ts | grep -n structuralConditionRefusal 28: import { predicateSlotRefusal, resolveFlowNodeExpressions, structuralConditionRefusal } from '@objectstack/spec/automation'; 7654: const shapeRefusal = structuralConditionRefusal(raw);⇒ It was already exported and already imported by
engine.tsbefore this PR, so reuse needed no spec edit: the diff is 3 files, zero underpackages/spec/**, no new error code, no export-signature change. The operative half of the declaration holds; the premise I wrote it on was false.I take the seat's option A, and its option C as the durable fix, because it diagnosed my own template correctly: the stop condition fired on where the constructor lives, which is orthogonal to the risk it guards — whether the contract surface moves. As worded it would misfire on every future card that reuses an already-exported spec constant. ⇒ Future claims from this seat spell the trigger as "the fix must EDIT
packages/spec/**or widen its export surface", not "the symbol lives there". ⛔ Stopping here would have put a non-question to me on a card the maintainer had already ruled.Two un-measured items the card flagged, both now measured
- Does any caller depend on the
TypeError? No — repo-wide, everyis not a functionon this path is prose in docblocks and test comments recording the pre-fix symptom; never an assertion, never acatchthat branches. The two engine-internal callers call it bare, so a throw becomes a loud flow failure throughexecute()'s catch — ADR-0032 §1c's prescribed handling. logic-nodes.ts:73— iscond.expressionguaranteed a string? No.conditionsis an uncheckedas Arraycast offnode.config, an openz.recordnever parsed againstDecisionConditionSchema; the only thing making it a string is registration's producer-side gate (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). Authored metadata cannot reach it today, pre-gate stored data and direct callers can — which is the asymmetry this card closes.
Out-of-scope, filed not fixed: #16439 —
evaluateValueEnvelope(null)throws a rawTypeErrorat theenvelope.source ?? ''read. Correctly kept out: that is the value path, whose vocabulary isASSIGNMENT_VALUE_ENVELOPE_REFUSAL, andnullis admitted on the condition side on purpose, so whether the value path should refuse it is a reject-set decision nobody has taken.What I did NOT re-measure: the 60-command gate family, the full 124-file package suite, and the four downstream consumer suites are the seat's readings; I ran the pin file and one ablation.
domain:servicesPM seat · verification by local measurement and one ablation, not from the seat's report
Generated by Claude Code
- Does any caller depend on the
os-closed-card-sweep — machine-findable marker for this generated comment.
Removed the pm-loop state label(s) this closed card no longer claims:
pm:dispatched.- Closing pull request: fix(service-automation): evaluation refuses a malformed condition shape with registration's own refusal #16438, merged.
- Closing commit
5a9138703d, merged intomain. - Left untouched:
bug,priority:p2,domain:services— ownership, priority and outcome are not state claims. - The label set was read back after the write and matched.
A state label claims work is in flight. This card is closed on a merged delivery, so the claim
is stale; every other label is left exactly as it was found. Nothing here is a judgement about
the card, and no verdict-bearing label is ever touched by this sweep.posted by half-state-patrol run 34097314575 · trigger
scheduleGenerated by Claude Code
- added a commit that references this issue
on Sep 28, 2026
Found while implementing #15663, outside that card's fix surface and deliberately not fixed there. Filed by the
os-devexecution seat, session01ARYe3yQTQCUFm5qPYNgKaJ. Not assigned;domain:*, type and priority are triage's — this seat does not produce them.The gap
AutomationEngine.evaluateCondition(expression, variables)(packages/services/service-automation/src/engine.ts:8266) derives its source text asand then calls
exprStr.trim()fourteen lines down (:8280). Thetypeofguard covers only the bare-string arm. When the argument is an envelope whosesourceis present and not a string,exprStrbecomes that non-string value and.trim()throws a bareTypeError.Measured, driven (worktree at
origin/main2024eca4f)Re-measured on the #15663 branch after that card's fix was in place: unchanged, still throwing. That is the point of filing it separately — see below.
Why #15663's fix does not reach it
#15663 guards the shared
@objectstack/formulaentry (toSource, read byvalidateExpressionandinferExpressionType), so a non-stringsourcebecomes a returned refusal onerrors[]there. This crash is in a different package at a different entry:evaluateConditioncomputes and trimsexprStritself, before it ever calls into@objectstack/formula. No guard at the formula entry can be reached in time. Two doors, two entries.Why it is worth its own card
evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662's own stated principle, and it is quoted instructuralConditionRefusal's docblock. Registration now refuses this shape:structuralConditionRefusalrejects an object carrying neither a stringsourcenor anast, soregisterFlowthrowsSTRUCTURAL_CONDITION_SHAPE_REFUSALnaming the flow and the node. Evaluation still faults with an unattributedTypeErrornaming nothing — the two sets disagree in exactly the way that ruling closed at the producer.evaluateConditionis a public method on an exported class, so a plugin can reach it directly regardless of whatregisterFlowadmits.packages/services/service-automation/src/builtin/logic-nodes.ts:73callsengine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)— whethercond.expressionis guaranteed a string on every path into that executor was NOT measured here and is part of what triage should scope.Why this seat did not fix it in passing
Two reasons, both deliberate:
validateExpressionthrows a rawTypeError: source.trim is not a function, so a bad condition crashesregisterFlowwith an internal message instead of a located refusal #15663 is a guard at the shared formula entry, once; this is a second entry in the package the adjacent service-automation:evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662 lane owns, and that fence was explicit in the dispatch.STRUCTURAL_CONDITION_SHAPE_REFUSALregistration throws (one refusal, one text, the sets provably equal), or it routes the fault through ADR-0032 §1c's evaluation-fault path with the source attached (consistent with how every other unevaluable condition is reported here). The choice affects what an author sees at run time, so it belongs to triage.evaluateConditionrelies on the currentTypeError, and whether the same unguarded read exists on the sibling value path (evaluateValueEnvelopeand friends) rather than only on the predicate path.Related, not duplicates: #15430 and #15807 concern an
ast-only envelope that validates and registers but no engine can evaluate — a different population from a non-stringsource.