@frockbot/configuration-core 0.0.0 → 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,2498 @@
1
+ import type {
2
+ ConnectionAuthorizationViewV1,
3
+ ConnectionModelCatalogV1,
4
+ } from "@frockbot/connection-core";
5
+ import {
6
+ decodeConnectionAuthorizationViewV1,
7
+ decodeConnectionModelCatalogV1,
8
+ } from "@frockbot/connection-core";
9
+ import { ConfigurationDecodeError } from "./errors.js";
10
+ import {
11
+ MAX_PACKAGE_SETTINGS_V1,
12
+ MAX_PACKAGE_SETTING_TEXT_V1,
13
+ type PackageSettingValueV1,
14
+ } from "./package-settings.js";
15
+ export { ConfigurationDecodeError } from "./errors.js";
16
+ import { isBotIdV1 } from "./bot-id.js";
17
+ import {
18
+ isConnectionIdentifier,
19
+ isPublicIdentifier,
20
+ isRpcIdentifier,
21
+ } from "./identifiers.js";
22
+ export { isBotIdV1 } from "./bot-id.js";
23
+ export {
24
+ decodePackageSettingsPatchV1,
25
+ decodePackageSettingValueV1,
26
+ decodePackageSettingValuesV1,
27
+ emptyPackageSettingValuesV1,
28
+ MAX_PACKAGE_SETTINGS_V1,
29
+ MAX_PACKAGE_SETTING_TEXT_V1,
30
+ resolvePackageSettingValuesV1,
31
+ type PackageSettingValueV1,
32
+ type PackageSettingValuesV1,
33
+ } from "./package-settings.js";
34
+ export {
35
+ isApplicationDeploymentHash,
36
+ isConnectionIdentifier,
37
+ isPublicIdentifier,
38
+ isRpcIdentifier,
39
+ } from "./identifiers.js";
40
+
41
+ export type JsonValue =
42
+ null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
43
+
44
+ export interface UserPrincipal {
45
+ userId: string;
46
+ }
47
+
48
+ export interface BotAuthority extends UserPrincipal {
49
+ botId: string;
50
+ invocationId: string;
51
+ }
52
+
53
+ /**
54
+ * Who wrote the Bot's current name. A User rename and a Bot self-rename are
55
+ * both durable writes of the same field, and "every durable write records its
56
+ * writer", so the provenance travels with the name rather than beside it.
57
+ */
58
+ export type BotNameProvenanceV1 = "user" | "bot";
59
+
60
+ /**
61
+ * The Bot that made a durable Bot-scoped write, and the admitted Turn it made
62
+ * it in. `namedBy` says *which kind* of writer changed a name; this says
63
+ * *which* Bot, in which Session and Turn, so a self-management write is
64
+ * reconstructable from durable state alone rather than only from the fact that
65
+ * something Bot-shaped touched it.
66
+ *
67
+ * It is optional everywhere it appears: a record written by a User carries no
68
+ * Bot writer, and a record written before this existed still decodes.
69
+ */
70
+ export interface BotSelfWriterV1 {
71
+ kind: "bot";
72
+ botId: string;
73
+ sessionId: string;
74
+ turnId: string;
75
+ }
76
+
77
+ export interface BotProfile {
78
+ name: string;
79
+ label?: string;
80
+ description?: string;
81
+ /** A short role line shown under the Bot's name. */
82
+ title?: string;
83
+ /** Provenance of the current `name`. Absent on records written before it. */
84
+ namedBy?: BotNameProvenanceV1;
85
+ /** Keeps the Bot out of the default sidebar list without archiving it. */
86
+ hiddenFromSidebar?: boolean;
87
+ }
88
+
89
+ /**
90
+ * A partial Bot profile. Only the keys that are present change; an absent key
91
+ * leaves the durable field exactly as it was. An empty string clears an
92
+ * optional text field.
93
+ */
94
+ export interface BotProfilePatchV1 {
95
+ name?: string;
96
+ label?: string;
97
+ description?: string;
98
+ title?: string;
99
+ hiddenFromSidebar?: boolean;
100
+ }
101
+
102
+ export interface BotNotificationPolicy {
103
+ enabled: boolean;
104
+ }
105
+
106
+ export interface ModelAssignment {
107
+ connectionId: string;
108
+ providerModelId: string;
109
+ }
110
+
111
+ /** Whether the User chose the default model or a provider selected it safely. */
112
+ export type NewBotModelTemplateSourceV1 = "user" | "auto";
113
+
114
+ /**
115
+ * Where an installed Package came from. `first-party` is a Package compiled
116
+ * into the running application; `catalog` is one admitted from a pinned remote
117
+ * Catalog generation, whose manifest is data and whose executing code is still
118
+ * a reviewed first-party Package (ADR 0014). Absent means `first-party`, so
119
+ * every installation recorded before the Catalog existed keeps its meaning.
120
+ */
121
+ export type PackageProvenanceV1 = "first-party" | "catalog";
122
+
123
+ export interface PackageInstallationView {
124
+ packageId: string;
125
+ version: string;
126
+ state: "installed" | "disabled" | "failed";
127
+ failure?: string;
128
+ /** The Catalog identity this installation was admitted from, if any. */
129
+ catalogId?: string;
130
+ /** The immutable Catalog generation `catalogId` was read from. */
131
+ catalogGeneration?: string;
132
+ provenance?: PackageProvenanceV1;
133
+ /** The setup values the install carried, as GrokBot's `InstallPlugin{values}`. */
134
+ values?: Record<string, JsonValue>;
135
+ }
136
+
137
+ /**
138
+ * A durable pending decision for the User: this Connection needs authorizing
139
+ * before it will do anything again.
140
+ *
141
+ * It carries **no URL**, and that is the whole design. A Bot may write one —
142
+ * `mcp_authenticate_server` does, and so does a mount that met a 401 — but a
143
+ * redirect is minted only by an authenticated User action. A single-use
144
+ * ten-minute link stored in a projection every client reads, and replayed into
145
+ * every transcript, would outlive the decision it belonged to and would let a
146
+ * Bot hand its User a link it authored. The card is drawn from this record and
147
+ * the link is authored when the User presses it.
148
+ */
149
+ export interface PendingAuthorizationV1 {
150
+ /** Why it is pending, in the Package's own vocabulary (`needs-auth`). */
151
+ reason: string;
152
+ /** When it became pending, ISO-8601. */
153
+ since: string;
154
+ connectionId: string;
155
+ label: string;
156
+ }
157
+
158
+ export interface ConnectionView {
159
+ connectionId: string;
160
+ packageId: string;
161
+ connectionTypeId: string;
162
+ displayName: string;
163
+ state:
164
+ | "authorizing"
165
+ | "ready"
166
+ | "disabled"
167
+ | "revoking"
168
+ | "revoked"
169
+ | "reconciliation-required"
170
+ | "failed";
171
+ generation?: string;
172
+ providerType?: string;
173
+ authorization?: ConnectionAuthorizationViewV1;
174
+ modelCatalog?: ConnectionModelCatalogV1;
175
+ settings?: Record<string, JsonValue>;
176
+ safeMetadata: Record<string, JsonValue>;
177
+ failure?: string;
178
+ /** Set while this Connection is waiting on a User authorization. */
179
+ pendingAuthorization?: PendingAuthorizationV1;
180
+ }
181
+
182
+ export interface StartConnectionCommandV1 {
183
+ schemaVersion: 1;
184
+ type: "connection/start";
185
+ commandId: string;
186
+ connectionTypeId: string;
187
+ alias?: string;
188
+ nativeReturnNonce?: string;
189
+ }
190
+
191
+ export interface RevokeConnectionCommandV1 {
192
+ schemaVersion: 1;
193
+ type: "connection/revoke";
194
+ }
195
+
196
+ export const MAX_USER_CONNECTIONS_V1 = 100;
197
+
198
+ /**
199
+ * The stored name of a User who has not chosen one. The contract requires a
200
+ * non-empty name, so "unset" is spelled with this sentinel.
201
+ */
202
+ export const USER_PROFILE_PLACEHOLDER_NAME_V1 = "FrockBot user";
203
+
204
+ /** Whether a profile name is one the User actually chose (not blank, not the sentinel). */
205
+ export function isChosenUserName(name: string | undefined): name is string {
206
+ const candidate = name?.trim();
207
+ return Boolean(candidate && candidate !== USER_PROFILE_PLACEHOLDER_NAME_V1);
208
+ }
209
+
210
+ export interface UserSettingsViewV1 {
211
+ schemaVersion: 1;
212
+ revision: number;
213
+ profile: { name: string; email?: string };
214
+ packages: PackageInstallationView[];
215
+ connections: ConnectionView[];
216
+ newBotModelTemplate?: ModelAssignment;
217
+ /**
218
+ * `user` is sticky, including when the User explicitly clears the default.
219
+ * Providers may replace only an `auto` default (or the untouched absence).
220
+ */
221
+ newBotModelTemplateSource?: NewBotModelTemplateSourceV1;
222
+ /**
223
+ * The remote Catalog generation this User is pinned to, and the content hash
224
+ * of that generation's index. Pinned on the first read that finds a Catalog
225
+ * and never moved by an install, so a Catalog install is always validated
226
+ * against an immutable, content-addressed generation. Both are absent for a
227
+ * User whose deployment has no Catalog, so the decoder must accept absence.
228
+ */
229
+ catalogGeneration?: string;
230
+ catalogIndexHash?: string;
231
+ }
232
+
233
+ export interface CapabilityAssignmentView {
234
+ assignmentId: string;
235
+ packageId: string;
236
+ capabilityId: string;
237
+ connectionId?: string;
238
+ state: "enabled" | "disabled" | "unavailable";
239
+ }
240
+
241
+ export interface ConnectionDependencyRequirementV1 {
242
+ schemaVersion: 1;
243
+ packageId: string;
244
+ packageVersion: string;
245
+ capabilityId: string;
246
+ connectionTypeIds: string[];
247
+ }
248
+
249
+ export interface CapabilityAssignmentOperationViewV1 {
250
+ commandId: string;
251
+ kind: "assigning" | "replacing" | "unassigning";
252
+ assignmentId: string;
253
+ state: "pending" | "retrying";
254
+ target?: Omit<CapabilityAssignmentView, "state">;
255
+ }
256
+
257
+ export interface BotSettingsViewV1 {
258
+ schemaVersion: 1;
259
+ botId: string;
260
+ revision: number;
261
+ profile: BotProfile;
262
+ notifications: BotNotificationPolicy;
263
+ assignments: CapabilityAssignmentView[];
264
+ assignmentOperations: CapabilityAssignmentOperationViewV1[];
265
+ model?: ModelAssignment;
266
+ }
267
+
268
+ export function initializeBotSettingsV1(
269
+ botId: string,
270
+ model?: ModelAssignment,
271
+ ): BotSettingsViewV1 {
272
+ return {
273
+ schemaVersion: 1,
274
+ botId,
275
+ revision: 0,
276
+ profile: { name: botId === "default" ? "Barebones" : botId },
277
+ notifications: { enabled: true },
278
+ assignments: [],
279
+ assignmentOperations: [],
280
+ model: model ? structuredClone(model) : undefined,
281
+ };
282
+ }
283
+
284
+ export type ConfigurationViewV1 = UserSettingsViewV1 | BotSettingsViewV1;
285
+
286
+ export type ConfigurationQueryV1 =
287
+ | { schemaVersion: 1; type: "user/get" }
288
+ | { schemaVersion: 1; type: "bot/get"; botId: string };
289
+
290
+ interface CommandMetaV1 {
291
+ schemaVersion: 1;
292
+ commandId: string;
293
+ expectedRevision: number;
294
+ }
295
+
296
+ export type ConfigurationCommandV1 =
297
+ | (CommandMetaV1 & {
298
+ type: "user/update-profile";
299
+ profile: UserSettingsViewV1["profile"];
300
+ })
301
+ | (CommandMetaV1 & {
302
+ type: "user/set-new-bot-model";
303
+ model?: ModelAssignment;
304
+ source: NewBotModelTemplateSourceV1;
305
+ })
306
+ | (CommandMetaV1 & {
307
+ type: "user/install-package";
308
+ packageId: string;
309
+ version: string;
310
+ /**
311
+ * A Catalog install names the entry and the generation it was read
312
+ * from. The User Durable Object refuses a generation other than the one
313
+ * it pinned, so a stale browser cannot install off a moved index. All
314
+ * three absent is the unchanged compiled-in install path.
315
+ */
316
+ catalogId?: string;
317
+ catalogGeneration?: string;
318
+ values?: Record<string, JsonValue>;
319
+ })
320
+ | (CommandMetaV1 & {
321
+ /**
322
+ * Removes the installation. Dependent Assignments are never deleted:
323
+ * they resolve as unavailable tombstones the User can repair (ADR 0003).
324
+ * Connections are untouched.
325
+ */
326
+ type: "user/uninstall-package";
327
+ packageId: string;
328
+ })
329
+ | (CommandMetaV1 & {
330
+ type: "user/set-package-enabled";
331
+ packageId: string;
332
+ enabled: boolean;
333
+ })
334
+ | (CommandMetaV1 & {
335
+ /**
336
+ * A partial update of one installed Package's setting values: only the
337
+ * ids it names change, and an id it omits keeps the value it had.
338
+ *
339
+ * The values are shape-checked here and schema-checked by the User
340
+ * Durable Object, which is the only place that knows which settings the
341
+ * installed version of that Package declares.
342
+ */
343
+ type: "user/set-package-settings";
344
+ packageId: string;
345
+ values: Record<string, PackageSettingValueV1>;
346
+ })
347
+ | (CommandMetaV1 & {
348
+ type: "bot/update-profile";
349
+ botId: string;
350
+ profile: BotProfile;
351
+ })
352
+ | (CommandMetaV1 & {
353
+ /**
354
+ * Partial profile update: only the fields the command carries change.
355
+ * `namedBy` records the writer of a name change and defaults to the
356
+ * User, so a Bot renaming itself states so explicitly.
357
+ */
358
+ type: "bot/set-profile";
359
+ botId: string;
360
+ namedBy?: BotNameProvenanceV1;
361
+ /**
362
+ * The Bot and Turn that wrote this patch, when a Bot wrote it. A User
363
+ * edit carries none: the authenticated principal is already the writer.
364
+ */
365
+ writer?: BotSelfWriterV1;
366
+ profile: BotProfilePatchV1;
367
+ })
368
+ | (CommandMetaV1 & {
369
+ type: "bot/update-notifications";
370
+ botId: string;
371
+ notifications: BotNotificationPolicy;
372
+ })
373
+ | (CommandMetaV1 & {
374
+ type: "bot/select-model";
375
+ botId: string;
376
+ model: ModelAssignment;
377
+ })
378
+ | (CommandMetaV1 & {
379
+ type: "bot/assign-capability";
380
+ botId: string;
381
+ assignment: Omit<CapabilityAssignmentView, "state">;
382
+ model?: ModelAssignment;
383
+ })
384
+ | (CommandMetaV1 & {
385
+ type: "bot/replace-capability";
386
+ botId: string;
387
+ assignment: Omit<CapabilityAssignmentView, "state">;
388
+ model?: ModelAssignment;
389
+ })
390
+ | (CommandMetaV1 & {
391
+ type: "bot/unassign-capability";
392
+ botId: string;
393
+ assignmentId: string;
394
+ })
395
+ | (CommandMetaV1 & {
396
+ type: "bot/unbind-model";
397
+ botId: string;
398
+ assignmentId: string;
399
+ });
400
+
401
+ export type UserConfigurationCommandV1 = Exclude<
402
+ ConfigurationCommandV1,
403
+ { botId: string }
404
+ >;
405
+
406
+ export type BotConfigurationCommandV1 = Extract<
407
+ ConfigurationCommandV1,
408
+ { botId: string }
409
+ >;
410
+
411
+ function canonicalFingerprintValue(value: unknown): string {
412
+ if (
413
+ value === null ||
414
+ typeof value === "string" ||
415
+ typeof value === "boolean" ||
416
+ typeof value === "number"
417
+ ) {
418
+ const encoded = JSON.stringify(value);
419
+ if (encoded !== undefined) return encoded;
420
+ }
421
+ if (Array.isArray(value)) {
422
+ return `[${value.map(canonicalFingerprintValue).join(",")}]`;
423
+ }
424
+ if (typeof value === "object") {
425
+ const record = value as Record<string, unknown>;
426
+ return `{${Object.keys(record)
427
+ .filter((key) => record[key] !== undefined)
428
+ .sort()
429
+ .map(
430
+ (key) =>
431
+ `${JSON.stringify(key)}:${canonicalFingerprintValue(record[key])}`,
432
+ )
433
+ .join(",")}}`;
434
+ }
435
+ throw new Error("Configuration command fingerprint value is not JSON");
436
+ }
437
+
438
+ /**
439
+ * The idempotency fingerprint of any command, under a caller-chosen namespace.
440
+ *
441
+ * One canonicalization serves every command family so a replayed idempotency
442
+ * key is compared the same way everywhere, and the namespace keeps two families
443
+ * from ever producing the same bytes for different meanings.
444
+ */
445
+ export function canonicalCommandFingerprintV1(
446
+ namespace: string,
447
+ command: unknown,
448
+ ): string {
449
+ return `${namespace}:${canonicalFingerprintValue(command)}`;
450
+ }
451
+
452
+ export function configurationCommandFingerprintV1(
453
+ command: ConfigurationCommandV1,
454
+ ): string {
455
+ const { commandId: _commandId, ...semanticCommand } = command;
456
+ return canonicalCommandFingerprintV1(
457
+ "configuration-command-v1",
458
+ semanticCommand,
459
+ );
460
+ }
461
+
462
+ export interface UserConfigurationReadRpcV1 {
463
+ schemaVersion: 1;
464
+ userId: string;
465
+ }
466
+
467
+ export interface UserConfigurationExecuteRpcV1 {
468
+ schemaVersion: 1;
469
+ userId: string;
470
+ command: UserConfigurationCommandV1;
471
+ }
472
+
473
+ export interface BotConfigurationReadRpcV1 {
474
+ schemaVersion: 1;
475
+ userId: string;
476
+ botId: string;
477
+ }
478
+
479
+ export interface BotConfigurationExecuteRpcV1 {
480
+ schemaVersion: 1;
481
+ userId: string;
482
+ botId: string;
483
+ command: BotConfigurationCommandV1;
484
+ }
485
+
486
+ export type OperationReceiptV1 =
487
+ | {
488
+ schemaVersion: 1;
489
+ commandId: string;
490
+ revision: number;
491
+ status: "pending" | "applied";
492
+ }
493
+ | {
494
+ schemaVersion: 1;
495
+ commandId: string;
496
+ revision: number;
497
+ status: "rejected";
498
+ failure: string;
499
+ };
500
+
501
+ export interface BotExecutionPlanV1 {
502
+ schemaVersion: 1;
503
+ botId: string;
504
+ revision: number;
505
+ model?: ModelAssignment;
506
+ assignments: CapabilityAssignmentView[];
507
+ }
508
+
509
+ export interface ExecutionPackageDefinition {
510
+ packageId: string;
511
+ version: string;
512
+ capabilities: Array<{
513
+ id: string;
514
+ kind?: "tool" | "model" | "memory" | "notification" | "computer";
515
+ connectionTypes: string[];
516
+ }>;
517
+ connectionTypes: Array<{
518
+ id: string;
519
+ capabilities: string[];
520
+ }>;
521
+ }
522
+
523
+ export function capabilityAssignmentFailureV1(input: {
524
+ assignment: Omit<CapabilityAssignmentView, "state">;
525
+ user: UserSettingsViewV1;
526
+ packages: readonly ExecutionPackageDefinition[];
527
+ }): string | undefined {
528
+ const installation = input.user.packages.find(
529
+ (pkg) =>
530
+ pkg.packageId === input.assignment.packageId && pkg.state === "installed",
531
+ );
532
+ const pkg = input.packages.find(
533
+ (candidate) =>
534
+ candidate.packageId === input.assignment.packageId &&
535
+ candidate.version === installation?.version,
536
+ );
537
+ if (!installation || !pkg) {
538
+ return `Package "${input.assignment.packageId}" is not installed and enabled`;
539
+ }
540
+ const capability = pkg.capabilities.find(
541
+ (candidate) => candidate.id === input.assignment.capabilityId,
542
+ );
543
+ if (!capability) {
544
+ return `Capability "${input.assignment.capabilityId}" is not declared by Package "${input.assignment.packageId}"`;
545
+ }
546
+ if (capability.connectionTypes.length === 0) {
547
+ return input.assignment.connectionId
548
+ ? `Capability "${input.assignment.capabilityId}" does not accept a Connection`
549
+ : undefined;
550
+ }
551
+ if (!input.assignment.connectionId) {
552
+ return `Capability "${input.assignment.capabilityId}" requires a Connection`;
553
+ }
554
+ const connection = input.user.connections.find(
555
+ (candidate) => candidate.connectionId === input.assignment.connectionId,
556
+ );
557
+ if (
558
+ !connection ||
559
+ connection.packageId !== input.assignment.packageId ||
560
+ connection.state !== "ready"
561
+ ) {
562
+ return `Connection "${input.assignment.connectionId}" is not a ready Connection for Package "${input.assignment.packageId}"`;
563
+ }
564
+ const connectionType = pkg.connectionTypes.find(
565
+ (candidate) => candidate.id === connection.connectionTypeId,
566
+ );
567
+ if (
568
+ !connectionType ||
569
+ !capability.connectionTypes.includes(connectionType.id) ||
570
+ !connectionType.capabilities.includes(capability.id)
571
+ ) {
572
+ return `Connection "${input.assignment.connectionId}" has an incompatible Connection Type`;
573
+ }
574
+ return undefined;
575
+ }
576
+
577
+ export interface ResolvedModelBindingV1 {
578
+ assignment: ModelAssignment;
579
+ state: "ready" | "requires-resolution" | "unavailable";
580
+ connection?: ConnectionView;
581
+ packageId?: string;
582
+ providerType?: string;
583
+ failure?: string;
584
+ }
585
+
586
+ export function resolveBotModelBindingV1(input: {
587
+ model: ModelAssignment;
588
+ assignments: readonly CapabilityAssignmentView[];
589
+ user: UserSettingsViewV1;
590
+ packages: readonly ExecutionPackageDefinition[];
591
+ }): ResolvedModelBindingV1 {
592
+ const unavailable = (failure: string): ResolvedModelBindingV1 => ({
593
+ assignment: structuredClone(input.model),
594
+ state: "unavailable",
595
+ failure,
596
+ });
597
+ const connection = input.user.connections.find(
598
+ (candidate) => candidate.connectionId === input.model.connectionId,
599
+ );
600
+ if (!connection) return unavailable("Connection is unavailable");
601
+ if (connection.state !== "ready") {
602
+ return unavailable(`Connection is ${connection.state}`);
603
+ }
604
+ const installation = input.user.packages.find(
605
+ (candidate) => candidate.packageId === connection.packageId,
606
+ );
607
+ if (!installation || installation.state !== "installed") {
608
+ return unavailable("Connection Package is not installed and enabled");
609
+ }
610
+ const pkg = input.packages.find(
611
+ (candidate) =>
612
+ candidate.packageId === connection.packageId &&
613
+ candidate.version === installation.version,
614
+ );
615
+ const connectionType = pkg?.connectionTypes.find(
616
+ (candidate) => candidate.id === connection.connectionTypeId,
617
+ );
618
+ const modelCapability = pkg?.capabilities.find(
619
+ (candidate) =>
620
+ candidate.kind === "model" &&
621
+ connectionType?.capabilities.includes(candidate.id) &&
622
+ candidate.connectionTypes.includes(connection.connectionTypeId),
623
+ );
624
+ if (!pkg || !connectionType || !modelCapability) {
625
+ return unavailable("Connection does not provide models");
626
+ }
627
+ const assignment = input.assignments.find(
628
+ (candidate) =>
629
+ candidate.packageId === pkg.packageId &&
630
+ candidate.capabilityId === modelCapability.id &&
631
+ candidate.connectionId === connection.connectionId &&
632
+ candidate.state === "enabled",
633
+ );
634
+ if (!assignment) {
635
+ return unavailable("Bot is not assigned the Connection model capability");
636
+ }
637
+ if (!connection.providerType) {
638
+ return unavailable("Connection provider type is unavailable");
639
+ }
640
+ const knownModel = connection.modelCatalog?.models.some(
641
+ (candidate: { providerModelId: string }) =>
642
+ candidate.providerModelId === input.model.providerModelId,
643
+ );
644
+ return {
645
+ assignment: structuredClone(input.model),
646
+ state: knownModel ? "ready" : "requires-resolution",
647
+ connection: structuredClone(connection),
648
+ packageId: pkg.packageId,
649
+ providerType: connection.providerType,
650
+ };
651
+ }
652
+
653
+ export interface EffectiveBotModelV1 {
654
+ /**
655
+ * "bot" when the Bot overrides the User default, "default" when the Bot
656
+ * follows `UserSettingsViewV1.newBotModelTemplate`, "none" when neither is
657
+ * set.
658
+ */
659
+ source: "bot" | "default" | "none";
660
+ model?: ModelAssignment;
661
+ binding?: ResolvedModelBindingV1;
662
+ }
663
+
664
+ /**
665
+ * The model a Bot actually runs on. A Bot without its own `model` follows the
666
+ * User's default dynamically, so changing the default changes every Bot that
667
+ * has not overridden it. Authority is unchanged: the returned binding is still
668
+ * resolved against the Bot's own Assignments, so a Bot that has never claimed
669
+ * the Connection's model Capability resolves "unavailable" until it does.
670
+ */
671
+ export function resolveEffectiveBotModelV1(input: {
672
+ bot: Pick<BotSettingsViewV1, "model" | "assignments">;
673
+ user: UserSettingsViewV1;
674
+ packages: readonly ExecutionPackageDefinition[];
675
+ }): EffectiveBotModelV1 {
676
+ const model = input.bot.model ?? input.user.newBotModelTemplate;
677
+ if (!model) return { source: "none" };
678
+ return {
679
+ source: input.bot.model ? "bot" : "default",
680
+ model: structuredClone(model),
681
+ binding: resolveBotModelBindingV1({
682
+ model,
683
+ assignments: input.bot.assignments,
684
+ user: input.user,
685
+ packages: input.packages,
686
+ }),
687
+ };
688
+ }
689
+
690
+ export function resolveBotExecutionPlanV1(input: {
691
+ bot: BotSettingsViewV1;
692
+ user: UserSettingsViewV1;
693
+ packages: readonly ExecutionPackageDefinition[];
694
+ }): BotExecutionPlanV1 {
695
+ const assignments = input.bot.assignments.map((assignment) => {
696
+ if (assignment.state !== "enabled") return structuredClone(assignment);
697
+ if (
698
+ capabilityAssignmentFailureV1({
699
+ assignment,
700
+ user: input.user,
701
+ packages: input.packages,
702
+ })
703
+ ) {
704
+ return { ...assignment, state: "unavailable" as const };
705
+ }
706
+ return structuredClone(assignment);
707
+ });
708
+ return {
709
+ schemaVersion: 1,
710
+ botId: input.bot.botId,
711
+ revision: input.bot.revision,
712
+ model: input.bot.model ? structuredClone(input.bot.model) : undefined,
713
+ assignments,
714
+ };
715
+ }
716
+
717
+ export interface ConfigurationApplication {
718
+ read(
719
+ principal: UserPrincipal,
720
+ query: ConfigurationQueryV1,
721
+ ): Promise<ConfigurationViewV1>;
722
+ execute(
723
+ principal: UserPrincipal,
724
+ command: ConfigurationCommandV1,
725
+ ): Promise<OperationReceiptV1>;
726
+ resolveBot(authority: BotAuthority): Promise<BotExecutionPlanV1>;
727
+ }
728
+
729
+ export class ConfigurationConflictError extends Error {
730
+ constructor(readonly currentRevision: number) {
731
+ super(`configuration revision is ${currentRevision}`);
732
+ this.name = "ConfigurationConflictError";
733
+ }
734
+ }
735
+
736
+ function record(value: unknown, label: string): Record<string, unknown> {
737
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
738
+ throw new ConfigurationDecodeError(`${label} must be an object`);
739
+ }
740
+ return value as Record<string, unknown>;
741
+ }
742
+
743
+ export function decodeBotIdV1(value: unknown, label = "botId"): string {
744
+ if (!isBotIdV1(value))
745
+ throw new ConfigurationDecodeError(`${label} is invalid`);
746
+ return value;
747
+ }
748
+
749
+ function identifier(value: unknown, label: string): string {
750
+ if (!isPublicIdentifier(value)) {
751
+ throw new ConfigurationDecodeError(`${label} is invalid`);
752
+ }
753
+ return value;
754
+ }
755
+
756
+ function connectionIdentifier(value: unknown, label: string): string {
757
+ if (!isConnectionIdentifier(value)) {
758
+ throw new ConfigurationDecodeError(`${label} is invalid`);
759
+ }
760
+ return value;
761
+ }
762
+
763
+ const rpcDisposalKeys: ReadonlySet<PropertyKey> = new Set<PropertyKey>([
764
+ Symbol.dispose,
765
+ Symbol.asyncDispose,
766
+ ]);
767
+
768
+ function exactRecord(
769
+ value: unknown,
770
+ label: string,
771
+ required: readonly PropertyKey[],
772
+ optional: readonly PropertyKey[] = [],
773
+ ): Record<string, unknown> {
774
+ const decoded = record(value, label);
775
+ const allowed = new Set<PropertyKey>([...required, ...optional]);
776
+ // Values returned over Durable Object RPC carry a disposal symbol as an own
777
+ // key; it is transport, not a field. Every other own key must be declared.
778
+ if (
779
+ !required.every((key) => Object.hasOwn(decoded, key)) ||
780
+ Reflect.ownKeys(decoded).some(
781
+ (key) => !allowed.has(key) && !rpcDisposalKeys.has(key),
782
+ )
783
+ ) {
784
+ throw new ConfigurationDecodeError(`${label} has invalid fields`);
785
+ }
786
+ return decoded;
787
+ }
788
+
789
+ export function decodeStartConnectionCommandV1(
790
+ input: unknown,
791
+ ): StartConnectionCommandV1 {
792
+ const value = exactRecord(
793
+ input,
794
+ "Connection start command",
795
+ ["schemaVersion", "type", "commandId", "connectionTypeId"],
796
+ ["alias", "nativeReturnNonce"],
797
+ );
798
+ if (value.schemaVersion !== 1 || value.type !== "connection/start") {
799
+ throw new ConfigurationDecodeError("unsupported Connection start command");
800
+ }
801
+ if (
802
+ value.alias !== undefined &&
803
+ (typeof value.alias !== "string" || value.alias.length > 100)
804
+ ) {
805
+ throw new ConfigurationDecodeError("alias is invalid");
806
+ }
807
+ return {
808
+ schemaVersion: 1,
809
+ type: "connection/start",
810
+ commandId: connectionIdentifier(value.commandId, "commandId"),
811
+ connectionTypeId: connectionIdentifier(
812
+ value.connectionTypeId,
813
+ "connectionTypeId",
814
+ ),
815
+ ...(value.alias === undefined ? {} : { alias: value.alias }),
816
+ ...(value.nativeReturnNonce === undefined
817
+ ? {}
818
+ : {
819
+ nativeReturnNonce: connectionIdentifier(
820
+ value.nativeReturnNonce,
821
+ "nativeReturnNonce",
822
+ ),
823
+ }),
824
+ };
825
+ }
826
+
827
+ export function decodeRevokeConnectionCommandV1(
828
+ input: unknown,
829
+ ): RevokeConnectionCommandV1 {
830
+ const value = exactRecord(input, "Connection revoke command", [
831
+ "schemaVersion",
832
+ "type",
833
+ ]);
834
+ if (value.schemaVersion !== 1 || value.type !== "connection/revoke") {
835
+ throw new ConfigurationDecodeError("unsupported Connection revoke command");
836
+ }
837
+ return { schemaVersion: 1, type: "connection/revoke" };
838
+ }
839
+
840
+ export function decodeConnectionDependencyRequirementV1(
841
+ input: unknown,
842
+ ): ConnectionDependencyRequirementV1 {
843
+ const value = exactRecord(input, "Connection dependency requirement", [
844
+ "schemaVersion",
845
+ "packageId",
846
+ "packageVersion",
847
+ "capabilityId",
848
+ "connectionTypeIds",
849
+ ]);
850
+ schemaVersion(value);
851
+ if (
852
+ !Array.isArray(value.connectionTypeIds) ||
853
+ value.connectionTypeIds.length === 0
854
+ ) {
855
+ throw new ConfigurationDecodeError(
856
+ "Connection dependency requirement connectionTypeIds are invalid",
857
+ );
858
+ }
859
+ return {
860
+ schemaVersion: 1,
861
+ packageId: identifier(value.packageId, "packageId"),
862
+ packageVersion: text(value.packageVersion, "packageVersion", 128),
863
+ capabilityId: identifier(value.capabilityId, "capabilityId"),
864
+ connectionTypeIds: value.connectionTypeIds.map((item) =>
865
+ identifier(item, "connectionTypeId"),
866
+ ),
867
+ };
868
+ }
869
+
870
+ function text(value: unknown, label: string, maximum: number): string {
871
+ if (typeof value !== "string") {
872
+ throw new ConfigurationDecodeError(`${label} must be a string`);
873
+ }
874
+ const normalized = value.trim();
875
+ if (!normalized || normalized.length > maximum) {
876
+ throw new ConfigurationDecodeError(`${label} is invalid`);
877
+ }
878
+ return normalized;
879
+ }
880
+
881
+ function optionalText(
882
+ value: unknown,
883
+ label: string,
884
+ maximum: number,
885
+ ): string | undefined {
886
+ return value === undefined ? undefined : text(value, label, maximum);
887
+ }
888
+
889
+ function revision(value: unknown): number {
890
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
891
+ throw new ConfigurationDecodeError("expectedRevision is invalid");
892
+ }
893
+ return value as number;
894
+ }
895
+
896
+ const COMMAND_META_FIELDS = [
897
+ "schemaVersion",
898
+ "type",
899
+ "commandId",
900
+ "expectedRevision",
901
+ ] as const;
902
+
903
+ function exactCommand(
904
+ input: unknown,
905
+ fields: readonly string[],
906
+ optional: readonly string[] = [],
907
+ ): Record<string, unknown> {
908
+ return exactRecord(
909
+ input,
910
+ "command",
911
+ [...COMMAND_META_FIELDS, ...fields],
912
+ optional,
913
+ );
914
+ }
915
+
916
+ function commandMeta(value: Record<string, unknown>): CommandMetaV1 {
917
+ if (value.schemaVersion !== 1) {
918
+ throw new ConfigurationDecodeError("unsupported configuration schema");
919
+ }
920
+ return {
921
+ schemaVersion: 1,
922
+ commandId: identifier(value.commandId, "commandId"),
923
+ expectedRevision: revision(value.expectedRevision),
924
+ };
925
+ }
926
+
927
+ function nameProvenance(value: unknown, label: string): BotNameProvenanceV1 {
928
+ if (value !== "user" && value !== "bot") {
929
+ throw new ConfigurationDecodeError(`${label} is invalid`);
930
+ }
931
+ return value;
932
+ }
933
+
934
+ /**
935
+ * The exact Bot writer DTO. It crosses the Bot Durable Object seam and the
936
+ * session event log, so — like every other cross-runtime value — it decodes
937
+ * exactly once, here, with no extra fields tolerated.
938
+ */
939
+ export function decodeBotSelfWriterV1(
940
+ value: unknown,
941
+ label = "writer",
942
+ ): BotSelfWriterV1 {
943
+ const writer = exactRecord(value, label, [
944
+ "kind",
945
+ "botId",
946
+ "sessionId",
947
+ "turnId",
948
+ ]);
949
+ if (writer.kind !== "bot") {
950
+ throw new ConfigurationDecodeError(`${label}.kind is invalid`);
951
+ }
952
+ return {
953
+ kind: "bot",
954
+ botId: decodeBotIdV1(writer.botId, `${label}.botId`),
955
+ sessionId: text(writer.sessionId, `${label}.sessionId`, 256),
956
+ turnId: text(writer.turnId, `${label}.turnId`, 128),
957
+ };
958
+ }
959
+
960
+ function flag(value: unknown, label: string): boolean {
961
+ if (typeof value !== "boolean") {
962
+ throw new ConfigurationDecodeError(`${label} must be a boolean`);
963
+ }
964
+ return value;
965
+ }
966
+
967
+ const BOT_PROFILE_OPTIONAL_FIELDS = [
968
+ "label",
969
+ "description",
970
+ "title",
971
+ "namedBy",
972
+ "hiddenFromSidebar",
973
+ ] as const;
974
+
975
+ function botProfile(value: unknown): BotProfile {
976
+ const profile = exactRecord(
977
+ value,
978
+ "profile",
979
+ ["name"],
980
+ BOT_PROFILE_OPTIONAL_FIELDS,
981
+ );
982
+ return {
983
+ name: text(profile.name, "profile.name", 100),
984
+ label: optionalText(profile.label, "profile.label", 120),
985
+ description: optionalText(
986
+ profile.description,
987
+ "profile.description",
988
+ 10_000,
989
+ ),
990
+ ...(profile.title === undefined
991
+ ? {}
992
+ : { title: text(profile.title, "profile.title", 120) }),
993
+ ...(profile.namedBy === undefined
994
+ ? {}
995
+ : { namedBy: nameProvenance(profile.namedBy, "profile.namedBy") }),
996
+ ...(profile.hiddenFromSidebar === undefined
997
+ ? {}
998
+ : {
999
+ hiddenFromSidebar: flag(
1000
+ profile.hiddenFromSidebar,
1001
+ "profile.hiddenFromSidebar",
1002
+ ),
1003
+ }),
1004
+ };
1005
+ }
1006
+
1007
+ /**
1008
+ * A patch field that carries text: a non-empty string sets it, and the empty
1009
+ * string clears it. A partial update has no other way to say "remove this".
1010
+ */
1011
+ function patchText(
1012
+ value: unknown,
1013
+ label: string,
1014
+ maximum: number,
1015
+ ): string | "" {
1016
+ if (typeof value !== "string") {
1017
+ throw new ConfigurationDecodeError(`${label} must be a string`);
1018
+ }
1019
+ const normalized = value.trim();
1020
+ if (normalized.length > maximum) {
1021
+ throw new ConfigurationDecodeError(`${label} is invalid`);
1022
+ }
1023
+ return normalized;
1024
+ }
1025
+
1026
+ function botProfilePatch(value: unknown): BotProfilePatchV1 {
1027
+ const patch = exactRecord(
1028
+ value,
1029
+ "profile",
1030
+ [],
1031
+ ["name", "label", "description", "title", "hiddenFromSidebar"],
1032
+ );
1033
+ if (Reflect.ownKeys(patch).length === 0) {
1034
+ throw new ConfigurationDecodeError("profile has invalid fields");
1035
+ }
1036
+ const optional = (key: "label" | "description" | "title", maximum: number) =>
1037
+ patch[key] === undefined
1038
+ ? {}
1039
+ : { [key]: patchText(patch[key], `profile.${key}`, maximum) };
1040
+ return {
1041
+ // The name is the one field a partial update may not blank.
1042
+ ...(patch.name === undefined
1043
+ ? {}
1044
+ : { name: text(patch.name, "profile.name", 100) }),
1045
+ ...optional("label", 120),
1046
+ ...optional("description", 10_000),
1047
+ ...optional("title", 120),
1048
+ ...(patch.hiddenFromSidebar === undefined
1049
+ ? {}
1050
+ : {
1051
+ hiddenFromSidebar: flag(
1052
+ patch.hiddenFromSidebar,
1053
+ "profile.hiddenFromSidebar",
1054
+ ),
1055
+ }),
1056
+ };
1057
+ }
1058
+
1059
+ /**
1060
+ * Apply a partial profile update. Only the keys the patch carries change; an
1061
+ * empty string clears an optional text field. `namedBy` is recorded only when
1062
+ * the name actually changes, so
1063
+ * an unrelated edit never rewrites the provenance of the current name.
1064
+ */
1065
+ export function applyBotProfilePatchV1(
1066
+ current: BotProfile,
1067
+ patch: BotProfilePatchV1,
1068
+ namedBy: BotNameProvenanceV1,
1069
+ ): BotProfile {
1070
+ const next: BotProfile = { ...structuredClone(current) };
1071
+ if (patch.name !== undefined && patch.name !== current.name) {
1072
+ next.name = patch.name;
1073
+ next.namedBy = namedBy;
1074
+ }
1075
+ for (const key of ["label", "description", "title"] as const) {
1076
+ const value = patch[key];
1077
+ if (value === undefined) continue;
1078
+ if (value === "") delete next[key];
1079
+ else next[key] = value;
1080
+ }
1081
+ if (patch.hiddenFromSidebar !== undefined) {
1082
+ if (patch.hiddenFromSidebar) next.hiddenFromSidebar = true;
1083
+ else delete next.hiddenFromSidebar;
1084
+ }
1085
+ return next;
1086
+ }
1087
+
1088
+ function notifications(value: unknown): BotNotificationPolicy {
1089
+ const policy = exactRecord(value, "notifications", ["enabled"]);
1090
+ if (typeof policy.enabled !== "boolean") {
1091
+ throw new ConfigurationDecodeError("notifications.enabled is invalid");
1092
+ }
1093
+ return { enabled: policy.enabled };
1094
+ }
1095
+
1096
+ /** The exact Bot model DTO, decoded wherever it crosses a durable seam. */
1097
+ export function decodeModelAssignmentV1(value: unknown): ModelAssignment {
1098
+ return model(value);
1099
+ }
1100
+
1101
+ function model(value: unknown): ModelAssignment {
1102
+ const assignment = exactRecord(value, "model", [
1103
+ "connectionId",
1104
+ "providerModelId",
1105
+ ]);
1106
+ return {
1107
+ connectionId: identifier(assignment.connectionId, "model.connectionId"),
1108
+ providerModelId: text(
1109
+ assignment.providerModelId,
1110
+ "model.providerModelId",
1111
+ 256,
1112
+ ),
1113
+ };
1114
+ }
1115
+
1116
+ export function decodeConfigurationQueryV1(
1117
+ input: unknown,
1118
+ ): ConfigurationQueryV1 {
1119
+ const value = record(input, "query");
1120
+ if (value.schemaVersion !== 1) {
1121
+ throw new ConfigurationDecodeError("unsupported configuration schema");
1122
+ }
1123
+ if (value.type === "user/get") {
1124
+ exactRecord(input, "query", ["schemaVersion", "type"]);
1125
+ return { schemaVersion: 1, type: "user/get" };
1126
+ }
1127
+ if (value.type === "bot/get") {
1128
+ const query = exactRecord(input, "query", [
1129
+ "schemaVersion",
1130
+ "type",
1131
+ "botId",
1132
+ ]);
1133
+ return {
1134
+ schemaVersion: 1,
1135
+ type: "bot/get",
1136
+ botId: identifier(query.botId, "botId"),
1137
+ };
1138
+ }
1139
+ throw new ConfigurationDecodeError("unknown configuration query");
1140
+ }
1141
+
1142
+ export function decodeConfigurationCommandV1(
1143
+ input: unknown,
1144
+ ): ConfigurationCommandV1 {
1145
+ const value = record(input, "command");
1146
+ switch (value.type) {
1147
+ case "user/update-profile": {
1148
+ const command = exactCommand(input, ["profile"]);
1149
+ const profile = exactRecord(
1150
+ command.profile,
1151
+ "profile",
1152
+ ["name"],
1153
+ ["email"],
1154
+ );
1155
+ return {
1156
+ ...commandMeta(command),
1157
+ type: value.type,
1158
+ profile: {
1159
+ name: text(profile.name, "profile.name", 100),
1160
+ email: optionalText(profile.email, "profile.email", 320),
1161
+ },
1162
+ };
1163
+ }
1164
+ case "user/set-new-bot-model": {
1165
+ const command = exactCommand(input, ["source"], ["model"]);
1166
+ if (command.source !== "user" && command.source !== "auto") {
1167
+ throw new ConfigurationDecodeError(
1168
+ "new Bot model source must be user or auto",
1169
+ );
1170
+ }
1171
+ if (command.source === "auto" && command.model === undefined) {
1172
+ throw new ConfigurationDecodeError(
1173
+ "an automatic new Bot model must name a model",
1174
+ );
1175
+ }
1176
+ return {
1177
+ ...commandMeta(command),
1178
+ type: value.type,
1179
+ model: command.model === undefined ? undefined : model(command.model),
1180
+ source: command.source,
1181
+ };
1182
+ }
1183
+ case "user/install-package": {
1184
+ const command = exactCommand(
1185
+ input,
1186
+ ["packageId", "version"],
1187
+ ["catalogId", "catalogGeneration", "values"],
1188
+ );
1189
+ // A Catalog install is all three of identity, generation and (optional)
1190
+ // values or none of them: half a Catalog install would be an install
1191
+ // against no pinned generation at all.
1192
+ if (
1193
+ (command.catalogId === undefined) !==
1194
+ (command.catalogGeneration === undefined)
1195
+ ) {
1196
+ throw new ConfigurationDecodeError(
1197
+ "a Catalog install requires both catalogId and catalogGeneration",
1198
+ );
1199
+ }
1200
+ if (command.catalogId === undefined && command.values !== undefined) {
1201
+ throw new ConfigurationDecodeError(
1202
+ "install values require a Catalog entry",
1203
+ );
1204
+ }
1205
+ return {
1206
+ ...commandMeta(command),
1207
+ type: value.type,
1208
+ packageId: identifier(command.packageId, "packageId"),
1209
+ version: text(command.version, "version", 100),
1210
+ ...(command.catalogId === undefined
1211
+ ? {}
1212
+ : {
1213
+ catalogId: identifier(command.catalogId, "catalogId"),
1214
+ catalogGeneration: identifier(
1215
+ command.catalogGeneration,
1216
+ "catalogGeneration",
1217
+ ),
1218
+ }),
1219
+ ...(command.values === undefined
1220
+ ? {}
1221
+ : { values: installValues(command.values) }),
1222
+ };
1223
+ }
1224
+ case "user/uninstall-package": {
1225
+ const command = exactCommand(input, ["packageId"]);
1226
+ return {
1227
+ ...commandMeta(command),
1228
+ type: value.type,
1229
+ packageId: identifier(command.packageId, "packageId"),
1230
+ };
1231
+ }
1232
+ case "user/set-package-enabled": {
1233
+ const command = exactCommand(input, ["packageId", "enabled"]);
1234
+ if (typeof command.enabled !== "boolean") {
1235
+ throw new ConfigurationDecodeError("enabled is invalid");
1236
+ }
1237
+ return {
1238
+ ...commandMeta(command),
1239
+ type: value.type,
1240
+ packageId: identifier(command.packageId, "packageId"),
1241
+ enabled: command.enabled,
1242
+ };
1243
+ }
1244
+ case "user/set-package-settings": {
1245
+ const command = exactCommand(input, ["packageId", "values"]);
1246
+ return {
1247
+ ...commandMeta(command),
1248
+ type: value.type,
1249
+ packageId: identifier(command.packageId, "packageId"),
1250
+ values: packageSettingsPatch(command.values),
1251
+ };
1252
+ }
1253
+ case "bot/update-profile": {
1254
+ const command = exactCommand(input, ["botId", "profile"]);
1255
+ return {
1256
+ ...commandMeta(command),
1257
+ type: value.type,
1258
+ botId: identifier(command.botId, "botId"),
1259
+ profile: botProfile(command.profile),
1260
+ };
1261
+ }
1262
+ case "bot/set-profile": {
1263
+ const command = exactCommand(
1264
+ input,
1265
+ ["botId", "profile"],
1266
+ ["namedBy", "writer"],
1267
+ );
1268
+ const botId = identifier(command.botId, "botId");
1269
+ const writer =
1270
+ command.writer === undefined
1271
+ ? undefined
1272
+ : decodeBotSelfWriterV1(command.writer);
1273
+ // A Bot writes only its own profile. The command names the target twice,
1274
+ // so the seam refuses a writer aimed at anything but itself rather than
1275
+ // recording a provenance the authority never granted.
1276
+ if (writer && writer.botId !== botId) {
1277
+ throw new ConfigurationDecodeError("writer.botId is invalid");
1278
+ }
1279
+ return {
1280
+ ...commandMeta(command),
1281
+ type: "bot/set-profile",
1282
+ botId,
1283
+ ...(command.namedBy === undefined
1284
+ ? {}
1285
+ : { namedBy: nameProvenance(command.namedBy, "namedBy") }),
1286
+ ...(writer ? { writer } : {}),
1287
+ profile: botProfilePatch(command.profile),
1288
+ };
1289
+ }
1290
+ case "bot/update-notifications": {
1291
+ const command = exactCommand(input, ["botId", "notifications"]);
1292
+ return {
1293
+ ...commandMeta(command),
1294
+ type: value.type,
1295
+ botId: identifier(command.botId, "botId"),
1296
+ notifications: notifications(command.notifications),
1297
+ };
1298
+ }
1299
+ case "bot/select-model": {
1300
+ const command = exactCommand(input, ["botId", "model"]);
1301
+ return {
1302
+ ...commandMeta(command),
1303
+ type: value.type,
1304
+ botId: identifier(command.botId, "botId"),
1305
+ model: model(command.model),
1306
+ };
1307
+ }
1308
+ case "bot/assign-capability":
1309
+ case "bot/replace-capability": {
1310
+ const command = exactCommand(input, ["botId", "assignment"], ["model"]);
1311
+ return {
1312
+ ...commandMeta(command),
1313
+ type: value.type,
1314
+ botId: identifier(command.botId, "botId"),
1315
+ assignment: assignmentTarget(command.assignment, "assignment"),
1316
+ model: command.model === undefined ? undefined : model(command.model),
1317
+ };
1318
+ }
1319
+ case "bot/unassign-capability":
1320
+ case "bot/unbind-model": {
1321
+ const command = exactCommand(input, ["botId", "assignmentId"]);
1322
+ return {
1323
+ ...commandMeta(command),
1324
+ type: value.type,
1325
+ botId: identifier(command.botId, "botId"),
1326
+ assignmentId: identifier(command.assignmentId, "assignmentId"),
1327
+ };
1328
+ }
1329
+ default:
1330
+ throw new ConfigurationDecodeError("unknown configuration command");
1331
+ }
1332
+ }
1333
+
1334
+ export function decodeUserConfigurationReadRpcV1(
1335
+ input: unknown,
1336
+ ): UserConfigurationReadRpcV1 {
1337
+ const value = exactRecord(input, "User configuration read RPC", [
1338
+ "schemaVersion",
1339
+ "userId",
1340
+ ]);
1341
+ schemaVersion(value);
1342
+ return {
1343
+ schemaVersion: 1,
1344
+ userId: identifier(value.userId, "userId"),
1345
+ };
1346
+ }
1347
+
1348
+ export function decodeUserConfigurationExecuteRpcV1(
1349
+ input: unknown,
1350
+ ): UserConfigurationExecuteRpcV1 {
1351
+ const value = exactRecord(input, "User configuration execute RPC", [
1352
+ "schemaVersion",
1353
+ "userId",
1354
+ "command",
1355
+ ]);
1356
+ schemaVersion(value);
1357
+ const command = decodeConfigurationCommandV1(value.command);
1358
+ if ("botId" in command) {
1359
+ throw new ConfigurationDecodeError(
1360
+ "User configuration RPC requires a User command",
1361
+ );
1362
+ }
1363
+ return {
1364
+ schemaVersion: 1,
1365
+ userId: identifier(value.userId, "userId"),
1366
+ command,
1367
+ };
1368
+ }
1369
+
1370
+ export function decodeBotConfigurationReadRpcV1(
1371
+ input: unknown,
1372
+ ): BotConfigurationReadRpcV1 {
1373
+ const value = exactRecord(input, "Bot configuration read RPC", [
1374
+ "schemaVersion",
1375
+ "userId",
1376
+ "botId",
1377
+ ]);
1378
+ schemaVersion(value);
1379
+ return {
1380
+ schemaVersion: 1,
1381
+ userId: identifier(value.userId, "userId"),
1382
+ botId: identifier(value.botId, "botId"),
1383
+ };
1384
+ }
1385
+
1386
+ export function decodeBotConfigurationExecuteRpcV1(
1387
+ input: unknown,
1388
+ ): BotConfigurationExecuteRpcV1 {
1389
+ const value = exactRecord(input, "Bot configuration execute RPC", [
1390
+ "schemaVersion",
1391
+ "userId",
1392
+ "botId",
1393
+ "command",
1394
+ ]);
1395
+ schemaVersion(value);
1396
+ const botId = identifier(value.botId, "botId");
1397
+ const command = decodeConfigurationCommandV1(value.command);
1398
+ if (!("botId" in command)) {
1399
+ throw new ConfigurationDecodeError(
1400
+ "Bot configuration RPC requires a Bot command",
1401
+ );
1402
+ }
1403
+ if (command.botId !== botId) {
1404
+ throw new ConfigurationDecodeError(
1405
+ "Bot configuration command does not match its authority",
1406
+ );
1407
+ }
1408
+ return {
1409
+ schemaVersion: 1,
1410
+ userId: identifier(value.userId, "userId"),
1411
+ botId,
1412
+ command,
1413
+ };
1414
+ }
1415
+
1416
+ function safeJsonValue(value: unknown, label: string): JsonValue {
1417
+ if (
1418
+ value === null ||
1419
+ typeof value === "string" ||
1420
+ typeof value === "boolean" ||
1421
+ (typeof value === "number" && Number.isFinite(value))
1422
+ ) {
1423
+ return value as JsonValue;
1424
+ }
1425
+ if (Array.isArray(value)) {
1426
+ return value.map((item) => safeJsonValue(item, label));
1427
+ }
1428
+ if (typeof value === "object" && value !== null) {
1429
+ return Object.fromEntries(
1430
+ Object.entries(value).map(([key, item]) => [
1431
+ key,
1432
+ safeJsonValue(item, label),
1433
+ ]),
1434
+ );
1435
+ }
1436
+ throw new ConfigurationDecodeError(`${label} is not JSON`);
1437
+ }
1438
+
1439
+ /** Most setup values one Catalog install may carry. */
1440
+ const MAX_INSTALL_VALUES_V1 = 32;
1441
+ const MAX_INSTALL_VALUES_BYTES_V1 = 16_384;
1442
+
1443
+ /**
1444
+ * The `values` a Catalog install carries. Bounded and JSON-only, because they
1445
+ * become durable User state: the User Durable Object stores them on the
1446
+ * installation, and nothing here may become a prototype or a function.
1447
+ */
1448
+ function installValues(value: unknown): Record<string, JsonValue> {
1449
+ const values = record(value, "values");
1450
+ const entries = Object.entries(values);
1451
+ if (entries.length > MAX_INSTALL_VALUES_V1) {
1452
+ throw new ConfigurationDecodeError("values is too large");
1453
+ }
1454
+ const decoded = Object.fromEntries(
1455
+ entries.map(([key, item]) => [
1456
+ identifier(key, "values key"),
1457
+ safeJsonValue(item, `values.${key}`),
1458
+ ]),
1459
+ );
1460
+ const serialized = JSON.stringify(decoded);
1461
+ if (
1462
+ serialized === undefined ||
1463
+ serialized.length > MAX_INSTALL_VALUES_BYTES_V1
1464
+ ) {
1465
+ throw new ConfigurationDecodeError("values is too large");
1466
+ }
1467
+ return decoded;
1468
+ }
1469
+
1470
+ /**
1471
+ * The shape of a `user/set-package-settings` payload, before anything knows
1472
+ * which Package it is for.
1473
+ *
1474
+ * Only the shape: ids are identifiers, values are scalars, and the bag is
1475
+ * bounded. Whether a named setting exists, and whether its value satisfies the
1476
+ * schema the Package declared, is the User Durable Object's answer — it is the
1477
+ * authority that holds the installed version.
1478
+ */
1479
+ function packageSettingsPatch(
1480
+ value: unknown,
1481
+ ): Record<string, PackageSettingValueV1> {
1482
+ const values = record(value, "values");
1483
+ const entries = Object.entries(values);
1484
+ if (entries.length === 0) {
1485
+ throw new ConfigurationDecodeError("values names no setting");
1486
+ }
1487
+ if (entries.length > MAX_PACKAGE_SETTINGS_V1) {
1488
+ throw new ConfigurationDecodeError("values is too large");
1489
+ }
1490
+ return Object.fromEntries(
1491
+ entries.map(([key, item]) => {
1492
+ if (
1493
+ typeof item !== "string" &&
1494
+ typeof item !== "number" &&
1495
+ typeof item !== "boolean"
1496
+ ) {
1497
+ throw new ConfigurationDecodeError(`values.${key} is invalid`);
1498
+ }
1499
+ if (typeof item === "number" && !Number.isFinite(item)) {
1500
+ throw new ConfigurationDecodeError(`values.${key} is invalid`);
1501
+ }
1502
+ if (
1503
+ typeof item === "string" &&
1504
+ item.length > MAX_PACKAGE_SETTING_TEXT_V1
1505
+ ) {
1506
+ throw new ConfigurationDecodeError(`values.${key} is too long`);
1507
+ }
1508
+ return [identifier(key, "values key"), item];
1509
+ }),
1510
+ );
1511
+ }
1512
+
1513
+ function viewRevision(value: unknown): number {
1514
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
1515
+ throw new ConfigurationDecodeError("configuration revision is invalid");
1516
+ }
1517
+ return value as number;
1518
+ }
1519
+
1520
+ function packageInstallation(value: unknown): PackageInstallationView {
1521
+ const installation = exactRecord(
1522
+ value,
1523
+ "Package installation",
1524
+ ["packageId", "version", "state"],
1525
+ ["failure", "catalogId", "catalogGeneration", "provenance", "values"],
1526
+ );
1527
+ if (
1528
+ installation.state !== "installed" &&
1529
+ installation.state !== "disabled" &&
1530
+ installation.state !== "failed"
1531
+ ) {
1532
+ throw new ConfigurationDecodeError("Package installation state is invalid");
1533
+ }
1534
+ if (
1535
+ installation.provenance !== undefined &&
1536
+ installation.provenance !== "first-party" &&
1537
+ installation.provenance !== "catalog"
1538
+ ) {
1539
+ throw new ConfigurationDecodeError(
1540
+ "Package installation provenance is invalid",
1541
+ );
1542
+ }
1543
+ return {
1544
+ packageId: identifier(installation.packageId, "packageId"),
1545
+ version: text(installation.version, "version", 100),
1546
+ state: installation.state,
1547
+ failure: optionalText(installation.failure, "failure", 2_000),
1548
+ ...(installation.catalogId === undefined
1549
+ ? {}
1550
+ : { catalogId: identifier(installation.catalogId, "catalogId") }),
1551
+ ...(installation.catalogGeneration === undefined
1552
+ ? {}
1553
+ : {
1554
+ catalogGeneration: identifier(
1555
+ installation.catalogGeneration,
1556
+ "catalogGeneration",
1557
+ ),
1558
+ }),
1559
+ ...(installation.provenance === undefined
1560
+ ? {}
1561
+ : { provenance: installation.provenance }),
1562
+ ...(installation.values === undefined
1563
+ ? {}
1564
+ : { values: installValues(installation.values) }),
1565
+ };
1566
+ }
1567
+
1568
+ function connectionView(value: unknown): ConnectionView {
1569
+ const connection = exactRecord(
1570
+ value,
1571
+ "Connection",
1572
+ [
1573
+ "connectionId",
1574
+ "packageId",
1575
+ "connectionTypeId",
1576
+ "displayName",
1577
+ "state",
1578
+ "safeMetadata",
1579
+ ],
1580
+ [
1581
+ "failure",
1582
+ "generation",
1583
+ "providerType",
1584
+ "authorization",
1585
+ "modelCatalog",
1586
+ "settings",
1587
+ "pendingAuthorization",
1588
+ ],
1589
+ );
1590
+ const states: ConnectionView["state"][] = [
1591
+ "authorizing",
1592
+ "ready",
1593
+ "disabled",
1594
+ "revoking",
1595
+ "revoked",
1596
+ "reconciliation-required",
1597
+ "failed",
1598
+ ];
1599
+ if (!states.includes(connection.state as ConnectionView["state"])) {
1600
+ throw new ConfigurationDecodeError("Connection state is invalid");
1601
+ }
1602
+ const safeMetadata = record(connection.safeMetadata, "safeMetadata");
1603
+ const settings =
1604
+ connection.settings === undefined
1605
+ ? undefined
1606
+ : record(connection.settings, "settings");
1607
+ return {
1608
+ connectionId: identifier(connection.connectionId, "connectionId"),
1609
+ packageId: identifier(connection.packageId, "packageId"),
1610
+ connectionTypeId: identifier(
1611
+ connection.connectionTypeId,
1612
+ "connectionTypeId",
1613
+ ),
1614
+ displayName: text(connection.displayName, "displayName", 200),
1615
+ state: connection.state as ConnectionView["state"],
1616
+ generation: optionalText(connection.generation, "generation", 128),
1617
+ providerType: optionalText(connection.providerType, "providerType", 128),
1618
+ ...(connection.authorization === undefined
1619
+ ? {}
1620
+ : {
1621
+ authorization: decodeConnectionAuthorizationViewV1(
1622
+ connection.authorization,
1623
+ ),
1624
+ }),
1625
+ ...(connection.modelCatalog === undefined
1626
+ ? {}
1627
+ : {
1628
+ modelCatalog: decodeConnectionModelCatalogV1(connection.modelCatalog),
1629
+ }),
1630
+ ...(settings === undefined
1631
+ ? {}
1632
+ : {
1633
+ settings: Object.fromEntries(
1634
+ Object.entries(settings).map(([key, item]) => [
1635
+ key,
1636
+ safeJsonValue(item, `settings.${key}`),
1637
+ ]),
1638
+ ),
1639
+ }),
1640
+ safeMetadata: Object.fromEntries(
1641
+ Object.entries(safeMetadata).map(([key, item]) => [
1642
+ key,
1643
+ safeJsonValue(item, `safeMetadata.${key}`),
1644
+ ]),
1645
+ ),
1646
+ failure: optionalText(connection.failure, "failure", 2_000),
1647
+ ...(connection.pendingAuthorization === undefined
1648
+ ? {}
1649
+ : {
1650
+ pendingAuthorization: decodePendingAuthorizationV1(
1651
+ connection.pendingAuthorization,
1652
+ ),
1653
+ }),
1654
+ };
1655
+ }
1656
+
1657
+ /**
1658
+ * The pending decision, decoded strictly — and refused outright if it carries
1659
+ * anything that looks like a redirect. The rule that a Bot never hands its
1660
+ * User a link it authored is worth an assertion rather than a convention.
1661
+ */
1662
+ export function decodePendingAuthorizationV1(
1663
+ input: unknown,
1664
+ ): PendingAuthorizationV1 {
1665
+ const value = exactRecord(input, "pendingAuthorization", [
1666
+ "reason",
1667
+ "since",
1668
+ "connectionId",
1669
+ "label",
1670
+ ]);
1671
+ return {
1672
+ reason: text(value.reason, "pendingAuthorization.reason", 64),
1673
+ since: text(value.since, "pendingAuthorization.since", 64),
1674
+ connectionId: identifier(
1675
+ value.connectionId,
1676
+ "pendingAuthorization.connectionId",
1677
+ ),
1678
+ label: text(value.label, "pendingAuthorization.label", 200),
1679
+ };
1680
+ }
1681
+
1682
+ export function decodeCapabilityAssignmentV1(
1683
+ value: unknown,
1684
+ ): CapabilityAssignmentView {
1685
+ const assignment = exactRecord(
1686
+ value,
1687
+ "Capability Assignment",
1688
+ ["assignmentId", "packageId", "capabilityId", "state"],
1689
+ ["connectionId"],
1690
+ );
1691
+ if (
1692
+ assignment.state !== "enabled" &&
1693
+ assignment.state !== "disabled" &&
1694
+ assignment.state !== "unavailable"
1695
+ ) {
1696
+ throw new ConfigurationDecodeError(
1697
+ "Capability Assignment state is invalid",
1698
+ );
1699
+ }
1700
+ return {
1701
+ assignmentId: identifier(assignment.assignmentId, "assignmentId"),
1702
+ packageId: identifier(assignment.packageId, "assignment.packageId"),
1703
+ capabilityId: identifier(
1704
+ assignment.capabilityId,
1705
+ "assignment.capabilityId",
1706
+ ),
1707
+ connectionId:
1708
+ assignment.connectionId === undefined
1709
+ ? undefined
1710
+ : identifier(assignment.connectionId, "assignment.connectionId"),
1711
+ state: assignment.state,
1712
+ };
1713
+ }
1714
+
1715
+ function assignmentTarget(
1716
+ value: unknown,
1717
+ label = "assignment target",
1718
+ ): Omit<CapabilityAssignmentView, "state"> {
1719
+ const assignment = exactRecord(
1720
+ value,
1721
+ label,
1722
+ ["assignmentId", "packageId", "capabilityId"],
1723
+ ["connectionId"],
1724
+ );
1725
+ return {
1726
+ assignmentId: identifier(assignment.assignmentId, `${label}.assignmentId`),
1727
+ packageId: identifier(assignment.packageId, `${label}.packageId`),
1728
+ capabilityId: identifier(assignment.capabilityId, `${label}.capabilityId`),
1729
+ connectionId:
1730
+ assignment.connectionId === undefined
1731
+ ? undefined
1732
+ : identifier(assignment.connectionId, `${label}.connectionId`),
1733
+ };
1734
+ }
1735
+
1736
+ function assignmentOperation(
1737
+ value: unknown,
1738
+ ): CapabilityAssignmentOperationViewV1 {
1739
+ const operation = exactRecord(
1740
+ value,
1741
+ "Assignment operation",
1742
+ ["commandId", "kind", "assignmentId", "state"],
1743
+ ["target"],
1744
+ );
1745
+ if (
1746
+ operation.kind !== "assigning" &&
1747
+ operation.kind !== "replacing" &&
1748
+ operation.kind !== "unassigning"
1749
+ ) {
1750
+ throw new ConfigurationDecodeError("Assignment operation kind is invalid");
1751
+ }
1752
+ if (operation.state !== "pending" && operation.state !== "retrying") {
1753
+ throw new ConfigurationDecodeError("Assignment operation state is invalid");
1754
+ }
1755
+ if (operation.kind !== "unassigning" && operation.target === undefined) {
1756
+ throw new ConfigurationDecodeError(
1757
+ "Assignment operation target is required",
1758
+ );
1759
+ }
1760
+ if (operation.kind === "unassigning" && operation.target !== undefined) {
1761
+ throw new ConfigurationDecodeError(
1762
+ "Unassign operation cannot have a target",
1763
+ );
1764
+ }
1765
+ return {
1766
+ commandId: identifier(operation.commandId, "operation.commandId"),
1767
+ kind: operation.kind,
1768
+ assignmentId: identifier(operation.assignmentId, "operation.assignmentId"),
1769
+ state: operation.state,
1770
+ target:
1771
+ operation.target === undefined
1772
+ ? undefined
1773
+ : assignmentTarget(operation.target),
1774
+ };
1775
+ }
1776
+
1777
+ function schemaVersion(value: Record<string, unknown>): void {
1778
+ if (value.schemaVersion !== 1) {
1779
+ throw new ConfigurationDecodeError("unsupported configuration schema");
1780
+ }
1781
+ }
1782
+
1783
+ export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
1784
+ const value = exactRecord(
1785
+ input,
1786
+ "User settings",
1787
+ ["schemaVersion", "revision", "profile", "packages", "connections"],
1788
+ [
1789
+ "newBotModelTemplate",
1790
+ "newBotModelTemplateSource",
1791
+ "catalogGeneration",
1792
+ "catalogIndexHash",
1793
+ ],
1794
+ );
1795
+ schemaVersion(value);
1796
+ const profile = exactRecord(value.profile, "profile", ["name"], ["email"]);
1797
+ if (
1798
+ !Array.isArray(value.packages) ||
1799
+ !Array.isArray(value.connections) ||
1800
+ value.connections.length > MAX_USER_CONNECTIONS_V1
1801
+ ) {
1802
+ throw new ConfigurationDecodeError(
1803
+ "User settings Packages and Connections must be bounded arrays",
1804
+ );
1805
+ }
1806
+ if (
1807
+ value.newBotModelTemplateSource !== undefined &&
1808
+ value.newBotModelTemplateSource !== "user" &&
1809
+ value.newBotModelTemplateSource !== "auto"
1810
+ ) {
1811
+ throw new ConfigurationDecodeError(
1812
+ "new Bot model source must be user or auto",
1813
+ );
1814
+ }
1815
+ if (
1816
+ value.newBotModelTemplate !== undefined &&
1817
+ value.newBotModelTemplateSource === undefined
1818
+ ) {
1819
+ throw new ConfigurationDecodeError(
1820
+ "a new Bot model must record its source",
1821
+ );
1822
+ }
1823
+ if (
1824
+ value.newBotModelTemplate === undefined &&
1825
+ value.newBotModelTemplateSource === "auto"
1826
+ ) {
1827
+ throw new ConfigurationDecodeError(
1828
+ "an automatic new Bot model must name a model",
1829
+ );
1830
+ }
1831
+ return {
1832
+ schemaVersion: 1,
1833
+ revision: viewRevision(value.revision),
1834
+ profile: {
1835
+ name: text(profile.name, "profile.name", 100),
1836
+ email: optionalText(profile.email, "profile.email", 320),
1837
+ },
1838
+ packages: value.packages.map(packageInstallation),
1839
+ connections: value.connections.map(connectionView),
1840
+ newBotModelTemplate:
1841
+ value.newBotModelTemplate === undefined
1842
+ ? undefined
1843
+ : model(value.newBotModelTemplate),
1844
+ newBotModelTemplateSource: value.newBotModelTemplateSource,
1845
+ // The pin is optional — a deployment with no Catalog has none — but never
1846
+ // half present: one field alone is a corrupt pin, not a pin.
1847
+ ...(value.catalogGeneration === undefined &&
1848
+ value.catalogIndexHash === undefined
1849
+ ? {}
1850
+ : {
1851
+ catalogGeneration: identifier(
1852
+ value.catalogGeneration,
1853
+ "catalogGeneration",
1854
+ ),
1855
+ catalogIndexHash: text(
1856
+ value.catalogIndexHash,
1857
+ "catalogIndexHash",
1858
+ 64,
1859
+ ),
1860
+ }),
1861
+ };
1862
+ }
1863
+
1864
+ export function decodeBotSettingsViewV1(input: unknown): BotSettingsViewV1 {
1865
+ const value = exactRecord(
1866
+ input,
1867
+ "Bot settings",
1868
+ [
1869
+ "schemaVersion",
1870
+ "botId",
1871
+ "revision",
1872
+ "profile",
1873
+ "notifications",
1874
+ "assignments",
1875
+ "assignmentOperations",
1876
+ ],
1877
+ ["model"],
1878
+ );
1879
+ schemaVersion(value);
1880
+ if (
1881
+ !Array.isArray(value.assignments) ||
1882
+ !Array.isArray(value.assignmentOperations)
1883
+ ) {
1884
+ throw new ConfigurationDecodeError(
1885
+ "Bot settings Assignments and operations must be arrays",
1886
+ );
1887
+ }
1888
+ return {
1889
+ schemaVersion: 1,
1890
+ botId: identifier(value.botId, "botId"),
1891
+ revision: viewRevision(value.revision),
1892
+ profile: botProfile(value.profile),
1893
+ notifications: notifications(value.notifications),
1894
+ assignments: value.assignments.map(decodeCapabilityAssignmentV1),
1895
+ assignmentOperations: value.assignmentOperations.map(assignmentOperation),
1896
+ model: value.model === undefined ? undefined : model(value.model),
1897
+ };
1898
+ }
1899
+
1900
+ export function decodeConfigurationViewV1(input: unknown): ConfigurationViewV1 {
1901
+ const value = record(input, "configuration");
1902
+ return "botId" in value
1903
+ ? decodeBotSettingsViewV1(value)
1904
+ : decodeUserSettingsViewV1(value);
1905
+ }
1906
+
1907
+ export function decodeOperationReceiptV1(input: unknown): OperationReceiptV1 {
1908
+ const candidate = record(input, "operation receipt");
1909
+ if (
1910
+ candidate.status !== "pending" &&
1911
+ candidate.status !== "applied" &&
1912
+ candidate.status !== "rejected"
1913
+ ) {
1914
+ throw new ConfigurationDecodeError("operation receipt status is invalid");
1915
+ }
1916
+ const value = exactRecord(
1917
+ input,
1918
+ "operation receipt",
1919
+ candidate.status === "rejected"
1920
+ ? ["schemaVersion", "commandId", "revision", "status", "failure"]
1921
+ : ["schemaVersion", "commandId", "revision", "status"],
1922
+ );
1923
+ schemaVersion(value);
1924
+ const receipt = {
1925
+ schemaVersion: 1,
1926
+ commandId: identifier(value.commandId, "commandId"),
1927
+ revision: viewRevision(value.revision),
1928
+ } as const;
1929
+ if (value.status === "rejected") {
1930
+ return {
1931
+ ...receipt,
1932
+ status: "rejected",
1933
+ failure: text(value.failure, "operation receipt failure", 1_000),
1934
+ };
1935
+ }
1936
+ return {
1937
+ ...receipt,
1938
+ status: value.status as "pending" | "applied",
1939
+ };
1940
+ }
1941
+
1942
+ // ---------------------------------------------------------------------------
1943
+ // Composition generations: the redacted Bot-scoped projection of the durable
1944
+ // `CompositionGenerationV1` records the Bot Durable Object owns. Artifact bytes
1945
+ // never cross this seam; a member carries its content hash, and its recorded
1946
+ // source text only when the Bot object holds an authorship record for it.
1947
+ // ---------------------------------------------------------------------------
1948
+
1949
+ export const MAX_COMPOSITION_GENERATION_PAGE_V1 = 50;
1950
+ export const MAX_COMPOSITION_MEMBERS_V1 = 512;
1951
+ export const MAX_COMPOSITION_MEMBER_SOURCE_V1 = 262_144;
1952
+
1953
+ export type CompositionGenerationStatusViewV1 =
1954
+ "pending" | "active" | "superseded" | "failed" | "quarantined";
1955
+
1956
+ const COMPOSITION_GENERATION_STATUSES_V1: readonly CompositionGenerationStatusViewV1[] =
1957
+ ["pending", "active", "superseded", "failed", "quarantined"];
1958
+
1959
+ export type CompositionProvenanceViewV1 =
1960
+ | { kind: "first-party" }
1961
+ | { kind: "user"; userId: string; authoredAt: string }
1962
+ | {
1963
+ kind: "bot";
1964
+ botId: string;
1965
+ sessionId: string;
1966
+ turnId: string;
1967
+ runId: string;
1968
+ authoredAt: string;
1969
+ };
1970
+
1971
+ export type CompositionOriginViewV1 =
1972
+ | { kind: "bootstrap" }
1973
+ | { kind: "bot-authored"; runId: string; sessionId: string; turnId: string }
1974
+ | { kind: "user-install"; userId: string }
1975
+ | { kind: "revert"; revertsTo: string; userId: string };
1976
+
1977
+ export interface CompositionMemberViewV1 {
1978
+ packageId: string;
1979
+ version: string;
1980
+ provenance: CompositionProvenanceViewV1;
1981
+ /** Artifact identity only; the bundled bytes never reach a client. */
1982
+ contentHash?: string;
1983
+ /** Recorded source text for an isolate member, when authorship holds it. */
1984
+ source?: string;
1985
+ }
1986
+
1987
+ export type CompositionFailurePhaseViewV1 =
1988
+ "resolve" | "bundle" | "mount" | "health";
1989
+
1990
+ const COMPOSITION_FAILURE_PHASES_V1: readonly CompositionFailurePhaseViewV1[] =
1991
+ ["resolve", "bundle", "mount", "health"];
1992
+
1993
+ export const MAX_COMPOSITION_FAILURE_PAGE_V1 = 32;
1994
+
1995
+ /**
1996
+ * One recorded activation failure. Diagnostics stay durable-side: they name
1997
+ * artifact content hashes and loader identities, which never cross this seam.
1998
+ */
1999
+ export interface CompositionFailureViewV1 {
2000
+ attempt: number;
2001
+ at: string;
2002
+ phase: CompositionFailurePhaseViewV1;
2003
+ message: string;
2004
+ }
2005
+
2006
+ export interface CompositionQuarantineViewV1 {
2007
+ quarantinedAt: string;
2008
+ reason: string;
2009
+ failures: number;
2010
+ }
2011
+
2012
+ export interface CompositionGenerationViewV1 {
2013
+ schemaVersion: 1;
2014
+ botId: string;
2015
+ generationId: string;
2016
+ createdAt: string;
2017
+ status: CompositionGenerationStatusViewV1;
2018
+ origin: CompositionOriginViewV1;
2019
+ parentGenerationId?: string;
2020
+ isCurrent: boolean;
2021
+ members: CompositionMemberViewV1[];
2022
+ /** Oldest attempt first; empty for a generation that never failed. */
2023
+ failures: CompositionFailureViewV1[];
2024
+ /** Present once three consecutive failures quarantined this generation. */
2025
+ quarantine?: CompositionQuarantineViewV1;
2026
+ }
2027
+
2028
+ export interface CompositionGenerationListViewV1 {
2029
+ schemaVersion: 1;
2030
+ botId: string;
2031
+ currentGenerationId: string;
2032
+ generations: CompositionGenerationViewV1[];
2033
+ cursor?: string;
2034
+ }
2035
+
2036
+ export interface CompositionMemberVersionV1 {
2037
+ version: string;
2038
+ contentHash?: string;
2039
+ }
2040
+
2041
+ export interface CompositionMemberDiffV1 {
2042
+ packageId: string;
2043
+ change: "added" | "removed" | "changed" | "unchanged";
2044
+ from?: CompositionMemberVersionV1;
2045
+ to?: CompositionMemberVersionV1;
2046
+ }
2047
+
2048
+ export interface CompositionDiffV1 {
2049
+ fromGenerationId: string;
2050
+ toGenerationId: string;
2051
+ members: CompositionMemberDiffV1[];
2052
+ }
2053
+
2054
+ export interface RevertCompositionCommandV1 {
2055
+ schemaVersion: 1;
2056
+ type: "composition/revert";
2057
+ commandId: string;
2058
+ botId: string;
2059
+ toGenerationId: string;
2060
+ /** Optimistic check, mirroring `expectedRevision` on configuration commands. */
2061
+ expectedGenerationId: string;
2062
+ }
2063
+
2064
+ export type CompositionCommandReceiptV1 =
2065
+ | {
2066
+ schemaVersion: 1;
2067
+ commandId: string;
2068
+ status: "applied";
2069
+ generationId: string;
2070
+ currentGenerationId: string;
2071
+ }
2072
+ | {
2073
+ schemaVersion: 1;
2074
+ commandId: string;
2075
+ status: "rejected";
2076
+ failure: string;
2077
+ currentGenerationId: string;
2078
+ };
2079
+
2080
+ export function decodeCompositionGenerationIdV1(
2081
+ value: unknown,
2082
+ label = "generationId",
2083
+ ): string {
2084
+ if (!isRpcIdentifier(value)) {
2085
+ throw new ConfigurationDecodeError(`${label} is invalid`);
2086
+ }
2087
+ return value;
2088
+ }
2089
+
2090
+ function compositionTimestamp(value: unknown, label: string): string {
2091
+ const candidate = text(value, label, 64);
2092
+ if (!Number.isFinite(Date.parse(candidate))) {
2093
+ throw new ConfigurationDecodeError(`${label} is invalid`);
2094
+ }
2095
+ return candidate;
2096
+ }
2097
+
2098
+ function compositionHash(value: unknown, label: string): string {
2099
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
2100
+ throw new ConfigurationDecodeError(`${label} is invalid`);
2101
+ }
2102
+ return value;
2103
+ }
2104
+
2105
+ function compositionProvenanceView(
2106
+ input: unknown,
2107
+ ): CompositionProvenanceViewV1 {
2108
+ const kind = record(input, "Composition provenance").kind;
2109
+ if (kind === "first-party") {
2110
+ exactRecord(input, "Composition provenance", ["kind"]);
2111
+ return { kind: "first-party" };
2112
+ }
2113
+ if (kind === "user") {
2114
+ const value = exactRecord(input, "Composition provenance", [
2115
+ "kind",
2116
+ "userId",
2117
+ "authoredAt",
2118
+ ]);
2119
+ return {
2120
+ kind: "user",
2121
+ userId: text(value.userId, "Composition provenance userId", 256),
2122
+ authoredAt: compositionTimestamp(
2123
+ value.authoredAt,
2124
+ "Composition provenance authoredAt",
2125
+ ),
2126
+ };
2127
+ }
2128
+ if (kind === "bot") {
2129
+ const value = exactRecord(input, "Composition provenance", [
2130
+ "kind",
2131
+ "botId",
2132
+ "sessionId",
2133
+ "turnId",
2134
+ "runId",
2135
+ "authoredAt",
2136
+ ]);
2137
+ return {
2138
+ kind: "bot",
2139
+ botId: decodeBotIdV1(value.botId),
2140
+ sessionId: text(value.sessionId, "Composition provenance sessionId", 257),
2141
+ turnId: text(value.turnId, "Composition provenance turnId", 128),
2142
+ runId: text(value.runId, "Composition provenance runId", 128),
2143
+ authoredAt: compositionTimestamp(
2144
+ value.authoredAt,
2145
+ "Composition provenance authoredAt",
2146
+ ),
2147
+ };
2148
+ }
2149
+ throw new ConfigurationDecodeError("Composition provenance kind is invalid");
2150
+ }
2151
+
2152
+ function compositionOriginView(input: unknown): CompositionOriginViewV1 {
2153
+ const kind = record(input, "Composition origin").kind;
2154
+ if (kind === "bootstrap") {
2155
+ exactRecord(input, "Composition origin", ["kind"]);
2156
+ return { kind: "bootstrap" };
2157
+ }
2158
+ if (kind === "bot-authored") {
2159
+ const value = exactRecord(input, "Composition origin", [
2160
+ "kind",
2161
+ "runId",
2162
+ "sessionId",
2163
+ "turnId",
2164
+ ]);
2165
+ return {
2166
+ kind: "bot-authored",
2167
+ runId: text(value.runId, "Composition origin runId", 128),
2168
+ sessionId: text(value.sessionId, "Composition origin sessionId", 257),
2169
+ turnId: text(value.turnId, "Composition origin turnId", 128),
2170
+ };
2171
+ }
2172
+ if (kind === "user-install") {
2173
+ const value = exactRecord(input, "Composition origin", ["kind", "userId"]);
2174
+ return {
2175
+ kind: "user-install",
2176
+ userId: text(value.userId, "Composition origin userId", 256),
2177
+ };
2178
+ }
2179
+ if (kind === "revert") {
2180
+ const value = exactRecord(input, "Composition origin", [
2181
+ "kind",
2182
+ "revertsTo",
2183
+ "userId",
2184
+ ]);
2185
+ return {
2186
+ kind: "revert",
2187
+ revertsTo: decodeCompositionGenerationIdV1(
2188
+ value.revertsTo,
2189
+ "Composition origin revertsTo",
2190
+ ),
2191
+ userId: text(value.userId, "Composition origin userId", 256),
2192
+ };
2193
+ }
2194
+ throw new ConfigurationDecodeError("Composition origin kind is invalid");
2195
+ }
2196
+
2197
+ function compositionMemberView(input: unknown): CompositionMemberViewV1 {
2198
+ const value = exactRecord(
2199
+ input,
2200
+ "Composition member",
2201
+ ["packageId", "version", "provenance"],
2202
+ ["contentHash", "source"],
2203
+ );
2204
+ if (
2205
+ value.source !== undefined &&
2206
+ (typeof value.source !== "string" ||
2207
+ value.source.length === 0 ||
2208
+ value.source.length > MAX_COMPOSITION_MEMBER_SOURCE_V1)
2209
+ ) {
2210
+ throw new ConfigurationDecodeError("Composition member source is invalid");
2211
+ }
2212
+ return {
2213
+ packageId: identifier(value.packageId, "Composition member packageId"),
2214
+ version: text(value.version, "Composition member version", 64),
2215
+ provenance: compositionProvenanceView(value.provenance),
2216
+ ...(value.contentHash === undefined
2217
+ ? {}
2218
+ : {
2219
+ contentHash: compositionHash(
2220
+ value.contentHash,
2221
+ "Composition member contentHash",
2222
+ ),
2223
+ }),
2224
+ ...(value.source === undefined ? {} : { source: value.source as string }),
2225
+ };
2226
+ }
2227
+
2228
+ function compositionFailureView(input: unknown): CompositionFailureViewV1 {
2229
+ const value = exactRecord(input, "Composition failure", [
2230
+ "attempt",
2231
+ "at",
2232
+ "phase",
2233
+ "message",
2234
+ ]);
2235
+ if (
2236
+ !Number.isSafeInteger(value.attempt) ||
2237
+ (value.attempt as number) < 1 ||
2238
+ (value.attempt as number) > 1_000
2239
+ ) {
2240
+ throw new ConfigurationDecodeError(
2241
+ "Composition failure attempt is invalid",
2242
+ );
2243
+ }
2244
+ if (
2245
+ !COMPOSITION_FAILURE_PHASES_V1.includes(
2246
+ value.phase as CompositionFailurePhaseViewV1,
2247
+ )
2248
+ ) {
2249
+ throw new ConfigurationDecodeError("Composition failure phase is invalid");
2250
+ }
2251
+ return {
2252
+ attempt: value.attempt as number,
2253
+ at: compositionTimestamp(value.at, "Composition failure at"),
2254
+ phase: value.phase as CompositionFailurePhaseViewV1,
2255
+ message: text(value.message, "Composition failure message", 2_000),
2256
+ };
2257
+ }
2258
+
2259
+ function compositionQuarantineView(
2260
+ input: unknown,
2261
+ ): CompositionQuarantineViewV1 {
2262
+ const value = exactRecord(input, "Composition quarantine", [
2263
+ "quarantinedAt",
2264
+ "reason",
2265
+ "failures",
2266
+ ]);
2267
+ if (
2268
+ !Number.isSafeInteger(value.failures) ||
2269
+ (value.failures as number) < 1 ||
2270
+ (value.failures as number) > 1_000
2271
+ ) {
2272
+ throw new ConfigurationDecodeError(
2273
+ "Composition quarantine failures is invalid",
2274
+ );
2275
+ }
2276
+ return {
2277
+ quarantinedAt: compositionTimestamp(
2278
+ value.quarantinedAt,
2279
+ "Composition quarantine quarantinedAt",
2280
+ ),
2281
+ reason: text(value.reason, "Composition quarantine reason", 2_000),
2282
+ failures: value.failures as number,
2283
+ };
2284
+ }
2285
+
2286
+ export function decodeCompositionGenerationViewV1(
2287
+ input: unknown,
2288
+ ): CompositionGenerationViewV1 {
2289
+ const value = exactRecord(
2290
+ input,
2291
+ "Composition generation",
2292
+ [
2293
+ "schemaVersion",
2294
+ "botId",
2295
+ "generationId",
2296
+ "createdAt",
2297
+ "status",
2298
+ "origin",
2299
+ "isCurrent",
2300
+ "members",
2301
+ "failures",
2302
+ ],
2303
+ ["parentGenerationId", "quarantine"],
2304
+ );
2305
+ if (
2306
+ !Array.isArray(value.failures) ||
2307
+ value.failures.length > MAX_COMPOSITION_FAILURE_PAGE_V1
2308
+ ) {
2309
+ throw new ConfigurationDecodeError(
2310
+ "Composition generation failures are invalid",
2311
+ );
2312
+ }
2313
+ schemaVersion(value);
2314
+ if (
2315
+ !COMPOSITION_GENERATION_STATUSES_V1.includes(
2316
+ value.status as CompositionGenerationStatusViewV1,
2317
+ )
2318
+ ) {
2319
+ throw new ConfigurationDecodeError(
2320
+ "Composition generation status is invalid",
2321
+ );
2322
+ }
2323
+ if (typeof value.isCurrent !== "boolean") {
2324
+ throw new ConfigurationDecodeError(
2325
+ "Composition generation isCurrent is invalid",
2326
+ );
2327
+ }
2328
+ if (
2329
+ !Array.isArray(value.members) ||
2330
+ value.members.length > MAX_COMPOSITION_MEMBERS_V1
2331
+ ) {
2332
+ throw new ConfigurationDecodeError(
2333
+ "Composition generation members are invalid",
2334
+ );
2335
+ }
2336
+ const members = value.members.map(compositionMemberView);
2337
+ if (
2338
+ new Set(members.map((member) => member.packageId)).size !== members.length
2339
+ )
2340
+ throw new ConfigurationDecodeError(
2341
+ "Composition generation members are invalid",
2342
+ );
2343
+ return {
2344
+ schemaVersion: 1,
2345
+ botId: decodeBotIdV1(value.botId),
2346
+ generationId: decodeCompositionGenerationIdV1(value.generationId),
2347
+ createdAt: compositionTimestamp(
2348
+ value.createdAt,
2349
+ "Composition generation createdAt",
2350
+ ),
2351
+ status: value.status as CompositionGenerationStatusViewV1,
2352
+ origin: compositionOriginView(value.origin),
2353
+ isCurrent: value.isCurrent,
2354
+ members,
2355
+ failures: value.failures.map(compositionFailureView),
2356
+ ...(value.quarantine === undefined
2357
+ ? {}
2358
+ : { quarantine: compositionQuarantineView(value.quarantine) }),
2359
+ ...(value.parentGenerationId === undefined
2360
+ ? {}
2361
+ : {
2362
+ parentGenerationId: decodeCompositionGenerationIdV1(
2363
+ value.parentGenerationId,
2364
+ "parentGenerationId",
2365
+ ),
2366
+ }),
2367
+ };
2368
+ }
2369
+
2370
+ export function decodeCompositionGenerationListViewV1(
2371
+ input: unknown,
2372
+ ): CompositionGenerationListViewV1 {
2373
+ const value = exactRecord(
2374
+ input,
2375
+ "Composition generation list",
2376
+ ["schemaVersion", "botId", "currentGenerationId", "generations"],
2377
+ ["cursor"],
2378
+ );
2379
+ schemaVersion(value);
2380
+ if (
2381
+ !Array.isArray(value.generations) ||
2382
+ value.generations.length > MAX_COMPOSITION_GENERATION_PAGE_V1
2383
+ ) {
2384
+ throw new ConfigurationDecodeError(
2385
+ "Composition generation list is invalid",
2386
+ );
2387
+ }
2388
+ const botId = decodeBotIdV1(value.botId);
2389
+ const generations = value.generations.map(decodeCompositionGenerationViewV1);
2390
+ if (generations.some((generation) => generation.botId !== botId)) {
2391
+ throw new ConfigurationDecodeError(
2392
+ "Composition generation list is invalid",
2393
+ );
2394
+ }
2395
+ return {
2396
+ schemaVersion: 1,
2397
+ botId,
2398
+ currentGenerationId: decodeCompositionGenerationIdV1(
2399
+ value.currentGenerationId,
2400
+ "currentGenerationId",
2401
+ ),
2402
+ generations,
2403
+ ...(value.cursor === undefined
2404
+ ? {}
2405
+ : { cursor: text(value.cursor, "cursor", 512) }),
2406
+ };
2407
+ }
2408
+
2409
+ export function decodeRevertCompositionCommandV1(
2410
+ input: unknown,
2411
+ ): RevertCompositionCommandV1 {
2412
+ const value = exactRecord(input, "Composition revert command", [
2413
+ "schemaVersion",
2414
+ "type",
2415
+ "commandId",
2416
+ "botId",
2417
+ "toGenerationId",
2418
+ "expectedGenerationId",
2419
+ ]);
2420
+ schemaVersion(value);
2421
+ if (value.type !== "composition/revert") {
2422
+ throw new ConfigurationDecodeError(
2423
+ "unsupported Composition revert command",
2424
+ );
2425
+ }
2426
+ const toGenerationId = decodeCompositionGenerationIdV1(
2427
+ value.toGenerationId,
2428
+ "toGenerationId",
2429
+ );
2430
+ const expectedGenerationId = decodeCompositionGenerationIdV1(
2431
+ value.expectedGenerationId,
2432
+ "expectedGenerationId",
2433
+ );
2434
+ if (toGenerationId === expectedGenerationId) {
2435
+ throw new ConfigurationDecodeError(
2436
+ "Composition revert command targets the current generation",
2437
+ );
2438
+ }
2439
+ return {
2440
+ schemaVersion: 1,
2441
+ type: "composition/revert",
2442
+ commandId: connectionIdentifier(value.commandId, "commandId"),
2443
+ botId: decodeBotIdV1(value.botId),
2444
+ toGenerationId,
2445
+ expectedGenerationId,
2446
+ };
2447
+ }
2448
+
2449
+ export function decodeCompositionCommandReceiptV1(
2450
+ input: unknown,
2451
+ ): CompositionCommandReceiptV1 {
2452
+ const candidate = record(input, "Composition command receipt");
2453
+ if (candidate.status !== "applied" && candidate.status !== "rejected") {
2454
+ throw new ConfigurationDecodeError(
2455
+ "Composition command receipt status is invalid",
2456
+ );
2457
+ }
2458
+ const value = exactRecord(
2459
+ input,
2460
+ "Composition command receipt",
2461
+ candidate.status === "applied"
2462
+ ? [
2463
+ "schemaVersion",
2464
+ "commandId",
2465
+ "status",
2466
+ "generationId",
2467
+ "currentGenerationId",
2468
+ ]
2469
+ : [
2470
+ "schemaVersion",
2471
+ "commandId",
2472
+ "status",
2473
+ "failure",
2474
+ "currentGenerationId",
2475
+ ],
2476
+ );
2477
+ schemaVersion(value);
2478
+ const shared = {
2479
+ schemaVersion: 1,
2480
+ commandId: connectionIdentifier(value.commandId, "commandId"),
2481
+ currentGenerationId: decodeCompositionGenerationIdV1(
2482
+ value.currentGenerationId,
2483
+ "currentGenerationId",
2484
+ ),
2485
+ } as const;
2486
+ if (value.status === "rejected") {
2487
+ return {
2488
+ ...shared,
2489
+ status: "rejected",
2490
+ failure: text(value.failure, "Composition command failure", 1_000),
2491
+ };
2492
+ }
2493
+ return {
2494
+ ...shared,
2495
+ status: "applied",
2496
+ generationId: decodeCompositionGenerationIdV1(value.generationId),
2497
+ };
2498
+ }