@wibly/internal-manifest 0.1.1 → 0.1.2

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # `@wibly/internal-manifest` — Changelog
2
2
 
3
+ ## 0.1.2 — 2026-06-08
4
+
5
+ ### Added
6
+
7
+ - `workflow.abortPhaseId` (optional) — the phase a `host.abort` jumps
8
+ to. When omitted the Runtime aborts to the last declared phase.
9
+ Validated against the declared phase ids (`unknown_abort_phase`).
10
+
3
11
  ## 0.1.1 — 2026-05-30
4
12
 
5
13
  Initial public npm release. Internal runtime dependency of `@wibly/sdk` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wibly/internal-manifest",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Wibly @wibly/internal-manifest",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -17,8 +17,8 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@wibly/internal-protocol": "0.1.1",
21
- "@wibly/internal-shared": "0.1.1",
20
+ "@wibly/internal-protocol": "0.1.2",
21
+ "@wibly/internal-shared": "0.1.2",
22
22
  "zod": "^3.25.76"
23
23
  },
24
24
  "peerDependencies": {}
package/src/fixtures.ts CHANGED
@@ -146,18 +146,22 @@ export const validManifestFixture: Manifest = {
146
146
  ],
147
147
  promptSlots: {
148
148
  experienceSystem: 'You are running Hello World, a tiny test Experience.',
149
- callTypes: {
150
- host_open_phase: 'Open the next phase with one short sentence.',
149
+ callTemplates: {
150
+ host_open_phase: {
151
+ prompt: 'Open the next phase with one short sentence.',
152
+ qualityTier: 'standard',
153
+ fallbackResponse: 'Welcome — let us begin.',
154
+ },
151
155
  host_recap: {
152
- template: 'Recap the round for {playerCount} players.',
153
- vars: ['playerCount'],
156
+ prompt: {
157
+ template: 'Recap the round for {playerCount} players.',
158
+ vars: ['playerCount'],
159
+ },
160
+ qualityTier: 'standard',
161
+ fallbackResponse: 'Thanks for playing.',
154
162
  },
155
163
  },
156
164
  },
157
- fallbackResponses: {
158
- host_open_phase: 'Welcome — let us begin.',
159
- host_recap: 'Thanks for playing.',
160
- },
161
165
  widgetDependencies: ['Lobby', 'GuessInput', 'Leaderboard'],
162
166
  contentRating: {
163
167
  tier: 'none',
@@ -205,12 +209,16 @@ export const cloneFixture = (): Manifest =>
205
209
  */
206
210
  export const cloneFixtureWithInferenceSideEffect = (): Manifest => {
207
211
  const fixture = cloneFixture();
212
+ fixture.promptSlots.callTemplates.host_judge = {
213
+ prompt: 'Judge submissions.',
214
+ qualityTier: 'standard',
215
+ fallbackResponse: 'tie',
216
+ };
208
217
  patchPhase(fixture, 'guess', {
209
218
  sideEffects: [
210
219
  {
211
220
  kind: 'inference',
212
- callKind: 'host_judge',
213
- qualityTier: 'standard',
221
+ templateId: 'host_judge',
214
222
  targetPath: '/session/inference/host_judge',
215
223
  },
216
224
  ],
package/src/index.ts CHANGED
@@ -51,13 +51,12 @@ export {
51
51
  AwardCriterionSchema,
52
52
  AwardDefinitionSchema,
53
53
  BundleContextSlotsSchema,
54
- CallKindSchema,
54
+ CallTemplateSchema,
55
55
  ConcurrentOpportunitySchema,
56
56
  ContentRatingAudienceSchema,
57
57
  ContentRatingFloorSchema,
58
58
  ContentRatingSchema,
59
59
  ExperienceContentRatingTierSchema,
60
- FallbackResponsesSchema,
61
60
  GameplayImageSchema,
62
61
  GameplayVideoSchema,
63
62
  InferenceEnvelopeSchema,
@@ -74,6 +73,9 @@ export {
74
73
  PromptSlotsSchema,
75
74
  PromptSlotValueSchema,
76
75
  QualityTierSchema,
76
+ PLATFORM_CLASSIFY_TEMPLATE_ID,
77
+ getCallTemplate,
78
+ isPlatformClassifyTemplate,
77
79
  ScoringAggregatorSchema,
78
80
  ScoringDimensionSchema,
79
81
  ScoringSchema,
@@ -82,13 +84,12 @@ export {
82
84
  type AwardCriterion,
83
85
  type AwardDefinition,
84
86
  type BundleContextSlots,
85
- type CallKind,
87
+ type CallTemplate,
86
88
  type ConcurrentOpportunity,
87
89
  type ContentRating,
88
90
  type ContentRatingAudience,
89
91
  type ContentRatingFloor,
90
92
  type ExperienceContentRatingTier,
91
- type FallbackResponses,
92
93
  type GameplayImage,
93
94
  type GameplayVideo,
94
95
  type InferenceEnvelope,
@@ -109,10 +110,24 @@ export {
109
110
  type Scoring,
110
111
  type ScoringAggregator,
111
112
  type ScoringDimension,
113
+ type SessionSettings,
112
114
  type StateSchema,
113
115
  type Workflow,
114
116
  } from './manifest.js';
115
117
 
118
+ export {
119
+ resolveSessionSettings,
120
+ SessionSettingBooleanDefinitionSchema,
121
+ SessionSettingDefinitionSchema,
122
+ SessionSettingEnumDefinitionSchema,
123
+ SessionSettingNumberDefinitionSchema,
124
+ SessionSettingsSchema,
125
+ validateSessionSettingsOverrides,
126
+ type ResolvedSessionSettings,
127
+ type SessionSettingDefinition,
128
+ type SessionSettingsValidationResult,
129
+ } from './session-settings.js';
130
+
116
131
  export {
117
132
  validateManifest,
118
133
  type ValidationError,
@@ -138,13 +138,18 @@ describe('PromptSlotValueSchema / PromptSlotsSchema', () => {
138
138
  ).toBe(false);
139
139
  });
140
140
 
141
- it('rejects a callTypes key outside the known callKind set', () => {
141
+ it('accepts callTemplates with free-form ids', () => {
142
142
  expect(
143
143
  PromptSlotsSchema.safeParse({
144
144
  experienceSystem: 'sys',
145
- callTypes: { unknown_kind: 'hello' },
145
+ callTemplates: {
146
+ my_custom_template: {
147
+ prompt: 'hello',
148
+ qualityTier: 'standard',
149
+ },
150
+ },
146
151
  }).success,
147
- ).toBe(false);
152
+ ).toBe(true);
148
153
  });
149
154
  });
150
155
 
@@ -266,7 +271,6 @@ describe('ManifestSchema — required fields', () => {
266
271
  'scoring',
267
272
  'lifecyclePolicies',
268
273
  'promptSlots',
269
- 'fallbackResponses',
270
274
  'widgetDependencies',
271
275
  'contentRating',
272
276
  'portalMetadata',
package/src/manifest.ts CHANGED
@@ -7,8 +7,7 @@
7
7
  * Session:
8
8
  *
9
9
  * - identity & ownership (`id`, `version`, `tenant`, `creator`),
10
- * - safety / cost shape (`inferenceEnvelope`, `contentRating`,
11
- * `fallbackResponses`),
10
+ * - safety / cost shape (`inferenceEnvelope`, `contentRating`),
12
11
  * - workflow (`workflow.phases`, `concurrentOpportunities`,
13
12
  * `lifecyclePolicies`),
14
13
  * - state shape (`stateSchema`),
@@ -32,13 +31,17 @@
32
31
  import { z } from 'zod';
33
32
 
34
33
  import {
35
- CallKindSchema,
36
34
  CollectionRuleSchema,
37
35
  InputSetSchema,
38
36
  PhaseSchema,
39
37
  } from './phase.js';
38
+ import { SessionSettingsSchema } from './session-settings.js';
40
39
 
41
- export { CallKindSchema };
40
+ /** Platform-internal template id for the safety classifier (not in manifest). */
41
+ export const PLATFORM_CLASSIFY_TEMPLATE_ID = 'classify';
42
+
43
+ export const isPlatformClassifyTemplate = (templateId: string): boolean =>
44
+ templateId === PLATFORM_CLASSIFY_TEMPLATE_ID;
42
45
 
43
46
  // -----------------------------------------------------------------------------
44
47
  // JSON value (used for state-schema slices and scoring payloads)
@@ -194,6 +197,14 @@ export const StateSchemaSchema = z.object({
194
197
  export const WorkflowSchema = z.object({
195
198
  initialPhase: z.string().min(1),
196
199
  phases: z.array(PhaseSchema).min(1),
200
+ /**
201
+ * Phase the platform jumps to on `host.abort` (a universal mid-game
202
+ * abort, handled by the Runtime — games need NO per-phase abort wiring).
203
+ * Optional: when omitted, the Runtime aborts to the LAST declared phase
204
+ * (conventionally the final/results screen). Set this only to override
205
+ * that default.
206
+ */
207
+ abortPhaseId: z.string().min(1).optional(),
197
208
  });
198
209
 
199
210
  // -----------------------------------------------------------------------------
@@ -360,43 +371,27 @@ export const PromptSlotValueSchema = z.union([
360
371
  ]);
361
372
 
362
373
  /**
363
- * The known set of `callKind`s the prompt composer (chunk B4) maps to
364
- * the eight-layer prompt. Listed in
365
- * `docs/conventions/prompt-composition.md`. Every callKind that appears
366
- * here MUST appear in that document; new callKinds land in the chunk
367
- * that introduces them with the doc updated in the same commit.
368
- *
369
- * Defined in `phase.ts` (re-exported above) so the chunk-B8c
370
- * `WorkflowSideEffect.inference.callKind` schema can reference it
371
- * without a circular import.
374
+ * One creator-defined LLM call template. Game code invokes by
375
+ * `templateId` (the record key); Studio edits prompt, schema, tier,
376
+ * and fallback copy without redeploying bundles.
372
377
  */
378
+ export const CallTemplateSchema = z.object({
379
+ prompt: PromptSlotValueSchema,
380
+ returnSchema: JsonValueSchema.optional(),
381
+ qualityTier: QualityTierSchema.default('standard'),
382
+ fallbackResponse: z.string().min(1).optional(),
383
+ });
373
384
 
374
385
  /**
375
386
  * Manifest-supplied prompt content. `experienceSystem` is the layer-3
376
- * Experience system message; `callTypes` keys per `callKind` populate
377
- * layer 4; `outputSchemas` populates layer 7 with a JSON-shaped schema
378
- * the Gateway renders. Per the prompt-composition doc, the platform
379
- * supplies the structural layers (1, 4-structural, 5, 6, 8) and the
380
- * Persona Service supplies layer 2; everything below is the manifest's
381
- * contribution.
387
+ * Experience system message; `callTemplates` supplies layer 4 (call
388
+ * instructions) and optional layer-7 return schemas per template id.
382
389
  */
383
390
  export const PromptSlotsSchema = z.object({
384
391
  experienceSystem: PromptSlotValueSchema,
385
- callTypes: z.record(CallKindSchema, PromptSlotValueSchema),
386
- outputSchemas: z.record(CallKindSchema, JsonValueSchema).optional(),
392
+ callTemplates: z.record(z.string().min(1), CallTemplateSchema),
387
393
  });
388
394
 
389
- // -----------------------------------------------------------------------------
390
- // Fallback responses
391
- // -----------------------------------------------------------------------------
392
-
393
- /**
394
- * Pre-written copy the Runtime emits when an inference call fails or
395
- * the safety pipeline blocks the model output. Keyed per `callKind` so
396
- * a host_judge fallback is distinct from a host_recap fallback.
397
- */
398
- export const FallbackResponsesSchema = z.record(CallKindSchema, z.string().min(1));
399
-
400
395
  // -----------------------------------------------------------------------------
401
396
  // Content rating
402
397
  // -----------------------------------------------------------------------------
@@ -554,10 +549,14 @@ export const ManifestSchema = z.object({
554
549
  scoring: ScoringSchema,
555
550
  lifecyclePolicies: z.array(LifecyclePolicySchema),
556
551
  promptSlots: PromptSlotsSchema,
557
- fallbackResponses: FallbackResponsesSchema,
558
552
  widgetDependencies: z.array(z.string().min(1)),
559
553
  contentRating: ContentRatingSchema,
560
554
  portalMetadata: PortalMetadataSchema,
555
+ /**
556
+ * Host-configurable knobs chosen at session provision (Portal launch
557
+ * dialog). Defaults are applied when the host omits a value.
558
+ */
559
+ sessionSettings: SessionSettingsSchema.optional(),
561
560
  /** Reserved for Phase 2; current Runtime ignores these. */
562
561
  bundleCompatible: z.boolean().default(false).optional(),
563
562
  /** Reserved for Phase 2; current Runtime ignores these. */
@@ -590,9 +589,14 @@ export type LifecycleSituation = z.infer<typeof LifecycleSituationSchema>;
590
589
  export type LifecycleAction = z.infer<typeof LifecycleActionSchema>;
591
590
  export type LifecyclePolicy = z.infer<typeof LifecyclePolicySchema>;
592
591
  export type PromptSlotValue = z.infer<typeof PromptSlotValueSchema>;
593
- export type CallKind = z.infer<typeof CallKindSchema>;
592
+ export type CallTemplate = z.infer<typeof CallTemplateSchema>;
594
593
  export type PromptSlots = z.infer<typeof PromptSlotsSchema>;
595
- export type FallbackResponses = z.infer<typeof FallbackResponsesSchema>;
594
+
595
+ export const getCallTemplate = (
596
+ manifest: Manifest,
597
+ templateId: string,
598
+ ): CallTemplate | undefined =>
599
+ manifest.promptSlots.callTemplates[templateId];
596
600
  export type ContentRatingFloor = z.infer<typeof ContentRatingFloorSchema>;
597
601
  export type ContentRatingAudience = z.infer<typeof ContentRatingAudienceSchema>;
598
602
  export type ExperienceContentRatingTier = z.infer<
@@ -603,5 +607,6 @@ export type OccasionTag = z.infer<typeof OccasionTagSchema>;
603
607
  export type GameplayImage = z.infer<typeof GameplayImageSchema>;
604
608
  export type GameplayVideo = z.infer<typeof GameplayVideoSchema>;
605
609
  export type PortalMetadata = z.infer<typeof PortalMetadataSchema>;
610
+ export type SessionSettings = z.infer<typeof SessionSettingsSchema>;
606
611
  export type BundleContextSlots = z.infer<typeof BundleContextSlotsSchema>;
607
612
  export type Manifest = z.infer<typeof ManifestSchema>;
package/src/phase.test.ts CHANGED
@@ -49,6 +49,10 @@ describe('CollectionRuleSchema', () => {
49
49
  expect(CollectionRuleSchema.safeParse({ kind: 'all_respond' }).success).toBe(
50
50
  true,
51
51
  );
52
+ expect(
53
+ CollectionRuleSchema.safeParse({ kind: 'all_respond_or_timeout', ms: 45_000 })
54
+ .success,
55
+ ).toBe(true);
52
56
  expect(
53
57
  CollectionRuleSchema.safeParse({ kind: 'first_respond', count: 3 })
54
58
  .success,
@@ -59,6 +63,13 @@ describe('CollectionRuleSchema', () => {
59
63
  expect(CollectionRuleSchema.safeParse({ kind: 'manual' }).success).toBe(
60
64
  true,
61
65
  );
66
+ expect(
67
+ CollectionRuleSchema.safeParse({
68
+ kind: 'after_tts_completes',
69
+ timeout: 60_000,
70
+ buffer: 2_000,
71
+ }).success,
72
+ ).toBe(true);
62
73
  });
63
74
 
64
75
  it('rejects a zero-ms timeout (would deadlock the Runtime)', () => {
@@ -143,7 +154,7 @@ describe('PhaseSchema', () => {
143
154
  ...baseline,
144
155
  sideEffects: [
145
156
  { kind: 'state_write', patches: [{ op: 'replace', path: '/x', value: 1 }] },
146
- { kind: 'inference', callKind: 'host_judge' },
157
+ { kind: 'inference', templateId: 'host_judge' },
147
158
  ],
148
159
  });
149
160
  expect(result.success).toBe(true);
@@ -159,6 +170,26 @@ describe('PhaseSchema', () => {
159
170
  });
160
171
  expect(result.success).toBe(false);
161
172
  });
173
+
174
+ it('accepts optional leadInSpeech variants', () => {
175
+ const result = PhaseSchema.safeParse({
176
+ ...baseline,
177
+ leadInSpeech: ['Welcome.', 'Let us begin.'],
178
+ });
179
+ expect(result.success).toBe(true);
180
+ if (result.success) {
181
+ expect(result.data.leadInSpeech).toEqual([
182
+ 'Welcome.',
183
+ 'Let us begin.',
184
+ ]);
185
+ }
186
+ });
187
+
188
+ it('rejects empty leadInSpeech lines', () => {
189
+ expect(
190
+ PhaseSchema.safeParse({ ...baseline, leadInSpeech: [''] }).success,
191
+ ).toBe(false);
192
+ });
162
193
  });
163
194
 
164
195
  describe('WorkflowSideEffectSchema (chunk B8c)', () => {
@@ -181,25 +212,24 @@ describe('WorkflowSideEffectSchema (chunk B8c)', () => {
181
212
  it('accepts an inference effect with a targetPath JSON-Pointer', () => {
182
213
  const result = WorkflowSideEffectSchema.safeParse({
183
214
  kind: 'inference',
184
- callKind: 'host_judge',
185
- qualityTier: 'standard',
215
+ templateId: 'host_judge',
186
216
  targetPath: '/session/inference/host_judge',
187
217
  });
188
218
  expect(result.success).toBe(true);
189
219
  });
190
220
 
191
- it('rejects an inference effect whose callKind is outside the enum', () => {
221
+ it('accepts any non-empty templateId string', () => {
192
222
  const result = WorkflowSideEffectSchema.safeParse({
193
223
  kind: 'inference',
194
- callKind: 'totally_made_up',
224
+ templateId: 'totally_made_up',
195
225
  });
196
- expect(result.success).toBe(false);
226
+ expect(result.success).toBe(true);
197
227
  });
198
228
 
199
229
  it('rejects an inference effect with a malformed targetPath', () => {
200
230
  const result = WorkflowSideEffectSchema.safeParse({
201
231
  kind: 'inference',
202
- callKind: 'host_judge',
232
+ templateId: 'host_judge',
203
233
  targetPath: 'not-a-pointer',
204
234
  });
205
235
  expect(result.success).toBe(false);
package/src/phase.ts CHANGED
@@ -70,6 +70,15 @@ export const InputSetSchema = z.object({
70
70
  */
71
71
  export const CollectionRuleSchema = z.discriminatedUnion('kind', [
72
72
  z.object({ kind: z.literal('all_respond') }),
73
+ z.object({
74
+ kind: z.literal('all_respond_or_timeout'),
75
+ ms: z.number().int().positive(),
76
+ }),
77
+ z.object({
78
+ kind: z.literal('after_tts_completes'),
79
+ timeout: z.number().int().positive(),
80
+ buffer: z.number().int().nonnegative(),
81
+ }),
73
82
  z.object({
74
83
  kind: z.literal('first_respond'),
75
84
  count: z.number().int().positive(),
@@ -97,35 +106,6 @@ export const TransitionSchema = z.object({
97
106
  // Phase side-effect declarations (chunk B8c)
98
107
  // -----------------------------------------------------------------------------
99
108
 
100
- /**
101
- * Quality-tier enum lifted here (chunk B8c) so the side-effect schema
102
- * can carry an optional `qualityTier` without requiring a circular
103
- * import. Mirrors `QualityTierSchema` re-exported from `manifest.ts`.
104
- */
105
- const PhaseSideEffectQualityTierSchema = z.enum([
106
- 'fast',
107
- 'standard',
108
- 'premium',
109
- 'creative',
110
- ]);
111
-
112
- /**
113
- * Known `callKind` set lifted here for the same reason as
114
- * {@link PhaseSideEffectQualityTierSchema}. Kept exactly in lock-step
115
- * with `CallKindSchema` in `manifest.ts`; `manifest.ts` imports this
116
- * symbol so they cannot drift.
117
- */
118
- export const CallKindSchema = z.enum([
119
- 'host_open_phase',
120
- 'host_judge',
121
- 'host_resolve',
122
- 'host_recap',
123
- 'judge_funniness',
124
- 'narrate_event',
125
- 'classify',
126
- 'compose_clue',
127
- ]);
128
-
129
109
  /**
130
110
  * RFC 6901 JSON-Pointer validator. Accepts the empty pointer `""`
131
111
  * (root) and any pointer starting with `/`. We do NOT enforce
@@ -161,17 +141,16 @@ export const StateWriteSideEffectSchema = z.object({
161
141
  });
162
142
 
163
143
  /**
164
- * `inference` side effect — dispatch a Gateway call. `callKind` keys
165
- * the prompt-composition layer 4 entry; `slots` carries per-call
166
- * variables for template interpolation. `targetPath` is an
167
- * RFC 6901 JSON-Pointer the Runtime writes the inference output to
168
- * once the Gateway returns (default at handler time:
169
- * `/session/inference/<callKind>`).
144
+ * `inference` side effect — dispatch a Gateway call. `templateId`
145
+ * keys a manifest `callTemplates` entry; `slots` carries per-call
146
+ * variables for template interpolation. Quality tier and return schema
147
+ * come from the template definition. `targetPath` is an RFC 6901
148
+ * JSON-Pointer the Runtime writes the inference output to once the
149
+ * Gateway returns (default: `/session/inference/<templateId>`).
170
150
  */
171
151
  export const InferenceSideEffectSchema = z.object({
172
152
  kind: z.literal('inference'),
173
- callKind: CallKindSchema,
174
- qualityTier: PhaseSideEffectQualityTierSchema.optional(),
153
+ templateId: z.string().min(1),
175
154
  slots: z.record(z.unknown()).optional(),
176
155
  targetPath: JsonPointerSchema.optional(),
177
156
  });
@@ -254,7 +233,16 @@ export type WorkflowSideEffect = z.infer<typeof WorkflowSideEffectSchema>;
254
233
  * fires the sandbox `onRoundEnd` hook on phase EXIT (before
255
234
  * the swap). Used by multi-round Experiences (Rashomon) to
256
235
  * trigger end-of-round bookkeeping in the bundle.
236
+ * - `leadInSpeech?: string[]` — optional static host-voice lines
237
+ * spoken on phase enter. Omitted or empty skips platform TTS.
238
+ * When multiple lines are declared the runtime picks one at
239
+ * random (deterministic per transition). The number of variants
240
+ * is unbounded — Experiences may ship large pools of filler lines
241
+ * to keep the host fresh across many rounds. Richer (dynamic)
242
+ * lead-ins still belong in the sandbox `onPhaseEnter` hook.
257
243
  */
244
+ const LEAD_IN_SPEECH_MAX_CHARS = 500;
245
+
258
246
  export const PhaseSchema = z.lazy(() =>
259
247
  z.object({
260
248
  id: z.string().min(1),
@@ -265,6 +253,9 @@ export const PhaseSchema = z.lazy(() =>
265
253
  subPhases: z.record(z.string().min(1), PhaseSchema).optional(),
266
254
  computeScoreOnEnter: z.boolean().optional(),
267
255
  endsRound: z.boolean().optional(),
256
+ leadInSpeech: z
257
+ .array(z.string().min(1).max(LEAD_IN_SPEECH_MAX_CHARS))
258
+ .optional(),
268
259
  }),
269
260
  ) as z.ZodType<ManifestPhase>;
270
261
 
@@ -301,4 +292,5 @@ export type ManifestPhase = {
301
292
  readonly subPhases?: Readonly<Record<string, ManifestPhase>>;
302
293
  readonly computeScoreOnEnter?: boolean;
303
294
  readonly endsRound?: boolean;
295
+ readonly leadInSpeech?: readonly string[];
304
296
  };
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ resolveSessionSettings,
5
+ validateSessionSettingsOverrides,
6
+ } from './session-settings.js';
7
+
8
+ describe('session-settings', () => {
9
+ const definitions = [
10
+ {
11
+ key: 'totalRounds',
12
+ type: 'number' as const,
13
+ label: 'Rounds',
14
+ min: 3,
15
+ max: 12,
16
+ default: 8,
17
+ },
18
+ {
19
+ key: 'hardMode',
20
+ type: 'boolean' as const,
21
+ label: 'Hard mode',
22
+ default: false,
23
+ },
24
+ ];
25
+
26
+ it('resolves defaults when overrides are omitted', () => {
27
+ expect(resolveSessionSettings(definitions, undefined)).toEqual({
28
+ totalRounds: 8,
29
+ hardMode: false,
30
+ });
31
+ });
32
+
33
+ it('validates in-range overrides', () => {
34
+ const result = validateSessionSettingsOverrides(definitions, {
35
+ totalRounds: 10,
36
+ hardMode: true,
37
+ });
38
+ expect(result.ok).toBe(true);
39
+ if (result.ok) {
40
+ expect(result.settings).toEqual({ totalRounds: 10, hardMode: true });
41
+ }
42
+ });
43
+
44
+ it('rejects unknown keys', () => {
45
+ const result = validateSessionSettingsOverrides(definitions, {
46
+ mystery: true,
47
+ });
48
+ expect(result.ok).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Host-configurable session settings declared in the Experience manifest.
3
+ *
4
+ * Settings are authored in Studio, chosen at provision time (Portal launch
5
+ * dialog), validated against manifest definitions, and written to
6
+ * `/session/settings` before sandbox hooks run.
7
+ */
8
+
9
+ import { z } from 'zod';
10
+
11
+ import type { JsonValue } from './manifest.js';
12
+
13
+ const SettingKeySchema = z
14
+ .string()
15
+ .regex(/^[a-z][a-zA-Z0-9_]*$/, 'setting key must be camelCase');
16
+
17
+ const SessionSettingDefinitionBaseSchema = z.object({
18
+ key: SettingKeySchema,
19
+ label: z.string().min(1),
20
+ description: z.string().optional(),
21
+ });
22
+
23
+ export const SessionSettingNumberDefinitionSchema =
24
+ SessionSettingDefinitionBaseSchema.extend({
25
+ type: z.literal('number'),
26
+ default: z.number(),
27
+ min: z.number().optional(),
28
+ max: z.number().optional(),
29
+ step: z.number().positive().optional(),
30
+ });
31
+
32
+ export const SessionSettingBooleanDefinitionSchema =
33
+ SessionSettingDefinitionBaseSchema.extend({
34
+ type: z.literal('boolean'),
35
+ default: z.boolean(),
36
+ });
37
+
38
+ export const SessionSettingEnumDefinitionSchema =
39
+ SessionSettingDefinitionBaseSchema.extend({
40
+ type: z.literal('enum'),
41
+ options: z
42
+ .array(
43
+ z.object({
44
+ value: z.string().min(1),
45
+ label: z.string().min(1),
46
+ }),
47
+ )
48
+ .min(1),
49
+ default: z.string().min(1),
50
+ });
51
+
52
+ export const SessionSettingDefinitionSchema = z.discriminatedUnion('type', [
53
+ SessionSettingNumberDefinitionSchema,
54
+ SessionSettingBooleanDefinitionSchema,
55
+ SessionSettingEnumDefinitionSchema,
56
+ ]);
57
+
58
+ export const SessionSettingsSchema = z.object({
59
+ definitions: z.array(SessionSettingDefinitionSchema).default([]),
60
+ });
61
+
62
+ export type SessionSettingDefinition = z.infer<
63
+ typeof SessionSettingDefinitionSchema
64
+ >;
65
+ export type SessionSettings = z.infer<typeof SessionSettingsSchema>;
66
+ export type ResolvedSessionSettings = Readonly<Record<string, JsonValue>>;
67
+
68
+ const definitionByKey = (
69
+ definitions: readonly SessionSettingDefinition[],
70
+ ): Map<string, SessionSettingDefinition> => {
71
+ const map = new Map<string, SessionSettingDefinition>();
72
+ for (const def of definitions) {
73
+ map.set(def.key, def);
74
+ }
75
+ return map;
76
+ };
77
+
78
+ const coerceNumber = (value: unknown): number | null => {
79
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
80
+ if (typeof value === 'string' && value.trim() !== '') {
81
+ const parsed = Number(value);
82
+ if (Number.isFinite(parsed)) return parsed;
83
+ }
84
+ return null;
85
+ };
86
+
87
+ const validateNumberValue = (
88
+ def: z.infer<typeof SessionSettingNumberDefinitionSchema>,
89
+ value: number,
90
+ ): string | null => {
91
+ if (def.min !== undefined && value < def.min) {
92
+ return `${def.label} must be at least ${def.min}.`;
93
+ }
94
+ if (def.max !== undefined && value > def.max) {
95
+ return `${def.label} must be at most ${def.max}.`;
96
+ }
97
+ return null;
98
+ };
99
+
100
+ /**
101
+ * Merge manifest defaults with host-provided overrides. Unknown keys are
102
+ * ignored; invalid values fall back to defaults.
103
+ */
104
+ export const resolveSessionSettings = (
105
+ definitions: readonly SessionSettingDefinition[],
106
+ overrides: Readonly<Record<string, unknown>> | undefined,
107
+ ): ResolvedSessionSettings => {
108
+ const resolved: Record<string, JsonValue> = {};
109
+ for (const def of definitions) {
110
+ const raw = overrides?.[def.key];
111
+ if (def.type === 'number') {
112
+ const coerced = raw === undefined ? def.default : coerceNumber(raw);
113
+ const value =
114
+ coerced === null
115
+ ? def.default
116
+ : validateNumberValue(def, coerced) === null
117
+ ? coerced
118
+ : def.default;
119
+ resolved[def.key] = value;
120
+ continue;
121
+ }
122
+ if (def.type === 'boolean') {
123
+ resolved[def.key] =
124
+ typeof raw === 'boolean' ? raw : def.default;
125
+ continue;
126
+ }
127
+ const allowed = new Set(def.options.map((opt) => opt.value));
128
+ resolved[def.key] =
129
+ typeof raw === 'string' && allowed.has(raw) ? raw : def.default;
130
+ }
131
+ return resolved;
132
+ };
133
+
134
+ export type SessionSettingsValidationResult =
135
+ | { readonly ok: true; readonly settings: ResolvedSessionSettings }
136
+ | { readonly ok: false; readonly message: string };
137
+
138
+ /**
139
+ * Strict validation for provision-time overrides. Rejects unknown keys
140
+ * and out-of-range values.
141
+ */
142
+ export const validateSessionSettingsOverrides = (
143
+ definitions: readonly SessionSettingDefinition[],
144
+ overrides: Readonly<Record<string, unknown>> | undefined,
145
+ ): SessionSettingsValidationResult => {
146
+ if (!overrides || Object.keys(overrides).length === 0) {
147
+ return {
148
+ ok: true,
149
+ settings: resolveSessionSettings(definitions, undefined),
150
+ };
151
+ }
152
+
153
+ const defs = definitionByKey(definitions);
154
+ for (const key of Object.keys(overrides)) {
155
+ if (!defs.has(key)) {
156
+ return { ok: false, message: `Unknown setting "${key}".` };
157
+ }
158
+ }
159
+
160
+ for (const def of definitions) {
161
+ const raw = overrides[def.key];
162
+ if (raw === undefined) continue;
163
+
164
+ if (def.type === 'number') {
165
+ const coerced = coerceNumber(raw);
166
+ if (coerced === null) {
167
+ return { ok: false, message: `${def.label} must be a number.` };
168
+ }
169
+ const err = validateNumberValue(def, coerced);
170
+ if (err) return { ok: false, message: err };
171
+ continue;
172
+ }
173
+
174
+ if (def.type === 'boolean') {
175
+ if (typeof raw !== 'boolean') {
176
+ return { ok: false, message: `${def.label} must be true or false.` };
177
+ }
178
+ continue;
179
+ }
180
+
181
+ if (typeof raw !== 'string' || !def.options.some((opt) => opt.value === raw)) {
182
+ return { ok: false, message: `${def.label} has an invalid option.` };
183
+ }
184
+ }
185
+
186
+ return {
187
+ ok: true,
188
+ settings: resolveSessionSettings(definitions, overrides),
189
+ };
190
+ };
@@ -104,6 +104,31 @@ describe('validateManifest — structural: initial phase', () => {
104
104
  });
105
105
  });
106
106
 
107
+ describe('validateManifest — structural: abort phase', () => {
108
+ it('accepts a manifest with no abortPhaseId (optional)', () => {
109
+ const fixture = cloneFixture();
110
+ expect(validateManifest(fixture).ok).toBe(true);
111
+ });
112
+
113
+ it('accepts a valid abortPhaseId that references a declared phase', () => {
114
+ const fixture = cloneFixture();
115
+ fixture.workflow.abortPhaseId = 'resolved';
116
+ expect(validateManifest(fixture).ok).toBe(true);
117
+ });
118
+
119
+ it('rejects an abortPhaseId that is not a declared phase', () => {
120
+ const fixture = cloneFixture();
121
+ fixture.workflow.abortPhaseId = 'nonexistent';
122
+ const result = validateManifest(fixture);
123
+ expect(result.ok).toBe(false);
124
+ if (!result.ok) {
125
+ expectErrorCode(result.error, 'unknown_abort_phase');
126
+ const issue = result.error.find((e) => e.code === 'unknown_abort_phase');
127
+ expect(issue?.path).toEqual(['workflow', 'abortPhaseId']);
128
+ }
129
+ });
130
+ });
131
+
107
132
  describe('validateManifest — structural: duplicate phase ids', () => {
108
133
  it('rejects a manifest where two phases share an id', () => {
109
134
  const fixture = cloneFixture();
@@ -272,6 +297,10 @@ describe('validateManifest — structural: phase side effects (chunk B8c)', () =
272
297
 
273
298
  it('accepts a phase with valid scoring + inference side effects', () => {
274
299
  const fixture = cloneFixture();
300
+ fixture.promptSlots.callTemplates.host_judge = {
301
+ prompt: 'Judge answers.',
302
+ qualityTier: 'standard',
303
+ };
275
304
  patchPhase(fixture, 'guess', {
276
305
  sideEffects: [
277
306
  {
@@ -282,7 +311,7 @@ describe('validateManifest — structural: phase side effects (chunk B8c)', () =
282
311
  },
283
312
  {
284
313
  kind: 'inference',
285
- callKind: 'host_judge',
314
+ templateId: 'host_judge',
286
315
  targetPath: '/session/inference/host_judge',
287
316
  },
288
317
  ],
package/src/validate.ts CHANGED
@@ -63,6 +63,7 @@ import type { ManifestPhase } from './phase.js';
63
63
  export type ValidationErrorCode =
64
64
  | 'schema'
65
65
  | 'unknown_initial_phase'
66
+ | 'unknown_abort_phase'
66
67
  | 'duplicate_phase_id'
67
68
  | 'unknown_transition_target'
68
69
  | 'unreachable_phase'
@@ -70,7 +71,9 @@ export type ValidationErrorCode =
70
71
  | 'unknown_opportunity_phase'
71
72
  | 'unknown_award_dimension'
72
73
  | 'unknown_side_effect_scoring_dimension'
73
- | 'unknown_side_effect_persona_binding';
74
+ | 'unknown_side_effect_persona_binding'
75
+ | 'unknown_side_effect_inference_template'
76
+ | 'call_template_tier_not_allowed';
74
77
 
75
78
  export type ValidationError = {
76
79
  readonly code: ValidationErrorCode;
@@ -135,6 +138,21 @@ const checkInitialPhase = (
135
138
  }
136
139
  };
137
140
 
141
+ const checkAbortPhase = (
142
+ workflow: Workflow,
143
+ validIds: ReadonlySet<string>,
144
+ out: ValidationError[],
145
+ ): void => {
146
+ if (workflow.abortPhaseId === undefined) return;
147
+ if (!validIds.has(workflow.abortPhaseId)) {
148
+ out.push({
149
+ code: 'unknown_abort_phase',
150
+ path: ['workflow', 'abortPhaseId'],
151
+ message: `abortPhaseId '${workflow.abortPhaseId}' is not declared in workflow.phases`,
152
+ });
153
+ }
154
+ };
155
+
138
156
  const checkTransitionTargets = (
139
157
  workflow: Workflow,
140
158
  validIds: ReadonlySet<string>,
@@ -276,15 +294,16 @@ const checkAwardDimensions = (
276
294
  * dispatch time; catching it at manifest validation surfaces
277
295
  * the error at publish time instead.
278
296
  *
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.
297
+ * `state_write` side effects have no cross-field dependencies the
298
+ * manifest layer can check — the JSON-Patch ops are validated against
299
+ * per-Experience state schemas at apply time. `inference.templateId`
300
+ * is cross-checked against `promptSlots.callTemplates`.
283
301
  */
284
302
  const checkPhaseSideEffects = (
285
303
  phases: readonly ManifestPhase[],
286
304
  dimensionIds: ReadonlySet<string>,
287
305
  personaIds: ReadonlySet<string>,
306
+ templateIds: ReadonlySet<string>,
288
307
  out: ValidationError[],
289
308
  ): void => {
290
309
  for (let i = 0; i < phases.length; i += 1) {
@@ -323,11 +342,44 @@ const checkPhaseSideEffects = (
323
342
  message: `phase '${phase.id}' persona_memory side effect references unbound persona '${effect.personaId}'`,
324
343
  });
325
344
  }
345
+ } else if (effect.kind === 'inference') {
346
+ if (!templateIds.has(effect.templateId)) {
347
+ out.push({
348
+ code: 'unknown_side_effect_inference_template',
349
+ path: [
350
+ 'workflow',
351
+ 'phases',
352
+ i,
353
+ 'sideEffects',
354
+ j,
355
+ 'templateId',
356
+ ],
357
+ message: `phase '${phase.id}' inference side effect references unknown call template '${effect.templateId}'`,
358
+ });
359
+ }
326
360
  }
327
361
  }
328
362
  }
329
363
  };
330
364
 
365
+ const checkCallTemplateQualityTiers = (
366
+ manifest: Manifest,
367
+ out: ValidationError[],
368
+ ): void => {
369
+ const allowed = new Set(manifest.inferenceEnvelope.qualityTiers);
370
+ for (const [templateId, template] of Object.entries(
371
+ manifest.promptSlots.callTemplates,
372
+ )) {
373
+ if (!allowed.has(template.qualityTier)) {
374
+ out.push({
375
+ code: 'call_template_tier_not_allowed',
376
+ path: ['promptSlots', 'callTemplates', templateId, 'qualityTier'],
377
+ message: `call template '${templateId}' uses quality tier '${template.qualityTier}' which is not in inferenceEnvelope.qualityTiers`,
378
+ });
379
+ }
380
+ }
381
+ };
382
+
331
383
  // -----------------------------------------------------------------------------
332
384
  // Public entrypoint
333
385
  // -----------------------------------------------------------------------------
@@ -358,6 +410,7 @@ export const validateManifest = (
358
410
 
359
411
  const validIds = checkPhaseIdsUnique(manifest.workflow, errors);
360
412
  checkInitialPhase(manifest.workflow, validIds, errors);
413
+ checkAbortPhase(manifest.workflow, validIds, errors);
361
414
  checkTransitionTargets(manifest.workflow, validIds, errors);
362
415
  checkReachability(manifest.workflow, validIds, errors);
363
416
  checkPersonaRolesUnique(manifest.personaBindings, errors);
@@ -377,8 +430,10 @@ export const validateManifest = (
377
430
  manifest.workflow.phases,
378
431
  dimensionIds,
379
432
  personaIds,
433
+ new Set(Object.keys(manifest.promptSlots.callTemplates)),
380
434
  errors,
381
435
  );
436
+ checkCallTemplateQualityTiers(manifest, errors);
382
437
 
383
438
  if (errors.length > 0) return err(errors);
384
439
  return ok(manifest);