@prodshape/integration-openspec 0.5.2 → 0.6.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The OpenSpec `product` schema shipped by this integration.
3
+ *
4
+ * The schema is a set of static assets (schema.yaml, templates, bridge scripts) bundled with the
5
+ * package and installed into a consumer repository at `openspec/schemas/product/`. OpenSpec reads
6
+ * project-local schemas from that directory natively; installing files there uses the framework's
7
+ * official extension surface, exactly as merging guidance into `openspec/config.yaml` does.
8
+ *
9
+ * The assets are data. Loading them performs no repository writes; installation, verification and
10
+ * removal live in `index.ts` beside the rest of the managed-surface lifecycle.
11
+ */
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { join } from 'node:path';
15
+
16
+ /** The OpenSpec schema name (the directory key OpenSpec resolves). */
17
+ export const OPENSPEC_PRODUCT_SCHEMA_NAME = 'product';
18
+
19
+ /** The repository-relative directory the product schema is installed into. */
20
+ export const OPENSPEC_PRODUCT_SCHEMA_RELATIVE = 'openspec/schemas/product';
21
+
22
+ /**
23
+ * The minimum OpenSpec CLI version the product workflow needs. Project-local schemas are far
24
+ * older, but three 1.7.0 behaviours are load-bearing: `openspec new change` records
25
+ * `skip_specs: true` for schemas with no specs artifact (so `openspec validate` stops demanding
26
+ * delta specs), artifacts follow schema declaration order, and the generated skills stop
27
+ * overriding custom-schema instructions with spec-driven patterns. Below this floor the schema
28
+ * files are inert data: the citation lane keeps working and the product workflow is reported
29
+ * unavailable, never broken.
30
+ */
31
+ export const PRODUCT_SCHEMA_MIN_OPENSPEC = '1.7.0';
32
+
33
+ /** One bundled product-schema file: its path relative to the installed schema directory. */
34
+ export interface ProductSchemaAsset {
35
+ relative: string;
36
+ content: string;
37
+ }
38
+
39
+ /** The bundled asset directory, resolved from this module (both src/ and dist/ sit one level below the package root). */
40
+ function assetsDir(): string {
41
+ return fileURLToPath(new URL('../assets/openspec-product-schema/', import.meta.url));
42
+ }
43
+
44
+ async function collectFiles(dir: string, prefix: string): Promise<string[]> {
45
+ const entries = await readdir(dir, { withFileTypes: true });
46
+ const files: string[] = [];
47
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
48
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
49
+ if (entry.isDirectory()) {
50
+ files.push(...(await collectFiles(join(dir, entry.name), relative)));
51
+ } else if (entry.isFile()) {
52
+ files.push(relative);
53
+ }
54
+ }
55
+ return files;
56
+ }
57
+
58
+ /**
59
+ * Load the bundled product-schema assets, sorted by relative path so installation, metadata and
60
+ * comparison all see one deterministic order.
61
+ */
62
+ export async function loadProductSchemaAssets(): Promise<ProductSchemaAsset[]> {
63
+ const dir = assetsDir();
64
+ const relatives = await collectFiles(dir, '');
65
+ const assets: ProductSchemaAsset[] = [];
66
+ for (const relative of relatives.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) {
67
+ assets.push({
68
+ relative,
69
+ content: await readFile(join(dir, ...relative.split('/')), 'utf8'),
70
+ });
71
+ }
72
+ return assets;
73
+ }
@@ -0,0 +1,557 @@
1
+ /**
2
+ * The deterministic rails of the OpenSpec-hosted PDaC product workflow.
3
+ *
4
+ * An OpenSpec change created with the `product` schema hosts a PDaC Product Change at
5
+ * `openspec/changes/<name>/product/` (change.md plus proposed/**, the normative delta format).
6
+ * OpenSpec owns the workflow orchestration (`/opsx:new`, `/opsx:continue`, `/opsx:apply`,
7
+ * `/opsx:archive`); this module owns what OpenSpec cannot provide: reading the accepted model,
8
+ * compiling the semantic graph in memory, validating the hosted delta as an overlay on the
9
+ * untouched baseline, applying a valid delta deterministically, and deriving fresh context for a
10
+ * later delivery workflow.
11
+ *
12
+ * Two boundaries are deliberate:
13
+ * - Validation is mandatory and happens AT APPLY TIME: `applyOpenSpecProductChange` performs its
14
+ * own full validation immediately before planning, so a stale earlier validate call can never
15
+ * authorise a write (no time-of-check to time-of-use gap). `validateOpenSpecProductChange`
16
+ * exists for explicit preflight, wrappers, diagnostics and tests; it is never a precondition
17
+ * token.
18
+ * - Authorisation policy stays outside this deterministic mechanism: `status: approved` in the
19
+ * hosted change.md is the apply-authorised protocol state (PRODUCT028 refuses anything else).
20
+ * ProductShape's accepted policy keeps product approval human; this rail verifies the recorded
21
+ * state but cannot identify or judge the actor, and nothing here performs the transition.
22
+ *
23
+ * Everything deterministic comes from `@prodshape/core`. The one lifecycle divergence from the
24
+ * native Product Change flow is the container: the hosted change directory belongs to OpenSpec,
25
+ * so the plan's `move-change` action (ProductShape's `changes/active -> changes/completed`
26
+ * archive move) is filtered out and `openspec archive` moves the container instead, as a separate
27
+ * action after apply. `planApply`'s contract sanctions callers filtering the returned action set.
28
+ */
29
+ import { readdir, readFile } from 'node:fs/promises';
30
+ import { join } from 'node:path';
31
+ import { parse } from 'yaml';
32
+ import {
33
+ analyzeImpact,
34
+ blockingDiagnostics,
35
+ compareCodePoints,
36
+ dedupeDiagnostics,
37
+ discoverChanges,
38
+ executeApply,
39
+ isNotFound,
40
+ loadChange,
41
+ openRepository,
42
+ planApply,
43
+ preflightApply,
44
+ sortDiagnostics,
45
+ validateBaseline,
46
+ validateChange,
47
+ } from '@prodshape/core';
48
+ import type {
49
+ ApplyPlan,
50
+ BaselineValidation,
51
+ Diagnostic,
52
+ ImpactReport,
53
+ LoadedArtifact,
54
+ LoadedChange,
55
+ ProductGraph,
56
+ ProductRepository,
57
+ } from '@prodshape/core';
58
+ import { OPENSPEC_PRODUCT_SCHEMA_NAME } from './product-schema.js';
59
+ import { pathExists } from './workspace.js';
60
+
61
+ /** The subdirectory of an OpenSpec change that hosts the PDaC Product Change delta. */
62
+ export const OPENSPEC_PRODUCT_SUBDIR = 'product';
63
+
64
+ /** OpenSpec 1.11's change-id grammar (`isKebabId`): one folder-safe path segment. */
65
+ const OPENSPEC_CHANGE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
66
+
67
+ function assertOpenSpecChangeName(changeName: string): void {
68
+ if (changeName.length > 200 || !OPENSPEC_CHANGE_NAME.test(changeName)) {
69
+ throw new Error(
70
+ `OpenSpec change '${changeName}' is not a valid OpenSpec change name; use kebab-case with lowercase letters, numbers and single hyphen separators.`,
71
+ );
72
+ }
73
+ }
74
+
75
+ /** The accepted product model, read directly from disk and validated, with its in-memory graph. */
76
+ export interface ProductModelInspection {
77
+ repo: ProductRepository;
78
+ artifacts: LoadedArtifact[];
79
+ graph: ProductGraph;
80
+ /** Configuration diagnostics plus full baseline validation, sorted. */
81
+ diagnostics: Diagnostic[];
82
+ }
83
+
84
+ /**
85
+ * Read the accepted model at the repository's product root (default docs/product), compile the
86
+ * semantic graph in memory and validate. Needs no `.product/generated` output and no
87
+ * configuration file: kernel defaults apply when `.product/config.yaml` is absent.
88
+ */
89
+ export async function inspectProductModel(root: string): Promise<ProductModelInspection> {
90
+ const repo = await openRepository(root);
91
+ const baseline = await validateBaseline(repo);
92
+ return {
93
+ repo,
94
+ artifacts: baseline.artifacts,
95
+ graph: baseline.graph,
96
+ diagnostics: sortDiagnostics([...repo.configDiagnostics, ...baseline.diagnostics]),
97
+ };
98
+ }
99
+
100
+ /** One OpenSpec change hosting a PDaC Product Change delta. */
101
+ export interface OpenSpecProductChangeRef {
102
+ /** The OpenSpec change name (the directory under openspec/changes/). */
103
+ name: string;
104
+ /** Absolute path of the hosted PDaC change directory (…/<name>/product). */
105
+ dir: string;
106
+ /** Repository-relative POSIX path of the hosted change.md. */
107
+ file: string;
108
+ }
109
+
110
+ /** The result of reading a change's `.openspec.yaml` schema pin. */
111
+ type HostedSchemaPin =
112
+ { schema: string } | { problem: 'missing' } | { problem: 'malformed'; detail: string };
113
+
114
+ const OPEN_SPEC_KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
115
+
116
+ /** Validate the OpenSpec 1.11 ChangeMetadataSchema fields without taking a runtime dependency. */
117
+ function openSpecChangeMetadataProblem(parsed: unknown): string | undefined {
118
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
119
+ return 'the document must be an object';
120
+ }
121
+ const record = parsed as Record<string, unknown>;
122
+ if (typeof record['schema'] !== 'string' || record['schema'].length === 0) {
123
+ return 'schema is required';
124
+ }
125
+ const created = record['created'];
126
+ if (
127
+ created !== undefined &&
128
+ (typeof created !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(created))
129
+ ) {
130
+ return 'created must be YYYY-MM-DD format';
131
+ }
132
+ const goal = record['goal'];
133
+ if (goal !== undefined && (typeof goal !== 'string' || goal.length === 0)) {
134
+ return 'goal must be a non-empty string';
135
+ }
136
+ const affectedAreas = record['affected_areas'];
137
+ if (
138
+ affectedAreas !== undefined &&
139
+ (!Array.isArray(affectedAreas) ||
140
+ !affectedAreas.every((area) => typeof area === 'string' && area.length > 0))
141
+ ) {
142
+ return 'affected_areas must be an array of non-empty strings';
143
+ }
144
+ const initiative = record['initiative'];
145
+ if (initiative !== undefined) {
146
+ if (initiative === null || typeof initiative !== 'object' || Array.isArray(initiative)) {
147
+ return 'initiative must be an object';
148
+ }
149
+ const link = initiative as Record<string, unknown>;
150
+ if (
151
+ Object.keys(link).some((key) => key !== 'store' && key !== 'id') ||
152
+ typeof link['store'] !== 'string' ||
153
+ !OPEN_SPEC_KEBAB_ID.test(link['store']) ||
154
+ typeof link['id'] !== 'string' ||
155
+ !OPEN_SPEC_KEBAB_ID.test(link['id'])
156
+ ) {
157
+ return 'initiative must contain only kebab-case store and id fields';
158
+ }
159
+ }
160
+ if (record['skip_specs'] !== undefined && typeof record['skip_specs'] !== 'boolean') {
161
+ return 'skip_specs must be a boolean';
162
+ }
163
+ if (
164
+ record['retire_capabilities'] !== undefined &&
165
+ typeof record['retire_capabilities'] !== 'boolean'
166
+ ) {
167
+ return 'retire_capabilities must be a boolean';
168
+ }
169
+ return undefined;
170
+ }
171
+
172
+ /**
173
+ * Read the schema a change is pinned to. OpenSpec records it in the change's `.openspec.yaml`
174
+ * (`openspec new change <name> --schema product` writes it), and the hosted product rail treats
175
+ * that pin as load-bearing: OpenSpec selects the workflow, so a change is a product change
176
+ * because its container says so, never because a `product/` directory happens to exist.
177
+ */
178
+ async function readHostedSchemaPin(changeDir: string): Promise<HostedSchemaPin> {
179
+ const metadataPath = join(changeDir, '.openspec.yaml');
180
+ if (!(await pathExists(metadataPath))) return { problem: 'missing' };
181
+ let parsed: unknown;
182
+ try {
183
+ parsed = parse(await readFile(metadataPath, 'utf8'));
184
+ } catch (error) {
185
+ return {
186
+ problem: 'malformed',
187
+ detail: error instanceof Error ? error.message : String(error),
188
+ };
189
+ }
190
+ const problem = openSpecChangeMetadataProblem(parsed);
191
+ if (problem !== undefined) return { problem: 'malformed', detail: problem };
192
+ return { schema: (parsed as Record<string, string>)['schema']! };
193
+ }
194
+
195
+ function hostedSchemaPinError(
196
+ changeName: string,
197
+ pin: Exclude<HostedSchemaPin, { schema: string }>,
198
+ ): Error {
199
+ if (pin.problem === 'missing') {
200
+ return new Error(
201
+ `OpenSpec change '${changeName}' has no .openspec.yaml, so its schema pin is unknown; the hosted product rail refuses it. Create product changes with: openspec new change ${changeName} --schema ${OPENSPEC_PRODUCT_SCHEMA_NAME}.`,
202
+ );
203
+ }
204
+ return new Error(
205
+ `OpenSpec change '${changeName}' has an unreadable .openspec.yaml (${pin.detail}); the hosted product rail refuses it until the metadata is valid under OpenSpec 1.11.`,
206
+ );
207
+ }
208
+
209
+ /**
210
+ * List the OpenSpec changes that host a PDaC Product Change: pinned to `schema: product` in
211
+ * `.openspec.yaml` AND carrying `product/change.md`. The OpenSpec archive is history and is never
212
+ * listed; a change without a product delta (for example one whose honest intent verdict was "no
213
+ * product delta") is not a product change; and a change pinned to another schema is not one
214
+ * either, whatever directories it contains, so it never enters the product rail or its
215
+ * concurrency input.
216
+ */
217
+ export async function listOpenSpecProductChanges(
218
+ root: string,
219
+ ): Promise<OpenSpecProductChangeRef[]> {
220
+ const changesDir = join(root, 'openspec', 'changes');
221
+ let entries;
222
+ try {
223
+ entries = await readdir(changesDir, { withFileTypes: true });
224
+ } catch (error) {
225
+ if (isNotFound(error)) return [];
226
+ throw error;
227
+ }
228
+ const names = entries
229
+ .filter((entry) => entry.isDirectory() && entry.name !== 'archive')
230
+ .map((entry) => entry.name)
231
+ .sort(compareCodePoints);
232
+ const refs: OpenSpecProductChangeRef[] = [];
233
+ for (const name of names) {
234
+ const changeDir = join(changesDir, name);
235
+ const dir = join(changeDir, OPENSPEC_PRODUCT_SUBDIR);
236
+ if (!(await pathExists(join(dir, 'change.md')))) continue;
237
+ assertOpenSpecChangeName(name);
238
+ const pin = await readHostedSchemaPin(changeDir);
239
+ if ('problem' in pin) throw hostedSchemaPinError(name, pin);
240
+ if (pin.schema !== OPENSPEC_PRODUCT_SCHEMA_NAME) continue;
241
+ refs.push({
242
+ name,
243
+ dir,
244
+ file: `openspec/changes/${name}/${OPENSPEC_PRODUCT_SUBDIR}/change.md`,
245
+ });
246
+ }
247
+ return refs;
248
+ }
249
+
250
+ /** Load one hosted Product Change through core's loader (change.md plus proposed/**). */
251
+ export async function loadOpenSpecProductChange(
252
+ root: string,
253
+ changeName: string,
254
+ ): Promise<LoadedChange> {
255
+ const repo = await openRepository(root);
256
+ return loadHostedChange(root, repo, changeName);
257
+ }
258
+
259
+ async function loadHostedChange(
260
+ root: string,
261
+ repo: ProductRepository,
262
+ changeName: string,
263
+ ): Promise<LoadedChange> {
264
+ assertOpenSpecChangeName(changeName);
265
+ const changesDir = join(root, 'openspec', 'changes');
266
+ const changeDir = join(changesDir, changeName);
267
+ const deltaDir = join(changeDir, OPENSPEC_PRODUCT_SUBDIR);
268
+ const hasDelta =
269
+ changeName !== 'archive' &&
270
+ (await pathExists(changeDir)) &&
271
+ (await pathExists(join(deltaDir, 'change.md')));
272
+ if (!hasDelta) {
273
+ const refs = await listOpenSpecProductChanges(root);
274
+ const known = refs.map((candidate) => candidate.name).join(', ') || 'none';
275
+ throw new Error(
276
+ `No OpenSpec product change named '${changeName}' under openspec/changes/ (a product change hosts product/change.md; found: ${known}).`,
277
+ );
278
+ }
279
+ // The schema pin is load-bearing and fails closed: without a readable pin naming the product
280
+ // schema, this change never enters the product rail, however product-shaped its contents look.
281
+ const pin = await readHostedSchemaPin(changeDir);
282
+ if ('problem' in pin) {
283
+ throw hostedSchemaPinError(changeName, pin);
284
+ }
285
+ if (pin.schema !== OPENSPEC_PRODUCT_SCHEMA_NAME) {
286
+ throw new Error(
287
+ `OpenSpec change '${changeName}' is pinned to schema '${pin.schema}', not '${OPENSPEC_PRODUCT_SCHEMA_NAME}'; the hosted product rail refuses it. Product intent travels through a change created with: openspec new change <name> --schema ${OPENSPEC_PRODUCT_SCHEMA_NAME}.`,
288
+ );
289
+ }
290
+ return loadChange(deltaDir, repo.root, repo.registry);
291
+ }
292
+
293
+ /**
294
+ * A hosted change in a terminal status is change history awaiting its container move: the native
295
+ * lifecycle expresses that state by location (changes/completed, rejected, superseded), while the
296
+ * hosted container keeps the directory in place until `openspec archive` moves it, so status is
297
+ * the lifecycle carrier here.
298
+ */
299
+ const TERMINAL_CHANGE_STATUSES = new Set(['applied', 'rejected', 'superseded']);
300
+
301
+ /**
302
+ * Every live change the concurrency rule (PRODUCT025) must see: hosted OpenSpec product changes
303
+ * that are not in a terminal status, plus every native change under `<changes>/active`.
304
+ * Concurrency spans BOTH containers, so a hosted change and a native change touching the same
305
+ * artifact report against each other; a terminal hosted change is inert exactly like the native
306
+ * archives.
307
+ */
308
+ async function loadAllLiveChanges(root: string, repo: ProductRepository): Promise<LoadedChange[]> {
309
+ const changes: LoadedChange[] = [];
310
+ for (const ref of await listOpenSpecProductChanges(root)) {
311
+ const loaded = await loadChange(ref.dir, repo.root, repo.registry);
312
+ if (loaded.status !== undefined && TERMINAL_CHANGE_STATUSES.has(loaded.status)) continue;
313
+ changes.push(loaded);
314
+ }
315
+ for (const dir of await discoverChanges(join(repo.changesDir, 'active'))) {
316
+ changes.push(await loadChange(dir, repo.root, repo.registry));
317
+ }
318
+ return changes;
319
+ }
320
+
321
+ /** The result of validating a hosted Product Change as an overlay on the untouched baseline. */
322
+ export interface OpenSpecProductChangeValidation {
323
+ change: LoadedChange;
324
+ baseline: BaselineValidation;
325
+ overlayArtifacts: LoadedArtifact[];
326
+ overlayGraph: ProductGraph;
327
+ /** Configuration, baseline and change diagnostics, deduplicated and sorted. */
328
+ diagnostics: Diagnostic[];
329
+ /** The diagnostics that block, under the repository's warnings-as-errors policy. */
330
+ blocking: Diagnostic[];
331
+ }
332
+
333
+ /**
334
+ * Validate one hosted Product Change: the accepted baseline stays untouched, the delta is
335
+ * compiled over it into an overlay, and the overlay is validated end to end (operation checks,
336
+ * cross-container concurrency, open-questions state, full structural validation with the
337
+ * PRODUCT023/PRODUCT024 overlay recodes). Useful as explicit preflight and for wrappers and
338
+ * tests; apply never trusts it and revalidates on its own.
339
+ */
340
+ export async function validateOpenSpecProductChange(
341
+ root: string,
342
+ changeName: string,
343
+ ): Promise<OpenSpecProductChangeValidation> {
344
+ const repo = await openRepository(root);
345
+ const baseline = await validateBaseline(repo);
346
+ const all = await loadAllLiveChanges(root, repo);
347
+ // An unknown name resolves through the loader, whose error names the known product changes.
348
+ const change =
349
+ all.find((candidate) => hostedName(root, candidate) === changeName) ??
350
+ (await loadHostedChange(root, repo, changeName));
351
+ const validation = validateChange(change, baseline.artifacts, all);
352
+ const diagnostics = sortDiagnostics(
353
+ dedupeDiagnostics([
354
+ ...repo.configDiagnostics,
355
+ ...baseline.diagnostics,
356
+ ...validation.diagnostics,
357
+ ]),
358
+ );
359
+ return {
360
+ change,
361
+ baseline,
362
+ overlayArtifacts: validation.overlayArtifacts,
363
+ overlayGraph: validation.overlayGraph,
364
+ diagnostics,
365
+ blocking: blockingDiagnostics(diagnostics, repo.config.validation['warnings-as-errors']),
366
+ };
367
+ }
368
+
369
+ /** The hosted OpenSpec change name of a loaded change, or undefined for a native change. */
370
+ function hostedName(root: string, change: LoadedChange): string | undefined {
371
+ const normalizedRoot = root.replaceAll('\\', '/');
372
+ const normalizedDir = change.dir.replaceAll('\\', '/');
373
+ const relative = normalizedDir.startsWith(normalizedRoot)
374
+ ? normalizedDir.slice(normalizedRoot.length).replace(/^\//, '')
375
+ : normalizedDir;
376
+ const match = relative.match(/^openspec\/changes\/([^/]+)\/product$/);
377
+ return match?.[1];
378
+ }
379
+
380
+ /** How an apply invocation ended. */
381
+ export type OpenSpecProductApplyOutcome = 'applied' | 'dry-run' | 'refused';
382
+
383
+ export interface OpenSpecProductApplyResult {
384
+ outcome: OpenSpecProductApplyOutcome;
385
+ /**
386
+ * The executed (or refused) plan. The ProductShape lifecycle's `move-change` action is already
387
+ * filtered out: the hosted change directory belongs to OpenSpec and only `openspec archive`
388
+ * moves it, as a separate action after apply.
389
+ */
390
+ plan: ApplyPlan;
391
+ change: LoadedChange;
392
+ /** Fresh read and validation of the accepted model after a real apply. */
393
+ resultingModel?: BaselineValidation;
394
+ }
395
+
396
+ /**
397
+ * Apply one hosted Product Change to the accepted model, deterministically and fail closed.
398
+ *
399
+ * The sequence mirrors the native apply and is fail closed end to end: full revalidation at apply
400
+ * time of the configuration, the baseline (including its per-document load diagnostics) and the
401
+ * overlay, the apply-authorised state gate (PRODUCT028; the integration never performs the
402
+ * authorising transition), base-revision drift by content digest (PRODUCT027), write and delete
403
+ * actions named by lowercase id, the product diff computed from the result, and the hosted
404
+ * change.md status flipped to applied in place. Any blocking diagnostic refuses BEFORE any
405
+ * mutation, with `docs/product/model` and the change container byte-identical. A dry run still
406
+ * preflights every action. Nothing is committed and nothing is archived: verification and the
407
+ * container move remain separate, later actions.
408
+ */
409
+ export async function applyOpenSpecProductChange(
410
+ root: string,
411
+ changeName: string,
412
+ options: { dryRun?: boolean } = {},
413
+ ): Promise<OpenSpecProductApplyResult> {
414
+ const { dryRun = false } = options;
415
+ const repo = await openRepository(root);
416
+ const baseline = await validateBaseline(repo);
417
+ const all = await loadAllLiveChanges(root, repo);
418
+ const change =
419
+ all.find((candidate) => hostedName(root, candidate) === changeName) ??
420
+ (await loadHostedChange(root, repo, changeName));
421
+
422
+ const validation = validateChange(change, baseline.artifacts, all);
423
+ // Everything capable of making the resulting model invalid blocks BEFORE any write:
424
+ // configuration diagnostics, the baseline's own load and per-document diagnostics, the change's
425
+ // diagnostics and the full overlay revalidation. The overlay pass alone is not enough, because
426
+ // per-document defects of UNTOUCHED baseline artifacts (parse failures, schema violations,
427
+ // body-section defects) are load-time diagnostics that graph-level revalidation never re-emits;
428
+ // omitting them let apply write into a model whose fresh validation then failed, violating the
429
+ // resulting-model obligation. Refusal happens with the working tree byte-identical.
430
+ const overlayErrors = blockingDiagnostics(
431
+ sortDiagnostics(
432
+ dedupeDiagnostics([
433
+ ...repo.configDiagnostics,
434
+ ...baseline.diagnostics,
435
+ ...validation.diagnostics,
436
+ ]),
437
+ ),
438
+ repo.config.validation['warnings-as-errors'],
439
+ );
440
+
441
+ const rawPlan = await planApply({
442
+ repoRoot: repo.root,
443
+ modelRelative: repo.config.product.model,
444
+ changesRelative: repo.config.product.changes,
445
+ change,
446
+ baseline: baseline.artifacts,
447
+ overlayErrors,
448
+ });
449
+ const plan: ApplyPlan = {
450
+ ...rawPlan,
451
+ actions: rawPlan.actions.filter((action) => action.kind !== 'move-change'),
452
+ // The hosted rail states authorisation as policy. Core's PRODUCT028 wording prescribes a
453
+ // human hand edit, which is the native lifecycle's own contract; here the code, severity,
454
+ // file and fields stay core's while the message (implementation-defined and never compared)
455
+ // says what the hosted contract requires. A message rewrite of an existing diagnostic, never
456
+ // a new emission.
457
+ diagnostics: rawPlan.diagnostics.map((diagnostic) =>
458
+ diagnostic.code === 'PRODUCT028'
459
+ ? {
460
+ ...diagnostic,
461
+ message: `Apply requires status 'approved'; the change is '${change.status ?? 'unknown'}'. The transition into the apply-authorised state belongs to the caller's authorisation policy; the integration never performs or judges it.`,
462
+ }
463
+ : diagnostic,
464
+ ),
465
+ };
466
+
467
+ if (plan.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) {
468
+ return { outcome: 'refused', plan, change };
469
+ }
470
+ if (dryRun) {
471
+ await preflightApply(repo.root, plan);
472
+ return { outcome: 'dry-run', plan, change };
473
+ }
474
+ await executeApply(repo.root, plan);
475
+ const resultingModel = await validateBaseline(repo);
476
+ return { outcome: 'applied', plan, change, resultingModel };
477
+ }
478
+
479
+ /** One artifact carried into delivery context, with the digest of its current accepted content. */
480
+ export interface DeliveryContextEntry {
481
+ id: string;
482
+ type?: string;
483
+ title?: string;
484
+ status?: string;
485
+ file: string;
486
+ digest: string;
487
+ }
488
+
489
+ /** Context derived from a fresh read of the accepted model, for a later delivery workflow. */
490
+ export interface DeliveryContextBundle {
491
+ ids: string[];
492
+ /** The requested artifacts, from the freshly read accepted model. */
493
+ artifacts: DeliveryContextEntry[];
494
+ /** Impact around each requested artifact (direction both, depth 2). */
495
+ impacts: Record<string, ImpactReport>;
496
+ /** Direct neighbours of the requested artifacts, deduplicated, requested ids excluded. */
497
+ neighbours: DeliveryContextEntry[];
498
+ /** Whether the freshly read accepted model validates with zero errors. */
499
+ modelErrorFree: boolean;
500
+ }
501
+
502
+ /**
503
+ * Derive delivery context from the accepted model as it exists on disk NOW: a fresh read and a
504
+ * fresh graph, never state carried over from a product workflow. This is the handoff boundary a
505
+ * future delivery workflow starts from.
506
+ */
507
+ export async function deriveDeliveryContext(
508
+ root: string,
509
+ ids: string[],
510
+ ): Promise<DeliveryContextBundle> {
511
+ const inspection = await inspectProductModel(root);
512
+ const byId = new Map(
513
+ inspection.artifacts
514
+ .filter((artifact) => artifact.id !== undefined)
515
+ .map((artifact) => [artifact.id as string, artifact]),
516
+ );
517
+ const entryOf = (artifact: LoadedArtifact): DeliveryContextEntry => {
518
+ const entry: DeliveryContextEntry = {
519
+ id: artifact.id as string,
520
+ file: artifact.file,
521
+ digest: artifact.digest,
522
+ };
523
+ if (artifact.type !== undefined) entry.type = artifact.type;
524
+ if (artifact.title !== undefined) entry.title = artifact.title;
525
+ if (artifact.status !== undefined) entry.status = artifact.status;
526
+ return entry;
527
+ };
528
+
529
+ const sortedIds = [...ids].sort(compareCodePoints);
530
+ const artifacts: DeliveryContextEntry[] = [];
531
+ const impacts: Record<string, ImpactReport> = {};
532
+ const neighbourIds = new Set<string>();
533
+ for (const id of sortedIds) {
534
+ const artifact = byId.get(id);
535
+ if (!artifact) {
536
+ throw new Error(`Unknown artifact id '${id}' in the accepted model at ${root}.`);
537
+ }
538
+ artifacts.push(entryOf(artifact));
539
+ const report = analyzeImpact(inspection.graph, id, { direction: 'both', depth: 2 });
540
+ impacts[id] = report;
541
+ for (const item of report.direct) neighbourIds.add(item.id);
542
+ }
543
+ for (const id of sortedIds) neighbourIds.delete(id);
544
+ const neighbours = [...neighbourIds]
545
+ .sort(compareCodePoints)
546
+ .map((id) => byId.get(id))
547
+ .filter((artifact): artifact is LoadedArtifact => artifact !== undefined)
548
+ .map(entryOf);
549
+
550
+ return {
551
+ ids: sortedIds,
552
+ artifacts,
553
+ impacts,
554
+ neighbours,
555
+ modelErrorFree: inspection.diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
556
+ };
557
+ }