@melite/estate-drafts 0.0.0-stage → 0.1.4-alpha

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/lib/bench.js ADDED
@@ -0,0 +1,1866 @@
1
+
2
+ //
3
+ // The bench doc IS a draft doc (same domain, same table, id
4
+ // "bench-<session>") plus a `bench` block: { rev, log } — the
5
+ // append-only revision log both authors walk. HTTP vocabulary is
6
+ // dead with the gateway: birth/load refuse as RemoteError codes,
7
+ // never status payloads.
8
+ //
9
+ // Laws carried over (the bench rework's, unchanged):
10
+ // CAS — ensure-with-stages takes expect_rev; a moved bench refuses
11
+ // loud (bench/cas-conflict carries current_rev), never merges.
12
+ // APPEND-ONLY LOG — load appends a revert-carrying entry; history
13
+ // is never rewritten (patch/undo arms land in later strokes).
14
+ // AUTHORSHIP — every entry carries author ("agent"|"user").
15
+ import { unwrapItemArrays } from '@melite/comfyui-card-kit';
16
+ import { benchDraftIdOf, BIRTH_STRIP_KEYS, LOAD_CARRY_KEYS, normalizeBenchStage, SELF_NAMED_FAMILIES, stripKeys } from '@melite/contracts/benchStage';
17
+ import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+ import { BENCH_DRAFT_RE, RESTORED_PROVENANCE_SPELLING, SESSION_RE } from "@melite/contracts/benchStage";
29
+ import { RESTORED_PROVENANCE } from "./remote.js";
30
+ export { SESSION_RE };
31
+ function benchIdOf(session) {
32
+ return benchDraftIdOf(session);
33
+ }
34
+ const AUTHORS = ["agent", "user"];
35
+ function authorOk(v) {
36
+ return v === "agent" || v === "user";
37
+ }
38
+ function isRecord(v) {
39
+ return typeof v === "object" && v !== null && !Array.isArray(v);
40
+ }
41
+ export function benchBlockOf(doc) {
42
+ const raw = doc["bench"];
43
+ if (isRecord(raw) &&
44
+ typeof raw["rev"] === "number" &&
45
+ Array.isArray(raw["log"])) {
46
+ return { rev: raw["rev"], log: raw["log"] };
47
+ }
48
+ return { rev: 0, log: [] };
49
+ }
50
+ function stagesOf(doc) {
51
+ // The domain schema promises stages is an array of id-carrying
52
+ // objects (DraftDocSchema), but nothing enforces the promise on
53
+ // this path — a doc written behind the domain's back (an older
54
+ // door, a hand edit) reads back as it was written, and a reader
55
+ // that trusted the promise crashed on a null row. The read
56
+ // projection owns the guarantee: only record rows ride, and a
57
+ // row's id is read where it is used, never assumed.
58
+ const stages = doc["stages"];
59
+ if (!Array.isArray(stages))
60
+ return [];
61
+ return stages.filter((s) => isRecord(s));
62
+ }
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+ function seedStateOf(row) {
71
+ // Post-normalize rows ALWAYS carry a record params (normalizeStages
72
+ // replaces junk/missing params with {}) — no re-defense here.
73
+ const seed = row["params"]["seed"];
74
+ if (typeof seed === "number" && seed !== -1)
75
+ return `${row["id"]} seed ${seed} (pinned)`;
76
+ if (seed === -1)
77
+ return `${row["id"]} seed random`;
78
+ return `${row["id"]} seed absent`;
79
+ }
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+ export function mintStageId(rows, body) {
95
+ if (typeof body["id"] === "string" && body["id"] !== "")
96
+ return body["id"];
97
+ return nextStageId(highestStageSuffix(rows), takenStageIds(rows));
98
+ }
99
+ /** THE HIGHEST `s_<n>` ON THE BENCH, or -1 when there is none (the
100
+ * ONE arithmetic; both id producers take it).
101
+ *
102
+ * It was written twice — once in the write door's mint and once in
103
+ * the load path's dedupe — and the identity wall caught the second
104
+ * copy on its first run, which is the wall doing precisely its job.
105
+ * A formula that decides what a stage is called belongs in one
106
+ * place; the two callers differ only in what they do with the
107
+ * answer. */
108
+ function highestStageSuffix(rows) {
109
+ let max = -1;
110
+ for (const row of rows) {
111
+ const m = /^s_(\d+)$/.exec(String(row["id"] ?? ""));
112
+ if (m !== null && Number(m[1]) > max)
113
+ max = Number(m[1]);
114
+ }
115
+ return max;
116
+ }
117
+ /** The next free `s_<n>` at or above `max + 1`, skipping any id the
118
+ * caller already holds. The writer's mint and the loader's repair
119
+ * both ask this, so "the next id" means one thing in this file. */
120
+ function nextStageId(max, taken) {
121
+ let n = max;
122
+ let fresh;
123
+ do {
124
+ n += 1;
125
+ fresh = "s_" + String(n);
126
+ } while (taken.has(fresh));
127
+ return fresh;
128
+ }
129
+ /** The ids already spoken for on the bench — every stage's own. A
130
+ * mint that skips these cannot collide with a row the caller has
131
+ * not seen yet. */
132
+ function takenStageIds(rows) {
133
+ const taken = new Set();
134
+ for (const row of rows) {
135
+ const id = row["id"];
136
+ if (typeof id === "string")
137
+ taken.add(id);
138
+ }
139
+ return taken;
140
+ }
141
+ /** Birth defaults per the composer's law (id, inputs, provenance)
142
+ * with the runner's object inputs. */
143
+ export function normalizeStages(stagesIn, author) {
144
+ if (!Array.isArray(stagesIn))
145
+ return [];
146
+ const kept = stagesIn.filter((s) => isRecord(s));
147
+ // which rows arrived with their OWN id (the dedupe's pass-1 set)
148
+ const arrivedWithId = kept.map((s) => typeof s["id"] === "string");
149
+ const normalized = kept
150
+ .map((s, idx) => {
151
+ // THE PARAMS-SHAPE REFUSAL (commission 015, §6 finding 5):
152
+ // normalizeBenchStage heals a non-record params to {} — the
153
+ // right law for a doc already on disk, but at the WRITE door
154
+ // that heal minted a stage from the card's knobs WRAPPED IN AN
155
+ // ARRAY with empty params instead of refusing, the bench door
156
+ // looser than the card it stages. The judgment runs on the
157
+ // UNWRAPPED value (the wire's `{item:[…]}` spelling resolves to
158
+ // the same bare array); the add and patch doors carry the same
159
+ // law.
160
+ const rawParams = wireParams(s["params"]);
161
+ if (rawParams !== undefined && !isRecord(rawParams)) {
162
+ const named = s["id"];
163
+ throw new RemoteError("bench/bad-body", "stage " + (typeof named === "string" && named !== "" ? named : "#" + idx) +
164
+ ": params must be an object (got " + (Array.isArray(rawParams) ? "an array — the card's knobs, not a list of them" : typeof rawParams) +
165
+ "); a wire that cannot be read is refused here, not healed", {});
166
+ }
167
+ // THE ONE NORMALIZATION (benchStage.ts — the contract): id
168
+ // fill, DERIVED engine, run-memory strip, run-media path heal,
169
+ // the self-naming model strip, inputs/params/outputs defaults.
170
+
171
+ // GUI's row, the agent's row and a restored row became three
172
+ // different documents.
173
+ const row = normalizeBenchStage(s, idx);
174
+ // the load arm's rows cross the same remote encoding — the
175
+ // store-seat unwrap applies here too (wireParams law)
176
+ row["params"] = wireParams(row["params"]);
177
+
178
+ // audit's CRITICAL-1): provenance 'restored' is how the model
179
+ // REPAIR law knows a row came off an archive. Unconditionally
180
+ // stamping the author here erased it on every load, so a
181
+ // design-era row (params.model naming the card, an unboarded
182
+ // family) refused at its next admission with no repair — the
183
+ // one job the coordinate exists for. The author's own rows are
184
+ // stamped as before; a restored row keeps its mark.
185
+ // THE COORDINATE HAS TWO SPELLINGS, AND BOTH ARE KEPT (the
186
+
187
+ // to judge its stored rows: the stamp respected the WIRE string
188
+ // and silently overwrote the in-process SYMBOL, so a
189
+ // server-armed restore lost its mark on the very path that
190
+ // exists to carry it). A coordinate with one spelling on the
191
+ // wire and another in memory is two facts wearing one name —
192
+ // the stamp respects both, and the door that mints the symbol
193
+ // is the only producer of it.
194
+ if (row["provenance"] !== RESTORED_PROVENANCE_SPELLING
195
+ && row["provenance"] !== RESTORED_PROVENANCE) {
196
+ row["provenance"] = author;
197
+ }
198
+ return row;
199
+ });
200
+ // THE SECOND PASS (the identity audit's A3). The map above is
201
+ // left exactly as it was; the uniqueness law is a separate step so
202
+ // the contract's positional fill stays the contract's.
203
+ return dedupeStageIds(normalized, arrivedWithId);
204
+ }
205
+
206
+
207
+
208
+
209
+
210
+
211
+
212
+
213
+
214
+
215
+
216
+
217
+
218
+
219
+
220
+
221
+
222
+
223
+
224
+ function dedupeStageIds(rows, arrivedWithId) {
225
+ // PASS 1 — the author's ids, reserved before anything is moved. An
226
+ // explicit id is never the one that gives way.
227
+ const named = [];
228
+ for (const [i, row] of rows.entries()) {
229
+ if (arrivedWithId[i] === true && typeof row["id"] === "string")
230
+ named.push(row);
231
+ }
232
+ const taken = takenStageIds(named);
233
+ // PASS 2 — only a FILLED id may be moved, and only when it is
234
+ // already spoken for. The next free id comes from the same
235
+ // arithmetic the write door mints with (highestStageSuffix), so a
236
+ // load and an add cannot disagree about what "next" means.
237
+ let max = highestStageSuffix(named);
238
+ for (const [i, row] of rows.entries()) {
239
+ if (arrivedWithId[i] === true)
240
+ continue;
241
+ // the id here is normalizeBenchStage's fill — always a string
242
+ // (a doc's non-string id heals to the minted form before this
243
+ // pass, so only a filled id rides)
244
+ const id = row["id"];
245
+ if (!taken.has(id)) {
246
+ taken.add(id);
247
+ continue;
248
+ }
249
+ let fresh;
250
+ do {
251
+ max += 1;
252
+ fresh = "s_" + String(max);
253
+ } while (taken.has(fresh));
254
+ row["id"] = fresh;
255
+ taken.add(fresh);
256
+ }
257
+ return rows;
258
+ }
259
+ const ENSURE_KEYS = ["session", "stages", "intent", "author", "expect_rev", "overwrite"];
260
+ function refuseUnknown(body, allowed) {
261
+ const unknown = Object.keys(body).filter((k) => !allowed.includes(k));
262
+ if (unknown.length > 0) {
263
+ throw new RemoteError("bench/bad-body", "unknown bench key(s): " +
264
+ unknown.sort().join(", ") +
265
+ " (want " +
266
+ allowed.join(", ") +
267
+ ")", {});
268
+ }
269
+ }
270
+ /** ensure — create the bench (birth: rev 0, empty log) or LOAD onto
271
+ * it (replace stages, snapshot inverse; a non-empty bench demands
272
+ * overwrite: true — the overwrite law). The only arm that may
273
+ * create; everything else refuses a missing bench. */
274
+
275
+
276
+
277
+
278
+
279
+
280
+
281
+
282
+
283
+
284
+
285
+
286
+
287
+
288
+
289
+
290
+
291
+ function syncIntent(doc, before, after) {
292
+ const cur = typeof doc["intent"] === "string" ? doc["intent"] : "";
293
+ if (cur === "" || cur === "bench")
294
+ return;
295
+ const prevCards = new Set();
296
+ for (const row of before) {
297
+ const c = row["card"];
298
+ if (typeof c === "string" && c !== "")
299
+ prevCards.add(c);
300
+ }
301
+ if (!prevCards.has(cur))
302
+ return;
303
+ const nextCards = new Set();
304
+ for (const row of after) {
305
+ const c = row["card"];
306
+ if (typeof c === "string" && c !== "")
307
+ nextCards.add(c);
308
+ }
309
+ if (nextCards.has(cur))
310
+ return;
311
+ for (let i = after.length - 1; i >= 0; i--) {
312
+ const c = after[i]["card"];
313
+ if (typeof c === "string" && c !== "") {
314
+ doc["intent"] = c;
315
+ return;
316
+ }
317
+ }
318
+ }
319
+ export async function ensureBench(drafts, body, admit) {
320
+ refuseUnknown(body, ENSURE_KEYS);
321
+ const session = body["session"];
322
+ if (typeof session !== "string" || !SESSION_RE.test(session)) {
323
+ throw new RemoteError("bench/bad-session", "bad session key (want [A-Za-z0-9_-]{1,64})", {});
324
+ }
325
+ const draftId = benchIdOf(session);
326
+ const author = authorOk(body["author"])
327
+ ? body["author"]
328
+ : "agent";
329
+ const rec = drafts.table("docs").get(draftId);
330
+ if (rec === undefined) {
331
+ const stagesIn = Array.isArray(body["stages"]) ? body["stages"] : [];
332
+ // THE JUDGE SEES THE STORED ROW — BIRTH TOO (the walls audit,
333
+
334
+ // the RAW body and then stored the NORMALIZED rows, so the
335
+ // "shaped first, then judged" comment above was false for the
336
+ // birth path. Two different objects were in play: a raw body row
337
+ // was judged, and a normalized one was stored — the exact law the
338
+ // load arm had already been fixed for. The birth arm now judges
339
+ // `born`, the very rows it is about to put on disk.
340
+ const born = normalizeStages(stagesIn, author);
341
+ for (const row of born)
342
+ stripKeys(row, BIRTH_STRIP_KEYS);
343
+ admitStages(admit, born);
344
+ // A BENCH IS BORN CLEAN OF ARCHIVES (the cross-bench history
345
+
346
+ // wand restores onto fresh sessions carrying stage rows from a
347
+ // FOREIGN bench's run snapshot; archives (history,
348
+ // resultParams, runStatus, resolved — the observe state THIS
349
+ // bench's own runs earn) never survive a birth. The one lawful
350
+ // birth memory is `result` — the wand's preview stamp for the
351
+ // ran card. The LOAD arm keeps the stage memory law whole
352
+ // (same-id carry + fill-never-clobber).
353
+ admitWiringList(born, session);
354
+ const doc = {
355
+ draft_id: draftId,
356
+ intent: typeof body["intent"] === "string" ? body["intent"] : "bench",
357
+ stages: born,
358
+ status: "ready",
359
+ createdAt: Date.now(),
360
+ completedAt: null,
361
+ bench: { rev: 0, log: [] },
362
+ };
363
+ await drafts.table("docs").put(draftId, {
364
+ updated_at: Math.floor(Date.now() / 1000),
365
+ doc: doc,
366
+ });
367
+ return {
368
+ draft_id: draftId,
369
+ session,
370
+ rev: 0,
371
+ intent: doc["intent"],
372
+ stages: doc["stages"],
373
+ created: true,
374
+ };
375
+ }
376
+ const doc = rec.doc;
377
+ const block = benchBlockOf(doc);
378
+ if (body["stages"] === undefined) {
379
+ return {
380
+ draft_id: draftId,
381
+ session,
382
+ rev: block.rev,
383
+ intent: doc["intent"],
384
+ stages: doc["stages"],
385
+ created: false,
386
+ };
387
+ }
388
+ const expect = body["expect_rev"];
389
+ if (typeof expect !== "number") {
390
+ throw new RemoteError("bench/cas-conflict", "expect_rev is required (the CAS law)", { current_rev: block.rev });
391
+ }
392
+ if (expect !== block.rev) {
393
+ throw new RemoteError("bench/cas-conflict", "bench moved: rev is " + block.rev + ", caller held " + expect, { current_rev: block.rev, expect_rev: expect });
394
+ }
395
+ const before = stagesOf(doc);
396
+
397
+ // image card with a wholesale load): replacing a NON-EMPTY bench
398
+ // is a destructive act and must say so. expect_rev proves the
399
+ // caller read the bench, not that the user agreed to lose it —
400
+ // pass overwrite: true to confirm (flow landings and the
401
+ // gallery's draft-click already do; the undo log still records
402
+ // stages_before either way).
403
+ if (before.length > 0 && body["overwrite"] !== true) {
404
+ throw new RemoteError("bench/overwrite-required", "bench holds " + before.length + " stage(s) at rev " + block.rev +
405
+ " — a wholesale load would discard them; confirm with overwrite: true, " +
406
+ "or grow the bench with add_stage / patch, or undo if this was a mistake", { current_rev: block.rev, stages: before.length });
407
+ }
408
+ // (the no-leak law: admit on the LOCAL normalized list, assign to
409
+ // the cached doc only after the gates pass.)
410
+ const staged = normalizeStages(body["stages"], author);
411
+ // THE JUDGED VIEW IS THE STORED VIEW (the bench audit's gap 11,
412
+
413
+ // normalized rows — the unwrap, the media heal and the self-naming
414
+ // strip all landed AFTER the judgment, so a body the normalizer
415
+ // would have healed could still be refused. The rows are shaped
416
+ // first, then judged — the order add uses.
417
+ admitStages(admit, staged);
418
+
419
+ // does it never track history?"): a stage's OUTPUT MEMORY — its
420
+ // landed result and archived history — belongs to the STAGE'S
421
+ // IDENTITY, not to the doc revision it happened to land in. A
422
+ // wholesale load rebuilding the bench with the SAME stage ids
423
+ // (a chat agent re-loading the card the operator is iterating
424
+ // on) CARRIES the memory forward: result, runStatus, history,
425
+ // resultParams ride from the outgoing row onto the incoming row
426
+ // of the same id. A genuinely new stage id starts clean. The
427
+ // bench is composed state; the gallery is the stage's own.
428
+ // THE LOAD ARM'S CARRY LIST (the contract's, benchStage.ts): a
429
+ // wholesale load onto the SAME stage ids carries the memory the
430
+ // bench itself earned — the stage's own result/status/history/
431
+ // resultParams. Named in the contract beside the birth strip (the
432
+ // two are complements: birth STRIPS, load CARRIES) so the two arms
433
+ // can never be edited apart.
434
+ const MEMORY_KEYS = LOAD_CARRY_KEYS;
435
+ for (const row of staged) {
436
+ const prev = before.find((b) => b["id"] === row["id"]);
437
+ if (prev === undefined)
438
+ continue;
439
+ for (const key of MEMORY_KEYS) {
440
+ if (prev[key] !== undefined && row[key] === undefined) {
441
+ row[key] = prev[key];
442
+ }
443
+ }
444
+ }
445
+ admitWiringList(staged, session);
446
+ doc["stages"] = staged;
447
+ syncIntent(doc, before, staged);
448
+ if (typeof body["intent"] === "string")
449
+ doc["intent"] = body["intent"];
450
+ const entry = {
451
+ author,
452
+ kind: "load",
453
+ summary: "load " + stagesOf(doc).length + " stages | " + staged.map(seedStateOf).join(" | "),
454
+ inverse: { stages_before: before },
455
+ rev: block.rev + 1,
456
+ at: Math.floor(Date.now() / 1000),
457
+ };
458
+ block.rev = entry.rev;
459
+ block.log.push(entry);
460
+ doc["bench"] = block;
461
+ await drafts.table("docs").put(draftId, {
462
+ updated_at: Math.floor(Date.now() / 1000),
463
+ doc: doc,
464
+ });
465
+ return {
466
+ draft_id: draftId,
467
+ session,
468
+ rev: block.rev,
469
+ intent: doc["intent"],
470
+ stages: doc["stages"],
471
+ created: false,
472
+ };
473
+ }
474
+ /** get — one session's bench block as the served row. */
475
+ export function getBench(drafts, session) {
476
+ if (!SESSION_RE.test(session)) {
477
+ throw new RemoteError("bench/bad-session", "bad session key (want [A-Za-z0-9_-]{1,64})", {});
478
+ }
479
+ const draftId = benchIdOf(session);
480
+ const rec = drafts.table("docs").get(draftId);
481
+ if (rec === undefined) {
482
+ throw new RemoteError("bench/not-found", "no such bench: " + session, {
483
+ session,
484
+ });
485
+ }
486
+ const doc = rec.doc;
487
+
488
+ // report: a bench doc born BEFORE the bench/self-wire law
489
+ // persisted its crime and served silently until a run tripped a
490
+ // generic wall). Scoped NARROW, by design: the read path is
491
+ // deliberately tolerant of loose-but-harmless shapes (junk cells
492
+ // and dead wires are refused with precise voices at the run door
493
+ // and at the next write door; the patch-tolerance laws pin that
494
+ // they serve). ONE crime is refused HERE, and it is the one that
495
+ // composes degenerately with no other voice: a SELF-WIRE, which is
496
+ // a cycle, and which the run's generic no-FromStage message hid.
497
+ //
498
+
499
+ // this gate also refused a wire to a DEAD STAGE. It never did, and
500
+ // the code below says why it must not — crafted-undo docs may hold
501
+ // one legitimately between mutations, and serve must not brick the
502
+ // bench a fix needs to open. The law was correct and the comment
503
+ // was the thing that drifted; a reader auditing against the prose
504
+ // would have gone looking for a check that does not exist.)
505
+ //
506
+ // Whatever wrote the doc — an older door, a hand edit — the store
507
+ // is held at read for that one crime.
508
+ if (Array.isArray(doc["stages"])) {
509
+ for (const st of doc["stages"]) {
510
+ if (typeof st !== "object" || st === null)
511
+ continue;
512
+ const row = st;
513
+ const id = typeof row["id"] === "string" ? row["id"] : undefined;
514
+ const ins = row["inputs"];
515
+ if (typeof ins !== "object" || ins === null || Array.isArray(ins))
516
+ continue;
517
+ for (const [chan, ref] of Object.entries(ins)) {
518
+ const rr = ref;
519
+ if (typeof rr !== "object" || rr === null || typeof rr["stage"] !== "string")
520
+ continue;
521
+ const up = rr["stage"];
522
+ if (id !== undefined && up === id) {
523
+ throw new RemoteError("bench/self-wire", "input '" + chan + "' wires its own stage '" + id + "' — a cycle composes nothing. " +
524
+ "This wire predates the bench door's self-wire law and persisted; clear it (rewire the input with no source, or wire a real prior stage).", { session, stage: id, input: chan });
525
+ }
526
+ // A wire to a stage NOT on the bench (its source removed
527
+ // later, or a crafted-log doc) stays the write doors' and
528
+ // the run door's voice — crafted-undo docs may hold one
529
+ // legitimately between mutations (bench-undo's inverse
530
+ // table); serve must not brick the bench a fix needs to
531
+ // open.
532
+ }
533
+ }
534
+ }
535
+ const block = benchBlockOf(doc);
536
+ return {
537
+ draft_id: draftId,
538
+ session,
539
+ rev: block.rev,
540
+ intent: doc["intent"],
541
+ stages: doc["stages"],
542
+ log: block.log.map((e) => ({
543
+ rev: e.rev,
544
+ at: e.at,
545
+ author: e.author,
546
+ kind: e.kind,
547
+ ...(e.stage !== undefined ? { stage: e.stage } : {}),
548
+ ...(e.summary !== undefined ? { summary: e.summary } : {}),
549
+ ...(e.reverted !== undefined ? { reverted: e.reverted } : {}),
550
+ })),
551
+ };
552
+ }
553
+ async function loadBench(drafts, session) {
554
+ if (!SESSION_RE.test(session)) {
555
+ throw new RemoteError("bench/bad-session", "bad session key (want [A-Za-z0-9_-]{1,64})", {});
556
+ }
557
+ const draftId = benchIdOf(session);
558
+ let rec = drafts.table("docs").get(draftId);
559
+ if (rec === undefined) {
560
+ // THE BIRTH (stroke 46g, the operator's "is this a forever fix?"):
561
+ // a conversation's bench is born at its FIRST write — never a
562
+ // refusal. rev 0, empty stages, the session's own id.
563
+ // THE BIRTH CARRIES EVERY REQUIRED KEY (the typed-seam audit,
564
+
565
+ // completedAt, and this literal left all three to a cast — so a
566
+ // reader that validated the record would have refused the estate's
567
+ // own first bench. The birth is a DRAFT: status "draft", created
568
+ // now, completed null.
569
+ const born = Math.floor(Date.now() / 1000);
570
+ const doc0 = {
571
+ draft_id: draftId,
572
+ status: "draft",
573
+ createdAt: born,
574
+ completedAt: null,
575
+ intent: "",
576
+ stages: [],
577
+ bench: { rev: 0, log: [] },
578
+ };
579
+ await drafts.table("docs").put(draftId, {
580
+ updated_at: born,
581
+ doc: doc0,
582
+ });
583
+ rec = drafts.table("docs").get(draftId);
584
+ if (rec === undefined) {
585
+ throw new RemoteError("bench/not-found", "could not birth bench: " + session, { session });
586
+ }
587
+ }
588
+ const doc = rec.doc;
589
+ return { draftId, doc, block: benchBlockOf(doc) };
590
+ }
591
+ /** The CAS law, one voice (missing expect_rev refuses too). */
592
+ function casCheck(block, expect, session) {
593
+ if (typeof expect !== "number") {
594
+ throw new RemoteError("bench/cas-conflict", "expect_rev is required (the CAS law)", { current_rev: block.rev });
595
+ }
596
+ if (expect !== block.rev) {
597
+ throw new RemoteError("bench/cas-conflict", "bench " +
598
+ session +
599
+ " moved: rev is " +
600
+ block.rev +
601
+ ", caller held " +
602
+ expect, { current_rev: block.rev, expect_rev: expect });
603
+ }
604
+ return block.rev;
605
+ }
606
+ /** Append one entry + persist; returns the new rev. */
607
+ async function appendAndSave(drafts, loaded, entry) {
608
+ const full = {
609
+ ...entry,
610
+ rev: loaded.block.rev + 1,
611
+ at: Math.floor(Date.now() / 1000),
612
+ };
613
+ loaded.block.rev = full.rev;
614
+ loaded.block.log.push(full);
615
+ loaded.doc["bench"] = loaded.block;
616
+ await drafts.table("docs").put(loaded.draftId, {
617
+ updated_at: Math.floor(Date.now() / 1000),
618
+ doc: loaded.doc,
619
+ });
620
+ return full.rev;
621
+ }
622
+ function stageRow(doc, stageId) {
623
+ // Schema law (see stagesOf): the projection keeps only record
624
+ // rows — a junk row in the stored doc never crashes the find.
625
+ const stages = stagesOf(doc);
626
+ return stages.find((st) => st["id"] === stageId) ?? null;
627
+ }
628
+ const PATCH_KEYS = ["stage", "params", "result", "runStatus", "expect_rev", "author"];
629
+
630
+
631
+
632
+
633
+
634
+
635
+
636
+
637
+
638
+ export async function patchBench(drafts, session, body, admit) {
639
+ refuseUnknown(body, PATCH_KEYS);
640
+ if ("result" in body || "runStatus" in body) {
641
+ throw new RemoteError("bench/bad-body", "run outcomes ride the drafts/patch door (result/runStatus refused on the bench verb — it authors params only)", {});
642
+ }
643
+ const loaded = await loadBench(drafts, session);
644
+ casCheck(loaded.block, body["expect_rev"], session);
645
+ const stageId = body["stage"];
646
+ if (typeof stageId !== "string") {
647
+ throw new RemoteError("bench/bad-body", "stage is required", {});
648
+ }
649
+ const st = stageRow(loaded.doc, stageId);
650
+ if (st === null) {
651
+ throw new RemoteError("bench/stage-not-found", "stage not found: " + stageId, { session, stage: stageId });
652
+ }
653
+ const author = authorOk(body["author"])
654
+ ? body["author"]
655
+ : "agent";
656
+ const before = {};
657
+ const added = [];
658
+ const paramsIn = wireParams(body["params"]);
659
+ if (paramsIn !== undefined) {
660
+ if (!isRecord(paramsIn)) {
661
+ throw new RemoteError("bench/bad-body", "params must be an object", {});
662
+ }
663
+
664
+ // view is a NEW object — a refused admission must not leave the
665
+ // patch's writes on the domain's cached doc (the live record is
666
+ // assigned only after every gate passes).
667
+ const cur = isRecord(st["params"])
668
+ ? { ...st["params"] }
669
+ : {};
670
+ for (const k of Object.keys(paramsIn)) {
671
+ if (Object.prototype.hasOwnProperty.call(cur, k))
672
+ before[k] = cur[k];
673
+ else
674
+ added.push(k);
675
+
676
+ // can't unset a param — a lane switch forced remove_stage +
677
+ // add_stage where one clear-semantics patch would do"). The
678
+ // drafts door has ALWAYS deleted null keys (JSON Merge Patch
679
+ // law, draft.ts's own doc: "null params values ride VERBATIM —
680
+ // the door's JSON Merge Patch arm deletes those keys"); this
681
+ // door landed them as literal nulls instead — a key that could
682
+ // never be cleared, only overwritten. Null now deletes here
683
+ // too: one law, two doors. The inverse restores from
684
+ // params_before, so undo resurrects a deleted key.
685
+ const v = paramsIn[k];
686
+ if (v === null)
687
+ delete cur[k];
688
+ else
689
+ cur[k] = v;
690
+ }
691
+ // admission on the MERGED view (the row's card + the params the
692
+ // NEXT run would compose — a patch that lands poison is as bad
693
+ // as an add that lands it). NO PROVENANCE RIDES (the audit's
694
+ // GAP 3): the restored carve is LOAD-TIME grace — "the archive
695
+ // loads; the run teaches" — and forwarding it here made the
696
+ // grace sticky: a restored row patched under restore law
697
+ // FOREVER, so a human actively editing a pre-law row never met
698
+ // today's law. A patch is a live compose act; it judges with
699
+ // the DECL the next run will compose from.
700
+ // THE STORED ROW IS THE JUDGED ROW (the bench audit's gap 3,
701
+
702
+ // raw one, so a doc written by patch held a self-naming model
703
+ // key that add/load never store — and the next door to read that
704
+ // row (a group, an undo) judged a different object than the one
705
+ // on disk. The merge strips before judging AND before storing:
706
+ // one row, one view.
707
+ const stored = strippedForAdmission(typeof st["card"] === "string" ? st["card"] : "", cur);
708
+ // THE COMPOUND IS JUDGED BY ITS MEMBERS HERE TOO (the walls
709
+
710
+ // belongs to a compound judged the COMPOUND ROW as a whole, and
711
+ // the literal "group" — a card no model board knows — reached the
712
+ // board and refused the operator's own patch. A group's patch
713
+ // is a patch of a MEMBER, so the members cross the door and the
714
+ // compound never does.)
715
+ if (st["card"] === GROUP_CARD && Array.isArray(st["children"])) {
716
+ admitRestored(admit, st, isRecord(st["params"]) ? st["params"] : {});
717
+ }
718
+ else {
719
+ admitStage(admit, st["card"], stored, undefined);
720
+ }
721
+ // THE WIRING ADMISSION (phantom-wire cure): a params patch can
722
+ // orphan a spell (drop the $image binding while the wire stays)
723
+ // or strand a wire (introduce $image with no channel) — the
724
+ // merged pair admits together, exactly as the run consumes them.
725
+ admitWiring(st, stored, isRecord(st["inputs"]) ? st["inputs"] : {},
726
+ // (the domain schema pins stages[].id: string with
727
+ // backup-and-skip at read — a nullish id never reaches here)
728
+ stagesOf(loaded.doc).map((r) => String(r["id"])), stageId, session,
729
+ // THE UN-WEDGE LAW at the patch seat: patch touches params,
730
+ // never inputs — a pre-broken input ref (already dead before
731
+ // this patch) tolerates so the row's OTHER knobs stay
732
+ // editable; the merged params×inputs law still judges what
733
+ // the patch itself can create (an orphaned spell refuses).
734
+ toleratedByRow([st], liveIdsOf(stagesOf(loaded.doc))).get(stageId));
735
+ st["params"] = stored;
736
+ }
737
+ if (paramsIn === undefined) {
738
+ throw new RemoteError("bench/bad-body", "nothing to patch (params)", {});
739
+ }
740
+ const newRev = await appendAndSave(drafts, loaded, {
741
+ author,
742
+ kind: "patch",
743
+ stage: stageId,
744
+ summary: "patch " + Object.keys(paramsIn).sort().join(","),
745
+ inverse: { stage: stageId, params_before: before, params_added: added },
746
+ });
747
+ return {
748
+ draft_id: loaded.draftId,
749
+ session,
750
+ rev: newRev,
751
+ stage: stageId,
752
+ patched: Object.keys(paramsIn).sort(),
753
+ };
754
+ }
755
+ const ADD_KEYS = ["id", "card", "engine", "params", "inputs", "outputs", "expected", "expect_rev", "author"];
756
+
757
+
758
+
759
+
760
+
761
+
762
+
763
+
764
+ const wireParams = (v) => (isRecord(v) ? unwrapItemArrays(v) : v);
765
+ /** Admit one RESTORED stage state (the undo arms): the same seat,
766
+ * NO restored carve — the audit's GAP 6 caught the lie: the comment
767
+ * claimed no exemption while the call forwarded the very
768
+ * provenance string the load seat's carve matches, so an undo on a
769
+ * restored row judged nothing at all. Undo rewrites live-authored
770
+ * state; it is judged like any other write, with no provenance. A
771
+ * stage with no card row (an mcp or flow id) is not this seat's
772
+ * business. */
773
+ function admitRestored(admit, stage, params) {
774
+ if (admit === undefined)
775
+ return;
776
+ const card = stage["card"];
777
+ if (typeof card !== 'string' || card === '')
778
+ return;
779
+ // A RESTORED COMPOUND IS JUDGED BY ITS MEMBERS (the bench audit's
780
+ // gap 9): the literal "group" card names nothing the model board
781
+ // knows, so an undo that reinserted a group row (or a snapshot
782
+ // restore) refused the bench's own state — an undo bricked by
783
+ // what the group door had just built. The members cross the same
784
+ // door they crossed when they were picked.
785
+ if (card === GROUP_CARD && Array.isArray(stage["children"])) {
786
+ for (const child of stage["children"]) {
787
+ if (!isRecord(child))
788
+ continue;
789
+ admitRestored(admit, child, isRecord(child["params"]) ? child["params"] : {});
790
+ }
791
+ return;
792
+ }
793
+ // THE STRIPPED VIEW, LIKE EVERY OTHER SEAT (the bench audit's gap
794
+
795
+ // {model: <own card>} — which normalizeBenchStage strips at store
796
+ // and which add/patch admit through the stripped view — was
797
+ // REFUSED here as an unknown model. One admission means one view:
798
+ // the restored row is judged exactly as it would be written.
799
+ admit(card, strippedForAdmission(card, params), undefined);
800
+ }
801
+
802
+
803
+
804
+
805
+
806
+ export function strippedForAdmission(card, params) {
807
+ const judged = { ...params };
808
+ if (judged["model"] === card && !SELF_NAMED_FAMILIES.has(card)) {
809
+ delete judged["model"];
810
+ }
811
+ return judged;
812
+ }
813
+ function admitStage(admit, card, params, provenance = undefined) {
814
+ if (admit === undefined)
815
+ return;
816
+ if (typeof card !== "string" || card === "")
817
+ return;
818
+
819
+ // add sailed past admission entirely) — admit the card against
820
+ // empty params; the model check passes vacuous, the card check
821
+ // still bites. Every caller hands this seat a record: the load and
822
+ // birth arms pass normalizeStages' rows, whose params normalizeBenchStage
823
+ // has already healed to {} when the doc carried anything else, and the
824
+ // patch arm passes strippedForAdmission's own copy.
825
+ admit(card, strippedForAdmission(card, params), provenance);
826
+ }
827
+ /** The compound row's card literal — the bench's own shape, never a
828
+ * declared card. Named once so every seat that must exempt a
829
+ * compound says the same word (the load arm below). */
830
+ const GROUP_CARD = "group";
831
+ /** Admission over a wholesale stage list (the load arm — every row
832
+ * passes the same door an add passes). Both callers hand it
833
+ * normalizeStages' own output, which keeps only records, so a row
834
+ * here is a record and the members of a compound are its own. */
835
+ function admitStages(admit, stages) {
836
+ if (admit === undefined || !Array.isArray(stages))
837
+ return;
838
+ for (const s of stages) {
839
+ // THE COMPOUND IS NOT A CARD (the bench audit's gap 9,
840
+
841
+ // "group" and whose members ride in `children` — so loading the
842
+ // bench's own read-back asked the model board about a card that
843
+ // does not exist ("no registered card definition"), and an undo
844
+ // of such a load bricked on the bench's own prior state. The
845
+ // compound is judged by its MEMBERS: the same door each member
846
+ // crossed when it was picked (the compound-no-blind-spot law,
847
+ // stated on the way back in).
848
+ if (s["card"] === GROUP_CARD && Array.isArray(s["children"])) {
849
+ admitStages(admit, s["children"]);
850
+ continue;
851
+ }
852
+ admitStage(admit, s["card"], s["params"], s["provenance"]);
853
+ }
854
+ }
855
+
856
+ // The bench's wiring vocabulary is the RUN's: `inputs` name channels,
857
+ // params bind them via `$channel` spells (rewriteSpells is the
858
+ // consumer — a string param starting with "$" IS a spell). Three
859
+
860
+ // run time, far from the write that caused them:
861
+ // a) a channel no param spells (dead wire — the agent's
862
+ // inputs:{image:{stage,...}} with no $image anywhere);
863
+ // b) a spell no channel binds (runs refuse at rewriteSpells);
864
+ // c) an upstream stage id that is not on the bench (the mint
865
+ // mints s_N; "stage-1" never existed).
866
+ // All three now refuse AT WRITE TIME, named.
867
+ /** Collect the `$channel` spell names a params body carries
868
+ * (rewriteSpells semantics verbatim: a string STARTING with "$" is
869
+ * one spell named slice(1) — "costs $5" is text, "$image" is a
870
+ * spell). */
871
+ function spellsOf(params, out = new Set()) {
872
+ if (typeof params === "string") {
873
+ if (params.startsWith("$"))
874
+ out.add(params.slice(1));
875
+ }
876
+ else if (Array.isArray(params)) {
877
+ for (const v of params)
878
+ spellsOf(v, out);
879
+ }
880
+ else if (typeof params === "object" && params !== null) {
881
+ for (const v of Object.values(params))
882
+ spellsOf(v, out);
883
+ }
884
+ return out;
885
+ }
886
+ /** One wiring binding's declared shape: {stage, output} (FromStage),
887
+ * {initial} (the request's initial), or anything else (garbage —
888
+ * the run maps only these two). */
889
+ function wireUpstreamOf(ref) {
890
+ if (typeof ref !== "object" || ref === null || Array.isArray(ref))
891
+ return {};
892
+ const r = ref;
893
+ if (typeof r["stage"] === "string")
894
+ return { stage: r["stage"] };
895
+ if (typeof r["initial"] === "string")
896
+ return { initial: true };
897
+ return {};
898
+ }
899
+ /** The wiring admission over ONE row (params = the MERGED view the
900
+ * next run composes; inputs = the MERGED wiring; liveIds = the
901
+ * bench's stage ids after the write; selfId = the row's own id —
902
+ * self-wiring is a cycle, refused). Throws bench/* named. */
903
+ export function admitWiring(row, params, inputs, liveIds, selfId, session, tolerate) {
904
+ const spells = spellsOf(params);
905
+ for (const [chan, ref] of Object.entries(inputs)) {
906
+ const up = wireUpstreamOf(ref);
907
+ if (up.stage === undefined && up.initial !== true) {
908
+ throw new RemoteError("bench/bad-wire", "input '" + chan + "' is neither {stage, output} nor {initial} — the run maps only those two shapes", { session, stage: selfId, input: chan });
909
+ }
910
+ if (up.stage !== undefined) {
911
+ if (up.stage === selfId) {
912
+ throw new RemoteError("bench/self-wire", "input '" + chan + "' wires its own stage '" + selfId + "' — a cycle composes nothing", { session, stage: selfId, input: chan });
913
+ }
914
+
915
+ // refusal fires only for a dangle the VERB CREATES. A ref the
916
+ // row already carried toward an ALREADY-DEAD stage (an
917
+ // interleaved write birthed it; no door admits creating it)
918
+ // passes through `tolerate` — the run door names it, and the
919
+ // cure doors (rewire-clear, remove, clear) must never refuse
920
+ // on the very state they cure. Creation still refuses LOUD.
921
+ if (!liveIds.includes(up.stage) && !(tolerate !== undefined && tolerate.has(up.stage))) {
922
+ throw new RemoteError("bench/wire-stage-not-found", "input '" + chan + "' wires stage '" + up.stage + "' — no such stage on the bench (live: " +
923
+ (liveIds.join(", ") || "none") + ")", { session, stage: selfId, input: chan, live: liveIds });
924
+ }
925
+ }
926
+ if (!spells.has(chan)) {
927
+ throw new RemoteError("bench/dead-wire", "input '" + chan + "' is bound by no '$" + chan + "' spell in params — the wiring would feed nothing " +
928
+ "(bind it, e.g. params: {image: \"$" + chan + "\"}, or drop the wire)", { session, stage: selfId, input: chan });
929
+ }
930
+ }
931
+ // NOTE: the reverse (a $spell with no channel) is NOT a write-time
932
+ // refusal — it is a legal INTERMEDIATE state (spell first, wire
933
+ // second; the two-step construction). The run refuses a stray
934
+ // spell LOUD at merge (rewriteSpells' own voice), so nothing is
935
+ // silent either way.
936
+ }
937
+ /** Wiring admission over a wholesale stage list (the load arm —
938
+ * every row checks against the list it lands in). Both call sites
939
+ * pass normalizeStages() output — non-arrays collapse to [] there,
940
+ * non-record rows filter out, and every surviving row carries a
941
+ * string id plus record params/inputs (the coerce arms at
942
+ * normalizeStages) — so no re-defense here. */
943
+ /** The live ids of a stage list (group children included — they
944
+ * keep their stage ids under the compound). */
945
+ function liveIdsOf(stages) {
946
+ const out = new Set();
947
+ for (const r of stages) {
948
+ out.add(String(r["id"] ?? ""));
949
+ if (r["card"] === GROUP_CARD && Array.isArray(r["children"])) {
950
+ for (const child of r["children"]) {
951
+ if (isRecord(child))
952
+ out.add(String(child["id"] ?? ""));
953
+ }
954
+ }
955
+ }
956
+ return out;
957
+ }
958
+ /** The un-wedge law's tolerance table: for every row in the list
959
+ * being JUDGED, the upstreams it wires that are absent from the
960
+ * PRE-verb live set — refs the verb did not create (they were
961
+ * already dead, or ride a restored snapshot the log itself
962
+ * recorded) and must not be refused for. Keyed by row/child id.
963
+ *
964
+ * The walk is over the JUDGED rows (not the pre rows): a verb
965
+ * like clear leaves no pre rows at all, and undo restores rows
966
+ * the pre state doesn't hold — the ref a row CARRIES is the
967
+ * thing to classify, against what was live before the verb. A
968
+ * ref newly introduced by a verb (rewire's `from`, a patch's
969
+ * spell changes) never rides this table: those seats build their
970
+ * tolerate sets from the PRE ROW's own inputs, so a fresh dead
971
+ * target stays strict. */
972
+ function toleratedByRow(stages, preLive) {
973
+ const out = new Map();
974
+ const rowOf = (r, id) => {
975
+ const inputs = r["inputs"];
976
+ if (!isRecord(inputs))
977
+ return;
978
+ for (const ref of Object.values(inputs)) {
979
+ const up = wireUpstreamOf(ref);
980
+ if (up.stage !== undefined && !preLive.has(up.stage)) {
981
+ const set = out.get(id);
982
+ if (set !== undefined)
983
+ set.add(up.stage);
984
+ else
985
+ out.set(id, new Set([up.stage]));
986
+ }
987
+ }
988
+ };
989
+ for (const r of stages) {
990
+ const id = String(r["id"] ?? "");
991
+ rowOf(r, id);
992
+ if (r["card"] === GROUP_CARD && Array.isArray(r["children"])) {
993
+ for (const child of r["children"]) {
994
+ if (isRecord(child))
995
+ rowOf(child, String(child["id"] ?? ""));
996
+ }
997
+ }
998
+ }
999
+ return out;
1000
+ }
1001
+ function admitWiringList(stages, session, tolerate) {
1002
+
1003
+ // survivor walk — feeds the LIVE doc list, whose rows predate the
1004
+ // normalize contract's guarantees whenever the doc predates it. A
1005
+ // row without record params/inputs judges over {} (vacuous pass),
1006
+ // never crashes the door; the normalized callers are unaffected.
1007
+ // (records themselves are the projection's guarantee — stagesOf
1008
+ // keeps only record rows and normalizeStages filters its own — so
1009
+ // no re-defense of record-ness here)
1010
+ const liveIds = stages
1011
+ .map((r) => String(r["id"] ?? ""));
1012
+ // THE UN-WEDGE TOLERANCE: the map (per row id) carries the refs
1013
+ // that pre-date the verb — the walk refuses only the danglings
1014
+ // THIS verb creates; refs already dead before it pass through
1015
+ // (the run door's voice — see admitWiring's tolerate law). Each
1016
+ // seat states its own base for toleratedByRow.
1017
+ const tolerateAll = tolerate;
1018
+ for (const r of stages) {
1019
+ // THE COMPOUND'S OWN WIRES ARE JUDGED (the bench audit's gap 7,
1020
+
1021
+ // walk passed over it vacuously — a wire hidden INSIDE a group
1022
+ // survived a remove of the stage it pointed at, and ungroup then
1023
+ // spilled the phantom onto the flat bench with nothing left to
1024
+ // catch it. The walk descends: the compound is judged, and so is
1025
+ // every child, against the same live set.
1026
+ admitWiring(r, isRecord(r["params"]) ? r["params"] : {}, isRecord(r["inputs"]) ? r["inputs"] : {}, liveIds, String(r["id"] ?? ""), session, tolerateAll?.get(String(r["id"] ?? "")));
1027
+ if (r["card"] === GROUP_CARD && Array.isArray(r["children"])) {
1028
+ for (const child of r["children"]) {
1029
+ if (!isRecord(child))
1030
+ continue;
1031
+ admitWiring(child, isRecord(child["params"]) ? child["params"] : {}, isRecord(child["inputs"]) ? child["inputs"] : {}, liveIds, String(child["id"] ?? ""), session, tolerateAll?.get(String(child["id"] ?? "")));
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ /** add_stage — append a card stage with optional wiring (the
1037
+ * runner's input convention rides the row). */
1038
+ export async function addStageBench(drafts, session, body, admit) {
1039
+ refuseUnknown(body, ADD_KEYS);
1040
+ const loadedForAdmit = await loadBench(drafts, session);
1041
+ casCheck(loadedForAdmit.block, body["expect_rev"], session);
1042
+ // THE JUDGED VIEW IS THE STORED VIEW (the bench audit's gap 11,
1043
+
1044
+ // normalized row — so the unwrap, the legacy-media heal and the
1045
+ // default overlay were applied AFTER the judgment, and the door
1046
+ // could refuse a row the normalizer would have healed (or admit one
1047
+ // it would have reshaped). The row is shaped FIRST, then judged —
1048
+ // the same order every other door uses.
1049
+ const cardForRow = body["card"];
1050
+ if (typeof cardForRow !== "string" || cardForRow === "") {
1051
+ throw new RemoteError("bench/bad-body", "card is required", {});
1052
+ }
1053
+ // THE ONE MINT, AND NOW IT IS ONE CALL (the identity audit's
1054
+
1055
+ // producer of the value" directly above a function that called the
1056
+ // producer TWICE — once for the row handed to admission, once for
1057
+ // the row stored below. They agree today only because both read the
1058
+ // same unmutated doc; any future edit between them, or a change to
1059
+ // one formula, reopens the judged-vs-stored split this comment
1060
+ // claimed to have closed. The value is bound ONCE and both doors
1061
+ // read it, so the sentence is now true rather than aspirational.
1062
+ //
1063
+ // (See mintStageId for why the formula is max+1 and not the count.)
1064
+ const stageIdForAdmit = mintStageId(stagesOf(loadedForAdmit.doc), body);
1065
+ // A MALFORMED `inputs` CONTAINER REFUSES (the audit's fail-open).
1066
+ // `isRecord(...) ? ... : {}` treats a non-record `inputs` exactly
1067
+ // like an absent one: the caller is told the stage was added, no
1068
+ // wire is stored, and the loss surfaces much later as a missing
1069
+ // required param at run time. A bad BINDING already refuses
1070
+ // (bench/bad-wire) and rewire's non-record `from` already refuses
1071
+ // (bench/bad-body) — the container was the one shape that failed
1072
+ // open. The READ path stays tolerant on purpose (a doc already on
1073
+ // disk is not the caller's mistake, and the serve/run doors judge
1074
+ // it there); this is the door where the caller can still be told.
1075
+ const rawInputs = body["inputs"];
1076
+ if (rawInputs !== undefined && !isRecord(rawInputs)) {
1077
+ throw new RemoteError("bench/bad-body", "stage " + JSON.stringify(stageIdForAdmit) +
1078
+ ": inputs must be an object of channel → {stage, output} | {initial} (got " +
1079
+ typeof rawInputs +
1080
+ "); a wire that cannot be read is refused here, not dropped", {});
1081
+ }
1082
+ // A MALFORMED `params` CONTAINER REFUSES (the same fail-open the
1083
+ // `inputs` container closed above — commission 015, §6 finding 5):
1084
+ // `isRecord(...) ? ... : {}` treated a non-record `params` exactly
1085
+ // like an absent one, so the card's knobs wrapped in an array
1086
+ // minted a stage with EMPTY params instead of refusing. The patch
1087
+
1088
+ // refuses at its own row admission — one law, every write door.
1089
+ const rawParams = wireParams(body["params"]);
1090
+ if (rawParams !== undefined && !isRecord(rawParams)) {
1091
+ throw new RemoteError("bench/bad-body", "stage " + JSON.stringify(stageIdForAdmit) + ": params must be an object (got " +
1092
+ (Array.isArray(rawParams) ? "an array — the card's knobs, not a list of them" : typeof rawParams) +
1093
+ "); a wire that cannot be read is refused here, not dropped", {});
1094
+ }
1095
+ const admitted = normalizeBenchStage({
1096
+ id: stageIdForAdmit,
1097
+ card: cardForRow,
1098
+ ...(typeof body["engine"] === "string" && body["engine"] !== "" ? { engine: body["engine"] } : {}),
1099
+ ...(rawParams !== undefined ? { params: rawParams } : {}),
1100
+ ...(isRecord(body["inputs"]) ? { inputs: body["inputs"] } : {}),
1101
+ ...(isRecord(body["outputs"]) ? { outputs: body["outputs"] } : {}),
1102
+ }, stageIdForAdmit);
1103
+ admitStage(admit, admitted["card"], admitted["params"]);
1104
+ const loaded = loadedForAdmit;
1105
+ // (the card was already refused at the top of this door when it is
1106
+ // not a non-empty string — the bound value here is one)
1107
+ const card = body["card"];
1108
+ const author = authorOk(body["author"])
1109
+ ? body["author"]
1110
+ : "agent";
1111
+ const stages = stagesOf(loaded.doc);
1112
+
1113
+ // ("s_" + stages.length) collides the moment any stage was ever
1114
+ // removed — [s_0..s_9] minus nine leaves s_1, and the count says
1115
+ // s_1 again. The mint takes MAX numeric suffix + 1 over the live
1116
+ // rows, so a fresh id is fresh against the bench AS IT STANDS.
1117
+ // THE STORED ROW'S ID IS THE ADMITTED ROW'S ID (the one mint,
1118
+ // above): read the bound value, never call the producer again.
1119
+ const stageId = stageIdForAdmit;
1120
+ if (stageRow(loaded.doc, stageId) !== null) {
1121
+ throw new RemoteError("bench/duplicate-stage", "stage id already on the bench: " + stageId, { session, stage: stageId });
1122
+ }
1123
+
1124
+ // arm hand-rolled the whole row, so a stage added here kept a dead
1125
+ // `/v1/assets/runs` path unhealed, skipped the self-naming strip,
1126
+ // and defaulted `outputs` by a SECOND law). One writer shapes a
1127
+ // stage row: normalizeBenchStage. `expected` rides on top, exactly
1128
+ // as it does on every other arm.
1129
+ const row = normalizeBenchStage({
1130
+ id: stageId,
1131
+ card,
1132
+ ...(typeof body["engine"] === "string" && body["engine"] !== "" ? { engine: body["engine"] } : {}),
1133
+ ...(rawParams !== undefined ? { params: rawParams } : {}),
1134
+ ...(isRecord(body["inputs"]) ? { inputs: body["inputs"] } : {}),
1135
+ ...(isRecord(body["outputs"]) ? { outputs: body["outputs"] } : {}),
1136
+ }, stageId);
1137
+ row["provenance"] = author;
1138
+ if (isRecord(body["expected"]))
1139
+ row["expected"] = body["expected"];
1140
+ // THE WIRING ADMISSION (phantom-wire cure) — BEFORE the push: the
1141
+ // row's wiring admits against a PROJECTED live set (current ids +
1142
+ // this row's). Never push-then-check: the loaded doc is the
1143
+ // domain's cached object, and a refusal after the push LEAKS the
1144
+
1145
+ // adds persisted as s_1/s_2 on the next good write — a refusal
1146
+ // that lies). Mutate only after every gate passes.
1147
+ admitWiring(row, row["params"], row["inputs"], [...stages.map((r) => String(r["id"] ?? "")), stageId], stageId, session);
1148
+ stages.push(row);
1149
+ loaded.doc["stages"] = stages;
1150
+ // add_stage never syncs the intent (the orphaning law): a pure
1151
+ // add grows the card set — a label true before stays true after.
1152
+ const newRev = await appendAndSave(drafts, loaded, {
1153
+ author,
1154
+ kind: "add_stage",
1155
+ stage: stageId,
1156
+ summary: "add " + card,
1157
+ inverse: { stage: stageId },
1158
+ });
1159
+ return { draft_id: loaded.draftId, session, rev: newRev, stage: stageId };
1160
+ }
1161
+ const REMOVE_KEYS = ["stage", "expect_rev", "author"];
1162
+ const REORDER_KEYS = ["stage", "to", "expect_rev", "author"];
1163
+
1164
+
1165
+
1166
+
1167
+
1168
+
1169
+
1170
+ export async function reorderBench(drafts, session, body) {
1171
+ refuseUnknown(body, REORDER_KEYS);
1172
+ const loaded = await loadBench(drafts, session);
1173
+ casCheck(loaded.block, body["expect_rev"], session);
1174
+ const stageId = body["stage"];
1175
+ if (typeof stageId !== "string") {
1176
+ throw new RemoteError("bench/bad-body", "stage is required", {});
1177
+ }
1178
+ const toRaw = body["to"];
1179
+ if (typeof toRaw !== "number" || !Number.isInteger(toRaw) || toRaw < 0) {
1180
+ throw new RemoteError("bench/bad-body", "to must be a non-negative integer index", {});
1181
+ }
1182
+ const author = authorOk(body["author"])
1183
+ ? body["author"]
1184
+ : "agent";
1185
+ const stages = stagesOf(loaded.doc);
1186
+ const from = stages.findIndex((s) => s["id"] === stageId);
1187
+ if (from < 0) {
1188
+ throw new RemoteError("bench/stage-not-found", "stage not found: " + stageId, { session, stage: stageId });
1189
+ }
1190
+ const [row] = stages.splice(from, 1);
1191
+ const to = Math.min(toRaw, stages.length);
1192
+ stages.splice(to, 0, row);
1193
+ loaded.doc["stages"] = stages;
1194
+ const newRev = await appendAndSave(drafts, loaded, {
1195
+ author,
1196
+ kind: "reorder",
1197
+ stage: stageId,
1198
+ summary: "reorder " + String(row["card"] ?? stageId),
1199
+ inverse: { stage: stageId, from, to },
1200
+ });
1201
+ return { draft_id: loaded.draftId, session, rev: newRev, stage: stageId, from, to };
1202
+ }
1203
+ const GROUP_KEYS = ["stages", "expect_rev", "author"];
1204
+
1205
+
1206
+
1207
+
1208
+
1209
+
1210
+ export async function groupBench(drafts, session, body, admit = undefined) {
1211
+ refuseUnknown(body, GROUP_KEYS);
1212
+ const loaded = await loadBench(drafts, session);
1213
+ casCheck(loaded.block, body["expect_rev"], session);
1214
+ const idsRaw = body["stages"];
1215
+ if (!Array.isArray(idsRaw) || idsRaw.length < 2
1216
+ || !idsRaw.every((x) => typeof x === "string")) {
1217
+ throw new RemoteError("bench/bad-body", "stages must be an array of 2+ stage ids", {});
1218
+ }
1219
+ const author = authorOk(body["author"])
1220
+ ? body["author"]
1221
+ : "agent";
1222
+ const stages = stagesOf(loaded.doc);
1223
+ // bench order (the ids arrive selection-order; the compound is the
1224
+ // bench's own order — the old law: "the compound parent lands at the
1225
+ // FIRST member's position, childIds in array order")
1226
+ const ids = idsRaw;
1227
+ const picked = [];
1228
+ for (const st of stages) {
1229
+ if (ids.includes(String(st["id"])))
1230
+ picked.push(st);
1231
+ }
1232
+ if (picked.length !== ids.length) {
1233
+ throw new RemoteError("bench/stage-not-found", "group: unknown stage id(s) — have " + String(picked.length) + " of " + String(ids.length), { session, stage: ids.join(",") });
1234
+ }
1235
+ const firstIndex = stages.indexOf(picked[0]);
1236
+
1237
+ // audit's rows 1+4): a compound row renders as one tile and its
1238
+ // children leave the flat bench — patch cannot reach inside it, so
1239
+ // a row parked before a law tightened would sit inside the group
1240
+ // judged by NOTHING until ungroup spilled it back. The picked rows
1241
+ // walk the same admission an add walks BEFORE they leave the flat
1242
+ // bench — the group is a write like every other write, and a
1243
+ // refusal here names the child the way add_stage would.
1244
+ for (const row of picked) {
1245
+ admitRestored(admit, row, isRecord(row["params"])
1246
+ ? row["params"]
1247
+ : {});
1248
+ }
1249
+
1250
+ // — the direction the first cut of this gate still missed): the
1251
+ // run's wiring walk (benchWiring in the runs registry) reads the
1252
+ // FLAT stage list only, so a member's wire dies the moment the
1253
+ // member leaves it — whether it pointed at another member or at a
1254
+ // bystander. Parking it is a silent run-time starvation, so the
1255
+ // door refuses and names the cure (ungroup first, or drop the wire).
1256
+ for (const row of picked) {
1257
+ const ins = row["inputs"];
1258
+ if (isRecord(ins) && Object.keys(ins).length > 0) {
1259
+ throw new RemoteError("bench/grouped-row-wired", "stage " + String(row["id"]) + " carries a wire (" + Object.keys(ins).join(", ") +
1260
+ ") — grouping hides it from the run, which wires only the flat bench; ungroup it first, or drop the wire", { session, stage: String(row["id"]) });
1261
+ }
1262
+ }
1263
+ // THE SURVIVORS RE-JUDGED, AND THE WIRES TOO (the bench audit's
1264
+
1265
+ // wire another member carried now points at a stage that is no
1266
+ // longer addressable — a state remove_stage refuses and rewire
1267
+ // refuses to create. Grouping admitted it silently, because this
1268
+ // arm only judged the picked rows' models. The post-move bench is
1269
+ // judged as a whole, on a copy, before the splice lands (the
1270
+ // survivor law remove established, now applied to the other door
1271
+ // that moves rows).
1272
+ const afterGroup = stages.filter((r) => !picked.includes(r));
1273
+ const compoundProbe = {
1274
+ id: "g_probe",
1275
+ card: GROUP_CARD,
1276
+ children: picked,
1277
+ };
1278
+ afterGroup.splice(Math.min(firstIndex, afterGroup.length), 0, compoundProbe);
1279
+ // THE UN-WEDGE LAW at the group seat: rows keep the refs they
1280
+ // carried; only the grouping changes. Judged against the pre live set.
1281
+ admitWiringList(afterGroup, session, toleratedByRow(afterGroup, liveIdsOf(stages)));
1282
+ for (const row of picked) {
1283
+ // picked rows ARE stages members (built from the walk above) —
1284
+ // splice by identity, no guard.
1285
+ stages.splice(stages.indexOf(row), 1);
1286
+ }
1287
+ // id from the doc's own coordinates (rev + count) — no global RNG
1288
+ // dependency; unique per append by construction (rev advances).
1289
+ const gid = "g_r" + String(loaded.block.rev) + "c" + String(picked.length) + "_" + String(stages.length);
1290
+ const compound = {
1291
+ id: gid,
1292
+ card: GROUP_CARD,
1293
+ children: picked,
1294
+ };
1295
+ stages.splice(Math.min(firstIndex, stages.length), 0, compound);
1296
+ loaded.doc["stages"] = stages;
1297
+ const newRev = await appendAndSave(drafts, loaded, {
1298
+ author,
1299
+ kind: "group",
1300
+ stage: gid,
1301
+ summary: "group " + String(ids.length) + " stages",
1302
+ inverse: { stage: gid, index: firstIndex, children: picked },
1303
+ });
1304
+ return { draft_id: loaded.draftId, session, rev: newRev, stage: gid };
1305
+ }
1306
+ const UNGROUP_KEYS = ["stage", "expect_rev", "author"];
1307
+ /** ungroup — the compound's door: spill the children back at the
1308
+ * compound's index, in their stored order. */
1309
+ export async function ungroupBench(drafts, session, body, admit = undefined) {
1310
+ refuseUnknown(body, UNGROUP_KEYS);
1311
+ const loaded = await loadBench(drafts, session);
1312
+ casCheck(loaded.block, body["expect_rev"], session);
1313
+ const stageId = body["stage"];
1314
+ if (typeof stageId !== "string") {
1315
+ throw new RemoteError("bench/bad-body", "stage is required", {});
1316
+ }
1317
+ const author = authorOk(body["author"])
1318
+ ? body["author"]
1319
+ : "agent";
1320
+ const stages = stagesOf(loaded.doc);
1321
+ const idx = stages.findIndex((s) => s["id"] === stageId);
1322
+ if (idx < 0 || stages[idx]["card"] !== GROUP_CARD) {
1323
+ throw new RemoteError("bench/stage-not-found", "ungroup: no group stage " + stageId, { session, stage: stageId });
1324
+ }
1325
+ const compound = stages[idx];
1326
+ const children = compound["children"];
1327
+ const rows = Array.isArray(children)
1328
+ ? children
1329
+ : [];
1330
+
1331
+ // the children re-enter the flat bench as patchable rows — every
1332
+ // other re-entry (undo-patch, undo-remove, load) walks admission,
1333
+ // and this door was the one place a pre-law row could come back
1334
+ // judged by nothing. Judged BEFORE the splice; a refusal keeps the
1335
+ // compound intact and names the child.
1336
+ for (const row of rows) {
1337
+ admitRestored(admit, row, isRecord(row["params"])
1338
+ ? row["params"]
1339
+ : {});
1340
+ }
1341
+ // THE SPILLED BENCH IS JUDGED AS A WHOLE (the bench audit's gap 6,
1342
+
1343
+ // compound hid — or a wire to a member that the group removed from
1344
+ // the flat bench — comes back as a PHANTOM, a state add, rewire and
1345
+ // remove all refuse. Judged on a copy, before the splice.
1346
+ const afterSpill = stages.filter((r) => r !== compound);
1347
+ afterSpill.splice(Math.min(idx, afterSpill.length), 0, ...rows);
1348
+ // THE UN-WEDGE LAW at the ungroup seat (same as group's).
1349
+ admitWiringList(afterSpill, session, toleratedByRow(afterSpill, liveIdsOf(stages)));
1350
+ stages.splice(idx, 1);
1351
+ stages.splice(Math.min(idx, stages.length), 0, ...rows);
1352
+ loaded.doc["stages"] = stages;
1353
+ const newRev = await appendAndSave(drafts, loaded, {
1354
+ author,
1355
+ kind: "ungroup",
1356
+ stage: stageId,
1357
+ summary: "ungroup " + String(rows.length) + " stages",
1358
+ inverse: { row: compound, index: idx },
1359
+ });
1360
+ return { draft_id: loaded.draftId, session, rev: newRev, stage: stageId };
1361
+ }
1362
+ /** remove_stage — splice one stage out (the inverse carries row +
1363
+ * index for undo). */
1364
+ export async function removeStageBench(drafts, session, body) {
1365
+ refuseUnknown(body, REMOVE_KEYS);
1366
+ const loaded = await loadBench(drafts, session);
1367
+ casCheck(loaded.block, body["expect_rev"], session);
1368
+ const stageId = body["stage"];
1369
+ if (typeof stageId !== "string") {
1370
+ throw new RemoteError("bench/bad-body", "stage is required", {});
1371
+ }
1372
+ const author = authorOk(body["author"])
1373
+ ? body["author"]
1374
+ : "agent";
1375
+ const stages = stagesOf(loaded.doc);
1376
+ const idx = stages.findIndex((s) => s["id"] === stageId);
1377
+ if (idx < 0) {
1378
+ throw new RemoteError("bench/stage-not-found", "stage not found: " + stageId, { session, stage: stageId });
1379
+ }
1380
+ const row = stages[idx];
1381
+ const before = [...stages];
1382
+
1383
+ // 5): removing a stage rewrites every row that wired it — the
1384
+ // survivor now holds bench/wire-stage-not-found, a state the
1385
+ // rewire door REFUSES to create. Door parity demands the removal
1386
+ // refuse it too (drop the wire first, or keep the stage). The
1387
+ // survivors' wiring walks the same law on a COPY, BEFORE the
1388
+ // splice — a refused removal leaks nothing (stagesOf returns the
1389
+ // doc's own array; the splice is the write, and it lands last).
1390
+ admitWiringList(stages.filter((_r, i) => i !== idx), session,
1391
+ // THE UN-WEDGE LAW at the remove seat: the survivors are judged
1392
+ // against the PRE-removal live set — this removal refuses only
1393
+ // the danglings IT creates (a survivor wiring the removed
1394
+ // stage); a survivor's PRE-EXISTING dead ref (born by an
1395
+ // interleaved write, refused by no door that admits creating
1396
+ // it) passes through, else NO remove could ever land and the
1397
+
1398
+ // clear or delete from the bench").
1399
+ toleratedByRow(stages.filter((_r, i) => i !== idx), liveIdsOf(stages)));
1400
+ stages.splice(idx, 1);
1401
+ loaded.doc["stages"] = stages;
1402
+ syncIntent(loaded.doc, before, stages);
1403
+ const newRev = await appendAndSave(drafts, loaded, {
1404
+ author,
1405
+ kind: "remove_stage",
1406
+ stage: stageId,
1407
+ summary: "remove " + String(row["card"] ?? stageId),
1408
+ inverse: { stage: stageId, row, index: idx },
1409
+ });
1410
+ return { draft_id: loaded.draftId, session, rev: newRev, stage: stageId };
1411
+ }
1412
+ const CLEAR_KEYS = ["expect_rev", "author"];
1413
+
1414
+
1415
+
1416
+
1417
+
1418
+
1419
+
1420
+ export async function clearBench(drafts, session, body) {
1421
+ refuseUnknown(body, CLEAR_KEYS);
1422
+ const loaded = await loadBench(drafts, session);
1423
+ casCheck(loaded.block, body["expect_rev"], session);
1424
+ const author = authorOk(body["author"])
1425
+ ? body["author"]
1426
+ : "agent";
1427
+ const stages = stagesOf(loaded.doc);
1428
+ const before = [...stages];
1429
+ stages.length = 0;
1430
+ loaded.doc["stages"] = stages;
1431
+ syncIntent(loaded.doc, before, stages);
1432
+ const newRev = await appendAndSave(drafts, loaded, {
1433
+ author,
1434
+ kind: "clear",
1435
+ summary: "clear " + String(before.length) + " stages",
1436
+ inverse: { stages_before: before },
1437
+ });
1438
+ return {
1439
+ draft_id: loaded.draftId,
1440
+ session,
1441
+ rev: newRev,
1442
+ cleared: before.length,
1443
+ };
1444
+ }
1445
+ const REWIRE_KEYS = ["stage", "input", "from", "expect_rev", "author"];
1446
+ /** rewire — set or clear one input binding. from:
1447
+ * {stage, output} | {initial: "image"} | absent → clear. */
1448
+ export async function rewireBench(drafts, session, body) {
1449
+ refuseUnknown(body, REWIRE_KEYS);
1450
+ const loaded = await loadBench(drafts, session);
1451
+ casCheck(loaded.block, body["expect_rev"], session);
1452
+ const stageId = body["stage"];
1453
+ const input = body["input"];
1454
+ if (typeof stageId !== "string" || typeof input !== "string") {
1455
+ throw new RemoteError("bench/bad-body", "stage and input are required", {});
1456
+ }
1457
+ const st = stageRow(loaded.doc, stageId);
1458
+ if (st === null) {
1459
+ throw new RemoteError("bench/stage-not-found", "stage not found: " + stageId, { session, stage: stageId });
1460
+ }
1461
+ const author = authorOk(body["author"])
1462
+ ? body["author"]
1463
+ : "agent";
1464
+ const from = body["from"];
1465
+ if (from !== undefined && !isRecord(from)) {
1466
+ throw new RemoteError("bench/bad-body", "from must be {stage,output} | {initial} | absent", {});
1467
+ }
1468
+ const inputs = isRecord(st["inputs"])
1469
+ ? { ...st["inputs"] }
1470
+ : {};
1471
+ const before = Object.prototype.hasOwnProperty.call(st["inputs"] === undefined ? {} : st["inputs"], input)
1472
+ ? st["inputs"][input]
1473
+ : undefined;
1474
+ if (from === undefined)
1475
+ delete inputs[input];
1476
+ else
1477
+ inputs[input] = from;
1478
+
1479
+ // the CANDIDATE wiring (a copy, never the live record: a refusal
1480
+ // must not leak the write into the domain's cached doc) admits
1481
+ // against the row's params and the live bench BEFORE anything
1482
+ // moves (a channel nothing spells, an upstream that doesn't
1483
+ // exist — all refuse here, named, instead of starving compose at
1484
+ // run time).
1485
+ admitWiring(st, isRecord(st["params"]) ? st["params"] : {}, inputs,
1486
+ // (the domain schema pins stages[].id: string with
1487
+ // backup-and-skip at read — a nullish id never reaches here)
1488
+ stagesOf(loaded.doc).map((r) => String(r["id"])), stageId, session,
1489
+ // THE UN-WEDGE LAW at the rewire seat: the tolerate table is
1490
+ // built from the PRE ROW's own inputs — a ref the rewire
1491
+ // NEWLY sets (its `from`) never rides it, so wiring a dead
1492
+ // stage still refuses (the phantom-wire cure stands), while
1493
+ // the row's other pre-broken inputs tolerate so rewiring one
1494
+ // wire on a wounded row never wedges.
1495
+ toleratedByRow([st], liveIdsOf(stagesOf(loaded.doc))).get(stageId));
1496
+ st["inputs"] = inputs;
1497
+ const inverse = { stage: stageId, input };
1498
+ if (before !== undefined)
1499
+ inverse["before"] = before;
1500
+ else
1501
+ inverse["added"] = true;
1502
+ const newRev = await appendAndSave(drafts, loaded, {
1503
+ author,
1504
+ kind: "rewire",
1505
+ stage: stageId,
1506
+ summary: "wire " + input,
1507
+ inverse,
1508
+ });
1509
+ return {
1510
+ draft_id: loaded.draftId,
1511
+ session,
1512
+ rev: newRev,
1513
+ stage: stageId,
1514
+ input,
1515
+ };
1516
+ }
1517
+ const UNDO_KEYS = ["n", "author", "expect_rev"];
1518
+ /** undo — walk the log tail, skip already-reverted entries and
1519
+ * entries the author filter excludes, apply inverses
1520
+ * newest-first, append ONE undo entry carrying the pre-undo
1521
+ * snapshot. Undo entries are NEVER candidates — a plain undo
1522
+ * walks ACTIONS (targeting them would ping-pong). */
1523
+ export async function undoBench(drafts, session, body, admit) {
1524
+ refuseUnknown(body, UNDO_KEYS);
1525
+ const loaded = await loadBench(drafts, session);
1526
+ if (body["expect_rev"] !== undefined) {
1527
+ casCheck(loaded.block, body["expect_rev"], session);
1528
+ }
1529
+ const n = typeof body["n"] === "number" &&
1530
+ Number.isInteger(body["n"]) &&
1531
+ body["n"] > 0
1532
+ ? body["n"]
1533
+ : 1;
1534
+ const filter = authorOk(body["author"])
1535
+ ? body["author"]
1536
+ : null;
1537
+ // The already-reverted set (union of every undo entry's reverted).
1538
+ const revertedSet = new Set();
1539
+ for (const e of loaded.block.log) {
1540
+ if (e.kind === "undo" && Array.isArray(e.reverted)) {
1541
+ for (const r of e.reverted)
1542
+ revertedSet.add(r);
1543
+ }
1544
+ }
1545
+ const targets = [];
1546
+ for (let i = loaded.block.log.length - 1; i >= 0 && targets.length < n; i--) {
1547
+ const e = loaded.block.log[i];
1548
+ if (e.kind === "undo")
1549
+ continue;
1550
+ if (revertedSet.has(e.rev))
1551
+ continue;
1552
+ if (filter !== null && e.author !== filter)
1553
+ continue;
1554
+ targets.push(e);
1555
+ }
1556
+ if (targets.length === 0) {
1557
+ throw new RemoteError("bench/nothing-to-undo", "nothing to undo" + (filter !== null ? " (author " + filter + ")" : ""), {});
1558
+ }
1559
+ const stagesBefore = stagesOf(loaded.doc);
1560
+ // THE MULTI-TARGET UNDO IS ATOMIC (the bench audit's gap 2,
1561
+
1562
+ // so `undo {n: 2}` where the first target reverts cleanly and the
1563
+ // second refuses left the first one's change in the live document
1564
+ // with no rev and no log — a refused write that mutated, through
1565
+ // the door whose whole job is repair. The inverses now run against
1566
+ // a DEEP COPY and the swap happens only when every target applied
1567
+ // (copy-on-judge, at the shape's own scale: an undo either lands
1568
+ // whole or not at all).
1569
+ const undoDoc = structuredClone(loaded.doc);
1570
+ for (const e of targets)
1571
+ applyInverse(undoDoc, e, admit, session);
1572
+ // THE UNDOED BENCH IS WIRE-JUDGED WHOLE (the bench audit's gaps 4
1573
+
1574
+ // the wiring half of a write was undo's blind spot: an undo-patch
1575
+ // restored params that stranded a spell the wire still fed (a dead
1576
+ // wire the forward patch refuses), and undo-remove/group/ungroup/
1577
+
1578
+ // bench. One walk over the RESULT covers every arm at once — the
1579
+ // survivor law remove established, applied to the repair door.
1580
+
1581
+ // wedge), with the resurrection line drawn by ROW, not by ref: a
1582
+ // row that was already on the bench before the undo (or rides an
1583
+ // undone CLEAR's recorded snapshot — clear's promise is that undo
1584
+ // restores EVERYTHING, wounds included; the run door names them,
1585
+ // the cure doors exist) keeps its pre-existing dead refs. A row
1586
+ // the undo RESURRECTS through any other arm (remove's inverse,
1587
+ // group's spill) judges STRICT — the phantom the forward doors
1588
+ // refuse must not launder back through undo (gap 5 stands). Refs
1589
+ // the undo itself orphans (a stage live before, gone after) still
1590
+ // refuse LOUD.
1591
+ (() => {
1592
+ const baseRows = [...stagesBefore];
1593
+ for (const e of targets) {
1594
+ const inv = e.inverse ?? {};
1595
+ if (e.kind === "clear" && Array.isArray(inv["stages_before"])) {
1596
+ for (const row of inv["stages_before"]) {
1597
+ if (isRecord(row))
1598
+ baseRows.push(row);
1599
+ }
1600
+ }
1601
+ }
1602
+ const baseIds = liveIdsOf(baseRows);
1603
+ const restored = stagesOf(undoDoc);
1604
+ admitWiringList(restored, session, toleratedByRow(restored.filter((r) => baseIds.has(String(r["id"] ?? ""))), baseIds));
1605
+ })();
1606
+ loaded.doc = undoDoc;
1607
+ const undone = targets
1608
+ .map((e) => e.rev)
1609
+ .sort((a, b) => a - b);
1610
+ const newRev = await appendAndSave(drafts, loaded, {
1611
+ author: filter === "user" ? "user" : "agent",
1612
+ kind: "undo",
1613
+ summary: "undo r" + undone.join(",r"),
1614
+ inverse: { stages_before: stagesBefore },
1615
+ reverted: undone,
1616
+ });
1617
+ return {
1618
+ draft_id: loaded.draftId,
1619
+ session,
1620
+ rev: newRev,
1621
+ undone,
1622
+ stages: loaded.doc["stages"],
1623
+ };
1624
+ }
1625
+ /** Apply one entry's inverse payload to the doc (best-effort per
1626
+ * entry; a stage an undo already removed is skipped loudly-free —
1627
+ * the snapshot entries carry the coarse fallback). */
1628
+ function applyInverse(doc, e, admit, session = "") {
1629
+ const inv = isRecord(e.inverse)
1630
+ ? e.inverse
1631
+ : {};
1632
+ const stages = stagesOf(doc);
1633
+ if (e.kind === "patch") {
1634
+ const st = stageRow(doc, String(inv["stage"] ?? e.stage ?? ""));
1635
+ if (st === null)
1636
+ return;
1637
+
1638
+ // live record, so a refused undo left the poisoned merge in the
1639
+ // doc's cached record before the throw. Judge the copy, THEN
1640
+ // assign; a refusal leaves the bench exactly as it was.
1641
+ const cur = isRecord(st["params"])
1642
+ ? { ...st["params"] }
1643
+ : {};
1644
+ const before = isRecord(inv["params_before"])
1645
+ ? inv["params_before"]
1646
+ : {};
1647
+ for (const k of Object.keys(before))
1648
+ cur[k] = before[k];
1649
+ const added = inv["params_added"];
1650
+ if (Array.isArray(added)) {
1651
+ for (const k of added)
1652
+ delete cur[k];
1653
+ }
1654
+
1655
+ // inverse restores params wholesale, so without this an undo was
1656
+ // the laundering path — plant a poison through a door that is not
1657
+ // judged, patch it clean, then undo back onto the poison. The
1658
+ // restored state walks the same composed admission BEFORE it
1659
+ // lands, and NO restored exemption applies: these were
1660
+ // live-authored states, so re-judging is safe and a refusal is
1661
+ // loud.
1662
+ admitRestored(admit, st, cur);
1663
+ st["params"] = cur;
1664
+ return;
1665
+ }
1666
+ if (e.kind === "add_stage") {
1667
+ const idx = stages.findIndex((s) => s["id"] === inv["stage"]);
1668
+ if (idx >= 0)
1669
+ stages.splice(idx, 1);
1670
+ doc["stages"] = stages;
1671
+ return;
1672
+ }
1673
+ if (e.kind === "remove_stage") {
1674
+ const row = inv["row"];
1675
+ const idx = typeof inv["index"] === "number" ? inv["index"] : stages.length;
1676
+ // THE REINSERTED ROW IS JUDGED (the audit's GAP 4): an inverse
1677
+ // row that left the doc before a law tightened re-enters here
1678
+ // with NO seat looking at it — the laundering path the patch
1679
+ // arm closed, reopened one verb over. Admission BEFORE the
1680
+ // splice: a refusal names the row and the doc keeps its stages.
1681
+ if (isRecord(row)) {
1682
+ admitRestored(admit, row, isRecord(row["params"])
1683
+ ? row["params"]
1684
+ : {});
1685
+ stages.splice(Math.min(idx, stages.length), 0, row);
1686
+ }
1687
+ doc["stages"] = stages;
1688
+ return;
1689
+ }
1690
+ if (e.kind === "group") {
1691
+ // undo(group) = ungroup: drop the compound row, spill children at
1692
+ // its index (bench order preserved inside the row).
1693
+ const idx = stages.findIndex((s) => s["id"] === inv["stage"]);
1694
+ const children = inv["children"];
1695
+ // (the inverse is a payload the log recorded, never a promise —
1696
+ // junk members degrade loudly-free: only record rows re-enter)
1697
+ const rows = Array.isArray(children)
1698
+ ? children.filter((r) => isRecord(r))
1699
+ : [];
1700
+
1701
+ // audit's row 1): the children re-enter the flat bench from the
1702
+ // INVERSE — a payload the log recorded, never a row the doors
1703
+ // just judged — so an inverse carrying pre-law poison laundered
1704
+ // straight back onto the bench. Every child walks admission
1705
+ // BEFORE the splice; a refusal leaves the compound standing.
1706
+ for (const row of rows) {
1707
+ admitRestored(admit, row, isRecord(row["params"])
1708
+ ? row["params"]
1709
+ : {});
1710
+ }
1711
+ if (idx >= 0) {
1712
+ stages.splice(idx, 1);
1713
+ stages.splice(Math.min(idx, stages.length), 0, ...rows);
1714
+ doc["stages"] = stages;
1715
+ }
1716
+ return;
1717
+ }
1718
+ if (e.kind === "ungroup") {
1719
+ // undo(ungroup) = re-group: splice the children back out, insert
1720
+ // the compound row at the recorded index.
1721
+ const row = inv["row"];
1722
+ if (!isRecord(row))
1723
+ return;
1724
+ const children = row["children"];
1725
+ // (the inverse's members ride as the log recorded them — only
1726
+ // record rows re-enter; junk degrades loudly-free)
1727
+ const members = Array.isArray(children)
1728
+ ? children.filter((c) => isRecord(c))
1729
+ : [];
1730
+ if (members.length > 0) {
1731
+
1732
+ // row 2): the compound hides its children from every patch
1733
+ // door — re-parking an inverse row re-opens the blind spot the
1734
+ // group door closed. Children walk admission BEFORE the
1735
+ // compound forms; a refusal aborts the undo whole.
1736
+ for (const child of members) {
1737
+ admitRestored(admit, child, isRecord(child["params"])
1738
+ ? child["params"]
1739
+ : {});
1740
+ }
1741
+ for (const child of members) {
1742
+ const idx = stages.findIndex((s) => s["id"] === child["id"]);
1743
+ if (idx >= 0)
1744
+ stages.splice(idx, 1);
1745
+ }
1746
+ }
1747
+ const back = typeof inv["index"] === "number"
1748
+ ? Math.min(inv["index"], stages.length)
1749
+ : stages.length;
1750
+ stages.splice(back, 0, row);
1751
+ doc["stages"] = stages;
1752
+ return;
1753
+ }
1754
+ if (e.kind === "reorder") {
1755
+ // the inverse of a move is the move back: splice out at the CURRENT
1756
+ // (post-move) position, reinsert at the ORIGINAL index (clamped).
1757
+ const idx = stages.findIndex((s) => s["id"] === inv["stage"]);
1758
+ if (idx >= 0) {
1759
+ const [row] = stages.splice(idx, 1);
1760
+ const back = typeof inv["from"] === "number"
1761
+ ? Math.min(inv["from"], stages.length)
1762
+ : stages.length;
1763
+ stages.splice(back, 0, row);
1764
+ doc["stages"] = stages;
1765
+ }
1766
+ return;
1767
+ }
1768
+ if (e.kind === "rewire") {
1769
+ // (stageRow matches by identity of the string — the row it
1770
+ // returns carries exactly this id, never a nullish one)
1771
+ const stageId = String(inv["stage"] ?? "");
1772
+ const st = stageRow(doc, stageId);
1773
+ if (st === null)
1774
+ return;
1775
+ // COPY-ON-JUDGE (the write-seat audit's row 6, second half): the
1776
+ // restored inputs are judged on a COPY and assigned only after
1777
+ // the verdict — the first cut mutated `st["inputs"]` in place
1778
+ // before the throw, so a REFUSED undo still leaked the dead wire
1779
+ // into the doc. No judgment path mutates what it has not yet
1780
+ // accepted.
1781
+ const inputs = {
1782
+ ...(isRecord(st["inputs"])
1783
+ ? st["inputs"]
1784
+ : {}),
1785
+ };
1786
+ const input = String(inv["input"] ?? "");
1787
+ if (inv["added"] === true)
1788
+ delete inputs[input];
1789
+ else if ("before" in inv)
1790
+ inputs[input] = inv["before"];
1791
+
1792
+ // audit's row 6): the inverse restores a wire the rewire door
1793
+ // judged when it was CUT — but the bench has moved since (the
1794
+ // upstream stage may be gone, the $spell may have left params),
1795
+ // and rewireBench refuses to create exactly this state. Undo
1796
+ // walks the same wiring law over the RESTORED view BEFORE the
1797
+ // assignment; a refusal aborts the undo.
1798
+ admitWiring(st, isRecord(st["params"]) ? st["params"] : {}, inputs,
1799
+ // (records are stagesOf's guarantee; an id-less row rides as "")
1800
+ stages
1801
+ .map((s) => String(s["id"] ?? "")), stageId, session);
1802
+ st["inputs"] = inputs;
1803
+ return;
1804
+ }
1805
+ // load/clear: the coarse fallbacks — restore the snapshot. (Undo
1806
+ // entries are never walk candidates — the skip at the top
1807
+ // guarantees it — so no "undo" disjunct here.)
1808
+ const snapshot = e.kind === "load" || e.kind === "clear" ? inv["stages_before"] : undefined;
1809
+ if (Array.isArray(snapshot)) {
1810
+ // THE SNAPSHOT IS JUDGED (the audit's GAP 4, the load arm): a
1811
+ // coarse fallback that assigns stages wholesale re-enters every
1812
+ // row with no seat looking — same laundering path as the
1813
+ // remove_stage arm, coarser. Judge every row BEFORE the
1814
+ // assignment; a refusal leaves the live bench untouched.
1815
+ for (const row of snapshot) {
1816
+ if (!isRecord(row))
1817
+ continue;
1818
+ admitRestored(admit, row, isRecord(row["params"])
1819
+ ? row["params"]
1820
+ : {});
1821
+ }
1822
+ doc["stages"] = snapshot;
1823
+ }
1824
+ }
1825
+ let benchChangeSeam = null;
1826
+ /** Install the seam (the plugin does this with its Context; tests do
1827
+ * it with their own). Pass null to detach. */
1828
+ export function setBenchChangeSeam(seam) {
1829
+ benchChangeSeam = seam;
1830
+ }
1831
+
1832
+
1833
+
1834
+
1835
+
1836
+
1837
+
1838
+
1839
+ export function onBenchChange(fn) {
1840
+ if (benchChangeSeam === null) {
1841
+ throw new Error("estate-drafts: no bench change seam installed — the plugin installs it in apply(), tests call setBenchChangeSeam()");
1842
+ }
1843
+ return benchChangeSeam.on((session) => {
1844
+ try {
1845
+ fn(session);
1846
+ }
1847
+ catch {
1848
+ /* a listener's fault never breaks the write */
1849
+ }
1850
+ });
1851
+ }
1852
+ /** Fire the write event — the remote layer calls this after every
1853
+ * successful bench write (the door seam: every in-process and
1854
+ * host-routed write crosses the remote arms). */
1855
+ export function notifyBenchChange(session) {
1856
+ benchChangeSeam?.emit(session);
1857
+ }
1858
+ /** The public notify (the drafts-patch door writes bench docs from
1859
+ * remote.ts — outside this module; it fires this with the session
1860
+ * parsed from the bench-shaped draft id). */
1861
+ export function notifyBenchDocWrite(draftId) {
1862
+ const m = BENCH_DRAFT_RE.exec(draftId);
1863
+ if (m !== null)
1864
+ notifyBenchChange(m[1]);
1865
+ }
1866
+ //# sourceMappingURL=bench.js.map