@smartmemory/compose 0.3.6-beta → 0.3.7

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