From e3cbb286f0e130aa1e3a8a90d4efe2090d4dacc1 Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 1 Sep 2026 03:54:14 +0000 Subject: [PATCH] feat(flows): fan an assignment out into one independent task per assignee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `record_change` flow on `duly_assignment` that turns a dispatched assignment into N `duly_task` rows, one owner each, plus — only when `needs_collection` is ticked — one follow-up task for the assigner. The gate is the dispatched STATE, not the transition into it. The platform can see a transition (`AutomationContext.previous` is bound on every run), but making the gate depend on it breaks two things: adding a sixth assignee to an already-dispatched assignment moves no status, so a transition gate would create zero tasks where the acceptance criteria say one; and `previous` is bound to `null` on an insert, where CEL field access through a null root throws rather than yielding false, faulting the flow on every assignment born directly as dispatched. Idempotency is therefore carried entirely by an explicit guard, and the guard is per OWNER: each iteration reads `duly_task where { assignment, owner }` and creates only on a miss. The `(duty, owner, period_key)` unique index cannot help — a fan-out task sets neither `duty` nor `period_key`. The guard deliberately does not key on `subject`, which would mint a duplicate for every owner as soon as somebody edits the assignment's subject and re-saves. Nothing here writes back to the assignment: `task_count` is a `Field.summary` computed on read and maintained by nobody. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/flows/assignment.flow.ts | 281 ++++++++++++++++++++++++++ src/flows/index.ts | 6 +- test/assignment-fanout.test.ts | 348 +++++++++++++++++++++++++++++++++ 3 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 src/flows/assignment.flow.ts create mode 100644 test/assignment-fanout.test.ts diff --git a/src/flows/assignment.flow.ts b/src/flows/assignment.flow.ts new file mode 100644 index 0000000..a68c96f --- /dev/null +++ b/src/flows/assignment.flow.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { P } from '@objectstack/spec'; +import { defineFlow } from '@objectstack/spec'; + +/** + * `duly_assignment_fanout` — one piece of work becomes N independent tasks. + * + * Assigning is the ONLY write a manager makes in this product, and every + * decision below protects that. Five names on one assignment become five + * `duly_task` rows, one owner each, each updated only by its owner. The + * manager's "3 of 5" is `duly_assignment.task_count` — a `Field.summary` + * rollup over the children, computed on read and maintained by nobody. This + * flow writes no progress field, no status rollup, and nothing back onto the + * assignment. + * + * ── Where the trigger binding lives ────────────────────────────────────── + * NOT at the flow top level. `FlowSchema` is `.strict()` and carries neither + * `object` nor `trigger`; writing either is a parse error whose message says + * so ("a record-change flow binds its object on the START node's `config` + * (`{ objectName, triggerType, condition }`)"). The engine's + * `resolveTriggerBinding` reads exactly those three keys off the node whose + * `type` is `'start'`, and reads `objectName` only — `object` is a load-time + * alias for the CRUD nodes, not for the trigger. + * + * ── Why the gate is STATE, not TRANSITION ──────────────────────────────── + * The platform CAN see a transition: `AutomationContext.previous` is bound on + * every run, so `previous.status != "dispatched"` is expressible. It is + * deliberately NOT used here, for two independent reasons. + * + * 1. The acceptance criteria require firing when there is no transition. + * "Adding a 6th assignee to an already-dispatched assignment creates + * exactly 1" is a save on which `status` does not move — a transition gate + * fires zero times and creates zero tasks. Idempotency is therefore carried + * by the per-assignee guard below, which has to exist anyway, rather than + * by the gate; making the gate load-bearing would buy nothing and cost the + * 6th assignee. + * + * 2. `previous` is bound to `null` on an insert (`seedRunVariables`: + * `variables.set('previous', context?.previous ?? null)`), and CEL field + * access through a `null` root ABORTS the predicate rather than yielding + * false. `evaluateCondition` never swallows that to `false` — it throws — + * so `previous.status != "dispatched"` would fault the flow on every + * assignment born directly in `dispatched` (an import, a REST create, a + * seed). `record.status` cannot fault the same way: the record-change + * trigger runs `materializeDeclaredFields` over both CEL roots, so every + * DECLARED field of `duly_assignment` is present, at worst as `null`. + * + * `record-after-write` is the create-OR-update union (`afterInsert` + + * `afterUpdate`), so an assignment dispatched at birth and one dispatched by a + * later edit take the same path. + * + * ── Idempotency ────────────────────────────────────────────────────────── + * The `duly_task` unique index covers `(duty, owner, period_key)`. For a + * fan-out task `duty` is null and `period_key` is unset, so the index does not + * constrain these rows at all and cannot be the guard. The guard is explicit + * and PER OWNER, not per assignment: each iteration reads back + * `duly_task where { assignment, owner }` and creates only on a miss. Adding a + * sixth name to a dispatched assignment therefore creates exactly one row — + * per-assignment guards create zero, which is the failure this shape exists to + * avoid. + * + * The guard filters on `(assignment, owner)` and nothing else — deliberately + * not on `subject`. A subject-aware guard would create a duplicate task for + * every owner the moment somebody edits the assignment's subject and re-saves, + * which is a worse failure than the one it would fix. One consequence, stated + * so it is a decision and not an accident: an assigner who is also one of the + * assignees already owns a task on this assignment, so `needs_collection` adds + * no second one for them. + */ +export const AssignmentFanout = defineFlow({ + name: 'duly_assignment_fanout', + label: 'Assignment fan-out', + description: + 'Turns a dispatched assignment into one independent task per assignee, and — only when the assigner asked for it — one follow-up task of their own.', + + type: 'record_change', + status: 'active', + + // The assignees are not the actor: a manager who can assign work is not + // thereby able to write rows owned by the people they assigned it to. #1888 + // still forwards the triggering user, so `created_by` and the tenant stamp + // land exactly as they would on a user-path insert. + runAs: 'system', + + /** + * Declared so they are BOUND (#4697), not merely documented. + * + * A `get_record` sets its `outputVariable` on every hit AND every miss — but + * it returns early WITHOUT setting it when no data engine is registered, and + * a name that was never set is unbound, which in strict CEL aborts the + * predicate reading it instead of yielding false. A declared `defaultValue` + * removes the unbound state entirely, which is the platform's own stated + * answer here (a `has()` guard would encode "unanswered means no" and leave + * the graph defect in place). + * + * None of these names collide with a `duly_assignment` field: a declared + * variable SHADOWS a record field of the same name, so a collision would + * silently replace the field the rest of the flow reads. + */ + variables: [ + { name: 'existing_task', type: 'record', defaultValue: null }, + { name: 'existing_assigner_task', type: 'record', defaultValue: null }, + { name: 'fanout_assignee_user', type: 'record', defaultValue: null }, + { name: 'assigner_user', type: 'record', defaultValue: null }, + ], + + nodes: [ + { + id: 'start', + type: 'start', + label: 'Assignment saved', + config: { + objectName: 'duly_assignment', + // create OR update — see the header. `record-after-write` maps to + // ['afterInsert', 'afterUpdate']; multi-event ARRAYS are unsupported + // and leave the flow silently unbound, so this stays a single token. + triggerType: 'record-after-write', + condition: P`record.status == "dispatched"`, + }, + }, + + { + id: 'fan_out', + type: 'loop', + label: 'One task per assignee', + config: { + // `flow-template` slot: single-brace interpolation, NOT bare CEL. A + // whole-string single token returns the raw value, so this resolves to + // the array itself — `Field.user({ multiple: true })` stores an array + // of `sys_user` ids. + collection: '{record.assignees}', + iteratorVariable: 'fanout_assignee', + body: { + nodes: [ + { + id: 'fanout_find_existing', + type: 'get_record', + label: 'Does this assignee already have a task?', + config: { + objectName: 'duly_task', + // Per OWNER, not per assignment. `limit` omitted → findOne → + // the variable is set to the row or to null, never to []. + filter: { assignment: '{record.id}', owner: '{fanout_assignee}' }, + fields: ['id'], + outputVariable: 'existing_task', + }, + }, + { + id: 'fanout_find_unit', + type: 'get_record', + label: "Read the assignee's business unit", + config: { + objectName: 'sys_user', + filter: { id: '{fanout_assignee}' }, + fields: ['id', 'primary_business_unit_id'], + outputVariable: 'fanout_assignee_user', + }, + }, + { + id: 'fanout_create_task', + type: 'create_record', + label: 'Create the assignee task', + config: { + objectName: 'duly_task', + fields: { + subject: '{record.subject}', + owner: '{fanout_assignee}', + // Denormalised at dispatch so a later transfer does not + // rewrite history (see duly_task.business_unit). + business_unit: '{fanout_assignee_user.primary_business_unit_id}', + assignment: '{record.id}', + source: 'assigned', + due_date: '{record.due_date}', + // An assignment has no lead time to spread, so the task is + // visible from the day it is due. + visible_from: '{record.due_date}', + status: 'open', + // `period_key` is NOT written. An assignment has no period, + // and the dispatch identity index does not apply to it. + }, + }, + }, + ], + edges: [ + { + id: 'fanout_e_missing', + source: 'fanout_find_existing', + target: 'fanout_find_unit', + type: 'conditional', + label: 'No task yet', + // `isBlank` takes the value itself (`dyn`), so it is total over + // null/undefined/'' /[] — unlike a field access through a null + // root, which aborts the predicate. + condition: P`isBlank(vars.existing_task)`, + }, + { id: 'fanout_e_create', source: 'fanout_find_unit', target: 'fanout_create_task' }, + ], + }, + }, + }, + + { + id: 'find_assigner_task', + type: 'get_record', + label: 'Does the assigner already have a task?', + config: { + objectName: 'duly_task', + filter: { assignment: '{record.id}', owner: '{record.assigner}' }, + fields: ['id'], + outputVariable: 'existing_assigner_task', + }, + }, + { + id: 'find_assigner_unit', + type: 'get_record', + label: "Read the assigner's business unit", + config: { + objectName: 'sys_user', + filter: { id: '{record.assigner}' }, + fields: ['id', 'primary_business_unit_id'], + outputVariable: 'assigner_user', + }, + }, + { + id: 'create_assigner_task', + type: 'create_record', + label: 'Create the follow-up task', + config: { + objectName: 'duly_task', + fields: { + // The assignment's own subject. No literal display text is authored + // here: English is the source language and every authored label + // belongs in a translation bundle, never inlined in a flow. + subject: '{record.subject}', + owner: '{record.assigner}', + business_unit: '{assigner_user.primary_business_unit_id}', + assignment: '{record.id}', + // Still assignment-sourced: this row came out of an assignment, and + // `source` is the column metrics read. `self` is for work somebody + // declared for themselves, which this is not. + source: 'assigned', + due_date: '{record.due_date}', + visible_from: '{record.due_date}', + status: 'open', + }, + }, + }, + + { id: 'end', type: 'end', label: 'Done' }, + ], + + edges: [ + { id: 'e_start', source: 'start', target: 'fan_out' }, + + // The opt-in. A manager who assigns work does not inherit a to-do list + // from having assigned it; only ticking `needs_collection` gives them one. + { + id: 'e_collection', + source: 'fan_out', + target: 'find_assigner_task', + type: 'conditional', + label: 'Assigner asked to follow up', + condition: P`record.needs_collection == true`, + }, + { id: 'e_no_collection', source: 'fan_out', target: 'end', isDefault: true }, + + { + id: 'e_assigner_missing', + source: 'find_assigner_task', + target: 'find_assigner_unit', + type: 'conditional', + label: 'No follow-up task yet', + condition: P`isBlank(vars.existing_assigner_task)`, + }, + { id: 'e_assigner_present', source: 'find_assigner_task', target: 'end', isDefault: true }, + + { id: 'e_assigner_create', source: 'find_assigner_unit', target: 'create_assigner_task' }, + { id: 'e_assigner_done', source: 'create_assigner_task', target: 'end' }, + ], +}); diff --git a/src/flows/index.ts b/src/flows/index.ts index 2c32e8a..a706c10 100644 --- a/src/flows/index.ts +++ b/src/flows/index.ts @@ -13,4 +13,8 @@ // makes `name` optional and fails the assignment. A named array is `never[]` // while empty and infers correctly the moment something is pushed into it. -export const dulyFlows = []; +import { AssignmentFanout } from './assignment.flow.js'; + +export { AssignmentFanout }; + +export const dulyFlows = [AssignmentFanout]; diff --git a/test/assignment-fanout.test.ts b/test/assignment-fanout.test.ts new file mode 100644 index 0000000..9c01bf5 --- /dev/null +++ b/test/assignment-fanout.test.ts @@ -0,0 +1,348 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { AssignmentFanout } from '../src/flows/assignment.flow.js'; +import { dulyFlows } from '../src/flows/index.js'; +import { Assignment, Task } from '../src/objects/index.js'; + +/** + * These are not schema tests — `pnpm validate` already parses the flow and + * resolves every `record.` in every predicate against the bound object + * (measured: misspelling one fails validate with a located finding). + * + * What validate does NOT check is the part that makes this flow correct, and + * that is what is pinned here: + * + * - `objectstack validate` deliberately does not flag a BARE field reference + * inside a flow predicate. `collectBoundRecordReads` skips bare identifiers + * on purpose, because the engine flattens the trigger record's fields to + * top-level variable names and a bare name there may genuinely be a flow + * variable. So the house rule "predicates say `record.`" has no gate + * behind it for flows — except this file. + * - Nothing in the toolchain checks that the idempotency guard is per OWNER + * rather than per assignment, that `period_key` stays unwritten, or that the + * trigger token is a single string. Each of those fails SILENTLY: a + * per-assignment guard creates zero tasks for a sixth assignee, and an + * ARRAY `triggerType` leaves the flow unbound and firing never. + */ + +type AnyRec = Record; +interface NodeLike { id: string; type: string; label: string; config?: AnyRec } +interface EdgeLike { + id: string; source: string; target: string; isDefault?: boolean; + condition?: string | { dialect?: string; source?: string }; +} + +const nodes = AssignmentFanout.nodes as unknown as NodeLike[]; +const edges = AssignmentFanout.edges as unknown as EdgeLike[]; + +const node = (id: string): NodeLike => { + const found = nodes.find((n) => n.id === id); + if (!found) throw new Error(`no node '${id}' — ${nodes.map((n) => n.id).join(', ')}`); + return found; +}; +const startNode = (): NodeLike => { + const found = nodes.find((n) => n.type === 'start'); + if (!found) throw new Error('flow has no start node'); + return found; +}; +const loopBody = (): { nodes: NodeLike[]; edges: EdgeLike[] } => { + const body = (node('fan_out').config?.body ?? {}) as { nodes?: NodeLike[]; edges?: EdgeLike[] }; + return { nodes: body.nodes ?? [], edges: body.edges ?? [] }; +}; +/** Every node in the flow, region bodies included. */ +const allNodes = (): NodeLike[] => [...nodes, ...loopBody().nodes]; +/** Every predicate authored anywhere, as its bare CEL source. */ +const allPredicates = (): { where: string; source: string }[] => { + const out: { where: string; source: string }[] = []; + const read = (where: string, c: EdgeLike['condition']) => { + if (c == null) return; + const source = typeof c === 'string' ? c : (c.source ?? ''); + if (source !== '') out.push({ where, source }); + }; + for (const n of allNodes()) read(`node '${n.id}' condition`, n.config?.condition as EdgeLike['condition']); + for (const e of [...edges, ...loopBody().edges]) read(`edge '${e.id}'`, e.condition); + return out; +}; + +describe('assignment fan-out — wiring', () => { + it('is registered in dulyFlows (a flow not in its barrel never runs)', () => { + expect(dulyFlows).toContain(AssignmentFanout); + }); + + it('runs as system and is active, not draft', () => { + // The assignees are not the actor. A draft flow is registered and never + // dispatches, which looks identical to a working flow from the outside. + expect(AssignmentFanout.runAs).toBe('system'); + expect(AssignmentFanout.status).toBe('active'); + expect(AssignmentFanout.type).toBe('record_change'); + }); +}); + +describe('assignment fan-out — the trigger binds where the engine reads it', () => { + it('binds on the START node config, never at the flow top level', () => { + // `FlowSchema` is strict and carries no `object` / `trigger` key; the + // engine's `resolveTriggerBinding` reads `objectName` / `triggerType` / + // `condition` off the start node and nowhere else. + expect(Object.keys(AssignmentFanout)).not.toContain('trigger'); + expect(Object.keys(AssignmentFanout)).not.toContain('object'); + expect(startNode().config?.objectName).toBe(Assignment.name); + }); + + it('uses a single-string record-* trigger token (an array binds nothing)', () => { + const triggerType = startNode().config?.triggerType; + expect(typeof triggerType, 'an ARRAY triggerType leaves the flow unbound').toBe('string'); + expect(triggerType).toMatch(/^record-(before|after)-(create|insert|update|delete|write)$/); + }); + + it('fires on create OR update, so an assignment born dispatched still fans out', () => { + // `record-after-write` maps to ['afterInsert', 'afterUpdate']. Narrowing it + // to '-update' would silently skip every assignment created straight into + // `dispatched` by an import, a REST create or a seed. + expect(startNode().config?.triggerType).toBe('record-after-write'); + }); +}); + +describe('assignment fan-out — the gate is state, not transition', () => { + it('gates on the dispatched STATE', () => { + const condition = startNode().config?.condition as { source?: string }; + expect(condition?.source).toBe('record.status == "dispatched"'); + }); + + it('no predicate reads `previous.` — it is null on insert and aborts the run', () => { + // The engine binds `previous` to null on an insert, and CEL field access + // through a null root throws rather than yielding false — `evaluateCondition` + // never swallows it. A transition gate would fault the flow on every + // assignment created directly as dispatched. + for (const { where, source } of allPredicates()) { + expect(source, `${where} must not gate on a transition`).not.toContain('previous.'); + } + }); + + it('a transition gate would also break the sixth assignee, so state is required', () => { + // Adding a name to an already-dispatched assignment moves no status. The + // acceptance criterion "creates exactly 1" is only reachable if the flow + // fires on a save with no transition — which is what makes the per-owner + // guard below the idempotency mechanism rather than the gate. + const condition = startNode().config?.condition as { source?: string }; + expect(condition?.source).not.toMatch(/previous/); + }); +}); + +describe('assignment fan-out — N assignees, N independent tasks', () => { + it('loops over the assignment\'s assignees', () => { + const cfg = node('fan_out').config ?? {}; + expect(node('fan_out').type).toBe('loop'); + // A `flow-template` slot: single-brace interpolation of the whole string + // returns the raw array. `assignees` is multi-valued; `assigner` is not. + expect(cfg.collection).toBe('{record.assignees}'); + expect(Assignment.fields.assignees.multiple).toBe(true); + expect(cfg.iteratorVariable).toBe('fanout_assignee'); + }); + + it('creates one duly_task per iteration, owned by the iterated assignee', () => { + const create = loopBody().nodes.find((n) => n.type === 'create_record'); + expect(create, 'the loop body must create a task').toBeDefined(); + const fields = (create!.config?.fields ?? {}) as AnyRec; + expect(create!.config?.objectName).toBe(Task.name); + expect(fields.owner).toBe('{fanout_assignee}'); + expect(fields.subject).toBe('{record.subject}'); + expect(fields.assignment).toBe('{record.id}'); + expect(fields.source).toBe('assigned'); + expect(fields.status).toBe('open'); + }); + + it('denormalises the business unit from the assignee, not the assigner', () => { + const create = loopBody().nodes.find((n) => n.type === 'create_record')!; + const fields = (create.config?.fields ?? {}) as AnyRec; + expect(fields.business_unit).toBe('{fanout_assignee_user.primary_business_unit_id}'); + }); + + it('an assignment has no lead time: visible_from tracks due_date', () => { + for (const create of allNodes().filter((n) => n.type === 'create_record')) { + const fields = (create.config?.fields ?? {}) as AnyRec; + expect(fields.visible_from, `${create.id}`).toBe(fields.due_date); + expect(fields.due_date, `${create.id}`).toBe('{record.due_date}'); + } + }); + + it('never writes period_key — an assignment has no period', () => { + for (const create of allNodes().filter((n) => n.type === 'create_record')) { + expect( + Object.keys((create.config?.fields ?? {}) as AnyRec), + `${create.id} must leave period_key unwritten`, + ).not.toContain('period_key'); + } + }); + + it('every created task has exactly one owner', () => { + for (const create of allNodes().filter((n) => n.type === 'create_record')) { + const fields = (create.config?.fields ?? {}) as AnyRec; + expect(typeof fields.owner, `${create.id}`).toBe('string'); + expect(Array.isArray(fields.owner), `${create.id}`).toBe(false); + } + }); +}); + +describe('assignment fan-out — idempotency is explicit and PER OWNER', () => { + it('the unique index cannot be the guard here', () => { + // (duty, owner, period_key) — a fan-out task sets none of duty or + // period_key, so the index never constrains these rows. This assertion is + // the reason the guard below has to exist at all. + const identity = Task.indexes?.find((i) => i.name === 'duly_task_dispatch_identity'); + expect(identity?.fields).toEqual(['duty', 'owner', 'period_key']); + const create = loopBody().nodes.find((n) => n.type === 'create_record')!; + const fields = Object.keys((create.config?.fields ?? {}) as AnyRec); + expect(fields).not.toContain('duty'); + expect(fields).not.toContain('period_key'); + }); + + it('reads back an existing task before creating one', () => { + const guard = loopBody().nodes.find((n) => n.type === 'get_record' && n.config?.objectName === Task.name); + expect(guard, 'the loop body must read duly_task back').toBeDefined(); + expect(guard!.config?.outputVariable).toBe('existing_task'); + }); + + it('the guard filters per OWNER, not per assignment', () => { + // The whole point. A guard keyed on `assignment` alone finds the first of + // the five tasks and creates NOTHING for a sixth assignee — zero, where the + // acceptance criterion says exactly one. + const guard = loopBody().nodes.find((n) => n.type === 'get_record' && n.config?.objectName === Task.name)!; + const filter = (guard.config?.filter ?? {}) as AnyRec; + expect(Object.keys(filter).sort()).toEqual(['assignment', 'owner']); + expect(filter.assignment).toBe('{record.id}'); + expect(filter.owner).toBe('{fanout_assignee}'); + }); + + it('the create is reachable only through the guard predicate', () => { + const body = loopBody(); + const create = body.nodes.find((n) => n.type === 'create_record')!; + // Walk back from the create to the guard; every hop must be gated or a + // straight continuation of a gated hop. + const incoming = (id: string) => body.edges.filter((e) => e.target === id); + const seenGate: string[] = []; + let frontier = [create.id]; + for (let hop = 0; hop < body.nodes.length && frontier.length > 0; hop++) { + const next: string[] = []; + for (const id of frontier) { + for (const e of incoming(id)) { + const source = typeof e.condition === 'string' ? e.condition : e.condition?.source; + if (source) seenGate.push(source); + else next.push(e.source); + } + } + frontier = next; + } + expect(seenGate, 'the create must sit behind a guard predicate').toContain( + 'isBlank(vars.existing_task)', + ); + }); + + it('the guard variable is DECLARED with a default, so it is never unbound', () => { + // `get_record` skips setting its outputVariable when no data engine is + // registered, and an unbound name aborts a strict-CEL predicate instead of + // yielding false. A declared default removes the unbound state. + const declared = AssignmentFanout.variables ?? []; + for (const name of ['existing_task', 'existing_assigner_task']) { + const v = declared.find((d) => d.name === name); + expect(v, `${name} must be declared`).toBeDefined(); + expect(v!.defaultValue, `${name} must carry a default`).toBeNull(); + } + }); + + it('no declared variable shadows a duly_assignment field', () => { + // A declared variable is seeded BEFORE the trigger record is flattened to + // top-level names, so a same-named declaration silently replaces the field. + const fields = Object.keys(Assignment.fields); + for (const v of AssignmentFanout.variables ?? []) { + expect(fields, `variable '${v.name}' would shadow the record field`).not.toContain(v.name); + } + }); +}); + +describe('assignment fan-out — the assigner task is opt-in', () => { + it('is reached only behind needs_collection', () => { + const gate = edges.find((e) => e.target === 'find_assigner_task'); + expect(gate, 'the assigner branch must be gated').toBeDefined(); + const source = typeof gate!.condition === 'string' ? gate!.condition : gate!.condition?.source; + expect(source).toBe('record.needs_collection == true'); + expect(gate!.isDefault).not.toBe(true); + }); + + it('the default path skips the assigner entirely', () => { + // A manager who assigns work must not inherit a to-do list from having + // assigned it, so "no opinion" has to route past the assigner branch. + const fallback = edges.find((e) => e.source === 'fan_out' && e.isDefault === true); + expect(fallback, 'fan_out needs a default out-edge').toBeDefined(); + expect(fallback!.target).toBe('end'); + }); + + it('creates at most one assigner task, behind its own per-owner guard', () => { + const guard = node('find_assigner_task'); + expect(guard.type).toBe('get_record'); + const filter = (guard.config?.filter ?? {}) as AnyRec; + expect(Object.keys(filter).sort()).toEqual(['assignment', 'owner']); + expect(filter.owner).toBe('{record.assigner}'); + + const gate = edges.find((e) => e.source === 'find_assigner_task' && e.target === 'find_assigner_unit'); + const source = typeof gate?.condition === 'string' ? gate.condition : gate?.condition?.source; + expect(source).toBe('isBlank(vars.existing_assigner_task)'); + + const create = (node('create_assigner_task').config?.fields ?? {}) as AnyRec; + expect(create.owner).toBe('{record.assigner}'); + expect(create.source).toBe('assigned'); + }); +}); + +describe('assignment fan-out — invariants a refactor would undo', () => { + it('nobody maintains a status rollup: the flow never writes duly_assignment', () => { + // `task_count` is a Field.summary computed on read. A create/update node + // pointed back at the assignment would also re-enter this same flow. + const writers = allNodes().filter((n) => ['create_record', 'update_record', 'delete_record'].includes(n.type)); + expect(writers.length).toBeGreaterThan(0); + for (const w of writers) { + expect(w.config?.objectName, `${w.id} must not write the assignment`).toBe(Task.name); + } + expect(Assignment.fields.task_count.type).toBe('summary'); + }); + + it('every predicate qualifies its record reads with `record.`', () => { + // Validate deliberately does not flag a bare identifier inside a flow, so + // this is the only gate on the house rule. A bare `status` also becomes + // ambiguous the moment a flow variable of that name is declared. + const assignmentFields = Object.keys(Assignment.fields); + for (const { where, source } of allPredicates()) { + for (const field of assignmentFields) { + const bare = new RegExp(`(^|[^.\\w])${field}\\b`); + if (bare.test(source)) { + expect( + source, + `${where} reads '${field}' bare — write record.${field}`, + ).toContain(`record.${field}`); + } + } + } + }); + + it('no predicate is wrapped in template braces', () => { + // `{amount}` parses as a CEL map literal and fails at registration. + for (const { where, source } of allPredicates()) { + expect(source.trim(), `${where}`).not.toMatch(/^\{.*\}$/); + } + }); + + it('every node id is unique across the graph and its regions', () => { + const ids = allNodes().map((n) => n.id); + expect(ids.length).toBe(new Set(ids).size); + }); + + it('no authored display text is hard-coded into a created record', () => { + // English is the source language and every authored label belongs in a + // translation bundle. A literal subject here would be untranslatable. + for (const create of allNodes().filter((n) => n.type === 'create_record')) { + const fields = (create.config?.fields ?? {}) as AnyRec; + expect(fields.subject, `${create.id}`).toBe('{record.subject}'); + } + }); +});