@smartmemory/compose 0.3.5-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 +572 -0
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/judgment-record.schema.json +821 -0
- 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-CUmTCrMW.js → c4Diagram-AHTNJAMY-CU_bhYag.js} +1 -1
- package/dist/assets/channel-qVK_qn4E.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-DQFs7gjy.js → chunk-4BX2VUAB-p8WsDwnO.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-B8h7-eR0.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-CSdt06Hp.js → chunk-55IACEB6-DxeEr98s.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dvhgnm7E.js → chunk-EDXVE4YY-BYt8F151.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-Qw-L7OoJ.js → chunk-FMBD7UC4-DGSOVeie.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-B-QdgYR2.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Cyvi1AFH.js → chunk-QZHKN3VN-Du5UAZLs.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-B1y5FpOd.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-G8l05tXy.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-Hm4zSDlM.js → ishikawaDiagram-UXIWVN3A-yuWWshKN.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-CIqcTFuO.js → journeyDiagram-VCZTEJTY-BOfhaJov.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-CB9jCXj_.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-CsXqv70E.js → linear-AvSTWMqx.js} +1 -1
- package/dist/assets/min-QBM8H4xN.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-CpqH5F_P.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-D1lG1P7h.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-CzqqUDvt.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/idempotency.js +33 -8
- package/lib/judgment/schema.js +23 -0
- package/lib/judgment/store/index.js +207 -0
- package/lib/judgment/store/records.js +405 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-gen.js +691 -0
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-write-guard.js +230 -0
- package/lib/judgment-writer.js +3403 -0
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +42 -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 +49 -1
- package/server/compose-mcp.js +220 -0
- package/server/mcp-tool-policy.js +1 -0
- package/dist/assets/App-BG92y_CY.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-Bip6cMjP.js +0 -1
- package/dist/assets/arc-CyMTM07c.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-BukV0CJw.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-Bjb_MNB4.js +0 -132
- package/dist/assets/channel-BzkmtaLy.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-G_MZEji_.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-CQZQYMcz.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-CmAda4sZ.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-CbjBz-Ek.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-BIvLcbex.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-BIvLcbex.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-C3n1PNJC.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-C8N_CvKJ.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-B2pkwDhN.js +0 -4
- package/dist/assets/diagram-FQU43EPY-DPHD_t-1.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DGmHIZRr.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BlLWwKVt.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-DEv704Re.js +0 -30
- package/dist/assets/diagram-WEI45ONY-Bj6PGUCe.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-Ch-icksT.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-CI3lwEFA.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DcUANpAz.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-Cy0Ie8KS.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-BswI52az.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-B1H7-G2y.js +0 -119
- package/dist/assets/infoDiagram-FWYZ7A6U-BBunqvr-.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-HpQyrDcm.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-xkcpHwnY.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-kebZIOmu.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-DNvGwWm9.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-DOEEcn52.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-DynIVCk_.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DJXFjUUL.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-OZ7trFh5.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-GcPRHAJT.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-DULYAFmB.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-BB0gCy01.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-CdKmtcFL.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-DknGwjCA.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DdYyKfQ3.js +0 -7
|
@@ -0,0 +1,3403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/judgment-writer.js — the typed writer for judgment canon (S05).
|
|
3
|
+
*
|
|
4
|
+
* The six operations of design Decision 4, transport-free `(cwd, args)`
|
|
5
|
+
* (journal-writer template): position create/amend, joint add, transition,
|
|
6
|
+
* ledger append, and the read op. All writes:
|
|
7
|
+
* - validate synchronously BEFORE the idempotency wrapper (feature-writer
|
|
8
|
+
* ordering);
|
|
9
|
+
* - run under the judgment advisory lock (`.compose/data/judgment.lock`,
|
|
10
|
+
* journal-writer recipe: mkdir lock, stale recovery, bounded retry);
|
|
11
|
+
* - replay pending intents first (the reconciler runs on every write and
|
|
12
|
+
* on getJudgmentState);
|
|
13
|
+
* - stamp provenance themselves — no caller can set any provenance field;
|
|
14
|
+
* - regenerate projections, with compensating rollback on failure
|
|
15
|
+
* (`JUDGMENT_PARTIAL_WRITE` carries the cause);
|
|
16
|
+
* - append a best-effort audit event AFTER commit (feature-events);
|
|
17
|
+
* - return small typed results (AUDIT-19), errors carrying `code`/`cause`.
|
|
18
|
+
*
|
|
19
|
+
* Transitions are intent-first (design D4, rounds 2–3): the COMPLETE
|
|
20
|
+
* mutation (joint + atomic ledger events + spawned predictions) is persisted
|
|
21
|
+
* as a pending-intent record BEFORE the guard call; on a verdict it is
|
|
22
|
+
* applied and the intent cleared; the reconciler replays incomplete intents
|
|
23
|
+
* idempotently — guard state authoritative for the edge, the intent
|
|
24
|
+
* authoritative for the payload. Where `capabilities.guard` is true the
|
|
25
|
+
* Stratum guard (via the untouched `guardedTransition` adapter, mode
|
|
26
|
+
* 'judgment' — a data-only LIFECYCLE_MODES entry) is the single lifecycle
|
|
27
|
+
* authority; where absent, the write-guard's identical edge table enforces
|
|
28
|
+
* (graph parity is contract-tested).
|
|
29
|
+
*
|
|
30
|
+
* ONE-UNDER-TEST is enforced writer-locally inside the advisory lock (the
|
|
31
|
+
* floor). Guard-predicate expressibility: STRAT-GUARD edge predicates are
|
|
32
|
+
* per-resource deterministic file checks (`server_file_exists`); a
|
|
33
|
+
* population invariant across joints is not expressible today — probed
|
|
34
|
+
* against the predicate surface in lifecycle-guard.js (edgePredicates’
|
|
35
|
+
* `deterministic` statements), noted in the implementation report, and the
|
|
36
|
+
* upstream population-invariant ask is filed in Stratum's tracker either way.
|
|
37
|
+
*/
|
|
38
|
+
import {
|
|
39
|
+
readFileSync, writeFileSync, renameSync, unlinkSync,
|
|
40
|
+
mkdirSync, rmSync, statSync, existsSync, utimesSync, readdirSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import { join, dirname } from 'node:path';
|
|
43
|
+
|
|
44
|
+
import { checkOrInsert } from './idempotency.js';
|
|
45
|
+
import { appendEvent } from './feature-events.js';
|
|
46
|
+
import {
|
|
47
|
+
createJudgmentStore,
|
|
48
|
+
effectiveStore,
|
|
49
|
+
goalCutoverComplete,
|
|
50
|
+
} from './judgment/store/index.js';
|
|
51
|
+
import { syncManifest } from './judgment-attest.js';
|
|
52
|
+
import { regenerateProjections } from './judgment-gen.js';
|
|
53
|
+
import { getJudgmentValidator } from './judgment/schema.js';
|
|
54
|
+
import {
|
|
55
|
+
assertValidRecord,
|
|
56
|
+
assertGrounding,
|
|
57
|
+
assertEdgeArtifact,
|
|
58
|
+
assertMethodGate,
|
|
59
|
+
} from './judgment-write-guard.js';
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Errors
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function typedError(code, message, cause) {
|
|
66
|
+
const err = new Error(message);
|
|
67
|
+
err.code = code;
|
|
68
|
+
if (cause !== undefined) err.cause = cause;
|
|
69
|
+
return err;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Advisory lock (journal-writer recipe: mkdir lock + stale recovery)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
// A judgment critical section can legitimately span a guard subprocess call
|
|
77
|
+
// (Stratum client timeout: 10s), so unlike the journal recipe the lock is
|
|
78
|
+
// HEARTBEATED: the holder refreshes the dir mtime every second, and only a
|
|
79
|
+
// lock whose heartbeat has stopped for LOCK_STALE_MS is stealable. An owner
|
|
80
|
+
// token inside the dir makes release/steal safe against the ABA case (my
|
|
81
|
+
// stale lock was stolen; I must not delete the thief's lock on release).
|
|
82
|
+
// The heartbeat runs on the event loop, so a SYNCHRONOUS block longer than
|
|
83
|
+
// LOCK_STALE_MS would defeat it — the threshold is therefore set well above
|
|
84
|
+
// any sync section this codebase can produce (sync work here is small-file
|
|
85
|
+
// fs I/O; the long operations — guard subprocess calls — are async and keep
|
|
86
|
+
// the loop free). 20s of contiguous sync blocking is the documented bound.
|
|
87
|
+
const LOCK_STALE_MS = 20000;
|
|
88
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
|
|
89
|
+
const LOCK_HEARTBEAT_MS = 1000;
|
|
90
|
+
const LOCK_RETRY_MS = 25;
|
|
91
|
+
|
|
92
|
+
function judgmentLockPath(cwd) {
|
|
93
|
+
return join(cwd, '.compose', 'data', 'judgment.lock');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function acquireJudgmentLock(cwd) {
|
|
97
|
+
const path = judgmentLockPath(cwd);
|
|
98
|
+
const ownerFile = join(path, 'owner');
|
|
99
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
100
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
101
|
+
const start = Date.now();
|
|
102
|
+
// eslint-disable-next-line no-constant-condition
|
|
103
|
+
while (true) {
|
|
104
|
+
try {
|
|
105
|
+
mkdirSync(path);
|
|
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
|
+
}
|
|
115
|
+
const heartbeat = setInterval(() => {
|
|
116
|
+
try { utimesSync(path, new Date(), new Date()); } catch { /* lock stolen/gone — release will no-op */ }
|
|
117
|
+
}, LOCK_HEARTBEAT_MS);
|
|
118
|
+
heartbeat.unref?.();
|
|
119
|
+
return () => {
|
|
120
|
+
clearInterval(heartbeat);
|
|
121
|
+
try {
|
|
122
|
+
if (readFileSync(ownerFile, 'utf8') === token) rmSync(path, { recursive: true, force: true });
|
|
123
|
+
} catch { /* not ours anymore — leave it */ }
|
|
124
|
+
};
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err.code !== 'EEXIST') throw err;
|
|
127
|
+
try {
|
|
128
|
+
const { mtimeMs } = statSync(path);
|
|
129
|
+
if (Date.now() - mtimeMs > LOCK_STALE_MS) {
|
|
130
|
+
rmSync(path, { recursive: true, force: true });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
} catch { /* stat raced; loop and retry */ }
|
|
134
|
+
if (Date.now() - start > LOCK_ACQUIRE_TIMEOUT_MS) {
|
|
135
|
+
throw typedError('JUDGMENT_LOCK_TIMEOUT', `judgment-writer lock timeout after ${LOCK_ACQUIRE_TIMEOUT_MS}ms: ${path}`);
|
|
136
|
+
}
|
|
137
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
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),
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
|
|
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`);
|
|
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;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
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
|
+
}
|
|
2307
|
+
|
|
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
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
return prepared;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
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
|
+
);
|
|
2337
|
+
}
|
|
2338
|
+
return {
|
|
2339
|
+
clauseId,
|
|
2340
|
+
text: requireString(args, 'text', GOAL_TOOL, 'correct'),
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
|
|
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);
|
|
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)}"`);
|
|
2411
|
+
}
|
|
2412
|
+
return { op, input };
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/**
|
|
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.
|
|
2420
|
+
*/
|
|
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
|
+
);
|
|
2441
|
+
}
|
|
2442
|
+
return { rawStore, effective };
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// ---------------------------------------------------------------------------
|
|
2446
|
+
// COMP-JUDGMENT-GOAL-MIGRATE S2 — completion predicate, wholesale fence
|
|
2447
|
+
// ---------------------------------------------------------------------------
|
|
2448
|
+
|
|
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
|
+
}
|
|
2472
|
+
|
|
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
|
+
}
|
|
2487
|
+
|
|
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 };
|
|
2498
|
+
}
|
|
2499
|
+
return {
|
|
2500
|
+
status: objectiveStatus === 'live' ? 'revived' : 'incomplete',
|
|
2501
|
+
attested: true,
|
|
2502
|
+
intent_id: intentId,
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
/**
|
|
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.
|
|
2511
|
+
*/
|
|
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
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
|
|
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`);
|
|
2530
|
+
}
|
|
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 };
|
|
2539
|
+
}
|
|
2540
|
+
|
|
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
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
for (const link of activeEntries(state.load_links)) {
|
|
2555
|
+
resolveEffectiveGoalClause(effective, link.clause);
|
|
2556
|
+
}
|
|
2557
|
+
assertValidRecord('goal_state', state);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
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
|
+
}
|
|
2567
|
+
|
|
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);
|
|
2595
|
+
|
|
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
|
+
);
|
|
2783
|
+
}
|
|
2784
|
+
|
|
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
|
+
}
|
|
2800
|
+
|
|
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);
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// ---------------------------------------------------------------------------
|
|
2836
|
+
// judgment_position_create
|
|
2837
|
+
// ---------------------------------------------------------------------------
|
|
2838
|
+
|
|
2839
|
+
/**
|
|
2840
|
+
* Create a position revision: a new chain, a new revision on an existing
|
|
2841
|
+
* chain (P1b update), a superseding revision (`supersedes: <slug>#r<N>`), or
|
|
2842
|
+
* a tombstone (`retracted: true`). Always a NEW immutable r<N>.json.
|
|
2843
|
+
*/
|
|
2844
|
+
export async function judgmentPositionCreate(cwd, args, internal = {}) {
|
|
2845
|
+
return runOp(cwd, args, {
|
|
2846
|
+
tool: 'judgment_position_create',
|
|
2847
|
+
validate: () => {
|
|
2848
|
+
const record = {
|
|
2849
|
+
slug: args.slug,
|
|
2850
|
+
claims: Array.isArray(args.claims) ? args.claims : [],
|
|
2851
|
+
conviction: args.conviction,
|
|
2852
|
+
provenance: stampProvenance(internal),
|
|
2853
|
+
};
|
|
2854
|
+
if (args.rejected_alternatives !== undefined) record.rejected_alternatives = args.rejected_alternatives;
|
|
2855
|
+
if (args.supersedes !== undefined) record.supersedes = args.supersedes;
|
|
2856
|
+
if (args.retracted !== undefined) record.retracted = args.retracted;
|
|
2857
|
+
if (args.provider_ids !== undefined) record.provider_ids = args.provider_ids;
|
|
2858
|
+
assertGrounding(record, { via: internal.via });
|
|
2859
|
+
assertValidRecord('position_revision', record);
|
|
2860
|
+
return record;
|
|
2861
|
+
},
|
|
2862
|
+
execute: (record) => {
|
|
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
|
+
}
|
|
2878
|
+
if (record.supersedes) {
|
|
2879
|
+
const [targetSlug, revPart] = record.supersedes.split('#r');
|
|
2880
|
+
if (!store.readPositionRevision(targetSlug, Number(revPart))) {
|
|
2881
|
+
throw typedError('JUDGMENT_NOT_FOUND', `supersedes target ${record.supersedes} does not exist`);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
const undo = new UndoLog();
|
|
2885
|
+
let written;
|
|
2886
|
+
commitWithProjections(cwd, undo, () => {
|
|
2887
|
+
written = store.writePositionRevision(record);
|
|
2888
|
+
undo.created(written.path);
|
|
2889
|
+
});
|
|
2890
|
+
return { slug: record.slug, rev: written.rev, ref: written.ref, status: store.derivePositionStatus(record.slug) };
|
|
2891
|
+
},
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// ---------------------------------------------------------------------------
|
|
2896
|
+
// judgment_position_amend
|
|
2897
|
+
// ---------------------------------------------------------------------------
|
|
2898
|
+
|
|
2899
|
+
const AMEND_ALLOWED_KEYS = new Set(['slug', 'claim_id', 'grounding', 'elicitation', 'conviction', 'idempotency_key']);
|
|
2900
|
+
|
|
2901
|
+
/**
|
|
2902
|
+
* Scoped amendment (P6 SHAKE-GROUNDING): a new revision whose delta is
|
|
2903
|
+
* restricted to one claim's grounding and/or the conviction block. Anything
|
|
2904
|
+
* else — claim text, branches, rejected alternatives — is supersession.
|
|
2905
|
+
*/
|
|
2906
|
+
export async function judgmentPositionAmend(cwd, args, internal = {}) {
|
|
2907
|
+
return runOp(cwd, args, {
|
|
2908
|
+
tool: 'judgment_position_amend',
|
|
2909
|
+
validate: () => {
|
|
2910
|
+
const illegal = Object.keys(args ?? {}).filter((k) => !AMEND_ALLOWED_KEYS.has(k));
|
|
2911
|
+
if (illegal.length > 0) {
|
|
2912
|
+
throw typedError(
|
|
2913
|
+
'JUDGMENT_INPUT',
|
|
2914
|
+
`judgment_position_amend: delta is restricted to grounding/conviction (illegal: ${illegal.join(', ')}) — for any other change create a superseding revision via judgment_position_create with supersedes: <slug>#r<N> (supersession path)`,
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
if (!args.grounding && !args.conviction) {
|
|
2918
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_position_amend: nothing to amend — provide grounding (with claim_id) and/or conviction');
|
|
2919
|
+
}
|
|
2920
|
+
if (args.grounding && !args.claim_id) {
|
|
2921
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_position_amend: grounding amendment requires claim_id');
|
|
2922
|
+
}
|
|
2923
|
+
// Sync shape validation BEFORE idempotency (A1/A4).
|
|
2924
|
+
if (args.grounding !== undefined && !['EXT', 'INT', 'ASSERT', 'DERIVED', 'AGENT'].includes(args.grounding)) {
|
|
2925
|
+
throw typedError('JUDGMENT_INPUT', `judgment_position_amend: unknown grounding "${args.grounding}"`);
|
|
2926
|
+
}
|
|
2927
|
+
if (args.conviction !== undefined) assertValidRecord('conviction', args.conviction);
|
|
2928
|
+
if (args.elicitation !== undefined) assertValidRecord('elicitation', args.elicitation);
|
|
2929
|
+
return null;
|
|
2930
|
+
},
|
|
2931
|
+
execute: async () => {
|
|
2932
|
+
const store = createJudgmentStore(cwd);
|
|
2933
|
+
const latest = store.latestPositionRevision(args.slug);
|
|
2934
|
+
if (!latest) throw typedError('JUDGMENT_NOT_FOUND', `position ${args.slug} does not exist`);
|
|
2935
|
+
if (latest.retracted) throw typedError('JUDGMENT_CONFLICT', `position ${args.slug} is retracted — a tombstone cannot be amended`);
|
|
2936
|
+
|
|
2937
|
+
const record = { ...latest };
|
|
2938
|
+
delete record.rev;
|
|
2939
|
+
record.claims = latest.claims.map((c) => ({ ...c }));
|
|
2940
|
+
if (args.grounding) {
|
|
2941
|
+
const claim = record.claims.find((c) => c.id === args.claim_id);
|
|
2942
|
+
if (!claim) throw typedError('JUDGMENT_NOT_FOUND', `claim ${args.claim_id} not found on ${args.slug}`);
|
|
2943
|
+
claim.grounding = args.grounding;
|
|
2944
|
+
if (args.elicitation) claim.elicitation = args.elicitation;
|
|
2945
|
+
if (args.grounding !== 'ASSERT') delete claim.elicitation;
|
|
2946
|
+
}
|
|
2947
|
+
if (args.conviction) record.conviction = args.conviction;
|
|
2948
|
+
record.provenance = stampProvenance(internal);
|
|
2949
|
+
|
|
2950
|
+
assertGrounding(record, { via: internal.via });
|
|
2951
|
+
assertValidRecord('position_revision', record);
|
|
2952
|
+
|
|
2953
|
+
const undo = new UndoLog();
|
|
2954
|
+
let written;
|
|
2955
|
+
commitWithProjections(cwd, undo, () => {
|
|
2956
|
+
written = store.writePositionRevision(record);
|
|
2957
|
+
undo.created(written.path);
|
|
2958
|
+
});
|
|
2959
|
+
return { slug: args.slug, rev: written.rev, ref: written.ref };
|
|
2960
|
+
},
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
// ---------------------------------------------------------------------------
|
|
2965
|
+
// judgment_joint_add
|
|
2966
|
+
// ---------------------------------------------------------------------------
|
|
2967
|
+
|
|
2968
|
+
/**
|
|
2969
|
+
* Add a joint. State is writer-stamped 'open' — never caller-set. Exception:
|
|
2970
|
+
* the importer (`internal.via === 'import'`, unreachable through MCP) may
|
|
2971
|
+
* transcribe a joint in its historical state with its resolution/dissolution
|
|
2972
|
+
* artifacts — import is transcription, not replayed transitions.
|
|
2973
|
+
*/
|
|
2974
|
+
export async function judgmentJointAdd(cwd, args, internal = {}) {
|
|
2975
|
+
const importing = internal.via === 'import';
|
|
2976
|
+
return runOp(cwd, args, {
|
|
2977
|
+
tool: 'judgment_joint_add',
|
|
2978
|
+
validate: () => {
|
|
2979
|
+
if (args.state !== undefined && !importing) {
|
|
2980
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_joint_add: state is the transition artifact — joints are born open');
|
|
2981
|
+
}
|
|
2982
|
+
const record = {
|
|
2983
|
+
slug: args.slug,
|
|
2984
|
+
question: args.question,
|
|
2985
|
+
branch_true: args.branch_true,
|
|
2986
|
+
branch_false: args.branch_false,
|
|
2987
|
+
resolve_by: args.resolve_by,
|
|
2988
|
+
cost: args.cost,
|
|
2989
|
+
rank: args.rank,
|
|
2990
|
+
state: importing ? (args.state ?? 'open') : 'open',
|
|
2991
|
+
provenance: stampProvenance(internal),
|
|
2992
|
+
};
|
|
2993
|
+
if (args.ext !== undefined) record.ext = args.ext;
|
|
2994
|
+
if (args.straddle !== undefined) record.straddle = args.straddle;
|
|
2995
|
+
if (args.flags !== undefined) record.flags = args.flags;
|
|
2996
|
+
if (importing) {
|
|
2997
|
+
if (args.resolution !== undefined) record.resolution = args.resolution;
|
|
2998
|
+
if (args.dissolution !== undefined) record.dissolution = args.dissolution;
|
|
2999
|
+
}
|
|
3000
|
+
assertValidRecord('joint', record);
|
|
3001
|
+
return record;
|
|
3002
|
+
},
|
|
3003
|
+
execute: (record) => {
|
|
3004
|
+
const store = createJudgmentStore(cwd);
|
|
3005
|
+
if (store.readJoint(record.slug)) {
|
|
3006
|
+
throw typedError('JUDGMENT_CONFLICT', `joint ${record.slug} already exists`);
|
|
3007
|
+
}
|
|
3008
|
+
const undo = new UndoLog();
|
|
3009
|
+
commitWithProjections(cwd, undo, () => {
|
|
3010
|
+
undo.created(store._jointPath(record.slug));
|
|
3011
|
+
store.writeJoint(record);
|
|
3012
|
+
});
|
|
3013
|
+
return { slug: record.slug, state: record.state, rank: record.rank };
|
|
3014
|
+
},
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
// ---------------------------------------------------------------------------
|
|
3019
|
+
// judgment_transition
|
|
3020
|
+
// ---------------------------------------------------------------------------
|
|
3021
|
+
|
|
3022
|
+
function noteEvent(slug, title, body, provenance) {
|
|
3023
|
+
return { kind: 'note', title, body, anchor: `joint:${slug}`, provenance };
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
/**
|
|
3027
|
+
* Joint state machine + rank changes. Intent-first: the complete mutation is
|
|
3028
|
+
* persisted before the guard call; guard refusal/unavailability never leaves
|
|
3029
|
+
* a half-applied write. Rank changes (alone or alongside an edge) atomically
|
|
3030
|
+
* emit their `rank` ledger event in the same locked operation.
|
|
3031
|
+
*/
|
|
3032
|
+
const JOINT_STATES = ['open', 'under_test', 'resolved', 'inconclusive', 'superseded', 'dissolved'];
|
|
3033
|
+
const RESOLVE_METHODS = ['EXT', 'INT', 'CONSTRUCT', 'ASSERT', 'STRADDLE'];
|
|
3034
|
+
|
|
3035
|
+
export async function judgmentTransition(cwd, args, internal = {}) {
|
|
3036
|
+
return runOp(cwd, args, {
|
|
3037
|
+
tool: 'judgment_transition',
|
|
3038
|
+
validate: () => {
|
|
3039
|
+
// Full sync shape validation BEFORE the idempotency wrapper (A1/A4):
|
|
3040
|
+
// a cached key must never mask malformed input.
|
|
3041
|
+
if (!args.slug) throw typedError('JUDGMENT_INPUT', 'judgment_transition: slug is required');
|
|
3042
|
+
if (!args.to && !args.rank) {
|
|
3043
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_transition: provide a target state (to) and/or a rank change');
|
|
3044
|
+
}
|
|
3045
|
+
if (args.to !== undefined && !JOINT_STATES.includes(args.to)) {
|
|
3046
|
+
throw typedError('JUDGMENT_INPUT', `judgment_transition: unknown target state "${args.to}"`);
|
|
3047
|
+
}
|
|
3048
|
+
if (args.resolution !== undefined) assertValidRecord('resolution', args.resolution);
|
|
3049
|
+
if (args.dissolution !== undefined) assertValidRecord('dissolution', args.dissolution);
|
|
3050
|
+
if (args.ext !== undefined) assertValidRecord('ext_package', args.ext);
|
|
3051
|
+
if (args.straddle !== undefined) assertValidRecord('straddle_package', args.straddle);
|
|
3052
|
+
if (args.reopen !== undefined && typeof args.reopen.shaken_evidence_ref !== 'string') {
|
|
3053
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_transition: reopen requires { shaken_evidence_ref }');
|
|
3054
|
+
}
|
|
3055
|
+
if (args.redispose !== undefined) {
|
|
3056
|
+
if (!RESOLVE_METHODS.includes(args.redispose.new_resolve_by)) {
|
|
3057
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_transition: redispose requires new_resolve_by (EXT|INT|CONSTRUCT|ASSERT|STRADDLE)');
|
|
3058
|
+
}
|
|
3059
|
+
if (args.redispose.ext !== undefined) assertValidRecord('ext_package', args.redispose.ext);
|
|
3060
|
+
if (args.redispose.straddle !== undefined) assertValidRecord('straddle_package', args.redispose.straddle);
|
|
3061
|
+
}
|
|
3062
|
+
if (args.rank !== undefined && !['high', 'medium'].includes(args.rank.to)) {
|
|
3063
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_transition: rank change requires rank.to (high|medium)');
|
|
3064
|
+
}
|
|
3065
|
+
return null;
|
|
3066
|
+
},
|
|
3067
|
+
execute: async () => {
|
|
3068
|
+
const store = createJudgmentStore(cwd);
|
|
3069
|
+
const joint = store.readJoint(args.slug);
|
|
3070
|
+
if (!joint) throw typedError('JUDGMENT_NOT_FOUND', `joint ${args.slug} does not exist`);
|
|
3071
|
+
const from = joint.state;
|
|
3072
|
+
const intentId = newIntentId();
|
|
3073
|
+
const provenance = stampProvenance({ ...internal, intentId });
|
|
3074
|
+
const events = [];
|
|
3075
|
+
const mutated = { ...joint };
|
|
3076
|
+
|
|
3077
|
+
const isEdge = args.to !== undefined && args.to !== from;
|
|
3078
|
+
if (args.to !== undefined && args.to === from) {
|
|
3079
|
+
throw typedError('JUDGMENT_INPUT', `judgment_transition: joint ${args.slug} is already ${from}`);
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
if (isEdge) {
|
|
3083
|
+
assertEdgeArtifact(from, args.to, args);
|
|
3084
|
+
|
|
3085
|
+
// Method packages attach ONLY at dispose (open → under_test) or via
|
|
3086
|
+
// the re-dispose input — never on resolution edges, where a supplied
|
|
3087
|
+
// package could silently erase a stored judgment-dispatch stamp.
|
|
3088
|
+
if ((args.ext || args.straddle) && !(from === 'open' && args.to === 'under_test')) {
|
|
3089
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_transition: ext/straddle packages attach at dispose (open → under_test) or inside redispose — not on this edge');
|
|
3090
|
+
}
|
|
3091
|
+
// Re-dispose is exclusively the inconclusive → under_test|open input;
|
|
3092
|
+
// on any other edge it would swap the method and erase the stored
|
|
3093
|
+
// package (incl. a judgment-dispatch stamp) before the gates run.
|
|
3094
|
+
if (args.redispose && from !== 'inconclusive') {
|
|
3095
|
+
throw typedError('JUDGMENT_INPUT', 'judgment_transition: redispose is only legal on inconclusive → under_test|open (P3 retry-with-different-method)');
|
|
3096
|
+
}
|
|
3097
|
+
if (args.redispose) {
|
|
3098
|
+
mutated.resolve_by = args.redispose.new_resolve_by;
|
|
3099
|
+
delete mutated.ext;
|
|
3100
|
+
delete mutated.straddle;
|
|
3101
|
+
if (args.redispose.ext) mutated.ext = args.redispose.ext;
|
|
3102
|
+
if (args.redispose.straddle) mutated.straddle = args.redispose.straddle;
|
|
3103
|
+
}
|
|
3104
|
+
if (args.ext) mutated.ext = args.ext;
|
|
3105
|
+
if (args.straddle) mutated.straddle = args.straddle;
|
|
3106
|
+
|
|
3107
|
+
assertMethodGate(mutated, args.to, args);
|
|
3108
|
+
|
|
3109
|
+
// ONE-UNDER-TEST, inside the advisory lock (the writer-local floor).
|
|
3110
|
+
// A pending intent targeting under_test (kept alive by a guard
|
|
3111
|
+
// outage) occupies the slot too — recovery must not roll past one.
|
|
3112
|
+
if (args.to === 'under_test') {
|
|
3113
|
+
const other = store.listJoints().find((j) => j.slug !== args.slug && j.state === 'under_test');
|
|
3114
|
+
if (other) {
|
|
3115
|
+
throw typedError('JUDGMENT_CONFLICT', `ONE-UNDER-TEST: joint ${other.slug} is already under test`);
|
|
3116
|
+
}
|
|
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
|
+
);
|
|
3124
|
+
if (pending) {
|
|
3125
|
+
throw typedError('JUDGMENT_CONFLICT', `ONE-UNDER-TEST: pending intent ${pending.id} already targets under_test for ${pending.payload.slug}`);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
// Per-edge apply semantics.
|
|
3130
|
+
if (['resolved', 'inconclusive', 'superseded'].includes(args.to)) {
|
|
3131
|
+
mutated.resolution = args.resolution;
|
|
3132
|
+
} else if (args.to === 'dissolved') {
|
|
3133
|
+
mutated.dissolution = args.dissolution;
|
|
3134
|
+
} else if (args.to === 'open' || (args.to === 'under_test' && args.redispose)) {
|
|
3135
|
+
// open-returning edges + re-dispose: the artifact is ledgered, the
|
|
3136
|
+
// stale resolution must not survive on the joint.
|
|
3137
|
+
if (args.resolution) {
|
|
3138
|
+
events.push(noteEvent(args.slug, `failed_to_run: ${args.slug}`, args.resolution.reason, provenance));
|
|
3139
|
+
}
|
|
3140
|
+
if (args.reopen) {
|
|
3141
|
+
events.push(noteEvent(
|
|
3142
|
+
args.slug,
|
|
3143
|
+
`reopened: ${args.slug}`,
|
|
3144
|
+
`shaken evidence: ${args.reopen.shaken_evidence_ref}${joint.resolution ? `; prior resolution: ${JSON.stringify(joint.resolution)}` : ''}`,
|
|
3145
|
+
provenance,
|
|
3146
|
+
));
|
|
3147
|
+
}
|
|
3148
|
+
if (args.redispose) {
|
|
3149
|
+
events.push(noteEvent(
|
|
3150
|
+
args.slug,
|
|
3151
|
+
`re-disposed: ${args.slug}`,
|
|
3152
|
+
`new method: ${args.redispose.new_resolve_by}${joint.resolution ? `; prior resolution: ${JSON.stringify(joint.resolution)}` : ''}`,
|
|
3153
|
+
provenance,
|
|
3154
|
+
));
|
|
3155
|
+
}
|
|
3156
|
+
delete mutated.resolution;
|
|
3157
|
+
}
|
|
3158
|
+
mutated.state = args.to;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
let rankChange = null;
|
|
3162
|
+
if (args.rank) {
|
|
3163
|
+
if (!args.rank.to) throw typedError('JUDGMENT_INPUT', 'judgment_transition: rank change requires rank.to');
|
|
3164
|
+
if (args.rank.to !== joint.rank) {
|
|
3165
|
+
rankChange = { joint: args.slug, from: joint.rank, to: args.rank.to };
|
|
3166
|
+
mutated.rank = args.rank.to;
|
|
3167
|
+
events.push({
|
|
3168
|
+
kind: 'rank',
|
|
3169
|
+
title: `rank ${args.slug}: ${rankChange.from} → ${rankChange.to}`,
|
|
3170
|
+
refs: [args.slug],
|
|
3171
|
+
rank_change: rankChange,
|
|
3172
|
+
provenance,
|
|
3173
|
+
});
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
if (!isEdge && !rankChange) {
|
|
3177
|
+
throw typedError('JUDGMENT_INPUT', `judgment_transition: rank is already ${joint.rank} — nothing to do`);
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
assertValidRecord('joint', mutated);
|
|
3181
|
+
for (const event of events) assertValidRecord('ledger_event', event);
|
|
3182
|
+
|
|
3183
|
+
// Intent-first: persist the COMPLETE mutation before the guard call.
|
|
3184
|
+
const intent = {
|
|
3185
|
+
id: intentId,
|
|
3186
|
+
kind: 'transition',
|
|
3187
|
+
tool: 'judgment_transition',
|
|
3188
|
+
op: 'transition',
|
|
3189
|
+
payload: { slug: args.slug, from, to: mutated.state, joint: mutated, events, predictions: [] },
|
|
3190
|
+
created_at: provenance.written_at,
|
|
3191
|
+
};
|
|
3192
|
+
assertValidRecord('pending_intent', intent);
|
|
3193
|
+
store.persistIntent(intent);
|
|
3194
|
+
syncManifest(cwd, [store._intentPath(intent.id)]);
|
|
3195
|
+
|
|
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
|
+
};
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
const result = { applied: true, slug: args.slug, from, to: mutated.state, state: mutated.state, rank: mutated.rank };
|
|
3221
|
+
if (publication.guard) result.guard = publication.guard;
|
|
3222
|
+
return result;
|
|
3223
|
+
},
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
// ---------------------------------------------------------------------------
|
|
3228
|
+
// judgment_ledger_append
|
|
3229
|
+
// ---------------------------------------------------------------------------
|
|
3230
|
+
|
|
3231
|
+
const LEDGER_PASSTHROUGH_KEYS = [
|
|
3232
|
+
'kind', 'title', 'body', 'refs', 'rejected', 'conviction', 'trigger',
|
|
3233
|
+
'open_joints', 'prediction', 'disposition', 'recall_verdict', 'attribution',
|
|
3234
|
+
'prediction_ref', 'prediction_grade', 'reason', 'anchor', 'rank_change',
|
|
3235
|
+
'elicitation', 'rests_on',
|
|
3236
|
+
];
|
|
3237
|
+
|
|
3238
|
+
function nextPredictionId(store) {
|
|
3239
|
+
return `p-${store.listPredictions().length + 1}`;
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
/**
|
|
3243
|
+
* Append a ledger event (kind-specific required fields enforced by the
|
|
3244
|
+
* schema). Writer-internal side-effects: a commit-moment `decide` (kind
|
|
3245
|
+
* decide + trigger) and a CONSTRUCT-disposition event spawn prediction
|
|
3246
|
+
* records from their embedded prediction; a `postmortem` carrying
|
|
3247
|
+
* prediction_ref + prediction_grade flips that prediction to graded.
|
|
3248
|
+
*/
|
|
3249
|
+
export async function judgmentLedgerAppend(cwd, args, internal = {}) {
|
|
3250
|
+
return runOp(cwd, args, {
|
|
3251
|
+
tool: 'judgment_ledger_append',
|
|
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
|
+
}
|
|
3259
|
+
const event = { provenance: stampProvenance(internal) };
|
|
3260
|
+
for (const key of LEDGER_PASSTHROUGH_KEYS) {
|
|
3261
|
+
if (args[key] !== undefined) event[key] = args[key];
|
|
3262
|
+
}
|
|
3263
|
+
assertValidRecord('ledger_event', event);
|
|
3264
|
+
return event;
|
|
3265
|
+
},
|
|
3266
|
+
execute: (event) => {
|
|
3267
|
+
const store = createJudgmentStore(cwd);
|
|
3268
|
+
|
|
3269
|
+
let spawned = null;
|
|
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
|
+
}
|
|
3293
|
+
const isConstructDisposition = event.disposition === 'CONSTRUCT';
|
|
3294
|
+
if ((isCommitDecide || isConstructDisposition) && event.prediction) {
|
|
3295
|
+
spawned = {
|
|
3296
|
+
id: nextPredictionId(store),
|
|
3297
|
+
text: event.prediction.text,
|
|
3298
|
+
outcome_criteria: event.prediction.outcome_criteria,
|
|
3299
|
+
made_at: event.provenance.written_at,
|
|
3300
|
+
context: isCommitDecide ? 'commit' : 'construct',
|
|
3301
|
+
refs: event.refs ?? [],
|
|
3302
|
+
status: 'open',
|
|
3303
|
+
provenance: event.provenance,
|
|
3304
|
+
};
|
|
3305
|
+
assertValidRecord('prediction', spawned);
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
let graded = null;
|
|
3309
|
+
if (event.kind === 'postmortem' && event.prediction_ref) {
|
|
3310
|
+
const prediction = store.readPrediction(event.prediction_ref);
|
|
3311
|
+
if (!prediction) throw typedError('JUDGMENT_NOT_FOUND', `prediction ${event.prediction_ref} does not exist`);
|
|
3312
|
+
graded = { ...prediction, status: 'graded', grade: event.prediction_grade };
|
|
3313
|
+
assertValidRecord('prediction', graded);
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
const undo = new UndoLog();
|
|
3317
|
+
let seq;
|
|
3318
|
+
commitWithProjections(cwd, undo, () => {
|
|
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));
|
|
3326
|
+
({ seq } = store.appendLedgerEvent(event));
|
|
3327
|
+
if (spawned) store.writePrediction(spawned);
|
|
3328
|
+
if (graded) store.writePrediction(graded);
|
|
3329
|
+
});
|
|
3330
|
+
|
|
3331
|
+
const result = { seq, kind: event.kind };
|
|
3332
|
+
if (spawned) result.prediction_id = spawned.id;
|
|
3333
|
+
if (graded) result.graded = graded.id;
|
|
3334
|
+
return result;
|
|
3335
|
+
},
|
|
3336
|
+
}, internal.appliers);
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
// ---------------------------------------------------------------------------
|
|
3340
|
+
// get_judgment_state (read)
|
|
3341
|
+
// ---------------------------------------------------------------------------
|
|
3342
|
+
|
|
3343
|
+
/**
|
|
3344
|
+
* Session-load / P6-sweep read: register + under-test + open predictions +
|
|
3345
|
+
* recent ledger. Small result (AUDIT-19) — titles and refs, never full
|
|
3346
|
+
* document text. Replays pending intents first (reconciler-on-read).
|
|
3347
|
+
*/
|
|
3348
|
+
export async function getJudgmentState(cwd) {
|
|
3349
|
+
const release = await acquireJudgmentLock(cwd);
|
|
3350
|
+
try {
|
|
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
|
+
};
|
|
3400
|
+
} finally {
|
|
3401
|
+
release();
|
|
3402
|
+
}
|
|
3403
|
+
}
|