@remnic/core 9.3.708 → 9.3.710

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 (266) hide show
  1. package/dist/access-boundary.d.ts +7 -7
  2. package/dist/access-boundary.js +8 -8
  3. package/dist/access-cli.js +111 -24
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +6 -6
  6. package/dist/access-http.js +12 -12
  7. package/dist/access-mcp.d.ts +14 -6
  8. package/dist/access-mcp.js +11 -11
  9. package/dist/access-operations-batch.js +9 -9
  10. package/dist/access-operations.d.ts +28 -8
  11. package/dist/access-operations.js +14 -10
  12. package/dist/access-schema.d.ts +6 -6
  13. package/dist/{access-service-j1c1gptF.d.ts → access-service-CoIA0NrG.d.ts} +186 -3
  14. package/dist/access-service.d.ts +6 -6
  15. package/dist/access-service.js +7 -7
  16. package/dist/access-surface-catalog.d.ts +6 -6
  17. package/dist/access-surface-catalog.js +7 -0
  18. package/dist/access-surface-catalog.js.map +1 -1
  19. package/dist/action-confidence.d.ts +1 -1
  20. package/dist/active-memory-bridge.d.ts +1 -1
  21. package/dist/active-recall.d.ts +1 -1
  22. package/dist/active-recall.js +1 -1
  23. package/dist/behavior-learner.d.ts +1 -1
  24. package/dist/behavior-signals.d.ts +1 -1
  25. package/dist/bootstrap.d.ts +4 -4
  26. package/dist/briefing.d.ts +1 -1
  27. package/dist/briefing.js +3 -3
  28. package/dist/buffer-surprise-report.d.ts +1 -1
  29. package/dist/buffer.d.ts +1 -1
  30. package/dist/calibration.d.ts +1 -1
  31. package/dist/capabilities.d.ts +1 -1
  32. package/dist/{catalog-CKxilpzS.d.ts → catalog-DQCZrBjw.d.ts} +1 -1
  33. package/dist/causal-behavior.d.ts +1 -1
  34. package/dist/causal-consolidation.d.ts +1 -1
  35. package/dist/causal-consolidation.js +4 -4
  36. package/dist/{chunk-PDJQVAGM.js → chunk-2T4TDXPC.js} +14 -14
  37. package/dist/{chunk-W2WQ4LE7.js → chunk-35YJ6KCV.js} +2 -2
  38. package/dist/{chunk-C63MK3WL.js → chunk-3TCRU4JA.js} +2 -2
  39. package/dist/{chunk-WRGPE6AW.js → chunk-3XHD3XGK.js} +44 -1
  40. package/dist/chunk-3XHD3XGK.js.map +1 -0
  41. package/dist/{chunk-GKI6LX5L.js → chunk-54TI5GLV.js} +2 -2
  42. package/dist/{chunk-IVCQW4C4.js → chunk-7IBEWQLG.js} +2 -2
  43. package/dist/{chunk-Y6YHAGQP.js → chunk-7NDYFAJS.js} +2 -2
  44. package/dist/{chunk-SPETAWFE.js → chunk-AZVHBFI3.js} +2 -2
  45. package/dist/{chunk-MLLTU5FX.js → chunk-BOGENF7P.js} +1 -1
  46. package/dist/chunk-BOGENF7P.js.map +1 -0
  47. package/dist/{chunk-3PTOZJOI.js → chunk-BQCFXAMN.js} +2 -2
  48. package/dist/{chunk-4E2QCH46.js → chunk-CNVIWMQI.js} +2 -2
  49. package/dist/{chunk-4DZATVK5.js → chunk-CXMXAC5R.js} +3 -3
  50. package/dist/{chunk-STYMKPFT.js → chunk-EVX52NCY.js} +1459 -21
  51. package/dist/chunk-EVX52NCY.js.map +1 -0
  52. package/dist/{chunk-W2S3Z5MT.js → chunk-GMRNKPWO.js} +2 -2
  53. package/dist/{chunk-HIV5E57C.js → chunk-HSDJCT3V.js} +2 -2
  54. package/dist/{chunk-NM6TSEGQ.js → chunk-JNOYYWCA.js} +2 -2
  55. package/dist/{chunk-IONFO7UK.js → chunk-JQ7XVM4V.js} +3 -3
  56. package/dist/{chunk-2VQYHHWB.js → chunk-JSDZMOT7.js} +11 -2
  57. package/dist/chunk-JSDZMOT7.js.map +1 -0
  58. package/dist/{chunk-WSWYIKXX.js → chunk-KOEKDZ6A.js} +62 -5
  59. package/dist/chunk-KOEKDZ6A.js.map +1 -0
  60. package/dist/{chunk-WQADZ3ZY.js → chunk-NK3SPJLM.js} +20 -19
  61. package/dist/chunk-NK3SPJLM.js.map +1 -0
  62. package/dist/{chunk-WDH3KUZU.js → chunk-NXL5CVE7.js} +2 -2
  63. package/dist/{chunk-BGAHTI4C.js → chunk-OK7FUX6R.js} +6 -6
  64. package/dist/{chunk-24FGNOQS.js → chunk-P6PRSI3W.js} +85 -4
  65. package/dist/chunk-P6PRSI3W.js.map +1 -0
  66. package/dist/{chunk-66TSLESZ.js → chunk-PKE7EJMX.js} +2 -2
  67. package/dist/chunk-PKE7EJMX.js.map +1 -0
  68. package/dist/{chunk-MOXFPLD6.js → chunk-QXNFQKWU.js} +2 -2
  69. package/dist/{chunk-MRX6ZXHZ.js → chunk-S6FQLQGH.js} +2 -2
  70. package/dist/{chunk-6HPJMR5I.js → chunk-SKQCFAYU.js} +2 -2
  71. package/dist/{chunk-EJDAXR7O.js → chunk-VWB3HDY6.js} +56 -8
  72. package/dist/chunk-VWB3HDY6.js.map +1 -0
  73. package/dist/{chunk-N75N5SNX.js → chunk-XD33EX2F.js} +3 -3
  74. package/dist/{chunk-ESMY4RJ4.js → chunk-XKU4YE6Z.js} +2 -2
  75. package/dist/{chunk-XKMDDM7P.js → chunk-Y6PIFKXX.js} +2 -2
  76. package/dist/{chunk-RH2OSRQY.js → chunk-Z56IHRVV.js} +2 -2
  77. package/dist/{cli-CbT-pyM4.d.ts → cli-qex-L3GT.d.ts} +3 -3
  78. package/dist/cli.d.ts +6 -6
  79. package/dist/cli.js +26 -26
  80. package/dist/compounding/engine.d.ts +1 -1
  81. package/dist/compounding/engine.js +3 -3
  82. package/dist/compounding/preference-consolidator.d.ts +1 -1
  83. package/dist/compression-optimizer.d.ts +1 -1
  84. package/dist/config.d.ts +1 -1
  85. package/dist/config.js +1 -1
  86. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  87. package/dist/connectors/codex-materialize-runner.js +3 -3
  88. package/dist/connectors/codex-materialize.d.ts +1 -1
  89. package/dist/connectors/index.d.ts +1 -1
  90. package/dist/connectors/index.js +3 -3
  91. package/dist/consolidation-provenance-check.d.ts +1 -1
  92. package/dist/consolidation-undo.d.ts +1 -1
  93. package/dist/contradiction/index.d.ts +1 -1
  94. package/dist/conversation-index/backend.d.ts +1 -1
  95. package/dist/conversation-index/chunker.d.ts +1 -1
  96. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  97. package/dist/conversation-index/indexer.d.ts +1 -1
  98. package/dist/conversation-index/search.d.ts +1 -1
  99. package/dist/day-summary.d.ts +1 -1
  100. package/dist/delinearize.d.ts +1 -1
  101. package/dist/direct-answer-wiring.d.ts +1 -1
  102. package/dist/direct-answer.d.ts +1 -1
  103. package/dist/embedding-fallback.d.ts +1 -1
  104. package/dist/enrichment/index.d.ts +1 -1
  105. package/dist/entity-retrieval.d.ts +1 -1
  106. package/dist/entity-retrieval.js +3 -3
  107. package/dist/entity-schema.d.ts +1 -1
  108. package/dist/explicit-capture.d.ts +4 -4
  109. package/dist/extraction-faithfulness.d.ts +1 -1
  110. package/dist/extraction-judge-telemetry.d.ts +1 -1
  111. package/dist/extraction-judge-training.d.ts +1 -1
  112. package/dist/extraction-judge.d.ts +1 -1
  113. package/dist/extraction.d.ts +1 -1
  114. package/dist/fallback-llm.d.ts +1 -1
  115. package/dist/identity-continuity.d.ts +1 -1
  116. package/dist/importance.d.ts +1 -1
  117. package/dist/index.d.ts +9 -9
  118. package/dist/index.js +32 -32
  119. package/dist/intent.d.ts +1 -1
  120. package/dist/lcm/engine.d.ts +1 -1
  121. package/dist/lcm/index.d.ts +1 -1
  122. package/dist/lcm/tools.d.ts +1 -1
  123. package/dist/lifecycle.d.ts +1 -1
  124. package/dist/live-connectors-runner.d.ts +1 -1
  125. package/dist/local-llm.d.ts +1 -1
  126. package/dist/maintenance/memory-governance.d.ts +1 -1
  127. package/dist/maintenance/memory-governance.js +3 -3
  128. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  129. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  130. package/dist/mcp-memory-inspector-app.d.ts +6 -6
  131. package/dist/memory-action-policy.d.ts +1 -1
  132. package/dist/memory-cache.d.ts +1 -1
  133. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  134. package/dist/memory-projection-store.d.ts +1 -1
  135. package/dist/memory-provenance.d.ts +1 -1
  136. package/dist/memory-worth-outcomes.d.ts +1 -1
  137. package/dist/models-json.d.ts +1 -1
  138. package/dist/namespaces/migrate.d.ts +2 -2
  139. package/dist/namespaces/migrate.js +4 -4
  140. package/dist/namespaces/principal.d.ts +1 -1
  141. package/dist/namespaces/search.d.ts +1 -1
  142. package/dist/namespaces/storage.d.ts +2 -2
  143. package/dist/namespaces/storage.js +3 -3
  144. package/dist/native-knowledge.d.ts +1 -1
  145. package/dist/operator-toolkit.d.ts +1 -1
  146. package/dist/operator-toolkit.js +9 -9
  147. package/dist/orchestration/maintenance.d.ts +2 -2
  148. package/dist/orchestration/maintenance.js +5 -5
  149. package/dist/{orchestrator-D4ovYV3x.d.ts → orchestrator-DsVKLEBk.d.ts} +3 -3
  150. package/dist/orchestrator.d.ts +4 -4
  151. package/dist/orchestrator.js +15 -15
  152. package/dist/patterns-cli.d.ts +1 -1
  153. package/dist/policy-runtime.d.ts +1 -1
  154. package/dist/provenance.d.ts +1 -1
  155. package/dist/qmd-recall-cache.d.ts +1 -1
  156. package/dist/qmd.d.ts +1 -1
  157. package/dist/recall-disclosure-escalation.d.ts +1 -1
  158. package/dist/recall-explain-renderer.d.ts +1 -1
  159. package/dist/recall-explain-renderer.js +3 -3
  160. package/dist/recall-planner-llm.d.ts +1 -1
  161. package/dist/recall-state.d.ts +1 -1
  162. package/dist/recall-tag-filter.d.ts +1 -1
  163. package/dist/recall-xray-cli.d.ts +1 -1
  164. package/dist/recall-xray-cli.js +4 -4
  165. package/dist/recall-xray-renderer.d.ts +1 -1
  166. package/dist/recall-xray-renderer.js +3 -3
  167. package/dist/recall-xray.d.ts +1 -1
  168. package/dist/recall-xray.js +2 -2
  169. package/dist/resolve-auth-token.d.ts +1 -1
  170. package/dist/resume-bundles.js +2 -2
  171. package/dist/retrieval-agents.d.ts +1 -1
  172. package/dist/retrieval-tiers.d.ts +1 -1
  173. package/dist/routing/engine.d.ts +1 -1
  174. package/dist/routing/store.d.ts +1 -1
  175. package/dist/schemas.d.ts +2 -2
  176. package/dist/search/embed-helper.d.ts +1 -1
  177. package/dist/search/factory.d.ts +1 -1
  178. package/dist/search/index.d.ts +1 -1
  179. package/dist/search/lancedb-backend.d.ts +1 -1
  180. package/dist/search/meilisearch-backend.d.ts +1 -1
  181. package/dist/search/noop-backend.d.ts +1 -1
  182. package/dist/search/orama-backend.d.ts +1 -1
  183. package/dist/search/port.d.ts +1 -1
  184. package/dist/search/remote-backend.d.ts +1 -1
  185. package/dist/{semantic-consolidation-DyMUCsfN.d.ts → semantic-consolidation-_hVxkTuF.d.ts} +1 -1
  186. package/dist/semantic-consolidation.d.ts +2 -2
  187. package/dist/semantic-consolidation.js +4 -4
  188. package/dist/semantic-rule-promotion.js +3 -3
  189. package/dist/semantic-rule-verifier.d.ts +1 -1
  190. package/dist/semantic-rule-verifier.js +3 -3
  191. package/dist/session-observer-bands.d.ts +1 -1
  192. package/dist/session-observer-state.d.ts +1 -1
  193. package/dist/shared-context/manager.d.ts +1 -1
  194. package/dist/signal.d.ts +1 -1
  195. package/dist/storage.d.ts +8 -1
  196. package/dist/storage.js +2 -2
  197. package/dist/summarizer.d.ts +1 -1
  198. package/dist/summary-snapshot.d.ts +1 -1
  199. package/dist/temporal-supersession.d.ts +1 -1
  200. package/dist/temporal-validity.d.ts +1 -1
  201. package/dist/threading.d.ts +1 -1
  202. package/dist/tier-migration.d.ts +1 -1
  203. package/dist/tier-routing.d.ts +1 -1
  204. package/dist/topics.d.ts +1 -1
  205. package/dist/transcript.d.ts +1 -1
  206. package/dist/transfer/types.d.ts +12 -12
  207. package/dist/{types-Couvz-L3.d.ts → types-DUK4vVnN.d.ts} +27 -0
  208. package/dist/types.d.ts +1 -1
  209. package/dist/types.js +1 -1
  210. package/dist/utility-runtime.d.ts +1 -1
  211. package/dist/verified-recall.js +3 -3
  212. package/package.json +2 -2
  213. package/src/access-boundary.ts +2 -0
  214. package/src/access-cli.ts +107 -2
  215. package/src/access-http.ts +70 -1
  216. package/src/access-mcp.ts +65 -0
  217. package/src/access-operations.ts +122 -0
  218. package/src/access-service.ts +92 -0
  219. package/src/access-surface-catalog.test.ts +6 -1
  220. package/src/access-surface-catalog.ts +7 -0
  221. package/src/cli.ts +1 -0
  222. package/src/config.test.ts +10 -0
  223. package/src/config.ts +43 -0
  224. package/src/correction/correction-access-wiring.ts +887 -0
  225. package/src/correction/correction-contract.ts +416 -0
  226. package/src/correction/correction-executor.test.ts +742 -0
  227. package/src/correction/correction-executor.ts +473 -0
  228. package/src/correction/correction-planner.test.ts +446 -0
  229. package/src/correction/correction-planner.ts +546 -0
  230. package/src/correction/correction-service.ts +180 -0
  231. package/src/correction/correction-surfaces.test.ts +245 -0
  232. package/src/correction/index.ts +43 -0
  233. package/src/storage.ts +10 -0
  234. package/src/types.ts +27 -0
  235. package/dist/chunk-24FGNOQS.js.map +0 -1
  236. package/dist/chunk-2VQYHHWB.js.map +0 -1
  237. package/dist/chunk-66TSLESZ.js.map +0 -1
  238. package/dist/chunk-EJDAXR7O.js.map +0 -1
  239. package/dist/chunk-MLLTU5FX.js.map +0 -1
  240. package/dist/chunk-STYMKPFT.js.map +0 -1
  241. package/dist/chunk-WQADZ3ZY.js.map +0 -1
  242. package/dist/chunk-WRGPE6AW.js.map +0 -1
  243. package/dist/chunk-WSWYIKXX.js.map +0 -1
  244. /package/dist/{chunk-PDJQVAGM.js.map → chunk-2T4TDXPC.js.map} +0 -0
  245. /package/dist/{chunk-W2WQ4LE7.js.map → chunk-35YJ6KCV.js.map} +0 -0
  246. /package/dist/{chunk-C63MK3WL.js.map → chunk-3TCRU4JA.js.map} +0 -0
  247. /package/dist/{chunk-GKI6LX5L.js.map → chunk-54TI5GLV.js.map} +0 -0
  248. /package/dist/{chunk-IVCQW4C4.js.map → chunk-7IBEWQLG.js.map} +0 -0
  249. /package/dist/{chunk-Y6YHAGQP.js.map → chunk-7NDYFAJS.js.map} +0 -0
  250. /package/dist/{chunk-SPETAWFE.js.map → chunk-AZVHBFI3.js.map} +0 -0
  251. /package/dist/{chunk-3PTOZJOI.js.map → chunk-BQCFXAMN.js.map} +0 -0
  252. /package/dist/{chunk-4E2QCH46.js.map → chunk-CNVIWMQI.js.map} +0 -0
  253. /package/dist/{chunk-4DZATVK5.js.map → chunk-CXMXAC5R.js.map} +0 -0
  254. /package/dist/{chunk-W2S3Z5MT.js.map → chunk-GMRNKPWO.js.map} +0 -0
  255. /package/dist/{chunk-HIV5E57C.js.map → chunk-HSDJCT3V.js.map} +0 -0
  256. /package/dist/{chunk-NM6TSEGQ.js.map → chunk-JNOYYWCA.js.map} +0 -0
  257. /package/dist/{chunk-IONFO7UK.js.map → chunk-JQ7XVM4V.js.map} +0 -0
  258. /package/dist/{chunk-WDH3KUZU.js.map → chunk-NXL5CVE7.js.map} +0 -0
  259. /package/dist/{chunk-BGAHTI4C.js.map → chunk-OK7FUX6R.js.map} +0 -0
  260. /package/dist/{chunk-MOXFPLD6.js.map → chunk-QXNFQKWU.js.map} +0 -0
  261. /package/dist/{chunk-MRX6ZXHZ.js.map → chunk-S6FQLQGH.js.map} +0 -0
  262. /package/dist/{chunk-6HPJMR5I.js.map → chunk-SKQCFAYU.js.map} +0 -0
  263. /package/dist/{chunk-N75N5SNX.js.map → chunk-XD33EX2F.js.map} +0 -0
  264. /package/dist/{chunk-ESMY4RJ4.js.map → chunk-XKU4YE6Z.js.map} +0 -0
  265. /package/dist/{chunk-XKMDDM7P.js.map → chunk-Y6PIFKXX.js.map} +0 -0
  266. /package/dist/{chunk-RH2OSRQY.js.map → chunk-Z56IHRVV.js.map} +0 -0
@@ -0,0 +1,887 @@
1
+ /**
2
+ * correction/correction-access-wiring.ts — wires the CorrectionService to the
3
+ * orchestrator's storage / tombstone / search surfaces (issue #1580 PR 3).
4
+ *
5
+ * Keeps the access-service god-file thin: the service calls `createCorrectionService`
6
+ * with two namespace-policy callbacks and gets back a fully-wired
7
+ * {@link CorrectionService}. All the heavy lifting (search, LLM classify,
8
+ * tombstone emission, audit-record write, propagation) lives here, in a
9
+ * dedicated module, so the only growth in access-service.ts is the four
10
+ * one-line delegators.
11
+ *
12
+ * Design rules honored:
13
+ * - Caller-supplied namespaces are NEVER trusted raw — the service resolves
14
+ * them through the injected policy (rule 42).
15
+ * - Corrections flow through the existing storage chokepoints: writeMemory
16
+ * (catalog/dedup/reindex fire — rule 43), appendTombstone (#1579),
17
+ * writeMemoryFrontmatter (status flip + validUntil — #1578).
18
+ * - The LLM classify+draft adapter routes through the existing extraction
19
+ * engine (Responses API only, gotcha 1). On any LLM failure the adapter
20
+ * returns a deterministic fallback so the planner never throws (rule 13).
21
+ */
22
+
23
+ import path from "node:path";
24
+ import { mkdir, writeFile } from "node:fs/promises";
25
+ import type { Orchestrator } from "../orchestrator.js";
26
+ import type { MemoryFile, MemoryStatus, PluginConfig } from "../types.js";
27
+ import { stripAttributesSuffix } from "../structured-attributes.js";
28
+ import {
29
+ CorrectionContractError,
30
+ validateCorrectionAction,
31
+ type CorrectionAction,
32
+ type CorrectionOutcome,
33
+ type CorrectionPlan,
34
+ } from "./correction-contract.js";
35
+ import {
36
+ type LlmClassificationResult,
37
+ type PlannerCandidate,
38
+ type PlannerDeps,
39
+ } from "./correction-planner.js";
40
+ import { type ExecutorDeps, type ExecutorMemory } from "./correction-executor.js";
41
+ import { CorrectionService, type CorrectionServiceDeps } from "./correction-service.js";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Public entry: build a fully-wired CorrectionService
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * The two namespace-policy callbacks the access-service supplies. These are
49
+ * the ONLY surface-area touch-points between the correction modules and the
50
+ * access-service god-file — everything else is wired from the orchestrator.
51
+ */
52
+ export interface CorrectionAccessWiring {
53
+ orchestrator: Orchestrator;
54
+ /** Resolve the AUTHORIZED namespace for a plan/apply request (write ACL). */
55
+ resolveAuthorizedNamespace(request: {
56
+ namespace?: string;
57
+ sessionKey?: string;
58
+ principal?: string;
59
+ }): Promise<string>;
60
+ /** Resolve the caller's READABLE namespaces (scopes the planner's search). */
61
+ resolveReadableNamespaces(request: {
62
+ namespace?: string;
63
+ sessionKey?: string;
64
+ principal?: string;
65
+ }): readonly string[];
66
+ /** Whether the caller may WRITE a namespace — authorizes rescope destinations. */
67
+ canWriteNamespace(request: {
68
+ namespace: string;
69
+ sessionKey?: string;
70
+ principal?: string;
71
+ }): Promise<boolean>;
72
+ /**
73
+ * Optional LLM-complete callback (Responses API only — gotcha 1). When
74
+ * absent, the planner's classify+draft step falls back to deterministic
75
+ * mode (rule 13). The access-service wires the orchestrator's extraction
76
+ * LLM here once it exposes a public accessor; until then the contract
77
+ * ships with the safe fallback.
78
+ */
79
+ llmComplete?(request: {
80
+ system: string;
81
+ user: string;
82
+ }): Promise<string>;
83
+ }
84
+
85
+ export function createCorrectionService(wiring: CorrectionAccessWiring): CorrectionService {
86
+ const cfg = wiring.orchestrator.config;
87
+ // parseConfig now owns these (review thread Txp): the orchestrator's config
88
+ // carries correctionEnabled / correctionApplyRequiresConfirm /
89
+ // correctionMaxAffected / correctionPlanTtlHours as parsed fields. The
90
+ // loose-read helpers stay as a fallback only for tests that construct a
91
+ // PluginConfig-shaped object without running parseConfig.
92
+ const correctionEnabled = isCorrectionFeatureEnabled(cfg);
93
+ const applyRequiresConfirm =
94
+ typeof cfg.correctionApplyRequiresConfirm === "boolean"
95
+ ? cfg.correctionApplyRequiresConfirm
96
+ : readCorrectionFlag(cfg, "applyRequiresConfirm", true);
97
+ const maxAffected =
98
+ typeof cfg.correctionMaxAffected === "number" && cfg.correctionMaxAffected >= 1
99
+ ? Math.floor(cfg.correctionMaxAffected)
100
+ : readCorrectionNumber(cfg, "maxAffected", 10);
101
+ const planTtlHours =
102
+ typeof cfg.correctionPlanTtlHours === "number" && cfg.correctionPlanTtlHours > 0
103
+ ? cfg.correctionPlanTtlHours
104
+ : readCorrectionNumber(cfg, "planTtlHours", 24);
105
+ const biTemporalEnabled = cfg.temporalBiTemporal === true;
106
+
107
+ const serviceDeps: CorrectionServiceDeps = {
108
+ policy: {
109
+ resolveAuthorizedNamespace: (req) => wiring.resolveAuthorizedNamespace(req),
110
+ canWriteNamespace: (req) => wiring.canWriteNamespace(req),
111
+ readableNamespaces: (req) =>
112
+ Promise.resolve(wiring.resolveReadableNamespaces(req)),
113
+ },
114
+ plannerDeps: () =>
115
+ makePlannerDeps(wiring, { maxAffected, planTtlHours }),
116
+ executorDeps: () => makeExecutorDeps(wiring, { biTemporalEnabled }),
117
+ isEnabled: () => correctionEnabled,
118
+ applyRequiresConfirm: () => applyRequiresConfirm,
119
+ };
120
+ return new CorrectionService(serviceDeps);
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Planner deps
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function makePlannerDeps(
128
+ wiring: CorrectionAccessWiring,
129
+ opts: { maxAffected: number; planTtlHours: number },
130
+ ): PlannerDeps {
131
+ return {
132
+ searchCorpus: async ({ text, namespaces, limit }) =>
133
+ searchMemories(wiring, text, namespaces, limit),
134
+ resolveTargets: async ({ targetIds, namespaces }) =>
135
+ resolveTargetMemories(wiring, targetIds, namespaces),
136
+ expandNeighbors: async ({ seedIds, namespaces, limit }) =>
137
+ expandEntityNeighbors(wiring, seedIds, namespaces, limit),
138
+ classifyAndDraft: async ({ text, candidates }) =>
139
+ classifyAndDraft(wiring, text, candidates),
140
+ renderDiff: async ({ candidates, actions }) =>
141
+ renderCorrectionDiff(wiring, candidates, actions),
142
+ storageDir: async (namespace) => (await wiring.orchestrator.getStorage(namespace)).dir,
143
+ maxAffected: opts.maxAffected,
144
+ planTtlHours: opts.planTtlHours,
145
+ now: () => new Date(),
146
+ };
147
+ }
148
+
149
+ async function searchMemories(
150
+ wiring: CorrectionAccessWiring,
151
+ text: string,
152
+ namespaces: readonly string[],
153
+ limit: number,
154
+ ): Promise<PlannerCandidate[]> {
155
+ // Tokenized keyword search (review thread OgIql): natural-language
156
+ // corrections describe the new truth, not quote the old memory verbatim
157
+ // ("we migrated from Postgres to MySQL" vs a memory saying "Postgres is
158
+ // the database"). The old 32-char exact-prefix substring missed these.
159
+ // We tokenize the correction into keywords, score each active memory by
160
+ // keyword overlap, and return memories above a minimum threshold. This is
161
+ // deliberately lightweight (no embedding/QMD dependency) — the planner's
162
+ // job is to LOCATE candidates for the LLM classify+draft, not to rank with
163
+ // the full recall pipeline.
164
+ const tokens = tokenize(text);
165
+ if (tokens.length === 0) return [];
166
+ const scored: Array<{ m: MemoryFile; ns: string; score: number }> = [];
167
+ for (const ns of namespaces) {
168
+ const storage = await wiring.orchestrator.getStorage(ns);
169
+ const all = await storage.readAllMemories();
170
+ for (const m of all) {
171
+ if (m.frontmatter.status && m.frontmatter.status !== "active") continue;
172
+ const hay = `${m.content} ${m.frontmatter.tags?.join(" ") ?? ""}`.toLowerCase();
173
+ let hits = 0;
174
+ for (const tok of tokens) {
175
+ if (hay.includes(tok)) hits++;
176
+ }
177
+ if (hits === 0) continue;
178
+ // Overlap ratio: how many query tokens the memory covers.
179
+ scored.push({ m, ns, score: hits / tokens.length });
180
+ }
181
+ }
182
+ scored.sort((a, b) => b.score - a.score);
183
+ return scored.slice(0, limit).map((s, i) => toCandidate(s.m, s.ns, s.score - i * 0.01));
184
+ }
185
+
186
+ /** Tokenize correction text into lowercase search keywords (OgIql). */
187
+ function tokenize(text: string): string[] {
188
+ const STOP = new Set([
189
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
190
+ "to", "of", "in", "on", "at", "by", "for", "with", "from", "into",
191
+ "and", "or", "but", "not", "no", "yes", "this", "that", "these",
192
+ "those", "it", "its", "we", "you", "i", "he", "she", "they", "them",
193
+ "our", "your", "my", "his", "her", "their", "as", "so", "if", "then",
194
+ "than", "too", "very", "can", "will", "just", "should", "now", "has",
195
+ "have", "had", "do", "does", "did", "about", "which", "what", "who",
196
+ "when", "where", "why", "how", "all", "each", "every", "both", "few",
197
+ "more", "most", "other", "some", "such", "only", "own", "same", "up",
198
+ ]);
199
+ const raw = text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
200
+ const out = raw.filter((w) => w.length >= 3 && !STOP.has(w));
201
+ // De-dup while preserving order.
202
+ return [...new Set(out)];
203
+ }
204
+
205
+ async function resolveTargetMemories(
206
+ wiring: CorrectionAccessWiring,
207
+ targetIds: readonly string[],
208
+ namespaces: readonly string[],
209
+ ): Promise<PlannerCandidate[]> {
210
+ const out: PlannerCandidate[] = [];
211
+ const missing: string[] = [];
212
+ for (const id of targetIds) {
213
+ let found: PlannerCandidate | null = null;
214
+ for (const ns of namespaces) {
215
+ const storage = await wiring.orchestrator.getStorage(ns);
216
+ const m = await storage.getMemoryById(id);
217
+ if (m) {
218
+ found = toCandidate(m, ns, 1);
219
+ break;
220
+ }
221
+ }
222
+ if (found) out.push(found);
223
+ else missing.push(id);
224
+ }
225
+ if (missing.length > 0) {
226
+ throw new CorrectionContractError(`target memory not found: ${missing[0]}`);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ async function expandEntityNeighbors(
232
+ wiring: CorrectionAccessWiring,
233
+ seedIds: readonly string[],
234
+ namespaces: readonly string[],
235
+ limit: number,
236
+ ): Promise<PlannerCandidate[]> {
237
+ // 1-hop entity-graph neighbors via entityRef-tagged siblings. We expand
238
+ // across the AUTHORIZED namespaces the planner supplied (review thread PG8),
239
+ // NOT only `config.defaultNamespace` — a correction planned in a non-default
240
+ // writable namespace must only ever draft siblings from that same authorized
241
+ // scope, or apply will fail as not-found / mutate a same-ID memory in the
242
+ // wrong namespace. The conservative entityRef fallback searches every
243
+ // readable namespace and tags each candidate with the namespace it was read
244
+ // from; a richer graph expansion can ride on top later (rule 57 — additive).
245
+ if (seedIds.length === 0 || limit <= 0 || namespaces.length === 0) return [];
246
+ const out: PlannerCandidate[] = [];
247
+ const seen = new Set<string>(seedIds);
248
+ // Collect the seed entityRefs across every authorized namespace (a seed
249
+ // memory may live in any of them), then surface active siblings sharing one.
250
+ const seedRefs = new Set<string>();
251
+ for (const ns of namespaces) {
252
+ const storage = await wiring.orchestrator.getStorage(ns);
253
+ const all = await storage.readAllMemories();
254
+ for (const m of all) {
255
+ if (seedIds.includes(m.frontmatter.id) && m.frontmatter.entityRef) {
256
+ seedRefs.add(m.frontmatter.entityRef);
257
+ }
258
+ }
259
+ }
260
+ if (seedRefs.size === 0) return [];
261
+ for (const ns of namespaces) {
262
+ if (out.length >= limit) break;
263
+ const storage = await wiring.orchestrator.getStorage(ns);
264
+ const all = await storage.readAllMemories();
265
+ for (const m of all) {
266
+ if (out.length >= limit) break;
267
+ if (seen.has(m.frontmatter.id)) continue;
268
+ if (m.frontmatter.status && m.frontmatter.status !== "active") continue;
269
+ if (m.frontmatter.entityRef && seedRefs.has(m.frontmatter.entityRef)) {
270
+ out.push(toCandidate(m, ns, 0.5));
271
+ seen.add(m.frontmatter.id);
272
+ }
273
+ }
274
+ }
275
+ return out;
276
+ }
277
+
278
+ async function classifyAndDraft(
279
+ wiring: CorrectionAccessWiring,
280
+ text: string,
281
+ candidates: PlannerCandidate[],
282
+ ): Promise<LlmClassificationResult> {
283
+ // Route through the injected LLM callback (Responses API only — gotcha 1).
284
+ // On any failure (or when no callback is wired), return the deterministic
285
+ // fallback (rule 13) — the planner never throws on an LLM outage.
286
+ try {
287
+ if (!wiring.llmComplete) {
288
+ return fallbackClassification(candidates, "no LLM client available");
289
+ }
290
+ const user = buildClassifyPrompt(text, candidates);
291
+ const raw = await wiring.llmComplete({ system: CLASSIFY_SYSTEM_PROMPT, user });
292
+ return parseClassifyResponse(raw, candidates);
293
+ } catch (err) {
294
+ return fallbackClassification(candidates, `LLM unavailable: ${errMsg(err)}`);
295
+ }
296
+ }
297
+
298
+ async function renderCorrectionDiff(
299
+ _wiring: CorrectionAccessWiring,
300
+ candidates: PlannerCandidate[],
301
+ actions: CorrectionAction[],
302
+ ): Promise<string> {
303
+ // Render a human-readable unified-diff-style preview. We snapshot each
304
+ // affected memory via page-versioning (reuse, don't fork — rule 23) when
305
+ // versioning is configured; otherwise fall back to a textual summary.
306
+ const lines: string[] = [];
307
+ for (const action of actions) {
308
+ const id =
309
+ action.kind === "supersede"
310
+ ? action.loserId
311
+ : action.kind === "edit" || action.kind === "retract" || action.kind === "rescope"
312
+ ? action.memoryId
313
+ : null;
314
+ const candidate = id ? candidates.find((c) => c.memoryId === id) : null;
315
+ const before = candidate?.content ?? "(new)";
316
+ const after = describeAfterState(action);
317
+ lines.push(`--- ${id ?? "redaction-rule"} (${action.kind})`);
318
+ lines.push(`- ${before.slice(0, 120)}`);
319
+ lines.push(`+ ${after.slice(0, 120)}`);
320
+ }
321
+ return lines.join("\n");
322
+ }
323
+
324
+ function describeAfterState(action: CorrectionAction): string {
325
+ switch (action.kind) {
326
+ case "supersede":
327
+ return action.replacement?.content ?? "(superseded without replacement)";
328
+ case "edit":
329
+ return action.patch;
330
+ case "retract":
331
+ return "(retracted + tombstoned)";
332
+ case "rescope":
333
+ return `(moved to namespace '${action.toNamespace}')`;
334
+ case "redaction_rule":
335
+ return `(redaction rule persisted for pattern '${action.pattern}')`;
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Executor deps
341
+ // ---------------------------------------------------------------------------
342
+
343
+ function makeExecutorDeps(
344
+ wiring: CorrectionAccessWiring,
345
+ opts: { biTemporalEnabled: boolean },
346
+ ): ExecutorDeps {
347
+ return {
348
+ getMemory: async (namespace, memoryId) => getExecutorMemory(wiring, namespace, memoryId),
349
+ writeReplacement: async (namespace, draft) =>
350
+ writeReplacementMemory(wiring, namespace, draft),
351
+ applyEdit: async (namespace, memoryId, patch) =>
352
+ applyEditMemory(wiring, namespace, memoryId, patch),
353
+ retireMemory: async (namespace, memoryId, retireOpts) =>
354
+ retireMemoryFn(wiring, namespace, memoryId, retireOpts),
355
+ rescopeMemory: async (namespace, memoryId, toNamespace) =>
356
+ rescopeMemoryFn(wiring, namespace, memoryId, toNamespace),
357
+ appendTombstone: async (namespace, input) =>
358
+ appendTombstoneFn(wiring, namespace, input),
359
+ registerRedactionRule: async (namespace, pattern) =>
360
+ registerRedactionRuleFn(wiring, namespace, pattern),
361
+ appendAuditRecord: async (namespace, record) =>
362
+ appendAuditRecordFn(wiring, namespace, record),
363
+ propagate: async (namespace, touchedMemoryIds) =>
364
+ propagateFn(wiring, namespace, touchedMemoryIds),
365
+ biTemporalEnabled: opts.biTemporalEnabled,
366
+ now: () => new Date(),
367
+ };
368
+ }
369
+
370
+ async function getExecutorMemory(
371
+ wiring: CorrectionAccessWiring,
372
+ namespace: string,
373
+ memoryId: string,
374
+ ): Promise<ExecutorMemory | null> {
375
+ const storage = await wiring.orchestrator.getStorage(namespace);
376
+ const m = await storage.getMemoryById(memoryId);
377
+ if (!m) return null;
378
+ return toExecutorMemory(m);
379
+ }
380
+
381
+ async function writeReplacementMemory(
382
+ wiring: CorrectionAccessWiring,
383
+ namespace: string,
384
+ draft: {
385
+ content: string;
386
+ category?: string;
387
+ confidence?: number;
388
+ tags?: string[];
389
+ entityRef?: string;
390
+ validAt?: string;
391
+ observedAt?: string;
392
+ structuredAttributes?: Record<string, string>;
393
+ supersedes?: string;
394
+ },
395
+ ): Promise<string> {
396
+ const storage = await wiring.orchestrator.getStorage(namespace);
397
+ // writeMemory is the single storage chokepoint — catalog/dedup/reindex fire
398
+ // here (rule 43). Tombstone blocking also fires here (#1579), so a
399
+ // resurrected fact lands as pending_review rather than silently overwriting.
400
+ const id = await storage.writeMemory(
401
+ (draft.category ?? "fact") as Parameters<typeof storage.writeMemory>[0],
402
+ draft.content,
403
+ {
404
+ source: "correction",
405
+ confidence: draft.confidence ?? 0.9,
406
+ tags: draft.tags ?? [],
407
+ ...(draft.entityRef ? { entityRef: draft.entityRef } : {}),
408
+ ...(draft.validAt ? { validAt: draft.validAt } : {}),
409
+ ...(draft.observedAt ? { observedAt: draft.observedAt } : {}),
410
+ ...(draft.structuredAttributes ? { structuredAttributes: draft.structuredAttributes } : {}),
411
+ ...(draft.supersedes ? { supersedes: draft.supersedes } : {}),
412
+ },
413
+ );
414
+ return id;
415
+ }
416
+
417
+ async function applyEditMemory(
418
+ wiring: CorrectionAccessWiring,
419
+ namespace: string,
420
+ memoryId: string,
421
+ patch: string,
422
+ ): Promise<string> {
423
+ const storage = await wiring.orchestrator.getStorage(namespace);
424
+ const existing = await storage.getMemoryById(memoryId);
425
+ if (!existing) throw new CorrectionContractError(`memory not found for edit: ${memoryId}`);
426
+ // Apply the patch by overwriting content through the storage chokepoint.
427
+ // The StorageManager's writeMemoryFrontmatter snapshots the prior version
428
+ // internally when page-versioning is configured (issue #371), so every edit
429
+ // is revertable without the correction layer forking versioning logic.
430
+ await storage.writeMemoryFrontmatter(
431
+ { ...existing, content: patch },
432
+ { updated: new Date().toISOString() },
433
+ );
434
+ return memoryId;
435
+ }
436
+
437
+ async function retireMemoryFn(
438
+ wiring: CorrectionAccessWiring,
439
+ namespace: string,
440
+ memoryId: string,
441
+ opts: { status: "superseded" | "retracted"; supersededBy?: string; validUntil?: string },
442
+ ): Promise<void> {
443
+ const storage = await wiring.orchestrator.getStorage(namespace);
444
+ const memory = await storage.getMemoryById(memoryId);
445
+ if (!memory) throw new CorrectionContractError(`memory not found for retire: ${memoryId}`);
446
+ // Map the correction-domain status to the storage-domain MemoryStatus.
447
+ // `retracted` (a correction concept) becomes `forgotten` — the soft-delete
448
+ // status that excludes the memory from recall/browse/attribution while
449
+ // keeping a page-version snapshot for reversibility (#686). `superseded`
450
+ // maps to itself.
451
+ const storageStatus: MemoryStatus = opts.status === "retracted" ? "forgotten" : "superseded";
452
+ // Flip status + stamp validUntil (when bi-temporal is on, #1578) +
453
+ // link the superseder. writeMemoryFrontmatter is the chokepoint.
454
+ await storage.writeMemoryFrontmatter(memory, {
455
+ status: storageStatus,
456
+ ...(opts.supersededBy ? { supersededBy: opts.supersededBy } : {}),
457
+ ...(opts.status === "superseded" ? { supersededAt: new Date().toISOString() } : {}),
458
+ // validUntil is the bi-temporal end; absent when the gate is off.
459
+ ...(opts.validUntil ? { invalid_at: opts.validUntil } : {}),
460
+ });
461
+ }
462
+
463
+ async function rescopeMemoryFn(
464
+ wiring: CorrectionAccessWiring,
465
+ namespace: string,
466
+ memoryId: string,
467
+ toNamespace: string,
468
+ ): Promise<string> {
469
+ // The destination namespace is re-authorized by the service before the
470
+ // executor runs; here we perform the move atomically (write-then-unlink).
471
+ const sourceStorage = await wiring.orchestrator.getStorage(namespace);
472
+ const memory = await sourceStorage.getMemoryById(memoryId);
473
+ if (!memory) throw new CorrectionContractError(`memory not found for rescope: ${memoryId}`);
474
+ if (memory.frontmatter.status && memory.frontmatter.status !== "active") {
475
+ // Don't copy a stale source: rescoping a superseded/retracted/archived
476
+ // memory duplicates outdated content into the destination (thread Ohjwb).
477
+ throw new CorrectionContractError(
478
+ `cannot rescope memory ${memoryId}: source is ${memory.frontmatter.status}, not active`,
479
+ );
480
+ }
481
+ const destStorage = await wiring.orchestrator.getStorage(toNamespace);
482
+ const fm = memory.frontmatter;
483
+ // Strip the `[Attributes: …]` suffix writeMemory appended to the source body
484
+ // (review thread Of0p6): we forward `structuredAttributes` separately, so
485
+ // writeMemory will re-append the suffix on the destination. Without this
486
+ // strip the destination would carry the suffix TWICE, producing duplicated
487
+ // attribute text and a different content hash/index entry.
488
+ const destContent = fm.structuredAttributes
489
+ ? stripAttributesSuffix(memory.content)
490
+ : memory.content;
491
+ const destId = await destStorage.writeMemory(fm.category, destContent, {
492
+ source: `correction:rescope:${namespace}`,
493
+ ...(typeof fm.confidence === "number" ? { confidence: fm.confidence } : {}),
494
+ ...(Array.isArray(fm.tags) ? { tags: fm.tags } : {}),
495
+ ...(fm.entityRef ? { entityRef: fm.entityRef } : {}),
496
+ ...(fm.structuredAttributes ? { structuredAttributes: fm.structuredAttributes } : {}),
497
+ ...(fm.valid_at ? { validAt: fm.valid_at } : {}),
498
+ ...(fm.observedAt ? { observedAt: fm.observedAt } : {}),
499
+ ...(fm.memoryKind ? { memoryKind: fm.memoryKind } : {}),
500
+ ...(Array.isArray(fm.links) ? { links: fm.links } : {}),
501
+ ...(fm.intentGoal ? { intentGoal: fm.intentGoal } : {}),
502
+ });
503
+ // Unlink the source by archiving (non-destructive — rule 25). If the archive
504
+ // fails AFTER the destination write succeeded, compensate by archiving the
505
+ // destination too so no duplicate ACTIVE fact remains, then re-throw so the
506
+ // executor records the action as failed (review: rescope-duplicates-on-fail).
507
+ try {
508
+ await sourceStorage.writeMemoryFrontmatter(memory, {
509
+ status: "archived",
510
+ archivedAt: new Date().toISOString(),
511
+ });
512
+ } catch (err) {
513
+ try {
514
+ const destMem = await destStorage.getMemoryById(destId);
515
+ if (destMem) {
516
+ await destStorage.writeMemoryFrontmatter(destMem, {
517
+ status: "archived",
518
+ archivedAt: new Date().toISOString(),
519
+ });
520
+ }
521
+ } catch {
522
+ // best-effort compensation
523
+ }
524
+ throw err;
525
+ }
526
+ return destId;
527
+ }
528
+
529
+ async function appendTombstoneFn(
530
+ wiring: CorrectionAccessWiring,
531
+ namespace: string,
532
+ input: {
533
+ reason: "correction" | "supersession" | "retraction";
534
+ sourceMemoryId: string;
535
+ rawContent: string;
536
+ entityRef?: string;
537
+ supersessionKey?: string;
538
+ },
539
+ ): Promise<string | null> {
540
+ const storage = await wiring.orchestrator.getStorage(namespace);
541
+ // storage.appendTombstone returns null for TWO reasons: tombstones disabled
542
+ // (off = pre-feature behavior) OR a swallowed store error (it catches I/O
543
+ // failures and returns null — review thread OgIqp). The executor writes the
544
+ // tombstone BEFORE retiring the source (PG9), so when tombstones are
545
+ // enabled a null return means persistence failed and the retire must NOT
546
+ // proceed (no tombstone → resurrection window). Distinguish the two cases
547
+ // via the public isTombstonesEnabled() accessor; when disabled, null is the
548
+ // expected pre-feature return and the action may still succeed.
549
+ const enabled =
550
+ typeof storage.isTombstonesEnabled === "function"
551
+ ? storage.isTombstonesEnabled()
552
+ : true;
553
+ const result = await storage.appendTombstone({
554
+ reason: input.reason,
555
+ createdBy: "user_correction",
556
+ sourceMemoryId: input.sourceMemoryId,
557
+ rawContent: input.rawContent,
558
+ ...(input.entityRef ? { entityRef: input.entityRef } : {}),
559
+ ...(input.supersessionKey ? { supersessionKey: input.supersessionKey } : {}),
560
+ });
561
+ if (result === null && enabled) {
562
+ throw new CorrectionContractError(
563
+ `tombstone persistence failed for memory ${input.sourceMemoryId} (tombstones enabled but store returned null — I/O error swallowed)`,
564
+ );
565
+ }
566
+ return result;
567
+ }
568
+
569
+ async function registerRedactionRuleFn(
570
+ wiring: CorrectionAccessWiring,
571
+ namespace: string,
572
+ pattern: string,
573
+ ): Promise<void> {
574
+ // Persist the redaction rule under state/ so extraction consults it the
575
+ // same way tombstones are consulted (route through the same chokepoint).
576
+ const storage = await wiring.orchestrator.getStorage(namespace);
577
+ const dir = path.join(storage.dir, "state", "corrections", "redaction-rules");
578
+ await mkdir(dir, { recursive: true });
579
+ // Idempotent: filename is a slug of the pattern so re-registering the same
580
+ // pattern overwrites rather than duplicates.
581
+ const slug = pattern.replace(/[^a-zA-Z0-9]+/g, "-").slice(0, 64) || "rule";
582
+ await writeFile(
583
+ path.join(dir, `${slug}.json`),
584
+ `${JSON.stringify({ pattern, namespace, createdAt: new Date().toISOString() })}\n`,
585
+ "utf-8",
586
+ );
587
+ }
588
+
589
+ async function appendAuditRecordFn(
590
+ wiring: CorrectionAccessWiring,
591
+ namespace: string,
592
+ record: {
593
+ planId: string;
594
+ classification: CorrectionPlan["classification"];
595
+ outcome: CorrectionOutcome;
596
+ requestText: string;
597
+ },
598
+ ): Promise<string> {
599
+ const storage = await wiring.orchestrator.getStorage(namespace);
600
+ // Corrections are themselves memories, searchable and namespaced (issue
601
+ // #1580 design §4). Write a correction-category memory capturing the
602
+ // plan + outcome as the audit trail.
603
+ const id = await storage.writeMemory("correction", buildAuditBody(record), {
604
+ source: "correction-contract",
605
+ confidence: 1.0,
606
+ tags: ["correction-audit", `plan:${record.planId}`, `classification:${record.classification}`],
607
+ });
608
+ return id;
609
+ }
610
+
611
+ function buildAuditBody(record: {
612
+ planId: string;
613
+ classification: CorrectionPlan["classification"];
614
+ outcome: CorrectionOutcome;
615
+ requestText: string;
616
+ }): string {
617
+ // Never-store / redaction corrections carry the very secret/pattern the user
618
+ // asked Remnic NOT to retain — withhold the request text from the durable
619
+ // audit memory so we don't persist it verbatim (#1580 review, P1).
620
+ const sensitive =
621
+ record.classification === "never_store" ||
622
+ record.outcome.results.some((r) => r.action.kind === "redaction_rule");
623
+ const safeRequest = sensitive
624
+ ? "[redacted — never-store/redaction correction text withheld from the audit trail]"
625
+ : record.requestText.slice(0, 200);
626
+ const lines = [
627
+ `Correction plan ${record.planId} applied (${record.outcome.status}).`,
628
+ "",
629
+ `Request: ${safeRequest}`,
630
+ `Classification: ${record.classification}`,
631
+ `Applied at: ${record.outcome.appliedAt}`,
632
+ "",
633
+ "Actions:",
634
+ ];
635
+ for (const r of record.outcome.results) {
636
+ // Never-store/redaction action errors can echo the secret/pattern — withhold
637
+ // the error text for those actions (review thread OhjwW).
638
+ const withhold =
639
+ r.action.kind === "redaction_rule" || record.classification === "never_store";
640
+ const errPart = r.error ? (withhold ? " (error withheld)" : ` (${r.error})`) : "";
641
+ lines.push(` - ${r.action.kind}: ${r.status}${errPart}`);
642
+ }
643
+ return lines.join("\n");
644
+ }
645
+
646
+ async function propagateFn(
647
+ wiring: CorrectionAccessWiring,
648
+ namespace: string,
649
+ touchedMemoryIds: readonly string[],
650
+ ): Promise<void> {
651
+ // Best-effort post-write propagation. The orchestrator's indexPersistedMemory
652
+ // fires the QMD reindex for a touched file (checklist §31). A failure here
653
+ // is non-fatal — the executor records it as a warning, never a failed action.
654
+ for (const id of touchedMemoryIds) {
655
+ try {
656
+ // indexPersistedMemory is keyed by the namespace's storage, NOT the
657
+ // default namespace (review thread: propagation-hardcodes-default-ns).
658
+ const storage = await wiring.orchestrator.getStorage(namespace);
659
+ const orchestrator = wiring.orchestrator as unknown as {
660
+ indexPersistedMemory?(storage: unknown, memoryId: string): Promise<void>;
661
+ };
662
+ if (typeof orchestrator.indexPersistedMemory === "function") {
663
+ await orchestrator.indexPersistedMemory(storage, id);
664
+ }
665
+ } catch {
666
+ // Swallow — propagation is best-effort.
667
+ }
668
+ }
669
+ }
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Helpers
673
+ // ---------------------------------------------------------------------------
674
+
675
+
676
+ function toCandidate(m: MemoryFile, namespace: string, score: number): PlannerCandidate {
677
+ return {
678
+ memoryId: m.frontmatter.id,
679
+ path: m.path,
680
+ content: m.content,
681
+ excerpt: m.content.slice(0, 160),
682
+ ...(m.frontmatter.entityRef ? { entityRef: m.frontmatter.entityRef } : {}),
683
+ score,
684
+ // namespace is implicit (the planner scopes by namespace); keep it on the
685
+ // candidate for diff rendering if needed.
686
+ } satisfies PlannerCandidate & { namespace?: string };
687
+ }
688
+
689
+ function toExecutorMemory(m: MemoryFile): ExecutorMemory {
690
+ const fm = m.frontmatter;
691
+ // The tombstone hash must use the ORIGINAL unsuffixed body: writeMemory
692
+ // appends an `[Attributes: …]` suffix when structuredAttributes are set, so
693
+ // hashing m.content would never match the pre-suffix content hash (thread
694
+ // OhX2N, rule 23). Strip the suffix when attributes are present.
695
+ const rawBody = fm.structuredAttributes ? stripAttributesSuffix(m.content) : m.content;
696
+ return {
697
+ memoryId: fm.id,
698
+ content: m.content,
699
+ category: fm.category,
700
+ rawContent: rawBody,
701
+ ...(fm.entityRef ? { entityRef: fm.entityRef } : {}),
702
+ } satisfies ExecutorMemory;
703
+ }
704
+
705
+ /**
706
+ * The single source of truth for whether the Correction Contract feature is
707
+ * enabled. Reads BOTH config shapes so tool visibility and the runtime gate
708
+ * can never drift out of sync (review thread: correction-gate-config-mismatch):
709
+ * - nested: `config.correction.enabled`
710
+ * - flat: the `correctionEnabled` legacy key (ratchet-safe shape)
711
+ * Nested wins when present; both default to `true` (plan is read-only, safe on).
712
+ */
713
+ export function isCorrectionFeatureEnabled(config: PluginConfig): boolean {
714
+ // parseConfig now resolves this into `config.correctionEnabled` (review
715
+ // thread Txp) — prefer the parsed boolean so operator config actually takes
716
+ // effect. The loose nested/flat read stays as a fallback for PluginConfig-
717
+ // shaped objects built without parseConfig (unit tests).
718
+ if (typeof config.correctionEnabled === "boolean") return config.correctionEnabled;
719
+ const nested = (config as unknown as Record<string, unknown>).correction as
720
+ | Record<string, unknown>
721
+ | undefined;
722
+ if (nested && typeof nested.enabled === "boolean") return nested.enabled;
723
+ if (nested && typeof nested.enabled === "string") return nested.enabled === "true" || nested.enabled === "1";
724
+ return readCorrectionFlag(config, "enabled", true);
725
+ }
726
+
727
+ /** Read a boolean correction flag from the loosely-typed config (ratchet-safe). */
728
+ function readCorrectionFlag(config: PluginConfig, key: string, fallback: boolean): boolean {
729
+ // Nested shape wins: config.correction.<key> (review thread: nested-correction-settings).
730
+ const nested = (config as unknown as Record<string, unknown>).correction as
731
+ | Record<string, unknown>
732
+ | undefined;
733
+ if (nested && typeof nested[key] === "boolean") return nested[key] as boolean;
734
+ if (nested && typeof nested[key] === "string") return (nested[key] as string) === "true" || (nested[key] as string) === "1";
735
+ // Flat legacy shape: config.correction<Key>.
736
+ const raw = (config as unknown as Record<string, unknown>)[`correction${capitalize(key)}`];
737
+ if (typeof raw === "boolean") return raw;
738
+ if (typeof raw === "string") return raw === "true" || raw === "1";
739
+ return fallback;
740
+ }
741
+
742
+ /** Read a numeric correction value from the loosely-typed config (ratchet-safe). */
743
+ function readCorrectionNumber(config: PluginConfig, key: string, fallback: number): number {
744
+ // Nested shape wins: config.correction.<key> (review thread: nested-correction-settings).
745
+ const nested = (config as unknown as Record<string, unknown>).correction as
746
+ | Record<string, unknown>
747
+ | undefined;
748
+ if (nested) {
749
+ const nv = nested[key];
750
+ if (typeof nv === "number" && Number.isFinite(nv)) return nv;
751
+ if (typeof nv === "string") {
752
+ const nn = Number(nv);
753
+ if (Number.isFinite(nn)) return nn;
754
+ }
755
+ }
756
+ const raw = (config as unknown as Record<string, unknown>)[`correction${capitalize(key)}`];
757
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
758
+ if (typeof raw === "string") {
759
+ const n = Number(raw);
760
+ if (Number.isFinite(n)) return n;
761
+ }
762
+ return fallback;
763
+ }
764
+
765
+ function capitalize(s: string): string {
766
+ return s.charAt(0).toUpperCase() + s.slice(1);
767
+ }
768
+
769
+ function errMsg(err: unknown): string {
770
+ if (err instanceof Error) return err.message;
771
+ return String(err);
772
+ }
773
+
774
+ // ---------------------------------------------------------------------------
775
+ // LLM classify+draft (Responses API only — gotcha 1)
776
+ // ---------------------------------------------------------------------------
777
+
778
+ const CLASSIFY_SYSTEM_PROMPT = `You classify memory corrections and draft per-memory actions.
779
+
780
+ Given a correction statement and candidate memories, respond with a JSON object:
781
+ {
782
+ "classification": "wrong" | "outdated" | "incomplete" | "wrong_scope" | "never_store",
783
+ "confidence": <number 0..1>,
784
+ "actions": [<one or more correction actions>],
785
+ "relevance": [{"memoryId": "<id>", "why": "<one short sentence>"}]
786
+ }
787
+
788
+ Action shapes:
789
+ - {"kind":"supersede","loserId":"<id>","replacement":{"content":"<new fact>"}}
790
+ - {"kind":"edit","memoryId":"<id>","patch":"<new full content>"}
791
+ - {"kind":"retract","memoryId":"<id>"}
792
+ - {"kind":"rescope","memoryId":"<id>","toNamespace":"<ns>"}
793
+ - {"kind":"redaction_rule","pattern":"<bounded literal or regex>"}
794
+
795
+ Only emit actions you are confident in. If uncertain, return confidence < 0.5 and few actions.`;
796
+
797
+ function buildClassifyPrompt(text: string, candidates: PlannerCandidate[]): string {
798
+ const lines = [
799
+ `Correction: ${text}`,
800
+ "",
801
+ "Candidate memories:",
802
+ ];
803
+ for (const c of candidates.slice(0, 20)) {
804
+ lines.push(`[${c.memoryId}] ${c.excerpt}`);
805
+ }
806
+ lines.push("", "Respond with the JSON object only.");
807
+ return lines.join("\n");
808
+ }
809
+
810
+ function parseClassifyResponse(
811
+ raw: string,
812
+ candidates: PlannerCandidate[],
813
+ ): LlmClassificationResult {
814
+ let parsed: unknown;
815
+ try {
816
+ parsed = JSON.parse(raw);
817
+ } catch {
818
+ return fallbackClassification(candidates, "LLM returned non-JSON response");
819
+ }
820
+ if (!parsed || typeof parsed !== "object") {
821
+ return fallbackClassification(candidates, "LLM returned non-object response");
822
+ }
823
+ const obj = parsed as Record<string, unknown>;
824
+ const classification = isClassification(obj.classification) ? obj.classification : "outdated";
825
+ const confidence = typeof obj.confidence === "number" ? Math.min(1, Math.max(0, obj.confidence)) : 0.5;
826
+ const rawActions = Array.isArray(obj.actions) ? obj.actions : [];
827
+ const actions: CorrectionAction[] = [];
828
+ const warnings: string[] = [];
829
+ for (const rawAction of rawActions) {
830
+ try {
831
+ validateCorrectionAction(rawAction);
832
+ actions.push(rawAction);
833
+ } catch (err) {
834
+ warnings.push(`dropped malformed action: ${errMsg(err)}`);
835
+ }
836
+ }
837
+ const relevance = Array.isArray(obj.relevance)
838
+ ? (obj.relevance as unknown[])
839
+ .filter((r): r is Record<string, unknown> => !!r && typeof r === "object")
840
+ .map((r) => ({
841
+ memoryId: typeof r.memoryId === "string" ? r.memoryId : "",
842
+ why: typeof r.why === "string" ? r.why : "",
843
+ }))
844
+ .filter((r) => r.memoryId.length > 0)
845
+ : [];
846
+ return {
847
+ classification,
848
+ confidence,
849
+ actions,
850
+ relevance,
851
+ warnings,
852
+ };
853
+ }
854
+
855
+ function isClassification(value: unknown): value is CorrectionPlan["classification"] {
856
+ return (
857
+ value === "wrong" ||
858
+ value === "outdated" ||
859
+ value === "incomplete" ||
860
+ value === "wrong_scope" ||
861
+ value === "never_store"
862
+ );
863
+ }
864
+
865
+ function fallbackClassification(
866
+ candidates: PlannerCandidate[],
867
+ reason: string,
868
+ ): LlmClassificationResult {
869
+ return {
870
+ classification: "outdated",
871
+ confidence: 0,
872
+ actions: [],
873
+ relevance: candidates.map((c) => ({ memoryId: c.memoryId, why: "located for review" })),
874
+ warnings: [reason],
875
+ fallback: true,
876
+ };
877
+ }
878
+
879
+ // Re-export the local helpers for tests that import this module directly.
880
+ // (deterministicFallbackPlan and newPlanId live in correction-contract.ts and
881
+ // are re-exported by the barrel from there — not duplicated here.)
882
+ export {
883
+ buildAuditBody,
884
+ buildClassifyPrompt,
885
+ CLASSIFY_SYSTEM_PROMPT,
886
+ parseClassifyResponse,
887
+ };