@jungjaehoon/mama-core 1.4.2 → 1.6.0
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/README.md +48 -10
- package/db/migrations/026-create-canonical-entity-tables.sql +99 -0
- package/db/migrations/027-create-entity-review-support.sql +44 -0
- package/db/migrations/028-create-entity-audit-tables.sql +45 -0
- package/db/migrations/029-extend-entity-observations-observation-type.sql +60 -0
- package/db/migrations/030-case-first-memory-substrate.sql +901 -0
- package/db/migrations/031-wiki-page-index-provenance-columns.sql +2 -0
- package/db/migrations/032-add-memory-provenance-columns.sql +21 -0
- package/db/migrations/033-create-model-runs-and-tool-traces.sql +53 -0
- package/db/migrations/034-add-connector-event-scope-columns.sql +14 -0
- package/db/migrations/035-create-twin-edges.sql +63 -0
- package/db/migrations/036-create-agent-situation-packets.sql +61 -0
- package/dist/agent-graph/alias-write.d.ts +3 -0
- package/dist/agent-graph/alias-write.js +231 -0
- package/dist/agent-graph/entity-resolve.d.ts +3 -0
- package/dist/agent-graph/entity-resolve.js +275 -0
- package/dist/agent-graph/errors.d.ts +4 -0
- package/dist/agent-graph/errors.js +11 -0
- package/dist/agent-graph/graph-query.d.ts +5 -0
- package/dist/agent-graph/graph-query.js +359 -0
- package/dist/agent-graph/index.d.ts +6 -0
- package/dist/agent-graph/index.js +22 -0
- package/dist/agent-graph/types.d.ts +162 -0
- package/dist/agent-graph/types.js +3 -0
- package/dist/agent-situation/builder.d.ts +8 -0
- package/dist/agent-situation/builder.js +239 -0
- package/dist/agent-situation/cache-key.d.ts +4 -0
- package/dist/agent-situation/cache-key.js +64 -0
- package/dist/agent-situation/index.d.ts +7 -0
- package/dist/agent-situation/index.js +28 -0
- package/dist/agent-situation/packet-store.d.ts +27 -0
- package/dist/agent-situation/packet-store.js +228 -0
- package/dist/agent-situation/ranking-policy.d.ts +18 -0
- package/dist/agent-situation/ranking-policy.js +75 -0
- package/dist/agent-situation/source-readers.d.ts +52 -0
- package/dist/agent-situation/source-readers.js +300 -0
- package/dist/agent-situation/types.d.ts +195 -0
- package/dist/agent-situation/types.js +3 -0
- package/dist/canonicalize.d.ts +38 -0
- package/dist/canonicalize.js +106 -0
- package/dist/cases/case-links.d.ts +116 -0
- package/dist/cases/case-links.js +794 -0
- package/dist/cases/composition-overrides.d.ts +90 -0
- package/dist/cases/composition-overrides.js +724 -0
- package/dist/cases/corrections.d.ts +156 -0
- package/dist/cases/corrections.js +1045 -0
- package/dist/cases/freshness.d.ts +57 -0
- package/dist/cases/freshness.js +283 -0
- package/dist/cases/live-state.d.ts +48 -0
- package/dist/cases/live-state.js +256 -0
- package/dist/cases/membership-explain.d.ts +56 -0
- package/dist/cases/membership-explain.js +140 -0
- package/dist/cases/membership-matcher.d.ts +23 -0
- package/dist/cases/membership-matcher.js +193 -0
- package/dist/cases/merge-split.d.ts +61 -0
- package/dist/cases/merge-split.js +368 -0
- package/dist/cases/role-inference.d.ts +10 -0
- package/dist/cases/role-inference.js +85 -0
- package/dist/cases/search-rollup.d.ts +27 -0
- package/dist/cases/search-rollup.js +309 -0
- package/dist/cases/sqlite-transaction.d.ts +10 -0
- package/dist/cases/sqlite-transaction.js +62 -0
- package/dist/cases/store.d.ts +74 -0
- package/dist/cases/store.js +816 -0
- package/dist/cases/target-ref.d.ts +25 -0
- package/dist/cases/target-ref.js +22 -0
- package/dist/cases/timeline-range.d.ts +6 -0
- package/dist/cases/timeline-range.js +322 -0
- package/dist/cases/tombstone-sweeper.d.ts +13 -0
- package/dist/cases/tombstone-sweeper.js +128 -0
- package/dist/cases/types.d.ts +244 -0
- package/dist/cases/types.js +47 -0
- package/dist/cases/wiki-page-index.d.ts +50 -0
- package/dist/cases/wiki-page-index.js +350 -0
- package/dist/connectors/event-index.d.ts +45 -0
- package/dist/connectors/event-index.js +381 -0
- package/dist/connectors/raw-query.d.ts +18 -0
- package/dist/connectors/raw-query.js +305 -0
- package/dist/connectors/types.d.ts +109 -0
- package/dist/connectors/types.js +3 -0
- package/dist/db-adapter/node-sqlite-adapter.d.ts +6 -0
- package/dist/db-adapter/node-sqlite-adapter.js +131 -0
- package/dist/db-manager.d.ts +12 -1
- package/dist/db-manager.js +60 -5
- package/dist/decision-formatter.d.ts +2 -0
- package/dist/decision-formatter.js +11 -8
- package/dist/edges/ref-validation.d.ts +8 -0
- package/dist/edges/ref-validation.js +262 -0
- package/dist/edges/store.d.ts +10 -0
- package/dist/edges/store.js +343 -0
- package/dist/edges/types.d.ts +86 -0
- package/dist/edges/types.js +18 -0
- package/dist/embedding-server/mobile/daemon.d.ts +1 -0
- package/dist/embedding-server/mobile/daemon.js +11 -1
- package/dist/embeddings.js +13 -0
- package/dist/entities/audit-metrics.d.ts +73 -0
- package/dist/entities/audit-metrics.js +128 -0
- package/dist/entities/candidate-generator.d.ts +9 -0
- package/dist/entities/candidate-generator.js +436 -0
- package/dist/entities/entity-impact.d.ts +41 -0
- package/dist/entities/entity-impact.js +140 -0
- package/dist/entities/entity-linked-decision-counts.d.ts +2 -0
- package/dist/entities/entity-linked-decision-counts.js +29 -0
- package/dist/entities/entity-list.d.ts +23 -0
- package/dist/entities/entity-list.js +99 -0
- package/dist/entities/entity-orphan-list.d.ts +26 -0
- package/dist/entities/entity-orphan-list.js +91 -0
- package/dist/entities/entity-search.d.ts +21 -0
- package/dist/entities/entity-search.js +150 -0
- package/dist/entities/errors.d.ts +65 -0
- package/dist/entities/errors.js +124 -0
- package/dist/entities/exact-merge-backfill.d.ts +11 -0
- package/dist/entities/exact-merge-backfill.js +161 -0
- package/dist/entities/lineage-backfill.d.ts +12 -0
- package/dist/entities/lineage-backfill.js +101 -0
- package/dist/entities/lineage-store.d.ts +51 -0
- package/dist/entities/lineage-store.js +308 -0
- package/dist/entities/normalization.d.ts +14 -0
- package/dist/entities/normalization.js +63 -0
- package/dist/entities/policy-store.d.ts +17 -0
- package/dist/entities/policy-store.js +263 -0
- package/dist/entities/policy-types.d.ts +72 -0
- package/dist/entities/policy-types.js +59 -0
- package/dist/entities/projection.d.ts +8 -0
- package/dist/entities/projection.js +82 -0
- package/dist/entities/provenance-query.d.ts +33 -0
- package/dist/entities/provenance-query.js +167 -0
- package/dist/entities/read-identity.d.ts +31 -0
- package/dist/entities/read-identity.js +264 -0
- package/dist/entities/recall-bridge.d.ts +5 -0
- package/dist/entities/recall-bridge.js +171 -0
- package/dist/entities/resolution-engine.d.ts +15 -0
- package/dist/entities/resolution-engine.js +69 -0
- package/dist/entities/rollback-preview.d.ts +39 -0
- package/dist/entities/rollback-preview.js +235 -0
- package/dist/entities/source-locator.d.ts +3 -0
- package/dist/entities/source-locator.js +32 -0
- package/dist/entities/store.d.ts +75 -0
- package/dist/entities/store.js +465 -0
- package/dist/entities/types.d.ts +162 -0
- package/dist/entities/types.js +30 -0
- package/dist/index.d.ts +62 -3
- package/dist/index.js +116 -26
- package/dist/mama-api.d.ts +53 -35
- package/dist/mama-api.js +437 -78
- package/dist/memory/api.d.ts +26 -38
- package/dist/memory/api.js +772 -68
- package/dist/memory/event-store.d.ts +3 -0
- package/dist/memory/event-store.js +21 -2
- package/dist/memory/provenance-audit.d.ts +22 -0
- package/dist/memory/provenance-audit.js +112 -0
- package/dist/memory/provenance-query.d.ts +11 -0
- package/dist/memory/provenance-query.js +193 -0
- package/dist/memory/provenance.d.ts +29 -0
- package/dist/memory/provenance.js +136 -0
- package/dist/memory/scope-backfill.d.ts +17 -0
- package/dist/memory/scope-backfill.js +77 -0
- package/dist/memory/types.d.ts +97 -16
- package/dist/model-runs/store.d.ts +13 -0
- package/dist/model-runs/store.js +457 -0
- package/dist/model-runs/tool-trace-store.d.ts +4 -0
- package/dist/model-runs/tool-trace-store.js +114 -0
- package/dist/model-runs/types.d.ts +69 -0
- package/dist/model-runs/types.js +5 -0
- package/dist/runtime/trusted-provenance.d.ts +3 -0
- package/dist/runtime/trusted-provenance.js +6 -0
- package/dist/search/feedback-store.d.ts +59 -0
- package/dist/search/feedback-store.js +211 -0
- package/dist/search/question-type.d.ts +5 -0
- package/dist/search/question-type.js +41 -0
- package/dist/search/ranker-features.d.ts +23 -0
- package/dist/search/ranker-features.js +104 -0
- package/dist/search/ranker-rescore.d.ts +31 -0
- package/dist/search/ranker-rescore.js +226 -0
- package/dist/search/ranker-trainer.d.ts +94 -0
- package/dist/search/ranker-trainer.js +860 -0
- package/dist/search/search-quality.d.ts +36 -0
- package/dist/search/search-quality.js +29 -0
- package/dist/test-utils.d.ts +14 -0
- package/dist/test-utils.js +52 -0
- package/package.json +28 -3
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.insertCaseCorrectionLock = insertCaseCorrectionLock;
|
|
4
|
+
exports.applyCorrection = applyCorrection;
|
|
5
|
+
exports.revertCorrection = revertCorrection;
|
|
6
|
+
exports.supersedeCorrection = supersedeCorrection;
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const canonicalize_js_1 = require("../canonicalize.js");
|
|
9
|
+
const sqlite_transaction_js_1 = require("./sqlite-transaction.js");
|
|
10
|
+
const target_ref_js_1 = require("./target-ref.js");
|
|
11
|
+
const RECONFIRM_TOKEN_TTL_MS = 10 * 60_000;
|
|
12
|
+
const TERMINAL_CASE_STATUSES = new Set(['merged', 'archived', 'split']);
|
|
13
|
+
const CORRECTION_FIELD_NAMES = new Set([
|
|
14
|
+
'status',
|
|
15
|
+
'status_reason',
|
|
16
|
+
'primary_actors',
|
|
17
|
+
'blockers',
|
|
18
|
+
'confidence',
|
|
19
|
+
]);
|
|
20
|
+
function normalizeNow(value) {
|
|
21
|
+
return value ?? new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
function timestampMs(value) {
|
|
24
|
+
const parsed = Date.parse(value);
|
|
25
|
+
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
26
|
+
}
|
|
27
|
+
function sha256Hex(value) {
|
|
28
|
+
return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
function hmacHex(secret, value) {
|
|
31
|
+
return (0, node_crypto_1.createHmac)('sha256', secret).update(value, 'utf8').digest('hex');
|
|
32
|
+
}
|
|
33
|
+
function deterministicCorrectionId(nonce) {
|
|
34
|
+
return `corr_${sha256Hex(`case-reconfirm:v1:${nonce}`).slice(0, 32)}`;
|
|
35
|
+
}
|
|
36
|
+
function randomCorrectionId() {
|
|
37
|
+
return `corr_${(0, node_crypto_1.randomUUID)().replace(/-/g, '')}`;
|
|
38
|
+
}
|
|
39
|
+
function isRecord(value) {
|
|
40
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
function parseJsonValue(value) {
|
|
43
|
+
return JSON.parse(value);
|
|
44
|
+
}
|
|
45
|
+
function canonicalJsonOrNull(value) {
|
|
46
|
+
if (typeof value !== 'string') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(parseJsonValue(value));
|
|
50
|
+
}
|
|
51
|
+
function nullableString(value) {
|
|
52
|
+
if (value === null || value === undefined) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return String(value);
|
|
56
|
+
}
|
|
57
|
+
function nullableNumber(value) {
|
|
58
|
+
if (value === null || value === undefined) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const n = Number(value);
|
|
62
|
+
if (!Number.isFinite(n)) {
|
|
63
|
+
throw new Error('Membership confidence must be a finite number.');
|
|
64
|
+
}
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
67
|
+
function canonicalStoredJson(value) {
|
|
68
|
+
if (value === null || value.trim().length === 0) {
|
|
69
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(null);
|
|
70
|
+
}
|
|
71
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(parseJsonValue(value));
|
|
72
|
+
}
|
|
73
|
+
function decodeSecretWithEncoding(value, encoding) {
|
|
74
|
+
if (value.length === 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (encoding === 'hex') {
|
|
78
|
+
if (!/^[0-9a-f]+$/i.test(value) || value.length % 2 !== 0) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const hex = Buffer.from(value, 'hex');
|
|
82
|
+
return hex.length >= 32 ? hex : null;
|
|
83
|
+
}
|
|
84
|
+
const decoded = Buffer.from(value, encoding);
|
|
85
|
+
return decoded.length >= 32 ? decoded : null;
|
|
86
|
+
}
|
|
87
|
+
function decodeSecret(raw) {
|
|
88
|
+
const trimmed = raw.trim();
|
|
89
|
+
if (trimmed.length === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const explicitEncoding = trimmed.match(/^(hex|base64|utf8):(.*)$/is);
|
|
93
|
+
if (explicitEncoding) {
|
|
94
|
+
return decodeSecretWithEncoding(explicitEncoding[2]?.trim() ?? '', explicitEncoding[1].toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
const hex = decodeSecretWithEncoding(trimmed, 'hex');
|
|
97
|
+
if (hex) {
|
|
98
|
+
return hex;
|
|
99
|
+
}
|
|
100
|
+
const base64 = decodeSecretWithEncoding(trimmed, 'base64');
|
|
101
|
+
if (base64) {
|
|
102
|
+
return base64;
|
|
103
|
+
}
|
|
104
|
+
return decodeSecretWithEncoding(trimmed, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
function secretKid(secret) {
|
|
107
|
+
return (0, node_crypto_1.createHash)('sha256').update(secret).digest('hex').slice(0, 16);
|
|
108
|
+
}
|
|
109
|
+
function currentSecret() {
|
|
110
|
+
const raw = process.env.MAMA_RECONFIRM_TOKEN_SECRET;
|
|
111
|
+
const secret = raw ? decodeSecret(raw) : null;
|
|
112
|
+
if (!secret) {
|
|
113
|
+
throw new Error('MAMA_RECONFIRM_TOKEN_SECRET must be a 32+ byte secret (optionally prefixed with hex:, base64:, or utf8:).');
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
kid: secretKid(secret),
|
|
117
|
+
secret,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function verificationSecrets() {
|
|
121
|
+
const secrets = new Map();
|
|
122
|
+
const current = currentSecret();
|
|
123
|
+
secrets.set(current.kid, current.secret);
|
|
124
|
+
for (const raw of (process.env.MAMA_RECONFIRM_TOKEN_OLD_SECRETS ?? '').split(',')) {
|
|
125
|
+
const secret = decodeSecret(raw);
|
|
126
|
+
if (secret) {
|
|
127
|
+
secrets.set(secretKid(secret), secret);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return secrets;
|
|
131
|
+
}
|
|
132
|
+
function encodeReconfirmToken(payload, secret) {
|
|
133
|
+
const payloadJson = (0, canonicalize_js_1.canonicalizeJSON)(payload);
|
|
134
|
+
const signatureHex = hmacHex(secret, payloadJson);
|
|
135
|
+
const envelope = {
|
|
136
|
+
kid: payload.kid,
|
|
137
|
+
payload,
|
|
138
|
+
signature_hex: signatureHex,
|
|
139
|
+
};
|
|
140
|
+
return Buffer.from((0, canonicalize_js_1.canonicalizeJSON)(envelope), 'utf8').toString('base64url');
|
|
141
|
+
}
|
|
142
|
+
function readReconfirmEnvelope(token) {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
|
|
145
|
+
if (!isRecord(parsed) || !isRecord(parsed.payload)) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const payload = parsed.payload;
|
|
149
|
+
if (parsed.kid !== payload.kid ||
|
|
150
|
+
payload.v !== 1 ||
|
|
151
|
+
typeof parsed.kid !== 'string' ||
|
|
152
|
+
typeof parsed.signature_hex !== 'string' ||
|
|
153
|
+
typeof payload.kid !== 'string' ||
|
|
154
|
+
typeof payload.nonce !== 'string' ||
|
|
155
|
+
typeof payload.case_id !== 'string' ||
|
|
156
|
+
typeof payload.target_kind !== 'string' ||
|
|
157
|
+
typeof payload.target_ref_hash_hex !== 'string' ||
|
|
158
|
+
typeof payload.current_value_hash_hex !== 'string' ||
|
|
159
|
+
typeof payload.proposed_value_hash_hex !== 'string' ||
|
|
160
|
+
typeof payload.confirmed_by !== 'string' ||
|
|
161
|
+
!(payload.session_id === null || typeof payload.session_id === 'string') ||
|
|
162
|
+
typeof payload.expires_at !== 'string') {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (payload.target_kind !== 'case_field' &&
|
|
166
|
+
payload.target_kind !== 'membership' &&
|
|
167
|
+
payload.target_kind !== 'wiki_section') {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
kid: parsed.kid,
|
|
172
|
+
payload: payload,
|
|
173
|
+
signature_hex: parsed.signature_hex,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function verifyReconfirmToken(input) {
|
|
181
|
+
const envelope = readReconfirmEnvelope(input.token);
|
|
182
|
+
if (!envelope) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const secret = verificationSecrets().get(envelope.kid);
|
|
186
|
+
if (!secret) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const expected = Buffer.from(hmacHex(secret, (0, canonicalize_js_1.canonicalizeJSON)(envelope.payload)), 'hex');
|
|
190
|
+
const actual = Buffer.from(envelope.signature_hex, 'hex');
|
|
191
|
+
if (actual.length !== expected.length || !(0, node_crypto_1.timingSafeEqual)(actual, expected)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const expiresAtMs = Date.parse(envelope.payload.expires_at);
|
|
195
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= timestampMs(input.now)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
if (envelope.payload.case_id !== input.case_id ||
|
|
199
|
+
envelope.payload.target_kind !== input.target_kind ||
|
|
200
|
+
envelope.payload.target_ref_hash_hex !== input.target_ref_hash_hex ||
|
|
201
|
+
envelope.payload.proposed_value_hash_hex !== input.proposed_value_hash_hex ||
|
|
202
|
+
envelope.payload.confirmed_by !== input.confirmed_by ||
|
|
203
|
+
envelope.payload.session_id !== input.session_id) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return envelope.payload;
|
|
207
|
+
}
|
|
208
|
+
function buildRequiresReconfirm(input) {
|
|
209
|
+
const secret = currentSecret();
|
|
210
|
+
const expiresAt = new Date(timestampMs(input.now) + RECONFIRM_TOKEN_TTL_MS).toISOString();
|
|
211
|
+
const payload = {
|
|
212
|
+
v: 1,
|
|
213
|
+
kid: secret.kid,
|
|
214
|
+
nonce: (0, node_crypto_1.randomUUID)(),
|
|
215
|
+
case_id: input.case_id,
|
|
216
|
+
target_kind: input.target_kind,
|
|
217
|
+
target_ref_hash_hex: input.target_ref_hash_hex,
|
|
218
|
+
current_value_hash_hex: sha256Hex(input.current_value_json),
|
|
219
|
+
proposed_value_hash_hex: sha256Hex(input.proposed_new_value_json),
|
|
220
|
+
confirmed_by: input.confirmed_by,
|
|
221
|
+
session_id: input.session_id,
|
|
222
|
+
expires_at: expiresAt,
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
kind: 'requires_reconfirm',
|
|
226
|
+
code: 'case.correction_requires_reconfirm',
|
|
227
|
+
case_id: input.case_id,
|
|
228
|
+
target_kind: input.target_kind,
|
|
229
|
+
target_ref_json: input.target_ref_json,
|
|
230
|
+
current_value_json: input.current_value_json,
|
|
231
|
+
old_value_json: input.old_value_json,
|
|
232
|
+
proposed_new_value_json: input.proposed_new_value_json,
|
|
233
|
+
reconfirm_token: encodeReconfirmToken(payload, secret.secret),
|
|
234
|
+
reconfirm_token_expires_at: expiresAt,
|
|
235
|
+
message: 'The target changed since the viewed snapshot. Reconfirm against the current value.',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function requireMatchingTargetKind(input) {
|
|
239
|
+
if (input.target_ref.kind !== input.target_kind) {
|
|
240
|
+
throw new Error(`Correction target_kind ${input.target_kind} does not match target_ref kind ${input.target_ref.kind}.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function correctionFieldName(input) {
|
|
244
|
+
const fieldFromRef = input.target_ref.kind === 'case_field' ? input.target_ref.field : null;
|
|
245
|
+
const field = input.field_name ?? fieldFromRef;
|
|
246
|
+
if (!field || !CORRECTION_FIELD_NAMES.has(field)) {
|
|
247
|
+
throw new Error(`Unsupported case correction field: ${String(field)}`);
|
|
248
|
+
}
|
|
249
|
+
if (input.field_name && fieldFromRef && input.field_name !== fieldFromRef) {
|
|
250
|
+
throw new Error('field_name must match target_ref.field.');
|
|
251
|
+
}
|
|
252
|
+
return field;
|
|
253
|
+
}
|
|
254
|
+
function membershipTarget(input) {
|
|
255
|
+
if (input.target_ref.kind !== 'membership') {
|
|
256
|
+
throw new Error('Membership correction requires membership target_ref.');
|
|
257
|
+
}
|
|
258
|
+
return input.target_ref;
|
|
259
|
+
}
|
|
260
|
+
function readCaseFieldCurrentValueJson(adapter, caseId, field) {
|
|
261
|
+
const row = adapter
|
|
262
|
+
.prepare(`
|
|
263
|
+
SELECT status, status_reason, primary_actors, blockers, confidence
|
|
264
|
+
FROM case_truth
|
|
265
|
+
WHERE case_id = ?
|
|
266
|
+
`)
|
|
267
|
+
.get(caseId);
|
|
268
|
+
if (!row) {
|
|
269
|
+
throw new Error(`case_truth row disappeared while applying correction: ${caseId}`);
|
|
270
|
+
}
|
|
271
|
+
if (field === 'primary_actors' || field === 'blockers') {
|
|
272
|
+
return canonicalStoredJson(row[field]);
|
|
273
|
+
}
|
|
274
|
+
if (field === 'confidence') {
|
|
275
|
+
if (row.confidence === null || row.confidence === undefined) {
|
|
276
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(null);
|
|
277
|
+
}
|
|
278
|
+
const confidence = Number(row.confidence);
|
|
279
|
+
if (!Number.isFinite(confidence)) {
|
|
280
|
+
throw new Error(`case_truth confidence is not a finite number for case_id=${caseId}`);
|
|
281
|
+
}
|
|
282
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(confidence);
|
|
283
|
+
}
|
|
284
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(row[field]);
|
|
285
|
+
}
|
|
286
|
+
function readMembershipCurrentValueJson(adapter, caseId, sourceType, sourceId) {
|
|
287
|
+
const row = adapter
|
|
288
|
+
.prepare(`
|
|
289
|
+
SELECT status, role, confidence, reason, user_locked
|
|
290
|
+
FROM case_memberships
|
|
291
|
+
WHERE case_id = ?
|
|
292
|
+
AND source_type = ?
|
|
293
|
+
AND source_id = ?
|
|
294
|
+
`)
|
|
295
|
+
.get(caseId, sourceType, sourceId);
|
|
296
|
+
if (!row) {
|
|
297
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(null);
|
|
298
|
+
}
|
|
299
|
+
return (0, canonicalize_js_1.canonicalizeJSON)({
|
|
300
|
+
status: row.status,
|
|
301
|
+
role: row.role,
|
|
302
|
+
confidence: row.confidence,
|
|
303
|
+
reason: row.reason,
|
|
304
|
+
user_locked: Number(row.user_locked) === 1 ? 1 : 0,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function readCurrentValueJson(adapter, input) {
|
|
308
|
+
if (input.target_kind === 'case_field') {
|
|
309
|
+
return readCaseFieldCurrentValueJson(adapter, input.case_id, correctionFieldName(input));
|
|
310
|
+
}
|
|
311
|
+
if (input.target_kind === 'membership') {
|
|
312
|
+
const target = membershipTarget(input);
|
|
313
|
+
return readMembershipCurrentValueJson(adapter, input.case_id, target.source_type, target.source_id);
|
|
314
|
+
}
|
|
315
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(null);
|
|
316
|
+
}
|
|
317
|
+
function storageValueForCaseField(field, newValueJson) {
|
|
318
|
+
const value = parseJsonValue(newValueJson);
|
|
319
|
+
if (value === null) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
if (field === 'primary_actors' || field === 'blockers') {
|
|
323
|
+
return (0, canonicalize_js_1.canonicalizeJSON)(value);
|
|
324
|
+
}
|
|
325
|
+
if (field === 'confidence') {
|
|
326
|
+
const confidence = Number(value);
|
|
327
|
+
if (!Number.isFinite(confidence)) {
|
|
328
|
+
throw new Error('Case field confidence correction value must be a finite number or null.');
|
|
329
|
+
}
|
|
330
|
+
return String(confidence);
|
|
331
|
+
}
|
|
332
|
+
if (typeof value !== 'string') {
|
|
333
|
+
throw new Error(`Case field ${field} correction value must be a JSON string or null.`);
|
|
334
|
+
}
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
function applyCaseFieldMutation(input) {
|
|
338
|
+
const storageValue = storageValueForCaseField(input.field, input.new_value_json);
|
|
339
|
+
const result = input.adapter
|
|
340
|
+
.prepare(`
|
|
341
|
+
UPDATE case_truth
|
|
342
|
+
SET ${input.field} = ?,
|
|
343
|
+
updated_at = ?
|
|
344
|
+
WHERE case_id = ?
|
|
345
|
+
AND status NOT IN ('merged','archived','split')
|
|
346
|
+
`)
|
|
347
|
+
.run(storageValue, input.now, input.case_id);
|
|
348
|
+
if (result.changes !== 1) {
|
|
349
|
+
throw new Error(`Failed to apply case field correction for case_id=${input.case_id}.`);
|
|
350
|
+
}
|
|
351
|
+
const actual = readCaseFieldCurrentValueJson(input.adapter, input.case_id, input.field);
|
|
352
|
+
const expected = (0, canonicalize_js_1.canonicalizeJSON)(parseJsonValue(input.new_value_json));
|
|
353
|
+
if (actual !== expected) {
|
|
354
|
+
throw new Error(`Case field correction verification failed for ${input.field}.`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function membershipStatus(value) {
|
|
358
|
+
if (value === 'active' || value === 'excluded' || value === 'removed') {
|
|
359
|
+
return value;
|
|
360
|
+
}
|
|
361
|
+
throw new Error('Membership correction status must be active, excluded, or removed.');
|
|
362
|
+
}
|
|
363
|
+
function verifyMembershipMutation(input) {
|
|
364
|
+
const row = input.adapter
|
|
365
|
+
.prepare(`
|
|
366
|
+
SELECT status, role, confidence, reason, user_locked, added_by
|
|
367
|
+
FROM case_memberships
|
|
368
|
+
WHERE case_id = ?
|
|
369
|
+
AND source_type = ?
|
|
370
|
+
AND source_id = ?
|
|
371
|
+
`)
|
|
372
|
+
.get(input.case_id, input.source_type, input.source_id);
|
|
373
|
+
if (!row) {
|
|
374
|
+
throw new Error('Membership correction verification failed: row missing.');
|
|
375
|
+
}
|
|
376
|
+
const expectedStatus = String(input.expected.status);
|
|
377
|
+
if (row.status !== expectedStatus ||
|
|
378
|
+
Number(row.user_locked) !== 1 ||
|
|
379
|
+
row.added_by !== 'user-correction') {
|
|
380
|
+
throw new Error('Membership correction verification failed.');
|
|
381
|
+
}
|
|
382
|
+
if (expectedStatus === 'active') {
|
|
383
|
+
const expectedRole = nullableString(input.expected.role);
|
|
384
|
+
const expectedConfidence = nullableNumber(input.expected.confidence);
|
|
385
|
+
const expectedReason = nullableString(input.expected.reason);
|
|
386
|
+
if (row.role !== expectedRole ||
|
|
387
|
+
row.confidence !== expectedConfidence ||
|
|
388
|
+
row.reason !== expectedReason) {
|
|
389
|
+
throw new Error('Active membership correction verification failed.');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function applyMembershipMutation(input) {
|
|
394
|
+
const value = parseJsonValue(input.new_value_json);
|
|
395
|
+
if (!isRecord(value)) {
|
|
396
|
+
throw new Error('Membership correction value must be a JSON object.');
|
|
397
|
+
}
|
|
398
|
+
const status = membershipStatus(value.status);
|
|
399
|
+
if (status === 'removed') {
|
|
400
|
+
input.adapter
|
|
401
|
+
.prepare(`
|
|
402
|
+
UPDATE case_memberships
|
|
403
|
+
SET status = 'removed',
|
|
404
|
+
user_locked = 1,
|
|
405
|
+
added_by = 'user-correction',
|
|
406
|
+
updated_at = ?
|
|
407
|
+
WHERE case_id = ?
|
|
408
|
+
AND source_type = ?
|
|
409
|
+
AND source_id = ?
|
|
410
|
+
`)
|
|
411
|
+
.run(input.now, input.case_id, input.target.source_type, input.target.source_id);
|
|
412
|
+
}
|
|
413
|
+
else if (status === 'excluded') {
|
|
414
|
+
const confidence = nullableNumber(value.confidence);
|
|
415
|
+
const reason = nullableString(value.reason) ?? input.reason;
|
|
416
|
+
input.adapter
|
|
417
|
+
.prepare(`
|
|
418
|
+
INSERT INTO case_memberships (
|
|
419
|
+
case_id, source_type, source_id, role, confidence, reason, status,
|
|
420
|
+
added_by, added_at, user_locked, updated_at
|
|
421
|
+
)
|
|
422
|
+
VALUES (?, ?, ?, NULL, ?, ?, 'excluded', 'user-correction', ?, 1, ?)
|
|
423
|
+
ON CONFLICT(case_id, source_type, source_id) DO UPDATE SET
|
|
424
|
+
role = excluded.role,
|
|
425
|
+
confidence = excluded.confidence,
|
|
426
|
+
reason = excluded.reason,
|
|
427
|
+
status = excluded.status,
|
|
428
|
+
added_by = 'user-correction',
|
|
429
|
+
user_locked = 1,
|
|
430
|
+
updated_at = excluded.updated_at
|
|
431
|
+
`)
|
|
432
|
+
.run(input.case_id, input.target.source_type, input.target.source_id, confidence, reason, input.now, input.now);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const role = nullableString(value.role);
|
|
436
|
+
const confidence = nullableNumber(value.confidence);
|
|
437
|
+
const reason = nullableString(value.reason);
|
|
438
|
+
input.adapter
|
|
439
|
+
.prepare(`
|
|
440
|
+
INSERT INTO case_memberships (
|
|
441
|
+
case_id, source_type, source_id, role, confidence, reason, status,
|
|
442
|
+
added_by, added_at, user_locked, updated_at
|
|
443
|
+
)
|
|
444
|
+
VALUES (?, ?, ?, ?, ?, ?, 'active', 'user-correction', ?, 1, ?)
|
|
445
|
+
ON CONFLICT(case_id, source_type, source_id) DO UPDATE SET
|
|
446
|
+
role = excluded.role,
|
|
447
|
+
confidence = excluded.confidence,
|
|
448
|
+
reason = excluded.reason,
|
|
449
|
+
status = excluded.status,
|
|
450
|
+
added_by = 'user-correction',
|
|
451
|
+
user_locked = 1,
|
|
452
|
+
updated_at = excluded.updated_at
|
|
453
|
+
`)
|
|
454
|
+
.run(input.case_id, input.target.source_type, input.target.source_id, role, confidence, reason, input.now, input.now);
|
|
455
|
+
}
|
|
456
|
+
verifyMembershipMutation({
|
|
457
|
+
adapter: input.adapter,
|
|
458
|
+
case_id: input.case_id,
|
|
459
|
+
source_type: input.target.source_type,
|
|
460
|
+
source_id: input.target.source_id,
|
|
461
|
+
expected: value,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
function applyTargetMutation(input) {
|
|
465
|
+
if (input.correction.target_kind === 'case_field') {
|
|
466
|
+
applyCaseFieldMutation({
|
|
467
|
+
adapter: input.adapter,
|
|
468
|
+
case_id: input.correction.case_id,
|
|
469
|
+
field: correctionFieldName(input.correction),
|
|
470
|
+
new_value_json: input.correction.new_value_json,
|
|
471
|
+
now: input.now,
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (input.correction.target_kind === 'membership') {
|
|
476
|
+
applyMembershipMutation({
|
|
477
|
+
adapter: input.adapter,
|
|
478
|
+
case_id: input.correction.case_id,
|
|
479
|
+
target: membershipTarget(input.correction),
|
|
480
|
+
new_value_json: input.correction.new_value_json,
|
|
481
|
+
reason: input.correction.reason,
|
|
482
|
+
now: input.now,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function revertMembershipValueJson(value) {
|
|
487
|
+
const canonicalValue = canonicalJsonOrNull(value);
|
|
488
|
+
if (canonicalValue === null || canonicalValue === (0, canonicalize_js_1.canonicalizeJSON)(null)) {
|
|
489
|
+
return (0, canonicalize_js_1.canonicalizeJSON)({ status: 'removed' });
|
|
490
|
+
}
|
|
491
|
+
return canonicalValue;
|
|
492
|
+
}
|
|
493
|
+
function insertCorrectionMemoryEvent(input) {
|
|
494
|
+
const evidenceRefs = [input.correction_id, ...(input.extra_refs ?? [])];
|
|
495
|
+
input.adapter
|
|
496
|
+
.prepare(`
|
|
497
|
+
INSERT INTO memory_events (
|
|
498
|
+
event_id, event_type, actor, source_turn_id, memory_id, topic,
|
|
499
|
+
scope_refs, evidence_refs, reason, created_at
|
|
500
|
+
)
|
|
501
|
+
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
|
|
502
|
+
`)
|
|
503
|
+
.run(`evt_${(0, node_crypto_1.randomUUID)().replace(/-/g, '')}`, input.event_type, input.actor, input.source_turn_id ?? null, `case:${input.case_id}`, (0, canonicalize_js_1.canonicalizeJSON)([{ type: 'case', id: input.case_id }]), (0, canonicalize_js_1.canonicalizeJSON)(evidenceRefs), (0, canonicalize_js_1.canonicalizeJSON)({
|
|
504
|
+
correction_id: input.correction_id,
|
|
505
|
+
confirmation_summary: input.confirmation_summary,
|
|
506
|
+
...(input.extra_refs ? { predecessor_correction_ids: input.extra_refs } : {}),
|
|
507
|
+
}), timestampMs(input.now));
|
|
508
|
+
}
|
|
509
|
+
function activeCorrectionForTarget(adapter, caseId, targetRefHash) {
|
|
510
|
+
const row = adapter
|
|
511
|
+
.prepare(`
|
|
512
|
+
SELECT correction_id
|
|
513
|
+
FROM case_corrections
|
|
514
|
+
WHERE case_id = ?
|
|
515
|
+
AND target_ref_hash = ?
|
|
516
|
+
AND is_lock_active = 1
|
|
517
|
+
AND reverted_at IS NULL
|
|
518
|
+
LIMIT 1
|
|
519
|
+
`)
|
|
520
|
+
.get(caseId, targetRefHash);
|
|
521
|
+
return row ?? null;
|
|
522
|
+
}
|
|
523
|
+
function correctionExists(adapter, correctionId) {
|
|
524
|
+
const row = adapter
|
|
525
|
+
.prepare('SELECT correction_id FROM case_corrections WHERE correction_id = ?')
|
|
526
|
+
.get(correctionId);
|
|
527
|
+
return row !== undefined;
|
|
528
|
+
}
|
|
529
|
+
function isUniqueConstraintError(error) {
|
|
530
|
+
return (error instanceof Error && /UNIQUE constraint|SQLITE_CONSTRAINT_UNIQUE/i.test(error.message));
|
|
531
|
+
}
|
|
532
|
+
function insertCaseCorrectionLock(adapter, input) {
|
|
533
|
+
const correctionAdapter = adapter;
|
|
534
|
+
const canonical = (0, target_ref_js_1.canonicalTargetRef)(input.target_ref);
|
|
535
|
+
const correctionId = input.correction_id ?? randomCorrectionId();
|
|
536
|
+
const isLockActive = input.is_lock_active ?? 1;
|
|
537
|
+
correctionAdapter
|
|
538
|
+
.prepare(`
|
|
539
|
+
INSERT INTO case_corrections (
|
|
540
|
+
correction_id, case_id, target_kind, target_ref_json, target_ref_hash,
|
|
541
|
+
field_name, old_value_json, new_value_json, reason,
|
|
542
|
+
applied_by, applied_at, is_lock_active, reverted_at, superseded_by
|
|
543
|
+
)
|
|
544
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
|
|
545
|
+
`)
|
|
546
|
+
.run(correctionId, input.case_id, input.target_kind, canonical.json, canonical.hash, input.field_name ?? null, input.old_value_json, input.new_value_json, input.reason, input.applied_by, input.applied_at, isLockActive, input.superseded_by ?? null);
|
|
547
|
+
return {
|
|
548
|
+
correction_id: correctionId,
|
|
549
|
+
case_id: input.case_id,
|
|
550
|
+
target_kind: input.target_kind,
|
|
551
|
+
target_ref_json: canonical.json,
|
|
552
|
+
target_ref_hash: canonical.hash,
|
|
553
|
+
canonical_hash_hex: canonical.hash.toString('hex'),
|
|
554
|
+
applied_at: input.applied_at,
|
|
555
|
+
is_lock_active: isLockActive,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function applyCorrection(adapter, input) {
|
|
559
|
+
const correctionAdapter = adapter;
|
|
560
|
+
return (0, sqlite_transaction_js_1.runImmediateTransaction)(correctionAdapter, () => {
|
|
561
|
+
requireMatchingTargetKind(input);
|
|
562
|
+
const now = normalizeNow(input.now);
|
|
563
|
+
const caseRow = correctionAdapter
|
|
564
|
+
.prepare(`
|
|
565
|
+
SELECT case_id, status, canonical_case_id
|
|
566
|
+
FROM case_truth
|
|
567
|
+
WHERE case_id = ?;
|
|
568
|
+
`)
|
|
569
|
+
.get(input.case_id);
|
|
570
|
+
if (!caseRow) {
|
|
571
|
+
return { kind: 'precompile_gap', code: 'case.precompile_gap', case_id: input.case_id };
|
|
572
|
+
}
|
|
573
|
+
if (TERMINAL_CASE_STATUSES.has(caseRow.status)) {
|
|
574
|
+
return {
|
|
575
|
+
kind: 'rejected',
|
|
576
|
+
code: 'case.terminal_status',
|
|
577
|
+
message: `Active corrections cannot target terminal case status ${caseRow.status}.`,
|
|
578
|
+
case_id: input.case_id,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const canonical = (0, target_ref_js_1.canonicalTargetRef)(input.target_ref);
|
|
582
|
+
const targetRefHashHex = canonical.hash.toString('hex');
|
|
583
|
+
const currentValueJson = readCurrentValueJson(correctionAdapter, input);
|
|
584
|
+
const expectedOldValueJson = canonicalJsonOrNull(input.old_value_json);
|
|
585
|
+
let correctionId = randomCorrectionId();
|
|
586
|
+
if (expectedOldValueJson !== null && expectedOldValueJson !== currentValueJson) {
|
|
587
|
+
const token = input.reconfirm_token ?? null;
|
|
588
|
+
if (!token) {
|
|
589
|
+
return buildRequiresReconfirm({
|
|
590
|
+
case_id: input.case_id,
|
|
591
|
+
target_kind: input.target_kind,
|
|
592
|
+
target_ref_json: canonical.json,
|
|
593
|
+
target_ref_hash_hex: targetRefHashHex,
|
|
594
|
+
current_value_json: currentValueJson,
|
|
595
|
+
old_value_json: expectedOldValueJson,
|
|
596
|
+
proposed_new_value_json: input.new_value_json,
|
|
597
|
+
confirmed_by: input.confirmed_by,
|
|
598
|
+
session_id: input.session_id ?? null,
|
|
599
|
+
now,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
const payload = verifyReconfirmToken({
|
|
603
|
+
token,
|
|
604
|
+
case_id: input.case_id,
|
|
605
|
+
target_kind: input.target_kind,
|
|
606
|
+
target_ref_hash_hex: targetRefHashHex,
|
|
607
|
+
proposed_value_hash_hex: sha256Hex(input.new_value_json),
|
|
608
|
+
confirmed_by: input.confirmed_by,
|
|
609
|
+
session_id: input.session_id ?? null,
|
|
610
|
+
now,
|
|
611
|
+
});
|
|
612
|
+
if (!payload) {
|
|
613
|
+
return buildRequiresReconfirm({
|
|
614
|
+
case_id: input.case_id,
|
|
615
|
+
target_kind: input.target_kind,
|
|
616
|
+
target_ref_json: canonical.json,
|
|
617
|
+
target_ref_hash_hex: targetRefHashHex,
|
|
618
|
+
current_value_json: currentValueJson,
|
|
619
|
+
old_value_json: expectedOldValueJson,
|
|
620
|
+
proposed_new_value_json: input.new_value_json,
|
|
621
|
+
confirmed_by: input.confirmed_by,
|
|
622
|
+
session_id: input.session_id ?? null,
|
|
623
|
+
now,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
correctionId = deterministicCorrectionId(payload.nonce);
|
|
627
|
+
if (correctionExists(correctionAdapter, correctionId)) {
|
|
628
|
+
return {
|
|
629
|
+
kind: 'rejected',
|
|
630
|
+
code: 'case.reconfirm_token_replayed',
|
|
631
|
+
message: 'Reconfirm token has already been consumed.',
|
|
632
|
+
case_id: input.case_id,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
if (payload.current_value_hash_hex !== sha256Hex(currentValueJson)) {
|
|
636
|
+
return buildRequiresReconfirm({
|
|
637
|
+
case_id: input.case_id,
|
|
638
|
+
target_kind: input.target_kind,
|
|
639
|
+
target_ref_json: canonical.json,
|
|
640
|
+
target_ref_hash_hex: targetRefHashHex,
|
|
641
|
+
current_value_json: currentValueJson,
|
|
642
|
+
old_value_json: expectedOldValueJson,
|
|
643
|
+
proposed_new_value_json: input.new_value_json,
|
|
644
|
+
confirmed_by: input.confirmed_by,
|
|
645
|
+
session_id: input.session_id ?? null,
|
|
646
|
+
now,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (activeCorrectionForTarget(correctionAdapter, input.case_id, canonical.hash)) {
|
|
651
|
+
return {
|
|
652
|
+
kind: 'rejected',
|
|
653
|
+
code: 'case.correction_active_conflict',
|
|
654
|
+
message: 'An active correction already locks this target.',
|
|
655
|
+
case_id: input.case_id,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
let inserted;
|
|
659
|
+
try {
|
|
660
|
+
inserted = insertCaseCorrectionLock(adapter, {
|
|
661
|
+
correction_id: correctionId,
|
|
662
|
+
case_id: input.case_id,
|
|
663
|
+
target_kind: input.target_kind,
|
|
664
|
+
target_ref: input.target_ref,
|
|
665
|
+
field_name: input.target_kind === 'case_field' ? correctionFieldName(input) : null,
|
|
666
|
+
old_value_json: currentValueJson,
|
|
667
|
+
new_value_json: input.new_value_json,
|
|
668
|
+
reason: input.reason,
|
|
669
|
+
applied_by: input.confirmed_by,
|
|
670
|
+
applied_at: now,
|
|
671
|
+
is_lock_active: 1,
|
|
672
|
+
superseded_by: null,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
if (isUniqueConstraintError(error)) {
|
|
677
|
+
return {
|
|
678
|
+
kind: 'rejected',
|
|
679
|
+
code: 'case.correction_active_conflict',
|
|
680
|
+
message: 'An active correction already locks this target.',
|
|
681
|
+
case_id: input.case_id,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
applyTargetMutation({ adapter: correctionAdapter, correction: input, now });
|
|
687
|
+
insertCorrectionMemoryEvent({
|
|
688
|
+
adapter: correctionAdapter,
|
|
689
|
+
event_type: 'case.correction_applied',
|
|
690
|
+
correction_id: inserted.correction_id,
|
|
691
|
+
case_id: input.case_id,
|
|
692
|
+
actor: input.confirmed_by,
|
|
693
|
+
source_turn_id: input.session_id ?? null,
|
|
694
|
+
confirmation_summary: input.confirmation_summary,
|
|
695
|
+
now,
|
|
696
|
+
});
|
|
697
|
+
return {
|
|
698
|
+
kind: 'applied',
|
|
699
|
+
correction_id: inserted.correction_id,
|
|
700
|
+
case_id: input.case_id,
|
|
701
|
+
target_ref_json: inserted.target_ref_json,
|
|
702
|
+
canonical_hash_hex: inserted.canonical_hash_hex,
|
|
703
|
+
};
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
function revertCorrection(adapter, input) {
|
|
707
|
+
const correctionAdapter = adapter;
|
|
708
|
+
return (0, sqlite_transaction_js_1.runImmediateTransaction)(correctionAdapter, () => {
|
|
709
|
+
const now = normalizeNow(input.now);
|
|
710
|
+
const row = correctionAdapter
|
|
711
|
+
.prepare(`
|
|
712
|
+
SELECT correction_id, case_id, target_kind, target_ref_json, field_name,
|
|
713
|
+
old_value_json, is_lock_active, reverted_at
|
|
714
|
+
, superseded_by
|
|
715
|
+
FROM case_corrections
|
|
716
|
+
WHERE correction_id = ?
|
|
717
|
+
`)
|
|
718
|
+
.get(input.correction_id);
|
|
719
|
+
if (!row) {
|
|
720
|
+
return {
|
|
721
|
+
kind: 'rejected',
|
|
722
|
+
code: 'case.correction_not_found',
|
|
723
|
+
message: 'Correction not found.',
|
|
724
|
+
correction_id: input.correction_id,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
if (row.reverted_at !== null) {
|
|
728
|
+
return {
|
|
729
|
+
kind: 'rejected',
|
|
730
|
+
code: 'case.correction_reverted',
|
|
731
|
+
message: 'Correction has already been reverted.',
|
|
732
|
+
correction_id: input.correction_id,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (Number(row.is_lock_active) === 0 && row.superseded_by) {
|
|
736
|
+
return {
|
|
737
|
+
kind: 'rejected',
|
|
738
|
+
code: 'case.correction_already_superseded',
|
|
739
|
+
message: 'Correction has already been superseded.',
|
|
740
|
+
correction_id: input.correction_id,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
if (Number(row.is_lock_active) === 0) {
|
|
744
|
+
return {
|
|
745
|
+
kind: 'rejected',
|
|
746
|
+
code: 'case.correction_inactive',
|
|
747
|
+
message: 'Correction is inactive.',
|
|
748
|
+
correction_id: input.correction_id,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
const caseRow = correctionAdapter
|
|
752
|
+
.prepare('SELECT status, canonical_case_id FROM case_truth WHERE case_id = ?')
|
|
753
|
+
.get(row.case_id);
|
|
754
|
+
if (!caseRow) {
|
|
755
|
+
return {
|
|
756
|
+
kind: 'precompile_gap',
|
|
757
|
+
code: 'case.precompile_gap',
|
|
758
|
+
case_id: row.case_id,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
if (TERMINAL_CASE_STATUSES.has(caseRow.status)) {
|
|
762
|
+
return {
|
|
763
|
+
kind: 'rejected',
|
|
764
|
+
code: 'case.terminal_status',
|
|
765
|
+
message: 'Case is in a terminal status.',
|
|
766
|
+
case_id: row.case_id,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
applyTargetMutation({
|
|
770
|
+
adapter: correctionAdapter,
|
|
771
|
+
correction: {
|
|
772
|
+
case_id: row.case_id,
|
|
773
|
+
target_kind: row.target_kind,
|
|
774
|
+
target_ref: parseJsonValue(row.target_ref_json),
|
|
775
|
+
field_name: row.field_name ?? undefined,
|
|
776
|
+
old_value_json: null,
|
|
777
|
+
new_value_json: row.target_kind === 'membership'
|
|
778
|
+
? revertMembershipValueJson(row.old_value_json)
|
|
779
|
+
: (row.old_value_json ?? (0, canonicalize_js_1.canonicalizeJSON)(null)),
|
|
780
|
+
reason: 'Revert correction',
|
|
781
|
+
confirmed: true,
|
|
782
|
+
confirmed_by: input.confirmed_by,
|
|
783
|
+
confirmation_summary: input.confirmation_summary,
|
|
784
|
+
},
|
|
785
|
+
now,
|
|
786
|
+
});
|
|
787
|
+
correctionAdapter
|
|
788
|
+
.prepare(`
|
|
789
|
+
UPDATE case_corrections
|
|
790
|
+
SET reverted_at = ?,
|
|
791
|
+
is_lock_active = 0
|
|
792
|
+
WHERE correction_id = ?
|
|
793
|
+
`)
|
|
794
|
+
.run(now, input.correction_id);
|
|
795
|
+
insertCorrectionMemoryEvent({
|
|
796
|
+
adapter: correctionAdapter,
|
|
797
|
+
event_type: 'case.correction_reverted',
|
|
798
|
+
correction_id: input.correction_id,
|
|
799
|
+
case_id: row.case_id,
|
|
800
|
+
actor: input.confirmed_by,
|
|
801
|
+
confirmation_summary: input.confirmation_summary,
|
|
802
|
+
now,
|
|
803
|
+
});
|
|
804
|
+
return { kind: 'reverted', correction_id: input.correction_id, case_id: row.case_id };
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* supersedeCorrection — Phase 2b. Unblocked by spec amendment-8 (2026-04-18)
|
|
809
|
+
* which added `superseded_by` to the mutable-column set in §5.4.
|
|
810
|
+
*
|
|
811
|
+
* Semantics: user changes their mind on an active correction. Inside a single
|
|
812
|
+
* transaction:
|
|
813
|
+
* 1. Read OLD row; reject if not found / reverted / already superseded / inactive.
|
|
814
|
+
* 2. INSERT new correction row referencing the same (case_id, target_kind,
|
|
815
|
+
* target_ref_hash) with new_value_json and is_lock_active=1.
|
|
816
|
+
* 3. UPDATE OLD row: superseded_by = new.correction_id, is_lock_active = 0.
|
|
817
|
+
* 4. Emit case.correction_superseded memory_event.
|
|
818
|
+
*
|
|
819
|
+
* Per spec §5.4 invariant (amendment-8), the three mutable columns are
|
|
820
|
+
* is_lock_active + reverted_at + superseded_by. superseded_by is written
|
|
821
|
+
* ONLY inside this transition.
|
|
822
|
+
*/
|
|
823
|
+
function supersedeCorrection(adapter, input) {
|
|
824
|
+
const correctionAdapter = adapter;
|
|
825
|
+
return (0, sqlite_transaction_js_1.runImmediateTransaction)(correctionAdapter, () => {
|
|
826
|
+
const now = normalizeNow(input.now);
|
|
827
|
+
const old = correctionAdapter
|
|
828
|
+
.prepare(`
|
|
829
|
+
SELECT correction_id, case_id, target_kind, target_ref_json, target_ref_hash,
|
|
830
|
+
field_name, is_lock_active, reverted_at, superseded_by
|
|
831
|
+
FROM case_corrections
|
|
832
|
+
WHERE correction_id = ?
|
|
833
|
+
`)
|
|
834
|
+
.get(input.old_correction_id);
|
|
835
|
+
if (!old) {
|
|
836
|
+
return {
|
|
837
|
+
kind: 'rejected',
|
|
838
|
+
code: 'case.correction_not_found',
|
|
839
|
+
message: 'Old correction not found.',
|
|
840
|
+
correction_id: input.old_correction_id,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (old.reverted_at !== null) {
|
|
844
|
+
return {
|
|
845
|
+
kind: 'rejected',
|
|
846
|
+
code: 'case.correction_reverted',
|
|
847
|
+
message: 'Cannot supersede a reverted correction.',
|
|
848
|
+
correction_id: input.old_correction_id,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
if (old.superseded_by !== null) {
|
|
852
|
+
return {
|
|
853
|
+
kind: 'rejected',
|
|
854
|
+
code: 'case.correction_already_superseded',
|
|
855
|
+
message: 'Correction has already been superseded.',
|
|
856
|
+
correction_id: input.old_correction_id,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
if (Number(old.is_lock_active) === 0) {
|
|
860
|
+
return {
|
|
861
|
+
kind: 'rejected',
|
|
862
|
+
code: 'case.correction_inactive',
|
|
863
|
+
message: 'Correction is inactive.',
|
|
864
|
+
correction_id: input.old_correction_id,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
// Terminal-case gate — mirror applyCorrection. Supersede must not
|
|
868
|
+
// rotate locks or mutate live state on merged/archived/split cases.
|
|
869
|
+
// Without this guard, case_field supersede throws from
|
|
870
|
+
// applyCaseFieldMutation (transaction rolls back but handler leaks
|
|
871
|
+
// 500) and membership supersede silently writes to a dead case.
|
|
872
|
+
const caseRow = correctionAdapter
|
|
873
|
+
.prepare('SELECT status, canonical_case_id FROM case_truth WHERE case_id = ?')
|
|
874
|
+
.get(old.case_id);
|
|
875
|
+
if (!caseRow) {
|
|
876
|
+
return {
|
|
877
|
+
kind: 'precompile_gap',
|
|
878
|
+
code: 'case.precompile_gap',
|
|
879
|
+
case_id: old.case_id,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
if (TERMINAL_CASE_STATUSES.has(caseRow.status)) {
|
|
883
|
+
return {
|
|
884
|
+
kind: 'rejected',
|
|
885
|
+
code: 'case.terminal_status',
|
|
886
|
+
message: `Supersede rejected: case is in terminal status ${caseRow.status}.`,
|
|
887
|
+
case_id: old.case_id,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
let newCorrectionId = randomCorrectionId();
|
|
891
|
+
// Reconstruct the ApplyCorrection-shaped input from OLD's canonical
|
|
892
|
+
// target_ref_json so we can reuse readCurrentValueJson + applyTargetMutation.
|
|
893
|
+
// The JSON round-trips through canonicalizeJSON on insert so it is safe to
|
|
894
|
+
// parse here without re-validating shape.
|
|
895
|
+
const syntheticTargetRef = parseJsonValue(old.target_ref_json);
|
|
896
|
+
const syntheticInput = {
|
|
897
|
+
case_id: old.case_id,
|
|
898
|
+
target_kind: old.target_kind,
|
|
899
|
+
target_ref: syntheticTargetRef,
|
|
900
|
+
field_name: old.field_name ?? undefined,
|
|
901
|
+
new_value_json: input.new_value_json,
|
|
902
|
+
reason: input.reason,
|
|
903
|
+
confirmed: true,
|
|
904
|
+
confirmed_by: input.confirmed_by,
|
|
905
|
+
confirmation_summary: input.confirmation_summary,
|
|
906
|
+
};
|
|
907
|
+
// Snapshot the current live value BEFORE mutating so the NEW row's
|
|
908
|
+
// old_value_json reflects the state the user actually superseded
|
|
909
|
+
// (not NULL, which would erase the audit trail).
|
|
910
|
+
const currentValueJson = readCurrentValueJson(correctionAdapter, syntheticInput);
|
|
911
|
+
const targetRefHashHex = Buffer.from(old.target_ref_hash).toString('hex');
|
|
912
|
+
// CAS / reconfirm dance — mirrors applyCorrection. Now that supersede
|
|
913
|
+
// actually writes live state (P1-1 fix), a stale-view supersede could
|
|
914
|
+
// silently overwrite a value the user never saw. Opt-in via
|
|
915
|
+
// expected_current_value_json (undefined/null = skip CAS).
|
|
916
|
+
//
|
|
917
|
+
// Canonicalize the caller's expected value before comparison so
|
|
918
|
+
// key-ordering or whitespace differences do not cause false 409s.
|
|
919
|
+
// Invalid JSON (including empty string "") throws at the handler
|
|
920
|
+
// boundary — the caller sees a validation_error rather than a bogus
|
|
921
|
+
// silent 409.
|
|
922
|
+
let canonicalExpectedCurrent = null;
|
|
923
|
+
if (typeof input.expected_current_value_json === 'string') {
|
|
924
|
+
canonicalExpectedCurrent = (0, canonicalize_js_1.canonicalizeJSON)(parseJsonValue(input.expected_current_value_json));
|
|
925
|
+
}
|
|
926
|
+
if (canonicalExpectedCurrent !== null && canonicalExpectedCurrent !== currentValueJson) {
|
|
927
|
+
const token = input.reconfirm_token ?? null;
|
|
928
|
+
if (!token) {
|
|
929
|
+
return buildRequiresReconfirm({
|
|
930
|
+
case_id: old.case_id,
|
|
931
|
+
target_kind: old.target_kind,
|
|
932
|
+
target_ref_json: old.target_ref_json,
|
|
933
|
+
target_ref_hash_hex: targetRefHashHex,
|
|
934
|
+
current_value_json: currentValueJson,
|
|
935
|
+
old_value_json: input.expected_current_value_json ?? null,
|
|
936
|
+
proposed_new_value_json: input.new_value_json,
|
|
937
|
+
confirmed_by: input.confirmed_by,
|
|
938
|
+
session_id: input.session_id ?? null,
|
|
939
|
+
now,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
const payload = verifyReconfirmToken({
|
|
943
|
+
token,
|
|
944
|
+
case_id: old.case_id,
|
|
945
|
+
target_kind: old.target_kind,
|
|
946
|
+
target_ref_hash_hex: targetRefHashHex,
|
|
947
|
+
proposed_value_hash_hex: sha256Hex(input.new_value_json),
|
|
948
|
+
confirmed_by: input.confirmed_by,
|
|
949
|
+
session_id: input.session_id ?? null,
|
|
950
|
+
now,
|
|
951
|
+
});
|
|
952
|
+
if (!payload) {
|
|
953
|
+
return buildRequiresReconfirm({
|
|
954
|
+
case_id: old.case_id,
|
|
955
|
+
target_kind: old.target_kind,
|
|
956
|
+
target_ref_json: old.target_ref_json,
|
|
957
|
+
target_ref_hash_hex: targetRefHashHex,
|
|
958
|
+
current_value_json: currentValueJson,
|
|
959
|
+
old_value_json: input.expected_current_value_json ?? null,
|
|
960
|
+
proposed_new_value_json: input.new_value_json,
|
|
961
|
+
confirmed_by: input.confirmed_by,
|
|
962
|
+
session_id: input.session_id ?? null,
|
|
963
|
+
now,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
newCorrectionId = deterministicCorrectionId(payload.nonce);
|
|
967
|
+
if (correctionExists(correctionAdapter, newCorrectionId)) {
|
|
968
|
+
return {
|
|
969
|
+
kind: 'rejected',
|
|
970
|
+
code: 'case.reconfirm_token_replayed',
|
|
971
|
+
message: 'Reconfirm token has already been consumed.',
|
|
972
|
+
case_id: old.case_id,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
if (payload.current_value_hash_hex !== sha256Hex(currentValueJson)) {
|
|
976
|
+
return buildRequiresReconfirm({
|
|
977
|
+
case_id: old.case_id,
|
|
978
|
+
target_kind: old.target_kind,
|
|
979
|
+
target_ref_json: old.target_ref_json,
|
|
980
|
+
target_ref_hash_hex: targetRefHashHex,
|
|
981
|
+
current_value_json: currentValueJson,
|
|
982
|
+
old_value_json: input.expected_current_value_json ?? null,
|
|
983
|
+
proposed_new_value_json: input.new_value_json,
|
|
984
|
+
confirmed_by: input.confirmed_by,
|
|
985
|
+
session_id: input.session_id ?? null,
|
|
986
|
+
now,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// Drop OLD lock FIRST so the partial-unique (case_id, target_ref_hash)
|
|
991
|
+
// slot is free before INSERTing the NEW active row.
|
|
992
|
+
correctionAdapter
|
|
993
|
+
.prepare(`
|
|
994
|
+
UPDATE case_corrections
|
|
995
|
+
SET is_lock_active = 0
|
|
996
|
+
WHERE correction_id = ?
|
|
997
|
+
`)
|
|
998
|
+
.run(input.old_correction_id);
|
|
999
|
+
insertCaseCorrectionLock(adapter, {
|
|
1000
|
+
correction_id: newCorrectionId,
|
|
1001
|
+
case_id: old.case_id,
|
|
1002
|
+
target_kind: old.target_kind,
|
|
1003
|
+
target_ref: syntheticTargetRef,
|
|
1004
|
+
field_name: old.field_name ?? undefined,
|
|
1005
|
+
old_value_json: currentValueJson,
|
|
1006
|
+
new_value_json: input.new_value_json,
|
|
1007
|
+
reason: input.reason,
|
|
1008
|
+
applied_by: input.confirmed_by,
|
|
1009
|
+
applied_at: now,
|
|
1010
|
+
is_lock_active: 1,
|
|
1011
|
+
});
|
|
1012
|
+
// Populate linkage last — sole mutation of superseded_by, permitted by
|
|
1013
|
+
// spec §5.4 amendment-8 (2026-04-18).
|
|
1014
|
+
correctionAdapter
|
|
1015
|
+
.prepare(`
|
|
1016
|
+
UPDATE case_corrections
|
|
1017
|
+
SET superseded_by = ?
|
|
1018
|
+
WHERE correction_id = ?
|
|
1019
|
+
`)
|
|
1020
|
+
.run(newCorrectionId, input.old_correction_id);
|
|
1021
|
+
// Write the revised value to the live target (case_truth / case_memberships)
|
|
1022
|
+
// so the correction drawer and the case board stay consistent. wiki_section
|
|
1023
|
+
// targets are overlay-only and intentionally no-op here.
|
|
1024
|
+
applyTargetMutation({ adapter: correctionAdapter, correction: syntheticInput, now });
|
|
1025
|
+
insertCorrectionMemoryEvent({
|
|
1026
|
+
adapter: correctionAdapter,
|
|
1027
|
+
event_type: 'case.correction_superseded',
|
|
1028
|
+
correction_id: newCorrectionId,
|
|
1029
|
+
case_id: old.case_id,
|
|
1030
|
+
actor: input.confirmed_by,
|
|
1031
|
+
confirmation_summary: input.confirmation_summary,
|
|
1032
|
+
now,
|
|
1033
|
+
extra_refs: [old.correction_id],
|
|
1034
|
+
});
|
|
1035
|
+
return {
|
|
1036
|
+
kind: 'superseded',
|
|
1037
|
+
old_correction_id: input.old_correction_id,
|
|
1038
|
+
new_correction_id: newCorrectionId,
|
|
1039
|
+
case_id: old.case_id,
|
|
1040
|
+
target_ref_json: old.target_ref_json,
|
|
1041
|
+
canonical_hash_hex: Buffer.from(old.target_ref_hash).toString('hex'),
|
|
1042
|
+
};
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
//# sourceMappingURL=corrections.js.map
|