Skip to content

Commit 39033a3

Browse files
os-zhuangclaude
andauthored
fix(app-shell): flow simulator evaluates a { dialect, source } edge guard (#3216) (#3217)
* fix(app-shell): flow simulator evaluates a `{ dialect, source }` edge guard (#3216) Two readers in `previews/simulator/` each hand-rolled `typeof c === 'string'` and returned `undefined` for anything else, so a decision out-edge guarded by the ADR-0089 expression envelope was reported as `Branch has no condition.` and skipped — while the engine evaluates it normally at run time. That envelope is not an exotic spelling: `ExpressionInputSchema` is a `ZodPipe` that rewrites an authored guard string INTO `{ dialect: 'cel', source }`, and `FlowEdgeSchema.condition` is that schema, so the shape the simulator could not read is the shape the platform produces. Both readers now go through `conditionText` — the one reader every other consumer of the field already used (canvas labels, `FlowEdgeInspector`, the Branches<->edges reconciliation) — so "how an edge guard is read" has exactly one answer in this repo. `validateFlowDraft`'s "no default branch" warning follows: a decision whose guards are envelopes was silently exempt from it. `SimEdge.condition` mirrors the spec's `ExpressionInput` by importing it — the last copy of the restatement objectui#3202 removed from `FlowDesignerEdge` — pinned by compile-time assertions in the project CI type-checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA * test(app-shell): pin "a persisted edge is a SimEdge", and stop overclaiming (#3216) The behavioural suite's `specEdge` helper annotates its return as `SimEdge`, and its JSDoc called that "a compile-time proof". It is not one here: the package tsconfig excludes `**/*.test.ts`, app-shell's test tree is still TEST_DEBT in the type-check ratchet, and vitest erases types — so no `tsc` run reads that annotation. A comment claiming enforcement that nothing performs is the objectui#3009 / objectui#3181 failure mode, in miniature. The claim moves to where it IS compiled: `flow-sim-edge.types.test.ts`, in `tsconfig.typetests.json`, now asserts `z.infer<typeof FlowEdgeSchema>` extends `SimEdge` — an edge the server hands back is a thing the simulator accepts, with no reconciliation and no cast. The helper's JSDoc says plainly that its annotation is a statement, not a check, and points at the pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 444457c commit 39033a3

8 files changed

Lines changed: 351 additions & 9 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
Flow simulator evaluates an edge guard stored as `{ dialect, source }` (objectui#3216).
6+
7+
A decision's out-edge whose guard is the ADR-0089 expression envelope — say
8+
`{ dialect: 'cel', source: 'amount > 10' }` — was reported on the debug timeline
9+
as `Branch has no condition.` and skipped. With `amount = 20` the simulation fell
10+
through to the default branch (or dead-ended with "No branch matched"), while the
11+
engine takes that branch at run time. A designer-time debugger that shows a
12+
different route than the runtime is worse than no debugger, and it is the one
13+
thing the simulator's own contract forbids: *never silently simulate semantics
14+
that differ from the runtime*.
15+
16+
The envelope is not an exotic spelling. `ExpressionInputSchema` in
17+
`@objectstack/spec` is a `ZodPipe`: parsing `condition: 'amount > 10'` **rewrites
18+
it into** `{ dialect: 'cel', source: 'amount > 10' }`, and `FlowEdgeSchema.condition`
19+
is that schema. So the shape the simulator could not read is the shape the
20+
platform itself produces for every authored guard.
21+
22+
Two readers in `previews/simulator/` each hand-rolled `typeof c === 'string' ? c :
23+
undefined`, while every other consumer in this repo already accepted both
24+
spellings — `conditionText` (canvas labels, `FlowEdgeInspector`, the Branches↔edges
25+
reconciliation) and `validateExpressionClient` (the Problems panel). Both now go
26+
through `conditionText`, so "how an edge guard is read" has exactly one answer.
27+
Its JSDoc says so, because a fifth hand-rolled copy brings this class of bug
28+
straight back.
29+
30+
Two behaviours change, both toward the runtime:
31+
32+
- **Decision routing** — a branch guarded by an envelope is now evaluated, and
33+
selected when true. The timeline shows the CEL source it ran instead of
34+
"no condition". An envelope carrying only a compiled `ast` and no `source`
35+
(spec phase M9.2) still reports "no condition": there is nothing to evaluate,
36+
and the simulator says so rather than faking a result.
37+
- **Preflight diagnostics**`validateFlowDraft` warns that a decision has no
38+
default branch when *every* out-edge is guarded. A decision whose guards were
39+
envelopes was silently exempt from that warning; it is exactly as able to
40+
dead-end, so the warning now appears in the Problems panel and the canvas
41+
banner for those flows too.
42+
43+
**Type change:** `SimEdge.condition` is now the spec's `ExpressionInput`,
44+
**imported** rather than restated — the last copy of the restatement objectui#3202
45+
removed from `FlowDesignerEdge`. `string | { source?: string }` was wrong in both
46+
directions at once: too wide, since it describes a `dialect`-less envelope the
47+
server rejects; too narrow, since excess-property checking then refused the
48+
canonical envelope written as a literal (`'dialect' does not exist in type
49+
'{ source?: string }'`) — the one shape a persisted flow actually carries was the
50+
one shape you could not write down. Compile-time assertions pin it in
51+
`tsconfig.typetests.json`, the project CI actually type-checks.

packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,26 @@ export function edgeKey(edge: FlowDesignerEdge, index: number): string {
457457
return edge.id || `${edge.source}->${edge.target}#${index}`;
458458
}
459459

460-
/** Human-readable condition text for an edge's optional guard. */
460+
/**
461+
* The CEL source of an edge's optional guard — **the** reader for that field.
462+
*
463+
* Both authoring spellings resolve here: the bare string, and the ADR-0089
464+
* envelope that the spec's `ExpressionInputSchema` normalizes every authored
465+
* string INTO at parse time (so the envelope is what a persisted flow carries).
466+
* An envelope with only a compiled `ast` and no `source` (spec phase M9.2) has
467+
* no readable source yet, and says so rather than inventing text.
468+
*
469+
* Every consumer of `condition` goes through this function — canvas labels and
470+
* the inspector, the Branches↔edges reconciliation in
471+
* `inspectors/flow-decision-edges.ts`, AND the simulator (both its decision
472+
* routing and its preflight diagnostics). That is deliberate and load-bearing:
473+
* "how an edge guard is read" had four hand-rolled answers, two of which
474+
* (`simulator/flow-simulator.ts`, `simulator/flow-sim-validate.ts`) accepted
475+
* only the bare string and so reported a spec-canonical envelope as "no
476+
* condition" — the simulator skipped a branch the engine takes (objectui#3216).
477+
* Add a fifth spelling and that class of bug comes straight back; call this
478+
* instead.
479+
*/
461480
export function conditionText(c: FlowDesignerEdge['condition']): string | undefined {
462481
if (!c) return undefined;
463482
if (typeof c === 'string') return c;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `SimEdge.condition` is the spec's expression envelope — pinned at compile
5+
* time (objectui#3216).
6+
*
7+
* This was the LAST copy of the restatement objectui#3202 removed from
8+
* `FlowDesignerEdge`: `string | { source?: string }`, a spelling wrong in both
9+
* directions at once. Too WIDE, because it describes an envelope with no
10+
* `dialect` — a shape the server's `FlowEdgeSchema` rejects outright. Too
11+
* NARROW, because excess-property checking then refuses the CANONICAL envelope
12+
* written as a literal: `condition: { dialect: 'cel', source }` fails with
13+
* "'dialect' does not exist in type '{ source?: string }'", so the one shape a
14+
* persisted flow actually carries was the one shape you could not write down.
15+
* Both errors come from the same act — writing the shape out by hand instead of
16+
* importing it — and correcting it by hand only moves the drift to the next
17+
* omitted member (`ast`, `meta`).
18+
*
19+
* The over-wide half is not academic: the simulator's readers took exactly the
20+
* branch that type invited (`typeof c === 'string'`, everything else
21+
* `undefined`) and skipped envelope guards the engine evaluates. The assertions
22+
* below make the local type incapable of describing either error again.
23+
*
24+
* The assertions ARE the file, so it is listed in
25+
* `packages/app-shell/tsconfig.typetests.json`: the package's build tsconfig
26+
* excludes `**\/*.test.ts` and vitest erases types before running
27+
* (objectui#3181), so unlisted they would be commentary, not a check.
28+
*/
29+
30+
import { describe, it, expect } from 'vitest';
31+
import { FlowEdgeSchema } from '@objectstack/spec/automation';
32+
import type { z } from 'zod';
33+
import type { ExpressionInput } from '@objectstack/spec/shared';
34+
import type { SimEdge } from '../flow-sim-types';
35+
import type { FlowDesignerEdge } from '../../flow-canvas-layout';
36+
37+
type Assert< T extends true > = T;
38+
type Extends< A, B > = [A] extends [B] ? true : false;
39+
type IsAny< T > = 0 extends 1 & T ? true : false;
40+
type Equal< A, B > = (< T >() => T extends A ? 1 : 2) extends < T >() => T extends B ? 1 : 2
41+
? true
42+
: false;
43+
44+
type Condition = NonNullable< SimEdge['condition'] >;
45+
/** An edge as the server PERSISTS it — `FlowEdgeSchema`'s parsed output. */
46+
type PersistedEdge = z.infer< typeof FlowEdgeSchema >;
47+
48+
describe('SimEdge.condition mirrors the spec ExpressionInput', () => {
49+
it('is pinned at compile time', () => {
50+
// Guard against the probe lying: were either side `any`, every
51+
// assignability assertion below would pass while proving nothing.
52+
type _SpecNotAny = Assert< Equal< IsAny< ExpressionInput >, false > >;
53+
type _LocalNotAny = Assert< Equal< IsAny< Condition >, false > >;
54+
55+
// Not "compatible with" — the SAME type. Restating it is how they drift.
56+
type _IsExactlyTheSpecType = Assert< Equal< Condition, ExpressionInput > >;
57+
58+
// …and therefore the same type the designer canvas carries, so an edge can
59+
// cross from the canvas into the simulator with nothing to reconcile.
60+
type _AgreesWithTheCanvas = Assert< Equal< Condition, NonNullable< FlowDesignerEdge['condition'] > > >;
61+
62+
// The authored shorthand (a bare CEL string) stays authorable…
63+
type _StringIsACondition = Assert< Extends< string, Condition > >;
64+
// …as does the envelope the spec canonicalises that string INTO — the form
65+
// the two readers used to report as "no condition".
66+
type _EnvelopeIsACondition = Assert< Extends< { dialect: 'cel'; source: string }, Condition > >;
67+
// …including the optional authorship `meta` the old restatement dropped.
68+
type _MetaIsACondition = Assert<
69+
Extends< { dialect: 'cel'; source: string; meta: { generatedBy: string } }, Condition >
70+
>;
71+
72+
// The over-wide half of the regression: a `dialect`-less envelope is no
73+
// longer expressible. `FlowEdgeSchema` rejects it, so this type must too.
74+
type _DialectlessRejected = Assert< Equal< Extends< { source: string }, Condition >, false > >;
75+
// And `dialect` is closed over the spec's three dialects.
76+
type _DialectIsClosed = Assert< Equal< Extends< { dialect: 'sql'; source: string }, Condition >, false > >;
77+
78+
// The whole point, stated end to end: an edge the SERVER hands back is a
79+
// thing the simulator accepts, with no reconciliation and no cast. That is
80+
// what objectui#3216 was not — the simulator's own input type described a
81+
// guard shape the persisted edge never has.
82+
type _PersistedEdgeNotAny = Assert< Equal< IsAny< PersistedEdge >, false > >;
83+
type _PersistedEdgeIsASimEdge = Assert< Extends< PersistedEdge, SimEdge > >;
84+
85+
expect(true).toBe(true);
86+
});
87+
88+
it('accepts the canonical envelope written INLINE — the half `Extends` cannot see', () => {
89+
// The assertions above compare types; this one exercises excess-property
90+
// checking, which is where the old restatement failed in the NARROW
91+
// direction. Under `{ source?: string }` both consts below are compile
92+
// errors ("'dialect' does not exist in…"), which is why an edge fixture in
93+
// this repo could never spell the shape the spec canonicalises guards into.
94+
const guarded: SimEdge = {
95+
source: 'd',
96+
target: 'hi',
97+
condition: { dialect: 'cel', source: 'amount > 10' },
98+
};
99+
// …including the optional authorship `meta` that a "corrected" restatement
100+
// would be the next thing to drop.
101+
const annotated: SimEdge = {
102+
source: 'd',
103+
target: 'hi',
104+
condition: { dialect: 'cel', source: 'amount > 10', meta: { generatedBy: 'agent' } },
105+
};
106+
expect(guarded.condition).toBeTruthy();
107+
expect(annotated.condition).toBeTruthy();
108+
});
109+
});

packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { FlowEdgeSchema } from '@objectstack/spec/automation';
45
import { FlowSimulator } from '../flow-simulator';
56
import { validateFlowDraft, findCycle } from '../flow-sim-validate';
67
import type { SimEdge, SimNode } from '../flow-sim-types';
@@ -12,6 +13,23 @@ const run = (nodes: SimNode[], edges: SimEdge[], seed = {}, mocks = {}) => {
1213
return sim;
1314
};
1415

16+
/**
17+
* An edge as the PLATFORM persists it, not as a test hand-writes it: authoring
18+
* input goes through `FlowEdgeSchema`, whose `ExpressionInput` pipe rewrites a
19+
* bare guard string into the `{ dialect: 'cel', source }` envelope. So the
20+
* envelope is not an exotic spelling the simulator may decline to support — it
21+
* is THE canonical persisted form, and a fixture that spells it by hand could
22+
* drift from what the spec actually emits (objectui#3216).
23+
*
24+
* The `SimEdge` return annotation states the matching type-level claim, but do
25+
* not read it as a proof: `tsc` never sees this file (the package tsconfig
26+
* excludes `**\/*.test.ts`, and app-shell's test tree is still TEST_DEBT), and
27+
* vitest erases types. The enforced version lives next door in
28+
* `flow-sim-edge.types.test.ts`, which IS compiled — `PersistedEdge extends
29+
* SimEdge`.
30+
*/
31+
const specEdge = (e: Record<string, unknown>): SimEdge => FlowEdgeSchema.parse(e);
32+
1533
describe('validateFlowDraft', () => {
1634
it('flags a missing entry node', () => {
1735
const v = validateFlowDraft(
@@ -464,3 +482,111 @@ describe('FlowSimulator', () => {
464482
expect(jStep.note).toMatch(/not modelled/i);
465483
});
466484
});
485+
486+
/**
487+
* objectui#3216 — the simulator reads an edge guard through `conditionText`,
488+
* the one reader every consumer of that field goes through.
489+
*
490+
* Both readers here (`flow-simulator.ts`'s decision routing and
491+
* `flow-sim-validate.ts`'s diagnostics) used to accept only a bare string and
492+
* return `undefined` for anything else. That made the simulator report
493+
* `Branch has no condition.` for — and skip — a branch the ENGINE evaluates
494+
* normally, i.e. it silently simulated semantics that differ from the runtime,
495+
* which is the one thing the simulator's own contract forbids. The spelling it
496+
* could not read is the spelling the platform itself produces (see
497+
* {@link specEdge}), so this was never "the simulator supports the shorthand
498+
* only"; it was a missing branch in two hand-rolled copies of one read.
499+
*/
500+
describe('decision guards in the spec expression envelope (#3216)', () => {
501+
const NODES: SimNode[] = [
502+
{ id: 's', type: 'start' },
503+
{ id: 'd', type: 'decision' },
504+
{ id: 'hi', type: 'end' },
505+
{ id: 'lo', type: 'end' },
506+
];
507+
const START: SimEdge = { id: 'e_s', source: 's', target: 'd' };
508+
509+
/** The decision's recorded evaluation of its out-edge to `target`. */
510+
const edgeEval = (sim: FlowSimulator, target: string) => {
511+
const step = sim.state.steps.find((x) => x.nodeId === 'd');
512+
const found = (step?.edges ?? []).find((x) => x.target === target);
513+
if (!found) throw new Error(`decision "d" recorded no evaluation for its edge to "${target}"`);
514+
return found;
515+
};
516+
517+
it('the spec canonicalises a bare guard INTO the envelope (premise of this suite)', () => {
518+
const edge = specEdge({ id: 'e_hi', source: 'd', target: 'hi', condition: 'amount > 10' });
519+
expect(edge.condition).toEqual({ dialect: 'cel', source: 'amount > 10' });
520+
});
521+
522+
it('selects the branch whose envelope guard is true (the issue repro)', () => {
523+
const guarded = specEdge({ id: 'e_hi', source: 'd', target: 'hi', condition: 'amount > 10' });
524+
const sim = run(NODES, [START, guarded, { id: 'e_lo', source: 'd', target: 'lo', isDefault: true }], { amount: 20 });
525+
526+
expect(sim.state.visitedNodeIds).toContain('hi');
527+
expect(sim.state.visitedNodeIds).not.toContain('lo');
528+
const dStep = sim.state.steps.find((x) => x.nodeId === 'd');
529+
expect(dStep?.edges?.find((x) => x.selected)?.target).toBe('hi');
530+
// The timeline shows the guard it evaluated, not "Branch has no condition."
531+
const hiEval = edgeEval(sim, 'hi');
532+
expect(hiEval.condition).toBe('amount > 10');
533+
expect(hiEval.error).toBeUndefined();
534+
});
535+
536+
it('EVALUATES an envelope guard rather than treating it as always-true', () => {
537+
const guarded = specEdge({ id: 'e_hi', source: 'd', target: 'hi', condition: 'amount > 10' });
538+
const sim = run(NODES, [START, guarded, { id: 'e_lo', source: 'd', target: 'lo', isDefault: true }], { amount: 5 });
539+
540+
expect(sim.state.visitedNodeIds).toContain('lo');
541+
expect(sim.state.visitedNodeIds).not.toContain('hi');
542+
const hiEval = edgeEval(sim, 'hi');
543+
// False for the right REASON: the guard ran and was false…
544+
expect(hiEval.result).toBe(false);
545+
expect(hiEval.condition).toBe('amount > 10');
546+
// …not because the reader could not see a condition at all.
547+
expect(hiEval.error).toBeUndefined();
548+
});
549+
550+
it('still says "no condition" for an envelope with nothing readable to evaluate', () => {
551+
// Spec phase M9.2 will emit `ast`-only envelopes. There is no CEL source to
552+
// run, and the simulator's rule is to say so rather than fake a result —
553+
// the fix widened the reader, it did not make every object a condition.
554+
const astOnly: SimEdge = { id: 'e_hi', source: 'd', target: 'hi', condition: { dialect: 'cel', ast: { op: 'gt' } } };
555+
const sim = run(NODES, [START, astOnly, { id: 'e_lo', source: 'd', target: 'lo', isDefault: true }], { amount: 20 });
556+
557+
const hiEval = edgeEval(sim, 'hi');
558+
expect(hiEval.error).toBe('Branch has no condition.');
559+
expect(sim.state.visitedNodeIds).toContain('lo');
560+
});
561+
562+
it('reports a dead-ending envelope guard as false, not as an absent condition', () => {
563+
const guarded = specEdge({ id: 'e_hi', source: 'd', target: 'hi', condition: 'amount > 10' });
564+
const sim = run(NODES, [START, guarded], { amount: 5 });
565+
566+
expect(sim.state.status).toBe('error');
567+
const hiEval = edgeEval(sim, 'hi');
568+
expect(hiEval.error).toBeUndefined();
569+
expect(hiEval.condition).toBe('amount > 10');
570+
});
571+
572+
// The same under-read, one layer up: `validateFlowDraft` warns when a decision
573+
// guards EVERY out-edge and has no default (it may dead-end). Reading only the
574+
// bare string made a decision whose guards are envelopes silently exempt.
575+
it('warns about a missing default when every guard is an envelope', () => {
576+
const v = validateFlowDraft(NODES, [
577+
START,
578+
specEdge({ id: 'e_hi', source: 'd', target: 'hi', condition: 'amount > 10' }),
579+
specEdge({ id: 'e_lo', source: 'd', target: 'lo', condition: 'amount <= 10' }),
580+
]);
581+
expect(v.warnings.some((w) => w.nodeId === 'd' && /no default branch/.test(w.message))).toBe(true);
582+
});
583+
584+
it('does not warn when one branch is unguarded (that branch cannot dead-end)', () => {
585+
const v = validateFlowDraft(NODES, [
586+
START,
587+
specEdge({ id: 'e_hi', source: 'd', target: 'hi', condition: 'amount > 10' }),
588+
{ id: 'e_lo', source: 'd', target: 'lo' },
589+
]);
590+
expect(v.warnings.some((w) => w.nodeId === 'd' && /no default branch/.test(w.message))).toBe(false);
591+
});
592+
});

packages/app-shell/src/views/metadata-admin/previews/simulator/flow-sim-types.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* "success".
1515
*/
1616

17+
import type { ExpressionInput } from '@objectstack/spec/shared';
18+
1719
export interface SimNode {
1820
id: string;
1921
type: string;
@@ -27,7 +29,24 @@ export interface SimEdge {
2729
id?: string;
2830
source: string;
2931
target: string;
30-
condition?: string | { source?: string };
32+
/**
33+
* Optional guard, in the spec's authoring shape: a bare CEL string, or the
34+
* ADR-0089 envelope (`{ dialect, source }`) that `ExpressionInputSchema`
35+
* normalizes every authored string INTO at parse time — so the envelope is
36+
* the form a persisted flow actually carries, not an exotic alternative.
37+
*
38+
* Imported from the spec rather than restated. The local spelling used to be
39+
* `string | { source?: string }`, and a restatement gets it wrong in BOTH
40+
* directions at once: too WIDE, because it describes a `dialect`-less
41+
* envelope that `FlowEdgeSchema` rejects outright; too NARROW, because
42+
* excess-property checking then refuses the canonical envelope written as a
43+
* literal (`dialect` "does not exist" on `{ source?: string }`). Correcting
44+
* it by hand only moves the drift — the next spelling omits `ast` or `meta`.
45+
* objectui#3202 removed the same restatement from `FlowDesignerEdge`; this
46+
* was its last copy, and the reason the simulator could not read a guard the
47+
* platform itself produces (objectui#3216).
48+
*/
49+
condition?: ExpressionInput;
3150
isDefault?: boolean;
3251
label?: string;
3352
/**

0 commit comments

Comments
 (0)