@principles/core 1.289.1 → 1.290.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/dist/runtime-v2/activation/activation-dispatcher.d.ts +4 -0
- package/dist/runtime-v2/activation/activation-dispatcher.d.ts.map +1 -1
- package/dist/runtime-v2/activation/activation-dispatcher.js +215 -63
- package/dist/runtime-v2/activation/activation-dispatcher.js.map +1 -1
- package/dist/runtime-v2/activation/activation-types.d.ts +48 -0
- package/dist/runtime-v2/activation/activation-types.d.ts.map +1 -1
- package/dist/runtime-v2/activation/activation-types.js.map +1 -1
- package/dist/runtime-v2/activation/approval-completion-service.d.ts +9 -0
- package/dist/runtime-v2/activation/approval-completion-service.d.ts.map +1 -1
- package/dist/runtime-v2/activation/approval-completion-service.js +4 -1
- package/dist/runtime-v2/activation/approval-completion-service.js.map +1 -1
- package/dist/runtime-v2/activation/index.d.ts +4 -2
- package/dist/runtime-v2/activation/index.d.ts.map +1 -1
- package/dist/runtime-v2/activation/index.js +4 -1
- package/dist/runtime-v2/activation/index.js.map +1 -1
- package/dist/runtime-v2/activation/low-risk-writers.d.ts.map +1 -1
- package/dist/runtime-v2/activation/low-risk-writers.js +2 -1
- package/dist/runtime-v2/activation/low-risk-writers.js.map +1 -1
- package/dist/runtime-v2/activation/prompt-activation-reader-contract.d.ts +11 -0
- package/dist/runtime-v2/activation/prompt-activation-reader-contract.d.ts.map +1 -1
- package/dist/runtime-v2/activation/prompt-activation-reader-contract.js +12 -2
- package/dist/runtime-v2/activation/prompt-activation-reader-contract.js.map +1 -1
- package/dist/runtime-v2/activation/prompt-replacement.d.ts +79 -0
- package/dist/runtime-v2/activation/prompt-replacement.d.ts.map +1 -0
- package/dist/runtime-v2/activation/prompt-replacement.js +171 -0
- package/dist/runtime-v2/activation/prompt-replacement.js.map +1 -0
- package/dist/runtime-v2/activation/sqlite-activation-state-store.d.ts +17 -1
- package/dist/runtime-v2/activation/sqlite-activation-state-store.d.ts.map +1 -1
- package/dist/runtime-v2/activation/sqlite-activation-state-store.js +132 -0
- package/dist/runtime-v2/activation/sqlite-activation-state-store.js.map +1 -1
- package/dist/runtime-v2/candidate-audit.d.ts +19 -0
- package/dist/runtime-v2/candidate-audit.d.ts.map +1 -1
- package/dist/runtime-v2/candidate-audit.js +72 -12
- package/dist/runtime-v2/candidate-audit.js.map +1 -1
- package/dist/runtime-v2/index.d.ts +8 -2
- package/dist/runtime-v2/index.d.ts.map +1 -1
- package/dist/runtime-v2/index.js +8 -2
- package/dist/runtime-v2/index.js.map +1 -1
- package/dist/runtime-v2/internalization/scribe-prompt-builder.d.ts +13 -1
- package/dist/runtime-v2/internalization/scribe-prompt-builder.d.ts.map +1 -1
- package/dist/runtime-v2/internalization/scribe-prompt-builder.js +15 -2
- package/dist/runtime-v2/internalization/scribe-prompt-builder.js.map +1 -1
- package/dist/runtime-v2/intervention-evidence-normalizer.d.ts +28 -0
- package/dist/runtime-v2/intervention-evidence-normalizer.d.ts.map +1 -0
- package/dist/runtime-v2/intervention-evidence-normalizer.js +654 -0
- package/dist/runtime-v2/intervention-evidence-normalizer.js.map +1 -0
- package/dist/runtime-v2/pain-chain-read-model.d.ts.map +1 -1
- package/dist/runtime-v2/pain-chain-read-model.js +48 -14
- package/dist/runtime-v2/pain-chain-read-model.js.map +1 -1
- package/dist/runtime-v2/receipt-coverage.d.ts +8 -0
- package/dist/runtime-v2/receipt-coverage.d.ts.map +1 -1
- package/dist/runtime-v2/receipt-coverage.js +18 -0
- package/dist/runtime-v2/receipt-coverage.js.map +1 -1
- package/dist/runtime-v2/store/intervention-evidence-schema.d.ts +6 -0
- package/dist/runtime-v2/store/intervention-evidence-schema.d.ts.map +1 -0
- package/dist/runtime-v2/store/intervention-evidence-schema.js +122 -0
- package/dist/runtime-v2/store/intervention-evidence-schema.js.map +1 -0
- package/dist/runtime-v2/store/sqlite-connection.d.ts +8 -0
- package/dist/runtime-v2/store/sqlite-connection.d.ts.map +1 -1
- package/dist/runtime-v2/store/sqlite-connection.js +62 -3
- package/dist/runtime-v2/store/sqlite-connection.js.map +1 -1
- package/dist/runtime-v2/store/sqlite-intervention-evidence-store.d.ts +67 -0
- package/dist/runtime-v2/store/sqlite-intervention-evidence-store.d.ts.map +1 -0
- package/dist/runtime-v2/store/sqlite-intervention-evidence-store.js +737 -0
- package/dist/runtime-v2/store/sqlite-intervention-evidence-store.js.map +1 -0
- package/dist/runtime-v2/types/event-types.d.ts +6 -0
- package/dist/runtime-v2/types/event-types.d.ts.map +1 -1
- package/dist/runtime-v2/types/event-types.js +2 -0
- package/dist/runtime-v2/types/event-types.js.map +1 -1
- package/dist/runtime-v2/types/intervention-evidence-contract.d.ts +287 -0
- package/dist/runtime-v2/types/intervention-evidence-contract.d.ts.map +1 -0
- package/dist/runtime-v2/types/intervention-evidence-contract.js +84 -0
- package/dist/runtime-v2/types/intervention-evidence-contract.js.map +1 -0
- package/package.json +5 -1
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import { PDRuntimeError } from '../error-categories.js';
|
|
2
|
+
import { INTERVENTION_EVIDENCE_TABLES, } from './intervention-evidence-schema.js';
|
|
3
|
+
import { INTERVENTION_RECORD_KINDS, INTERVENTION_SOURCE_KINDS } from '../types/intervention-evidence-contract.js';
|
|
4
|
+
import { INTERVENTION_SHORT_RETENTION_SOURCE_KINDS, INTERVENTION_SHORT_SOURCE_RETENTION_DAYS, LEGACY_OPENCLAW_APPLICATION_LEDGER_LOCATOR_PREFIX, RECEIPT_RETENTION_POLICY_DAYS, interventionEvidenceSourcePolicy, } from '../receipt-coverage.js';
|
|
5
|
+
const AUDIT_DEFAULT_LIMIT = 50;
|
|
6
|
+
const AUDIT_MAX_LIMIT = 200;
|
|
7
|
+
const SHA256_RE = /^sha256:[a-f0-9]{64}$/;
|
|
8
|
+
function isValidAuditTimestamp(value) {
|
|
9
|
+
if (typeof value !== 'string')
|
|
10
|
+
return false;
|
|
11
|
+
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/.exec(value);
|
|
12
|
+
return match !== null && Number.isFinite(Date.parse(value))
|
|
13
|
+
&& new Date(Date.parse(value)).toISOString() === `${match[1]}.${(match[2] ?? '').padEnd(3, '0')}Z`;
|
|
14
|
+
}
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function parseJsonColumn(column, rowId) {
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(column);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: JSON column is not parseable`, { nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.' });
|
|
25
|
+
}
|
|
26
|
+
if (!isRecord(parsed)) {
|
|
27
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: JSON column is not an object`, { nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.' });
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
function requirePayloadField(payload, field, rowId) {
|
|
32
|
+
if (!Object.hasOwn(payload, field)) {
|
|
33
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: payload is missing ${field}`, { nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.' });
|
|
34
|
+
}
|
|
35
|
+
return payload[field];
|
|
36
|
+
}
|
|
37
|
+
const RECORD_KIND_SET = new Set(INTERVENTION_RECORD_KINDS);
|
|
38
|
+
const SOURCE_KIND_SET = new Set(INTERVENTION_SOURCE_KINDS);
|
|
39
|
+
/** rc-2: narrow a stored enum column through a real guard, never a cast. */
|
|
40
|
+
function narrowEnumColumn(spec) {
|
|
41
|
+
const { value, allowed, column, rowId } = spec;
|
|
42
|
+
if (typeof value !== 'string' || !allowed.has(value)) {
|
|
43
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: ${column} is outside the closed vocabulary (${String(value)})`, { nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.' });
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function strColumn(row, column) {
|
|
48
|
+
const value = row[column];
|
|
49
|
+
if (typeof value !== 'string') {
|
|
50
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${column}: expected a string column`, { nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.' });
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function strOrNullColumn(row, column) {
|
|
55
|
+
const value = row[column];
|
|
56
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
57
|
+
}
|
|
58
|
+
function parseOptionalJsonColumn(column, rowId) {
|
|
59
|
+
if (column === null || column === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (typeof column !== 'string') {
|
|
62
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: JSON reference is not text`, {
|
|
63
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return parseJsonColumn(column, rowId);
|
|
67
|
+
}
|
|
68
|
+
function parseNativeRefs(column, rowId) {
|
|
69
|
+
const raw = parseJsonColumn(typeof column === 'string' ? column : '', rowId);
|
|
70
|
+
if (raw.hostKind !== 'openclaw' && raw.hostKind !== 'codex') {
|
|
71
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: native host is invalid`, {
|
|
72
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const refs = { hostKind: raw.hostKind };
|
|
76
|
+
for (const key of ['sessionId', 'runId', 'turnId', 'toolCallId', 'toolName', 'rolloutIdentity']) {
|
|
77
|
+
const value = raw[key];
|
|
78
|
+
if (value !== undefined) {
|
|
79
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
|
|
80
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: native ${key} is invalid`, {
|
|
81
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
refs[key] = value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return refs;
|
|
88
|
+
}
|
|
89
|
+
function parseContentRef(column, rowId) {
|
|
90
|
+
const raw = parseOptionalJsonColumn(column, rowId);
|
|
91
|
+
if (!raw)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (typeof raw.principleId !== 'string' || raw.principleId.length === 0 || raw.principleId.length > 256
|
|
94
|
+
|| (raw.resolution !== 'resolved' && raw.resolution !== 'revision_reference_unresolved')
|
|
95
|
+
|| (raw.payloadDigest !== undefined && (typeof raw.payloadDigest !== 'string' || !SHA256_RE.test(raw.payloadDigest)))
|
|
96
|
+
|| (raw.resolution === 'resolved' && (typeof raw.payloadDigest !== 'string' || !SHA256_RE.test(raw.payloadDigest)))) {
|
|
97
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: content reference is invalid`, {
|
|
98
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const ref = { principleId: raw.principleId, resolution: raw.resolution };
|
|
102
|
+
if (typeof raw.payloadDigest === 'string')
|
|
103
|
+
ref.payloadDigest = raw.payloadDigest;
|
|
104
|
+
for (const key of ['artifactId', 'version', 'approvalRef']) {
|
|
105
|
+
const value = raw[key];
|
|
106
|
+
if (value !== undefined) {
|
|
107
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
|
|
108
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: content ${key} is invalid`, {
|
|
109
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
ref[key] = value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return ref;
|
|
116
|
+
}
|
|
117
|
+
function parseActivationRef(column, rowId) {
|
|
118
|
+
const raw = parseOptionalJsonColumn(column, rowId);
|
|
119
|
+
if (!raw)
|
|
120
|
+
return undefined;
|
|
121
|
+
if (typeof raw.activationId !== 'string' || raw.activationId.length === 0 || raw.activationId.length > 256
|
|
122
|
+
|| typeof raw.sourceSnapshotDigest !== 'string' || !SHA256_RE.test(raw.sourceSnapshotDigest)
|
|
123
|
+
|| (raw.activatedAt !== undefined && !isValidAuditTimestamp(raw.activatedAt))) {
|
|
124
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: activation reference is invalid`, {
|
|
125
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const ref = { activationId: raw.activationId, sourceSnapshotDigest: raw.sourceSnapshotDigest };
|
|
129
|
+
for (const key of ['idempotencyKey', 'artifactId', 'channel', 'activatedAt']) {
|
|
130
|
+
const value = raw[key];
|
|
131
|
+
if (value !== undefined) {
|
|
132
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
|
|
133
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${rowId}: activation ${key} is invalid`, {
|
|
134
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
ref[key] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return ref;
|
|
141
|
+
}
|
|
142
|
+
function redactPayloadText(recordKind, rawPayload, evidenceId) {
|
|
143
|
+
if (typeof recordKind !== 'string')
|
|
144
|
+
return null;
|
|
145
|
+
const payload = parseJsonColumn(typeof rawPayload === 'string' ? rawPayload : '', evidenceId);
|
|
146
|
+
let changed = false;
|
|
147
|
+
const remove = (key) => {
|
|
148
|
+
if (Object.hasOwn(payload, key)) {
|
|
149
|
+
delete payload[key];
|
|
150
|
+
changed = true;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const replace = (key) => {
|
|
154
|
+
if (Object.hasOwn(payload, key) && payload[key] !== '[expired]') {
|
|
155
|
+
payload[key] = '[expired]';
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
switch (recordKind) {
|
|
160
|
+
case 'delivery':
|
|
161
|
+
remove('failureReason');
|
|
162
|
+
remove('nonAttemptReason');
|
|
163
|
+
remove('unsupportedNote');
|
|
164
|
+
break;
|
|
165
|
+
case 'application':
|
|
166
|
+
remove('claimText');
|
|
167
|
+
break;
|
|
168
|
+
case 'behavior_episode':
|
|
169
|
+
remove('inputPreview');
|
|
170
|
+
remove('resultSummary');
|
|
171
|
+
replace('actionSummary');
|
|
172
|
+
break;
|
|
173
|
+
case 'effect':
|
|
174
|
+
remove('disputeReason');
|
|
175
|
+
replace('observationSummary');
|
|
176
|
+
break;
|
|
177
|
+
case 'outcome':
|
|
178
|
+
remove('feedbackText');
|
|
179
|
+
remove('actorId');
|
|
180
|
+
replace('observationSummary');
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: record kind is invalid during retention`, {
|
|
184
|
+
nextAction: 'Inspect the evidence ledger schema and preserve its raw source before retrying retention.',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return changed ? JSON.stringify(payload) : null;
|
|
188
|
+
}
|
|
189
|
+
function redactCorrectionReason(value) {
|
|
190
|
+
return value !== null && value !== undefined && value !== '[expired]' ? '[expired]' : null;
|
|
191
|
+
}
|
|
192
|
+
function redactExpiredSensitivePayloads(db, asOf) {
|
|
193
|
+
const receiptCutoff = new Date(asOf.getTime() - RECEIPT_RETENTION_POLICY_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
194
|
+
const shortSourceCutoff = new Date(asOf.getTime() - INTERVENTION_SHORT_SOURCE_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
195
|
+
const rows = db.prepare(`SELECT evidence_id, record_kind, payload_json, correction_reason
|
|
196
|
+
FROM intervention_evidence_records
|
|
197
|
+
WHERE ((source_kind IN (?, ?, ?) AND NOT (source_kind = ? AND source_locator LIKE ?) AND
|
|
198
|
+
(recorded_at < ? OR (occurred_at < ? AND occurred_at < recorded_at))) OR
|
|
199
|
+
((source_kind NOT IN (?, ?, ?) OR (source_kind = ? AND source_locator LIKE ?)) AND
|
|
200
|
+
(recorded_at < ? OR (occurred_at < ? AND occurred_at < recorded_at))))
|
|
201
|
+
AND (
|
|
202
|
+
(content_redacted_at IS NULL AND (
|
|
203
|
+
(record_kind = 'delivery' AND (json_type(payload_json, '$.failureReason') IS NOT NULL OR json_type(payload_json, '$.nonAttemptReason') IS NOT NULL OR json_type(payload_json, '$.unsupportedNote') IS NOT NULL)) OR
|
|
204
|
+
(record_kind = 'application' AND json_type(payload_json, '$.claimText') IS NOT NULL) OR
|
|
205
|
+
(record_kind = 'behavior_episode' AND (json_type(payload_json, '$.inputPreview') IS NOT NULL OR json_type(payload_json, '$.resultSummary') IS NOT NULL OR (json_type(payload_json, '$.actionSummary') IS NOT NULL AND json_extract(payload_json, '$.actionSummary') <> '[expired]'))) OR
|
|
206
|
+
(record_kind = 'effect' AND (json_type(payload_json, '$.disputeReason') IS NOT NULL OR (json_type(payload_json, '$.observationSummary') IS NOT NULL AND json_extract(payload_json, '$.observationSummary') <> '[expired]'))) OR
|
|
207
|
+
(record_kind = 'outcome' AND (json_type(payload_json, '$.feedbackText') IS NOT NULL OR json_type(payload_json, '$.actorId') IS NOT NULL OR (json_type(payload_json, '$.observationSummary') IS NOT NULL AND json_extract(payload_json, '$.observationSummary') <> '[expired]')))
|
|
208
|
+
)) OR (correction_reason IS NOT NULL AND correction_reason <> '[expired]')
|
|
209
|
+
)
|
|
210
|
+
ORDER BY recorded_at ASC, evidence_id ASC LIMIT 200`).all(...INTERVENTION_SHORT_RETENTION_SOURCE_KINDS, 'openclaw_plugin_event_log', `${LEGACY_OPENCLAW_APPLICATION_LEDGER_LOCATOR_PREFIX}%`, shortSourceCutoff, shortSourceCutoff, ...INTERVENTION_SHORT_RETENTION_SOURCE_KINDS, 'openclaw_plugin_event_log', `${LEGACY_OPENCLAW_APPLICATION_LEDGER_LOCATOR_PREFIX}%`, receiptCutoff, receiptCutoff);
|
|
211
|
+
const redact = db.prepare(`UPDATE intervention_evidence_records
|
|
212
|
+
SET payload_json = ?, correction_reason = ?, content_redacted_at = COALESCE(content_redacted_at, ?)
|
|
213
|
+
WHERE evidence_id = ? AND (content_redacted_at IS NULL OR (correction_reason IS NOT NULL AND correction_reason <> '[expired]'))`);
|
|
214
|
+
let count = 0;
|
|
215
|
+
const redactedAt = asOf.toISOString();
|
|
216
|
+
for (const row of rows) {
|
|
217
|
+
if (!isRecord(row) || typeof row.evidence_id !== 'string') {
|
|
218
|
+
throw new PDRuntimeError('storage_unavailable', 'Malformed intervention evidence row during retention sweep', {
|
|
219
|
+
nextAction: 'Inspect the evidence ledger schema and preserve its raw source before retrying retention.',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const payload = redactPayloadText(row.record_kind, row.payload_json, row.evidence_id);
|
|
223
|
+
const correctionReason = redactCorrectionReason(row.correction_reason);
|
|
224
|
+
if (payload !== null || correctionReason !== null) {
|
|
225
|
+
count += redact.run(payload ?? String(row.payload_json), correctionReason ?? row.correction_reason ?? null, redactedAt, row.evidence_id).changes;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return count;
|
|
229
|
+
}
|
|
230
|
+
function mapRowToSummary(row, existingKeys) {
|
|
231
|
+
// rc-1: SELECT rows arrive as unknown; every column is narrowed through a
|
|
232
|
+
// guard before use, and out-of-vocabulary values fail loud as corruption.
|
|
233
|
+
if (!isRecord(row)) {
|
|
234
|
+
throw new PDRuntimeError('storage_unavailable', 'Malformed intervention evidence row: not an object', { nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.' });
|
|
235
|
+
}
|
|
236
|
+
const evidenceId = strColumn(row, 'evidence_id');
|
|
237
|
+
const recordKind = narrowEnumColumn({ value: row.record_kind, allowed: RECORD_KIND_SET, column: 'record_kind', rowId: evidenceId });
|
|
238
|
+
const sourceKind = narrowEnumColumn({ value: row.source_kind, allowed: SOURCE_KIND_SET, column: 'source_kind', rowId: evidenceId });
|
|
239
|
+
const sourceLocator = strColumn(row, 'source_locator');
|
|
240
|
+
let payload = parseJsonColumn(strColumn(row, 'payload_json'), evidenceId);
|
|
241
|
+
const nativeRefs = parseNativeRefs(row.native_refs_json, evidenceId);
|
|
242
|
+
const contentRef = parseContentRef(row.content_ref_json, evidenceId);
|
|
243
|
+
const activationRef = parseActivationRef(row.activation_ref_json, evidenceId);
|
|
244
|
+
let contentRedactedAt = strOrNullColumn(row, 'content_redacted_at') ?? undefined;
|
|
245
|
+
const recordedAt = strColumn(row, 'recorded_at');
|
|
246
|
+
const occurredAt = strOrNullColumn(row, 'occurred_at') ?? undefined;
|
|
247
|
+
if (!isValidAuditTimestamp(recordedAt) || (occurredAt !== undefined && !isValidAuditTimestamp(occurredAt))) {
|
|
248
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: source timestamp is invalid`, {
|
|
249
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (Date.parse(recordedAt) > Date.now() + 5 * 60 * 1000 || (occurredAt !== undefined && Date.parse(occurredAt) > Date.parse(recordedAt) + 5 * 60 * 1000)) {
|
|
253
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: future source timestamp cannot extend retention`, {
|
|
254
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const sourceTime = occurredAt && Date.parse(occurredAt) < Date.parse(recordedAt) ? occurredAt : recordedAt;
|
|
258
|
+
const { retentionDays, sourceStatus } = interventionEvidenceSourcePolicy(sourceKind, sourceLocator);
|
|
259
|
+
const expiresAt = Date.parse(sourceTime) + retentionDays * 24 * 60 * 60 * 1000;
|
|
260
|
+
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
|
|
261
|
+
const redactedPayload = redactPayloadText(recordKind, JSON.stringify(payload), evidenceId);
|
|
262
|
+
const redactedCorrectionReason = redactCorrectionReason(row.correction_reason);
|
|
263
|
+
if (redactedPayload !== null) {
|
|
264
|
+
payload = parseJsonColumn(redactedPayload, evidenceId);
|
|
265
|
+
}
|
|
266
|
+
if (redactedPayload !== null || redactedCorrectionReason !== null)
|
|
267
|
+
contentRedactedAt ??= new Date(expiresAt).toISOString();
|
|
268
|
+
}
|
|
269
|
+
if (contentRedactedAt !== undefined) {
|
|
270
|
+
if (!isValidAuditTimestamp(contentRedactedAt) || Date.parse(contentRedactedAt) > Date.now() + 5 * 60 * 1000) {
|
|
271
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: redaction timestamp is invalid`, {
|
|
272
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const stillSensitive = redactPayloadText(recordKind, JSON.stringify(payload), evidenceId);
|
|
276
|
+
if (stillSensitive !== null) {
|
|
277
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: redacted row still contains sensitive text`, {
|
|
278
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const principleId = strOrNullColumn(row, 'principle_id') ?? undefined;
|
|
283
|
+
const activationId = strOrNullColumn(row, 'activation_id') ?? undefined;
|
|
284
|
+
if (contentRef && principleId !== undefined && contentRef.principleId !== principleId) {
|
|
285
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: content reference principle does not match record`, {
|
|
286
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (activationRef && activationRef.activationId !== activationId) {
|
|
290
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: activation occurrence does not match record`, {
|
|
291
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (activationRef?.activatedAt && occurredAt !== undefined && Date.parse(activationRef.activatedAt) > Date.parse(occurredAt)) {
|
|
295
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: activation occurs after referenced evidence`, {
|
|
296
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (contentRef?.artifactId && activationRef?.artifactId && contentRef.artifactId !== activationRef.artifactId) {
|
|
300
|
+
throw new PDRuntimeError('storage_unavailable', `Malformed intervention evidence row ${evidenceId}: content and activation artifacts differ`, {
|
|
301
|
+
nextAction: 'The ledger row is corrupted; preserve the raw source, then quarantine and re-record from the durable source batch.',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// Minimal per-kind structural check before the typed read: full validation
|
|
305
|
+
// happened at ingress time; this only fails loud on ledger corruption
|
|
306
|
+
// instead of trusting stored JSON blind (rc-2).
|
|
307
|
+
if (recordKind === 'delivery') {
|
|
308
|
+
requirePayloadField(payload, 'targetKind', evidenceId);
|
|
309
|
+
requirePayloadField(payload, 'confirmation', evidenceId);
|
|
310
|
+
requirePayloadField(payload, 'outcome', evidenceId);
|
|
311
|
+
}
|
|
312
|
+
else if (recordKind === 'application') {
|
|
313
|
+
requirePayloadField(payload, 'proofMethod', evidenceId);
|
|
314
|
+
requirePayloadField(payload, 'action', evidenceId);
|
|
315
|
+
}
|
|
316
|
+
else if (recordKind === 'behavior_episode') {
|
|
317
|
+
requirePayloadField(payload, 'status', evidenceId);
|
|
318
|
+
requirePayloadField(payload, 'actionSummary', evidenceId);
|
|
319
|
+
}
|
|
320
|
+
else if (recordKind === 'effect') {
|
|
321
|
+
requirePayloadField(payload, 'status', evidenceId);
|
|
322
|
+
requirePayloadField(payload, 'observationSummary', evidenceId);
|
|
323
|
+
}
|
|
324
|
+
else if (recordKind === 'outcome') {
|
|
325
|
+
requirePayloadField(payload, 'outcomeSource', evidenceId);
|
|
326
|
+
requirePayloadField(payload, 'observationSummary', evidenceId);
|
|
327
|
+
}
|
|
328
|
+
// Every payload member was presence-checked per kind above; this narrows VALIDATED data (the union type has no guard form).
|
|
329
|
+
// runtime-contract-exempt: ERR-001 payload-union narrowing after per-kind field validation — see the checks immediately above
|
|
330
|
+
const typedPayload = payload;
|
|
331
|
+
const deliveryKey = strOrNullColumn(row, 'delivery_key');
|
|
332
|
+
const episodeKey = strOrNullColumn(row, 'episode_key');
|
|
333
|
+
const effectKey = strOrNullColumn(row, 'effect_key');
|
|
334
|
+
const correctionOf = strOrNullColumn(row, 'correction_of');
|
|
335
|
+
const pendingFields = [];
|
|
336
|
+
if (contentRef?.resolution === 'revision_reference_unresolved') {
|
|
337
|
+
pendingFields.push({ field: 'contentRef', missingKey: `revision:${contentRef.principleId}` });
|
|
338
|
+
}
|
|
339
|
+
for (const [field, key] of [
|
|
340
|
+
['deliveryKey', deliveryKey],
|
|
341
|
+
['episodeKey', episodeKey],
|
|
342
|
+
['effectKey', effectKey],
|
|
343
|
+
['correctionOf', correctionOf],
|
|
344
|
+
]) {
|
|
345
|
+
if (key !== null && !existingKeys.has(key)) {
|
|
346
|
+
pendingFields.push({ field, missingKey: key });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (activationId !== undefined && activationRef === undefined) {
|
|
350
|
+
pendingFields.push({ field: 'activationRef', missingKey: activationId });
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
evidenceId,
|
|
354
|
+
kind: recordKind,
|
|
355
|
+
observationKey: strColumn(row, 'observation_key'),
|
|
356
|
+
sourceKind,
|
|
357
|
+
sourceLocator,
|
|
358
|
+
recordedAt,
|
|
359
|
+
occurredAt,
|
|
360
|
+
principleId,
|
|
361
|
+
activationId,
|
|
362
|
+
deliveryKey: deliveryKey ?? undefined,
|
|
363
|
+
episodeKey: episodeKey ?? undefined,
|
|
364
|
+
effectKey: effectKey ?? undefined,
|
|
365
|
+
correctionOf: correctionOf ?? undefined,
|
|
366
|
+
nativeRefs,
|
|
367
|
+
...(contentRef ? { contentRef } : {}),
|
|
368
|
+
...(activationRef ? { activationRef } : {}),
|
|
369
|
+
...(contentRedactedAt ? { contentRedactedAt } : {}),
|
|
370
|
+
...(sourceStatus ? { sourceStatus } : {}),
|
|
371
|
+
payload: typedPayload,
|
|
372
|
+
recordDigest: strColumn(row, 'record_digest'),
|
|
373
|
+
associationStatus: pendingFields.length === 0 ? 'linked' : 'pending_association',
|
|
374
|
+
pendingFields,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function readCapabilityDeclarations(db) {
|
|
378
|
+
const rows = db.prepare('SELECT host_kind, capability, status, adapter_version, channel, max_confirmation, note FROM intervention_capability_declarations').all();
|
|
379
|
+
const declarations = [];
|
|
380
|
+
for (const rawRow of rows) {
|
|
381
|
+
if (!isRecord(rawRow))
|
|
382
|
+
continue;
|
|
383
|
+
const declaration = {
|
|
384
|
+
hostKind: rawRow.host_kind,
|
|
385
|
+
capability: rawRow.capability,
|
|
386
|
+
status: rawRow.status,
|
|
387
|
+
adapterVersion: typeof rawRow.adapter_version === 'string' ? rawRow.adapter_version : '',
|
|
388
|
+
channel: typeof rawRow.channel === 'string' ? rawRow.channel : '',
|
|
389
|
+
};
|
|
390
|
+
if (typeof rawRow.max_confirmation === 'string') {
|
|
391
|
+
declaration.maxConfirmation = rawRow.max_confirmation;
|
|
392
|
+
}
|
|
393
|
+
if (typeof rawRow.note === 'string')
|
|
394
|
+
declaration.note = rawRow.note;
|
|
395
|
+
declarations.push(declaration);
|
|
396
|
+
}
|
|
397
|
+
return declarations;
|
|
398
|
+
}
|
|
399
|
+
function interventionEvidenceTablesExist(db) {
|
|
400
|
+
const check = db.prepare("SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type = 'table' AND name = ?");
|
|
401
|
+
for (const table of INTERVENTION_EVIDENCE_TABLES) {
|
|
402
|
+
const row = check.get(table);
|
|
403
|
+
if (!isRecord(row) || row.cnt !== 1)
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
export class SqliteInterventionEvidenceStore {
|
|
409
|
+
connection;
|
|
410
|
+
constructor(connection) {
|
|
411
|
+
this.connection = connection;
|
|
412
|
+
}
|
|
413
|
+
/** Returns the workspace evidence scope, or null when not yet established. */
|
|
414
|
+
getEvidenceScope() {
|
|
415
|
+
const db = this.connection.getDb();
|
|
416
|
+
if (!interventionEvidenceTablesExist(db))
|
|
417
|
+
return null;
|
|
418
|
+
const row = db.prepare('SELECT scope_id, established_at FROM intervention_evidence_scope LIMIT 1').get();
|
|
419
|
+
if (!isRecord(row))
|
|
420
|
+
return null;
|
|
421
|
+
const scopeId = typeof row.scope_id === 'string' ? row.scope_id : null;
|
|
422
|
+
const establishedAt = typeof row.established_at === 'string' ? row.established_at : null;
|
|
423
|
+
if (!scopeId || !establishedAt)
|
|
424
|
+
return null;
|
|
425
|
+
return { scopeId, establishedAt };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Resolve the host that owns a behavior episode: the episode row's own
|
|
429
|
+
* native_refs hostKind (an episode IS observed by exactly one host).
|
|
430
|
+
* Returns null when the episode is absent or its stored refs are corrupt —
|
|
431
|
+
* callers fall back to an explicit Unknown/derived marker, never a guess.
|
|
432
|
+
*/
|
|
433
|
+
findEpisodeHostKind(episodeKey) {
|
|
434
|
+
const db = this.connection.getDb();
|
|
435
|
+
if (!interventionEvidenceTablesExist(db))
|
|
436
|
+
return null;
|
|
437
|
+
const row = db.prepare(`SELECT native_refs_json FROM intervention_evidence_records
|
|
438
|
+
WHERE record_kind = 'behavior_episode' AND observation_key = ?
|
|
439
|
+
ORDER BY recorded_at DESC, evidence_id DESC LIMIT 1`).get(episodeKey);
|
|
440
|
+
if (!isRecord(row) || typeof row.native_refs_json !== 'string')
|
|
441
|
+
return null;
|
|
442
|
+
let refs;
|
|
443
|
+
try {
|
|
444
|
+
refs = JSON.parse(row.native_refs_json);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
if (!isRecord(refs))
|
|
450
|
+
return null;
|
|
451
|
+
return refs.hostKind === 'openclaw' || refs.hostKind === 'codex' ? refs.hostKind : null;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Append one normalized batch atomically. Scope is established on first
|
|
455
|
+
* append; a batch from a different scope is rejected without writes.
|
|
456
|
+
* Conflicting payloads under an existing natural key are reported — the
|
|
457
|
+
* original stays intact and other records in the batch still commit.
|
|
458
|
+
*/
|
|
459
|
+
appendObservationBatch(batch) {
|
|
460
|
+
const db = this.connection.getDb();
|
|
461
|
+
const insertRecord = db.prepare(`
|
|
462
|
+
INSERT INTO intervention_evidence_records (
|
|
463
|
+
evidence_id, scope_id, source_kind, observation_key, source_locator, record_kind,
|
|
464
|
+
principle_id, activation_id, delivery_key, episode_key, effect_key,
|
|
465
|
+
correction_of, correction_reason, occurred_at, recorded_at,
|
|
466
|
+
native_refs_json, content_ref_json, activation_ref_json, payload_json, record_digest, content_redacted_at
|
|
467
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
468
|
+
`);
|
|
469
|
+
const findExisting = db.prepare(`
|
|
470
|
+
SELECT evidence_id, record_digest FROM intervention_evidence_records
|
|
471
|
+
WHERE source_kind = ? AND source_locator = ? AND observation_key = ?
|
|
472
|
+
`);
|
|
473
|
+
const upsertCapability = db.prepare(`
|
|
474
|
+
INSERT INTO intervention_capability_declarations (
|
|
475
|
+
host_kind, capability, status, adapter_version, channel, max_confirmation, note, declared_at
|
|
476
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
477
|
+
ON CONFLICT(host_kind, capability) DO UPDATE SET
|
|
478
|
+
status = excluded.status,
|
|
479
|
+
adapter_version = excluded.adapter_version,
|
|
480
|
+
channel = excluded.channel,
|
|
481
|
+
max_confirmation = excluded.max_confirmation,
|
|
482
|
+
note = excluded.note,
|
|
483
|
+
declared_at = excluded.declared_at
|
|
484
|
+
`);
|
|
485
|
+
let insertedCount = 0;
|
|
486
|
+
const duplicates = [];
|
|
487
|
+
const conflicts = [];
|
|
488
|
+
db.exec('BEGIN IMMEDIATE');
|
|
489
|
+
try {
|
|
490
|
+
const scopeRow = db.prepare('SELECT scope_id FROM intervention_evidence_scope LIMIT 1').get();
|
|
491
|
+
if (isRecord(scopeRow) && typeof scopeRow.scope_id === 'string') {
|
|
492
|
+
if (scopeRow.scope_id !== batch.evidenceScopeId) {
|
|
493
|
+
db.exec('ROLLBACK');
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
insertedCount: 0,
|
|
497
|
+
duplicates,
|
|
498
|
+
conflicts,
|
|
499
|
+
reason: `evidence_scope_mismatch:${scopeRow.scope_id}`,
|
|
500
|
+
nextAction: `This state.db evidence ledger belongs to scope ${scopeRow.scope_id}; re-resolve the workspace to its canonical path (check symlinks, renames or bind mounts — evidenceScopeIdFor uses realpath) and replay the same batch, or inspect .pd/state.db ownership when two workspaces share one db file.`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
db.prepare('INSERT INTO intervention_evidence_scope (scope_id, established_at) VALUES (?, ?)')
|
|
506
|
+
.run(batch.evidenceScopeId, batch.recordedAt);
|
|
507
|
+
}
|
|
508
|
+
// Reuse the existing receipt horizon at the evidence writer boundary.
|
|
509
|
+
// Redaction is bounded and preserves the immutable identity/provenance
|
|
510
|
+
// columns; there is no background process or caller-controlled bypass.
|
|
511
|
+
redactExpiredSensitivePayloads(db, new Date());
|
|
512
|
+
for (const record of batch.records) {
|
|
513
|
+
const existing = findExisting.get(record.sourceKind, record.sourceLocator, record.observationKey);
|
|
514
|
+
if (isRecord(existing)) {
|
|
515
|
+
const existingDigest = typeof existing.record_digest === 'string' ? existing.record_digest : '';
|
|
516
|
+
if (existingDigest === record.recordDigest) {
|
|
517
|
+
duplicates.push(record.observationKey);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
conflicts.push({
|
|
521
|
+
observationKey: record.observationKey,
|
|
522
|
+
existingDigest,
|
|
523
|
+
incomingDigest: record.recordDigest,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const recordTime = record.occurredAt && Date.parse(record.occurredAt) < Date.parse(record.recordedAt)
|
|
529
|
+
? record.occurredAt
|
|
530
|
+
: record.recordedAt;
|
|
531
|
+
const { retentionDays } = interventionEvidenceSourcePolicy(record.sourceKind, record.sourceLocator);
|
|
532
|
+
const expiresAt = Date.parse(recordTime) + retentionDays * 24 * 60 * 60 * 1000;
|
|
533
|
+
let payloadJson = JSON.stringify(record.payload);
|
|
534
|
+
let correctionReason = record.correctionReason ?? null;
|
|
535
|
+
let contentRedactedAt = null;
|
|
536
|
+
const insertedAt = new Date();
|
|
537
|
+
if (Number.isFinite(expiresAt) && expiresAt <= insertedAt.getTime()) {
|
|
538
|
+
const redactedPayload = redactPayloadText(record.kind, payloadJson, record.evidenceId);
|
|
539
|
+
const redactedCorrectionReason = redactCorrectionReason(correctionReason);
|
|
540
|
+
if (redactedPayload !== null || redactedCorrectionReason !== null) {
|
|
541
|
+
payloadJson = redactedPayload ?? payloadJson;
|
|
542
|
+
correctionReason = redactedCorrectionReason ?? correctionReason;
|
|
543
|
+
contentRedactedAt = insertedAt.toISOString();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
insertRecord.run(record.evidenceId, record.evidenceScopeId, record.sourceKind, record.observationKey, record.sourceLocator, record.kind, record.principleId ?? null, record.activationRef?.activationId ?? record.activationId ?? null, record.deliveryKey ?? null, record.episodeKey ?? null, record.effectKey ?? null, record.correctionOf ?? null, correctionReason, record.occurredAt ?? null, record.recordedAt, JSON.stringify(record.nativeRefs), record.contentRef ? JSON.stringify(record.contentRef) : null, record.activationRef ? JSON.stringify(record.activationRef) : null, payloadJson, record.recordDigest, contentRedactedAt);
|
|
547
|
+
insertedCount += 1;
|
|
548
|
+
}
|
|
549
|
+
for (const declaration of batch.capabilityDeclarations) {
|
|
550
|
+
upsertCapability.run(declaration.hostKind, declaration.capability, declaration.status, declaration.adapterVersion, declaration.channel, declaration.maxConfirmation ?? null, declaration.note ?? null, batch.recordedAt);
|
|
551
|
+
}
|
|
552
|
+
db.exec('COMMIT');
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
try {
|
|
556
|
+
db.exec('ROLLBACK');
|
|
557
|
+
}
|
|
558
|
+
catch { /* best-effort: tx may already be rolled back */ }
|
|
559
|
+
const busy = typeof error === 'object' && error !== null
|
|
560
|
+
&& error.code === 'SQLITE_BUSY';
|
|
561
|
+
throw new PDRuntimeError('storage_unavailable', busy
|
|
562
|
+
? 'Intervention evidence append hit a busy state.db lock (evidence writers never wait on locks).'
|
|
563
|
+
: `Intervention evidence append failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
564
|
+
nextAction: busy
|
|
565
|
+
? 'The raw source remains durable; replay the same observation batch later — idempotency makes replay safe.'
|
|
566
|
+
: 'The transaction rolled back atomically; the raw source remains durable. Retry the same batch after resolving the storage error.',
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
ok: conflicts.length === 0,
|
|
571
|
+
insertedCount,
|
|
572
|
+
duplicates,
|
|
573
|
+
conflicts,
|
|
574
|
+
...(conflicts.length > 0
|
|
575
|
+
? {
|
|
576
|
+
reason: `source_conflict:${conflicts.length}`,
|
|
577
|
+
nextAction: 'A different payload arrived under an existing source key. The original record is preserved; inspect both digests from the durable sources before recording a correction.',
|
|
578
|
+
}
|
|
579
|
+
: {}),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Read-side for the four audit queries (SPEC §13.8). Read-only friendly:
|
|
584
|
+
* on a state.db that predates the evidence tables this reports
|
|
585
|
+
* unavailable instead of creating anything.
|
|
586
|
+
*/
|
|
587
|
+
readAuditRelations(selector, options = {}) {
|
|
588
|
+
const db = this.connection.getDb();
|
|
589
|
+
if (!interventionEvidenceTablesExist(db)) {
|
|
590
|
+
return {
|
|
591
|
+
available: false,
|
|
592
|
+
reason: 'evidence_schema_not_initialized',
|
|
593
|
+
nextAction: 'Evidence tables are created on the first evidence write; run a host with evidence collection enabled, or verify the workspace has any intervention evidence yet.',
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
const limit = Math.min(Math.max(options.limit ?? AUDIT_DEFAULT_LIMIT, 1), AUDIT_MAX_LIMIT);
|
|
597
|
+
const evidenceColumns = db.prepare('PRAGMA table_info(intervention_evidence_records)').all();
|
|
598
|
+
const hasRedactionColumn = evidenceColumns.some((column) => isRecord(column) && column.name === 'content_redacted_at');
|
|
599
|
+
const redactedAtSelect = hasRedactionColumn ? 'content_redacted_at' : 'NULL AS content_redacted_at';
|
|
600
|
+
const baseSelect = `
|
|
601
|
+
SELECT evidence_id, scope_id, source_kind, observation_key, source_locator, record_kind,
|
|
602
|
+
principle_id, activation_id, delivery_key, episode_key, effect_key,
|
|
603
|
+
correction_of, correction_reason, occurred_at, recorded_at,
|
|
604
|
+
native_refs_json, content_ref_json, activation_ref_json, payload_json, record_digest,
|
|
605
|
+
${redactedAtSelect}
|
|
606
|
+
FROM intervention_evidence_records`;
|
|
607
|
+
let selectorWhere;
|
|
608
|
+
let selectorArgs;
|
|
609
|
+
switch (selector.type) {
|
|
610
|
+
case 'principle':
|
|
611
|
+
selectorWhere = 'principle_id = ?';
|
|
612
|
+
selectorArgs = [selector.principleId];
|
|
613
|
+
break;
|
|
614
|
+
case 'activation':
|
|
615
|
+
selectorWhere = 'activation_id = ?';
|
|
616
|
+
selectorArgs = [selector.activationId];
|
|
617
|
+
break;
|
|
618
|
+
case 'episode':
|
|
619
|
+
selectorWhere = '(observation_key = ? OR episode_key = ?)';
|
|
620
|
+
selectorArgs = [selector.observationKey, selector.observationKey];
|
|
621
|
+
break;
|
|
622
|
+
case 'effect':
|
|
623
|
+
selectorWhere = '(observation_key = ? OR effect_key = ?)';
|
|
624
|
+
selectorArgs = [selector.observationKey, selector.observationKey];
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
const kinds = ['delivery', 'application', 'behavior_episode', 'effect', 'outcome'];
|
|
628
|
+
const kindRows = new Map();
|
|
629
|
+
const pages = {
|
|
630
|
+
delivery: { hasMore: false, nextCursor: null },
|
|
631
|
+
application: { hasMore: false, nextCursor: null },
|
|
632
|
+
behavior_episode: { hasMore: false, nextCursor: null },
|
|
633
|
+
effect: { hasMore: false, nextCursor: null },
|
|
634
|
+
outcome: { hasMore: false, nextCursor: null },
|
|
635
|
+
};
|
|
636
|
+
const selectorKind = (kind) => {
|
|
637
|
+
if (selector.type !== 'principle' && selector.type !== 'effect')
|
|
638
|
+
return selectorWhere;
|
|
639
|
+
if (kind !== 'outcome')
|
|
640
|
+
return selectorWhere;
|
|
641
|
+
// Outcome rows commonly have no principle/effect id of their own. Reach
|
|
642
|
+
// them through exact episode references selected by the same query.
|
|
643
|
+
return `(${selectorWhere} OR (record_kind = 'outcome' AND episode_key IN (
|
|
644
|
+
SELECT episode_key FROM intervention_evidence_records WHERE ${selectorWhere} AND episode_key IS NOT NULL
|
|
645
|
+
)))`;
|
|
646
|
+
};
|
|
647
|
+
for (const kind of kinds) {
|
|
648
|
+
const cursor = options.cursor?.kind === kind ? options.cursor.after : undefined;
|
|
649
|
+
const where = `record_kind = ? AND ${selectorKind(kind)}`;
|
|
650
|
+
const cursorClause = cursor ? ' AND (recorded_at < ? OR (recorded_at = ? AND evidence_id < ?))' : '';
|
|
651
|
+
const args = [kind, ...selectorArgs];
|
|
652
|
+
if (kind === 'outcome' && (selector.type === 'principle' || selector.type === 'effect')) {
|
|
653
|
+
args.push(...selectorArgs);
|
|
654
|
+
}
|
|
655
|
+
if (cursor)
|
|
656
|
+
args.push(cursor.recordedAt, cursor.recordedAt, cursor.evidenceId);
|
|
657
|
+
args.push(limit + 1);
|
|
658
|
+
const fetched = db.prepare(`${baseSelect} WHERE ${where}${cursorClause} ORDER BY recorded_at DESC, evidence_id DESC LIMIT ?`).all(...args);
|
|
659
|
+
const hasMore = fetched.length > limit;
|
|
660
|
+
const pageRows = fetched.slice(0, limit);
|
|
661
|
+
const last = pageRows.at(-1);
|
|
662
|
+
pages[kind] = {
|
|
663
|
+
hasMore,
|
|
664
|
+
nextCursor: hasMore && isRecord(last)
|
|
665
|
+
&& typeof last.recorded_at === 'string' && typeof last.evidence_id === 'string'
|
|
666
|
+
? { recordedAt: last.recorded_at, evidenceId: last.evidence_id }
|
|
667
|
+
: null,
|
|
668
|
+
};
|
|
669
|
+
kindRows.set(kind, pageRows);
|
|
670
|
+
}
|
|
671
|
+
const rows = kinds.flatMap((kind) => kindRows.get(kind) ?? []);
|
|
672
|
+
const keyExists = db.prepare('SELECT 1 FROM intervention_evidence_records WHERE observation_key = ? LIMIT 1');
|
|
673
|
+
// correctionOf cites an evidenceId, not an observation_key (contract
|
|
674
|
+
// §correctionOf) — it needs its own existence probe.
|
|
675
|
+
const evidenceExists = db.prepare('SELECT 1 FROM intervention_evidence_records WHERE evidence_id = ? LIMIT 1');
|
|
676
|
+
const existingKeys = new Set();
|
|
677
|
+
const summaries = [];
|
|
678
|
+
for (const rawRow of rows) {
|
|
679
|
+
if (!isRecord(rawRow))
|
|
680
|
+
continue;
|
|
681
|
+
for (const column of ['delivery_key', 'episode_key', 'effect_key', 'correction_of']) {
|
|
682
|
+
const key = rawRow[column];
|
|
683
|
+
if (typeof key === 'string' && key.length > 0 && !existingKeys.has(key)) {
|
|
684
|
+
const hit = column === 'correction_of' ? evidenceExists.get(key) : keyExists.get(key);
|
|
685
|
+
if (isRecord(hit))
|
|
686
|
+
existingKeys.add(key);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
summaries.push(mapRowToSummary(rawRow, existingKeys));
|
|
690
|
+
}
|
|
691
|
+
const deliveries = [];
|
|
692
|
+
const applications = [];
|
|
693
|
+
const episodes = [];
|
|
694
|
+
const effects = [];
|
|
695
|
+
const outcomes = [];
|
|
696
|
+
const unresolvedReferences = [];
|
|
697
|
+
for (const summary of summaries) {
|
|
698
|
+
const { pendingFields, ...plain } = summary;
|
|
699
|
+
for (const pending of pendingFields) {
|
|
700
|
+
unresolvedReferences.push({ evidenceId: summary.evidenceId, missingKey: pending.missingKey, field: pending.field });
|
|
701
|
+
}
|
|
702
|
+
switch (summary.kind) {
|
|
703
|
+
case 'delivery':
|
|
704
|
+
deliveries.push(plain);
|
|
705
|
+
break;
|
|
706
|
+
case 'application':
|
|
707
|
+
applications.push(plain);
|
|
708
|
+
break;
|
|
709
|
+
case 'behavior_episode':
|
|
710
|
+
episodes.push(plain);
|
|
711
|
+
break;
|
|
712
|
+
case 'effect':
|
|
713
|
+
effects.push(plain);
|
|
714
|
+
break;
|
|
715
|
+
case 'outcome':
|
|
716
|
+
outcomes.push(plain);
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
available: true,
|
|
722
|
+
relations: {
|
|
723
|
+
selector,
|
|
724
|
+
deliveries,
|
|
725
|
+
applications,
|
|
726
|
+
episodes,
|
|
727
|
+
effects,
|
|
728
|
+
outcomes,
|
|
729
|
+
pages,
|
|
730
|
+
unresolvedReferences,
|
|
731
|
+
capabilityDeclarations: readCapabilityDeclarations(db),
|
|
732
|
+
asOf: new Date().toISOString(),
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
//# sourceMappingURL=sqlite-intervention-evidence-store.js.map
|