yam-flow 2.5.0 → 2.6.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,1441 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { compareStableText } from './stable-order.js';
5
+ import { inspectImageFile } from './ueye-artifacts.js';
6
+
7
+ const MAX_JSON_BYTES = 4 * 1024 * 1024;
8
+ const MAX_ANCHOR_BYTES = 1024 * 1024;
9
+ const MAX_REVISION_ROUNDS = 2;
10
+ const MAX_PHASE_ITEMS = 256;
11
+
12
+ type JsonObject = Record<string, unknown>;
13
+
14
+ export type PlanReviewVerdict = 'approve' | 'request_changes';
15
+ export type DesignRevisionStatus = 'active' | 'accepted' | 'two_round_limit' | 'integrity_blocked';
16
+ export type GalleryCompletionState = 'draft' | 'ready_for_inspection' | 'packaged';
17
+
18
+ export interface PlanReviewArtifact {
19
+ id: string;
20
+ role: 'plan' | 'reference' | 'deliverable';
21
+ file_path: string;
22
+ sha256: string;
23
+ }
24
+
25
+ export interface PlanReviewComment {
26
+ id: string;
27
+ anchor: {
28
+ artifact_id: string;
29
+ kind: 'line' | 'region' | 'text';
30
+ locator: string;
31
+ };
32
+ finding: string;
33
+ requested_change: string;
34
+ anchor_context: string;
35
+ }
36
+
37
+ export interface PlanReviewSession {
38
+ schema: 'yam.plan-review-canvas.v1';
39
+ session_id: string;
40
+ title: string;
41
+ created_at: string;
42
+ updated_at: string;
43
+ status: 'open' | 'closed';
44
+ verdict: PlanReviewVerdict | null;
45
+ closed_at: string | null;
46
+ demand_trigger: DesignProductionDemandTrigger & {
47
+ evidence_truth: 'operator_asserted';
48
+ recorded_at: string;
49
+ };
50
+ execution_boundary: {
51
+ storage: 'local_only';
52
+ rendering: 'static_html';
53
+ sandbox: 'csp_no_script_no_network';
54
+ remote_sharing: false;
55
+ server: false;
56
+ background_service: false;
57
+ };
58
+ artifacts: PlanReviewArtifact[];
59
+ comments: PlanReviewComment[];
60
+ }
61
+
62
+ export interface UeyeRevisionReference {
63
+ artifact_id: string;
64
+ manifest_path: string;
65
+ round: number;
66
+ sha256: string;
67
+ archived_at: string;
68
+ intent: 'preserve' | 'edit_copy';
69
+ asset_manifest_path: string | null;
70
+ asset_id: string | null;
71
+ }
72
+
73
+ export interface DesignRevisionRound {
74
+ round: number;
75
+ reviewer_finding: {
76
+ id: string;
77
+ source_comment_id: string;
78
+ summary: string;
79
+ evidence: string;
80
+ };
81
+ planned_change: string;
82
+ outcome: 'accepted' | 'changes_requested';
83
+ revision_refs: UeyeRevisionReference[];
84
+ recorded_at: string;
85
+ }
86
+
87
+ export interface DesignRevisionState {
88
+ schema: 'yam.design-revision-state.v1';
89
+ session_id: string;
90
+ canvas_session_path: string;
91
+ canvas_session_sha256: string;
92
+ updated_at: string;
93
+ max_rounds: 2;
94
+ status: DesignRevisionStatus;
95
+ stop_reason: string;
96
+ rounds: DesignRevisionRound[];
97
+ }
98
+
99
+ export interface GalleryArtifact {
100
+ id: string;
101
+ role: 'primary' | 'supporting' | 'reference';
102
+ provenance: {
103
+ kind: 'operator' | 'generated' | 'downloaded';
104
+ source_ref: string;
105
+ license_note: string;
106
+ };
107
+ revision_ref: UeyeRevisionReference;
108
+ dimensions: string;
109
+ sha256: string;
110
+ completion_state: GalleryCompletionState;
111
+ final_path: string;
112
+ }
113
+
114
+ export interface FinalGalleryManifest {
115
+ schema: 'yam.final-gallery-manifest.v1';
116
+ session_id: string;
117
+ created_at: string;
118
+ completion_state: GalleryCompletionState;
119
+ purpose: 'inspection_and_packaging_evidence_only';
120
+ claims: {
121
+ visual_correctness: 'not_verified';
122
+ license_correctness: 'not_verified';
123
+ implementation_correctness: 'not_verified';
124
+ };
125
+ artifacts: GalleryArtifact[];
126
+ }
127
+
128
+ export interface DesignProductionDemandTrigger {
129
+ kind: 'repeated_plan_review' | 'multi_asset_production';
130
+ evidence: string;
131
+ }
132
+
133
+ export async function createPlanReviewCanvas(input: {
134
+ root: string;
135
+ session_id: string;
136
+ title: string;
137
+ demand_trigger: DesignProductionDemandTrigger;
138
+ artifacts: Array<Omit<PlanReviewArtifact, 'sha256'> & { sha256?: string }>;
139
+ session_path?: string;
140
+ render_path?: string;
141
+ }) {
142
+ assertExactKeys(input, ['root', 'session_id', 'title', 'demand_trigger', 'artifacts', 'session_path', 'render_path'], 'plan review input');
143
+ const root = await canonicalRoot(input.root);
144
+ const sessionId = safeId(input.session_id, 'session_id');
145
+ const title = boundedText(input.title, 'title', 200);
146
+ assertExactKeys(input.demand_trigger, ['kind', 'evidence'], 'demand_trigger');
147
+ const demandTrigger = validateDemandTrigger(input.demand_trigger);
148
+ if (!Array.isArray(input.artifacts) || !input.artifacts.length) throw new Error('plan review requires at least one artifact');
149
+ if (input.artifacts.length > MAX_PHASE_ITEMS) throw new Error(`plan review accepts at most ${MAX_PHASE_ITEMS} artifacts`);
150
+ const artifacts = await Promise.all(input.artifacts.map(async (artifact, index) => {
151
+ assertExactKeys(artifact, ['id', 'role', 'file_path', 'sha256'], `artifacts[${index}]`);
152
+ const id = safeId(artifact.id, `artifacts[${index}].id`);
153
+ const role = enumValue(artifact.role, ['plan', 'reference', 'deliverable'], `artifacts[${index}].role`);
154
+ const file = await resolveExistingWithin(root, artifact.file_path, `artifacts[${index}].file_path`);
155
+ const sha256 = await hashFile(file);
156
+ if (artifact.sha256 !== undefined) assertSha256(artifact.sha256, `artifacts[${index}].sha256`);
157
+ if (artifact.sha256 && artifact.sha256 !== sha256) throw new Error(`${id}: artifact sha256 mismatch`);
158
+ return { id, role, file_path: portableRelative(root, file), sha256 } as PlanReviewArtifact;
159
+ }));
160
+ assertUnique(artifacts.map((item) => item.id), 'artifact id');
161
+ artifacts.sort((left, right) => compareStableText(left.id, right.id));
162
+
163
+ const defaultDir = `.yam/ueye/design-production/${sessionId}`;
164
+ const defaultSessionPath = `${defaultDir}/plan-review.json`;
165
+ const defaultRenderPath = `${defaultDir}/canvas.html`;
166
+ if (input.session_path && safeRelativePath(input.session_path, 'session_path') !== defaultSessionPath) {
167
+ throw new Error(`session_path is fixed to ${defaultSessionPath} so phase evidence cannot target unrelated project files`);
168
+ }
169
+ if (input.render_path && safeRelativePath(input.render_path, 'render_path') !== defaultRenderPath) {
170
+ throw new Error(`render_path is fixed to ${defaultRenderPath} so Canvas updates cannot overwrite unrelated project files`);
171
+ }
172
+ const sessionPath = await resolveWritableWithin(root, defaultSessionPath, 'session_path');
173
+ const renderPath = await resolveWritableWithin(root, defaultRenderPath, 'render_path');
174
+ if (sessionPath === renderPath) throw new Error('session_path and render_path must be different files');
175
+ const now = new Date().toISOString();
176
+ const session: PlanReviewSession = {
177
+ schema: 'yam.plan-review-canvas.v1',
178
+ session_id: sessionId,
179
+ title,
180
+ created_at: now,
181
+ updated_at: now,
182
+ status: 'open',
183
+ verdict: null,
184
+ closed_at: null,
185
+ demand_trigger: { ...demandTrigger, evidence_truth: 'operator_asserted', recorded_at: now },
186
+ execution_boundary: localCanvasBoundary(),
187
+ artifacts,
188
+ comments: []
189
+ };
190
+ await assertAbsent(sessionPath, 'session_path');
191
+ await assertAbsent(renderPath, 'render_path');
192
+ await writeJsonAtomic(sessionPath, session, false);
193
+ await writeTextAtomic(renderPath, renderPlanReviewCanvas(session), false);
194
+ return canvasReceipt(sessionPath, renderPath, session);
195
+ }
196
+
197
+ export async function addPlanReviewComment(input: {
198
+ root: string;
199
+ session_path: string;
200
+ render_path: string;
201
+ comment: Omit<PlanReviewComment, 'anchor_context'>;
202
+ }) {
203
+ assertExactKeys(input, ['root', 'session_path', 'render_path', 'comment'], 'plan review comment input');
204
+ const root = await canonicalRoot(input.root);
205
+ const sessionPath = await resolveExistingWithin(root, input.session_path, 'session_path');
206
+ const renderPath = await resolveWritableWithin(root, input.render_path, 'render_path');
207
+ if (sessionPath === renderPath) throw new Error('session_path and render_path must be different files');
208
+ const session = await readPlanReviewSession(sessionPath);
209
+ await verifyPlanReviewEvidencePaths(root, session, sessionPath, renderPath);
210
+ if (session.status !== 'open') throw new Error('plan review session is closed; comments cannot be added');
211
+ await verifyPlanReviewArtifacts(root, session.artifacts);
212
+ await verifyPlanReviewComments(root, session);
213
+ if (session.comments.length >= MAX_PHASE_ITEMS) throw new Error(`plan review accepts at most ${MAX_PHASE_ITEMS} comments`);
214
+ const comment = await validateAndCapturePlanReviewComment(root, input.comment, session.artifacts);
215
+ if (session.comments.some((item) => item.id === comment.id)) throw new Error(`duplicate comment id: ${comment.id}`);
216
+ session.comments.push(comment);
217
+ session.comments.sort((left, right) => compareStableText(left.id, right.id));
218
+ session.updated_at = new Date().toISOString();
219
+ await writeJsonAtomic(sessionPath, session);
220
+ await writeTextAtomic(renderPath, renderPlanReviewCanvas(session));
221
+ return canvasReceipt(sessionPath, renderPath, session);
222
+ }
223
+
224
+ export async function closePlanReviewCanvas(input: {
225
+ root: string;
226
+ session_path: string;
227
+ render_path: string;
228
+ verdict: PlanReviewVerdict;
229
+ }) {
230
+ assertExactKeys(input, ['root', 'session_path', 'render_path', 'verdict'], 'plan review close input');
231
+ const root = await canonicalRoot(input.root);
232
+ const sessionPath = await resolveExistingWithin(root, input.session_path, 'session_path');
233
+ const renderPath = await resolveWritableWithin(root, input.render_path, 'render_path');
234
+ if (sessionPath === renderPath) throw new Error('session_path and render_path must be different files');
235
+ const session = await readPlanReviewSession(sessionPath);
236
+ await verifyPlanReviewEvidencePaths(root, session, sessionPath, renderPath);
237
+ if (session.status !== 'open') throw new Error('plan review session is already closed');
238
+ await verifyPlanReviewArtifacts(root, session.artifacts);
239
+ await verifyPlanReviewComments(root, session);
240
+ const verdict = enumValue(input.verdict, ['approve', 'request_changes'], 'verdict');
241
+ if (verdict === 'request_changes' && !session.comments.length) {
242
+ throw new Error('request_changes verdict requires at least one anchored reviewer finding');
243
+ }
244
+ const now = new Date().toISOString();
245
+ session.status = 'closed';
246
+ session.verdict = verdict;
247
+ session.closed_at = now;
248
+ session.updated_at = now;
249
+ await writeJsonAtomic(sessionPath, session);
250
+ await writeTextAtomic(renderPath, renderPlanReviewCanvas(session));
251
+ return canvasReceipt(sessionPath, renderPath, session);
252
+ }
253
+
254
+ export async function readPlanReviewSession(file: string): Promise<PlanReviewSession> {
255
+ const data = await readJson(file, 'plan review session');
256
+ assertExactKeys(data, ['schema', 'session_id', 'title', 'created_at', 'updated_at', 'status', 'verdict', 'closed_at', 'demand_trigger', 'execution_boundary', 'artifacts', 'comments'], 'plan review session');
257
+ if (data.schema !== 'yam.plan-review-canvas.v1') throw new Error('invalid plan review session schema');
258
+ const sessionId = safeId(data.session_id, 'session_id');
259
+ const status = enumValue(data.status, ['open', 'closed'], 'status');
260
+ const verdict = data.verdict === null ? null : enumValue(data.verdict, ['approve', 'request_changes'], 'verdict');
261
+ if ((status === 'open' && (verdict !== null || data.closed_at !== null)) || (status === 'closed' && (!verdict || !isIsoDate(data.closed_at)))) {
262
+ throw new Error('plan review close state is inconsistent');
263
+ }
264
+ validateBoundary(data.execution_boundary);
265
+ assertExactKeys(data.demand_trigger, ['kind', 'evidence', 'evidence_truth', 'recorded_at'], 'demand_trigger');
266
+ const storedDemand = data.demand_trigger as JsonObject;
267
+ const demandTrigger = validateDemandTrigger(storedDemand);
268
+ if (storedDemand.evidence_truth !== 'operator_asserted') throw new Error('demand_trigger.evidence_truth must remain operator_asserted');
269
+ const demandRecordedAt = requiredIsoDate(storedDemand.recorded_at, 'demand_trigger.recorded_at');
270
+ const createdAt = requiredIsoDate(data.created_at, 'created_at');
271
+ if (demandRecordedAt !== createdAt) throw new Error('demand_trigger must be recorded at Canvas creation time');
272
+ if (!Array.isArray(data.artifacts) || !data.artifacts.length) throw new Error('plan review artifacts must be a non-empty array');
273
+ if (data.artifacts.length > MAX_PHASE_ITEMS) throw new Error(`plan review artifacts exceed the ${MAX_PHASE_ITEMS}-item limit`);
274
+ const artifacts = data.artifacts.map((item, index) => validateStoredPlanArtifact(item, index));
275
+ assertUnique(artifacts.map((item) => item.id), 'artifact id');
276
+ if (!Array.isArray(data.comments)) throw new Error('plan review comments must be an array');
277
+ if (data.comments.length > MAX_PHASE_ITEMS) throw new Error(`plan review comments exceed the ${MAX_PHASE_ITEMS}-item limit`);
278
+ const comments = data.comments.map((item) => validatePlanReviewComment(item, artifacts));
279
+ assertUnique(comments.map((item) => item.id), 'comment id');
280
+ return {
281
+ schema: 'yam.plan-review-canvas.v1',
282
+ session_id: sessionId,
283
+ title: boundedText(data.title, 'title', 200),
284
+ created_at: createdAt,
285
+ updated_at: requiredIsoDate(data.updated_at, 'updated_at'),
286
+ status,
287
+ verdict,
288
+ closed_at: data.closed_at as string | null,
289
+ demand_trigger: { ...demandTrigger, evidence_truth: 'operator_asserted', recorded_at: demandRecordedAt },
290
+ execution_boundary: localCanvasBoundary(),
291
+ artifacts,
292
+ comments
293
+ };
294
+ }
295
+
296
+ export async function createDesignRevisionState(input: {
297
+ root: string;
298
+ session_id: string;
299
+ state_path?: string;
300
+ }) {
301
+ assertExactKeys(input, ['root', 'session_id', 'state_path'], 'design revision state input');
302
+ const root = await canonicalRoot(input.root);
303
+ const sessionId = safeId(input.session_id, 'session_id');
304
+ const canonicalStatePath = revisionStateRelativePath(sessionId);
305
+ if (input.state_path && safeRelativePath(input.state_path, 'state_path') !== canonicalStatePath) {
306
+ throw new Error(`state_path is fixed to ${canonicalStatePath} so a session cannot reset its two-round limit with another state file`);
307
+ }
308
+ const canvasRelativePath = planReviewRelativePath(sessionId);
309
+ const canvasPath = await resolveExistingWithin(root, canvasRelativePath, 'canvas_session_path');
310
+ const canvas = await readPlanReviewSession(canvasPath);
311
+ await verifyPlanReviewEvidencePaths(root, canvas, canvasPath);
312
+ await verifyPlanReviewArtifacts(root, canvas.artifacts);
313
+ await verifyPlanReviewComments(root, canvas);
314
+ if (canvas.session_id !== sessionId) throw new Error('revision state session_id must match its canonical Plan Review Canvas');
315
+ if (canvas.status !== 'closed' || canvas.verdict !== 'request_changes') {
316
+ throw new Error('revision state requires a closed request_changes Plan Review Canvas');
317
+ }
318
+ const statePath = await resolveWritableWithin(root, canonicalStatePath, 'state_path');
319
+ if (statePath !== path.join(root, canonicalStatePath)) throw new Error('canonical state_path must not be redirected through a symlink');
320
+ const state: DesignRevisionState = {
321
+ schema: 'yam.design-revision-state.v1',
322
+ session_id: sessionId,
323
+ canvas_session_path: canvasRelativePath,
324
+ canvas_session_sha256: await hashFile(canvasPath),
325
+ updated_at: new Date().toISOString(),
326
+ max_rounds: MAX_REVISION_ROUNDS,
327
+ status: 'active',
328
+ stop_reason: '',
329
+ rounds: []
330
+ };
331
+ await assertAbsent(statePath, 'state_path');
332
+ await writeJsonAtomic(statePath, state, false);
333
+ return { schema: 'yam.design-revision-state-receipt.v1', state_path: statePath, state, truth_status: 'verified' as const };
334
+ }
335
+
336
+ export async function recordDesignRevisionRound(input: {
337
+ root: string;
338
+ state_path: string;
339
+ reviewer_finding: DesignRevisionRound['reviewer_finding'];
340
+ planned_change: string;
341
+ outcome: DesignRevisionRound['outcome'];
342
+ revision_refs: UeyeRevisionReference[];
343
+ }) {
344
+ assertExactKeys(input, ['root', 'state_path', 'reviewer_finding', 'planned_change', 'outcome', 'revision_refs'], 'design revision round input');
345
+ const root = await canonicalRoot(input.root);
346
+ const { statePath, state, canvas } = await loadBoundDesignRevisionState(root, input.state_path);
347
+ if (state.status !== 'active') throw new Error(`revision state stopped with status ${state.status}: ${state.stop_reason}`);
348
+ const round = state.rounds.length + 1;
349
+ if (round > MAX_REVISION_ROUNDS) throw new Error(`revision round ${round} is not allowed; maximum is ${MAX_REVISION_ROUNDS}`);
350
+ const finding = validateReviewerFinding(input.reviewer_finding);
351
+ if (!canvas.comments.some((comment) => comment.id === finding.source_comment_id)) {
352
+ throw new Error(`reviewer finding source_comment_id is not present in the bound Plan Review Canvas: ${finding.source_comment_id}`);
353
+ }
354
+ if (state.rounds.some((item) => item.reviewer_finding.id === finding.id)) throw new Error(`duplicate reviewer finding id: ${finding.id}`);
355
+ const plannedChange = boundedText(input.planned_change, 'planned_change', 4000, 8);
356
+ const outcome = enumValue(input.outcome, ['accepted', 'changes_requested'], 'outcome');
357
+ if (!Array.isArray(input.revision_refs) || !input.revision_refs.length) throw new Error('each revision round requires at least one Ueye revision reference');
358
+ if (input.revision_refs.length > MAX_PHASE_ITEMS) throw new Error(`each revision round accepts at most ${MAX_PHASE_ITEMS} Ueye revision references`);
359
+ let refs: UeyeRevisionReference[];
360
+ try {
361
+ for (const previousRound of state.rounds) {
362
+ for (let index = 0; index < previousRound.revision_refs.length; index += 1) {
363
+ await validateUeyeRevisionReference(root, previousRound.revision_refs[index], `rounds[${previousRound.round}].revision_refs[${index}]`);
364
+ }
365
+ }
366
+ refs = [];
367
+ for (let index = 0; index < input.revision_refs.length; index += 1) {
368
+ refs.push(await validateUeyeRevisionReference(root, input.revision_refs[index], `revision_refs[${index}]`));
369
+ }
370
+ assertUnique(refs.map((item) => `${item.artifact_id}:${item.manifest_path}:${item.round}`), 'revision reference');
371
+ const previousRefs = new Set(state.rounds.flatMap((item) => item.revision_refs.map(revisionReferenceKey)));
372
+ if (refs.every((item) => previousRefs.has(revisionReferenceKey(item)))) {
373
+ throw new Error('each revision round requires at least one newly archived Ueye revision reference');
374
+ }
375
+ } catch (error) {
376
+ state.status = 'integrity_blocked';
377
+ state.stop_reason = boundedText(error instanceof Error ? error.message : String(error), 'integrity blocker', 1000);
378
+ state.updated_at = new Date().toISOString();
379
+ await writeJsonAtomic(statePath, state);
380
+ throw error;
381
+ }
382
+ const recordedAt = new Date().toISOString();
383
+ assertRevisionRoundChronology(canvas, state, refs, recordedAt, round);
384
+ const entry: DesignRevisionRound = {
385
+ round,
386
+ reviewer_finding: finding,
387
+ planned_change: plannedChange,
388
+ outcome,
389
+ revision_refs: refs,
390
+ recorded_at: recordedAt
391
+ };
392
+ state.rounds.push(entry);
393
+ if (outcome === 'accepted') {
394
+ state.status = 'accepted';
395
+ state.stop_reason = `accepted after revision round ${round}`;
396
+ } else if (round === MAX_REVISION_ROUNDS) {
397
+ state.status = 'two_round_limit';
398
+ state.stop_reason = `stopped after the maximum ${MAX_REVISION_ROUNDS} focused revision rounds`;
399
+ }
400
+ state.updated_at = new Date().toISOString();
401
+ await writeJsonAtomic(statePath, state);
402
+ return {
403
+ schema: 'yam.design-revision-round-receipt.v1',
404
+ state_path: statePath,
405
+ round: entry,
406
+ state,
407
+ next_action: state.status === 'active' ? 'record one more focused round only if the reviewer has a concrete finding' : state.stop_reason,
408
+ truth_status: 'verified' as const
409
+ };
410
+ }
411
+
412
+ export async function readDesignRevisionState(file: string): Promise<DesignRevisionState> {
413
+ const data = await readJson(file, 'design revision state');
414
+ assertExactKeys(data, ['schema', 'session_id', 'canvas_session_path', 'canvas_session_sha256', 'updated_at', 'max_rounds', 'status', 'stop_reason', 'rounds'], 'design revision state');
415
+ if (data.schema !== 'yam.design-revision-state.v1' || data.max_rounds !== MAX_REVISION_ROUNDS) throw new Error('invalid design revision state schema');
416
+ const status = enumValue(data.status, ['active', 'accepted', 'two_round_limit', 'integrity_blocked'], 'status');
417
+ if (!Array.isArray(data.rounds) || data.rounds.length > MAX_REVISION_ROUNDS) throw new Error('design revision rounds exceed the maximum of two');
418
+ const rounds = data.rounds.map((item, index) => validateStoredRevisionRound(item, index));
419
+ rounds.forEach((item, index) => {
420
+ if (item.round !== index + 1) throw new Error('design revision rounds must be contiguous and start at one');
421
+ });
422
+ if (status === 'active' && rounds.length >= MAX_REVISION_ROUNDS) throw new Error('active revision state cannot contain two completed rounds');
423
+ if (rounds.slice(0, -1).some((item) => item.outcome === 'accepted')) throw new Error('an accepted revision round must be the final round');
424
+ if (status === 'active' && rounds.at(-1)?.outcome === 'accepted') throw new Error('active revision state cannot contain an accepted round');
425
+ if (status === 'accepted' && rounds.at(-1)?.outcome !== 'accepted') throw new Error('accepted revision state requires an accepted final round');
426
+ if (status === 'two_round_limit' && (rounds.length !== MAX_REVISION_ROUNDS || rounds.at(-1)?.outcome !== 'changes_requested')) throw new Error('two_round_limit requires exactly two changes_requested rounds');
427
+ assertUnique(rounds.map((item) => item.reviewer_finding.id), 'reviewer finding id');
428
+ return {
429
+ schema: 'yam.design-revision-state.v1',
430
+ session_id: safeId(data.session_id, 'session_id'),
431
+ canvas_session_path: safeRelativePath(data.canvas_session_path, 'canvas_session_path'),
432
+ canvas_session_sha256: assertSha256(data.canvas_session_sha256, 'canvas_session_sha256'),
433
+ updated_at: requiredIsoDate(data.updated_at, 'updated_at'),
434
+ max_rounds: MAX_REVISION_ROUNDS,
435
+ status,
436
+ stop_reason: boundedText(data.stop_reason, 'stop_reason', 1000, 0),
437
+ rounds
438
+ };
439
+ }
440
+
441
+ export async function readCanonicalDesignRevisionState(input: { root: string; state_path: string }) {
442
+ assertExactKeys(input, ['root', 'state_path'], 'canonical design revision state input');
443
+ const root = await canonicalRoot(input.root);
444
+ const { state } = await loadBoundDesignRevisionState(root, input.state_path);
445
+ return state;
446
+ }
447
+
448
+ export async function writeFinalGalleryManifest(input: {
449
+ root: string;
450
+ manifest_path: string;
451
+ session_id: string;
452
+ completion_state: GalleryCompletionState;
453
+ artifacts: GalleryArtifact[];
454
+ }) {
455
+ assertExactKeys(input, ['root', 'manifest_path', 'session_id', 'completion_state', 'artifacts'], 'final gallery input');
456
+ const root = await canonicalRoot(input.root);
457
+ const manifestPath = await resolveWritableWithin(root, input.manifest_path, 'manifest_path');
458
+ const completionState = enumValue(input.completion_state, ['draft', 'ready_for_inspection', 'packaged'], 'completion_state');
459
+ if (!Array.isArray(input.artifacts) || !input.artifacts.length) throw new Error('final gallery requires at least one artifact');
460
+ if (input.artifacts.length > MAX_PHASE_ITEMS) throw new Error(`final gallery accepts at most ${MAX_PHASE_ITEMS} artifacts`);
461
+ const artifacts: GalleryArtifact[] = [];
462
+ for (let index = 0; index < input.artifacts.length; index += 1) {
463
+ const item = input.artifacts[index];
464
+ assertExactKeys(item, ['id', 'role', 'provenance', 'revision_ref', 'dimensions', 'sha256', 'completion_state', 'final_path'], `artifacts[${index}]`);
465
+ const id = safeId(item.id, `artifacts[${index}].id`);
466
+ const role = enumValue(item.role, ['primary', 'supporting', 'reference'], `artifacts[${index}].role`);
467
+ const provenance = validateProvenance(item.provenance, `artifacts[${index}].provenance`);
468
+ const revisionRef = await validateUeyeRevisionReference(root, item.revision_ref, `artifacts[${index}].revision_ref`);
469
+ const finalFile = await resolveExistingWithin(root, item.final_path, `artifacts[${index}].final_path`);
470
+ const info = await inspectImageFile(finalFile);
471
+ const sha256 = assertSha256(item.sha256, `artifacts[${index}].sha256`);
472
+ if (sha256 !== info.sha256) throw new Error(`${id}: final artifact sha256 mismatch`);
473
+ const dimensions = boundedText(item.dimensions, `artifacts[${index}].dimensions`, 32);
474
+ if (!/^\d+x\d+$/.test(dimensions) || dimensions !== info.dimensions) throw new Error(`${id}: final artifact dimensions mismatch`);
475
+ const itemCompletion = enumValue(item.completion_state, ['draft', 'ready_for_inspection', 'packaged'], `artifacts[${index}].completion_state`);
476
+ artifacts.push({
477
+ id,
478
+ role,
479
+ provenance,
480
+ revision_ref: revisionRef,
481
+ dimensions,
482
+ sha256,
483
+ completion_state: itemCompletion,
484
+ final_path: portableRelative(root, finalFile)
485
+ });
486
+ }
487
+ assertUnique(artifacts.map((item) => item.id), 'gallery artifact id');
488
+ const aggregateCompletion = aggregateGalleryCompletion(artifacts.map((item) => item.completion_state));
489
+ if (completionState !== aggregateCompletion) throw new Error(`gallery completion_state must equal aggregate artifact state ${aggregateCompletion}`);
490
+ artifacts.sort((left, right) => compareStableText(left.id, right.id));
491
+ const manifest: FinalGalleryManifest = {
492
+ schema: 'yam.final-gallery-manifest.v1',
493
+ session_id: safeId(input.session_id, 'session_id'),
494
+ created_at: new Date().toISOString(),
495
+ completion_state: completionState,
496
+ purpose: 'inspection_and_packaging_evidence_only',
497
+ claims: {
498
+ visual_correctness: 'not_verified',
499
+ license_correctness: 'not_verified',
500
+ implementation_correctness: 'not_verified'
501
+ },
502
+ artifacts
503
+ };
504
+ await assertAbsent(manifestPath, 'manifest_path');
505
+ await writeJsonAtomic(manifestPath, manifest, false);
506
+ return {
507
+ schema: 'yam.final-gallery-receipt.v1',
508
+ manifest_path: manifestPath,
509
+ manifest,
510
+ ready: true,
511
+ warnings: ['gallery evidence does not verify visual correctness, license correctness, or implementation correctness'],
512
+ next_action: 'inspect the packaged artifacts, validate usage rights, and run implementation checks before making correctness claims',
513
+ truth_status: 'partial' as const
514
+ };
515
+ }
516
+
517
+ export async function verifyFinalGalleryManifest(input: { root: string; manifest_path: string }) {
518
+ assertExactKeys(input, ['root', 'manifest_path'], 'final gallery verification input');
519
+ const root = await canonicalRoot(input.root);
520
+ const manifestPath = await resolveExistingWithin(root, input.manifest_path, 'manifest_path');
521
+ const data = await readJson(manifestPath, 'final gallery manifest');
522
+ assertExactKeys(data, ['schema', 'session_id', 'created_at', 'completion_state', 'purpose', 'claims', 'artifacts'], 'final gallery manifest');
523
+ if (data.schema !== 'yam.final-gallery-manifest.v1' || data.purpose !== 'inspection_and_packaging_evidence_only') throw new Error('invalid final gallery manifest schema');
524
+ safeId(data.session_id, 'session_id');
525
+ requiredIsoDate(data.created_at, 'created_at');
526
+ const completionState = enumValue(data.completion_state, ['draft', 'ready_for_inspection', 'packaged'], 'completion_state');
527
+ assertExactKeys(data.claims, ['visual_correctness', 'license_correctness', 'implementation_correctness'], 'final gallery claims');
528
+ if ((data.claims as JsonObject).visual_correctness !== 'not_verified'
529
+ || (data.claims as JsonObject).license_correctness !== 'not_verified'
530
+ || (data.claims as JsonObject).implementation_correctness !== 'not_verified') {
531
+ throw new Error('final gallery must not claim visual, license, or implementation correctness');
532
+ }
533
+ if (!Array.isArray(data.artifacts) || !data.artifacts.length) throw new Error('final gallery artifacts must be a non-empty array');
534
+ if (data.artifacts.length > MAX_PHASE_ITEMS) throw new Error(`final gallery artifacts exceed the ${MAX_PHASE_ITEMS}-item limit`);
535
+ const errors: string[] = [];
536
+ const ids: string[] = [];
537
+ for (let index = 0; index < data.artifacts.length; index += 1) {
538
+ try {
539
+ const item = data.artifacts[index] as GalleryArtifact;
540
+ assertExactKeys(item, ['id', 'role', 'provenance', 'revision_ref', 'dimensions', 'sha256', 'completion_state', 'final_path'], `artifacts[${index}]`);
541
+ ids.push(safeId(item.id, `artifacts[${index}].id`));
542
+ enumValue(item.role, ['primary', 'supporting', 'reference'], `artifacts[${index}].role`);
543
+ validateProvenance(item.provenance, `artifacts[${index}].provenance`);
544
+ await validateUeyeRevisionReference(root, item.revision_ref, `artifacts[${index}].revision_ref`);
545
+ const file = await resolveExistingWithin(root, item.final_path, `artifacts[${index}].final_path`);
546
+ const info = await inspectImageFile(file);
547
+ if (assertSha256(item.sha256, `artifacts[${index}].sha256`) !== info.sha256) throw new Error('sha256 mismatch');
548
+ if (!/^\d+x\d+$/.test(item.dimensions) || item.dimensions !== info.dimensions) throw new Error('dimensions mismatch');
549
+ enumValue(item.completion_state, ['draft', 'ready_for_inspection', 'packaged'], `artifacts[${index}].completion_state`);
550
+ } catch (error) {
551
+ errors.push(`artifacts[${index}]: ${error instanceof Error ? error.message : String(error)}`);
552
+ }
553
+ }
554
+ try {
555
+ assertUnique(ids, 'gallery artifact id');
556
+ } catch (error) {
557
+ errors.push(error instanceof Error ? error.message : String(error));
558
+ }
559
+ try {
560
+ const itemStates: GalleryCompletionState[] = data.artifacts.map((item, index) => enumValue((item as JsonObject).completion_state, ['draft', 'ready_for_inspection', 'packaged'] as const, `artifacts[${index}].completion_state`));
561
+ const aggregate = aggregateGalleryCompletion(itemStates);
562
+ if (completionState !== aggregate) throw new Error(`gallery completion_state must equal aggregate artifact state ${aggregate}`);
563
+ } catch (error) {
564
+ errors.push(error instanceof Error ? error.message : String(error));
565
+ }
566
+ return {
567
+ schema: 'yam.final-gallery-verification.v1',
568
+ manifest_path: manifestPath,
569
+ artifact_count: data.artifacts.length,
570
+ errors: uniqueStrings(errors),
571
+ ready: errors.length === 0,
572
+ claims: { visual_correctness: 'not_verified', license_correctness: 'not_verified', implementation_correctness: 'not_verified' },
573
+ next_action: errors[0] || 'inspect the gallery; this verification covers paths, hashes, dimensions, and references only',
574
+ truth_status: errors.length ? 'blocked' as const : 'partial' as const
575
+ };
576
+ }
577
+
578
+ export async function finalizeDesignProductionPhase(input: {
579
+ root: string;
580
+ demand_trigger: DesignProductionDemandTrigger;
581
+ canvas_session_path: string;
582
+ revision_state_path: string | null;
583
+ gallery_manifest_path: string;
584
+ receipt_path: string;
585
+ }) {
586
+ assertExactKeys(input, ['root', 'demand_trigger', 'canvas_session_path', 'revision_state_path', 'gallery_manifest_path', 'receipt_path'], 'design production phase input');
587
+ const root = await canonicalRoot(input.root);
588
+ assertExactKeys(input.demand_trigger, ['kind', 'evidence'], 'demand_trigger');
589
+ const demandTrigger = validateDemandTrigger(input.demand_trigger);
590
+ const canvasPath = await resolveExistingWithin(root, input.canvas_session_path, 'canvas_session_path');
591
+ const canvas = await readPlanReviewSession(canvasPath);
592
+ await verifyPlanReviewEvidencePaths(root, canvas, canvasPath);
593
+ if (canvas.demand_trigger.kind !== demandTrigger.kind || canvas.demand_trigger.evidence !== demandTrigger.evidence) {
594
+ throw new Error('finalize demand_trigger must exactly match the operator-asserted trigger recorded before Canvas creation');
595
+ }
596
+ await verifyPlanReviewArtifacts(root, canvas.artifacts);
597
+ await verifyPlanReviewComments(root, canvas);
598
+ if (canvas.status !== 'closed' || !canvas.verdict) throw new Error('design production phase requires an explicitly closed Plan Review Canvas');
599
+
600
+ const galleryPath = await resolveExistingWithin(root, input.gallery_manifest_path, 'gallery_manifest_path');
601
+ const galleryVerification = await verifyFinalGalleryManifest({ root, manifest_path: portableRelative(root, galleryPath) });
602
+ if (!galleryVerification.ready) throw new Error(`final gallery integrity is blocked: ${galleryVerification.next_action}`);
603
+ const galleryData = await readJson(galleryPath, 'final gallery manifest');
604
+ const gallerySessionId = safeId(galleryData.session_id, 'final gallery session_id');
605
+ if (gallerySessionId !== canvas.session_id) throw new Error('Canvas and final gallery session_id values do not match');
606
+
607
+ let revisionState: DesignRevisionState | null = null;
608
+ let revisionPath: string | null = null;
609
+ if (input.revision_state_path !== null) {
610
+ const boundRevision = await loadBoundDesignRevisionState(root, input.revision_state_path, 'revision_state_path');
611
+ revisionPath = boundRevision.statePath;
612
+ revisionState = boundRevision.state;
613
+ if (revisionState.session_id !== canvas.session_id) throw new Error('Canvas and revision state session_id values do not match');
614
+ for (const round of revisionState.rounds) {
615
+ for (let index = 0; index < round.revision_refs.length; index += 1) {
616
+ await validateUeyeRevisionReference(root, round.revision_refs[index], `rounds[${round.round}].revision_refs[${index}]`);
617
+ }
618
+ }
619
+ }
620
+ if (canvas.verdict === 'request_changes' && !revisionState) throw new Error('request_changes verdict requires a phase revision state');
621
+ if (revisionState && canvas.verdict !== 'request_changes') throw new Error('a phase revision state requires a request_changes Canvas verdict');
622
+ if (revisionState && !['accepted', 'two_round_limit'].includes(revisionState.status)) {
623
+ throw new Error(`revision state is not terminal: ${revisionState.status}`);
624
+ }
625
+ if (revisionState) assertRevisionChronology(canvas, revisionState);
626
+ assertGalleryChronology(canvas, revisionState, galleryData);
627
+ if (canvas.verdict === 'request_changes' && revisionState) {
628
+ const canvasCommentIds = new Set(canvas.comments.map((comment) => comment.id));
629
+ for (const round of revisionState.rounds) {
630
+ if (!canvasCommentIds.has(round.reviewer_finding.source_comment_id)) {
631
+ throw new Error(`revision round ${round.round} is not linked to a Canvas comment id`);
632
+ }
633
+ }
634
+ const finalRoundRefs = new Set((revisionState.rounds.at(-1)?.revision_refs || []).map(revisionReferenceKey));
635
+ for (const [index, artifact] of (galleryData.artifacts as GalleryArtifact[]).entries()) {
636
+ if (!finalRoundRefs.has(revisionReferenceKey(artifact.revision_ref))) {
637
+ throw new Error(`gallery artifact ${index} revision_ref is not recorded in the final phase revision round`);
638
+ }
639
+ }
640
+ }
641
+ const completionState = enumValue(galleryData.completion_state, ['draft', 'ready_for_inspection', 'packaged'], 'gallery completion_state');
642
+ if (revisionState?.status === 'two_round_limit' && completionState !== 'draft') {
643
+ throw new Error('a two_round_limit stop may only finalize a draft gallery; unresolved work cannot be packaged');
644
+ }
645
+ const receiptPath = await resolveWritableWithin(root, input.receipt_path, 'receipt_path');
646
+ await assertAbsent(receiptPath, 'receipt_path');
647
+ const fullyAccepted = canvas.verdict === 'approve' || revisionState?.status === 'accepted';
648
+ const phaseEvidenceComplete = fullyAccepted && completionState === 'packaged';
649
+ const upstreamDigests = {
650
+ canvas_session: `sha256:${await hashFile(canvasPath)}`,
651
+ revision_state: revisionPath ? `sha256:${await hashFile(revisionPath)}` : null,
652
+ gallery_manifest: `sha256:${await hashFile(galleryPath)}`
653
+ };
654
+ const receiptBase = {
655
+ schema: 'yam.design-production-phase-receipt.v1',
656
+ session_id: canvas.session_id,
657
+ recorded_at: new Date().toISOString(),
658
+ demand_trigger: {
659
+ ...demandTrigger,
660
+ evidence_truth: 'operator_asserted' as const
661
+ },
662
+ canvas: {
663
+ path: portableRelative(root, canvasPath),
664
+ status: canvas.status,
665
+ verdict: canvas.verdict,
666
+ artifact_integrity: 'verified'
667
+ },
668
+ revision: revisionState ? {
669
+ path: portableRelative(root, revisionPath as string),
670
+ status: revisionState.status,
671
+ rounds: revisionState.rounds.length,
672
+ reference_integrity: 'verified'
673
+ } : null,
674
+ gallery: {
675
+ path: portableRelative(root, galleryPath),
676
+ completion_state: completionState,
677
+ integrity: 'verified',
678
+ claims: galleryVerification.claims
679
+ },
680
+ upstream_digests: upstreamDigests,
681
+ execution_boundary: localCanvasBoundary(),
682
+ phase_evidence_status: phaseEvidenceComplete ? 'complete' as const : revisionState?.status === 'two_round_limit' ? 'bounded_stop' as const : 'incomplete' as const,
683
+ ready: phaseEvidenceComplete,
684
+ ready_to_claim_correctness: false,
685
+ next_action: designPhaseNextAction(phaseEvidenceComplete, revisionState?.status),
686
+ truth_status: 'partial' as const
687
+ };
688
+ const receipt = {
689
+ ...receiptBase,
690
+ receipt_digest: `sha256:${createHash('sha256').update(stableJson(receiptBase)).digest('hex')}`
691
+ };
692
+ await writeJsonAtomic(receiptPath, receipt, false);
693
+ return { ...receipt, receipt_path: receiptPath };
694
+ }
695
+
696
+ export async function verifyDesignProductionPhaseReceipt(input: { root: string; receipt_path: string }) {
697
+ assertExactKeys(input, ['root', 'receipt_path'], 'design production phase receipt verification input');
698
+ const root = await canonicalRoot(input.root);
699
+ const receiptPath = await resolveExistingWithin(root, input.receipt_path, 'receipt_path');
700
+ const data = await readJson(receiptPath, 'design production phase receipt');
701
+ assertExactKeys(data, [
702
+ 'schema', 'session_id', 'recorded_at', 'demand_trigger', 'canvas', 'revision', 'gallery', 'upstream_digests',
703
+ 'execution_boundary', 'phase_evidence_status', 'ready', 'ready_to_claim_correctness', 'next_action', 'truth_status', 'receipt_digest'
704
+ ], 'design production phase receipt');
705
+ if (data.schema !== 'yam.design-production-phase-receipt.v1') throw new Error('invalid design production phase receipt schema');
706
+ const sessionId = safeId(data.session_id, 'session_id');
707
+ requiredIsoDate(data.recorded_at, 'recorded_at');
708
+ const errors: string[] = [];
709
+ if (!isObject(data.canvas) || !isObject(data.gallery) || !isObject(data.upstream_digests)) {
710
+ throw new Error('design production phase receipt evidence objects are invalid');
711
+ }
712
+ assertExactObjectKeys(data.demand_trigger, ['kind', 'evidence', 'evidence_truth'], 'phase receipt demand_trigger');
713
+ assertExactObjectKeys(data.canvas, ['path', 'status', 'verdict', 'artifact_integrity'], 'phase receipt canvas');
714
+ assertExactObjectKeys(data.gallery, ['path', 'completion_state', 'integrity', 'claims'], 'phase receipt gallery');
715
+ assertExactObjectKeys(data.gallery.claims, ['visual_correctness', 'license_correctness', 'implementation_correctness'], 'phase receipt gallery claims');
716
+ assertExactObjectKeys(data.upstream_digests, ['canvas_session', 'revision_state', 'gallery_manifest'], 'phase receipt upstream_digests');
717
+ if (data.revision !== null) {
718
+ assertExactObjectKeys(data.revision, ['path', 'status', 'rounds', 'reference_integrity'], 'phase receipt revision');
719
+ }
720
+ validateBoundary(data.execution_boundary);
721
+ const demandTrigger = validateDemandTrigger(data.demand_trigger);
722
+ if ((data.demand_trigger as JsonObject).evidence_truth !== 'operator_asserted') errors.push('demand_trigger evidence_truth must remain operator_asserted');
723
+ const canvasPath = await resolveExistingWithin(root, data.canvas.path, 'canvas.path');
724
+ const galleryPath = await resolveExistingWithin(root, data.gallery.path, 'gallery.path');
725
+ await compareReceiptFileDigest(canvasPath, data.upstream_digests.canvas_session, 'canvas_session', errors);
726
+ await compareReceiptFileDigest(galleryPath, data.upstream_digests.gallery_manifest, 'gallery_manifest', errors);
727
+ const canvas = await readPlanReviewSession(canvasPath);
728
+ await verifyPlanReviewEvidencePaths(root, canvas, canvasPath);
729
+ await verifyPlanReviewArtifacts(root, canvas.artifacts);
730
+ await verifyPlanReviewComments(root, canvas);
731
+ if (canvas.session_id !== sessionId) errors.push('receipt session_id does not match the upstream Canvas');
732
+ if (canvas.status !== 'closed' || !canvas.verdict) errors.push('upstream Canvas is not explicitly closed');
733
+ if (canvas.demand_trigger.kind !== demandTrigger.kind || canvas.demand_trigger.evidence !== demandTrigger.evidence) {
734
+ errors.push('receipt demand_trigger does not exactly match the upstream Canvas');
735
+ }
736
+ if (data.canvas.path !== portableRelative(root, canvasPath)
737
+ || data.canvas.status !== canvas.status
738
+ || data.canvas.verdict !== canvas.verdict
739
+ || data.canvas.artifact_integrity !== 'verified') {
740
+ errors.push('receipt Canvas semantics do not match the upstream Canvas');
741
+ }
742
+
743
+ const galleryVerification = await verifyFinalGalleryManifest({ root, manifest_path: portableRelative(root, galleryPath) });
744
+ const galleryData = await readJson(galleryPath, 'final gallery manifest');
745
+ const gallerySessionId = safeId(galleryData.session_id, 'final gallery session_id');
746
+ const completionState = enumValue(galleryData.completion_state, ['draft', 'ready_for_inspection', 'packaged'], 'gallery completion_state');
747
+ if (!galleryVerification.ready) errors.push(`final gallery integrity is blocked: ${galleryVerification.next_action}`);
748
+ if (gallerySessionId !== sessionId) errors.push('receipt session_id does not match the upstream final gallery');
749
+ if (data.gallery.path !== portableRelative(root, galleryPath)
750
+ || data.gallery.completion_state !== completionState
751
+ || data.gallery.integrity !== 'verified'
752
+ || stableJson(data.gallery.claims) !== stableJson(galleryVerification.claims)) {
753
+ errors.push('receipt gallery semantics do not match the upstream final gallery');
754
+ }
755
+
756
+ let revisionState: DesignRevisionState | null = null;
757
+ if (data.revision === null) {
758
+ if (data.upstream_digests.revision_state !== null) errors.push('revision_state digest must be null when no revision state is recorded');
759
+ } else {
760
+ const revisionEvidence = data.revision as JsonObject;
761
+ try {
762
+ const boundRevision = await loadBoundDesignRevisionState(root, revisionEvidence.path, 'revision.path');
763
+ const revisionPath = boundRevision.statePath;
764
+ revisionState = boundRevision.state;
765
+ await compareReceiptFileDigest(revisionPath, data.upstream_digests.revision_state, 'revision_state', errors);
766
+ if (revisionState.session_id !== sessionId
767
+ || revisionEvidence.path !== portableRelative(root, revisionPath)
768
+ || revisionEvidence.status !== revisionState.status
769
+ || revisionEvidence.rounds !== revisionState.rounds.length
770
+ || revisionEvidence.reference_integrity !== 'verified') {
771
+ errors.push('receipt revision semantics do not match the upstream revision state');
772
+ }
773
+ if (!['accepted', 'two_round_limit'].includes(revisionState.status)) errors.push(`revision state is not terminal: ${revisionState.status}`);
774
+ assertRevisionChronology(canvas, revisionState);
775
+ const canvasCommentIds = new Set(canvas.comments.map((comment) => comment.id));
776
+ for (const round of revisionState.rounds) {
777
+ if (!canvasCommentIds.has(round.reviewer_finding.source_comment_id)) errors.push(`revision round ${round.round} is not linked to a Canvas comment id`);
778
+ for (let index = 0; index < round.revision_refs.length; index += 1) {
779
+ await validateUeyeRevisionReference(root, round.revision_refs[index], `rounds[${round.round}].revision_refs[${index}]`);
780
+ }
781
+ }
782
+ const finalRoundRefs = new Set((revisionState.rounds.at(-1)?.revision_refs || []).map(revisionReferenceKey));
783
+ for (const [index, artifact] of (galleryData.artifacts as GalleryArtifact[]).entries()) {
784
+ if (!finalRoundRefs.has(revisionReferenceKey(artifact.revision_ref))) errors.push(`gallery artifact ${index} is not linked to the final revision round`);
785
+ }
786
+ } catch (error) {
787
+ errors.push(`revision_state: ${error instanceof Error ? error.message : String(error)}`);
788
+ }
789
+ }
790
+ if (canvas.verdict === 'request_changes' && !revisionState) errors.push('request_changes Canvas requires a bound phase revision state');
791
+ if (canvas.verdict !== 'request_changes' && revisionState) errors.push('phase revision state requires a request_changes Canvas verdict');
792
+ if (revisionState?.status === 'two_round_limit' && completionState !== 'draft') errors.push('two_round_limit requires a draft gallery');
793
+ try {
794
+ assertGalleryChronology(canvas, revisionState, galleryData);
795
+ } catch (error) {
796
+ errors.push(`gallery chronology: ${error instanceof Error ? error.message : String(error)}`);
797
+ }
798
+
799
+ const fullyAccepted = canvas.verdict === 'approve' || revisionState?.status === 'accepted';
800
+ const phaseEvidenceComplete = fullyAccepted && completionState === 'packaged';
801
+ const expectedPhaseStatus = phaseEvidenceComplete ? 'complete' : revisionState?.status === 'two_round_limit' ? 'bounded_stop' : 'incomplete';
802
+ if (data.phase_evidence_status !== expectedPhaseStatus || data.ready !== phaseEvidenceComplete) {
803
+ errors.push('receipt ready/phase_evidence_status is inconsistent with upstream evidence');
804
+ }
805
+ const receiptDigest = prefixedSha256(data.receipt_digest, 'receipt_digest');
806
+ const receiptBase = { ...data };
807
+ delete receiptBase.receipt_digest;
808
+ const expectedReceiptDigest = `sha256:${createHash('sha256').update(stableJson(receiptBase)).digest('hex')}`;
809
+ if (receiptDigest !== expectedReceiptDigest) errors.push('receipt_digest mismatch');
810
+ if (data.ready_to_claim_correctness !== false || data.truth_status !== 'partial'
811
+ || data.next_action !== designPhaseNextAction(phaseEvidenceComplete, revisionState?.status)) {
812
+ errors.push('phase receipt must not claim correctness or verified overall truth');
813
+ }
814
+ return {
815
+ schema: 'yam.design-production-phase-verification.v1',
816
+ receipt_path: receiptPath,
817
+ ready: errors.length === 0,
818
+ errors: uniqueStrings(errors),
819
+ upstream_digests: data.upstream_digests,
820
+ receipt_digest: receiptDigest,
821
+ ready_to_claim_correctness: false,
822
+ next_action: errors[0] || 'phase receipt and upstream evidence digests match; run separate visual, license, and implementation checks',
823
+ truth_status: errors.length ? 'blocked' as const : 'partial' as const
824
+ };
825
+ }
826
+
827
+ function canvasReceipt(sessionPath: string, renderPath: string, session: PlanReviewSession) {
828
+ return {
829
+ schema: 'yam.plan-review-canvas-receipt.v1',
830
+ session_path: sessionPath,
831
+ render_path: renderPath,
832
+ session,
833
+ next_action: session.status === 'open' ? 'record anchored findings, then explicitly close with approve or request_changes' : `session closed with ${session.verdict}`,
834
+ claims: {
835
+ session_artifact_integrity: session.status === 'closed' ? 'verified' : 'pending',
836
+ visual_correctness: 'not_verified',
837
+ implementation_correctness: 'not_verified'
838
+ },
839
+ truth_status: session.status === 'closed' ? 'verified' as const : 'partial' as const
840
+ };
841
+ }
842
+
843
+ function localCanvasBoundary(): PlanReviewSession['execution_boundary'] {
844
+ return {
845
+ storage: 'local_only',
846
+ rendering: 'static_html',
847
+ sandbox: 'csp_no_script_no_network',
848
+ remote_sharing: false,
849
+ server: false,
850
+ background_service: false
851
+ };
852
+ }
853
+
854
+ function validateBoundary(value: unknown) {
855
+ assertExactKeys(value, ['storage', 'rendering', 'sandbox', 'remote_sharing', 'server', 'background_service'], 'execution_boundary');
856
+ const boundary = value as JsonObject;
857
+ if (boundary.storage !== 'local_only' || boundary.rendering !== 'static_html' || boundary.sandbox !== 'csp_no_script_no_network'
858
+ || boundary.remote_sharing !== false || boundary.server !== false || boundary.background_service !== false) {
859
+ throw new Error('plan review execution boundary must remain local, static, sandboxed, and inert');
860
+ }
861
+ }
862
+
863
+ function validateStoredPlanArtifact(value: unknown, index: number): PlanReviewArtifact {
864
+ assertExactKeys(value, ['id', 'role', 'file_path', 'sha256'], `artifacts[${index}]`);
865
+ const item = value as JsonObject;
866
+ return {
867
+ id: safeId(item.id, `artifacts[${index}].id`),
868
+ role: enumValue(item.role, ['plan', 'reference', 'deliverable'], `artifacts[${index}].role`),
869
+ file_path: safeRelativePath(item.file_path, `artifacts[${index}].file_path`),
870
+ sha256: assertSha256(item.sha256, `artifacts[${index}].sha256`)
871
+ };
872
+ }
873
+
874
+ function validatePlanReviewComment(value: unknown, artifacts: PlanReviewArtifact[]): PlanReviewComment {
875
+ assertExactKeys(value, ['id', 'anchor', 'finding', 'requested_change', 'anchor_context'], 'comment');
876
+ const item = value as JsonObject;
877
+ assertExactKeys(item.anchor, ['artifact_id', 'kind', 'locator'], 'comment.anchor');
878
+ const anchor = item.anchor as JsonObject;
879
+ const artifactId = safeId(anchor.artifact_id, 'comment.anchor.artifact_id');
880
+ if (!artifacts.some((artifact) => artifact.id === artifactId)) throw new Error(`comment anchor references unknown artifact: ${artifactId}`);
881
+ return {
882
+ id: safeId(item.id, 'comment.id'),
883
+ anchor: {
884
+ artifact_id: artifactId,
885
+ kind: enumValue(anchor.kind, ['line', 'region', 'text'], 'comment.anchor.kind'),
886
+ locator: boundedText(anchor.locator, 'comment.anchor.locator', 500, 1)
887
+ },
888
+ finding: boundedText(item.finding, 'comment.finding', 4000, 8),
889
+ requested_change: boundedText(item.requested_change, 'comment.requested_change', 4000, 8),
890
+ anchor_context: boundedText(item.anchor_context, 'comment.anchor_context', 4000, 1)
891
+ };
892
+ }
893
+
894
+ async function validateAndCapturePlanReviewComment(root: string, value: unknown, artifacts: PlanReviewArtifact[]): Promise<PlanReviewComment> {
895
+ assertExactKeys(value, ['id', 'anchor', 'finding', 'requested_change'], 'comment');
896
+ const item = value as JsonObject;
897
+ assertExactKeys(item.anchor, ['artifact_id', 'kind', 'locator'], 'comment.anchor');
898
+ const anchor = item.anchor as JsonObject;
899
+ const artifactId = safeId(anchor.artifact_id, 'comment.anchor.artifact_id');
900
+ const artifact = artifacts.find((candidate) => candidate.id === artifactId);
901
+ if (!artifact) throw new Error(`comment anchor references unknown artifact: ${artifactId}`);
902
+ const kind = enumValue(anchor.kind, ['line', 'region', 'text'], 'comment.anchor.kind');
903
+ const locator = boundedText(anchor.locator, 'comment.anchor.locator', 500, 1);
904
+ const file = await resolveExistingWithin(root, artifact.file_path, 'comment anchor artifact path');
905
+ const anchorContext = await captureAnchorContext(file, kind, locator);
906
+ return {
907
+ id: safeId(item.id, 'comment.id'),
908
+ anchor: { artifact_id: artifactId, kind, locator },
909
+ finding: boundedText(item.finding, 'comment.finding', 4000, 8),
910
+ requested_change: boundedText(item.requested_change, 'comment.requested_change', 4000, 8),
911
+ anchor_context: anchorContext
912
+ };
913
+ }
914
+
915
+ function validateReviewerFinding(value: unknown): DesignRevisionRound['reviewer_finding'] {
916
+ assertExactKeys(value, ['id', 'source_comment_id', 'summary', 'evidence'], 'reviewer_finding');
917
+ const finding = value as JsonObject;
918
+ return {
919
+ id: safeId(finding.id, 'reviewer_finding.id'),
920
+ source_comment_id: safeId(finding.source_comment_id, 'reviewer_finding.source_comment_id'),
921
+ summary: boundedText(finding.summary, 'reviewer_finding.summary', 4000, 8),
922
+ evidence: boundedText(finding.evidence, 'reviewer_finding.evidence', 4000, 8)
923
+ };
924
+ }
925
+
926
+ function validateDemandTrigger(value: unknown): DesignProductionDemandTrigger {
927
+ if (!isObject(value)) throw new Error('demand_trigger must be an object');
928
+ return {
929
+ kind: enumValue(value.kind, ['repeated_plan_review', 'multi_asset_production'], 'demand_trigger.kind'),
930
+ evidence: boundedText(value.evidence, 'demand_trigger.evidence', 4000, 12)
931
+ };
932
+ }
933
+
934
+ function validateStoredRevisionRound(value: unknown, index: number): DesignRevisionRound {
935
+ assertExactKeys(value, ['round', 'reviewer_finding', 'planned_change', 'outcome', 'revision_refs', 'recorded_at'], `rounds[${index}]`);
936
+ const item = value as JsonObject;
937
+ if (!Number.isInteger(item.round) || Number(item.round) < 1 || Number(item.round) > MAX_REVISION_ROUNDS) throw new Error(`rounds[${index}].round must be one or two`);
938
+ if (!Array.isArray(item.revision_refs) || !item.revision_refs.length) throw new Error(`rounds[${index}].revision_refs must be a non-empty array`);
939
+ const references = item.revision_refs.map((ref, refIndex) => validateStoredRevisionReference(ref, `rounds[${index}].revision_refs[${refIndex}]`));
940
+ assertUnique(references.map((ref) => `${ref.artifact_id}:${ref.manifest_path}:${ref.round}`), `rounds[${index}] revision reference`);
941
+ return {
942
+ round: Number(item.round),
943
+ reviewer_finding: validateReviewerFinding(item.reviewer_finding),
944
+ planned_change: boundedText(item.planned_change, `rounds[${index}].planned_change`, 4000, 8),
945
+ outcome: enumValue(item.outcome, ['accepted', 'changes_requested'], `rounds[${index}].outcome`),
946
+ revision_refs: references,
947
+ recorded_at: requiredIsoDate(item.recorded_at, `rounds[${index}].recorded_at`)
948
+ };
949
+ }
950
+
951
+ function validateStoredRevisionReference(value: unknown, label: string): UeyeRevisionReference {
952
+ assertExactKeys(value, ['artifact_id', 'manifest_path', 'round', 'sha256', 'archived_at', 'intent', 'asset_manifest_path', 'asset_id'], label);
953
+ const item = value as JsonObject;
954
+ if (!Number.isInteger(item.round) || Number(item.round) < 1) throw new Error(`${label}.round must be a positive integer`);
955
+ const assetManifestPath = item.asset_manifest_path === null ? null : safeRelativePath(item.asset_manifest_path, `${label}.asset_manifest_path`);
956
+ const assetId = item.asset_id === null ? null : safeId(item.asset_id, `${label}.asset_id`);
957
+ if ((assetManifestPath === null) !== (assetId === null)) throw new Error(`${label} asset_manifest_path and asset_id must both be set or both be null`);
958
+ return {
959
+ artifact_id: safeId(item.artifact_id, `${label}.artifact_id`),
960
+ manifest_path: safeRelativePath(item.manifest_path, `${label}.manifest_path`),
961
+ round: Number(item.round),
962
+ sha256: assertSha256(item.sha256, `${label}.sha256`),
963
+ archived_at: requiredIsoDate(item.archived_at, `${label}.archived_at`),
964
+ intent: enumValue(item.intent, ['preserve', 'edit_copy'], `${label}.intent`),
965
+ asset_manifest_path: assetManifestPath,
966
+ asset_id: assetId
967
+ };
968
+ }
969
+
970
+ async function validateUeyeRevisionReference(root: string, value: unknown, label: string): Promise<UeyeRevisionReference> {
971
+ const reference = validateStoredRevisionReference(value, label);
972
+ if (!reference.asset_manifest_path && reference.intent !== 'preserve') {
973
+ throw new Error(`${label}: an unlinked Ueye revision may only use preserve intent; edit_copy requires an asset manifest entry proving the source is editable`);
974
+ }
975
+ const manifestPath = await resolveExistingWithin(root, reference.manifest_path, `${label}.manifest_path`);
976
+ const history = await readJson(manifestPath, 'Ueye revision history');
977
+ assertExactKeys(history, ['schema', 'updated_at', 'revisions'], 'Ueye revision history');
978
+ if (history.schema !== 'yam.ueye-revision-history.v1' || !Array.isArray(history.revisions)) throw new Error(`${label}: invalid Ueye revision history schema`);
979
+ const matches = history.revisions.filter((entry) => {
980
+ const item = entry as JsonObject;
981
+ return item.artifact_id === reference.artifact_id && item.round === reference.round;
982
+ });
983
+ if (matches.length !== 1) throw new Error(`${label}: Ueye revision reference is ${matches.length ? 'ambiguous' : 'missing'}`);
984
+ const entry = matches[0] as JsonObject;
985
+ assertExactKeys(entry, ['artifact_id', 'round', 'source_path', 'archived_path', 'archived_at', 'sha256', 'bytes', 'dimensions'], 'Ueye revision entry');
986
+ const manifestHash = assertSha256(entry.sha256, 'Ueye revision entry sha256');
987
+ if (manifestHash !== reference.sha256) throw new Error(`${label}: Ueye revision reference sha256 mismatch`);
988
+ const archivedAt = requiredIsoDate(entry.archived_at, 'Ueye revision entry archived_at');
989
+ if (archivedAt !== reference.archived_at) throw new Error(`${label}: Ueye revision archived_at mismatch`);
990
+ const archivedFile = await resolveExistingWithin(path.dirname(manifestPath), entry.archived_path, 'Ueye revision archived_path', root);
991
+ if (await hashFile(archivedFile) !== reference.sha256) throw new Error(`${label}: archived Ueye revision content changed`);
992
+
993
+ if (reference.asset_manifest_path && reference.asset_id) {
994
+ const assetManifestPath = await resolveExistingWithin(root, reference.asset_manifest_path, `${label}.asset_manifest_path`);
995
+ const manifest = await readJson(assetManifestPath, 'Ueye asset manifest');
996
+ assertExactKeys(manifest, ['schema', 'updated_at', 'assets'], 'Ueye asset manifest');
997
+ if (manifest.schema !== 'yam.ueye-asset-manifest.v1' || !Array.isArray(manifest.assets)) throw new Error(`${label}: invalid Ueye asset manifest schema`);
998
+ const assets = manifest.assets.filter((asset) => (asset as JsonObject).id === reference.asset_id);
999
+ if (assets.length !== 1) throw new Error(`${label}: Ueye asset protection reference is ${assets.length ? 'ambiguous' : 'missing'}`);
1000
+ const asset = assets[0] as JsonObject;
1001
+ if (typeof asset.do_not_replace !== 'boolean') throw new Error(`${label}: Ueye asset protection flag is invalid`);
1002
+ if (typeof asset.allowed_for_edit !== 'boolean') throw new Error(`${label}: Ueye asset editability flag is invalid`);
1003
+ const assetHash = assertSha256(asset.sha256, `${label}: Ueye asset sha256`);
1004
+ if (typeof asset.file_path !== 'string') throw new Error(`${label}: Ueye asset file_path is invalid`);
1005
+ const assetFile = await resolveExistingWithin(path.dirname(assetManifestPath), asset.file_path, `${label}: Ueye asset file_path`, root);
1006
+ if (assetHash !== reference.sha256) throw new Error(`${label}: Ueye asset and preserved revision sha256 do not match`);
1007
+ const currentAssetHash = await hashFile(assetFile);
1008
+ if (asset.do_not_replace) {
1009
+ if (currentAssetHash !== assetHash) throw new Error(`${label}: protected Ueye asset content changed after its manifest was recorded`);
1010
+ if (reference.intent !== 'preserve') {
1011
+ throw new Error(`${label}: protected Ueye asset ${reference.asset_id} must use preserve intent; archived revisions must not be overwritten`);
1012
+ }
1013
+ } else {
1014
+ if (reference.intent === 'edit_copy' && asset.allowed_for_edit !== true) {
1015
+ throw new Error(`${label}: Ueye asset ${reference.asset_id} is not explicitly allowed_for_edit`);
1016
+ }
1017
+ if (reference.intent === 'preserve' && currentAssetHash !== assetHash) {
1018
+ throw new Error(`${label}: preserved Ueye asset content changed after its manifest was recorded`);
1019
+ }
1020
+ }
1021
+ }
1022
+ return {
1023
+ ...reference,
1024
+ manifest_path: portableRelative(root, manifestPath),
1025
+ asset_manifest_path: reference.asset_manifest_path
1026
+ ? portableRelative(root, await resolveExistingWithin(root, reference.asset_manifest_path, `${label}.asset_manifest_path`))
1027
+ : null
1028
+ };
1029
+ }
1030
+
1031
+ function validateProvenance(value: unknown, label: string): GalleryArtifact['provenance'] {
1032
+ assertExactKeys(value, ['kind', 'source_ref', 'license_note'], label);
1033
+ const item = value as JsonObject;
1034
+ return {
1035
+ kind: enumValue(item.kind, ['operator', 'generated', 'downloaded'], `${label}.kind`),
1036
+ source_ref: boundedText(item.source_ref, `${label}.source_ref`, 2000, 1),
1037
+ license_note: boundedText(item.license_note, `${label}.license_note`, 2000, 0)
1038
+ };
1039
+ }
1040
+
1041
+ function renderPlanReviewCanvas(session: PlanReviewSession): string {
1042
+ const artifactRows = session.artifacts.map((artifact) => (
1043
+ `<li><strong>${escapeHtml(artifact.id)}</strong> <span>${escapeHtml(artifact.role)}</span><br><code>${escapeHtml(artifact.file_path)}</code><br><small>sha256:${escapeHtml(artifact.sha256)}</small></li>`
1044
+ )).join('\n');
1045
+ const commentRows = session.comments.map((comment) => (
1046
+ `<li><strong>${escapeHtml(comment.id)}</strong> — ${escapeHtml(comment.anchor.artifact_id)} / ${escapeHtml(comment.anchor.kind)} / ${escapeHtml(comment.anchor.locator)}<pre>${escapeHtml(comment.anchor_context)}</pre><p>${escapeHtml(comment.finding)}</p><p>Requested: ${escapeHtml(comment.requested_change)}</p></li>`
1047
+ )).join('\n');
1048
+ return `<!doctype html>
1049
+ <html lang="en">
1050
+ <head>
1051
+ <meta charset="utf-8">
1052
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'">
1053
+ <meta name="referrer" content="no-referrer">
1054
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1055
+ <title>${escapeHtml(session.title)}</title>
1056
+ <style>*{box-sizing:border-box}body{font:16px/1.5 system-ui,sans-serif;max-width:960px;margin:2rem auto;padding:0 1rem;color:#171717;background:#fff}header{border-bottom:1px solid #bbb}section{margin:2rem 0}li{margin:1rem 0;padding:1rem;border:1px solid #ccc;border-radius:.5rem}h1,p,pre,code,small{overflow-wrap:anywhere}pre{white-space:pre-wrap;max-width:100%}.boundary{padding:.75rem;background:#f3f3f3}</style>
1057
+ </head>
1058
+ <body>
1059
+ <header><h1>${escapeHtml(session.title)}</h1><p>Session ${escapeHtml(session.session_id)} · ${escapeHtml(session.status)} · verdict ${escapeHtml(session.verdict || 'pending')}</p></header>
1060
+ <p class="boundary">Local-only static review evidence. Scripts, network requests, forms, remote sharing, servers, and background services are disabled by contract.</p>
1061
+ <section><h2>Demand trigger</h2><p><strong>${escapeHtml(session.demand_trigger.kind)}</strong> · operator asserted at ${escapeHtml(session.demand_trigger.recorded_at)}</p><p>${escapeHtml(session.demand_trigger.evidence)}</p></section>
1062
+ <section><h2>Artifacts</h2><ul>${artifactRows}</ul></section>
1063
+ <section><h2>Anchored findings</h2>${commentRows ? `<ol>${commentRows}</ol>` : '<p>No findings recorded.</p>'}</section>
1064
+ </body>
1065
+ </html>
1066
+ `;
1067
+ }
1068
+
1069
+ function escapeHtml(value: unknown): string {
1070
+ return String(value).replace(/[&<>"']/g, (character) => ({
1071
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
1072
+ })[character] || character);
1073
+ }
1074
+
1075
+ async function verifyPlanReviewArtifacts(root: string, artifacts: PlanReviewArtifact[]) {
1076
+ for (const artifact of artifacts) {
1077
+ const file = await resolveExistingWithin(root, artifact.file_path, `${artifact.id}.file_path`);
1078
+ const digest = await hashFile(file);
1079
+ if (digest !== artifact.sha256) throw new Error(`${artifact.id}: plan review artifact sha256 changed after the session was created`);
1080
+ }
1081
+ }
1082
+
1083
+ async function verifyPlanReviewEvidencePaths(root: string, session: PlanReviewSession, sessionPath: string, renderPath?: string) {
1084
+ const base = `.yam/ueye/design-production/${session.session_id}`;
1085
+ const expectedSession = path.join(root, base, 'plan-review.json');
1086
+ const expectedRender = path.join(root, base, 'canvas.html');
1087
+ if (sessionPath !== expectedSession) {
1088
+ throw new Error(`plan review session path must remain fixed to ${portableRelative(root, expectedSession)}`);
1089
+ }
1090
+ if (renderPath && renderPath !== expectedRender) {
1091
+ throw new Error(`plan review render path must remain fixed to ${portableRelative(root, expectedRender)} and cannot target unrelated project files`);
1092
+ }
1093
+ }
1094
+
1095
+ async function verifyPlanReviewComments(root: string, session: PlanReviewSession) {
1096
+ for (const comment of session.comments) {
1097
+ const artifact = session.artifacts.find((candidate) => candidate.id === comment.anchor.artifact_id);
1098
+ if (!artifact) throw new Error(`comment ${comment.id} references an unknown artifact`);
1099
+ const file = await resolveExistingWithin(root, artifact.file_path, `${comment.id}.anchor artifact`);
1100
+ const context = await captureAnchorContext(file, comment.anchor.kind, comment.anchor.locator);
1101
+ if (context !== comment.anchor_context) {
1102
+ throw new Error(`comment ${comment.id} anchor context changed after it was captured`);
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ async function captureAnchorContext(file: string, kind: PlanReviewComment['anchor']['kind'], locator: string): Promise<string> {
1108
+ if (kind === 'region') {
1109
+ const match = /^(\d+),(\d+),(\d+),(\d+)$/.exec(locator);
1110
+ if (!match) throw new Error('region locator must use x,y,width,height non-negative integer syntax');
1111
+ const [x, y, width, height] = match.slice(1).map(Number);
1112
+ if (width < 1 || height < 1) throw new Error('region locator width and height must be positive');
1113
+ const info = await inspectImageFile(file);
1114
+ const dimensions = /^(\d+)x(\d+)$/.exec(info.dimensions);
1115
+ if (dimensions) {
1116
+ const imageWidth = Number(dimensions[1]);
1117
+ const imageHeight = Number(dimensions[2]);
1118
+ if (x + width > imageWidth || y + height > imageHeight) throw new Error(`region locator exceeds artifact dimensions ${info.dimensions}`);
1119
+ }
1120
+ return `operator-asserted region ${locator}; artifact dimensions ${info.dimensions}; target semantics and pixel correctness are not independently verified`;
1121
+ }
1122
+ const stat = await fsp.stat(file);
1123
+ if (stat.size > MAX_ANCHOR_BYTES) throw new Error(`text anchor artifact exceeds the ${MAX_ANCHOR_BYTES}-byte limit`);
1124
+ const buffer = await fsp.readFile(file);
1125
+ if (buffer.includes(0)) throw new Error('line/text anchors require a text artifact');
1126
+ const text = buffer.toString('utf8');
1127
+ if (kind === 'text') {
1128
+ const index = text.indexOf(locator);
1129
+ if (index < 0) throw new Error('text anchor locator was not found in the artifact');
1130
+ if (index !== text.lastIndexOf(locator)) throw new Error('text anchor locator is ambiguous; use a unique text locator or an exact line anchor');
1131
+ const start = Math.max(0, index - 120);
1132
+ const end = Math.min(text.length, index + locator.length + 120);
1133
+ return text.slice(start, end).trim().slice(0, 1000);
1134
+ }
1135
+ const lineMatch = /^L(\d+)(?:-L?(\d+))?$/.exec(locator);
1136
+ if (!lineMatch) throw new Error('line locator must use L<number> or L<number>-L<number> syntax');
1137
+ const startLine = Number(lineMatch[1]);
1138
+ const endLine = Number(lineMatch[2] || lineMatch[1]);
1139
+ const lines = text.split(/\r?\n/);
1140
+ if (startLine < 1 || endLine < startLine || endLine > lines.length) throw new Error(`line anchor ${locator} is outside the artifact line range 1-${lines.length}`);
1141
+ return lines.slice(startLine - 1, endLine).map((line, index) => `L${startLine + index}: ${line}`).join('\n').slice(0, 4000);
1142
+ }
1143
+
1144
+ async function canonicalRoot(value: unknown): Promise<string> {
1145
+ if (typeof value !== 'string' || !value.trim()) throw new Error('root must be a non-empty path');
1146
+ const absolute = path.resolve(value);
1147
+ await fsp.mkdir(absolute, { recursive: true });
1148
+ return fsp.realpath(absolute);
1149
+ }
1150
+
1151
+ async function resolveExistingWithin(rootOrBase: string, value: unknown, label: string, outerRoot = rootOrBase): Promise<string> {
1152
+ const relative = outerRoot === rootOrBase ? safeRelativePath(value, label) : safeManifestRelativePath(value, label);
1153
+ const candidate = path.resolve(rootOrBase, relative);
1154
+ assertContained(outerRoot, candidate, label);
1155
+ const real = await fsp.realpath(candidate);
1156
+ assertContained(outerRoot, real, label);
1157
+ return real;
1158
+ }
1159
+
1160
+ function safeManifestRelativePath(value: unknown, label: string): string {
1161
+ if (typeof value !== 'string' || !value || value.includes('\0')) throw new Error(`${label} must be a non-empty relative path`);
1162
+ if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) throw new Error(`${label} must be relative to its manifest`);
1163
+ return path.normalize(value);
1164
+ }
1165
+
1166
+ async function resolveWritableWithin(root: string, value: unknown, label: string): Promise<string> {
1167
+ const relative = safeRelativePath(value, label);
1168
+ const candidate = path.resolve(root, relative);
1169
+ assertContained(root, candidate, label);
1170
+ let ancestor = path.dirname(candidate);
1171
+ while (true) {
1172
+ try {
1173
+ const realAncestor = await fsp.realpath(ancestor);
1174
+ assertContained(root, realAncestor, label);
1175
+ break;
1176
+ } catch (error: any) {
1177
+ if (error?.code !== 'ENOENT') throw error;
1178
+ const parent = path.dirname(ancestor);
1179
+ if (parent === ancestor) throw error;
1180
+ ancestor = parent;
1181
+ }
1182
+ }
1183
+ try {
1184
+ const realCandidate = await fsp.realpath(candidate);
1185
+ assertContained(root, realCandidate, label);
1186
+ return realCandidate;
1187
+ } catch (error: any) {
1188
+ if (error?.code !== 'ENOENT') throw error;
1189
+ }
1190
+ return candidate;
1191
+ }
1192
+
1193
+ function safeRelativePath(value: unknown, label: string): string {
1194
+ if (typeof value !== 'string' || !value || value.includes('\0')) throw new Error(`${label} must be a non-empty relative path`);
1195
+ if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) throw new Error(`${label} must be relative to the project root`);
1196
+ const normalized = path.normalize(value);
1197
+ if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) throw new Error(`${label} escapes the project root`);
1198
+ return normalized;
1199
+ }
1200
+
1201
+ function assertContained(root: string, candidate: string, label: string) {
1202
+ const relative = path.relative(root, candidate);
1203
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`${label} escapes the project root`);
1204
+ }
1205
+
1206
+ function portableRelative(root: string, target: string): string {
1207
+ return path.relative(root, target).split(path.sep).join('/');
1208
+ }
1209
+
1210
+ async function readJson(file: string, label: string): Promise<JsonObject> {
1211
+ const stat = await fsp.stat(file);
1212
+ if (!stat.isFile()) throw new Error(`${label} must be a regular file`);
1213
+ if (stat.size > MAX_JSON_BYTES) throw new Error(`${label} exceeds the ${MAX_JSON_BYTES}-byte limit`);
1214
+ let value: unknown;
1215
+ try {
1216
+ value = JSON.parse(await fsp.readFile(file, 'utf8'));
1217
+ } catch (error) {
1218
+ throw new Error(`${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
1219
+ }
1220
+ if (!isObject(value)) throw new Error(`${label} must be a JSON object`);
1221
+ return value;
1222
+ }
1223
+
1224
+ async function hashFile(file: string): Promise<string> {
1225
+ const stat = await fsp.stat(file);
1226
+ if (!stat.isFile()) throw new Error(`${file} must be a regular file`);
1227
+ return createHash('sha256').update(await fsp.readFile(file)).digest('hex');
1228
+ }
1229
+
1230
+ async function compareReceiptFileDigest(file: string, expected: unknown, label: string, errors: string[]) {
1231
+ try {
1232
+ const digest = prefixedSha256(expected, `${label} digest`);
1233
+ const actual = `sha256:${await hashFile(file)}`;
1234
+ if (digest !== actual) errors.push(`${label} digest mismatch`);
1235
+ } catch (error) {
1236
+ errors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
1237
+ }
1238
+ }
1239
+
1240
+ async function writeJsonAtomic(file: string, value: unknown, replace = true) {
1241
+ await writeTextAtomic(file, `${JSON.stringify(value, null, 2)}\n`, replace);
1242
+ }
1243
+
1244
+ async function writeTextAtomic(file: string, value: string, replace = true) {
1245
+ await fsp.mkdir(path.dirname(file), { recursive: true });
1246
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}-${createHash('sha256').update(file).digest('hex').slice(0, 8)}`;
1247
+ try {
1248
+ await fsp.writeFile(temporary, value, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
1249
+ if (replace) await fsp.rename(temporary, file);
1250
+ else await fsp.link(temporary, file);
1251
+ } finally {
1252
+ await fsp.rm(temporary, { force: true }).catch(() => {});
1253
+ }
1254
+ }
1255
+
1256
+ async function assertAbsent(file: string, label: string) {
1257
+ try {
1258
+ await fsp.lstat(file);
1259
+ throw new Error(`${label} already exists; refusing to overwrite immutable design evidence`);
1260
+ } catch (error: any) {
1261
+ if (error?.code !== 'ENOENT') throw error;
1262
+ }
1263
+ }
1264
+
1265
+ function assertExactKeys(value: unknown, allowed: string[], label: string) {
1266
+ if (!isObject(value)) throw new Error(`${label} must be an object`);
1267
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
1268
+ if (unknown.length) throw new Error(`${label} contains unknown field: ${unknown.sort(compareStableText)[0]}`);
1269
+ const required = allowed.filter((key) => !['session_path', 'render_path', 'state_path', 'sha256'].includes(key));
1270
+ const missing = required.filter((key) => !(key in value));
1271
+ if (missing.length) throw new Error(`${label} is missing field: ${missing[0]}`);
1272
+ }
1273
+
1274
+ function assertExactObjectKeys(value: unknown, keys: string[], label: string): asserts value is JsonObject {
1275
+ if (!isObject(value)) throw new Error(`${label} must be an object`);
1276
+ const actual = Object.keys(value).sort(compareStableText);
1277
+ const expected = [...keys].sort(compareStableText);
1278
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
1279
+ throw new Error(`${label} must contain exactly: ${expected.join(', ')}`);
1280
+ }
1281
+ }
1282
+
1283
+ function isObject(value: unknown): value is JsonObject {
1284
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
1285
+ }
1286
+
1287
+ function safeId(value: unknown, label: string): string {
1288
+ const text = String(value || '');
1289
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(text)) throw new Error(`${label} must match [A-Za-z0-9._-] and be at most 64 characters`);
1290
+ if (text === '.' || text === '..') throw new Error(`${label} must not be a dot path segment`);
1291
+ return text;
1292
+ }
1293
+
1294
+ function boundedText(value: unknown, label: string, maximum: number, minimum = 1): string {
1295
+ if (typeof value !== 'string') throw new Error(`${label} must be a string`);
1296
+ const text = value.trim();
1297
+ if (text.length < minimum || text.length > maximum) throw new Error(`${label} must contain ${minimum}-${maximum} characters`);
1298
+ return text;
1299
+ }
1300
+
1301
+ function enumValue<T extends string>(value: unknown, allowed: readonly T[], label: string): T {
1302
+ if (typeof value !== 'string' || !allowed.includes(value as T)) throw new Error(`${label} must be one of: ${allowed.join(', ')}`);
1303
+ return value as T;
1304
+ }
1305
+
1306
+ function assertSha256(value: unknown, label: string): string {
1307
+ if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) throw new Error(`${label} must be a lowercase sha256 digest`);
1308
+ return value;
1309
+ }
1310
+
1311
+ function prefixedSha256(value: unknown, label: string): string {
1312
+ if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) throw new Error(`${label} must be a prefixed lowercase sha256 digest`);
1313
+ return value;
1314
+ }
1315
+
1316
+ function assertUnique(values: string[], label: string) {
1317
+ const seen = new Set<string>();
1318
+ for (const value of values) {
1319
+ if (seen.has(value)) throw new Error(`duplicate ${label}: ${value}`);
1320
+ seen.add(value);
1321
+ }
1322
+ }
1323
+
1324
+ function requiredIsoDate(value: unknown, label: string): string {
1325
+ if (!isIsoDate(value)) throw new Error(`${label} must be an ISO timestamp`);
1326
+ return value;
1327
+ }
1328
+
1329
+ function isIsoDate(value: unknown): value is string {
1330
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
1331
+ }
1332
+
1333
+ function uniqueStrings(values: string[]): string[] {
1334
+ return [...new Set(values.filter(Boolean))];
1335
+ }
1336
+
1337
+ function aggregateGalleryCompletion(states: GalleryCompletionState[]): GalleryCompletionState {
1338
+ const rank: Record<GalleryCompletionState, number> = { draft: 0, ready_for_inspection: 1, packaged: 2 };
1339
+ return states.reduce((lowest, state) => rank[state] < rank[lowest] ? state : lowest, 'packaged');
1340
+ }
1341
+
1342
+ function revisionReferenceKey(reference: UeyeRevisionReference): string {
1343
+ return `${reference.artifact_id}\0${reference.manifest_path}\0${reference.round}\0${reference.sha256}`;
1344
+ }
1345
+
1346
+ function designPhaseNextAction(phaseEvidenceComplete: boolean, revisionStatus?: DesignRevisionStatus): string {
1347
+ if (phaseEvidenceComplete) return 'phase evidence is complete; visual, license, and implementation correctness still require their own verification';
1348
+ if (revisionStatus === 'two_round_limit') return 'stop after two rounds and leave the gallery draft until a new explicitly authorized phase';
1349
+ return 'finish accepted revisions and package the gallery before a complete phase claim';
1350
+ }
1351
+
1352
+ function stableJson(value: unknown): string {
1353
+ if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(',')}]`;
1354
+ if (value && typeof value === 'object') {
1355
+ const record = value as Record<string, unknown>;
1356
+ return `{${Object.keys(record).sort(compareStableText).map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`;
1357
+ }
1358
+ return JSON.stringify(value);
1359
+ }
1360
+
1361
+ function assertRevisionChronology(canvas: PlanReviewSession, state: DesignRevisionState) {
1362
+ let lowerBound = Date.parse(canvas.closed_at as string);
1363
+ for (const round of state.rounds) {
1364
+ const recordedAt = Date.parse(round.recorded_at);
1365
+ if (recordedAt < lowerBound) throw new Error(`revision round ${round.round} was recorded before its phase review boundary`);
1366
+ const archiveTimes = round.revision_refs.map((reference) => Date.parse(reference.archived_at));
1367
+ if (archiveTimes.some((timestamp) => timestamp > recordedAt)) {
1368
+ throw new Error(`revision round ${round.round} references an archive created after the round was recorded`);
1369
+ }
1370
+ if (!archiveTimes.some((timestamp) => timestamp >= lowerBound && timestamp <= recordedAt)) {
1371
+ throw new Error(`revision round ${round.round} requires a fresh Ueye archive created after its triggering review boundary`);
1372
+ }
1373
+ lowerBound = recordedAt;
1374
+ }
1375
+ }
1376
+
1377
+ function assertGalleryChronology(canvas: PlanReviewSession, state: DesignRevisionState | null, gallery: JsonObject) {
1378
+ const galleryCreatedAt = Date.parse(requiredIsoDate(gallery.created_at, 'final gallery created_at'));
1379
+ const canvasClosedAt = Date.parse(canvas.closed_at as string);
1380
+ if (galleryCreatedAt < canvasClosedAt) {
1381
+ throw new Error('final gallery was created before the Plan Review Canvas closed');
1382
+ }
1383
+ const finalRoundRecordedAt = state?.rounds.at(-1)?.recorded_at;
1384
+ if (finalRoundRecordedAt && galleryCreatedAt < Date.parse(finalRoundRecordedAt)) {
1385
+ throw new Error('final gallery was created before the final revision round was recorded');
1386
+ }
1387
+ }
1388
+
1389
+ function planReviewRelativePath(sessionId: string): string {
1390
+ return `.yam/ueye/design-production/${safeId(sessionId, 'session_id')}/plan-review.json`;
1391
+ }
1392
+
1393
+ function revisionStateRelativePath(sessionId: string): string {
1394
+ return `.yam/ueye/design-production/${safeId(sessionId, 'session_id')}/revision-state.json`;
1395
+ }
1396
+
1397
+ async function loadBoundDesignRevisionState(root: string, suppliedPath: unknown, label = 'state_path') {
1398
+ const relative = safeRelativePath(suppliedPath, label);
1399
+ const statePath = await resolveExistingWithin(root, relative, label);
1400
+ const state = await readDesignRevisionState(statePath);
1401
+ const expectedStateRelative = revisionStateRelativePath(state.session_id);
1402
+ const expectedStatePath = await resolveExistingWithin(root, expectedStateRelative, label);
1403
+ if (relative !== expectedStateRelative || statePath !== expectedStatePath || statePath !== path.join(root, expectedStateRelative)) {
1404
+ throw new Error(`${label} must be the session-derived canonical path ${expectedStateRelative}`);
1405
+ }
1406
+ const expectedCanvasRelative = planReviewRelativePath(state.session_id);
1407
+ if (state.canvas_session_path !== expectedCanvasRelative) {
1408
+ throw new Error(`revision state canvas_session_path must remain fixed to ${expectedCanvasRelative}`);
1409
+ }
1410
+ const canvasPath = await resolveExistingWithin(root, state.canvas_session_path, 'canvas_session_path');
1411
+ if (await hashFile(canvasPath) !== state.canvas_session_sha256) {
1412
+ throw new Error('bound Plan Review Canvas changed after the revision state was created');
1413
+ }
1414
+ const canvas = await readPlanReviewSession(canvasPath);
1415
+ await verifyPlanReviewEvidencePaths(root, canvas, canvasPath);
1416
+ await verifyPlanReviewArtifacts(root, canvas.artifacts);
1417
+ await verifyPlanReviewComments(root, canvas);
1418
+ if (canvas.session_id !== state.session_id || canvas.status !== 'closed' || canvas.verdict !== 'request_changes') {
1419
+ throw new Error('revision state must remain bound to its closed request_changes Plan Review Canvas');
1420
+ }
1421
+ return { statePath, state, canvasPath, canvas };
1422
+ }
1423
+
1424
+ function assertRevisionRoundChronology(
1425
+ canvas: PlanReviewSession,
1426
+ state: DesignRevisionState,
1427
+ refs: UeyeRevisionReference[],
1428
+ recordedAt: string,
1429
+ round: number
1430
+ ) {
1431
+ const lowerBound = Date.parse(state.rounds.at(-1)?.recorded_at || (canvas.closed_at as string));
1432
+ const recordedTime = Date.parse(recordedAt);
1433
+ if (recordedTime < lowerBound) throw new Error(`revision round ${round} was recorded before its current review boundary`);
1434
+ const archiveTimes = refs.map((reference) => Date.parse(reference.archived_at));
1435
+ if (archiveTimes.some((timestamp) => timestamp > recordedTime)) {
1436
+ throw new Error(`revision round ${round} references an archive created after the round was recorded`);
1437
+ }
1438
+ if (!archiveTimes.some((timestamp) => timestamp >= lowerBound)) {
1439
+ throw new Error(`revision round ${round} requires a fresh Ueye archive created after its current review boundary`);
1440
+ }
1441
+ }