From 22574a8e8b5bf73a927abc058bd4e948234e9c31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:31:45 +0000 Subject: [PATCH 1/8] wip(spec): reserve the retired callout node types; retire flow-node-http-callout-rename from the load path --- .../automation/flow-reserved-node-types.ts | 58 +++++++ packages/spec/src/automation/flow.zod.ts | 37 ++++- packages/spec/src/automation/index.ts | 3 + .../src/conversions/apply-live-window.test.ts | 88 +++++++++++ .../spec/src/conversions/conversions.test.ts | 146 +++++++++++------- .../spec/src/conversions/region-walk.test.ts | 32 ++-- packages/spec/src/conversions/registry.ts | 67 ++++---- packages/spec/src/conversions/stored.test.ts | 27 +++- packages/spec/src/conversions/stored.ts | 13 +- 9 files changed, 355 insertions(+), 116 deletions(-) create mode 100644 packages/spec/src/automation/flow-reserved-node-types.ts create mode 100644 packages/spec/src/conversions/apply-live-window.test.ts 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 00000000000..09c1c3f1675 --- /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 protocol 11 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 f69316ecce7..1f0e23538bf 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 2a21b142dfd..b7064459cf3 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 00000000000..4ab834741b7 --- /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 249cb7c1b70..19229e46957 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 e7bafe4eb71..53e94fc4ca9 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 7ab536dc825..c5edb0fcdff 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 1f5d99a652e..7049ddb78e7 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 1c948ff8d70..70209626079 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, From 539fdb1fb21f8f61b81dcd646a7d48d6aa5dc5e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:34:08 +0000 Subject: [PATCH 2/8] wip(spec): reserved-name pins; re-home the live-window cases to test-only probes Claude-Session: https://claude.ai/code/session_01SgC7iCUX8y2TeJAvz8wcaw Co-authored-by: Claude --- .../flow-reserved-node-types.test.ts | 88 ++++++++++++++ .../automation/flow-reserved-node-types.ts | 2 +- .../retired-key-removal-version.pin.test.ts | 10 ++ .../spec/src/stack-conversion-notices.test.ts | 114 ++++++++++++++++++ .../spec/src/stack-conversions-record.test.ts | 44 +++++-- packages/spec/src/stack.test.ts | 78 +----------- 6 files changed, 249 insertions(+), 87 deletions(-) create mode 100644 packages/spec/src/automation/flow-reserved-node-types.test.ts create mode 100644 packages/spec/src/stack-conversion-notices.test.ts 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 00000000000..5aa08862383 --- /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 index 09c1c3f1675..1d508665062 100644 --- a/packages/spec/src/automation/flow-reserved-node-types.ts +++ b/packages/spec/src/automation/flow-reserved-node-types.ts @@ -51,7 +51,7 @@ export function reservedFlowNodeTypeRefusal(type: unknown): string | undefined { ? " 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 protocol 11 unified into \`${canonical}\`, the loader rewrote it to \`${canonical}\` ` + + `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/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 68acb129805..65309dc69d3 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 00000000000..e3fa1d3904b --- /dev/null +++ b/packages/spec/src/stack-conversion-notices.test.ts @@ -0,0 +1,114 @@ +// 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. + 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(`'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' }] }] }, + { 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 73afae47192..74cc7acf7f6 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 5022365e255..836fb2da120 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 = { From b727a86da3f01199759c505474b411d4639d8e95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:45:02 +0000 Subject: [PATCH 3/8] wip: engine refuses an executor under a reserved callout name; D3 entry Claude-Session: https://claude.ai/code/session_01SgC7iCUX8y2TeJAvz8wcaw Co-authored-by: Claude --- .../services/service-automation/src/engine.ts | 40 ++++++++-- .../src/flow-load-conversion.test.ts | 76 ++++++++++++++----- ...8.flow-node-http-callout-types-reserved.ts | 38 ++++++++++ 3 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-node-http-callout-types-reserved.ts diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 4c735c08881..551b5b238f6 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, @@ -4546,9 +4569,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 2dbadc3c4bf..f7f522a6ac9 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/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 00000000000..755b08ee2ac --- /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` and `defineStack`, 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 ' + + '`os migrate meta --from 17` 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.', +}; From 17fb1e3d3eca777d4c02f31c0b7b6d89a4013b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:51:45 +0000 Subject: [PATCH 4/8] wip(test): door suites drive a test-only live conversion probe Claude-Session: https://claude.ai/code/session_01SgC7iCUX8y2TeJAvz8wcaw Co-authored-by: Claude --- .../test/fixtures/live-conversion-probe.mjs | 67 +++++++++++++++++ .../cli/test/helpers/live-conversion-probe.ts | 35 +++++++++ .../test/stack-conversion-record-door.test.ts | 71 +++++++++++++------ .../src/artifact-forward-conversion.test.ts | 70 +++++++++++++++--- 4 files changed, 209 insertions(+), 34 deletions(-) create mode 100644 packages/cli/test/fixtures/live-conversion-probe.mjs create mode 100644 packages/cli/test/helpers/live-conversion-probe.ts 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 00000000000..a4861200c4e --- /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 * as esm 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.ALL_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 00000000000..1e8611a51e8 --- /dev/null +++ b/packages/cli/test/helpers/live-conversion-probe.ts @@ -0,0 +1,35 @@ +// 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', + 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/stack-conversion-record-door.test.ts b/packages/cli/test/stack-conversion-record-door.test.ts index 655d197d467..67d0548400e 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/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 9746e660190..07f3a6fdd27 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'); }); /** From 629bf85b936e2493ea742e344c859744f43bf89f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:56:28 +0000 Subject: [PATCH 5/8] wip(cli): e2e conversion suites drive the test-only live probe Claude-Session: https://claude.ai/code/session_01SgC7iCUX8y2TeJAvz8wcaw Co-authored-by: Claude --- .../validate-json-strict-exit.e2e.test.ts | 71 +++++++++++-------- ...build-json-failure-conversions.e2e.test.ts | 28 ++++---- .../cli/test/helpers/live-conversion-probe.ts | 1 + .../test/lint-conversion-notices.e2e.test.ts | 26 ++++--- ...idate-json-failure-conversions.e2e.test.ts | 28 ++++---- 5 files changed, 90 insertions(+), 64 deletions(-) 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 625b3ad72c5..bca34da64db 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 d74b4925115..cb14fdd1eed 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/helpers/live-conversion-probe.ts b/packages/cli/test/helpers/live-conversion-probe.ts index 1e8611a51e8..dd726a576c3 100644 --- a/packages/cli/test/helpers/live-conversion-probe.ts +++ b/packages/cli/test/helpers/live-conversion-probe.ts @@ -18,6 +18,7 @@ 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; diff --git a/packages/cli/test/lint-conversion-notices.e2e.test.ts b/packages/cli/test/lint-conversion-notices.e2e.test.ts index 27b18a76810..b5693c7423e 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/validate-json-failure-conversions.e2e.test.ts b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts index 8aff1c6503d..c1a6f7e554c 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 () => { From aa36ed07718bfa54fbf167c9a17ef79174405cde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:58:47 +0000 Subject: [PATCH 6/8] wip: changeset; runtime dispatcher fixture names http Claude-Session: https://claude.ai/code/session_01SgC7iCUX8y2TeJAvz8wcaw Co-authored-by: Claude --- ...543-f5-http-callout-node-types-reserved.md | 46 +++++++++++++++++++ packages/runtime/src/http-dispatcher.test.ts | 8 ++-- 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 .changeset/23543-f5-http-callout-node-types-reserved.md 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 00000000000..fe60f73bc3c --- /dev/null +++ b/.changeset/23543-f5-http-callout-node-types-reserved.md @@ -0,0 +1,46 @@ +--- +'@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), and `os validate`, `os build` and `os lint` (whose config calls `defineStack`) print the issue: + +> 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 `os migrate meta --from 17` 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. + +**Not refused yet: a flow saved through the metadata API.** The save door replays every retired flow conversion before it judges a flow body, so a flow saved there with an old name is accepted — stored as `http` when an automation service canonicalizes it, stored as written otherwise and converted on every read. Whether that door should refuse retired flow spellings instead is an open decision covering every retired flow conversion, not this one alone. + +## 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/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index e24f546ea63..f304e134a26 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 () => { From bf004d7ae492273fec9191306fc2ee8f69845dcf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:06:27 +0000 Subject: [PATCH 7/8] chore(spec): regenerate api-surface, export-origins, reference docs; shrink the test-typecheck ledger Claude-Session: https://claude.ai/code/session_01SgC7iCUX8y2TeJAvz8wcaw Co-authored-by: Claude --- content/docs/references/api/automation-api.mdx | 2 +- content/docs/references/automation/control-flow.mdx | 4 ++-- content/docs/references/automation/flow.mdx | 4 ++-- packages/spec/api-surface/automation.json | 2 ++ packages/spec/export-origins/automation.json | 2 ++ packages/spec/src/stack-conversion-notices.test.ts | 7 +++++-- packages/spec/test-typecheck-debt.json | 4 +--- 7 files changed, 15 insertions(+), 10 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 2144840b05b..084d5c5b344 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 fbef1bd26c9..cd180fa93a0 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 f21a90bb837..75652cf4ea4 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/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 9b21a98491b..1103c92fa72 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 5626398aa72..4abe3af59b2 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/stack-conversion-notices.test.ts b/packages/spec/src/stack-conversion-notices.test.ts index e3fa1d3904b..90bbb406a17 100644 --- a/packages/spec/src/stack-conversion-notices.test.ts +++ b/packages/spec/src/stack-conversion-notices.test.ts @@ -68,8 +68,11 @@ describe('defineStack — ADR-0087 D2 conversion notices', () => { // 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)] }, { strict: false }); + defineStack({ manifest, objects: [obj], flows: [legacyFlowAt(index)] as never }, { strict: false }); let warn: ReturnType; beforeEach(() => { @@ -106,7 +109,7 @@ describe('defineStack — ADR-0087 D2 conversion notices', () => { it('stays quiet for a stack that is already canonical', () => { defineStack( - { manifest, objects: [obj], flows: [{ name: 'conv_canonical', nodes: [{ id: 'n1', type: 'http' }] }] }, + { 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/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 366d85d68a7..306f5a69174 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 From f459d50c5b3869cc77fa6bdedced8ad732344eb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:51:02 +0000 Subject: [PATCH 8/8] =?UTF-8?q?fix(spec,cli):=20F5-http=20patch=20round=20?= =?UTF-8?q?=E2=80=94=20named=20spec=20import=20in=20the=20CLI=20probe=20pr?= =?UTF-8?q?eload;=20no=20house-sentence=20marker=20in=20the=20D3=20reason;?= =?UTF-8?q?=20the=20save=20door=20refuses=20a=20new=20flow=20(main's=20#23?= =?UTF-8?q?902),=20so=20the=20changeset=20says=20so?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01SgC7iCUX8y2TeJAvz8wcaw Co-authored-by: Claude --- .changeset/23543-f5-http-callout-node-types-reserved.md | 6 ++---- packages/cli/test/fixtures/live-conversion-probe.mjs | 4 ++-- .../semantic/18.flow-node-http-callout-types-reserved.ts | 8 ++++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.changeset/23543-f5-http-callout-node-types-reserved.md b/.changeset/23543-f5-http-callout-node-types-reserved.md index fe60f73bc3c..5b614482140 100644 --- a/.changeset/23543-f5-http-callout-node-types-reserved.md +++ b/.changeset/23543-f5-http-callout-node-types-reserved.md @@ -20,7 +20,7 @@ Clause-②: yes (narrowing) **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), and `os validate`, `os build` and `os lint` (whose config calls `defineStack`) print the issue: +**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. @@ -28,12 +28,10 @@ The refusal is a `pattern` on `FlowNodeSchema.type`, so the published JSON Schem **`@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 `os migrate meta --from 17` 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. +**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. -**Not refused yet: a flow saved through the metadata API.** The save door replays every retired flow conversion before it judges a flow body, so a flow saved there with an old name is accepted — stored as `http` when an automation service canonicalizes it, stored as written otherwise and converted on every read. Whether that door should refuse retired flow spellings instead is an open decision covering every retired flow conversion, not this one alone. - ## 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. diff --git a/packages/cli/test/fixtures/live-conversion-probe.mjs b/packages/cli/test/fixtures/live-conversion-probe.mjs index a4861200c4e..58f92eb3728 100644 --- a/packages/cli/test/fixtures/live-conversion-probe.mjs +++ b/packages/cli/test/fixtures/live-conversion-probe.mjs @@ -30,7 +30,7 @@ */ import { createRequire } from 'node:module'; -import * as esm from '@objectstack/spec'; +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'; @@ -62,6 +62,6 @@ const probe = { }; const cjs = createRequire(import.meta.url)('@objectstack/spec'); -for (const table of new Set([esm.ALL_CONVERSIONS, cjs.ALL_CONVERSIONS])) { +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/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 index 755b08ee2ac..a994918c4ac 100644 --- 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 @@ -21,12 +21,12 @@ export const entry: SemanticMigration = { + '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` and `defineStack`, 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 ' + + '`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 ' - + '`os migrate meta --from 17` applies no edit for it and prints each such node among the refusals left ' + + '`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 '