@frockbot/configuration-core 0.1.3 → 0.2.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 CHANGED
@@ -6,10 +6,18 @@ import {
6
6
  decodeConnectionAuthorizationViewV1,
7
7
  decodeConnectionModelCatalogV1,
8
8
  } from "@frockbot/connection-core";
9
+ import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
9
10
  import { ConfigurationDecodeError } from "./errors.js";
10
11
  import {
12
+ decodeInstalledPackageSettingsPatchV1,
13
+ decodeInstalledPackageSettingIdsV1,
14
+ decodeModelBindingV1,
11
15
  MAX_PACKAGE_SETTINGS_V1,
12
16
  MAX_PACKAGE_SETTING_TEXT_V1,
17
+ resolvePackageSettingValuesV1,
18
+ type InstalledPackageSettingsV1,
19
+ type ModelBindingV1,
20
+ type PackageSettingsDefinitionV1,
13
21
  type PackageSettingValueV1,
14
22
  } from "./package-settings.js";
15
23
  export { ConfigurationDecodeError } from "./errors.js";
@@ -21,13 +29,20 @@ import {
21
29
  } from "./identifiers.js";
22
30
  export { isBotIdV1 } from "./bot-id.js";
23
31
  export {
32
+ decodeInstalledPackageSettingsPatchV1,
33
+ decodeInstalledPackageSettingIdsV1,
34
+ decodeModelBindingV1,
24
35
  decodePackageSettingsPatchV1,
36
+ decodePackageSettingIdsV1,
25
37
  decodePackageSettingValueV1,
26
38
  decodePackageSettingValuesV1,
27
39
  emptyPackageSettingValuesV1,
28
40
  MAX_PACKAGE_SETTINGS_V1,
29
41
  MAX_PACKAGE_SETTING_TEXT_V1,
30
42
  resolvePackageSettingValuesV1,
43
+ type InstalledPackageSettingsV1,
44
+ type ModelBindingV1,
45
+ type PackageSettingsDefinitionV1,
31
46
  type PackageSettingValueV1,
32
47
  type PackageSettingValuesV1,
33
48
  } from "./package-settings.js";
@@ -103,14 +118,6 @@ export interface BotNotificationPolicy {
103
118
  enabled: boolean;
104
119
  }
105
120
 
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
121
  /**
115
122
  * Where an installed Package came from. `first-party` is a Package compiled
116
123
  * into the running application; `catalog` is one admitted from a pinned remote
@@ -129,18 +136,20 @@ export interface PackageInstallationView {
129
136
  catalogId?: string;
130
137
  /** The immutable Catalog generation `catalogId` was read from. */
131
138
  catalogGeneration?: string;
139
+ /** Exact non-first-party bundle admitted from the Catalog, when it carries code. */
140
+ contentHash?: string;
132
141
  provenance?: PackageProvenanceV1;
133
142
  /** The setup values the install carried, as GrokBot's `InstallPlugin{values}`. */
134
- values?: Record<string, JsonValue>;
143
+ values?: Record<string, JsonValue | ModelBindingV1>;
135
144
  }
136
145
 
137
146
  /**
138
147
  * A durable pending decision for the User: this Connection needs authorizing
139
148
  * before it will do anything again.
140
149
  *
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
150
+ * It carries **no URL**, and that is the whole design. A mount that meets a
151
+ * 401 may write one, but a Bot has no command that requests authorization and
152
+ * a redirect is minted only by an authenticated User action. A single-use
144
153
  * ten-minute link stored in a projection every client reads, and replayed into
145
154
  * every transcript, would outlive the decision it belonged to and would let a
146
155
  * Bot hand its User a link it authored. The card is drawn from this record and
@@ -213,12 +222,11 @@ export interface UserSettingsViewV1 {
213
222
  profile: { name: string; email?: string };
214
223
  packages: PackageInstallationView[];
215
224
  connections: ConnectionView[];
216
- newBotModelTemplate?: ModelAssignment;
217
225
  /**
218
- * `user` is sticky, including when the User explicitly clears the default.
219
- * Providers may replace only an `auto` default (or the untouched absence).
226
+ * The model the platform chose for this User. Only a provider bootstrap
227
+ * writes it; no User command may do so (AGENTS.md Configuration shape).
220
228
  */
221
- newBotModelTemplateSource?: NewBotModelTemplateSourceV1;
229
+ platformModel?: ModelBindingV1;
222
230
  /**
223
231
  * The remote Catalog generation this User is pinned to, and the content hash
224
232
  * of that generation's index. Pinned on the first read that finds a Catalog
@@ -230,54 +238,27 @@ export interface UserSettingsViewV1 {
230
238
  catalogIndexHash?: string;
231
239
  }
232
240
 
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
241
  export interface BotSettingsViewV1 {
258
242
  schemaVersion: 1;
259
243
  botId: string;
260
244
  revision: number;
261
245
  profile: BotProfile;
262
246
  notifications: BotNotificationPolicy;
263
- assignments: CapabilityAssignmentView[];
264
- assignmentOperations: CapabilityAssignmentOperationViewV1[];
265
- model?: ModelAssignment;
247
+ /**
248
+ * Package-owned Bot overrides. Disabling a Package leaves these durable but
249
+ * inert so re-enabling restores them (ADR 0019).
250
+ */
251
+ packageValues: Record<string, Record<string, unknown>>;
266
252
  }
267
253
 
268
- export function initializeBotSettingsV1(
269
- botId: string,
270
- model?: ModelAssignment,
271
- ): BotSettingsViewV1 {
254
+ export function initializeBotSettingsV1(botId: string): BotSettingsViewV1 {
272
255
  return {
273
256
  schemaVersion: 1,
274
257
  botId,
275
258
  revision: 0,
276
259
  profile: { name: botId === "default" ? "Barebones" : botId },
277
260
  notifications: { enabled: true },
278
- assignments: [],
279
- assignmentOperations: [],
280
- model: model ? structuredClone(model) : undefined,
261
+ packageValues: {},
281
262
  };
282
263
  }
283
264
 
@@ -299,14 +280,20 @@ export type ConfigurationCommandV1 =
299
280
  profile: UserSettingsViewV1["profile"];
300
281
  })
301
282
  | (CommandMetaV1 & {
302
- type: "user/set-new-bot-model";
303
- model?: ModelAssignment;
304
- source: NewBotModelTemplateSourceV1;
283
+ /**
284
+ * Only a backend Contribution may issue this command. The User gateway
285
+ * enforces that authority; a User can choose a model only by enabling a
286
+ * Package that owns model-role settings.
287
+ */
288
+ type: "user/set-platform-model";
289
+ model: ModelBindingV1;
305
290
  })
306
291
  | (CommandMetaV1 & {
307
292
  type: "user/install-package";
308
293
  packageId: string;
309
294
  version: string;
295
+ /** Installs enabled unless the caller explicitly asks otherwise. */
296
+ enabled?: boolean;
310
297
  /**
311
298
  * A Catalog install names the entry and the generation it was read
312
299
  * from. The User Durable Object refuses a generation other than the one
@@ -315,14 +302,11 @@ export type ConfigurationCommandV1 =
315
302
  */
316
303
  catalogId?: string;
317
304
  catalogGeneration?: string;
305
+ contentHash?: string;
318
306
  values?: Record<string, JsonValue>;
319
307
  })
320
308
  | (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
- */
309
+ /** Removes the installation; Connections remain User-owned (ADR 0019). */
326
310
  type: "user/uninstall-package";
327
311
  packageId: string;
328
312
  })
@@ -342,7 +326,9 @@ export type ConfigurationCommandV1 =
342
326
  */
343
327
  type: "user/set-package-settings";
344
328
  packageId: string;
345
- values: Record<string, PackageSettingValueV1>;
329
+ values?: Record<string, PackageSettingValueV1>;
330
+ /** Setting ids whose stored values are removed by this command. */
331
+ unset?: string[];
346
332
  })
347
333
  | (CommandMetaV1 & {
348
334
  type: "bot/update-profile";
@@ -371,31 +357,17 @@ export type ConfigurationCommandV1 =
371
357
  notifications: BotNotificationPolicy;
372
358
  })
373
359
  | (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";
360
+ /**
361
+ * A partial update of one installed Package's Bot-scoped setting bag.
362
+ * The Bot authority validates it against that installed version's
363
+ * manifest through the same codec as User-scoped Package settings.
364
+ */
365
+ type: "bot/set-package-settings";
397
366
  botId: string;
398
- assignmentId: string;
367
+ packageId: string;
368
+ values?: Record<string, PackageSettingValueV1>;
369
+ /** Setting ids whose stored values are removed by this command. */
370
+ unset?: string[];
399
371
  });
400
372
 
401
373
  export type UserConfigurationCommandV1 = Exclude<
@@ -502,16 +474,25 @@ export interface BotExecutionPlanV1 {
502
474
  schemaVersion: 1;
503
475
  botId: string;
504
476
  revision: number;
505
- model?: ModelAssignment;
506
- assignments: CapabilityAssignmentView[];
477
+ model?: ModelBindingV1;
478
+ capabilities: EnabledCapabilityV1[];
479
+ }
480
+
481
+ /** One Capability granted account-wide by an enabled Package (ADR 0019). */
482
+ export interface EnabledCapabilityV1 {
483
+ packageId: string;
484
+ capabilityId: string;
485
+ kind: "tool" | "model" | "memory" | "notification" | "computer";
486
+ connectionId?: string;
507
487
  }
508
488
 
509
489
  export interface ExecutionPackageDefinition {
510
490
  packageId: string;
511
491
  version: string;
492
+ settings: PackageSettingDefinition[];
512
493
  capabilities: Array<{
513
494
  id: string;
514
- kind?: "tool" | "model" | "memory" | "notification" | "computer";
495
+ kind: "tool" | "model" | "memory" | "notification" | "computer";
515
496
  connectionTypes: string[];
516
497
  }>;
517
498
  connectionTypes: Array<{
@@ -520,62 +501,55 @@ export interface ExecutionPackageDefinition {
520
501
  }>;
521
502
  }
522
503
 
523
- export function capabilityAssignmentFailureV1(input: {
524
- assignment: Omit<CapabilityAssignmentView, "state">;
504
+ function compareIdentifiers(left: string, right: string): number {
505
+ return left < right ? -1 : left > right ? 1 : 0;
506
+ }
507
+
508
+ export function modelBindingFailureV1(input: {
509
+ model: ModelBindingV1;
525
510
  user: UserSettingsViewV1;
526
511
  packages: readonly ExecutionPackageDefinition[];
527
512
  }): 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,
513
+ const connection = input.user.connections.find(
514
+ (candidate) => candidate.connectionId === input.model.connectionId,
542
515
  );
543
- if (!capability) {
544
- return `Capability "${input.assignment.capabilityId}" is not declared by Package "${input.assignment.packageId}"`;
516
+ if (!connection) {
517
+ return `Connection "${input.model.connectionId}" is unavailable; reconnect it or choose another model`;
545
518
  }
546
- if (capability.connectionTypes.length === 0) {
547
- return input.assignment.connectionId
548
- ? `Capability "${input.assignment.capabilityId}" does not accept a Connection`
549
- : undefined;
519
+ if (connection.state !== "ready") {
520
+ return `Connection "${input.model.connectionId}" is ${connection.state}; enable or reconnect it`;
550
521
  }
551
- if (!input.assignment.connectionId) {
552
- return `Capability "${input.assignment.capabilityId}" requires a Connection`;
522
+ const installation = input.user.packages.find(
523
+ (candidate) => candidate.packageId === connection.packageId,
524
+ );
525
+ if (!installation || installation.state !== "installed") {
526
+ return `Package "${connection.packageId}" is not installed and enabled; enable it to use Connection "${connection.connectionId}"`;
553
527
  }
554
- const connection = input.user.connections.find(
555
- (candidate) => candidate.connectionId === input.assignment.connectionId,
528
+ const pkg = input.packages.find(
529
+ (candidate) =>
530
+ candidate.packageId === connection.packageId &&
531
+ candidate.version === installation.version,
556
532
  );
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}"`;
533
+ if (!pkg) {
534
+ return `Installed Package "${connection.packageId}" version "${installation.version}" is unavailable`;
563
535
  }
564
536
  const connectionType = pkg.connectionTypes.find(
565
537
  (candidate) => candidate.id === connection.connectionTypeId,
566
538
  );
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`;
539
+ const modelCapability = pkg.capabilities.find(
540
+ (candidate) =>
541
+ candidate.kind === "model" &&
542
+ candidate.connectionTypes.includes(connection.connectionTypeId) &&
543
+ connectionType?.capabilities.includes(candidate.id),
544
+ );
545
+ if (!connectionType || !modelCapability) {
546
+ return `Connection "${connection.connectionId}" does not provide a model Capability accepted by Package "${pkg.packageId}"`;
573
547
  }
574
548
  return undefined;
575
549
  }
576
550
 
577
551
  export interface ResolvedModelBindingV1 {
578
- assignment: ModelAssignment;
552
+ model?: ModelBindingV1;
579
553
  state: "ready" | "requires-resolution" | "unavailable";
580
554
  connection?: ConnectionView;
581
555
  packageId?: string;
@@ -584,65 +558,39 @@ export interface ResolvedModelBindingV1 {
584
558
  }
585
559
 
586
560
  export function resolveBotModelBindingV1(input: {
587
- model: ModelAssignment;
588
- assignments: readonly CapabilityAssignmentView[];
561
+ model: ModelBindingV1;
589
562
  user: UserSettingsViewV1;
590
563
  packages: readonly ExecutionPackageDefinition[];
591
564
  }): ResolvedModelBindingV1 {
592
565
  const unavailable = (failure: string): ResolvedModelBindingV1 => ({
593
- assignment: structuredClone(input.model),
566
+ model: structuredClone(input.model),
594
567
  state: "unavailable",
595
568
  failure,
596
569
  });
570
+ const failure = modelBindingFailureV1(input);
571
+ if (failure) return unavailable(failure);
597
572
  const connection = input.user.connections.find(
598
573
  (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
- }
574
+ )!;
604
575
  const installation = input.user.packages.find(
605
576
  (candidate) => candidate.packageId === connection.packageId,
606
- );
607
- if (!installation || installation.state !== "installed") {
608
- return unavailable("Connection Package is not installed and enabled");
609
- }
577
+ )!;
610
578
  const pkg = input.packages.find(
611
579
  (candidate) =>
612
580
  candidate.packageId === connection.packageId &&
613
581
  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
- }
582
+ )!;
637
583
  if (!connection.providerType) {
638
- return unavailable("Connection provider type is unavailable");
584
+ return unavailable(
585
+ `Connection "${connection.connectionId}" provider type is unavailable`,
586
+ );
639
587
  }
640
588
  const knownModel = connection.modelCatalog?.models.some(
641
589
  (candidate: { providerModelId: string }) =>
642
590
  candidate.providerModelId === input.model.providerModelId,
643
591
  );
644
592
  return {
645
- assignment: structuredClone(input.model),
593
+ model: structuredClone(input.model),
646
594
  state: knownModel ? "ready" : "requires-resolution",
647
595
  connection: structuredClone(connection),
648
596
  packageId: pkg.packageId,
@@ -652,35 +600,119 @@ export function resolveBotModelBindingV1(input: {
652
600
 
653
601
  export interface EffectiveBotModelV1 {
654
602
  /**
655
- * "bot" when the Bot overrides the User default, "default" when the Bot
656
- * follows `UserSettingsViewV1.newBotModelTemplate`, "none" when neither is
657
- * set.
603
+ * The Package setting scope that supplied the model, or the platform
604
+ * bootstrap when no enabled model-choice Package supplied one.
658
605
  */
659
- source: "bot" | "default" | "none";
660
- model?: ModelAssignment;
606
+ source: "bot" | "account" | "platform" | "none";
607
+ model?: ModelBindingV1;
661
608
  binding?: ResolvedModelBindingV1;
662
609
  }
663
610
 
664
611
  /**
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.
612
+ * The model a Bot actually runs on. The kernel knows only the manifest role:
613
+ * Bot value, User value, then platform bootstrap. Disabled Package values stay
614
+ * present but are inert, as required by AGENTS.md Configuration shape.
670
615
  */
671
616
  export function resolveEffectiveBotModelV1(input: {
672
- bot: Pick<BotSettingsViewV1, "model" | "assignments">;
617
+ bot: Pick<BotSettingsViewV1, "packageValues">;
673
618
  user: UserSettingsViewV1;
674
619
  packages: readonly ExecutionPackageDefinition[];
675
620
  }): EffectiveBotModelV1 {
676
- const model = input.bot.model ?? input.user.newBotModelTemplate;
621
+ const enabled = input.user.packages
622
+ .filter((installation) => installation.state === "installed")
623
+ .map((installation) => ({
624
+ installation,
625
+ pkg: input.packages.find(
626
+ (candidate) =>
627
+ candidate.packageId === installation.packageId &&
628
+ candidate.version === installation.version,
629
+ ),
630
+ }))
631
+ .filter(
632
+ (
633
+ candidate,
634
+ ): candidate is {
635
+ installation: PackageInstallationView;
636
+ pkg: ExecutionPackageDefinition;
637
+ } => candidate.pkg !== undefined,
638
+ );
639
+
640
+ const fromScope = (
641
+ scope: "user" | "bot",
642
+ ): { model?: ModelBindingV1; conflict?: string } | undefined => {
643
+ const declarations = enabled
644
+ .map(({ installation, pkg }) => ({
645
+ installation,
646
+ pkg,
647
+ definition: pkg.settings
648
+ .filter(
649
+ (definition) =>
650
+ definition.role === "model" && definition.scopes.includes(scope),
651
+ )
652
+ .sort((left, right) => compareIdentifiers(left.id, right.id))[0],
653
+ }))
654
+ .filter(
655
+ (
656
+ candidate,
657
+ ): candidate is typeof candidate & {
658
+ definition: PackageSettingDefinition;
659
+ } => candidate.definition !== undefined,
660
+ )
661
+ .sort((left, right) =>
662
+ compareIdentifiers(left.pkg.packageId, right.pkg.packageId),
663
+ );
664
+ if (declarations.length > 1) {
665
+ const names = declarations.map(({ pkg }) => `"${pkg.packageId}"`);
666
+ return {
667
+ conflict: `Enabled Packages ${names.join(" and ")} both declare the model role at ${scope === "user" ? "User" : "Bot"} scope; disable one Package`,
668
+ };
669
+ }
670
+ const declaration = declarations[0];
671
+ if (!declaration) return undefined;
672
+ const stored =
673
+ scope === "bot"
674
+ ? Object.hasOwn(input.bot.packageValues, declaration.pkg.packageId)
675
+ ? input.bot.packageValues[declaration.pkg.packageId]
676
+ : undefined
677
+ : declaration.installation.values;
678
+ const value = resolvePackageSettingValuesV1(
679
+ declaration.pkg.settings,
680
+ stored,
681
+ scope,
682
+ )[declaration.definition.id];
683
+ return {
684
+ ...(typeof value === "object" && value !== null ? { model: value } : {}),
685
+ };
686
+ };
687
+
688
+ for (const scope of ["bot", "user"] as const) {
689
+ const resolved = fromScope(scope);
690
+ if (resolved?.conflict) {
691
+ return {
692
+ source: scope === "bot" ? "bot" : "account",
693
+ binding: { state: "unavailable", failure: resolved.conflict },
694
+ };
695
+ }
696
+ if (resolved?.model) {
697
+ return {
698
+ source: scope === "bot" ? "bot" : "account",
699
+ model: structuredClone(resolved.model),
700
+ binding: resolveBotModelBindingV1({
701
+ model: resolved.model,
702
+ user: input.user,
703
+ packages: input.packages,
704
+ }),
705
+ };
706
+ }
707
+ }
708
+
709
+ const model = input.user.platformModel;
677
710
  if (!model) return { source: "none" };
678
711
  return {
679
- source: input.bot.model ? "bot" : "default",
712
+ source: "platform",
680
713
  model: structuredClone(model),
681
714
  binding: resolveBotModelBindingV1({
682
715
  model,
683
- assignments: input.bot.assignments,
684
716
  user: input.user,
685
717
  packages: input.packages,
686
718
  }),
@@ -692,25 +724,53 @@ export function resolveBotExecutionPlanV1(input: {
692
724
  user: UserSettingsViewV1;
693
725
  packages: readonly ExecutionPackageDefinition[];
694
726
  }): 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);
727
+ const capabilities = input.user.packages.flatMap((installation) => {
728
+ if (installation.state !== "installed") return [];
729
+ const pkg = input.packages.find(
730
+ (candidate) =>
731
+ candidate.packageId === installation.packageId &&
732
+ candidate.version === installation.version,
733
+ );
734
+ if (!pkg) return [];
735
+ return pkg.capabilities.flatMap((capability): EnabledCapabilityV1[] => {
736
+ const base = {
737
+ packageId: pkg.packageId,
738
+ capabilityId: capability.id,
739
+ kind: capability.kind,
740
+ };
741
+ if (capability.connectionTypes.length === 0) return [base];
742
+ return input.user.connections
743
+ .filter((connection) => {
744
+ if (
745
+ connection.packageId !== pkg.packageId ||
746
+ connection.state !== "ready" ||
747
+ !capability.connectionTypes.includes(connection.connectionTypeId)
748
+ ) {
749
+ return false;
750
+ }
751
+ return pkg.connectionTypes
752
+ .find((candidate) => candidate.id === connection.connectionTypeId)
753
+ ?.capabilities.includes(capability.id);
754
+ })
755
+ .map((connection) => ({
756
+ ...base,
757
+ connectionId: connection.connectionId,
758
+ }));
759
+ });
707
760
  });
761
+ capabilities.sort(
762
+ (left, right) =>
763
+ compareIdentifiers(left.packageId, right.packageId) ||
764
+ compareIdentifiers(left.capabilityId, right.capabilityId) ||
765
+ compareIdentifiers(left.connectionId ?? "", right.connectionId ?? ""),
766
+ );
767
+ const effective = resolveEffectiveBotModelV1(input);
708
768
  return {
709
769
  schemaVersion: 1,
710
770
  botId: input.bot.botId,
711
771
  revision: input.bot.revision,
712
- model: input.bot.model ? structuredClone(input.bot.model) : undefined,
713
- assignments,
772
+ ...(effective.model ? { model: structuredClone(effective.model) } : {}),
773
+ capabilities,
714
774
  };
715
775
  }
716
776
 
@@ -737,7 +797,17 @@ function record(value: unknown, label: string): Record<string, unknown> {
737
797
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
738
798
  throw new ConfigurationDecodeError(`${label} must be an object`);
739
799
  }
740
- return value as Record<string, unknown>;
800
+ const candidate = value as Record<string, unknown>;
801
+ const prototype = Object.getPrototypeOf(candidate);
802
+ if (prototype !== Object.prototype && prototype !== null) {
803
+ throw new ConfigurationDecodeError(`${label} must be a plain object`);
804
+ }
805
+ for (const key in candidate) {
806
+ if (!Object.hasOwn(candidate, key)) {
807
+ throw new ConfigurationDecodeError(`${label} has inherited fields`);
808
+ }
809
+ }
810
+ return candidate;
741
811
  }
742
812
 
743
813
  export function decodeBotIdV1(value: unknown, label = "botId"): string {
@@ -837,36 +907,6 @@ export function decodeRevokeConnectionCommandV1(
837
907
  return { schemaVersion: 1, type: "connection/revoke" };
838
908
  }
839
909
 
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
910
  function text(value: unknown, label: string, maximum: number): string {
871
911
  if (typeof value !== "string") {
872
912
  throw new ConfigurationDecodeError(`${label} must be a string`);
@@ -1093,26 +1133,6 @@ function notifications(value: unknown): BotNotificationPolicy {
1093
1133
  return { enabled: policy.enabled };
1094
1134
  }
1095
1135
 
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
1136
  export function decodeConfigurationQueryV1(
1117
1137
  input: unknown,
1118
1138
  ): ConfigurationQueryV1 {
@@ -1161,30 +1181,19 @@ export function decodeConfigurationCommandV1(
1161
1181
  },
1162
1182
  };
1163
1183
  }
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
- }
1184
+ case "user/set-platform-model": {
1185
+ const command = exactCommand(input, ["model"]);
1176
1186
  return {
1177
1187
  ...commandMeta(command),
1178
1188
  type: value.type,
1179
- model: command.model === undefined ? undefined : model(command.model),
1180
- source: command.source,
1189
+ model: decodeModelBindingV1(command.model),
1181
1190
  };
1182
1191
  }
1183
1192
  case "user/install-package": {
1184
1193
  const command = exactCommand(
1185
1194
  input,
1186
1195
  ["packageId", "version"],
1187
- ["catalogId", "catalogGeneration", "values"],
1196
+ ["catalogId", "catalogGeneration", "contentHash", "values", "enabled"],
1188
1197
  );
1189
1198
  // A Catalog install is all three of identity, generation and (optional)
1190
1199
  // values or none of them: half a Catalog install would be an install
@@ -1202,11 +1211,26 @@ export function decodeConfigurationCommandV1(
1202
1211
  "install values require a Catalog entry",
1203
1212
  );
1204
1213
  }
1214
+ if (
1215
+ command.catalogId === undefined &&
1216
+ command.contentHash !== undefined
1217
+ ) {
1218
+ throw new ConfigurationDecodeError(
1219
+ "install contentHash requires a Catalog entry",
1220
+ );
1221
+ }
1222
+ if (
1223
+ command.enabled !== undefined &&
1224
+ typeof command.enabled !== "boolean"
1225
+ ) {
1226
+ throw new ConfigurationDecodeError("enabled is invalid");
1227
+ }
1205
1228
  return {
1206
1229
  ...commandMeta(command),
1207
1230
  type: value.type,
1208
1231
  packageId: identifier(command.packageId, "packageId"),
1209
1232
  version: text(command.version, "version", 100),
1233
+ ...(command.enabled === undefined ? {} : { enabled: command.enabled }),
1210
1234
  ...(command.catalogId === undefined
1211
1235
  ? {}
1212
1236
  : {
@@ -1215,6 +1239,14 @@ export function decodeConfigurationCommandV1(
1215
1239
  command.catalogGeneration,
1216
1240
  "catalogGeneration",
1217
1241
  ),
1242
+ ...(command.contentHash === undefined
1243
+ ? {}
1244
+ : {
1245
+ contentHash: compositionHash(
1246
+ command.contentHash,
1247
+ "contentHash",
1248
+ ),
1249
+ }),
1218
1250
  }),
1219
1251
  ...(command.values === undefined
1220
1252
  ? {}
@@ -1242,12 +1274,22 @@ export function decodeConfigurationCommandV1(
1242
1274
  };
1243
1275
  }
1244
1276
  case "user/set-package-settings": {
1245
- const command = exactCommand(input, ["packageId", "values"]);
1277
+ const command = exactCommand(input, ["packageId"], ["values", "unset"]);
1278
+ const values =
1279
+ command.values === undefined
1280
+ ? undefined
1281
+ : packageSettingsPatch(command.values);
1282
+ const unset =
1283
+ command.unset === undefined
1284
+ ? undefined
1285
+ : packageSettingIds(command.unset);
1286
+ requirePackageSettingsChange(values, unset);
1246
1287
  return {
1247
1288
  ...commandMeta(command),
1248
1289
  type: value.type,
1249
1290
  packageId: identifier(command.packageId, "packageId"),
1250
- values: packageSettingsPatch(command.values),
1291
+ ...(values ? { values } : {}),
1292
+ ...(unset ? { unset } : {}),
1251
1293
  };
1252
1294
  }
1253
1295
  case "bot/update-profile": {
@@ -1296,34 +1338,28 @@ export function decodeConfigurationCommandV1(
1296
1338
  notifications: notifications(command.notifications),
1297
1339
  };
1298
1340
  }
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"]);
1341
+ case "bot/set-package-settings": {
1342
+ const command = exactCommand(
1343
+ input,
1344
+ ["botId", "packageId"],
1345
+ ["values", "unset"],
1346
+ );
1347
+ const values =
1348
+ command.values === undefined
1349
+ ? undefined
1350
+ : packageSettingsPatch(command.values);
1351
+ const unset =
1352
+ command.unset === undefined
1353
+ ? undefined
1354
+ : packageSettingIds(command.unset);
1355
+ requirePackageSettingsChange(values, unset);
1322
1356
  return {
1323
1357
  ...commandMeta(command),
1324
1358
  type: value.type,
1325
1359
  botId: identifier(command.botId, "botId"),
1326
- assignmentId: identifier(command.assignmentId, "assignmentId"),
1360
+ packageId: identifier(command.packageId, "packageId"),
1361
+ ...(values ? { values } : {}),
1362
+ ...(unset ? { unset } : {}),
1327
1363
  };
1328
1364
  }
1329
1365
  default:
@@ -1423,11 +1459,41 @@ function safeJsonValue(value: unknown, label: string): JsonValue {
1423
1459
  return value as JsonValue;
1424
1460
  }
1425
1461
  if (Array.isArray(value)) {
1462
+ if (
1463
+ Object.getPrototypeOf(value) !== Array.prototype ||
1464
+ Reflect.ownKeys(value).some(
1465
+ (key) =>
1466
+ typeof key !== "string" ||
1467
+ (key !== "length" &&
1468
+ (!/^(0|[1-9][0-9]*)$/.test(key) ||
1469
+ Number(key) >= value.length ||
1470
+ !Object.getOwnPropertyDescriptor(value, key)?.enumerable)),
1471
+ ) ||
1472
+ Array.from({ length: value.length }, (_item, index) => index).some(
1473
+ (index) => !Object.hasOwn(value, index),
1474
+ )
1475
+ ) {
1476
+ throw new ConfigurationDecodeError(`${label} is not plain JSON`);
1477
+ }
1426
1478
  return value.map((item) => safeJsonValue(item, label));
1427
1479
  }
1428
1480
  if (typeof value === "object" && value !== null) {
1481
+ const object = record(value, label);
1482
+ if (
1483
+ Reflect.ownKeys(object).some((key) => {
1484
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
1485
+ return (
1486
+ typeof key !== "string" ||
1487
+ !descriptor ||
1488
+ !("value" in descriptor) ||
1489
+ !descriptor.enumerable
1490
+ );
1491
+ })
1492
+ ) {
1493
+ throw new ConfigurationDecodeError(`${label} is not plain JSON`);
1494
+ }
1429
1495
  return Object.fromEntries(
1430
- Object.entries(value).map(([key, item]) => [
1496
+ Object.entries(object).map(([key, item]) => [
1431
1497
  key,
1432
1498
  safeJsonValue(item, label),
1433
1499
  ]),
@@ -1471,15 +1537,29 @@ function installValues(value: unknown): Record<string, JsonValue> {
1471
1537
  * The shape of a `user/set-package-settings` payload, before anything knows
1472
1538
  * which Package it is for.
1473
1539
  *
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.
1540
+ * Only the shape: ids are identifiers, ordinary values are scalars, the model
1541
+ * role may carry its exact binding object, and the bag is bounded. Whether a
1542
+ * named setting exists, and whether its value satisfies the schema the Package
1543
+ * declared, is the owning Durable Object's answer — it validates against the
1544
+ * installed version through `decodeInstalledPackageSettingsPatchV1`.
1478
1545
  */
1479
1546
  function packageSettingsPatch(
1480
1547
  value: unknown,
1481
1548
  ): Record<string, PackageSettingValueV1> {
1482
1549
  const values = record(value, "values");
1550
+ if (
1551
+ Reflect.ownKeys(values).some((key) => {
1552
+ const descriptor = Object.getOwnPropertyDescriptor(values, key);
1553
+ return (
1554
+ typeof key !== "string" ||
1555
+ !descriptor ||
1556
+ !("value" in descriptor) ||
1557
+ !descriptor.enumerable
1558
+ );
1559
+ })
1560
+ ) {
1561
+ throw new ConfigurationDecodeError("values has invalid fields");
1562
+ }
1483
1563
  const entries = Object.entries(values);
1484
1564
  if (entries.length === 0) {
1485
1565
  throw new ConfigurationDecodeError("values names no setting");
@@ -1489,6 +1569,9 @@ function packageSettingsPatch(
1489
1569
  }
1490
1570
  return Object.fromEntries(
1491
1571
  entries.map(([key, item]) => {
1572
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
1573
+ return [identifier(key, "values key"), decodeModelBindingV1(item)];
1574
+ }
1492
1575
  if (
1493
1576
  typeof item !== "string" &&
1494
1577
  typeof item !== "number" &&
@@ -1510,6 +1593,34 @@ function packageSettingsPatch(
1510
1593
  );
1511
1594
  }
1512
1595
 
1596
+ function packageSettingIds(value: unknown): string[] {
1597
+ if (!Array.isArray(value) || value.length === 0) {
1598
+ throw new ConfigurationDecodeError("unset names no setting");
1599
+ }
1600
+ if (value.length > MAX_PACKAGE_SETTINGS_V1) {
1601
+ throw new ConfigurationDecodeError("unset is too large");
1602
+ }
1603
+ const decoded = value.map((item) => identifier(item, "unset setting id"));
1604
+ if (new Set(decoded).size !== decoded.length) {
1605
+ throw new ConfigurationDecodeError("unset repeats a setting");
1606
+ }
1607
+ return decoded;
1608
+ }
1609
+
1610
+ function requirePackageSettingsChange(
1611
+ values: Record<string, PackageSettingValueV1> | undefined,
1612
+ unset: readonly string[] | undefined,
1613
+ ): void {
1614
+ if (!values && !unset) {
1615
+ throw new ConfigurationDecodeError("Package settings command is empty");
1616
+ }
1617
+ if (values && unset?.some((settingId) => Object.hasOwn(values, settingId))) {
1618
+ throw new ConfigurationDecodeError(
1619
+ "Package settings command both sets and unsets a setting",
1620
+ );
1621
+ }
1622
+ }
1623
+
1513
1624
  function viewRevision(value: unknown): number {
1514
1625
  if (!Number.isSafeInteger(value) || (value as number) < 0) {
1515
1626
  throw new ConfigurationDecodeError("configuration revision is invalid");
@@ -1522,7 +1633,14 @@ function packageInstallation(value: unknown): PackageInstallationView {
1522
1633
  value,
1523
1634
  "Package installation",
1524
1635
  ["packageId", "version", "state"],
1525
- ["failure", "catalogId", "catalogGeneration", "provenance", "values"],
1636
+ [
1637
+ "failure",
1638
+ "catalogId",
1639
+ "catalogGeneration",
1640
+ "contentHash",
1641
+ "provenance",
1642
+ "values",
1643
+ ],
1526
1644
  );
1527
1645
  if (
1528
1646
  installation.state !== "installed" &&
@@ -1556,6 +1674,11 @@ function packageInstallation(value: unknown): PackageInstallationView {
1556
1674
  "catalogGeneration",
1557
1675
  ),
1558
1676
  }),
1677
+ ...(installation.contentHash === undefined
1678
+ ? {}
1679
+ : {
1680
+ contentHash: compositionHash(installation.contentHash, "contentHash"),
1681
+ }),
1559
1682
  ...(installation.provenance === undefined
1560
1683
  ? {}
1561
1684
  : { provenance: installation.provenance }),
@@ -1679,105 +1802,119 @@ export function decodePendingAuthorizationV1(
1679
1802
  };
1680
1803
  }
1681
1804
 
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
- );
1805
+ function schemaVersion(value: Record<string, unknown>): void {
1806
+ if (value.schemaVersion !== 1) {
1807
+ throw new ConfigurationDecodeError("unsupported configuration schema");
1699
1808
  }
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
1809
  }
1714
1810
 
1715
- function assignmentTarget(
1811
+ function storedPlainRecordV1(
1716
1812
  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
- };
1813
+ ): Record<string, unknown> | undefined {
1814
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1815
+ return undefined;
1816
+ }
1817
+ const prototype = Object.getPrototypeOf(value);
1818
+ return prototype === Object.prototype || prototype === null
1819
+ ? (value as Record<string, unknown>)
1820
+ : undefined;
1734
1821
  }
1735
1822
 
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
- };
1823
+ function storedDataValueV1(
1824
+ value: Record<string, unknown>,
1825
+ key: string,
1826
+ ): unknown {
1827
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
1828
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
1775
1829
  }
1776
1830
 
1777
- function schemaVersion(value: Record<string, unknown>): void {
1778
- if (value.schemaVersion !== 1) {
1779
- throw new ConfigurationDecodeError("unsupported configuration schema");
1831
+ function cloneStoredRecordV1(
1832
+ value: Record<string, unknown>,
1833
+ changes: Readonly<Record<string, unknown>>,
1834
+ removed: readonly string[] = [],
1835
+ ): Record<string, unknown> {
1836
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1837
+ for (const key of removed) delete descriptors[key];
1838
+ for (const [key, next] of Object.entries(changes)) {
1839
+ const descriptor = descriptors[key];
1840
+ descriptors[key] =
1841
+ descriptor && "value" in descriptor
1842
+ ? { ...descriptor, value: next }
1843
+ : {
1844
+ configurable: true,
1845
+ enumerable: true,
1846
+ value: next,
1847
+ writable: true,
1848
+ };
1849
+ }
1850
+ return Object.create(Object.getPrototypeOf(value), descriptors) as Record<
1851
+ string,
1852
+ unknown
1853
+ >;
1854
+ }
1855
+
1856
+ const PRE_ACCOUNT_WIDE_USER_FIELDS_V1 = [
1857
+ "newBotModelTemplate",
1858
+ "newBotModelTemplateSource",
1859
+ ] as const;
1860
+ const PRE_ACCOUNT_WIDE_CONNECTION_METADATA_FIELDS_V1 = [
1861
+ "dependentAssignments",
1862
+ ] as const;
1863
+
1864
+ /**
1865
+ * Migrates one raw User settings record across known durable shapes before the
1866
+ * current exact-field decoder sees it. Commit 1571b62 removed the model
1867
+ * template and commit d6730ad removed the Connection dependency ledger along
1868
+ * with the Assignment feature; migration drops those fields without
1869
+ * interpreting them.
1870
+ */
1871
+ export function migrateStoredUserSettingsV1(stored: unknown): unknown {
1872
+ const settings = storedPlainRecordV1(stored);
1873
+ if (!settings || storedDataValueV1(settings, "schemaVersion") !== 1) {
1874
+ return stored;
1875
+ }
1876
+
1877
+ let changed = false;
1878
+ const removedSettingsFields = PRE_ACCOUNT_WIDE_USER_FIELDS_V1.filter((key) =>
1879
+ Object.hasOwn(settings, key),
1880
+ );
1881
+ changed ||= removedSettingsFields.length > 0;
1882
+
1883
+ const storedConnections = storedDataValueV1(settings, "connections");
1884
+ let connections = storedConnections;
1885
+ if (Array.isArray(storedConnections)) {
1886
+ let connectionsChanged = false;
1887
+ const nextConnections = storedConnections.map((storedConnection) => {
1888
+ const connection = storedPlainRecordV1(storedConnection);
1889
+ if (!connection) return storedConnection;
1890
+ const storedMetadata = storedDataValueV1(connection, "safeMetadata");
1891
+ const metadata = storedPlainRecordV1(storedMetadata);
1892
+ if (!metadata) return storedConnection;
1893
+ const removedMetadataFields =
1894
+ PRE_ACCOUNT_WIDE_CONNECTION_METADATA_FIELDS_V1.filter((key) =>
1895
+ Object.hasOwn(metadata, key),
1896
+ );
1897
+ if (removedMetadataFields.length === 0) return storedConnection;
1898
+ connectionsChanged = true;
1899
+ const safeMetadata = cloneStoredRecordV1(
1900
+ metadata,
1901
+ {},
1902
+ removedMetadataFields,
1903
+ );
1904
+ return cloneStoredRecordV1(connection, { safeMetadata });
1905
+ });
1906
+ if (connectionsChanged) {
1907
+ changed = true;
1908
+ connections = nextConnections;
1909
+ }
1780
1910
  }
1911
+
1912
+ if (!changed) return stored;
1913
+ return cloneStoredRecordV1(
1914
+ settings,
1915
+ connections === storedConnections ? {} : { connections },
1916
+ removedSettingsFields,
1917
+ );
1781
1918
  }
1782
1919
 
1783
1920
  export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
@@ -1785,12 +1922,7 @@ export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
1785
1922
  input,
1786
1923
  "User settings",
1787
1924
  ["schemaVersion", "revision", "profile", "packages", "connections"],
1788
- [
1789
- "newBotModelTemplate",
1790
- "newBotModelTemplateSource",
1791
- "catalogGeneration",
1792
- "catalogIndexHash",
1793
- ],
1925
+ ["platformModel", "catalogGeneration", "catalogIndexHash"],
1794
1926
  );
1795
1927
  schemaVersion(value);
1796
1928
  const profile = exactRecord(value.profile, "profile", ["name"], ["email"]);
@@ -1803,31 +1935,6 @@ export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
1803
1935
  "User settings Packages and Connections must be bounded arrays",
1804
1936
  );
1805
1937
  }
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
1938
  return {
1832
1939
  schemaVersion: 1,
1833
1940
  revision: viewRevision(value.revision),
@@ -1837,11 +1944,9 @@ export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
1837
1944
  },
1838
1945
  packages: value.packages.map(packageInstallation),
1839
1946
  connections: value.connections.map(connectionView),
1840
- newBotModelTemplate:
1841
- value.newBotModelTemplate === undefined
1842
- ? undefined
1843
- : model(value.newBotModelTemplate),
1844
- newBotModelTemplateSource: value.newBotModelTemplateSource,
1947
+ ...(value.platformModel === undefined
1948
+ ? {}
1949
+ : { platformModel: decodeModelBindingV1(value.platformModel) }),
1845
1950
  // The pin is optional — a deployment with no Catalog has none — but never
1846
1951
  // half present: one field alone is a corrupt pin, not a pin.
1847
1952
  ...(value.catalogGeneration === undefined &&
@@ -1861,42 +1966,105 @@ export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
1861
1966
  };
1862
1967
  }
1863
1968
 
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);
1969
+ function packageValueBags(
1970
+ input: unknown,
1971
+ ): Record<string, Record<string, unknown>> {
1972
+ const packages = record(input, "packageValues");
1880
1973
  if (
1881
- !Array.isArray(value.assignments) ||
1882
- !Array.isArray(value.assignmentOperations)
1974
+ Reflect.ownKeys(packages).some((key) => {
1975
+ const descriptor = Object.getOwnPropertyDescriptor(packages, key);
1976
+ return (
1977
+ typeof key !== "string" ||
1978
+ !descriptor ||
1979
+ !("value" in descriptor) ||
1980
+ !descriptor.enumerable
1981
+ );
1982
+ })
1883
1983
  ) {
1884
- throw new ConfigurationDecodeError(
1885
- "Bot settings Assignments and operations must be arrays",
1886
- );
1984
+ throw new ConfigurationDecodeError("packageValues has invalid fields");
1887
1985
  }
1986
+ return Object.fromEntries(
1987
+ Object.entries(packages).map(([packageId, stored]) => {
1988
+ identifier(packageId, "packageValues packageId");
1989
+ const values = record(stored, `packageValues.${packageId}`);
1990
+ if (
1991
+ Reflect.ownKeys(values).length > MAX_PACKAGE_SETTINGS_V1 ||
1992
+ Reflect.ownKeys(values).some((key) => {
1993
+ const descriptor = Object.getOwnPropertyDescriptor(values, key);
1994
+ return (
1995
+ typeof key !== "string" ||
1996
+ !descriptor ||
1997
+ !("value" in descriptor) ||
1998
+ !descriptor.enumerable
1999
+ );
2000
+ })
2001
+ ) {
2002
+ throw new ConfigurationDecodeError(
2003
+ `packageValues.${packageId} has invalid fields`,
2004
+ );
2005
+ }
2006
+ return [
2007
+ packageId,
2008
+ Object.fromEntries(
2009
+ Object.entries(values).map(([settingId, value]) => [
2010
+ identifier(settingId, "packageValues settingId"),
2011
+ safeJsonValue(value, `packageValues.${packageId}.${settingId}`),
2012
+ ]),
2013
+ ),
2014
+ ];
2015
+ }),
2016
+ );
2017
+ }
2018
+
2019
+ export function decodeBotSettingsViewV1(input: unknown): BotSettingsViewV1 {
2020
+ const value = exactRecord(input, "Bot settings", [
2021
+ "schemaVersion",
2022
+ "botId",
2023
+ "revision",
2024
+ "profile",
2025
+ "notifications",
2026
+ "packageValues",
2027
+ ]);
2028
+ schemaVersion(value);
1888
2029
  return {
1889
2030
  schemaVersion: 1,
1890
2031
  botId: identifier(value.botId, "botId"),
1891
2032
  revision: viewRevision(value.revision),
1892
2033
  profile: botProfile(value.profile),
1893
2034
  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),
2035
+ packageValues: packageValueBags(value.packageValues),
1897
2036
  };
1898
2037
  }
1899
2038
 
2039
+ const PRE_ACCOUNT_WIDE_BOT_FIELDS_V1 = [
2040
+ "assignments",
2041
+ "assignmentOperations",
2042
+ "model",
2043
+ // Some pre-release records used this name for the same removed feature.
2044
+ "modelAssignment",
2045
+ ] as const;
2046
+
2047
+ /**
2048
+ * Migrates one raw Bot settings record across known durable shapes before the
2049
+ * current exact-field decoder sees it. The removed Assignment/model fields are
2050
+ * discarded; their presence identifies the shape that predates packageValues.
2051
+ */
2052
+ export function migrateStoredBotSettingsV1(stored: unknown): unknown {
2053
+ const settings = storedPlainRecordV1(stored);
2054
+ if (!settings || storedDataValueV1(settings, "schemaVersion") !== 1) {
2055
+ return stored;
2056
+ }
2057
+ const removed = PRE_ACCOUNT_WIDE_BOT_FIELDS_V1.filter((key) =>
2058
+ Object.hasOwn(settings, key),
2059
+ );
2060
+ if (removed.length === 0) return stored;
2061
+ return cloneStoredRecordV1(
2062
+ settings,
2063
+ Object.hasOwn(settings, "packageValues") ? {} : { packageValues: {} },
2064
+ removed,
2065
+ );
2066
+ }
2067
+
1900
2068
  export function decodeConfigurationViewV1(input: unknown): ConfigurationViewV1 {
1901
2069
  const value = record(input, "configuration");
1902
2070
  return "botId" in value
@@ -1958,6 +2126,11 @@ const COMPOSITION_GENERATION_STATUSES_V1: readonly CompositionGenerationStatusVi
1958
2126
 
1959
2127
  export type CompositionProvenanceViewV1 =
1960
2128
  | { kind: "first-party" }
2129
+ | {
2130
+ kind: "catalog";
2131
+ catalogId: string;
2132
+ catalogGeneration: string;
2133
+ }
1961
2134
  | { kind: "user"; userId: string; authoredAt: string }
1962
2135
  | {
1963
2136
  kind: "bot";
@@ -1972,7 +2145,14 @@ export type CompositionOriginViewV1 =
1972
2145
  | { kind: "bootstrap" }
1973
2146
  | { kind: "bot-authored"; runId: string; sessionId: string; turnId: string }
1974
2147
  | { kind: "user-install"; userId: string }
1975
- | { kind: "revert"; revertsTo: string; userId: string };
2148
+ | { kind: "revert"; revertsTo: string; userId: string }
2149
+ | {
2150
+ kind: "revert";
2151
+ revertsTo: string;
2152
+ botId: string;
2153
+ runId: string;
2154
+ turnId: string;
2155
+ };
1976
2156
 
1977
2157
  export interface CompositionMemberViewV1 {
1978
2158
  packageId: string;
@@ -2110,6 +2290,25 @@ function compositionProvenanceView(
2110
2290
  exactRecord(input, "Composition provenance", ["kind"]);
2111
2291
  return { kind: "first-party" };
2112
2292
  }
2293
+ if (kind === "catalog") {
2294
+ const value = exactRecord(input, "Composition provenance", [
2295
+ "kind",
2296
+ "catalogId",
2297
+ "catalogGeneration",
2298
+ ]);
2299
+ return {
2300
+ kind: "catalog",
2301
+ catalogId: identifier(
2302
+ value.catalogId,
2303
+ "Composition provenance catalogId",
2304
+ ),
2305
+ catalogGeneration: text(
2306
+ value.catalogGeneration,
2307
+ "Composition provenance catalogGeneration",
2308
+ 256,
2309
+ ),
2310
+ };
2311
+ }
2113
2312
  if (kind === "user") {
2114
2313
  const value = exactRecord(input, "Composition provenance", [
2115
2314
  "kind",
@@ -2177,18 +2376,32 @@ function compositionOriginView(input: unknown): CompositionOriginViewV1 {
2177
2376
  };
2178
2377
  }
2179
2378
  if (kind === "revert") {
2180
- const value = exactRecord(input, "Composition origin", [
2379
+ const value = record(input, "Composition origin");
2380
+ const revertsTo = decodeCompositionGenerationIdV1(
2381
+ value.revertsTo,
2382
+ "Composition origin revertsTo",
2383
+ );
2384
+ if (Object.hasOwn(value, "userId")) {
2385
+ exactRecord(input, "Composition origin", ["kind", "revertsTo", "userId"]);
2386
+ return {
2387
+ kind: "revert",
2388
+ revertsTo,
2389
+ userId: text(value.userId, "Composition origin userId", 256),
2390
+ };
2391
+ }
2392
+ const bot = exactRecord(input, "Composition origin", [
2181
2393
  "kind",
2182
2394
  "revertsTo",
2183
- "userId",
2395
+ "botId",
2396
+ "runId",
2397
+ "turnId",
2184
2398
  ]);
2185
2399
  return {
2186
2400
  kind: "revert",
2187
- revertsTo: decodeCompositionGenerationIdV1(
2188
- value.revertsTo,
2189
- "Composition origin revertsTo",
2190
- ),
2191
- userId: text(value.userId, "Composition origin userId", 256),
2401
+ revertsTo,
2402
+ botId: decodeBotIdV1(bot.botId),
2403
+ runId: text(bot.runId, "Composition origin runId", 128),
2404
+ turnId: text(bot.turnId, "Composition origin turnId", 128),
2192
2405
  };
2193
2406
  }
2194
2407
  throw new ConfigurationDecodeError("Composition origin kind is invalid");