@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.
- package/CHANGELOG.md +6 -0
- package/package.json +25 -0
- package/src/content-rating.test.ts +26 -0
- package/src/content-rating.ts +51 -0
- package/src/fixtures.ts +229 -0
- package/src/index.ts +126 -0
- package/src/manifest.test.ts +351 -0
- package/src/manifest.ts +607 -0
- package/src/phase.test.ts +227 -0
- package/src/phase.ts +304 -0
- package/src/portal-display.test.ts +54 -0
- package/src/portal-display.ts +40 -0
- package/src/validate.test.ts +310 -0
- package/src/validate.ts +385 -0
|
@@ -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
|
+
});
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
};
|