Skip to content

Commit 8b3d363

Browse files
os-zhuangclaude
andauthored
fix(seed/automation): package seed can't wedge the platform — suppress flows on seed + coerce SQLite booleans + re-entrancy loop guard (#2661)
* fix(service-automation): break record-flow self-trigger loops (re-entrancy guard) A `record-after-update` flow whose action writes back to its OWN trigger record re-fires itself (update → afterUpdate → dispatch → execute → update → …). The start `condition` is meant to suppress the second fire, but a broken guard makes it INFINITE and nothing stops it: each re-fire is a NEW run, so the existing intra-run MAX_NODE_REENTRIES back-edge guard never sees it. 2026-07-06 incident: HotCRM `case_escalation` guards on `record.is_escalated != true`, but a `boolean` field persists as integer `1` on SQLite/libsql and CEL `1 != true` evaluates true, so the guard never trips. During a new env's first-boot metadata seed (which awaits automation to settle) this infinite cascade never settles → the per-env kernel build hangs forever → the runtime serves 503 kernel_warming → the workspace is unopenable (sso-open retried to attempt=5 for hours). Add `activeRecordFlows`: the SAME flow re-entering for the SAME record while an execution is still on the stack is broken (returns skipped, logs a hint about the boolean-stored-as-0/1 footgun). Different flows or different records are untouched, so cross-record fan-out and distinct-flow chains still run. Cleanup in `finally` runs before the returned promise settles, so an error-retry re-run is not falsely blocked. 252 tests pass (23 files) incl. 3 new: breaks the loop, allows cross-record fan-out, allows distinct-flow-same-record. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(seed/automation): package seed suppresses record-change flows + coerce SQLite booleans for flow conditions Two root fixes for the class behind the 2026-07-06 first-boot wedge (a seeded critical case whose escalation flow self-triggered into an infinite loop): 1. **Seed skips record-change automation.** A package's metadata seed is pre-existing END-STATE reference/sample data, not a stream of user events — firing on-create/on-update flows (notifications, escalations, assignments, approvals) for it is semantically wrong and was the loop's vector. Add `ExecutionContext.skipTriggers`; the seed-loader's SEED_OPTIONS sets it (with the existing isSystem); `buildSession` threads it onto `HookContext.session`; the RecordChangeTrigger dispatch handler skips flow launch when set. Lifecycle HOOKS (derived/default fields, validation) still run — only automation flows are suppressed. 2. **Coerce boolean fields for the flow/hook view.** SQLite/libsql have no native boolean, so a driver returns integer `1` for a true column; a flow guard `record.is_escalated != true` then evaluates `1 != true` = true and never suppresses the re-fire. New `coerceBooleanFields(schema,row)` (record-validator) converts 0/1 (and '0'/'1'/'true'/'false') → real booleans on a shallow copy; the engine applies it to `hookContext.result` (and `.previous` on update) before after-hooks fire, so flow conditions and `{record.<bool>}` see JS booleans. The value RETURNED to the caller is untouched (no API shape change). Null/undefined preserved (a nullable boolean stays null). Complements the re-entrancy loop guard (prior commit): seed-skip prevents the loop from ever starting during seed; boolean-coercion fixes the guard class at runtime; the loop guard is the last-resort backstop for any other self-trigger. Tests: objectql 797, service-automation 252, trigger-record-change 22, record-validator 34 — all green. New: 6 coerceBooleanFields cases, 2 seed-skip dispatch cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fixup! fix(service-automation): break record-flow self-trigger loops (re-entrancy guard) * chore(changeset): seed-skip-flows + boolean coercion + loop guard --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b29bca4 commit 8b3d363

11 files changed

Lines changed: 379 additions & 7 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/metadata-protocol': patch
5+
'@objectstack/trigger-record-change': patch
6+
'@objectstack/service-automation': patch
7+
---
8+
9+
Package metadata seed can no longer wedge the platform via record-change automation.
10+
11+
A seeded record whose lifecycle flow self-triggered (a `record-after-update` flow
12+
writing back to its own trigger record) looped forever when its boolean re-entry
13+
guard never tripped — booleans persist as integer `1` on SQLite/libsql and CEL
14+
`1 != true` is `true`. During first-boot seed (which awaits automation) this hung
15+
the whole kernel build.
16+
17+
Three layers:
18+
- `ExecutionContext.skipTriggers` (set by the seed-loader, threaded onto
19+
`HookContext.session` via `buildSession`) makes the record-change trigger skip
20+
flow dispatch for seed/bulk writes — seed data is end-state reference data, not
21+
user events. Lifecycle hooks still run.
22+
- `coerceBooleanFields()` converts SQLite 0/1 (and `'0'/'1'/'true'/'false'`) to
23+
real booleans on the after-hook view of a record (`hookContext.result` /
24+
`.previous`), so flow conditions see JS booleans. The value returned to the
25+
caller is unchanged.
26+
- The automation engine breaks a flow re-entering for the same record while an
27+
execution is still on the stack (`activeRecordFlows`), a backstop for any
28+
self-trigger loop.

packages/metadata-protocol/src/seed-loader.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,8 +606,16 @@ export class SeedLoaderService implements ISeedLoaderService {
606606
* disables the SecurityPlugin's auto-injection of `organization_id` /
607607
* `owner_id` — seeds either declare those fields explicitly per
608608
* record, or are intentionally cross-tenant / global.
609+
*
610+
* `skipTriggers` suppresses record-change AUTOMATION (autolaunched flow
611+
* triggers) for seed writes: a package's seed is pre-existing END-STATE
612+
* reference/sample data, not a stream of user events, so firing
613+
* on-create/on-update flows (notifications, escalations, assignments,
614+
* approvals) for it is semantically wrong and dangerous — a self-triggering
615+
* flow can loop and wedge the whole first-boot (2026-07-06 incident).
616+
* Lifecycle HOOKS (derived/default fields, validation) still run.
609617
*/
610-
private static readonly SEED_OPTIONS = { context: { isSystem: true } } as const;
618+
private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true } } as const;
611619

612620
private async writeRecord(
613621
objectName: string,

packages/objectql/src/engine.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { ExpressionEngine } from '@objectstack/formula';
3030
import type { Expression } from '@objectstack/spec';
3131
import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec';
3232
import { bindHooksToEngine } from './hook-binder.js';
33-
import { validateRecord, normalizeMultiValueFields } from './validation/record-validator.js';
33+
import { validateRecord, normalizeMultiValueFields, coerceBooleanFields } from './validation/record-validator.js';
3434
import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields } from './validation/rule-validator.js';
3535
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
3636

@@ -690,6 +690,10 @@ export class ObjectQL implements IDataEngine {
690690
// Propagate system-elevated flag so hooks can distinguish engine
691691
// self-writes (e.g. approval status mirror) from genuine user writes.
692692
...((execCtx as any).isSystem ? { isSystem: true } : {}),
693+
// Propagate the automation-suppression flag so the record-change trigger
694+
// can skip flow dispatch for seed/bulk writes (ADR: seed loads end-state
695+
// data, not user events).
696+
...((execCtx as any).skipTriggers ? { skipTriggers: true } : {}),
693697
} as HookContext['session'];
694698
}
695699

@@ -2189,7 +2193,13 @@ export class ObjectQL implements IDataEngine {
21892193
}
21902194

21912195
hookContext.event = 'afterInsert';
2192-
hookContext.result = result;
2196+
// Coerce `boolean` fields (SQLite/libsql return 0/1) to real booleans on
2197+
// the after-hook view so flow trigger conditions (`record.is_escalated
2198+
// != true`) and `{record.<bool>}` interpolation see JS booleans, not
2199+
// ints. A shallow copy — the value returned to the caller is untouched.
2200+
hookContext.result = Array.isArray(result)
2201+
? result.map((r) => coerceBooleanFields(schemaForValidation as any, r as any))
2202+
: coerceBooleanFields(schemaForValidation as any, result as any);
21932203
await this.triggerHooks('afterInsert', hookContext);
21942204

21952205
// Roll-up: recompute parent summary fields that aggregate this object.
@@ -2328,8 +2338,14 @@ export class ObjectQL implements IDataEngine {
23282338
}
23292339

23302340
hookContext.event = 'afterUpdate';
2331-
hookContext.result = result;
2332-
if (priorRecord) hookContext.previous = priorRecord;
2341+
// Coerce boolean fields (SQLite 0/1 → JS bool) on the after-hook view
2342+
// of both the new row and the prior row, so flow conditions comparing
2343+
// `record.is_escalated`/`previous.status` against booleans behave.
2344+
// Shallow copies — the value returned to the caller is untouched.
2345+
hookContext.result = Array.isArray(result)
2346+
? result.map((r) => coerceBooleanFields(updateSchema as any, r as any))
2347+
: coerceBooleanFields(updateSchema as any, result as any);
2348+
if (priorRecord) hookContext.previous = coerceBooleanFields(updateSchema as any, priorRecord as any);
23332349
await this.triggerHooks('afterUpdate', hookContext);
23342350

23352351
// Roll-up: recompute parent summaries; pass priorRecord too so a child

packages/objectql/src/validation/record-validator.test.ts

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

33
import { describe, it, expect } from 'vitest';
4-
import { validateRecord, normalizeMultiValueFields, ValidationError } from './record-validator.js';
4+
import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError } from './record-validator.js';
55

66
/**
77
* Required-field validation, with the autonumber exemption (#1603).
@@ -231,3 +231,60 @@ describe('ValidationError — top-level message is human-readable', () => {
231231
]);
232232
});
233233
});
234+
235+
describe('coerceBooleanFields — SQLite 0/1 → real booleans', () => {
236+
const schema = {
237+
fields: {
238+
is_escalated: { type: 'boolean' },
239+
is_closed: { type: 'boolean' },
240+
active: { type: 'boolean' },
241+
name: { type: 'text' },
242+
priority: { type: 'select', options: ['low', 'critical'] },
243+
count: { type: 'number' },
244+
},
245+
};
246+
247+
it('coerces integer 0/1 on boolean fields, leaves others untouched', () => {
248+
const row = { is_escalated: 1, is_closed: 0, name: 'Case', priority: 'critical', count: 5 };
249+
const out = coerceBooleanFields(schema, row);
250+
expect(out.is_escalated).toBe(true);
251+
expect(out.is_closed).toBe(false);
252+
expect(out.name).toBe('Case');
253+
expect(out.priority).toBe('critical');
254+
expect(out.count).toBe(5);
255+
});
256+
257+
it('fixes the incident predicate: `is_escalated != true` after coercion', () => {
258+
const raw = { is_escalated: 1 };
259+
// Pre-coercion: an int 1 is NOT === true (the bug).
260+
expect((raw.is_escalated as unknown) !== true).toBe(true);
261+
const out = coerceBooleanFields(schema, raw);
262+
// Post-coercion: the guard correctly suppresses re-fire.
263+
expect(out.is_escalated !== true).toBe(false);
264+
});
265+
266+
it('coerces string forms too', () => {
267+
expect(coerceBooleanFields(schema, { active: '1' }).active).toBe(true);
268+
expect(coerceBooleanFields(schema, { active: 'true' }).active).toBe(true);
269+
expect(coerceBooleanFields(schema, { active: '0' }).active).toBe(false);
270+
expect(coerceBooleanFields(schema, { active: 'false' }).active).toBe(false);
271+
});
272+
273+
it('preserves null/undefined (nullable boolean stays null, not false)', () => {
274+
const out = coerceBooleanFields(schema, { is_escalated: null, is_closed: undefined });
275+
expect(out.is_escalated).toBe(null);
276+
expect(out.is_closed).toBe(undefined);
277+
});
278+
279+
it('leaves real booleans and unrecognised strings as-is; no copy when nothing changes', () => {
280+
expect(coerceBooleanFields(schema, { active: true }).active).toBe(true);
281+
expect(coerceBooleanFields(schema, { active: 'maybe' }).active).toBe('maybe');
282+
const noBool = { name: 'x', count: 2 };
283+
expect(coerceBooleanFields(schema, noBool)).toBe(noBool); // same ref — untouched
284+
});
285+
286+
it('is null/empty-safe', () => {
287+
expect(coerceBooleanFields(undefined, { a: 1 } as any)).toEqual({ a: 1 });
288+
expect(coerceBooleanFields(schema, null as any)).toBe(null);
289+
});
290+
});

packages/objectql/src/validation/record-validator.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,43 @@ export function normalizeMultiValueFields(
170170
}
171171
}
172172

173+
/**
174+
* Coerce `boolean`-typed fields from their SQL storage form (integer `0`/`1`,
175+
* or the strings `'0'`/`'1'`/`'true'`/`'false'`) into real JS booleans, on a
176+
* SHALLOW COPY of `row`. SQLite/libsql have no native boolean, so a driver
177+
* returns `1` for a `true` column — which then leaks into CEL/flow conditions
178+
* where `record.is_escalated != true` becomes `1 != true` (always true, no
179+
* int↔bool coercion) and a re-entry guard never trips (2026-07-06 infinite
180+
* escalation loop). Returns the input unchanged when there is nothing to coerce.
181+
*
182+
* Only touches declared `boolean` fields; every other value is passed through.
183+
* Null/undefined are preserved (a nullable boolean stays null, not `false`).
184+
*/
185+
export function coerceBooleanFields<T extends Record<string, unknown>>(
186+
objectSchema: { fields?: Record<string, FieldDef> } | undefined | null,
187+
row: T | undefined | null,
188+
): T {
189+
if (!objectSchema?.fields || !row || typeof row !== 'object') return row as T;
190+
let copy: Record<string, unknown> | undefined;
191+
for (const [name, def] of Object.entries(objectSchema.fields)) {
192+
if (!def || def.type !== 'boolean') continue;
193+
if (!(name in row)) continue;
194+
const v = (row as Record<string, unknown>)[name];
195+
if (v === null || v === undefined || typeof v === 'boolean') continue;
196+
let coerced: boolean;
197+
if (typeof v === 'number') coerced = v !== 0;
198+
else if (typeof v === 'string') {
199+
const s = v.trim().toLowerCase();
200+
if (s === '1' || s === 'true') coerced = true;
201+
else if (s === '0' || s === 'false' || s === '') coerced = false;
202+
else continue; // unrecognised — leave as-is
203+
} else continue;
204+
if (!copy) copy = { ...(row as Record<string, unknown>) };
205+
copy[name] = coerced;
206+
}
207+
return (copy ?? row) as T;
208+
}
209+
173210
function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null {
174211
// ── required ────────────────────────────────────────────────────
175212
// `autonumber` is runtime-owned: the value is generated by the engine /
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Regression guard for the 2026-07-06 incident: a `record-after-update` flow
5+
* whose action writes back to its OWN trigger record re-fires itself. Normally
6+
* the start `condition` suppresses the second fire, but a broken guard makes it
7+
* INFINITE — HotCRM's `case_escalation` guards on `record.is_escalated != true`,
8+
* yet a `boolean` field persists as integer `1` on SQLite/libsql and CEL
9+
* `1 != true` is `true`, so it never trips. During first-boot seed (which awaits
10+
* automation to settle) that infinite cascade wedged the whole per-env kernel
11+
* build, leaving the environment unopenable.
12+
*
13+
* The engine now breaks the SAME flow re-entering for the SAME record while an
14+
* execution is still on the stack (see `activeRecordFlows`). This test drives
15+
* the exact shape: a node executor that re-invokes `execute()` for the same
16+
* flow+record, simulating the update→afterUpdate→dispatch→execute cascade.
17+
*/
18+
19+
import { describe, it, expect, beforeEach } from 'vitest';
20+
import { AutomationEngine } from './engine.js';
21+
22+
function createTestLogger() {
23+
return { debug() {}, info() {}, warn() {}, error() {} } as any;
24+
}
25+
26+
describe('AutomationEngine — record-flow re-entrancy loop guard', () => {
27+
let engine: AutomationEngine;
28+
beforeEach(() => {
29+
engine = new AutomationEngine(createTestLogger());
30+
});
31+
32+
it('breaks a self-triggering flow that re-fires for the same record', async () => {
33+
let executeCalls = 0;
34+
let skippedInner = false;
35+
36+
// A node that mimics `case_escalation`'s action: it writes back to the
37+
// trigger record, which (in the real runtime) re-dispatches the same flow
38+
// for the same record. Here we invoke execute() directly to model that
39+
// synchronous cascade.
40+
engine.registerNodeExecutor({
41+
type: 'self_retrigger',
42+
async execute(_node, _vars, _ctx?: any) {
43+
executeCalls += 1;
44+
if (executeCalls > 50) throw new Error('INFINITE LOOP — guard failed to break re-entry');
45+
// Re-fire the SAME flow for the SAME record (the loop shape).
46+
const r = await engine.execute('looping_flow', {
47+
record: { id: 'case-1', is_escalated: 1 }, // int 1, like SQLite
48+
object: 'crm_case',
49+
event: 'record-after-update',
50+
} as any);
51+
if ((r.output as any)?.reason === 'reentrancy_loop_guard') skippedInner = true;
52+
return { success: true };
53+
},
54+
});
55+
56+
engine.registerFlow('looping_flow', {
57+
name: 'looping_flow',
58+
label: 'Looping',
59+
type: 'autolaunched',
60+
nodes: [
61+
{ id: 'start', type: 'start', label: 'Start' },
62+
{ id: 'act', type: 'self_retrigger', label: 'Re-fire' },
63+
{ id: 'end', type: 'end', label: 'End' },
64+
],
65+
edges: [
66+
{ id: 'e1', source: 'start', target: 'act' },
67+
{ id: 'e2', source: 'act', target: 'end' },
68+
],
69+
});
70+
71+
const result = await engine.execute('looping_flow', {
72+
record: { id: 'case-1', is_escalated: 1 },
73+
object: 'crm_case',
74+
event: 'record-after-update',
75+
} as any);
76+
77+
// The OUTER run completes; the INNER re-entry is broken by the guard
78+
// (not an infinite loop). executeCalls stays at 1 (the guard short-circuits
79+
// before the inner run reaches the node again).
80+
expect(result.success).toBe(true);
81+
expect(skippedInner).toBe(true);
82+
expect(executeCalls).toBe(1);
83+
});
84+
85+
it('does NOT block a different record (legitimate cross-record fan-out)', async () => {
86+
const seen: string[] = [];
87+
engine.registerNodeExecutor({
88+
type: 'touch',
89+
async execute() { return { success: true }; },
90+
});
91+
engine.registerFlow('per_record', {
92+
name: 'per_record', label: 'Per record', type: 'autolaunched',
93+
nodes: [
94+
{ id: 'start', type: 'start', label: 'Start' },
95+
{ id: 't', type: 'touch', label: 'Touch' },
96+
{ id: 'end', type: 'end', label: 'End' },
97+
],
98+
edges: [
99+
{ id: 'e1', source: 'start', target: 't' },
100+
{ id: 'e2', source: 't', target: 'end' },
101+
],
102+
});
103+
for (const id of ['a', 'b', 'c']) {
104+
const r = await engine.execute('per_record', { record: { id }, object: 'o', event: 'record-after-insert' } as any);
105+
if (r.success && !(r.output as any)?.reason) seen.push(id);
106+
}
107+
// All three distinct records run fully — the guard only trips on re-entry
108+
// for the SAME record while active.
109+
expect(seen).toEqual(['a', 'b', 'c']);
110+
});
111+
112+
it('does NOT block a different flow on the same record (distinct-flow chain)', async () => {
113+
engine.registerNodeExecutor({ type: 'noop', async execute() { return { success: true }; } });
114+
for (const name of ['flow_x', 'flow_y']) {
115+
engine.registerFlow(name, {
116+
name, label: name, type: 'autolaunched',
117+
nodes: [
118+
{ id: 'start', type: 'start', label: 'Start' },
119+
{ id: 'n', type: 'noop', label: 'noop' },
120+
{ id: 'end', type: 'end', label: 'End' },
121+
],
122+
edges: [
123+
{ id: 'e1', source: 'start', target: 'n' },
124+
{ id: 'e2', source: 'n', target: 'end' },
125+
],
126+
});
127+
}
128+
const rx = await engine.execute('flow_x', { record: { id: 'rec-1' }, object: 'o', event: 'record-after-update' } as any);
129+
const ry = await engine.execute('flow_y', { record: { id: 'rec-1' }, object: 'o', event: 'record-after-update' } as any);
130+
expect(rx.success).toBe(true);
131+
expect((rx.output as any)?.reason).toBeUndefined();
132+
expect(ry.success).toBe(true);
133+
expect((ry.output as any)?.reason).toBeUndefined();
134+
});
135+
});

0 commit comments

Comments
 (0)