synartesis 0.7.0 → 0.8.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,38 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.8.0 — 2026-09-16
6
+
7
+ ### Added
8
+
9
+ - **`expect: absent`, and `move_file` becomes undoable.** A pre-read normally
10
+ captures what a call is about to replace, so a read that finds nothing means
11
+ there is nothing to put back. For a few calls that is exactly backwards:
12
+ moving a file onto a free path is undone by moving it back, and it is finding
13
+ *something* there that puts the call beyond undo, because one inverse cannot
14
+ both move your file back and restore what it landed on.
15
+
16
+ Both halves were measured before anything was written. Declared plainly
17
+ `reversible`, the safe move came back `partial` with the file still moved —
18
+ the trivially reversible case was the one that could not be undone — and the
19
+ dangerous one came back `rolled_back` with the overwritten file gone, which
20
+ is the confident wrong undo this exists to prevent. `expect: absent` on the
21
+ snapshot swaps the two: finding nothing is reversible and the inverse runs;
22
+ finding something is held for a person and recorded with **no inverse**, so
23
+ undo says it cannot be undone rather than putting half of it back.
24
+
25
+ The shipped filesystem policy uses it, so moving a file to a fresh path is
26
+ now an ordinary undoable action instead of an approval prompt. A policy that
27
+ declares it on a non-`reversible` rule, on a `verify` read, or with an
28
+ inverse reading `$snapshot.` — which can never resolve, since nothing is
29
+ captured — is refused at load.
30
+
31
+ Worth recording: this server's own description of `move_file` says "If the
32
+ destination exists, the operation will fail." It does not; it renames over
33
+ the top, because that is what `rename(2)` does. Taking the documentation at
34
+ its word would have made the rule unconditionally reversible and the
35
+ overwrite both unrecoverable and unremarked.
36
+
5
37
  ## 0.7.0 — 2026-09-16
6
38
 
7
39
  A pass over everything, after an audit of the desktop app, the core and the
package/README.md CHANGED
@@ -156,7 +156,15 @@ Every tool gets one of four classifications, written down in a manifest:
156
156
 
157
157
  A tool your manifest does not mention is treated as `irreversible`. That is
158
158
  deliberate: silently forwarding an unknown destructive call is the one failure
159
- worth avoiding most.
159
+ worth avoiding most. `synartesis check` names them, so you meet that decision
160
+ before your agent does.
161
+
162
+ A few calls are reversible only when nothing is in the way — moving a file onto
163
+ a free path is undone by moving it back, moving it onto an existing file
164
+ destroys what was there. For those, `expect: absent` on the pre-read swaps the
165
+ two: finding nothing is the reversible case, finding something is held for a
166
+ person and recorded with no inverse, so undo says it cannot be undone rather
167
+ than putting half of it back and calling that success.
160
168
 
161
169
  ## Has anybody touched it since?
162
170
 
@@ -1155,7 +1155,11 @@ var templateValue = z2.lazy(
1155
1155
  var callTemplate = z2.strictObject({
1156
1156
  tool: z2.string().min(1),
1157
1157
  args: z2.record(z2.string(), templateValue).default({}),
1158
- absent_when: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).optional()
1158
+ absent_when: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).optional(),
1159
+ // Only `absent` for now. `present` would mean "refuse unless something is
1160
+ // already here", which is a different feature nobody has asked for, and a
1161
+ // value with no meaning behind it is worse than one that is missing.
1162
+ expect: z2.literal("absent").optional()
1159
1163
  });
1160
1164
  var toolPolicy = z2.strictObject({
1161
1165
  match: z2.string().min(1),
@@ -1304,12 +1308,25 @@ function validate(source, manifest) {
1304
1308
  if (policy.class === "readonly" && policy.snapshot !== void 0) {
1305
1309
  source.fail([...path, "snapshot"], "a readonly tool must not declare a snapshot");
1306
1310
  }
1311
+ const expectsAbsent = policy.snapshot?.expect === "absent";
1312
+ if (policy.verify?.expect !== void 0) {
1313
+ source.fail(
1314
+ [...path, "verify", "expect"],
1315
+ "expect belongs on a snapshot; a verify read runs after the call, when there is nothing left to expect"
1316
+ );
1317
+ }
1318
+ if (expectsAbsent && policy.class !== "reversible") {
1319
+ source.fail(
1320
+ [...path, "snapshot", "expect"],
1321
+ `expect: absent says this call is reversible exactly when the read finds nothing, which only means something for a reversible tool, not a ${policy.class} one`
1322
+ );
1323
+ }
1307
1324
  if (policy.snapshot !== void 0) {
1308
1325
  checkCall(source, [...path, "snapshot"], policy.snapshot, servers, ["$."]);
1309
1326
  }
1310
1327
  if (policy.inverse !== void 0) {
1311
1328
  const allowed = ["$.", "$result."];
1312
- if (policy.snapshot !== void 0) {
1329
+ if (policy.snapshot !== void 0 && !expectsAbsent) {
1313
1330
  allowed.push("$snapshot.");
1314
1331
  }
1315
1332
  checkCall(source, [...path, "inverse"], policy.inverse, servers, allowed);
@@ -1329,7 +1346,8 @@ function withGate(policy) {
1329
1346
  args: call.args,
1330
1347
  ...call.absent_when === void 0 ? {} : {
1331
1348
  absentWhen: typeof call.absent_when === "string" ? [call.absent_when] : [...call.absent_when]
1332
- }
1349
+ },
1350
+ ...call.expect === void 0 ? {} : { expect: call.expect }
1333
1351
  });
1334
1352
  const gate = policy.gate ?? (policy.class === "irreversible" ? "always" : "never");
1335
1353
  return {
@@ -1993,4 +2011,4 @@ export {
1993
2011
  observeState,
1994
2012
  connectStdioUpstream
1995
2013
  };
1996
- //# sourceMappingURL=chunk-FXSKWMNG.js.map
2014
+ //# sourceMappingURL=chunk-O3QDPYEL.js.map
package/dist/cli.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  verifyAgainstServers,
39
39
  warnUntested,
40
40
  wasRefused
41
- } from "./chunk-FXSKWMNG.js";
41
+ } from "./chunk-O3QDPYEL.js";
42
42
  import {
43
43
  DriftConflict,
44
44
  ManifestError,
package/dist/proxy.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  verifyAgainstServers,
26
26
  warnUntested,
27
27
  withIdempotencyKey
28
- } from "./chunk-FXSKWMNG.js";
28
+ } from "./chunk-O3QDPYEL.js";
29
29
  import {
30
30
  SnapshotError,
31
31
  UpstreamError,
@@ -709,18 +709,39 @@ function createProxyServer(options) {
709
709
  let snapshot;
710
710
  let verify;
711
711
  let probe;
712
- let missingPriorState;
712
+ let noWayBack;
713
+ let foundNothing = false;
714
+ const expectsAbsent = policy.snapshot?.expect === "absent";
713
715
  if (policy.snapshot !== void 0) {
714
716
  try {
715
717
  verify = planRead(policy.snapshot, { args });
716
718
  probe = verify;
717
719
  snapshot = await runRead(router, verify, extra.signal);
718
- journal.attachSnapshot(pending.actionId, snapshot);
720
+ if (expectsAbsent) {
721
+ noWayBack = {
722
+ asked: `something is already there, so this cannot be undone \u2014 putting back what this call moves would leave nothing where the old contents were`,
723
+ recorded: `the pre-read expected nothing and found something, so this call overwrote it and no single inverse can put both back`
724
+ };
725
+ verify = void 0;
726
+ } else {
727
+ journal.attachSnapshot(pending.actionId, snapshot);
728
+ }
719
729
  } catch (error) {
720
730
  const reason = describe(error);
721
731
  if (error instanceof SnapshotError && error.absent) {
722
- missingPriorState = reason;
723
- verify = void 0;
732
+ foundNothing = true;
733
+ if (expectsAbsent) {
734
+ log?.debug(
735
+ { seq: pending.seq, tool: route.tool },
736
+ "pre-read found nothing, which is what makes this reversible"
737
+ );
738
+ } else {
739
+ noWayBack = {
740
+ asked: `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${reason}`,
741
+ recorded: `no prior state existed, so there is nothing to restore: ${reason}`
742
+ };
743
+ verify = void 0;
744
+ }
724
745
  } else {
725
746
  journal.markFailed(pending.actionId, reason);
726
747
  log?.error(
@@ -734,8 +755,8 @@ function createProxyServer(options) {
734
755
  }
735
756
  }
736
757
  }
737
- const priorState = probe === void 0 || missingPriorState !== void 0 ? { present: false } : { present: true, value: snapshot };
738
- if (missingPriorState !== void 0 && !askedAlready) {
758
+ const priorState = probe === void 0 || foundNothing ? { present: false } : { present: true, value: snapshot };
759
+ if (noWayBack !== void 0 && !askedAlready) {
739
760
  const standing = journal.findApproval({
740
761
  server: route.upstream.name,
741
762
  tool: route.tool,
@@ -743,18 +764,14 @@ function createProxyServer(options) {
743
764
  notBefore: new Date(Date.now() - APPROVAL_WINDOW_MS).toISOString()
744
765
  });
745
766
  if (standing === void 0) {
746
- await decide(
747
- `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
748
- );
767
+ await decide(noWayBack.asked);
749
768
  } else if (journal.adoptApproval(pending.actionId, standing)) {
750
769
  log?.info(
751
770
  { action: pending.actionId, by: standing.approvedBy, from: standing.runId },
752
771
  "proceeding on a standing approval"
753
772
  );
754
773
  } else {
755
- await decide(
756
- `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
757
- );
774
+ await decide(noWayBack.asked);
758
775
  }
759
776
  }
760
777
  const forwarded = {
@@ -800,13 +817,11 @@ function createProxyServer(options) {
800
817
  if (inferred !== void 0) {
801
818
  warnings.push(inferred);
802
819
  }
803
- if (missingPriorState !== void 0) {
804
- warnings.push(
805
- `no prior state existed, so there is nothing to restore: ${missingPriorState}`
806
- );
820
+ if (noWayBack !== void 0) {
821
+ warnings.push(noWayBack.recorded);
807
822
  }
808
823
  let inverse;
809
- if (policy.inverse !== void 0 && missingPriorState === void 0) {
824
+ if (policy.inverse !== void 0 && noWayBack === void 0) {
810
825
  try {
811
826
  inverse = planInverse(policy.inverse, context);
812
827
  } catch (error) {
@@ -857,7 +872,7 @@ function createProxyServer(options) {
857
872
  const recovered = recoverInverse(
858
873
  policy,
859
874
  { args, snapshot },
860
- missingPriorState !== void 0
875
+ noWayBack !== void 0
861
876
  );
862
877
  journal.markApplied(pending.actionId, {
863
878
  result: void 0,
@@ -88,16 +88,46 @@ tools:
88
88
  path: "$.path"
89
89
  content: "$snapshot.content"
90
90
 
91
- # Only reversible when the destination did not exist. Moving onto a file
92
- # that did overwrites it, and moving back afterwards restores the source and
93
- # leaves nothing where the destination's contents were: undo reports success
94
- # while the file it destroyed stays destroyed. There is no pre-read that
95
- # would tell the two cases apart -- a snapshot that finds nothing is how this
96
- # policy says "cannot be undone", which is backwards here, since finding
97
- # nothing is the safe case. So it asks.
91
+ # Reversible exactly when the destination did not exist, which is what
92
+ # `expect: absent` says.
93
+ #
94
+ # Moving onto a path where nothing is, is undone by moving the file back:
95
+ # the state this replaces is absence, and moving it off restores absence
96
+ # exactly. Moving onto a file that does exist overwrites it, and one inverse
97
+ # cannot both move the file back and restore what it landed on -- undo would
98
+ # report success over a file it had destroyed.
99
+ #
100
+ # Both cases were gated before, because the machinery read "the pre-read
101
+ # found nothing" as "there is nothing to put back" -- true of a write, and
102
+ # precisely backwards here, where finding nothing is the safe case. Measured
103
+ # both ways against the real server: as a plain reversible rule the safe move
104
+ # came back `partial` with the file still moved, and the dangerous one came
105
+ # back `rolled_back` with the overwritten file gone.
106
+ #
107
+ # And the pre-read is load-bearing, not belt and braces. This server's own
108
+ # description of move_file says "If the destination exists, the operation
109
+ # will fail" -- it does not. It renames over the top, silently, because
110
+ # that is what rename(2) does on POSIX. Taking the description at its word
111
+ # would have made this rule unconditionally reversible and the overwrite
112
+ # unrecoverable and unremarked. tests/expect-absent.test.ts pins the real
113
+ # behaviour so a version that starts matching its documentation is a
114
+ # failing test rather than a surprise.
98
115
  - match: "fs.move_file"
99
- class: irreversible
100
- gate: always
116
+ class: reversible
117
+ snapshot:
118
+ tool: "fs.read_text_file"
119
+ args:
120
+ path: "$.destination"
121
+ # Same reasoning as the two writes above: anything that is not absence
122
+ # is a failed read, not an empty destination, and a destination that
123
+ # exists but cannot be read must not be treated as a free space.
124
+ absent_when: ["ENOENT", "no such file"]
125
+ expect: absent
126
+ inverse:
127
+ tool: "fs.move_file"
128
+ args:
129
+ source: "$.destination"
130
+ destination: "$.source"
101
131
 
102
132
  # No rmdir exists on this server, so a directory once created stays.
103
133
  - match: "fs.create_directory"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,