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}'); + } + }); +});