@@ -386,6 +386,100 @@ export function predicateSlotRefusal(value: unknown): { message: string; source:
386386 } ;
387387}
388388
389+ /**
390+ * The one sentence a refused **structural** condition leads with (#15662) —
391+ * `config.condition` on any node and `edge.condition`, the two predicate
392+ * surfaces every flow has whether or not any ledger entry names them.
393+ *
394+ * ⚠️ Deliberately NOT {@link PREDICATE_SLOT_STRING_REFUSAL}. That one says
395+ * "bare text, an envelope is not authorable" because a ledger `predicate` slot
396+ * is *declared* `z.string()`. Neither structural slot is:
397+ *
398+ * - `FlowEdgeSchema.condition` is `ExpressionInputSchema`, whose string arm
399+ * **transforms into** `{ dialect: 'cel', source }` — so after
400+ * `FlowSchema.parse` EVERY authored edge condition is an envelope, and the
401+ * ledger arm's rule applied here would refuse every conditional edge in
402+ * every flow.
403+ * - `FlowNodeSchema.config` is an open `z.record`, so an envelope written at
404+ * `config.condition` is passed through by the parse verbatim and evaluated
405+ * correctly by `evaluateCondition` (both spellings, by #4336's ruling).
406+ *
407+ * Both shapes are therefore legitimate here and this refusal admits them. What
408+ * it refuses is the third population, which no layer ever admitted on purpose:
409+ * a value that is neither text nor an expression.
410+ */
411+ export const STRUCTURAL_CONDITION_SHAPE_REFUSAL =
412+ 'A structural condition (`config.condition` on a node, `edge.condition`) holds either BARE CEL TEXT or an '
413+ + 'expression envelope — an object carrying a string `source`, or an `ast`. No other shape is authorable there.' ;
414+
415+ /**
416+ * Why a value sitting in a structural condition slot is not authorable at all —
417+ * the SINGLE notion both consumers apply, derived once (#15662).
418+ *
419+ * `undefined` — admitted — for:
420+ *
421+ * - every **string**, including a whitespace-only one. What a non-empty string
422+ * *says* stays `validateExpression('predicate', …)`'s verdict, and a
423+ * whitespace-only condition meaning `false` is consistent on both sides and
424+ * is ruled correct, not a defect.
425+ * - absent / `null`. "Not authored" is not a malformed predicate; both callers
426+ * already return early on it, and this agrees rather than disagreeing.
427+ * - an **expression envelope**: an object carrying a string `source`, or an
428+ * `ast`. That is `ExpressionSchema`'s own rule (`.refine(e => e.source !==
429+ * undefined || e.ast !== undefined)`), read here rather than re-derived, and
430+ * it is the shape `FlowEdgeSchema` produces for every parsed edge condition.
431+ * `dialect` is not required: an envelope without one is CEL, which is what
432+ * `evaluateCondition` already does with it.
433+ *
434+ * ## What it refuses, and what that was doing before
435+ *
436+ * A number, a boolean, an array, or an object that is neither — `{ source: 1 }`,
437+ * `{ dialect: 'cel' }` with no source and no ast, `{}`. `evaluateCondition`
438+ * reads the source as `expression?.source ?? ''` and the empty-source arm
439+ * returns **`false`**: the "an unauthored branch must not open" rule, applied to
440+ * a value that was very much authored. Measured: `42`, `true` and `['a']` at a
441+ * node's `config.condition` each registered clean, executed `success: true`, and
442+ * said nothing anywhere — on the same key the **start node's trigger gate** is
443+ * read from, so a flow could be silently gated shut forever. `{ source: 1 }`
444+ * did not even get that far: it reached `exprStr.trim()` and threw a bare
445+ * `TypeError` out of the validator.
446+ *
447+ * Refusing at the producer is the contract-first half: the flow does not
448+ * register and `objectstack validate` locates it, rather than the reject set of
449+ * registration and the reject set of evaluation being two different sets.
450+ *
451+ * @returns the refusal and the source to attribute it to, or `undefined` when
452+ * the value is authorable and therefore this function's business is done.
453+ */
454+ export function structuralConditionRefusal (
455+ value : unknown ,
456+ ) : { message : string ; source : string } | undefined {
457+ if ( value == null ) return undefined ;
458+ if ( typeof value === 'string' ) return undefined ;
459+ if ( typeof value === 'object' && ! Array . isArray ( value ) ) {
460+ const rec = value as { source ?: unknown ; ast ?: unknown } ;
461+ if ( typeof rec . source === 'string' || rec . ast !== undefined ) return undefined ;
462+ }
463+ const found = Array . isArray ( value )
464+ ? 'an array'
465+ : typeof value === 'object'
466+ ? 'an object carrying neither a string `source` nor an `ast`'
467+ : `a ${ typeof value } ` ;
468+ // The envelope's own `source`, when it has one, so the finding still points at
469+ // the text the author wrote rather than at an empty string. A non-string
470+ // `source` (the `{ source: 1 }` case) is exactly what is being refused, so it
471+ // cannot be the attribution.
472+ const rawSource = ( value as { source ?: unknown } ) . source ;
473+ return {
474+ message :
475+ `${ STRUCTURAL_CONDITION_SHAPE_REFUSAL } Found ${ found } . Write the condition as bare CEL text `
476+ + '(e.g. `record.rating >= 4`), or as an expression envelope (`{ dialect: \'cel\', source: \'…\' }`). '
477+ + 'A value that is neither is read by the evaluator as an EMPTY condition, which answers `false` '
478+ + 'without saying anything — and on a start node that is the trigger gate.' ,
479+ source : typeof rawSource === 'string' ? rawSource : '' ,
480+ } ;
481+ }
482+
389483/**
390484 * Descend `segments` through `node`, expanding a `key[]` segment over every
391485 * element of that array and a `*` segment over every own key of that object,
0 commit comments