Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/23543-f5-http-callout-node-types-reserved.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered flow-node-http-callout-types-reserved -->
2 changes: 1 addition & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | optional | Node configuration |
| **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record<string, any> }` | optional | |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/control-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | optional | Node configuration |
| **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record<string, any> }` | optional | |
Expand Down Expand Up @@ -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<string, any>` | optional | Node configuration |
| **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record<string, any> }` | optional | |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | optional | Node configuration |
| **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record<string, any> }` | optional | |
Expand Down Expand Up @@ -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<string, any>` | optional | Node configuration |
| **connectorConfig** | `{ connectorId: string; actionId: string; input?: Record<string, any> }` | optional | |
Expand Down
71 changes: 42 additions & 29 deletions packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
*
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -221,7 +232,11 @@ function runCli(args: string[], cwd: string): Promise<Run> {
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,
Expand All @@ -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'));
Expand Down Expand Up @@ -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);
}
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading
Loading