synartesis 0.4.1 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.4.2 — 2026-09-09
6
+
7
+ Three bugs, found by auditing the paths the last pass did not touch. Each is
8
+ pinned by a test that fails against 0.4.1.
9
+
10
+ ### Fixed
11
+
12
+ - **One approval could authorise two irreversible calls.** Spending a standing
13
+ approval was an announcement rather than a claim: `markInFlight` and
14
+ `adoptApproval` both wrote unconditionally, so two proxies — which share one
15
+ journal, the reason `close` is never automatic — could read the same approved
16
+ row before either had used it, and both proceed. One person's yes, two emails
17
+ sent, which is the single thing this is here to prevent.
18
+
19
+ Both are now conditional on the row still being `approved` and report whether
20
+ they won it. `adoptApproval` spends first and only carries the approval across
21
+ if it did. The proxy treats losing the race as never having had an approval:
22
+ it asks. The guard already existed for inverses, and its comment describes
23
+ this exact failure; the approval path never got one.
24
+
25
+ - **`prune` deleted sessions still waiting on a person.** `--help` and the
26
+ README both promise that nothing waiting on a person is ever pruned. The query
27
+ enforced it for `pending`, `gated` and `rolling_back`, and not for the two
28
+ other statuses that mean the same thing: `approved`, somebody's yes the agent
29
+ has not spent, and `unrecoverable`, an undo that stopped because somebody had
30
+ changed the resource and is waiting for them to choose. Pruning the first threw
31
+ away a human decision; the second threw away both the conflict and the undo
32
+ they were deciding about.
33
+
34
+ - **Forcing an undo walked around the double-apply guard.** `markRollingBack`
35
+ claims an action by moving it out of `applied`, so two rollbacks cannot both
36
+ send one inverse. `undo --force`, added in 0.4.1, acts on rows an earlier
37
+ refusal left `unrecoverable` — which the claim did not know about, so it never
38
+ claimed them and never reported that it had not. Two concurrent forced undos
39
+ both sent the inverse: harmless for an idempotent write, a second real change
40
+ to the world for a compensable one.
41
+
5
42
  ## 0.4.1 — 2026-09-09
6
43
 
7
44
  ### Added
@@ -470,9 +470,26 @@ var SqliteJournal = class {
470
470
  this.#db.prepare("UPDATE actions SET status = 'pending', error = ? WHERE id = ?").run(error, actionId);
471
471
  });
472
472
  }
473
- markRollingBack(actionId) {
473
+ /**
474
+ * Claim an action to send its inverse, or report that somebody else has.
475
+ *
476
+ * Conditional, so the transition is a claim rather than an announcement. Two
477
+ * rollbacks of one run both read the action as applied and both sent its
478
+ * inverse; for a compensating call rather than a restore, that is a second
479
+ * real change to the world.
480
+ *
481
+ * `from` is which statuses may be claimed. It exists because `undo --force`
482
+ * acts on rows left `unrecoverable` by an earlier refusal, and a claim that
483
+ * only knew `applied` never claimed those at all -- so the guard above was
484
+ * simply absent on the one path where a person had already been told the
485
+ * resource is contested.
486
+ */
487
+ markRollingBack(actionId, from = ["applied"]) {
474
488
  return this.#run("markRollingBack", () => {
475
- const result = this.#db.prepare("UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status = 'applied'").run(actionId);
489
+ const slots = from.map(() => "?").join(",");
490
+ const result = this.#db.prepare(
491
+ `UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status IN (${slots})`
492
+ ).run(actionId, ...from);
476
493
  return result.changes === 1;
477
494
  });
478
495
  }
@@ -507,9 +524,19 @@ var SqliteJournal = class {
507
524
  this.#db.prepare("UPDATE actions SET status = 'gated', error = ? WHERE id = ?").run(why ?? null, actionId);
508
525
  });
509
526
  }
527
+ /**
528
+ * Spend a standing approval, or report that somebody else already has.
529
+ *
530
+ * Conditional for the same reason markRollingBack is. Several proxies share
531
+ * one journal, so two can read the same approved row before either has used
532
+ * it, and an unconditional write let both proceed -- one person's yes
533
+ * authorising two irreversible calls, which is the single thing this is here
534
+ * to prevent.
535
+ */
510
536
  markInFlight(actionId) {
511
- this.#run("markInFlight", () => {
512
- this.#db.prepare("UPDATE actions SET status = 'pending' WHERE id = ?").run(actionId);
537
+ return this.#run("markInFlight", () => {
538
+ const result = this.#db.prepare("UPDATE actions SET status = 'pending' WHERE id = ? AND status = 'approved'").run(actionId);
539
+ return result.changes === 1;
513
540
  });
514
541
  }
515
542
  /**
@@ -542,12 +569,18 @@ var SqliteJournal = class {
542
569
  });
543
570
  }
544
571
  adoptApproval(actionId, granted) {
545
- this.#run("adoptApproval", () => {
572
+ return this.#run("adoptApproval", () => {
546
573
  const move = this.#db.transaction(() => {
574
+ const spent = this.#db.prepare(
575
+ "UPDATE actions SET status = 'denied', error = ? WHERE id = ? AND status = 'approved'"
576
+ ).run(`${SPENT_APPROVAL} ${actionId}`, granted.id);
577
+ if (spent.changes !== 1) {
578
+ return false;
579
+ }
547
580
  this.#db.prepare("UPDATE actions SET approved_by = ?, approved_at = ? WHERE id = ?").run(granted.approvedBy ?? null, granted.approvedAt ?? null, actionId);
548
- this.#db.prepare("UPDATE actions SET status = 'denied', error = ? WHERE id = ?").run(`${SPENT_APPROVAL} ${actionId}`, granted.id);
581
+ return true;
549
582
  });
550
- move.immediate();
583
+ return move.immediate();
551
584
  });
552
585
  }
553
586
  listGated() {
@@ -639,10 +672,18 @@ var SqliteJournal = class {
639
672
  FROM runs r
640
673
  WHERE r.status != 'active'
641
674
  AND COALESCE(r.ended_at, r.started_at) < ?
675
+ -- Everything a person still has business with. approved is
676
+ -- somebody's yes that the agent has not spent yet, and
677
+ -- unrecoverable is an undo that stopped because somebody had
678
+ -- changed the resource, waiting for them to choose. Both were
679
+ -- missing, so the promise made in --help and the README -- that
680
+ -- nothing waiting on a person is ever pruned -- was not one this
681
+ -- query kept.
642
682
  AND NOT EXISTS (
643
683
  SELECT 1 FROM actions a
644
684
  WHERE a.run_id = r.id
645
- AND a.status IN ('pending','gated','rolling_back'))
685
+ AND a.status IN
686
+ ('pending','gated','approved','rolling_back','unrecoverable'))
646
687
  ORDER BY at, r.rowid`
647
688
  ).all(before).map(
648
689
  (row) => z.object({
@@ -1493,4 +1534,4 @@ export {
1493
1534
  observeState,
1494
1535
  connectStdioUpstream
1495
1536
  };
1496
- //# sourceMappingURL=chunk-FOA4UIDE.js.map
1537
+ //# sourceMappingURL=chunk-LAZLEAZW.js.map
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  toPayload,
26
26
  verifyAgainstServers,
27
27
  wasRefused
28
- } from "./chunk-FOA4UIDE.js";
28
+ } from "./chunk-LAZLEAZW.js";
29
29
  import {
30
30
  DriftConflict,
31
31
  ManifestError,
@@ -491,8 +491,11 @@ ${seen}` : seen;
491
491
  if (dryRun) {
492
492
  continue;
493
493
  }
494
- const claimed = journal.markRollingBack(action.id);
495
- if (!claimed && action.status === "applied") {
494
+ const claimed = journal.markRollingBack(
495
+ action.id,
496
+ force ? ["applied", "unrecoverable"] : ["applied"]
497
+ );
498
+ if (!claimed && action.status !== "rolling_back") {
496
499
  const reason = "another undo is already working on this action";
497
500
  halted = { seq: action.seq, reason, detail: "" };
498
501
  steps[steps.length - 1] = {
package/dist/proxy.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  runRead,
20
20
  toPayload,
21
21
  verifyAgainstServers
22
- } from "./chunk-FOA4UIDE.js";
22
+ } from "./chunk-LAZLEAZW.js";
23
23
  import {
24
24
  SnapshotError,
25
25
  UpstreamError,
@@ -588,10 +588,11 @@ function createProxyServer(options) {
588
588
  seq: reusable.seq,
589
589
  idempotencyKey: reusable.idempotencyKey
590
590
  };
591
+ let spent = true;
591
592
  if (inherited !== void 0) {
592
- journal.adoptApproval(pending.actionId, inherited);
593
+ spent = journal.adoptApproval(pending.actionId, inherited);
593
594
  } else if (granted !== void 0 && waiting === void 0) {
594
- journal.markInFlight(granted.id);
595
+ spent = journal.markInFlight(granted.id);
595
596
  }
596
597
  if (granted !== void 0) {
597
598
  log?.info(
@@ -654,7 +655,7 @@ function createProxyServer(options) {
654
655
  }
655
656
  };
656
657
  const askedAlready = wantsGate;
657
- if (wantsGate && granted === void 0) {
658
+ if (wantsGate && (granted === void 0 || !spent)) {
658
659
  await decide("this action cannot be undone");
659
660
  }
660
661
  let snapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,