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