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