@jr2/orchestrator 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -4
  13. package/src/actor.ts +502 -49
  14. package/src/ambient.ts +2 -0
  15. package/src/assembly-checks.ts +165 -0
  16. package/src/assembly-tar.ts +494 -0
  17. package/src/assembly.ts +721 -0
  18. package/src/chart.ts +2050 -0
  19. package/src/charts.ts +720 -0
  20. package/src/config.ts +152 -46
  21. package/src/content-hash.ts +105 -0
  22. package/src/custodian.lua +6 -5
  23. package/src/custodian.ts +71 -19
  24. package/src/customize.ts +216 -25
  25. package/src/effect-checks.ts +557 -0
  26. package/src/effect.ts +905 -0
  27. package/src/effector-wire.ts +525 -0
  28. package/src/fingerprint.ts +4 -3
  29. package/src/gate.ts +8 -0
  30. package/src/git-clone.ts +65 -0
  31. package/src/git-push.ts +336 -0
  32. package/src/harness-client.ts +245 -23
  33. package/src/held-secrets.ts +82 -26
  34. package/src/holds.ts +156 -0
  35. package/src/http-effect.ts +981 -0
  36. package/src/http.ts +496 -104
  37. package/src/images.ts +47 -21
  38. package/src/index.ts +43 -2
  39. package/src/instance.ts +444 -58
  40. package/src/kube-client.ts +333 -0
  41. package/src/machine-doc.ts +88 -6
  42. package/src/mount-digest.ts +53 -0
  43. package/src/names.ts +66 -8
  44. package/src/notices.ts +88 -0
  45. package/src/open-body.ts +1444 -0
  46. package/src/open-effect.ts +144 -0
  47. package/src/open.ts +4 -4
  48. package/src/parts.ts +530 -33
  49. package/src/party.ts +468 -0
  50. package/src/pick.ts +309 -0
  51. package/src/pool.ts +1 -1
  52. package/src/registration.ts +301 -17
  53. package/src/repo-fetch.ts +60 -85
  54. package/src/repos.ts +63 -74
  55. package/src/rest.ts +133 -0
  56. package/src/run-host.ts +1832 -98
  57. package/src/sandbox-effect.ts +891 -0
  58. package/src/sandbox-effector.ts +111 -0
  59. package/src/sandbox-kube.ts +1719 -0
  60. package/src/sandbox-watch.ts +260 -0
  61. package/src/secret-placement.ts +494 -0
  62. package/src/server.ts +281 -84
  63. package/src/setup.ts +589 -70
  64. package/src/size.ts +342 -0
  65. package/src/snapshot-store.ts +64 -13
  66. package/src/stub-harness.ts +5 -0
  67. package/src/sync-client.ts +54 -0
  68. package/src/timers.ts +178 -0
  69. package/src/tokens.ts +35 -4
  70. package/src/vocabulary.ts +66 -1
  71. package/src/wire.ts +156 -2
  72. package/src/workspace.ts +529 -194
  73. package/src/sandbox-kubectl.ts +0 -1108
package/src/customize.ts CHANGED
@@ -9,24 +9,31 @@
9
9
  // `.provide()`, nothing to learn (ADR-0015's rule, restated by ADR-0049 — a `.with()` method and
10
10
  // a callable-machine hybrid were both rejected there).
11
11
  //
12
- // Three moves, and each is the smallest one that works:
12
+ // Four moves, and each is the smallest one that works:
13
13
  //
14
14
  // - An AGENT override is xstate's own `provide`: `machine.provide({ actors: { coder:
15
15
  // agent({ ...stock, ...override }) } })`. The slot already IS the Agent (ADR-0049), so
16
16
  // retuning one is substituting a slot's logic, which is what `provide` is for.
17
+ // - An EFFECT binding is the same substitution (ADR-0054): an Effect whose url or argv the
18
+ // package left `open`, or whose auth, header or env names `secret(open)`, carries a binder,
19
+ // which builds the Effect again with the composer's parts, so the bound slot is checked as a
20
+ // written one is. Once — a bound part is fixed from then on.
17
21
  // - A CHILD override is the same call one level down, recursing. Each level is exactly the
18
22
  // one-level reach ADR-0015 found `provide` has — held by the composer who owns the child
19
23
  // object, never host-side injection into somebody else's Machine.
20
- // - The IMAGE and the REPOS are the parts `provide` cannot carry: xstate copies implementations
24
+ // - The IMAGE, the REPOS and the SIZE are the parts `provide` cannot carry: xstate copies implementations
21
25
  // and passes the CONFIG through by reference, and every part a Machine carries is keyed on
22
26
  // that config (parts.ts, vocabulary.ts). A new image or a bound slot therefore needs a new key
23
27
  // — so those fields clone the wrapper's config and rebuild it with the same implementations,
24
- // re-attaching what the original carried.
28
+ // re-attaching what the original carried. The load warnings of a bound argv are the same
29
+ // kind of part: the Machine the author wrote is rebuilt the same way, carrying the rechecked
30
+ // warnings in place of the Open Machine's.
25
31
  //
26
32
  // jr2's wrappers are TRANSPARENT: `agents`/`actors` route through `workspace()`'s `body` and
27
33
  // `pool()`'s `worker`, so a consumer customizing a Workspace-rooted workflow never writes `body`
28
- // and never has to know that jr2 wrapped anything. `image`/`user`/`repos` travel the same chain in
29
- // the other direction — down to the `workspace()` that owns the seats and the slots.
34
+ // and never has to know that jr2 wrapped anything. `image`/`user`/`repos`/`resources` travel the same
35
+ // chain in the other direction — down to the `workspace()` that owns the seats, the slots and the
36
+ // Size.
30
37
  //
31
38
  // Both halves route on the wrapper's own RECORD of being one, never on a slot's spelling: the
32
39
  // runtime reads `wrapperBodyOf` and the types read `JR2Wrapper` (parts.ts), which the wrappers
@@ -38,7 +45,7 @@
38
45
  // its slot MAP Open (`repos: open`, ADR-0051) declared exactly this: every slot is the composer's
39
46
  // to name, so the map they write is the declaration, not an addition to one.
40
47
 
41
- import { StateMachine, type AnyStateMachine, type InputFrom, type ProvidedActor } from "xstate";
48
+ import { StateMachine, type AnyStateMachine, type InputFrom, type ProvidedActor, type UnknownActorLogic } from "xstate";
42
49
  import type { AgentLogic } from "./actor.ts";
43
50
  import { isAgent, type AgentDefinition } from "./agent.ts";
44
51
  import { agent } from "./harness-client.ts";
@@ -56,7 +63,30 @@ import {
56
63
  type RepoSlot,
57
64
  type SandboxParts,
58
65
  } from "./parts.ts";
59
- import { attachInputSchema, attachVocabulary, inputSchemaOf, vocabularyOf } from "./vocabulary.ts";
66
+ import { assertSize, assertUserSeat, layerSize, type Size, type UserContainer } from "./size.ts";
67
+ import {
68
+ attachInputSchema,
69
+ attachLoadWarnings,
70
+ attachSeats,
71
+ attachVocabulary,
72
+ inputSchemaOf,
73
+ loadWarningsOf,
74
+ seatsOf,
75
+ vocabularyOf,
76
+ } from "./vocabulary.ts";
77
+ import { isEffect, type Arg } from "./effect.ts";
78
+ import { attachLent, copyOpenBody, lentIn, openBodiesOf, openBodyDeclarationOf } from "./open-body.ts";
79
+ import { effectChecks } from "./effect-checks.ts";
80
+ import type { Text } from "./http-effect.ts";
81
+ import {
82
+ binderOf,
83
+ effectBindingShape,
84
+ openPartsOf,
85
+ openPartsText,
86
+ type EffectBinding,
87
+ type OpenEffect,
88
+ type OpenEffectPart,
89
+ } from "./open-effect.ts";
60
90
 
61
91
  // --- What a Machine declares, read at the TYPE level ---------------------------------------------
62
92
  // Everything below reads xstate's own `TActor` parameter — the `{ src, logic, id }` union
@@ -92,6 +122,26 @@ type LogicAt<M extends AnyStateMachine, K extends string> = Extract<SlotsOf<M>,
92
122
  /** The Agent slots — told from every other slot by their logic, which no other actor kind has. */
93
123
  type AgentSlots<M extends AnyStateMachine> = Extract<SlotsOf<M>, { logic: AgentLogic }>["src"];
94
124
 
125
+ /** The Effect slots with an Open part — told by the phantom `OpenEffect` their constructor's
126
+ * overload states for an `open` url or argv, or a `secret(open)` (open-effect.ts). */
127
+ type OpenEffectSlots<M extends AnyStateMachine> = Extract<SlotsOf<M>, { logic: OpenEffect<OpenEffectPart> }>["src"];
128
+
129
+ /** What one Open part binds to: a url, an argv, a secret's name. */
130
+ type PartBinding<P> = P extends "url"
131
+ ? { url: string | Text }
132
+ : P extends "argv"
133
+ ? { argv: readonly (string | Arg)[] }
134
+ : P extends "secret"
135
+ ? { secret: string }
136
+ : never;
137
+
138
+ /** A union of object types as their intersection: one binding object with every key. */
139
+ type Intersected<U> = (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
140
+
141
+ /** What one Open Effect binds to: its url, its argv or its secret — for an Effect with two Open
142
+ * parts, one object with both keys, the intersection of their bindings (ADR-0054). */
143
+ type BindingOf<L> = L extends OpenEffect<infer P> ? Intersected<PartBinding<P>> : never;
144
+
95
145
  /** The child-Machine slots: the slots composition reaches (ADR-0049 — a nested Machine is an
96
146
  * ordinary `invoke` of an ordinary slot). */
97
147
  type ChildSlots<M extends AnyStateMachine> = Extract<SlotsOf<M>, { logic: AnyStateMachine }>["src"];
@@ -150,6 +200,15 @@ export type Customize<M extends AnyStateMachine> = {
150
200
  agents?: [AgentSlots<Reached<M>>] extends [never]
151
201
  ? never
152
202
  : { [K in AgentSlots<Reached<M>>]?: Partial<AgentDefinition> };
203
+ /** Bind an Effect's Open parts (ADR-0054): `{ url }` for an http Effect (whatever its body) whose url
204
+ * the package left Open, `{ argv }` for a command whose argv it left Open, `{ secret }` for an
205
+ * Effect that names `secret(open)` — one object with both keys for an Effect with two, so a binding
206
+ * that leaves one out does not compile. Bound once, here — a value, never a mapper over the door,
207
+ * so the host is fixed before any run. Only an Effect with an Open part is offered: a part its
208
+ * author fixed stays fixed. A Machine with none takes `never`, as `agents` does. */
209
+ effects?: [OpenEffectSlots<Reached<M>>] extends [never]
210
+ ? never
211
+ : { [K in OpenEffectSlots<Reached<M>>]?: BindingOf<LogicAt<Reached<M>, K>> };
153
212
  /** Retune a Machine this one composes — the same shape, one level down. */
154
213
  actors?: [ChildSlots<Reached<M>>] extends [never]
155
214
  ? never
@@ -157,8 +216,12 @@ export type Customize<M extends AnyStateMachine> = {
157
216
  /** The Sandbox Image (ADR-0037): a `file:` URL to a docker context this module ships, or a
158
217
  * registry ref. */
159
218
  image?: string;
160
- /** The User Container's image (ADR-0005), in the same two shapes. */
161
- user?: string;
219
+ /** The User Container (ADR-0005): its image in the same two shapes, or `{ image, resources }`
220
+ * with its split of the Size (ADR-0060). Replaces the seat whole — split included. */
221
+ user?: string | UserContainer;
222
+ /** The Size (ADR-0060), layered field by field over the `workspace()`'s own: the composer's
223
+ * Repo may be bigger than the author's. `limits.memory` and `limits.cpu`, nothing else. */
224
+ resources?: Size;
162
225
  /** Bind the Repo Slots the reached `workspace()` declared (ADR-0051) — an open slot to a url,
163
226
  * or a bound or per-run one to a different Binding; any of the three forms, so a consumer can
164
227
  * also bind a mapper over the wrapper's door. A slot the Machine does not declare is a compile
@@ -171,10 +234,12 @@ export type Customize<M extends AnyStateMachine> = {
171
234
  /** The same shape with the types erased — what the implementation walks. */
172
235
  type LooseParts = {
173
236
  agents?: Record<string, Partial<AgentDefinition> | undefined>;
237
+ effects?: Record<string, EffectBinding | undefined>;
174
238
  actors?: Record<string, LooseParts | undefined>;
175
239
  image?: string;
176
- user?: string;
240
+ user?: string | UserContainer;
177
241
  repos?: Record<string, RepoSlot | undefined>;
242
+ resources?: Size;
178
243
  };
179
244
 
180
245
  /**
@@ -183,23 +248,32 @@ type LooseParts = {
183
248
  * `deep`/`quick`), and a run of either carries what it was given.
184
249
  */
185
250
  export function customize<M extends AnyStateMachine>(machine: M, parts: Customize<M>): M {
186
- const { agents, actors, image, user, repos } = parts as LooseParts;
251
+ const { agents, effects, actors, image, user, repos, resources } = parts as LooseParts;
187
252
  let out: AnyStateMachine = machine;
188
253
  // Order matters in one direction only: the Sandbox seats and slots REBUILD the wrapper, so they
189
254
  // go last and carry the retuned implementations with them.
190
- if (agents || actors) out = retune(out, { agents, actors });
255
+ if (agents || effects || actors) out = retune(out, { agents, effects, actors });
191
256
  const seats: Seats = {
192
257
  ...(image !== undefined ? { image } : {}),
193
258
  ...(user !== undefined ? { user } : {}),
194
259
  ...(repos !== undefined ? { repos } : {}),
260
+ ...(resources !== undefined ? { resources } : {}),
195
261
  };
196
- if (seats.image !== undefined || seats.user !== undefined || seats.repos !== undefined) out = reseat(out, seats);
262
+ // Refused where the composer wrote them, by field (ADR-0060), before any wrapper is rebuilt.
263
+ if (seats.user !== undefined) assertUserSeat("customize()", seats.user);
264
+ if (seats.resources !== undefined) assertSize("customize(): resources", seats.resources);
265
+ if (Object.keys(seats).length > 0) out = reseat(out, seats);
197
266
  return out as M;
198
267
  }
199
268
 
200
- /** What `reseat` carries down the chain: the two image seats and the slot overrides, each present
201
- * only when the composer named it. */
202
- type Seats = { image?: string; user?: string; repos?: Record<string, RepoSlot | undefined> };
269
+ /** What `reseat` carries down the chain: the two image seats, the slot overrides and the Size, each
270
+ * present only when the composer named it. */
271
+ type Seats = {
272
+ image?: string;
273
+ user?: string | UserContainer;
274
+ repos?: Record<string, RepoSlot | undefined>;
275
+ resources?: Size;
276
+ };
203
277
 
204
278
  /** The slot keys whose logic answers `pred` — what an error names, so the message is the
205
279
  * Machine's own declaration rather than advice. */
@@ -246,6 +320,8 @@ function retune(machine: AnyStateMachine, parts: LooseParts): AnyStateMachine {
246
320
  // walk reports it again (ADR-0054).
247
321
  actors[name] = agent({ ...logic.definition, ...override });
248
322
  }
323
+ const bound = Object.entries(parts.effects ?? {}).filter(([, b]) => b !== undefined);
324
+ for (const [name, binding] of bound) actors[name] = bindEffect(machine, name, binding!);
249
325
  for (const [name, childParts] of Object.entries(parts.actors ?? {})) {
250
326
  if (!childParts) continue;
251
327
  const child = asMachine((machine.implementations.actors as Record<string, unknown>)[name]);
@@ -257,15 +333,114 @@ function retune(machine: AnyStateMachine, parts: LooseParts): AnyStateMachine {
257
333
  }
258
334
  actors[name] = customize(child, childParts as Customize<AnyStateMachine>);
259
335
  }
260
- return substitute(machine, actors);
336
+ // An Open body lends by slot name (ADR-0070), and its stamped lent set must be the retuned slots,
337
+ // not the import's: each body is copied (the import keeps its own stamp) and stamped again over
338
+ // the retuned actors.
339
+ if (Object.keys(actors).length > 0) {
340
+ const impl = machine.implementations as Parameters<typeof lentIn>[0];
341
+ const merged = { ...impl, actors: { ...(impl.actors ?? {}), ...actors } };
342
+ for (const { slot, logic } of openBodiesOf(machine)) {
343
+ const copy = copyOpenBody(logic);
344
+ attachLent(copy, lentIn(merged, openBodyDeclarationOf(logic)));
345
+ actors[slot] = copy;
346
+ }
347
+ }
348
+ const out = substitute(machine, actors);
349
+ if (bound.length === 0) return out;
350
+ // The bound Machine's warnings differ from the Open one's, and `provide` kept the Open one's
351
+ // config, which is what every attachment keys on (ADR-0049) — so it gets a config of its own,
352
+ // carrying what the original carried and the rechecked warnings, and any later `provide()` of
353
+ // it (a run's, ADR-0015) keeps them.
354
+ const warnings = recheck(machine, out);
355
+ const rebuilt = cloneConfig(out);
356
+ attachLoadWarnings(rebuilt, warnings);
357
+ return rebuilt;
358
+ }
359
+
360
+ /**
361
+ * Bind one Effect's Open parts (ADR-0054): the slot must be an Effect, each key of the binding one
362
+ * of its Open parts, and each value the value that part takes — a url (a string, or a `text` with a
363
+ * secret in its path), an argv, or a secret's name. A key left out leaves that part Open. The binder
364
+ * builds the Effect again, whole, so the bound one meets every check its constructor runs: the url's
365
+ * literal host, the argv's entries, the secret's name. The compiler's check of the
366
+ * invoke's input against the argv's slots does not reach it (an Open argv's input is one string per
367
+ * key): a slot the invoke does not supply faults when the Effect starts (ADR-0054).
368
+ */
369
+ function bindEffect(machine: AnyStateMachine, name: string, binding: EffectBinding): unknown {
370
+ const logic = (machine.implementations.actors as Record<string, unknown>)[name];
371
+ if (!isEffect(logic)) {
372
+ throw new Error(
373
+ `customize(): machine "${machine.id}" carries no Effect slot "${name}" — its Effects are: ` +
374
+ `${listed(slotsWhere(machine, isEffect))} (ADR-0067). Only declared parts can be bound.`,
375
+ );
376
+ }
377
+ const parts = openPartsOf(logic.effect);
378
+ const binder = binderOf(logic);
379
+ if (parts.length === 0 || binder === undefined) {
380
+ throw new Error(
381
+ `customize(): the ${logic.effect.kind} Effect "${name}" of machine "${machine.id}" leaves nothing Open — its ` +
382
+ "parts are fixed in its definition, and only a part a package left `open` is bound, once (ADR-0054).",
383
+ );
384
+ }
385
+ // One object binds them, each key an Open part; a key it leaves out leaves that part Open, and
386
+ // the walk reports it again (ADR-0054).
387
+ const given = typeof binding === "object" && binding !== null ? (binding as Record<string, unknown>) : {};
388
+ const keys = Object.keys(given).filter((k) => given[k] !== undefined);
389
+ if (keys.length === 0 || keys.some((k) => !parts.includes(k as OpenEffectPart))) {
390
+ throw new Error(
391
+ `customize(): the ${logic.effect.kind} Effect "${name}" leaves its ${openPartsText(parts)} Open — bind it ` +
392
+ `with ${effectBindingShape(parts)} (ADR-0054)`,
393
+ );
394
+ }
395
+ for (const part of keys) {
396
+ const value = given[part];
397
+ if (value === open || typeof value === "function") {
398
+ throw new Error(
399
+ `customize(): the ${logic.effect.kind} Effect "${name}"'s ${part} is bound once, to a value — not ` +
400
+ `${value === open ? "`open` again" : "a function of the run"}: no run chooses an Effect's ${part} (ADR-0054)`,
401
+ );
402
+ }
403
+ if (part === "secret" && typeof value !== "string") {
404
+ throw new Error(
405
+ `customize(): the ${logic.effect.kind} Effect "${name}"'s secret is bound to a secret's name, a string — ` +
406
+ `the file the Effector reads it from: { secret: "<name>" } (ADR-0054, ADR-0068)`,
407
+ );
408
+ }
409
+ }
410
+ return binder(binding);
411
+ }
412
+
413
+ /**
414
+ * The load walk's Effect checks (ADR-0067), again, on a Machine whose Open argv this `customize()`
415
+ * bound: at build its argv slots were unknown and every pick param counted as read; now the bound
416
+ * argv names them. A param it does not read is refused as at build, and a warning the walk gives
417
+ * for the bound argv (a param only an action the probe does not run may read) replaces the
418
+ * findings it gave for the Open one: the result is the warnings the same Machine written with that
419
+ * argv carries, which the caller attaches to the bound Machine's own config.
420
+ */
421
+ function recheck(before: AnyStateMachine, machine: AnyStateMachine): string[] {
422
+ const defs = vocabularyOf(machine);
423
+ if (!defs) return loadWarningsOf(before);
424
+ const checks = (m: AnyStateMachine) =>
425
+ effectChecks(m, m.implementations.actors as Record<string, UnknownActorLogic>, defs);
426
+ const { errors, warnings } = checks(machine);
427
+ if (errors.length) throw new Error(`customize(): machine "${machine.id}": ${errors.join("; ")}`);
428
+ const unbound = new Set(checks(before).warnings);
429
+ const kept = loadWarningsOf(before).filter((warning) => !unbound.has(warning));
430
+ return [...new Set([...kept, ...warnings])];
261
431
  }
262
432
 
263
433
  /** The Sandbox seats and slots, applied to the `workspace()` the chain reaches. */
264
434
  function reseat(machine: AnyStateMachine, seats: Seats): AnyStateMachine {
265
435
  if (composesSandbox(machine)) {
266
- const { repos: override, ...images } = seats;
436
+ const { repos: override, resources, ...images } = seats;
267
437
  const parts = sandboxPartsOf(machine);
268
- if (parts.repos === open) return rebuild(machine, { ...parts, ...images, repos: nameSlots(machine, override) });
438
+ // The Size layers over the author's, field by field — a composer who raises memory keeps the
439
+ // author's cpu (ADR-0060).
440
+ const size = layerSize(parts.resources, resources);
441
+ const sized = size !== undefined ? { resources: size } : {};
442
+ if (parts.repos === open)
443
+ return rebuild(machine, { ...parts, ...images, ...sized, repos: nameSlots(machine, override) });
269
444
  const repos = { ...parts.repos };
270
445
  // Only DECLARED slots can be bound (ADR-0051): the Machine's own word for each Repo is the
271
446
  // key, and a key it never declared would attach a repository the body has no handle for.
@@ -285,15 +460,16 @@ function reseat(machine: AnyStateMachine, seats: Seats): AnyStateMachine {
285
460
  assertRepoSlot("customize()", slot, value);
286
461
  repos[slot] = value;
287
462
  }
288
- return rebuild(machine, { ...parts, ...images, repos });
463
+ return rebuild(machine, { ...parts, ...images, ...sized, repos });
289
464
  }
290
465
  const inner = bodyOf(machine);
291
466
  if (!inner) {
292
- const named = seats.repos !== undefined ? "`repos`" : "`image`/`user`";
467
+ const named =
468
+ seats.repos !== undefined ? "`repos`" : seats.resources !== undefined ? "`resources`" : "`image`/`user`";
293
469
  throw new Error(
294
- `customize(): ${named} say what a SANDBOX is made of and which Repos it attaches, and machine ` +
295
- `"${machine.id}" composes none — only a workspace() wrapper carries those seats and slots ` +
296
- "(ADR-0049, ADR-0037, ADR-0051).",
470
+ `customize(): ${named} say what a SANDBOX is made of, how big it is and which Repos it attaches, and ` +
471
+ `machine "${machine.id}" composes none — only a workspace() wrapper carries those seats, that Size ` +
472
+ "and those slots (ADR-0049, ADR-0037, ADR-0051, ADR-0060).",
297
473
  );
298
474
  }
299
475
  return substitute(machine, { [inner.slot]: reseat(inner.body, seats) });
@@ -336,13 +512,28 @@ function nameSlots(machine: AnyStateMachine, override: Seats["repos"]): SandboxP
336
512
  * fingerprint a restore compares (ADR-0030) — is bit-identical to the original's.
337
513
  */
338
514
  function rebuild(machine: AnyStateMachine, seats: SandboxParts): AnyStateMachine {
515
+ const clone = cloneConfig(machine);
516
+ attachSandboxParts(clone, seats);
517
+ return clone;
518
+ }
519
+
520
+ /**
521
+ * The Machine around a NEW config object — a shallow clone, so the shape and the fingerprint are
522
+ * the original's — with the same implementations, and every attachment the original carried
523
+ * re-attached under the new key (ADR-0049): the vocabulary, the door, the seats, the load
524
+ * warnings and a wrapper's body. What the caller attaches after is the clone's alone.
525
+ */
526
+ function cloneConfig(machine: AnyStateMachine): AnyStateMachine {
339
527
  const clone = new StateMachine({ ...machine.config }, machine.implementations) as AnyStateMachine;
340
528
  const vocabulary = vocabularyOf(machine);
341
529
  if (vocabulary) attachVocabulary(clone, vocabulary);
342
530
  const door = inputSchemaOf(machine);
343
531
  if (door) attachInputSchema(clone, door);
532
+ const seats = seatsOf(machine);
533
+ if (seats.length > 0) attachSeats(clone, seats);
534
+ const warnings = loadWarningsOf(machine);
535
+ if (warnings.length > 0) attachLoadWarnings(clone, warnings);
344
536
  const slot = wrapperBodyOf(machine);
345
537
  if (slot) attachWrapperBody(clone, slot);
346
- attachSandboxParts(clone, seats);
347
538
  return clone;
348
539
  }