Skip to content

Commit 5fd2c76

Browse files
feat(spec)!: retire seven overdue ADR-0087 D2 conversions from the load path at 18; record why eight stay (#23523)
Fixes #23487 Clause-②: no (narrowing) ## What The family closing card for ADR-0087 D2 conversions that were still on the load path at 18 although their window had closed. Every entry now has a verdict: seven are retired from the load path, and eight stay, each with the reason written into its own docblock, including what would have to change for it to retire. **Retired** (`retiredFromLoadPath: true`, `retiredAfter: '17.7.0'`, the newest published stable release under the `18.0.0-next` label, the same stamp PR #23485 used): | entry | `toMajor` | refusal an author now meets | |:--|:--|:--| | `flow-node-crud-object-alias` | 17 | CRUD node contract, at `config.object`, naming `objectName` | | `flow-node-crud-filter-alias` | 11 | CRUD node contract, at `config.filters`, naming `filter` | | `flow-node-notify-config-aliases` | 17 | `notify` contract, at `to` / `subject` / `body` / `url` / nested `source` | | `flow-node-map-flow-alias` | 17 | `map` contract, at `config.flow`, naming `flowName` | | `flow-node-subflow-flow-alias` | 17 | `subflow` contract, at `config.flow`, naming `flowName` | | `flow-node-script-config-aliases` | 17 | `script` contract, at `functionName` / `input`, naming `function` / `inputs` | | `sharing-rule-access-level-full-to-edit` | 17 | `SharingLevel`, now an `enumWithRetiredValues` whose `full` prescription names `edit` | Every one of those refusals is now the upgrade prescription: it names the canonical key, the release that refuses the old spelling (`@objectstack/spec 18`), and, for the step-17 entries, `os migrate meta --from 16`. The flow-node prescriptions used to say "the conversion rewrites it at load", which becomes false at authoring once the entry retires. `accessLevel: 'full'` used to get zod's bare `Invalid value 'full'. Expected one of: read, edit.` Each entry has a row in the removal-version pin's `JOIN` (`retired-key-removal-version.pin.test.ts`), so the release each prescription names is derived from the registry, not trusted. **Kept, with the reason recorded in the docblock:** | entry | why it stays (measured on this tree) | |:--|:--| | `flow-node-http-callout-rename` (11) | `flow.node.type` is an open namespace (ADR-0018): retired, `type: 'http_request'` passes `defineStack` silently. Retiring it needs a decision to reserve retired official names. | | `page-kind-jsx-to-html` (11) | `'jsx'` is still a declared member of `PageSchema.kind` and of `COMPILED_PAGE_KINDS`: retired, it passes silently. | | `view-visibleOn-to-visibleWhen` (15) | `visibleOn` is still a declared `[DEPRECATED]` key that the form schemas fold into `visibleWhen` at parse, so retiring the entry alone changes nothing an author can observe. It retires with the alias (a `retiredKey` tombstone). | | `page-component-visibility-to-visibleWhen` (15) | Same, on `PageComponentSchema.visibility`. | | `flow-node-wait-event-config-lift` (17) | `wait` has no `config` contract: retired, a loose `config.timerDuration` beside a declared `waitEventConfig` passes silently. Refusing loose keys would also refuse stored flows whose loose key the block shadows (the rehydration seam parses the same schema), which needs a ruling. | | `flow-node-connector-config-lift` (17) | Same shape: a loose `config.input` beside a complete `connectorConfig` passes silently, and the action would dispatch with no input. | | `datasource-driver-mongo-to-mongodb` (17) | `'mongo'` is still a legal value, so there is no refusal to hand the author to. Needs a decision: retire the value, or record the entry as permanent. | | `retry-policy-converged` (17) | The job half alone. This entry writes the pre-17 `maxRetries: 3` / `backoffMultiplier: 2` into a source `job.retryPolicy` that omits them; retired, such a job would silently drop from 3 retries to 0 under the merged defaults, a default flip no gate can see. It can retire once that flip has a ruling for authored sources. (The `retryDelayMs` rename is not a blocker: every surface already refuses it, a `try_catch` node's `config.retry` included, by the `RetryPolicySchema` tombstone. An earlier version of this row said otherwise; that was a wrong reading taken from a node-level parse, corrected at contract review.) | `conversions.test.ts` holds the verdict list. A new entry below 18 that is neither retired nor on the kept list is an entry nobody judged, and the test goes red on it. ## Enumeration (done before any edit) Re-derived from `packages/spec/src/conversions/registry.ts` on `origin/main` `1418de4c7`, by importing `ALL_CONVERSIONS` and keeping every entry whose `toMajor` is below 18 and that carries no `retiredFromLoadPath`: 15 entries (3 at `toMajor: 11`, 2 at 15, 10 at 17). This matches the card's table exactly (10 + 5). There is no difference to report. ## Premise check, per entry (before deciding) For each entry, `fixture.before` (completed with the labels, flow `type` and `edges` the fixtures leave out) went through `defineStack` twice: once with the entry live, and once with `retiredFromLoadPath` set on it in memory. The issues the second run adds are the answer an author would meet after retirement: - The six flow-node aliases and `full`: the second run adds a refusal at the old key, naming the canonical key. That is the precondition, met. For `full`, the text was the generic enum message, so the retirement adds the prescription. - `http_request`, `kind: 'jsx'`, `visibleOn`, `visibility` and `mongo`: the second run adds nothing, so the old spelling would be accepted silently. Kept. - wait and connector: the second run refuses only when the block is missing, and a loose key beside a declared block passes. Kept. - retry: `flow.errorHandling.retryDelayMs` is tombstoned, but the `try_catch` `config.retry` spelling passes. Kept. PM mechanism assumption ②, measured: stored flows still heal. `AutomationEngine.canonicalizeFlow` replays with `includeRetired: true`, and its only exclusion (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`) is `flow-decision-mode-inclusive-explicit`, which is not in this family. `service-automation`'s `crud-config-aliases.test.ts` now pins both halves: `normalizeStackInput` hands `{ object, filters }` on as written, and `registerFlow` plus `execute` still query `crm_lead` with `{ id: 'L1' }`. ## A second producer: the metadata save door's flow fallback (`packages/metadata-protocol`) This is outside the claim's file surface, so the landing spot and the reason are stated here. `saveMetaItem` canonicalizes a flow through the automation engine (the full chain, retired entries included). When no automation service is reachable, or the canonicalizer throws, it judges a fallback verdict body built with `applyConversionsToFlow(request.item)`, in the authoring posture. Under the #21982 ruling, that verdict must not depend on whether the host runs an automation service. Retiring these seven entries would have broken that: 9 tests in `protocol.save-flow-canonicalization.test.ts` went red, because the fallback now refused `filters` and `functionName` bodies that the canonicalizer heals. The fix replays the chain the canonicalizer replays: `includeRetired: true`, minus the same default flip, listed beside it with its reason (`FLOW_FALLBACK_VERDICT_NOT_REPLAYED`, following the per-door exclusion pattern of the engine and the artifact door). With the fix, the fallback reaches the canonicalizer's verdict for every retired flow shape: the seven retired here, and those retired earlier that walk a flow, `flow-inert-keys-removed` (`flow.active` / `flow.template` / `nodes[].outputSchema` / `errorHandling.fallbackNodeId`) and `flow-node-wait-timeout-keys-removed` (`waitEventConfig.timeoutMs` / `onTimeout`). So this is a behaviour change on the fallback path. On a host with no automation service, a body carrying one of those older keys was refused 422 by its tombstone before this PR; it is now healed for the verdict and stored as submitted. That is what the service-backed path already did, and what #21982 asks for. A new pin beside the #21982 block (`flow.active` on a no-service host saves, stored raw) holds it. The 9 red tests are the before reading for the seven, and the after reading is green. ## The record and strict-exit suites `page-component-visibility-to-visibleWhen` stays live, so the three suites that ride it (`stack-conversions-record.test.ts`, `stack-conversion-record-door.test.ts`, `validate-json-strict-exit.e2e.test.ts`) need no re-pointing. The wrong sentence at `validate-json-strict-exit.e2e.test.ts:63-65` is fixed: the conversion that retired is `page-header-subtitle-alias`. ## Pin sweep The conversion ids and the old refusal wording were grepped across the whole repo. These pins were flipped to the new semantics: - Spec: `builtin-node-config.test.ts`, `io-node-config.test.ts`, `schemaless-node-config.test.ts` and `sharing.test.ts`. Each now asserts the prescription text (`` `X` was removed in @objectstack/spec 18 `` plus `Use CANONICAL`). The `full` pin also asserts code `invalid_value`, a single issue, and no "Expected one of" text. - Spec: `flow-builtin-node-config-keys.test.ts`. `defineStack` now refuses `STACK_SCHEMA_INVALID` / 422 at `flows.0.nodes.1.config.filters`, and a stored body converted with `includeRetired` parses clean. - Spec: `conversions.test.ts` and `region-walk.test.ts`. The rewrite cases run the data-at-rest posture. - `service-automation`: `crud-config-aliases.test.ts`, as above. - Comments that became false: `flow.test.ts`, the D3 entry `18.flow-script-subflow-config-undeclared-keys-refused`, and the reference page `schemaless-node-config.mdx`, which `gen:docs` regenerates from the docblock. ## Verification Commands, exit codes and real readings are in the report comment on the card. Summary: - `@objectstack/spec`: `test` 678 files / 20728 tests passed; `test:repo` 60 / 1029 passed; `typecheck` exit 0; `check:generated` all 14 artifacts up to date. The four ratchets show zero change, as expected for value and key narrowing on existing defs. - Consumers: `service-automation` 186 files / 2486 tests passed; `lint` 139 / 6726 passed; `metadata-protocol` and `plugin-sharing` are in the report. - Derived gate families: `dispatch-gates.mjs --commands`, 115 commands, reconciled with `--ran`. ## Acceptance notes - Several executor comments in `service-automation` (`crud-nodes.ts`, `notify-node.ts`, `map-node.ts`, `subflow-node.ts`, `screen-nodes.ts`) and `lint` (`validate-flow-node-writes.ts`, `validate-readonly-flow-writes.ts`, `validate-expressions.ts`) still say an alias "is rewritten at load". That is still true of the seam those executors sit behind (flow rehydration), so they are left alone. - The save door with an automation service heals a retired flow alias through the engine instead of refusing it. That was the case before this PR (and already holds for the earlier retired flow-node tombstones). The authoring funnel is the jurisdiction of `retiredFromLoadPath`. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 51b9990 commit 5fd2c76

21 files changed

Lines changed: 543 additions & 158 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(spec)!: seven overdue ADR-0087 D2 conversions leave the load path at 18 — six flow-node config aliases and a sharing rule's `accessLevel: 'full'` are refused with their rename at every door that judges a flow or a sharing rule
7+
8+
Clause-②: no (narrowing)
9+
10+
**BREAKING (authoring):** seven ADR-0087 D2 conversions whose one-major window had already run are now retired from the load path: `flow-node-crud-object-alias`, `flow-node-notify-config-aliases`, `flow-node-map-flow-alias`, `flow-node-subflow-flow-alias`, `flow-node-script-config-aliases` and `sharing-rule-access-level-full-to-edit` (protocol 17's window), and `flow-node-crud-filter-alias` (protocol 11's). The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites these spellings. An author who still writes one meets the refusal of the surface's own contract, and every one of those refusals is now the upgrade prescription: it names the canonical key and the release that refuses the old spelling. The flow-node prescriptions used to say "the conversion rewrites it at load". `accessLevel: 'full'` used to get zod's bare "Expected one of: read, edit"; `SharingLevel` now names `edit`.
11+
12+
Migration:
13+
14+
- CRUD node (`get_record` / `create_record` / `update_record` / `delete_record`) `config.object` → `config.objectName`, and `config.filters` → `config.filter`. Rename the key; the value is unchanged.
15+
- `notify` node `config.to` → `recipients`, `config.subject` → `title`, `config.body` → `message`, `config.url` → `actionUrl`, and a nested `config.source: { object, id }` → the flat pair `sourceObject` / `sourceId`. Rename (or move) the key; the value is unchanged.
16+
- `map` and `subflow` node `config.flow` → `config.flowName`. Rename the key; the value is unchanged.
17+
- `script` node `config.functionName` → `config.function`, and `config.input` → `config.inputs`. Rename the key; the value is unchanged. (`connectorConfig.input` on a `connector_action` node is canonical; leave it.)
18+
- Sharing rule `accessLevel: 'full'` → `accessLevel: 'edit'`. `full` never granted more than `edit`.
19+
- If a config carries both spellings with different values, decide which one you mean, keep it under the canonical key, and delete the old one.
20+
21+
Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. (`config.filters` belongs to protocol 11, below the migration floor, so `os migrate meta` does not list it: rename that one by hand.)
22+
23+
Stored metadata needs no edit. The automation engine's flow rehydration and stored-row rehydration still replay the whole conversion chain, these seven entries included, so `sys_metadata` flows and sharing-rule rows written under an old spelling keep loading in canonical form. A built artifact whose declared protocol floor is at or below `17.7.0` is also still converted at the artifact-ingestion door.
24+
25+
The metadata save door's flow fallback now reaches the canonicalizer's verdict for every retired flow shape. When no automation service is reachable (or its canonicalizer throws), `saveMetaItem` judges a flow body after replaying the full conversion chain the automation engine's canonicalizer replays, retired entries included (the default flip `flow-decision-mode-inclusive-explicit` excepted, as there). That covers the seven entries retired here and those retired earlier that walk a flow: `flow-inert-keys-removed` (`flow.active`, `flow.template`, `nodes[].outputSchema`, `errorHandling.fallbackNodeId`) and `flow-node-wait-timeout-keys-removed` (`waitEventConfig.timeoutMs` / `onTimeout`). On such a host a body carrying one of those older keys was refused 422 by its tombstone before; it is now healed for the verdict, which is what a host with an automation service already did. What is stored stays the body as submitted, so a host's verdict on a saved flow no longer depends on whether it runs an automation service.
26+
27+
Eight more entries below 18 stay on the load path, each with the reason recorded in its docblock: `flow-node-http-callout-rename` and `page-kind-jsx-to-html` (the old value is still accepted by its schema), `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (the schema still declares and folds the deprecated key), `flow-node-wait-event-config-lift` and `flow-node-connector-config-lift` (the node's `config` has no contract to refuse the loose keys), `datasource-driver-mongo-to-mongodb` and `retry-policy-converged`.
28+
29+
<!-- adr-0087: not-required (already-registered flow-node-crud-object-alias, flow-node-crud-filter-alias, flow-node-notify-config-aliases, flow-node-map-flow-alias, flow-node-subflow-flow-alias, flow-node-script-config-aliases, sharing-rule-access-level-full-to-edit) All seven D2 conversions are registered in their migration steps before this change, and they are the whole mechanical migration: this diff marks them retiredFromLoadPath with retiredAfter 17.7.0 and makes the refusals an author now meets name the release, so the authoring funnel stops replaying them while os migrate meta, stored-row and flow rehydration and the artifact door keep replaying them. No key, value or shape is added or removed, so there is no new D2 conversion and no D3 semantic judgment to register: each rewrite is a lossless rename. -->

‎content/docs/references/automation/schemaless-node-config.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ form authoring keys nothing reads).
8282

8383
Undeclared aliases are NOT part of these contracts: `subflow`'s historical
8484
`flow` spelling graduated into the ADR-0087 D2 conversion
85-
`flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only
86-
ever sees `flowName`.
85+
`flow-node-subflow-flow-alias` (the `map.flow` path), which left the load
86+
path at 18 (#23487) and rewrites only stored flows, so an authored `flow`
87+
meets `SUBFLOW_KEY_GUIDANCE` and the executor only ever sees
88+
`flowName`.
8789

8890
## Unknown keys — closed as of #4001 批 9, and this class had NO other door
8991

‎packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
* `page-component-visibility-to-visibleWhen` rewrites at load, against
6262
* `visibleWhen`, the canonical spelling that converts nothing. (The pair used
6363
* to differ in the header's `description` against `subtitle`; that conversion,
64-
* `page-component-visibility-to-visibleWhen`, retired from the load path at protocol 18 and
64+
* `page-header-subtitle-alias`, retired from the load path at protocol 18 and
6565
* the fixture was re-pointed here, as the paragraph below prescribes.) So the pin discriminates on the
6666
* CONVERSION and not on "a page is present" — measured on the pair before it
6767
* was written: `visibility` → text `--strict` 1, `--json --strict` 1, `--json`

‎packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,13 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
401401
* [#21982] On the fallback path — no canonicalizer resolved, or it threw —
402402
* the gate judges the body in its ADR-0087 D2-converted spelling, for the
403403
* verdict only. The flow parse refuses an undeclared config key on the
404-
* builtin node types, so without the conversion a D2 spelling every other
405-
* door converts first (`os validate`, `defineStack`, `registerFlow`) would
406-
* be refused here, and only here: a verdict that depended on whether the
407-
* host runs an automation service. What is STORED stays the raw body.
404+
* builtin node types, so without the conversion a D2 spelling the
405+
* canonicalizer converts first (`registerFlow`'s full chain, retired
406+
* entries included) would be refused here, and only here: a verdict that
407+
* depended on whether the host runs an automation service. So the fallback
408+
* replays that same full chain (#23487) — for the seven flow spellings
409+
* retired from the authoring funnel at 18 and for those retired before
410+
* them alike. What is STORED stays the raw body.
408411
*/
409412
describe('[#21982] the fallback gate judges the D2-converted body, and stores the raw one', () => {
410413
const scriptBody = (config: Record<string, unknown>) => ({
@@ -430,6 +433,20 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
430433
expect(storedFlow(rows)!.body.nodes[0].config).toEqual({ functionName: 'helpdesk.triage' });
431434
});
432435

436+
it('an older-retired flow shape (`flow.active`) reaches the canonicalizer\'s verdict too — healed for the verdict, stored raw', async () => {
437+
// `flow-inert-keys-removed` (retired from the load path after 16.1.0)
438+
// tombstones `active`. On a host with no automation service the
439+
// fallback used to judge it in the authoring posture and refuse it
440+
// 422; the service-backed path replays the entry and heals it. Since
441+
// #23487 the fallback replays the same chain, so both hosts agree.
442+
const { protocol, rows } = makeProtocol(new Map());
443+
444+
const result = await save(protocol, { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false });
445+
446+
expect(result.success).toBe(true);
447+
expect(storedFlow(rows)!.body.active).toBe(false);
448+
});
449+
433450
it('an undeclared key on delete_record is refused 422 INVALID_METADATA at its path — the alias beside it is not', async () => {
434451
const { protocol, rows } = makeProtocol(new Map());
435452

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3247,6 +3247,22 @@ function isoFromValidDate(value: unknown): unknown {
32473247
return value;
32483248
}
32493249

3250+
/**
3251+
* ADR-0087 conversions the save door's flow fallback verdict (#21982) does
3252+
* not replay, by id — the same DEFAULT-FLIP refusal the automation engine's
3253+
* canonicalizer makes (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in
3254+
* `service-automation/src/engine.ts`), because the fallback stands in for that
3255+
* canonicalizer and must reach the same verdict on the same body.
3256+
*
3257+
* - `flow-decision-mode-inclusive-explicit` (#15429) rewrites an omitted
3258+
* decision `mode` into `'inclusive'`, which is sound only where "this body
3259+
* predates the flip" is a fact; a save never knows that, exactly as the
3260+
* engine's canonicalizer does not.
3261+
*/
3262+
const FLOW_FALLBACK_VERDICT_NOT_REPLAYED: readonly string[] = [
3263+
'flow-decision-mode-inclusive-explicit',
3264+
];
3265+
32503266
// Lifecycle columns the engine always owns; the clone path drops them by NAME
32513267
// so the insert re-stamps fresh values instead of copying the source's. Mirrors
32523268
// record-validator's SKIP_FIELDS (system-injected, never author-supplied).
@@ -22405,11 +22421,14 @@ export class ObjectStackProtocolImplementation implements
2240522421
// conversions applied (`applyConversionsToFlow`) — for the VERDICT
2240622422
// only: what is stored stays the raw request body, exactly as before.
2240722423
// The flow parse judges an undeclared config key on the builtin node
22408-
// types, so a D2 spelling the load path still rewrites (`filters`,
22424+
// types, so a D2 spelling the canonicalizer rewrites (`filters`,
2240922425
// script `functionName`, subflow `flow`, …) would otherwise be refused
22410-
// here and accepted at every converting door (`os validate`,
22411-
// `defineStack`, `registerFlow`): a verdict that depended on whether
22412-
// this host runs an automation service. No `reservedNodeTypes` (no
22426+
// here and accepted by `registerFlow`, which the canonicalizer is: a
22427+
// verdict that depended on whether this host runs an automation
22428+
// service. So the fallback replays the chain the canonicalizer replays
22429+
// — the FULL chain, retired entries included (#23487 retired those
22430+
// spellings from the authoring funnel, not from this seam), minus the
22431+
// same default flip ({@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED}). No `reservedNodeTypes` (no
2241322432
// engine here): a node-type rename's conflict guard keeps its one home
2241422433
// in the engine's canonicalizer, and this converted body is never
2241522434
// persisted. A key no conversion rewrites is judged exactly as written.
@@ -22484,7 +22503,12 @@ export class ObjectStackProtocolImplementation implements
2248422503
canonicalized = true;
2248522504
}
2248622505
}
22487-
if (!canonicalized) flowGateVerdictBody = applyConversionsToFlow(request.item);
22506+
if (!canonicalized) {
22507+
flowGateVerdictBody = applyConversionsToFlow(request.item, {
22508+
includeRetired: true,
22509+
excludeConversionIds: FLOW_FALLBACK_VERDICT_NOT_REPLAYED,
22510+
});
22511+
}
2248822512
}
2248922513

2249022514
// Spec-conformance check: if a Zod schema is registered for this

‎packages/services/service-automation/src/builtin/crud-config-aliases.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,19 @@ describe('CRUD config-key aliases: object→objectName + filters→filter (load-
9898
expect(calls[0].opts.where).toEqual({ id: 'L1' }); // filter preserved, not dropped
9999
});
100100

101-
it('the same conversions run on the build/validate seam too (normalizeStackInput)', async () => {
101+
it('the build/validate seam no longer converts them (retired at 18), and the rehydration seam still does', async () => {
102102
const engine = new AutomationEngine(silentLogger());
103103
const { data, calls } = fakeData();
104104
registerCrudNodes(engine, ctxWith(data, silentLogger()));
105105

106+
// Both conversions left the load path at 18 (#23487): the authoring
107+
// funnel hands the aliases on as written, for the node contract's
108+
// refusal to name the rename…
106109
const raw = getRecordFlow({ object: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' });
107-
const converted = (normalizeStackInput({ flows: [raw] }).flows as any[])[0];
108-
engine.registerFlow('gr', converted);
110+
const authored = (normalizeStackInput({ flows: [raw] }).flows as any[])[0];
111+
expect(authored.nodes[1].config).toEqual({ object: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' });
112+
// …while a flow that reaches the engine as data at rest still heals.
113+
engine.registerFlow('gr', authored);
109114
await engine.execute('gr', { userId: 'u1' });
110115

111116
expect(calls[0].obj).toBe('crm_lead');

‎packages/spec/src/automation/builtin-node-config.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', ()
7575
const message = unknownKeyMessage(schema, { ...base, object: 'lead' })!;
7676
expect(message).toContain(`this ${nodeType} node config`);
7777
expect(message).toContain('`objectName`');
78-
expect(message).toContain('flow-node-crud-object-alias');
78+
// The upgrade prescription since the conversion left the load path at 18.
79+
expect(message).toContain('`object` was removed in @objectstack/spec 18');
80+
expect(message).toContain('Use `objectName`. Rename the key');
7981
// The distance claim in the schema's comment, pinned: without the
8082
// curated entry this key would get NO suggestion at all.
8183
expect(message).not.toContain('`object` → ');
@@ -90,7 +92,8 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', ()
9092
'%s: prescribes `filter` for the retired `filters` spelling, and names the match-everything-write hazard',
9193
(_nodeType, schema, base) => {
9294
const message = unknownKeyMessage(schema, { ...base, filters: { status: 'stale' } })!;
93-
expect(message).toContain('flow-node-crud-filter-alias');
95+
expect(message).toContain('`filters` was removed in @objectstack/spec 18');
96+
expect(message).toContain('Use `filter`. Rename the key');
9497
expect(message).toContain('match-everything write');
9598
expect(message, 'the prescription names the hazard, never a tracker id').not.toMatch(/#\d{3,5}/);
9699
},
@@ -101,7 +104,7 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', ()
101104
// not declare. `create_record` is outside `flow-node-crud-filter-alias`'s
102105
// node-type set precisely because it has no filter.
103106
const message = unknownKeyMessage(CreateRecordConfigSchema, { objectName: 'task', filters: {} })!;
104-
expect(message).not.toContain('flow-node-crud-filter-alias');
107+
expect(message).not.toContain('Use `filter`');
105108
expect(message).not.toContain('`filter` (singular)');
106109
});
107110

@@ -412,7 +415,8 @@ describe('MapConfigSchema — an unknown key is refused, not stripped', () => {
412415
it('prescribes `flowName` for the undeclared `flow` fallback', () => {
413416
const message = unknownKeyMessage(MapConfigSchema, { collection: { dialect: 'cel', source: 'tasks' }, flowName: 'per_row', flow: 'ignored' })!;
414417
expect(message).toContain('this map node config');
415-
expect(message).toContain('flow-node-map-flow-alias');
418+
expect(message).toContain('`flow` was removed in @objectstack/spec 18');
419+
expect(message).toContain('Use `flowName`');
416420
expect(message).toMatch(/delete/i);
417421
});
418422

0 commit comments

Comments
 (0)