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