@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.
- package/.claude/skills/compose/SKILL.md +42 -88
- package/bin/compose.js +288 -0
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +7 -0
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/judgment-record.schema.json +425 -4
- package/dist/assets/App-PkZzHeMj.js +894 -0
- package/dist/assets/_baseUniq-Bo837sRJ.js +1 -0
- package/dist/assets/arc-BafGpyqE.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-BOBfUsqL.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-Dwodev1a.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-1ntj1-x_.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-CU_bhYag.js} +1 -1
- package/dist/assets/channel-qVK_qn4E.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-p8WsDwnO.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-B8h7-eR0.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-DxeEr98s.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-BYt8F151.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-DGSOVeie.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-B-QdgYR2.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-Du5UAZLs.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-C8JbNBSk.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +1 -0
- package/dist/assets/clone-Pu3RyLUh.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-O1ESaqge.js} +1 -1
- package/dist/assets/dagre-KV5264BT-CPTmFPHw.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-B3PNrWs5.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Cscfr6vc.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-CSfqZ-TM.js +43 -0
- package/dist/assets/diagram-TYMM5635-Cg4aYS7W.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-_ZqwG5pl.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-C83boxFT.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-CWnIjuEi.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-DrMdxZfH.js +106 -0
- package/dist/assets/graph-Bi99_6Yf.js +331 -0
- package/dist/assets/graph-RE4I7Ty7.js +1 -0
- package/dist/assets/index-Rm2RE-c0.js +123 -0
- package/dist/assets/infoDiagram-42DDH7IO-BLmP4Epr.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-yuWWshKN.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-BOfhaJov.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-Bbolde15.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BSf33zm8.js +1 -0
- package/dist/assets/{linear-Ck7gpa5N.js → linear-AvSTWMqx.js} +1 -1
- package/dist/assets/min-QBM8H4xN.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-BuvgtqIc.js} +7 -7
- package/dist/assets/{mobile-CaoXUwAr.js → mobile-BnXEOE3U.js} +2 -2
- package/dist/assets/pieDiagram-DEJITSTG-DIzF16vh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-D-mbUIjS.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-CEs4kCLd.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-DFsnCr9n.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-BEJYdTjQ.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BBXs57uY.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BGvLoVAY.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-9LaBTMe0.js +34 -0
- package/dist/assets/wardley-RL74JXVD-P4MEqMTP.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-o-tmxnlC.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-Dpn7V6qk.js +7 -0
- package/dist/index.html +2 -2
- package/lib/bug-escalation.js +30 -4
- package/lib/build.js +777 -52
- package/lib/canon-guard.js +223 -0
- package/lib/canon-registry.js +187 -0
- package/lib/codex-preflight.js +26 -4
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-writer.js +9 -0
- package/lib/gsd.js +11 -2
- package/lib/hooks-status.js +32 -3
- package/lib/judgment/store/index.js +158 -0
- package/lib/judgment/store/records.js +183 -24
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-gen.js +370 -21
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-writer.js +2803 -277
- package/lib/lane-gate.js +2 -0
- package/lib/local-claude-connector.js +199 -54
- package/lib/mcp-enforcement.js +21 -35
- package/lib/result-normalizer.js +93 -15
- package/lib/review-normalize.js +4 -0
- package/lib/stratum-mcp-client.js +131 -6
- package/package.json +2 -2
- package/server/compose-mcp-tools.js +15 -1
- package/server/compose-mcp.js +96 -1
- package/server/mcp-tool-policy.js +1 -1
- package/dist/assets/App-BG3ngu8H.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
- package/dist/assets/arc-_v4hR_uD.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
- package/dist/assets/channel-B3U5wFAT.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
- package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
- package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-3ZH5eMcZ.js +0 -119
- package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
package/lib/judgment-writer.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
//
|
|
2446
|
+
// COMP-JUDGMENT-GOAL-MIGRATE S2 — completion predicate, wholesale fence
|
|
207
2447
|
// ---------------------------------------------------------------------------
|
|
208
2448
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
2499
|
+
return {
|
|
2500
|
+
status: objectiveStatus === 'live' ? 'revived' : 'incomplete',
|
|
2501
|
+
attested: true,
|
|
2502
|
+
intent_id: intentId,
|
|
2503
|
+
};
|
|
232
2504
|
}
|
|
233
2505
|
|
|
234
2506
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
(
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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:
|
|
694
|
-
|
|
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
|
-
|
|
702
|
-
if (
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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 (
|
|
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.
|
|
815
|
-
if (spawned)
|
|
816
|
-
|
|
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
|
}
|