Skip to content

Commit e394749

Browse files
os-samclaude
andcommitted
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
1 parent 809d417 commit e394749

2 files changed

Lines changed: 99 additions & 8 deletions

File tree

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)