Skip to content

Commit 40b329e

Browse files
committed
feat(spec,cli): ADR-0087 P2 — replayable migration chain + spec-changes manifest (D3/D4)
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 <file.json> (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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YA9vjVpDrKLuaK6nbm1s37
1 parent a2b7e8d commit 40b329e

11 files changed

Lines changed: 902 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
ADR-0087 P2:可重放迁移链 + 机器可读变更清单(D3 / D4)。
7+
8+
**D3 —— 迁移链(`@objectstack/spec` 新增 `migrations/`)。** 一条永久、有序、按协议大版本组织的迁移链。每个大版本的步骤由两个来源合成:**已毕业的转换**(P1 的 D2 转换条目从加载路径退役后,以其 id 引用复用,作为该大版本的“机械变换”,转换与 fixture 不重复)和**语义变更**(无损映射无法表达的破坏,以结构化 TODO —— surface / 原因 / 验收标准 —— 呈现,而非静默或有损自动改写)。
9+
10+
- `applyMetaMigrations(stack, fromMajor, toMajor?)` 折叠 `fromMajor+1 … 当前` 的步骤,一次性把任意历史大版本的元数据迁到当前;跨大版本是设计主场景。每一跳(hop)都做检查点,便于逐跳验证与二分定位。**时效性从不承重** —— 迟到的使用方到达时重放链即可。
11+
- `composeMigrationChain``MigrationFloorError`,以及显式的发布策略旋钮 `MIGRATION_SUPPORT_FLOOR`(链能回溯到多久)。
12+
- 种子:protocol 11 步骤 —— 机械项为三条已毕业的 P1 转换;语义项为两个真实存量窗口:`titleFormat` 复合模板 → `nameField`(需公式字段,非无损)、SQL 式 RLS 谓词 → 规范 CEL。
13+
- CI 把整条链当作链来测:每条转换的 old-shape fixture 从支持下限重放到目标大版本,组合性破坏即发布阻断。
14+
15+
**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` 工具都是它的投影。
16+
17+
**CLI —— `objectstack migrate meta --from N`** 重放迁移链:展示生成的、经 `ObjectStackDefinitionSchema` 校验的机械变更 diff(逐条 `path: 旧 → 新`)与需人工判断的语义 TODO;`--to``--step`(逐跳检查点)、`--out <file.json>`(把规范化后的栈写为可 diff 的 JSON 快照)、`--json`。命令不静默改写 TS 配置源(AST 改写不安全且有损)—— 输出供使用方 agent 审阅采纳,这正是握手错误(P0)所指向的命令。
18+
19+
`normalizeStackInput` 新增可选 `convert: false`(仅做 map→array,不跑 D2 转换),供 `migrate meta` 对原始编写源重放链、把每处改写归因到对应链步。新增导出纯增量,无破坏性移除。
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Args, Command, Flags } from '@oclif/core';
4+
import { writeFileSync } from 'node:fs';
5+
import { resolve } from 'node:path';
6+
import chalk from 'chalk';
7+
import {
8+
ObjectStackDefinitionSchema,
9+
applyMetaMigrations,
10+
composeSpecChanges,
11+
normalizeStackInput,
12+
MigrationFloorError,
13+
} from '@objectstack/spec';
14+
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
15+
import { loadConfig } from '../../utils/config.js';
16+
import {
17+
printHeader,
18+
printSuccess,
19+
printWarning,
20+
printError,
21+
printInfo,
22+
printStep,
23+
createTimer,
24+
} from '../../utils/format.js';
25+
26+
/**
27+
* `os migrate meta --from N` — replay the ADR-0087 D3 migration chain.
28+
*
29+
* Composes the per-major steps N+1 → … → current and applies each major's
30+
* mechanical transforms (the graduated D2 conversions) to the loaded stack in
31+
* one run — cross-major is the designed-for case, not an edge. It reports a
32+
* generated, schema-validated diff (the mechanical rewrites) plus the structured
33+
* TODOs for the semantic changes the chain cannot apply, so the consumer agent
34+
* reviews a provably-valid change instead of hand-porting from prose.
35+
*
36+
* The command does not silently rewrite TS config source (that AST rewrite is
37+
* unsafe and lossy); `--out` writes the canonicalized stack as a JSON snapshot
38+
* the agent can diff and adopt. `--step` prints a per-hop checkpoint so a failure
39+
* can be bisected to the exact major.
40+
*/
41+
export default class MigrateMeta extends Command {
42+
static override description =
43+
'Replay the metadata protocol migration chain from a past major to current (ADR-0087 D3).';
44+
45+
static override examples = [
46+
'$ os migrate meta --from 10',
47+
'$ os migrate meta --from 10 --step',
48+
'$ os migrate meta --from 11 --to 12 --json',
49+
'$ os migrate meta --from 10 --out migrated.stack.json',
50+
];
51+
52+
static override args = {
53+
config: Args.string({ description: 'Path to the stack config (defaults to auto-detected).' }),
54+
};
55+
56+
static override flags = {
57+
from: Flags.integer({
58+
description: 'The protocol major the metadata was authored against.',
59+
required: true,
60+
}),
61+
to: Flags.integer({
62+
description: `Target protocol major (defaults to this runtime's, ${PROTOCOL_MAJOR}).`,
63+
}),
64+
step: Flags.boolean({
65+
description: 'Print a per-hop checkpoint (for per-major verify / bisection).',
66+
default: false,
67+
}),
68+
out: Flags.string({ description: 'Write the migrated stack as a JSON snapshot to this path.' }),
69+
json: Flags.boolean({ description: 'Output the machine-readable migration result as JSON.' }),
70+
};
71+
72+
async run(): Promise<void> {
73+
const { args, flags } = await this.parse(MigrateMeta);
74+
const timer = createTimer();
75+
const toMajor = flags.to ?? PROTOCOL_MAJOR;
76+
77+
if (!flags.json) printHeader('Migrate · meta');
78+
79+
try {
80+
if (!flags.json) printStep('Loading configuration…');
81+
const { config, absolutePath } = await loadConfig(args.config);
82+
83+
// Map→array normalization ONLY (convert:false): the chain must replay the
84+
// conversions itself against the raw authored source so each rewrite is
85+
// attributed to a chain hop, not silently pre-applied by the load-time
86+
// D2 pass. Running the D2 pass here would leave the chain's diff empty.
87+
const normalized = normalizeStackInput(config as Record<string, unknown>, { convert: false });
88+
89+
if (!flags.json) printStep(`Replaying chain: protocol ${flags.from}${toMajor}…`);
90+
const result = applyMetaMigrations(normalized, flags.from, toMajor);
91+
92+
// Prove the migrated stack is schema-valid — the "generated, provably valid
93+
// diff" the consumer agent reviews (ADR-0087 D3/D5).
94+
const parsed = ObjectStackDefinitionSchema.safeParse(result.stack);
95+
const specChanges = composeSpecChanges(flags.from, toMajor);
96+
97+
if (flags.json) {
98+
console.log(
99+
JSON.stringify(
100+
{
101+
from: result.fromMajor,
102+
to: result.toMajor,
103+
runtime: PROTOCOL_VERSION,
104+
applied: result.applied,
105+
todos: result.todos,
106+
hops: flags.step
107+
? result.hops.map((h) => ({
108+
toMajor: h.toMajor,
109+
rationale: h.rationale,
110+
applied: h.applied,
111+
todos: h.todos,
112+
}))
113+
: undefined,
114+
specChanges,
115+
schemaValid: parsed.success,
116+
duration: timer.elapsed(),
117+
},
118+
null,
119+
2,
120+
),
121+
);
122+
if (flags.out) writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2));
123+
return;
124+
}
125+
126+
printInfo(`Config: ${chalk.white(absolutePath)}`);
127+
printInfo(`Chain: protocol ${flags.from}${toMajor} (runtime ${PROTOCOL_VERSION})`);
128+
console.log('');
129+
130+
if (result.applied.length === 0 && result.todos.length === 0) {
131+
printSuccess('Nothing to migrate — the metadata is already canonical for this range.');
132+
return;
133+
}
134+
135+
// Mechanical rewrites (auto-applied).
136+
if (result.applied.length > 0) {
137+
console.log(chalk.bold(` Applied ${result.applied.length} mechanical change(s):`));
138+
for (const a of result.applied) {
139+
console.log(` • ${a.path}: ${chalk.red(a.from)}${chalk.green(a.to)} ${chalk.dim(`(${a.conversionId})`)}`);
140+
}
141+
console.log('');
142+
}
143+
144+
// Per-hop checkpoints.
145+
if (flags.step) {
146+
for (const hop of result.hops) {
147+
console.log(chalk.bold(` ── protocol ${hop.toMajor} ──`));
148+
console.log(chalk.dim(` ${hop.rationale}`));
149+
console.log(chalk.dim(` ${hop.applied.length} mechanical, ${hop.todos.length} manual`));
150+
}
151+
console.log('');
152+
}
153+
154+
// Semantic TODOs (delegated to the agent — never auto-applied).
155+
if (result.todos.length > 0) {
156+
console.log(chalk.bold(chalk.yellow(` ${result.todos.length} manual change(s) require your judgment:`)));
157+
for (const t of result.todos) {
158+
console.log(` ${chalk.yellow('⚠')} [protocol ${t.toMajor}] ${t.surface}${t.replacement}`);
159+
console.log(chalk.dim(` why: ${t.reason}`));
160+
console.log(chalk.dim(` verify: ${t.acceptanceCriteria}`));
161+
}
162+
console.log('');
163+
}
164+
165+
if (flags.out) {
166+
writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2));
167+
printInfo(`Wrote migrated stack snapshot → ${chalk.white(resolve(flags.out))}`);
168+
}
169+
170+
if (parsed.success) {
171+
printSuccess(`Migrated stack is schema-valid ${chalk.dim(`(${timer.display()})`)}`);
172+
} else {
173+
printWarning(
174+
'Migrated stack does not yet pass schema validation — resolve the manual changes above, ' +
175+
'then run `os validate`.',
176+
);
177+
}
178+
console.log('');
179+
} catch (error: any) {
180+
if (error instanceof MigrationFloorError) {
181+
if (flags.json) {
182+
console.log(JSON.stringify({ error: 'unsupported_from_major', message: error.message }));
183+
this.exit(1);
184+
}
185+
printError(error.message);
186+
this.exit(1);
187+
return;
188+
}
189+
if (flags.json) {
190+
console.log(JSON.stringify({ error: error.message }));
191+
this.exit(1);
192+
}
193+
printError(error.message || String(error));
194+
this.exit(1);
195+
}
196+
}
197+
}

packages/spec/api-surface.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,18 @@
4242
"GUEST_POSITION (const)",
4343
"MAP_SUPPORTED_FIELDS (const)",
4444
"METADATA_ALIASES (const)",
45+
"MIGRATIONS_BY_MAJOR (const)",
46+
"MIGRATION_MAJORS (const)",
47+
"MIGRATION_SUPPORT_FLOOR (const)",
4548
"MapSupportedField (type)",
4649
"MetadataCollectionInput (type)",
4750
"MetadataConversion (interface)",
51+
"MigrationApplication (interface)",
52+
"MigrationChainResult (interface)",
53+
"MigrationFloorError (class)",
54+
"MigrationHopResult (interface)",
55+
"MigrationStep (interface)",
56+
"MigrationTodo (interface)",
4857
"NormalizeStackInputOptions (interface)",
4958
"ObjectOSCapabilities (type)",
5059
"ObjectOSCapabilitiesSchema (const)",
@@ -65,13 +74,28 @@
6574
"PredicateInput (type)",
6675
"PredicateInputSchema (const)",
6776
"PredicateSchema (const)",
77+
"SemanticMigration (interface)",
6878
"Skill (type)",
79+
"SpecChanges (type)",
80+
"SpecChangesSchema (const)",
81+
"SpecConverted (type)",
82+
"SpecConvertedSchema (const)",
83+
"SpecMigrated (type)",
84+
"SpecMigratedSchema (const)",
85+
"SpecSurfaceAdd (type)",
86+
"SpecSurfaceAddSchema (const)",
87+
"SpecSurfaceRemove (type)",
88+
"SpecSurfaceRemoveSchema (const)",
89+
"SurfaceDiff (interface)",
6990
"TemplateExpressionInputSchema (const)",
7091
"Tool (type)",
7192
"ViewKeyCollision (interface)",
7293
"applyConversions (function)",
94+
"applyMetaMigrations (function)",
7395
"cel (function)",
7496
"collectConversionNotices (function)",
97+
"composeMigrationChain (function)",
98+
"composeSpecChanges (function)",
7599
"composeStacks (function)",
76100
"createEvalUser (function)",
77101
"cron (function)",

packages/spec/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ export type { MetadataCollectionInput, MapSupportedField, NormalizeStackInputOpt
113113
// Metadata conversion layer (ADR-0087 D2) — old-shape → canonical-shape transforms applied at load.
114114
export * from './conversions/index.js';
115115

116+
// Metadata migration chain + change manifest (ADR-0087 D3/D4).
117+
export * from './migrations/index.js';
118+
116119
export { type PluginContext } from './kernel/plugin.zod';
117120

118121
// Expression Protocol (M9 — canonical wire format for formulas / predicates / conditions)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Compose and apply the migration chain (ADR-0087 D3).
5+
*
6+
* `objectstack migrate meta --from N` calls {@link applyMetaMigrations}, which
7+
* folds the steps N+1 → … → current and applies each major's *mechanical*
8+
* transforms (the graduated D2 conversions) to the consumer's stack in one run,
9+
* collecting a review diff and the semantic TODOs the agent must resolve. Every
10+
* hop is checkpointed (`hops[]`) so an agent can run its verify loop per major
11+
* and bisect a failure to the exact hop — the agent's `git bisect` for an
12+
* upgrade.
13+
*/
14+
15+
import { PROTOCOL_MAJOR } from '../kernel/protocol-version.js';
16+
import { ALL_CONVERSIONS } from '../conversions/registry.js';
17+
import type { MetadataConversion } from '../conversions/types.js';
18+
import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR } from './registry.js';
19+
import type {
20+
MigrationApplication,
21+
MigrationChainResult,
22+
MigrationHopResult,
23+
MigrationStep,
24+
MigrationTodo,
25+
} from './types.js';
26+
27+
const CONVERSION_BY_ID: ReadonlyMap<string, MetadataConversion> = new Map(
28+
ALL_CONVERSIONS.map((c) => [c.id, c]),
29+
);
30+
31+
/**
32+
* The ordered steps that migrate a `fromMajor` source up to `toMajor`
33+
* (defaults to the running protocol major). Only majors that carry a step
34+
* appear; a major with no break contributes nothing (a no-op hop is elided).
35+
*/
36+
export function composeMigrationChain(
37+
fromMajor: number,
38+
toMajor: number = PROTOCOL_MAJOR,
39+
): MigrationStep[] {
40+
return MIGRATION_MAJORS
41+
.filter((m) => m > fromMajor && m <= toMajor)
42+
.map((m) => MIGRATIONS_BY_MAJOR[m]!);
43+
}
44+
45+
/** Thrown when `--from N` is below the documented support floor. */
46+
export class MigrationFloorError extends Error {
47+
constructor(
48+
public readonly fromMajor: number,
49+
public readonly floor: number,
50+
) {
51+
super(
52+
`Cannot migrate from protocol ${fromMajor}: the chain's support floor is ${floor} ` +
53+
`(ADR-0087 D3). Upgrade to protocol ${floor} by another path first, then re-run.`,
54+
);
55+
this.name = 'MigrationFloorError';
56+
}
57+
}
58+
59+
/**
60+
* Apply the migration chain from `fromMajor` up to `toMajor` to a stack.
61+
*
62+
* Pure and immutable: reuses the D2 conversion transforms (copy-on-write), so
63+
* the input is never mutated. Mechanical rewrites are applied; semantic changes
64+
* are reported as {@link MigrationTodo}s, never auto-applied. Never throws on
65+
* stack content — only {@link MigrationFloorError} when `fromMajor` is
66+
* unsupported.
67+
*/
68+
export function applyMetaMigrations(
69+
stack: Record<string, unknown>,
70+
fromMajor: number,
71+
toMajor: number = PROTOCOL_MAJOR,
72+
): MigrationChainResult {
73+
if (fromMajor < MIGRATION_SUPPORT_FLOOR) {
74+
throw new MigrationFloorError(fromMajor, MIGRATION_SUPPORT_FLOOR);
75+
}
76+
77+
const steps = composeMigrationChain(fromMajor, toMajor);
78+
const applied: MigrationApplication[] = [];
79+
const todos: MigrationTodo[] = [];
80+
const hops: MigrationHopResult[] = [];
81+
let current = stack;
82+
83+
for (const step of steps) {
84+
const hopApplied: MigrationApplication[] = [];
85+
for (const conversionId of step.conversionIds) {
86+
const conversion = CONVERSION_BY_ID.get(conversionId);
87+
// A step referencing an unknown conversion id is a registry authoring bug,
88+
// caught by `migrations.test.ts`; skip defensively rather than crash a
89+
// consumer's upgrade run.
90+
if (!conversion) continue;
91+
current = conversion.apply(current, (detail) => {
92+
hopApplied.push({
93+
toMajor: step.toMajor,
94+
conversionId,
95+
surface: conversion.surface,
96+
from: detail.from,
97+
to: detail.to,
98+
path: detail.path,
99+
});
100+
});
101+
}
102+
const hopTodos: MigrationTodo[] = step.semantic.map((s) => ({ ...s, toMajor: step.toMajor }));
103+
104+
applied.push(...hopApplied);
105+
todos.push(...hopTodos);
106+
hops.push({
107+
toMajor: step.toMajor,
108+
rationale: step.rationale,
109+
stack: current,
110+
applied: hopApplied,
111+
todos: hopTodos,
112+
});
113+
}
114+
115+
return { fromMajor, toMajor, stack: current, applied, todos, hops };
116+
}

0 commit comments

Comments
 (0)