Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/seed-skip-flows-boolean-coercion-loop-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/spec': patch
'@objectstack/objectql': patch
'@objectstack/metadata-protocol': patch
'@objectstack/trigger-record-change': patch
'@objectstack/service-automation': patch
---

Package metadata seed can no longer wedge the platform via record-change automation.

A seeded record whose lifecycle flow self-triggered (a `record-after-update` flow
writing back to its own trigger record) looped forever when its boolean re-entry
guard never tripped — booleans persist as integer `1` on SQLite/libsql and CEL
`1 != true` is `true`. During first-boot seed (which awaits automation) this hung
the whole kernel build.

Three layers:
- `ExecutionContext.skipTriggers` (set by the seed-loader, threaded onto
`HookContext.session` via `buildSession`) makes the record-change trigger skip
flow dispatch for seed/bulk writes — seed data is end-state reference data, not
user events. Lifecycle hooks still run.
- `coerceBooleanFields()` converts SQLite 0/1 (and `'0'/'1'/'true'/'false'`) to
real booleans on the after-hook view of a record (`hookContext.result` /
`.previous`), so flow conditions see JS booleans. The value returned to the
caller is unchanged.
- The automation engine breaks a flow re-entering for the same record while an
execution is still on the stack (`activeRecordFlows`), a backstop for any
self-trigger loop.
10 changes: 9 additions & 1 deletion packages/metadata-protocol/src/seed-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,8 +606,16 @@ export class SeedLoaderService implements ISeedLoaderService {
* disables the SecurityPlugin's auto-injection of `organization_id` /
* `owner_id` — seeds either declare those fields explicitly per
* record, or are intentionally cross-tenant / global.
*
* `skipTriggers` suppresses record-change AUTOMATION (autolaunched flow
* triggers) for seed writes: a package's seed is pre-existing END-STATE
* reference/sample data, not a stream of user events, so firing
* on-create/on-update flows (notifications, escalations, assignments,
* approvals) for it is semantically wrong and dangerous — a self-triggering
* flow can loop and wedge the whole first-boot (2026-07-06 incident).
* Lifecycle HOOKS (derived/default fields, validation) still run.
*/
private static readonly SEED_OPTIONS = { context: { isSystem: true } } as const;
private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true } } as const;

private async writeRecord(
objectName: string,
Expand Down
24 changes: 20 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { ExpressionEngine } from '@objectstack/formula';
import type { Expression } from '@objectstack/spec';
import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec';
import { bindHooksToEngine } from './hook-binder.js';
import { validateRecord, normalizeMultiValueFields } from './validation/record-validator.js';
import { validateRecord, normalizeMultiValueFields, coerceBooleanFields } from './validation/record-validator.js';
import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields } from './validation/rule-validator.js';
import { applyInMemoryAggregation } from './in-memory-aggregation.js';

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

Expand Down Expand Up @@ -2189,7 +2193,13 @@ export class ObjectQL implements IDataEngine {
}

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

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

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

// Roll-up: recompute parent summaries; pass priorRecord too so a child
Expand Down
59 changes: 58 additions & 1 deletion packages/objectql/src/validation/record-validator.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* Required-field validation, with the autonumber exemption (#1603).
Expand Down Expand Up @@ -231,3 +231,60 @@ describe('ValidationError — top-level message is human-readable', () => {
]);
});
});

describe('coerceBooleanFields — SQLite 0/1 → real booleans', () => {
const schema = {
fields: {
is_escalated: { type: 'boolean' },
is_closed: { type: 'boolean' },
active: { type: 'boolean' },
name: { type: 'text' },
priority: { type: 'select', options: ['low', 'critical'] },
count: { type: 'number' },
},
};

it('coerces integer 0/1 on boolean fields, leaves others untouched', () => {
const row = { is_escalated: 1, is_closed: 0, name: 'Case', priority: 'critical', count: 5 };
const out = coerceBooleanFields(schema, row);
expect(out.is_escalated).toBe(true);
expect(out.is_closed).toBe(false);
expect(out.name).toBe('Case');
expect(out.priority).toBe('critical');
expect(out.count).toBe(5);
});

it('fixes the incident predicate: `is_escalated != true` after coercion', () => {
const raw = { is_escalated: 1 };
// Pre-coercion: an int 1 is NOT === true (the bug).
expect((raw.is_escalated as unknown) !== true).toBe(true);
const out = coerceBooleanFields(schema, raw);
// Post-coercion: the guard correctly suppresses re-fire.
expect(out.is_escalated !== true).toBe(false);
});

it('coerces string forms too', () => {
expect(coerceBooleanFields(schema, { active: '1' }).active).toBe(true);
expect(coerceBooleanFields(schema, { active: 'true' }).active).toBe(true);
expect(coerceBooleanFields(schema, { active: '0' }).active).toBe(false);
expect(coerceBooleanFields(schema, { active: 'false' }).active).toBe(false);
});

it('preserves null/undefined (nullable boolean stays null, not false)', () => {
const out = coerceBooleanFields(schema, { is_escalated: null, is_closed: undefined });
expect(out.is_escalated).toBe(null);
expect(out.is_closed).toBe(undefined);
});

it('leaves real booleans and unrecognised strings as-is; no copy when nothing changes', () => {
expect(coerceBooleanFields(schema, { active: true }).active).toBe(true);
expect(coerceBooleanFields(schema, { active: 'maybe' }).active).toBe('maybe');
const noBool = { name: 'x', count: 2 };
expect(coerceBooleanFields(schema, noBool)).toBe(noBool); // same ref — untouched
});

it('is null/empty-safe', () => {
expect(coerceBooleanFields(undefined, { a: 1 } as any)).toEqual({ a: 1 });
expect(coerceBooleanFields(schema, null as any)).toBe(null);
});
});
37 changes: 37 additions & 0 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,43 @@ export function normalizeMultiValueFields(
}
}

/**
* Coerce `boolean`-typed fields from their SQL storage form (integer `0`/`1`,
* or the strings `'0'`/`'1'`/`'true'`/`'false'`) into real JS booleans, on a
* SHALLOW COPY of `row`. SQLite/libsql have no native boolean, so a driver
* returns `1` for a `true` column — which then leaks into CEL/flow conditions
* where `record.is_escalated != true` becomes `1 != true` (always true, no
* int↔bool coercion) and a re-entry guard never trips (2026-07-06 infinite
* escalation loop). Returns the input unchanged when there is nothing to coerce.
*
* Only touches declared `boolean` fields; every other value is passed through.
* Null/undefined are preserved (a nullable boolean stays null, not `false`).
*/
export function coerceBooleanFields<T extends Record<string, unknown>>(
objectSchema: { fields?: Record<string, FieldDef> } | undefined | null,
row: T | undefined | null,
): T {
if (!objectSchema?.fields || !row || typeof row !== 'object') return row as T;
let copy: Record<string, unknown> | undefined;
for (const [name, def] of Object.entries(objectSchema.fields)) {
if (!def || def.type !== 'boolean') continue;
if (!(name in row)) continue;
const v = (row as Record<string, unknown>)[name];
if (v === null || v === undefined || typeof v === 'boolean') continue;
let coerced: boolean;
if (typeof v === 'number') coerced = v !== 0;
else if (typeof v === 'string') {
const s = v.trim().toLowerCase();
if (s === '1' || s === 'true') coerced = true;
else if (s === '0' || s === 'false' || s === '') coerced = false;
else continue; // unrecognised — leave as-is
} else continue;
if (!copy) copy = { ...(row as Record<string, unknown>) };
copy[name] = coerced;
}
return (copy ?? row) as T;
}

function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null {
// ── required ────────────────────────────────────────────────────
// `autonumber` is runtime-owned: the value is generated by the engine /
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Regression guard for the 2026-07-06 incident: a `record-after-update` flow
* whose action writes back to its OWN trigger record re-fires itself. Normally
* the start `condition` suppresses the second fire, but a broken guard makes it
* INFINITE — HotCRM's `case_escalation` guards on `record.is_escalated != true`,
* yet a `boolean` field persists as integer `1` on SQLite/libsql and CEL
* `1 != true` is `true`, so it never trips. During first-boot seed (which awaits
* automation to settle) that infinite cascade wedged the whole per-env kernel
* build, leaving the environment unopenable.
*
* The engine now breaks the SAME flow re-entering for the SAME record while an
* execution is still on the stack (see `activeRecordFlows`). This test drives
* the exact shape: a node executor that re-invokes `execute()` for the same
* flow+record, simulating the update→afterUpdate→dispatch→execute cascade.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { AutomationEngine } from './engine.js';

function createTestLogger() {
return { debug() {}, info() {}, warn() {}, error() {} } as any;
}

describe('AutomationEngine — record-flow re-entrancy loop guard', () => {
let engine: AutomationEngine;
beforeEach(() => {
engine = new AutomationEngine(createTestLogger());
});

it('breaks a self-triggering flow that re-fires for the same record', async () => {
let executeCalls = 0;
let skippedInner = false;

// A node that mimics `case_escalation`'s action: it writes back to the
// trigger record, which (in the real runtime) re-dispatches the same flow
// for the same record. Here we invoke execute() directly to model that
// synchronous cascade.
engine.registerNodeExecutor({
type: 'self_retrigger',
async execute(_node, _vars, _ctx?: any) {
executeCalls += 1;
if (executeCalls > 50) throw new Error('INFINITE LOOP — guard failed to break re-entry');
// Re-fire the SAME flow for the SAME record (the loop shape).
const r = await engine.execute('looping_flow', {
record: { id: 'case-1', is_escalated: 1 }, // int 1, like SQLite
object: 'crm_case',
event: 'record-after-update',
} as any);
if ((r.output as any)?.reason === 'reentrancy_loop_guard') skippedInner = true;
return { success: true };
},
});

engine.registerFlow('looping_flow', {
name: 'looping_flow',
label: 'Looping',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'act', type: 'self_retrigger', label: 'Re-fire' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'act' },
{ id: 'e2', source: 'act', target: 'end' },
],
});

const result = await engine.execute('looping_flow', {
record: { id: 'case-1', is_escalated: 1 },
object: 'crm_case',
event: 'record-after-update',
} as any);

// The OUTER run completes; the INNER re-entry is broken by the guard
// (not an infinite loop). executeCalls stays at 1 (the guard short-circuits
// before the inner run reaches the node again).
expect(result.success).toBe(true);
expect(skippedInner).toBe(true);
expect(executeCalls).toBe(1);
});

it('does NOT block a different record (legitimate cross-record fan-out)', async () => {
const seen: string[] = [];
engine.registerNodeExecutor({
type: 'touch',
async execute() { return { success: true }; },
});
engine.registerFlow('per_record', {
name: 'per_record', label: 'Per record', type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 't', type: 'touch', label: 'Touch' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 't' },
{ id: 'e2', source: 't', target: 'end' },
],
});
for (const id of ['a', 'b', 'c']) {
const r = await engine.execute('per_record', { record: { id }, object: 'o', event: 'record-after-insert' } as any);
if (r.success && !(r.output as any)?.reason) seen.push(id);
}
// All three distinct records run fully — the guard only trips on re-entry
// for the SAME record while active.
expect(seen).toEqual(['a', 'b', 'c']);
});

it('does NOT block a different flow on the same record (distinct-flow chain)', async () => {
engine.registerNodeExecutor({ type: 'noop', async execute() { return { success: true }; } });
for (const name of ['flow_x', 'flow_y']) {
engine.registerFlow(name, {
name, label: name, type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'n', type: 'noop', label: 'noop' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'n' },
{ id: 'e2', source: 'n', target: 'end' },
],
});
}
const rx = await engine.execute('flow_x', { record: { id: 'rec-1' }, object: 'o', event: 'record-after-update' } as any);
const ry = await engine.execute('flow_y', { record: { id: 'rec-1' }, object: 'o', event: 'record-after-update' } as any);
expect(rx.success).toBe(true);
expect((rx.output as any)?.reason).toBeUndefined();
expect(ry.success).toBe(true);
expect((ry.output as any)?.reason).toBeUndefined();
});
});
Loading