From a2b7e8d86807996565ed2129b6c5807db42eb1af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 06:13:46 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(spec):=20ADR-0087=20P1=20=E2=80=94=20m?= =?UTF-8?q?etadata=20conversion=20layer=20(load-time,=20lossless,=20D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the L1 rung of ADR-0087's preference ladder ("break invisibly"): a versioned, declarative, lossless conversion table in @objectstack/spec that rewrites old (N−1) metadata shapes to the canonical protocol-N shape at load — the same normalizeStackInput seam defineStack / validate / lint / info / doctor share — so a consumer still authoring the old shape keeps loading with zero action while the runtime only sees the canonical shape. Each rewrite emits a structured OS_METADATA_CONVERTED notice; `objectstack validate` surfaces them as non-blocking deprecation warnings (and in --json). Seeded with the retroactive protocol-11 renames the ADR names as its calibration set: - flow-node-http-callout-rename: flow callout node types http_request / http_call / webhook → http - page-kind-jsx-to-html: page kind 'jsx' → 'html' (ADR-0080) - flow-node-crud-filter-alias: CRUD flow-node config.filters → config.filter PD #12 retirement path demonstrated: the filters → filter alias is removed from the service-automation executor's readAliasedConfig fallback and promoted into the declared, loud, tested, expiring conversion entry above; the CRUD executors now read the canonical cfg.filter directly. The layer is the deliberate opposite of a PD #12 consumer-side dialect fallback on every axis: one central versioned table (not scattered `cfg.a ?? cfg.b`), declared in the spec, loud (a notice per application), tested (each entry ships an old→new fixture pair driven through the load path), and expiring (applied for one major, then graduated into the P2 migration chain — never deleted). Immutable copy-on-write walkers touch only the changed path, so non-clonable values (plugin instances) are preserved by reference. api-surface additions are purely additive (no breaking removals). Refs #2643, #2645 (ADR-0087 D2). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YA9vjVpDrKLuaK6nbm1s37 --- .changeset/adr-0087-p1-conversion-layer.md | 18 ++ packages/cli/src/commands/validate.ts | 20 +- .../src/builtin/config-aliases.ts | 10 +- .../src/builtin/crud-config-aliases.test.ts | 64 +++++-- .../src/builtin/crud-nodes.ts | 11 +- packages/spec/api-surface.json | 12 ++ packages/spec/src/conversions/apply.ts | 77 ++++++++ .../spec/src/conversions/conversions.test.ts | 169 +++++++++++++++++ packages/spec/src/conversions/index.ts | 24 +++ packages/spec/src/conversions/registry.ts | 175 ++++++++++++++++++ packages/spec/src/conversions/types.ts | 107 +++++++++++ packages/spec/src/conversions/walk.ts | 89 +++++++++ packages/spec/src/index.ts | 5 +- .../src/shared/metadata-collection.zod.ts | 37 +++- 14 files changed, 786 insertions(+), 32 deletions(-) create mode 100644 .changeset/adr-0087-p1-conversion-layer.md create mode 100644 packages/spec/src/conversions/apply.ts create mode 100644 packages/spec/src/conversions/conversions.test.ts create mode 100644 packages/spec/src/conversions/index.ts create mode 100644 packages/spec/src/conversions/registry.ts create mode 100644 packages/spec/src/conversions/types.ts create mode 100644 packages/spec/src/conversions/walk.ts diff --git a/.changeset/adr-0087-p1-conversion-layer.md b/.changeset/adr-0087-p1-conversion-layer.md new file mode 100644 index 0000000000..95ca955a93 --- /dev/null +++ b/.changeset/adr-0087-p1-conversion-layer.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': patch +--- + +ADR-0087 P1:元数据转换层(conversion layer,D2)——大多数破坏性变更对使用方零操作。 + +`@objectstack/spec` 新增 `conversions/` 模块:一张按协议大版本组织、声明式、无损的转换表,在**加载时**(`normalizeStackInput` —— `defineStack` / `objectstack validate` / `lint` / `info` / `doctor` 共用的同一入口)把旧(N−1)形态的元数据改写为规范的 N 形态,并对每处改写发出结构化弃用通知(`OS_METADATA_CONVERTED`)。使用方仍按旧形态编写也能零操作加载,运行时只会看到规范形态。这是把 Kubernetes storage-version/conversion 模型套用到元数据上;它与 Prime Directive #12 禁止的“使用方侧方言兜底”在每个维度上都相反:一张集中、随 spec 版本化、声明化、显式(每次应用都发通知)、带测试(每条附 old→new fixture)、会过期(仅在一个大版本内加载期生效,之后退役并沉淀进 P2 迁移链)的表,而非散落的 `cfg.a ?? cfg.b`。 + +首批以已发布的 protocol 11 重命名回填播种: + +- `flow-node-http-callout-rename`:流程回调节点 `http_request` / `http_call` / `webhook` → `http`。 +- `page-kind-jsx-to-html`:页面 `kind: 'jsx'` → `'html'`(ADR-0080 规范拼写)。 +- `flow-node-crud-filter-alias`:CRUD 流程节点 `config.filters` → `config.filter`。 + +并落实 PD #12 退役路径示范:`filters` → `filter` 别名从 `service-automation` 执行器的 `readAliasedConfig` 兜底中删除,提升为上面这条声明式转换条目;执行器改为直接读取规范键 `cfg.filter`。 + +新增导出(纯增量,无破坏):`applyConversions`、`collectConversionNotices`、`ALL_CONVERSIONS`、`CONVERSIONS_BY_MAJOR`、`CONVERSION_NOTICE_CODE`,以及类型 `MetadataConversion`、`ConversionNotice`、`ConversionApplication`、`ConversionFixture`、`ApplyConversionsOptions`、`NormalizeStackInputOptions`。`normalizeStackInput` 现接受可选第二参 `{ onConversionNotice }`(向后兼容)。 diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 43cd34ee6e..e87e652e99 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -6,7 +6,7 @@ import { createRequire } from 'node:module'; import { join } from 'node:path'; import chalk from 'chalk'; import { ZodError } from 'zod'; -import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; +import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec'; import { loadConfig } from '../utils/config.js'; import { validateStackExpressions } from '@objectstack/lint'; import { validateListViewMode } from '@objectstack/lint'; @@ -58,9 +58,16 @@ export default class Validate extends Command { printKV('Load time', `${duration}ms`); } - // 2. Normalize map-formatted stack definition and validate against schema + // 2. Normalize map-formatted stack definition and validate against schema. + // The ADR-0087 D2 conversion layer runs here (inside normalizeStackInput); + // surface each applied conversion as a non-blocking deprecation notice so + // the author knows the source still carries an old-shape key that will + // retire from the load path in a future major. if (!flags.json) printStep('Validating against ObjectStack Protocol...'); - const normalized = normalizeStackInput(config as Record); + const conversionNotices: ConversionNotice[] = []; + const normalized = normalizeStackInput(config as Record, { + onConversionNotice: (n) => conversionNotices.push(n), + }); const result = ObjectStackDefinitionSchema.safeParse(normalized); if (!result.success) { @@ -341,6 +348,7 @@ export default class Validate extends Command { manifest: config.manifest, stats, warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings], + conversions: conversionNotices, duration: timer.elapsed(), }, null, 2)); return; @@ -349,6 +357,12 @@ export default class Validate extends Command { // 5. Warnings (non-blocking) const warnings: string[] = []; + // ADR-0087 D2 conversion notices: the source used a deprecated shape that + // was auto-converted at load. No action is required to keep loading, but + // the notice steers the author to the canonical key before it retires. + for (const n of conversionNotices) { + warnings.push(`${n.path}: '${n.from}' → '${n.to}' (converted at load; conversion '${n.conversionId}', retires in protocol ${n.retiresIn})`); + } for (const i of exprWarnings) { warnings.push(`${i.where}: ${i.message}`); } diff --git a/packages/services/service-automation/src/builtin/config-aliases.ts b/packages/services/service-automation/src/builtin/config-aliases.ts index a4fe56174c..e1a361574a 100644 --- a/packages/services/service-automation/src/builtin/config-aliases.ts +++ b/packages/services/service-automation/src/builtin/config-aliases.ts @@ -25,7 +25,15 @@ * the canonical key. Removal of the alias paths is tracked as a follow-up and * happens once the window has elapsed and graph-lint has been enforcing. * - * @see crud-nodes.ts for the first call sites (`objectName`, `filter`). + * The `filters` → `filter` alias has since been **retired from this shim** and + * promoted into the ADR-0087 D2 conversion layer (`@objectstack/spec` conversion + * `flow-node-crud-filter-alias`): it is rewritten to the canonical key **at + * load**, so the CRUD executors read `cfg.filter` directly. That is the PD #12 + * retirement path the ADR prescribes — a scattered consumer-side fallback + * replaced by one declared, loud, tested, expiring conversion entry. The + * remaining `object` → `objectName` alias is the next candidate to graduate. + * + * @see crud-nodes.ts for the remaining call site (`objectName`). */ /** One-time-warning ledger, keyed by `${nodeType}:${canonical}<-${alias}`. */ diff --git a/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts b/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts index 260dd92139..2c19a0a9f2 100644 --- a/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts +++ b/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts @@ -1,12 +1,20 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Deprecation window for non-canonical `config` keys on the CRUD nodes - * (`object` → `objectName`, `filters` → `filter`). The alias keeps working so - * already-stored flows keep running, but emits a one-time `logger.warn` steering - * the author to the canonical key. See config-aliases.ts. + * Deprecation window for non-canonical `config` keys on the CRUD nodes. + * + * Two aliases, now handled at two different layers: + * - `object` → `objectName` is still tolerated by the **executor** shim + * (`readAliasedConfig`), warning once per alias. See config-aliases.ts. + * - `filters` → `filter` has been **retired from the executor** and promoted + * into the ADR-0087 D2 conversion layer (`@objectstack/spec` conversion + * `flow-node-crud-filter-alias`): it is rewritten to `filter` at load, so a + * raw `filters` key reaching the executor directly (a flow that skipped the + * load seam) is no longer honored, and the executor emits no alias warning + * for it. This test documents that split — the PD #12 retirement path. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { normalizeStackInput } from '@objectstack/spec'; import { AutomationEngine } from '../engine.js'; import { registerCrudNodes } from './crud-nodes.js'; import { __resetAliasDeprecationWarnings } from './config-aliases.js'; @@ -55,43 +63,63 @@ function getRecordFlow(config: Record) { } as any; } -describe('CRUD config-key alias deprecation (object→objectName, filters→filter)', () => { +describe('CRUD config-key aliases: object→objectName (executor shim) + filters→filter (retired to load-time conversion)', () => { beforeEach(() => __resetAliasDeprecationWarnings()); - it('still resolves the deprecated `object` + `filters` aliases at runtime', async () => { + it('still resolves the deprecated `object` alias at runtime, warning once', async () => { const engine = new AutomationEngine(silentLogger()); const { data, calls } = fakeData(); const warns: string[] = []; registerCrudNodes(engine, ctxWith(data, collectingLogger(warns))); - engine.registerFlow('gr', getRecordFlow({ object: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' })); + // Canonical `filter`; deprecated `object`. + engine.registerFlow('gr', getRecordFlow({ object: 'crm_lead', filter: { id: 'L1' }, outputVariable: 'lead' })); const res = await engine.execute('gr'); expect(res.success).toBe(true); - // The alias values reached the data engine unchanged. expect(calls).toHaveLength(1); expect(calls[0].obj).toBe('crm_lead'); expect(calls[0].opts.where).toEqual({ id: 'L1' }); + + const objectWarn = warns.find((w) => w.includes("'object'") && w.includes("'objectName'")); + expect(objectWarn).toBeTruthy(); + + // One-time per alias: a second run does not warn again. + const before = warns.length; + await engine.execute('gr'); + expect(warns.length).toBe(before); }); - it('warns once per alias, naming the canonical key', async () => { + it('no longer honors a raw `filters` alias in the executor (retired to the D2 conversion layer)', async () => { const engine = new AutomationEngine(silentLogger()); - const { data } = fakeData(); + const { data, calls } = fakeData(); const warns: string[] = []; registerCrudNodes(engine, ctxWith(data, collectingLogger(warns))); - engine.registerFlow('gr', getRecordFlow({ object: 'crm_lead', filters: { id: 'L1' } })); + // A flow that reached the executor WITHOUT going through the load conversion + // still carries `filters`. The executor now reads only the canonical `filter`, + // so `filters` is ignored and no `filters`→`filter` warning is emitted here. + engine.registerFlow('gr', getRecordFlow({ objectName: 'crm_lead', filters: { id: 'L1' } })); await engine.execute('gr'); - const objectWarn = warns.find((w) => w.includes("'object'") && w.includes("'objectName'")); - const filterWarn = warns.find((w) => w.includes("'filters'") && w.includes("'filter'")); - expect(objectWarn).toBeTruthy(); - expect(filterWarn).toBeTruthy(); + expect(calls[0].opts.where).toEqual({}); // `filters` no longer honored by the executor + const filterWarn = warns.find((w) => w.includes("'filters'")); + expect(filterWarn).toBeFalsy(); + }); - // Second run in the same process must NOT warn again (one-time per alias). - const before = warns.length; + it('the D2 conversion at load rewrites `filters` → `filter` so the flow works end-to-end', async () => { + const engine = new AutomationEngine(silentLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data, silentLogger())); + + // Author with the deprecated `filters` key, then run it through the same load + // seam a real stack load uses. The conversion canonicalizes it to `filter`. + const raw = getRecordFlow({ objectName: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' }); + const converted = (normalizeStackInput({ flows: [raw] }).flows as any[])[0]; + engine.registerFlow('gr', converted); await engine.execute('gr'); - expect(warns.length).toBe(before); + + expect(calls[0].opts.where).toEqual({ id: 'L1' }); }); it('does NOT warn when the canonical keys are used', async () => { diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 89fd8474ad..4dbcb1e605 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -47,7 +47,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): const objectName = String(readAliasedConfig(cfg, 'get_record', 'objectName', ['object'], ctx.logger) ?? ''); if (!objectName) return { success: false, error: 'get_record: objectName required' }; - const filter = interpolate(readAliasedConfig(cfg, 'get_record', 'filter', ['filters'], ctx.logger) ?? {}, variables, context) as Record; + // `filters` → `filter` is now handled at load by the ADR-0087 D2 + // conversion layer ('flow-node-crud-filter-alias'), so the executor + // reads the canonical key directly (PD #12 fallback retired). + const filter = interpolate(cfg.filter ?? {}, variables, context) as Record; const fields = cfg.fields as string[] | undefined; const limit = typeof cfg.limit === 'number' ? cfg.limit : undefined; const outputVariable = cfg.outputVariable as string | undefined; @@ -138,7 +141,8 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): const objectName = String(readAliasedConfig(cfg, 'update_record', 'objectName', ['object'], ctx.logger) ?? ''); if (!objectName) return { success: false, error: 'update_record: objectName required' }; - const filter = interpolate(readAliasedConfig(cfg, 'update_record', 'filter', ['filters'], ctx.logger) ?? {}, variables, context) as Record; + // `filters` → `filter` converted at load (ADR-0087 D2); read canonical. + const filter = interpolate(cfg.filter ?? {}, variables, context) as Record; // `fields` is the single canonical write-map key — no alias (the wrong key // `fieldValues` is corrected at the authoring source + rejected by graph-lint). const fields = interpolate(cfg.fields ?? {}, variables, context) as Record; @@ -173,7 +177,8 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): const objectName = String(readAliasedConfig(cfg, 'delete_record', 'objectName', ['object'], ctx.logger) ?? ''); if (!objectName) return { success: false, error: 'delete_record: objectName required' }; - const filter = interpolate(readAliasedConfig(cfg, 'delete_record', 'filter', ['filters'], ctx.logger) ?? {}, variables, context) as Record; + // `filters` → `filter` converted at load (ADR-0087 D2); read canonical. + const filter = interpolate(cfg.filter ?? {}, variables, context) as Record; const data = getData(); if (!data) return { success: true }; diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 0b57bac528..2ccc748534 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1,8 +1,10 @@ { ".": [ "ADMIN_FULL_ACCESS (const)", + "ALL_CONVERSIONS (const)", "AUDIENCE_ANCHOR_POSITIONS (const)", "Agent (type)", + "ApplyConversionsOptions (interface)", "BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES (const)", "BUILTIN_IDENTITY_ORG_ADMIN (const)", @@ -10,10 +12,15 @@ "BUILTIN_IDENTITY_ORG_OWNER (const)", "BUILTIN_IDENTITY_PLATFORM_ADMIN (const)", "BuiltinIdentityName (type)", + "CONVERSIONS_BY_MAJOR (const)", + "CONVERSION_NOTICE_CODE (const)", "ComposeStacksOptions (type)", "ComposeStacksOptionsSchema (const)", "ConflictStrategy (type)", "ConflictStrategySchema (const)", + "ConversionApplication (interface)", + "ConversionFixture (interface)", + "ConversionNotice (interface)", "CronExpressionInputSchema (const)", "DatasourceMappingRule (type)", "DatasourceMappingRuleSchema (const)", @@ -37,6 +44,8 @@ "METADATA_ALIASES (const)", "MapSupportedField (type)", "MetadataCollectionInput (type)", + "MetadataConversion (interface)", + "NormalizeStackInputOptions (interface)", "ObjectOSCapabilities (type)", "ObjectOSCapabilitiesSchema (const)", "ObjectQLCapabilities (type)", @@ -60,7 +69,9 @@ "TemplateExpressionInputSchema (const)", "Tool (type)", "ViewKeyCollision (interface)", + "applyConversions (function)", "cel (function)", + "collectConversionNotices (function)", "composeStacks (function)", "createEvalUser (function)", "cron (function)", @@ -4347,6 +4358,7 @@ "MetadataFormatSchema (const)", "MutationEvent (type)", "MutationEventEnum (const)", + "NormalizeStackInputOptions (interface)", "ObjectName (type)", "ObjectNameSchema (const)", "ObjectStackRawIssue (type)", diff --git a/packages/spec/src/conversions/apply.ts b/packages/spec/src/conversions/apply.ts new file mode 100644 index 0000000000..0b43261075 --- /dev/null +++ b/packages/spec/src/conversions/apply.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The central conversion pass (ADR-0087 D2). + * + * {@link applyConversions} runs every registered {@link MetadataConversion} + * against a normalized stack, threading the (immutably updated) stack through + * each entry and turning each rewrite into a structured {@link ConversionNotice}. + * It is wired into `normalizeStackInput`, so it fires on the single seam every + * load path funnels through — `defineStack`, `objectstack validate`, `lint`, + * `info`, and `doctor`. + */ + +import { ALL_CONVERSIONS } from './registry.js'; +import { CONVERSION_NOTICE_CODE, type ConversionNotice } from './types.js'; + +export interface ApplyConversionsOptions { + /** + * Sink for each structured notice. Defaults to a no-op: converting the shape + * is the point (zero consumer action); *surfacing* the notice is the caller's + * choice. `objectstack validate` passes a sink that prints them. + */ + onNotice?: (notice: ConversionNotice) => void; +} + +/** + * Apply the whole conversion table to a normalized stack. + * + * Pure and immutable: returns the original reference untouched when nothing + * converts, otherwise a copy-on-write stack with old shapes rewritten to + * canonical. Never throws — a conversion only rewrites shapes it positively + * recognizes, mirroring the handshake's "never false-reject" discipline (D1). + */ +export function applyConversions( + stack: Record, + options: ApplyConversionsOptions = {}, +): Record { + const { onNotice } = options; + let current = stack; + + for (const conversion of ALL_CONVERSIONS) { + const retiresIn = conversion.toMajor + 1; + current = conversion.apply(current, (detail) => { + if (!onNotice) return; + onNotice({ + code: CONVERSION_NOTICE_CODE, + conversionId: conversion.id, + surface: conversion.surface, + toMajor: conversion.toMajor, + retiresIn, + from: detail.from, + to: detail.to, + path: detail.path, + message: + `[protocol] converted ${conversion.surface} at ${detail.path}: ` + + `'${detail.from}' → '${detail.to}' (deprecated; ADR-0087 conversion ` + + `'${conversion.id}', retires from the load path in protocol ${retiresIn}). ` + + `Update the source to '${detail.to}'.`, + }); + }); + } + + return current; +} + +/** + * Collect the notices a stack would emit without needing an external sink — + * convenience for `validate` / `lint` / the future MCP `spec_deprecations` tool. + */ +export function collectConversionNotices(stack: Record): { + stack: Record; + notices: ConversionNotice[]; +} { + const notices: ConversionNotice[] = []; + const converted = applyConversions(stack, { onNotice: (n) => notices.push(n) }); + return { stack: converted, notices }; +} diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts new file mode 100644 index 0000000000..adef333922 --- /dev/null +++ b/packages/spec/src/conversions/conversions.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; +import { applyConversions, collectConversionNotices } from './apply.js'; +import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; +import { CONVERSION_NOTICE_CODE, type ConversionNotice } from './types.js'; + +describe('conversion layer (ADR-0087 D2)', () => { + describe('fixture pairs — every entry converts old shape → canonical', () => { + for (const conversion of ALL_CONVERSIONS) { + it(`${conversion.id}: before → after, emits ${conversion.fixture.expectedNotices} notice(s)`, () => { + const { stack, notices } = collectConversionNotices( + structuredClone(conversion.fixture.before), + ); + // The whole table runs, but fixtures are disjoint, so the result must + // equal exactly this entry's `after`. + expect(stack).toEqual(conversion.fixture.after); + expect(notices).toHaveLength(conversion.fixture.expectedNotices); + // Every notice this fixture produced must come from this conversion. + for (const n of notices) { + expect(n.conversionId).toBe(conversion.id); + expect(n.code).toBe(CONVERSION_NOTICE_CODE); + expect(n.toMajor).toBe(conversion.toMajor); + expect(n.retiresIn).toBe(conversion.toMajor + 1); + expect(n.surface).toBe(conversion.surface); + } + }); + } + }); + + describe('immutability & non-interference', () => { + it('returns the same reference when nothing converts', () => { + const clean = { objects: [{ name: 'account' }], flows: [{ name: 'f', nodes: [] }] }; + expect(applyConversions(clean)).toBe(clean); + }); + + 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); + expect(before).toEqual(snapshot); + }); + + it('shares untouched branches (copy-on-write, so plugins survive)', () => { + const plugin = { onEnable() {} }; // non-clonable value that must be preserved by reference + const stack: Record = { + plugins: [plugin], + pages: [{ name: 'p', kind: 'jsx', source: '
' }], + }; + const out = applyConversions(stack); + expect(out).not.toBe(stack); + expect((out.plugins as unknown[])[0]).toBe(plugin); // same reference, untouched + }); + }); + + describe('flow-node-http-callout-rename', () => { + it('rewrites http_request / http_call / webhook → http, leaving http untouched', () => { + const { stack, notices } = collectConversionNotices({ + flows: [ + { + name: 'f', + nodes: [ + { id: 'a', type: 'http_call' }, + { id: 'b', type: 'http' }, + { id: 'c', type: 'webhook' }, + ], + }, + ], + }); + 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 + expect(notices.map((n) => n.path)).toEqual(['flows[0].nodes[0].type', 'flows[0].nodes[2].type']); + }); + }); + + describe('flow-node-crud-filter-alias (PD #12 retirement)', () => { + it('renames config.filters → config.filter only for CRUD node types', () => { + const { stack, notices } = collectConversionNotices({ + flows: [ + { + name: 'f', + nodes: [ + { id: 'a', type: 'get_record', config: { objectName: 'lead', filters: { x: 1 } } }, + // non-CRUD type: `filters` is left alone (not this conversion's surface) + { id: 'b', type: 'custom', config: { filters: { y: 2 } } }, + ], + }, + ], + }); + const nodes = (stack.flows as any[])[0].nodes; + expect(nodes[0].config).toEqual({ objectName: 'lead', filter: { x: 1 } }); + expect(nodes[1].config).toEqual({ filters: { y: 2 } }); + expect(notices).toHaveLength(1); + }); + + it('does not clobber an existing canonical filter', () => { + const { stack, notices } = collectConversionNotices({ + flows: [ + { + name: 'f', + nodes: [ + { + id: 'a', + type: 'delete_record', + config: { filter: { keep: true }, filters: { drop: true } }, + }, + ], + }, + ], + }); + const node = (stack.flows as any[])[0].nodes[0]; + expect(node.config.filter).toEqual({ keep: true }); + expect(notices).toHaveLength(0); // canonical present → no conversion + }); + }); + + describe('registry invariants', () => { + it('every conversion carries a fixture pair and a positive retirement window', () => { + for (const c of ALL_CONVERSIONS) { + expect(c.fixture.before).toBeTypeOf('object'); + expect(c.fixture.after).toBeTypeOf('object'); + expect(c.toMajor).toBeGreaterThan(0); + } + }); + + it('conversion ids are unique', () => { + const ids = ALL_CONVERSIONS.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('ALL_CONVERSIONS is the flattened CONVERSIONS_BY_MAJOR', () => { + const flat = Object.values(CONVERSIONS_BY_MAJOR).flat(); + expect(ALL_CONVERSIONS).toHaveLength(flat.length); + }); + }); + + describe('normalizeStackInput integration (the load seam)', () => { + it('converts at load and surfaces notices through the sink', () => { + const notices: ConversionNotice[] = []; + const out = normalizeStackInput( + { pages: [{ name: 'p', kind: 'jsx', source: '
' }] }, + { onConversionNotice: (n) => notices.push(n) }, + ); + expect((out.pages as any[])[0].kind).toBe('html'); + expect(notices).toHaveLength(1); + expect(notices[0]!.message).toContain("'jsx' → 'html'"); + }); + + it('still converts silently when no sink is provided (zero consumer action)', () => { + const out = normalizeStackInput({ pages: [{ name: 'p', kind: 'jsx', source: '
' }] }); + expect((out.pages as any[])[0].kind).toBe('html'); + }); + + it('normalizes map collections and converts in one pass', () => { + const out = normalizeStackInput({ + flows: { my_flow: { nodes: [{ id: 'n', type: 'webhook' }] } } 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/index.ts b/packages/spec/src/conversions/index.ts new file mode 100644 index 0000000000..20888d487e --- /dev/null +++ b/packages/spec/src/conversions/index.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Metadata conversion layer (ADR-0087 D2) — public surface. + * + * The versioned, declarative table that lets a consumer authoring an old + * (N−1) metadata shape keep loading with zero action under protocol N, while + * the runtime sees only the canonical shape. See {@link ./types} for the design + * rationale and the PD #12 boundary. + */ + +export { + CONVERSION_NOTICE_CODE, + type ConversionApplication, + type ConversionFixture, + type ConversionNotice, + type MetadataConversion, +} from './types.js'; +export { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; +export { + applyConversions, + collectConversionNotices, + type ApplyConversionsOptions, +} from './apply.js'; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts new file mode 100644 index 0000000000..f39c418d55 --- /dev/null +++ b/packages/spec/src/conversions/registry.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The metadata conversion table (ADR-0087 D2). + * + * Seeded with the **retroactive protocol-11 renames** — the calibration set the + * ADR names: had this layer existed, protocol 11 would have needed *zero* + * consumer action for these. Each entry is lossless, declared, loud, tested, and + * expiring (see {@link MetadataConversion}). + * + * Entries are grouped by the major that introduced the canonical shape + * (`toMajor`): a runtime on major N applies every conversion with + * `toMajor === N` (it accepts the N−1 shape at load), and the N+1 loader retires + * them — graduating them into the P2 migration chain rather than deleting them. + * Until P2 exists these remain the permanent, replayable transform history. + */ + +import type { MetadataConversion } from './types.js'; +import { mapFlowNodes, mapPages, renameConfigKey } from './walk.js'; + +/** + * Flow callout node type rename (protocol 11.0). + * + * 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. + */ +const flowNodeHttpRename: MetadataConversion = { + id: 'flow-node-http-callout-rename', + toMajor: 11, + surface: 'flow.node.type', + summary: "flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http'", + apply(stack, emit) { + const aliases = new Set(['http_request', 'http_call', 'webhook']); + return mapFlowNodes(stack, (node, path) => { + const type = node.type; + if (typeof type !== 'string' || !aliases.has(type)) return node; + emit({ from: type, to: 'http', path: `${path}.type` }); + return { ...node, type: 'http' }; + }); + }, + fixture: { + before: { + flows: [ + { + name: 'notify_flow', + nodes: [ + { id: 'n1', type: 'start' }, + { id: 'n2', type: 'http_request', config: { url: 'https://example.com' } }, + { id: 'n3', type: 'webhook', config: { url: 'https://hooks.example.com' } }, + ], + }, + ], + }, + after: { + flows: [ + { + name: 'notify_flow', + nodes: [ + { id: 'n1', type: 'start' }, + { id: 'n2', type: 'http', config: { url: 'https://example.com' } }, + { id: 'n3', type: 'http', config: { url: 'https://hooks.example.com' } }, + ], + }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * Page `kind: 'jsx'` → `kind: 'html'` (protocol 11.4). + * + * `'jsx'` is a documented deprecated alias of the canonical `'html'` page kind + * (ADR-0080; see `spec/src/ui/page.zod.ts`). The `source` semantics are + * identical, so the rename is lossless. + */ +const pageKindJsxToHtml: MetadataConversion = { + id: 'page-kind-jsx-to-html', + toMajor: 11, + surface: 'page.kind', + summary: "page kind 'jsx' → 'html' (ADR-0080 canonical spelling)", + apply(stack, emit) { + return mapPages(stack, (page, path) => { + if (page.kind !== 'jsx') return page; + emit({ from: 'jsx', to: 'html', path: `${path}.kind` }); + return { ...page, kind: 'html' }; + }); + }, + fixture: { + before: { + pages: [{ name: 'landing', kind: 'jsx', source: '
hi
' }], + }, + after: { + pages: [{ name: 'landing', kind: 'html', source: '
hi
' }], + }, + expectedNotices: 1, + }, +}; + +/** + * CRUD flow-node `config.filters` → `config.filter` (protocol 11.0). + * + * This entry demonstrates ADR-0087's **PD #12 retirement path** (issue #2645): + * the `get_record` / `update_record` / `delete_record` executors historically + * tolerated the `filters` alias via a consumer-side + * `readAliasedConfig(cfg, …, 'filter', ['filters'], …)` fallback. That scattered + * dialect tolerance is promoted here into one declared, expiring conversion and + * the executor fallback is deleted: the load path now hands the executor the + * canonical `filter` key, so the executor reads `cfg.filter` directly. + */ +const flowNodeFilterAlias: MetadataConversion = { + id: 'flow-node-crud-filter-alias', + toMajor: 11, + surface: 'flow.node.config.filter', + summary: "CRUD flow-node config key 'filters' → 'filter'", + apply(stack, emit) { + const crudTypes = new Set(['get_record', 'update_record', 'delete_record']); + return mapFlowNodes(stack, (node, path) => { + if (typeof node.type !== 'string' || !crudTypes.has(node.type)) return node; + const renamed = renameConfigKey(node, 'filters', 'filter'); + if (!renamed) return node; + emit({ from: 'filters', to: 'filter', path: `${path}.config.filter` }); + return renamed; + }); + }, + fixture: { + before: { + flows: [ + { + name: 'purge_flow', + nodes: [ + { id: 'n1', type: 'start' }, + { + id: 'n2', + type: 'delete_record', + config: { objectName: 'lead', filters: { status: 'stale' } }, + }, + ], + }, + ], + }, + after: { + flows: [ + { + name: 'purge_flow', + nodes: [ + { id: 'n1', type: 'start' }, + { + id: 'n2', + type: 'delete_record', + config: { objectName: 'lead', filter: { status: 'stale' } }, + }, + ], + }, + ], + }, + expectedNotices: 1, + }, +}; + +/** + * All conversions, keyed by the protocol major that introduced the canonical + * shape. Newest majors last; ordering within a major is application order. + */ +export const CONVERSIONS_BY_MAJOR: Readonly> = { + 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias], +}; + +/** Flattened, deterministic list of every conversion the loader knows about. */ +export const ALL_CONVERSIONS: readonly MetadataConversion[] = Object.keys(CONVERSIONS_BY_MAJOR) + .map(Number) + .sort((a, b) => a - b) + .flatMap((major) => CONVERSIONS_BY_MAJOR[major]!); diff --git a/packages/spec/src/conversions/types.ts b/packages/spec/src/conversions/types.ts new file mode 100644 index 0000000000..6063700d55 --- /dev/null +++ b/packages/spec/src/conversions/types.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Types for the metadata **conversion layer** (ADR-0087 D2). + * + * The conversion layer is the L1 rung of ADR-0087's preference ladder: *break + * invisibly*. For every lossless protocol break — a rename, a field move, an + * enum re-spelling, an alias removal — the spec ships a declarative transform + * from the **N−1 shape** to the **N shape**, applied **centrally at load** (the + * same `normalizeStackInput` seam `objectstack validate` uses). A consumer that + * still authors the old shape keeps loading with **zero action**; the runtime + * only ever sees the canonical shape. + * + * This is the Kubernetes storage-version / conversion model applied to + * metadata, and it is deliberately the opposite of a Prime-Directive-#12 + * consumer-side dialect fallback on every axis (ADR-0087 §"Why the conversion + * layer does not violate PD #12"): + * + * - **one** central, versioned table — not N scattered `cfg.a ?? cfg.b`s; + * - **declared in the spec** — the contract owns its own history; + * - **loud** — every application emits a structured {@link ConversionNotice}; + * - **tested** — each entry ships an old→new {@link ConversionFixture} pair; + * - **expiring** — applied by the loader for exactly one major, then retired + * from the load path (graduating into the P2 migration chain, never deleted). + */ + +/** Stable code stamped on every conversion notice — greppable, MCP-serializable. */ +export const CONVERSION_NOTICE_CODE = 'OS_METADATA_CONVERTED' as const; + +/** + * A structured deprecation notice emitted once per applied conversion. + * + * Machine-readable first (ADR-0087 D4): the loader, `validate`, and the future + * MCP `spec_deprecations` tool all consume this shape, not prose. `message` is + * the derived human line; every other field is data. + */ +export interface ConversionNotice { + code: typeof CONVERSION_NOTICE_CODE; + /** The {@link MetadataConversion.id} that fired. */ + conversionId: string; + /** Dotted surface the conversion governs, e.g. `flow.node.type`. */ + surface: string; + /** The protocol major that introduced the canonical shape (accepts N−1 at load). */ + toMajor: number; + /** The protocol major in which this conversion retires from the load path (`toMajor + 1`). */ + retiresIn: number; + /** The off-spec token/shape actually seen in the source. */ + from: string; + /** The canonical token/shape it was converted to. */ + to: string; + /** Where in the stack it applied, e.g. `flows[0].nodes[2].type`. */ + path: string; + /** Derived, human-facing one-liner (prose is derived, never the source of truth). */ + message: string; +} + +/** The per-application detail a conversion reports; the registry derives the full notice. */ +export interface ConversionApplication { + from: string; + to: string; + path: string; +} + +/** + * An old-shape → new-shape fixture pair. Every conversion entry ships one; a CI + * check drives `before` through the load path and asserts it equals `after` and + * emits exactly `expectedNotices` notices (ADR-0087 D2: "each entry carries an + * old-shape → new-shape fixture pair"). + */ +export interface ConversionFixture { + /** A minimal stack authored in the old (N−1) shape. */ + before: Record; + /** The same stack after the conversion runs. */ + after: Record; + /** How many notices `before` is expected to emit (usually the count of old-shape sites). */ + expectedNotices: number; +} + +/** + * A single declarative, lossless metadata conversion. + * + * `apply` is a **pure, immutable** transform: it returns a stack with the old + * shape rewritten to the canonical one (copy-on-write — untouched branches are + * shared, so `plugins` and other non-clonable values are never touched), and + * reports each rewrite via `emit`. Registry glue turns each + * {@link ConversionApplication} into a full {@link ConversionNotice}. + */ +export interface MetadataConversion { + /** Stable, kebab-case id; also the migration-chain step id when this graduates (P2). */ + id: string; + /** The protocol major that introduced the canonical shape. */ + toMajor: number; + /** Dotted surface, e.g. `flow.node.type`, `page.kind`, `flow.node.config`. */ + surface: string; + /** One-line human summary of the rename/move (the load-bearing prose, kept to one field). */ + summary: string; + /** + * Apply the conversion to a normalized stack, immutably. Returns the (possibly + * new) stack and calls `emit` once per rewritten site. + */ + apply( + stack: Record, + emit: (detail: ConversionApplication) => void, + ): Record; + /** Old→new fixture pair driving the CI check. */ + fixture: ConversionFixture; +} diff --git a/packages/spec/src/conversions/walk.ts b/packages/spec/src/conversions/walk.ts new file mode 100644 index 0000000000..1e018380e5 --- /dev/null +++ b/packages/spec/src/conversions/walk.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Copy-on-write walkers over the collections conversions target. + * + * A conversion must rewrite deep-nested metadata (`flows[].nodes[]`, `pages[]`) + * without mutating the caller's input and without cloning branches it doesn't + * touch — `normalizeStackInput` shares array/object references from the caller's + * definition, and an ObjectStack stack can carry non-clonable values (plugin + * instances with methods), so a blanket `structuredClone` is both wasteful and + * unsafe. These helpers copy **only** the path from the root down to a changed + * leaf; if a mapper returns its input unchanged, the original references are + * preserved all the way up. + */ + +type Dict = Record; + +function isDict(v: unknown): v is Dict { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Immutably map every flow node in `stack.flows[].nodes[]`. + * + * `mapper` receives each node dict and its path (`flows[i].nodes[j]`) and + * returns either the same reference (no change) or a new dict. The stack, the + * `flows` array, an individual flow, and its `nodes` array are each copied only + * when a descendant actually changed. + */ +export function mapFlowNodes( + stack: Dict, + mapper: (node: Dict, path: string) => Dict, +): Dict { + const flows = stack.flows; + if (!Array.isArray(flows)) return stack; + + let flowsChanged = false; + const nextFlows = flows.map((flow, fi) => { + if (!isDict(flow) || !Array.isArray(flow.nodes)) return flow; + let nodesChanged = false; + const nextNodes = flow.nodes.map((node, ni) => { + if (!isDict(node)) return node; + const mapped = mapper(node, `flows[${fi}].nodes[${ni}]`); + if (mapped !== node) nodesChanged = true; + return mapped; + }); + if (!nodesChanged) return flow; + flowsChanged = true; + return { ...flow, nodes: nextNodes }; + }); + + if (!flowsChanged) return stack; + return { ...stack, flows: nextFlows }; +} + +/** + * Immutably map every page in `stack.pages[]`. + * + * `mapper` receives each page dict and its path (`pages[i]`) and returns the + * same reference (no change) or a new dict. The stack and `pages` array are + * copied only when a page actually changed. + */ +export function mapPages(stack: Dict, mapper: (page: Dict, path: string) => Dict): Dict { + const pages = stack.pages; + if (!Array.isArray(pages)) return stack; + + let changed = false; + const nextPages = pages.map((page, pi) => { + if (!isDict(page)) return page; + const mapped = mapper(page, `pages[${pi}]`); + if (mapped !== page) changed = true; + return mapped; + }); + + if (!changed) return stack; + return { ...stack, pages: nextPages }; +} + +/** Rename `config[from]` → `config[to]` on a node dict, immutably, only if `to` is absent. */ +export function renameConfigKey(node: Dict, from: string, to: string): Dict | null { + const config = node.config; + if (!isDict(config)) return null; + if (!(from in config) || config[from] == null) return null; + if (config[to] != null) return null; // canonical already wins — nothing to do + const nextConfig: Dict = { ...config }; + nextConfig[to] = nextConfig[from]; + delete nextConfig[from]; + return { ...node, config: nextConfig }; +} diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 90ea92514b..ce0f1e3e32 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -108,7 +108,10 @@ export type { Skill } from './ai/skill.zod'; export { objectStackErrorMap, formatZodError, formatZodIssue, safeParsePretty } from './shared/error-map.zod'; export { suggestFieldType, findClosestMatches, formatSuggestion } from './shared/suggestions.zod'; export { normalizeMetadataCollection, normalizeStackInput, normalizePluginMetadata, MAP_SUPPORTED_FIELDS, METADATA_ALIASES } from './shared/metadata-collection.zod'; -export type { MetadataCollectionInput, MapSupportedField } from './shared/metadata-collection.zod'; +export type { MetadataCollectionInput, MapSupportedField, NormalizeStackInputOptions } from './shared/metadata-collection.zod'; + +// Metadata conversion layer (ADR-0087 D2) — old-shape → canonical-shape transforms applied at load. +export * from './conversions/index.js'; export { type PluginContext } from './kernel/plugin.zod'; diff --git a/packages/spec/src/shared/metadata-collection.zod.ts b/packages/spec/src/shared/metadata-collection.zod.ts index cc0c589e86..94b142e40f 100644 --- a/packages/spec/src/shared/metadata-collection.zod.ts +++ b/packages/spec/src/shared/metadata-collection.zod.ts @@ -32,6 +32,9 @@ * @module */ +import { applyConversions } from '../conversions/apply.js'; +import type { ConversionNotice } from '../conversions/types.js'; + /** * Input type for metadata collections: accepts either an array or a named map. * When using map format, the key is injected as the `name` field of each item. @@ -206,24 +209,46 @@ export function normalizeMetadataCollection(value: unknown, keyField = 'name'): return value; } +/** + * Options for {@link normalizeStackInput}. + */ +export interface NormalizeStackInputOptions { + /** + * Sink for the structured deprecation notices emitted by the ADR-0087 D2 + * conversion pass (one per rewritten old-shape site). Defaults to a no-op: + * the conversion still runs (zero consumer action is the point), but the + * notice is only *surfaced* when a caller asks — `objectstack validate` + * passes a sink that prints them. + */ + onConversionNotice?: (notice: ConversionNotice) => void; +} + /** * Normalize all metadata collections in a stack definition input. - * Converts any map-formatted collections to arrays with key→name injection. - * + * Converts any map-formatted collections to arrays with key→name injection, + * then runs the ADR-0087 D2 **conversion layer** so old (N−1) metadata shapes + * are rewritten to the canonical protocol-N shape at load — the single seam + * every load path (`defineStack`, `validate`, `lint`, `info`, `doctor`) shares. + * * This function is applied to the raw input before Zod validation, * ensuring the canonical internal format is always arrays. - * + * * @param input - The raw stack definition input - * @returns A new object with all map collections normalized to arrays + * @param options - Optional conversion-notice sink (see {@link NormalizeStackInputOptions}) + * @returns A new object with all map collections normalized to arrays and + * off-spec shapes converted to canonical */ -export function normalizeStackInput>(input: T): T { +export function normalizeStackInput>( + input: T, + options: NormalizeStackInputOptions = {}, +): T { const result = { ...input }; for (const field of MAP_SUPPORTED_FIELDS) { if (field in result) { (result as Record)[field] = normalizeMetadataCollection(result[field]); } } - return result; + return applyConversions(result, { onNotice: options.onConversionNotice }) as T; } /** From 40b329eec6e8639b448a7fda0a45af474bc97341 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:00:10 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(spec,cli):=20ADR-0087=20P2=20=E2=80=94?= =?UTF-8?q?=20replayable=20migration=20chain=20+=20spec-changes=20manifest?= =?UTF-8?q?=20(D3/D4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the L2 rung ("break executably") and the machine-readable release record on top of P1's conversion layer. D3 — migration chain (@objectstack/spec migrations/): a permanent, ordered, per-major chain. Each major's step composes two feeders — the graduated D2 conversions (referenced by id, reusing their transform + fixture, no duplication) as the mechanical transforms, and semantic changes with no lossless mapping surfaced as structured TODOs (surface, reason, acceptance criteria — never silence). applyMetaMigrations(stack, fromMajor, toMajor?) folds the steps fromMajor+1..current and carries any past major to current in one run; cross-major is the designed-for case. Each hop is checkpointed for per-hop verify / bisection. MIGRATION_SUPPORT_FLOOR is an explicit release-policy knob. Seeded with the protocol-11 step: three graduated conversions (mechanical) plus the two non-lossless live windows (titleFormat composite → nameField, SQL-ish RLS predicate → CEL) as semantic TODOs. CI replays each conversion fixture through the full chain — a composability break is a release blocker. D4 — spec-changes.json: a Zod-defined machine-readable record { from, to, added, converted, migrated, removed }. composeSpecChanges folds the conversion table (D2) and migration set (D3) across majors and joins the release-time api-surface diff; per-major manifests compose into one from→to view. Every downstream artifact (generated guide, P3 MCP spec_changes) is a projection of this. CLI — `objectstack migrate meta --from N` (the command the P0 handshake error already points to): replays the chain, prints a generated + ObjectStackDefinition -validated mechanical diff plus the semantic TODOs; --to / --step (per-hop checkpoints) / --out (canonicalized snapshot) / --json. It does not silently rewrite TS config source (that AST rewrite is unsafe/lossy) — it emits a reviewable artifact for the consumer agent. normalizeStackInput gains an optional `convert: false` (map→array only) so migrate meta replays the conversions itself against the raw authored source, attributing each rewrite to a chain hop. Proven end-to-end: a 10.x stack with all three deprecated shapes migrates to a schema-valid canonical stack. New exports are purely additive. Refs #2643, #2647 (ADR-0087 D3/D4). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YA9vjVpDrKLuaK6nbm1s37 --- .changeset/adr-0087-p2-migration-chain.md | 19 ++ packages/cli/src/commands/migrate/meta.ts | 197 ++++++++++++++++++ packages/spec/api-surface.json | 24 +++ packages/spec/src/index.ts | 3 + packages/spec/src/migrations/chain.ts | 116 +++++++++++ packages/spec/src/migrations/index.ts | 43 ++++ .../spec/src/migrations/migrations.test.ts | 169 +++++++++++++++ packages/spec/src/migrations/registry.ts | 91 ++++++++ packages/spec/src/migrations/spec-changes.ts | 130 ++++++++++++ packages/spec/src/migrations/types.ts | 101 +++++++++ .../src/shared/metadata-collection.zod.ts | 9 + 11 files changed, 902 insertions(+) create mode 100644 .changeset/adr-0087-p2-migration-chain.md create mode 100644 packages/cli/src/commands/migrate/meta.ts create mode 100644 packages/spec/src/migrations/chain.ts create mode 100644 packages/spec/src/migrations/index.ts create mode 100644 packages/spec/src/migrations/migrations.test.ts create mode 100644 packages/spec/src/migrations/registry.ts create mode 100644 packages/spec/src/migrations/spec-changes.ts create mode 100644 packages/spec/src/migrations/types.ts diff --git a/.changeset/adr-0087-p2-migration-chain.md b/.changeset/adr-0087-p2-migration-chain.md new file mode 100644 index 0000000000..09a19d1916 --- /dev/null +++ b/.changeset/adr-0087-p2-migration-chain.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +ADR-0087 P2:可重放迁移链 + 机器可读变更清单(D3 / D4)。 + +**D3 —— 迁移链(`@objectstack/spec` 新增 `migrations/`)。** 一条永久、有序、按协议大版本组织的迁移链。每个大版本的步骤由两个来源合成:**已毕业的转换**(P1 的 D2 转换条目从加载路径退役后,以其 id 引用复用,作为该大版本的“机械变换”,转换与 fixture 不重复)和**语义变更**(无损映射无法表达的破坏,以结构化 TODO —— surface / 原因 / 验收标准 —— 呈现,而非静默或有损自动改写)。 + +- `applyMetaMigrations(stack, fromMajor, toMajor?)` 折叠 `fromMajor+1 … 当前` 的步骤,一次性把任意历史大版本的元数据迁到当前;跨大版本是设计主场景。每一跳(hop)都做检查点,便于逐跳验证与二分定位。**时效性从不承重** —— 迟到的使用方到达时重放链即可。 +- `composeMigrationChain`、`MigrationFloorError`,以及显式的发布策略旋钮 `MIGRATION_SUPPORT_FLOOR`(链能回溯到多久)。 +- 种子:protocol 11 步骤 —— 机械项为三条已毕业的 P1 转换;语义项为两个真实存量窗口:`titleFormat` 复合模板 → `nameField`(需公式字段,非无损)、SQL 式 RLS 谓词 → 规范 CEL。 +- CI 把整条链当作链来测:每条转换的 old-shape fixture 从支持下限重放到目标大版本,组合性破坏即发布阻断。 + +**D4 —— `spec-changes.json` 变更清单。** Zod 定义的机器可读记录 `{ from, to, added, converted, migrated, removed }`,由 `composeSpecChanges(from, to, surfaceDiff?)` 跨大版本折叠转换表(D2)与迁移集(D3),并与发布期 api-surface 差异连接。按大版本的清单可组合成单一 `from→to` 视图;后续生成式升级指南与 P3 的 MCP `spec_changes` 工具都是它的投影。 + +**CLI —— `objectstack migrate meta --from N`。** 重放迁移链:展示生成的、经 `ObjectStackDefinitionSchema` 校验的机械变更 diff(逐条 `path: 旧 → 新`)与需人工判断的语义 TODO;`--to`、`--step`(逐跳检查点)、`--out `(把规范化后的栈写为可 diff 的 JSON 快照)、`--json`。命令不静默改写 TS 配置源(AST 改写不安全且有损)—— 输出供使用方 agent 审阅采纳,这正是握手错误(P0)所指向的命令。 + +`normalizeStackInput` 新增可选 `convert: false`(仅做 map→array,不跑 D2 转换),供 `migrate meta` 对原始编写源重放链、把每处改写归因到对应链步。新增导出纯增量,无破坏性移除。 diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts new file mode 100644 index 0000000000..6e4e30fd5d --- /dev/null +++ b/packages/cli/src/commands/migrate/meta.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { Args, Command, Flags } from '@oclif/core'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import chalk from 'chalk'; +import { + ObjectStackDefinitionSchema, + applyMetaMigrations, + composeSpecChanges, + normalizeStackInput, + MigrationFloorError, +} from '@objectstack/spec'; +import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; +import { loadConfig } from '../../utils/config.js'; +import { + printHeader, + printSuccess, + printWarning, + printError, + printInfo, + printStep, + createTimer, +} from '../../utils/format.js'; + +/** + * `os migrate meta --from N` — replay the ADR-0087 D3 migration chain. + * + * Composes the per-major steps N+1 → … → current and applies each major's + * mechanical transforms (the graduated D2 conversions) to the loaded stack in + * one run — cross-major is the designed-for case, not an edge. It reports a + * generated, schema-validated diff (the mechanical rewrites) plus the structured + * TODOs for the semantic changes the chain cannot apply, so the consumer agent + * reviews a provably-valid change instead of hand-porting from prose. + * + * The command does not silently rewrite TS config source (that AST rewrite is + * unsafe and lossy); `--out` writes the canonicalized stack as a JSON snapshot + * the agent can diff and adopt. `--step` prints a per-hop checkpoint so a failure + * can be bisected to the exact major. + */ +export default class MigrateMeta extends Command { + static override description = + 'Replay the metadata protocol migration chain from a past major to current (ADR-0087 D3).'; + + static override examples = [ + '$ os migrate meta --from 10', + '$ os migrate meta --from 10 --step', + '$ os migrate meta --from 11 --to 12 --json', + '$ os migrate meta --from 10 --out migrated.stack.json', + ]; + + static override args = { + config: Args.string({ description: 'Path to the stack config (defaults to auto-detected).' }), + }; + + static override flags = { + from: Flags.integer({ + description: 'The protocol major the metadata was authored against.', + required: true, + }), + to: Flags.integer({ + description: `Target protocol major (defaults to this runtime's, ${PROTOCOL_MAJOR}).`, + }), + step: Flags.boolean({ + description: 'Print a per-hop checkpoint (for per-major verify / bisection).', + default: false, + }), + out: Flags.string({ description: 'Write the migrated stack as a JSON snapshot to this path.' }), + json: Flags.boolean({ description: 'Output the machine-readable migration result as JSON.' }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(MigrateMeta); + const timer = createTimer(); + const toMajor = flags.to ?? PROTOCOL_MAJOR; + + if (!flags.json) printHeader('Migrate · meta'); + + try { + if (!flags.json) printStep('Loading configuration…'); + const { config, absolutePath } = await loadConfig(args.config); + + // Map→array normalization ONLY (convert:false): the chain must replay the + // conversions itself against the raw authored source so each rewrite is + // attributed to a chain hop, not silently pre-applied by the load-time + // D2 pass. Running the D2 pass here would leave the chain's diff empty. + const normalized = normalizeStackInput(config as Record, { convert: false }); + + if (!flags.json) printStep(`Replaying chain: protocol ${flags.from} → ${toMajor}…`); + const result = applyMetaMigrations(normalized, flags.from, toMajor); + + // Prove the migrated stack is schema-valid — the "generated, provably valid + // diff" the consumer agent reviews (ADR-0087 D3/D5). + const parsed = ObjectStackDefinitionSchema.safeParse(result.stack); + const specChanges = composeSpecChanges(flags.from, toMajor); + + if (flags.json) { + console.log( + JSON.stringify( + { + from: result.fromMajor, + to: result.toMajor, + runtime: PROTOCOL_VERSION, + applied: result.applied, + todos: result.todos, + hops: flags.step + ? result.hops.map((h) => ({ + toMajor: h.toMajor, + rationale: h.rationale, + applied: h.applied, + todos: h.todos, + })) + : undefined, + specChanges, + schemaValid: parsed.success, + duration: timer.elapsed(), + }, + null, + 2, + ), + ); + if (flags.out) writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2)); + return; + } + + printInfo(`Config: ${chalk.white(absolutePath)}`); + printInfo(`Chain: protocol ${flags.from} → ${toMajor} (runtime ${PROTOCOL_VERSION})`); + console.log(''); + + if (result.applied.length === 0 && result.todos.length === 0) { + printSuccess('Nothing to migrate — the metadata is already canonical for this range.'); + return; + } + + // Mechanical rewrites (auto-applied). + if (result.applied.length > 0) { + console.log(chalk.bold(` Applied ${result.applied.length} mechanical change(s):`)); + for (const a of result.applied) { + console.log(` • ${a.path}: ${chalk.red(a.from)} → ${chalk.green(a.to)} ${chalk.dim(`(${a.conversionId})`)}`); + } + console.log(''); + } + + // Per-hop checkpoints. + if (flags.step) { + for (const hop of result.hops) { + console.log(chalk.bold(` ── protocol ${hop.toMajor} ──`)); + console.log(chalk.dim(` ${hop.rationale}`)); + console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual`)); + } + console.log(''); + } + + // Semantic TODOs (delegated to the agent — never auto-applied). + if (result.todos.length > 0) { + console.log(chalk.bold(chalk.yellow(` ${result.todos.length} manual change(s) require your judgment:`))); + for (const t of result.todos) { + console.log(` ${chalk.yellow('⚠')} [protocol ${t.toMajor}] ${t.surface} → ${t.replacement}`); + console.log(chalk.dim(` why: ${t.reason}`)); + console.log(chalk.dim(` verify: ${t.acceptanceCriteria}`)); + } + console.log(''); + } + + if (flags.out) { + writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2)); + printInfo(`Wrote migrated stack snapshot → ${chalk.white(resolve(flags.out))}`); + } + + if (parsed.success) { + printSuccess(`Migrated stack is schema-valid ${chalk.dim(`(${timer.display()})`)}`); + } else { + printWarning( + 'Migrated stack does not yet pass schema validation — resolve the manual changes above, ' + + 'then run `os validate`.', + ); + } + console.log(''); + } catch (error: any) { + if (error instanceof MigrationFloorError) { + if (flags.json) { + console.log(JSON.stringify({ error: 'unsupported_from_major', message: error.message })); + this.exit(1); + } + printError(error.message); + this.exit(1); + return; + } + if (flags.json) { + console.log(JSON.stringify({ error: error.message })); + this.exit(1); + } + printError(error.message || String(error)); + this.exit(1); + } + } +} diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 2ccc748534..43b68334a0 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -42,9 +42,18 @@ "GUEST_POSITION (const)", "MAP_SUPPORTED_FIELDS (const)", "METADATA_ALIASES (const)", + "MIGRATIONS_BY_MAJOR (const)", + "MIGRATION_MAJORS (const)", + "MIGRATION_SUPPORT_FLOOR (const)", "MapSupportedField (type)", "MetadataCollectionInput (type)", "MetadataConversion (interface)", + "MigrationApplication (interface)", + "MigrationChainResult (interface)", + "MigrationFloorError (class)", + "MigrationHopResult (interface)", + "MigrationStep (interface)", + "MigrationTodo (interface)", "NormalizeStackInputOptions (interface)", "ObjectOSCapabilities (type)", "ObjectOSCapabilitiesSchema (const)", @@ -65,13 +74,28 @@ "PredicateInput (type)", "PredicateInputSchema (const)", "PredicateSchema (const)", + "SemanticMigration (interface)", "Skill (type)", + "SpecChanges (type)", + "SpecChangesSchema (const)", + "SpecConverted (type)", + "SpecConvertedSchema (const)", + "SpecMigrated (type)", + "SpecMigratedSchema (const)", + "SpecSurfaceAdd (type)", + "SpecSurfaceAddSchema (const)", + "SpecSurfaceRemove (type)", + "SpecSurfaceRemoveSchema (const)", + "SurfaceDiff (interface)", "TemplateExpressionInputSchema (const)", "Tool (type)", "ViewKeyCollision (interface)", "applyConversions (function)", + "applyMetaMigrations (function)", "cel (function)", "collectConversionNotices (function)", + "composeMigrationChain (function)", + "composeSpecChanges (function)", "composeStacks (function)", "createEvalUser (function)", "cron (function)", diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index ce0f1e3e32..860021f103 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -113,6 +113,9 @@ export type { MetadataCollectionInput, MapSupportedField, NormalizeStackInputOpt // Metadata conversion layer (ADR-0087 D2) — old-shape → canonical-shape transforms applied at load. export * from './conversions/index.js'; +// Metadata migration chain + change manifest (ADR-0087 D3/D4). +export * from './migrations/index.js'; + export { type PluginContext } from './kernel/plugin.zod'; // Expression Protocol (M9 — canonical wire format for formulas / predicates / conditions) diff --git a/packages/spec/src/migrations/chain.ts b/packages/spec/src/migrations/chain.ts new file mode 100644 index 0000000000..bb7890c84d --- /dev/null +++ b/packages/spec/src/migrations/chain.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Compose and apply the migration chain (ADR-0087 D3). + * + * `objectstack migrate meta --from N` calls {@link applyMetaMigrations}, which + * folds the steps N+1 → … → current and applies each major's *mechanical* + * transforms (the graduated D2 conversions) to the consumer's stack in one run, + * collecting a review diff and the semantic TODOs the agent must resolve. Every + * hop is checkpointed (`hops[]`) so an agent can run its verify loop per major + * and bisect a failure to the exact hop — the agent's `git bisect` for an + * upgrade. + */ + +import { PROTOCOL_MAJOR } from '../kernel/protocol-version.js'; +import { ALL_CONVERSIONS } from '../conversions/registry.js'; +import type { MetadataConversion } from '../conversions/types.js'; +import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR } from './registry.js'; +import type { + MigrationApplication, + MigrationChainResult, + MigrationHopResult, + MigrationStep, + MigrationTodo, +} from './types.js'; + +const CONVERSION_BY_ID: ReadonlyMap = new Map( + ALL_CONVERSIONS.map((c) => [c.id, c]), +); + +/** + * The ordered steps that migrate a `fromMajor` source up to `toMajor` + * (defaults to the running protocol major). Only majors that carry a step + * appear; a major with no break contributes nothing (a no-op hop is elided). + */ +export function composeMigrationChain( + fromMajor: number, + toMajor: number = PROTOCOL_MAJOR, +): MigrationStep[] { + return MIGRATION_MAJORS + .filter((m) => m > fromMajor && m <= toMajor) + .map((m) => MIGRATIONS_BY_MAJOR[m]!); +} + +/** Thrown when `--from N` is below the documented support floor. */ +export class MigrationFloorError extends Error { + constructor( + public readonly fromMajor: number, + public readonly floor: number, + ) { + super( + `Cannot migrate from protocol ${fromMajor}: the chain's support floor is ${floor} ` + + `(ADR-0087 D3). Upgrade to protocol ${floor} by another path first, then re-run.`, + ); + this.name = 'MigrationFloorError'; + } +} + +/** + * Apply the migration chain from `fromMajor` up to `toMajor` to a stack. + * + * Pure and immutable: reuses the D2 conversion transforms (copy-on-write), so + * the input is never mutated. Mechanical rewrites are applied; semantic changes + * are reported as {@link MigrationTodo}s, never auto-applied. Never throws on + * stack content — only {@link MigrationFloorError} when `fromMajor` is + * unsupported. + */ +export function applyMetaMigrations( + stack: Record, + fromMajor: number, + toMajor: number = PROTOCOL_MAJOR, +): MigrationChainResult { + if (fromMajor < MIGRATION_SUPPORT_FLOOR) { + throw new MigrationFloorError(fromMajor, MIGRATION_SUPPORT_FLOOR); + } + + const steps = composeMigrationChain(fromMajor, toMajor); + const applied: MigrationApplication[] = []; + const todos: MigrationTodo[] = []; + const hops: MigrationHopResult[] = []; + let current = stack; + + for (const step of steps) { + const hopApplied: MigrationApplication[] = []; + for (const conversionId of step.conversionIds) { + const conversion = CONVERSION_BY_ID.get(conversionId); + // A step referencing an unknown conversion id is a registry authoring bug, + // caught by `migrations.test.ts`; skip defensively rather than crash a + // consumer's upgrade run. + if (!conversion) continue; + current = conversion.apply(current, (detail) => { + hopApplied.push({ + toMajor: step.toMajor, + conversionId, + surface: conversion.surface, + from: detail.from, + to: detail.to, + path: detail.path, + }); + }); + } + const hopTodos: MigrationTodo[] = step.semantic.map((s) => ({ ...s, toMajor: step.toMajor })); + + applied.push(...hopApplied); + todos.push(...hopTodos); + hops.push({ + toMajor: step.toMajor, + rationale: step.rationale, + stack: current, + applied: hopApplied, + todos: hopTodos, + }); + } + + return { fromMajor, toMajor, stack: current, applied, todos, hops }; +} diff --git a/packages/spec/src/migrations/index.ts b/packages/spec/src/migrations/index.ts new file mode 100644 index 0000000000..39f12b0613 --- /dev/null +++ b/packages/spec/src/migrations/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Metadata migration chain + change manifest (ADR-0087 D3/D4) — public surface. + * + * The permanent, replayable chain that carries any past major's metadata to + * current in one command (`objectstack migrate meta --from N`), and the + * machine-readable `spec-changes.json` manifest every other release artifact is + * a projection of. See {@link ./types} and {@link ./spec-changes} for rationale. + */ + +export type { + MigrationApplication, + MigrationChainResult, + MigrationHopResult, + MigrationStep, + MigrationTodo, + SemanticMigration, +} from './types.js'; +export { + MIGRATIONS_BY_MAJOR, + MIGRATION_MAJORS, + MIGRATION_SUPPORT_FLOOR, +} from './registry.js'; +export { + applyMetaMigrations, + composeMigrationChain, + MigrationFloorError, +} from './chain.js'; +export { + composeSpecChanges, + SpecChangesSchema, + SpecConvertedSchema, + SpecMigratedSchema, + SpecSurfaceAddSchema, + SpecSurfaceRemoveSchema, + type SpecChanges, + type SpecConverted, + type SpecMigrated, + type SpecSurfaceAdd, + type SpecSurfaceRemove, + type SurfaceDiff, +} from './spec-changes.js'; diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts new file mode 100644 index 0000000000..3085d5b38a --- /dev/null +++ b/packages/spec/src/migrations/migrations.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from '../conversions/registry.js'; +import { PROTOCOL_MAJOR } from '../kernel/protocol-version.js'; +import { + applyMetaMigrations, + composeMigrationChain, + MigrationFloorError, +} from './chain.js'; +import { + MIGRATIONS_BY_MAJOR, + MIGRATION_MAJORS, + MIGRATION_SUPPORT_FLOOR, +} from './registry.js'; +import { composeSpecChanges, SpecChangesSchema } from './spec-changes.js'; + +const CONVERSION_IDS = new Set(ALL_CONVERSIONS.map((c) => c.id)); + +describe('migration chain (ADR-0087 D3)', () => { + describe('registry integrity', () => { + it('every step references only real conversion ids', () => { + for (const step of Object.values(MIGRATIONS_BY_MAJOR)) { + for (const id of step.conversionIds) { + expect(CONVERSION_IDS.has(id)).toBe(true); + } + } + }); + + it('a graduated conversion belongs to the step for its own major', () => { + for (const [majorStr, step] of Object.entries(MIGRATIONS_BY_MAJOR)) { + const major = Number(majorStr); + for (const id of step.conversionIds) { + const conv = ALL_CONVERSIONS.find((c) => c.id === id)!; + expect(conv.toMajor).toBe(major); + } + } + }); + + it('semantic migrations carry acceptance criteria (never silence)', () => { + for (const step of Object.values(MIGRATIONS_BY_MAJOR)) { + for (const s of step.semantic) { + expect(s.acceptanceCriteria.length).toBeGreaterThan(0); + expect(s.reason.length).toBeGreaterThan(0); + } + } + }); + + it('the support floor is at or below the earliest step', () => { + expect(MIGRATION_SUPPORT_FLOOR).toBeLessThanOrEqual(MIGRATION_MAJORS[0]!); + }); + }); + + describe('composition (cross-major is the designed-for case)', () => { + it('composes only the steps in (from, to]', () => { + const chain = composeMigrationChain(10, 11); + expect(chain.map((s) => s.toMajor)).toEqual([11]); + }); + + it('a consumer already at current gets an empty chain', () => { + expect(composeMigrationChain(PROTOCOL_MAJOR, PROTOCOL_MAJOR)).toHaveLength(0); + }); + + it('refuses a from-major below the support floor', () => { + expect(() => applyMetaMigrations({}, MIGRATION_SUPPORT_FLOOR - 1)).toThrow(MigrationFloorError); + }); + }); + + describe('replay — the chain applies the graduated mechanical transforms', () => { + it('migrates a 10.x stack with all three protocol-11 shapes to canonical', () => { + const stack = { + flows: [ + { + name: 'f', + nodes: [ + { id: 'a', type: 'http_request', config: { url: 'x' } }, + { id: 'b', type: 'delete_record', config: { objectName: 'lead', filters: { s: 1 } } }, + ], + }, + ], + pages: [{ name: 'p', kind: 'jsx', source: '
' }], + }; + const result = applyMetaMigrations(stack, 10, 11); + + const flow = (result.stack.flows as any[])[0]; + expect(flow.nodes[0].type).toBe('http'); + expect(flow.nodes[1].config).toEqual({ objectName: 'lead', filter: { s: 1 } }); + expect((result.stack.pages as any[])[0].kind).toBe('html'); + + // Three mechanical rewrites, no semantic TODOs triggered by these shapes + // (semantic TODOs are advisory per-major, always surfaced for the hop). + expect(result.applied).toHaveLength(3); + expect(result.todos.map((t) => t.id).sort()).toEqual([ + 'object-titleFormat-to-nameField', + 'rls-sql-predicate-to-cel', + ]); + }); + + it('is immutable — the input stack is not mutated', () => { + const stack = { pages: [{ name: 'p', kind: 'jsx', source: '
' }] }; + const snapshot = structuredClone(stack); + applyMetaMigrations(stack, 10, 11); + expect(stack).toEqual(snapshot); + }); + + it('checkpoints each hop for per-hop verify / bisection', () => { + const stack = { pages: [{ name: 'p', kind: 'jsx', source: '
' }] }; + const result = applyMetaMigrations(stack, 10, 11); + expect(result.hops).toHaveLength(1); + expect(result.hops[0]!.toMajor).toBe(11); + expect((result.hops[0]!.stack.pages as any[])[0].kind).toBe('html'); + }); + }); + + describe('chain-replay from every conversion fixture (CI composability gate)', () => { + // Each graduated conversion's old-shape fixture must reach canonical when + // replayed through the full chain from the support floor — a composability + // break is a release blocker (ADR-0087 D3), caught here, not by a consumer. + for (const conversion of ALL_CONVERSIONS) { + it(`${conversion.id}: fixture.before → fixture.after via the chain`, () => { + const result = applyMetaMigrations( + structuredClone(conversion.fixture.before), + MIGRATION_SUPPORT_FLOOR, + conversion.toMajor, + ); + expect(result.stack).toEqual(conversion.fixture.after); + }); + } + }); +}); + +describe('spec-changes.json manifest (ADR-0087 D4)', () => { + it('composes conversions + semantic migrations across the range', () => { + const changes = composeSpecChanges(10, 11); + expect(changes.from).toBe(10); + expect(changes.to).toBe(11); + expect(changes.converted.map((c) => c.conversionId).sort()).toEqual( + (CONVERSIONS_BY_MAJOR[11] ?? []).map((c) => c.id).sort(), + ); + expect(changes.migrated.map((m) => m.migrationId).sort()).toEqual([ + 'object-titleFormat-to-nameField', + 'rls-sql-predicate-to-cel', + ]); + }); + + it('validates against its own schema', () => { + const changes = composeSpecChanges(10, 11, { + added: [{ surface: 'applyConversions (function)', since: 11 }], + removed: [{ surface: 'httpRequestNode (const)', removedIn: 11, replacement: 'http node' }], + }); + expect(SpecChangesSchema.safeParse(changes).success).toBe(true); + }); + + it('per-major manifests compose into one aggregate view', () => { + // Folding 10→11 (the only major with a step today) must match a direct 10→11. + const direct = composeSpecChanges(10, PROTOCOL_MAJOR); + const convertedIds = direct.converted.map((c) => c.conversionId); + // Every conversion in range appears exactly once (no duplication across the fold). + expect(new Set(convertedIds).size).toBe(convertedIds.length); + }); + + it('an empty range yields an empty manifest', () => { + const changes = composeSpecChanges(PROTOCOL_MAJOR, PROTOCOL_MAJOR); + expect(changes.converted).toHaveLength(0); + expect(changes.migrated).toHaveLength(0); + expect(SpecChangesSchema.safeParse(changes).success).toBe(true); + }); +}); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts new file mode 100644 index 0000000000..7834eb8ba2 --- /dev/null +++ b/packages/spec/src/migrations/registry.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The permanent metadata migration chain (ADR-0087 D3). + * + * One {@link MigrationStep} per protocol major that carried a break. Each step's + * mechanical transforms are the D2 conversions that graduated into it (referenced + * by id, so the transform + fixture pair are never duplicated), and its + * `semantic` list is the non-lossless residue D2 could not express. + * + * The chain is a **forever artifact**: every step back to + * {@link MIGRATION_SUPPORT_FLOOR} stays replayable, and CI replays the full chain + * from the oldest supported major's fixtures to current on every release + * (`migrations.test.ts`). The support floor is an explicit release-policy knob — + * how far back `migrate meta --from N` reaches — revisitable per major, never an + * accident of deletion. + */ + +import type { MigrationStep } from './types.js'; + +/** + * The oldest protocol major the chain guarantees a replayable path from. + * `objectstack migrate meta --from N` supports any `N >= MIGRATION_SUPPORT_FLOOR`. + * A release-policy decision (ADR-0087 D3), not an accident of what still exists. + */ +export const MIGRATION_SUPPORT_FLOOR = 10; + +/** + * Protocol 11 step. + * + * Mechanical: the three protocol-11 conversions graduated from the D2 load path + * (`flow-node-http-callout-rename`, `page-kind-jsx-to-html`, + * `flow-node-crud-filter-alias`). Semantic: the two non-lossless live windows the + * conversion layer deliberately excludes — a composite `titleFormat` template and + * SQL-ish RLS predicates — each surfaced as a structured TODO rather than a silent + * or lossy auto-rewrite. + */ +const step11: MigrationStep = { + toMajor: 11, + rationale: + 'Protocol 11 unified the divergent HTTP callout node types to `http`, made ' + + "`html` the canonical page kind (deprecating the `jsx` alias), and canonicalized " + + 'the CRUD flow-node filter key. These are mechanical and replay losslessly. Two ' + + 'related deprecations are semantic and cannot be auto-applied: a composite ' + + '`titleFormat` render template has no single canonical `nameField`, and SQL-ish ' + + 'RLS predicates must be rewritten to canonical CEL — both are delegated to the ' + + 'consumer with explicit acceptance criteria.', + conversionIds: [ + 'flow-node-http-callout-rename', + 'page-kind-jsx-to-html', + 'flow-node-crud-filter-alias', + ], + semantic: [ + { + id: 'object-titleFormat-to-nameField', + surface: 'object.titleFormat', + replacement: 'object.nameField', + reason: + 'A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template ' + + '(e.g. `{firstName} {lastName}`) has no lossless single-field target — it must ' + + 'become a formula field designated as `nameField`. The choice of formula is a ' + + 'judgment the transform cannot make.', + acceptanceCriteria: + 'Each object with a `titleFormat` declares a `nameField`; a composite title is ' + + 'backed by a formula field. `objectstack validate` passes and record display ' + + 'names render identically to before.', + }, + { + id: 'rls-sql-predicate-to-cel', + surface: 'security.rls.predicate', + replacement: 'CEL predicate', + reason: + 'SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation ' + + 'is not a pure token rename — operators, functions, and null semantics differ — ' + + 'so it cannot be applied losslessly by the chain.', + acceptanceCriteria: + 'Every RLS predicate parses as CEL and `objectstack validate` reports no ' + + 'expression errors; row visibility is unchanged for a representative fixture set.', + }, + ], +}; + +/** All migration steps, keyed by the major they migrate into. */ +export const MIGRATIONS_BY_MAJOR: Readonly> = { + 11: step11, +}; + +/** The majors that have a step, ascending. */ +export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJOR) + .map(Number) + .sort((a, b) => a - b); diff --git a/packages/spec/src/migrations/spec-changes.ts b/packages/spec/src/migrations/spec-changes.ts new file mode 100644 index 0000000000..1d09d5e87d --- /dev/null +++ b/packages/spec/src/migrations/spec-changes.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The machine-readable-first change manifest, `spec-changes.json` (ADR-0087 D4). + * + * The Release workflow diffs the current `api-surface.json` against the + * previously published one (reusing the ADR-0059 §3 gate artifact instead of + * discarding it), then joins the conversion table (D2) and the migration set + * (D3) into a single `{ from, to, added, converted, migrated, removed }` record. + * Every downstream artifact is a projection of this: the generated upgrade + * guide, and the P3 MCP `spec_changes` tool. Prose inverts from primary to + * derived — a `rationale` anchor is the only prose, and it lives in the data. + * + * Per-major manifests **compose**: because the record is pure data, any tool can + * fold a 10→11, 11→12, … series into a single 10→N view, so a cross-major + * consumer gets one aggregate answer instead of N documents to reconcile. + * {@link composeSpecChanges} is that fold, computed from the registries; the + * `added`/`removed` arrays are supplied by the release-time api-surface diff. + */ + +import { z } from 'zod'; +import { CONVERSIONS_BY_MAJOR } from '../conversions/registry.js'; +import { MIGRATIONS_BY_MAJOR } from './registry.js'; + +/** An export added to the public surface in this range (from the api-surface diff). */ +export const SpecSurfaceAddSchema = z + .object({ + surface: z.string().describe('The exported name, e.g. `applyConversions (function)`.'), + since: z.number().int().describe('The protocol major that added it.'), + }) + .describe('A newly added public export.'); + +/** An export removed from the public surface (from the api-surface diff). */ +export const SpecSurfaceRemoveSchema = z + .object({ + surface: z.string().describe('The removed export name.'), + removedIn: z.number().int().describe('The protocol major that removed it.'), + replacement: z.string().optional().describe('The canonical replacement, if any.'), + }) + .describe('A removed public export.'); + +/** A losslessly converted surface (from the D2 conversion table). */ +export const SpecConvertedSchema = z + .object({ + surface: z.string(), + to: z.string().describe('The canonical shape it converts to.'), + conversionId: z.string().describe('The D2 conversion id (also its graduated chain-step id).'), + toMajor: z.number().int().describe('The major that introduced the canonical shape.'), + }) + .describe('A lossless conversion applied at load (D2).'); + +/** A semantic (non-lossless) migration (from the D3 migration chain). */ +export const SpecMigratedSchema = z + .object({ + surface: z.string(), + replacement: z.string(), + migrationId: z.string().describe('The D3 semantic-migration id.'), + toMajor: z.number().int(), + rationale: z.string().describe('Why it is not losslessly convertible (the load-bearing prose).'), + }) + .describe('A semantic migration requiring consumer judgment (D3).'); + +/** The full `spec-changes.json` record for a `from → to` version pair. */ +export const SpecChangesSchema = z + .object({ + from: z.number().int().describe('The starting protocol major.'), + to: z.number().int().describe('The target protocol major.'), + added: z.array(SpecSurfaceAddSchema), + converted: z.array(SpecConvertedSchema), + migrated: z.array(SpecMigratedSchema), + removed: z.array(SpecSurfaceRemoveSchema), + }) + .describe('ADR-0087 D4 machine-readable change manifest for a protocol version pair.'); + +export type SpecSurfaceAdd = z.infer; +export type SpecSurfaceRemove = z.infer; +export type SpecConverted = z.infer; +export type SpecMigrated = z.infer; +export type SpecChanges = z.infer; + +/** Release-time api-surface diff, supplied to {@link composeSpecChanges}. */ +export interface SurfaceDiff { + added?: SpecSurfaceAdd[]; + removed?: SpecSurfaceRemove[]; +} + +/** + * Fold the conversion table (D2) and migration chain (D3) across every major in + * `(fromMajor, toMajor]` into a single {@link SpecChanges} record, joined with + * the release-time api-surface diff. Pure — derived entirely from the registries + * plus the supplied `surfaceDiff`. + */ +export function composeSpecChanges( + fromMajor: number, + toMajor: number, + surfaceDiff: SurfaceDiff = {}, +): SpecChanges { + const converted: SpecConverted[] = []; + const migrated: SpecMigrated[] = []; + + for (let major = fromMajor + 1; major <= toMajor; major++) { + for (const conversion of CONVERSIONS_BY_MAJOR[major] ?? []) { + converted.push({ + surface: conversion.surface, + to: conversion.summary, + conversionId: conversion.id, + toMajor: conversion.toMajor, + }); + } + const step = MIGRATIONS_BY_MAJOR[major]; + for (const semantic of step?.semantic ?? []) { + migrated.push({ + surface: semantic.surface, + replacement: semantic.replacement, + migrationId: semantic.id, + toMajor: major, + rationale: semantic.reason, + }); + } + } + + return { + from: fromMajor, + to: toMajor, + added: surfaceDiff.added ?? [], + converted, + migrated, + removed: surfaceDiff.removed ?? [], + }; +} diff --git a/packages/spec/src/migrations/types.ts b/packages/spec/src/migrations/types.ts new file mode 100644 index 0000000000..92bd55e891 --- /dev/null +++ b/packages/spec/src/migrations/types.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Types for the replayable **migration chain** (ADR-0087 D3). + * + * Where the conversion layer (D2) breaks *invisibly* — accepting the old shape + * at load for one major — the migration chain is the L2 rung: *break + * executably*. For the breaks D2 cannot hide (semantic changes with no lossless + * mapping) and for the graduated conversions retired from the load path, the + * spec ships a **permanent, ordered chain of per-major steps**. A consumer that + * slept through four majors runs `objectstack migrate meta --from N` and replays + * every step in one command — it never needed to be present, warned, or reading + * anything while those majors shipped. This is the database-migration model + * applied to metadata source files; **timeliness is never load-bearing**. + * + * Two feeders compose each major's step (ADR-0087 D3): + * - **graduated conversions** — the D2 entries with `toMajor === N`, retired + * from the load path in N+1 and preserved here as that major's *mechanical* + * transforms (reusing the very same declarative transform + fixture pair); + * - **semantic changes** — breaks with no lossless mapping, which cannot be + * auto-applied and instead emit a structured TODO (surface, reason, + * acceptance criteria) so the consumer agent knows exactly what judgment is + * delegated to it, rather than silence. + */ + +/** + * A non-lossless change that cannot be auto-applied — surfaced as a structured + * TODO the consumer agent must resolve by hand (ADR-0087 D3: "never silence"). + */ +export interface SemanticMigration { + /** Stable, kebab-case id. */ + id: string; + /** Dotted surface the change governs, e.g. `object.titleFormat`. */ + surface: string; + /** The canonical replacement the author should move to. */ + replacement: string; + /** Why this is not losslessly convertible (the one load-bearing prose field). */ + reason: string; + /** How the consumer proves the hand-migration correct (their own verify loop). */ + acceptanceCriteria: string; +} + +/** + * One major's step in the chain: the mechanical transforms (graduated D2 + * conversions, referenced by id) plus any semantic TODOs, with a single prose + * `rationale`. + */ +export interface MigrationStep { + /** The protocol major this step migrates *into* (N; migrates N−1 sources to N). */ + toMajor: number; + /** One-paragraph human rationale — the one place prose is load-bearing (D3). */ + rationale: string; + /** + * Ids of the D2 conversions that graduated into this step. Their declarative + * transforms are replayed against the consumer's source (not just at load). + */ + conversionIds: readonly string[]; + /** Non-lossless changes authored for this major, as structured TODOs. */ + semantic: readonly SemanticMigration[]; +} + +/** A single mechanical rewrite the chain applied to a source, for the review diff. */ +export interface MigrationApplication { + /** The major whose step produced this rewrite. */ + toMajor: number; + /** The graduated conversion id that performed it. */ + conversionId: string; + surface: string; + from: string; + to: string; + /** Where in the stack it applied, e.g. `flows[0].nodes[2].type`. */ + path: string; +} + +/** A semantic TODO emitted for a hop, carrying the major it belongs to. */ +export interface MigrationTodo extends SemanticMigration { + toMajor: number; +} + +/** The result of replaying one hop (one major) — enables `--step` per-hop verify. */ +export interface MigrationHopResult { + toMajor: number; + rationale: string; + stack: Record; + applied: MigrationApplication[]; + todos: MigrationTodo[]; +} + +/** The full result of composing + applying a chain from `fromMajor` to `toMajor`. */ +export interface MigrationChainResult { + fromMajor: number; + toMajor: number; + /** The migrated stack (mechanical transforms applied; semantic TODOs left for the agent). */ + stack: Record; + /** Every mechanical rewrite across all hops, in application order. */ + applied: MigrationApplication[]; + /** Every semantic TODO across all hops — the judgment delegated to the consumer. */ + todos: MigrationTodo[]; + /** Per-hop checkpoints, in order (for `--step` bisection). */ + hops: MigrationHopResult[]; +} diff --git a/packages/spec/src/shared/metadata-collection.zod.ts b/packages/spec/src/shared/metadata-collection.zod.ts index 94b142e40f..faae132b18 100644 --- a/packages/spec/src/shared/metadata-collection.zod.ts +++ b/packages/spec/src/shared/metadata-collection.zod.ts @@ -221,6 +221,14 @@ export interface NormalizeStackInputOptions { * passes a sink that prints them. */ onConversionNotice?: (notice: ConversionNotice) => void; + /** + * Whether to run the D2 conversion pass. Defaults to `true` (the load-time + * behavior). Set `false` to get map→array normalization *only* — used by + * `objectstack migrate meta`, which must replay the conversions itself as + * chain steps against the raw authored source so each rewrite is attributed + * to the chain rather than silently pre-applied here. + */ + convert?: boolean; } /** @@ -248,6 +256,7 @@ export function normalizeStackInput>( (result as Record)[field] = normalizeMetadataCollection(result[field]); } } + if (options.convert === false) return result; return applyConversions(result, { onNotice: options.onConversionNotice }) as T; } From bf7425899f8fddb404aeeb7be2f0797dea3cd595 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:25:10 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(spec,service-automation):=20ADR-0087=20?= =?UTF-8?q?=E2=80=94=20wire=20conversions=20into=20the=20runtime=20flow-lo?= =?UTF-8?q?ad=20seam=20+=20conflict=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close two third-party-facing risks in the P1 conversion layer. Risk 1 (data-loss regression) — retiring the executor's `filters` fallback while the conversion only ran on the build/validate seam meant a stored flow rehydrated from the DB (engine.registerFlow → FlowSchema.parse, which bypassed the conversion) kept carrying `config.filters`; with the fallback gone the executor saw an empty `filter` and a `delete_record`/`update_record` widened to the whole table. Fix: run the D2 conversion in registerFlow BEFORE parse (new applyConversionsToFlow), the runtime load seam ADR-0087 D2 calls for. Stored old-shape flows (`filters`, `webhook`/`http_request` callouts) are canonicalized on rehydration; the executor only ever sees the canonical shape. This is the gap config-aliases.ts already described (graph-lint can't reach never-republished prod flows) — now closed for `filters` by the conversion, not a fallback. Risk 2 (silent third-party clobber) — `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. The node-type rename is now conflict-aware: the runtime seam passes its live executor registry as reservedNodeTypes; if a retired alias (`http_request`/`http_call`/`webhook`) is a live custom node, the rewrite is REFUSED and a loud, structured OS_METADATA_CONVERSION_CONFLICT diagnostic (node path, conversion id, rename hint) is emitted instead of a silent clobber (ADR-0078). The pure build/validate seam has no registry, so the historical alias converts as before. Mechanism/policy split kept clean: spec provides the conflict-aware conversion (reservedNodeTypes context on apply); service-automation supplies the data. The crud-config-aliases test is rewritten to prove the stored-`filters` flow now keeps its filter (the risk-1 fix); a new flow-load-conversion test covers the rehydration rename and the conflict guard. New exports are additive. Refs #2643, #2645 (ADR-0087 D2), ADR-0078. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YA9vjVpDrKLuaK6nbm1s37 --- .changeset/adr-0087-p1-conversion-layer.md | 8 +- .../src/builtin/config-aliases.ts | 9 +- .../src/builtin/crud-config-aliases.test.ts | 22 ++--- .../services/service-automation/src/engine.ts | 22 ++++- .../src/flow-load-conversion.test.ts | 79 +++++++++++++++ packages/spec/api-surface.json | 5 + packages/spec/src/conversions/apply.ts | 95 +++++++++++++++---- .../spec/src/conversions/conversions.test.ts | 30 ++++++ packages/spec/src/conversions/index.ts | 5 + packages/spec/src/conversions/registry.ts | 21 +++- packages/spec/src/conversions/types.ts | 57 ++++++++++- 11 files changed, 313 insertions(+), 40 deletions(-) create mode 100644 packages/services/service-automation/src/flow-load-conversion.test.ts diff --git a/.changeset/adr-0087-p1-conversion-layer.md b/.changeset/adr-0087-p1-conversion-layer.md index 95ca955a93..327101bdd1 100644 --- a/.changeset/adr-0087-p1-conversion-layer.md +++ b/.changeset/adr-0087-p1-conversion-layer.md @@ -1,6 +1,6 @@ --- '@objectstack/spec': minor -'@objectstack/service-automation': patch +'@objectstack/service-automation': minor --- ADR-0087 P1:元数据转换层(conversion layer,D2)——大多数破坏性变更对使用方零操作。 @@ -13,6 +13,10 @@ ADR-0087 P1:元数据转换层(conversion layer,D2)——大多数破坏性变 - `page-kind-jsx-to-html`:页面 `kind: 'jsx'` → `'html'`(ADR-0080 规范拼写)。 - `flow-node-crud-filter-alias`:CRUD 流程节点 `config.filters` → `config.filter`。 +**运行时加载 seam(存量流程零回归的关键)。** 转换不仅接在构建/校验入口,也接到运行时 `AutomationEngine.registerFlow`(在 `FlowSchema.parse` 之前跑,新增 `applyConversionsToFlow`)。这样从数据库 rehydrate 的**存量流程**也会被规范化——否则删掉 `filters` 执行器兜底会让存量 `delete_record` / `update_record` 的过滤条件被静默清空(退化成作用于全表)。这才真正兑现 D2 “applied at load, the same seam”。 + +**开放命名空间的冲突守卫(第三方零静默误伤)。** `flow.node.type` 是开放命名空间(ADR-0018 移除了 enum gate),退役的官方名可能被第三方复用为自定义节点。转换层新增“保留名冲突”感知:运行时 seam 传入本环境已注册的执行器类型,若某退役别名(`http_request`/`http_call`/`webhook`)正被活的自定义执行器占用,则**拒绝改写并发出响亮的结构化告警 `OS_METADATA_CONVERSION_CONFLICT`**(带节点位置、conversion id、“请改名”的处置建议),而不是静默把它改成 `http` 破坏第三方节点。构建/校验入口无注册表上下文,历史别名照常转换。 + 并落实 PD #12 退役路径示范:`filters` → `filter` 别名从 `service-automation` 执行器的 `readAliasedConfig` 兜底中删除,提升为上面这条声明式转换条目;执行器改为直接读取规范键 `cfg.filter`。 -新增导出(纯增量,无破坏):`applyConversions`、`collectConversionNotices`、`ALL_CONVERSIONS`、`CONVERSIONS_BY_MAJOR`、`CONVERSION_NOTICE_CODE`,以及类型 `MetadataConversion`、`ConversionNotice`、`ConversionApplication`、`ConversionFixture`、`ApplyConversionsOptions`、`NormalizeStackInputOptions`。`normalizeStackInput` 现接受可选第二参 `{ onConversionNotice }`(向后兼容)。 +新增导出(纯增量,无破坏):`applyConversions`、`applyConversionsToFlow`、`collectConversionNotices`、`ALL_CONVERSIONS`、`CONVERSIONS_BY_MAJOR`、`CONVERSION_NOTICE_CODE`、`CONVERSION_CONFLICT_CODE`,以及类型 `MetadataConversion`、`ConversionNotice`、`ConversionApplication`、`ConversionFixture`、`ConversionContext`、`ConversionConflictNotice`、`ConversionConflictDetail`、`ApplyConversionsOptions`、`NormalizeStackInputOptions`。`normalizeStackInput` 现接受可选第二参 `{ onConversionNotice, convert }`(向后兼容)。 diff --git a/packages/services/service-automation/src/builtin/config-aliases.ts b/packages/services/service-automation/src/builtin/config-aliases.ts index e1a361574a..b40e3e1b17 100644 --- a/packages/services/service-automation/src/builtin/config-aliases.ts +++ b/packages/services/service-automation/src/builtin/config-aliases.ts @@ -28,9 +28,12 @@ * The `filters` → `filter` alias has since been **retired from this shim** and * promoted into the ADR-0087 D2 conversion layer (`@objectstack/spec` conversion * `flow-node-crud-filter-alias`): it is rewritten to the canonical key **at - * load**, so the CRUD executors read `cfg.filter` directly. That is the PD #12 - * retirement path the ADR prescribes — a scattered consumer-side fallback - * replaced by one declared, loud, tested, expiring conversion entry. The + * load** — including the runtime rehydration seam (`AutomationEngine.registerFlow` + * runs the conversion before parse), which is exactly the stored-prod-flow gap + * this shim describes above, now closed for `filters` by the conversion instead + * of an executor fallback. So the CRUD executors read `cfg.filter` directly. That + * is the PD #12 retirement path the ADR prescribes — a scattered consumer-side + * fallback replaced by one declared, loud, tested, expiring conversion entry. The * remaining `object` → `objectName` alias is the next candidate to graduate. * * @see crud-nodes.ts for the remaining call site (`objectName`). diff --git a/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts b/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts index 2c19a0a9f2..bcc9bb25f9 100644 --- a/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts +++ b/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts @@ -90,30 +90,28 @@ describe('CRUD config-key aliases: object→objectName (executor shim) + filters expect(warns.length).toBe(before); }); - it('no longer honors a raw `filters` alias in the executor (retired to the D2 conversion layer)', async () => { + it('a stored `filters` flow is canonicalized at registerFlow, so the filter is NOT dropped', async () => { + // Risk-1 regression guard (ADR-0087 D2 runtime load seam). Before the seam + // was wired, retiring the executor's `filters` fallback meant a stored + // `delete_record`/`get_record` flow carrying `filters` reached the executor + // with an empty `filter` — silently widening the query to the whole table. + // Now `registerFlow` runs the D2 conversion, so `filters`→`filter` happens + // on rehydration and the stored filter survives. const engine = new AutomationEngine(silentLogger()); const { data, calls } = fakeData(); - const warns: string[] = []; - registerCrudNodes(engine, ctxWith(data, collectingLogger(warns))); + registerCrudNodes(engine, ctxWith(data, silentLogger())); - // A flow that reached the executor WITHOUT going through the load conversion - // still carries `filters`. The executor now reads only the canonical `filter`, - // so `filters` is ignored and no `filters`→`filter` warning is emitted here. engine.registerFlow('gr', getRecordFlow({ objectName: 'crm_lead', filters: { id: 'L1' } })); await engine.execute('gr'); - expect(calls[0].opts.where).toEqual({}); // `filters` no longer honored by the executor - const filterWarn = warns.find((w) => w.includes("'filters'")); - expect(filterWarn).toBeFalsy(); + expect(calls[0].opts.where).toEqual({ id: 'L1' }); // filter preserved, not dropped }); - it('the D2 conversion at load rewrites `filters` → `filter` so the flow works end-to-end', async () => { + it('the same conversion runs on the build/validate seam too (normalizeStackInput)', async () => { const engine = new AutomationEngine(silentLogger()); const { data, calls } = fakeData(); registerCrudNodes(engine, ctxWith(data, silentLogger())); - // Author with the deprecated `filters` key, then run it through the same load - // seam a real stack load uses. The conversion canonicalizes it to `filter`. const raw = getRecordFlow({ objectName: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' }); const converted = (normalizeStackInput({ flows: [raw] }).flows as any[])[0]; engine.registerFlow('gr', converted); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index a4082d7e8a..53f6c31dfe 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -5,6 +5,7 @@ import type { ExecutionLog, ActionDescriptor } from '@objectstack/spec/automatio import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, ScreenSpec } from '@objectstack/spec/contracts'; import type { Logger } from '@objectstack/spec/contracts'; import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation'; +import { applyConversionsToFlow } from '@objectstack/spec'; import type { FlowRegionParsed } from '@objectstack/spec/automation'; import type { Connector } from '@objectstack/spec/integration'; import { ConnectorSchema } from '@objectstack/spec/integration'; @@ -884,7 +885,26 @@ export class AutomationEngine implements IAutomationService { // ── IAutomationService Contract Implementation ──────── registerFlow(name: string, definition: unknown): void { - const parsed = FlowSchema.parse(definition); + // ADR-0087 D2 — the runtime load seam. A stored flow authored against an + // old shape (a `webhook`/`http_request` callout node, a `delete_record` + // with `config.filters`) is canonicalized on rehydration, BEFORE parse + + // 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. + const reservedNodeTypes = new Set([ + ...FLOW_STRUCTURAL_NODE_TYPES, + ...this.nodeExecutors.keys(), + ...this.actionDescriptors.keys(), + ]); + const converted = applyConversionsToFlow(definition, { + reservedNodeTypes, + onNotice: (n) => this.logger.warn(`[flow '${name}'] ${n.code}: ${n.message}`), + onConflict: (c) => this.logger.warn(`[flow '${name}'] ${c.code}: ${c.message}`), + }); + const parsed = FlowSchema.parse(converted); // DAG cycle detection this.detectCycles(parsed); diff --git a/packages/services/service-automation/src/flow-load-conversion.test.ts b/packages/services/service-automation/src/flow-load-conversion.test.ts new file mode 100644 index 0000000000..2dbadc3c4b --- /dev/null +++ b/packages/services/service-automation/src/flow-load-conversion.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0087 D2 runtime load seam: `AutomationEngine.registerFlow` canonicalizes a + * stored flow's shape on rehydration, BEFORE parse + execution — so a stored + * 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. + */ +import { describe, it, expect } from 'vitest'; +import { AutomationEngine } from './engine.js'; + +function collectingLogger(warns: string[]): any { + const l: any = { info() {}, warn(m: string) { warns.push(m); }, error() {}, debug() {} }; + l.child = () => l; + return l; +} + +function flowWith(node: Record) { + return { + name: 'f', label: 'F', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'n', label: 'N', ...node }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'n' }, + { id: 'e2', source: 'n', target: 'end' }, + ], + }; +} + +describe('registerFlow canonicalizes stored flows (ADR-0087 D2 runtime seam)', () => { + it('rewrites a retired callout node type to `http` on rehydration', async () => { + const warns: string[] = []; + const engine = new AutomationEngine(collectingLogger(warns)); + engine.registerFlow('f', flowWith({ id: 'n', type: 'http_request', config: { url: 'https://x' } })); + + const stored = await engine.getFlow('f'); + expect(stored?.nodes.find((n) => n.id === 'n')?.type).toBe('http'); + expect(warns.some((w) => w.includes("'http_request' → 'http'"))).toBe(true); + }); + + it('leaves a canonical flow untouched (no notice)', async () => { + const warns: string[] = []; + const engine = new AutomationEngine(collectingLogger(warns)); + engine.registerFlow('f', flowWith({ id: 'n', type: 'http', config: { url: 'https://x' } })); + + const stored = await engine.getFlow('f'); + expect(stored?.nodes.find((n) => n.id === 'n')?.type).toBe('http'); + 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 () => { + 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', 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); + + await engine.execute('f'); + expect(ran).toBe(true); // the custom executor actually ran + }); +}); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 43b68334a0..ba9586fadc 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -13,12 +13,16 @@ "BUILTIN_IDENTITY_PLATFORM_ADMIN (const)", "BuiltinIdentityName (type)", "CONVERSIONS_BY_MAJOR (const)", + "CONVERSION_CONFLICT_CODE (const)", "CONVERSION_NOTICE_CODE (const)", "ComposeStacksOptions (type)", "ComposeStacksOptionsSchema (const)", "ConflictStrategy (type)", "ConflictStrategySchema (const)", "ConversionApplication (interface)", + "ConversionConflictDetail (interface)", + "ConversionConflictNotice (interface)", + "ConversionContext (interface)", "ConversionFixture (interface)", "ConversionNotice (interface)", "CronExpressionInputSchema (const)", @@ -91,6 +95,7 @@ "Tool (type)", "ViewKeyCollision (interface)", "applyConversions (function)", + "applyConversionsToFlow (function)", "applyMetaMigrations (function)", "cel (function)", "collectConversionNotices (function)", diff --git a/packages/spec/src/conversions/apply.ts b/packages/spec/src/conversions/apply.ts index 0b43261075..ea2d6b8df8 100644 --- a/packages/spec/src/conversions/apply.ts +++ b/packages/spec/src/conversions/apply.ts @@ -12,7 +12,13 @@ */ import { ALL_CONVERSIONS } from './registry.js'; -import { CONVERSION_NOTICE_CODE, type ConversionNotice } from './types.js'; +import { + CONVERSION_CONFLICT_CODE, + CONVERSION_NOTICE_CODE, + type ConversionConflictNotice, + type ConversionContext, + type ConversionNotice, +} from './types.js'; export interface ApplyConversionsOptions { /** @@ -21,6 +27,18 @@ export interface ApplyConversionsOptions { * choice. `objectstack validate` passes a sink that prints them. */ onNotice?: (notice: ConversionNotice) => void; + /** + * Sink for each structured **conflict** — a rename refused because its old + * token is a live name in this environment (see {@link ConversionContext}). + * Populated by the runtime load seam; absent on the build/validate seam. + */ + onConflict?: (notice: ConversionConflictNotice) => void; + /** + * Node types that are live in this environment. Supplied by the runtime load + * seam so open-namespace renames can detect a collision with a live owner + * rather than silently clobber it. + */ + reservedNodeTypes?: ReadonlySet; } /** @@ -35,34 +53,71 @@ export function applyConversions( stack: Record, options: ApplyConversionsOptions = {}, ): Record { - const { onNotice } = options; + const { onNotice, onConflict, reservedNodeTypes } = options; let current = stack; for (const conversion of ALL_CONVERSIONS) { const retiresIn = conversion.toMajor + 1; - current = conversion.apply(current, (detail) => { - if (!onNotice) return; - onNotice({ - code: CONVERSION_NOTICE_CODE, - conversionId: conversion.id, - surface: conversion.surface, - toMajor: conversion.toMajor, - retiresIn, - from: detail.from, - to: detail.to, - path: detail.path, - message: - `[protocol] converted ${conversion.surface} at ${detail.path}: ` + - `'${detail.from}' → '${detail.to}' (deprecated; ADR-0087 conversion ` + - `'${conversion.id}', retires from the load path in protocol ${retiresIn}). ` + - `Update the source to '${detail.to}'.`, - }); - }); + const context: ConversionContext = { + reservedNodeTypes, + reportConflict: onConflict + ? (detail) => + onConflict({ + code: CONVERSION_CONFLICT_CODE, + conversionId: conversion.id, + surface: conversion.surface, + token: detail.token, + path: detail.path, + message: `[protocol] ${detail.reason} (ADR-0087 conversion '${conversion.id}').`, + }) + : undefined, + }; + current = conversion.apply( + current, + (detail) => { + if (!onNotice) return; + onNotice({ + code: CONVERSION_NOTICE_CODE, + conversionId: conversion.id, + surface: conversion.surface, + toMajor: conversion.toMajor, + retiresIn, + from: detail.from, + to: detail.to, + path: detail.path, + message: + `[protocol] converted ${conversion.surface} at ${detail.path}: ` + + `'${detail.from}' → '${detail.to}' (deprecated; ADR-0087 conversion ` + + `'${conversion.id}', retires from the load path in protocol ${retiresIn}). ` + + `Update the source to '${detail.to}'.`, + }); + }, + context, + ); } return current; } +/** + * Apply the conversion pass to a **single flow definition** — the shape the + * runtime automation engine loads one at a time via `registerFlow`. + * + * This is the runtime load seam ADR-0087 D2 calls for: a stored flow authored + * against an old shape (e.g. a `delete_record` node with `config.filters`, or a + * `webhook` callout node) is canonicalized on rehydration, so the executor only + * ever sees the canonical shape. Callers pass `reservedNodeTypes` (their live + * executor registry) so an open-namespace rename over a live custom node becomes + * a reported conflict, not a silent clobber. Non-object input is returned + * unchanged. + */ +export function applyConversionsToFlow(flow: T, options: ApplyConversionsOptions = {}): T { + if (flow == null || typeof flow !== 'object' || Array.isArray(flow)) return flow; + const converted = applyConversions({ flows: [flow as Record] }, options); + const flows = converted.flows; + return Array.isArray(flows) && flows.length > 0 ? (flows[0] as T) : flow; +} + /** * Collect the notices a stack would emit without needing an external sink — * convenience for `validate` / `lint` / the future MCP `spec_deprecations` tool. diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index adef333922..881788f54a 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -78,6 +78,36 @@ 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', () => { + const notices: ConversionNotice[] = []; + const conflicts: { token: string; path: string; conversionId: string }[] = []; + const out = applyConversions( + { flows: [{ name: 'f', nodes: [{ id: 'a', type: 'webhook' }] }] }, + { + 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 + }, + ); + // 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' }, + ]); + }); + + 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); + }); + }); + describe('flow-node-crud-filter-alias (PD #12 retirement)', () => { it('renames config.filters → config.filter only for CRUD node types', () => { const { stack, notices } = collectConversionNotices({ diff --git a/packages/spec/src/conversions/index.ts b/packages/spec/src/conversions/index.ts index 20888d487e..5af7dcf94f 100644 --- a/packages/spec/src/conversions/index.ts +++ b/packages/spec/src/conversions/index.ts @@ -10,8 +10,12 @@ */ export { + CONVERSION_CONFLICT_CODE, CONVERSION_NOTICE_CODE, type ConversionApplication, + type ConversionConflictDetail, + type ConversionConflictNotice, + type ConversionContext, type ConversionFixture, type ConversionNotice, type MetadataConversion, @@ -19,6 +23,7 @@ export { export { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; export { applyConversions, + applyConversionsToFlow, collectConversionNotices, type ApplyConversionsOptions, } from './apply.js'; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index f39c418d55..3542850bcc 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -31,11 +31,30 @@ const flowNodeHttpRename: MetadataConversion = { toMajor: 11, surface: 'flow.node.type', summary: "flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http'", - apply(stack, emit) { + apply(stack, emit, context) { const aliases = new Set(['http_request', 'http_call', 'webhook']); 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' }; }); diff --git a/packages/spec/src/conversions/types.ts b/packages/spec/src/conversions/types.ts index 6063700d55..d5914eae36 100644 --- a/packages/spec/src/conversions/types.ts +++ b/packages/spec/src/conversions/types.ts @@ -27,6 +27,15 @@ /** Stable code stamped on every conversion notice — greppable, MCP-serializable. */ export const CONVERSION_NOTICE_CODE = 'OS_METADATA_CONVERTED' as const; +/** + * Stable code for a **conversion conflict** — a rename whose old token is, in + * this environment, a *live* name owned by something else (e.g. a third-party + * flow-node executor registered under a since-retired official node type). The + * conversion refuses to rewrite it (which would silently break that owner) and + * instead surfaces this loud, actionable diagnostic (ADR-0078: never silent). + */ +export const CONVERSION_CONFLICT_CODE = 'OS_METADATA_CONVERSION_CONFLICT' as const; + /** * A structured deprecation notice emitted once per applied conversion. * @@ -61,6 +70,48 @@ export interface ConversionApplication { path: string; } +/** The per-conflict detail a conversion reports when it refuses to rewrite a live name. */ +export interface ConversionConflictDetail { + /** The reserved/retired token that is currently a live name owned by something else. */ + token: string; + /** Where the conflicting site is, e.g. `flows[0].nodes[2].type`. */ + path: string; + /** Why the rewrite was refused (actionable — tells the owner what to do). */ + reason: string; +} + +/** + * A structured conflict notice: a rename was **refused** because its old token + * is a live name in this environment. Same machine-first shape as + * {@link ConversionNotice}; different code. + */ +export interface ConversionConflictNotice { + code: typeof CONVERSION_CONFLICT_CODE; + conversionId: string; + surface: string; + token: string; + path: string; + message: string; +} + +/** + * Environment-supplied context for a conversion pass. Empty on the pure + * build/validate seam (no runtime registry to consult); populated on the + * runtime load seam (`engine.registerFlow`) so a rename over an *open* + * namespace (flow node types) can detect a collision with a live owner instead + * of silently clobbering it. + */ +export interface ConversionContext { + /** + * Node types that are *live* in this environment (registered executors + + * action descriptors + structural types). A node-type rename whose old token + * is in this set is a conflict, not a conversion. + */ + reservedNodeTypes?: ReadonlySet; + /** Sink for a refused rewrite (see {@link ConversionConflictDetail}). */ + reportConflict?: (detail: ConversionConflictDetail) => void; +} + /** * An old-shape → new-shape fixture pair. Every conversion entry ships one; a CI * check drives `before` through the load path and asserts it equals `after` and @@ -96,11 +147,15 @@ export interface MetadataConversion { summary: string; /** * Apply the conversion to a normalized stack, immutably. Returns the (possibly - * new) stack and calls `emit` once per rewritten site. + * new) stack and calls `emit` once per rewritten site. A conversion over an + * open namespace consults `context` (when supplied) to refuse — and report via + * `context.reportConflict` — a rewrite whose old token is a live name; a + * conversion over a closed surface ignores `context`. */ apply( stack: Record, emit: (detail: ConversionApplication) => void, + context?: ConversionContext, ): Record; /** Old→new fixture pair driving the CI check. */ fixture: ConversionFixture;