foottrace 0.0.0-stage → 1.0.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.
Files changed (196) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -2
  3. package/dist/esm/index.d.ts +27 -0
  4. package/dist/esm/index.js +12 -0
  5. package/dist/esm/lib/capture/freeze.d.ts +91 -0
  6. package/dist/esm/lib/capture/freeze.js +313 -0
  7. package/dist/esm/lib/capture/ownData.d.ts +18 -0
  8. package/dist/esm/lib/capture/ownData.js +35 -0
  9. package/dist/esm/lib/capture/valueKinds.d.ts +57 -0
  10. package/dist/esm/lib/capture/valueKinds.js +141 -0
  11. package/dist/esm/lib/ids/runtimeStageId.d.ts +173 -0
  12. package/dist/esm/lib/ids/runtimeStageId.js +193 -0
  13. package/dist/esm/lib/memory/EventLog.d.ts +105 -0
  14. package/dist/esm/lib/memory/EventLog.js +147 -0
  15. package/dist/esm/lib/memory/RecordFrame.d.ts +232 -0
  16. package/dist/esm/lib/memory/RecordFrame.js +295 -0
  17. package/dist/esm/lib/memory/SharedMemory.d.ts +46 -0
  18. package/dist/esm/lib/memory/SharedMemory.js +87 -0
  19. package/dist/esm/lib/memory/TransactionBuffer.d.ts +521 -0
  20. package/dist/esm/lib/memory/TransactionBuffer.js +946 -0
  21. package/dist/esm/lib/memory/admission.d.ts +81 -0
  22. package/dist/esm/lib/memory/admission.js +152 -0
  23. package/dist/esm/lib/memory/backtrack.d.ts +286 -0
  24. package/dist/esm/lib/memory/backtrack.js +456 -0
  25. package/dist/esm/lib/memory/commitLogUtils.d.ts +217 -0
  26. package/dist/esm/lib/memory/commitLogUtils.js +383 -0
  27. package/dist/esm/lib/memory/deltaEncoding.d.ts +66 -0
  28. package/dist/esm/lib/memory/deltaEncoding.js +121 -0
  29. package/dist/esm/lib/memory/equality.d.ts +93 -0
  30. package/dist/esm/lib/memory/equality.js +261 -0
  31. package/dist/esm/lib/memory/eventPosition.d.ts +18 -0
  32. package/dist/esm/lib/memory/eventPosition.js +1 -0
  33. package/dist/esm/lib/memory/honesty.d.ts +93 -0
  34. package/dist/esm/lib/memory/honesty.js +91 -0
  35. package/dist/esm/lib/memory/keyPaths.d.ts +125 -0
  36. package/dist/esm/lib/memory/keyPaths.js +226 -0
  37. package/dist/esm/lib/memory/logModel.d.ts +76 -0
  38. package/dist/esm/lib/memory/logModel.js +363 -0
  39. package/dist/esm/lib/memory/merge.d.ts +26 -0
  40. package/dist/esm/lib/memory/merge.js +51 -0
  41. package/dist/esm/lib/memory/pathOps.d.ts +118 -0
  42. package/dist/esm/lib/memory/pathOps.js +292 -0
  43. package/dist/esm/lib/memory/paths.d.ts +56 -0
  44. package/dist/esm/lib/memory/paths.js +60 -0
  45. package/dist/esm/lib/memory/placeholders.d.ts +39 -0
  46. package/dist/esm/lib/memory/placeholders.js +39 -0
  47. package/dist/esm/lib/memory/recordCommit.d.ts +45 -0
  48. package/dist/esm/lib/memory/recordCommit.js +60 -0
  49. package/dist/esm/lib/memory/scrub.d.ts +52 -0
  50. package/dist/esm/lib/memory/scrub.js +72 -0
  51. package/dist/esm/lib/memory/types.d.ts +209 -0
  52. package/dist/esm/lib/memory/types.js +9 -0
  53. package/dist/esm/lib/memory/utils.d.ts +73 -0
  54. package/dist/esm/lib/memory/utils.js +150 -0
  55. package/dist/esm/lib/memory/verbs.d.ts +333 -0
  56. package/dist/esm/lib/memory/verbs.js +426 -0
  57. package/dist/esm/lib/recorder/CommitRangeIndex.d.ts +147 -0
  58. package/dist/esm/lib/recorder/CommitRangeIndex.js +206 -0
  59. package/dist/esm/lib/slice/elementProvenance.d.ts +81 -0
  60. package/dist/esm/lib/slice/elementProvenance.js +189 -0
  61. package/dist/esm/lib/slice/forwardSliceForKey.d.ts +88 -0
  62. package/dist/esm/lib/slice/forwardSliceForKey.js +242 -0
  63. package/dist/esm/lib/slice/index.d.ts +26 -0
  64. package/dist/esm/lib/slice/index.js +25 -0
  65. package/dist/esm/lib/slice/keyIndex.d.ts +137 -0
  66. package/dist/esm/lib/slice/keyIndex.js +292 -0
  67. package/dist/esm/lib/slice/keyTimeline.d.ts +49 -0
  68. package/dist/esm/lib/slice/keyTimeline.js +112 -0
  69. package/dist/esm/lib/slice/keysReadSources.d.ts +51 -0
  70. package/dist/esm/lib/slice/keysReadSources.js +94 -0
  71. package/dist/esm/lib/slice/serialize.d.ts +78 -0
  72. package/dist/esm/lib/slice/serialize.js +277 -0
  73. package/dist/esm/lib/slice/sliceForKey.d.ts +40 -0
  74. package/dist/esm/lib/slice/sliceForKey.js +78 -0
  75. package/dist/esm/lib/slice/types.d.ts +567 -0
  76. package/dist/esm/lib/slice/types.js +24 -0
  77. package/dist/esm/lib/time-travel/axis.d.ts +119 -0
  78. package/dist/esm/lib/time-travel/axis.js +178 -0
  79. package/dist/esm/lib/time-travel/bundles.d.ts +77 -0
  80. package/dist/esm/lib/time-travel/bundles.js +135 -0
  81. package/dist/esm/lib/time-travel/chain.d.ts +93 -0
  82. package/dist/esm/lib/time-travel/chain.js +187 -0
  83. package/dist/esm/lib/time-travel/commitStops.d.ts +55 -0
  84. package/dist/esm/lib/time-travel/commitStops.js +201 -0
  85. package/dist/esm/lib/time-travel/index.d.ts +22 -0
  86. package/dist/esm/lib/time-travel/index.js +20 -0
  87. package/dist/esm/lib/time-travel/stateAt.d.ts +102 -0
  88. package/dist/esm/lib/time-travel/stateAt.js +152 -0
  89. package/dist/esm/lib/time-travel/tagStops.d.ts +72 -0
  90. package/dist/esm/lib/time-travel/tagStops.js +101 -0
  91. package/dist/esm/lib/time-travel/timeTravel.d.ts +70 -0
  92. package/dist/esm/lib/time-travel/timeTravel.js +359 -0
  93. package/dist/esm/lib/time-travel/types.d.ts +470 -0
  94. package/dist/esm/lib/time-travel/types.js +22 -0
  95. package/dist/esm/package.json +1 -0
  96. package/dist/esm/paths.d.ts +4 -0
  97. package/dist/esm/paths.js +4 -0
  98. package/dist/esm/write.d.ts +49 -0
  99. package/dist/esm/write.js +47 -0
  100. package/dist/index.js +67 -0
  101. package/dist/lib/capture/freeze.js +319 -0
  102. package/dist/lib/capture/ownData.js +40 -0
  103. package/dist/lib/capture/valueKinds.js +145 -0
  104. package/dist/lib/ids/runtimeStageId.js +209 -0
  105. package/dist/lib/memory/EventLog.js +151 -0
  106. package/dist/lib/memory/RecordFrame.js +299 -0
  107. package/dist/lib/memory/SharedMemory.js +91 -0
  108. package/dist/lib/memory/TransactionBuffer.js +950 -0
  109. package/dist/lib/memory/admission.js +158 -0
  110. package/dist/lib/memory/backtrack.js +462 -0
  111. package/dist/lib/memory/commitLogUtils.js +398 -0
  112. package/dist/lib/memory/deltaEncoding.js +127 -0
  113. package/dist/lib/memory/equality.js +265 -0
  114. package/dist/lib/memory/eventPosition.js +2 -0
  115. package/dist/lib/memory/honesty.js +94 -0
  116. package/dist/lib/memory/keyPaths.js +242 -0
  117. package/dist/lib/memory/logModel.js +371 -0
  118. package/dist/lib/memory/merge.js +55 -0
  119. package/dist/lib/memory/pathOps.js +308 -0
  120. package/dist/lib/memory/paths.js +66 -0
  121. package/dist/lib/memory/placeholders.js +42 -0
  122. package/dist/lib/memory/recordCommit.js +64 -0
  123. package/dist/lib/memory/scrub.js +77 -0
  124. package/dist/lib/memory/types.js +10 -0
  125. package/dist/lib/memory/utils.js +169 -0
  126. package/dist/lib/memory/verbs.js +443 -0
  127. package/dist/lib/recorder/CommitRangeIndex.js +210 -0
  128. package/dist/lib/slice/elementProvenance.js +194 -0
  129. package/dist/lib/slice/forwardSliceForKey.js +246 -0
  130. package/dist/lib/slice/index.js +42 -0
  131. package/dist/lib/slice/keyIndex.js +312 -0
  132. package/dist/lib/slice/keyTimeline.js +116 -0
  133. package/dist/lib/slice/keysReadSources.js +100 -0
  134. package/dist/lib/slice/serialize.js +285 -0
  135. package/dist/lib/slice/sliceForKey.js +82 -0
  136. package/dist/lib/slice/types.js +25 -0
  137. package/dist/lib/time-travel/axis.js +183 -0
  138. package/dist/lib/time-travel/bundles.js +143 -0
  139. package/dist/lib/time-travel/chain.js +192 -0
  140. package/dist/lib/time-travel/commitStops.js +205 -0
  141. package/dist/lib/time-travel/index.js +31 -0
  142. package/dist/lib/time-travel/stateAt.js +159 -0
  143. package/dist/lib/time-travel/tagStops.js +105 -0
  144. package/dist/lib/time-travel/timeTravel.js +363 -0
  145. package/dist/lib/time-travel/types.js +23 -0
  146. package/dist/paths.js +14 -0
  147. package/dist/types/index.d.ts +27 -0
  148. package/dist/types/lib/capture/freeze.d.ts +91 -0
  149. package/dist/types/lib/capture/ownData.d.ts +18 -0
  150. package/dist/types/lib/capture/valueKinds.d.ts +57 -0
  151. package/dist/types/lib/ids/runtimeStageId.d.ts +173 -0
  152. package/dist/types/lib/memory/EventLog.d.ts +105 -0
  153. package/dist/types/lib/memory/RecordFrame.d.ts +232 -0
  154. package/dist/types/lib/memory/SharedMemory.d.ts +46 -0
  155. package/dist/types/lib/memory/TransactionBuffer.d.ts +521 -0
  156. package/dist/types/lib/memory/admission.d.ts +81 -0
  157. package/dist/types/lib/memory/backtrack.d.ts +286 -0
  158. package/dist/types/lib/memory/commitLogUtils.d.ts +217 -0
  159. package/dist/types/lib/memory/deltaEncoding.d.ts +66 -0
  160. package/dist/types/lib/memory/equality.d.ts +93 -0
  161. package/dist/types/lib/memory/eventPosition.d.ts +18 -0
  162. package/dist/types/lib/memory/honesty.d.ts +93 -0
  163. package/dist/types/lib/memory/keyPaths.d.ts +125 -0
  164. package/dist/types/lib/memory/logModel.d.ts +76 -0
  165. package/dist/types/lib/memory/merge.d.ts +26 -0
  166. package/dist/types/lib/memory/pathOps.d.ts +118 -0
  167. package/dist/types/lib/memory/paths.d.ts +56 -0
  168. package/dist/types/lib/memory/placeholders.d.ts +39 -0
  169. package/dist/types/lib/memory/recordCommit.d.ts +45 -0
  170. package/dist/types/lib/memory/scrub.d.ts +52 -0
  171. package/dist/types/lib/memory/types.d.ts +209 -0
  172. package/dist/types/lib/memory/utils.d.ts +73 -0
  173. package/dist/types/lib/memory/verbs.d.ts +333 -0
  174. package/dist/types/lib/recorder/CommitRangeIndex.d.ts +147 -0
  175. package/dist/types/lib/slice/elementProvenance.d.ts +81 -0
  176. package/dist/types/lib/slice/forwardSliceForKey.d.ts +88 -0
  177. package/dist/types/lib/slice/index.d.ts +26 -0
  178. package/dist/types/lib/slice/keyIndex.d.ts +137 -0
  179. package/dist/types/lib/slice/keyTimeline.d.ts +49 -0
  180. package/dist/types/lib/slice/keysReadSources.d.ts +51 -0
  181. package/dist/types/lib/slice/serialize.d.ts +78 -0
  182. package/dist/types/lib/slice/sliceForKey.d.ts +40 -0
  183. package/dist/types/lib/slice/types.d.ts +567 -0
  184. package/dist/types/lib/time-travel/axis.d.ts +119 -0
  185. package/dist/types/lib/time-travel/bundles.d.ts +77 -0
  186. package/dist/types/lib/time-travel/chain.d.ts +93 -0
  187. package/dist/types/lib/time-travel/commitStops.d.ts +55 -0
  188. package/dist/types/lib/time-travel/index.d.ts +22 -0
  189. package/dist/types/lib/time-travel/stateAt.d.ts +102 -0
  190. package/dist/types/lib/time-travel/tagStops.d.ts +72 -0
  191. package/dist/types/lib/time-travel/timeTravel.d.ts +70 -0
  192. package/dist/types/lib/time-travel/types.d.ts +470 -0
  193. package/dist/types/paths.d.ts +4 -0
  194. package/dist/types/write.d.ts +49 -0
  195. package/dist/write.js +56 -0
  196. package/package.json +48 -4
@@ -0,0 +1,950 @@
1
+ "use strict";
2
+ /**
3
+ * TransactionBuffer — Per-stage STAGING buffer for state mutations
4
+ *
5
+ * What it IS: a staging buffer with read-your-writes and net-change commits.
6
+ * - Changes are staged here during stage execution and flushed to
7
+ * SharedMemory in ONE batch per stage (`commit()`) — other stages and
8
+ * parallel siblings never observe a stage's half-finished writes.
9
+ * - Read-after-write consistency within a stage — a stage sees its own
10
+ * staged writes immediately.
11
+ * - `commit()` records the stage's NET change (see {@link commit}), plus an
12
+ * operation trace for deterministic replay.
13
+ *
14
+ * What it is NOT: a rollback mechanism. Despite the name, there is no
15
+ * abort/rollback path — when a stage THROWS, the engine still commits
16
+ * everything staged so far before re-throwing (commit-on-error in
17
+ * `FlowchartTraverser`). That is deliberate: the audit trail must record
18
+ * what the failing stage changed. Do not rely on "stage failed → its
19
+ * writes vanished".
20
+ *
21
+ * The patch trees hold REFERENCES until commit (9.23.0). A `set` stores the
22
+ * caller's own value in `overwritePatch` exactly as it always did in
23
+ * `workingCopy`; the copy the record needs is taken ONCE per surviving path
24
+ * when the payload leaves the buffer (`toChangeOnlyPayload` /
25
+ * `toDeltaPayload` — every value they emit passes through `structuredClone`
26
+ * there). The law "the record never aliases a caller's object" is kept at
27
+ * the commit boundary instead of at every write, which is what makes N
28
+ * writes to one path O(N) instead of O(N × size). Consequence, by design: a
29
+ * caller who mutates its own object AFTER the write and BEFORE the stage
30
+ * ends commits the value as the stage read it back — the record agrees with
31
+ * the stage (CLAUDE.md landmine 3's first bite, closed). The one place the
32
+ * engine itself edits THROUGH a staged value — a later nested op under a
33
+ * `set` path — detaches that value first ({@link detachHeldAncestors}), so
34
+ * `workingCopy` and `overwritePatch` never share a container the engine
35
+ * mutates on one side only.
36
+ *
37
+ * COPY-ON-WRITE (9.29.0 — docs/design/2026-10-copy-on-write-commit.md). The
38
+ * buffer no longer clones the committed state it starts from. `baseSnapshot`
39
+ * IS the committed generation the stage first touched (held by reference —
40
+ * committed state is immutable-after-swap), and `workingCopy` starts as a
41
+ * copy of its ROOT only: each write copies the containers on its own path
42
+ * ({@link ownSpine}), and the first READ after the stage's first write of a
43
+ * container still shared with committed state takes a private deep copy of
44
+ * it ({@link privatise}) — what the whole-state clone used to give every
45
+ * read, so an in-place edit of a read value stays private exactly as it did.
46
+ * A read the working copy cannot answer is served from live state by the
47
+ * caller, as before; the buffer then replaces that path of its diff base
48
+ * with a private copy ({@link detachBase}), as the clone had it.
49
+ *
50
+ * THE ADMITTED RECORD (9.30.0 — docs/design/2026-10-admitted-record.md). A
51
+ * commit is admitted only if its bundle folds back to the stage's
52
+ * read-your-writes view at every path the stage touched (and at every
53
+ * container its writes created on the way there, below the stage's address).
54
+ * Both encoders commit the value the stage read: a compact row — a `merge`
55
+ * delta, an `append` tail — is kept where it provably folds back; a family of
56
+ * rows that does not is recorded as `set` rows of the values the stage read
57
+ * ({@link admit}). Before 9.30.0 the one accumulated merge delta per path was
58
+ * replayed at every `merge` row of that path, which lied across a hard write
59
+ * (`$update(k,{x}); k = {y}; $update(k,{z})` committed `{y, x, z}` for a
60
+ * read-back of `{y, z}`), an `[]` clear, a kind change or an array union
61
+ * deduplicated by reference. Verified when the stage staged a `merge` or a
62
+ * nested op; a stage that staged only `set` / `delete` of keys directly under
63
+ * its address is coherent by construction — both trees receive the same
64
+ * write — and is admitted without the fold.
65
+ */
66
+ Object.defineProperty(exports, "__esModule", { value: true });
67
+ exports.TransactionBuffer = void 0;
68
+ const ownData_js_1 = require("../capture/ownData.js");
69
+ const admission_js_1 = require("./admission.js");
70
+ const deltaEncoding_js_1 = require("./deltaEncoding.js");
71
+ const keyPaths_js_1 = require("./keyPaths.js");
72
+ const pathOps_js_1 = require("./pathOps.js");
73
+ const utils_js_1 = require("./utils.js");
74
+ class TransactionBuffer {
75
+ /**
76
+ * The net-change diff base: the committed generation the stage first
77
+ * touched, by reference — except where {@link detachBase} replaced a path
78
+ * of it with a private copy (its root and the containers on the way are
79
+ * then copies too, owned by {@link baseOwned}).
80
+ */
81
+ baseSnapshot;
82
+ workingCopy;
83
+ overwritePatch = {};
84
+ updatePatch = {};
85
+ opTrace = [];
86
+ redactedPaths = new Set();
87
+ /**
88
+ * Paths whose `overwritePatch` value is the caller's own object (9.23.0):
89
+ * a `set` of a container stores the reference, and this is the ledger of
90
+ * which ones are still shared with the caller (and with `workingCopy`).
91
+ * Consulted only by the engine's nested ops — see
92
+ * {@link detachHeldAncestors}; the commit payload clones every surviving
93
+ * value anyway and never asks. Cleared with the rest at `commit()`.
94
+ */
95
+ heldRefs = new Set();
96
+ /** Commit-value encoding policy (#13c-B). `'full'` = historical bytes. */
97
+ commitValues;
98
+ /** Per-write read-provenance source (#P1). When set (the
99
+ * `writeProvenance: 'reads-prefix'` dial), every staged op snapshots the
100
+ * keys tracked-read so far — the temporal-prefix attribution consumed by
101
+ * causal slicing. Undefined (default) = zero cost, byte-identical ops. */
102
+ readKeysProvider;
103
+ /**
104
+ * The containers in `workingCopy` this buffer CREATED: its root copy, the
105
+ * path copies its writes made, the merge results it built, and every
106
+ * container of the private copies its reads took. A write edits these in
107
+ * place. Everything else in `workingCopy` is SHARED — with the committed
108
+ * generation the stage first touched, or with a value the stage staged —
109
+ * and is copied before a write passes through it ({@link ownSpine}).
110
+ */
111
+ owned = new WeakSet();
112
+ /**
113
+ * Container values the stage staged with {@link set} — its own objects,
114
+ * held by reference (9.23.0). A read hands them back as they are, exactly
115
+ * as the private working copy always did, and never privatises them: a
116
+ * private copy of a staged value would leave `workingCopy` and
117
+ * `overwritePatch` disagreeing about a value the stage wrote.
118
+ */
119
+ staged = new WeakSet();
120
+ /**
121
+ * Owned containers with no container still shared with committed state
122
+ * anywhere below them (a private deep copy, a merge result built on a
123
+ * private base, a path copy whose shared children were privatised). A read
124
+ * that lands on one needs no walk — see {@link privatiseBelow}.
125
+ */
126
+ privateTrees = new WeakSet();
127
+ /**
128
+ * The containers of {@link baseSnapshot} that {@link detachBase} created:
129
+ * its root copy and the copies on the way to a detached path. Allocated
130
+ * only by the first detach — the in-contract stage never pays for it.
131
+ */
132
+ baseOwned;
133
+ /**
134
+ * The private deep copies {@link detachBase} put into the diff base: nothing
135
+ * at or below one is committed state, so a later detach inside it is a no-op.
136
+ */
137
+ detachedBase;
138
+ /**
139
+ * Where the stage writes (9.30.0): a path prefix its frame computed and
140
+ * hands over as data (C2 — for the engine, the frame's run namespace, or
141
+ * `[]` for the root). Its containers are the stage's ADDRESS, not a value
142
+ * the stage reads (every read it makes is a key below it), so a shell a
143
+ * write leaves there is no part of the read-back the record must hold.
144
+ */
145
+ address;
146
+ /** `merge` ops staged since the last commit — see {@link admit}. */
147
+ stagedMerges = 0;
148
+ /** Ops staged deeper than one key below the stage's address — see {@link admit}. */
149
+ nestedOps = 0;
150
+ constructor(base, commitValues = 'full', readKeysProvider, address = []) {
151
+ // `base` is the committed generation the stage first touched —
152
+ // immutable-after-swap, the invariant the first-touch view already rests
153
+ // on — so the diff base is held by REFERENCE, and the working copy starts
154
+ // as a copy of its ROOT only. Before 9.29.0 these two lines were two
155
+ // `structuredClone`s of the whole state, paid by every stage that wrote.
156
+ this.baseSnapshot = base;
157
+ this.workingCopy = (0, pathOps_js_1.ownedRootOf)(base, this.owned);
158
+ this.commitValues = commitValues;
159
+ this.readKeysProvider = readKeysProvider;
160
+ this.address = address;
161
+ }
162
+ /** Stamp the current read prefix onto a staged op — only when the
163
+ * provenance dial is on (provider present), so the default path allocates
164
+ * nothing and commit bundles stay byte-identical. */
165
+ stampReadKeys(op) {
166
+ if (this.readKeysProvider)
167
+ op.readKeys = this.readKeysProvider();
168
+ return op;
169
+ }
170
+ /** Stage an op into `opTrace`, counting what decides whether {@link admit} must fold. */
171
+ stage(path, key, verb) {
172
+ if (verb === 'merge')
173
+ this.stagedMerges++;
174
+ if (path.length - (0, admission_js_1.addressDepthOf)(path, this.address) > 1)
175
+ this.nestedOps++;
176
+ this.opTrace.push(this.stampReadKeys({ path: key, verb }));
177
+ }
178
+ /**
179
+ * Hard overwrite at the specified path. Stores the REFERENCE in both trees
180
+ * (9.23.0) — the record's copy is taken once, at commit; see the header.
181
+ */
182
+ set(path, value, shouldRedact = false) {
183
+ const key = (0, utils_js_1.normalisePath)(path);
184
+ this.detachHeldAncestors(path);
185
+ (0, pathOps_js_1.ownSpine)(this.workingCopy, path, this.owned);
186
+ (0, pathOps_js_1.nativeSet)(this.workingCopy, path, value);
187
+ (0, pathOps_js_1.nativeSet)(this.overwritePatch, path, value);
188
+ if ((0, pathOps_js_1.isContainer)(value)) {
189
+ this.heldRefs.add(key);
190
+ this.staged.add(value);
191
+ }
192
+ else {
193
+ this.heldRefs.delete(key);
194
+ }
195
+ if (shouldRedact) {
196
+ this.redactedPaths.add(key);
197
+ }
198
+ this.stage(path, key, 'set');
199
+ }
200
+ /**
201
+ * A nested op is about to write THROUGH a path whose `overwritePatch`
202
+ * value is a held reference (9.23.0). Today's law is that the two trees
203
+ * receive the same nested `set`/`delete` mutations but ONLY `workingCopy`
204
+ * receives a nested `merge`'s result (`updatePatch` gets the delta) — so a
205
+ * container shared between the trees would leak the merged value into the
206
+ * overwrite payload, and a shared container is also the caller's own
207
+ * object, which the engine must not edit on the record's behalf. Replacing
208
+ * the held ancestor with its clone in `overwritePatch` restores exactly
209
+ * the private tree the per-write clone used to give it, paid once per
210
+ * ancestor and only on the nested-op path the public surface never takes
211
+ * (the scope proxy writes ROOT keys). `workingCopy` keeps the reference,
212
+ * as it always did.
213
+ */
214
+ detachHeldAncestors(path) {
215
+ if (this.heldRefs.size === 0)
216
+ return;
217
+ for (let i = 1; i < path.length; i++) {
218
+ const ancestor = path.slice(0, i);
219
+ const key = (0, utils_js_1.normalisePath)(ancestor);
220
+ if (!this.heldRefs.has(key))
221
+ continue;
222
+ const held = (0, pathOps_js_1.nativeGet)(this.overwritePatch, ancestor);
223
+ if (held !== null && typeof held === 'object')
224
+ (0, pathOps_js_1.nativeSet)(this.overwritePatch, ancestor, structuredClone(held));
225
+ this.heldRefs.delete(key);
226
+ }
227
+ }
228
+ /**
229
+ * Explicit key deletion at the specified path (#13c-B; absorbs backlog B8).
230
+ *
231
+ * Stages EXACTLY the same buffer mutations as `set(path, undefined)` —
232
+ * `workingCopy`/`overwritePatch` get an own `undefined` at the path (the
233
+ * historical flattening, preserving read behavior and the dedup diff base
234
+ * across modes) — but records the op verb as `'delete'`. At commit:
235
+ * `'full'` mode maps it back to a `'set'` trace entry (byte-identical to
236
+ * today); `'delta'` mode emits a real `'delete'` entry whose replay
237
+ * REMOVES the key instead of leaving `key: undefined` behind.
238
+ *
239
+ * The net-change filter reads that staged own `undefined` as ABSENT
240
+ * (`deepEqual`, 9.19.1) — so under `'delta'`, whose committed state really
241
+ * lacks the key, deleting an absent key inside a container the stage also
242
+ * re-wrote is the no-op it is, not a `set` of the whole container.
243
+ */
244
+ delete(path, shouldRedact = false) {
245
+ const key = (0, utils_js_1.normalisePath)(path);
246
+ this.detachHeldAncestors(path);
247
+ (0, pathOps_js_1.ownSpine)(this.workingCopy, path, this.owned);
248
+ (0, pathOps_js_1.nativeSet)(this.workingCopy, path, undefined);
249
+ (0, pathOps_js_1.nativeSet)(this.overwritePatch, path, undefined);
250
+ this.heldRefs.delete(key);
251
+ if (shouldRedact) {
252
+ this.redactedPaths.add(key);
253
+ }
254
+ this.stage(path, key, 'delete');
255
+ }
256
+ /** Deep union merge at the specified path. `deepSmartMerge` builds fresh
257
+ * containers, so neither tree ever holds the merge INPUT by reference;
258
+ * the nested-op detach guards the ancestor it writes INTO.
259
+ *
260
+ * The value merged INTO is made private first (`privatise`), as the
261
+ * whole-state clone made it before 9.29.0. Not only for the reads that
262
+ * follow: `deepSmartMerge` unions arrays BY REFERENCE, so whether an
263
+ * element the stage passes in is "already there" depends on which objects
264
+ * the base holds — a committed array would dedup an element the stage
265
+ * read from it before its first write, where the private copy (and
266
+ * 9.28.0) appends it. */
267
+ merge(path, value, shouldRedact = false) {
268
+ const key = (0, utils_js_1.normalisePath)(path);
269
+ this.detachHeldAncestors(path);
270
+ this.privatise(path);
271
+ const existing = (0, pathOps_js_1.nativeGet)(this.workingCopy, path) ?? {};
272
+ const merged = (0, utils_js_1.deepSmartMerge)(existing, value);
273
+ (0, pathOps_js_1.ownSpine)(this.workingCopy, path, this.owned);
274
+ (0, pathOps_js_1.own)(merged, this.owned);
275
+ if ((0, pathOps_js_1.isContainer)(merged))
276
+ this.privateTrees.add(merged);
277
+ (0, pathOps_js_1.nativeSet)(this.workingCopy, path, merged);
278
+ (0, pathOps_js_1.nativeSet)(this.updatePatch, path, (0, utils_js_1.deepSmartMerge)((0, pathOps_js_1.nativeGet)(this.updatePatch, path) ?? {}, value));
279
+ if (shouldRedact) {
280
+ this.redactedPaths.add(key);
281
+ }
282
+ this.stage(path, key, 'merge');
283
+ }
284
+ /**
285
+ * Field-level redaction (9.19.0): mark dot-paths INSIDE the value staged at
286
+ * `path` as secret, so `scrubPatch` scrubs them in the commit log and the
287
+ * mirror the same way a whole-key redaction is scrubbed. A field is
288
+ * registered as a literal key AND, when dotted, as the nested path it
289
+ * names — whichever exists in the patch is the one `scrubPatch` finds.
290
+ * Marked paths survive commit only while the op path they sit under does
291
+ * (see {@link survivingRedactedPaths}).
292
+ */
293
+ markRedactedFields(path, fields) {
294
+ for (const field of fields) {
295
+ this.redactedPaths.add((0, utils_js_1.normalisePath)([...path, field]));
296
+ if (field.includes('.'))
297
+ this.redactedPaths.add((0, utils_js_1.normalisePath)([...path, ...field.split('.')]));
298
+ }
299
+ }
300
+ /**
301
+ * Does a redaction mark address an array ELEMENT right below `path`
302
+ * (`list.1.token`)? An `append` row re-bases the indices onto the tail, so
303
+ * such a path takes the whole-value `set` (`deltaEncoding · pushValueRow`).
304
+ * A mark by NAME below an array (`obj.y`) means the same thing on the tail
305
+ * as on the whole value, so the compact row stays (byte-identical).
306
+ */
307
+ markedBelow(path) {
308
+ const prefix = path + utils_js_1.DELIM;
309
+ for (const marked of this.redactedPaths) {
310
+ if (!marked.startsWith(prefix))
311
+ continue;
312
+ const next = marked.slice(prefix.length).split(utils_js_1.DELIM)[0];
313
+ if (/^(0|[1-9]\d*)$/.test(next))
314
+ return true;
315
+ }
316
+ return false;
317
+ }
318
+ /**
319
+ * The redacted paths that survive the net-change filter: a path survives
320
+ * when it IS a surviving op path (a whole-key redaction) or sits UNDER one
321
+ * (a field inside a surviving write). A dropped op takes its fields with it.
322
+ */
323
+ survivingRedactedPaths(survivingPaths) {
324
+ const out = new Set();
325
+ for (const path of this.redactedPaths) {
326
+ if (survivingPaths.has(path)) {
327
+ out.add(path);
328
+ continue;
329
+ }
330
+ for (const op of survivingPaths) {
331
+ if (path.startsWith(op + utils_js_1.DELIM)) {
332
+ out.add(path);
333
+ break;
334
+ }
335
+ }
336
+ }
337
+ return out;
338
+ }
339
+ /**
340
+ * Read current value at path (includes uncommitted changes). The value is
341
+ * the stage's own — private to it, as the whole-state clone made every read
342
+ * after the first write before 9.29.0 (`privatise`).
343
+ */
344
+ get(path, defaultValue) {
345
+ this.privatise(path);
346
+ return (0, pathOps_js_1.nativeGet)(this.workingCopy, path, defaultValue);
347
+ }
348
+ /**
349
+ * Read the working copy at `path` WITHOUT taking a private copy — for a
350
+ * reader that only compares (the dev-mode borrowed-mutation report in
351
+ * `StageContext.commit`), never for a value handed to a stage.
352
+ */
353
+ peek(path) {
354
+ return (0, pathOps_js_1.nativeGet)(this.workingCopy, path);
355
+ }
356
+ /**
357
+ * Keep the diff base exact under a read the working copy cannot answer.
358
+ *
359
+ * When `workingCopy` holds nothing at `path` — the stage deleted or unset
360
+ * it, or a write replaced a container above it — `RecordFrame · read`
361
+ * serves the read from LIVE committed state, exactly as 9.28.0
362
+ * did, so the value it hands out is committed state itself. On 9.28.0 the
363
+ * diff base was a whole-state clone taken at the stage's first write, and
364
+ * an in-place edit of that value (out of contract — reads are borrowed)
365
+ * could not move it: written back, the edit was recorded. Here the diff
366
+ * base IS a committed generation, and it very often holds that same
367
+ * container at `path` — the edit would move the base with it, and the
368
+ * write-back would commit no change. So before the value goes out, the
369
+ * base's value at `path` is replaced by a private deep copy (the base's
370
+ * root and the containers on the way copied shallowly) — what 9.28.0's
371
+ * base held there. The commit's net-change test compares against it and
372
+ * the delta encoder replays from it; the value handed out stays the live
373
+ * one, so whatever else an edit of it reaches (live state, the next
374
+ * generation) behaves as on 9.28.0 too.
375
+ *
376
+ * Not `privatise`: a private copy handed to the stage would also keep the
377
+ * edit out of live state, where 9.28.0 let it in — a run-namespaced read
378
+ * that falls back to a global key would then end differently (the
379
+ * differential's NESTED family fails within 100 programs that way).
380
+ *
381
+ * Copies nothing when the base holds no container at `path`, and nothing
382
+ * twice: a path at or below an earlier detach is already private. O(path)
383
+ * otherwise, plus O(value) once per detached path.
384
+ */
385
+ detachBase(path) {
386
+ if (path.length === 0 || !(0, pathOps_js_1.isContainer)(this.baseSnapshot))
387
+ return;
388
+ let at = this.baseSnapshot;
389
+ for (const k of path) {
390
+ if (this.detachedBase?.has(at))
391
+ return;
392
+ const child = (0, pathOps_js_1.ownChild)(at, k);
393
+ if (!(0, pathOps_js_1.isContainer)(child))
394
+ return;
395
+ at = child;
396
+ }
397
+ if (this.detachedBase?.has(at))
398
+ return;
399
+ const owned = (this.baseOwned ??= new WeakSet());
400
+ if (!owned.has(this.baseSnapshot))
401
+ this.baseSnapshot = (0, pathOps_js_1.ownedRootOf)(this.baseSnapshot, owned);
402
+ (0, pathOps_js_1.ownSpine)(this.baseSnapshot, path, owned);
403
+ const copy = structuredClone(at);
404
+ (this.detachedBase ??= new WeakSet()).add(copy);
405
+ (0, pathOps_js_1.nativeSet)(this.baseSnapshot, path, copy);
406
+ }
407
+ /**
408
+ * Private reads (9.29.0): make the value at `path` the stage's own before
409
+ * a read hands it out — so a read after the stage's first write behaves
410
+ * exactly as it did when the buffer began with a deep clone of the whole
411
+ * state. An in-place edit of what the read returned (out of contract —
412
+ * reads are borrowed) stays inside the buffer, and committed state is never
413
+ * touched. Whether the RECORD keeps it is the admitted record's law
414
+ * (9.30.0, owner ruling R1 — the record keeps what the stage read back): it
415
+ * is kept when the edited value lies at or below a path the stage wrote in
416
+ * this stage — set, merged or written back alike — and lost otherwise.
417
+ *
418
+ * Walking the path from the (owned) root:
419
+ * - a container still SHARED with committed state at the same position is
420
+ * replaced by a private deep copy when it is the value read, or by a
421
+ * shallow copy when it is a container on the way to it (so a read of
422
+ * `k` under a fork child's address copies `k`, not every child's namespace);
423
+ * - a container the stage STAGED (or anything else the buffer did not
424
+ * take from committed state) is handed back by reference, as before;
425
+ * - a container the buffer owns is walked on — and when it is the value
426
+ * read, its still-shared descendants are privatised
427
+ * ({@link privatiseBelow}).
428
+ *
429
+ * Cost: O(path) per read, plus O(value) ONCE per shared container a stage
430
+ * reads after its first write. A stage that never reads after writing pays
431
+ * nothing; a read before the first write never reaches the buffer.
432
+ */
433
+ privatise(path) {
434
+ if (path.length === 0)
435
+ return;
436
+ // Resolve first: a read that lands on nothing (a missing key, a refused
437
+ // segment, a primitive) returns nothing and must copy nothing.
438
+ const chain = [];
439
+ let at = this.workingCopy;
440
+ for (const k of path) {
441
+ at = (0, pathOps_js_1.ownChild)(at, k);
442
+ if (!(0, pathOps_js_1.isContainer)(at))
443
+ return;
444
+ chain.push(at);
445
+ }
446
+ let parent = this.workingCopy;
447
+ let base = this.baseSnapshot;
448
+ for (let i = 0; i < path.length; i++) {
449
+ const k = path[i];
450
+ const atBase = (0, pathOps_js_1.ownChild)(base, k);
451
+ let value = chain[i];
452
+ if (!this.owned.has(value)) {
453
+ if (value !== atBase || this.staged.has(value))
454
+ return;
455
+ if (i === path.length - 1) {
456
+ (0, ownData_js_1.putOwn)(parent, k, this.privateCopyOf(value));
457
+ return;
458
+ }
459
+ value = (0, pathOps_js_1.shallowCopy)(value);
460
+ this.owned.add(value);
461
+ (0, ownData_js_1.putOwn)(parent, k, value);
462
+ }
463
+ parent = value;
464
+ base = atBase;
465
+ }
466
+ this.privatiseBelow(parent, base);
467
+ }
468
+ /**
469
+ * An OWNED container is about to be read whole (a path copy a nested write
470
+ * made, say): its descendants still shared with committed state are
471
+ * replaced by private deep copies, so nothing the read hands out is
472
+ * committed state. Walks owned containers only; marks each one finished
473
+ * ({@link privateTrees}), so a container is scanned once per stage.
474
+ */
475
+ privatiseBelow(container, base) {
476
+ const work = [[container, base]];
477
+ while (work.length > 0) {
478
+ const [node, nodeBase] = work.pop();
479
+ if (this.privateTrees.has(node))
480
+ continue;
481
+ for (const key of Object.keys(node)) {
482
+ const child = (0, pathOps_js_1.ownChild)(node, key);
483
+ if (!(0, pathOps_js_1.isContainer)(child))
484
+ continue;
485
+ const atBase = (0, pathOps_js_1.ownChild)(nodeBase, key);
486
+ if (this.owned.has(child))
487
+ work.push([child, atBase]);
488
+ else if (child === atBase && !this.staged.has(child)) {
489
+ (0, ownData_js_1.putOwn)(node, key, this.privateCopyOf(child));
490
+ }
491
+ }
492
+ this.privateTrees.add(node);
493
+ }
494
+ }
495
+ /** A private deep copy of a committed container — owned through and through. */
496
+ privateCopyOf(value) {
497
+ const copy = structuredClone(value);
498
+ (0, pathOps_js_1.adopt)(copy, this.owned);
499
+ this.privateTrees.add(copy);
500
+ return copy;
501
+ }
502
+ /**
503
+ * Did any staged op touch `path`, or a path above or below it?
504
+ *
505
+ * The dev-mode borrowed-read check (`StageContext.commit`) asks this before
506
+ * accusing a stage of mutating a value in place: a key the stage legitimately
507
+ * WROTE is expected to differ from what it read, and writes reach the buffer
508
+ * from paths that bypass user-level write tracking too (the subflow seed,
509
+ * `outputMapper` merge-back, the resume re-seed). Dev-mode only — nothing on
510
+ * the hot path calls it.
511
+ */
512
+ wasStaged(path) {
513
+ const target = (0, utils_js_1.normalisePath)(path);
514
+ for (const op of this.opTrace) {
515
+ // On the path, below it or above it — the one path relation every key query asks.
516
+ if ((0, keyPaths_js_1.relation)(op.path, target) !== undefined)
517
+ return true;
518
+ }
519
+ return false;
520
+ }
521
+ /**
522
+ * Flush all staged mutations and return the commit bundle — recording the
523
+ * stage's NET CHANGE, not its raw write log.
524
+ *
525
+ * ── WHY (the defect this fixes) ─────────────────────────────────────────
526
+ * Previously every `set`/`merge` was recorded verbatim, so the commit bundle
527
+ * was a log of *operations* rather than *changes*. Two operations produce no
528
+ * net change yet were still committed as "mutations":
529
+ *
530
+ * 1. No-op write — writing a key the value it already holds (e.g. an
531
+ * agent context slot re-emitting identical content every
532
+ * turn). base K=1, stage writes K=1.
533
+ * 2. Write-revert — changing then restoring a key within one stage.
534
+ * base K=1, stage writes K=2 then K=1.
535
+ *
536
+ * Recording these as mutations (a) bloated causal slicing / backtracking with
537
+ * spurious dependencies on intermediate values that never reach final state,
538
+ * and (b) made downstream "what changed here?" consumers light up stages that
539
+ * changed nothing — most visibly the lens highlight flagging every slot.
540
+ *
541
+ * ── HOW ─────────────────────────────────────────────────────────────────
542
+ * At commit we hold BOTH `baseSnapshot` (state when the stage began) and
543
+ * `workingCopy` (state after all its writes). For each path the stage touched
544
+ * we keep it in the bundle ONLY if its final value differs from the base
545
+ * value ({@link deepEqual}). No-op AND write-revert paths drop out, because
546
+ * both compare equal to base. This is a single net-delta diff at commit time
547
+ * — one deep compare per touched path, O(changed state), paid once per stage
548
+ * (NOT per write). A naive per-write deep-equal skip would be more expensive
549
+ * and would still miss write-revert (the intermediate write differs from the
550
+ * value present at the moment of writing).
551
+ *
552
+ * ── TWO HONEST TIERS (by design — do not "unify" them) ──────────────────
553
+ * • commit (here) = CHANGE-level — truthful net delta. Feeds the commit
554
+ * log, causal chain, narrative, and the lens highlight.
555
+ * • `onWrite` event = OP-level — fires on EVERY write attempt regardless of
556
+ * net change. Feeds metrics / behavioural observability
557
+ * (a debugger wants to see "wrote 2, then reverted").
558
+ * `onWrite` is unchanged by this method; only the COMMIT becomes change-only.
559
+ *
560
+ * ── EMPTY COMMITS ARE INTENTIONAL ───────────────────────────────────────
561
+ * A stage that nets no change commits an EMPTY patch — NOT nothing.
562
+ * {@link StageContext.commit} still records the bundle unconditionally, so
563
+ * every executed stage remains a time-travel cursor stop (its `runtimeStageId`
564
+ * marker is preserved); only its PATCH is empty. This is what keeps the
565
+ * commit-indexed slider stable while making the highlight truthful.
566
+ *
567
+ * ── KNOWN LIMITATIONS / FUTURE ──────────────────────────────────────────
568
+ * • Explicit key DELETION under the default 'full' mode is still
569
+ * flattened to set-of-`undefined` (a removed key cannot be expressed
570
+ * in MemoryPatch alone). CLOSED under `commitValues: 'delta'` (#13c-B):
571
+ * {@link delete} stages a distinct op and the bundle carries a real
572
+ * `delete` trace verb whose replay removes the key.
573
+ * • Array-merge dedup in {@link deepSmartMerge} still uses reference equality
574
+ * (`new Set`), so deep-equal *objects* in a merged array are not deduped.
575
+ * Orthogonal to this change; tracked separately.
576
+ *
577
+ * Resets the buffer to empty state after commit.
578
+ */
579
+ commit() {
580
+ const rows = this.commitValues === 'delta' ? this.toDeltaPayload() : this.toChangeOnlyPayload();
581
+ const redactedPaths = this.survivingRedactedPaths(new Set(rows.trace.map((t) => t.path)));
582
+ // The bundle's key order is `recordCommit`'s; a direct caller keeps this same order.
583
+ const payload = { overwrite: rows.overwrite, updates: rows.updates, redactedPaths, trace: rows.trace };
584
+ this.overwritePatch = {};
585
+ this.updatePatch = {};
586
+ this.opTrace.length = 0;
587
+ this.redactedPaths.clear();
588
+ this.heldRefs.clear();
589
+ this.workingCopy = {};
590
+ this.owned = new WeakSet();
591
+ this.owned.add(this.workingCopy);
592
+ this.staged = new WeakSet();
593
+ this.privateTrees = new WeakSet();
594
+ this.stagedMerges = 0;
595
+ this.nestedOps = 0;
596
+ return payload;
597
+ }
598
+ /**
599
+ * Rebuild overwrite / updates / trace keeping ONLY paths whose final value
600
+ * differs from the base value — i.e. the stage's net change. See
601
+ * {@link TransactionBuffer.commit} for the rationale.
602
+ *
603
+ * Paths are compared at the exact granularity they were written (each trace
604
+ * entry's path), against `workingCopy` (final) vs `baseSnapshot` (start).
605
+ * Surviving `set` paths copy their final value from `overwritePatch`;
606
+ * surviving `merge` paths copy their accumulated delta from `updatePatch`,
607
+ * keeping the set-vs-merge verb — and the result is ADMITTED ({@link admit}):
608
+ * a family of rows whose replay does not give back what the stage read is
609
+ * recorded as `set` rows of the read-back instead (9.30.0).
610
+ *
611
+ * This is the DEFAULT (`commitValues: 'full'`) payload, including the
612
+ * historical flattening of staged `delete` ops into `set`-of-`undefined`
613
+ * trace entries. The delta encoding lives in {@link toDeltaPayload}.
614
+ *
615
+ * Work, not bytes (9.22.1): the net-change verdict is paid once per PATH
616
+ * and the clone once per CONSECUTIVE run of ops on a path, not once per op
617
+ * — see the two memos inside. Pinned by
618
+ * test/lib/memory/scenario/repeated-path-byte-identity.test.ts against the
619
+ * 9.22.0 bytes. Since 9.23.0 that clone is THE clone: the patch trees hold
620
+ * the caller's references (see the class header), so this loop is the one
621
+ * place the record is detached from them.
622
+ */
623
+ toChangeOnlyPayload() {
624
+ return this.admit((lossy) => this.changeOnlyRows(lossy));
625
+ }
626
+ /** The 'full' rows; a family in `lossy` is written as its read-back, at its last touch ({@link emitReadBack}). */
627
+ changeOnlyRows(lossy) {
628
+ const rows = { overwrite: {}, updates: {}, trace: [] };
629
+ const { overwrite, updates, trace } = rows;
630
+ // Per PATH, decided once (9.22.1): base and final value are fixed at
631
+ // commit, so every later op on a path gets the verdict its first op got.
632
+ // `undefined` = not yet decided; the surviving paths are the `true` keys.
633
+ const survives = new Map();
634
+ // The path the LAST surviving op copied into each patch tree (9.22.1).
635
+ // The value at a path is fixed at commit, so an op whose predecessor on
636
+ // the SAME tree already copied that path would write the same bytes over
637
+ // the same key — it keeps its trace row (the log is byte-identical) and
638
+ // pays no second clone. The 9.22.0 element-write funnel stages N
639
+ // whole-array sets on ONE path; this is what makes that commit O(N)
640
+ // instead of O(N × ops). CONSECUTIVE only — an op on another path in
641
+ // between may have coerced or grown this one's container (a descendant
642
+ // `nativeSet` through a primitive, a `key: undefined` shell) and the
643
+ // re-copy is what repairs it; a `merge` between two sets touches the
644
+ // other tree and does not count. Same law as `supersededByNextSet`.
645
+ let lastSetPath;
646
+ let lastMergePath;
647
+ for (let i = 0; i < this.opTrace.length; i++) {
648
+ const op = this.opTrace[i];
649
+ const family = lossy?.families.get(lossy.rootOf.get(op.path));
650
+ if (family !== undefined) {
651
+ if (i === family.slot) {
652
+ this.emitReadBack(family, rows, 'full');
653
+ lastSetPath = lastMergePath = undefined;
654
+ }
655
+ continue;
656
+ }
657
+ let keep = survives.get(op.path);
658
+ if (keep === undefined) {
659
+ keep = this.changedSinceBase(op.path.split(utils_js_1.DELIM));
660
+ survives.set(op.path, keep);
661
+ }
662
+ if (!keep)
663
+ continue; // no-op or write-then-revert → no net change
664
+ trace.push(flattenedTraceRow(op));
665
+ if (op.verb === 'merge') {
666
+ if (lastMergePath === op.path)
667
+ continue;
668
+ lastMergePath = op.path;
669
+ const segments = op.path.split(utils_js_1.DELIM);
670
+ (0, pathOps_js_1.nativeSet)(updates, segments, structuredClone((0, pathOps_js_1.nativeGet)(this.updatePatch, segments)));
671
+ }
672
+ else {
673
+ if (lastSetPath === op.path)
674
+ continue;
675
+ lastSetPath = op.path;
676
+ const segments = op.path.split(utils_js_1.DELIM);
677
+ (0, pathOps_js_1.nativeSet)(overwrite, segments, structuredClone((0, pathOps_js_1.nativeGet)(this.overwritePatch, segments)));
678
+ }
679
+ }
680
+ return rows;
681
+ }
682
+ /**
683
+ * Delta-encoded payload (`commitValues: 'delta'`, #13c-B) — same net-change
684
+ * filter as {@link toChangeOnlyPayload}, three encoding differences:
685
+ *
686
+ * 1. **One trace entry per surviving path** (the §2.5 dedup rule — `append`
687
+ * is NOT idempotent on replay, so duplicate entries would multiply
688
+ * tails). The verb is resolved from the path's op mix + base→final
689
+ * relationship; entries are ordered by each path's LAST touch,
690
+ * preserving last-writer-wins for nested/overlapping paths.
691
+ * 2. **Verb resolution per path** — for a path with NO surviving relative
692
+ * (see 3):
693
+ * - last op `'delete'` AND final value gone → `delete` (the path stays
694
+ * enumerated in `overwrite` with `undefined` for key-set consumers).
695
+ * Requires the parent to be a real container in the base — replay uses
696
+ * `nativeDelete`, which is inert on a primitive/absent parent, whereas
697
+ * the `'full'` flattening (`_set` of `undefined`) COERCES that parent
698
+ * into an object; when they would disagree we keep the flattening;
699
+ * - ONLY `'merge'` ops → `merge` with the accumulated `updatePatch`
700
+ * delta;
701
+ * - otherwise (`set`/mixed): the value the stage's 'full' rows give the
702
+ * path ({@link fullRowsValue}). If base and that value are arrays and
703
+ * base is a STRICT PREFIX → `append` storing only the tail; else `set`
704
+ * storing the full value.
705
+ * 3. **OVERLAP FAMILIES take the full-value fallback.** `overwrite` /
706
+ * `updates` are nested path TREES, so two surviving paths where one is
707
+ * an ancestor of the other (`a` and `a.p`) share storage. Delta's
708
+ * per-path encodings would clobber each other there (an `append`
709
+ * ancestor stores only a TAIL, a `delete` ancestor `undefined`), so every
710
+ * path that has a surviving ancestor/descendant is committed as a plain
711
+ * `set` of the value the 'full' rows give it, and the family's
712
+ * shallowest path (whose set covers the whole subtree) is emitted LAST.
713
+ *
714
+ * The result is ADMITTED ({@link admit}) like the 'full' payload: a compact
715
+ * row is kept only where the bundle provably folds back, and a family that
716
+ * does not is recorded as what the stage read. So both encodings commit the
717
+ * value the stage read; before 9.30.0 they shared a replay that did not
718
+ * (`replayPathVerbs` / `replayFamilyVerbs`, deleted).
719
+ */
720
+ toDeltaPayload() {
721
+ const fullValueAt = this.fullRowsValue();
722
+ return this.admit((lossy) => this.deltaRows(lossy, fullValueAt));
723
+ }
724
+ /** The delta rows; a family in `lossy` is written as its read-back, at its last touch ({@link emitReadBack}). */
725
+ deltaRows(lossy, fullValueAt) {
726
+ const rows = { overwrite: {}, updates: {}, trace: [] };
727
+ const { overwrite, updates, trace } = rows;
728
+ const inLossy = (path) => lossy?.families.has(lossy.rootOf.get(path)) === true;
729
+ const survivors = this.netChangeSurvivors(this.opsByPath()).filter((s) => !inLossy(s.path));
730
+ const { rootOf, byRoot } = (0, deltaEncoding_js_1.groupIntoFamilies)(survivors, new Set(survivors.map((s) => s.path)));
731
+ // THE VERB CHOICE — the delta encoder picks a verb per path; the VALUES
732
+ // it encodes come from the one replay (`fullValueAt`), never a replica of
733
+ // the verb law (9.30.0: CLAUDE.md's verb-switch replicas lost two here).
734
+ const emit = (s) => {
735
+ const { path, segments, verbs, before } = s;
736
+ const prov = provenance(s.readKeys);
737
+ if (byRoot.get(rootOf.get(path)).length > 1) {
738
+ // Rule 3 — one coherent tree: every member a set of its value.
739
+ trace.push({ path, verb: 'set', ...prov });
740
+ (0, pathOps_js_1.nativeSet)(overwrite, segments, structuredClone(fullValueAt(segments)));
741
+ return;
742
+ }
743
+ const lastVerb = verbs[verbs.length - 1];
744
+ if (lastVerb === 'delete' && s.after === undefined && this.deleteReplaysAsFlattening(segments)) {
745
+ // Real deletion — replay removes the key. Keep the path enumerated
746
+ // in `overwrite` (undefined) so Object.keys consumers see it.
747
+ trace.push({ path, verb: 'delete', ...prov });
748
+ (0, pathOps_js_1.nativeSet)(overwrite, segments, undefined);
749
+ }
750
+ else if (verbs.every((v) => v === 'merge')) {
751
+ trace.push({ path, verb: 'merge', ...prov });
752
+ (0, pathOps_js_1.nativeSet)(updates, segments, structuredClone((0, pathOps_js_1.nativeGet)(this.updatePatch, segments)));
753
+ }
754
+ else {
755
+ (0, deltaEncoding_js_1.pushValueRow)(rows, path, segments, before, fullValueAt(segments), prov, this.markedBelow(path));
756
+ }
757
+ };
758
+ // A family that did not fold back takes its place by its last touch.
759
+ const pending = lossy === undefined ? [] : [...lossy.families.values()].sort((a, b) => a.slot - b.slot);
760
+ let next = 0;
761
+ const flushBefore = (touch) => {
762
+ while (next < pending.length && pending[next].slot < touch)
763
+ this.emitReadBack(pending[next++], rows, 'delta');
764
+ };
765
+ (0, deltaEncoding_js_1.emitInFamilyOrder)(survivors, rootOf, byRoot, emit, flushBefore);
766
+ flushBefore(Infinity);
767
+ return rows;
768
+ }
769
+ // ── The admitted record (9.30.0) ───────────────────────────────────────
770
+ /**
771
+ * THE ADMISSION — a commit is admitted only if its bundle folds back to the
772
+ * stage's read-your-writes view at every path the stage touched. `build`
773
+ * lays the rows out (one encoder: `'full'` or `'delta'`); the candidate is
774
+ * checked ({@link lossyFamilies}), and when some family's rows do not fold
775
+ * back the rows are laid out again with those families written as what the
776
+ * stage read ({@link emitReadBack}) — every other family's bytes exactly as
777
+ * the first layout made them.
778
+ */
779
+ admit(build) {
780
+ const candidate = build(undefined);
781
+ const lossy = this.lossyFamilies(candidate);
782
+ return lossy === undefined ? candidate : build(lossy);
783
+ }
784
+ /**
785
+ * The families whose rows do not fold back to what the stage read —
786
+ * `undefined` when there are none (`admission.ts · lossyFamilies`: the
787
+ * candidate replayed onto the diff base by `dryFold`, every family of
788
+ * touched paths compared with the working copy at its root and on the way
789
+ * down to it).
790
+ *
791
+ * Without a fold, when the stage staged no `merge` and no nested op: its
792
+ * rows are `set` / `delete` of keys directly under its address, each
793
+ * writing the SAME value into `workingCopy` and `overwritePatch` — the
794
+ * record is the read-back by construction (the property's ROOT-ONLY arm
795
+ * pins this). That keeps the commit of the typed scope's root-key writes
796
+ * exactly as cheap as it was.
797
+ */
798
+ lossyFamilies(candidate) {
799
+ if (this.stagedMerges === 0 && this.nestedOps === 0)
800
+ return undefined;
801
+ return (0, admission_js_1.lossyFamilies)(candidate, this.baseSnapshot, this.workingCopy, this.opTrace, this.address);
802
+ }
803
+ /**
804
+ * A family whose rows did not fold back, written as what the stage read:
805
+ * one `set` row per member that changed (descendants by last touch, each
806
+ * with its own read prefix), the ROOT last, and the root's read-back value
807
+ * in `overwrite` — one coherent tree, so no row can clobber another. The
808
+ * root's row is written even when the root itself did not change: it is the
809
+ * row that carries the family's read-back (and the containers L-1's dropped
810
+ * op left). A lone root under `'delta'` keeps the compact `append` when the
811
+ * value it read is its base plus a tail.
812
+ */
813
+ emitReadBack(family, rows, encoding) {
814
+ let root = family.members[0];
815
+ let changed = 0;
816
+ for (const m of family.members) {
817
+ if (m.path === family.root) {
818
+ root = m;
819
+ continue;
820
+ }
821
+ if (!this.changedSinceBase(m.path.split(utils_js_1.DELIM)))
822
+ continue;
823
+ rows.trace.push({ path: m.path, verb: 'set', ...provenance(m.readKeys) });
824
+ changed++;
825
+ }
826
+ const after = (0, pathOps_js_1.nativeGet)(this.workingCopy, family.rootSegments);
827
+ if (encoding === 'delta' && changed === 0 && after !== undefined) {
828
+ const before = (0, pathOps_js_1.nativeGet)(this.baseSnapshot, family.rootSegments);
829
+ (0, deltaEncoding_js_1.pushValueRow)(rows, family.root, family.rootSegments, before, after, provenance(root.readKeys), this.markedBelow(family.root));
830
+ return;
831
+ }
832
+ rows.trace.push({ path: family.root, verb: 'set', ...provenance(root.readKeys) });
833
+ // The clone the check took (`admission.ts · foldsBack`), when it took one.
834
+ (0, pathOps_js_1.nativeSet)(rows.overwrite, family.rootSegments, family.held !== undefined ? family.held.value : structuredClone(after));
835
+ }
836
+ /**
837
+ * The value the stage's 'full' rows give a path — what the delta encoder
838
+ * commits for a family member or a path with a `set` — read from ONE
839
+ * {@link dryFold} of those rows over the patch trees themselves, taken the
840
+ * first time it is asked. By reference, as the stage held them: an element
841
+ * a `set` and a `merge` of one path share stays one element. A stage that
842
+ * staged no `merge` and no nested op needs no fold — a path's value is its
843
+ * last staged value. (Before 9.30.0: two replicas of the verb law,
844
+ * `replayPathVerbs` and `replayFamilyVerbs`.)
845
+ */
846
+ fullRowsValue() {
847
+ if (this.stagedMerges === 0 && this.nestedOps === 0)
848
+ return (segments) => (0, pathOps_js_1.nativeGet)(this.overwritePatch, segments);
849
+ let fold;
850
+ return (segments) => {
851
+ fold ??= (0, utils_js_1.dryFold)(this.baseSnapshot, this.updatePatch, this.overwritePatch, this.fullRows());
852
+ return (0, pathOps_js_1.nativeGet)(fold, segments);
853
+ };
854
+ }
855
+ /** The stage's 'full' rows: every op on a path the net-change filter keeps, a `delete` spelled `set`. */
856
+ fullRows() {
857
+ const keep = new Map();
858
+ const rows = [];
859
+ for (const op of this.opTrace) {
860
+ let k = keep.get(op.path);
861
+ if (k === undefined)
862
+ keep.set(op.path, (k = this.changedSinceBase(op.path.split(utils_js_1.DELIM))));
863
+ if (k)
864
+ rows.push({ path: op.path, verb: op.verb === 'merge' ? 'merge' : 'set' });
865
+ }
866
+ return rows;
867
+ }
868
+ /**
869
+ * Did the stage change the value at `segments`? Base and final value are
870
+ * fixed at commit, so this is the ONE net-change verdict both payload
871
+ * encodings apply (a no-op write and a write-then-revert both fail it).
872
+ */
873
+ changedSinceBase(segments) {
874
+ return !(0, utils_js_1.deepEqual)((0, pathOps_js_1.nativeGet)(this.baseSnapshot, segments), (0, pathOps_js_1.nativeGet)(this.workingCopy, segments));
875
+ }
876
+ /**
877
+ * Path → its op-verb sequence, ordered by LAST touch (delete + re-insert
878
+ * moves a re-touched path to the end of the Map's insertion order —
879
+ * preserving last-writer-wins for nested/overlapping paths). Per-write
880
+ * provenance (#P1): the LAST op's readKeys is kept — read prefixes only grow
881
+ * within a stage, so last == union across the path.
882
+ */
883
+ opsByPath() {
884
+ const byPath = new Map();
885
+ for (let i = 0; i < this.opTrace.length; i++) {
886
+ const op = this.opTrace[i];
887
+ const prev = byPath.get(op.path);
888
+ if (prev) {
889
+ prev.verbs.push(op.verb);
890
+ prev.last = i;
891
+ if (op.readKeys !== undefined)
892
+ prev.readKeys = op.readKeys;
893
+ byPath.delete(op.path);
894
+ byPath.set(op.path, prev);
895
+ }
896
+ else {
897
+ byPath.set(op.path, {
898
+ verbs: [op.verb],
899
+ last: i,
900
+ ...(op.readKeys !== undefined && { readKeys: op.readKeys }),
901
+ });
902
+ }
903
+ }
904
+ return byPath;
905
+ }
906
+ /** The net-change filter — identical to 'full' ({@link changedSinceBase}). Survivors keep last-touch order. */
907
+ netChangeSurvivors(byPath) {
908
+ const survivors = [];
909
+ for (const [path, { verbs, readKeys, last }] of byPath) {
910
+ const segments = path.split(utils_js_1.DELIM);
911
+ if (!this.changedSinceBase(segments))
912
+ continue; // no-op or write-then-revert → no net change
913
+ const before = (0, pathOps_js_1.nativeGet)(this.baseSnapshot, segments);
914
+ const after = (0, pathOps_js_1.nativeGet)(this.workingCopy, segments);
915
+ survivors.push({ path, segments, verbs, readKeys, last, before, after });
916
+ }
917
+ return survivors;
918
+ }
919
+ /**
920
+ * May a staged `delete` at this path commit as the `'delete'` verb?
921
+ *
922
+ * Only when removing the key and the `'full'` mode's flattening (`_set` of
923
+ * `undefined`) land in the same place. They diverge when the parent is not
924
+ * a container: `nativeDelete` walks away untouched, while `nativeSet`
925
+ * COERCES the primitive parent into an object to hold the key. Committing
926
+ * `delete` there would silently drop a state change the stage really made,
927
+ * so those (pathological) deletes keep the historical flattening.
928
+ */
929
+ deleteReplaysAsFlattening(segments) {
930
+ if (segments.length === 1)
931
+ return true; // parent is the state root — always a container
932
+ const parent = (0, pathOps_js_1.nativeGet)(this.baseSnapshot, segments.slice(0, -1));
933
+ return parent !== null && typeof parent === 'object';
934
+ }
935
+ }
936
+ exports.TransactionBuffer = TransactionBuffer;
937
+ /**
938
+ * Historical flattening for the `'full'` payload: an explicit delete commits
939
+ * as set-of-undefined. Per-write provenance (#P1) rides each surviving entry
940
+ * untouched.
941
+ */
942
+ function flattenedTraceRow(op) {
943
+ return op.verb === 'delete'
944
+ ? { path: op.path, verb: 'set', ...(op.readKeys !== undefined && { readKeys: op.readKeys }) }
945
+ : op;
946
+ }
947
+ /** A row's `readKeys` fragment — absent unless the provenance dial recorded one (#P1). */
948
+ function provenance(readKeys) {
949
+ return readKeys !== undefined ? { readKeys } : undefined;
950
+ }