@smartmemory/compose 0.3.7 → 0.4.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 (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,1695 @@
1
+ /**
2
+ * lib/fluid/smartmemory-provider.js — the SmartMemory fluid provider (COMP-FOH FOH-1).
3
+ *
4
+ * Implements the fluid-store seam over SmartMemory's generic MemoryItem CRUD
5
+ * routes, via `lib/smartmemory-client.js`. Storage only: this slice declares
6
+ * RECORDS, EVENTS and LINKS and nothing else. Recall, challenge, conviction,
7
+ * calibration and contradiction stay refused by the base class until FOH-2
8
+ * implements and declares them together.
9
+ *
10
+ * Storage layout (blueprint D-FOH-2):
11
+ * - a record → one MemoryItem, `memory_type: fluid_<kind>`, the canonical
12
+ * record JSON-encoded into `metadata.fluid_record_json`
13
+ * - an event → one MemoryItem, `memory_type: fluid_event`, payload in
14
+ * `metadata.fluid_event_json`
15
+ *
16
+ * WHY THE RECORD IS AN OPAQUE STRING
17
+ * ----------------------------------
18
+ * SmartMemory treats `metadata` as an open bag of graph properties it owns and
19
+ * rewrites. A structured record stored there does not survive the round trip:
20
+ * nested dicts are exploded into `parent__child` properties, empty containers
21
+ * are dropped, the server overwrites `created_at`, and merge-mode updates hoist
22
+ * old metadata to the top level as a stale shadow copy.
23
+ *
24
+ * Worse, a field CANNOT BE CLEARED: null and empty-string values are filtered
25
+ * out before the write and the SET simply omits them, so the previous value
26
+ * survives behind a 200 (filed upstream as smart-memory-core#3). Under any
27
+ * structured mapping, clearing a record's `priority` would silently no-op.
28
+ *
29
+ * One JSON string is inert to every one of those paths. It is never null and
30
+ * never empty, so it is always written; the metadata merge is a one-level
31
+ * spread, so one key replaces the whole record; and the backend codec
32
+ * marker-escapes JSON-looking strings and returns them verbatim, making the
33
+ * round trip byte-stable by construction rather than by hope.
34
+ *
35
+ * The cost is that record fields are not server-queryable. That costs nothing
36
+ * here: `/memory/list` accepts a single filter pair, the floor already filters
37
+ * client-side, and the seam exposes no field-query API. `handle`, `kind` and
38
+ * `fluid_ns` stay flat beside the blob precisely so lookup still works.
39
+ *
40
+ * WHAT THIS PROVIDER OWNS THAT THE FLOOR DOES NOT
41
+ * -----------------------------------------------
42
+ * Nothing below it enforces the seam's invariants. `memory_type` is frozen at
43
+ * creation server-side and the blob is opaque, so a `kind` change would diverge
44
+ * permanently and silently keep the old embedding policy. There is still no
45
+ * uniqueness CONSTRAINT on a metadata field — handle uniqueness is produced
46
+ * here, by allocating from a server counter and serializing every mutation
47
+ * behind a server lease, not enforced by the store.
48
+ *
49
+ * COORDINATION (COMP-FLUID-SEAM-GUARANTEES)
50
+ * -----------------------------------------
51
+ * Two different server primitives, for two different problems:
52
+ *
53
+ * - `SVC-ALLOC-1` sequences allocate handles. `$inc` is atomic, so concurrent
54
+ * creates get distinct numbers with no lock at all.
55
+ * - `SVC-LEASE-1` leases serialize mutation, because read-modify-write on one
56
+ * opaque blob cannot be made atomic by a counter.
57
+ *
58
+ * Together they are what lets `mutationScope()` answer CLUSTER instead of NONE.
59
+ */
60
+
61
+ import { randomUUID } from 'node:crypto';
62
+
63
+ import { createSmartmemoryClient } from '../smartmemory-client.js';
64
+ import {
65
+ CAP,
66
+ CHALLENGEABLE_KINDS,
67
+ CONVICTION_STRATEGIES,
68
+ FluidAmbiguousMatch,
69
+ FluidConfigError,
70
+ FluidInvalidStrategy,
71
+ FluidInvalidTarget,
72
+ FluidKindUnsupported,
73
+ FluidProvider,
74
+ FluidRecordNotFound,
75
+ FluidResolutionConflict,
76
+ FluidResolutionIndeterminate,
77
+ FluidResolutionNoOp,
78
+ KIND,
79
+ MUTATION_SCOPE,
80
+ STORAGE_CAP,
81
+ normalizeRecallLimit,
82
+ } from './provider.js';
83
+ import {
84
+ HANDLE_PREFIX,
85
+ HANDLE_RE,
86
+ UNPATCHABLE,
87
+ assertHandle,
88
+ assertLink,
89
+ assertPatchable,
90
+ assertStatus,
91
+ eventTypeForUpdate,
92
+ normalizeRecord,
93
+ } from './record-shape.js';
94
+ import { assertValid, getFluidValidator } from './schema.js';
95
+
96
+ /** Namespace markers. The analogue of the floor's `fluid_ext` presence check:
97
+ * a MemoryItem without the marker is invisible to this provider, so a
98
+ * workspace shared with other SmartMemory content stays safe to read. */
99
+ const RECORD_NS = 'compose.fluid.v1';
100
+ const EVENT_NS = 'compose.fluid.events.v1';
101
+
102
+ /** Wire type prefix (blueprint D-FOH-3).
103
+ *
104
+ * `decision` is ALREADY a registered SmartMemory memory_type with a different
105
+ * meaning, so a bare kind would merge two populations in one workspace — and,
106
+ * because the per-kind embedding config key is derived from `memory_type`,
107
+ * would make Compose's recallability policy rewrite embedding behaviour for
108
+ * native SmartMemory decisions deployment-wide. The prefix keeps the blast
109
+ * radius inside Compose's own namespace. */
110
+ const WIRE_PREFIX = 'fluid_';
111
+ const EVENT_WIRE_TYPE = 'fluid_event';
112
+
113
+ /** The route's own default page size. Every enumeration here loops; see
114
+ * `_listAllItems`. */
115
+ const PAGE_SIZE = 50;
116
+
117
+ /**
118
+ * Kinds `recall()` may return.
119
+ *
120
+ * `cluster` stays out: it is a hand-authored grouping label, not a fuzzy-recall
121
+ * target. It remains stored and readable the moment you name its handle.
122
+ *
123
+ * `decision` is IN, by owner ruling 2026-08-04, reversing architecture.md §Q3's
124
+ * provisional INDEXED default (which flagged itself as the one item worth a
125
+ * second look). §Q3's stated fear was surfacing a superseded decision "as if it
126
+ * were live" — and the word doing the work there is *as if*. A hit carries the
127
+ * whole record, `status` and `killed` included, so a killed decision is
128
+ * distinguishable by anything that looks. See the residual limitation below.
129
+ *
130
+ * THIS LIST IS THE ENFORCEMENT, and it stays that way even once the server can
131
+ * be told what to embed. Filtering the OUTPUT holds regardless of how the
132
+ * server is configured; making it contingent on a server flag would put recall
133
+ * correctness at the mercy of deployment state.
134
+ *
135
+ * (Corrected 2026-08-05: the reason used to be stated as "Compose can neither
136
+ * set nor verify" the per-request override. Right about the outcome, wrong
137
+ * about the cause — core HAS the override and honours it ahead of every
138
+ * memory_type and config default (`_embed`, crud.py:100 and :160). No REST
139
+ * route plumbs it through, which is a field to expose rather than a mechanism
140
+ * to build. Tracked in SmartMemory as SVC-EMBED-CONTROL-1.)
141
+ *
142
+ * CORRECTED 2026-08-05, verified against live FalkorDB. This comment used to
143
+ * claim supersession was invisible on the superseded record — that detecting it
144
+ * "would mean scanning every record for links targeting this handle, which
145
+ * recall cannot do per hit". That was WRONG. The old node carries plain
146
+ * properties, readable per hit with zero extra queries:
147
+ *
148
+ * superseded=true, superseded_by=<new_id>, superseded_at=<ts>
149
+ *
150
+ * Set by both `SmartMemory.supersede()` and `ingest_superseding()`
151
+ * (smart_memory.py:3531, :3546 — the latter's docstring states it outright).
152
+ * So a superseded decision IS distinguishable in recall today, and `decision`
153
+ * belongs in this set with no asterisk.
154
+ *
155
+ * The one real narrowing is different and narrower than what was claimed: both
156
+ * REST supersede routes CREATE the replacement (`ingest_superseding`, reached
157
+ * via POST /memory/{item_id}/supersede). The link-two-existing-records form,
158
+ * `supersede(old_id, new_id)`, is not exposed over REST — so "record B, already
159
+ * stored, supersedes record A" has no REST path. Tracked in SmartMemory as
160
+ * SVC-SUPERSEDE-LINK-1; do not build a Compose workaround for it.
161
+ */
162
+ export const RECALLABLE_KINDS = Object.freeze(
163
+ new Set([KIND.IDEA, KIND.THREAD, KIND.QUESTION, KIND.DECISION]),
164
+ );
165
+
166
+ /**
167
+ * Over-fetch factor, floor and ceiling for the single recall query.
168
+ *
169
+ * One unfiltered query is issued and filtered client-side, so the fetch has to
170
+ * be wide enough to survive dropping every non-fluid and non-recallable hit. The
171
+ * floor matters more than the factor: at `limit: 1` a bare factor would fetch 4,
172
+ * which almost any workspace could fill with items that get filtered away.
173
+ *
174
+ * The ceiling is not decoration — `top_k` is unconstrained on the wire and the
175
+ * route doubles it before searching, so an unbounded value here becomes an
176
+ * unbounded fetch and a proportional ranking cost on the server.
177
+ */
178
+ const OVERFETCH = 4;
179
+ const MIN_FETCH = 20;
180
+ const TOP_K_CAP = 200;
181
+
182
+ /**
183
+ * Mutation lease (SVC-LEASE-1).
184
+ *
185
+ * ONE lease for the whole store rather than one per record, mirroring the
186
+ * floor's single `withDirLock` path and for the same reasons: writes here are
187
+ * human-scale, and a single lease also orders allocation against mutation. Two
188
+ * granularities would buy nothing and cost a deadlock-ordering rule.
189
+ *
190
+ * The TTL is generous because a critical section is several NETWORK calls, not
191
+ * a few file writes — a create is peek + allocate + event + record. It is
192
+ * renewed on a heartbeat rather than simply set high: a crashed holder must not
193
+ * strand the store for the whole TTL, and the heartbeat is what lets the TTL
194
+ * stay short enough for that while still covering a slow section.
195
+ *
196
+ * Server bounds (`lease.py`): 5..300s. The retry schedule is bounded and
197
+ * explicit — acquisition FAILS rather than waiting forever, because a caller
198
+ * blocked indefinitely on a lease is indistinguishable from a hung CLI.
199
+ */
200
+ /**
201
+ * Challenge deadline (FOH-3). The client default is 3s and it spans the body
202
+ * read; with the LLM cascade running over up to ~10 related facts, a challenge
203
+ * routinely exceeds it. This is a per-call override, so CRUD/recall keep the
204
+ * tight default that catches a hung service.
205
+ */
206
+ const CHALLENGE_TIMEOUT_MS = 30_000;
207
+
208
+ // Conviction (COMP-FOH FOH-4). The resolve call is deterministic (no LLM) but
209
+ // still does a server-side get + decay + update; 8s is generous ON PURPOSE —
210
+ // an abort here manufactures an ambiguous outcome the provider then has to
211
+ // reconcile, so a spurious timeout is strictly worse than a slow success.
212
+ const RESOLVE_TIMEOUT_MS = 8_000;
213
+ // The reconciliation poll after an ambiguous transport outcome: bounded and
214
+ // read-only. Short, because the server mutates synchronously — if the decay is
215
+ // going to land at all, it lands within the request's own lifetime.
216
+ const RESOLVE_RECONCILE_ATTEMPTS = 3;
217
+ const RESOLVE_RECONCILE_DELAY_MS = 250;
218
+ // Contradiction linkage (COMP-FOH FOH-5). The best-effort edge write after a
219
+ // landed decay. Safe to retry (idempotent MERGE), so a small bounded retry
220
+ // closes over a transient network blip without ever risking a double-decay.
221
+ const LINK_RETRY_ATTEMPTS = 3;
222
+ const LINK_RETRY_DELAY_MS = 200;
223
+ // Uppercase to match SmartMemory's managed contradiction path (framework.py:887)
224
+ // so a Compose-written edge and a native-written one read back under one label.
225
+ const CONTRADICTS_EDGE = 'CONTRADICTS';
226
+ /** Numeric tolerance for the decay postcondition. The confidence value round
227
+ * trips Python → JSON → JS; 1.0/0.5/0.0 are exact doubles but a legacy value
228
+ * like 0.9 is not, and a strict `===` on a destructive-write check would
229
+ * false-alarm on representation noise. */
230
+ const CONFIDENCE_EPSILON = 1e-9;
231
+
232
+ /** Python `[:200]` slices Unicode CODE POINTS; JS `String.slice` cuts UTF-16
233
+ * code units. The attribution check compares our text against what the server
234
+ * stored, so the truncation must match Python's or an astral character near
235
+ * the boundary mis-classifies a landed decay. */
236
+ function codePointSlice(s, n) {
237
+ return Array.from(String(s ?? '')).slice(0, n).join('');
238
+ }
239
+
240
+ /**
241
+ * Classify what a resolve attempt actually did, from the confidence-history
242
+ * reads bracketing it (COMP-FOH FOH-4 D4). Pure — shared by the clean-response
243
+ * path and the reconciliation poll so the two can never disagree.
244
+ *
245
+ * Never trusts the `/resolve` response: the server ignores `apply_decay`'s
246
+ * success and its `confidence` field is ambiguous. The verdict comes from
247
+ * SmartMemory's deterministic contract instead: one decay = challenge_count+1,
248
+ * confidence = max(0, old - 0.5), one attributed history event.
249
+ *
250
+ * @param {object} pre confidence-history envelope read before the resolve
251
+ * @param {object} post confidence-history envelope read after
252
+ * @param {string} expectedFact our new_fact, truncated to 200 code points —
253
+ * matched against the newest event so someone ELSE's numerically identical
254
+ * decay is never mistaken for ours
255
+ * @returns {'landed'|'no-op'|'conflict'|'indeterminate'}
256
+ */
257
+ export function classifyResolution(pre, post, expectedFact) {
258
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
259
+ const near = (a, b) => finite(a) && finite(b) && Math.abs(a - b) < CONFIDENCE_EPSILON;
260
+ // The envelope must be structurally sound AND be about the same item as the
261
+ // pre-read — a wrong-item envelope must never classify anything.
262
+ if (!post || !finite(post.current_confidence)
263
+ || !Number.isInteger(post.challenge_count) || !Array.isArray(post.history)
264
+ || post.item_id !== pre.item_id) {
265
+ return 'indeterminate';
266
+ }
267
+
268
+ const delta = post.challenge_count - pre.challenge_count;
269
+ if (delta === 1) {
270
+ const ev = post.history[post.history.length - 1];
271
+ // Ours? The reason is fixed by the strategy and the fact text is ours.
272
+ const attributed = !!ev
273
+ && typeof ev.timestamp === 'string'
274
+ && ev.reason === 'manual_resolution:accept_new'
275
+ && ev.conflicting_fact === expectedFact;
276
+ if (!attributed) return 'conflict'; // someone else's decay landed; ours did not
277
+ const expected = Math.max(0, pre.current_confidence - 0.5);
278
+ const numeric = near(post.current_confidence, expected)
279
+ && near(ev.old_confidence, pre.current_confidence)
280
+ && near(ev.new_confidence, expected)
281
+ && ev.decay_factor === 0.5;
282
+ // Attributed but numerically wrong = a partial or corrupt write (the
283
+ // pre-fix-runtime shape: count moves, confidence does not). Not retryable.
284
+ return numeric ? 'landed' : 'indeterminate';
285
+ }
286
+ if (delta === 0) {
287
+ // A genuine no-op only if NOTHING moved — confidence AND the history
288
+ // CONTENT. Comparing `history_count` alone is not enough twice over: the
289
+ // real route reads the item and the history separately (a torn read can
290
+ // pair old confidence with new history), and once the history sits at its
291
+ // server-side cap (20) an external decay rotates entries without changing
292
+ // the count. Both must land in 'indeterminate', never in the one verdict
293
+ // that invites a retry. Same JSON round-trip on both reads, so exact
294
+ // comparison is right here.
295
+ const unchanged = post.current_confidence === pre.current_confidence
296
+ && JSON.stringify(post.history) === JSON.stringify(pre.history);
297
+ return unchanged ? 'no-op' : 'indeterminate';
298
+ }
299
+ if (delta > 1) return 'conflict'; // more than one decay interleaved
300
+ return 'indeterminate'; // count regression: malformed or incompatible runtime
301
+ }
302
+
303
+ const LEASE_TTL_SECONDS = 60;
304
+ const LEASE_RENEW_MS = 20_000;
305
+ const LEASE_BACKOFF_MS = Object.freeze([0, 50, 150, 400, 900, 1500, 2500, 4000]);
306
+ const LEASE_KEY = 'compose.fluid.mutate';
307
+
308
+ /** Handle counters, one sequence per kind. Server-side these are scoped to the
309
+ * workspace, so the name carries only the namespace and the kind. Matches the
310
+ * service's `SEQUENCE_NAME_PATTERN`. */
311
+ function sequenceNameFor(kind) { return `compose.fluid.handle.${kind}`; }
312
+
313
+ function nowIso() { return new Date().toISOString(); }
314
+
315
+ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
316
+
317
+ /** `memory_type` for a record kind. Never the bare kind. */
318
+ function wireTypeFor(kind) { return `${WIRE_PREFIX}${kind}`; }
319
+
320
+ export class SmartMemoryFluidProvider extends FluidProvider {
321
+ name() { return 'smartmemory'; }
322
+
323
+ /** Storage, plus RECALL (FOH-2), CHALLENGE (FOH-3), CONVICTION (FOH-4) and
324
+ * CONTRADICTION (FOH-5). Declaring a capability without implementing it is the
325
+ * one thing PROVIDER-SEAM forbids, so each line here moves with its method
326
+ * (`recall()`, `challenge()`, `conviction()`/`resolveConflict()`,
327
+ * `contradictions()`) — CALIBRATION stays undeclared (no subject exists) and
328
+ * keeps inheriting refusal. */
329
+ capabilities() {
330
+ return new Set([
331
+ STORAGE_CAP.RECORDS, STORAGE_CAP.EVENTS, STORAGE_CAP.LINKS,
332
+ CAP.RECALL, CAP.CHALLENGE, CAP.CONVICTION, CAP.CONTRADICTION,
333
+ ]);
334
+ }
335
+
336
+ /**
337
+ * The floor's full kind set, not `idea` alone.
338
+ *
339
+ * The pilot workload requires it: the ideabox import creates CLUSTERS before
340
+ * ideas, because members reference them by handle, so an idea-only provider
341
+ * throws on the import's first write. Kinds are free here because storage is
342
+ * generic — `memory_type` is a free-form string with no typed schema behind
343
+ * it — so matching the floor exactly makes a provider swap lossless rather
344
+ * than parity-gapped.
345
+ *
346
+ * `position` and `joint` stay refused, matching the floor: the judgment layer
347
+ * already owns both kinds with its own store and write tools, and accepting
348
+ * them would give one kind two canons.
349
+ */
350
+ supportedKinds() {
351
+ return new Set([KIND.IDEA, KIND.DECISION, KIND.THREAD, KIND.QUESTION, KIND.CLUSTER]);
352
+ }
353
+
354
+ /**
355
+ * CLUSTER since COMP-FLUID-SEAM-GUARANTEES. This used to be a confession.
356
+ *
357
+ * It was NONE because allocation read the issued set and added one across two
358
+ * remote calls with nothing holding the gap, so N concurrent creates allocated
359
+ * the same handle. No record was LOST: this store writes by `item_id`, so all
360
+ * N persisted and N-1 were silently orphaned behind a handle resolving to one
361
+ * of them. The floor loses them outright because it keys the file by handle —
362
+ * do not carry the floor's loss claim across this seam (measured 2026-08-05).
363
+ * `lib/dir-lock.js`
364
+ * could not have fixed it: it is a local mutex and this store is reachable from
365
+ * every machine sharing the workspace, which is the entire reason to use it.
366
+ *
367
+ * Both halves now rest on server primitives, and they are different primitives
368
+ * on purpose:
369
+ *
370
+ * - ALLOCATION is not serialized at all, because it does not need to be. A
371
+ * sequence `$inc` is atomic per document, so concurrent callers receive
372
+ * distinct numbers with no lock and no contention. Wrapping it in the
373
+ * lease would be strictly worse — slower, and no safer.
374
+ * - MUTATION is serialized by a scoped lease, because read-modify-write on
375
+ * one record genuinely cannot be made atomic by a counter. This is the
376
+ * quieter half: a lost update leaves no trace, both writers succeed and one
377
+ * edit is simply gone.
378
+ *
379
+ * Declared honestly in BOTH directions — the conformance suite asserts the
380
+ * failure when a provider declares NONE, so a stale declaration here starts
381
+ * failing rather than silently over-promising.
382
+ */
383
+ mutationScope() { return MUTATION_SCOPE.CLUSTER; }
384
+
385
+ /** A workspace on a server. Reachable from every machine holding the key. */
386
+ isShared() { return true; }
387
+
388
+ /**
389
+ * @param {string} cwd project root
390
+ * @param {object} config merged fluid.smartmemory + top-level smartmemory config
391
+ * @param {string} config.baseUrl SmartMemory endpoint
392
+ * @param {string} config.apiKeyEnv env var holding the API key
393
+ * @param {string} config.workspaceId configured workspace (owner ruling: never derived)
394
+ * @param {object} [config.client] injected client, for tests
395
+ */
396
+ async init(cwd, config = {}) {
397
+ this.cwd = cwd;
398
+ this.config = config;
399
+
400
+ // Fail loud, before any network call, naming the exact missing setting.
401
+ //
402
+ // All four are checked, not just the block's presence: the client
403
+ // interpolates baseUrl straight into the URL, so an unset endpoint would
404
+ // otherwise surface as a request failure against the literal string
405
+ // "undefined" — blaming the server for a local misconfiguration.
406
+ if (!config.baseUrl) {
407
+ throw new FluidConfigError(
408
+ 'compose: fluid provider "smartmemory" requires smartmemory.baseUrl to be set',
409
+ { provider: 'smartmemory', setting: 'smartmemory.baseUrl' }
410
+ );
411
+ }
412
+ if (!config.apiKeyEnv) {
413
+ throw new FluidConfigError(
414
+ 'compose: fluid provider "smartmemory" requires smartmemory.apiKeyEnv to be set',
415
+ { provider: 'smartmemory', setting: 'smartmemory.apiKeyEnv' }
416
+ );
417
+ }
418
+ if (!process.env[config.apiKeyEnv]) {
419
+ throw new FluidConfigError(
420
+ `compose: fluid provider "smartmemory" needs an API key in $${config.apiKeyEnv}, ` +
421
+ 'which is unset or empty. The key needs the read:memories, write:memories AND ' +
422
+ 'delete:memories scopes — a key missing delete fails only on deleteRecord, long ' +
423
+ 'after setup appears to have worked.',
424
+ { provider: 'smartmemory', setting: config.apiKeyEnv }
425
+ );
426
+ }
427
+ // Owner ruling (blueprint C4): the workspace id is CONFIGURED, never
428
+ // derived. A locally derived project tag was never usable — the service
429
+ // validates the header against the principal's memberships and 403s a
430
+ // workspace they are not a member of.
431
+ if (!config.workspaceId) {
432
+ throw new FluidConfigError(
433
+ 'compose: fluid provider "smartmemory" requires fluid.smartmemory.workspaceId. ' +
434
+ 'It must be a workspace the configured API key is a member of; it is not derived ' +
435
+ 'from the project directory.',
436
+ { provider: 'smartmemory', setting: 'fluid.smartmemory.workspaceId' }
437
+ );
438
+ }
439
+
440
+ this.client = config.client ?? createSmartmemoryClient({
441
+ baseUrl: config.baseUrl,
442
+ apiKeyEnv: config.apiKeyEnv,
443
+ workspaceId: config.workspaceId,
444
+ timeoutMs: config.timeoutMs,
445
+ });
446
+
447
+ /** The serialization mechanism this provider's `mutationScope()` claims.
448
+ * Exposed under this name because the conformance suite cross-checks the
449
+ * declaration against a mechanism — the floor exposes `lockPath`, a
450
+ * server-backed provider exposes this. Leases are workspace-scoped
451
+ * server-side, so the key needs no workspace component. */
452
+ this.leaseClient = this.client;
453
+ this.leaseKey = LEASE_KEY;
454
+ return this;
455
+ }
456
+
457
+ // -------------------------------------------------------------------------
458
+ // Mapping
459
+ // -------------------------------------------------------------------------
460
+
461
+ /**
462
+ * The searchable projection (blueprint D-FOH-1).
463
+ *
464
+ * Title, body and discussion text, not the title alone. Embeddings are
465
+ * generated from `content`, so a title-only projection would leave a later
466
+ * recall slice unable to reach an idea's prose while the architecture
467
+ * declares ideas fully recallable — a failure that would surface only after
468
+ * that slice shipped, as empty results.
469
+ *
470
+ * DISCLOSED LIMITATION: PATCH does not reindex, so an edited record keeps its
471
+ * original embedding. Nothing reads embeddings in FOH-1, so nothing is broken
472
+ * today; this is FOH-2's entry gate. Do NOT work around it by delete +
473
+ * recreate, which burns a handle the tombstone invariant forbids.
474
+ */
475
+ _renderContent(record) {
476
+ const parts = [record.title];
477
+ if (record.body) parts.push(record.body);
478
+ for (const entry of record.discussion ?? []) {
479
+ if (entry?.text) parts.push(entry.text);
480
+ }
481
+ return parts.join('\n\n');
482
+ }
483
+
484
+ /** Record → wire metadata. Three flat lookup fields, then the sealed blob. */
485
+ _toMetadata(record) {
486
+ return {
487
+ fluid_ns: RECORD_NS,
488
+ handle: record.handle,
489
+ kind: record.kind,
490
+ fluid_record_json: JSON.stringify(record),
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Wire item → record, or null when the item is not one of ours / unreadable.
496
+ *
497
+ * A blob that fails to parse is treated as absent rather than thrown: these
498
+ * items live in a shared workspace, and one corrupt row must not make every
499
+ * enumeration fail. The `handle` is reported so it is findable.
500
+ */
501
+ _fromItem(item) {
502
+ const meta = item?.metadata ?? {};
503
+ if (meta.fluid_ns !== RECORD_NS) return null;
504
+ let parsed;
505
+ try {
506
+ parsed = JSON.parse(meta.fluid_record_json);
507
+ } catch {
508
+ process.emitWarning(
509
+ `fluid(smartmemory): record ${meta.handle ?? item?.item_id} has an unreadable ` +
510
+ 'fluid_record_json blob and was skipped',
511
+ );
512
+ return null;
513
+ }
514
+ if (!parsed || typeof parsed !== 'object') return null;
515
+ // Normalize on every read: a record written before a field existed, or
516
+ // imported from the floor, reads back in today's shape without a migration.
517
+ return normalizeRecord(parsed);
518
+ }
519
+
520
+ // -------------------------------------------------------------------------
521
+ // Enumeration
522
+ // -------------------------------------------------------------------------
523
+
524
+ /**
525
+ * Every matching item, following pagination to the end.
526
+ *
527
+ * The route defaults to `limit=50`. A provider that silently saw only the
528
+ * first page would under-report the ideabox — and once the projection is
529
+ * generated from it, DELETE the rest from `ideabox.md`.
530
+ *
531
+ * Only one metadata filter pair is supported server-side, so the namespace
532
+ * check is applied client-side by the caller's mapper.
533
+ */
534
+ async _listAllItems({ metadataKey, metadataValue } = {}) {
535
+ const out = [];
536
+ let offset = 0;
537
+ for (;;) {
538
+ const page = await this.client.listItems({
539
+ limit: PAGE_SIZE, offset, metadataKey, metadataValue,
540
+ });
541
+ const items = page.items ?? [];
542
+ out.push(...items);
543
+ // Stop on a short page OR on reaching `total`. Either alone is fragile:
544
+ // a full last page with no `total` would loop forever without the first,
545
+ // and a `total` that lags a concurrent write would truncate without the
546
+ // second.
547
+ if (items.length < PAGE_SIZE) break;
548
+ if (typeof page.total === 'number' && out.length >= page.total) break;
549
+ offset += items.length;
550
+ }
551
+ return out;
552
+ }
553
+
554
+ /** Every live record item, namespace-filtered. */
555
+ async _allRecordItems() {
556
+ const items = await this._listAllItems({
557
+ metadataKey: 'fluid_ns', metadataValue: RECORD_NS,
558
+ });
559
+ return items.filter((it) => it?.metadata?.fluid_ns === RECORD_NS);
560
+ }
561
+
562
+ // -------------------------------------------------------------------------
563
+ // Handle resolution and duplicate repair (blueprint D-FOH-4)
564
+ // -------------------------------------------------------------------------
565
+
566
+ /**
567
+ * Every live item carrying this handle, in a deterministic total order.
568
+ *
569
+ * Ordered by the SERVER-stamped `metadata.created_at`, ties broken by
570
+ * `item_id`. The server stamps that field itself on every add, so this is one
571
+ * clock rather than each client's — and a racing writer cannot forge an
572
+ * earlier one. The record's own `created_at`, inside the blob, is the
573
+ * record's account of itself and is deliberately NOT used for ordering.
574
+ */
575
+ async _resolveItems(handle) {
576
+ const items = await this._listAllItems({ metadataKey: 'handle', metadataValue: handle });
577
+ return items
578
+ .filter((it) => it?.metadata?.fluid_ns === RECORD_NS && it?.metadata?.handle === handle)
579
+ .sort((a, b) => {
580
+ const ca = a.metadata?.created_at ?? '';
581
+ const cb = b.metadata?.created_at ?? '';
582
+ if (ca !== cb) return ca < cb ? -1 : 1;
583
+ return String(a.item_id) < String(b.item_id) ? -1 : 1;
584
+ });
585
+ }
586
+
587
+ /**
588
+ * Read resolution: deterministic, never writes, never throws on a duplicate.
589
+ *
590
+ * SmartMemory has no uniqueness constraint on a metadata field, so a duplicate
591
+ * is still REPRESENTABLE even though the counter and the lease mean this
592
+ * provider no longer produces one: a workspace written by a pre-SVC-ALLOC-1
593
+ * Compose can already hold a pair, and nothing retroactively repairs history.
594
+ * Throwing here — the earlier design — makes that state PERMANENTLY
595
+ * unreadable, which is strictly worse than the ambiguity it was meant to flag.
596
+ * Earliest wins, loudly.
597
+ */
598
+ async _resolveOne(handle) {
599
+ const items = await this._resolveItems(handle);
600
+ if (items.length === 0) return null;
601
+ if (items.length > 1) {
602
+ process.emitWarning(
603
+ `fluid(smartmemory): handle ${handle} resolves to ${items.length} live records; ` +
604
+ 'using the earliest. The duplicate is repaired on the next write to this handle.',
605
+ );
606
+ }
607
+ return items[0];
608
+ }
609
+
610
+ /**
611
+ * Write resolution: repairs a duplicate before proceeding.
612
+ *
613
+ * Keeps the earliest and reassigns every later item a freshly allocated
614
+ * handle, appending a `reassigned` event so the trail is auditable. Nothing
615
+ * is discarded and no handle is ever reissued — which makes this strictly
616
+ * better than the local floor, where two racing creates collide on one path
617
+ * and one idea is silently lost.
618
+ */
619
+ async _resolveForWrite(handle) {
620
+ const items = await this._resolveItems(handle);
621
+ if (items.length === 0) return null;
622
+ if (items.length === 1) return items[0];
623
+
624
+ const [keep, ...duplicates] = items;
625
+ for (const dup of duplicates) {
626
+ const record = this._fromItem(dup);
627
+ if (!record) continue;
628
+ const fresh = await this._nextHandle(record.kind);
629
+ const moved = { ...record, handle: fresh, updated_at: nowIso() };
630
+ await this.client.updateItem(dup.item_id, {
631
+ content: this._renderContent(moved),
632
+ metadata: this._toMetadata(moved),
633
+ });
634
+ await this.appendEvent({
635
+ handle: fresh,
636
+ type: 'created',
637
+ at: nowIso(),
638
+ detail: { kind: record.kind, reassigned_from: handle },
639
+ });
640
+ process.emitWarning(
641
+ `fluid(smartmemory): duplicate handle ${handle} repaired — the later record ` +
642
+ `was reassigned ${fresh}`,
643
+ );
644
+ }
645
+ return keep;
646
+ }
647
+
648
+ // -------------------------------------------------------------------------
649
+ // Handle allocation
650
+ // -------------------------------------------------------------------------
651
+
652
+ /** Every handle ever issued — live records UNION the append-only event log.
653
+ * Derived from live records alone it would miss a deleted record's handle,
654
+ * and handles are quoted in docs, commits and conversation.
655
+ *
656
+ * This is the expensive read in the file: two full paginated enumerations.
657
+ * It used to run on EVERY create; since COMP-FLUID-SEAM-GUARANTEES it runs only on the
658
+ * explicit-handle path (the import) and once per kind to seed the counter.
659
+ * Keep it that way — anything that puts it back in the automatic path undoes
660
+ * the change. */
661
+ async _issuedHandles() {
662
+ const issued = new Set();
663
+ for (const item of await this._allRecordItems()) {
664
+ const h = item?.metadata?.handle;
665
+ if (h) issued.add(h);
666
+ }
667
+ for (const event of await this.readEvents()) {
668
+ if (event?.handle) issued.add(event.handle);
669
+ }
670
+ return issued;
671
+ }
672
+
673
+ /** The highest number already issued under `prefix`. Only ever read to SEED
674
+ * the counter — never to allocate from. See `_sequenceFloor`. */
675
+ async _maxIssued(prefix) {
676
+ let max = 0;
677
+ for (const handle of await this._issuedHandles()) {
678
+ const m = HANDLE_RE.exec(handle);
679
+ if (m && m[1] === prefix) max = Math.max(max, Number(m[2]));
680
+ }
681
+ return max;
682
+ }
683
+
684
+ /**
685
+ * The `floor` to send with an allocation, or `undefined` when none is needed.
686
+ *
687
+ * `floor` is a high-water mark applied with `$max` BEFORE the `$inc`, so it can
688
+ * only ever raise the counter — idempotent, and safe to race.
689
+ *
690
+ * It is computed from a full scan EXACTLY ONCE per kind per workspace: on the
691
+ * first touch, when the counter does not exist yet. That case is not
692
+ * hypothetical — a workspace written by a pre-sequence Compose already holds
693
+ * handles the counter has never seen, and a counter starting at 1 would reissue
694
+ * every one of them. Once seeded, the counter is authoritative and the scan
695
+ * never runs again, which is the whole point of the change: allocation drops
696
+ * from two full paginated enumerations (every record UNION every event) to one
697
+ * cheap GET plus one POST.
698
+ *
699
+ * Racing two seeders is harmless: both scan the same store, both send the same
700
+ * `$max`, and the `$inc` still hands them distinct numbers.
701
+ *
702
+ * @param {string} kind
703
+ * @param {number} atLeast raise the counter to at least this, for a handle
704
+ * supplied by the caller rather than allocated
705
+ */
706
+ async _sequenceFloor(kind, atLeast = 0) {
707
+ const seeded = await this.client.peekSequence(sequenceNameFor(kind)) !== null;
708
+ if (seeded) return atLeast > 0 ? atLeast : undefined;
709
+ return Math.max(await this._maxIssued(HANDLE_PREFIX[kind]), atLeast);
710
+ }
711
+
712
+ /**
713
+ * The next handle for `kind`, from the server's monotonic counter.
714
+ *
715
+ * Deliberately NOT wrapped in the mutation lease. `$inc` is atomic per
716
+ * document, so concurrent callers receive distinct numbers with no lock;
717
+ * holding the lease across it would serialize allocations that never needed
718
+ * serializing. The counter is also the reason the old
719
+ * `if (issued.has(candidate)) throw` guard is gone: it defended against a
720
+ * derivation that could repeat itself, and a counter cannot.
721
+ *
722
+ * Numbers allocated but not used are LOST. Gaps are guaranteed by the
723
+ * primitive and cost nothing here — a handle is a citation, not a count.
724
+ */
725
+ async _nextHandle(kind) {
726
+ const floor = await this._sequenceFloor(kind);
727
+ const { value } = await this.client.allocateSequence(sequenceNameFor(kind), { floor });
728
+ return `${HANDLE_PREFIX[kind]}-${value}`;
729
+ }
730
+
731
+ /**
732
+ * Raise the counter past a handle the CALLER supplied, so a later allocation
733
+ * cannot hand it out a second time.
734
+ *
735
+ * Passing `n - 1` rather than `n` is what keeps a sequential import gapless:
736
+ * `$max(n-1)` then `$inc` leaves the counter at exactly `n`, so the next
737
+ * automatic handle is `n + 1`. The number this call consumes is discarded —
738
+ * there is no way to raise the floor without consuming one, and one wasted
739
+ * number per explicit handle is a great deal cheaper than a counter that lags
740
+ * a live handle.
741
+ */
742
+ async _burnHandle(kind, handle) {
743
+ const m = HANDLE_RE.exec(handle);
744
+ if (!m) return;
745
+ const floor = await this._sequenceFloor(kind, Math.max(Number(m[2]) - 1, 0));
746
+ await this.client.allocateSequence(sequenceNameFor(kind), { floor });
747
+ }
748
+
749
+ async _handleWasIssued(handle) {
750
+ return (await this._issuedHandles()).has(handle);
751
+ }
752
+
753
+ /**
754
+ * Was this handle issued, never made live, and never deleted?
755
+ *
756
+ * The seam's `reclaimAborted` predicate, mirroring the floor's
757
+ * `_isAbortedAllocation` deliberately — the narrowness IS the safety. Only a
758
+ * handle stranded between its tombstone and its record qualifies:
759
+ *
760
+ * - a handle whose record EXISTS is in use, and
761
+ * - a handle with a `deleted` event is retired, and retired handles stay
762
+ * retired, because reissuing one repoints every existing citation at a
763
+ * different record.
764
+ *
765
+ * So the only thing this can hand back is a handle that names nothing and
766
+ * never will — which is exactly the state an interrupted import leaves and
767
+ * nothing else produces.
768
+ */
769
+ async _isAbortedAllocation(handle) {
770
+ if (await this.getRecord(handle)) return false;
771
+ for (const event of await this.readEvents()) {
772
+ if (event?.handle === handle && event.type === 'deleted') return false;
773
+ }
774
+ return true;
775
+ }
776
+
777
+ // -------------------------------------------------------------------------
778
+ // Mutation lease (SVC-LEASE-1) — what makes mutationScope() CLUSTER
779
+ // -------------------------------------------------------------------------
780
+
781
+ /**
782
+ * Run `fn` while holding the store's single mutation lease.
783
+ *
784
+ * Every public mutating method is a thin wrapper over a `*Locked` body for the
785
+ * same reason the floor's are: **this is NOT reentrant.** A locked body that
786
+ * called another public method would ask the server for a lease it already
787
+ * holds, be told `lock_held` by its own other half, and fail after the full
788
+ * backoff. Compose by calling the inner form.
789
+ *
790
+ * `appendEvent` deliberately stays unlocked: it is an append-only write that
791
+ * every locked body performs, and locking it would deadlock all of them.
792
+ *
793
+ * Failure is loud and total. A caller that believes it is serialized and is
794
+ * not loses records without an error — the exact failure this whole mechanism
795
+ * exists to make impossible — so exhausting the backoff throws rather than
796
+ * proceeding unserialized.
797
+ */
798
+ async _withLease(op, fn) {
799
+ let lease = null;
800
+ for (const wait of LEASE_BACKOFF_MS) {
801
+ if (wait) await sleep(wait);
802
+ lease = await this.client.acquireLock(this.leaseKey, { ttlSeconds: LEASE_TTL_SECONDS });
803
+ if (lease) break;
804
+ }
805
+ if (!lease) {
806
+ throw new Error(
807
+ `fluid(smartmemory): ${op} could not acquire the mutation lease ` +
808
+ `"${this.leaseKey}" after ${LEASE_BACKOFF_MS.length} attempts — another writer ` +
809
+ 'is holding it. Nothing was written.',
810
+ );
811
+ }
812
+
813
+ // Heartbeat rather than a long TTL: a crashed holder must not strand every
814
+ // other writer for the full lease, and renewing is what lets the TTL stay
815
+ // short enough for that while still covering a slow section.
816
+ const heartbeat = setInterval(() => {
817
+ this.client.renewLock(this.leaseKey, lease.token, { ttlSeconds: LEASE_TTL_SECONDS })
818
+ .then((renewed) => {
819
+ if (renewed === null) {
820
+ process.emitWarning(
821
+ `fluid(smartmemory): the mutation lease expired during ${op} and was taken by ` +
822
+ 'another writer; this write is no longer serialized',
823
+ );
824
+ }
825
+ })
826
+ .catch((err) => {
827
+ process.emitWarning(`fluid(smartmemory): lease renewal failed during ${op}: ${err.message}`);
828
+ });
829
+ }, LEASE_RENEW_MS);
830
+ // Never hold the event loop open for a renewal nobody is waiting on.
831
+ heartbeat.unref?.();
832
+
833
+ try {
834
+ return await fn();
835
+ } finally {
836
+ clearInterval(heartbeat);
837
+ try {
838
+ await this.client.releaseLock(this.leaseKey, lease.token);
839
+ } catch (err) {
840
+ // Releasing is an optimisation, not a correctness requirement — the TTL
841
+ // reclaims it either way. Warn rather than mask the caller's own error.
842
+ process.emitWarning(
843
+ `fluid(smartmemory): releasing the mutation lease after ${op} failed ` +
844
+ `(${err.message}); it expires within ${LEASE_TTL_SECONDS}s`,
845
+ );
846
+ }
847
+ }
848
+ }
849
+
850
+ // -------------------------------------------------------------------------
851
+ // Records
852
+ // -------------------------------------------------------------------------
853
+
854
+ /** Absence is `null`, matching the floor. Only mutating paths throw. A
855
+ * malformed handle is a miss, not a crash — this is the lookup a caller
856
+ * makes with untrusted input. */
857
+ async getRecord(handle) {
858
+ if (!HANDLE_RE.test(handle ?? '')) return null;
859
+ const item = await this._resolveOne(handle);
860
+ return item ? this._fromItem(item) : null;
861
+ }
862
+
863
+ async listRecords(filter = {}) {
864
+ const items = await this._allRecordItems();
865
+ let records = items.map((it) => this._fromItem(it)).filter(Boolean);
866
+ if (filter.kind) records = records.filter((r) => r.kind === filter.kind);
867
+ if (filter.status) records = records.filter((r) => r.status === filter.status);
868
+ if (filter.cluster !== undefined) records = records.filter((r) => r.cluster === filter.cluster);
869
+ // Same stable order as the floor, so a projection regenerated after a
870
+ // provider swap does not churn.
871
+ return records.sort((a, b) => {
872
+ const ca = a.cluster_order ?? Number.MAX_SAFE_INTEGER;
873
+ const cb = b.cluster_order ?? Number.MAX_SAFE_INTEGER;
874
+ if (ca !== cb) return ca - cb;
875
+ return (Number(HANDLE_RE.exec(a.handle)?.[2] ?? 0)) - (Number(HANDLE_RE.exec(b.handle)?.[2] ?? 0));
876
+ });
877
+ }
878
+
879
+ /**
880
+ * Genuinely atomic since COMP-FLUID-SEAM-GUARANTEES: the lookup and the create happen
881
+ * inside ONE hold of the mutation lease, so a second writer racing the same
882
+ * title waits, then finds the record the first one made instead of creating a
883
+ * twin. Overridden rather than inherited because the base implementation
884
+ * composes on the PUBLIC `createRecord`, which would try to re-acquire.
885
+ */
886
+ async findOrCreateRecord({ kind, title }, input = {}) {
887
+ return this._withLease('findOrCreateRecord', async () => {
888
+ const matches = (await this.listRecords({ kind }))
889
+ .filter((r) => r.title.toLowerCase() === String(title).toLowerCase());
890
+ if (matches.length > 1) {
891
+ throw new FluidAmbiguousMatch(kind, title, matches.map((m) => m.handle));
892
+ }
893
+ if (matches.length === 1) return { record: matches[0], created: false };
894
+ return { record: await this._createRecordLocked({ ...input, kind, title }), created: true };
895
+ });
896
+ }
897
+
898
+ async createRecord(input) {
899
+ return this._withLease('createRecord', () => this._createRecordLocked(input));
900
+ }
901
+
902
+ async _createRecordLocked(input) {
903
+ const kind = input.kind ?? KIND.IDEA;
904
+ this.requireKind(kind);
905
+ if (!input.title) throw new Error('fluid: createRecord requires a title');
906
+
907
+ let handle;
908
+ if (input.handle === undefined) {
909
+ handle = await this._nextHandle(kind);
910
+ } else {
911
+ handle = assertHandle(input.handle, kind);
912
+ // Checked against every handle EVER issued, not just the live ones. A
913
+ // retired handle is still spoken for: reissuing it would repoint existing
914
+ // citations at a different record.
915
+ //
916
+ // `reclaimAborted` is honoured here with the floor's exact semantics
917
+ // (COMP-FLUID-SEAM-GUARANTEES). It was ignored until now, and the guard
918
+ // below threw unconditionally — which made the one-time import
919
+ // NON-RESTARTABLE against this provider. The window it has to survive is
920
+ // far wider here than on the floor: creation burns the handle in one
921
+ // network call and writes the record in the next, so any blip between them
922
+ // stranded that handle permanently and `ensureIdeaboxMigrated`'s resume
923
+ // path then failed forever, since `importIdeabox` passes `reclaimAborted`
924
+ // and this provider dropped it.
925
+ //
926
+ // Needs no server primitive — it is a question about this provider's own
927
+ // event log and record set, both already readable, which is why it was
928
+ // fixed before SVC-LEASE-1 landed rather than waiting on it. The counter
929
+ // does not subsume this check and never will: it knows the numbers it has
930
+ // handed out, not which of them were retired.
931
+ if (await this._handleWasIssued(handle)
932
+ && !(input.reclaimAborted && await this._isAbortedAllocation(handle))) {
933
+ throw new Error(
934
+ `fluid: handle ${handle} has already been issued and cannot be reused ` +
935
+ `(handles are external citations; retired ones stay retired)`
936
+ );
937
+ }
938
+ // A caller-supplied handle bypasses the counter, so the counter has to be
939
+ // told about it — otherwise a later automatic allocation could hand out
940
+ // the same number. Done BEFORE anything is written, so a failure here
941
+ // leaves no record whose handle the counter does not know.
942
+ await this._burnHandle(kind, handle);
943
+ }
944
+
945
+ for (const link of input.links ?? []) assertLink(link);
946
+ const status = assertStatus(input.status ?? 'new');
947
+ const now = nowIso();
948
+ const record = normalizeRecord({
949
+ handle,
950
+ kind,
951
+ title: input.title,
952
+ body: input.body ?? '',
953
+ status,
954
+ status_label: input.status_label ?? null,
955
+ priority: input.priority ?? null,
956
+ effort: input.effort ?? null,
957
+ impact: input.impact ?? null,
958
+ cluster: input.cluster ?? null,
959
+ cluster_order: input.cluster_order ?? null,
960
+ tags: input.tags ?? [],
961
+ source: input.source ?? null,
962
+ links: input.links ?? [],
963
+ killed: input.killed ?? null,
964
+ discussion: input.discussion ?? [],
965
+ provenance: {
966
+ origin: input.provenance?.origin ?? 'cli:ideabox',
967
+ recorded_at: input.provenance?.recorded_at ?? now,
968
+ author: input.provenance?.author ?? null,
969
+ },
970
+ // Provider-assigned and provider-scoped, per the contract: `id` changes
971
+ // when a record moves providers, while `handle` survives the swap.
972
+ // Deliberately NOT SmartMemory's item_id, which does not exist until the
973
+ // write returns — the contract requires `id` on the object being written.
974
+ id: randomUUID(),
975
+ created_at: now,
976
+ updated_at: now,
977
+ });
978
+ assertValid('record', record, 'record');
979
+
980
+ // The tombstone is written BEFORE the record exists. Persisting first and
981
+ // appending after means a failed append leaves a discoverable record whose
982
+ // handle was never burned — delete it and the handle is reissued, defeating
983
+ // the invariant. Burning first can at worst waste a handle, and a wasted
984
+ // handle is free while a reissued one is unrecoverable.
985
+ await this.appendEvent({
986
+ handle,
987
+ type: input.provenance?.origin === 'import:ideabox' ? 'imported' : 'created',
988
+ at: now,
989
+ detail: { kind },
990
+ });
991
+
992
+ await this.client.createItem({
993
+ content: this._renderContent(record),
994
+ memoryType: wireTypeFor(kind),
995
+ metadata: this._toMetadata(record),
996
+ });
997
+
998
+ return record;
999
+ }
1000
+
1001
+ async updateRecord(handle, patch) {
1002
+ return this._withLease('updateRecord', () => this._updateRecordLocked(handle, patch));
1003
+ }
1004
+
1005
+ async _updateRecordLocked(handle, patch) {
1006
+ const item = await this._resolveForWrite(handle);
1007
+ if (!item) throw new FluidRecordNotFound(handle, this.name());
1008
+
1009
+ assertPatchable(patch, this.name());
1010
+
1011
+ const current = this._fromItem(item);
1012
+ if (!current) throw new FluidRecordNotFound(handle, this.name());
1013
+
1014
+ const merged = {
1015
+ ...current,
1016
+ ...patch,
1017
+ ...Object.fromEntries(UNPATCHABLE.map((f) => [f, current[f]])),
1018
+ updated_at: nowIso(),
1019
+ };
1020
+
1021
+ // Validate the RAW merge, then normalize — order is load-bearing.
1022
+ // Normalizing first would hand the schema an already-sanitized object and
1023
+ // the schema would approve what the sanitizer had quietly repaired.
1024
+ assertStatus(merged.status);
1025
+ assertValid('record', merged, 'record');
1026
+ for (const link of merged.links) assertLink(link);
1027
+
1028
+ const next = normalizeRecord(merged);
1029
+ const eventType = eventTypeForUpdate(current, next, patch);
1030
+
1031
+ // One metadata key carrying the whole record. The server's merge is a
1032
+ // one-level spread, so this replaces the record wholesale while leaving
1033
+ // every server-owned metadata key untouched. `properties` is deliberately
1034
+ // not used: it bypasses the merge and is the mass-assignment surface the
1035
+ // server's protected fields exist to guard.
1036
+ await this.client.updateItem(item.item_id, {
1037
+ content: this._renderContent(next),
1038
+ metadata: this._toMetadata(next),
1039
+ });
1040
+
1041
+ await this.appendEvent({
1042
+ handle,
1043
+ type: eventType,
1044
+ at: nowIso(),
1045
+ detail: { fields: Object.keys(patch) },
1046
+ });
1047
+
1048
+ return next;
1049
+ }
1050
+
1051
+ /** The only way discussion grows. Append-only in the API, not merely by
1052
+ * convention in the contract — a deliberation trail that can be rewritten is
1053
+ * not evidence. */
1054
+ async appendDiscussion(handle, entry) {
1055
+ return this._withLease('appendDiscussion', () => this._appendDiscussionLocked(handle, entry));
1056
+ }
1057
+
1058
+ async _appendDiscussionLocked(handle, entry) {
1059
+ const item = await this._resolveForWrite(handle);
1060
+ if (!item) throw new FluidRecordNotFound(handle, this.name());
1061
+ if (!entry?.text) throw new Error('fluid: a discussion entry requires text');
1062
+
1063
+ const record = this._fromItem(item);
1064
+ if (!record) throw new FluidRecordNotFound(handle, this.name());
1065
+
1066
+ record.discussion.push({
1067
+ at: entry.at ?? nowIso(),
1068
+ text: entry.text,
1069
+ author: entry.author ?? null,
1070
+ });
1071
+ record.updated_at = nowIso();
1072
+ assertValid('record', record, 'record');
1073
+
1074
+ await this.client.updateItem(item.item_id, {
1075
+ content: this._renderContent(record),
1076
+ metadata: this._toMetadata(record),
1077
+ });
1078
+ await this.appendEvent({ handle, type: 'discussed', at: nowIso(), detail: {} });
1079
+ return record;
1080
+ }
1081
+
1082
+ /**
1083
+ * Hard delete. NOT the lifecycle path — killing an idea is
1084
+ * `updateRecord(handle, {status: 'killed'})`, which keeps the record and its
1085
+ * reasoning.
1086
+ *
1087
+ * The discussion is carried into the append-only log before the record goes,
1088
+ * so "append-only" is not true of every path except the one that erases it.
1089
+ */
1090
+ async deleteRecord(handle) {
1091
+ return this._withLease('deleteRecord', () => this._deleteRecordLocked(handle));
1092
+ }
1093
+
1094
+ async _deleteRecordLocked(handle) {
1095
+ const item = await this._resolveForWrite(handle);
1096
+ if (!item) throw new FluidRecordNotFound(handle, this.name());
1097
+ const record = this._fromItem(item);
1098
+
1099
+ await this.appendEvent({
1100
+ handle,
1101
+ type: 'deleted',
1102
+ at: nowIso(),
1103
+ detail: {
1104
+ kind: record?.kind,
1105
+ title: record?.title,
1106
+ discussion: record?.discussion ?? [],
1107
+ },
1108
+ });
1109
+
1110
+ await this.client.deleteItem(item.item_id);
1111
+ return { ok: true };
1112
+ }
1113
+
1114
+ // -------------------------------------------------------------------------
1115
+ // Links
1116
+ // -------------------------------------------------------------------------
1117
+
1118
+ async addLink(handle, link) {
1119
+ return this._withLease('addLink', () => this._addLinkLocked(handle, link));
1120
+ }
1121
+
1122
+ async _addLinkLocked(handle, link) {
1123
+ assertLink(link);
1124
+ const item = await this._resolveForWrite(handle);
1125
+ if (!item) throw new FluidRecordNotFound(handle, this.name());
1126
+ const record = this._fromItem(item);
1127
+ if (!record) throw new FluidRecordNotFound(handle, this.name());
1128
+
1129
+ const exists = record.links.some((l) => l.type === link.type && l.target === link.target);
1130
+ // Idempotent, and deliberately emits no event on a repeat: a `linked` event
1131
+ // per repeat would inflate the lifecycle history a later semantic layer
1132
+ // reads as signal.
1133
+ if (!exists) {
1134
+ record.links.push({ ...link });
1135
+ record.updated_at = nowIso();
1136
+ await this.client.updateItem(item.item_id, {
1137
+ content: this._renderContent(record),
1138
+ metadata: this._toMetadata(record),
1139
+ });
1140
+ await this.appendEvent({ handle, type: 'linked', at: nowIso(), detail: { ...link } });
1141
+ }
1142
+ return record;
1143
+ }
1144
+
1145
+ async removeLink(handle, link) {
1146
+ return this._withLease('removeLink', () => this._removeLinkLocked(handle, link));
1147
+ }
1148
+
1149
+ async _removeLinkLocked(handle, link) {
1150
+ const item = await this._resolveForWrite(handle);
1151
+ if (!item) throw new FluidRecordNotFound(handle, this.name());
1152
+ const record = this._fromItem(item);
1153
+ if (!record) throw new FluidRecordNotFound(handle, this.name());
1154
+
1155
+ const before = record.links.length;
1156
+ record.links = record.links.filter((l) => !(l.type === link.type && l.target === link.target));
1157
+ if (record.links.length !== before) {
1158
+ record.updated_at = nowIso();
1159
+ await this.client.updateItem(item.item_id, {
1160
+ content: this._renderContent(record),
1161
+ metadata: this._toMetadata(record),
1162
+ });
1163
+ }
1164
+ return record;
1165
+ }
1166
+
1167
+ // -------------------------------------------------------------------------
1168
+ // Recall (CAP.RECALL)
1169
+ // -------------------------------------------------------------------------
1170
+
1171
+ /**
1172
+ * Semantic recall over fluid records.
1173
+ *
1174
+ * One unfiltered, over-fetched query, then filtered client-side to this
1175
+ * provider's namespace and to `RECALLABLE_KINDS`. Filtering the OUTPUT rather
1176
+ * than scoping the REQUEST is deliberate: `memory_type` on the wire takes a
1177
+ * single value, so covering three recallable kinds would need three queries,
1178
+ * and scores from separate searches are not comparable — merging them would
1179
+ * fabricate a ranking.
1180
+ *
1181
+ * DISCLOSED BOUND: because filtering happens after ranking, recall can return
1182
+ * fewer than `limit` results even when more matching records exist, if enough
1183
+ * higher-scoring non-recallable items fill the over-fetch. The alternative is
1184
+ * unbounded fetching. Asserted in the tests so a future change is deliberate.
1185
+ *
1186
+ * DISCLOSED BOUND: an edited record keeps its original embedding, because
1187
+ * PATCH does not reindex and no per-item reindex exists over HTTP
1188
+ * (smart-memory-core#4). The record returned here is CURRENT — search hydrates
1189
+ * from the live graph node — so what lags is why a record matched and where it
1190
+ * ranked, not what you receive. The lexical channels still see current text.
1191
+ *
1192
+ * @param {string} query
1193
+ * @param {{limit?: number}} [opts]
1194
+ * @returns {Promise<import('./provider.js').RecallHit[]>}
1195
+ */
1196
+ async recall(query, opts = {}) {
1197
+ this.require(CAP.RECALL);
1198
+
1199
+ const limit = normalizeRecallLimit(opts?.limit);
1200
+ const topK = Math.min(Math.max(limit * OVERFETCH, MIN_FETCH), TOP_K_CAP);
1201
+
1202
+ const raw = await this.client.searchItems(query, { topK });
1203
+
1204
+ const hits = [];
1205
+ const seen = new Map();
1206
+ for (const result of raw?.results ?? []) {
1207
+ const meta = result?.metadata ?? {};
1208
+ // Namespace first: a shared workspace holds items that are not ours at
1209
+ // all, and they must never reach a caller.
1210
+ if (meta.fluid_ns !== RECORD_NS) continue;
1211
+
1212
+ const record = this._fromItem(result);
1213
+ // `_fromItem` warns and returns null on an unreadable blob. One corrupt
1214
+ // row must degrade to "that record is not in these results", never to a
1215
+ // failed recall.
1216
+ if (!record) continue;
1217
+
1218
+ // THE ENFORCEMENT. Holds with the deployment's embedding config unset,
1219
+ // which is exactly the case the config dial cannot defend.
1220
+ if (!RECALLABLE_KINDS.has(record.kind)) continue;
1221
+
1222
+ const score = typeof result?.score === 'number' ? result.score : null;
1223
+ const createdAt = meta.created_at ?? '';
1224
+ const itemId = String(result?.item_id ?? '');
1225
+
1226
+ // Collapse duplicate handles using D-FOH-4's full order — earliest
1227
+ // server-stamped created_at, ties broken by item_id. Dropping the second
1228
+ // key would leave same-instant duplicates resolving nondeterministically,
1229
+ // and disagreeing with getRecord(). Both keys are already on the hit, so
1230
+ // this costs no extra request.
1231
+ const existing = seen.get(record.handle);
1232
+ if (existing) {
1233
+ const isEarlier = createdAt !== existing.createdAt
1234
+ ? createdAt < existing.createdAt
1235
+ : itemId < existing.itemId;
1236
+ if (isEarlier) {
1237
+ existing.hit.record = record;
1238
+ existing.createdAt = createdAt;
1239
+ existing.itemId = itemId;
1240
+ }
1241
+ // The surviving hit keeps its ORIGINAL rank and score: the duplicate is
1242
+ // a storage artifact, and letting it reshuffle the ranking would leak
1243
+ // that artifact into the answer.
1244
+ continue;
1245
+ }
1246
+
1247
+ const hit = { handle: record.handle, score, record };
1248
+ seen.set(record.handle, { hit, createdAt, itemId });
1249
+ hits.push(hit);
1250
+ }
1251
+
1252
+ // Server order is the ranking. Filtering removes entries; it never reorders
1253
+ // the survivors, and nothing here recomputes a score.
1254
+ return hits.slice(0, limit);
1255
+ }
1256
+
1257
+ // -------------------------------------------------------------------------
1258
+ // Challenge (CAP.CHALLENGE)
1259
+ // -------------------------------------------------------------------------
1260
+
1261
+ /**
1262
+ * Same-kind contradiction detection (FOH-3).
1263
+ *
1264
+ * A record is challenged against OTHERS OF ITS OWN KIND, because the service's
1265
+ * `memory_type` is an exact filter with no wildcard (`search.py:123`): one call
1266
+ * covers one kind, and we send the record's exact wire type. Only `decision`
1267
+ * and `idea` are challengeable — the endpoint runs its cascade directly with no
1268
+ * `should_challenge` gate, so a non-assertional kind is refused, not fed to it.
1269
+ *
1270
+ * Two filters compose. The SERVER filters candidates to the requested type;
1271
+ * THIS method then drops anything that is not one of ours (namespace) or is the
1272
+ * challenged record itself, exactly as recall does — a shared workspace holds
1273
+ * items that must never reach a caller. Because that second filter changes the
1274
+ * conflict set, `hasConflicts` and `confidence` are recomputed from what
1275
+ * survives, never the service's pre-filter aggregates (which would let a result
1276
+ * claim conflicts it then shows none of).
1277
+ *
1278
+ * @param {string} handle
1279
+ * @param {{useLlm?: boolean, timeoutMs?: number}} [opts]
1280
+ * @returns {Promise<import('./provider.js').ChallengeResult>}
1281
+ */
1282
+ async challenge(handle, opts = {}) {
1283
+ this.require(CAP.CHALLENGE);
1284
+
1285
+ const record = await this.getRecord(handle);
1286
+ if (!record) throw new FluidRecordNotFound(handle, this.name());
1287
+ if (!CHALLENGEABLE_KINDS.has(record.kind)) {
1288
+ throw new FluidKindUnsupported(record.kind, this.name(), [...CHALLENGEABLE_KINDS]);
1289
+ }
1290
+
1291
+ const assertion = this._renderContent(record);
1292
+ const raw = await this.client.challenge(assertion, {
1293
+ memoryType: wireTypeFor(record.kind), // exact fluid_<kind> — the crux
1294
+ useLlm: opts.useLlm ?? true,
1295
+ timeoutMs: opts.timeoutMs ?? CHALLENGE_TIMEOUT_MS,
1296
+ });
1297
+
1298
+ const conflicts = [];
1299
+ for (const c of raw?.conflicts ?? []) {
1300
+ const item = await this.client.getItem(String(c?.existing_item_id ?? ''));
1301
+ const meta = item?.metadata ?? {};
1302
+ if (meta.fluid_ns !== RECORD_NS) continue; // not ours → drop (D1a)
1303
+ const conflictHandle = meta.handle;
1304
+ if (!conflictHandle || conflictHandle === record.handle) continue; // self → drop (D1b)
1305
+ conflicts.push({
1306
+ handle: conflictHandle,
1307
+ existingText: c.existing_fact ?? '',
1308
+ conflictType: c.conflict_type ?? '',
1309
+ confidence: typeof c.confidence === 'number' ? c.confidence : 0,
1310
+ explanation: c.explanation ?? '',
1311
+ suggestedResolution: c.suggested_resolution ?? '',
1312
+ });
1313
+ }
1314
+ conflicts.sort((a, b) => b.confidence - a.confidence); // best-first
1315
+
1316
+ // D1c: aggregates from the RETAINED set. The service's confidence formula,
1317
+ // recomputed over what the caller actually receives (challenger.py:256-262).
1318
+ const hasConflicts = conflicts.length > 0;
1319
+ const confidence = hasConflicts
1320
+ ? Math.max(0, 1 - (conflicts.reduce((s, c) => s + c.confidence, 0) / conflicts.length) * 0.5)
1321
+ : 1.0;
1322
+
1323
+ return { assertion, hasConflicts, confidence, conflicts };
1324
+ }
1325
+
1326
+ // -------------------------------------------------------------------------
1327
+ // Conviction (CAP.CONVICTION) — COMP-FOH FOH-4
1328
+ // -------------------------------------------------------------------------
1329
+
1330
+ /** One `/confidence-history` envelope → the seam's ConvictionResult. Used by
1331
+ * the read AND by a landed resolution, so both return the same shape from
1332
+ * the same source. `challenged`/`lastChallengedAt` are DERIVED — the
1333
+ * envelope is the read path's single call, no `getItem` behind it. */
1334
+ _toConvictionResult(handle, raw) {
1335
+ const history = raw.history.map((e) => ({
1336
+ timestamp: e.timestamp,
1337
+ oldConfidence: e.old_confidence,
1338
+ newConfidence: e.new_confidence,
1339
+ decayFactor: e.decay_factor,
1340
+ reason: e.reason,
1341
+ ...(e.conflicting_fact !== undefined ? { conflictingFact: e.conflicting_fact } : {}),
1342
+ }));
1343
+ return {
1344
+ handle,
1345
+ confidence: raw.current_confidence,
1346
+ challenged: raw.challenge_count > 0,
1347
+ challengeCount: raw.challenge_count,
1348
+ lastChallengedAt: history.length ? history[history.length - 1].timestamp : null,
1349
+ history,
1350
+ };
1351
+ }
1352
+
1353
+ /**
1354
+ * Belief-strength read. A never-resolved record honestly returns
1355
+ * `{confidence: 1.0, challenged: false, history: []}` — nothing has moved it.
1356
+ *
1357
+ * @param {string} handle
1358
+ * @returns {Promise<import('./provider.js').ConvictionResult>}
1359
+ */
1360
+ async conviction(handle) {
1361
+ this.require(CAP.CONVICTION);
1362
+ if (!HANDLE_RE.test(handle ?? '')) throw new FluidRecordNotFound(handle, this.name());
1363
+ const item = await this._resolveOne(handle);
1364
+ if (!item) throw new FluidRecordNotFound(handle, this.name());
1365
+ let raw;
1366
+ try {
1367
+ raw = await this.client.confidenceHistory(String(item.item_id));
1368
+ } catch (err) {
1369
+ // The item vanished between resolution and read (a race with a delete).
1370
+ if (err?.status === 404) throw new FluidRecordNotFound(handle, this.name());
1371
+ throw err;
1372
+ }
1373
+ return this._toConvictionResult(handle, raw);
1374
+ }
1375
+
1376
+ /**
1377
+ * Gated resolution: decay `targetHandle`'s confidence because `sourceHandle`
1378
+ * supersedes it. See the base class JSDoc for the caller-facing contract
1379
+ * (explicit strategy, near-irreversible, only NoOp retryable, trust
1380
+ * boundary); what lives HERE is the safety machinery:
1381
+ *
1382
+ * - Every authorization invariant is enforced at this seam — the server's
1383
+ * `/resolve` does no re-detection and would decay whatever id it is
1384
+ * handed. Handles resolve via the READ-ONLY `_resolveOne` (the write
1385
+ * resolver repairs duplicates as a side effect, which must not run
1386
+ * before authorization).
1387
+ * - The contradicting fact is derived from the SOURCE record's own stored
1388
+ * content, exactly as `challenge()` derives its assertion. No caller
1389
+ * text reaches the wire.
1390
+ * - The whole sequence runs inside the workspace lease, which serializes
1391
+ * Compose-side mutations. It does NOT serialize direct SmartMemory
1392
+ * callers — which is why the postcondition has a 'conflict' verdict.
1393
+ * - The `/resolve` response is never trusted. The outcome is classified
1394
+ * from confidence-history reads bracketing the call
1395
+ * ({@link classifyResolution}); an ambiguous transport failure (abort,
1396
+ * malformed 2xx, gateway 5xx — none of which prove the server's
1397
+ * synchronous mutation stopped) goes through a bounded read-only
1398
+ * reconciliation poll and, failing a positive match, surfaces as
1399
+ * Indeterminate rather than inviting a double-decaying retry.
1400
+ *
1401
+ * @param {string} sourceHandle
1402
+ * @param {string} targetHandle
1403
+ * @param {{strategy: string, timeoutMs?: number}} [opts]
1404
+ * @returns {Promise<import('./provider.js').ConvictionResult>}
1405
+ */
1406
+ async resolveConflict(sourceHandle, targetHandle, opts = {}) {
1407
+ this.require(CAP.CONVICTION);
1408
+ const strategy = opts.strategy;
1409
+ if (!CONVICTION_STRATEGIES.includes(strategy)) {
1410
+ throw new FluidInvalidStrategy(strategy, [...CONVICTION_STRATEGIES]);
1411
+ }
1412
+
1413
+ // The lease serializes the DECAY. The best-effort contradiction link (FOH-5)
1414
+ // is deliberately done AFTER the lease releases: the edge write is idempotent
1415
+ // (MERGE by identity) and touches no confidence, so holding the mutation lease
1416
+ // across its network retries would only serialize unrelated callers for no
1417
+ // safety gain. The closure therefore returns {result, link}: `link` is set on
1418
+ // EITHER landed exit (clean or reconciled), null on every throw.
1419
+ const { result, link } = await this._withLease('resolveConflict', async () => {
1420
+ const sourceItem = await this._resolveOne(sourceHandle);
1421
+ if (!sourceItem) throw new FluidRecordNotFound(sourceHandle, this.name());
1422
+ const targetItem = await this._resolveOne(targetHandle);
1423
+ if (!targetItem) throw new FluidRecordNotFound(targetHandle, this.name());
1424
+ // _fromItem is null for a corrupt blob even on a namespaced item — refuse
1425
+ // typed, before touching `.kind`.
1426
+ const sourceRec = this._fromItem(sourceItem);
1427
+ if (!sourceRec) throw new FluidRecordNotFound(sourceHandle, this.name());
1428
+ const targetRec = this._fromItem(targetItem);
1429
+ if (!targetRec) throw new FluidRecordNotFound(targetHandle, this.name());
1430
+ if (!CHALLENGEABLE_KINDS.has(sourceRec.kind)) {
1431
+ throw new FluidKindUnsupported(sourceRec.kind, this.name(), [...CHALLENGEABLE_KINDS]);
1432
+ }
1433
+ if (targetRec.kind !== sourceRec.kind) {
1434
+ throw new FluidKindUnsupported(targetRec.kind, this.name(), [sourceRec.kind]);
1435
+ }
1436
+ if (String(sourceItem.item_id) === String(targetItem.item_id)) {
1437
+ throw new FluidInvalidTarget(targetHandle, 'self-target');
1438
+ }
1439
+
1440
+ const newFact = this._renderContent(sourceRec);
1441
+ const expectedFact = codePointSlice(newFact, 200);
1442
+ const targetId = String(targetItem.item_id);
1443
+ let pre;
1444
+ try {
1445
+ pre = await this.client.confidenceHistory(targetId);
1446
+ } catch (err) {
1447
+ // The target vanished between resolution and the pre-read (a direct
1448
+ // caller's delete) — the same race conviction() maps. Nothing mutated.
1449
+ if (err?.status === 404) throw new FluidRecordNotFound(targetHandle, this.name());
1450
+ throw err;
1451
+ }
1452
+
1453
+ try {
1454
+ await this.client.resolveConflict({
1455
+ existingItemId: targetId,
1456
+ newFact,
1457
+ strategy,
1458
+ timeoutMs: opts.timeoutMs ?? RESOLVE_TIMEOUT_MS,
1459
+ });
1460
+ } catch (err) {
1461
+ const status = typeof err?.status === 'number' ? err.status : 0;
1462
+ const ambiguous = status === 0 || err?.kind === 'malformed-response' || status >= 500;
1463
+ if (!ambiguous) {
1464
+ // A real 4xx: rejected before the mutation. Nothing to reconcile.
1465
+ if (status === 404) throw new FluidRecordNotFound(targetHandle, this.name());
1466
+ throw err;
1467
+ }
1468
+ // Returns only on a landed decay (else throws) — so a value here means
1469
+ // the decay landed and the contradiction link is owed.
1470
+ return {
1471
+ result: await this._reconcileResolution(targetHandle, targetId, pre, expectedFact),
1472
+ link: { sourceId: String(sourceItem.item_id), targetId },
1473
+ };
1474
+ }
1475
+
1476
+ // Clean response: the handler finished, so one immediate re-read is
1477
+ // authoritative. If even that read fails, fall back to the poll — a
1478
+ // reconciliation-read failure must not surface as its own error.
1479
+ let post;
1480
+ try {
1481
+ post = await this.client.confidenceHistory(targetId);
1482
+ } catch {
1483
+ // Returns only on a landed decay (else throws) — so a value here means
1484
+ // the decay landed and the contradiction link is owed.
1485
+ return {
1486
+ result: await this._reconcileResolution(targetHandle, targetId, pre, expectedFact),
1487
+ link: { sourceId: String(sourceItem.item_id), targetId },
1488
+ };
1489
+ }
1490
+ const verdict = classifyResolution(pre, post, expectedFact);
1491
+ if (verdict === 'landed') {
1492
+ return {
1493
+ result: this._toConvictionResult(targetHandle, post),
1494
+ link: { sourceId: String(sourceItem.item_id), targetId },
1495
+ };
1496
+ }
1497
+ if (verdict === 'no-op') throw new FluidResolutionNoOp(targetHandle);
1498
+ if (verdict === 'conflict') throw new FluidResolutionConflict(targetHandle);
1499
+ throw new FluidResolutionIndeterminate(targetHandle);
1500
+ });
1501
+
1502
+ // Post-success epilogue (FOH-5), OUTSIDE the lease. The decay is already
1503
+ // durable and authoritative; the CONTRADICTS edge is a best-effort enhancement
1504
+ // so `contradictions()` can resolve this decay back to its source. A failed
1505
+ // link never fails the resolution — it degrades `contradictions()` to a lower
1506
+ // bound, which its contract already permits.
1507
+ if (link) await this._linkContradiction(link.sourceId, link.targetId);
1508
+ return result;
1509
+ }
1510
+
1511
+ /**
1512
+ * Best-effort durable link: source CONTRADICTS target, written after the decay
1513
+ * landed. Bounded retry, because the edge write is safe to repeat (idempotent
1514
+ * MERGE by identity, no confidence touched) — unlike the resolve it follows.
1515
+ * On persistent failure it WARNS and returns; it never throws, because the
1516
+ * resolution it belongs to already succeeded and the caller must not be handed
1517
+ * a failure for a durability detail.
1518
+ */
1519
+ async _linkContradiction(sourceId, targetId) {
1520
+ for (let attempt = 0; attempt < LINK_RETRY_ATTEMPTS; attempt += 1) {
1521
+ try {
1522
+ await this.client.addEdge({
1523
+ sourceId,
1524
+ targetId,
1525
+ relationType: CONTRADICTS_EDGE,
1526
+ properties: { origin: 'fluid:resolveConflict' },
1527
+ });
1528
+ return;
1529
+ } catch (err) {
1530
+ if (attempt + 1 < LINK_RETRY_ATTEMPTS) {
1531
+ await sleep(LINK_RETRY_DELAY_MS);
1532
+ continue;
1533
+ }
1534
+ process.emitWarning(
1535
+ `fluid(smartmemory): decayed ${targetId} but could not link its contradiction from `
1536
+ + `${sourceId} after ${LINK_RETRY_ATTEMPTS} attempts (${err?.message ?? err}); `
1537
+ + 'contradictions() will under-report this pair until re-linked',
1538
+ );
1539
+ }
1540
+ }
1541
+ }
1542
+
1543
+ /**
1544
+ * Bounded read-only poll after an ambiguous outcome. 'no-op' is deliberately
1545
+ * NOT trusted here: an aborted request's mutation may still be in flight
1546
+ * server-side, so "unchanged" only means "not yet" — the round-3 race. Only
1547
+ * a positive 'landed' (ours, verified) or 'conflict' (someone else moved it)
1548
+ * ends the poll early; anything less resolves to Indeterminate.
1549
+ */
1550
+ async _reconcileResolution(targetHandle, targetId, pre, expectedFact) {
1551
+ for (let attempt = 0; attempt < RESOLVE_RECONCILE_ATTEMPTS; attempt += 1) {
1552
+ await sleep(RESOLVE_RECONCILE_DELAY_MS);
1553
+ let post;
1554
+ try {
1555
+ post = await this.client.confidenceHistory(targetId);
1556
+ } catch {
1557
+ continue; // a failed read is a failed attempt, never its own error
1558
+ }
1559
+ const verdict = classifyResolution(pre, post, expectedFact);
1560
+ if (verdict === 'landed') return this._toConvictionResult(targetHandle, post);
1561
+ if (verdict === 'conflict') throw new FluidResolutionConflict(targetHandle);
1562
+ }
1563
+ throw new FluidResolutionIndeterminate(targetHandle);
1564
+ }
1565
+
1566
+ // -------------------------------------------------------------------------
1567
+ // Contradiction (CAP.CONTRADICTION) — COMP-FOH FOH-5
1568
+ // -------------------------------------------------------------------------
1569
+
1570
+ /**
1571
+ * The records that contradict `handle`. See the base-class JSDoc for the
1572
+ * caller-facing contract (best-effort lower bound, canonicalized, handle is
1573
+ * authority); the read machinery lives here:
1574
+ *
1575
+ * - Direction: a resolution writes `source CONTRADICTS target`, so the
1576
+ * records contradicting `handle` are its INCOMING CONTRADICTS neighbours.
1577
+ * - The target-deletion race (handle resolves, then vanishes before the
1578
+ * neighbours read) maps to FluidRecordNotFound, exactly as {@link conviction}.
1579
+ * - Each hit is canonicalized: a neighbour is emitted only when it IS the
1580
+ * canonical item for its own handle, so `hit.record` can never disagree
1581
+ * with a later `getRecord(hit.handle)`. Later duplicates are skipped.
1582
+ * - Per-neighbour failure is typed: a 404 is a deletion race (skip via
1583
+ * `getItem`'s null), a corrupt/non-fluid item is skipped (`_fromItem`
1584
+ * already warned), and ANY other fetch failure PROPAGATES — a silent
1585
+ * partial is worse than an error for a read that is already a lower bound.
1586
+ *
1587
+ * @param {string} handle
1588
+ * @returns {Promise<import('./provider.js').ContradictionHit[]>}
1589
+ */
1590
+ async contradictions(handle) {
1591
+ this.require(CAP.CONTRADICTION);
1592
+ if (!HANDLE_RE.test(handle ?? '')) throw new FluidRecordNotFound(handle, this.name());
1593
+ const item = await this._resolveOne(handle);
1594
+ if (!item) throw new FluidRecordNotFound(handle, this.name());
1595
+
1596
+ let neighbours;
1597
+ try {
1598
+ neighbours = await this.client.neighbors(String(item.item_id));
1599
+ } catch (err) {
1600
+ // Target deleted between resolve and read — the race conviction() maps.
1601
+ if (err?.status === 404) throw new FluidRecordNotFound(handle, this.name());
1602
+ throw err;
1603
+ }
1604
+
1605
+ const hits = [];
1606
+ const seen = new Set();
1607
+ for (const n of neighbours) {
1608
+ if (n?.link_type !== CONTRADICTS_EDGE || n?.direction !== 'incoming') continue;
1609
+ const sourceId = String(n.item_id);
1610
+
1611
+ // `getItem` returns null on 404 (deletion race → skip) and THROWS on any
1612
+ // other failure, which is exactly the "fail rather than under-report"
1613
+ // semantics — so it is deliberately not caught here.
1614
+ const raw = await this.client.getItem(sourceId);
1615
+ if (raw === null) continue;
1616
+
1617
+ const record = this._fromItem(raw);
1618
+ if (!record) continue; // corrupt / non-fluid: _fromItem already warned
1619
+
1620
+ // `_fromItem` guards a bad-JSON blob but NOT a parseable-but-schema-invalid
1621
+ // one (e.g. `{}` or a handle with no kind). Such a blob would otherwise be
1622
+ // emitted as a malformed hit, or — with no handle — crash `_resolveOne`'s
1623
+ // paired metadata filter below. A shared workspace can hold one, so skip it
1624
+ // exactly like a corrupt row rather than letting it break the whole read.
1625
+ if (!getFluidValidator().validate('record', record).valid) {
1626
+ process.emitWarning(
1627
+ `fluid(smartmemory): CONTRADICTS neighbour ${sourceId} holds a schema-invalid `
1628
+ + 'record blob; skipped',
1629
+ );
1630
+ continue;
1631
+ }
1632
+
1633
+ // Canonical-handle rule: emit only when this neighbour is the item
1634
+ // `getRecord(record.handle)` resolves to (the earliest). Otherwise the
1635
+ // returned record would disagree with the authority the handle names.
1636
+ const canonical = await this._resolveOne(record.handle);
1637
+ if (!canonical || String(canonical.item_id) !== sourceId) continue;
1638
+ if (seen.has(record.handle)) continue;
1639
+ seen.add(record.handle);
1640
+
1641
+ hits.push({ handle: record.handle, kind: record.kind, record });
1642
+ }
1643
+ return hits;
1644
+ }
1645
+
1646
+ // -------------------------------------------------------------------------
1647
+ // Lifecycle events
1648
+ // -------------------------------------------------------------------------
1649
+
1650
+ /**
1651
+ * Events are SEPARATE items on purpose.
1652
+ *
1653
+ * Stored inside a record's metadata they would be destroyed with the record,
1654
+ * and handle retirement is precisely what must outlive deletion. They are
1655
+ * written and never updated or deleted.
1656
+ */
1657
+ async appendEvent(event) {
1658
+ const full = { at: nowIso(), ...event };
1659
+ // The log is append-only, so a malformed entry is permanent. Validate
1660
+ // before it lands rather than on a later read with no caller to blame.
1661
+ assertValid('lifecycle_event', full, 'lifecycle event');
1662
+ await this.client.createItem({
1663
+ content: `${full.type} ${full.handle}`,
1664
+ memoryType: EVENT_WIRE_TYPE,
1665
+ metadata: {
1666
+ fluid_ns: EVENT_NS,
1667
+ handle: full.handle,
1668
+ fluid_event_json: JSON.stringify(full),
1669
+ },
1670
+ });
1671
+ return full;
1672
+ }
1673
+
1674
+ async readEvents(handle) {
1675
+ const items = await this._listAllItems({
1676
+ metadataKey: 'fluid_ns', metadataValue: EVENT_NS,
1677
+ });
1678
+ const events = [];
1679
+ for (const item of items) {
1680
+ const meta = item?.metadata ?? {};
1681
+ if (meta.fluid_ns !== EVENT_NS) continue;
1682
+ if (handle && meta.handle !== handle) continue;
1683
+ try {
1684
+ events.push(JSON.parse(meta.fluid_event_json));
1685
+ } catch {
1686
+ process.emitWarning(
1687
+ `fluid(smartmemory): event item ${item?.item_id} has an unreadable payload and was skipped`,
1688
+ );
1689
+ }
1690
+ }
1691
+ // Chronological, matching the floor's append-only file order. Handle
1692
+ // allocation reads this, so a stable order keeps allocation reproducible.
1693
+ return events.sort((a, b) => String(a?.at ?? '').localeCompare(String(b?.at ?? '')));
1694
+ }
1695
+ }