@smartmemory/compose 0.3.7 → 0.3.8

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 (215) hide show
  1. package/.compose-deps.json +1 -13
  2. package/README.md +72 -5
  3. package/bin/compose.js +470 -351
  4. package/bin/judgment-migrate.js +387 -0
  5. package/contracts/comp-obs-contract.schema.json +9 -3
  6. package/contracts/fluid-record.schema.json +209 -0
  7. package/contracts/lifecycle-backfill.schema.json +322 -0
  8. package/dist/assets/App-Z4MU-H_F.js +916 -0
  9. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  10. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  13. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  15. package/dist/assets/channel-SnZzzh7k.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  26. package/dist/assets/clone-DgklGjHm.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  37. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  38. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  39. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  40. package/dist/assets/index-yyrA5OZd.css +1 -0
  41. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  44. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  45. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  46. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  47. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  48. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  49. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  50. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  52. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  53. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  54. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  55. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  56. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  57. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  58. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  59. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  60. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  61. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  62. package/dist/index.html +3 -3
  63. package/lib/agent-string.js +7 -5
  64. package/lib/append-integrity.js +81 -0
  65. package/lib/backfill-evidence.js +109 -0
  66. package/lib/bug-escalation.js +9 -0
  67. package/lib/build-stream-schema.js +3 -1
  68. package/lib/build-stream-writer.js +25 -0
  69. package/lib/build.js +874 -170
  70. package/lib/canon-guard.js +28 -6
  71. package/lib/canon-override.js +196 -0
  72. package/lib/canon-registry.js +104 -0
  73. package/lib/cli-commands.js +144 -0
  74. package/lib/codex-preflight.js +26 -13
  75. package/lib/colleague/context.js +215 -0
  76. package/lib/colleague/writeback.js +95 -0
  77. package/lib/completion-gate.js +1421 -0
  78. package/lib/completion-writer.js +47 -47
  79. package/lib/consumer-fanout.js +105 -11
  80. package/lib/coverage-gate.js +200 -0
  81. package/lib/dir-lock.js +170 -0
  82. package/lib/dispatch-ledger.js +3 -3
  83. package/lib/feature-json.js +1 -1
  84. package/lib/feature-reconciler.js +8 -0
  85. package/lib/feature-validator.js +64 -1
  86. package/lib/feature-writer.js +57 -2
  87. package/lib/fluid/factory.js +167 -0
  88. package/lib/fluid/ideabox-dates.js +73 -0
  89. package/lib/fluid/ideabox-migrate.js +154 -0
  90. package/lib/fluid/ideabox-ops.js +585 -0
  91. package/lib/fluid/ideabox-view.js +146 -0
  92. package/lib/fluid/import-ideabox.js +186 -0
  93. package/lib/fluid/local-provider.js +606 -0
  94. package/lib/fluid/provider.js +684 -0
  95. package/lib/fluid/record-shape.js +214 -0
  96. package/lib/fluid/record-store.js +328 -0
  97. package/lib/fluid/render-ideabox.js +261 -0
  98. package/lib/fluid/schema.js +40 -0
  99. package/lib/fluid/smartmemory-provider.js +1695 -0
  100. package/lib/gsd.js +63 -23
  101. package/lib/guard-cli.js +175 -0
  102. package/lib/guard-custody.js +141 -0
  103. package/lib/guard-descriptors.js +530 -0
  104. package/lib/guard-enrol.js +254 -0
  105. package/lib/health-score.js +1 -1
  106. package/lib/ideabox-cli.js +315 -0
  107. package/lib/ideabox.js +121 -21
  108. package/lib/judgment/store/index.js +9 -1
  109. package/lib/judgment/store/records.js +1 -1
  110. package/lib/judgment/trace.js +380 -0
  111. package/lib/judgment-decision-write.js +277 -0
  112. package/lib/judgment-decisions.js +466 -0
  113. package/lib/judgment-gen.js +5 -1
  114. package/lib/judgment-writer.js +56 -2
  115. package/lib/lifecycle-modes.js +4 -4
  116. package/lib/lineage.js +400 -0
  117. package/lib/local-claude-connector.js +52 -1
  118. package/lib/maya-client.js +302 -0
  119. package/lib/maya-config.js +53 -0
  120. package/lib/maya-identity.js +283 -0
  121. package/lib/migrate-anon.js +5 -0
  122. package/lib/migrate-roadmap.js +15 -0
  123. package/lib/new.js +13 -1
  124. package/lib/pipeline-compat.js +104 -0
  125. package/lib/policy-catalog.js +295 -0
  126. package/lib/policy-check.js +0 -0
  127. package/lib/process-termination.js +98 -0
  128. package/lib/resolve-workspace.js +5 -1
  129. package/lib/result-normalizer.js +396 -199
  130. package/lib/roadmap-errors.js +65 -0
  131. package/lib/roadmap-preservers.js +24 -4
  132. package/lib/roadmap-residue.js +299 -0
  133. package/lib/smartmemory-client.js +614 -78
  134. package/lib/smartmemory-config.js +54 -0
  135. package/lib/smartmemory-ingest.js +19 -2
  136. package/lib/step-prompt.js +7 -6
  137. package/lib/stratum-engine.js +53 -4
  138. package/lib/stratum-mcp-client.js +271 -36
  139. package/lib/test-bootstrap.js +31 -0
  140. package/lib/tool-inventory.js +122 -0
  141. package/lib/version-check.js +91 -19
  142. package/lib/vision-writer.js +88 -1
  143. package/package.json +7 -6
  144. package/pipelines/bug-fix.stratum.yaml +205 -211
  145. package/pipelines/build-quick.profiles.json +12 -0
  146. package/pipelines/build-quick.stratum.yaml +263 -350
  147. package/pipelines/content.stratum.yaml +81 -77
  148. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  149. package/pipelines/plan.stratum.yaml +76 -86
  150. package/pipelines/refactor.stratum.yaml +125 -125
  151. package/pipelines/research.stratum.yaml +56 -58
  152. package/pipelines/review-fix.profiles.json +6 -0
  153. package/pipelines/review-fix.stratum.yaml +110 -83
  154. package/presets/team-feature.profiles.json +6 -0
  155. package/presets/team-feature.stratum.yaml +93 -66
  156. package/presets/team-research.profiles.json +6 -0
  157. package/presets/team-research.stratum.yaml +89 -80
  158. package/presets/team-review.profiles.json +8 -0
  159. package/presets/team-review.stratum.yaml +98 -80
  160. package/scripts/cost-census.mjs +70 -0
  161. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  162. package/server/agent-health.js +22 -0
  163. package/server/agent-hooks.js +14 -1
  164. package/server/agent-server.js +5 -248
  165. package/server/agent-spawn.js +3 -4
  166. package/server/agent-workspace.js +294 -0
  167. package/server/build-routes.js +6 -5
  168. package/server/build-stream-bridge.js +53 -0
  169. package/server/cc-session-watcher.js +4 -1
  170. package/server/coalescing-buffer.js +7 -1
  171. package/server/completion-projection.js +228 -0
  172. package/server/compose-mcp-tools.js +109 -23
  173. package/server/compose-mcp.js +88 -882
  174. package/server/decision-event-emit.js +41 -2
  175. package/server/decision-event-id.js +17 -0
  176. package/server/decision-events-snapshot.js +3 -0
  177. package/server/design-routes.js +14 -8
  178. package/server/feature-scan.js +76 -2
  179. package/server/file-watcher.js +170 -21
  180. package/server/ideabox-routes.js +166 -224
  181. package/server/index.js +70 -100
  182. package/server/lifecycle-guard.js +240 -10
  183. package/server/lifecycle-phase-history.js +276 -0
  184. package/server/maya-routes.js +507 -0
  185. package/server/mcp-tool-defs.js +940 -0
  186. package/server/mcp-tool-policy.js +34 -2
  187. package/server/model-tiers.js +22 -5
  188. package/server/pipeline-routes.js +21 -11
  189. package/server/project-root.js +58 -19
  190. package/server/remote-utils.js +3 -1
  191. package/server/schema-validator.js +7 -1
  192. package/server/session-manager.js +5 -6
  193. package/server/session-routes.js +3 -1
  194. package/server/stratum-client.js +57 -10
  195. package/server/stratum-sync.js +6 -3
  196. package/server/summarizer.js +3 -4
  197. package/server/supervisor.js +0 -1
  198. package/server/vision-routes.js +208 -98
  199. package/server/vision-server.js +86 -23
  200. package/server/vision-store.js +60 -6
  201. package/server/vision-utils.js +3 -4
  202. package/server/workspace-activity.js +18 -0
  203. package/server/workspace-middleware.js +2 -2
  204. package/server/workspace-runtime.js +243 -0
  205. package/server/worktree-gc.js +1 -0
  206. package/dist/assets/App-PkZzHeMj.js +0 -894
  207. package/dist/assets/channel-qVK_qn4E.js +0 -1
  208. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  209. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  210. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  211. package/dist/assets/index-LIwREYgH.css +0 -1
  212. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  213. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  214. package/lib/staleness.js +0 -87
  215. package/server/ideabox-cache.js +0 -77
@@ -11,8 +11,24 @@
11
11
  * instant as the new `timestamp`. The previous entry's `exitedAt` is closed out
12
12
  * to the new entry's `enteredAt` (legacy semantic: a phase exits when its
13
13
  * successor begins).
14
+ *
15
+ * COMP-LIFECYCLE-BACKFILL (blueprint §4.1): the history is BITEMPORAL. `enteredAt`
16
+ * / `exitedAt` are VALID time (when the phase was actually in force) and
17
+ * `recordedAt` is TRANSACTION time (when we learned it). A live walk has the two
18
+ * equal, which is why `appendPhaseHistory` stamps `recordedAt: timestamp`,
19
+ * `origin: 'live'` and `confidence: 1.0` on every NEW write. Nothing migrates:
20
+ * records written before this feature carry none of the three, and every reader
21
+ * goes through `normaliseOrigin` so absence reads as 'live' in exactly one place.
14
22
  */
15
23
 
24
+ import { transitionsOf, terminalOf } from '../lib/lifecycle-modes.js';
25
+ import { buildPhaseGraph } from './lifecycle-guard.js';
26
+
27
+ /** One definition of "what does a missing `origin` mean", shared by every READER. */
28
+ export function normaliseOrigin(entry) {
29
+ return entry?.origin ?? 'live';
30
+ }
31
+
16
32
  /**
17
33
  * Append one phase transition entry to item.lifecycle.phaseHistory and close
18
34
  * out the prior entry's `exitedAt`.
@@ -40,5 +56,265 @@ export function appendPhaseHistory(item, { from, to, outcome, timestamp }) {
40
56
  to,
41
57
  outcome: outcome ?? null,
42
58
  timestamp,
59
+ // Bitemporal provenance (COMP-LIFECYCLE-BACKFILL). A live walk learns the
60
+ // transition at the instant it happens, so transaction time == valid time.
61
+ recordedAt: timestamp,
62
+ origin: 'live',
63
+ confidence: 1.0,
64
+ });
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // COMP-LIFECYCLE-BACKFILL §4.1 — the valid-time merge
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const UNIT_SEPARATOR = '\u001f';
72
+
73
+ /** BP-8: every ordering comparison is numeric on epoch ms, never on the string. */
74
+ const ms = (x) => Date.parse(x);
75
+
76
+ const clone = (x) => JSON.parse(JSON.stringify(x));
77
+
78
+ /** phase + UNIT SEPARATOR + evidence ref. Never a NUL byte — it breaks grep. */
79
+ export function occurrenceKey(o) {
80
+ return `${o.phase}${UNIT_SEPARATOR}${o.evidence?.ref ?? ''}`;
81
+ }
82
+
83
+ /** The nodes a lifecycle may actually WALK in this mode. */
84
+ function walkableNodes(fwdGraph) {
85
+ const nodes = new Set();
86
+ for (const [from, tos] of Object.entries(fwdGraph)) {
87
+ nodes.add(from);
88
+ for (const t of tos || []) nodes.add(t);
89
+ }
90
+ return nodes;
91
+ }
92
+
93
+ function reachable(graph, from, to) {
94
+ if (from === to) return true;
95
+ const seen = new Set([from]);
96
+ const queue = [from];
97
+ while (queue.length) {
98
+ const cur = queue.shift();
99
+ for (const next of graph[cur] || []) {
100
+ if (next === to) return true;
101
+ if (seen.has(next)) continue;
102
+ seen.add(next);
103
+ queue.push(next);
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+
109
+ const IMMUTABLE_FIELDS = (x) => ({
110
+ phase: x.phase,
111
+ enteredAt: x.enteredAt,
112
+ timestamp: x.timestamp,
113
+ recordedAt: x.recordedAt,
114
+ outcome: x.outcome,
115
+ origin: x.origin,
116
+ evidenceKind: x.evidence?.kind ?? null,
117
+ evidenceRef: x.evidence?.ref ?? null,
118
+ observedEpochMs: x.evidence?.observedEpochMs ?? null,
119
+ });
120
+
121
+ const CLAIM_FIELDS = (x) => ({
122
+ phase: x.phase,
123
+ kind: x.evidence?.kind ?? null,
124
+ ref: x.evidence?.ref ?? null,
125
+ // A STORED entry carries the epoch on its evidence; an incoming one does too.
126
+ observedEpochMs: x.evidence?.observedEpochMs ?? null,
127
+ });
128
+
129
+ const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
130
+
131
+ const refuse = (...reasons) => ({ ok: false, reasons: reasons.flat() });
132
+
133
+ /**
134
+ * Merge backfilled occurrences into a phase history by VALID TIME.
135
+ *
136
+ * PURE (BP-7): `item` is never mutated and no input object is ever aliased into
137
+ * the result. R2B-5: the comparison baseline is an INDEPENDENT deep clone taken
138
+ * before anything is rewritten.
139
+ *
140
+ * @param {object} item vision item, read-only
141
+ * @param {object[]} incoming already materialised occurrences (§5.5)
142
+ * @returns {{ok:true, history:Array, written:string[], skipped:string[]}
143
+ * | {ok:false, reasons:string[]}}
144
+ */
145
+ export function insertBackfilledPhases(item, incoming) {
146
+ const lc = item.lifecycle;
147
+ const mode = lc.mode ?? 'build';
148
+ const fwdGraph = transitionsOf(mode);
149
+ const fullGraph = buildPhaseGraph(mode);
150
+ // R2B-1: fwdGraph is what a lifecycle may WALK; fullGraph is what the guard
151
+ // ACCEPTS, and only fullGraph contains complete_backfilled.
152
+ const walkable = walkableNodes(fwdGraph);
153
+ const adapterTerminals = new Set(terminalOf(mode).filter((t) => !walkable.has(t)));
154
+
155
+ const startedMs = ms(lc.startedAt);
156
+ if (!Number.isFinite(startedMs)) {
157
+ return refuse('lifecycle.startedAt is not a parseable instant');
158
+ }
159
+ for (const o of incoming) {
160
+ if (!Number.isFinite(o.evidence?.observedEpochMs)) {
161
+ return refuse(`unparseable evidence time for ${occurrenceKey(o)}`);
162
+ }
163
+ }
164
+ for (const e of lc.phaseHistory ?? []) {
165
+ if (!Number.isFinite(ms(e.enteredAt))) {
166
+ return refuse('stored occurrence has an unparseable enteredAt');
167
+ }
168
+ }
169
+
170
+ // BP-7 / R2B-5: temp ids WE assign, on CLONES, plus a SEPARATE immutable
171
+ // snapshot cloned independently so step 4d and the marker test both read
172
+ // pre-rewrite values.
173
+ const existing = (lc.phaseHistory ?? []).map((e, seq) => ({ ...clone(e), _tid: `x${seq}`, _seq: seq }));
174
+ const snapshot = new Map(existing.map((e) => [e._tid, clone(e)]));
175
+
176
+ // MARKER IDENTITY IS COMPUTED NOW, off the snapshot, before any rewrite.
177
+ const markerTids = new Set(
178
+ existing
179
+ .filter((e) => e._seq === 0
180
+ && normaliseOrigin(e) !== 'backfill'
181
+ && snapshot.get(e._tid).from == null
182
+ && !walkable.has(e.phase))
183
+ .map((e) => e._tid),
184
+ );
185
+
186
+ const skipped = [];
187
+
188
+ // ---- STEP 3a FIRST OF ALL (R3-5): dedup by operation_id.
189
+ const byOpId = new Map(
190
+ existing.filter((e) => e.operation_id != null).map((e) => [e.operation_id, e]),
191
+ );
192
+ const remaining = [];
193
+ for (const o of incoming) {
194
+ if (o.operation_id == null) { remaining.push(o); continue; }
195
+ const prior = byOpId.get(o.operation_id);
196
+ if (prior === undefined) { remaining.push(o); continue; }
197
+ // ALREADY PERSISTED BY THIS OPERATION. A stored entry sharing our id but
198
+ // differing in any immutable field is corruption, not idempotence.
199
+ if (!eq(IMMUTABLE_FIELDS(prior), IMMUTABLE_FIELDS(o))) {
200
+ return refuse(
201
+ `an entry under operation ${o.operation_id} is already stored with different `
202
+ + 'immutable fields — refusing to overwrite it',
203
+ );
204
+ }
205
+ skipped.push(occurrenceKey(o));
206
+ }
207
+
208
+ // ---- STEP 3b: dedup by CLAIM, for everything with no operation id.
209
+ const byKey = new Map(
210
+ existing.filter((e) => normaliseOrigin(e) === 'backfill').map((e) => [occurrenceKey(e), e]),
211
+ );
212
+ const batch = [];
213
+ for (const o of remaining) {
214
+ const prior = byKey.get(occurrenceKey(o));
215
+ if (prior === undefined) {
216
+ batch.push({ ...clone(o), _tid: `n${batch.length}`, _seq: Number.POSITIVE_INFINITY });
217
+ continue;
218
+ }
219
+ if (eq(CLAIM_FIELDS(prior), CLAIM_FIELDS(o))) { skipped.push(occurrenceKey(o)); continue; }
220
+ return refuse(`occurrence already recorded with other evidence: ${occurrenceKey(o)}`);
221
+ }
222
+ if (batch.length === 0) {
223
+ // Pure no-op: the persisted history is returned untouched.
224
+ return { ok: true, history: lc.phaseHistory ?? [], written: [], skipped };
225
+ }
226
+
227
+ // ---- STEP 4 refusals. Whole batch, nothing persisted.
228
+
229
+ // 4a. TIE — only an INCOMING backfilled occurrence triggers it.
230
+ const instants = [
231
+ ...existing.map((e) => ms(e.enteredAt)),
232
+ ...batch.map((o) => o.evidence.observedEpochMs),
233
+ ];
234
+ for (const o of batch) {
235
+ const at = o.evidence.observedEpochMs;
236
+ if (instants.filter((x) => x === at).length > 1) {
237
+ return refuse(`two phases cannot start at the same instant — cite distinct evidence for ${occurrenceKey(o)}`);
238
+ }
239
+ }
240
+
241
+ // 4b. ADOPTION INSTANT — anchored to lc.startedAt, not to any occurrence.
242
+ for (const o of batch) {
243
+ if (o.evidence.observedEpochMs === startedMs) {
244
+ return refuse('backfilled occurrence at the adoption instant is ambiguously placed');
245
+ }
246
+ }
247
+ const episodeOf = (at) => (at < startedMs ? 1 : 2);
248
+
249
+ // 4c. CLOSED LIVE INTERVAL.
250
+ for (const o of batch) {
251
+ for (const e of existing) {
252
+ if (normaliseOrigin(e) === 'backfill' || e.exitedAt == null) continue;
253
+ const at = o.evidence.observedEpochMs;
254
+ if (ms(e.enteredAt) < at && at < ms(e.exitedAt)) {
255
+ return refuse(
256
+ `backfilled ${occurrenceKey(o)} falls inside the closed live interval `
257
+ + `${e.phase} [${e.enteredAt}, ${e.exitedAt})`,
258
+ );
259
+ }
260
+ }
261
+ }
262
+
263
+ // ---- STEP 2. Merge and recompute closure from valid time.
264
+ const instantOf = (x) => x.evidence?.observedEpochMs ?? ms(x.enteredAt);
265
+ const merged = [...existing, ...batch].sort((a, b) => {
266
+ const d = instantOf(a) - instantOf(b);
267
+ if (d !== 0) return d;
268
+ return a._seq - b._seq;
269
+ });
270
+ for (let i = 0; i < merged.length; i += 1) {
271
+ merged[i].exitedAt = i + 1 < merged.length ? merged[i + 1].enteredAt : null;
272
+ merged[i].from = i > 0 ? merged[i - 1].phase : null;
273
+ merged[i].episode = episodeOf(instantOf(merged[i]));
274
+ }
275
+
276
+ // 4d. LIVE RECORDS ARE NEVER REWRITTEN — compared against the INDEPENDENT
277
+ // pre-rewrite clones.
278
+ for (const e of existing) {
279
+ if (normaliseOrigin(e) === 'backfill') continue;
280
+ const original = snapshot.get(e._tid);
281
+ const m = merged.find((x) => x._tid === e._tid);
282
+ if (m.enteredAt !== original.enteredAt) {
283
+ return refuse(`the merge would move the live occurrence ${original.phase}`);
284
+ }
285
+ if (original.exitedAt != null && m.exitedAt !== original.exitedAt) {
286
+ return refuse(
287
+ `the merge would re-close the live occurrence ${original.phase} `
288
+ + `from ${original.exitedAt} to ${m.exitedAt}`,
289
+ );
290
+ }
291
+ // An OPEN live occurrence may legitimately be closed by a later backfilled one.
292
+ }
293
+
294
+ // 4e. TRANSITIVE REACHABILITY within an episode.
295
+ for (let i = 1; i < merged.length; i += 1) {
296
+ const a = merged[i - 1];
297
+ const b = merged[i];
298
+ if (markerTids.has(a._tid) || markerTids.has(b._tid)) continue;
299
+ if (a.episode !== b.episode) continue; // the single adoption-boundary pair
300
+ const g = adapterTerminals.has(b.phase) ? fullGraph : fwdGraph;
301
+ if (!reachable(g, a.phase, b.phase)) {
302
+ return refuse(`${a.phase} -> ${b.phase} is not reachable in ${mode}`);
303
+ }
304
+ }
305
+
306
+ // 4f. INTERVAL INVARIANT, last, numerically, over the whole merged list.
307
+ for (const m of merged) {
308
+ if (m.exitedAt != null && ms(m.exitedAt) < ms(m.enteredAt)) {
309
+ return refuse(`interval invariant violated at ${m.phase}`);
310
+ }
311
+ }
312
+
313
+ const history = merged.map((m) => {
314
+ const out = { ...m };
315
+ delete out._tid;
316
+ delete out._seq;
317
+ return out;
43
318
  });
319
+ return { ok: true, history, written: batch.map(occurrenceKey), skipped };
44
320
  }