fix(flows): author conditions as CEL envelopes so they actually evaluate - #565
Merged
Merged
Conversation
Every decision node and conditional edge in src/flows/ wrote its condition
as a bare string. AutomationEngine.evaluateCondition only routes an
Expression envelope ({ dialect, source }) to the CEL engine; a bare string
falls through to a legacy template path that substitutes {var} braces and
then compares both sides as strings. No condition in the app was ever
evaluated as an expression.
The failure mode was not a uniform no-op, which is what made it dangerous:
existingStallTask == null -> 'existingStallTask' === 'null' -> always false
record.rating >= 4 -> 'record.rating' >= '4' -> always TRUE
so opportunity_stagnation dropped every stalled deal at its idempotency
gate while reporting a successful run, and lead_assignment pinned the Hot
branch open and never took the Standard path.
All 41 condition sites now use the P tagged template from @objectstack/spec,
which emits the envelope at authoring time. The sources are unchanged: they
were already valid CEL, because the engine merges its variable map onto the
CEL scope via ctx.extra, so flow variables resolve by bare name and `record`
is the triggering record. Only the envelope was missing.
defineFlow() would not have sufficed: it normalizes the typed edge
condition, but a node's config is z.record(z.unknown()), so every start-node
trigger gate would have stayed a bare string.
Verified on a booted server with a temporary 1-minute cron: the stagnation
sweep now creates 30 follow-up tasks, one per stalled deal, and holds at 30
across later ticks as the idempotency gate closes. It created zero before.
A guard in test/metadata-references.test.ts fails on any condition that is
not a CEL envelope, at either site, so the bare form cannot come back
silently. The two case-escalation assertions that read the condition as a
string now read the envelope's source.
Refs #562
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJygXUafjc7ckGMmE2AaGz
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
yinlianghui
marked this pull request as ready for review
July 31, 2026 04:06
yinlianghui
pushed a commit
that referenced
this pull request
Jul 31, 2026
#565 landed the same CEL-envelope fix this branch carried, using the `P` tagged-template helper from @objectstack/spec — a cleaner notation, so the flow files take main's version wholesale. What remains here is the part #565 did not cover, plus the two red tests its merge left on main. main is currently RED. Both failures are tests this branch's parent (#563) introduced, and both fired exactly as designed: - flow-scheduled: the KNOWN DEFECT case asserted the stagnation gate stays shut. #565 opened it, so the test failed with the message it was written to emit — "gate now opens — rewrite this test". Rewritten here to assert the real behaviour (nudge created, idempotent across sweeps, re-arms once the task completes). - flow-record-change: `opportunity_approval` asserted `typeof startCondition === 'string'`. #565 converted it to an envelope. The assertion was pinning the NOTATION rather than the thing that matters, so it now accepts either form and checks a non-empty expression exists. Second defect #565 left reachable: `timestamp(currentContract.end_date)` throws "timestamp() requires a string in ISO 8601 format" because `end_date` is a DATE field arriving as YYYY-MM-DD. While the condition was a bare string it was never evaluated, so this was latent; wrapping it as a real envelope makes it throw mid-sweep. Appending T00:00:00Z fixes it — verified load-bearing by reverting it, which fails 4 contract_renewal tests. So on main as it stands, contract_renewal books nothing: the gate evaluates and then blows up. That is a live regression, not a theoretical one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127MccuEdXF7pxJFmaiD7UU
yinlianghui
pushed a commit
that referenced
this pull request
Jul 31, 2026
#565 landed the CEL-envelope conversion, so this branch no longer carries it. What remains is the timestamp() ISO defect it left reachable, the two tests its merge turned red on main, and the guards that keep the class fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0127MccuEdXF7pxJFmaiD7UU
Merged
13 tasks
yinlianghui
added a commit
that referenced
this pull request
Jul 31, 2026
…ns-and-recurrence fix(flows): unbreak main — contract_renewal throws; rewrite the two tests #565 turned red
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Every
decisionnode and conditional edge insrc/flows/authored its condition as a bare string.AutomationEngine.evaluateConditiononly routes anExpressionenvelope ({ dialect, source }) to the CEL engine — a bare string falls through to a legacy template path that substitutes{var}braces and then compares both sides as strings. No condition in this app was ever evaluated as an expression.The failure mode was not a uniform no-op, which is what made it dangerous:
existingStallTask == null'existingStallTask' === 'null'record.rating >= 4'record.rating' >= '4''r' > '4')So
opportunity_stagnationselected the right stalled deals (after #560) and then silently dropped every one of them at its idempotency gate — no notification, no follow-up task, and asuccessrun record either way. In the other direction,lead_assignmentpinned the Hot branch open and never took the Standard path.This is #562, split out of #489 while verifying #560.
Type of Change
Related Issues
Refs #562
Changes Made
Ptagged template from@objectstack/spec, which emits the envelope at authoring time.ctx.extra(buildScopeassignsctx.extraafterscope.record, and the variable map carries arecordkey), so flow variables resolve by bare name andrecordis the triggering record. Only the envelope was missing.defineFlow()would not have been sufficient. It normalizes the typed edgecondition(FlowEdgeSchema.conditionisExpressionInputSchema), but a node'sconfigisz.record(z.string(), z.unknown())— so every start-node trigger gate would have stayed a bare string.Pcovers both sites and needs no parse step.test/metadata-references.test.ts: no flow condition may be a bare string, at either site, with a floor on the number of sites inspected so a walker that stops matching cannot make the test vacuous.case_escalationassertions intest/actions-flows-integrity.test.tsreadcondition.sourceinstead of the raw value.case_escalation,opportunity_approval) are now single multi-line templates — CEL treats the newline as whitespace.Testing
pnpm test— 169 passing)pnpm lint— 1 warning / 9 suggestions, byte-identical to the same run on an unmodified tree)pnpm build);pnpm verifyclean end to endArtifact check — all 46 condition sites in
dist/objectstack.json(more than the 41 source sites becausedemo_bootstrapgenerates edges withflatMap) arecelenvelopes, zero bare strings, and every source parses against the real CEL engine.Verified on a booted server (
pnpm start, fresh DB, temporary 1-minute cron onopportunity_stagnation)check_not_nudgeddecision node every iteration.Additional Notes
Not verified end to end: the
lead_assignmentHot/Standard branch. Its trigger isrecord-after-create, the REST data route requires auth, and the seeded leads'next_followup_datevalues are seed literals rather than flow output — so there was no zero-auth path to exercise it on a live server. What is verified is the mechanism (identicalevaluateCondition→ edge-condition path, proven end to end by the stagnation sweep) and the expressions themselves (record.rating >= 4→trueandrecord.rating < 4→falseagainst the packaged CEL engine using the exact context shape the automation engine builds).Dead config left in place: a
decisionnode'sconfig.condition(singular) is never read — the executor readsconfig.conditions(plural, an array of{expression, label}) and so always returnsbranchLabel: 'default'. That is currently harmless because no edge carrieslabel: 'default', which makestraverseNextfall back to the full edge set. Restructuring it would change routing, so it is out of scope here, but if anyone ever labels an edgedefault, that edge will monopolise the branch and bypass every conditional edge. Noted on #562.Upstream, not fixable here: node
configis an untypedz.record(z.string(), z.unknown()), so no node-level expression can ever be normalized by schema parsing; andevaluateCondition's legacy path silently string-compares instead of failing, which is what turns an unresolved identifier into a wrong branch rather than an error. Both are recorded on #562.Generated by Claude Code