@wibly/internal-manifest 0.1.1

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,310 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ cloneFixture,
5
+ patchPhase,
6
+ patchPhaseAt,
7
+ validManifestFixture,
8
+ } from './fixtures.js';
9
+ import { validateManifest, type ValidationErrorCode } from './validate.js';
10
+
11
+ const expectErrorCode = (
12
+ errors: ReadonlyArray<{ readonly code: ValidationErrorCode }>,
13
+ code: ValidationErrorCode,
14
+ ): void => {
15
+ expect(errors.map((e) => e.code)).toContain(code);
16
+ };
17
+
18
+ describe('validateManifest — happy path', () => {
19
+ it('returns Ok on the baseline fixture', () => {
20
+ const result = validateManifest(validManifestFixture);
21
+ expect(result.ok).toBe(true);
22
+ if (result.ok) {
23
+ // The Ok branch surfaces the parsed manifest, not the input;
24
+ // round-trip equality is the contract.
25
+ expect(result.value.id).toBe(validManifestFixture.id);
26
+ expect(result.value.workflow.phases).toHaveLength(
27
+ validManifestFixture.workflow.phases.length,
28
+ );
29
+ }
30
+ });
31
+
32
+ it('returns Ok when `tenant` is null (first-party)', () => {
33
+ const fixture = cloneFixture();
34
+ fixture.tenant = null;
35
+ expect(validateManifest(fixture).ok).toBe(true);
36
+ });
37
+
38
+ it('returns Ok when bundle reservation fields are omitted (Chunk B18)', () => {
39
+ const fixture = cloneFixture();
40
+ delete (fixture as { bundleCompatible?: unknown }).bundleCompatible;
41
+ delete (fixture as { bundleScopedDimensions?: unknown })
42
+ .bundleScopedDimensions;
43
+ delete (fixture as { bundleWriteDimensions?: unknown })
44
+ .bundleWriteDimensions;
45
+ delete (fixture as { bundleContextSlots?: unknown }).bundleContextSlots;
46
+ expect(validateManifest(fixture).ok).toBe(true);
47
+ });
48
+
49
+ it('returns Ok when bundle reservation fields are populated (Chunk B18)', () => {
50
+ const fixture = cloneFixture();
51
+ fixture.bundleCompatible = true;
52
+ fixture.bundleScopedDimensions = ['score'];
53
+ fixture.bundleWriteDimensions = ['notes'];
54
+ fixture.bundleContextSlots = { theme: 'party' };
55
+ const result = validateManifest(fixture);
56
+ expect(result.ok).toBe(true);
57
+ if (result.ok) {
58
+ expect(result.value.bundleCompatible).toBe(true);
59
+ expect(result.value.bundleScopedDimensions).toEqual(['score']);
60
+ }
61
+ });
62
+ });
63
+
64
+ describe('validateManifest — schema-level failures surface as `schema` errors', () => {
65
+ it('reports a schema error when a top-level field is missing', () => {
66
+ const fixture: Record<string, unknown> = cloneFixture();
67
+ delete fixture.name;
68
+ const result = validateManifest(fixture);
69
+ expect(result.ok).toBe(false);
70
+ if (!result.ok) {
71
+ expectErrorCode(result.error, 'schema');
72
+ expect(result.error.some((e) => e.path.includes('name'))).toBe(true);
73
+ }
74
+ });
75
+
76
+ it('skips structural checks when the parse fails', () => {
77
+ // Deliberately combine a schema error AND a structural error;
78
+ // verify only the schema error is surfaced (structural traversal
79
+ // requires a parsed shape).
80
+ const fixture = cloneFixture() as { name?: unknown; workflow: { initialPhase: string } };
81
+ delete fixture.name;
82
+ fixture.workflow.initialPhase = 'nonexistent';
83
+ const result = validateManifest(fixture);
84
+ expect(result.ok).toBe(false);
85
+ if (!result.ok) {
86
+ expect(result.error.every((e) => e.code === 'schema')).toBe(true);
87
+ }
88
+ });
89
+ });
90
+
91
+ describe('validateManifest — structural: initial phase', () => {
92
+ it('rejects a manifest whose initialPhase is not declared in phases', () => {
93
+ const fixture = cloneFixture();
94
+ fixture.workflow.initialPhase = 'nonexistent';
95
+ const result = validateManifest(fixture);
96
+ expect(result.ok).toBe(false);
97
+ if (!result.ok) {
98
+ expectErrorCode(result.error, 'unknown_initial_phase');
99
+ const issue = result.error.find(
100
+ (e) => e.code === 'unknown_initial_phase',
101
+ );
102
+ expect(issue?.path).toEqual(['workflow', 'initialPhase']);
103
+ }
104
+ });
105
+ });
106
+
107
+ describe('validateManifest — structural: duplicate phase ids', () => {
108
+ it('rejects a manifest where two phases share an id', () => {
109
+ const fixture = cloneFixture();
110
+ // Make `lobby` and `guess` share an id; mutate `guess` only so
111
+ // every other check still has a coherent traversal target.
112
+ patchPhaseAt(fixture, 1, { id: 'lobby' });
113
+ const result = validateManifest(fixture);
114
+ expect(result.ok).toBe(false);
115
+ if (!result.ok) {
116
+ expectErrorCode(result.error, 'duplicate_phase_id');
117
+ }
118
+ });
119
+ });
120
+
121
+ describe('validateManifest — structural: transition target', () => {
122
+ it('rejects a manifest whose transition points to a non-existent phase', () => {
123
+ const fixture = cloneFixture();
124
+ patchPhaseAt(fixture, 0, { transitions: [{ to: 'nonexistent' }] });
125
+ const result = validateManifest(fixture);
126
+ expect(result.ok).toBe(false);
127
+ if (!result.ok) {
128
+ expectErrorCode(result.error, 'unknown_transition_target');
129
+ const issue = result.error.find(
130
+ (e) => e.code === 'unknown_transition_target',
131
+ );
132
+ expect(issue?.path).toEqual([
133
+ 'workflow',
134
+ 'phases',
135
+ 0,
136
+ 'transitions',
137
+ 0,
138
+ 'to',
139
+ ]);
140
+ }
141
+ });
142
+ });
143
+
144
+ describe('validateManifest — structural: unreachable phases', () => {
145
+ it('rejects a manifest with a phase no transition points to', () => {
146
+ // Build a manifest where `guess` is reachable but `resolved` is
147
+ // not — the lobby transition jumps straight back to itself, and
148
+ // guess transitions back to lobby. Hello-World fixture's
149
+ // resolved-phase loops back to lobby; we have to break that.
150
+ const fixture = cloneFixture();
151
+ // lobby → guess, guess → lobby, resolved still has its old
152
+ // outgoing edge but nothing points TO it.
153
+ patchPhaseAt(fixture, 1, { transitions: [{ to: 'lobby' }] });
154
+ const result = validateManifest(fixture);
155
+ expect(result.ok).toBe(false);
156
+ if (!result.ok) {
157
+ expectErrorCode(result.error, 'unreachable_phase');
158
+ const issue = result.error.find((e) => e.code === 'unreachable_phase');
159
+ expect(issue?.message).toContain('resolved');
160
+ }
161
+ });
162
+
163
+ it('does not flag any phase as unreachable when the workflow is a single self-loop', () => {
164
+ const fixture = cloneFixture();
165
+ fixture.workflow.initialPhase = 'lobby';
166
+ fixture.workflow.phases = [
167
+ {
168
+ id: 'lobby',
169
+ inputSet: { actors: ['host'], inputType: 'tick' },
170
+ collectionRule: { kind: 'manual' },
171
+ transitions: [{ to: 'lobby' }],
172
+ sideEffects: [],
173
+ },
174
+ ];
175
+ fixture.concurrentOpportunities = [];
176
+ const result = validateManifest(fixture);
177
+ expect(result.ok).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe('validateManifest — structural: persona-binding role uniqueness', () => {
182
+ it('rejects two bindings sharing a role', () => {
183
+ const fixture = cloneFixture();
184
+ fixture.personaBindings = [
185
+ { role: 'host', personaId: 'per_first0000000000000' },
186
+ { role: 'host', personaId: 'per_second000000000000' },
187
+ ];
188
+ const result = validateManifest(fixture);
189
+ expect(result.ok).toBe(false);
190
+ if (!result.ok) {
191
+ expectErrorCode(result.error, 'duplicate_persona_role');
192
+ }
193
+ });
194
+ });
195
+
196
+ describe('validateManifest — structural: opportunity phase references', () => {
197
+ it('rejects an opportunity attached to a non-existent phase', () => {
198
+ const fixture = cloneFixture();
199
+ fixture.concurrentOpportunities = [
200
+ {
201
+ id: 'side_chat',
202
+ attachedToPhases: ['nowhere'],
203
+ inputSet: { actors: ['player'], inputType: 'chatter' },
204
+ collectionRule: { kind: 'timeout', ms: 30_000 },
205
+ scoringEffect: { kind: 'noop' },
206
+ },
207
+ ];
208
+ const result = validateManifest(fixture);
209
+ expect(result.ok).toBe(false);
210
+ if (!result.ok) {
211
+ expectErrorCode(result.error, 'unknown_opportunity_phase');
212
+ }
213
+ });
214
+ });
215
+
216
+ describe('validateManifest — structural: award dimension references', () => {
217
+ it('rejects an award referencing a missing scoring dimension', () => {
218
+ const fixture = cloneFixture();
219
+ fixture.scoring.awards = [
220
+ {
221
+ id: 'top_guesser',
222
+ label: 'Top Guesser',
223
+ dimensionId: 'speed',
224
+ criterion: { kind: 'top_n', n: 1 },
225
+ },
226
+ ];
227
+ const result = validateManifest(fixture);
228
+ expect(result.ok).toBe(false);
229
+ if (!result.ok) {
230
+ expectErrorCode(result.error, 'unknown_award_dimension');
231
+ }
232
+ });
233
+ });
234
+
235
+ describe('validateManifest — structural: phase side effects (chunk B8c)', () => {
236
+ it('rejects a scoring side effect referencing an unknown dimension', () => {
237
+ const fixture = cloneFixture();
238
+ patchPhase(fixture, 'guess', {
239
+ sideEffects: [
240
+ {
241
+ kind: 'scoring',
242
+ dimension: 'speed',
243
+ value: 1,
244
+ source: 'submit',
245
+ },
246
+ ],
247
+ });
248
+ const result = validateManifest(fixture);
249
+ expect(result.ok).toBe(false);
250
+ if (!result.ok) {
251
+ expectErrorCode(result.error, 'unknown_side_effect_scoring_dimension');
252
+ }
253
+ });
254
+
255
+ it('rejects a persona_memory side effect referencing an unbound persona', () => {
256
+ const fixture = cloneFixture();
257
+ patchPhase(fixture, 'guess', {
258
+ sideEffects: [
259
+ {
260
+ kind: 'persona_memory',
261
+ personaId: 'per_unbound00000000000',
262
+ op: 'write',
263
+ },
264
+ ],
265
+ });
266
+ const result = validateManifest(fixture);
267
+ expect(result.ok).toBe(false);
268
+ if (!result.ok) {
269
+ expectErrorCode(result.error, 'unknown_side_effect_persona_binding');
270
+ }
271
+ });
272
+
273
+ it('accepts a phase with valid scoring + inference side effects', () => {
274
+ const fixture = cloneFixture();
275
+ patchPhase(fixture, 'guess', {
276
+ sideEffects: [
277
+ {
278
+ kind: 'scoring',
279
+ dimension: 'accuracy',
280
+ value: 1,
281
+ source: 'submit',
282
+ },
283
+ {
284
+ kind: 'inference',
285
+ callKind: 'host_judge',
286
+ targetPath: '/session/inference/host_judge',
287
+ },
288
+ ],
289
+ });
290
+ const result = validateManifest(fixture);
291
+ expect(result.ok).toBe(true);
292
+ });
293
+ });
294
+
295
+ describe('validateManifest — multiple structural failures surface together', () => {
296
+ it('reports both a duplicate-role and an unknown-initial-phase error in one call', () => {
297
+ const fixture = cloneFixture();
298
+ fixture.personaBindings = [
299
+ { role: 'host', personaId: 'per_first0000000000000' },
300
+ { role: 'host', personaId: 'per_second000000000000' },
301
+ ];
302
+ fixture.workflow.initialPhase = 'nonexistent';
303
+ const result = validateManifest(fixture);
304
+ expect(result.ok).toBe(false);
305
+ if (!result.ok) {
306
+ expectErrorCode(result.error, 'duplicate_persona_role');
307
+ expectErrorCode(result.error, 'unknown_initial_phase');
308
+ }
309
+ });
310
+ });
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Manifest validation — schema parse + structural cross-field checks.
3
+ *
4
+ * Per the chunk-B1 spec: `validateManifest` runs the full Zod schema
5
+ * check, then layers structural validations the schema cannot express
6
+ * (cross-field references between phases / transitions, persona-binding
7
+ * uniqueness, scoring-award dimension references, reachability of every
8
+ * phase from `initialPhase`).
9
+ *
10
+ * Returns `Result<Manifest, readonly ValidationError[]>` so the caller
11
+ * branches once on the discriminant. Per the chunk-B1 trap: this is the
12
+ * *only* manifest-validation entrypoint; downstream consumers must not
13
+ * reach into `ManifestSchema.safeParse` directly. New checks land here,
14
+ * not in ad-hoc utilities sprinkled across services.
15
+ *
16
+ * The structural checks short-circuit only on errors that would make
17
+ * later checks meaningless (e.g. duplicate phase ids would mis-key the
18
+ * reachability traversal). The Zod parse always runs first; if the
19
+ * parse fails the structural checks are skipped because the manifest
20
+ * doesn't have a valid shape to traverse.
21
+ */
22
+
23
+ import { err, ok, type Result } from '@wibly/internal-shared';
24
+ import type { ZodIssue } from 'zod';
25
+
26
+ import {
27
+ ManifestSchema,
28
+ type AwardDefinition,
29
+ type ConcurrentOpportunity,
30
+ type Manifest,
31
+ type PersonaBinding,
32
+ type Workflow,
33
+ } from './manifest.js';
34
+ import type { ManifestPhase } from './phase.js';
35
+
36
+ // -----------------------------------------------------------------------------
37
+ // ValidationError — categorised, located, operator-readable
38
+ // -----------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Categorised manifest-validation failure.
42
+ *
43
+ * - `schema` — Zod-level shape failure. `path` /
44
+ * `message` come from a `ZodIssue`.
45
+ * - `unknown_initial_phase` — `workflow.initialPhase` doesn't
46
+ * appear in `workflow.phases`.
47
+ * - `duplicate_phase_id` — Two or more phases share an `id`.
48
+ * - `unknown_transition_target` — A phase transitions to an `id`
49
+ * that doesn't appear in
50
+ * `workflow.phases`.
51
+ * - `unreachable_phase` — A phase isn't reachable from
52
+ * `workflow.initialPhase` via the
53
+ * transition graph.
54
+ * - `duplicate_persona_role` — Two `personaBindings` share a
55
+ * `role`.
56
+ * - `unknown_opportunity_phase` — A `concurrentOpportunities` entry's
57
+ * `attachedToPhases` references an
58
+ * `id` not in `workflow.phases`.
59
+ * - `unknown_award_dimension` — A `scoring.awards` entry's
60
+ * `dimensionId` doesn't match any
61
+ * `scoring.dimensions[].id`.
62
+ */
63
+ export type ValidationErrorCode =
64
+ | 'schema'
65
+ | 'unknown_initial_phase'
66
+ | 'duplicate_phase_id'
67
+ | 'unknown_transition_target'
68
+ | 'unreachable_phase'
69
+ | 'duplicate_persona_role'
70
+ | 'unknown_opportunity_phase'
71
+ | 'unknown_award_dimension'
72
+ | 'unknown_side_effect_scoring_dimension'
73
+ | 'unknown_side_effect_persona_binding';
74
+
75
+ export type ValidationError = {
76
+ readonly code: ValidationErrorCode;
77
+ /**
78
+ * Dotted path into the manifest, e.g.
79
+ * `['workflow', 'phases', 2, 'transitions', 0, 'to']`. Mirrors
80
+ * `ZodIssue.path` so a downstream renderer can treat both error
81
+ * sources uniformly.
82
+ */
83
+ readonly path: readonly (string | number)[];
84
+ readonly message: string;
85
+ };
86
+
87
+ const fromZodIssue = (issue: ZodIssue): ValidationError => ({
88
+ code: 'schema',
89
+ path: [...issue.path],
90
+ message: issue.message,
91
+ });
92
+
93
+ // -----------------------------------------------------------------------------
94
+ // Structural checks
95
+ // -----------------------------------------------------------------------------
96
+
97
+ const checkPhaseIdsUnique = (
98
+ workflow: Workflow,
99
+ out: ValidationError[],
100
+ ): ReadonlySet<string> => {
101
+ const seen = new Set<string>();
102
+ const duplicates = new Set<string>();
103
+ for (let i = 0; i < workflow.phases.length; i += 1) {
104
+ const phase = workflow.phases[i];
105
+ if (!phase) continue;
106
+ if (seen.has(phase.id)) {
107
+ duplicates.add(phase.id);
108
+ out.push({
109
+ code: 'duplicate_phase_id',
110
+ path: ['workflow', 'phases', i, 'id'],
111
+ message: `duplicate phase id '${phase.id}'`,
112
+ });
113
+ } else {
114
+ seen.add(phase.id);
115
+ }
116
+ }
117
+ // The "valid id set" excludes duplicates so subsequent checks (e.g.
118
+ // transition-target resolution) don't accidentally rely on a
119
+ // duplicate id.
120
+ for (const dup of duplicates) seen.delete(dup);
121
+ return seen;
122
+ };
123
+
124
+ const checkInitialPhase = (
125
+ workflow: Workflow,
126
+ validIds: ReadonlySet<string>,
127
+ out: ValidationError[],
128
+ ): void => {
129
+ if (!validIds.has(workflow.initialPhase)) {
130
+ out.push({
131
+ code: 'unknown_initial_phase',
132
+ path: ['workflow', 'initialPhase'],
133
+ message: `initialPhase '${workflow.initialPhase}' is not declared in workflow.phases`,
134
+ });
135
+ }
136
+ };
137
+
138
+ const checkTransitionTargets = (
139
+ workflow: Workflow,
140
+ validIds: ReadonlySet<string>,
141
+ out: ValidationError[],
142
+ ): void => {
143
+ for (let i = 0; i < workflow.phases.length; i += 1) {
144
+ const phase = workflow.phases[i];
145
+ if (!phase) continue;
146
+ for (let j = 0; j < phase.transitions.length; j += 1) {
147
+ const transition = phase.transitions[j];
148
+ if (!transition) continue;
149
+ if (!validIds.has(transition.to)) {
150
+ out.push({
151
+ code: 'unknown_transition_target',
152
+ path: ['workflow', 'phases', i, 'transitions', j, 'to'],
153
+ message: `transition target '${transition.to}' is not a declared phase id`,
154
+ });
155
+ }
156
+ }
157
+ }
158
+ };
159
+
160
+ const checkReachability = (
161
+ workflow: Workflow,
162
+ validIds: ReadonlySet<string>,
163
+ out: ValidationError[],
164
+ ): void => {
165
+ // Skip reachability if the initial phase itself is unknown — every
166
+ // phase would trivially appear unreachable, and the operator already
167
+ // has the `unknown_initial_phase` error to act on.
168
+ if (!validIds.has(workflow.initialPhase)) return;
169
+
170
+ const adjacency = new Map<string, readonly string[]>();
171
+ for (const phase of workflow.phases) {
172
+ if (!validIds.has(phase.id)) continue;
173
+ adjacency.set(
174
+ phase.id,
175
+ phase.transitions.map((t) => t.to).filter((to) => validIds.has(to)),
176
+ );
177
+ }
178
+
179
+ const reached = new Set<string>();
180
+ const stack: string[] = [workflow.initialPhase];
181
+ while (stack.length > 0) {
182
+ const cur = stack.pop();
183
+ if (cur === undefined) break;
184
+ if (reached.has(cur)) continue;
185
+ reached.add(cur);
186
+ const nexts = adjacency.get(cur);
187
+ if (!nexts) continue;
188
+ for (const next of nexts) {
189
+ if (!reached.has(next)) stack.push(next);
190
+ }
191
+ }
192
+
193
+ for (let i = 0; i < workflow.phases.length; i += 1) {
194
+ const phase = workflow.phases[i];
195
+ if (!phase) continue;
196
+ if (!validIds.has(phase.id)) continue;
197
+ if (!reached.has(phase.id)) {
198
+ out.push({
199
+ code: 'unreachable_phase',
200
+ path: ['workflow', 'phases', i, 'id'],
201
+ message: `phase '${phase.id}' is unreachable from initialPhase '${workflow.initialPhase}'`,
202
+ });
203
+ }
204
+ }
205
+ };
206
+
207
+ const checkPersonaRolesUnique = (
208
+ bindings: readonly PersonaBinding[],
209
+ out: ValidationError[],
210
+ ): void => {
211
+ const seen = new Set<string>();
212
+ for (let i = 0; i < bindings.length; i += 1) {
213
+ const binding = bindings[i];
214
+ if (!binding) continue;
215
+ if (seen.has(binding.role)) {
216
+ out.push({
217
+ code: 'duplicate_persona_role',
218
+ path: ['personaBindings', i, 'role'],
219
+ message: `duplicate persona role '${binding.role}'`,
220
+ });
221
+ } else {
222
+ seen.add(binding.role);
223
+ }
224
+ }
225
+ };
226
+
227
+ const checkOpportunityPhases = (
228
+ opportunities: readonly ConcurrentOpportunity[],
229
+ validIds: ReadonlySet<string>,
230
+ out: ValidationError[],
231
+ ): void => {
232
+ for (let i = 0; i < opportunities.length; i += 1) {
233
+ const opp = opportunities[i];
234
+ if (!opp) continue;
235
+ for (let j = 0; j < opp.attachedToPhases.length; j += 1) {
236
+ const phaseId = opp.attachedToPhases[j];
237
+ if (phaseId === undefined) continue;
238
+ if (!validIds.has(phaseId)) {
239
+ out.push({
240
+ code: 'unknown_opportunity_phase',
241
+ path: ['concurrentOpportunities', i, 'attachedToPhases', j],
242
+ message: `concurrent opportunity '${opp.id}' attaches to unknown phase '${phaseId}'`,
243
+ });
244
+ }
245
+ }
246
+ }
247
+ };
248
+
249
+ const checkAwardDimensions = (
250
+ awards: readonly AwardDefinition[],
251
+ dimensionIds: ReadonlySet<string>,
252
+ out: ValidationError[],
253
+ ): void => {
254
+ for (let i = 0; i < awards.length; i += 1) {
255
+ const award = awards[i];
256
+ if (!award) continue;
257
+ if (!dimensionIds.has(award.dimensionId)) {
258
+ out.push({
259
+ code: 'unknown_award_dimension',
260
+ path: ['scoring', 'awards', i, 'dimensionId'],
261
+ message: `award '${award.id}' references unknown scoring dimension '${award.dimensionId}'`,
262
+ });
263
+ }
264
+ }
265
+ };
266
+
267
+ /**
268
+ * Chunk B8c — cross-check `phase.sideEffects` entries.
269
+ *
270
+ * - `scoring` side effects reference a `scoring.dimensions[].id`
271
+ * (mirrors the {@link checkAwardDimensions} check, just shifted
272
+ * to the per-phase side-effect surface).
273
+ * - `persona_memory` side effects reference a persona id that
274
+ * appears in `personaBindings[].personaId`. If the binding is
275
+ * missing the Runtime would log an `unbound_persona` event at
276
+ * dispatch time; catching it at manifest validation surfaces
277
+ * the error at publish time instead.
278
+ *
279
+ * `state_write` and `inference` side effects have no cross-field
280
+ * dependencies the manifest layer can check — the JSON-Patch ops are
281
+ * validated against per-Experience state schemas at apply time, and
282
+ * `inference.callKind` is enum-pinned at the schema level already.
283
+ */
284
+ const checkPhaseSideEffects = (
285
+ phases: readonly ManifestPhase[],
286
+ dimensionIds: ReadonlySet<string>,
287
+ personaIds: ReadonlySet<string>,
288
+ out: ValidationError[],
289
+ ): void => {
290
+ for (let i = 0; i < phases.length; i += 1) {
291
+ const phase = phases[i];
292
+ if (!phase) continue;
293
+ for (let j = 0; j < phase.sideEffects.length; j += 1) {
294
+ const effect = phase.sideEffects[j];
295
+ if (!effect) continue;
296
+ if (effect.kind === 'scoring') {
297
+ if (!dimensionIds.has(effect.dimension)) {
298
+ out.push({
299
+ code: 'unknown_side_effect_scoring_dimension',
300
+ path: [
301
+ 'workflow',
302
+ 'phases',
303
+ i,
304
+ 'sideEffects',
305
+ j,
306
+ 'dimension',
307
+ ],
308
+ message: `phase '${phase.id}' scoring side effect references unknown dimension '${effect.dimension}'`,
309
+ });
310
+ }
311
+ } else if (effect.kind === 'persona_memory') {
312
+ if (!personaIds.has(effect.personaId)) {
313
+ out.push({
314
+ code: 'unknown_side_effect_persona_binding',
315
+ path: [
316
+ 'workflow',
317
+ 'phases',
318
+ i,
319
+ 'sideEffects',
320
+ j,
321
+ 'personaId',
322
+ ],
323
+ message: `phase '${phase.id}' persona_memory side effect references unbound persona '${effect.personaId}'`,
324
+ });
325
+ }
326
+ }
327
+ }
328
+ }
329
+ };
330
+
331
+ // -----------------------------------------------------------------------------
332
+ // Public entrypoint
333
+ // -----------------------------------------------------------------------------
334
+
335
+ /**
336
+ * Validate a candidate manifest blob.
337
+ *
338
+ * Returns `Ok(manifest)` only if the Zod parse succeeds AND every
339
+ * structural check passes. On any failure, returns `Err([...errors])`
340
+ * with at least one entry — schema errors and structural errors are
341
+ * reported together so a UI can surface them in one round-trip.
342
+ *
343
+ * Note: when the Zod parse fails the structural checks are skipped
344
+ * (the manifest doesn't have a parseable shape to traverse). Schema
345
+ * errors are emitted in source order from the underlying Zod issue
346
+ * list; structural errors follow in declaration order.
347
+ */
348
+ export const validateManifest = (
349
+ candidate: unknown,
350
+ ): Result<Manifest, readonly ValidationError[]> => {
351
+ const parsed = ManifestSchema.safeParse(candidate);
352
+ if (!parsed.success) {
353
+ return err(parsed.error.issues.map(fromZodIssue));
354
+ }
355
+
356
+ const manifest = parsed.data;
357
+ const errors: ValidationError[] = [];
358
+
359
+ const validIds = checkPhaseIdsUnique(manifest.workflow, errors);
360
+ checkInitialPhase(manifest.workflow, validIds, errors);
361
+ checkTransitionTargets(manifest.workflow, validIds, errors);
362
+ checkReachability(manifest.workflow, validIds, errors);
363
+ checkPersonaRolesUnique(manifest.personaBindings, errors);
364
+ checkOpportunityPhases(
365
+ manifest.concurrentOpportunities,
366
+ validIds,
367
+ errors,
368
+ );
369
+ const dimensionIds = new Set(
370
+ manifest.scoring.dimensions.map((d) => d.id),
371
+ );
372
+ checkAwardDimensions(manifest.scoring.awards, dimensionIds, errors);
373
+ const personaIds = new Set(
374
+ manifest.personaBindings.map((b) => b.personaId),
375
+ );
376
+ checkPhaseSideEffects(
377
+ manifest.workflow.phases,
378
+ dimensionIds,
379
+ personaIds,
380
+ errors,
381
+ );
382
+
383
+ if (errors.length > 0) return err(errors);
384
+ return ok(manifest);
385
+ };