Skip to content

Commit b91c351

Browse files
os-samclaude
andauthored
fix(spec): send a top-level flow trigger to the START node config, not to a type rename (#14735)
* fix(spec): send a top-level flow trigger to the START node config, not to a `type` rename `FlowSchema` aliased `trigger` and `triggerType` to `type`, and `type` is the flow KIND enum (`autolaunched` | `record_change` | `schedule` | `screen` | `api`). An author who took that rename landed on `Invalid option: expected one of "autolaunched"|…` one round later with the trigger binding still nowhere. Both keys move to the `guidance` table beside `object` / `objectName` / `schedule`, naming where the binding really lives: the START node's `config` (`{ objectName, triggerType, condition }`, `triggerType` a `record-*` token). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE * chore(changeset): flow trigger key guidance Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE * chore(docs): re-anchor the system-context census citation after the flow.zod line shift `node scripts/check-system-context-census.mjs --fix` — pure line rot from the guidance entries added above the cited line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 13b5200 commit b91c351

4 files changed

Lines changed: 139 additions & 9 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): a top-level flow `trigger` / `triggerType` is now sent to the START node's `config`, not to a `type` rename
6+
7+
`FlowSchema`'s alias table pointed both keys at `type`, so a flow carrying a
8+
top-level trigger block was refused with the rename `trigger``type`.
9+
That rename cannot be taken: `type` is the flow KIND
10+
(`autolaunched` | `record_change` | `schedule` | `screen` | `api`), so an author
11+
who followed the advice landed on
12+
`Invalid option: expected one of "autolaunched"|…` one round later, with the
13+
trigger binding still nowhere — and a `.strict()` refusal carries exactly one
14+
actionable sentence.
15+
16+
The trigger does not move to `type`. It binds on the START node's `config`, as
17+
`{ objectName, triggerType, condition }` with a `record-*` token such as
18+
`record-after-create` — the shape the automation engine and the authoring-time
19+
`resolveFlowTriggerKind` both read. Both keys are `guidance` entries now, beside
20+
the `object` / `objectName` / `schedule` prescriptions that already name that
21+
config, so the rejection says where the binding really lives instead of
22+
prescribing a name:
23+
24+
```
25+
Unrecognized key(s) on this flow: `trigger`.
26+
• `trigger` is not a Flow field — a record-change flow binds its trigger on
27+
the START node's `config` (`{ objectName, triggerType, condition }`, where
28+
`triggerType` is a `record-*` token such as `record-after-create`), not at
29+
the flow top level; the flow-level `type` names the flow kind
30+
(`record_change`), not the binding.
31+
```
32+
33+
No accept/reject behaviour changes: a top-level `trigger` / `triggerType` was
34+
refused before and is refused now, and `FlowSchema`'s accepted keys and its
35+
`type` enum are untouched — only the prescription the refusal carries. One
36+
measured consequence of dropping the alias row: the guidance channel matches the
37+
exact authored spelling (case folding is the rename channel's job), so a
38+
non-canonical spelling such as `triggertype` now gets the bare rejection rather
39+
than the rename it cannot take.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ assuming `isSystem` covers it is a documented source of bugs.
193193

194194
| Assumption | Reality | Anchor |
195195
|:---|:---|:---|
196-
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881``1883`, #3760), `flow.zod.ts:685` |
196+
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881``1883`, #3760), `flow.zod.ts:702` |
197197
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
198198
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10007``10024` |
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |

packages/spec/src/automation/flow.test.ts

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,21 +1547,95 @@ describe('unknown keys are rejected, not stripped (#4001)', () => {
15471547
expect(issue!.message).toContain('`notAKey`');
15481548
});
15491549

1550-
it('points builder vocabulary (steps/connections/trigger) at the canonical keys', () => {
1550+
it('points builder vocabulary (steps/connections) at the canonical keys', () => {
15511551
expect(unknownKeyIssue(FlowSchema, { ...minimalFlow, steps: [] })!.message)
15521552
.toContain('`steps` → `nodes`');
15531553
expect(unknownKeyIssue(FlowSchema, { ...minimalFlow, connections: [] })!.message)
15541554
.toContain('`connections` → `edges`');
1555-
expect(unknownKeyIssue(FlowSchema, { ...minimalFlow, trigger: 'record_change' })!.message)
1556-
.toContain('`trigger` → `type`');
15571555
});
15581556

1559-
it('points a top-level object binding at the START node config', () => {
1560-
for (const key of ['object', 'objectName']) {
1561-
const message = unknownKeyIssue(FlowSchema, { ...minimalFlow, [key]: 'task' })!.message;
1557+
// `trigger` and `triggerType` were ALIASES pointing at `type` — a rename no
1558+
// author can take: `type` is the flow KIND enum, so following it lands on
1559+
// `Invalid option: expected one of "autolaunched"|…` one round later with the
1560+
// binding still nowhere. They are `guidance` entries now, so the rejection
1561+
// says where the binding really lives instead of prescribing a name. Both
1562+
// directions are pinned: the prescription is present, AND the rename is gone.
1563+
it('sends a top-level `trigger` to the START node config, never to a `type` rename', () => {
1564+
const message = unknownKeyIssue(FlowSchema, {
1565+
...minimalFlow,
1566+
trigger: { type: 'record_change', object: 'task', events: ['create'] },
1567+
})!.message;
1568+
expect(message).toContain('START node');
1569+
expect(message).toContain('`{ objectName, triggerType, condition }`');
1570+
expect(message, 'the prescription names a real `record-*` token').toContain('record-after-create');
1571+
expect(message, 'the rename an author cannot take is gone').not.toContain('`trigger` → `type`');
1572+
});
1573+
1574+
it('sends a top-level `triggerType` to the START node config, never to a `type` rename', () => {
1575+
const message = unknownKeyIssue(FlowSchema, {
1576+
...minimalFlow,
1577+
triggerType: 'record-after-create',
1578+
})!.message;
1579+
expect(message).toContain('START node');
1580+
expect(message).toContain('`{ objectName, triggerType, condition }`');
1581+
expect(message, 'the rename an author cannot take is gone').not.toContain('`triggerType` → `type`');
1582+
});
1583+
1584+
// The alias table is probed case- and separator-insensitively, so removing
1585+
// the `triggertype` row takes every spelling of it with the canonical one.
1586+
// `guidance` is exact-spelling by design (case folding is the rename
1587+
// channel's job — `shared/suggestions.zod.ts`), so a non-canonical spelling
1588+
// now gets the bare rejection: no prescription, and — the point — no
1589+
// confidently wrong one either.
1590+
it('no spelling of the removed alias renames to `type` any more', () => {
1591+
const message = unknownKeyIssue(FlowSchema, {
1592+
...minimalFlow,
1593+
triggertype: 'record-after-create',
1594+
})!.message;
1595+
expect(message).toContain('`triggertype`');
1596+
expect(message).not.toContain('→ `type`');
1597+
});
1598+
1599+
// Why both renames were dead ends, pinned so the guidance above cannot
1600+
// quietly turn into correct advice: `type` names the flow KIND and accepts
1601+
// no lifecycle-event token at all.
1602+
it('`type` accepts no `record-*` event token — the reason neither key renames to it', () => {
1603+
const result = FlowSchema.safeParse({ ...minimalFlow, type: 'record-after-create' });
1604+
expect(result.success).toBe(false);
1605+
const issue = result.error!.issues.find((i: { code: string }) => i.code === 'invalid_value');
1606+
expect(issue!.message).toContain('expected one of');
1607+
expect(issue!.message).not.toContain('record-');
1608+
});
1609+
1610+
it('points a top-level object/schedule binding at the START node config', () => {
1611+
const cases: Array<[string, unknown]> = [
1612+
['object', 'task'],
1613+
['objectName', 'task'],
1614+
['schedule', '0 8 * * *'],
1615+
];
1616+
for (const [key, value] of cases) {
1617+
const message = unknownKeyIssue(FlowSchema, { ...minimalFlow, [key]: value })!.message;
15621618
expect(message, `\`${key}\` should point at the start node`).toContain('START node');
15631619
}
15641620
});
1621+
1622+
// Positive control for the shape every one of those prescriptions points at:
1623+
// the trigger really does bind on the START node's `config`, and a flow that
1624+
// writes it there parses.
1625+
it('accepts the trigger bound on the START node config — the shape the guidance prescribes', () => {
1626+
const result = FlowSchema.safeParse({
1627+
...minimalFlow,
1628+
type: 'record_change',
1629+
nodes: [
1630+
{
1631+
id: 'start', type: 'start', label: 'Start',
1632+
config: { objectName: 'task', triggerType: 'record-after-create' },
1633+
},
1634+
{ id: 'end', type: 'end', label: 'End' },
1635+
],
1636+
});
1637+
expect(result.success).toBe(true);
1638+
});
15651639
});
15661640

15671641
describe('FlowNodeSchema', () => {

packages/spec/src/automation/flow.zod.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,11 +591,28 @@ export const FlowSchema = lazySchema(() => strictObject(
591591
connections: 'edges',
592592
transitions: 'edges',
593593
links: 'edges',
594-
trigger: 'type',
595-
triggertype: 'type',
596594
title: 'label',
597595
},
598596
guidance: {
597+
// `trigger` / `triggerType` were ALIASES pointing at `type` until an
598+
// author took the advice: `type` is the flow KIND
599+
// (`z.enum(['autolaunched', 'record_change', …])`), so the rename lands
600+
// on `Invalid option: expected one of "autolaunched"|…` one round later,
601+
// with the binding still nowhere — the `inputSchema.optional` case this
602+
// file already names, where a rename would be actively wrong. The trigger
603+
// does not move to `type`; it moves to the START node's `config`, which
604+
// is where `resolveFlowTriggerKind` (`automation/flow-trigger-kind.ts`)
605+
// and the engine's `AutomationEngine.resolveTriggerBinding` read it from.
606+
trigger:
607+
'`trigger` is not a Flow field — a record-change flow binds its trigger on the ' +
608+
'START node\'s `config` (`{ objectName, triggerType, condition }`, where `triggerType` ' +
609+
'is a `record-*` token such as `record-after-create`), not at the flow top level; the ' +
610+
'flow-level `type` names the flow kind (`record_change`), not the binding.',
611+
triggerType:
612+
'`triggerType` is not a Flow field — it belongs on the START node\'s `config` ' +
613+
'(`{ objectName, triggerType, condition }`), where a `record-*` token such as ' +
614+
'`record-after-create` binds the lifecycle event; the flow-level `type` names the ' +
615+
'flow kind (`record_change`), not an event token.',
599616
object:
600617
'`object` is not a Flow field — a record-change flow binds its object on the ' +
601618
'START node\'s `config` (`{ objectName, triggerType, condition }`), not at the ' +

0 commit comments

Comments
 (0)