synartesis 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,181 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.5.0 — 2026-09-10
6
+
7
+ A safety release. Two more races of the same family as 0.4.2's, one structural
8
+ change so that family cannot be written again, the first proof that a shipped
9
+ policy actually restores anything, and durability by default.
10
+
11
+ Minor rather than patch: two behaviours change in ways you will notice.
12
+
13
+ ### Changed
14
+
15
+ - **The journal is durable by default.** It ran with `synchronous = NORMAL`, on
16
+ the reasoning that the tail of the write-ahead log holds the record of a call
17
+ and never the call itself. True, and the wrong way round: the call reached the
18
+ server or it did not regardless of this file, so losing the record means the
19
+ world changed and the journal does not know. Undo cannot reverse what it has
20
+ no record of, and `show --live` would report that nothing was recorded — the
21
+ reassuring answer, on no evidence.
22
+
23
+ Measured here on 2,000 inserts of a 2 kB payload: NORMAL 0.0246 ms per write,
24
+ FULL 0.0594. Durability costs **0.035 ms per action**, about eight per cent of
25
+ the proxy's own 0.43 ms overhead. `SYNARTESIS_SYNC=normal` restores the old
26
+ behaviour.
27
+
28
+ - **An interrupted inverse no longer resumes itself.** An action in
29
+ `rolling_back` was let through without a claim, on the reasoning that it must
30
+ be a crash. It is also what a live undo looks like between claiming an action
31
+ and finishing its inverse, and nothing here can tell those apart, so a second
32
+ undo starting in that window sent the same inverse again. It now halts and
33
+ names the evidence to look at. Finishing such an inverse needs a lease that
34
+ distinguishes a live owner from an abandoned attempt, which needs a schema
35
+ and a migration path this build does not have; that limit is now stated
36
+ rather than silently crossed.
37
+
38
+ ### Added
39
+
40
+ - **Adapter contract tests for the shipped filesystem policy**, run against the
41
+ real server. Policies were checked only for tool *existence* — that
42
+ `fs.read_text_file` is a real tool — and never for whether the declared
43
+ inverse puts the file back. That is the failure mode that looks fine: a real
44
+ tool, a clean resolution, a report saying `rolled_back`, and the file still
45
+ wrong. Drift detection cannot catch it, because nothing is wrong with the
46
+ drift.
47
+
48
+ The tests make the mutation, undo it and compare bytes: exact restoration
49
+ including trailing whitespace and non-ascii, repeated writes to one file, a
50
+ colleague's edit refused, absence told apart from a read that failed, and
51
+ inspection distinguishing a touched file from an untouched one. Changing the
52
+ inverse to restore `$.content` instead of `$snapshot.content` fails them.
53
+
54
+ The tested server version is named in the file. **memory, git and github ship
55
+ policies with no such proof**; those servers are not installed in this
56
+ repository and their guarantees remain assumed.
57
+
58
+ ### Fixed
59
+
60
+ - **Every status change now goes through one door.** Three of thirteen
61
+ transitions were conditional; the other ten wrote whatever they were told and
62
+ were safe only by convention. The convention failed three times in two days.
63
+ There are now two ways to change a status and no third: one reports whether it
64
+ won the race, the other names the statuses the caller must be holding and
65
+ throws when the row is not in one of them. Turning it on found three states
66
+ nothing can produce, all of them in tests that had been setting end states
67
+ directly.
68
+
69
+ - **A second place one approval could authorise two calls.** The
70
+ missing-prior-state branch ignored the result of spending the approval.
71
+
72
+ ## 0.4.3 — 2026-09-10
73
+
74
+ A correctness release. Seven reproduced defects in how effects are accounted
75
+ for and how recovery is planned. Each is pinned by a test that fails against
76
+ 0.4.2 on real state and inverse-call counts, not on messages.
77
+
78
+ The theme is one mistake made in seven places: treating absence of evidence as
79
+ evidence. An error was read as proof nothing happened, a missing post-state as
80
+ permission to write anyway, a stripped rule as proof of absence, and an
81
+ unreadable resource as one with nothing left applied.
82
+
83
+ ### Fixed
84
+
85
+ - **A timeout after the write was recorded as never applied.** The uncertainty
86
+ check knew only "Connection closed" and a downstream abort, so an upstream
87
+ that mutates a record and then times out fell through to `failed` — and
88
+ rollback steps over a failed action without looking at it. The change was
89
+ invisible to recovery.
90
+
91
+ - **`isError` was treated as proof of a refusal.** The protocol gives the flag
92
+ no transactional meaning: it covers business-logic failures that happen after
93
+ a write as readily as a refusal before one.
94
+
95
+ Both now ask instead of assuming. Where a pre-read was declared, that same
96
+ read says what is true now, and comparing it against what was there before
97
+ turns a guess into evidence: unchanged is recorded as never applied, changed
98
+ is recorded as applied so it can be undone, and a read that cannot answer
99
+ leaves the outcome unknown. Only evidence of no dispatch — no transport, or a
100
+ JSON-RPC rejection of the envelope — is now recorded as definitely not
101
+ applied. Where there is no pre-read to ask, an adapter can state the
102
+ guarantee itself with `refusal: clean` on a tool policy; the default is
103
+ `uncertain`.
104
+
105
+ - **Undo overwrote human edits when the post-state was missing.** A reversible
106
+ action promises evidence. Where the post-read failed at capture, rollback
107
+ reverted anyway, labelled it unverified and reported success — writing the
108
+ old value over whatever was there, with no `--force` required. It now halts
109
+ and says which half of the evidence is missing. `--force` is how a person
110
+ decides to proceed; the step stays unverified in the report. Compensable
111
+ actions, whose policies declare no pre-read at all, are deliberately not
112
+ caught by this.
113
+
114
+ - **`absentWhen` was stripped on the way back in.** Rollback and inspection
115
+ each parsed a stored verify read through a schema naming only server, tool
116
+ and args, so the absence rules were dropped and every read failure became
117
+ "the resource is gone". A "permission denied" was reported as a definite
118
+ change and could send an inverse. There is now one shared schema for a stored
119
+ read.
120
+
121
+ - **`--replan` could not recover a resolved conflict.** Taking the halt's own
122
+ advice — put the resource back, then replan — passed every check and then
123
+ failed to claim the row, reporting that another undo held it, which was never
124
+ true. Unresolved drift still halts.
125
+
126
+ - **Preview invented drift it would never meet.** Undo puts intervening states
127
+ back as it walks down; a preview sends nothing, so an older write to a
128
+ twice-written record was compared against the newest value and called drift.
129
+ The preview now carries the state each planned inverse would leave, and only
130
+ where that is knowable — a compensation stops the chain rather than guessing.
131
+
132
+ - **Inspection claimed nothing was left applied about resources it could not
133
+ read.** The summary counted only changed and unchanged, so an unreadable
134
+ resource fell through to the most reassuring sentence available. Unknowns are
135
+ now said first and never absorbed into a clean answer; `tally()` exposes the
136
+ counts. A failed post-read is also no longer reported as "no pre-read was
137
+ declared".
138
+
139
+ - **A second place where one approval could authorise two calls.** The
140
+ missing-prior-state branch ignored the boolean from `adoptApproval`, so a
141
+ caller that lost the claim proceeded anyway. It now asks, like the gate path.
142
+
143
+ ## 0.4.2 — 2026-09-09
144
+
145
+ Three bugs, found by auditing the paths the last pass did not touch. Each is
146
+ pinned by a test that fails against 0.4.1.
147
+
148
+ ### Fixed
149
+
150
+ - **One approval could authorise two irreversible calls.** Spending a standing
151
+ approval was an announcement rather than a claim: `markInFlight` and
152
+ `adoptApproval` both wrote unconditionally, so two proxies — which share one
153
+ journal, the reason `close` is never automatic — could read the same approved
154
+ row before either had used it, and both proceed. One person's yes, two emails
155
+ sent, which is the single thing this is here to prevent.
156
+
157
+ Both are now conditional on the row still being `approved` and report whether
158
+ they won it. `adoptApproval` spends first and only carries the approval across
159
+ if it did. The proxy treats losing the race as never having had an approval:
160
+ it asks. The guard already existed for inverses, and its comment describes
161
+ this exact failure; the approval path never got one.
162
+
163
+ - **`prune` deleted sessions still waiting on a person.** `--help` and the
164
+ README both promise that nothing waiting on a person is ever pruned. The query
165
+ enforced it for `pending`, `gated` and `rolling_back`, and not for the two
166
+ other statuses that mean the same thing: `approved`, somebody's yes the agent
167
+ has not spent, and `unrecoverable`, an undo that stopped because somebody had
168
+ changed the resource and is waiting for them to choose. Pruning the first threw
169
+ away a human decision; the second threw away both the conflict and the undo
170
+ they were deciding about.
171
+
172
+ - **Forcing an undo walked around the double-apply guard.** `markRollingBack`
173
+ claims an action by moving it out of `applied`, so two rollbacks cannot both
174
+ send one inverse. `undo --force`, added in 0.4.1, acts on rows an earlier
175
+ refusal left `unrecoverable` — which the claim did not know about, so it never
176
+ claimed them and never reported that it had not. Two concurrent forced undos
177
+ both sent the inverse: harmless for an idempotent write, a second real change
178
+ to the world for a compensable one.
179
+
5
180
  ## 0.4.1 — 2026-09-09
6
181
 
7
182
  ### Added
@@ -339,7 +339,9 @@ function openDatabase(path) {
339
339
  db.pragma("journal_mode = WAL");
340
340
  db.pragma("foreign_keys = ON");
341
341
  db.pragma("busy_timeout = 5000");
342
- db.pragma("synchronous = NORMAL");
342
+ db.pragma(
343
+ process.env["SYNARTESIS_SYNC"]?.toLowerCase() === "normal" ? "synchronous = NORMAL" : "synchronous = FULL"
344
+ );
343
345
  if (path !== ":memory:") {
344
346
  restrictToOwner(path);
345
347
  }
@@ -454,11 +456,46 @@ var SqliteJournal = class {
454
456
  );
455
457
  });
456
458
  }
457
- markFailed(actionId, error) {
458
- this.#run("markFailed", () => {
459
- this.#db.prepare("UPDATE actions SET status = 'failed', error = ? WHERE id = ?").run(error, actionId);
459
+ /**
460
+ * Every status change, in one place, and none of them silent.
461
+ *
462
+ * Three of thirteen transitions used to be conditional; the other ten wrote
463
+ * whatever they were told. That was safe only by convention -- each caller
464
+ * happened to hold a claim first -- and the convention failed three times:
465
+ * a forced undo, a spent approval, and an adopted one all wrote a status
466
+ * they did not own, and two irreversible calls went out on one person's yes.
467
+ *
468
+ * `from` is the set of statuses the caller must be holding. #settle throws
469
+ * when the row is not in one of them, because a caller that has lost its
470
+ * claim has lost the right to say what happened; #claim reports it instead,
471
+ * for the callers whose job is to race. Neither can be bypassed by writing
472
+ * status directly, because nothing else in this class does.
473
+ */
474
+ #settle(operation, actionId, to, from, error) {
475
+ this.#run(operation, () => {
476
+ const slots = from.map(() => "?").join(",");
477
+ const sql = error === void 0 ? `UPDATE actions SET status = ? WHERE id = ? AND status IN (${slots})` : `UPDATE actions SET status = ?, error = ? WHERE id = ? AND status IN (${slots})`;
478
+ const args = error === void 0 ? [to, actionId, ...from] : [to, error, actionId, ...from];
479
+ const result = this.#db.prepare(sql).run(...args);
480
+ if (result.changes !== 1) {
481
+ const now = this.getAction(actionId)?.status ?? "gone";
482
+ throw new Error(
483
+ `cannot move ${actionId} to ${to}: it is ${now}, not one of ${from.join(", ")}`
484
+ );
485
+ }
486
+ });
487
+ }
488
+ #claim(operation, actionId, to, from, error) {
489
+ return this.#run(operation, () => {
490
+ const slots = from.map(() => "?").join(",");
491
+ const sql = error === void 0 ? `UPDATE actions SET status = ? WHERE id = ? AND status IN (${slots})` : `UPDATE actions SET status = ?, error = ? WHERE id = ? AND status IN (${slots})`;
492
+ const args = error === void 0 ? [to, actionId, ...from] : [to, error, actionId, ...from];
493
+ return this.#db.prepare(sql).run(...args).changes === 1;
460
494
  });
461
495
  }
496
+ markFailed(actionId, error) {
497
+ this.#settle("markFailed", actionId, "failed", ["pending", "gated", "approved"], error);
498
+ }
462
499
  /**
463
500
  * The call was interrupted, so whether the upstream applied it is genuinely
464
501
  * unknown. The row deliberately stays `pending`: recording it as failed
@@ -466,20 +503,37 @@ var SqliteJournal = class {
466
503
  * case surfaced rather than resolved by guesswork.
467
504
  */
468
505
  markUnknown(actionId, error) {
469
- this.#run("markUnknown", () => {
470
- this.#db.prepare("UPDATE actions SET status = 'pending', error = ? WHERE id = ?").run(error, actionId);
471
- });
506
+ this.#settle("markUnknown", actionId, "pending", ["pending", "gated", "approved"], error);
472
507
  }
473
- markRollingBack(actionId) {
508
+ /**
509
+ * Claim an action to send its inverse, or report that somebody else has.
510
+ *
511
+ * Conditional, so the transition is a claim rather than an announcement. Two
512
+ * rollbacks of one run both read the action as applied and both sent its
513
+ * inverse; for a compensating call rather than a restore, that is a second
514
+ * real change to the world.
515
+ *
516
+ * `from` is which statuses may be claimed. It exists because `undo --force`
517
+ * acts on rows left `unrecoverable` by an earlier refusal, and a claim that
518
+ * only knew `applied` never claimed those at all -- so the guard above was
519
+ * simply absent on the one path where a person had already been told the
520
+ * resource is contested.
521
+ */
522
+ markRollingBack(actionId, from = ["applied"]) {
474
523
  return this.#run("markRollingBack", () => {
475
- const result = this.#db.prepare("UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status = 'applied'").run(actionId);
524
+ const slots = from.map(() => "?").join(",");
525
+ const result = this.#db.prepare(
526
+ `UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status IN (${slots})`
527
+ ).run(actionId, ...from);
476
528
  return result.changes === 1;
477
529
  });
478
530
  }
479
531
  markRolledBack(actionId) {
480
- this.#run("markRolledBack", () => {
481
- this.#db.prepare("UPDATE actions SET status = 'rolled_back' WHERE id = ?").run(actionId);
482
- });
532
+ this.#settle("markRolledBack", actionId, "rolled_back", [
533
+ "rolling_back",
534
+ "applied",
535
+ "unrecoverable"
536
+ ]);
483
537
  }
484
538
  /**
485
539
  * The upstream processed the inverse and refused it, so nothing was applied
@@ -488,9 +542,7 @@ var SqliteJournal = class {
488
542
  * was briefly unwell, and rollback is expected to be retried (D7).
489
543
  */
490
544
  markInverseRejected(actionId, error) {
491
- this.#run("markInverseRejected", () => {
492
- this.#db.prepare("UPDATE actions SET status = 'applied', error = ? WHERE id = ?").run(error, actionId);
493
- });
545
+ this.#settle("markInverseRejected", actionId, "applied", ["rolling_back"], error);
494
546
  }
495
547
  /**
496
548
  * The inverse may or may not have reached the upstream. The row stays in
@@ -498,18 +550,24 @@ var SqliteJournal = class {
498
550
  * current state rather than assuming either way.
499
551
  */
500
552
  markUnknownInverse(actionId, error) {
501
- this.#run("markUnknownInverse", () => {
502
- this.#db.prepare("UPDATE actions SET status = 'rolling_back', error = ? WHERE id = ?").run(error, actionId);
503
- });
553
+ this.#settle("markUnknownInverse", actionId, "rolling_back", ["rolling_back"], error);
504
554
  }
505
555
  markGated(actionId, why) {
506
- this.#run("markGated", () => {
507
- this.#db.prepare("UPDATE actions SET status = 'gated', error = ? WHERE id = ?").run(why ?? null, actionId);
508
- });
556
+ this.#settle("markGated", actionId, "gated", ["pending", "gated"], why ?? null);
509
557
  }
558
+ /**
559
+ * Spend a standing approval, or report that somebody else already has.
560
+ *
561
+ * Conditional for the same reason markRollingBack is. Several proxies share
562
+ * one journal, so two can read the same approved row before either has used
563
+ * it, and an unconditional write let both proceed -- one person's yes
564
+ * authorising two irreversible calls, which is the single thing this is here
565
+ * to prevent.
566
+ */
510
567
  markInFlight(actionId) {
511
- this.#run("markInFlight", () => {
512
- this.#db.prepare("UPDATE actions SET status = 'pending' WHERE id = ?").run(actionId);
568
+ return this.#run("markInFlight", () => {
569
+ const result = this.#db.prepare("UPDATE actions SET status = 'pending' WHERE id = ? AND status = 'approved'").run(actionId);
570
+ return result.changes === 1;
513
571
  });
514
572
  }
515
573
  /**
@@ -542,12 +600,18 @@ var SqliteJournal = class {
542
600
  });
543
601
  }
544
602
  adoptApproval(actionId, granted) {
545
- this.#run("adoptApproval", () => {
603
+ return this.#run("adoptApproval", () => {
546
604
  const move = this.#db.transaction(() => {
605
+ const spent = this.#db.prepare(
606
+ "UPDATE actions SET status = 'denied', error = ? WHERE id = ? AND status = 'approved'"
607
+ ).run(`${SPENT_APPROVAL} ${actionId}`, granted.id);
608
+ if (spent.changes !== 1) {
609
+ return false;
610
+ }
547
611
  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);
612
+ return true;
549
613
  });
550
- move.immediate();
614
+ return move.immediate();
551
615
  });
552
616
  }
553
617
  listGated() {
@@ -587,9 +651,11 @@ var SqliteJournal = class {
587
651
  });
588
652
  }
589
653
  markUnrecoverable(actionId, error) {
590
- this.#run("markUnrecoverable", () => {
591
- this.#db.prepare("UPDATE actions SET status = 'unrecoverable', error = ? WHERE id = ?").run(error, actionId);
592
- });
654
+ this.#settle("markUnrecoverable", actionId, "unrecoverable", [
655
+ "applied",
656
+ "rolling_back",
657
+ "unrecoverable"
658
+ ], error);
593
659
  }
594
660
  listRuns() {
595
661
  return this.#run(
@@ -639,10 +705,18 @@ var SqliteJournal = class {
639
705
  FROM runs r
640
706
  WHERE r.status != 'active'
641
707
  AND COALESCE(r.ended_at, r.started_at) < ?
708
+ -- Everything a person still has business with. approved is
709
+ -- somebody's yes that the agent has not spent yet, and
710
+ -- unrecoverable is an undo that stopped because somebody had
711
+ -- changed the resource, waiting for them to choose. Both were
712
+ -- missing, so the promise made in --help and the README -- that
713
+ -- nothing waiting on a person is ever pruned -- was not one this
714
+ -- query kept.
642
715
  AND NOT EXISTS (
643
716
  SELECT 1 FROM actions a
644
717
  WHERE a.run_id = r.id
645
- AND a.status IN ('pending','gated','rolling_back'))
718
+ AND a.status IN
719
+ ('pending','gated','approved','rolling_back','unrecoverable'))
646
720
  ORDER BY at, r.rowid`
647
721
  ).all(before).map(
648
722
  (row) => z.object({
@@ -880,6 +954,7 @@ var toolPolicy = z2.strictObject({
880
954
  match: z2.string().min(1),
881
955
  class: z2.enum(["readonly", "reversible", "compensable", "irreversible"]),
882
956
  gate: z2.enum(["always", "on_write", "never"]).optional(),
957
+ refusal: z2.enum(["uncertain", "clean"]).optional(),
883
958
  snapshot: callTemplate.optional(),
884
959
  inverse: callTemplate.optional()
885
960
  });
@@ -1036,6 +1111,7 @@ function withGate(policy) {
1036
1111
  match: policy.match,
1037
1112
  class: policy.class,
1038
1113
  gate,
1114
+ refusal: policy.refusal ?? "uncertain",
1039
1115
  ...policy.snapshot === void 0 ? {} : { snapshot: toCall(policy.snapshot) },
1040
1116
  ...policy.inverse === void 0 ? {} : { inverse: toCall(policy.inverse) }
1041
1117
  };
@@ -1306,7 +1382,7 @@ function literalLength(pattern) {
1306
1382
  return pattern.length - pattern.split("*").length + 1;
1307
1383
  }
1308
1384
  function failClosed(qualifiedName) {
1309
- return { match: qualifiedName, class: "irreversible", gate: "always" };
1385
+ return { match: qualifiedName, class: "irreversible", gate: "always", refusal: "uncertain" };
1310
1386
  }
1311
1387
  function createPolicyResolver(manifest) {
1312
1388
  const compiled = manifest.tools.map((policy) => ({
@@ -1383,6 +1459,20 @@ function planInverse(call, context) {
1383
1459
  }
1384
1460
  return { server: target.server, tool: target.tool, args: resolveArgs(call, context) };
1385
1461
  }
1462
+ var resolvedRead = z4.object({
1463
+ server: z4.string(),
1464
+ tool: z4.string(),
1465
+ args: z4.record(z4.string(), z4.unknown()),
1466
+ absentWhen: z4.array(z4.string()).optional()
1467
+ });
1468
+ function toResolvedRead(parsed) {
1469
+ return {
1470
+ server: parsed.server,
1471
+ tool: parsed.tool,
1472
+ args: parsed.args,
1473
+ ...parsed.absentWhen === void 0 ? {} : { absentWhen: parsed.absentWhen }
1474
+ };
1475
+ }
1386
1476
  function planRead(call, context) {
1387
1477
  const target = splitQualified(call.tool);
1388
1478
  if (target === void 0) {
@@ -1403,10 +1493,6 @@ function isDisconnected(error) {
1403
1493
  const message = error instanceof Error ? error.message : String(error);
1404
1494
  return message.includes("Not connected") || message.includes("Connection closed");
1405
1495
  }
1406
- function mayHaveArrived(error) {
1407
- const message = error instanceof Error ? error.message : String(error);
1408
- return message.includes("Connection closed");
1409
- }
1410
1496
  async function runRead(router, read2, signal) {
1411
1497
  const label = `${read2.server}.${read2.tool}`;
1412
1498
  const upstream = router.byName(read2.server);
@@ -1486,11 +1572,12 @@ export {
1486
1572
  refusal,
1487
1573
  toPayload,
1488
1574
  planInverse,
1575
+ resolvedRead,
1576
+ toResolvedRead,
1489
1577
  planRead,
1490
1578
  isDisconnected,
1491
- mayHaveArrived,
1492
1579
  runRead,
1493
1580
  observeState,
1494
1581
  connectStdioUpstream
1495
1582
  };
1496
- //# sourceMappingURL=chunk-FOA4UIDE.js.map
1583
+ //# sourceMappingURL=chunk-CACV6R74.js.map
package/dist/cli.js CHANGED
@@ -20,12 +20,14 @@ import {
20
20
  planRead,
21
21
  proxyCommand,
22
22
  qualify,
23
+ resolvedRead,
23
24
  rule,
24
25
  style,
25
26
  toPayload,
27
+ toResolvedRead,
26
28
  verifyAgainstServers,
27
29
  wasRefused
28
- } from "./chunk-FOA4UIDE.js";
30
+ } from "./chunk-CACV6R74.js";
29
31
  import {
30
32
  DriftConflict,
31
33
  ManifestError,
@@ -384,6 +386,7 @@ async function rollback(options) {
384
386
  );
385
387
  let halted;
386
388
  let leftInPlace = false;
389
+ const projected = /* @__PURE__ */ new Map();
387
390
  for (const action of inScope) {
388
391
  let forcedOver;
389
392
  const early = classify(action, policies !== void 0, force || dryRun);
@@ -414,13 +417,14 @@ ${seen}` : seen;
414
417
  continue;
415
418
  }
416
419
  const plan = parsedPlan.data;
417
- const verifyRead = inversePlan.safeParse(rebuilt.verify ?? action.verify);
420
+ const verifyRead = resolvedRead.safeParse(rebuilt.verify ?? action.verify);
418
421
  const recordedPost = observation.safeParse(action.postSnapshot);
419
422
  let verified = false;
420
423
  if (recordedPost.success && verifyRead.success) {
424
+ const foreseen = dryRun ? projected.get(resourceKey(toResolvedRead(verifyRead.data))) : void 0;
421
425
  let current;
422
426
  try {
423
- current = await observeState(router, verifyRead.data, signal);
427
+ current = foreseen !== void 0 && foreseen !== UNFORESEEABLE ? foreseen : await observeState(router, toResolvedRead(verifyRead.data), signal);
424
428
  } catch (error) {
425
429
  const reason = `could not read current state to check for drift: ${describe(error)}`;
426
430
  halted = { seq: action.seq, reason, detail: "" };
@@ -471,6 +475,17 @@ ${seen}` : seen;
471
475
  forcedOver = "the resource had changed since; that change was overwritten";
472
476
  }
473
477
  }
478
+ if (!verified && action.class === "reversible" && !force) {
479
+ const reason = unverifiedBecause(action);
480
+ halted = {
481
+ seq: action.seq,
482
+ reason,
483
+ detail: "Without it there is no way to tell this resource from one somebody has edited since, so the recorded value was not written.",
484
+ conflict: true
485
+ };
486
+ steps.push({ ...describeStep(action), kind: "halt", reason, verified: false, plan });
487
+ break;
488
+ }
474
489
  if (!verified && action.status === "rolling_back") {
475
490
  const reason = "an inverse was already sent before an interruption and no pre-read is declared, so whether it applied cannot be determined";
476
491
  halted = { seq: action.seq, reason, detail: action.error ?? "" };
@@ -480,6 +495,10 @@ ${seen}` : seen;
480
495
  }
481
496
  break;
482
497
  }
498
+ if (dryRun && verifyRead.success) {
499
+ const after = intendedAfterInverse(action);
500
+ projected.set(resourceKey(toResolvedRead(verifyRead.data)), after ?? UNFORESEEABLE);
501
+ }
483
502
  steps.push({
484
503
  ...describeStep(action),
485
504
  kind: "revert",
@@ -491,10 +510,15 @@ ${seen}` : seen;
491
510
  if (dryRun) {
492
511
  continue;
493
512
  }
494
- const claimed = journal.markRollingBack(action.id);
495
- if (!claimed && action.status === "applied") {
496
- const reason = "another undo is already working on this action";
497
- halted = { seq: action.seq, reason, detail: "" };
513
+ const claimable = force || policies !== void 0 ? ["applied", "unrecoverable"] : ["applied"];
514
+ const claimed = journal.markRollingBack(action.id, claimable);
515
+ if (!claimed) {
516
+ const reason = action.status === "rolling_back" ? "an inverse for this action was already sent and never finished; whether another undo still holds it cannot be told from here" : "another undo is already working on this action";
517
+ halted = {
518
+ seq: action.seq,
519
+ reason,
520
+ detail: action.status === "rolling_back" ? "Nothing here can tell a live owner from a dead one, and this build has no lease to consult. Read the action with `show --live`: if the resource still shows the agent's write, the inverse never landed." : ""
521
+ };
498
522
  steps[steps.length - 1] = {
499
523
  ...describeStep(action),
500
524
  kind: "halt",
@@ -552,6 +576,10 @@ function unverifiedBecause(action) {
552
576
  function describeStep(action) {
553
577
  return { seq: action.seq, server: action.server, tool: action.tool };
554
578
  }
579
+ function resourceKey(read) {
580
+ return canonical({ server: read.server, tool: read.tool, args: read.args });
581
+ }
582
+ var UNFORESEEABLE = /* @__PURE__ */ Symbol("unforeseeable");
555
583
  function overwriteText(current, action) {
556
584
  const intended2 = intendedAfterInverse(action);
557
585
  if (intended2 === void 0) {
@@ -597,11 +625,6 @@ async function executeInverse(router, plan, idempotencyKey, signal) {
597
625
 
598
626
  // src/rollback/inspect.ts
599
627
  import { z as z3 } from "zod";
600
- var inversePlan2 = z3.object({
601
- server: z3.string(),
602
- tool: z3.string(),
603
- args: z3.record(z3.string(), z3.unknown())
604
- });
605
628
  var observation2 = z3.union([
606
629
  z3.object({ present: z3.literal(true), value: z3.unknown() }),
607
630
  z3.object({ present: z3.literal(false) })
@@ -612,7 +635,7 @@ function sameState2(a, b) {
612
635
  function intended(action) {
613
636
  return action.snapshot === void 0 ? void 0 : { present: true, value: action.snapshot };
614
637
  }
615
- function resourceKey(plan) {
638
+ function resourceKey2(plan) {
616
639
  return canonical({ server: plan.server, tool: plan.tool, args: plan.args });
617
640
  }
618
641
  var SETTLED = /* @__PURE__ */ new Set(["failed", "denied", "gated", "approved"]);
@@ -635,17 +658,18 @@ async function inspect(options) {
635
658
  resources.push({ ...at, condition: "restored" });
636
659
  continue;
637
660
  }
638
- const verify = inversePlan2.safeParse(action.verify);
661
+ const verify = resolvedRead.safeParse(action.verify);
639
662
  const post = observation2.safeParse(action.postSnapshot);
640
663
  if (!verify.success || !post.success) {
664
+ const note = !verify.success ? action.verify === void 0 ? "no pre-read was declared, so there is nothing to compare against" : "the recorded pre-read could not be read back" : action.postSnapshot === void 0 ? "the post-state was never captured, so there is nothing to compare against" : "the recorded post-state could not be read back";
641
665
  resources.push({
642
666
  ...at,
643
667
  condition: "unknowable",
644
- note: action.inverse === void 0 ? "nothing was captured to restore" : "no pre-read was declared"
668
+ note: action.inverse === void 0 ? `${note}; and nothing was captured to restore` : note
645
669
  });
646
670
  continue;
647
671
  }
648
- const key = resourceKey(verify.data);
672
+ const key = resourceKey2(toResolvedRead(verify.data));
649
673
  if (seen.has(key)) {
650
674
  resources.push({ ...at, condition: "superseded" });
651
675
  continue;
@@ -653,7 +677,7 @@ async function inspect(options) {
653
677
  seen.add(key);
654
678
  let current;
655
679
  try {
656
- current = await observeState(router, verify.data, signal);
680
+ current = await observeState(router, toResolvedRead(verify.data), signal);
657
681
  } catch (error) {
658
682
  resources.push({ ...at, condition: "unknowable", note: `could not read it: ${describe(error)}` });
659
683
  continue;
@@ -675,16 +699,49 @@ async function inspect(options) {
675
699
  }
676
700
  return { runId, resources: resources.reverse() };
677
701
  }
702
+ function tally(inspection) {
703
+ const counts = {
704
+ unchanged: 0,
705
+ changed: 0,
706
+ restored: 0,
707
+ superseded: 0,
708
+ "not-applied": 0,
709
+ unknowable: 0
710
+ };
711
+ for (const resource of inspection.resources) {
712
+ counts[resource.condition] += 1;
713
+ }
714
+ return counts;
715
+ }
678
716
  function verdict(inspection) {
679
- const changed = inspection.resources.filter((r) => r.condition === "changed").length;
680
- const undoable = inspection.resources.filter((r) => r.condition === "unchanged").length;
681
- if (changed > 0) {
682
- return `${String(changed)} changed since this ran; undoing would write over ${changed === 1 ? "it" : "them"}`;
717
+ const counts = tally(inspection);
718
+ const said = [];
719
+ if (counts.changed > 0) {
720
+ said.push(
721
+ `${String(counts.changed)} changed since this ran; undoing would write over ${counts.changed === 1 ? "it" : "them"}`
722
+ );
723
+ }
724
+ if (counts.unknowable > 0) {
725
+ said.push(
726
+ `${String(counts.unknowable)} could not be checked, so whether ${counts.unknowable === 1 ? "it is" : "they are"} still applied is unknown`
727
+ );
728
+ }
729
+ if (said.length > 0) {
730
+ if (counts.unchanged > 0) {
731
+ said.push(`${String(counts.unchanged)} unchanged`);
732
+ }
733
+ return said.join(" \xB7 ");
734
+ }
735
+ if (counts.unchanged > 0) {
736
+ return `nothing has been touched since; all ${String(counts.unchanged)} would undo cleanly`;
737
+ }
738
+ if (counts.restored > 0 || counts.superseded > 0) {
739
+ return "everything here has already been put back";
683
740
  }
684
- if (undoable > 0) {
685
- return `nothing has been touched since; all ${String(undoable)} would undo cleanly`;
741
+ if (counts["not-applied"] > 0) {
742
+ return "nothing here was ever applied";
686
743
  }
687
- return "nothing here is still applied";
744
+ return "nothing was recorded in this session";
688
745
  }
689
746
 
690
747
  // src/watch.ts
package/dist/proxy.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ canonical,
3
4
  cliCommandFrom,
4
5
  connectStdioUpstream,
5
6
  createPolicyResolver,
@@ -9,7 +10,6 @@ import {
9
10
  isDisconnected,
10
11
  loadManifest,
11
12
  mark,
12
- mayHaveArrived,
13
13
  observeState,
14
14
  openJournal,
15
15
  planInverse,
@@ -19,7 +19,7 @@ import {
19
19
  runRead,
20
20
  toPayload,
21
21
  verifyAgainstServers
22
- } from "./chunk-FOA4UIDE.js";
22
+ } from "./chunk-CACV6R74.js";
23
23
  import {
24
24
  SnapshotError,
25
25
  UpstreamError,
@@ -322,6 +322,25 @@ function identityFor(router) {
322
322
  }
323
323
  return { name: "synartesis", version: "0.0.0" };
324
324
  }
325
+ async function whatHappened(router, verify, before, signal) {
326
+ if (verify === void 0) {
327
+ return "unknown";
328
+ }
329
+ try {
330
+ const now = await observeState(router, verify, signal);
331
+ return canonical(now) === canonical(before) ? "none" : "applied";
332
+ } catch {
333
+ return "unknown";
334
+ }
335
+ }
336
+ function neverDispatched(error) {
337
+ const message = error instanceof Error ? error.message : String(error);
338
+ if (message.includes("Not connected")) {
339
+ return true;
340
+ }
341
+ const code = isRecord(error) ? error["code"] : void 0;
342
+ return code === ErrorCode.MethodNotFound || code === ErrorCode.InvalidParams || code === ErrorCode.InvalidRequest || code === ErrorCode.ParseError;
343
+ }
325
344
  var SYNARTESIS_INSTRUCTIONS = [
326
345
  "These tools are guarded by Synartesis, which records every change so it can be undone later.",
327
346
  "",
@@ -588,10 +607,11 @@ function createProxyServer(options) {
588
607
  seq: reusable.seq,
589
608
  idempotencyKey: reusable.idempotencyKey
590
609
  };
610
+ let spent = true;
591
611
  if (inherited !== void 0) {
592
- journal.adoptApproval(pending.actionId, inherited);
612
+ spent = journal.adoptApproval(pending.actionId, inherited);
593
613
  } else if (granted !== void 0 && waiting === void 0) {
594
- journal.markInFlight(granted.id);
614
+ spent = journal.markInFlight(granted.id);
595
615
  }
596
616
  if (granted !== void 0) {
597
617
  log?.info(
@@ -654,15 +674,17 @@ function createProxyServer(options) {
654
674
  }
655
675
  };
656
676
  const askedAlready = wantsGate;
657
- if (wantsGate && granted === void 0) {
677
+ if (wantsGate && (granted === void 0 || !spent)) {
658
678
  await decide("this action cannot be undone");
659
679
  }
660
680
  let snapshot;
661
681
  let verify;
682
+ let probe;
662
683
  let missingPriorState;
663
684
  if (policy.snapshot !== void 0) {
664
685
  try {
665
686
  verify = planRead(policy.snapshot, { args });
687
+ probe = verify;
666
688
  snapshot = await runRead(router, verify, extra.signal);
667
689
  journal.attachSnapshot(pending.actionId, snapshot);
668
690
  } catch (error) {
@@ -683,6 +705,7 @@ function createProxyServer(options) {
683
705
  }
684
706
  }
685
707
  }
708
+ const priorState = probe === void 0 || missingPriorState !== void 0 ? { present: false } : { present: true, value: snapshot };
686
709
  if (missingPriorState !== void 0 && !askedAlready) {
687
710
  const standing = journal.findApproval({
688
711
  server: route.upstream.name,
@@ -694,12 +717,15 @@ function createProxyServer(options) {
694
717
  await decide(
695
718
  `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
696
719
  );
697
- } else {
698
- journal.adoptApproval(pending.actionId, standing);
720
+ } else if (journal.adoptApproval(pending.actionId, standing)) {
699
721
  log?.info(
700
722
  { action: pending.actionId, by: standing.approvedBy, from: standing.runId },
701
723
  "proceeding on a standing approval"
702
724
  );
725
+ } else {
726
+ await decide(
727
+ `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
728
+ );
703
729
  }
704
730
  }
705
731
  const forwarded = {
@@ -712,12 +738,27 @@ function createProxyServer(options) {
712
738
  });
713
739
  const refused = refusal(result);
714
740
  if (refused !== void 0) {
715
- journal.markFailed(pending.actionId, `the upstream refused the call: ${refused}`);
716
- log?.debug(
741
+ const settled = policy.refusal === "clean" ? "none" : await whatHappened(router, probe, priorState, extra.signal);
742
+ if (settled !== "applied") {
743
+ const why = `the upstream refused the call: ${refused}`;
744
+ if (settled === "none") {
745
+ journal.markFailed(pending.actionId, why);
746
+ } else {
747
+ journal.markUnknown(
748
+ pending.actionId,
749
+ `${why} -- and whether anything changed could not be established`
750
+ );
751
+ }
752
+ log?.debug(
753
+ { seq: pending.seq, tool: route.tool, reason: refused, settled },
754
+ "refused by the upstream"
755
+ );
756
+ return result;
757
+ }
758
+ log?.warn(
717
759
  { seq: pending.seq, tool: route.tool, reason: refused },
718
- "refused by the upstream"
760
+ "the upstream reported an error after changing the resource"
719
761
  );
720
- return result;
721
762
  }
722
763
  const context = { args, snapshot, result: toPayload(result) };
723
764
  const warnings = [];
@@ -759,10 +800,30 @@ function createProxyServer(options) {
759
800
  return result;
760
801
  } catch (error) {
761
802
  const disconnected = isDisconnected(error);
762
- if (extra.signal.aborted || mayHaveArrived(error)) {
763
- journal.markUnknown(pending.actionId, describe(error));
764
- } else {
803
+ if (neverDispatched(error)) {
765
804
  journal.markFailed(pending.actionId, describe(error));
805
+ } else {
806
+ const settled = await whatHappened(router, probe, priorState, extra.signal);
807
+ if (settled === "none") {
808
+ journal.markFailed(pending.actionId, describe(error));
809
+ } else if (settled === "unknown") {
810
+ journal.markUnknown(pending.actionId, describe(error));
811
+ } else {
812
+ let recovered;
813
+ if (policy.inverse !== void 0 && missingPriorState === void 0) {
814
+ try {
815
+ recovered = planInverse(policy.inverse, { args, snapshot, result: void 0 });
816
+ } catch {
817
+ recovered = void 0;
818
+ }
819
+ }
820
+ journal.markApplied(pending.actionId, {
821
+ result: void 0,
822
+ ...recovered === void 0 ? {} : { inverse: recovered },
823
+ ...verify === void 0 ? {} : { verify },
824
+ warning: `the call applied but its answer never arrived: ${describe(error)}`
825
+ });
826
+ }
766
827
  }
767
828
  if (disconnected && route.upstream.reconnect !== void 0) {
768
829
  await route.upstream.reconnect().catch(() => void 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,