@smartmemory/compose 0.3.6-beta → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
@@ -37,14 +37,23 @@
37
37
  */
38
38
  import {
39
39
  readFileSync, writeFileSync, renameSync, unlinkSync,
40
- mkdirSync, rmSync, statSync, existsSync, utimesSync,
40
+ mkdirSync, rmSync, statSync, existsSync, utimesSync, readdirSync,
41
41
  } from 'node:fs';
42
42
  import { join, dirname } from 'node:path';
43
43
 
44
44
  import { checkOrInsert } from './idempotency.js';
45
45
  import { appendEvent } from './feature-events.js';
46
- import { createJudgmentStore } from './judgment/store/index.js';
46
+ import {
47
+ createJudgmentStore,
48
+ effectiveStore,
49
+ goalCutoverComplete,
50
+ } from './judgment/store/index.js';
51
+ import { buildSupersessionIndex, tracePosition } from './judgment/trace.js';
52
+ import { syncManifest } from './judgment-attest.js';
47
53
  import { regenerateProjections } from './judgment-gen.js';
54
+ import { getJudgmentValidator } from './judgment/schema.js';
55
+ import { ledgerEntryToDecision } from './judgment-decisions.js';
56
+ import { writeJudgmentDecision } from './judgment-decision-write.js';
48
57
  import {
49
58
  assertValidRecord,
50
59
  assertGrounding,
@@ -97,7 +106,15 @@ async function acquireJudgmentLock(cwd) {
97
106
  while (true) {
98
107
  try {
99
108
  mkdirSync(path);
100
- writeFileSync(ownerFile, token);
109
+ try {
110
+ writeFileSync(ownerFile, token);
111
+ } catch (err) {
112
+ // The lock dir exists but carries no owner token: every later release
113
+ // would decline to remove it (not provably ours), stranding the lock
114
+ // until the stale window expires. Undo our own partial acquisition.
115
+ rmSync(path, { recursive: true, force: true });
116
+ throw err;
117
+ }
101
118
  const heartbeat = setInterval(() => {
102
119
  try { utimesSync(path, new Date(), new Date()); } catch { /* lock stolen/gone — release will no-op */ }
103
120
  }, LOCK_HEARTBEAT_MS);
@@ -125,6 +142,15 @@ async function acquireJudgmentLock(cwd) {
125
142
  }
126
143
  }
127
144
 
145
+ export async function withJudgmentLock(cwd, fn) {
146
+ const release = await acquireJudgmentLock(cwd);
147
+ try {
148
+ return await fn();
149
+ } finally {
150
+ release();
151
+ }
152
+ }
153
+
128
154
  // ---------------------------------------------------------------------------
129
155
  // Provenance + config
130
156
  // ---------------------------------------------------------------------------
@@ -141,222 +167,2672 @@ function stampProvenance(internal = {}) {
141
167
  session: process.env.CLAUDE_SESSION_ID || null,
142
168
  written_at: internal.writtenAt ?? new Date().toISOString(),
143
169
  };
144
- if (internal.via) provenance.via = internal.via;
145
- return provenance;
170
+ if (internal.via) provenance.via = internal.via;
171
+ if (internal.intentId) provenance.intent_id = internal.intentId;
172
+ return provenance;
173
+ }
174
+
175
+ function guardEnabled(cwd) {
176
+ try {
177
+ const config = JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf8'));
178
+ return config?.capabilities?.guard === true;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Compensating rollback
186
+ // ---------------------------------------------------------------------------
187
+
188
+ function atomicWrite(path, content) {
189
+ const tmp = `${path}.tmp.${process.pid}`;
190
+ try {
191
+ writeFileSync(tmp, content);
192
+ renameSync(tmp, path);
193
+ } catch (err) {
194
+ try { unlinkSync(tmp); } catch { /* tmp may not exist */ }
195
+ throw err;
196
+ }
197
+ }
198
+
199
+ class UndoLog {
200
+ constructor() {
201
+ this._entries = [];
202
+ this._paths = new Set();
203
+ }
204
+
205
+ /** Capture a mutable file's exact preimage before its first overwrite. */
206
+ capture(path) {
207
+ if (this._paths.has(path)) return;
208
+ this._paths.add(path);
209
+ this._entries.push({ path, prior: existsSync(path) ? readFileSync(path, 'utf8') : null });
210
+ }
211
+
212
+ /** Mark a newly created file so compensation removes it. */
213
+ created(path) {
214
+ if (this._paths.has(path)) return;
215
+ this._paths.add(path);
216
+ this._entries.push({ path, prior: null });
217
+ }
218
+
219
+ touchedPaths() {
220
+ return [...this._paths];
221
+ }
222
+
223
+ restore() {
224
+ for (const { path, prior } of [...this._entries].reverse()) {
225
+ try {
226
+ if (prior === null) { try { unlinkSync(path); } catch { /* gone */ } } else atomicWrite(path, prior);
227
+ } catch { /* best-effort compensation */ }
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Run record mutations + projection regen with compensating rollback: on any
234
+ * failure the touched records are restored, projections re-regenerated from
235
+ * the restored records, and JUDGMENT_PARTIAL_WRITE surfaces the cause.
236
+ */
237
+ function commitWithProjections(cwd, undo, mutate) {
238
+ try {
239
+ mutate();
240
+ regenerateProjections(cwd);
241
+ syncManifest(cwd, undo.touchedPaths());
242
+ } catch (err) {
243
+ undo.restore();
244
+ try { regenerateProjections(cwd); } catch { /* projections follow restored records on next regen */ }
245
+ try { syncManifest(cwd, undo.touchedPaths()); } catch { /* manifest follows restored records on next write */ }
246
+ throw typedError('JUDGMENT_PARTIAL_WRITE', `judgment write rolled back: ${err.message}`, err);
247
+ }
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Intent replay (the reconciler)
252
+ // ---------------------------------------------------------------------------
253
+
254
+ let _intentCounter = 0;
255
+ function newIntentId() {
256
+ _intentCounter += 1;
257
+ return `intent-${Date.now()}-${process.pid}-${_intentCounter}`;
258
+ }
259
+
260
+ async function callGuard(cwd, { slug, from, to }) {
261
+ // Lazy import: keeps this module leaf-light and cycle-free; the adapter is
262
+ // only loaded where capabilities.guard is on.
263
+ const { guardedTransition } = await import('../server/lifecycle-guard.js');
264
+ return guardedTransition({ featureCode: slug, from, to, workspaceRoot: cwd, mode: 'judgment' });
265
+ }
266
+
267
+ /** Apply an intent payload idempotently (joint write, deduped events, predictions). */
268
+ function applyPayload(store, payload) {
269
+ if (payload.joint) store.writeJoint(payload.joint);
270
+ if (Array.isArray(payload.events) && payload.events.length > 0) {
271
+ const existing = new Set(store.readLedgerEvents().map((e) => JSON.stringify(e)));
272
+ for (const event of payload.events) {
273
+ const serialized = JSON.stringify(event);
274
+ if (!existing.has(serialized)) {
275
+ store.appendLedgerEvent(event);
276
+ existing.add(serialized);
277
+ }
278
+ }
279
+ }
280
+ for (const prediction of payload.predictions ?? []) {
281
+ const existing = store.readPrediction(prediction.id);
282
+ if (existing && JSON.stringify(existing) !== JSON.stringify(prediction)) {
283
+ throw typedError(
284
+ 'JUDGMENT_CONFLICT',
285
+ `intent prediction ${prediction.id} conflicts with an existing record`,
286
+ );
287
+ }
288
+ if (!existing) store.writePrediction(prediction);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Drop an intent DURABLY: the ledger note lands (idempotent on its title,
294
+ * which embeds the intent id) BEFORE the intent is cleared. If the append
295
+ * fails the intent survives and the error propagates — a dropped divergence
296
+ * must never silently vanish.
297
+ */
298
+ function dropIntentDurably(cwd, store, intent, reason, detail) {
299
+ const payload = intent.payload ?? {};
300
+ const title = `intent dropped (${intent.id}): ${payload.slug} ${payload.from} → ${payload.to} — ${reason}`;
301
+ const already = store.readLedgerEvents().some((e) => e.kind === 'note' && e.title === title);
302
+ try {
303
+ if (!already) {
304
+ store.appendLedgerEvent({
305
+ kind: 'note',
306
+ title,
307
+ body: JSON.stringify(detail),
308
+ anchor: `joint:${payload.slug}`,
309
+ provenance: stampProvenance(),
310
+ });
311
+ }
312
+ store.clearIntent(intent.id);
313
+ } catch (err) {
314
+ try { syncManifest(cwd, [store._ledgerPath()]); } catch { /* preserve the refusal error */ }
315
+ throw err;
316
+ }
317
+ syncManifest(cwd, [store._ledgerPath(), store._intentPath(intent.id)]);
318
+ }
319
+
320
+ const RESERVED_INTENT_KINDS = new Set(['package_transition']);
321
+
322
+ function intentAttributedRecord(record, intentId) {
323
+ return {
324
+ ...record,
325
+ provenance: {
326
+ ...(record?.provenance ?? stampProvenance()),
327
+ intent_id: intentId,
328
+ },
329
+ };
330
+ }
331
+
332
+ function attributedTransitionPayload(intent) {
333
+ const payload = intent.payload ?? {};
334
+ return {
335
+ ...payload,
336
+ events: (payload.events ?? []).map(
337
+ (event) => intentAttributedRecord(event, intent.id),
338
+ ),
339
+ predictions: (payload.predictions ?? []).map(
340
+ (prediction) => intentAttributedRecord(prediction, intent.id),
341
+ ),
342
+ };
343
+ }
344
+
345
+ /**
346
+ * The only registered S4 intent applier. Payload shape never selects this
347
+ * handler; the dispatcher selects it exclusively from `intent.kind`.
348
+ */
349
+ async function applyTransitionIntent(cwd, store, intent, { undo, unpublishedUndo }) {
350
+ const payload = attributedTransitionPayload(intent);
351
+
352
+ // Population invariant FIRST — before any guard call, so recovery can
353
+ // never advance the authoritative guard for an intent it is about to drop.
354
+ if (payload.to === 'under_test') {
355
+ const occupant = store.listJoints().find(
356
+ (joint) => joint.slug !== payload.slug && joint.state === 'under_test',
357
+ );
358
+ if (occupant) {
359
+ return {
360
+ status: 'refused',
361
+ reason: `ONE-UNDER-TEST refused on replay (${occupant.slug} already under test)`,
362
+ detail: { occupant: occupant.slug },
363
+ divergence: {
364
+ intent: intent.id,
365
+ slug: payload.slug,
366
+ refused: true,
367
+ oneUnderTest: occupant.slug,
368
+ },
369
+ };
370
+ }
371
+ }
372
+
373
+ let guardInfo = null;
374
+ if (guardEnabled(cwd) && payload.to && payload.from !== payload.to) {
375
+ const guard = await callGuard(cwd, {
376
+ slug: payload.slug,
377
+ from: payload.from,
378
+ to: payload.to,
379
+ });
380
+ if (!guard.applied) {
381
+ if (guard.error) {
382
+ return {
383
+ status: 'blocked',
384
+ reason: 'guard_unavailable',
385
+ error: guard.error,
386
+ };
387
+ }
388
+ const alreadyAdvanced = guard.currentState === payload.to;
389
+ if (!alreadyAdvanced) {
390
+ if (!guard.refused) {
391
+ return {
392
+ status: 'blocked',
393
+ reason: 'ambiguous_guard_verdict',
394
+ };
395
+ }
396
+ return {
397
+ status: 'refused',
398
+ reason: 'refused by guard',
399
+ detail: {
400
+ verdict: guard.verdict ?? null,
401
+ currentState: guard.currentState ?? null,
402
+ },
403
+ divergence: {
404
+ intent: intent.id,
405
+ slug: payload.slug,
406
+ refused: true,
407
+ verdict: guard.verdict ?? null,
408
+ },
409
+ guard: {
410
+ verdict: guard.verdict ?? null,
411
+ ledgerRef: guard.ledgerRef ?? null,
412
+ currentState: guard.currentState ?? null,
413
+ },
414
+ };
415
+ }
416
+ }
417
+ guardInfo = {
418
+ verdict: guard.verdict ?? null,
419
+ ledgerRef: guard.ledgerRef ?? null,
420
+ currentState: guard.currentState ?? null,
421
+ };
422
+ }
423
+
424
+ if (payload.joint) {
425
+ const path = store._jointPath(payload.joint.slug);
426
+ undo.capture(path);
427
+ unpublishedUndo.capture(path);
428
+ }
429
+ if ((payload.events?.length ?? 0) > 0) undo.capture(store._ledgerPath());
430
+ for (const prediction of payload.predictions ?? []) {
431
+ const path = store._predictionPath(prediction.id);
432
+ if (existsSync(path)) undo.capture(path);
433
+ else undo.created(path);
434
+ }
435
+ applyPayload(store, payload);
436
+ return { status: 'applied', guard: guardInfo };
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // COMP-JUDGMENT-GOAL-MIGRATE S1 — objective → goal migration intent
441
+ // ---------------------------------------------------------------------------
442
+
443
+ // The objective's canonical joints, enumerated in fixed order (design r4 /
444
+ // blueprint C12). NOT parsed from projection markers. `self-report-reliable`
445
+ // is deliberately absent — it has no canonical joint record and is named in
446
+ // the migration note as unmigrated.
447
+ const MIGRATION_JOINT_SLUGS = Object.freeze(['horizon', 'success-criteria', 'commercial-intent']);
448
+
449
+ const MIGRATION_NOTE_TITLE = 'Legacy objective migrated to goal:v1';
450
+ const MIGRATION_NOTE_ANCHOR = 'position:objective';
451
+
452
+ function migrationConflict(intent, detail) {
453
+ return typedError('JUDGMENT_MIGRATION_CONFLICT', `goal migration ${intent.id}: ${detail}`);
454
+ }
455
+
456
+ /**
457
+ * Order-insensitive canonical serialization (plain JSON only). Built by direct
458
+ * string assembly — NEVER by writing keys into an accumulator object, which
459
+ * would route an own `__proto__` key through the prototype setter and silently
460
+ * drop it (making two records that differ only in `__proto__` compare equal).
461
+ */
462
+ function stableStringify(value) {
463
+ if (Array.isArray(value)) {
464
+ return `[${value.map(stableStringify).join(',')}]`;
465
+ }
466
+ if (value && typeof value === 'object') {
467
+ const keys = Object.keys(value).sort();
468
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
469
+ }
470
+ return JSON.stringify(value);
471
+ }
472
+
473
+ // Parsed records compare by STRUCTURE (blueprint: full parsed equality, not
474
+ // byte identity — only goal/state.json is byte-level). Field reordering of an
475
+ // otherwise-identical record must not read as a conflict; every own key,
476
+ // including `__proto__`, participates.
477
+ function artifactsEqual(a, b) {
478
+ return stableStringify(a) === stableStringify(b);
479
+ }
480
+
481
+ /** Exact bytes the store would write for a state record (skip-detection oracle). */
482
+ function serializeStoreRecord(record) {
483
+ return JSON.stringify(record, null, 2) + '\n';
484
+ }
485
+
486
+ /**
487
+ * Highest chain revision present ON DISK by FILENAME (`<prefix><N>.json`),
488
+ * independent of whether the file parses. Occupancy must not depend on a
489
+ * forgiving parsed read: a malformed `v1.json` still occupies the slot, and
490
+ * appending past it would corrupt the chain.
491
+ */
492
+ function highestChainNumberOnDisk(dir, prefix) {
493
+ if (!existsSync(dir)) return 0;
494
+ const fileRe = new RegExp(`^${escapeRegExp(prefix)}([1-9][0-9]*)\\.json$`);
495
+ return readdirSync(dir)
496
+ .map((file) => fileRe.exec(file))
497
+ .filter(Boolean)
498
+ .reduce((max, match) => Math.max(max, Number(match[1])), 0);
499
+ }
500
+
501
+ function migrationNoteBody(legacyProjection) {
502
+ return [
503
+ 'Migrated to `goal:v1`. The legacy `objective` position is retired (tombstoned); goal meaning now lives in the goal store.',
504
+ '`self-report-reliable` was not migrated because no canonical joint record exists.',
505
+ 'Legacy OBJECTIVE.md follows verbatim:',
506
+ legacyProjection,
507
+ ].join('\n\n');
508
+ }
509
+
510
+ /**
511
+ * Capture the current goal-state sidecar as a byte+record preimage (C4). The
512
+ * caller holds the writer lock. Returns null for absence; otherwise
513
+ * `{ bytes, record }`. The parsed record is schema-validated so a malformed
514
+ * sidecar surfaces as a migration conflict, not a downstream collapse.
515
+ */
516
+ function captureGoalStatePreimage(rawStore) {
517
+ const path = rawStore._goalStatePath();
518
+ if (!existsSync(path)) return null;
519
+ const bytes = readFileSync(path, 'utf8');
520
+ let record;
521
+ try {
522
+ record = JSON.parse(bytes);
523
+ } catch (err) {
524
+ throw typedError('JUDGMENT_MIGRATION_CONFLICT', `goal migration: goal/state.json is not valid JSON: ${err.message}`);
525
+ }
526
+ try {
527
+ assertValidRecord('goal_state', record);
528
+ // C4: ambiguous/malformed sidecar IDs (duplicate or non-`gj`/`gl` entries
529
+ // the schema's array shape would otherwise accept) must fail as a
530
+ // migration conflict before the intent is persisted — never carried into a
531
+ // published state where a later removal could address only the first match.
532
+ allocateStableEntryId(record, 'joints', 'gj');
533
+ allocateStableEntryId(record, 'load_links', 'gl');
534
+ } catch (err) {
535
+ throw typedError('JUDGMENT_MIGRATION_CONFLICT', `goal migration: goal/state.json is not a valid goal_state record: ${err.message}`);
536
+ }
537
+ return { bytes, record };
538
+ }
539
+
540
+ /**
541
+ * Deterministic merged goal state (blueprint "Exact content mapping"):
542
+ * preimage active entries preserved (IDs/content/order) but re-stamped with
543
+ * migration provenance, followed by the fixed joints missing from the active
544
+ * set, deduped by `link.joint`. New `gj<N>` IDs are allocated above the
545
+ * high-water mark of ALL preimage joints (including removed ones), then the
546
+ * removed preimage entries are dropped from the output.
547
+ */
548
+ function buildMigrationState(preimageRecord, provenance) {
549
+ const preimageJoints = preimageRecord?.joints ?? [];
550
+ const preimageLoadLinks = preimageRecord?.load_links ?? [];
551
+
552
+ // High-water over ALL preimage joints (incl. removed) so dropped entries
553
+ // never free an id for reuse.
554
+ let highWater = 0;
555
+ for (const link of preimageJoints) {
556
+ const match = /^gj([1-9][0-9]*)$/.exec(link?.id ?? '');
557
+ if (match) highWater = Math.max(highWater, Number(match[1]));
558
+ }
559
+
560
+ const activeJoints = preimageJoints
561
+ .filter((link) => link.removed == null)
562
+ .map((link) => ({ id: link.id, joint: link.joint, provenance, removed: null }));
563
+ const presentSlugs = new Set(activeJoints.map((link) => link.joint));
564
+
565
+ for (const slug of MIGRATION_JOINT_SLUGS) {
566
+ if (presentSlugs.has(slug)) continue; // dedupe by joint, not association id
567
+ highWater += 1;
568
+ activeJoints.push({ id: `gj${highWater}`, joint: slug, provenance, removed: null });
569
+ presentSlugs.add(slug);
570
+ }
571
+
572
+ const activeLoadLinks = preimageLoadLinks
573
+ .filter((link) => link.removed == null)
574
+ .map((link) => ({
575
+ id: link.id, clause: link.clause, carries: link.carries, provenance, removed: null,
576
+ }));
577
+
578
+ return { joints: activeJoints, load_links: activeLoadLinks, provenance };
579
+ }
580
+
581
+ /**
582
+ * Build the complete `goal_migration` intent from the effective pre-migration
583
+ * view. Every artifact (goal v1, tombstone, merged state, note) is a FINAL
584
+ * record carrying migration provenance — not a recipe re-read on replay. The
585
+ * three canonical joints must resolve before the intent is persisted (C12).
586
+ */
587
+ function buildGoalMigrationIntent(cwd, rawStore, effective, { intentId, provenance }) {
588
+ const objective = effective.latestPositionRevision('objective');
589
+ if (!objective) {
590
+ throw typedError('JUDGMENT_REF', 'judgment_goal_write migrate: no live objective position to migrate');
591
+ }
592
+ const claim = objective.claims?.[0];
593
+ if (!claim) {
594
+ throw typedError('JUDGMENT_REF', 'judgment_goal_write migrate: objective r1 has no claim to migrate');
595
+ }
596
+
597
+ for (const slug of MIGRATION_JOINT_SLUGS) {
598
+ if (!effective.readJoint(slug)) {
599
+ throw typedError('JUDGMENT_REF', `judgment_goal_write migrate: required joint ${slug} does not resolve`);
600
+ }
601
+ }
602
+
603
+ // Goal v1 — one clause from the objective's claim; inferred channel (the
604
+ // live claim is a back-inferred draft, never owner speech); null provocation
605
+ // and no ratification are legal under via:migration.
606
+ const goalVersion = {
607
+ version: 1,
608
+ clauses: [{
609
+ id: 'c1',
610
+ text: claim.text,
611
+ channel: 'inferred',
612
+ elicitation: cloneRecord(claim.elicitation),
613
+ provenance,
614
+ trace: [],
615
+ }],
616
+ provocation: null,
617
+ diff_note: 'Migrated from legacy objective objective#r1.',
618
+ provenance,
619
+ };
620
+ assertValidRecord('goal_version', goalVersion);
621
+
622
+ // Tombstone — the next exact objective revision, empty claims, retracted,
623
+ // carrying the objective's own conviction.
624
+ const tombstoneRev = effective.readPositionChain('objective').length + 1;
625
+ const tombstone = {
626
+ slug: 'objective',
627
+ claims: [],
628
+ conviction: cloneRecord(objective.conviction),
629
+ retracted: true,
630
+ provenance,
631
+ rev: tombstoneRev,
632
+ };
633
+ assertValidRecord('position_revision', tombstone);
634
+
635
+ // Sidecar preimage + deterministic merged state.
636
+ const preimage = captureGoalStatePreimage(rawStore);
637
+ const goalState = buildMigrationState(preimage?.record ?? null, provenance);
638
+ assertValidRecord('goal_state', goalState);
639
+ // Re-validate the MERGED ids, not just the preimage: a pathological huge
640
+ // preimage id (beyond exact float range) can collide with a freshly
641
+ // allocated fixed-joint id. allocateStableEntryId's string-duplicate check
642
+ // catches the collision the schema's array shape would otherwise publish.
643
+ try {
644
+ allocateStableEntryId(goalState, 'joints', 'gj');
645
+ allocateStableEntryId(goalState, 'load_links', 'gl');
646
+ } catch (err) {
647
+ throw typedError('JUDGMENT_MIGRATION_CONFLICT', `goal migration: merged goal state has ambiguous association ids: ${err.message}`);
648
+ }
649
+
650
+ // Anchored note — carries the legacy projection verbatim.
651
+ const objectivePath = join(cwd, 'docs', 'judgment', 'OBJECTIVE.md');
652
+ const legacyProjection = existsSync(objectivePath) ? readFileSync(objectivePath, 'utf8') : '';
653
+ const note = {
654
+ kind: 'note',
655
+ title: MIGRATION_NOTE_TITLE,
656
+ body: migrationNoteBody(legacyProjection),
657
+ anchor: MIGRATION_NOTE_ANCHOR,
658
+ refs: ['objective#r1', 'goal:v1'],
659
+ provenance,
660
+ };
661
+ assertValidRecord('ledger_event', note);
662
+
663
+ const intent = {
664
+ id: intentId,
665
+ kind: 'goal_migration',
666
+ tool: 'judgment_goal_write',
667
+ op: 'migrate',
668
+ payload: {
669
+ source: {
670
+ objective_ref: 'objective#r1',
671
+ objective: cloneRecord(objective),
672
+ legacy_projection: legacyProjection,
673
+ },
674
+ goal_version: goalVersion,
675
+ objective_tombstone: tombstone,
676
+ goal_state_preimage: preimage,
677
+ goal_state: goalState,
678
+ note,
679
+ },
680
+ created_at: provenance.written_at,
681
+ };
682
+ assertValidRecord('pending_intent', intent);
683
+ return intent;
684
+ }
685
+
686
+ /**
687
+ * Apply the migration idempotently. Every occupied target is skipped only on
688
+ * full equality; any mismatch throws JUDGMENT_MIGRATION_CONFLICT BEFORE the
689
+ * first mutation, so replay after a mid-migration crash either completes the
690
+ * remaining artifacts or refuses cleanly. The real applier never returns
691
+ * `blocked` — that path exists only for the C13 injection seam.
692
+ */
693
+ async function applyGoalMigrationIntent(cwd, store, intent, { undo, unpublishedUndo }) {
694
+ const { payload } = intent;
695
+ const goalPath = join(store._goalDir(), 'v1.json');
696
+ const tombstoneRev = payload.objective_tombstone.rev;
697
+ const tombstonePath = join(store._positionDir('objective'), `r${tombstoneRev}.json`);
698
+ const statePath = store._goalStatePath();
699
+ const ledgerPath = store._ledgerPath();
700
+
701
+ // ── Preflight (no mutation) ──────────────────────────────────────────────
702
+ // Occupancy is FILENAME-based (a malformed slot still occupies the chain);
703
+ // an occupied slot skips only when it parses AND structurally equals the
704
+ // payload AND no later revision exists — anything else conflicts.
705
+ const goalOnDisk = highestChainNumberOnDisk(store._goalDir(), 'v');
706
+ let writeGoal = true;
707
+ if (goalOnDisk > 1) {
708
+ throw migrationConflict(intent, 'goal/v1.json differs from the persisted payload');
709
+ }
710
+ if (goalOnDisk === 1) {
711
+ const existingGoal = store.readGoalVersion(1);
712
+ if (!existingGoal || !artifactsEqual(existingGoal, payload.goal_version)) {
713
+ throw migrationConflict(intent, 'goal/v1.json differs from the persisted payload');
714
+ }
715
+ writeGoal = false;
716
+ }
717
+
718
+ const objectiveOnDisk = highestChainNumberOnDisk(store._positionDir('objective'), 'r');
719
+ let writeTombstone = true;
720
+ if (objectiveOnDisk > tombstoneRev) {
721
+ throw migrationConflict(intent, 'objective tombstone differs from the persisted payload');
722
+ }
723
+ if (objectiveOnDisk === tombstoneRev) {
724
+ const existingTombstone = store.readPositionRevision('objective', tombstoneRev);
725
+ if (!existingTombstone || !artifactsEqual(existingTombstone, payload.objective_tombstone)) {
726
+ throw migrationConflict(intent, 'objective tombstone differs from the persisted payload');
727
+ }
728
+ writeTombstone = false;
729
+ }
730
+
731
+ const expectedStateBytes = serializeStoreRecord(payload.goal_state);
732
+ const preimageBytes = payload.goal_state_preimage ? payload.goal_state_preimage.bytes : null;
733
+ const currentStateBytes = existsSync(statePath) ? readFileSync(statePath, 'utf8') : null;
734
+ let writeState = true;
735
+ if (currentStateBytes === expectedStateBytes) {
736
+ writeState = false; // this migration already wrote the merged state
737
+ } else if (currentStateBytes !== preimageBytes) {
738
+ throw migrationConflict(intent, 'goal/state.json no longer matches the persisted preimage');
739
+ }
740
+
741
+ // Identify the migration note by (kind:note + this intent_id) ALONE. The
742
+ // migration writes exactly one such note, so any same-intent note whose body
743
+ // (incl. title/anchor) differs from the payload is a corrupted artifact and
744
+ // must conflict — matching on title/anchor too would let a tampered title
745
+ // masquerade as absence and append a second note.
746
+ const noteMatches = store.readLedgerEvents().filter((event) => (
747
+ event.kind === 'note' && event.provenance?.intent_id === intent.id
748
+ ));
749
+ let writeNote = true;
750
+ if (noteMatches.length > 1) {
751
+ throw migrationConflict(intent, 'anchored migration note differs from the persisted payload');
752
+ }
753
+ if (noteMatches.length === 1) {
754
+ if (!artifactsEqual(noteMatches[0], payload.note)) {
755
+ throw migrationConflict(intent, 'anchored migration note differs from the persisted payload');
756
+ }
757
+ writeNote = false;
758
+ }
759
+
760
+ const badAttestation = store.readLedgerEvents().find((event) => (
761
+ event.kind === 'attest'
762
+ && event.intent_id === intent.id
763
+ && (event.tool !== 'judgment_goal_write' || event.op !== 'migrate')
764
+ ));
765
+ if (badAttestation) {
766
+ throw migrationConflict(intent, 'durable attestation attribution conflicts');
767
+ }
768
+
769
+ // ── Apply (undo-logged) ──────────────────────────────────────────────────
770
+ if (writeGoal) {
771
+ undo.created(goalPath);
772
+ store.writeGoalVersion(payload.goal_version);
773
+ }
774
+ if (writeTombstone) {
775
+ undo.created(tombstonePath);
776
+ store.writePositionRevision(payload.objective_tombstone);
777
+ }
778
+ if (writeState) {
779
+ if (preimageBytes === null) {
780
+ undo.created(statePath);
781
+ unpublishedUndo.created(statePath);
782
+ } else {
783
+ undo.capture(statePath);
784
+ unpublishedUndo.capture(statePath);
785
+ }
786
+ store.writeGoalState(payload.goal_state);
787
+ }
788
+ if (writeNote) {
789
+ undo.capture(ledgerPath);
790
+ store.appendLedgerEvent(payload.note);
791
+ }
792
+ return { status: 'applied' };
793
+ }
794
+
795
+ /**
796
+ * Typed replay dispatch. Future payloads cannot impersonate a transition:
797
+ * only the registered kinds reach their handlers.
798
+ */
799
+ export const INTENT_APPLIERS = Object.freeze({
800
+ transition: applyTransitionIntent,
801
+ goal_migration: applyGoalMigrationIntent,
802
+ });
803
+
804
+ function appendIntentAttestation(store, intent) {
805
+ const matches = store.readLedgerEvents().filter(
806
+ (event) => event.kind === 'attest' && event.intent_id === intent.id,
807
+ );
808
+ const mismatch = matches.find(
809
+ (event) => event.tool !== intent.tool || event.op !== intent.op,
810
+ );
811
+ if (mismatch) {
812
+ throw typedError(
813
+ 'JUDGMENT_CONFLICT',
814
+ `intent ${intent.id} attestation attribution conflicts: expected ${intent.tool}/${intent.op}, found ${mismatch.tool}/${mismatch.op}`,
815
+ );
816
+ }
817
+ if (matches.length > 0) return { appended: false };
818
+
819
+ const event = {
820
+ kind: 'attest',
821
+ title: `intent published: ${intent.id}`,
822
+ intent_id: intent.id,
823
+ tool: intent.tool,
824
+ op: intent.op,
825
+ provenance: stampProvenance({ intentId: intent.id }),
826
+ };
827
+ assertValidRecord('ledger_event', event);
828
+ store.appendLedgerEvent(event);
829
+ return { appended: true };
830
+ }
831
+
832
+ function intentPartialError(intent, stage, err) {
833
+ if (err?.code?.startsWith?.('JUDGMENT_')) return err;
834
+ return typedError(
835
+ 'JUDGMENT_PARTIAL_WRITE',
836
+ `intent ${intent.id} ${stage} failed before publication; intent retained: ${err.message}`,
837
+ err,
838
+ );
839
+ }
840
+
841
+ /**
842
+ * Single inline + replay success path.
843
+ *
844
+ * Publication order is binding: idempotent apply → durable attestation →
845
+ * durable clear (the publication point) → projection regeneration.
846
+ *
847
+ * The `appliers` map is a NON-EXPORTED dependency: the C13 unit-injection seam
848
+ * threads a substitute map through the internal replay/runOp path only. The
849
+ * exported `publishIntentLocked` below always uses the real INTENT_APPLIERS, so
850
+ * no public mutation surface can inject a no-op applier and clear an intent
851
+ * without producing its artifacts.
852
+ */
853
+ async function publishIntentWith(cwd, store, intent, appliers) {
854
+ const applier = appliers[intent.kind];
855
+ if (!applier) {
856
+ if (RESERVED_INTENT_KINDS.has(intent.kind)) {
857
+ return {
858
+ status: 'blocked',
859
+ reason: 'reserved_intent_kind',
860
+ kind: intent.kind,
861
+ };
862
+ }
863
+ throw typedError(
864
+ 'JUDGMENT_INTENT_KIND',
865
+ `intent ${intent.id} kind "${intent.kind}" is unregistered and was retained`,
866
+ );
867
+ }
868
+
869
+ const undo = new UndoLog();
870
+ const unpublishedUndo = new UndoLog();
871
+ let applied;
872
+ try {
873
+ applied = await applier(cwd, store, intent, { undo, unpublishedUndo });
874
+ } catch (err) {
875
+ undo.restore();
876
+ try { syncManifest(cwd, undo.touchedPaths()); } catch { /* preserve the publication error */ }
877
+ throw intentPartialError(intent, 'apply', err);
878
+ }
879
+
880
+ if (applied.status === 'blocked') {
881
+ syncManifest(cwd, undo.touchedPaths());
882
+ return applied;
883
+ }
884
+ if (applied.status === 'refused') {
885
+ dropIntentDurably(cwd, store, intent, applied.reason, applied.detail);
886
+ syncManifest(cwd, undo.touchedPaths());
887
+ regenerateProjections(cwd);
888
+ return applied;
889
+ }
890
+
891
+ // Capture the ledger preimage even when the payload carried no events:
892
+ // attestation append is part of the pre-publication undo window.
893
+ undo.capture(store._ledgerPath());
894
+ try {
895
+ appendIntentAttestation(store, intent);
896
+ } catch (err) {
897
+ undo.restore();
898
+ try { syncManifest(cwd, undo.touchedPaths()); } catch { /* preserve the publication error */ }
899
+ throw intentPartialError(intent, 'attestation append', err);
900
+ }
901
+
902
+ try {
903
+ store.clearIntent(intent.id);
904
+ } catch (err) {
905
+ // New intent-attributed ledger lines/predictions stay hidden and are
906
+ // deduped on retry. Existing in-place records have no effective preimage,
907
+ // so restore those until durable clear succeeds.
908
+ unpublishedUndo.restore();
909
+ try { syncManifest(cwd, undo.touchedPaths()); } catch { /* preserve the publication error */ }
910
+ throw typedError(
911
+ 'JUDGMENT_PARTIAL_WRITE',
912
+ `intent ${intent.id} publication point clear failed; intent retained: ${err.message}`,
913
+ err,
914
+ );
915
+ }
916
+
917
+ syncManifest(cwd, [...undo.touchedPaths(), store._intentPath(intent.id)]);
918
+ try {
919
+ regenerateProjections(cwd);
920
+ } catch (err) {
921
+ throw typedError(
922
+ 'JUDGMENT_PROJECTION_STALE',
923
+ `intent ${intent.id} canonical effects are committed but projections are stale: ${err.message}`,
924
+ err,
925
+ );
926
+ }
927
+ return { status: 'published', guard: applied.guard ?? null };
928
+ }
929
+
930
+ /**
931
+ * Exported publication entry — always the real applier table. External callers
932
+ * (e.g. the inline transition publish) cannot substitute the dispatch map.
933
+ */
934
+ export async function publishIntentLocked(cwd, store, intent) {
935
+ return publishIntentWith(cwd, store, intent, INTENT_APPLIERS);
936
+ }
937
+
938
+ /**
939
+ * Replay every pending intent. MUST be called with the judgment lock held.
940
+ * Guard state is authoritative for transition edges; intent kind is
941
+ * authoritative for dispatch, and the intent payload is authoritative for
942
+ * the complete mutation. `appliers` is the internal C13 seam (default real).
943
+ */
944
+ async function replayIntentsLocked(cwd, appliers = INTENT_APPLIERS) {
945
+ const store = createJudgmentStore(cwd);
946
+ const intents = store.readIntents();
947
+ let replayed = 0;
948
+ const divergences = [];
949
+ const blocked = [];
950
+ for (const intent of intents) {
951
+ const outcome = await publishIntentWith(cwd, store, intent, appliers);
952
+ if (outcome.status === 'published') replayed += 1;
953
+ else if (outcome.status === 'refused') divergences.push(outcome.divergence);
954
+ else if (outcome.status === 'blocked') {
955
+ blocked.push({
956
+ intent: intent.id,
957
+ kind: intent.kind,
958
+ reason: outcome.reason,
959
+ });
960
+ }
961
+ }
962
+ return { replayed, divergences, blocked };
963
+ }
964
+
965
+ /**
966
+ * Public reconciler: replay pending intents under the lock. Also runs
967
+ * automatically at the head of every write op and getJudgmentState. Always
968
+ * uses the real applier table — the injection seam is internal-only (C13).
969
+ */
970
+ export async function replayPendingIntents(cwd) {
971
+ const release = await acquireJudgmentLock(cwd);
972
+ try {
973
+ return await replayIntentsLocked(cwd, INTENT_APPLIERS);
974
+ } finally {
975
+ release();
976
+ }
977
+ }
978
+
979
+ // ---------------------------------------------------------------------------
980
+ // Op wrapper: validate → (idempotency) → lock → replay → execute → audit
981
+ // ---------------------------------------------------------------------------
982
+
983
+ async function runOp(cwd, args, { tool, validate, execute }, appliers = INTENT_APPLIERS) {
984
+ if (args?.provenance !== undefined) {
985
+ throw typedError('JUDGMENT_INPUT', `${tool}: provenance is writer-stamped and cannot be set by the caller`);
986
+ }
987
+ const prepared = validate ? validate() : undefined; // sync, BEFORE idempotency (A1/A4)
988
+
989
+ const doWrite = async () => {
990
+ const release = await acquireJudgmentLock(cwd);
991
+ try {
992
+ await replayIntentsLocked(cwd, appliers);
993
+ return await execute(prepared);
994
+ } finally {
995
+ release();
996
+ }
997
+ };
998
+
999
+ let result;
1000
+ let cached = false;
1001
+ if (args?.idempotency_key) {
1002
+ ({ result, cached } = await checkOrInsert(cwd, `${tool}:${args.idempotency_key}`, doWrite));
1003
+ } else {
1004
+ result = await doWrite();
1005
+ }
1006
+
1007
+ if (!cached) {
1008
+ // Best-effort audit AFTER commit (A5) — never let audit failure surface.
1009
+ try { appendEvent(cwd, { tool, result }); } catch { /* best-effort */ }
1010
+ }
1011
+ return result;
1012
+ }
1013
+
1014
+ // ---------------------------------------------------------------------------
1015
+ // COMP-JUDGMENT-STORES S3 — shared person/situation writer primitives
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ const FACT_CHANNELS = new Set(['said', 'observed', 'secondhand', 'inferred']);
1019
+ const PERSON_SECTIONS = new Set(['role', 'life', 'stated', 'revealed']);
1020
+ const LOAD_CHANNELS = new Set(['said', 'observed']);
1021
+
1022
+ function hasOwn(value, key) {
1023
+ return Object.prototype.hasOwnProperty.call(value ?? {}, key);
1024
+ }
1025
+
1026
+ function requireString(args, key, tool, op) {
1027
+ const value = args?.[key];
1028
+ if (typeof value !== 'string' || value.trim().length === 0) {
1029
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: ${key} must be a non-empty string`);
1030
+ }
1031
+ return value;
1032
+ }
1033
+
1034
+ function optionalString(args, key, tool, op) {
1035
+ if (!hasOwn(args, key)) return undefined;
1036
+ return requireString(args, key, tool, op);
1037
+ }
1038
+
1039
+ function requireSlug(args, tool, op) {
1040
+ const slug = requireString(args, 'slug', tool, op);
1041
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
1042
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: slug must match ^[a-z0-9][a-z0-9-]*$`);
1043
+ }
1044
+ return slug;
1045
+ }
1046
+
1047
+ function requireDate(args, key, tool, op) {
1048
+ const value = requireString(args, key, tool, op);
1049
+ const parsed = new Date(`${value}T00:00:00.000Z`);
1050
+ if (
1051
+ !/^\d{4}-\d{2}-\d{2}$/.test(value)
1052
+ || Number.isNaN(parsed.getTime())
1053
+ || parsed.toISOString().slice(0, 10) !== value
1054
+ ) {
1055
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: ${key} must be an ISO date (YYYY-MM-DD)`);
1056
+ }
1057
+ return value;
1058
+ }
1059
+
1060
+ function validateChannelAndVia(args, tool, op, { requireChannel = true } = {}) {
1061
+ const channel = requireChannel
1062
+ ? requireString(args, 'channel', tool, op)
1063
+ : optionalString(args, 'channel', tool, op);
1064
+ const via = optionalString(args, 'via', tool, op);
1065
+ if (channel !== undefined && !FACT_CHANNELS.has(channel)) {
1066
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: unknown channel "${channel}"`);
1067
+ }
1068
+ if (channel === 'secondhand' && via === undefined) {
1069
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: secondhand channel requires a non-empty via`);
1070
+ }
1071
+ if (channel !== undefined && channel !== 'secondhand' && via !== undefined) {
1072
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: via is only valid for secondhand channel`);
1073
+ }
1074
+ const prepared = {};
1075
+ if (channel !== undefined) prepared.channel = channel;
1076
+ if (via !== undefined) prepared.via = via;
1077
+ return prepared;
1078
+ }
1079
+
1080
+ function validateCreateArgs(args, tool) {
1081
+ return {
1082
+ slug: requireSlug(args, tool, 'create'),
1083
+ displayName: requireString(args, 'display_name', tool, 'create'),
1084
+ };
1085
+ }
1086
+
1087
+ function validatePersonAddFactArgs(args) {
1088
+ const tool = 'judgment_person_write';
1089
+ const section = requireString(args, 'section', tool, 'add_fact');
1090
+ if (!PERSON_SECTIONS.has(section)) {
1091
+ throw typedError('JUDGMENT_INPUT', `${tool} add_fact: unknown section "${section}"`);
1092
+ }
1093
+ return {
1094
+ slug: requireSlug(args, tool, 'add_fact'),
1095
+ section,
1096
+ text: requireString(args, 'text', tool, 'add_fact'),
1097
+ ...validateChannelAndVia(args, tool, 'add_fact'),
1098
+ at: requireDate(args, 'at', tool, 'add_fact'),
1099
+ };
1100
+ }
1101
+
1102
+ function validateSituationAddFactArgs(args) {
1103
+ const tool = 'judgment_situation_write';
1104
+ return {
1105
+ slug: requireSlug(args, tool, 'add_fact'),
1106
+ text: requireString(args, 'text', tool, 'add_fact'),
1107
+ ...validateChannelAndVia(args, tool, 'add_fact'),
1108
+ at: requireDate(args, 'at', tool, 'add_fact'),
1109
+ };
1110
+ }
1111
+
1112
+ function validateCorrectArgs(args, tool, { person = false } = {}) {
1113
+ const prepared = {
1114
+ slug: requireSlug(args, tool, 'correct'),
1115
+ factId: requireString(args, 'fact_id', tool, 'correct'),
1116
+ };
1117
+ if (hasOwn(args, 'text')) prepared.text = requireString(args, 'text', tool, 'correct');
1118
+ if (hasOwn(args, 'at')) prepared.at = requireDate(args, 'at', tool, 'correct');
1119
+ if (hasOwn(args, 'channel') || hasOwn(args, 'via')) {
1120
+ Object.assign(prepared, validateChannelAndVia(
1121
+ args,
1122
+ tool,
1123
+ 'correct',
1124
+ { requireChannel: hasOwn(args, 'channel') },
1125
+ ));
1126
+ }
1127
+ if (person && hasOwn(args, 'section')) {
1128
+ const section = requireString(args, 'section', tool, 'correct');
1129
+ if (!PERSON_SECTIONS.has(section)) {
1130
+ throw typedError('JUDGMENT_INPUT', `${tool} correct: unknown section "${section}"`);
1131
+ }
1132
+ prepared.section = section;
1133
+ }
1134
+ if (person && hasOwn(args, 'pair_with')) {
1135
+ prepared.pairWith = requireString(args, 'pair_with', tool, 'correct');
1136
+ }
1137
+ if (person && hasOwn(args, 'clear')) {
1138
+ if (
1139
+ !Array.isArray(args.clear)
1140
+ || args.clear.length !== 1
1141
+ || args.clear[0] !== 'diverges_with'
1142
+ ) {
1143
+ throw typedError('JUDGMENT_INPUT', `${tool} correct: clear must be exactly ["diverges_with"]`);
1144
+ }
1145
+ prepared.clearDivergence = true;
1146
+ }
1147
+ if (prepared.pairWith && prepared.clearDivergence) {
1148
+ throw typedError('JUDGMENT_INPUT', `${tool} correct: pair_with and clear are mutually exclusive`);
1149
+ }
1150
+
1151
+ const mutableKeys = person
1152
+ ? ['text', 'at', 'channel', 'via', 'section', 'pairWith', 'clearDivergence']
1153
+ : ['text', 'at', 'channel', 'via'];
1154
+ if (!mutableKeys.some((key) => hasOwn(prepared, key))) {
1155
+ throw typedError('JUDGMENT_INPUT', `${tool} correct: provide at least one correctable field`);
1156
+ }
1157
+ return prepared;
1158
+ }
1159
+
1160
+ function validateOpenFieldArgs(args) {
1161
+ const tool = 'judgment_person_write';
1162
+ const slug = requireSlug(args, tool, 'open_field');
1163
+ const create = hasOwn(args, 'name');
1164
+ const fill = hasOwn(args, 'open_field_id') && hasOwn(args, 'filled_by') && !hasOwn(args, 'reopen');
1165
+ const reopen = hasOwn(args, 'open_field_id') && args.reopen === true;
1166
+ if (Number(create) + Number(fill) + Number(reopen) !== 1) {
1167
+ throw typedError(
1168
+ 'JUDGMENT_INPUT',
1169
+ `${tool} open_field: select exactly one create, fill, or reopen branch`,
1170
+ );
1171
+ }
1172
+ if (create) {
1173
+ if (hasOwn(args, 'open_field_id') || hasOwn(args, 'filled_by') || hasOwn(args, 'reopen')) {
1174
+ throw typedError('JUDGMENT_INPUT', `${tool} open_field: select exactly one create, fill, or reopen branch`);
1175
+ }
1176
+ return { branch: 'create', slug, name: requireString(args, 'name', tool, 'open_field') };
1177
+ }
1178
+ const openFieldId = requireString(args, 'open_field_id', tool, 'open_field');
1179
+ if (fill) {
1180
+ return {
1181
+ branch: 'fill',
1182
+ slug,
1183
+ openFieldId,
1184
+ filledBy: requireString(args, 'filled_by', tool, 'open_field'),
1185
+ };
1186
+ }
1187
+ if (hasOwn(args, 'filled_by') || hasOwn(args, 'name')) {
1188
+ throw typedError('JUDGMENT_INPUT', `${tool} open_field: select exactly one create, fill, or reopen branch`);
1189
+ }
1190
+ return {
1191
+ branch: 'reopen',
1192
+ slug,
1193
+ openFieldId,
1194
+ reason: requireString(args, 'reason', tool, 'open_field'),
1195
+ };
1196
+ }
1197
+
1198
+ function validateOwedArgs(args) {
1199
+ const tool = 'judgment_situation_write';
1200
+ const slug = requireSlug(args, tool, 'owed');
1201
+ const create = hasOwn(args, 'name') || hasOwn(args, 'why_load_bearing');
1202
+ const give = hasOwn(args, 'owed_id') && hasOwn(args, 'filled_by') && !hasOwn(args, 'reopen');
1203
+ const reopen = hasOwn(args, 'owed_id') && args.reopen === true;
1204
+ if (Number(create) + Number(give) + Number(reopen) !== 1) {
1205
+ throw typedError('JUDGMENT_INPUT', `${tool} owed: select exactly one create, give, or reopen branch`);
1206
+ }
1207
+ if (create) {
1208
+ if (hasOwn(args, 'owed_id') || hasOwn(args, 'filled_by') || hasOwn(args, 'reopen')) {
1209
+ throw typedError('JUDGMENT_INPUT', `${tool} owed: select exactly one create, give, or reopen branch`);
1210
+ }
1211
+ return {
1212
+ branch: 'create',
1213
+ slug,
1214
+ name: requireString(args, 'name', tool, 'owed'),
1215
+ whyLoadBearing: requireString(args, 'why_load_bearing', tool, 'owed'),
1216
+ };
1217
+ }
1218
+ const owedId = requireString(args, 'owed_id', tool, 'owed');
1219
+ if (give) {
1220
+ return {
1221
+ branch: 'give',
1222
+ slug,
1223
+ owedId,
1224
+ filledBy: requireString(args, 'filled_by', tool, 'owed'),
1225
+ };
1226
+ }
1227
+ if (hasOwn(args, 'filled_by') || hasOwn(args, 'name') || hasOwn(args, 'why_load_bearing')) {
1228
+ throw typedError('JUDGMENT_INPUT', `${tool} owed: select exactly one create, give, or reopen branch`);
1229
+ }
1230
+ return {
1231
+ branch: 'reopen',
1232
+ slug,
1233
+ owedId,
1234
+ reason: requireString(args, 'reason', tool, 'owed'),
1235
+ };
1236
+ }
1237
+
1238
+ function validateRetirableArgs(args, tool, op, { createKeys, idKey }) {
1239
+ const slug = requireSlug(args, tool, op);
1240
+ const create = createKeys.every((key) => hasOwn(args, key));
1241
+ const remove = hasOwn(args, idKey) && args.remove === true;
1242
+ if (Number(create) + Number(remove) !== 1) {
1243
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: select exactly one create or remove branch`);
1244
+ }
1245
+ if (create) {
1246
+ if (hasOwn(args, idKey) || hasOwn(args, 'remove') || hasOwn(args, 'reason')) {
1247
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: select exactly one create or remove branch`);
1248
+ }
1249
+ const prepared = { branch: 'create', slug };
1250
+ for (const key of createKeys) {
1251
+ prepared[key] = requireString(args, key, tool, op);
1252
+ }
1253
+ return prepared;
1254
+ }
1255
+ if (createKeys.some((key) => hasOwn(args, key))) {
1256
+ throw typedError('JUDGMENT_INPUT', `${tool} ${op}: select exactly one create or remove branch`);
1257
+ }
1258
+ return {
1259
+ branch: 'remove',
1260
+ slug,
1261
+ entryId: requireString(args, idKey, tool, op),
1262
+ reason: requireString(args, 'reason', tool, op),
1263
+ };
1264
+ }
1265
+
1266
+ function validateFamilyDispatch(args, tool, validators) {
1267
+ const op = args?.op;
1268
+ if (typeof op !== 'string' || !hasOwn(validators, op)) {
1269
+ throw typedError('JUDGMENT_INPUT', `${tool}: unknown op "${String(op)}"`);
1270
+ }
1271
+ return { op, input: validators[op](args) };
1272
+ }
1273
+
1274
+ function cloneRecord(record) {
1275
+ return JSON.parse(JSON.stringify(record));
1276
+ }
1277
+
1278
+ function correctionTrace(prior, provenance) {
1279
+ return {
1280
+ prior,
1281
+ corrected_at: provenance.written_at,
1282
+ provenance,
1283
+ };
1284
+ }
1285
+
1286
+ function removedBlock(reason, provenance) {
1287
+ return {
1288
+ at: provenance.written_at,
1289
+ reason,
1290
+ provenance,
1291
+ };
1292
+ }
1293
+
1294
+ function persistAggregate(cwd, path, { created = false, write }) {
1295
+ const undo = new UndoLog();
1296
+ commitWithProjections(cwd, undo, () => {
1297
+ if (created) undo.created(path);
1298
+ else undo.capture(path);
1299
+ write();
1300
+ });
1301
+ }
1302
+
1303
+ function escapeRegExp(value) {
1304
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1305
+ }
1306
+
1307
+ /**
1308
+ * Allocate the next permanent sub-entry ID from the collection high-water
1309
+ * mark. The caller holds the writer lock. Retired entries remain in the
1310
+ * collection and therefore participate automatically.
1311
+ */
1312
+ export function allocateStableEntryId(record, collection, prefix) {
1313
+ const entries = Array.isArray(collection) ? collection : record?.[collection];
1314
+ const collectionName = Array.isArray(collection) ? 'collection' : collection;
1315
+ if (!Array.isArray(entries)) {
1316
+ throw typedError(
1317
+ 'JUDGMENT_CONFLICT',
1318
+ `${record?.slug ?? 'record'} ${String(collectionName)} is not an on-disk entry collection`,
1319
+ );
1320
+ }
1321
+ const pattern = new RegExp(`^${escapeRegExp(prefix)}([1-9][0-9]*)$`);
1322
+ const seen = new Set();
1323
+ let highWater = 0;
1324
+ for (const entry of entries) {
1325
+ const id = entry?.id;
1326
+ const match = typeof id === 'string' ? pattern.exec(id) : null;
1327
+ if (!match) {
1328
+ throw typedError(
1329
+ 'JUDGMENT_CONFLICT',
1330
+ `${record?.slug ?? 'record'} ${String(collectionName)} has malformed id ${String(id)}; expected ${prefix}<positive-integer>`,
1331
+ );
1332
+ }
1333
+ if (seen.has(id)) {
1334
+ throw typedError(
1335
+ 'JUDGMENT_CONFLICT',
1336
+ `${record?.slug ?? 'record'} ${String(collectionName)} has duplicate id ${id}`,
1337
+ );
1338
+ }
1339
+ seen.add(id);
1340
+ highWater = Math.max(highWater, Number(match[1]));
1341
+ }
1342
+ return `${prefix}${highWater + 1}`;
1343
+ }
1344
+
1345
+ function assertFactVia(fact, ownerLabel) {
1346
+ if (fact.channel === 'secondhand') {
1347
+ if (typeof fact.via !== 'string' || fact.via.trim().length === 0) {
1348
+ throw typedError(
1349
+ 'JUDGMENT_INPUT',
1350
+ `${ownerLabel} fact ${fact.id}: secondhand channel requires a non-empty via`,
1351
+ );
1352
+ }
1353
+ } else if (fact.via !== undefined) {
1354
+ throw typedError(
1355
+ 'JUDGMENT_INPUT',
1356
+ `${ownerLabel} fact ${fact.id}: via is only valid for secondhand channel`,
1357
+ );
1358
+ }
1359
+ }
1360
+
1361
+ function activeEntries(entries) {
1362
+ return entries.filter((entry) => entry.removed == null);
1363
+ }
1364
+
1365
+ function assertPersonSemantics(store, record) {
1366
+ allocateStableEntryId(record, 'facts', 'f');
1367
+ allocateStableEntryId(record, 'edges', 'e');
1368
+ allocateStableEntryId(record, 'open_fields', 'of');
1369
+ allocateStableEntryId(record, 'load_links', 'l');
1370
+
1371
+ const facts = new Map(record.facts.map((fact) => [fact.id, fact]));
1372
+ for (const fact of record.facts) assertFactVia(fact, `person ${record.slug}`);
1373
+
1374
+ for (const fact of record.facts) {
1375
+ if (fact.diverges_with === undefined) continue;
1376
+ const paired = facts.get(fact.diverges_with);
1377
+ if (!paired) {
1378
+ throw typedError(
1379
+ 'JUDGMENT_REF',
1380
+ `person ${record.slug} fact ${fact.id}: diverges_with ${fact.diverges_with} does not resolve`,
1381
+ );
1382
+ }
1383
+ if (paired.diverges_with !== fact.id) {
1384
+ throw typedError(
1385
+ 'JUDGMENT_REF',
1386
+ `person ${record.slug} divergence ${fact.id}↔${paired.id} is not reciprocal`,
1387
+ );
1388
+ }
1389
+ if (new Set([fact.section, paired.section]).size !== 2
1390
+ || ![fact.section, paired.section].every((section) => ['stated', 'revealed'].includes(section))) {
1391
+ throw typedError(
1392
+ 'JUDGMENT_REF',
1393
+ `person ${record.slug} divergence ${fact.id}↔${paired.id} must join stated↔revealed`,
1394
+ );
1395
+ }
1396
+ }
1397
+
1398
+ for (const field of record.open_fields) {
1399
+ if (!['open', 'filled'].includes(field.status)) {
1400
+ throw typedError(
1401
+ 'JUDGMENT_CONFLICT',
1402
+ `person ${record.slug} open field ${field.id} has invalid lifecycle state ${String(field.status)}`,
1403
+ );
1404
+ }
1405
+ if (field.status === 'filled') {
1406
+ const fact = facts.get(field.filled_by);
1407
+ if (!fact) {
1408
+ throw typedError(
1409
+ 'JUDGMENT_REF',
1410
+ `person ${record.slug} open field ${field.id}: filled_by ${field.filled_by} does not resolve`,
1411
+ );
1412
+ }
1413
+ if (fact.channel !== 'said') {
1414
+ throw typedError(
1415
+ 'JUDGMENT_REF',
1416
+ `person ${record.slug} open field ${field.id}: filled_by ${field.filled_by} must name a said fact`,
1417
+ );
1418
+ }
1419
+ } else if (field.status === 'open' && field.filled_by !== undefined) {
1420
+ throw typedError(
1421
+ 'JUDGMENT_CONFLICT',
1422
+ `person ${record.slug} open field ${field.id} is open but retains filled_by ${field.filled_by}`,
1423
+ );
1424
+ }
1425
+ }
1426
+
1427
+ for (const edge of activeEntries(record.edges)) {
1428
+ if (!store.readPerson(edge.to)) {
1429
+ throw typedError(
1430
+ 'JUDGMENT_REF',
1431
+ `person ${record.slug} edge ${edge.id}: target person ${edge.to} does not exist`,
1432
+ );
1433
+ }
1434
+ }
1435
+
1436
+ const links = activeEntries(record.load_links);
1437
+ for (const link of links) {
1438
+ const fact = facts.get(link.fact);
1439
+ if (!fact) {
1440
+ throw typedError(
1441
+ 'JUDGMENT_REF',
1442
+ `person ${record.slug} load link ${link.id}: fact ${link.fact} does not resolve`,
1443
+ );
1444
+ }
1445
+ }
1446
+ if (links.length > 0 && !record.facts.some((fact) => fact.channel === 'said')) {
1447
+ throw typedError(
1448
+ 'JUDGMENT_LOAD_CHANNEL',
1449
+ `person ${record.slug} is a stub and cannot carry load (active: ${links.map((link) => link.id).join(', ')})`,
1450
+ );
1451
+ }
1452
+ for (const link of links) {
1453
+ const fact = facts.get(link.fact);
1454
+ if (!LOAD_CHANNELS.has(fact.channel)) {
1455
+ throw typedError(
1456
+ 'JUDGMENT_LOAD_CHANNEL',
1457
+ `person ${record.slug} load link ${link.id}: fact ${fact.id} channel ${fact.channel} cannot carry load`,
1458
+ );
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ function assertSituationSemantics(record) {
1464
+ allocateStableEntryId(record, 'facts', 'f');
1465
+ allocateStableEntryId(record, 'owed', 'o');
1466
+ allocateStableEntryId(record, 'load_links', 'l');
1467
+
1468
+ const facts = new Map(record.facts.map((fact) => [fact.id, fact]));
1469
+ for (const fact of record.facts) assertFactVia(fact, `situation ${record.slug}`);
1470
+
1471
+ for (const owed of record.owed) {
1472
+ if (!['open', 'given'].includes(owed.status)) {
1473
+ throw typedError(
1474
+ 'JUDGMENT_CONFLICT',
1475
+ `situation ${record.slug} owed ${owed.id} has invalid lifecycle state ${String(owed.status)}`,
1476
+ );
1477
+ }
1478
+ if (owed.status === 'given') {
1479
+ if (!facts.has(owed.filled_by)) {
1480
+ throw typedError(
1481
+ 'JUDGMENT_REF',
1482
+ `situation ${record.slug} owed ${owed.id}: filled_by ${owed.filled_by} does not resolve`,
1483
+ );
1484
+ }
1485
+ } else if (owed.status === 'open' && owed.filled_by !== undefined) {
1486
+ throw typedError(
1487
+ 'JUDGMENT_CONFLICT',
1488
+ `situation ${record.slug} owed ${owed.id} is open but retains filled_by ${owed.filled_by}`,
1489
+ );
1490
+ }
1491
+ }
1492
+
1493
+ for (const link of activeEntries(record.load_links)) {
1494
+ const fact = facts.get(link.fact);
1495
+ if (!fact) {
1496
+ throw typedError(
1497
+ 'JUDGMENT_REF',
1498
+ `situation ${record.slug} load link ${link.id}: fact ${link.fact} does not resolve`,
1499
+ );
1500
+ }
1501
+ if (!LOAD_CHANNELS.has(fact.channel)) {
1502
+ throw typedError(
1503
+ 'JUDGMENT_LOAD_CHANNEL',
1504
+ `situation ${record.slug} load link ${link.id}: fact ${fact.id} channel ${fact.channel} cannot carry load`,
1505
+ );
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+ function personCorrectionBlockers(record, factId) {
1511
+ const fact = record.facts.find((entry) => entry.id === factId);
1512
+ const refBlockers = [];
1513
+ const loadBlockers = [];
1514
+
1515
+ if (fact.diverges_with !== undefined) {
1516
+ const paired = record.facts.find((entry) => entry.id === fact.diverges_with);
1517
+ if (
1518
+ !paired
1519
+ || paired.diverges_with !== fact.id
1520
+ || new Set([fact.section, paired.section]).size !== 2
1521
+ || ![fact.section, paired.section].every((section) => ['stated', 'revealed'].includes(section))
1522
+ ) {
1523
+ refBlockers.push(fact.diverges_with);
1524
+ }
1525
+ }
1526
+ if (fact.channel !== 'said') {
1527
+ refBlockers.push(
1528
+ ...record.open_fields
1529
+ .filter((field) => field.status === 'filled' && field.filled_by === fact.id)
1530
+ .map((field) => field.id),
1531
+ );
1532
+ }
1533
+
1534
+ const activeLinks = activeEntries(record.load_links);
1535
+ if (!LOAD_CHANNELS.has(fact.channel)) {
1536
+ loadBlockers.push(
1537
+ ...activeLinks.filter((link) => link.fact === fact.id).map((link) => link.id),
1538
+ );
1539
+ }
1540
+ if (!record.facts.some((entry) => entry.channel === 'said')) {
1541
+ loadBlockers.push(...activeLinks.map((link) => link.id));
1542
+ }
1543
+
1544
+ return {
1545
+ ref: [...new Set(refBlockers)].filter(Boolean),
1546
+ load: [...new Set(loadBlockers)].filter(Boolean),
1547
+ };
1548
+ }
1549
+
1550
+ function situationCorrectionBlockers(record, factId) {
1551
+ const fact = record.facts.find((entry) => entry.id === factId);
1552
+ if (LOAD_CHANNELS.has(fact.channel)) return [];
1553
+ return activeEntries(record.load_links)
1554
+ .filter((link) => link.fact === factId)
1555
+ .map((link) => link.id);
1556
+ }
1557
+
1558
+ function appendCorrectionTraces(priorByFact, provenance) {
1559
+ for (const [fact, prior] of priorByFact) {
1560
+ if (Object.keys(prior).length > 0) {
1561
+ fact.trace.push(correctionTrace(prior, provenance));
1562
+ }
1563
+ }
1564
+ }
1565
+
1566
+ function recordPrior(priorByFact, fact, key) {
1567
+ let prior = priorByFact.get(fact);
1568
+ if (!prior) {
1569
+ prior = {};
1570
+ priorByFact.set(fact, prior);
1571
+ }
1572
+ if (!hasOwn(prior, key)) prior[key] = fact[key] ?? null;
1573
+ }
1574
+
1575
+ function applyFactScalarCorrections(fact, input, priorByFact) {
1576
+ for (const key of ['text', 'at']) {
1577
+ if (hasOwn(input, key) && input[key] !== fact[key]) {
1578
+ recordPrior(priorByFact, fact, key);
1579
+ fact[key] = input[key];
1580
+ }
1581
+ }
1582
+
1583
+ if (hasOwn(input, 'channel') && input.channel !== fact.channel) {
1584
+ recordPrior(priorByFact, fact, 'channel');
1585
+ fact.channel = input.channel;
1586
+ if (input.channel === 'secondhand') {
1587
+ recordPrior(priorByFact, fact, 'via');
1588
+ fact.via = input.via;
1589
+ } else if (fact.via !== undefined) {
1590
+ recordPrior(priorByFact, fact, 'via');
1591
+ delete fact.via;
1592
+ }
1593
+ } else if (hasOwn(input, 'via')) {
1594
+ if (fact.channel !== 'secondhand') {
1595
+ throw typedError(
1596
+ 'JUDGMENT_INPUT',
1597
+ `fact ${fact.id}: via is only valid while channel is secondhand`,
1598
+ );
1599
+ }
1600
+ if (input.via !== fact.via) {
1601
+ recordPrior(priorByFact, fact, 'via');
1602
+ fact.via = input.via;
1603
+ }
1604
+ }
1605
+ }
1606
+
1607
+ const PERSON_VALIDATORS = Object.freeze({
1608
+ create: (args) => validateCreateArgs(args, 'judgment_person_write'),
1609
+ add_fact: validatePersonAddFactArgs,
1610
+ correct: (args) => validateCorrectArgs(args, 'judgment_person_write', { person: true }),
1611
+ open_field: validateOpenFieldArgs,
1612
+ edge: (args) => validateRetirableArgs(
1613
+ args,
1614
+ 'judgment_person_write',
1615
+ 'edge',
1616
+ { createKeys: ['to', 'kind'], idKey: 'edge_id' },
1617
+ ),
1618
+ load_link: (args) => validateRetirableArgs(
1619
+ args,
1620
+ 'judgment_person_write',
1621
+ 'load_link',
1622
+ { createKeys: ['fact', 'carries'], idKey: 'load_link_id' },
1623
+ ),
1624
+ });
1625
+
1626
+ function personRecord(store, slug) {
1627
+ const record = store.readPerson(slug);
1628
+ if (!record) throw typedError('JUDGMENT_NOT_FOUND', `person ${slug} does not exist`);
1629
+ return cloneRecord(record);
1630
+ }
1631
+
1632
+ function writePersonAggregate(cwd, store, record, { created = false } = {}) {
1633
+ assertPersonSemantics(store, record);
1634
+ assertValidRecord('person', record);
1635
+ persistAggregate(cwd, store._personPath(record.slug), {
1636
+ created,
1637
+ write: () => store.writePerson(record),
1638
+ });
1639
+ }
1640
+
1641
+ const PERSON_EXECUTORS = Object.freeze({
1642
+ create(cwd, input, internal) {
1643
+ const store = createJudgmentStore(cwd);
1644
+ if (store.readPerson(input.slug)) {
1645
+ throw typedError('JUDGMENT_CONFLICT', `person ${input.slug} already exists`);
1646
+ }
1647
+ const provenance = stampProvenance(internal);
1648
+ const record = {
1649
+ slug: input.slug,
1650
+ display_name: input.displayName,
1651
+ facts: [],
1652
+ edges: [],
1653
+ open_fields: [],
1654
+ load_links: [],
1655
+ provenance,
1656
+ };
1657
+ writePersonAggregate(cwd, store, record, { created: true });
1658
+ return { op: 'create', slug: record.slug };
1659
+ },
1660
+
1661
+ add_fact(cwd, input, internal) {
1662
+ const store = createJudgmentStore(cwd);
1663
+ const record = personRecord(store, input.slug);
1664
+ const provenance = stampProvenance(internal);
1665
+ const fact = {
1666
+ id: allocateStableEntryId(record, 'facts', 'f'),
1667
+ section: input.section,
1668
+ text: input.text,
1669
+ channel: input.channel,
1670
+ at: input.at,
1671
+ provenance,
1672
+ trace: [],
1673
+ };
1674
+ if (input.via !== undefined) fact.via = input.via;
1675
+ record.facts.push(fact);
1676
+ record.provenance = provenance;
1677
+ writePersonAggregate(cwd, store, record);
1678
+ return { op: 'add_fact', slug: record.slug, id: fact.id };
1679
+ },
1680
+
1681
+ correct(cwd, input, internal) {
1682
+ const store = createJudgmentStore(cwd);
1683
+ const record = personRecord(store, input.slug);
1684
+ const fact = record.facts.find((entry) => entry.id === input.factId);
1685
+ if (!fact) {
1686
+ throw typedError('JUDGMENT_NOT_FOUND', `person ${record.slug} fact ${input.factId} not found`);
1687
+ }
1688
+
1689
+ const provenance = stampProvenance(internal);
1690
+ const priorByFact = new Map();
1691
+ applyFactScalarCorrections(fact, input, priorByFact);
1692
+
1693
+ if (hasOwn(input, 'section') && input.section !== fact.section) {
1694
+ recordPrior(priorByFact, fact, 'section');
1695
+ fact.section = input.section;
1696
+ }
1697
+
1698
+ if (input.pairWith) {
1699
+ const paired = record.facts.find((entry) => entry.id === input.pairWith);
1700
+ if (!paired) {
1701
+ throw typedError(
1702
+ 'JUDGMENT_REF',
1703
+ `person ${record.slug} pair endpoint ${input.pairWith} does not resolve`,
1704
+ );
1705
+ }
1706
+ if (fact.diverges_with !== undefined) {
1707
+ throw typedError(
1708
+ 'JUDGMENT_CONFLICT',
1709
+ `person ${record.slug} fact ${fact.id} is already paired with ${fact.diverges_with}`,
1710
+ );
1711
+ }
1712
+ if (paired.diverges_with !== undefined) {
1713
+ throw typedError(
1714
+ 'JUDGMENT_CONFLICT',
1715
+ `person ${record.slug} fact ${paired.id} is already paired with ${paired.diverges_with}`,
1716
+ );
1717
+ }
1718
+ if (
1719
+ new Set([fact.section, paired.section]).size !== 2
1720
+ || ![fact.section, paired.section].every((section) => ['stated', 'revealed'].includes(section))
1721
+ ) {
1722
+ throw typedError(
1723
+ 'JUDGMENT_REF',
1724
+ `person ${record.slug} pair ${fact.id}↔${paired.id} must join stated↔revealed`,
1725
+ );
1726
+ }
1727
+ recordPrior(priorByFact, fact, 'diverges_with');
1728
+ recordPrior(priorByFact, paired, 'diverges_with');
1729
+ fact.diverges_with = paired.id;
1730
+ paired.diverges_with = fact.id;
1731
+ }
1732
+
1733
+ if (input.clearDivergence) {
1734
+ const pairedId = fact.diverges_with;
1735
+ if (pairedId === undefined) {
1736
+ throw typedError(
1737
+ 'JUDGMENT_CONFLICT',
1738
+ `person ${record.slug} fact ${fact.id} has no divergence pair to clear`,
1739
+ );
1740
+ }
1741
+ const paired = record.facts.find((entry) => entry.id === pairedId);
1742
+ recordPrior(priorByFact, fact, 'diverges_with');
1743
+ delete fact.diverges_with;
1744
+ if (paired?.diverges_with === fact.id) {
1745
+ recordPrior(priorByFact, paired, 'diverges_with');
1746
+ delete paired.diverges_with;
1747
+ }
1748
+ }
1749
+
1750
+ if ([...priorByFact.values()].every((prior) => Object.keys(prior).length === 0)) {
1751
+ throw typedError('JUDGMENT_INPUT', `judgment_person_write correct: fact ${fact.id} has no changed value`);
1752
+ }
1753
+
1754
+ const blockers = personCorrectionBlockers(record, fact.id);
1755
+ const allBlockers = [...new Set([...blockers.ref, ...blockers.load])];
1756
+ if (blockers.ref.length > 0) {
1757
+ throw typedError(
1758
+ 'JUDGMENT_REF',
1759
+ `person ${record.slug} correction of fact ${fact.id} is blocked by dependent entries: ${allBlockers.join(', ')}`,
1760
+ );
1761
+ }
1762
+ if (blockers.load.length > 0) {
1763
+ throw typedError(
1764
+ 'JUDGMENT_LOAD_CHANNEL',
1765
+ `person ${record.slug} correction of fact ${fact.id} is blocked by active load links: ${blockers.load.join(', ')}`,
1766
+ );
1767
+ }
1768
+
1769
+ appendCorrectionTraces(priorByFact, provenance);
1770
+ record.provenance = provenance;
1771
+ writePersonAggregate(cwd, store, record);
1772
+ return { op: 'correct', slug: record.slug, fact_id: fact.id };
1773
+ },
1774
+
1775
+ open_field(cwd, input, internal) {
1776
+ const store = createJudgmentStore(cwd);
1777
+ const record = personRecord(store, input.slug);
1778
+ const provenance = stampProvenance(internal);
1779
+
1780
+ let field;
1781
+ if (input.branch === 'create') {
1782
+ field = {
1783
+ id: allocateStableEntryId(record, 'open_fields', 'of'),
1784
+ name: input.name,
1785
+ status: 'open',
1786
+ provenance,
1787
+ trace: [],
1788
+ };
1789
+ record.open_fields.push(field);
1790
+ } else {
1791
+ field = record.open_fields.find((entry) => entry.id === input.openFieldId);
1792
+ if (!field) {
1793
+ throw typedError(
1794
+ 'JUDGMENT_NOT_FOUND',
1795
+ `person ${record.slug} open field ${input.openFieldId} not found`,
1796
+ );
1797
+ }
1798
+ if (input.branch === 'fill') {
1799
+ if (field.status !== 'open') {
1800
+ throw typedError(
1801
+ 'JUDGMENT_CONFLICT',
1802
+ `person ${record.slug} open field ${field.id} is already ${field.status}`,
1803
+ );
1804
+ }
1805
+ const fact = record.facts.find((entry) => entry.id === input.filledBy);
1806
+ if (!fact) {
1807
+ throw typedError(
1808
+ 'JUDGMENT_REF',
1809
+ `person ${record.slug} open field ${field.id}: filled_by ${input.filledBy} does not resolve`,
1810
+ );
1811
+ }
1812
+ if (fact.channel !== 'said') {
1813
+ throw typedError(
1814
+ 'JUDGMENT_REF',
1815
+ `person ${record.slug} open field ${field.id}: filled_by ${input.filledBy} must name a said fact`,
1816
+ );
1817
+ }
1818
+ field.trace.push(correctionTrace({ status: 'open', filled_by: null }, provenance));
1819
+ field.status = 'filled';
1820
+ field.filled_by = fact.id;
1821
+ } else {
1822
+ if (field.status !== 'filled') {
1823
+ throw typedError(
1824
+ 'JUDGMENT_CONFLICT',
1825
+ `person ${record.slug} open field ${field.id} is already open`,
1826
+ );
1827
+ }
1828
+ field.trace.push(correctionTrace({
1829
+ status: 'filled',
1830
+ filled_by: field.filled_by,
1831
+ }, provenance));
1832
+ field.status = 'open';
1833
+ delete field.filled_by;
1834
+ }
1835
+ }
1836
+
1837
+ record.provenance = provenance;
1838
+ writePersonAggregate(cwd, store, record);
1839
+ return {
1840
+ op: 'open_field',
1841
+ slug: record.slug,
1842
+ id: field.id,
1843
+ status: field.status,
1844
+ };
1845
+ },
1846
+
1847
+ edge(cwd, input, internal) {
1848
+ const store = createJudgmentStore(cwd);
1849
+ const record = personRecord(store, input.slug);
1850
+ const provenance = stampProvenance(internal);
1851
+ let edge;
1852
+
1853
+ if (input.branch === 'create') {
1854
+ if (!store.readPerson(input.to)) {
1855
+ throw typedError(
1856
+ 'JUDGMENT_REF',
1857
+ `person ${record.slug} edge target person ${input.to} does not exist`,
1858
+ );
1859
+ }
1860
+ edge = {
1861
+ id: allocateStableEntryId(record, 'edges', 'e'),
1862
+ to: input.to,
1863
+ kind: input.kind,
1864
+ provenance,
1865
+ removed: null,
1866
+ };
1867
+ record.edges.push(edge);
1868
+ } else {
1869
+ edge = record.edges.find((entry) => entry.id === input.entryId);
1870
+ if (!edge) {
1871
+ throw typedError('JUDGMENT_NOT_FOUND', `person ${record.slug} edge ${input.entryId} not found`);
1872
+ }
1873
+ if (edge.removed !== null) {
1874
+ throw typedError('JUDGMENT_CONFLICT', `person ${record.slug} edge ${edge.id} is already removed`);
1875
+ }
1876
+ edge.removed = removedBlock(input.reason, provenance);
1877
+ }
1878
+
1879
+ record.provenance = provenance;
1880
+ writePersonAggregate(cwd, store, record);
1881
+ return {
1882
+ op: 'edge',
1883
+ slug: record.slug,
1884
+ id: edge.id,
1885
+ removed: edge.removed !== null,
1886
+ };
1887
+ },
1888
+
1889
+ load_link(cwd, input, internal) {
1890
+ const store = createJudgmentStore(cwd);
1891
+ const record = personRecord(store, input.slug);
1892
+ const provenance = stampProvenance(internal);
1893
+ let link;
1894
+
1895
+ if (input.branch === 'create') {
1896
+ const fact = record.facts.find((entry) => entry.id === input.fact);
1897
+ if (!fact) {
1898
+ throw typedError(
1899
+ 'JUDGMENT_REF',
1900
+ `person ${record.slug} load link fact ${input.fact} does not resolve`,
1901
+ );
1902
+ }
1903
+ if (!record.facts.some((entry) => entry.channel === 'said')) {
1904
+ throw typedError('JUDGMENT_LOAD_CHANNEL', `person ${record.slug} is a stub and cannot carry load`);
1905
+ }
1906
+ if (!LOAD_CHANNELS.has(fact.channel)) {
1907
+ throw typedError(
1908
+ 'JUDGMENT_LOAD_CHANNEL',
1909
+ `person ${record.slug} fact ${fact.id} channel ${fact.channel} cannot carry load`,
1910
+ );
1911
+ }
1912
+ link = {
1913
+ id: allocateStableEntryId(record, 'load_links', 'l'),
1914
+ fact: input.fact,
1915
+ carries: input.carries,
1916
+ provenance,
1917
+ removed: null,
1918
+ };
1919
+ record.load_links.push(link);
1920
+ } else {
1921
+ link = record.load_links.find((entry) => entry.id === input.entryId);
1922
+ if (!link) {
1923
+ throw typedError(
1924
+ 'JUDGMENT_NOT_FOUND',
1925
+ `person ${record.slug} load link ${input.entryId} not found`,
1926
+ );
1927
+ }
1928
+ if (link.removed !== null) {
1929
+ throw typedError(
1930
+ 'JUDGMENT_CONFLICT',
1931
+ `person ${record.slug} load link ${link.id} is already removed`,
1932
+ );
1933
+ }
1934
+ link.removed = removedBlock(input.reason, provenance);
1935
+ }
1936
+
1937
+ record.provenance = provenance;
1938
+ writePersonAggregate(cwd, store, record);
1939
+ return {
1940
+ op: 'load_link',
1941
+ slug: record.slug,
1942
+ id: link.id,
1943
+ removed: link.removed !== null,
1944
+ };
1945
+ },
1946
+ });
1947
+
1948
+ /**
1949
+ * One op-discriminated writer for the entire person family.
1950
+ */
1951
+ export async function judgmentPersonWrite(cwd, args, internal = {}) {
1952
+ return runOp(cwd, args, {
1953
+ tool: 'judgment_person_write',
1954
+ validate: () => validateFamilyDispatch(args, 'judgment_person_write', PERSON_VALIDATORS),
1955
+ execute: ({ op, input }) => PERSON_EXECUTORS[op](cwd, input, internal),
1956
+ });
1957
+ }
1958
+
1959
+ const SITUATION_VALIDATORS = Object.freeze({
1960
+ create: (args) => validateCreateArgs(args, 'judgment_situation_write'),
1961
+ add_fact: validateSituationAddFactArgs,
1962
+ correct: (args) => validateCorrectArgs(args, 'judgment_situation_write'),
1963
+ owed: validateOwedArgs,
1964
+ load_link: (args) => validateRetirableArgs(
1965
+ args,
1966
+ 'judgment_situation_write',
1967
+ 'load_link',
1968
+ { createKeys: ['fact', 'carries'], idKey: 'load_link_id' },
1969
+ ),
1970
+ });
1971
+
1972
+ function situationRecord(store, slug) {
1973
+ const record = store.readSituationEntity(slug);
1974
+ if (!record) {
1975
+ throw typedError('JUDGMENT_NOT_FOUND', `situation entity ${slug} does not exist`);
1976
+ }
1977
+ return cloneRecord(record);
1978
+ }
1979
+
1980
+ function writeSituationAggregate(cwd, store, record, { created = false } = {}) {
1981
+ assertSituationSemantics(record);
1982
+ assertValidRecord('situation_entity', record);
1983
+ persistAggregate(cwd, store._situationEntityPath(record.slug), {
1984
+ created,
1985
+ write: () => store.writeSituationEntity(record),
1986
+ });
1987
+ }
1988
+
1989
+ const SITUATION_EXECUTORS = Object.freeze({
1990
+ create(cwd, input, internal) {
1991
+ const store = createJudgmentStore(cwd);
1992
+ if (store.readSituationEntity(input.slug)) {
1993
+ throw typedError('JUDGMENT_CONFLICT', `situation entity ${input.slug} already exists`);
1994
+ }
1995
+ const provenance = stampProvenance(internal);
1996
+ const record = {
1997
+ slug: input.slug,
1998
+ display_name: input.displayName,
1999
+ facts: [],
2000
+ owed: [],
2001
+ load_links: [],
2002
+ provenance,
2003
+ };
2004
+ writeSituationAggregate(cwd, store, record, { created: true });
2005
+ return { op: 'create', slug: record.slug };
2006
+ },
2007
+
2008
+ add_fact(cwd, input, internal) {
2009
+ const store = createJudgmentStore(cwd);
2010
+ const record = situationRecord(store, input.slug);
2011
+ const provenance = stampProvenance(internal);
2012
+ const fact = {
2013
+ id: allocateStableEntryId(record, 'facts', 'f'),
2014
+ text: input.text,
2015
+ channel: input.channel,
2016
+ at: input.at,
2017
+ provenance,
2018
+ trace: [],
2019
+ };
2020
+ if (input.via !== undefined) fact.via = input.via;
2021
+ record.facts.push(fact);
2022
+ record.provenance = provenance;
2023
+ writeSituationAggregate(cwd, store, record);
2024
+ return { op: 'add_fact', slug: record.slug, id: fact.id };
2025
+ },
2026
+
2027
+ correct(cwd, input, internal) {
2028
+ const store = createJudgmentStore(cwd);
2029
+ const record = situationRecord(store, input.slug);
2030
+ const fact = record.facts.find((entry) => entry.id === input.factId);
2031
+ if (!fact) {
2032
+ throw typedError('JUDGMENT_NOT_FOUND', `situation ${record.slug} fact ${input.factId} not found`);
2033
+ }
2034
+ const provenance = stampProvenance(internal);
2035
+ const priorByFact = new Map();
2036
+ applyFactScalarCorrections(fact, input, priorByFact);
2037
+ if ([...priorByFact.values()].every((prior) => Object.keys(prior).length === 0)) {
2038
+ throw typedError('JUDGMENT_INPUT', `judgment_situation_write correct: fact ${fact.id} has no changed value`);
2039
+ }
2040
+ const blockers = situationCorrectionBlockers(record, fact.id);
2041
+ if (blockers.length > 0) {
2042
+ throw typedError(
2043
+ 'JUDGMENT_LOAD_CHANNEL',
2044
+ `situation ${record.slug} correction of fact ${fact.id} is blocked by active load links: ${blockers.join(', ')}`,
2045
+ );
2046
+ }
2047
+ appendCorrectionTraces(priorByFact, provenance);
2048
+ record.provenance = provenance;
2049
+ writeSituationAggregate(cwd, store, record);
2050
+ return { op: 'correct', slug: record.slug, fact_id: fact.id };
2051
+ },
2052
+
2053
+ owed(cwd, input, internal) {
2054
+ const store = createJudgmentStore(cwd);
2055
+ const record = situationRecord(store, input.slug);
2056
+ const provenance = stampProvenance(internal);
2057
+ let owed;
2058
+
2059
+ if (input.branch === 'create') {
2060
+ owed = {
2061
+ id: allocateStableEntryId(record, 'owed', 'o'),
2062
+ name: input.name,
2063
+ why_load_bearing: input.whyLoadBearing,
2064
+ status: 'open',
2065
+ provenance,
2066
+ trace: [],
2067
+ };
2068
+ record.owed.push(owed);
2069
+ } else {
2070
+ owed = record.owed.find((entry) => entry.id === input.owedId);
2071
+ if (!owed) {
2072
+ throw typedError('JUDGMENT_NOT_FOUND', `situation ${record.slug} owed ${input.owedId} not found`);
2073
+ }
2074
+ if (input.branch === 'give') {
2075
+ if (owed.status !== 'open') {
2076
+ throw typedError(
2077
+ 'JUDGMENT_CONFLICT',
2078
+ `situation ${record.slug} owed ${owed.id} is already ${owed.status}`,
2079
+ );
2080
+ }
2081
+ if (!record.facts.some((fact) => fact.id === input.filledBy)) {
2082
+ throw typedError(
2083
+ 'JUDGMENT_REF',
2084
+ `situation ${record.slug} owed ${owed.id}: filled_by ${input.filledBy} does not resolve`,
2085
+ );
2086
+ }
2087
+ owed.trace.push(correctionTrace({ status: 'open', filled_by: null }, provenance));
2088
+ owed.status = 'given';
2089
+ owed.filled_by = input.filledBy;
2090
+ } else {
2091
+ if (owed.status !== 'given') {
2092
+ throw typedError(
2093
+ 'JUDGMENT_CONFLICT',
2094
+ `situation ${record.slug} owed ${owed.id} is already open`,
2095
+ );
2096
+ }
2097
+ owed.trace.push(correctionTrace({
2098
+ status: 'given',
2099
+ filled_by: owed.filled_by,
2100
+ }, provenance));
2101
+ owed.status = 'open';
2102
+ delete owed.filled_by;
2103
+ }
2104
+ }
2105
+
2106
+ record.provenance = provenance;
2107
+ writeSituationAggregate(cwd, store, record);
2108
+ return {
2109
+ op: 'owed',
2110
+ slug: record.slug,
2111
+ id: owed.id,
2112
+ status: owed.status,
2113
+ };
2114
+ },
2115
+
2116
+ load_link(cwd, input, internal) {
2117
+ const store = createJudgmentStore(cwd);
2118
+ const record = situationRecord(store, input.slug);
2119
+ const provenance = stampProvenance(internal);
2120
+ let link;
2121
+
2122
+ if (input.branch === 'create') {
2123
+ const fact = record.facts.find((entry) => entry.id === input.fact);
2124
+ if (!fact) {
2125
+ throw typedError(
2126
+ 'JUDGMENT_REF',
2127
+ `situation ${record.slug} load link fact ${input.fact} does not resolve`,
2128
+ );
2129
+ }
2130
+ if (!LOAD_CHANNELS.has(fact.channel)) {
2131
+ throw typedError(
2132
+ 'JUDGMENT_LOAD_CHANNEL',
2133
+ `situation ${record.slug} fact ${fact.id} channel ${fact.channel} cannot carry load`,
2134
+ );
2135
+ }
2136
+ link = {
2137
+ id: allocateStableEntryId(record, 'load_links', 'l'),
2138
+ fact: input.fact,
2139
+ carries: input.carries,
2140
+ provenance,
2141
+ removed: null,
2142
+ };
2143
+ record.load_links.push(link);
2144
+ } else {
2145
+ link = record.load_links.find((entry) => entry.id === input.entryId);
2146
+ if (!link) {
2147
+ throw typedError(
2148
+ 'JUDGMENT_NOT_FOUND',
2149
+ `situation ${record.slug} load link ${input.entryId} not found`,
2150
+ );
2151
+ }
2152
+ if (link.removed !== null) {
2153
+ throw typedError(
2154
+ 'JUDGMENT_CONFLICT',
2155
+ `situation ${record.slug} load link ${link.id} is already removed`,
2156
+ );
2157
+ }
2158
+ link.removed = removedBlock(input.reason, provenance);
2159
+ }
2160
+
2161
+ record.provenance = provenance;
2162
+ writeSituationAggregate(cwd, store, record);
2163
+ return {
2164
+ op: 'load_link',
2165
+ slug: record.slug,
2166
+ id: link.id,
2167
+ removed: link.removed !== null,
2168
+ };
2169
+ },
2170
+ });
2171
+
2172
+ /**
2173
+ * One op-discriminated writer for the entire situation family.
2174
+ */
2175
+ export async function judgmentSituationWrite(cwd, args, internal = {}) {
2176
+ return runOp(cwd, args, {
2177
+ tool: 'judgment_situation_write',
2178
+ validate: () => validateFamilyDispatch(args, 'judgment_situation_write', SITUATION_VALIDATORS),
2179
+ execute: ({ op, input }) => SITUATION_EXECUTORS[op](cwd, input, internal),
2180
+ });
2181
+ }
2182
+
2183
+ // ---------------------------------------------------------------------------
2184
+ // COMP-JUDGMENT-STORES S4 — judgment_goal_write
2185
+ // ---------------------------------------------------------------------------
2186
+
2187
+ const GOAL_TOOL = 'judgment_goal_write';
2188
+
2189
+ // The precheck must be exactly as strict as the contract's
2190
+ // `format: "date-time"` (a laxer precheck would let malformed input reach
2191
+ // the idempotency cache before validation), so it delegates to the same
2192
+ // ajv instance that performs final schema validation.
2193
+ let _dateTimeValidate = null;
2194
+
2195
+ function isContractDateTime(value) {
2196
+ if (!_dateTimeValidate) {
2197
+ _dateTimeValidate = getJudgmentValidator().ajv
2198
+ .compile({ type: 'string', format: 'date-time' });
2199
+ }
2200
+ return _dateTimeValidate(value);
2201
+ }
2202
+
2203
+ function requireDateTimeValue(value, label, code = 'JUDGMENT_INPUT') {
2204
+ if (typeof value !== 'string' || !isContractDateTime(value)) {
2205
+ throw typedError(code, `${label} must be a valid date-time string`);
2206
+ }
2207
+ return value;
2208
+ }
2209
+
2210
+ function requireNestedString(value, key, label, code = 'JUDGMENT_INPUT') {
2211
+ const field = value?.[key];
2212
+ if (typeof field !== 'string' || field.trim().length === 0) {
2213
+ throw typedError(code, `${label}.${key} must be a non-empty string`);
2214
+ }
2215
+ return field;
2216
+ }
2217
+
2218
+ function sanitizeElicitation(value, label, code = 'JUDGMENT_INPUT') {
2219
+ return {
2220
+ asked: requireNestedString(value, 'asked', label, code),
2221
+ answered_at: requireDateTimeValue(value?.answered_at, `${label}.answered_at`, code),
2222
+ answer_ref: requireNestedString(value, 'answer_ref', label, code),
2223
+ };
2224
+ }
2225
+
2226
+ function sanitizeRatification(value, code = 'JUDGMENT_INPUT') {
2227
+ return {
2228
+ ...sanitizeElicitation(value, `${GOAL_TOOL} cut: ratification`, code),
2229
+ quote: requireNestedString(value, 'quote', `${GOAL_TOOL} cut: ratification`, code),
2230
+ };
146
2231
  }
147
2232
 
148
- function guardEnabled(cwd) {
149
- try {
150
- const config = JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf8'));
151
- return config?.capabilities?.guard === true;
152
- } catch {
153
- return false;
2233
+ function sanitizeProvocation(value, code = 'JUDGMENT_INPUT') {
2234
+ return {
2235
+ quote: requireNestedString(value, 'quote', `${GOAL_TOOL} cut: provocation`, code),
2236
+ at: requireDateTimeValue(value?.at, `${GOAL_TOOL} cut: provocation.at`, code),
2237
+ };
2238
+ }
2239
+
2240
+ function validateGoalClauseInput(value, index, { ordinary }) {
2241
+ const label = `${GOAL_TOOL} cut: clause ${index + 1}`;
2242
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
2243
+ throw typedError('JUDGMENT_INPUT', `${label} must be an object`);
2244
+ }
2245
+ if (ordinary && (value.elicitation == null || typeof value.elicitation !== 'object')) {
2246
+ throw typedError(
2247
+ 'JUDGMENT_UNRATIFIED_CUT',
2248
+ `${label} requires an elicitation citation before an ordinary goal cut`,
2249
+ );
2250
+ }
2251
+ const text = requireNestedString(value, 'text', label);
2252
+ const channel = requireNestedString(value, 'channel', label);
2253
+ if (!FACT_CHANNELS.has(channel)) {
2254
+ throw typedError('JUDGMENT_INPUT', `${label} has unknown channel "${channel}"`);
2255
+ }
2256
+ const via = hasOwn(value, 'via')
2257
+ ? requireNestedString(value, 'via', label)
2258
+ : undefined;
2259
+ if (channel === 'secondhand' && via === undefined) {
2260
+ throw typedError(
2261
+ 'JUDGMENT_INPUT',
2262
+ `${label}: secondhand channel requires a non-empty via`,
2263
+ );
154
2264
  }
2265
+ if (channel !== 'secondhand' && via !== undefined) {
2266
+ throw typedError(
2267
+ 'JUDGMENT_INPUT',
2268
+ `${label}: via is only valid for secondhand channel`,
2269
+ );
2270
+ }
2271
+ const clause = {
2272
+ text,
2273
+ channel,
2274
+ elicitation: sanitizeElicitation(
2275
+ value.elicitation,
2276
+ `${label}.elicitation`,
2277
+ ordinary ? 'JUDGMENT_UNRATIFIED_CUT' : 'JUDGMENT_INPUT',
2278
+ ),
2279
+ };
2280
+ if (via !== undefined) clause.via = via;
2281
+ return clause;
155
2282
  }
156
2283
 
157
- // ---------------------------------------------------------------------------
158
- // Compensating rollback
159
- // ---------------------------------------------------------------------------
2284
+ function validateGoalCutArgs(args, internal) {
2285
+ const internalDraft = internal.via === 'import' || internal.via === 'migration';
2286
+ if (internal.via !== undefined && !internalDraft) {
2287
+ throw typedError(
2288
+ 'JUDGMENT_INPUT',
2289
+ `${GOAL_TOOL} cut: internal via must be import or migration`,
2290
+ );
2291
+ }
2292
+ if (!Array.isArray(args.clauses) || args.clauses.length === 0) {
2293
+ throw typedError(
2294
+ internalDraft ? 'JUDGMENT_INPUT' : 'JUDGMENT_UNRATIFIED_CUT',
2295
+ `${GOAL_TOOL} cut: ordinary cuts require non-empty clauses`,
2296
+ );
2297
+ }
2298
+ if (!internalDraft && (args.provocation == null || typeof args.provocation !== 'object')) {
2299
+ throw typedError(
2300
+ 'JUDGMENT_UNRATIFIED_CUT',
2301
+ `${GOAL_TOOL} cut: ordinary cuts require a provocation citation`,
2302
+ );
2303
+ }
2304
+ if (!internalDraft && (args.ratification == null || typeof args.ratification !== 'object')) {
2305
+ throw typedError(
2306
+ 'JUDGMENT_UNRATIFIED_CUT',
2307
+ `${GOAL_TOOL} cut: ordinary cuts require an owner ratification citation`,
2308
+ );
2309
+ }
160
2310
 
161
- function atomicWrite(path, content) {
162
- const tmp = `${path}.tmp.${process.pid}`;
163
- try {
164
- writeFileSync(tmp, content);
165
- renameSync(tmp, path);
166
- } catch (err) {
167
- try { unlinkSync(tmp); } catch { /* tmp may not exist */ }
168
- throw err;
2311
+ const prepared = {
2312
+ clauses: args.clauses.map(
2313
+ (clause, index) => validateGoalClauseInput(clause, index, { ordinary: !internalDraft }),
2314
+ ),
2315
+ provocation: args.provocation == null
2316
+ ? null
2317
+ : sanitizeProvocation(
2318
+ args.provocation,
2319
+ internalDraft ? 'JUDGMENT_INPUT' : 'JUDGMENT_UNRATIFIED_CUT',
2320
+ ),
2321
+ diffNote: requireString(args, 'diff_note', GOAL_TOOL, 'cut'),
2322
+ internalDraft,
2323
+ };
2324
+ if (args.ratification != null) {
2325
+ prepared.ratification = sanitizeRatification(
2326
+ args.ratification,
2327
+ internalDraft ? 'JUDGMENT_INPUT' : 'JUDGMENT_UNRATIFIED_CUT',
2328
+ );
169
2329
  }
2330
+ return prepared;
170
2331
  }
171
2332
 
172
- class UndoLog {
173
- constructor() { this.entries = []; }
174
-
175
- /** Record a file's prior bytes (null = did not exist) before it is touched. */
176
- note(path) {
177
- this.entries.push({ path, prior: existsSync(path) ? readFileSync(path, 'utf8') : null });
2333
+ function validateGoalCorrectArgs(args) {
2334
+ const clauseId = requireString(args, 'clause_id', GOAL_TOOL, 'correct');
2335
+ if (!/^c[1-9][0-9]*$/.test(clauseId)) {
2336
+ throw typedError(
2337
+ 'JUDGMENT_INPUT',
2338
+ `${GOAL_TOOL} correct: clause_id must match c<positive-integer>`,
2339
+ );
178
2340
  }
2341
+ return {
2342
+ clauseId,
2343
+ text: requireString(args, 'text', GOAL_TOOL, 'correct'),
2344
+ };
2345
+ }
179
2346
 
180
- restore() {
181
- for (const { path, prior } of [...this.entries].reverse()) {
182
- try {
183
- if (prior === null) { try { unlinkSync(path); } catch { /* gone */ } } else atomicWrite(path, prior);
184
- } catch { /* best-effort compensation */ }
2347
+ function validateGoalRetirableArgs(args, op, { createKey, idKey }) {
2348
+ const create = hasOwn(args, createKey);
2349
+ const remove = hasOwn(args, idKey) && args.remove === true;
2350
+ if (Number(create) + Number(remove) !== 1) {
2351
+ throw typedError(
2352
+ 'JUDGMENT_INPUT',
2353
+ `${GOAL_TOOL} ${op}: select exactly one create or remove branch`,
2354
+ );
2355
+ }
2356
+ if (create) {
2357
+ if (hasOwn(args, idKey) || hasOwn(args, 'remove') || hasOwn(args, 'reason')) {
2358
+ throw typedError(
2359
+ 'JUDGMENT_INPUT',
2360
+ `${GOAL_TOOL} ${op}: select exactly one create or remove branch`,
2361
+ );
185
2362
  }
2363
+ const input = {
2364
+ branch: 'create',
2365
+ value: requireString(args, createKey, GOAL_TOOL, op),
2366
+ };
2367
+ if (op === 'load_link') {
2368
+ input.carries = requireString(args, 'carries', GOAL_TOOL, op);
2369
+ }
2370
+ return input;
2371
+ }
2372
+ if (hasOwn(args, createKey) || (op === 'load_link' && hasOwn(args, 'carries'))) {
2373
+ throw typedError(
2374
+ 'JUDGMENT_INPUT',
2375
+ `${GOAL_TOOL} ${op}: select exactly one create or remove branch`,
2376
+ );
2377
+ }
2378
+ return {
2379
+ branch: 'remove',
2380
+ entryId: requireString(args, idKey, GOAL_TOOL, op),
2381
+ reason: requireString(args, 'reason', GOAL_TOOL, op),
2382
+ };
2383
+ }
2384
+
2385
+ function validateGoalDispatch(args, internal) {
2386
+ const op = args?.op;
2387
+ let input;
2388
+ switch (op) {
2389
+ case 'cut':
2390
+ input = validateGoalCutArgs(args, internal);
2391
+ break;
2392
+ case 'correct':
2393
+ input = validateGoalCorrectArgs(args);
2394
+ break;
2395
+ case 'joint_link':
2396
+ input = validateGoalRetirableArgs(
2397
+ args,
2398
+ op,
2399
+ { createKey: 'joint', idKey: 'joint_link_id' },
2400
+ );
2401
+ break;
2402
+ case 'load_link':
2403
+ input = validateGoalRetirableArgs(
2404
+ args,
2405
+ op,
2406
+ { createKey: 'clause', idKey: 'load_link_id' },
2407
+ );
2408
+ break;
2409
+ case 'migrate':
2410
+ input = validateGoalMigrateArgs(args);
2411
+ break;
2412
+ default:
2413
+ throw typedError('JUDGMENT_INPUT', `${GOAL_TOOL}: unknown op "${String(op)}"`);
186
2414
  }
2415
+ return { op, input };
187
2416
  }
188
2417
 
189
2418
  /**
190
- * Run record mutations + projection regen with compensating rollback: on any
191
- * failure the touched records are restored, projections re-regenerated from
192
- * the restored records, and JUDGMENT_PARTIAL_WRITE surfaces the cause.
2419
+ * `op=migrate` takes no caller payload: goal v1, the tombstone, the merged
2420
+ * state, the note, and all provenance are writer-constructed. Any extra key
2421
+ * (records, provenance, intent ids, joint lists, note content) is rejected
2422
+ * before idempotency so a malformed migrate can never be cached as a hit.
193
2423
  */
194
- function commitWithProjections(cwd, undo, mutate) {
195
- try {
196
- mutate();
197
- regenerateProjections(cwd);
198
- } catch (err) {
199
- undo.restore();
200
- try { regenerateProjections(cwd); } catch { /* projections follow restored records on next regen */ }
201
- throw typedError('JUDGMENT_PARTIAL_WRITE', `judgment write rolled back: ${err.message}`, err);
2424
+ function validateGoalMigrateArgs(args) {
2425
+ const allowed = new Set(['op', 'idempotency_key']);
2426
+ const extras = Object.keys(args).filter((key) => !allowed.has(key));
2427
+ if (extras.length > 0) {
2428
+ throw typedError(
2429
+ 'JUDGMENT_INPUT',
2430
+ `${GOAL_TOOL} migrate: takes no payload; unexpected ${extras.sort().join(', ')}`,
2431
+ );
2432
+ }
2433
+ return { branch: 'migrate' };
2434
+ }
2435
+
2436
+ function goalWriteContext(cwd) {
2437
+ const rawStore = createJudgmentStore(cwd);
2438
+ const effective = effectiveStore(rawStore);
2439
+ if (effective.hasPendingIntentKind('goal_migration')) {
2440
+ throw typedError(
2441
+ 'JUDGMENT_INTENT_PENDING',
2442
+ `${GOAL_TOOL}: goal_migration intent is still pending`,
2443
+ );
202
2444
  }
2445
+ return { rawStore, effective };
203
2446
  }
204
2447
 
205
2448
  // ---------------------------------------------------------------------------
206
- // Intent replay (the reconciler)
2449
+ // COMP-JUDGMENT-GOAL-MIGRATE S2 completion predicate, wholesale fence
207
2450
  // ---------------------------------------------------------------------------
208
2451
 
209
- let _intentCounter = 0;
210
- function newIntentId() {
211
- _intentCounter += 1;
212
- return `intent-${Date.now()}-${process.pid}-${_intentCounter}`;
213
- }
2452
+ /**
2453
+ * Durable state of the objective→goal cutover (design r4 / blueprint line 144).
2454
+ * Completion is true ONLY when goal v1 carries `via:"migration"` with a
2455
+ * non-empty intent id, the ledger holds the exact `judgment_goal_write/migrate`
2456
+ * attestation for that id, no matching intent remains, and the objective
2457
+ * position is retracted. Later ordinary goal versions do not invalidate it.
2458
+ *
2459
+ * @returns {{status: 'none'|'attribution_conflict'|'pending'|'revived'|'incomplete'|'complete',
2460
+ * attested: boolean, intent_id: string|null}}
2461
+ * `attested` is the weaker fence predicate: a migration-attested v1 exists,
2462
+ * regardless of whether the objective is currently retracted.
2463
+ */
2464
+ function migrationCompletion(effective, raw) {
2465
+ const v1 = effective.readGoalVersion(1);
2466
+ const intentId = v1?.provenance?.intent_id;
2467
+ if (
2468
+ !v1
2469
+ || v1.provenance?.via !== 'migration'
2470
+ || typeof intentId !== 'string'
2471
+ || intentId === ''
2472
+ ) {
2473
+ return { status: 'none', attested: false, intent_id: null };
2474
+ }
214
2475
 
215
- async function callGuard(cwd, { slug, from, to }) {
216
- // Lazy import: keeps this module leaf-light and cycle-free; the adapter is
217
- // only loaded where capabilities.guard is on.
218
- const { guardedTransition } = await import('../server/lifecycle-guard.js');
219
- return guardedTransition({ featureCode: slug, from, to, workspaceRoot: cwd, mode: 'judgment' });
220
- }
2476
+ const attestations = effective.readLedgerEvents().filter(
2477
+ (event) => event.kind === 'attest' && event.intent_id === intentId,
2478
+ );
2479
+ if (attestations.length === 0) {
2480
+ return { status: 'none', attested: false, intent_id: intentId };
2481
+ }
2482
+ // C11: attribution-only matching is not enough — the attestation resolving a
2483
+ // migration intent must be exactly this tool/op, or the cutover is forged.
2484
+ const mismatch = attestations.find(
2485
+ (event) => event.tool !== GOAL_TOOL || event.op !== 'migrate',
2486
+ );
2487
+ if (mismatch) {
2488
+ return { status: 'attribution_conflict', attested: false, intent_id: intentId };
2489
+ }
221
2490
 
222
- /** Apply an intent payload idempotently (joint write, deduped events, predictions). */
223
- function applyPayload(store, payload) {
224
- if (payload.joint) store.writeJoint(payload.joint);
225
- if (Array.isArray(payload.events) && payload.events.length > 0) {
226
- const existing = new Set(store.readLedgerEvents().map((e) => JSON.stringify(e)));
227
- for (const event of payload.events) {
228
- if (!existing.has(JSON.stringify(event))) store.appendLedgerEvent(event);
229
- }
2491
+ // "No matching intent remains" is a binding conjunct. The effective view
2492
+ // already hides an artifact whose intent is pending, so this is normally
2493
+ // unreachable; it stays as the fail-closed reading of the raw store.
2494
+ if (raw.readIntents().some((intent) => intent.id === intentId)) {
2495
+ return { status: 'pending', attested: true, intent_id: intentId };
2496
+ }
2497
+
2498
+ const objectiveStatus = effective.derivePositionStatus('objective');
2499
+ if (objectiveStatus === 'retracted') {
2500
+ return { status: 'complete', attested: true, intent_id: intentId };
230
2501
  }
231
- for (const prediction of payload.predictions ?? []) store.writePrediction(prediction);
2502
+ return {
2503
+ status: objectiveStatus === 'live' ? 'revived' : 'incomplete',
2504
+ attested: true,
2505
+ intent_id: intentId,
2506
+ };
232
2507
  }
233
2508
 
234
2509
  /**
235
- * Replay every pending intent. MUST be called with the judgment lock held.
236
- * Guard state is authoritative for the edge (re-issuing the transition:
237
- * applied/replayed apply payload; refused drop intent, divergence
238
- * surfaced; unreachable keep intent for the next replay); the intent is
239
- * authoritative for the payload — recovery restores the WHOLE mutation,
240
- * never a bare state flip.
241
- */
242
- /**
243
- * Drop an intent DURABLY: the ledger note lands (idempotent on its title,
244
- * which embeds the intent id) BEFORE the intent is cleared. If the append
245
- * fails the intent survives and the error propagates — a dropped divergence
246
- * must never silently vanish.
2510
+ * The wholesale pre-cutover fence (C5). Applied ONCE, before the executor, to
2511
+ * every non-`migrate` goal op never copied into the ordinary executors.
2512
+ * `goalWriteContext` runs first so a surviving migration intent keeps its
2513
+ * `JUDGMENT_INTENT_PENDING` precedence after replay.
247
2514
  */
248
- function dropIntentDurably(store, intent, reason, detail) {
249
- const payload = intent.payload ?? {};
250
- const title = `intent dropped (${intent.id}): ${payload.slug} ${payload.from} → ${payload.to} — ${reason}`;
251
- const already = store.readLedgerEvents().some((e) => e.kind === 'note' && e.title === title);
252
- if (!already) {
253
- store.appendLedgerEvent({
254
- kind: 'note',
255
- title,
256
- body: JSON.stringify(detail),
257
- anchor: `joint:${payload.slug}`,
258
- provenance: stampProvenance(),
259
- });
2515
+ function assertGoalMigrationFence(cwd, op) {
2516
+ const { rawStore, effective } = goalWriteContext(cwd);
2517
+ const completion = migrationCompletion(effective, rawStore);
2518
+ if (effective.derivePositionStatus('objective') === 'live' && !completion.attested) {
2519
+ throw typedError(
2520
+ 'JUDGMENT_MIGRATION_REQUIRED',
2521
+ `${GOAL_TOOL} ${op}: legacy objective is live; run ${GOAL_TOOL} op=migrate before any other goal operation`,
2522
+ );
260
2523
  }
261
- store.clearIntent(intent.id);
262
2524
  }
263
2525
 
264
- async function replayIntentsLocked(cwd) {
265
- const store = createJudgmentStore(cwd);
266
- const intents = store.readIntents();
267
- let replayed = 0;
268
- let mutated = false;
269
- const divergences = [];
270
- for (const intent of intents) {
271
- const payload = intent.payload ?? {};
272
- // Population invariant FIRST — before any guard call, so recovery can
273
- // never advance the authoritative guard for an intent it is about to
274
- // drop (stacked intents must not split guard state from records).
275
- if (payload.to === 'under_test') {
276
- const occupant = store.listJoints().find((j) => j.slug !== payload.slug && j.state === 'under_test');
277
- if (occupant) {
278
- dropIntentDurably(store, intent, `ONE-UNDER-TEST refused on replay (${occupant.slug} already under test)`, { occupant: occupant.slug });
279
- divergences.push({ intent: intent.id, slug: payload.slug, refused: true, oneUnderTest: occupant.slug });
280
- mutated = true;
281
- continue;
282
- }
283
- }
284
- if (guardEnabled(cwd) && payload.to && payload.from !== payload.to) {
285
- const guard = await callGuard(cwd, { slug: payload.slug, from: payload.from, to: payload.to });
286
- if (!guard.applied) {
287
- // guard unreachable → keep the intent; the next replay retries
288
- if (guard.error) continue;
289
- // The crash window: the guard ALREADY advanced this edge in the
290
- // interrupted attempt, so re-issuing is refused with currentState at
291
- // the target. Guard state is authoritative for the edge — the intent
292
- // is authoritative for the payload: roll the whole mutation forward.
293
- const alreadyAdvanced = guard.currentState === payload.to;
294
- if (!alreadyAdvanced) {
295
- if (!guard.refused) continue; // ambiguous non-verdict → retry later
296
- dropIntentDurably(store, intent, 'refused by guard', {
297
- verdict: guard.verdict ?? null, currentState: guard.currentState ?? null,
298
- });
299
- divergences.push({ intent: intent.id, slug: payload.slug, refused: true, verdict: guard.verdict ?? null });
300
- mutated = true;
301
- continue;
302
- }
303
- }
304
- }
305
- applyPayload(store, payload);
306
- store.clearIntent(intent.id);
307
- replayed += 1;
308
- mutated = true;
2526
+ function resolveEffectiveGoalClause(effective, reference, { ledger = false } = {}) {
2527
+ const pattern = ledger
2528
+ ? /^goal:v([1-9][0-9]*)#(c[1-9][0-9]*)$/
2529
+ : /^v([1-9][0-9]*)#(c[1-9][0-9]*)$/;
2530
+ const match = pattern.exec(reference);
2531
+ if (!match) {
2532
+ throw typedError('JUDGMENT_REF', `goal clause reference ${reference} is invalid`);
2533
+ }
2534
+ const version = Number(match[1]);
2535
+ const clauseId = match[2];
2536
+ const goal = effective.readGoalVersion(version);
2537
+ const clause = goal?.clauses?.find((entry) => entry.id === clauseId);
2538
+ if (!goal || !clause) {
2539
+ throw typedError('JUDGMENT_REF', `goal clause reference ${reference} does not resolve`);
309
2540
  }
310
- if (mutated) regenerateProjections(cwd);
311
- return { replayed, divergences };
2541
+ return { goal, clause };
312
2542
  }
313
2543
 
314
- /**
315
- * Public reconciler: replay pending intents under the lock. Also runs
316
- * automatically at the head of every write op and getJudgmentState.
317
- */
318
- export async function replayPendingIntents(cwd) {
319
- const release = await acquireJudgmentLock(cwd);
320
- try {
321
- return await replayIntentsLocked(cwd);
322
- } finally {
323
- release();
2544
+ function assertGoalStateSemantics(effective, state) {
2545
+ // Calling the high-water allocator validates every extant ID, including
2546
+ // retired entries, without consuming the returned candidate.
2547
+ allocateStableEntryId(state, 'joints', 'gj');
2548
+ allocateStableEntryId(state, 'load_links', 'gl');
2549
+ for (const link of activeEntries(state.joints)) {
2550
+ if (!effective.readJoint(link.joint)) {
2551
+ throw typedError(
2552
+ 'JUDGMENT_REF',
2553
+ `goal joint link ${link.id}: joint ${link.joint} does not resolve`,
2554
+ );
2555
+ }
2556
+ }
2557
+ for (const link of activeEntries(state.load_links)) {
2558
+ resolveEffectiveGoalClause(effective, link.clause);
324
2559
  }
2560
+ assertValidRecord('goal_state', state);
325
2561
  }
326
2562
 
327
- // ---------------------------------------------------------------------------
328
- // Op wrapper: validate → (idempotency) → lock → replay → execute → audit
329
- // ---------------------------------------------------------------------------
2563
+ function writeGoalState(cwd, rawStore, effective, state) {
2564
+ assertGoalStateSemantics(effective, state);
2565
+ persistAggregate(cwd, rawStore._goalStatePath(), {
2566
+ created: !existsSync(rawStore._goalStatePath()),
2567
+ write: () => rawStore.writeGoalState(state),
2568
+ });
2569
+ }
330
2570
 
331
- async function runOp(cwd, args, { tool, validate, execute }) {
332
- if (args?.provenance !== undefined) {
333
- throw typedError('JUDGMENT_INPUT', `${tool}: provenance is writer-stamped and cannot be set by the caller`);
334
- }
335
- const prepared = validate ? validate() : undefined; // sync, BEFORE idempotency (A1/A4)
2571
+ const GOAL_EXECUTORS = Object.freeze({
2572
+ cut(cwd, input, internal) {
2573
+ const { rawStore, effective } = goalWriteContext(cwd);
2574
+ const rawChain = rawStore.readGoalChain();
2575
+ const version = (rawChain.at(-1)?.version ?? 0) + 1;
2576
+ const provenance = stampProvenance(internal);
2577
+ const record = {
2578
+ version,
2579
+ clauses: [],
2580
+ provocation: input.provocation,
2581
+ diff_note: input.diffNote,
2582
+ provenance,
2583
+ };
2584
+ if (input.ratification !== undefined) record.ratification = input.ratification;
2585
+ for (const inputClause of input.clauses) {
2586
+ const clause = {
2587
+ id: allocateStableEntryId(record, 'clauses', 'c'),
2588
+ text: inputClause.text,
2589
+ channel: inputClause.channel,
2590
+ elicitation: inputClause.elicitation,
2591
+ provenance,
2592
+ trace: [],
2593
+ };
2594
+ if (inputClause.via !== undefined) clause.via = inputClause.via;
2595
+ record.clauses.push(clause);
2596
+ }
2597
+ assertValidRecord('goal_version', record);
336
2598
 
337
- const doWrite = async () => {
338
- const release = await acquireJudgmentLock(cwd);
339
- try {
340
- await replayIntentsLocked(cwd);
341
- return await execute(prepared);
342
- } finally {
343
- release();
2599
+ const undo = new UndoLog();
2600
+ const path = join(rawStore._goalDir(), `v${version}.json`);
2601
+ commitWithProjections(cwd, undo, () => {
2602
+ undo.created(path);
2603
+ rawStore.writeGoalVersion(record);
2604
+ });
2605
+ return {
2606
+ op: 'cut',
2607
+ version,
2608
+ ref: `goal:v${version}`,
2609
+ ratified: record.ratification != null,
2610
+ };
2611
+ },
2612
+
2613
+ correct(cwd, input, internal) {
2614
+ const { rawStore, effective } = goalWriteContext(cwd);
2615
+ const current = effective.latestGoalVersion();
2616
+ if (!current) {
2617
+ throw typedError('JUDGMENT_REF', `${GOAL_TOOL} correct: no effective current goal version`);
2618
+ }
2619
+ const record = cloneRecord(current);
2620
+ const clause = record.clauses.find((entry) => entry.id === input.clauseId);
2621
+ if (!clause) {
2622
+ throw typedError(
2623
+ 'JUDGMENT_REF',
2624
+ `${GOAL_TOOL} correct: current goal v${record.version} has no clause ${input.clauseId}`,
2625
+ );
2626
+ }
2627
+ if (clause.text === input.text) {
2628
+ throw typedError(
2629
+ 'JUDGMENT_INPUT',
2630
+ `${GOAL_TOOL} correct: clause ${input.clauseId} wording is unchanged`,
2631
+ );
2632
+ }
2633
+ const provenance = stampProvenance(internal);
2634
+ clause.trace.push(correctionTrace({ text: clause.text }, provenance));
2635
+ clause.text = input.text;
2636
+ assertValidRecord('goal_version', record);
2637
+
2638
+ const undo = new UndoLog();
2639
+ const path = join(rawStore._goalDir(), `v${record.version}.json`);
2640
+ commitWithProjections(cwd, undo, () => {
2641
+ undo.capture(path);
2642
+ rawStore.replaceGoalVersion(record.version, record);
2643
+ });
2644
+ return {
2645
+ op: 'correct',
2646
+ version: record.version,
2647
+ clause_id: clause.id,
2648
+ };
2649
+ },
2650
+
2651
+ joint_link(cwd, input, internal) {
2652
+ const { rawStore, effective } = goalWriteContext(cwd);
2653
+ const provenance = stampProvenance(internal);
2654
+ const state = cloneRecord(effective.readGoalState() ?? {
2655
+ joints: [],
2656
+ load_links: [],
2657
+ provenance,
2658
+ });
2659
+ let link;
2660
+ if (input.branch === 'create') {
2661
+ if (!effective.readJoint(input.value)) {
2662
+ throw typedError(
2663
+ 'JUDGMENT_REF',
2664
+ `${GOAL_TOOL} joint_link: joint ${input.value} does not resolve`,
2665
+ );
2666
+ }
2667
+ link = {
2668
+ id: allocateStableEntryId(state, 'joints', 'gj'),
2669
+ joint: input.value,
2670
+ provenance,
2671
+ removed: null,
2672
+ };
2673
+ state.joints.push(link);
2674
+ } else {
2675
+ link = state.joints.find((entry) => entry.id === input.entryId);
2676
+ if (!link) {
2677
+ throw typedError(
2678
+ 'JUDGMENT_NOT_FOUND',
2679
+ `${GOAL_TOOL} joint_link: association ${input.entryId} not found`,
2680
+ );
2681
+ }
2682
+ if (link.removed !== null) {
2683
+ throw typedError(
2684
+ 'JUDGMENT_CONFLICT',
2685
+ `${GOAL_TOOL} joint_link: association ${link.id} is already removed`,
2686
+ );
2687
+ }
2688
+ link.removed = removedBlock(input.reason, provenance);
2689
+ }
2690
+ state.provenance = provenance;
2691
+ writeGoalState(cwd, rawStore, effective, state);
2692
+ return {
2693
+ op: 'joint_link',
2694
+ id: link.id,
2695
+ removed: link.removed !== null,
2696
+ };
2697
+ },
2698
+
2699
+ load_link(cwd, input, internal) {
2700
+ const { rawStore, effective } = goalWriteContext(cwd);
2701
+ const provenance = stampProvenance(internal);
2702
+ const state = cloneRecord(effective.readGoalState() ?? {
2703
+ joints: [],
2704
+ load_links: [],
2705
+ provenance,
2706
+ });
2707
+ let link;
2708
+ if (input.branch === 'create') {
2709
+ resolveEffectiveGoalClause(effective, input.value);
2710
+ link = {
2711
+ id: allocateStableEntryId(state, 'load_links', 'gl'),
2712
+ clause: input.value,
2713
+ carries: input.carries,
2714
+ provenance,
2715
+ removed: null,
2716
+ };
2717
+ state.load_links.push(link);
2718
+ } else {
2719
+ link = state.load_links.find((entry) => entry.id === input.entryId);
2720
+ if (!link) {
2721
+ throw typedError(
2722
+ 'JUDGMENT_NOT_FOUND',
2723
+ `${GOAL_TOOL} load_link: association ${input.entryId} not found`,
2724
+ );
2725
+ }
2726
+ if (link.removed !== null) {
2727
+ throw typedError(
2728
+ 'JUDGMENT_CONFLICT',
2729
+ `${GOAL_TOOL} load_link: association ${link.id} is already removed`,
2730
+ );
2731
+ }
2732
+ link.removed = removedBlock(input.reason, provenance);
2733
+ }
2734
+ state.provenance = provenance;
2735
+ writeGoalState(cwd, rawStore, effective, state);
2736
+ return {
2737
+ op: 'load_link',
2738
+ id: link.id,
2739
+ removed: link.removed !== null,
2740
+ };
2741
+ },
2742
+
2743
+ /**
2744
+ * One-shot, fail-closed cutover. `migrate` is exempt from the wholesale
2745
+ * fence (it is the op that lifts it) but not from intent precedence: a
2746
+ * migration intent that survived replay is reported, never duplicated.
2747
+ * Completion and its repair diagnostics are checked before the legality
2748
+ * window so a rerun is a no-op and a revived objective names its repair.
2749
+ * Every refusal below performs no canonical write.
2750
+ */
2751
+ async migrate(cwd, input, internal) {
2752
+ const { rawStore, effective } = goalWriteContext(cwd);
2753
+ const completion = migrationCompletion(effective, rawStore);
2754
+
2755
+ if (completion.status === 'attribution_conflict') {
2756
+ throw migrationConflict({ id: completion.intent_id }, 'durable attestation attribution conflicts');
2757
+ }
2758
+ if (completion.status === 'complete') {
2759
+ // No new record: republish projections so a stale generated surface
2760
+ // converges, and report the original publication's intent id. There is
2761
+ // nothing to roll back — the migration is already published — so a
2762
+ // regeneration failure keeps the published-migration semantics: report
2763
+ // stale and repair on the next regeneration/read.
2764
+ try {
2765
+ regenerateProjections(cwd);
2766
+ } catch (err) {
2767
+ throw typedError(
2768
+ 'JUDGMENT_PROJECTION_STALE',
2769
+ `intent ${completion.intent_id} canonical effects are committed but projections are stale: ${err.message}`,
2770
+ err,
2771
+ );
2772
+ }
2773
+ return {
2774
+ op: 'migrate',
2775
+ status: 'already migrated',
2776
+ version: 1,
2777
+ ref: 'goal:v1',
2778
+ intent_id: completion.intent_id,
2779
+ };
2780
+ }
2781
+ if (completion.status === 'revived') {
2782
+ throw typedError(
2783
+ 'JUDGMENT_MIGRATION_CONFLICT',
2784
+ `${GOAL_TOOL} migrate: migration-attested goal v1 exists but objective is live; append retracted:true through judgment_position_create, then retry`,
2785
+ );
344
2786
  }
345
- };
346
2787
 
347
- let result;
348
- let cached = false;
349
- if (args?.idempotency_key) {
350
- ({ result, cached } = await checkOrInsert(cwd, `${tool}:${args.idempotency_key}`, doWrite));
351
- } else {
352
- result = await doWrite();
353
- }
2788
+ // Legality window: a live effective objective plus an empty goal chain in
2789
+ // BOTH views. A sidecar does not close the window; a hidden or ordinary
2790
+ // goal version does.
2791
+ if (effective.readGoalChain().length > 0 || rawStore.readGoalChain().length > 0) {
2792
+ throw typedError(
2793
+ 'JUDGMENT_MIGRATION_CONFLICT',
2794
+ `${GOAL_TOOL} migrate: goal chain is non-empty without a durable migration-attested v1`,
2795
+ );
2796
+ }
2797
+ if (effective.derivePositionStatus('objective') !== 'live') {
2798
+ throw typedError(
2799
+ 'JUDGMENT_MIGRATION_CONFLICT',
2800
+ `${GOAL_TOOL} migrate: legacy objective is not live and no durable migration is complete`,
2801
+ );
2802
+ }
354
2803
 
355
- if (!cached) {
356
- // Best-effort audit AFTER commit (A5) never let audit failure surface.
357
- try { appendEvent(cwd, { tool, result }); } catch { /* best-effort */ }
358
- }
359
- return result;
2804
+ const intentId = newIntentId();
2805
+ const provenance = stampProvenance({ ...internal, via: 'migration', intentId });
2806
+ const intent = buildGoalMigrationIntent(cwd, rawStore, effective, { intentId, provenance });
2807
+ rawStore.persistIntent(intent);
2808
+ syncManifest(cwd, [rawStore._intentPath(intent.id)]);
2809
+ await publishIntentWith(cwd, rawStore, intent, internal.appliers ?? INTENT_APPLIERS);
2810
+ return {
2811
+ op: 'migrate',
2812
+ status: 'migrated',
2813
+ version: 1,
2814
+ ref: 'goal:v1',
2815
+ intent_id: intentId,
2816
+ };
2817
+ },
2818
+ });
2819
+
2820
+ /**
2821
+ * One op-discriminated writer for goal versions and their mutable sidecar.
2822
+ * `internal.appliers` is the C13 unit-injection seam (never MCP-reachable):
2823
+ * it threads a substitute intent-applier map through replay for failure tests.
2824
+ */
2825
+ export async function judgmentGoalWrite(cwd, args, internal = {}) {
2826
+ return runOp(cwd, args, {
2827
+ tool: GOAL_TOOL,
2828
+ validate: () => validateGoalDispatch(args, internal),
2829
+ execute: ({ op, input }) => {
2830
+ // One central fence for the whole goal store (C5), after replay and
2831
+ // under the held lock. `migrate` is the op that lifts it.
2832
+ if (op !== 'migrate') assertGoalMigrationFence(cwd, op);
2833
+ return GOAL_EXECUTORS[op](cwd, input, internal);
2834
+ },
2835
+ }, internal.appliers);
360
2836
  }
361
2837
 
362
2838
  // ---------------------------------------------------------------------------
@@ -388,6 +2864,20 @@ export async function judgmentPositionCreate(cwd, args, internal = {}) {
388
2864
  },
389
2865
  execute: (record) => {
390
2866
  const store = createJudgmentStore(cwd);
2867
+ // C6 — terminal objective retirement. Store-backed, so it lives here
2868
+ // (after replay, under the lock), never in synchronous validation. A
2869
+ // `retracted:true` tombstone stays legal: it is the repair path for a
2870
+ // revived objective.
2871
+ if (
2872
+ record.slug === 'objective'
2873
+ && record.retracted !== true
2874
+ && goalCutoverComplete(effectiveStore(store))
2875
+ ) {
2876
+ throw typedError(
2877
+ 'JUDGMENT_OBJECTIVE_RETIRED',
2878
+ 'judgment_position_create: objective is retired after goal cutover; use judgment_goal_write for goal changes (retracted:true remains legal)',
2879
+ );
2880
+ }
391
2881
  if (record.supersedes) {
392
2882
  const [targetSlug, revPart] = record.supersedes.split('#r');
393
2883
  if (!store.readPositionRevision(targetSlug, Number(revPart))) {
@@ -398,9 +2888,7 @@ export async function judgmentPositionCreate(cwd, args, internal = {}) {
398
2888
  let written;
399
2889
  commitWithProjections(cwd, undo, () => {
400
2890
  written = store.writePositionRevision(record);
401
- undo.note(written.path); // written before noted? note AFTER write would store new bytes —
402
- // the file is brand new, so restore() must delete it: record prior=null explicitly.
403
- undo.entries[undo.entries.length - 1].prior = null;
2891
+ undo.created(written.path);
404
2892
  });
405
2893
  return { slug: record.slug, rev: written.rev, ref: written.ref, status: store.derivePositionStatus(record.slug) };
406
2894
  },
@@ -469,7 +2957,7 @@ export async function judgmentPositionAmend(cwd, args, internal = {}) {
469
2957
  let written;
470
2958
  commitWithProjections(cwd, undo, () => {
471
2959
  written = store.writePositionRevision(record);
472
- undo.entries.push({ path: written.path, prior: null });
2960
+ undo.created(written.path);
473
2961
  });
474
2962
  return { slug: args.slug, rev: written.rev, ref: written.ref };
475
2963
  },
@@ -522,7 +3010,7 @@ export async function judgmentJointAdd(cwd, args, internal = {}) {
522
3010
  }
523
3011
  const undo = new UndoLog();
524
3012
  commitWithProjections(cwd, undo, () => {
525
- undo.entries.push({ path: store._jointPath(record.slug), prior: null });
3013
+ undo.created(store._jointPath(record.slug));
526
3014
  store.writeJoint(record);
527
3015
  });
528
3016
  return { slug: record.slug, state: record.state, rank: record.rank };
@@ -584,7 +3072,8 @@ export async function judgmentTransition(cwd, args, internal = {}) {
584
3072
  const joint = store.readJoint(args.slug);
585
3073
  if (!joint) throw typedError('JUDGMENT_NOT_FOUND', `joint ${args.slug} does not exist`);
586
3074
  const from = joint.state;
587
- const provenance = stampProvenance(internal);
3075
+ const intentId = newIntentId();
3076
+ const provenance = stampProvenance({ ...internal, intentId });
588
3077
  const events = [];
589
3078
  const mutated = { ...joint };
590
3079
 
@@ -628,7 +3117,13 @@ export async function judgmentTransition(cwd, args, internal = {}) {
628
3117
  if (other) {
629
3118
  throw typedError('JUDGMENT_CONFLICT', `ONE-UNDER-TEST: joint ${other.slug} is already under test`);
630
3119
  }
631
- const pending = store.readIntents().find((i) => i.payload?.to === 'under_test' && i.payload?.slug !== args.slug);
3120
+ const pending = store.readIntents().find(
3121
+ (intent) => (
3122
+ intent.kind === 'transition'
3123
+ && intent.payload?.to === 'under_test'
3124
+ && intent.payload?.slug !== args.slug
3125
+ ),
3126
+ );
632
3127
  if (pending) {
633
3128
  throw typedError('JUDGMENT_CONFLICT', `ONE-UNDER-TEST: pending intent ${pending.id} already targets under_test for ${pending.payload.slug}`);
634
3129
  }
@@ -690,58 +3185,43 @@ export async function judgmentTransition(cwd, args, internal = {}) {
690
3185
 
691
3186
  // Intent-first: persist the COMPLETE mutation before the guard call.
692
3187
  const intent = {
693
- id: newIntentId(),
694
- op: 'judgment_transition',
3188
+ id: intentId,
3189
+ kind: 'transition',
3190
+ tool: 'judgment_transition',
3191
+ op: 'transition',
695
3192
  payload: { slug: args.slug, from, to: mutated.state, joint: mutated, events, predictions: [] },
696
3193
  created_at: provenance.written_at,
697
3194
  };
698
3195
  assertValidRecord('pending_intent', intent);
699
3196
  store.persistIntent(intent);
3197
+ syncManifest(cwd, [store._intentPath(intent.id)]);
700
3198
 
701
- let guardInfo = null;
702
- if (isEdge && guardEnabled(cwd)) {
703
- const guard = await callGuard(cwd, { slug: args.slug, from, to: args.to });
704
- if (!guard.applied) {
705
- if (guard.refused) {
706
- // Local graph said legal, guard said no — surfaced, never hidden,
707
- // and DURABLY: the drop lands as a ledger note before the intent
708
- // clears, so the refusal survives even if the caller drops the
709
- // transient result.
710
- dropIntentDurably(store, intent, 'refused by guard', {
711
- verdict: guard.verdict ?? null, currentState: guard.currentState ?? null,
712
- });
713
- regenerateProjections(cwd);
714
- return {
715
- applied: false,
716
- refused: true,
717
- slug: args.slug,
718
- from,
719
- to: args.to,
720
- guard: { verdict: guard.verdict ?? null, ledgerRef: guard.ledgerRef ?? null, currentState: guard.currentState ?? null },
721
- divergence: true,
722
- };
723
- }
724
- // Guard ERROR (unreachable/timeout) is ambiguous — the guard may
725
- // have advanced before the response was lost. KEEP the intent: the
726
- // reconciler resolves it on the next write/read (fresh verdict →
727
- // roll forward; currentState at target → roll forward; genuine
728
- // refusal → durable drop).
729
- throw typedError('JUDGMENT_GUARD_UNAVAILABLE', `guard transition failed closed (intent ${intent.id} kept for replay): ${guard.error?.message ?? JSON.stringify(guard.error)}`, guard.error);
730
- }
731
- guardInfo = { verdict: guard.verdict ?? null, ledgerRef: guard.ledgerRef ?? null, currentState: guard.currentState ?? null };
3199
+ const publication = await publishIntentLocked(cwd, store, intent);
3200
+ if (publication.status === 'blocked') {
3201
+ throw typedError(
3202
+ 'JUDGMENT_GUARD_UNAVAILABLE',
3203
+ `guard transition failed closed (intent ${intent.id} kept for replay): ${publication.error?.message ?? publication.reason}`,
3204
+ publication.error,
3205
+ );
3206
+ }
3207
+ if (publication.status === 'refused') {
3208
+ return {
3209
+ applied: false,
3210
+ refused: true,
3211
+ slug: args.slug,
3212
+ from,
3213
+ to: args.to,
3214
+ guard: publication.guard ?? {
3215
+ verdict: null,
3216
+ ledgerRef: null,
3217
+ currentState: null,
3218
+ },
3219
+ divergence: true,
3220
+ };
732
3221
  }
733
-
734
- const undo = new UndoLog();
735
- commitWithProjections(cwd, undo, () => {
736
- undo.note(store._jointPath(args.slug));
737
- undo.note(store._ledgerPath());
738
- for (const p of intent.payload.predictions) undo.note(store._predictionPath(p.id));
739
- applyPayload(store, intent.payload);
740
- });
741
- store.clearIntent(intent.id);
742
3222
 
743
3223
  const result = { applied: true, slug: args.slug, from, to: mutated.state, state: mutated.state, rank: mutated.rank };
744
- if (guardInfo) result.guard = guardInfo;
3224
+ if (publication.guard) result.guard = publication.guard;
745
3225
  return result;
746
3226
  },
747
3227
  });
@@ -755,7 +3235,7 @@ const LEDGER_PASSTHROUGH_KEYS = [
755
3235
  'kind', 'title', 'body', 'refs', 'rejected', 'conviction', 'trigger',
756
3236
  'open_joints', 'prediction', 'disposition', 'recall_verdict', 'attribution',
757
3237
  'prediction_ref', 'prediction_grade', 'reason', 'anchor', 'rank_change',
758
- 'elicitation',
3238
+ 'elicitation', 'rests_on',
759
3239
  ];
760
3240
 
761
3241
  function nextPredictionId(store) {
@@ -773,6 +3253,12 @@ export async function judgmentLedgerAppend(cwd, args, internal = {}) {
773
3253
  return runOp(cwd, args, {
774
3254
  tool: 'judgment_ledger_append',
775
3255
  validate: () => {
3256
+ if (args.kind === 'attest') {
3257
+ throw typedError(
3258
+ 'JUDGMENT_INPUT',
3259
+ 'judgment_ledger_append: attest is a writer-reserved intent publication event',
3260
+ );
3261
+ }
776
3262
  const event = { provenance: stampProvenance(internal) };
777
3263
  for (const key of LEDGER_PASSTHROUGH_KEYS) {
778
3264
  if (args[key] !== undefined) event[key] = args[key];
@@ -780,11 +3266,33 @@ export async function judgmentLedgerAppend(cwd, args, internal = {}) {
780
3266
  assertValidRecord('ledger_event', event);
781
3267
  return event;
782
3268
  },
783
- execute: (event) => {
3269
+ execute: async (event) => {
784
3270
  const store = createJudgmentStore(cwd);
785
3271
 
786
3272
  let spawned = null;
787
3273
  const isCommitDecide = event.kind === 'decide' && event.trigger !== undefined;
3274
+ if (isCommitDecide && (event.rests_on?.length ?? 0) > 0) {
3275
+ const effective = effectiveStore(store);
3276
+ if (effective.hasPendingIntentKind('goal_migration')) {
3277
+ throw typedError(
3278
+ 'JUDGMENT_INTENT_PENDING',
3279
+ 'judgment_ledger_append: goal_migration intent is still pending',
3280
+ );
3281
+ }
3282
+ for (const reference of event.rests_on) {
3283
+ const { clause } = resolveEffectiveGoalClause(
3284
+ effective,
3285
+ reference,
3286
+ { ledger: true },
3287
+ );
3288
+ if (clause.channel === 'inferred') {
3289
+ throw typedError(
3290
+ 'JUDGMENT_INFERRED_COMMIT',
3291
+ `judgment_ledger_append: ${reference} is inferred and may not carry a commit`,
3292
+ );
3293
+ }
3294
+ }
3295
+ }
788
3296
  const isConstructDisposition = event.disposition === 'CONSTRUCT';
789
3297
  if ((isCommitDecide || isConstructDisposition) && event.prediction) {
790
3298
  spawned = {
@@ -808,12 +3316,42 @@ export async function judgmentLedgerAppend(cwd, args, internal = {}) {
808
3316
  assertValidRecord('prediction', graded);
809
3317
  }
810
3318
 
3319
+ // GOV-COMPOSE-SEAM-1 `canon-on-decisions` P2 — the SmartMemory write.
3320
+ //
3321
+ // BEFORE the local commit, deliberately. Under D1 the SmartMemory
3322
+ // decision is the canon and the markdown is a projection, so the canon
3323
+ // is written first and the projection follows. It is also the only
3324
+ // ordering that fails safely: a remote failure throws and nothing local
3325
+ // is written, whereas committing locally first would leave a decision
3326
+ // that exists in the projection and nowhere else.
3327
+ //
3328
+ // The seq is computed here rather than taken from `appendLedgerEvent`
3329
+ // because the key needs it before the append. Safe: we hold the advisory
3330
+ // lock, so the length cannot move underneath us. If the local commit
3331
+ // below then fails, a retry recomputes the SAME seq, hits the sidecar,
3332
+ // skips the remote write and completes the local one.
3333
+ //
3334
+ // `writeJudgmentDecision` is FAIL-CLOSED and returns null only when the
3335
+ // coupling is disabled. A live entry carrying an inferred conviction is
3336
+ // written on the inferred confidence scale and flagged
3337
+ // `conviction_review_required` for a later ruling — the P2.5 gate blocks
3338
+ // the BACKFILL, not new entries, which would otherwise be unrecordable.
3339
+ const nextSeq = store.readLedgerEvents().length + 1;
3340
+ const mapped = ledgerEntryToDecision(event, nextSeq);
3341
+ if (mapped) {
3342
+ await writeJudgmentDecision(cwd, mapped);
3343
+ }
3344
+
811
3345
  const undo = new UndoLog();
812
3346
  let seq;
813
3347
  commitWithProjections(cwd, undo, () => {
814
- undo.note(store._ledgerPath());
815
- if (spawned) undo.entries.push({ path: store._predictionPath(spawned.id), prior: null });
816
- if (graded) undo.note(store._predictionPath(graded.id));
3348
+ undo.capture(store._ledgerPath());
3349
+ if (spawned) {
3350
+ const path = store._predictionPath(spawned.id);
3351
+ if (existsSync(path)) undo.capture(path);
3352
+ else undo.created(path);
3353
+ }
3354
+ if (graded) undo.capture(store._predictionPath(graded.id));
817
3355
  ({ seq } = store.appendLedgerEvent(event));
818
3356
  if (spawned) store.writePrediction(spawned);
819
3357
  if (graded) store.writePrediction(graded);
@@ -824,7 +3362,7 @@ export async function judgmentLedgerAppend(cwd, args, internal = {}) {
824
3362
  if (graded) result.graded = graded.id;
825
3363
  return result;
826
3364
  },
827
- });
3365
+ }, internal.appliers);
828
3366
  }
829
3367
 
830
3368
  // ---------------------------------------------------------------------------
@@ -836,42 +3374,84 @@ export async function judgmentLedgerAppend(cwd, args, internal = {}) {
836
3374
  * recent ledger. Small result (AUDIT-19) — titles and refs, never full
837
3375
  * document text. Replays pending intents first (reconciler-on-read).
838
3376
  */
839
- export async function getJudgmentState(cwd) {
3377
+ /**
3378
+ * Causal ancestry for one position (COMP-JUDGMENT-PRECEDENT slice A).
3379
+ *
3380
+ * Read-only counterpart to getJudgmentState, which returns latest-only and so
3381
+ * cannot answer "what did this replace, and what did we believe then". Takes the
3382
+ * same lock + intent replay so the trace reflects committed state, not a
3383
+ * half-applied intent queue.
3384
+ */
3385
+ export async function getJudgmentTrace(cwd, slug) {
3386
+ if (typeof slug !== 'string' || slug.trim() === '') {
3387
+ throw typedError('JUDGMENT_INPUT', 'get_judgment_trace: slug is required');
3388
+ }
840
3389
  const release = await acquireJudgmentLock(cwd);
841
- let replay;
842
3390
  try {
843
- replay = await replayIntentsLocked(cwd);
3391
+ await replayIntentsLocked(cwd);
3392
+ const store = effectiveStore(createJudgmentStore(cwd));
3393
+ return tracePosition(store, slug.trim());
844
3394
  } finally {
845
3395
  release();
846
3396
  }
847
- const store = createJudgmentStore(cwd);
848
- const positions = store.listPositionSlugs().map((slug) => {
849
- const latest = store.latestPositionRevision(slug);
3397
+ }
3398
+
3399
+ export async function getJudgmentState(cwd) {
3400
+ const release = await acquireJudgmentLock(cwd);
3401
+ try {
3402
+ const replay = await replayIntentsLocked(cwd);
3403
+ regenerateProjections(cwd);
3404
+ const store = effectiveStore(createJudgmentStore(cwd));
3405
+ // Prebuilt index — see COMP-JUDGMENT-PRECEDENT: without it this loop is
3406
+ // O(n^2) over the store.
3407
+ const supersession = buildSupersessionIndex(store);
3408
+ const positions = store.listPositionSlugs().map((slug) => {
3409
+ const latest = store.latestPositionRevision(slug);
3410
+ return {
3411
+ slug,
3412
+ ref: `${slug}#r${latest.rev}`,
3413
+ status: store.derivePositionStatus(slug, supersession),
3414
+ conviction: latest.conviction?.level ?? null,
3415
+ };
3416
+ });
3417
+ const joints = store.listJoints().map((j) => ({
3418
+ slug: j.slug,
3419
+ state: j.state,
3420
+ resolve_by: j.resolve_by,
3421
+ cost: j.cost,
3422
+ rank: j.rank,
3423
+ }));
3424
+ const openPredictions = store.listPredictions({ status: 'open' })
3425
+ .map((p) => ({ id: p.id, text: p.text, context: p.context }));
3426
+ const recentLedger = store.readLedgerEvents().slice(-10)
3427
+ .map((e) => ({ kind: e.kind, title: e.title }));
3428
+ const people = store.listPeople();
3429
+ const latestGoal = store.latestGoalVersion();
850
3430
  return {
851
- slug,
852
- ref: `${slug}#r${latest.rev}`,
853
- status: store.derivePositionStatus(slug),
854
- conviction: latest.conviction?.level ?? null,
3431
+ positions,
3432
+ joints,
3433
+ under_test: joints.filter((j) => j.state === 'under_test').map((j) => j.slug),
3434
+ open_predictions: openPredictions,
3435
+ recent_ledger: recentLedger,
3436
+ intents_replayed: replay.replayed,
3437
+ divergences: replay.divergences,
3438
+ counts: {
3439
+ people: {
3440
+ spoken: people.filter(
3441
+ (person) => person.facts.some((fact) => fact.channel === 'said'),
3442
+ ).length,
3443
+ stub: people.filter(
3444
+ (person) => !person.facts.some((fact) => fact.channel === 'said'),
3445
+ ).length,
3446
+ },
3447
+ entities: store.listSituationEntities().length,
3448
+ goal: {
3449
+ version: latestGoal?.version ?? null,
3450
+ ratified: Boolean(latestGoal?.ratification),
3451
+ },
3452
+ },
855
3453
  };
856
- });
857
- const joints = store.listJoints().map((j) => ({
858
- slug: j.slug,
859
- state: j.state,
860
- resolve_by: j.resolve_by,
861
- cost: j.cost,
862
- rank: j.rank,
863
- }));
864
- const openPredictions = store.listPredictions({ status: 'open' })
865
- .map((p) => ({ id: p.id, text: p.text, context: p.context }));
866
- const recentLedger = store.readLedgerEvents().slice(-10)
867
- .map((e) => ({ kind: e.kind, title: e.title }));
868
- return {
869
- positions,
870
- joints,
871
- under_test: joints.filter((j) => j.state === 'under_test').map((j) => j.slug),
872
- open_predictions: openPredictions,
873
- recent_ledger: recentLedger,
874
- intents_replayed: replay.replayed,
875
- divergences: replay.divergences,
876
- };
3454
+ } finally {
3455
+ release();
3456
+ }
877
3457
  }