Found while implementing #15572 (the envelope in a z.string() predicate slot), outside that card's surface and deliberately not fixed there. Filed by the os-dev execution seat, session 01XpTx2tbq3pZRYAdoGt6E6Y. ⛔ domain:*, type and priority are triage's — this seat does not produce them.
The gap
#15572 is about the ledger-declared predicate slots (decision.conditions[].expression, screen.fields[].visibleWhen). This is the structural predicate surface next to it — config.condition on any node and edge.condition — which is walked by a different arm of the same validator and is not covered by that fix.
evaluateCondition derives its source as typeof expression === 'string' ? expression : (expression?.source ?? ''). For a non-string that is not 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.
Measured, driven (worktree at origin/main d30ccb9 + the #15572 branch; AutomationEngine constructed directly and through LiteKernel)
engine.evaluateCondition(<value>, new Map()):
| value |
result |
42 |
false — silent |
true |
false — silent |
['a'] |
false — silent |
{ source: 1 } |
throws TypeError: exprStr.trim is not a function |
And through registerFlow, a decision node carrying config: { condition: 42 }:
registerFlow → REGISTERED
execute → success: true, no error, nothing said anywhere
edge.condition: 42 is refused (the edge schema is typed), so the reachable half is the node-level config.condition — the same key the start node's trigger gate is read from, which is a gate that decides whether a flow runs at all.
Why this is its own card
⛔ Not a defect, stated so nobody "fixes" it: a whitespace-only string condition returning false is consistent on both sides and is ruled correct.
Suggested shape (not a decision — triage's)
The refusal already exists and is exported: predicateSlotRefusal / PREDICATE_SLOT_STRING_REFUSAL in @objectstack/spec/automation, landed for #15572. The structural arm (check(...) in AutomationEngine.validateFlowExpressions, and the matching one in @objectstack/lint's validateStackExpressions) could apply the same refusal to cfg.condition / edge.condition, which would make one notion cover both arms. ⚠️ Blast radius unmeasured: config.condition is parsed into an ExpressionInput envelope on some paths (graftConditionEnvelopes), so an envelope there is legitimate and the refusal would have to admit it — NOT the same rule as the ledger slots, where the declaration is z.string().
Found while implementing #15572 (the envelope in a
z.string()predicate slot), outside that card's surface and deliberately not fixed there. Filed by theos-devexecution seat, session01XpTx2tbq3pZRYAdoGt6E6Y. ⛔domain:*, type and priority are triage's — this seat does not produce them.The gap
#15572 is about the ledger-declared predicate slots (
decision.conditions[].expression,screen.fields[].visibleWhen). This is the structural predicate surface next to it —config.conditionon any node andedge.condition— which is walked by a different arm of the same validator and is not covered by that fix.evaluateConditionderives its source astypeof expression === 'string' ? expression : (expression?.source ?? ''). For a non-string that is not envelope-shaped the read yieldsundefined, the??supplies'', and the empty-source arm returnsfalse— the documented "an unauthored branch must not open" rule, applied to a value that was very much authored.Measured, driven (worktree at
origin/maind30ccb9 + the #15572 branch;AutomationEngineconstructed directly and throughLiteKernel)engine.evaluateCondition(<value>, new Map()):42false— silenttruefalse— silent['a']false— silent{ source: 1 }TypeError: exprStr.trim is not a functionAnd through
registerFlow, a decision node carryingconfig: { condition: 42 }:registerFlow→ REGISTEREDexecute→success: true, no error, nothing said anywhereedge.condition: 42is refused (the edge schema is typed), so the reachable half is the node-levelconfig.condition— the same key the start node's trigger gate is read from, which is a gate that decides whether a flow runs at all.Why this is its own card
decisioncondition accepts a CEL envelope that neither validator can see — a malformed one evaluates tofalseSILENTLY at run time and takes the wrong branch #15572 (this seat's, in flight) closes the ledger-declared predicate slots by refusing a non-string atregisterFlowand atobjectstack validate.config.conditionis deliberately NOT a ledger entry — it is a structural surface both validators already walk through a separate arm — so that refusal does not reach it.ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430 isExpressionSchemaaccepting asource-or-astenvelope. This is a value that is not an envelope at all.⛔ Not a defect, stated so nobody "fixes" it: a whitespace-only string condition returning
falseis consistent on both sides and is ruled correct.Suggested shape (not a decision — triage's)
The refusal already exists and is exported:⚠️ Blast radius unmeasured:
predicateSlotRefusal/PREDICATE_SLOT_STRING_REFUSALin@objectstack/spec/automation, landed for #15572. The structural arm (check(...)inAutomationEngine.validateFlowExpressions, and the matching one in@objectstack/lint'svalidateStackExpressions) could apply the same refusal tocfg.condition/edge.condition, which would make one notion cover both arms.config.conditionis parsed into an ExpressionInput envelope on some paths (graftConditionEnvelopes), so an envelope there is legitimate and the refusal would have to admit it — NOT the same rule as the ledger slots, where the declaration isz.string().