diff --git a/.changeset/23543-f5-http-callout-node-types-reserved.md b/.changeset/23543-f5-http-callout-node-types-reserved.md new file mode 100644 index 0000000000..5b61448214 --- /dev/null +++ b/.changeset/23543-f5-http-callout-node-types-reserved.md @@ -0,0 +1,44 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +--- + +feat(spec)!: the retired callout node types `http_request`, `http_call` and `webhook` are reserved inside the open flow-node namespace; `flow-node-http-callout-rename` leaves the load path + +Clause-②: yes (narrowing) + +**BREAKING (authoring):** ADR-0018 unified the three outbound callout node types `http_request`, `http_call` and `webhook` into the one `http` node, and through 17 the loader rewrote an authored node typed with one of them to `http` with a notice. A flow node's `type` is an open namespace (a plugin registers its own node types), so nothing refused the old names. At 18 they are reserved: the flow parse refuses each as a node's `type` by name, the automation engine refuses to register a node executor under one, and the ADR-0087 D2 conversion `flow-node-http-callout-rename` (protocol 11) is retired from the load path (`retiredAfter: '17.7.0'`). A wait node's `waitEventConfig.eventType: 'webhook'` is a different field and is not affected. + +## FROM → TO + +| you wrote (17.7 and earlier) | write instead | +| --- | --- | +| a flow node `type: 'http_request'` | `type: 'http'` | +| a flow node `type: 'http_call'` | `type: 'http'` | +| a flow node `type: 'webhook'` | `type: 'http'` | +| a plugin's `engine.registerNodeExecutor({ type: 'webhook', … })` (or either other name) | a type of your own, e.g. `'acme_webhook'` | + +**The one-line fix:** change the node's `type` to `'http'`; the node's `id`, `label` and `config` stay as they are (the conversion never touched `config`, so the `http` contract judges it as written). + +**What an author who still writes it sees.** The parse refuses the node at its own `type` path — a top-level node and a node inside a `loop`, `parallel` or `try_catch` region alike. `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate`, `os build` and `os lint` (whose config calls `defineStack`) print the issue, and the metadata save door answers a NEW flow with `422 INVALID_METADATA` at the node's `type`, with or without an automation service, and stores nothing: + +> Flow node `type: 'http_request'` was removed in @objectstack/spec 18 (ADR-0087 D2) — it is one of the three callout node types ADR-0018 unified into `http`, the loader rewrote it to `http` until this release, and the name stays reserved inside the open node namespace so no plugin can claim it (ADR-0018). Use `type: 'http'`. Change the value; the conversion never touched the node's `config`, so the `http` contract judges it as written. + +The refusal is a `pattern` on `FlowNodeSchema.type`, so the published JSON Schema carries it too. + +**`@objectstack/service-automation`:** `AutomationEngine.registerNodeExecutor` (and `registerNodeAlias`, which calls it) throws when the executor's `type` or its descriptor's `type` is one of the three names, and registers nothing. No node could ever reach such an executor: an authored node with the name is refused at parse, and a stored one is rewritten to `http` before it parses. A plugin that registered its own executor under one of these names fails at that call and needs a type of its own. + +**No tool makes the rename.** The conversion belongs to protocol 11, below the migration floor, so `objectstack migrate meta` applies no edit for it and `--write` writes none: it prints each such node among the refusals the migrated stack still has, at `flows.N.nodes.M.type`, and lists the D3 entry `flow-node-http-callout-types-reserved` as the hand edit. + +**Stored metadata needs no edit.** Stored-row rehydration, the automation engine's flow rehydration and the artifact-ingestion door inside its window still replay the conversion, so a `sys_metadata` flow row written with an old name keeps loading as an `http` node. The rewrite there is now unconditional: until 18 it skipped a node whose old name a live executor owned and reported a conflict, and the engine no longer lets such an executor exist. + +## The retirement kit + +- **The reserved-name table** `FLOW_RESERVED_NODE_TYPES` and its refusal `reservedFlowNodeTypeRefusal` (`@objectstack/spec/automation`), read by the flow parse, the engine's registration refusal and the conversion itself. +- **The conversion** is `retiredFromLoadPath: true`, `retiredAfter: '17.7.0'`; its ADR-0078 conflict guard is gone. +- **The D3 entry `flow-node-http-callout-types-reserved`.** +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` and `@objectstack/service-automation` are published, so this is breaking for consumers no telemetry was consulted for. + + diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 2144840b05..084d5c5b34 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -146,7 +146,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Node unique ID | -| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. | +| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. The retired callout names 'http_request', 'http_call' and 'webhook' are reserved and refused: use 'http'. | | **label** | `string` | ✅ | Node label | | **config** | `Record` | optional | Node configuration | | **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record }` | optional | | diff --git a/content/docs/references/automation/control-flow.mdx b/content/docs/references/automation/control-flow.mdx index fbef1bd26c..cd180fa93a 100644 --- a/content/docs/references/automation/control-flow.mdx +++ b/content/docs/references/automation/control-flow.mdx @@ -112,7 +112,7 @@ const result = FlowRegionSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Node unique ID | -| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. | +| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. The retired callout names 'http_request', 'http_call' and 'webhook' are reserved and refused: use 'http'. | | **label** | `string` | ✅ | Node label | | **config** | `Record` | optional | Node configuration | | **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record }` | optional | | @@ -175,7 +175,7 @@ const result = FlowRegionSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Node unique ID | -| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. | +| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. The retired callout names 'http_request', 'http_call' and 'webhook' are reserved and refused: use 'http'. | | **label** | `string` | ✅ | Node label | | **config** | `Record` | optional | Node configuration | | **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record }` | optional | | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index f21a90bb83..75652cf4ea 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -67,7 +67,7 @@ const result = FlowSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Node unique ID | -| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. | +| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. The retired callout names 'http_request', 'http_call' and 'webhook' are reserved and refused: use 'http'. | | **label** | `string` | ✅ | Node label | | **config** | `Record` | optional | Node configuration | | **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record }` | optional | | @@ -138,7 +138,7 @@ const result = FlowSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Node unique ID | -| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. | +| **type** | `string` | ✅ | Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. The retired callout names 'http_request', 'http_call' and 'webhook' are reserved and refused: use 'http'. | | **label** | `string` | ✅ | Node label | | **config** | `Record` | optional | Node configuration | | **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record }` | optional | | diff --git a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts index 625b3ad72c..bca34da64d 100644 --- a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts +++ b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts @@ -57,14 +57,18 @@ * the original divergence for exactly these configs with all of them green. * * The two fixtures below are a MINIMAL PAIR from one template, differing in a - * single value on one flow node: `type: 'http_request'`, the retired callout - * name `flow-node-http-callout-rename` rewrites at load, against `type: 'http'`, - * the canonical spelling that converts nothing. (The pair used to differ in - * the header's `description` against `subtitle`, then in a page component's - * `visibility` against `visibleWhen`; each of those conversions retired from - * the load path at protocol 18 — `page-header-subtitle-alias`, then - * `page-component-visibility-to-visibleWhen` with its key, #23543 F4 — and the - * fixture was re-pointed, as the paragraph below prescribes.) So the pin + * single value on one flow node: `type: 'probe_old_callout'`, which a TEST-ONLY + * live conversion rewrites at load, against `type: 'http'`, the canonical + * spelling that converts nothing. (The pair used to differ in the header's + * `description` against `subtitle`, then in a page component's `visibility` + * against `visibleWhen`, then in `type: 'http_request'` against `http`; each of + * those conversions retired from the load path at protocol 18 — + * `page-header-subtitle-alias`, `page-component-visibility-to-visibleWhen` with + * its key (#23543 F4), and `flow-node-http-callout-rename` with the reservation + * of its old names (#23543 F5-http), the last real entry left live. So the + * converting member is now a probe the child loads with `--import`, + * `test/fixtures/live-conversion-probe.mjs`, which appends one live entry to + * the table the producer reads and changes nothing else.) So the pin * discriminates on the CONVERSION and not on "a flow is present". The cells * below are the same as they were measured on the earlier pairs: the * converting member → text `--strict` 1, `--json --strict` 1, `--json` 0, @@ -82,14 +86,11 @@ * * The live-notice assertion (the payload entry, and the producer's one * stderr line) is the anti-vacuity guard, and it is load-bearing rather than - * decorative. `flow-node-http-callout-rename` is a LIVE - * window (the last one); the day it retires from the load path, - * this fixture raises nothing and, without that assertion, the file would keep - * passing while pinning an empty cell — precisely the failure this test exists - * to remove. It goes red instead, and whoever retires the entry re-points the - * fixture at another live conversion. (The obvious candidate for this fixture, - * `object-compactLayout-to-highlightFields`, is already `retiredFromLoadPath`: - * the schema tombstones the key, so it raises a validation ERROR, not a notice.) + * decorative: were the probe not loaded, this fixture would raise nothing and, + * without that assertion, the file would keep passing while pinning an empty + * cell — precisely the failure this test exists to remove. It goes red instead. + * (A real retired entry cannot stand in: its old spelling is refused by the + * schema, so it raises a validation ERROR, not a notice.) * * ## Why a real child process * @@ -125,7 +126,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; /** * The fixture projects are `defineStack` configs (`os validate` refuses any @@ -144,6 +145,16 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../../bin/run-dev.js'); const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx'); +/** + * The test-only live conversion the child loads with `--import` (its header + * says why): flow node `type: 'probe_old_callout'` → `'http'`. Spelled here + * rather than imported from `test/helpers/live-conversion-probe.ts` for the + * reason {@link linkSpec} is: this file lives under `src/`. + */ +const PROBE_HOOK = pathToFileURL(resolve(HERE, '../../test/fixtures/live-conversion-probe.mjs')).href; +const PROBE_ID = 'cli-live-conversion-probe'; +const PROBE_FROM = 'probe_old_callout'; + /** * Raises all four structural advisories at once and nothing else: no * `manifest`, no objects, no apps. The card's own measurement used this shape, @@ -184,7 +195,7 @@ export default defineStack({ * `normalized`, so what they see is the canonical `http` either way, and both * members are pinned to `warnings: []` below rather than assumed to be. */ -const calloutFlowSource = (calloutNodeType: 'http_request' | 'http'): string => ` +const calloutFlowSource = (calloutNodeType: typeof PROBE_FROM | 'http'): string => ` import { defineStack } from '@objectstack/spec'; export default defineStack({ @@ -221,7 +232,11 @@ function runCli(args: string[], cwd: string): Promise { execFile( TSX, [CLI, ...args], - { cwd, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, NO_COLOR: '1' } }, + { + cwd, + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, NO_COLOR: '1', NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import ${PROBE_HOOK}`].filter(Boolean).join(' ') }, + }, (err, stdout, stderr) => { resolvePromise({ code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, @@ -246,7 +261,7 @@ beforeAll(() => { writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE); linkSpec(cleanDir); conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-')); - writeFileSync(join(conversionsDir, 'objectstack.config.ts'), calloutFlowSource('http_request')); + writeFileSync(join(conversionsDir, 'objectstack.config.ts'), calloutFlowSource(PROBE_FROM)); linkSpec(conversionsDir); conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-')); writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), calloutFlowSource('http')); @@ -335,25 +350,23 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => 'the producer-applied conversion reaches the payload, once', ).toEqual([ { - conversionId: 'flow-node-http-callout-rename', + conversionId: PROBE_ID, path: 'flows[0].nodes[1].type', - from: 'http_request', + from: PROBE_FROM, to: 'http', }, ]); expect(typeof payload.conversions?.[0]?.retiresIn, 'the expiry rides the entry').toBe('number'); // The text face names what failed it, in its `⚠` block. - expect(text.stdout).toContain("conversion 'flow-node-http-callout-rename'"); + expect(text.stdout).toContain(`conversion '${PROBE_ID}'`); // Anti-vacuity AND the one-stderr-line property: the conversion is LIVE — // it fired, in the producer, once per run — and no door-side line repeats it - // on stderr. The day `flow-node-http-callout-rename` retires this goes red; - // re-point `calloutFlowSource` at a live entry in - // `packages/spec/src/conversions/registry.ts`. + // on stderr. Were the probe not loaded this goes red. for (const run of [text, json]) { expect( - run.stderr.split("conversion 'flow-node-http-callout-rename'").length - 1, + run.stderr.split(`conversion '${PROBE_ID}'`).length - 1, 'defineStack reported the conversion at load, on exactly one stderr line', ).toBe(1); } @@ -365,7 +378,7 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => const json = await runCli(['validate', '--json'], conversionsDir); expect(json.code, `--json without --strict must stay 0:\n${json.stdout}\n${json.stderr}`).toBe(0); const payload = JSON.parse(json.stdout) as { conversions?: Array<{ conversionId?: unknown }> }; - expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['flow-node-http-callout-rename']); + expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual([PROBE_ID]); }, 120_000); it('control: the same flow under the CANONICAL node type converts nothing and exits 0 on both faces', async () => { @@ -381,7 +394,7 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => expect(payload.warnings).toEqual([]); expect(payload.conversions).toEqual([]); // The discriminator's other half: the canonical type raises no notice anywhere. - expect(json.stderr).not.toContain("conversion 'flow-node-http-callout-rename'"); + expect(json.stderr).not.toContain(`conversion '${PROBE_ID}'`); }, 120_000); it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => { diff --git a/packages/cli/test/build-json-failure-conversions.e2e.test.ts b/packages/cli/test/build-json-failure-conversions.e2e.test.ts index d74b492511..cb14fdd1ee 100644 --- a/packages/cli/test/build-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/build-json-failure-conversions.e2e.test.ts @@ -58,8 +58,8 @@ * * ⭐ A pin asserting `'conversions' in payload` passes against a `conversions: * []` hard-coded at every exit. So every fixture drives a LIVE conversion - * (`flow-node-http-callout-rename`, ADR-0087 D2, protocol 11, on - * `flows[0].nodes[1].type: 'http_request'`) and each exit is asserted to carry exactly that one notice — no + * (a test-only probe loaded with `--import`, ADR-0087 D2 shape, on + * `flows[0].nodes[1].type: 'probe_old_callout'`) and each exit is asserted to carry exactly that one notice — no * fewer, and NO MORE, the array being asserted whole. `converts nothing` runs * the same exit with the canonical `type: 'http'` and requires `[]`, which is * the negative whose positive is every other test here. @@ -84,6 +84,7 @@ import { fileURLToPath } from 'node:url'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; import { childEnv } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; +import { LIVE_PROBE, liveProbeNodeOptions } from './helpers/live-conversion-probe.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -91,10 +92,13 @@ const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); const COMPILE_TS = resolve(HERE, '../src/commands/compile.ts'); /** - * The live ADR-0087 D2 conversion these fixtures drive (protocol 11). It was - * `page-kind-jsx-to-html` until `jsx` retired as a page kind at 18. + * The live ADR-0087 D2 conversion these fixtures drive: a TEST-ONLY probe the + * child loads with `--import` (`helpers/live-conversion-probe.ts`). It was a + * real entry before — `page-kind-jsx-to-html` until `jsx` retired as a page + * kind at 18, then `flow-node-http-callout-rename` until #23543 F5-http + * reserved its old names, the last real entry left live. */ -const CONVERSION_ID = 'flow-node-http-callout-rename'; +const CONVERSION_ID = LIVE_PROBE.conversionId; /** The FATAL capability token — `unavailable` whatever is installed. */ const FATAL_TOKEN = 'governance'; @@ -109,7 +113,7 @@ function runCli(args: string[], cwd: string): Promise { execFile( TSX, [CLI, ...args], - { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: liveProbeNodeOptions() }) }, (err, stdout, stderr) => { resolvePromise({ code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, @@ -135,7 +139,7 @@ function payloadOf(run: Run, label: string): Record { * shape with the CANONICAL spelling, where there is nothing to convert. */ function stack(ns: string, opts: { nodeType?: string; requires?: string[]; extraFields?: string; extraTop?: string } = {}): string { - const { nodeType = 'http_request', requires = [], extraFields = '', extraTop = '' } = opts; + const { nodeType = LIVE_PROBE.from as string, requires = [], extraFields = '', extraTop = '' } = opts; return ` import { defineStack } from '@objectstack/spec'; @@ -201,9 +205,9 @@ function conversionsOf(payload: Record): unknown[] { */ const THE_NOTICE = { conversionId: CONVERSION_ID, - surface: 'flow.node.type', - from: 'http_request', - to: 'http', + surface: LIVE_PROBE.surface, + from: LIVE_PROBE.from, + to: LIVE_PROBE.to, path: 'flows[0].nodes[1].type', }; @@ -317,7 +321,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(payload.success).toBe(true); expectTheOneNotice(payload, 'control', run); // The expiry rides the payload entry (`expectTheOneNotice`) and the producer's line. - expect(run.stderr).toMatch(/conversion 'flow-node-http-callout-rename', retires in protocol \d+\)/); + expect(run.stderr).toMatch(new RegExp(`conversion '${CONVERSION_ID}', retires in protocol \\d+\\)`)); }, 180_000); it('2b (--strict-body) — ⭐ carries the notice, where `warnings` is empty by construction', async () => { @@ -351,7 +355,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect('conversions' in payload, 'the field must be PRESENT even when empty').toBe(true); expect(payload.conversions, 'a canonical node type converts nothing').toEqual([]); // …and, since the producer is what converts now, raised no notice at load either. - expect(run.stderr).not.toContain("conversion 'flow-node-http-callout-rename'"); + expect(run.stderr).not.toContain(`conversion '${CONVERSION_ID}'`); }, 180_000); it('3b (author-time rules) — the notice rides the rule gate', async () => { diff --git a/packages/cli/test/fixtures/live-conversion-probe.mjs b/packages/cli/test/fixtures/live-conversion-probe.mjs new file mode 100644 index 0000000000..58f92eb372 --- /dev/null +++ b/packages/cli/test/fixtures/live-conversion-probe.mjs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A LIVE ADR-0087 D2 conversion for a spawned CLI — test-only, loaded with + * `node --import` (as `NODE_OPTIONS`), and never part of the published + * registry. + * + * The door suites that pin how `os validate` / `os build` / `os lint` report + * load-time conversions (`--json` `conversions`, `--strict`, the record a + * refusal carries) need a conversion that is LIVE on the load path. Until + * #23543 F5-http that was `flow-node-http-callout-rename`, the last real entry + * below 18 left live; it retired with the reservation of its old names, so no + * real entry is live any more. In-process suites append a probe with + * `vi.mock`; a spawned CLI is out of `vi.mock`'s reach, so this module does the + * same before the CLI's first line runs. + * + * It appends one conversion — flow node `type: 'probe_old_callout'` → `'http'` + * — to `ALL_CONVERSIONS`, the table `applyConversions` reads, in BOTH builds of + * `@objectstack/spec` the child can load: the CLI imports the ESM build, and a + * fixture's `defineStack` config can be loaded through `require`, the CJS + * build. The two are distinct module instances (measured: two arrays), and the + * pass that records a conversion runs in whichever one the config loaded. The + * specifier resolves from `packages/cli`, and pnpm's symlink realpaths to the + * same `packages/spec/dist` file a fixture's `node_modules` link reaches, so + * each build is the instance the CLI and the config share. + * + * Nothing else changes: every real entry stays as published, so a suite using + * this probe still meets every real refusal (the reserved `http_request` + * included). + */ +import { createRequire } from 'node:module'; + +import { ALL_CONVERSIONS as ESM_CONVERSIONS } from '@objectstack/spec'; + +export const PROBE_CONVERSION_ID = 'cli-live-conversion-probe'; +export const PROBE_OLD_TYPE = 'probe_old_callout'; + +const probe = { + id: PROBE_CONVERSION_ID, + toMajor: 18, + surface: 'flow.node.type (test probe)', + summary: `test-only probe: a live load-path rename, node type '${PROBE_OLD_TYPE}' → 'http'`, + apply(stack, emit) { + if (!Array.isArray(stack.flows)) return stack; + let changed = false; + const flows = stack.flows.map((flow, f) => { + if (!flow || !Array.isArray(flow.nodes)) return flow; + let hit = false; + const nodes = flow.nodes.map((node, n) => { + if (!node || node.type !== PROBE_OLD_TYPE) return node; + hit = true; + emit({ from: PROBE_OLD_TYPE, to: 'http', path: `flows[${f}].nodes[${n}].type` }); + return { ...node, type: 'http' }; + }); + if (!hit) return flow; + changed = true; + return { ...flow, nodes }; + }); + return changed ? { ...stack, flows } : stack; + }, + fixture: { before: {}, after: {}, expectedNotices: 0 }, +}; + +const cjs = createRequire(import.meta.url)('@objectstack/spec'); +for (const table of new Set([ESM_CONVERSIONS, cjs.ALL_CONVERSIONS])) { + if (!table.some((c) => c.id === PROBE_CONVERSION_ID)) table.push(probe); +} diff --git a/packages/cli/test/helpers/live-conversion-probe.ts b/packages/cli/test/helpers/live-conversion-probe.ts new file mode 100644 index 0000000000..dd726a576c --- /dev/null +++ b/packages/cli/test/helpers/live-conversion-probe.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A live ADR-0087 D2 conversion for the door suites that spawn the CLI — the + * `--import` preload `fixtures/live-conversion-probe.mjs` (its header says why + * it exists and what it touches) and the names a suite asserts on. + * + * Until #23543 F5-http these suites drove `flow-node-http-callout-rename`, the + * last real entry below 18 left live on the load path. It retired, so a fixture + * types its converting node {@link LIVE_PROBE.from} instead and the child runs + * with {@link liveProbeNodeOptions}. + */ +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); + +/** The probe's identity, site spelling and direction — what a notice carries. */ +export const LIVE_PROBE = { + conversionId: 'cli-live-conversion-probe', + surface: 'flow.node.type (test probe)', + from: 'probe_old_callout', + to: 'http', +} as const; + +/** The preload, as the URL `--import` takes. */ +export const LIVE_PROBE_HOOK = pathToFileURL(resolve(HERE, '../fixtures/live-conversion-probe.mjs')).href; + +/** + * `NODE_OPTIONS` for a child that must see the probe: whatever the runner was + * started with, plus the preload. Stated on every call that wants it; a child + * spawned without it sees only the published registry. + */ +export function liveProbeNodeOptions(): string { + return [process.env.NODE_OPTIONS, `--import ${LIVE_PROBE_HOOK}`].filter(Boolean).join(' '); +} diff --git a/packages/cli/test/lint-conversion-notices.e2e.test.ts b/packages/cli/test/lint-conversion-notices.e2e.test.ts index 27b18a7681..b5693c7423 100644 --- a/packages/cli/test/lint-conversion-notices.e2e.test.ts +++ b/packages/cli/test/lint-conversion-notices.e2e.test.ts @@ -48,8 +48,8 @@ * `conversions: []` hard-coded at the exit — the defect with a lid on it. So: * * - every positive fixture drives a LIVE conversion - * (`flow-node-http-callout-rename`, ADR-0087 D2, protocol 11, on - * `flows[0].nodes[1].type: 'http_request'`), and the notice is + * (a test-only probe loaded with `--import`, ADR-0087 D2 shape, on + * `flows[0].nodes[1].type: 'probe_old_callout'`), and the notice is * asserted by IDENTITY — `conversionId` plus the converted path — over the * WHOLE array, so "and NO MORE" is asserted too; * - `converts nothing` runs the identical shape with the canonical spelling @@ -78,16 +78,20 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { LIVE_PROBE, liveProbeNodeOptions } from './helpers/live-conversion-probe.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); /** - * The live ADR-0087 D2 conversion these fixtures drive (protocol 11). It was - * `page-kind-jsx-to-html` until `jsx` retired as a page kind at 18. + * The live ADR-0087 D2 conversion these fixtures drive: a TEST-ONLY probe the + * child loads with `--import` (`helpers/live-conversion-probe.ts`). It was a + * real entry before — `page-kind-jsx-to-html` until `jsx` retired as a page + * kind at 18, then `flow-node-http-callout-rename` until #23543 F5-http + * reserved its old names, the last real entry left live. */ -const CONVERSION_ID = 'flow-node-http-callout-rename'; +const CONVERSION_ID = LIVE_PROBE.conversionId; interface Run { code: number; @@ -100,7 +104,7 @@ function runCli(args: string[], cwd: string): Promise { execFile( TSX, [CLI, ...args], - { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: liveProbeNodeOptions() }) }, (err, stdout, stderr) => { resolvePromise({ code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, @@ -126,7 +130,7 @@ function payloadOf(run: Run, label: string): Record { * with the CANONICAL spelling, where there is nothing to convert. */ function stack(ns: string, opts: { nodeType?: string } = {}): string { - const { nodeType = 'http_request' } = opts; + const { nodeType = LIVE_PROBE.from as string } = opts; return ` export default { manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, @@ -169,9 +173,9 @@ function conversionsOf(payload: Record): unknown[] { */ const THE_NOTICE = { conversionId: CONVERSION_ID, - surface: 'flow.node.type', - from: 'http_request', - to: 'http', + surface: LIVE_PROBE.surface, + from: LIVE_PROBE.from, + to: LIVE_PROBE.to, path: 'flows[0].nodes[1].type', }; @@ -249,7 +253,7 @@ describe('#12297 — `os lint` surfaces ADR-0087 conversion notices in both face const run = await runCli(['lint'], dirs.converts); expect(run.stdout, 'the converted path').toContain('flows[0].nodes[1].type'); expect(run.stdout, 'the conversion identity').toContain(CONVERSION_ID); - expect(run.stdout, 'the from → to rewrite').toMatch(/'http_request'.*'http'/); + expect(run.stdout, 'the from → to rewrite').toMatch(new RegExp(`'${LIVE_PROBE.from}'.*'http'`)); expect(run.stdout, 'the EXPIRY — the part that makes it actionable').toMatch(/retires in protocol \d+/); }, 120_000); diff --git a/packages/cli/test/stack-conversion-record-door.test.ts b/packages/cli/test/stack-conversion-record-door.test.ts index 655d197d46..67d0548400 100644 --- a/packages/cli/test/stack-conversion-record-door.test.ts +++ b/packages/cli/test/stack-conversion-record-door.test.ts @@ -59,14 +59,16 @@ * needed no conversion answers `[]`, so an exit that reports the notice without * reading it off the refusal it caught is red there. * - * `flow-node-http-callout-rename` is the live conversion driven here (a flow - * node typed `http_request`, canonical `http`; it was the header's - * `description` → `subtitle`, then a page component's `visibility` → - * `visibleWhen`, until each entry retired from the load path at protocol 18 — - * the second with its key, #23543 F4). It is the last live entry. The day it - * retires from - * the load path the non-empty rows go red; re-point the fixture at a live entry - * in `packages/spec/src/conversions/registry.ts`. + * The live conversion driven here is a TEST-ONLY probe the child loads with + * `--import` (`helpers/live-conversion-probe.ts`): a flow node typed + * `probe_old_callout`, canonical `http`. It was a real entry each time before — + * the header's `description` → `subtitle`, then a page component's + * `visibility` → `visibleWhen`, then the callout rename `http_request` → + * `http` — until each retired from the load path at protocol 18 (the last with + * the reservation of its old names, #23543 F5-http), and no real entry is live + * any more. The probe keeps the rows about the door's record, not about + * whichever entry happens to be live. One case runs WITHOUT it: the retired + * `http_request` spelling, refused at `os validate` with nothing to record. * * The CLI runs from source (`bin/run-dev.js`), but `@objectstack/spec` — the * producer and the record's reader — resolves through `exports` to its @@ -80,6 +82,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; +import { LIVE_PROBE, liveProbeNodeOptions } from './helpers/live-conversion-probe.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -91,12 +94,16 @@ interface Run { stderr: string; } -function runCli(args: string[], cwd: string): Promise { +function runCli(args: string[], cwd: string, probe = true): Promise { return new Promise((resolvePromise) => { execFile( TSX, [CLI, ...args], - { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + { + cwd, + maxBuffer: 16 * 1024 * 1024, + env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: probe ? liveProbeNodeOptions() : process.env.NODE_OPTIONS }), + }, (err, stdout, stderr) => { resolvePromise({ code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, @@ -126,10 +133,10 @@ function payloadOf(run: Run, label: string): Payload { /** The one entry every non-empty row must carry, by identity, site and direction. */ const THE_NOTICE = { code: 'OS_METADATA_CONVERTED', - conversionId: 'flow-node-http-callout-rename', + conversionId: LIVE_PROBE.conversionId as string, path: 'flows[0].nodes[1].type', - from: 'http_request', - to: 'http', + from: LIVE_PROBE.from as string, + to: LIVE_PROBE.to as string, }; function expectExactly(payload: Payload, expected: Array, label: string): void { @@ -145,7 +152,9 @@ function expectExactly(payload: Payload, expected: Array, lab const manifest = (ns: string) => `{ id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }`; -const flow = (ns: string, nodeType: 'http_request' | 'http') => `{ +type NodeType = typeof LIVE_PROBE.from | 'http_request' | 'http'; + +const flow = (ns: string, nodeType: NodeType) => `{ name: '${ns}_notify', label: 'Notify', type: 'autolaunched', @@ -157,7 +166,7 @@ const flow = (ns: string, nodeType: 'http_request' | 'http') => `{ edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }], }`; -const stackBody = (ns: string, nodeType: 'http_request' | 'http' | null, requires?: string) => `{ +const stackBody = (ns: string, nodeType: NodeType | null, requires?: string) => `{ manifest: ${manifest(ns)}, objects: [{ name: '${ns}_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], apps: [{ name: '${ns}_app', label: 'App' }],${nodeType ? `\n flows: [${flow(ns, nodeType)}],` : ''}${requires ? `\n requires: ['${requires}'],` : ''} @@ -167,32 +176,35 @@ const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\ const FIXTURES: Record = { // The plain case: one `defineStack` default export, no named export. - plain: IMPORT + `export default defineStack(${stackBody('pln', 'http_request')});\n`, + plain: IMPORT + `export default defineStack(${stackBody('pln', LIVE_PROBE.from)});\n`, // The record, across `loadConfig`'s named-export spread. recordAcrossSpread: IMPORT + `export const onEnable = async () => {};\n\n` + - `export default defineStack(${stackBody('rec', 'http_request')});\n`, + `export default defineStack(${stackBody('rec', LIVE_PROBE.from)});\n`, // The record of a composed stack: its inputs' records. composed: IMPORT + - `const withIt = defineStack(${stackBody('cmpa', 'http_request')});\n` + + `const withIt = defineStack(${stackBody('cmpa', LIVE_PROBE.from)});\n` + `const without = defineStack({ manifest: ${manifest('cmpb')}, objects: [{ name: 'cmpb_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }] });\n\n` + `export default composeStacks([withIt, without]);\n`, // A key merged after the producer ran: only the door's own pass sees it. namedExportPass: IMPORT + - `export const flows = [${flow('nep', 'http_request')}];\n\n` + + `export const flows = [${flow('nep', LIVE_PROBE.from)}];\n\n` + `export default defineStack(${stackBody('nep', null)});\n`, // The control: the canonical spelling. canonical: IMPORT + `export default defineStack(${stackBody('can', 'http')});\n`, // Convert, then refuse: the strict (default) `defineStack` converts the // retiring spelling, then refuses an unknown capability token. convertThenRefuse: - IMPORT + `export default defineStack(${stackBody('ctr', 'http_request', 'no-such-capability')});\n`, + IMPORT + `export default defineStack(${stackBody('ctr', LIVE_PROBE.from, 'no-such-capability')});\n`, // Its control: the same refusal from a source that needed no conversion. canonicalThenRefuse: IMPORT + `export default defineStack(${stackBody('ctc', 'http', 'no-such-capability')});\n`, + // The real retired spelling, run WITHOUT the probe: no entry converts it + // since #23543 F5-http, so the producer refuses it by name. + reservedName: IMPORT + `export default defineStack(${stackBody('rsv', 'http_request')});\n`, }; let root = ''; @@ -240,7 +252,7 @@ for (const command of ['validate', 'build'] as const) { expect(run.code, run.stdout + run.stderr).toBe(0); expectExactly(p, [THE_NOTICE], `${command} namedExportPass`); // The producer never saw that key, so it printed nothing for it. - expect(run.stderr).not.toContain("conversion 'flow-node-http-callout-rename'"); + expect(run.stderr).not.toContain(`conversion '${LIVE_PROBE.conversionId}'`); }, 180_000); it('control: the canonical spelling converts nothing — `[]`', async () => { @@ -259,7 +271,7 @@ describe("os lint --json — the producer's conversion record reaches `conversio // and still converts through the pass (`lint-conversion-notices.e2e.test.ts` // pins that half on a plain object-literal export). const lint = (label: string) => runCli(['lint', '--json'], dirs[label]); - const producerLine = "conversion 'flow-node-http-callout-rename'"; + const producerLine = `conversion '${LIVE_PROBE.conversionId}'`; it('the plain case: one `defineStack` default export carrying the retiring spelling', async () => { const run = await lint('plain'); @@ -341,7 +353,7 @@ describe('os validate --strict — a conversion the producer applied fails it, o const json = await runCli(['validate', '--json', '--strict'], dirs.recordAcrossSpread); expect(text.code, text.stdout + text.stderr).toBe(1); expect(text.stdout).toContain('Strict mode: warnings treated as errors'); - expect(text.stdout).toContain("conversion 'flow-node-http-callout-rename'"); + expect(text.stdout).toContain(`conversion '${LIVE_PROBE.conversionId}'`); expect(json.code, json.stdout + json.stderr).toBe(1); const p = payloadOf(json, 'validate --json --strict'); expect(p.valid).toBe(true); @@ -356,3 +368,16 @@ describe('os validate --strict — a conversion the producer applied fails it, o expect(json.code, json.stdout + json.stderr).toBe(0); }, 180_000); }); + +describe('the retired callout spelling, with only the published registry (#23543 F5-http)', () => { + it('os validate --json refuses `http_request` by name, naming http, and records no conversion', async () => { + const run = await runCli(['validate', '--json'], dirs.reservedName, false); + expect(run.code, run.stdout + run.stderr).toBe(1); + const p = payloadOf(run, 'validate reservedName') as Payload & { code?: unknown; error?: unknown }; + expect(p.valid).toBe(false); + expect(p.code).toBe('STACK_SCHEMA_INVALID'); + expect(String(p.error)).toContain("Flow node `type: 'http_request'` was removed in @objectstack/spec 18"); + expect(String(p.error)).toContain("Use `type: 'http'`"); + expectExactly(p, [], 'validate reservedName'); + }, 180_000); +}); diff --git a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts index 8aff1c6503..c1a6f7e554 100644 --- a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts @@ -66,8 +66,8 @@ * * ⭐ A pin asserting `'conversions' in payload` passes against a `conversions: * []` hard-coded at every exit — the defect with a lid on it. So every fixture - * here drives a LIVE conversion (`flow-node-http-callout-rename`, ADR-0087 D2, - * protocol 11, on `flows[0].nodes[1].type: 'http_request'`) and each exit is asserted to carry exactly the + * here drives a LIVE conversion (a test-only probe loaded with `--import`, ADR-0087 D2 + * shape, on `flows[0].nodes[1].type: 'probe_old_callout'`) and each exit is asserted to carry exactly the * notice the run had computed by then — no fewer, and NO MORE: * * exit | conversions @@ -111,6 +111,7 @@ import { fileURLToPath } from 'node:url'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; import { childEnv } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; +import { LIVE_PROBE, liveProbeNodeOptions } from './helpers/live-conversion-probe.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -118,10 +119,13 @@ const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); const VALIDATE_TS = resolve(HERE, '../src/commands/validate.ts'); /** - * The live ADR-0087 D2 conversion these fixtures drive (protocol 11). It was - * `page-kind-jsx-to-html` until `jsx` retired as a page kind at 18. + * The live ADR-0087 D2 conversion these fixtures drive: a TEST-ONLY probe the + * child loads with `--import` (`helpers/live-conversion-probe.ts`). It was a + * real entry before — `page-kind-jsx-to-html` until `jsx` retired as a page + * kind at 18, then `flow-node-http-callout-rename` until #23543 F5-http + * reserved its old names, the last real entry left live. */ -const CONVERSION_ID = 'flow-node-http-callout-rename'; +const CONVERSION_ID = LIVE_PROBE.conversionId; /** The FATAL capability token — `{package: null, edition: 'cloud'}` in the spec * registry, so it classifies `unavailable` whatever is installed. */ const FATAL_TOKEN = 'governance'; @@ -137,7 +141,7 @@ function runCli(args: string[], cwd: string): Promise { execFile( TSX, [CLI, ...args], - { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: liveProbeNodeOptions() }) }, (err, stdout, stderr) => { resolvePromise({ code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, @@ -163,7 +167,7 @@ function payloadOf(run: Run, label: string): Record { * shape with the CANONICAL spelling, where there is nothing to convert. */ function stack(ns: string, opts: { nodeType?: string; requires?: string[]; extraFields?: string } = {}): string { - const { nodeType = 'http_request', requires = [], extraFields = '' } = opts; + const { nodeType = LIVE_PROBE.from as string, requires = [], extraFields = '' } = opts; return ` import { defineStack } from '@objectstack/spec'; @@ -216,9 +220,9 @@ function conversionsOf(payload: Record): unknown[] { */ const THE_NOTICE = { conversionId: CONVERSION_ID, - surface: 'flow.node.type', - from: 'http_request', - to: 'http', + surface: LIVE_PROBE.surface, + from: LIVE_PROBE.from, + to: LIVE_PROBE.to, path: 'flows[0].nodes[1].type', }; @@ -321,7 +325,7 @@ describe('#12125 — every `os validate --json` failure exit carries the convers expectTheOneNotice(payload, 'control', run); // The expiry is the reason this field cannot just be dropped into prose: // it rides the payload entry (`expectTheOneNotice`) and the producer's line. - expect(run.stderr).toMatch(/conversion 'flow-node-http-callout-rename', retires in protocol \d+\)/); + expect(run.stderr).toMatch(new RegExp(`conversion '${CONVERSION_ID}', retires in protocol \\d+\\)`)); }, 120_000); it('parse failure — THE HEADLINE: the notice computed at step 2 survives the schema error', async () => { @@ -344,7 +348,7 @@ describe('#12125 — every `os validate --json` failure exit carries the convers expect('conversions' in payload, 'the field must be PRESENT even when empty').toBe(true); expect(payload.conversions, 'a canonical node type converts nothing').toEqual([]); // …and, since the producer is what converts now, raised no notice at load either. - expect(run.stderr).not.toContain("conversion 'flow-node-http-callout-rename'"); + expect(run.stderr).not.toContain(`conversion '${CONVERSION_ID}'`); }, 120_000); it('rule errors — the notice rides the author-time gate', async () => { diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 9746e66019..07f3a6fdd2 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -17,7 +17,7 @@ */ import { describe, it, expect } from 'vitest'; -import { ALL_CONVERSIONS, ObjectStackDefinitionSchema, applyConversions, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; +import { ALL_CONVERSIONS, ObjectStackDefinitionSchema, applyConversions, applyConversionsToStoredItem, type ConversionNotice, type MetadataConversion } from '@objectstack/spec'; import { applyArtifactForwardConversions, parseRangeFloor, @@ -441,14 +441,65 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub /** * ⭐ FIRING CONTROL 1 — a NON-RETIRED conversion still fires in the subject's - * own window. `flow-node-http-callout-rename` is not retired, so a door that - * had stopped converting anything would fail here. (The subject was - * `page-kind-jsx-to-html` until `jsx` retired as a page kind at 18, then - * `page-component-visibility-to-visibleWhen` until that key retired too, - * #23543 F4. The callout rename is the last live entry, and the family that - * retires it inherits this control.) + * own window, so a door that had stopped converting anything would fail + * here. (The subject was `page-kind-jsx-to-html` until `jsx` retired as a + * page kind at 18, then `page-component-visibility-to-visibleWhen` until that + * key retired too, #23543 F4, then `flow-node-http-callout-rename` until + * #23543 F5-http reserved the callout names — the last real entry left live.) + * No real entry is live now, so the subject is a test-only flow-node type + * rename appended, for this case alone, to the very table the door reads + * (`ALL_CONVERSIONS` from `@objectstack/spec`, the instance this package + * imports) and removed again in `finally`. The door itself is the real one. */ it('still applies a non-retired conversion in that same window', () => { + const probe: MetadataConversion = { + id: 'artifact-forward-live-probe', + toMajor: 18, + surface: 'flow.node.type (test probe)', + summary: "test-only probe: a live load-path rename, node type 'probe_old_callout' → 'http'", + apply(stack, emit) { + const flows = stack.flows as Array<{ nodes: Array> }> | undefined; + if (!Array.isArray(flows) || !flows.some((f) => f.nodes.some((n) => n.type === 'probe_old_callout'))) return stack; + return { + ...stack, + flows: flows.map((flow, f) => ({ + ...flow, + nodes: flow.nodes.map((node, n) => { + if (node.type !== 'probe_old_callout') return node; + emit({ from: 'probe_old_callout', to: 'http', path: `flows[${f}].nodes[${n}].type` }); + return { ...node, type: 'http' }; + }), + })), + }; + }, + fixture: { before: {}, after: {}, expectedNotices: 0 }, + }; + const table = ALL_CONVERSIONS as MetadataConversion[]; + expect(table.some((c) => !c.retiredFromLoadPath), 'premise: no real entry is live, so the probe is the subject').toBe(false); + table.push(probe); + try { + const def = { + ...hiddenAppDefinition('^17.0.0'), + flows: [{ name: 'notify', nodes: [{ id: 'n1', type: 'probe_old_callout', config: { url: 'https://example.com' } }] }], + }; + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + expect(result.verdict).toBe('converted-forward'); + expect(result.notices.map((n) => n.conversionId)).toContain('artifact-forward-live-probe'); + const converted = (result.definition as { flows: { nodes: Record[] }[] }).flows[0]!.nodes[0]!; + expect(converted.type).toBe('http'); + // …and the app in the SAME artifact still keeps its authored key. + expect((result.definition as { apps: Record[] }).apps[0]!.hidden).toBe(true); + } finally { + table.splice(table.indexOf(probe), 1); + } + }); + + /** + * [#23543 F5-http] The retired callout rename still fires at this door + * inside its window: an artifact built against 17 that carries an old + * callout node keeps loading, as `http`. + */ + it('still replays the retired callout rename in that same window', () => { const def = { ...hiddenAppDefinition('^17.0.0'), flows: [{ name: 'notify', nodes: [{ id: 'n1', type: 'http_request', config: { url: 'https://example.com' } }] }], @@ -456,10 +507,7 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); expect(result.verdict).toBe('converted-forward'); expect(result.notices.map((n) => n.conversionId)).toContain('flow-node-http-callout-rename'); - const converted = (result.definition as { flows: { nodes: Record[] }[] }).flows[0]!.nodes[0]!; - expect(converted.type).toBe('http'); - // …and the app in the SAME artifact still keeps its authored key. - expect((result.definition as { apps: Record[] }).apps[0]!.hidden).toBe(true); + expect((result.definition as { flows: { nodes: Record[] }[] }).flows[0]!.nodes[0]!.type).toBe('http'); }); /** diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index e24f546ea6..f304e134a2 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -334,7 +334,9 @@ describe('HttpDispatcher', () => { getSuspendedScreen: vi.fn().mockResolvedValue({ nodeId: 'collect', fields: [] }), getActionDescriptors: vi.fn().mockReturnValue([ { type: 'decision', name: 'Decision', category: 'logic', paradigms: ['flow'], source: 'builtin' }, - { type: 'http_request', name: 'HTTP Request', category: 'io', paradigms: ['flow', 'approval'], source: 'builtin' }, + // The canonical callout: `http_request` is a reserved retired name + // since 18 (#23543 F5-http), so no registry can list it. + { type: 'http', name: 'HTTP', category: 'io', paradigms: ['flow', 'approval'], source: 'builtin' }, { type: 'send_sms', name: 'Send SMS', category: 'io', paradigms: ['flow'], source: 'plugin' }, ]), // [#4127] Typed as `ConnectorDescriptor[]` now that the contract @@ -757,7 +759,7 @@ describe('HttpDispatcher', () => { expect(mockAutomationService.getActionDescriptors).toHaveBeenCalled(); expect(result.response?.body?.data?.total).toBe(3); expect(result.response?.body?.data?.actions.map((a: any) => a.type)).toEqual( - ['decision', 'http_request', 'send_sms'], + ['decision', 'http', 'send_sms'], ); }); @@ -780,7 +782,7 @@ describe('HttpDispatcher', () => { const result = await dispatcher.handleAutomation('actions', 'GET', {}, AUTHED_CALLER(), { paradigm: 'approval' }); expect(result.handled).toBe(true); expect(result.response?.body?.data?.total).toBe(1); - expect(result.response?.body?.data?.actions[0].type).toBe('http_request'); + expect(result.response?.body?.data?.actions[0].type).toBe('http'); }); it('should return an empty registry when the service lacks getActionDescriptors', async () => { diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 218cccea47..9b7b8def9b 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -23,7 +23,7 @@ import { import type { Logger } from '@objectstack/spec/contracts'; import type { FlowUnboundCause } from '@objectstack/spec/contracts'; import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js'; -import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor, DecisionConfigSchema } from '@objectstack/spec/automation'; +import { FlowSchema, FLOW_RESERVED_NODE_TYPES, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor, DecisionConfigSchema } from '@objectstack/spec/automation'; // [#14328] The ONE answer to "which trigger kind does this flow ask for?" — // shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s // `validate-flow-trigger-readiness`, so the runtime cannot drift from what @@ -3486,8 +3486,29 @@ export class AutomationEngine implements IAutomationService { // ── Plugin Extension API ────────────────────────────── - /** Register a node executor (called by plugins) */ + /** + * Register a node executor (called by plugins). + * + * [#23543 F5-http] Refuses a type — the executor's or its descriptor's — + * that is one of the retired callout names reserved inside the open node + * namespace ({@link FLOW_RESERVED_NODE_TYPES}). No node could ever reach + * such an executor: the flow parse refuses an authored node typed with the + * name, and flow rehydration rewrites a stored one to `http` before it + * parses. Refused loudly here, at the plugin's own registration call, so + * the author hears it at boot instead of shipping a node type that is dead + * on arrival. Nothing is registered on a refusal. + */ registerNodeExecutor(executor: NodeExecutor): void { + for (const type of new Set([executor.type, executor.descriptor?.type])) { + if (typeof type !== 'string' || !Object.prototype.hasOwnProperty.call(FLOW_RESERVED_NODE_TYPES, type)) continue; + throw new Error( + `Node executor type '${type}' is reserved: it is a retired official flow node name, superseded by ` + + `'${FLOW_RESERVED_NODE_TYPES[type]}' (@objectstack/spec 18, ADR-0018). No node can reach an executor ` + + `registered under it — the flow parse refuses an authored node typed '${type}', and a stored one is ` + + `rewritten to '${FLOW_RESERVED_NODE_TYPES[type]}' before it parses. Register the executor under a ` + + `type of your own (for example a namespaced 'acme_${type}').`, + ); + } if (this.nodeExecutors.has(executor.type)) { this.logger.warn(`Node executor '${executor.type}' replaced`); } @@ -3582,9 +3603,11 @@ export class AutomationEngine implements IAutomationService { * `aliasOf` so the designer palette can hide or mark it while the canonical * type is the one offered for new authoring. * - * This is how ADR-0018 collapses the five outbound verbs onto `http` / - * `notify`: `http_request` / `http_call` / `webhook` become aliases of - * `http`. + * ADR-0018 M3 first collapsed the outbound verbs onto `http` / `notify` + * this way: `http_request` / `http_call` / `webhook` were aliases of `http` + * until 11.0 removed them. Since 18 those three names are reserved + * (#23543 F5-http), so {@link registerNodeExecutor} refuses an alias under + * one of them too. */ registerNodeAlias( alias: string, @@ -4558,9 +4581,10 @@ export class AutomationEngine implements IAutomationService { // execution, so the executor only ever sees the canonical shape and a // dropped-alias upgrade never silently changes behavior (e.g. an empty // `filter` deleting a whole table). `reservedNodeTypes` is this engine's - // live executor registry: an open-namespace node-type rename over a type - // a custom executor owns becomes a loud, refused conflict — never a - // silent clobber of the third party's node. + // live executor registry, handed to any open-namespace node-type rename + // that guards against clobbering a live owner. The one such rename, the + // callout rename, no longer consults it (#23543 F5-http): its old names + // are reserved, and `registerNodeExecutor` refuses an executor under one. // // `includeRetired` (#3903): this seam rehydrates DATA AT REST — flows // stored in `sys_metadata` outlive any load window, and a row written diff --git a/packages/services/service-automation/src/flow-load-conversion.test.ts b/packages/services/service-automation/src/flow-load-conversion.test.ts index 2dbadc3c4b..f7f522a6ac 100644 --- a/packages/services/service-automation/src/flow-load-conversion.test.ts +++ b/packages/services/service-automation/src/flow-load-conversion.test.ts @@ -6,9 +6,12 @@ * flow authored against an old protocol shape keeps running with zero action, * and dropping a deprecated executor alias never silently changes behavior. * - * Open-namespace node-type renames (`webhook`/`http_request`/`http_call` → `http`) - * are conflict-aware: if a live custom executor owns the retired name, the rename - * is refused and a loud diagnostic is logged instead of clobbering the node. + * The retired callout node types (`webhook`/`http_request`/`http_call` → `http`) + * are reserved since 18 (#23543 F5-http): the authoring doors refuse them, and + * this engine refuses to register an executor under one. So a stored flow that + * still carries one is rewritten to `http` unconditionally — until 18 the + * rename skipped a node whose old name a live custom executor owned, and that + * owner can no longer exist. */ import { describe, it, expect } from 'vitest'; import { AutomationEngine } from './engine.js'; @@ -55,25 +58,64 @@ describe('registerFlow canonicalizes stored flows (ADR-0087 D2 runtime seam)', ( expect(warns.some((w) => w.includes('OS_METADATA_CONVERTED') || w.includes('→'))).toBe(false); }); - it('refuses to rewrite a retired name a live custom executor owns, logging a conflict', async () => { + it('rewrites every reserved callout name in a stored flow, with no conflict', async () => { const warns: string[] = []; const engine = new AutomationEngine(collectingLogger(warns)); - - // A third party registers a custom node under the retired official name. - let ran = false; - engine.registerNodeExecutor({ - type: 'webhook', - async execute() { ran = true; return { success: true, output: {} }; }, + engine.registerFlow('f', { + name: 'f', label: 'F', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'n', type: 'webhook', label: 'N', config: { url: 'https://x' } }, + { id: 'm', type: 'http_call', label: 'M', config: { url: 'https://y' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'n' }, + { id: 'e2', source: 'n', target: 'm' }, + { id: 'e3', source: 'm', target: 'end' }, + ], }); - engine.registerFlow('f', flowWith({ id: 'n', type: 'webhook', config: {} })); - const stored = await engine.getFlow('f'); - // NOT clobbered to `http` — the custom node survives. - expect(stored?.nodes.find((n) => n.id === 'n')?.type).toBe('webhook'); - expect(warns.some((w) => w.includes('OS_METADATA_CONVERSION_CONFLICT'))).toBe(true); + expect(stored?.nodes.filter((n) => n.id === 'n' || n.id === 'm').map((n) => n.type)).toEqual(['http', 'http']); + expect(warns.some((w) => w.includes('OS_METADATA_CONVERSION_CONFLICT'))).toBe(false); + }); +}); + +describe('registerNodeExecutor refuses the reserved callout names (#23543 F5-http)', () => { + it.each(['http_request', 'http_call', 'webhook'])("refuses an executor typed '%s', naming http, and registers nothing", (type) => { + const engine = new AutomationEngine(collectingLogger([])); + const before = engine.getRegisteredNodeTypes(); + expect(() => engine.registerNodeExecutor({ + type, + async execute() { return { success: true, output: {} }; }, + })).toThrow(new RegExp(`^Node executor type '${type}' is reserved: it is a retired official flow node name, superseded by 'http'`)); + expect(engine.getRegisteredNodeTypes()).toEqual(before); + expect(engine.getActionDescriptor(type)).toBeUndefined(); + }); + + it('refuses a descriptor published under a reserved name beside a type of its own', () => { + const engine = new AutomationEngine(collectingLogger([])); + expect(() => engine.registerNodeExecutor({ + type: 'acme_callout', + descriptor: { type: 'webhook', version: '1.0.0', name: 'Webhook' } as never, + async execute() { return { success: true, output: {} }; }, + })).toThrow(/^Node executor type 'webhook' is reserved/); + expect(engine.getRegisteredNodeTypes()).not.toContain('acme_callout'); + expect(engine.getActionDescriptor('webhook')).toBeUndefined(); + }); + + it('refuses a deprecated alias under a reserved name too', () => { + const engine = new AutomationEngine(collectingLogger([])); + expect(() => engine.registerNodeAlias('http_request', 'http')).toThrow(/^Node executor type 'http_request' is reserved/); + expect(engine.getRegisteredNodeTypes()).not.toContain('http_request'); + }); - await engine.execute('f'); - expect(ran).toBe(true); // the custom executor actually ran + it('CONTROL — a plugin type of its own registers, including one that merely contains a reserved name', () => { + const engine = new AutomationEngine(collectingLogger([])); + for (const type of ['acme_webhook', 'http_request_v2']) { + engine.registerNodeExecutor({ type, async execute() { return { success: true, output: {} }; } }); + expect(engine.getRegisteredNodeTypes()).toContain(type); + } }); }); diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 9b21a98491..1103c92fa7 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -115,6 +115,7 @@ "FLOW_REGION_CONFIG_KEYS (const)", "FLOW_REGION_SLOTS (const)", "FLOW_REGION_SLOTS_BY_TYPE (const)", + "FLOW_RESERVED_NODE_TYPES (const)", "FLOW_SLOT_REFUSAL_CODES (const)", "FLOW_STRUCTURAL_NODE_TYPES (const)", "FLOW_TRIGGER_KINDS (const)", @@ -305,6 +306,7 @@ "normalizeFlowFunctionEntry (function)", "parseFlowNodeRegions (function)", "predicateSlotRefusal (function)", + "reservedFlowNodeTypeRefusal (function)", "resolveFlowNodeExpressions (function)", "resolveFlowNodeValueSlots (function)", "resolveFlowTriggerKind (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 5626398aa7..4abe3af59b 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -111,6 +111,7 @@ "FLOW_REGION_CONFIG_KEYS": "src/automation/region-slots.ts#FLOW_REGION_CONFIG_KEYS (const)", "FLOW_REGION_SLOTS": "src/automation/region-slots.ts#FLOW_REGION_SLOTS (const)", "FLOW_REGION_SLOTS_BY_TYPE": "src/automation/region-slots.ts#FLOW_REGION_SLOTS_BY_TYPE (const)", + "FLOW_RESERVED_NODE_TYPES": "src/automation/flow-reserved-node-types.ts#FLOW_RESERVED_NODE_TYPES (const)", "FLOW_SLOT_REFUSAL_CODES": "src/automation/flow-node-expression-paths.ts#FLOW_SLOT_REFUSAL_CODES (const)", "FLOW_STRUCTURAL_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_STRUCTURAL_NODE_TYPES (const)", "FLOW_TRIGGER_KINDS": "src/automation/flow-trigger-kind.ts#FLOW_TRIGGER_KINDS (const)", @@ -299,6 +300,7 @@ "normalizeFlowFunctionEntry": "src/automation/flow-function.zod.ts#normalizeFlowFunctionEntry (function)", "parseFlowNodeRegions": "src/automation/control-flow.zod.ts#parseFlowNodeRegions (function)", "predicateSlotRefusal": "src/automation/flow-node-expression-paths.ts#predicateSlotRefusal (function)", + "reservedFlowNodeTypeRefusal": "src/automation/flow-reserved-node-types.ts#reservedFlowNodeTypeRefusal (function)", "resolveFlowNodeExpressions": "src/automation/flow-node-expression-paths.ts#resolveFlowNodeExpressions (function)", "resolveFlowNodeValueSlots": "src/automation/flow-node-expression-paths.ts#resolveFlowNodeValueSlots (function)", "resolveFlowTriggerKind": "src/automation/flow-trigger-kind.ts#resolveFlowTriggerKind (function)", diff --git a/packages/spec/src/automation/flow-reserved-node-types.test.ts b/packages/spec/src/automation/flow-reserved-node-types.test.ts new file mode 100644 index 0000000000..5aa0886238 --- /dev/null +++ b/packages/spec/src/automation/flow-reserved-node-types.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The retired callout names are reserved inside the open node namespace + * (#23543 F5-http): the flow parse refuses `http_request` / `http_call` / + * `webhook` as a node `type`, naming `http`, at every depth the region walk + * reaches, once per site — and nothing else about the open namespace moves. + */ +import { describe, expect, it } from 'vitest'; + +import { FlowNodeSchema, FlowSchema } from './flow.zod'; +import { FLOW_RESERVED_NODE_TYPES, reservedFlowNodeTypeRefusal } from './flow-reserved-node-types'; + +const RESERVED = ['http_request', 'http_call', 'webhook'] as const; + +const flowWith = (node: Record) => ({ + name: 'callout', + label: 'Callout', + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'step', label: 'Step', ...node }], + edges: [{ id: 'e1', source: 'start', target: 'step' }], +}); + +const loopAround = (inner: Record) => ({ + type: 'loop', + config: { + collection: { dialect: 'cel', source: 'rows' }, + body: { nodes: [{ id: 'inner', label: 'Inner', ...inner }], edges: [] }, + }, +}); + +const issuesOf = (result: { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } }) => + (result.error?.issues ?? []).map((i) => ({ path: i.path.join('.'), message: i.message })); + +describe('the reserved callout node types', () => { + it('are exactly the three callout names ADR-0018 unified into http', () => { + expect(FLOW_RESERVED_NODE_TYPES).toEqual({ http_request: 'http', http_call: 'http', webhook: 'http' }); + expect(Object.isFrozen(FLOW_RESERVED_NODE_TYPES)).toBe(true); + }); + + it.each(RESERVED)("a top-level node typed '%s' is refused once, at its type, naming http and the release", (type) => { + const issues = issuesOf(FlowSchema.safeParse(flowWith({ type, config: { url: 'https://example.com' } }))); + expect(issues.map((i) => i.path)).toEqual(['nodes.1.type']); + expect(issues[0]!.message).toBe(reservedFlowNodeTypeRefusal(type)); + expect(issues[0]!.message).toContain(`Flow node \`type: '${type}'\` was removed in @objectstack/spec 18`); + expect(issues[0]!.message).toContain("Use `type: 'http'`"); + }); + + it.each(RESERVED)("FlowNodeSchema alone refuses '%s' at type", (type) => { + const issues = issuesOf(FlowNodeSchema.safeParse({ id: 'n', label: 'N', type })); + expect(issues.map((i) => i.path)).toEqual(['type']); + }); + + it.each(RESERVED)("a node typed '%s' inside a loop body is refused once, where the author wrote it", (type) => { + const issues = issuesOf(FlowSchema.safeParse(flowWith(loopAround({ type, config: { url: 'https://example.com' } })))); + const atType = issues.filter((i) => i.path.endsWith('.type')); + expect(atType.map((i) => i.path)).toEqual(['nodes.1.config.body.nodes.0.type']); + expect(atType[0]!.message).toBe(reservedFlowNodeTypeRefusal(type)); + }); + + it('only the webhook refusal says the wait eventType of the same name stays legal', () => { + expect(reservedFlowNodeTypeRefusal('webhook')).toContain("`waitEventConfig.eventType: 'webhook'` is a different field"); + expect(reservedFlowNodeTypeRefusal('http_request')).not.toContain('waitEventConfig'); + }); + + describe('controls — the open namespace is otherwise unchanged', () => { + it('the canonical http node parses', () => { + expect(FlowSchema.safeParse(flowWith({ type: 'http', config: { url: 'https://example.com' } })).success).toBe(true); + }); + + it('a plugin-registered node type parses, top level and inside a region', () => { + expect(FlowSchema.safeParse(flowWith({ type: 'send_sms', config: { to: '+1' } })).success).toBe(true); + expect(FlowSchema.safeParse(flowWith(loopAround({ type: 'send_sms', config: { to: '+1' } }))).success).toBe(true); + }); + + it('a name that only contains a reserved one parses — the refusal is by whole name', () => { + for (const type of ['http_request_v2', 'my_webhook', 'webhooks', 'HTTP_REQUEST']) { + expect(FlowNodeSchema.safeParse({ id: 'n', label: 'N', type }).success, type).toBe(true); + expect(reservedFlowNodeTypeRefusal(type), type).toBeUndefined(); + } + }); + + it("a wait node's waitEventConfig.eventType 'webhook' stays legal", () => { + const result = FlowSchema.safeParse(flowWith({ type: 'wait', waitEventConfig: { eventType: 'webhook' } })); + expect(issuesOf(result)).toEqual([]); + }); + }); +}); diff --git a/packages/spec/src/automation/flow-reserved-node-types.ts b/packages/spec/src/automation/flow-reserved-node-types.ts new file mode 100644 index 0000000000..1d50866506 --- /dev/null +++ b/packages/spec/src/automation/flow-reserved-node-types.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module automation/flow-reserved-node-types + * + * **The retired official flow-node type names, reserved inside the open node + * namespace** (#23543 F5-http, ADR-0018). + * + * `FlowNodeSchema.type` is an open string: ADR-0018 removed the enum gate so a + * plugin can register its own node types. Protocol 11 unified the three + * divergent callout types `http_request` / `http_call` / `webhook` into the one + * canonical `http` node, and the ADR-0087 D2 conversion + * `flow-node-http-callout-rename` rewrote them at load until 18. At 18 that + * conversion left the load path, so the names are reserved instead: + * + * - the flow parse refuses each one as a node `type`, naming `http` + * ({@link reservedFlowNodeTypeRefusal}, read by `FlowNodeSchema.type` for a + * top-level node and by the `FlowSchema` superRefine for a node in an + * ADR-0031 region body); + * - the automation engine refuses to register a node executor under one + * (`AutomationEngine.registerNodeExecutor`), because no node could ever + * reach it: an authored node is refused here, and a stored node is + * rewritten to `http` before anything parses it. + * + * ONE table, three readers, so they cannot drift: the two refusals above and + * the conversion itself, which still runs at every data-at-rest seam + * (`includeRetired`) so a stored flow written with an old name keeps loading + * as `http`. + * + * ⛔ Not the wait node's `waitEventConfig.eventType: 'webhook'`: that is a + * different field with its own closed vocabulary, and it stays legal. + */ + +/** Retired official node type → the canonical type that superseded it. */ +export const FLOW_RESERVED_NODE_TYPES: Readonly> = Object.freeze({ + http_request: 'http', + http_call: 'http', + webhook: 'http', +}); + +/** + * The refusal an author meets for writing a reserved name as a flow node's + * `type`, or `undefined` for any other value. No `os migrate meta` sentence, on + * purpose: the rename belongs to protocol 11, below `MIGRATION_SUPPORT_FLOOR`, + * so the tool lists no edit for it. + */ +export function reservedFlowNodeTypeRefusal(type: unknown): string | undefined { + if (typeof type !== 'string' || !Object.prototype.hasOwnProperty.call(FLOW_RESERVED_NODE_TYPES, type)) return undefined; + const canonical = FLOW_RESERVED_NODE_TYPES[type]; + const eventTypeNote = type === 'webhook' + ? " A wait node's `waitEventConfig.eventType: 'webhook'` is a different field and stays legal." + : ''; + return `Flow node \`type: '${type}'\` was removed in @objectstack/spec 18 (ADR-0087 D2) — it is one of the ` + + `three callout node types ADR-0018 unified into \`${canonical}\`, the loader rewrote it to \`${canonical}\` ` + + 'until this release, and the name stays reserved inside the open node namespace so no plugin can claim it ' + + `(ADR-0018). Use \`type: '${canonical}'\`. Change the value; the conversion never touched the node's \`config\`, ` + + `so the \`${canonical}\` contract judges it as written.${eventTypeNote}`; +} diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index f69316ecce..1f0e23538b 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -29,6 +29,10 @@ import { flowNodeConfigRefusals } from './flow-node-config-refusals'; // [#23543 F3] The loose `config` keys of the two sibling-block node types — one // table, shared with the two D2 lifts that still run at rehydration. import { siblingBlockLooseConfigRefusals } from './flow-sibling-block-loose-keys'; +// [#23543 F5-http] The retired callout names, reserved inside the open node +// namespace — one table, shared with the D2 rename that still runs at +// rehydration and the engine's registration refusal. +import { FLOW_RESERVED_NODE_TYPES, reservedFlowNodeTypeRefusal } from './flow-reserved-node-types'; import { EndConfigSchema } from './builtin-node-config.zod'; // [#22572] The `$` names are the flow engine's at every binding door — the one // rule, composed into a declared variable's `name` and judging an `assignment` @@ -605,10 +609,21 @@ function flowNodeObject() { return strictObject( }, { id: z.string().describe('Node unique ID').meta({ title: 'ID' }), - type: z.string().min(1).describe( - 'Action type — a built-in FlowNodeAction id or a plugin-registered node type. ' + - 'Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum.', - ).meta({ title: 'Node Type' }), + // An open namespace (ADR-0018), with one closed exception: the retired + // callout names are reserved and refused by name, naming `http` (#23543 + // F5-http). A `pattern` per name, like `DriverType`'s retired spellings, so + // the refusal survives the JSON Schema projection. This reaches a top-level + // node; a node in a region body is judged by the `FlowSchema` superRefine. + type: Object.keys(FLOW_RESERVED_NODE_TYPES) + .reduce( + (schema, name) => schema.regex(new RegExp(`^(?!${name}$)`), reservedFlowNodeTypeRefusal(name)!), + z.string().min(1), + ) + .describe( + 'Action type — a built-in FlowNodeAction id or a plugin-registered node type. ' + + 'Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum. ' + + "The retired callout names 'http_request', 'http_call' and 'webhook' are reserved and refused: use 'http'.", + ).meta({ title: 'Node Type' }), label: z.string().describe('Node label').meta({ title: 'Label' }), /** Node Configuration Options (Specific to type) */ @@ -1601,6 +1616,20 @@ export const FlowSchema = lazySchema(() => strictObject( }); } + // [#23543 F5-http] A reserved callout name as a node `type`, refused naming + // `http`. A top-level node meets the same refusal at its `type` field (the + // `pattern` above); a region-body node's region parse is left raw on a + // refusal (`parseFlowNodeRegions`), so this walk is what reaches it — top-level + // graphs are skipped so each site is reported once. + for (const graph of collectFlowGraphs(flow)) { + if (graph.path.length === 0) continue; + graph.nodes.forEach((node, index) => { + const message = reservedFlowNodeTypeRefusal((node as { type?: unknown } | null)?.type); + if (message === undefined) return; + ctx.addIssue({ code: 'custom', path: [...graph.path, 'nodes', index, 'type'], message }); + }); + } + // Edges (#14964): every reader of `edges[].id` assumes the ids are unique — // a designer, a BPMN export, a flow diff, any traversal that dedupes by id — // while nothing enforced it: two edges carrying one id parsed, shipped diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 2a21b142df..b7064459cf 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -2,6 +2,9 @@ export * from './flow.zod'; +// [#23543 F5-http] The reserved retired callout names — the engine's +// registration refusal reads the same table the flow parse refuses with. +export { FLOW_RESERVED_NODE_TYPES, reservedFlowNodeTypeRefusal } from './flow-reserved-node-types'; export * from './region-slots'; export * from './control-flow.zod'; export * from './io-node-config.zod'; diff --git a/packages/spec/src/conversions/apply-live-window.test.ts b/packages/spec/src/conversions/apply-live-window.test.ts new file mode 100644 index 0000000000..4ab834741b --- /dev/null +++ b/packages/spec/src/conversions/apply-live-window.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The LIVE half of the ADR-0087 D2 window: an entry that has not left the load + * path is applied by the default pass and by the authoring funnel + * (`normalizeStackInput`), reported through the caller's sink, and applied + * silently when the caller gave none. + * + * These cases lived in `conversions.test.ts`, driven by + * `flow-node-http-callout-rename`, until #23543 F5-http retired it — the last + * real entry below 18 left live. No real entry is live on the default path + * now, so the subject is a conversion this file owns: a test-only flow-node + * type rename appended to the table the pass reads. A probe nobody retires + * keeps these pins about the live window, not about whichever entry happens to + * be live. The retired half is pinned in `conversions.test.ts` ("load-path + * retirement"). + * + * `vi.mock` replaces the registry module for the whole file, so `apply.ts` + * (which `normalizeStackInput` calls) reads the extended table; nothing outside + * this file sees the probe. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; +import { collectConversionNotices } from './apply.js'; +import type { ConversionNotice, MetadataConversion } from './types.js'; + +const PROBE = 'apply-live-window-probe'; + +vi.mock('./registry.js', async (importOriginal) => { + const original = await importOriginal(); + const { mapFlowNodes } = await import('./walk.js'); + const probe: MetadataConversion = { + id: 'apply-live-window-probe', + toMajor: 18, + surface: 'flow.node.type (test probe)', + summary: "test-only probe: a live load-path rename, node type 'probe_old_callout' → 'http'", + apply(stack, emit) { + return mapFlowNodes(stack, (node, path) => { + if (node.type !== 'probe_old_callout') return node; + emit({ from: 'probe_old_callout', to: 'http', path: `${path}.type` }); + return { ...node, type: 'http' }; + }); + }, + fixture: { before: {}, after: {}, expectedNotices: 0 }, + }; + return { ...original, ALL_CONVERSIONS: [...original.ALL_CONVERSIONS, probe] }; +}); + +describe('a live-window conversion applies at load', () => { + it('the default pass applies it, one notice naming the entry', () => { + const { stack, notices } = collectConversionNotices({ + flows: [{ name: 'f', nodes: [{ id: 'n1', type: 'probe_old_callout', config: { url: 'https://example.com' } }] }], + }); + expect((stack.flows as any[])[0].nodes[0].type).toBe('http'); + expect(notices).toHaveLength(1); + expect(notices[0]!.conversionId).toBe(PROBE); + expect(notices[0]!.retiresIn).toBe(19); + }); + + describe('normalizeStackInput (the authoring funnel)', () => { + it('converts at load and surfaces notices through the sink', () => { + const notices: ConversionNotice[] = []; + const out = normalizeStackInput( + { flows: [{ name: 'f', nodes: [{ id: 'n', type: 'probe_old_callout' }] }] }, + { onConversionNotice: (n) => notices.push(n) }, + ); + expect((out.flows as any[])[0].nodes[0].type).toBe('http'); + expect(notices).toHaveLength(1); + expect(notices[0]!.message).toContain("'probe_old_callout' → 'http'"); + }); + + it('still converts silently when no sink is provided (zero consumer action)', () => { + const out = normalizeStackInput({ flows: [{ name: 'f', nodes: [{ id: 'n', type: 'probe_old_callout' }] }] }); + expect((out.flows as any[])[0].nodes[0].type).toBe('http'); + }); + + it('normalizes map collections and converts in one pass', () => { + const out = normalizeStackInput({ + flows: { my_flow: { nodes: [{ id: 'n', type: 'probe_old_callout' }] } } as any, + }); + const flows = out.flows as any[]; + expect(Array.isArray(flows)).toBe(true); + expect(flows[0].name).toBe('my_flow'); // map key injected + expect(flows[0].nodes[0].type).toBe('http'); // converted + }); + }); +}); diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 249cb7c1b7..19229e4695 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { CreateRecordConfigSchema } from '../automation/builtin-node-config.zod.js'; import { FlowSchema } from '../automation/flow.zod.js'; +import { FLOW_RESERVED_NODE_TYPES } from '../automation/flow-reserved-node-types.js'; import { ScriptConfigSchema } from '../automation/schemaless-node-config.zod.js'; import { DatasourceSchema } from '../data/datasource.zod.js'; import { @@ -107,12 +108,16 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(applyConversions(clean)).toBe(clean); }); + // The data-at-rest posture (`includeRetired`): since #23543 F5-http no + // entry is live on the default path, and a pass that converts nothing + // would hold both claims below vacuously. it('never mutates the caller input', () => { const before = { flows: [{ name: 'f', nodes: [{ id: 'n', type: 'http_request', config: { url: 'x' } }] }], }; const snapshot = structuredClone(before); - applyConversions(before); + const out = applyConversions(before, { includeRetired: true }); + expect((out.flows as any[])[0].nodes[0].type, 'premise: the pass converted').toBe('http'); expect(before).toEqual(snapshot); }); @@ -122,12 +127,16 @@ describe('conversion layer (ADR-0087 D2)', () => { plugins: [plugin], flows: [{ name: 'f', nodes: [{ id: 'n', type: 'http_request', config: { url: 'x' } }] }], }; - const out = applyConversions(stack); + const out = applyConversions(stack, { includeRetired: true }); expect(out).not.toBe(stack); expect((out.plugins as unknown[])[0]).toBe(plugin); // same reference, untouched }); }); + // `flow-node-http-callout-rename` left the load path at 18 (#23543 F5-http): + // the rewrite cases run the data-at-rest posture (`includeRetired`) — the + // replay stored flows still get — and the retirement itself is pinned in the + // load-path retirement block. describe('flow-node-http-callout-rename', () => { it('rewrites http_request / http_call / webhook → http, leaving http untouched', () => { const { stack, notices } = collectConversionNotices({ @@ -141,7 +150,7 @@ describe('conversion layer (ADR-0087 D2)', () => { ], }, ], - }); + }, { includeRetired: true }); const nodes = (stack.flows as any[])[0].nodes; expect(nodes.map((n: any) => n.type)).toEqual(['http', 'http', 'http']); expect(notices).toHaveLength(2); // 'http' was already canonical @@ -149,33 +158,41 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); - describe('flow-node-http-callout-rename — reserved-name conflict guard', () => { - it('refuses to rewrite an alias a live executor owns, reporting a conflict', () => { + /** + * The ADR-0078 conflict guard this entry carried until 18 is gone (#23543 + * F5-http): it skipped a node whose old name a live executor owned. The + * engine now refuses to register an executor under a reserved name + * (`AutomationEngine.registerNodeExecutor`), so no live owner can exist, and + * the rewrite is unconditional — a caller's `reservedNodeTypes` naming one of + * the three no longer stops it, and no conflict is reported. + */ + describe('flow-node-http-callout-rename — the rewrite is unconditional', () => { + it('rewrites a reserved name even when the caller lists it as live, reporting no conflict', () => { const notices: ConversionNotice[] = []; - const conflicts: { token: string; path: string; conversionId: string }[] = []; + const conflicts: unknown[] = []; const out = applyConversions( { flows: [{ name: 'f', nodes: [{ id: 'a', type: 'webhook' }] }] }, { + includeRetired: true, onNotice: (n) => notices.push(n), - onConflict: (c) => conflicts.push({ token: c.token, path: c.path, conversionId: c.conversionId }), - reservedNodeTypes: new Set(['webhook']), // a third-party custom node owns this name + onConflict: (c) => conflicts.push(c), + reservedNodeTypes: new Set(['webhook']), }, ); - // Not rewritten — the custom node is preserved. - expect((out.flows as any[])[0].nodes[0].type).toBe('webhook'); - expect(notices).toHaveLength(0); - expect(conflicts).toEqual([ - { token: 'webhook', path: 'flows[0].nodes[0].type', conversionId: 'flow-node-http-callout-rename' }, - ]); + expect((out.flows as any[])[0].nodes[0].type).toBe('http'); + expect(notices.map((n) => n.conversionId)).toEqual(['flow-node-http-callout-rename']); + expect(conflicts).toEqual([]); }); - it('converts normally when the alias is not a live type (build/validate seam)', () => { - // No reservedNodeTypes → the historical alias converts as usual. - const { stack, notices } = collectConversionNotices({ - flows: [{ name: 'f', nodes: [{ id: 'a', type: 'webhook' }] }], - }); - expect((stack.flows as any[])[0].nodes[0].type).toBe('http'); - expect(notices).toHaveLength(1); + it('reads the reserved-name table the flow parse refuses with', () => { + const types = Object.keys(FLOW_RESERVED_NODE_TYPES); + expect([...types].sort()).toEqual(['http_call', 'http_request', 'webhook']); + const { stack, notices } = collectConversionNotices( + { flows: [{ name: 'f', nodes: types.map((type, i) => ({ id: `n${i}`, type })) }] }, + { includeRetired: true }, + ); + expect((stack.flows as any[])[0].nodes.map((n: any) => n.type)).toEqual(types.map((t) => FLOW_RESERVED_NODE_TYPES[t])); + expect(notices).toHaveLength(types.length); }); }); @@ -395,16 +412,13 @@ describe('conversion layer (ADR-0087 D2)', () => { * before 18 has a verdict. Seven left the load path (with #23421's two, * nine of the family); eight stayed, each with the reason written into its * own docblock and what would have to change for it to retire. The - * #23543 rulings retired seven of those eight (F1–F4, listed below); the - * one still kept retires with F5-http. + * #23543 rulings retired all eight (F1–F5, listed below), so none is kept. * A new entry below 18 that is neither retired nor listed here is an * entry nobody judged. */ it('every entry whose window ran out before 18 is retired or kept by name', () => { const kept = ALL_CONVERSIONS.filter((c) => c.toMajor < 18 && !c.retiredFromLoadPath).map((c) => c.id).sort(); - expect(kept).toEqual([ - 'flow-node-http-callout-rename', - ]); + expect(kept).toEqual([]); for (const id of [ 'flow-node-crud-filter-alias', 'flow-node-crud-object-alias', @@ -428,6 +442,10 @@ describe('conversion layer (ADR-0087 D2)', () => { // component's `visibility` were themselves retired as keys. 'view-visibleOn-to-visibleWhen', 'page-component-visibility-to-visibleWhen', + // #23543 F5-http: retired once the three names were reserved inside + // the open node namespace — refused by the flow parse and by the + // engine's executor registration. + 'flow-node-http-callout-rename', ]) { expect(ALL_CONVERSIONS.find((c) => c.id === id), id).toMatchObject({ retiredFromLoadPath: true, retiredAfter: '17.7.0' }); } @@ -459,17 +477,38 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(issues[0]!.message).toContain('Use `objectName`'); }); - // Re-pointed by #23543 F4, whose two protocol-15 entries were this case's - // subject: the callout rename is the one entry still live, and F5-http, - // which retires it next, inherits this case with its two siblings above. - it('a live-window conversion still applies at load (the protocol-11 callout rename)', () => { - const { stack, notices } = collectConversionNotices({ - flows: [{ name: 'f', nodes: [{ id: 'n1', type: 'http_request', config: { url: 'https://example.com' } }] }], - }); - expect((stack.flows as any[])[0].nodes[0].type).toBe('http'); - expect(notices).toHaveLength(1); - expect(notices[0]!.conversionId).toBe('flow-node-http-callout-rename'); + it('a reserved callout node type reaches defineStack as authored and is refused naming http and the release', () => { + const authored = { + flows: [{ + name: 'f', + label: 'F', + type: 'autolaunched', + nodes: [ + { id: 's', type: 'start', label: 'S' }, + { id: 'h', type: 'http_request', label: 'H', config: { url: 'https://example.com' } }, + ], + edges: [], + }], + }; + let refusal: unknown; + try { + defineStack(authored as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: { path: PropertyKey[]; message: string }[] }).issues; + expect(issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.type']); + expect(issues[0]!.message).toContain("Flow node `type: 'http_request'` was removed in @objectstack/spec 18"); + expect(issues[0]!.message).toContain("Use `type: 'http'`"); }); + + // Until #23543 F5-http this block also held 'a live-window conversion still + // applies at load', driven by the callout rename, the last entry below 18 + // left live. With it retired no real entry is live on the default path, so + // that claim (and the `normalizeStackInput` sink cases below that rode the + // same entry) moved to `apply-live-window.test.ts`, which owns a test-only + // live entry and so does not depend on any real entry's lifecycle. }); /** @@ -618,30 +657,21 @@ describe('conversion layer (ADR-0087 D2)', () => { }); describe('normalizeStackInput integration (the load seam)', () => { - it('converts at load and surfaces notices through the sink', () => { + // The three sink / map-collection cases that stood here moved to + // `apply-live-window.test.ts` with #23543 F5-http: they need a LIVE entry, + // and the callout rename that drove them was the last one below 18. + it('a retired callout name passes the authoring funnel as written, while the stored-row door converts it', () => { const notices: ConversionNotice[] = []; - const out = normalizeStackInput( - { flows: [{ name: 'f', nodes: [{ id: 'n', type: 'http_request' }] }] }, - { onConversionNotice: (n) => notices.push(n) }, - ); - expect((out.flows as any[])[0].nodes[0].type).toBe('http'); - expect(notices).toHaveLength(1); - expect(notices[0]!.message).toContain("'http_request' → 'http'"); - }); - - it('still converts silently when no sink is provided (zero consumer action)', () => { - const out = normalizeStackInput({ flows: [{ name: 'f', nodes: [{ id: 'n', type: 'http_request' }] }] }); - expect((out.flows as any[])[0].nodes[0].type).toBe('http'); - }); - - it('normalizes map collections and converts in one pass', () => { - const out = normalizeStackInput({ - flows: { my_flow: { nodes: [{ id: 'n', type: 'webhook' }] } } as any, - }); + const authored = { flows: { my_flow: { nodes: [{ id: 'n', type: 'webhook' }] } } as any }; + const out = normalizeStackInput(structuredClone(authored), { onConversionNotice: (n) => notices.push(n) }); const flows = out.flows as any[]; - expect(Array.isArray(flows)).toBe(true); - expect(flows[0].name).toBe('my_flow'); // map key injected - expect(flows[0].nodes[0].type).toBe('http'); // converted + expect(flows[0].name).toBe('my_flow'); // the map is still normalized + expect(flows[0].nodes[0].type).toBe('webhook'); // …but not converted: the parse refuses it + expect(notices).toEqual([]); + const stored = applyConversionsToStoredItem('flow', { name: 'my_flow', nodes: [{ id: 'n', type: 'webhook' }] }) as { + nodes: Array<{ type: string }>; + }; + expect(stored.nodes[0]!.type).toBe('http'); }); /** diff --git a/packages/spec/src/conversions/region-walk.test.ts b/packages/spec/src/conversions/region-walk.test.ts index e7bafe4eb7..53e94fc4ca 100644 --- a/packages/spec/src/conversions/region-walk.test.ts +++ b/packages/spec/src/conversions/region-walk.test.ts @@ -17,6 +17,16 @@ import { describe, expect, it } from 'vitest'; import { FLOW_REGION_SLOTS_BY_TYPE } from '../automation/region-slots.js'; import { applyConversions, applyConversionsToFlow, collectConversionNotices } from './apply.js'; +/** + * The vehicle is the protocol-11 callout rename, which left the load path at 18 + * (#23543 F5-http): every pass below runs the posture that still replays it, + * data at rest (`includeRetired`) — the stored-row and flow-rehydration seams, + * where a region-nested old node still has to come out canonical. + */ +const collect = (stack: Record, options: Parameters[1] = {}) => + collectConversionNotices(stack, { includeRetired: true, ...options }); +const atRest = (stack: Record) => applyConversions(stack, { includeRetired: true }); + /** A protocol-11 callout alias — renamed to `http` by the table. */ const webhookNode = (id: string) => ({ id, type: 'webhook', label: id, config: { url: 'https://x' } }); /** The converted form of {@link webhookNode}. */ @@ -26,7 +36,7 @@ const region = (...nodes: unknown[]) => ({ nodes, edges: [] }); describe('conversions reach nodes inside structured regions', () => { it('converts a node in a `loop` body exactly as it converts its top-level twin', () => { - const { stack, notices } = collectConversionNotices({ + const { stack, notices } = collect({ flows: [{ name: 'sweep', nodes: [ @@ -48,7 +58,7 @@ describe('conversions reach nodes inside structured regions', () => { }); it('converts nodes in every `parallel` branch', () => { - const { stack, notices } = collectConversionNotices({ + const { stack, notices } = collect({ flows: [{ name: 'fan', nodes: [{ @@ -68,7 +78,7 @@ describe('conversions reach nodes inside structured regions', () => { }); it('converts nodes in both `try_catch` regions', () => { - const { stack, notices } = collectConversionNotices({ + const { stack, notices } = collect({ flows: [{ name: 'guarded', nodes: [{ @@ -85,7 +95,7 @@ describe('conversions reach nodes inside structured regions', () => { }); it('recurses through nested containers (loop → try_catch → loop)', () => { - const { stack, notices } = collectConversionNotices({ + const { stack, notices } = collect({ flows: [{ name: 'deep', nodes: [{ @@ -123,7 +133,7 @@ describe('conversions reach nodes inside structured regions', () => { // unconverted leaves it with NO filter at all. It has to reach a loop body. // Both CRUD aliases left the load path at 18 (#23487), so the walk is // exercised in the posture that still replays them: data at rest. - const { stack } = collectConversionNotices({ + const { stack } = collect({ flows: [{ name: 'purge', nodes: [{ @@ -161,7 +171,7 @@ describe('the region walk stays copy-on-write and shape-gated', () => { nodes: [{ id: 'loop', type: 'loop', label: 'Loop', config: { collection: { dialect: 'cel', source: 'rows' }, body: region(httpNode('inner')) } }], }], }; - expect(applyConversions(clean)).toBe(clean); + expect(atRest(clean)).toBe(clean); }); it('shares untouched branches — only the changed path is copied', () => { @@ -175,7 +185,7 @@ describe('the region walk stays copy-on-write and shape-gated', () => { }], }], }; - const out = applyConversions(stack) as any; + const out = atRest(stack) as any; expect(out).not.toBe(stack); expect(out.flows[0].nodes[0].config.branches[1]).toBe(untouchedBranch); }); @@ -193,7 +203,7 @@ describe('the region walk stays copy-on-write and shape-gated', () => { ], }], }; - expect(applyConversions(stack)).toBe(stack); + expect(atRest(stack)).toBe(stack); }); it('terminates on a self-referential region instead of recursing forever', () => { @@ -201,7 +211,7 @@ describe('the region walk stays copy-on-write and shape-gated', () => { // so a cycle is reachable on the load path. const selfRegion: { nodes: unknown[]; edges: unknown[] } = { nodes: [], edges: [] }; selfRegion.nodes.push({ id: 'l', type: 'loop', label: 'L', config: { collection: { dialect: 'cel', source: 'r' }, body: selfRegion } }); - expect(() => applyConversions({ flows: [{ name: 'cyclic', nodes: selfRegion.nodes }] })).not.toThrow(); + expect(() => atRest({ flows: [{ name: 'cyclic', nodes: selfRegion.nodes }] })).not.toThrow(); }); it('cannot be steered onto Object.prototype by a hostile node type', () => { @@ -213,7 +223,7 @@ describe('the region walk stays copy-on-write and shape-gated', () => { }; // `constructor` is not a container: the region is left alone, and the lookup // must not resolve through the prototype chain and throw. - expect(applyConversions(stack)).toBe(stack); + expect(atRest(stack)).toBe(stack); }); }); @@ -229,7 +239,7 @@ describe('the conversion walk descends into every declared container', () => { for (const nodeType of FLOW_REGION_SLOTS_BY_TYPE.keys()) { for (const { key, arity } of FLOW_REGION_SLOTS_BY_TYPE.get(nodeType)!) { const value = arity === 'many' ? [region(webhookNode('inner'))] : region(webhookNode('inner')); - const { stack } = collectConversionNotices({ + const { stack } = collect({ flows: [{ name: 'f', nodes: [{ id: 'c', type: nodeType, label: 'C', config: { [key]: value } }] }], }); const slot = ((stack.flows as any[])[0].nodes[0].config)[key]; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 7ab536dc82..c5edb0fcdf 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -47,54 +47,57 @@ import { import { ASSEMBLED_VIEW_ITEMS_KEY } from '../ui/assembled-views.zod.js'; import { FLOW_REGION_SLOTS_BY_TYPE } from '../automation/region-slots.js'; import { CONNECTOR_CONFIG_LOOSE_KEYS, WAIT_EVENT_CONFIG_LOOSE_KEYS } from '../automation/flow-sibling-block-loose-keys.js'; +import { FLOW_RESERVED_NODE_TYPES } from '../automation/flow-reserved-node-types.js'; /** - * Flow callout node type rename (protocol 11.0). + * Flow callout node type rename (protocol 11.0; retired from the load path at + * 18, #23543 F5-http). * * The divergent `http_request` / `http_call` / `webhook` node types were * unified to the single canonical `http` node (see * `services/service-automation/src/builtin/http-nodes.ts`). A pure enum * re-spelling — losslessly convertible. * - * **Stays on the load path at 18 — a recorded verdict (#23487).** `flow.node.type` - * is an open namespace (ADR-0018), so no door refuses `http_request` / - * `http_call` / `webhook`: retired, this entry would leave an authored node - * silently carrying a type no builtin executor owns, where today it converts - * with a notice. It can retire once the authoring doors refuse those three - * reserved names with the rename to `http`, which is a decision about - * reserving retired official names inside an open namespace. + * ## Retired from the load path at 18 (#23543, ruling F5-http A) + * + * Through 17 it stayed live because `flow.node.type` is an open namespace + * (ADR-0018) and no door refused the three names, so retired alone it would + * have left an authored node silently carrying a type no builtin executor owns + * (the verdict #23487 recorded). The ruling reserved the names inside the open + * namespace: the flow parse refuses each one as a node `type` with the + * prescription naming `http`, and the automation engine refuses to register a + * node executor under one (`FLOW_RESERVED_NODE_TYPES`, + * `automation/flow-reserved-node-types.ts`, the table this entry reads too). So + * the authoring funnel (`defineStack`, `os validate` / `os build`) no longer + * replays this entry, and an author is taught the canonical type instead of + * being rewritten. Stored flows are the `includeRetired` seams: the automation + * engine's flow rehydration and stored-row rehydration + * (`applyConversionsToStoredItem`) still rewrite a `sys_metadata` flow row + * written with an old name before anything parses it, and so does the + * artifact-ingestion door inside its window. `os migrate meta` does not reach + * it: protocol 11 is below `MIGRATION_SUPPORT_FLOOR`. The family's D3 entry, + * `flow-node-http-callout-types-reserved`, states the edit for authored sources. + * + * The rewrite is unconditional. Until 18 it skipped a node whose old name a + * live executor owned and reported a conflict instead (ADR-0078's guard, + * through `ConversionContext.reservedNodeTypes`); the engine now refuses that + * registration, so no live executor can own a reserved name and the guard had + * no case left to judge. */ const flowNodeHttpRename: MetadataConversion = { id: 'flow-node-http-callout-rename', toMajor: 11, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'flow.node.type', summary: "flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http'", - apply(stack, emit, context) { - const aliases = new Set(['http_request', 'http_call', 'webhook']); + apply(stack, emit) { return mapFlowNodes(stack, (node, path) => { const type = node.type; - if (typeof type !== 'string' || !aliases.has(type)) return node; - // `flow.node.type` is an OPEN namespace (ADR-0018 removed the enum gate), - // so a retired official name could be re-registered by a third party. If a - // live executor owns this token in this environment, refuse the rewrite — - // clobbering it would silently break that node — and report a loud, - // actionable conflict instead (ADR-0078). On the pure build/validate seam - // `context` is absent, so the historical alias converts as normal. - if (context?.reservedNodeTypes?.has(type)) { - context.reportConflict?.({ - token: type, - path: `${path}.type`, - reason: - `'${type}' is a protocol-11 retired official flow-node type, but a live ` + - `executor is registered under that exact name in this environment. The ` + - `conversion to 'http' was skipped to avoid breaking it. Rename your ` + - `custom node to a non-reserved type (the reserved names are ` + - `'http_request' / 'http_call' / 'webhook', all superseded by 'http').`, - }); - return node; - } - emit({ from: type, to: 'http', path: `${path}.type` }); - return { ...node, type: 'http' }; + if (typeof type !== 'string' || !Object.prototype.hasOwnProperty.call(FLOW_RESERVED_NODE_TYPES, type)) return node; + const canonical = FLOW_RESERVED_NODE_TYPES[type]!; + emit({ from: type, to: canonical, path: `${path}.type` }); + return { ...node, type: canonical }; }); }, fixture: { diff --git a/packages/spec/src/conversions/stored.test.ts b/packages/spec/src/conversions/stored.test.ts index 1f5d99a652..7049ddb78e 100644 --- a/packages/spec/src/conversions/stored.test.ts +++ b/packages/spec/src/conversions/stored.test.ts @@ -196,19 +196,36 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { }); }); - it('threads the conflict guard context through (flow callers that own a registry)', () => { + // [#23543 F5-http] A stored flow row written with a retired callout name + // still loads, as `http`: the rename left the authoring funnel, not this + // seam. Until 18 a caller's `reservedNodeTypes` naming the old token made it + // a refused conflict instead (ADR-0078's guard); the engine now refuses an + // executor under a reserved name, so the guard is gone and the rewrite is + // unconditional. + it('rewrites a stored flow\'s retired callout node type to http, unconditionally', () => { const flow = { name: 'notify_flow', - nodes: [{ id: 'n1', type: 'webhook', config: { url: 'https://hooks.example.com' } }], + nodes: [ + { id: 'n1', type: 'webhook', config: { url: 'https://hooks.example.com' } }, + { id: 'n2', type: 'http_request', config: { url: 'https://example.com' } }, + { id: 'n3', type: 'http_call', config: { url: 'https://example.com' } }, + ], }; const conflicts: string[] = []; + const notices: ConversionNotice[] = []; const out = applyConversionsToStoredItem('flow', flow, { reservedNodeTypes: new Set(['webhook']), onConflict: (c) => conflicts.push(c.token), + onNotice: (n) => notices.push(n), }) as typeof flow; - // A live executor owns 'webhook' → the rename is refused and reported. - expect(out.nodes[0]!.type).toBe('webhook'); - expect(conflicts).toEqual(['webhook']); + expect(out.nodes.map((n) => n.type)).toEqual(['http', 'http', 'http']); + expect(out.nodes.map((n) => n.config)).toEqual(flow.nodes.map((n) => n.config)); + expect(notices.map((n) => [n.conversionId, n.from, n.to])).toEqual([ + ['flow-node-http-callout-rename', 'webhook', 'http'], + ['flow-node-http-callout-rename', 'http_request', 'http'], + ['flow-node-http-callout-rename', 'http_call', 'http'], + ]); + expect(conflicts).toEqual([]); }); // [#17321] The TODO lane rides through the stored seam the way the conflict diff --git a/packages/spec/src/conversions/stored.ts b/packages/spec/src/conversions/stored.ts index 1c948ff8d7..7020962607 100644 --- a/packages/spec/src/conversions/stored.ts +++ b/packages/spec/src/conversions/stored.ts @@ -86,12 +86,13 @@ export type StoredConversionOptions = Omit( type: string, diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-node-http-callout-types-reserved.ts b/packages/spec/src/migrations/entries/semantic/18.flow-node-http-callout-types-reserved.ts new file mode 100644 index 0000000000..a994918c4a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-node-http-callout-types-reserved.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23543 F5-http — the reservation's one D3 entry. The D2 half is +// `flow-node-http-callout-rename` (protocol 11), now retired from the load +// path. It carries no `conversionIds`: protocol 11 is below +// MIGRATION_SUPPORT_FLOOR, so no step of the chain replays that conversion, +// and `os migrate meta` lists this entry as the hand edit it is. No +// `relevantWhen` either, so it is always listed (the F5-jsx shape). +export const entry: SemanticMigration = { + id: 'flow-node-http-callout-types-reserved', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: "flow node type authored as 'http_request', 'http_call' or 'webhook' (FlowNodeSchema.type, automation/flow.zod.ts)", + replacement: + "`type: 'http'` — the one outbound callout node since ADR-0018 unified the three; the node's `id`, `label` " + + 'and `config` stay as they are', + reason: + 'A maintainer ruling reserved the three retired callout node names inside the open flow-node namespace at ' + + '18. ADR-0018 unified `http_request`, `http_call` and `webhook` into the one `http` node, and through 17 ' + + 'the loader rewrote an authored node typed with one of them to `http` with a notice. At 18 the flow parse ' + + "refuses each as a node's `type`, with the prescription naming `http`: at `objectstack validate`, " + + '`objectstack build`, `objectstack lint`, `defineStack` and the metadata save door (a 422 for a new ' + + 'flow), for a node in a loop, parallel or try_catch region too. The automation engine also refuses to ' + + 'register a node executor under one of the three names, so a plugin that registered its own executor under one fails at registration and needs a type of ' + + 'its own. No tool makes the rename for an authored source: the D2 conversion ' + + '`flow-node-http-callout-rename` belongs to protocol 11, below the migration floor, so ' + + '`objectstack migrate meta` applies no edit for it and prints each such node among the refusals left ' + + 'after the chain, at `flows.N.nodes.M.type`; change the value by hand. Stored flows need no edit: the ' + + "automation engine's flow rehydration and every stored-row rehydration seam replay the retired " + + 'conversion, so a `sys_metadata` flow row written with an old name keeps loading as an `http` node. A ' + + "wait node's `waitEventConfig.eventType: 'webhook'` is a different field and is not affected.", + acceptanceCriteria: + "`objectstack validate` reports no refusal at any flow node's `type` path, no flow source in the project " + + "types a node `http_request`, `http_call` or `webhook`, no plugin registers a node executor under one of " + + 'those names, and each former callout node sends the same request as an `http` node.', +}; diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 68acb12980..65309dc69d 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -380,6 +380,16 @@ const JOIN: Readonly> = { tombstone(FormSectionSchema, 'view.form.sections[]', 'visibleOn'), ], 'page-component-visibility-to-visibleWhen': [tombstone(PageComponentSchema, 'page.components[]', 'visibility')], + // #23543 F5-http: the three retired callout names, reserved inside the open + // node namespace and refused by the flow parse at the node's `type`; the + // flow is otherwise complete, so `nodes` is the only issue path. + 'flow-node-http-callout-rename': (['http_request', 'http_call', 'webhook'] as const).map((type) => + refusal( + `a flow node typed '${type}'`, + () => FlowSchema.safeParse(flowWithNode({ type, config: { url: 'https://example.com' } })), + 'nodes', + ), + ), // Shipped by a 17.x minor; `toMajor: 18` like the rows above. 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')], diff --git a/packages/spec/src/stack-conversion-notices.test.ts b/packages/spec/src/stack-conversion-notices.test.ts new file mode 100644 index 0000000000..90bbb406a1 --- /dev/null +++ b/packages/spec/src/stack-conversion-notices.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defineStack` — ADR-0087 D2 conversion notices reach the author. + * + * A conversion is deliberately silent about FIXING the shape — zero consumer + * action is the point. It is not supposed to be silent about having had to: + * the notice carries "retires in protocol N", after which the old spelling + * stops loading. `defineStack` once passed no sink, so the author who wrote the + * old shape heard nothing unless they happened to run `os validate` (`os build` + * was deaf too). + * + * This block lived in `stack.test.ts`, driven by + * `flow-node-http-callout-rename`, until #23543 F5-http retired that entry — + * the last real one left live on the load path. The subject is the sink and its + * warn-once, not any one entry, so the vehicle is a conversion this file owns: a + * test-only flow-node type rename appended to the table the pass reads. + * `vi.mock` replaces the registry module for the whole file, so `apply.ts` + * (which `defineStack` calls) reads the extended table; nothing outside this + * file sees the probe. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { defineStack } from './stack.zod'; +import type { MetadataConversion } from './conversions/types'; + +const PROBE = 'stack-conversion-notices-probe'; + +vi.mock('./conversions/registry', async (importOriginal) => { + const original = await importOriginal(); + const { mapFlowNodes } = await import('./conversions/walk'); + const probe: MetadataConversion = { + id: 'stack-conversion-notices-probe', + toMajor: 18, + surface: 'flow.node.type (test probe)', + summary: "test-only probe: a live load-path rename, node type 'probe_old_callout' → 'http'", + apply(stack, emit) { + return mapFlowNodes(stack, (node, path) => { + if (node.type !== 'probe_old_callout') return node; + emit({ from: 'probe_old_callout', to: 'http', path: `${path}.type` }); + return { ...node, type: 'http' }; + }); + }, + fixture: { before: {}, after: {}, expectedNotices: 0 }, + }; + return { ...original, ALL_CONVERSIONS: [...original.ALL_CONVERSIONS, probe] }; +}); + +describe('defineStack — ADR-0087 D2 conversion notices', () => { + const manifest = { id: 'com.example.p', version: '1.0.0', type: 'app' as const, name: 'P', namespace: 'demo' }; + const obj = { name: 'demo_task', label: 'Task', fields: { title: { type: 'text' as const } } }; + + // A flow node typed with the probe's old spelling — it converts to `http`. + // + // The warn-once key is (conversionId, path, from, to) and the notice path is + // INDEX-based (`flows[i].nodes[j].type`) — the flow's name never enters it. + // Since the dedupe set is process-scoped by design, every case below has to + // occupy its own node index or it silently reuses an earlier case's key and + // asserts nothing. + const legacyFlowAt = (index: number) => ({ + name: `conv_demo_${index}`, + nodes: [ + ...Array.from({ length: index }, (_, i) => ({ id: `n_pad_${i}`, type: 'http' })), + { id: 'n_call', type: 'probe_old_callout' }, + ], + }); + // Non-strict throughout: the assertion is about the conversion sink alone, + // not about satisfying every downstream cross-reference check. The sink is + // wired on the single `normalizeStackInput` call that runs BEFORE the strict + // branch, so both modes go through it. + // The flows are deliberately minimal (no label, type or edges) — the + // non-strict pass is all that runs on them — so they are cast past the + // authoring type rather than padded out. + const define = (index: number) => + defineStack({ manifest, objects: [obj], flows: [legacyFlowAt(index)] as never }, { strict: false }); + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warn.mockRestore(); + }); + + it('warns when a conversion rewrites an old shape, naming the retirement major', () => { + define(0); + + expect(warn).toHaveBeenCalledTimes(1); + const msg = warn.mock.calls[0][0] as string; + expect(msg).toContain(`'probe_old_callout' → 'http'`); + expect(msg).toContain(PROBE); + expect(msg).toContain('retires in protocol'); + }); + + it('nags once per distinct conversion site', () => { + define(1); + define(1); + define(1); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('reports a second, distinct site separately', () => { + // Two different authored occurrences, each needing its own fix — dedupe + // must not swallow the second. + define(2); + define(3); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('stays quiet for a stack that is already canonical', () => { + defineStack( + { manifest, objects: [obj], flows: [{ name: 'conv_canonical', nodes: [{ id: 'n1', type: 'http' }] }] as never }, + { strict: false }, + ); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/spec/src/stack-conversions-record.test.ts b/packages/spec/src/stack-conversions-record.test.ts index 73afae4719..74cc7acf7f 100644 --- a/packages/spec/src/stack-conversions-record.test.ts +++ b/packages/spec/src/stack-conversions-record.test.ts @@ -45,10 +45,12 @@ import { CONVERSION_NOTICE_CODE, type ConversionNotice, type MetadataConversion * the page vehicle is a conversion this file owns: a live copy of the page * component `visibility` → `visibleWhen` rename, appended to the table the pass * reads. It was the real entry `page-component-visibility-to-visibleWhen` until - * #23543 F4 retired it with its key, and the one entry still live on the load - * path, the callout rename below, retires in the same card. A probe nobody - * retires keeps this file about the record, not about whichever entry happens to - * be live. (`toMajor: 15`, the real entry's, keeps every pinned `retiresIn: 16`.) + * #23543 F4 retired it with its key. The second vehicle, for the ordering case, + * is a test-only flow-node type rename: it was the real callout rename + * `flow-node-http-callout-rename` until #23543 F5-http retired that, the last + * real entry left live on the load path. Probes nobody retires keep this file + * about the record, not about whichever entry happens to be live. (`toMajor: + * 15`, the real page entry's, keeps every pinned `retiresIn: 16`.) * * `vi.mock` replaces the registry module for the whole file, so `apply.ts` * (which `defineStack` calls) reads the extended table; nothing outside this @@ -74,7 +76,22 @@ vi.mock('./conversions/registry', async (importOriginal) => { }, fixture: { before: {}, after: {}, expectedNotices: 0 }, }; - return { ...original, ALL_CONVERSIONS: [...original.ALL_CONVERSIONS, probe] }; + const { mapFlowNodes } = await import('./conversions/walk'); + const calloutProbe: MetadataConversion = { + id: 'stack-conversions-record-callout-probe', + toMajor: 18, + surface: 'flow.node.type (test probe)', + summary: "test-only probe: a live load-path rename, node type 'probe_old_callout' → 'http'", + apply(stack, emit) { + return mapFlowNodes(stack, (node, path) => { + if (node.type !== 'probe_old_callout') return node; + emit({ from: 'probe_old_callout', to: 'http', path: `${path}.type` }); + return { ...node, type: 'http' }; + }); + }, + fixture: { before: {}, after: {}, expectedNotices: 0 }, + }; + return { ...original, ALL_CONVERSIONS: [...original.ALL_CONVERSIONS, probe, calloutProbe] }; }); afterEach(() => { @@ -90,12 +107,13 @@ afterEach(() => { * load path at 18 — any live page conversion carries what is pinned here.) * No standalone actions, so the same built stack can be composed twice. * - * `callout` adds a flow whose node is authored as `http_request`, the spelling - * the live conversion `flow-node-http-callout-rename` rewrites to `http` — a - * second, different conversion for the ordering case. (It used to be a - * `kind: 'jsx'` page, until `jsx` retired as a page kind at 18.) + * `callout` adds a flow whose node is authored as `probe_old_callout`, the + * spelling the second probe rewrites to `http` — a second, different conversion + * for the ordering case. (It used to be a `kind: 'jsx'` page, until `jsx` + * retired as a page kind at 18, and then the real `http_request` callout rename, + * until that retired too.) */ -const source = (ns: string, headerKey: 'visibility' | 'visibleWhen', callout: 'http_request' | 'http' | null = null) => ({ +const source = (ns: string, headerKey: 'visibility' | 'visibleWhen', callout: 'probe_old_callout' | 'http' | null = null) => ({ manifest: { id: `com.example.${ns}`, name: ns, version: '1.0.0', type: 'app' as const, namespace: ns }, objects: [{ name: `${ns}_thing`, label: 'Thing', fields: { title: { type: 'text' as const, label: 'Title' } } }], pages: [ @@ -136,9 +154,9 @@ const HEADER_NOTICE = { const CALLOUT_NOTICE = { code: CONVERSION_NOTICE_CODE, - conversionId: 'flow-node-http-callout-rename', + conversionId: 'stack-conversions-record-callout-probe', path: 'flows[0].nodes[0].type', - from: 'http_request', + from: 'probe_old_callout', to: 'http', }; @@ -171,7 +189,7 @@ describe('defineStack records the conversions it applied', () => { it('two conversions in one source are recorded in the order they were applied', () => { quiet(); - const built = defineStack(source('aa', 'visibility', 'http_request') as never, { strict: false }); + const built = defineStack(source('aa', 'visibility', 'probe_old_callout') as never, { strict: false }); const record = stackConversionsOf(built).map(substance); expect(record).toHaveLength(2); expect(record).toContainEqual(HEADER_NOTICE); diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 5022365e25..836fb2da12 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { ObjectStackDefinitionSchema, defineStack, @@ -1434,78 +1434,10 @@ describe('defineStack — at most one App per package (ADR-0019 D1/D3)', () => { // ── ADR-0087 D2 conversion notices reach the author ───────────────────────── // -// A conversion is deliberately silent about FIXING the shape — zero consumer -// action is the point. It is not supposed to be silent about having had to: -// the notice carries "retires in protocol N", after which the old spelling -// stops loading. `defineStack` passed no sink, so the author who wrote the old -// shape heard nothing unless they happened to run `os validate` (`os build` -// was deaf too). Five conversions are live today, so this was a real gap. -describe('defineStack — ADR-0087 D2 conversion notices', () => { - const manifest = { id: 'com.example.p', version: '1.0.0', type: 'app' as const, name: 'P', namespace: 'demo' }; - const obj = { name: 'demo_task', label: 'Task', fields: { title: { type: 'text' as const } } }; - - // A protocol-11 flow callout node type — `webhook` converts to `http`. - // - // The warn-once key is (conversionId, path, from, to) and the notice path is - // INDEX-based (`flows[i].nodes[j].type`) — the flow's name never enters it. - // Since the dedupe set is process-scoped by design, every case below has to - // occupy its own node index or it silently reuses an earlier case's key and - // asserts nothing. - const legacyFlowAt = (index: number) => ({ - name: `conv_demo_${index}`, - nodes: [ - ...Array.from({ length: index }, (_, i) => ({ id: `n_pad_${i}`, type: 'http' })), - { id: 'n_call', type: 'webhook' }, - ], - }); - // Non-strict throughout: the assertion is about the conversion sink alone, - // not about satisfying every downstream cross-reference check. The sink is - // wired on the single `normalizeStackInput` call that runs BEFORE the strict - // branch, so both modes go through it. - const define = (index: number) => - defineStack({ manifest, objects: [obj], flows: [legacyFlowAt(index)] }, { strict: false }); - - let warn: ReturnType; - beforeEach(() => { - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - afterEach(() => { - warn.mockRestore(); - }); - - it('warns when a conversion rewrites an old shape, naming the retirement major', () => { - define(0); - - expect(warn).toHaveBeenCalledTimes(1); - const msg = warn.mock.calls[0][0] as string; - expect(msg).toContain(`'webhook' → 'http'`); - expect(msg).toContain('flow-node-http-callout-rename'); - expect(msg).toContain('retires in protocol'); - }); - - it('nags once per distinct conversion site', () => { - define(1); - define(1); - define(1); - expect(warn).toHaveBeenCalledTimes(1); - }); - - it('reports a second, distinct site separately', () => { - // Two different authored occurrences, each needing its own fix — dedupe - // must not swallow the second. - define(2); - define(3); - expect(warn).toHaveBeenCalledTimes(2); - }); - - it('stays quiet for a stack that is already canonical', () => { - defineStack( - { manifest, objects: [obj], flows: [{ name: 'conv_canonical', nodes: [{ id: 'n1', type: 'http' }] }] }, - { strict: false }, - ); - expect(warn).not.toHaveBeenCalled(); - }); -}); +// Moved to `stack-conversion-notices.test.ts` with #23543 F5-http: the block +// was driven by `flow-node-http-callout-rename`, the last real entry left live +// on the load path, and it retired there. That file owns a test-only live entry +// so the pins stay about `defineStack`'s notice sink, not about any one entry. describe('defineStack — the RETIRED `type: page` view mount', () => { const baseManifest = { diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 366d85d68a..306f5a6917 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -157,9 +157,7 @@ }, "src/stack.test.ts": { "TS18048: 'result.manifest' is possibly 'undefined'.": 4, - "TS2345: Argument of type '…' is not assignable to parameter of type 'ObjectStackDefinitionInput'.": 27, - "TS2739: Type '…' is missing the following properties from type '…': label, type, edges": 1, - "TS2741: Property 'label' is missing in type '…' but required in type '…'.": 1 + "TS2345: Argument of type '…' is not assignable to parameter of type 'ObjectStackDefinitionInput'.": 27 }, "src/system/collaboration.test.ts": { "TS2339: Property 'attributes' does not exist on type '…'.": 2