@dsh-enhanced/assistant-evolution 0.1.7 → 0.1.14
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 +58 -19
- package/cordis.patch.yml +3 -1
- package/lib/guidance.d.ts +2 -0
- package/lib/guidance.d.ts.map +1 -1
- package/lib/guidance.js +7 -1
- package/lib/guidance.js.map +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/review.d.ts +1 -1
- package/lib/review.d.ts.map +1 -1
- package/lib/review.js +22 -0
- package/lib/review.js.map +1 -1
- package/lib/service.d.ts +188 -6
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +903 -45
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +449 -7
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +98 -3
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +1493 -107
- package/lib/store.js.map +1 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +133 -2
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +192 -11
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/package.json +14 -4
package/lib/store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { openEvolutionDatabase } from './sqlite.js';
|
|
3
3
|
import { evolutionMutationReview } from './review.js';
|
|
4
|
-
import { legacyEvolutionScope } from './types.js';
|
|
4
|
+
import { legacyEvolutionScope, SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION, } from './types.js';
|
|
5
5
|
export class EvolutionStoreError extends Error {
|
|
6
6
|
code;
|
|
7
7
|
constructor(code, message) {
|
|
@@ -22,12 +22,39 @@ function episode(row) {
|
|
|
22
22
|
detail: row.detail,
|
|
23
23
|
source: row.source,
|
|
24
24
|
trust: row.trust,
|
|
25
|
+
evidenceKind: row.evidence_kind,
|
|
26
|
+
evidenceRef: row.evidence_ref ?? undefined,
|
|
27
|
+
learningSubjectRef: row.learning_subject_ref ?? undefined,
|
|
28
|
+
learningEligible: row.learning_eligible === 1,
|
|
25
29
|
ruleId: row.rule_id ?? undefined,
|
|
26
30
|
guidanceVersion: row.guidance_version ?? undefined,
|
|
27
31
|
claimedRuleId: row.claimed_rule_id ?? undefined,
|
|
28
32
|
occurredAt: row.occurred_at,
|
|
29
33
|
});
|
|
30
34
|
}
|
|
35
|
+
function exactEpisodePayload(row, input, allowCompatibleQuarantinedReplay = false) {
|
|
36
|
+
const compatibleQuarantinedReplay = allowCompatibleQuarantinedReplay
|
|
37
|
+
&& row.evidence_kind === 'legacy-unknown'
|
|
38
|
+
&& row.evidence_ref === null
|
|
39
|
+
&& row.learning_eligible === 0
|
|
40
|
+
&& input.evidenceKind === 'operational'
|
|
41
|
+
&& input.evidenceRef === undefined
|
|
42
|
+
&& input.learningEligible === 0;
|
|
43
|
+
return row.scope_key === input.scopeKey
|
|
44
|
+
&& row.situation === input.situation
|
|
45
|
+
&& row.outcome === input.outcome
|
|
46
|
+
&& row.detail === input.detail
|
|
47
|
+
&& row.source === input.source
|
|
48
|
+
&& row.trust === input.trust
|
|
49
|
+
&& (row.evidence_kind === input.evidenceKind || compatibleQuarantinedReplay)
|
|
50
|
+
&& (row.evidence_ref ?? undefined) === input.evidenceRef
|
|
51
|
+
&& (row.learning_subject_ref ?? undefined) === input.learningSubjectRef
|
|
52
|
+
&& (row.learning_eligible === input.learningEligible || compatibleQuarantinedReplay)
|
|
53
|
+
&& (row.rule_id ?? undefined) === input.ruleId
|
|
54
|
+
&& (row.guidance_version ?? undefined) === input.guidanceVersion
|
|
55
|
+
&& (row.claimed_rule_id ?? undefined) === input.claimedRuleId
|
|
56
|
+
&& row.occurred_at === input.occurredAt;
|
|
57
|
+
}
|
|
31
58
|
function rule(row) {
|
|
32
59
|
return Object.freeze({
|
|
33
60
|
id: row.id,
|
|
@@ -44,6 +71,57 @@ function rule(row) {
|
|
|
44
71
|
generation: row.generation,
|
|
45
72
|
});
|
|
46
73
|
}
|
|
74
|
+
function approvalDispatch(input, errorCode, allowLegacy) {
|
|
75
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
76
|
+
throw new EvolutionStoreError(errorCode, 'Policy dispatch is invalid');
|
|
77
|
+
}
|
|
78
|
+
const route = input;
|
|
79
|
+
const keys = Object.keys(route).sort().join(',');
|
|
80
|
+
const legacyKeys = 'bindingId,principal,sourceId,workspace';
|
|
81
|
+
const versionedLegacyKeys = 'bindingId,principal,routeVersion,sourceId,workspace';
|
|
82
|
+
const v2Keys = [
|
|
83
|
+
'bindingGeneration', 'bindingId', 'bindingVersion', 'principal', 'principalRecordId',
|
|
84
|
+
'principalVersion', 'routeVersion', 'sourceId', 'workspace',
|
|
85
|
+
].sort().join(',');
|
|
86
|
+
const legacy = keys === legacyKeys || (keys === versionedLegacyKeys && route['routeVersion'] === 1);
|
|
87
|
+
const v2 = keys === v2Keys && route['routeVersion'] === 2
|
|
88
|
+
&& typeof route['bindingVersion'] === 'number'
|
|
89
|
+
&& Number.isSafeInteger(route['bindingVersion']) && route['bindingVersion'] > 0
|
|
90
|
+
&& typeof route['bindingGeneration'] === 'number'
|
|
91
|
+
&& Number.isSafeInteger(route['bindingGeneration']) && route['bindingGeneration'] > 0
|
|
92
|
+
&& typeof route['principalRecordId'] === 'string'
|
|
93
|
+
&& route['principalRecordId'].trim() !== ''
|
|
94
|
+
&& Buffer.byteLength(route['principalRecordId'], 'utf8') <= 500
|
|
95
|
+
&& typeof route['principalVersion'] === 'number'
|
|
96
|
+
&& Number.isSafeInteger(route['principalVersion']) && route['principalVersion'] > 0;
|
|
97
|
+
if ((!v2 && !(allowLegacy && legacy))
|
|
98
|
+
|| typeof route['sourceId'] !== 'string' || route['sourceId'].trim() === ''
|
|
99
|
+
|| typeof route['bindingId'] !== 'string' || route['bindingId'].trim() === ''
|
|
100
|
+
|| typeof route['workspace'] !== 'string' || route['workspace'].trim() === ''
|
|
101
|
+
|| typeof route['principal'] !== 'string' || route['principal'].trim() === '') {
|
|
102
|
+
throw new EvolutionStoreError(errorCode, 'Policy dispatch is invalid');
|
|
103
|
+
}
|
|
104
|
+
return Object.freeze(input);
|
|
105
|
+
}
|
|
106
|
+
function creationIntent(value) {
|
|
107
|
+
if (value === null)
|
|
108
|
+
return undefined;
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(value);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new EvolutionStoreError('invalid-state', 'stored Policy creation intent is invalid');
|
|
115
|
+
}
|
|
116
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
117
|
+
throw new EvolutionStoreError('invalid-state', 'stored Policy creation intent is invalid');
|
|
118
|
+
}
|
|
119
|
+
const intent = parsed;
|
|
120
|
+
if (Object.hasOwn(intent, 'dispatch') && intent['dispatch'] !== undefined) {
|
|
121
|
+
intent['dispatch'] = approvalDispatch(intent['dispatch'], 'invalid-state', true);
|
|
122
|
+
}
|
|
123
|
+
return Object.freeze(parsed);
|
|
124
|
+
}
|
|
47
125
|
function proposal(row) {
|
|
48
126
|
return Object.freeze({
|
|
49
127
|
proposalId: row.id,
|
|
@@ -54,9 +132,7 @@ function proposal(row) {
|
|
|
54
132
|
scopeKey: row.scope_key,
|
|
55
133
|
mutationHash: row.mutation_hash,
|
|
56
134
|
mutation: JSON.parse(row.mutation_json),
|
|
57
|
-
creationIntent: row.creation_intent_json
|
|
58
|
-
? undefined
|
|
59
|
-
: JSON.parse(row.creation_intent_json),
|
|
135
|
+
creationIntent: creationIntent(row.creation_intent_json),
|
|
60
136
|
status: row.status,
|
|
61
137
|
expiresAt: row.expires_at,
|
|
62
138
|
resultRuleId: row.result_rule_id ?? undefined,
|
|
@@ -104,6 +180,82 @@ function autonomousRollback(row, situation) {
|
|
|
104
180
|
occurredAt: row.occurred_at,
|
|
105
181
|
});
|
|
106
182
|
}
|
|
183
|
+
function taskLearningProjection(row) {
|
|
184
|
+
return Object.freeze({
|
|
185
|
+
scopeKey: row.scope_key,
|
|
186
|
+
scopeWatermark: row.scope_watermark,
|
|
187
|
+
subjectKind: row.subject_kind,
|
|
188
|
+
subjectRef: row.subject_ref,
|
|
189
|
+
version: row.version,
|
|
190
|
+
digest: row.digest,
|
|
191
|
+
disposition: row.disposition,
|
|
192
|
+
situation: row.situation,
|
|
193
|
+
...(row.episode_id === null ? {} : { episodeId: row.episode_id }),
|
|
194
|
+
updatedAt: row.updated_at,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function supervisedGrowthAnalystReview(row) {
|
|
198
|
+
const evidence = JSON.parse(row.evidence_json);
|
|
199
|
+
return Object.freeze({
|
|
200
|
+
reviewToken: row.review_token,
|
|
201
|
+
scopeKey: row.scope_key,
|
|
202
|
+
occurrenceId: row.occurrence_id,
|
|
203
|
+
evidence: Object.freeze({
|
|
204
|
+
contractVersion: row.contract_version,
|
|
205
|
+
situation: row.situation,
|
|
206
|
+
failures: row.failures,
|
|
207
|
+
total: row.total,
|
|
208
|
+
evidenceDigest: row.evidence_digest,
|
|
209
|
+
evidenceTotal: row.evidence_total,
|
|
210
|
+
evidenceWindow: row.evidence_window,
|
|
211
|
+
sampleEpisodeIds: Object.freeze(JSON.parse(row.sample_episode_ids_json)),
|
|
212
|
+
evidence: Object.freeze(evidence.map(entry => Object.freeze({ ...entry }))),
|
|
213
|
+
scopeWatermark: row.scope_watermark,
|
|
214
|
+
taskRevisions: Object.freeze(JSON.parse(row.task_revisions_json).map(entry => Object.freeze({ ...entry }))),
|
|
215
|
+
}),
|
|
216
|
+
...(row.proposal_id === null ? {} : { proposalId: row.proposal_id }),
|
|
217
|
+
createdAt: row.created_at,
|
|
218
|
+
...(row.proposed_at === null ? {} : { proposedAt: row.proposed_at }),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function evolutionApplicationReceipt(row) {
|
|
222
|
+
return Object.freeze({
|
|
223
|
+
localProposalId: row.local_proposal_id,
|
|
224
|
+
policyProposalId: row.policy_proposal_id,
|
|
225
|
+
applicationStatus: row.application_status,
|
|
226
|
+
operation: row.operation,
|
|
227
|
+
terminalAt: row.terminal_at,
|
|
228
|
+
receiptDigest: row.receipt_digest,
|
|
229
|
+
revision: row.revision,
|
|
230
|
+
...(row.rule_id === null ? {} : { ruleId: row.rule_id }),
|
|
231
|
+
...(row.resulting_rule_version === null
|
|
232
|
+
? {} : { resultingRuleVersion: row.resulting_rule_version }),
|
|
233
|
+
...(row.rule_status === null ? {} : { ruleStatus: row.rule_status }),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function evolutionApplicationOutbox(row) {
|
|
237
|
+
return Object.freeze({
|
|
238
|
+
receipt: evolutionApplicationReceipt(row),
|
|
239
|
+
state: row.state,
|
|
240
|
+
attemptCount: row.attempt_count,
|
|
241
|
+
updatedAt: row.updated_at,
|
|
242
|
+
...(row.published_at === null ? {} : { publishedAt: row.published_at }),
|
|
243
|
+
...(row.last_error === null ? {} : { lastError: row.last_error }),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Stable analyst proposal identity. Deliberately do not add guidance, owner,
|
|
248
|
+
* execution, token, TTL, samples or timestamps: those are not the learned fact.
|
|
249
|
+
*/
|
|
250
|
+
export function supervisedGrowthAnalystProposalIdempotencyKey(input) {
|
|
251
|
+
return `evolution-analyst:${digest([
|
|
252
|
+
input.scopeKey,
|
|
253
|
+
input.situation,
|
|
254
|
+
input.evidenceDigest,
|
|
255
|
+
input.evidenceTotal,
|
|
256
|
+
input.contractVersion,
|
|
257
|
+
])}`;
|
|
258
|
+
}
|
|
107
259
|
/**
|
|
108
260
|
* Durable evidence and rule ledger.
|
|
109
261
|
*
|
|
@@ -125,6 +277,7 @@ export class EvolutionStore {
|
|
|
125
277
|
this.#maxSituationBytes = options.maxSituationBytes ?? 200;
|
|
126
278
|
this.#maxGuidanceBytes = options.maxGuidanceBytes ?? 2_048;
|
|
127
279
|
this.#maxDetailBytes = options.maxDetailBytes ?? 1_024;
|
|
280
|
+
this.#backfillEvolutionApplicationReceipts();
|
|
128
281
|
}
|
|
129
282
|
close() {
|
|
130
283
|
this.#database.close();
|
|
@@ -141,11 +294,42 @@ export class EvolutionStore {
|
|
|
141
294
|
(SELECT COUNT(*) FROM evolution_proposals WHERE status = 'pending') AS pending_proposals,
|
|
142
295
|
(SELECT COUNT(*) FROM evolution_proposals WHERE status = 'conflicted') AS conflicted_proposals,
|
|
143
296
|
(SELECT COUNT(*) FROM evolution_episodes WHERE trust = 'trusted') AS trusted_episodes,
|
|
297
|
+
(SELECT COUNT(*) FROM evolution_episodes
|
|
298
|
+
WHERE learning_eligible = 1) AS quality_eligible_episodes,
|
|
299
|
+
(SELECT COUNT(*) FROM evolution_episodes
|
|
300
|
+
WHERE evidence_kind = 'operational') AS operational_episodes,
|
|
301
|
+
(SELECT COUNT(*) FROM evolution_episodes
|
|
302
|
+
WHERE evidence_kind = 'legacy-unknown') AS legacy_quarantined_episodes,
|
|
144
303
|
(SELECT COUNT(*) FROM evolution_episodes
|
|
145
304
|
WHERE trust = 'trusted' AND rule_id IS NULL) AS unattributed_trusted_episodes,
|
|
305
|
+
(SELECT COUNT(*) FROM evolution_episodes
|
|
306
|
+
WHERE learning_eligible = 1 AND rule_id IS NULL) AS unattributed_quality_eligible_episodes,
|
|
146
307
|
(SELECT COALESCE(MAX(occurred_at), 0) FROM evolution_episodes
|
|
147
308
|
WHERE trust = 'trusted') AS last_trusted_episode_at,
|
|
309
|
+
(SELECT COALESCE(MAX(occurred_at), 0) FROM evolution_episodes
|
|
310
|
+
WHERE learning_eligible = 1) AS last_quality_eligible_episode_at,
|
|
148
311
|
(SELECT COUNT(*) FROM evolution_autonomous_rollbacks) AS autonomous_rollbacks
|
|
312
|
+
,(SELECT COUNT(*) FROM evolution_task_learning_state) AS task_learning_projections
|
|
313
|
+
,(SELECT COUNT(*) FROM evolution_task_learning_state
|
|
314
|
+
WHERE disposition = 'retract') AS retracted_task_learning_projections
|
|
315
|
+
,(SELECT COUNT(*) FROM evolution_task_learning_revisions) AS task_learning_projection_revisions
|
|
316
|
+
,(SELECT COUNT(*)
|
|
317
|
+
FROM evolution_task_learning_state state
|
|
318
|
+
LEFT JOIN evolution_task_learning_revisions revision
|
|
319
|
+
ON revision.scope_key = state.scope_key
|
|
320
|
+
AND revision.subject_kind = state.subject_kind
|
|
321
|
+
AND revision.subject_ref = state.subject_ref
|
|
322
|
+
AND revision.version = state.version
|
|
323
|
+
LEFT JOIN evolution_episodes episode ON episode.id = state.episode_id
|
|
324
|
+
WHERE revision.version IS NULL OR revision.digest <> state.digest
|
|
325
|
+
OR revision.disposition <> state.disposition
|
|
326
|
+
OR revision.situation <> state.situation
|
|
327
|
+
OR NOT (revision.episode_id IS state.episode_id)
|
|
328
|
+
OR (state.disposition = 'upsert' AND (
|
|
329
|
+
episode.id IS NULL OR episode.learning_eligible <> 1
|
|
330
|
+
OR episode.scope_key <> state.scope_key))
|
|
331
|
+
OR (state.disposition = 'retract' AND state.episode_id IS NOT NULL)
|
|
332
|
+
) AS task_learning_projection_integrity_errors
|
|
149
333
|
`).get();
|
|
150
334
|
return Object.freeze({
|
|
151
335
|
activeRules: row.active_rules,
|
|
@@ -153,9 +337,18 @@ export class EvolutionStore {
|
|
|
153
337
|
pendingProposals: row.pending_proposals,
|
|
154
338
|
conflictedProposals: row.conflicted_proposals,
|
|
155
339
|
trustedEpisodes: row.trusted_episodes,
|
|
340
|
+
qualityEligibleEpisodes: row.quality_eligible_episodes,
|
|
341
|
+
operationalEpisodes: row.operational_episodes,
|
|
342
|
+
legacyQuarantinedEpisodes: row.legacy_quarantined_episodes,
|
|
156
343
|
unattributedTrustedEpisodes: row.unattributed_trusted_episodes,
|
|
344
|
+
unattributedQualityEligibleEpisodes: row.unattributed_quality_eligible_episodes,
|
|
157
345
|
lastTrustedEpisodeAt: row.last_trusted_episode_at,
|
|
346
|
+
lastQualityEligibleEpisodeAt: row.last_quality_eligible_episode_at,
|
|
158
347
|
autonomousRollbacks: row.autonomous_rollbacks,
|
|
348
|
+
taskLearningProjections: row.task_learning_projections,
|
|
349
|
+
retractedTaskLearningProjections: row.retracted_task_learning_projections,
|
|
350
|
+
taskLearningProjectionRevisions: row.task_learning_projection_revisions,
|
|
351
|
+
taskLearningProjectionIntegrityErrors: row.task_learning_projection_integrity_errors,
|
|
159
352
|
});
|
|
160
353
|
}
|
|
161
354
|
/** Record one observed outcome. Idempotent on `idempotencyKey`. */
|
|
@@ -170,9 +363,36 @@ export class EvolutionStore {
|
|
|
170
363
|
if (!['trusted', 'self-reported', 'legacy'].includes(input.trust)) {
|
|
171
364
|
throw new EvolutionStoreError('invalid-input', 'trust is invalid');
|
|
172
365
|
}
|
|
366
|
+
if (!['operational', 'objective', 'verification', 'legacy-unknown'].includes(input.evidenceKind)) {
|
|
367
|
+
throw new EvolutionStoreError('invalid-input', 'evidenceKind is invalid');
|
|
368
|
+
}
|
|
173
369
|
if (scopeKey === legacyEvolutionScope && input.trust !== 'legacy') {
|
|
174
370
|
throw new EvolutionStoreError('invalid-input', 'legacy scope may only contain quarantined evidence');
|
|
175
371
|
}
|
|
372
|
+
if (input.trust === 'legacy' && input.evidenceKind !== 'legacy-unknown') {
|
|
373
|
+
throw new EvolutionStoreError('invalid-input', 'legacy evidence must remain quarantined');
|
|
374
|
+
}
|
|
375
|
+
const evidenceRef = input.evidenceRef === undefined
|
|
376
|
+
? undefined
|
|
377
|
+
: this.#opaque(input.evidenceRef, 'evidenceRef', 500);
|
|
378
|
+
const learningSubjectRef = input.learningSubjectRef === undefined
|
|
379
|
+
? undefined
|
|
380
|
+
: this.#opaque(input.learningSubjectRef, 'learningSubjectRef', 1_000);
|
|
381
|
+
const isQualityKind = input.evidenceKind === 'objective' || input.evidenceKind === 'verification';
|
|
382
|
+
if ((input.source === 'evaluation') !== isQualityKind) {
|
|
383
|
+
throw new EvolutionStoreError('invalid-input', 'learning-eligible quality evidence must use authoritative Evaluation provenance');
|
|
384
|
+
}
|
|
385
|
+
if (isQualityKind && (input.trust !== 'trusted' || evidenceRef === undefined
|
|
386
|
+
|| learningSubjectRef === undefined)) {
|
|
387
|
+
throw new EvolutionStoreError('invalid-input', 'quality evidence requires trusted attribution, an Evaluation reference, and a learning subject');
|
|
388
|
+
}
|
|
389
|
+
if (!isQualityKind && evidenceRef !== undefined) {
|
|
390
|
+
throw new EvolutionStoreError('invalid-input', 'only quality evidence may carry an Evaluation reference');
|
|
391
|
+
}
|
|
392
|
+
if (!isQualityKind && learningSubjectRef !== undefined) {
|
|
393
|
+
throw new EvolutionStoreError('invalid-input', 'only quality evidence may carry a learning subject');
|
|
394
|
+
}
|
|
395
|
+
const learningEligible = isQualityKind ? 1 : 0;
|
|
176
396
|
const trustedRuleId = input.trust === 'trusted' && input.ruleId !== undefined
|
|
177
397
|
? this.#bounded(input.ruleId, 'ruleId', 200)
|
|
178
398
|
: undefined;
|
|
@@ -189,29 +409,363 @@ export class EvolutionStore {
|
|
|
189
409
|
: (input.claimedRuleId ?? input.ruleId) === undefined
|
|
190
410
|
? undefined
|
|
191
411
|
: this.#bounded((input.claimedRuleId ?? input.ruleId), 'claimedRuleId', 200);
|
|
412
|
+
const normalized = {
|
|
413
|
+
scopeKey,
|
|
414
|
+
situation,
|
|
415
|
+
outcome: input.outcome,
|
|
416
|
+
detail,
|
|
417
|
+
source: input.source,
|
|
418
|
+
trust: input.trust,
|
|
419
|
+
evidenceKind: input.evidenceKind,
|
|
420
|
+
evidenceRef,
|
|
421
|
+
learningSubjectRef,
|
|
422
|
+
learningEligible,
|
|
423
|
+
ruleId: trustedRuleId,
|
|
424
|
+
guidanceVersion,
|
|
425
|
+
claimedRuleId,
|
|
426
|
+
occurredAt: input.occurredAt,
|
|
427
|
+
};
|
|
192
428
|
const id = `episode-${randomUUID()}`;
|
|
193
429
|
this.#database.prepare(`
|
|
194
430
|
INSERT INTO evolution_episodes(
|
|
195
431
|
id, idempotency_key, scope_key, situation, outcome, detail, source,
|
|
196
|
-
trust,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
432
|
+
trust, evidence_kind, evidence_ref, learning_subject_ref, learning_eligible,
|
|
433
|
+
rule_id, guidance_version, claimed_rule_id, occurred_at)
|
|
434
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
435
|
+
ON CONFLICT DO NOTHING
|
|
436
|
+
`).run(id, key, scopeKey, situation, input.outcome, detail, input.source, input.trust, input.evidenceKind, evidenceRef ?? null, learningSubjectRef ?? null, learningEligible, trustedRuleId ?? null, guidanceVersion ?? null, claimedRuleId ?? null, input.occurredAt);
|
|
437
|
+
const idempotencyWinner = this.#database.prepare('SELECT * FROM evolution_episodes WHERE idempotency_key = ?').get(key);
|
|
438
|
+
if (idempotencyWinner !== undefined) {
|
|
439
|
+
// Replay must be exact; a reused key with different content is a caller bug.
|
|
440
|
+
if (!exactEpisodePayload(idempotencyWinner, normalized, true)) {
|
|
441
|
+
throw new EvolutionStoreError('idempotency-conflict', 'episode idempotency key was reused with different content');
|
|
442
|
+
}
|
|
443
|
+
return episode(idempotencyWinner);
|
|
444
|
+
}
|
|
445
|
+
if (learningEligible === 1) {
|
|
446
|
+
const subjectWinner = this.#database.prepare(`
|
|
447
|
+
SELECT * FROM evolution_episodes
|
|
448
|
+
WHERE scope_key = ? AND learning_subject_ref = ?
|
|
449
|
+
AND learning_eligible = 1
|
|
450
|
+
`).get(scopeKey, learningSubjectRef);
|
|
451
|
+
if (subjectWinner !== undefined) {
|
|
452
|
+
const exactEvaluationReplay = subjectWinner.evidence_ref === evidenceRef;
|
|
453
|
+
const matches = exactEvaluationReplay
|
|
454
|
+
? exactEpisodePayload(subjectWinner, normalized)
|
|
455
|
+
: this.#sameLearningSubjectResult(subjectWinner, normalized);
|
|
456
|
+
if (!matches) {
|
|
457
|
+
throw new EvolutionStoreError('idempotency-conflict', 'immutable learning subject was assessed with a contradictory result');
|
|
458
|
+
}
|
|
459
|
+
return episode(subjectWinner);
|
|
460
|
+
}
|
|
461
|
+
const evidenceWinner = this.#database.prepare(`
|
|
462
|
+
SELECT * FROM evolution_episodes
|
|
463
|
+
WHERE scope_key = ? AND evidence_ref = ?
|
|
464
|
+
AND learning_eligible = 1
|
|
465
|
+
`).get(scopeKey, evidenceRef);
|
|
466
|
+
if (evidenceWinner !== undefined) {
|
|
467
|
+
if (!exactEpisodePayload(evidenceWinner, normalized)) {
|
|
468
|
+
throw new EvolutionStoreError('idempotency-conflict', 'immutable quality evidence reference was reused with different content');
|
|
469
|
+
}
|
|
470
|
+
return episode(evidenceWinner);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
throw new EvolutionStoreError('invalid-state', 'episode insert did not produce an exact idempotency or evidence winner');
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Apply one authoritative Evaluation task revision. The current pointer,
|
|
477
|
+
* immutable revision audit, active learning vote and stale-proposal fence are
|
|
478
|
+
* committed under one SQLite writer lock.
|
|
479
|
+
*
|
|
480
|
+
* The first accepted revision may be greater than one because several old
|
|
481
|
+
* Evaluation outbox triggers all resolve to the latest canonical task. A
|
|
482
|
+
* lower revision is never acknowledged: it indicates an Evaluation rollback
|
|
483
|
+
* or a mismatched database restore and must remain visible to recovery.
|
|
484
|
+
*/
|
|
485
|
+
applyTaskLearningProjection(input) {
|
|
486
|
+
const scopeKey = this.#scopeKey(input.scopeKey);
|
|
487
|
+
const subjectKind = input.subjectKind;
|
|
488
|
+
if (subjectKind !== 'automation-run' && subjectKind !== 'outcome') {
|
|
489
|
+
throw new EvolutionStoreError('invalid-input', 'task learning subjectKind is invalid');
|
|
490
|
+
}
|
|
491
|
+
const subjectRef = this.#opaque(input.subjectRef, 'subjectRef', 1_000);
|
|
492
|
+
if (!Number.isSafeInteger(input.version) || input.version < 1
|
|
493
|
+
|| input.version > 1_000_000_000) {
|
|
494
|
+
throw new EvolutionStoreError('invalid-input', 'task learning version must be a positive safe integer');
|
|
495
|
+
}
|
|
496
|
+
if (!Number.isSafeInteger(input.scopeWatermark) || input.scopeWatermark < 1) {
|
|
497
|
+
throw new EvolutionStoreError('invalid-input', 'task learning scope watermark must be a positive safe integer');
|
|
498
|
+
}
|
|
499
|
+
const projectionDigest = this.#bounded(input.digest, 'digest', 64);
|
|
500
|
+
if (!/^[a-f\d]{64}$/u.test(projectionDigest)) {
|
|
501
|
+
throw new EvolutionStoreError('invalid-input', 'task learning digest must be lowercase SHA-256');
|
|
502
|
+
}
|
|
503
|
+
const situation = this.#situation(input.situation);
|
|
504
|
+
if (!Number.isSafeInteger(input.occurredAt) || input.occurredAt < 0) {
|
|
505
|
+
throw new EvolutionStoreError('invalid-input', 'task learning occurredAt is invalid');
|
|
506
|
+
}
|
|
507
|
+
if (input.disposition !== 'upsert' && input.disposition !== 'retract') {
|
|
508
|
+
throw new EvolutionStoreError('invalid-input', 'task learning disposition is invalid');
|
|
509
|
+
}
|
|
510
|
+
let result;
|
|
511
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
512
|
+
try {
|
|
513
|
+
const scopeWatermark = this.#database.prepare(`
|
|
514
|
+
SELECT watermark FROM evolution_scope_learning_watermarks WHERE scope_key = ?
|
|
515
|
+
`).get(scopeKey);
|
|
516
|
+
if (scopeWatermark !== undefined && input.scopeWatermark < scopeWatermark.watermark) {
|
|
517
|
+
throw new EvolutionStoreError('version-conflict', 'task learning receipt carries an older Evaluation scope watermark');
|
|
518
|
+
}
|
|
519
|
+
const current = this.#database.prepare(`
|
|
520
|
+
SELECT * FROM evolution_task_learning_state
|
|
521
|
+
WHERE scope_key = ? AND subject_kind = ? AND subject_ref = ?
|
|
522
|
+
`).get(scopeKey, subjectKind, subjectRef);
|
|
523
|
+
if (current !== undefined)
|
|
524
|
+
this.#assertTaskLearningState(current);
|
|
525
|
+
if (current !== undefined && input.version < current.version) {
|
|
526
|
+
throw new EvolutionStoreError('version-conflict', 'task learning revision is older than the current authoritative state');
|
|
527
|
+
}
|
|
528
|
+
if (current !== undefined && input.version === current.version) {
|
|
529
|
+
if (current.digest !== projectionDigest || current.disposition !== input.disposition
|
|
530
|
+
|| current.situation !== situation) {
|
|
531
|
+
throw new EvolutionStoreError('idempotency-conflict', 'task learning version was reused with different canonical content');
|
|
532
|
+
}
|
|
533
|
+
this.#recordScopeLearningWatermark(scopeKey, input.scopeWatermark, this.#now());
|
|
534
|
+
this.#database.prepare(`
|
|
535
|
+
UPDATE evolution_task_learning_state SET scope_watermark = ?
|
|
536
|
+
WHERE scope_key = ? AND subject_kind = ? AND subject_ref = ?
|
|
537
|
+
AND scope_watermark <= ?
|
|
538
|
+
`).run(input.scopeWatermark, scopeKey, subjectKind, subjectRef, input.scopeWatermark);
|
|
539
|
+
const replayState = this.#database.prepare(`
|
|
540
|
+
SELECT * FROM evolution_task_learning_state
|
|
541
|
+
WHERE scope_key = ? AND subject_kind = ? AND subject_ref = ?
|
|
542
|
+
`).get(scopeKey, subjectKind, subjectRef);
|
|
543
|
+
const currentEpisode = replayState.episode_id === null
|
|
544
|
+
? undefined
|
|
545
|
+
: this.#database.prepare('SELECT * FROM evolution_episodes WHERE id = ?')
|
|
546
|
+
.get(replayState.episode_id);
|
|
547
|
+
result = Object.freeze({
|
|
548
|
+
projection: taskLearningProjection(replayState),
|
|
549
|
+
...(currentEpisode === undefined ? {} : { episode: episode(currentEpisode) }),
|
|
550
|
+
replayed: true,
|
|
551
|
+
});
|
|
552
|
+
this.#database.exec('COMMIT');
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
555
|
+
if (current?.episode_id !== null && current?.episode_id !== undefined) {
|
|
556
|
+
const invalidated = this.#database.prepare(`
|
|
557
|
+
UPDATE evolution_episodes
|
|
558
|
+
SET learning_subject_ref = NULL, learning_eligible = 0
|
|
559
|
+
WHERE id = ? AND scope_key = ? AND learning_eligible = 1
|
|
560
|
+
`).run(current.episode_id, scopeKey);
|
|
561
|
+
if (invalidated.changes !== 1) {
|
|
562
|
+
throw new EvolutionStoreError('invalid-state', 'current task learning vote could not be retracted');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const now = this.#now();
|
|
566
|
+
let storedEpisode;
|
|
567
|
+
if (input.disposition === 'upsert') {
|
|
568
|
+
const identity = digest([
|
|
569
|
+
'evaluation-task-revision/v1', scopeKey, subjectKind, subjectRef,
|
|
570
|
+
input.version, projectionDigest,
|
|
571
|
+
]);
|
|
572
|
+
storedEpisode = this.recordEpisode({
|
|
573
|
+
scopeKey,
|
|
574
|
+
situation,
|
|
575
|
+
outcome: input.outcome,
|
|
576
|
+
detail: input.detail,
|
|
577
|
+
source: 'evaluation',
|
|
578
|
+
trust: 'trusted',
|
|
579
|
+
evidenceKind: 'objective',
|
|
580
|
+
evidenceRef: input.evidenceRef,
|
|
581
|
+
learningSubjectRef: JSON.stringify([subjectKind, subjectRef]),
|
|
582
|
+
occurredAt: input.occurredAt,
|
|
583
|
+
idempotencyKey: `evaluation-task-revision:${identity}`,
|
|
584
|
+
...(input.ruleId === undefined ? {} : { ruleId: input.ruleId }),
|
|
585
|
+
...(input.guidanceVersion === undefined
|
|
586
|
+
? {} : { guidanceVersion: input.guidanceVersion }),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
this.#database.prepare(`
|
|
590
|
+
INSERT INTO evolution_task_learning_revisions(
|
|
591
|
+
scope_key, scope_watermark, subject_kind, subject_ref, version, digest, disposition,
|
|
592
|
+
situation, episode_id, applied_at)
|
|
593
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
594
|
+
`).run(scopeKey, input.scopeWatermark, subjectKind, subjectRef, input.version, projectionDigest, input.disposition, situation, storedEpisode?.id ?? null, now);
|
|
595
|
+
this.#database.prepare(`
|
|
596
|
+
INSERT INTO evolution_task_learning_state(
|
|
597
|
+
scope_key, scope_watermark, subject_kind, subject_ref, version, digest, disposition,
|
|
598
|
+
situation, episode_id, updated_at)
|
|
599
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
600
|
+
ON CONFLICT(scope_key, subject_kind, subject_ref) DO UPDATE SET
|
|
601
|
+
scope_watermark = excluded.scope_watermark,
|
|
602
|
+
version = excluded.version,
|
|
603
|
+
digest = excluded.digest,
|
|
604
|
+
disposition = excluded.disposition,
|
|
605
|
+
situation = excluded.situation,
|
|
606
|
+
episode_id = excluded.episode_id,
|
|
607
|
+
updated_at = excluded.updated_at
|
|
608
|
+
`).run(scopeKey, input.scopeWatermark, subjectKind, subjectRef, input.version, projectionDigest, input.disposition, situation, storedEpisode?.id ?? null, now);
|
|
609
|
+
this.#recordScopeLearningWatermark(scopeKey, input.scopeWatermark, now);
|
|
610
|
+
// Any evidence-dependent card for this exact scoped situation was built
|
|
611
|
+
// from an older complete window. Fence it before a later Policy decision
|
|
612
|
+
// can apply stale guidance. Owner-undo is independent of learning data.
|
|
613
|
+
const affected = this.#database.prepare(`
|
|
614
|
+
SELECT * FROM evolution_proposals
|
|
615
|
+
WHERE status = 'pending' AND scope_key = ?
|
|
616
|
+
AND json_extract(mutation_json, '$.op') IN ('adopt', 'retire')
|
|
617
|
+
AND COALESCE(
|
|
618
|
+
json_extract(mutation_json, '$.input.situation'),
|
|
619
|
+
json_extract(mutation_json, '$.situation')
|
|
620
|
+
) = ?
|
|
621
|
+
ORDER BY id
|
|
622
|
+
`).all(scopeKey, situation);
|
|
623
|
+
this.#database.prepare(`
|
|
624
|
+
UPDATE evolution_proposals
|
|
625
|
+
SET status = 'conflicted', version = version + 1, updated_at = ?
|
|
626
|
+
WHERE status = 'pending' AND scope_key = ?
|
|
627
|
+
AND json_extract(mutation_json, '$.op') IN ('adopt', 'retire')
|
|
628
|
+
AND COALESCE(
|
|
629
|
+
json_extract(mutation_json, '$.input.situation'),
|
|
630
|
+
json_extract(mutation_json, '$.situation')
|
|
631
|
+
) = ?
|
|
632
|
+
`).run(now, scopeKey, situation);
|
|
633
|
+
for (const pending of affected) {
|
|
634
|
+
const terminal = this.#requireProposal(pending.id);
|
|
635
|
+
this.#recordEvolutionApplicationReceipt(terminal);
|
|
636
|
+
}
|
|
637
|
+
// A correction to an exact subject that justified an already-applied
|
|
638
|
+
// local rule wins after that legal linearization point as well. Retire
|
|
639
|
+
// only rules whose frozen full tuple contained this subject; unrelated
|
|
640
|
+
// new evidence does not churn active guidance.
|
|
641
|
+
this.#retireRulesDependingOnTaskRevision({
|
|
642
|
+
scopeKey,
|
|
643
|
+
subjectKind,
|
|
644
|
+
subjectRef,
|
|
645
|
+
now,
|
|
646
|
+
});
|
|
647
|
+
const saved = this.#database.prepare(`
|
|
648
|
+
SELECT * FROM evolution_task_learning_state
|
|
649
|
+
WHERE scope_key = ? AND subject_kind = ? AND subject_ref = ?
|
|
650
|
+
`).get(scopeKey, subjectKind, subjectRef);
|
|
651
|
+
this.#assertTaskLearningState(saved);
|
|
652
|
+
result = Object.freeze({
|
|
653
|
+
projection: taskLearningProjection(saved),
|
|
654
|
+
...(storedEpisode === undefined ? {} : { episode: storedEpisode }),
|
|
655
|
+
replayed: false,
|
|
656
|
+
});
|
|
657
|
+
this.#database.exec('COMMIT');
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
this.#database.exec('ROLLBACK');
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
getTaskLearningProjection(input) {
|
|
666
|
+
const row = this.#database.prepare(`
|
|
667
|
+
SELECT * FROM evolution_task_learning_state
|
|
668
|
+
WHERE scope_key = ? AND subject_kind = ? AND subject_ref = ?
|
|
669
|
+
`).get(this.#scopeKey(input.scopeKey), input.subjectKind, this.#opaque(input.subjectRef, 'subjectRef', 1_000));
|
|
670
|
+
if (row === undefined)
|
|
671
|
+
return undefined;
|
|
672
|
+
this.#assertTaskLearningState(row);
|
|
673
|
+
return taskLearningProjection(row);
|
|
674
|
+
}
|
|
675
|
+
#sameLearningSubjectResult(row, input) {
|
|
676
|
+
// evidenceRef, detail, and evidenceKind are deliberately absent: several
|
|
677
|
+
// immutable Evaluation rows (for example objective + verification) may
|
|
678
|
+
// independently reach the same behavioural result for one immutable run.
|
|
679
|
+
// The first row remains the provenance anchor; behavioural identity,
|
|
680
|
+
// attribution, outcome, and run time must still match.
|
|
681
|
+
return row.scope_key === input.scopeKey
|
|
682
|
+
&& row.learning_subject_ref === input.learningSubjectRef
|
|
683
|
+
&& row.situation === input.situation
|
|
684
|
+
&& row.outcome === input.outcome
|
|
685
|
+
&& row.source === input.source
|
|
686
|
+
&& row.trust === input.trust
|
|
687
|
+
&& row.learning_eligible === input.learningEligible
|
|
688
|
+
&& (row.rule_id ?? undefined) === input.ruleId
|
|
689
|
+
&& (row.guidance_version ?? undefined) === input.guidanceVersion
|
|
690
|
+
&& (row.claimed_rule_id ?? undefined) === input.claimedRuleId
|
|
691
|
+
&& row.occurred_at === input.occurredAt;
|
|
692
|
+
}
|
|
693
|
+
#assertTaskLearningState(row) {
|
|
694
|
+
const revision = this.#database.prepare(`
|
|
695
|
+
SELECT scope_key, scope_watermark, subject_kind, subject_ref, version, digest, disposition,
|
|
696
|
+
situation, episode_id, applied_at AS updated_at
|
|
697
|
+
FROM evolution_task_learning_revisions
|
|
698
|
+
WHERE scope_key = ? AND subject_kind = ? AND subject_ref = ? AND version = ?
|
|
699
|
+
`).get(row.scope_key, row.subject_kind, row.subject_ref, row.version);
|
|
700
|
+
if (revision === undefined || revision.digest !== row.digest
|
|
701
|
+
|| revision.disposition !== row.disposition || revision.situation !== row.situation
|
|
702
|
+
|| revision.episode_id !== row.episode_id) {
|
|
703
|
+
throw new EvolutionStoreError('invalid-state', 'task learning current state does not match its immutable revision');
|
|
704
|
+
}
|
|
705
|
+
if (row.disposition === 'retract') {
|
|
706
|
+
if (row.episode_id !== null) {
|
|
707
|
+
throw new EvolutionStoreError('invalid-state', 'retracted task learning state has an active vote');
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (row.episode_id === null) {
|
|
712
|
+
throw new EvolutionStoreError('invalid-state', 'upserted task learning state is missing its vote');
|
|
713
|
+
}
|
|
714
|
+
const active = this.#database.prepare('SELECT * FROM evolution_episodes WHERE id = ?')
|
|
715
|
+
.get(row.episode_id);
|
|
716
|
+
if (active === undefined || active.scope_key !== row.scope_key || active.situation !== row.situation
|
|
717
|
+
|| active.learning_eligible !== 1
|
|
718
|
+
|| active.learning_subject_ref !== JSON.stringify([row.subject_kind, row.subject_ref])) {
|
|
719
|
+
throw new EvolutionStoreError('invalid-state', 'task learning vote does not match current state');
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/** Must be called under this store's writer transaction. */
|
|
723
|
+
#recordScopeLearningWatermark(scopeKey, watermark, now) {
|
|
724
|
+
this.#database.prepare(`
|
|
725
|
+
INSERT INTO evolution_scope_learning_watermarks(scope_key, watermark, updated_at)
|
|
726
|
+
VALUES (?, ?, ?)
|
|
727
|
+
ON CONFLICT(scope_key) DO UPDATE SET
|
|
728
|
+
watermark = excluded.watermark,
|
|
729
|
+
updated_at = excluded.updated_at
|
|
730
|
+
WHERE excluded.watermark >= evolution_scope_learning_watermarks.watermark
|
|
731
|
+
`).run(scopeKey, watermark, now);
|
|
732
|
+
const saved = this.#database.prepare(`
|
|
733
|
+
SELECT watermark FROM evolution_scope_learning_watermarks WHERE scope_key = ?
|
|
734
|
+
`).get(scopeKey);
|
|
735
|
+
if (saved?.watermark !== watermark) {
|
|
736
|
+
throw new EvolutionStoreError('version-conflict', 'Evaluation scope watermark moved backwards');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/** Must be called after the new task state is durable in the current writer transaction. */
|
|
740
|
+
#retireRulesDependingOnTaskRevision(input) {
|
|
741
|
+
const rows = this.#database.prepare(`
|
|
742
|
+
SELECT rule.*
|
|
743
|
+
FROM evolution_proposals proposal
|
|
744
|
+
JOIN evolution_rules rule ON rule.id = proposal.result_rule_id
|
|
745
|
+
JOIN json_each(proposal.mutation_json, '$.evidence.taskRevisions') revision
|
|
746
|
+
WHERE proposal.status = 'approved'
|
|
747
|
+
AND proposal.scope_key = ?
|
|
748
|
+
AND json_extract(proposal.mutation_json, '$.op') = 'adopt'
|
|
749
|
+
AND rule.status = 'active'
|
|
750
|
+
AND json_extract(revision.value, '$.subjectKind') = ?
|
|
751
|
+
AND json_extract(revision.value, '$.subjectRef') = ?
|
|
752
|
+
ORDER BY rule.id
|
|
753
|
+
`).all(input.scopeKey, input.subjectKind, input.subjectRef);
|
|
754
|
+
for (const row of rows) {
|
|
755
|
+
const reason = 'Automatic safe rollback: authoritative Evaluation evidence '
|
|
756
|
+
+ `${input.subjectKind}:${input.subjectRef} changed after adoption.`;
|
|
757
|
+
const version = row.version + 1;
|
|
758
|
+
const retired = this.#database.prepare(`
|
|
759
|
+
UPDATE evolution_rules
|
|
760
|
+
SET status = 'retired', retired_reason = ?, updated_at = ?, version = ?
|
|
761
|
+
WHERE id = ? AND status = 'active' AND version = ?
|
|
762
|
+
`).run(reason, input.now, version, row.id, row.version);
|
|
763
|
+
if (retired.changes === 1) {
|
|
764
|
+
this.#audit('evidence-correction-rollback', row.id, version, input.now);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/** Failure counts over the most recent explicitly quality-eligible episodes. */
|
|
215
769
|
stats(scopeKey, situation, window) {
|
|
216
770
|
const scope = this.#scopeKey(scopeKey);
|
|
217
771
|
const label = this.#situation(situation);
|
|
@@ -219,7 +773,7 @@ export class EvolutionStore {
|
|
|
219
773
|
const row = this.#database.prepare(`
|
|
220
774
|
SELECT COUNT(*) AS total, SUM(outcome = 'failed') AS failures FROM (
|
|
221
775
|
SELECT outcome FROM evolution_episodes
|
|
222
|
-
WHERE scope_key = ? AND situation = ? AND
|
|
776
|
+
WHERE scope_key = ? AND situation = ? AND learning_eligible = 1 AND rule_id IS NULL
|
|
223
777
|
ORDER BY occurred_at DESC, id DESC LIMIT ?
|
|
224
778
|
)
|
|
225
779
|
`).get(scope, label, window);
|
|
@@ -239,6 +793,17 @@ export class EvolutionStore {
|
|
|
239
793
|
.get(ruleId);
|
|
240
794
|
return row === undefined ? undefined : rule(row);
|
|
241
795
|
}
|
|
796
|
+
/** Principal whose exact approved adoption created this immutable rule. */
|
|
797
|
+
ruleApprovalPrincipal(ruleId) {
|
|
798
|
+
const id = this.#serverRuleId(ruleId);
|
|
799
|
+
const rows = this.#database.prepare(`
|
|
800
|
+
SELECT principal FROM evolution_proposals
|
|
801
|
+
WHERE result_rule_id = ? AND status = 'approved'
|
|
802
|
+
AND json_extract(mutation_json, '$.op') = 'adopt'
|
|
803
|
+
ORDER BY id LIMIT 2
|
|
804
|
+
`).all(id);
|
|
805
|
+
return rows.length === 1 ? rows[0].principal : undefined;
|
|
806
|
+
}
|
|
242
807
|
activeRule(scopeKey, situation) {
|
|
243
808
|
const row = this.#database.prepare("SELECT * FROM evolution_rules WHERE scope_key = ? AND situation = ? AND status = 'active'").get(this.#scopeKey(scopeKey), this.#situation(situation));
|
|
244
809
|
return row === undefined ? undefined : rule(row);
|
|
@@ -288,6 +853,13 @@ export class EvolutionStore {
|
|
|
288
853
|
`).get(this.#opaque(sessionId, 'sessionId', 500), this.#scopeKey(scopeKey), this.#bounded(ruleId, 'ruleId', 200), guidanceVersion);
|
|
289
854
|
return row !== undefined;
|
|
290
855
|
}
|
|
856
|
+
countGuidanceExposures(sessionId, scopeKey) {
|
|
857
|
+
const row = this.#database.prepare(`
|
|
858
|
+
SELECT COUNT(*) AS count FROM evolution_guidance_exposures
|
|
859
|
+
WHERE session_id = ? AND scope_key = ?
|
|
860
|
+
`).get(this.#opaque(sessionId, 'sessionId', 500), this.#scopeKey(scopeKey));
|
|
861
|
+
return row.count;
|
|
862
|
+
}
|
|
291
863
|
/** Return only an unambiguous exact-situation receipt for one session. */
|
|
292
864
|
captureGuidanceExposure(sessionId, scopeKey, situation) {
|
|
293
865
|
const rows = this.#database.prepare(`
|
|
@@ -337,7 +909,7 @@ export class EvolutionStore {
|
|
|
337
909
|
}
|
|
338
910
|
const situations = this.#database.prepare(`
|
|
339
911
|
SELECT DISTINCT situation FROM evolution_episodes
|
|
340
|
-
WHERE scope_key = ? AND
|
|
912
|
+
WHERE scope_key = ? AND learning_eligible = 1
|
|
341
913
|
ORDER BY situation
|
|
342
914
|
`).all(scopeKey);
|
|
343
915
|
const candidates = [];
|
|
@@ -394,6 +966,180 @@ export class EvolutionStore {
|
|
|
394
966
|
return undefined;
|
|
395
967
|
return this.#retirementCandidateFromRule(active, options.window, options.minSample, options.retireFailureRate, evidenceSampleLimit);
|
|
396
968
|
}
|
|
969
|
+
/**
|
|
970
|
+
* Freeze the one adoption candidate shown to an exact production analyst
|
|
971
|
+
* execution. Repeating review in the same occurrence returns the same opaque
|
|
972
|
+
* token; if its evidence changed, the old execution is fenced instead.
|
|
973
|
+
*/
|
|
974
|
+
registerSupervisedGrowthAnalystReview(input) {
|
|
975
|
+
const scopeKey = this.#scopeKey(input.scopeKey);
|
|
976
|
+
const occurrenceId = this.#opaque(input.occurrenceId, 'occurrenceId', 500);
|
|
977
|
+
const evidence = this.#normalizeSupervisedGrowthAnalystEvidence(scopeKey, input.candidate, input.evidenceWindow);
|
|
978
|
+
const reviewToken = `analyst-review-${randomUUID()}`;
|
|
979
|
+
const sampleJson = JSON.stringify(evidence.sampleEpisodeIds);
|
|
980
|
+
const evidenceJson = JSON.stringify(evidence.evidence);
|
|
981
|
+
const taskRevisionsJson = JSON.stringify(evidence.taskRevisions);
|
|
982
|
+
let row;
|
|
983
|
+
let proposalExists = false;
|
|
984
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
985
|
+
try {
|
|
986
|
+
if (!this.#supervisedGrowthAnalystEvidenceMatches(scopeKey, evidence)) {
|
|
987
|
+
throw new EvolutionStoreError('version-conflict', 'analyst review evidence changed before it could be frozen');
|
|
988
|
+
}
|
|
989
|
+
const existing = this.#database.prepare(`
|
|
990
|
+
SELECT * FROM evolution_supervised_analyst_reviews
|
|
991
|
+
WHERE scope_key = ? AND occurrence_id = ?
|
|
992
|
+
`).get(scopeKey, occurrenceId);
|
|
993
|
+
if (existing === undefined) {
|
|
994
|
+
const now = this.#now();
|
|
995
|
+
this.#database.prepare(`
|
|
996
|
+
INSERT INTO evolution_supervised_analyst_reviews(
|
|
997
|
+
review_token, scope_key, occurrence_id, contract_version, situation,
|
|
998
|
+
failures, total, evidence_digest, evidence_total, evidence_window,
|
|
999
|
+
sample_episode_ids_json, evidence_json, scope_watermark, task_revisions_json,
|
|
1000
|
+
proposal_id, created_at, proposed_at)
|
|
1001
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, NULL)
|
|
1002
|
+
`).run(reviewToken, scopeKey, occurrenceId, evidence.contractVersion, evidence.situation, evidence.failures, evidence.total, evidence.evidenceDigest, evidence.evidenceTotal, evidence.evidenceWindow, sampleJson, evidenceJson, evidence.scopeWatermark, taskRevisionsJson, now);
|
|
1003
|
+
row = this.#database.prepare(`
|
|
1004
|
+
SELECT * FROM evolution_supervised_analyst_reviews WHERE review_token = ?
|
|
1005
|
+
`).get(reviewToken);
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
if (!this.#sameSupervisedGrowthAnalystReview(existing, evidence, sampleJson, evidenceJson, taskRevisionsJson)) {
|
|
1009
|
+
throw new EvolutionStoreError('version-conflict', 'one analyst execution cannot review more than one evidence identity');
|
|
1010
|
+
}
|
|
1011
|
+
row = existing;
|
|
1012
|
+
}
|
|
1013
|
+
const key = supervisedGrowthAnalystProposalIdempotencyKey({
|
|
1014
|
+
scopeKey,
|
|
1015
|
+
situation: evidence.situation,
|
|
1016
|
+
evidenceDigest: evidence.evidenceDigest,
|
|
1017
|
+
evidenceTotal: evidence.evidenceTotal,
|
|
1018
|
+
contractVersion: evidence.contractVersion,
|
|
1019
|
+
});
|
|
1020
|
+
const winner = this.#database.prepare(`
|
|
1021
|
+
SELECT * FROM evolution_proposals WHERE idempotency_key = ?
|
|
1022
|
+
`).get(key);
|
|
1023
|
+
if (winner !== undefined && !this.#supervisedGrowthAnalystProposalMatches(winner, evidence)) {
|
|
1024
|
+
throw new EvolutionStoreError('invalid-state', 'analyst proposal identity points to invalid content');
|
|
1025
|
+
}
|
|
1026
|
+
if (row.proposal_id !== null && winner?.id !== row.proposal_id) {
|
|
1027
|
+
throw new EvolutionStoreError('invalid-state', 'analyst execution points to another proposal');
|
|
1028
|
+
}
|
|
1029
|
+
proposalExists = winner !== undefined;
|
|
1030
|
+
this.#database.exec('COMMIT');
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
this.#database.exec('ROLLBACK');
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
return Object.freeze({ review: supervisedGrowthAnalystReview(row), proposalExists });
|
|
1037
|
+
}
|
|
1038
|
+
getSupervisedGrowthAnalystReview(reviewToken) {
|
|
1039
|
+
const token = this.#analystReviewToken(reviewToken);
|
|
1040
|
+
const row = this.#database.prepare(`
|
|
1041
|
+
SELECT * FROM evolution_supervised_analyst_reviews WHERE review_token = ?
|
|
1042
|
+
`).get(token);
|
|
1043
|
+
return row === undefined ? undefined : supervisedGrowthAnalystReview(row);
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Create or join the proposal for one evidence identity under the SQLite
|
|
1047
|
+
* writer lock. The existing proposal is deliberately returned without
|
|
1048
|
+
* comparing guidance: the first committed wording is the durable winner.
|
|
1049
|
+
*/
|
|
1050
|
+
createSupervisedGrowthAnalystProposal(input) {
|
|
1051
|
+
const reviewToken = this.#analystReviewToken(input.reviewToken);
|
|
1052
|
+
const scopeKey = this.#scopeKey(input.scopeKey);
|
|
1053
|
+
const occurrenceId = this.#opaque(input.occurrenceId, 'occurrenceId', 500);
|
|
1054
|
+
const requester = this.#bounded(input.requester, 'requester', 200);
|
|
1055
|
+
const principal = this.#bounded(input.principal, 'principal', 500);
|
|
1056
|
+
if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt < 0) {
|
|
1057
|
+
throw new EvolutionStoreError('invalid-input', 'proposal expiresAt must be a non-negative safe integer');
|
|
1058
|
+
}
|
|
1059
|
+
const mutation = this.#validateStoredMutation(input.mutation);
|
|
1060
|
+
if (mutation.op !== 'adopt' || mutation.evidence === undefined
|
|
1061
|
+
|| mutation.input.scopeKey !== scopeKey) {
|
|
1062
|
+
throw new EvolutionStoreError('invalid-input', 'analyst may only create an evidence-bound adoption');
|
|
1063
|
+
}
|
|
1064
|
+
const key = supervisedGrowthAnalystProposalIdempotencyKey({
|
|
1065
|
+
scopeKey,
|
|
1066
|
+
situation: mutation.input.situation,
|
|
1067
|
+
evidenceDigest: mutation.evidence.digest,
|
|
1068
|
+
evidenceTotal: mutation.evidence.total,
|
|
1069
|
+
contractVersion: SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION,
|
|
1070
|
+
});
|
|
1071
|
+
const creationIntent = this.#validateCreationIntent(input.creationIntent, {
|
|
1072
|
+
key,
|
|
1073
|
+
requester,
|
|
1074
|
+
principal,
|
|
1075
|
+
});
|
|
1076
|
+
const creationIntentJson = JSON.stringify(creationIntent);
|
|
1077
|
+
const mutationJson = JSON.stringify(mutation);
|
|
1078
|
+
const mutationHash = digest(mutation);
|
|
1079
|
+
const id = `evolution-proposal-${randomUUID()}`;
|
|
1080
|
+
let winner;
|
|
1081
|
+
let replayed = false;
|
|
1082
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1083
|
+
try {
|
|
1084
|
+
const review = this.#database.prepare(`
|
|
1085
|
+
SELECT * FROM evolution_supervised_analyst_reviews WHERE review_token = ?
|
|
1086
|
+
`).get(reviewToken);
|
|
1087
|
+
if (review === undefined || review.scope_key !== scopeKey
|
|
1088
|
+
|| review.occurrence_id !== occurrenceId) {
|
|
1089
|
+
throw new EvolutionStoreError('not-found', 'analyst review token does not belong to this production execution');
|
|
1090
|
+
}
|
|
1091
|
+
const evidence = supervisedGrowthAnalystReview(review).evidence;
|
|
1092
|
+
if (!this.#supervisedGrowthAnalystMutationMatches(mutation, evidence)
|
|
1093
|
+
|| !this.#proposalEvidenceMatches(mutation)) {
|
|
1094
|
+
throw new EvolutionStoreError('version-conflict', 'analyst proposal evidence no longer matches the frozen review');
|
|
1095
|
+
}
|
|
1096
|
+
const existing = this.#database.prepare(`
|
|
1097
|
+
SELECT * FROM evolution_proposals WHERE idempotency_key = ?
|
|
1098
|
+
`).get(key);
|
|
1099
|
+
if (review.proposal_id !== null) {
|
|
1100
|
+
if (existing?.id !== review.proposal_id) {
|
|
1101
|
+
throw new EvolutionStoreError('invalid-state', 'analyst execution proposal binding is corrupt');
|
|
1102
|
+
}
|
|
1103
|
+
winner = existing;
|
|
1104
|
+
replayed = true;
|
|
1105
|
+
}
|
|
1106
|
+
else if (existing !== undefined) {
|
|
1107
|
+
winner = existing;
|
|
1108
|
+
replayed = true;
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
const now = this.#now();
|
|
1112
|
+
this.#database.prepare(`
|
|
1113
|
+
INSERT INTO evolution_proposals(
|
|
1114
|
+
id, policy_proposal_id, idempotency_key, requester, principal, scope_key, mutation_hash,
|
|
1115
|
+
mutation_json, creation_intent_json, settlement_expectation_json,
|
|
1116
|
+
status, expires_at, created_at, updated_at, version)
|
|
1117
|
+
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?, 1)
|
|
1118
|
+
`).run(id, key, requester, principal, scopeKey, mutationHash, mutationJson, creationIntentJson, input.expiresAt, now, now);
|
|
1119
|
+
winner = this.#database.prepare(`
|
|
1120
|
+
SELECT * FROM evolution_proposals WHERE idempotency_key = ?
|
|
1121
|
+
`).get(key);
|
|
1122
|
+
}
|
|
1123
|
+
if (!this.#supervisedGrowthAnalystProposalMatches(winner, evidence)) {
|
|
1124
|
+
throw new EvolutionStoreError('invalid-state', 'analyst proposal winner does not match its evidence identity');
|
|
1125
|
+
}
|
|
1126
|
+
const proposedAt = this.#now();
|
|
1127
|
+
const linked = this.#database.prepare(`
|
|
1128
|
+
UPDATE evolution_supervised_analyst_reviews
|
|
1129
|
+
SET proposal_id = ?, proposed_at = COALESCE(proposed_at, ?)
|
|
1130
|
+
WHERE review_token = ? AND proposal_id IS NULL
|
|
1131
|
+
`).run(winner.id, proposedAt, reviewToken);
|
|
1132
|
+
if (linked.changes === 0 && review.proposal_id !== winner.id) {
|
|
1133
|
+
throw new EvolutionStoreError('invalid-state', 'analyst execution already proposed another change');
|
|
1134
|
+
}
|
|
1135
|
+
this.#database.exec('COMMIT');
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
this.#database.exec('ROLLBACK');
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
return Object.freeze({ proposal: proposal(winner), replayed });
|
|
1142
|
+
}
|
|
397
1143
|
/**
|
|
398
1144
|
* Retire one exact active guidance generation through the narrow autonomous
|
|
399
1145
|
* rollback lane.
|
|
@@ -552,24 +1298,44 @@ export class EvolutionStore {
|
|
|
552
1298
|
`).get(scopeKey, situation);
|
|
553
1299
|
if (retired === undefined) {
|
|
554
1300
|
return this.#database.prepare(`
|
|
555
|
-
SELECT
|
|
556
|
-
|
|
557
|
-
|
|
1301
|
+
SELECT episode.*, state.subject_kind AS task_subject_kind,
|
|
1302
|
+
state.subject_ref AS task_subject_ref, state.version AS task_version,
|
|
1303
|
+
state.digest AS task_digest, state.disposition AS task_disposition
|
|
1304
|
+
FROM evolution_episodes episode
|
|
1305
|
+
JOIN evolution_task_learning_state state
|
|
1306
|
+
ON state.scope_key = episode.scope_key AND state.episode_id = episode.id
|
|
1307
|
+
AND state.disposition = 'upsert'
|
|
1308
|
+
WHERE episode.scope_key = ? AND episode.situation = ?
|
|
1309
|
+
AND episode.learning_eligible = 1 AND episode.rule_id IS NULL
|
|
1310
|
+
ORDER BY episode.occurred_at DESC, episode.id DESC LIMIT ?
|
|
558
1311
|
`).all(scopeKey, situation, window);
|
|
559
1312
|
}
|
|
560
1313
|
return this.#database.prepare(`
|
|
561
|
-
SELECT
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
1314
|
+
SELECT episode.*, state.subject_kind AS task_subject_kind,
|
|
1315
|
+
state.subject_ref AS task_subject_ref, state.version AS task_version,
|
|
1316
|
+
state.digest AS task_digest, state.disposition AS task_disposition
|
|
1317
|
+
FROM evolution_episodes episode
|
|
1318
|
+
JOIN evolution_task_learning_state state
|
|
1319
|
+
ON state.scope_key = episode.scope_key AND state.episode_id = episode.id
|
|
1320
|
+
AND state.disposition = 'upsert'
|
|
1321
|
+
WHERE episode.scope_key = ? AND episode.situation = ?
|
|
1322
|
+
AND episode.learning_eligible = 1 AND episode.rule_id IS NULL
|
|
1323
|
+
AND episode.occurred_at > ?
|
|
1324
|
+
ORDER BY episode.occurred_at DESC, episode.id DESC LIMIT ?
|
|
565
1325
|
`).all(scopeKey, situation, retired.updated_at, window);
|
|
566
1326
|
}
|
|
567
1327
|
#evaluationEpisodes(active, window) {
|
|
568
1328
|
return this.#database.prepare(`
|
|
569
|
-
SELECT
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
1329
|
+
SELECT episode.*, state.subject_kind AS task_subject_kind,
|
|
1330
|
+
state.subject_ref AS task_subject_ref, state.version AS task_version,
|
|
1331
|
+
state.digest AS task_digest, state.disposition AS task_disposition
|
|
1332
|
+
FROM evolution_episodes episode
|
|
1333
|
+
JOIN evolution_task_learning_state state
|
|
1334
|
+
ON state.scope_key = episode.scope_key AND state.episode_id = episode.id
|
|
1335
|
+
AND state.disposition = 'upsert'
|
|
1336
|
+
WHERE episode.scope_key = ? AND episode.situation = ? AND episode.learning_eligible = 1
|
|
1337
|
+
AND episode.rule_id = ? AND episode.guidance_version = ? AND episode.occurred_at > ?
|
|
1338
|
+
ORDER BY episode.occurred_at DESC, episode.id DESC LIMIT ?
|
|
573
1339
|
`).all(active.scopeKey, active.situation, active.id, active.generation, active.adoptedAt, window);
|
|
574
1340
|
}
|
|
575
1341
|
#statsFromEpisodes(scopeKey, situation, episodes) {
|
|
@@ -581,9 +1347,28 @@ export class EvolutionStore {
|
|
|
581
1347
|
});
|
|
582
1348
|
}
|
|
583
1349
|
#candidateEvidence(episodes, sampleLimit) {
|
|
1350
|
+
if (episodes.length < 1) {
|
|
1351
|
+
throw new EvolutionStoreError('invalid-state', 'candidate evidence window is empty');
|
|
1352
|
+
}
|
|
1353
|
+
const watermark = this.#database.prepare(`
|
|
1354
|
+
SELECT watermark FROM evolution_scope_learning_watermarks WHERE scope_key = ?
|
|
1355
|
+
`).get(episodes[0].scope_key);
|
|
1356
|
+
if (watermark === undefined || !Number.isSafeInteger(watermark.watermark)
|
|
1357
|
+
|| watermark.watermark < 1) {
|
|
1358
|
+
throw new EvolutionStoreError('invalid-state', 'candidate has no authoritative scope watermark');
|
|
1359
|
+
}
|
|
1360
|
+
const taskRevisions = episodes.map(row => Object.freeze({
|
|
1361
|
+
subjectKind: row.task_subject_kind,
|
|
1362
|
+
subjectRef: row.task_subject_ref,
|
|
1363
|
+
version: row.task_version,
|
|
1364
|
+
digest: row.task_digest,
|
|
1365
|
+
disposition: 'upsert',
|
|
1366
|
+
}));
|
|
584
1367
|
const evidence = episodes.slice(0, sampleLimit).map(row => Object.freeze({
|
|
585
1368
|
episodeId: row.id,
|
|
586
1369
|
outcome: row.outcome,
|
|
1370
|
+
evidenceKind: row.evidence_kind,
|
|
1371
|
+
evidenceRef: row.evidence_ref,
|
|
587
1372
|
detail: row.detail,
|
|
588
1373
|
occurredAt: row.occurred_at,
|
|
589
1374
|
}));
|
|
@@ -597,14 +1382,23 @@ export class EvolutionStore {
|
|
|
597
1382
|
detail: row.detail,
|
|
598
1383
|
source: row.source,
|
|
599
1384
|
trust: row.trust,
|
|
1385
|
+
evidenceKind: row.evidence_kind,
|
|
1386
|
+
evidenceRef: row.evidence_ref,
|
|
1387
|
+
learningEligible: row.learning_eligible,
|
|
600
1388
|
ruleId: row.rule_id,
|
|
601
1389
|
guidanceVersion: row.guidance_version,
|
|
602
1390
|
occurredAt: row.occurred_at,
|
|
1391
|
+
taskSubjectKind: row.task_subject_kind,
|
|
1392
|
+
taskSubjectRef: row.task_subject_ref,
|
|
1393
|
+
taskVersion: row.task_version,
|
|
1394
|
+
taskDigest: row.task_digest,
|
|
603
1395
|
})));
|
|
604
1396
|
return Object.freeze({
|
|
605
1397
|
evidence: Object.freeze(evidence),
|
|
606
1398
|
evidenceDigest,
|
|
607
1399
|
evidenceTotal: episodes.length,
|
|
1400
|
+
scopeWatermark: watermark.watermark,
|
|
1401
|
+
taskRevisions: Object.freeze(taskRevisions),
|
|
608
1402
|
});
|
|
609
1403
|
}
|
|
610
1404
|
// ---- proposals -----------------------------------------------------------
|
|
@@ -628,64 +1422,97 @@ export class EvolutionStore {
|
|
|
628
1422
|
const hash = digest(mutation);
|
|
629
1423
|
const scopeKey = mutation.op === 'adopt'
|
|
630
1424
|
? mutation.input.scopeKey
|
|
631
|
-
:
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
status, expires_at, created_at, updated_at, version)
|
|
637
|
-
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?, 1)
|
|
638
|
-
ON CONFLICT(idempotency_key) DO NOTHING
|
|
639
|
-
`).run(id, key, requester, principal, scopeKey, hash, JSON.stringify(mutation), creationIntentJson, input.expiresAt, now, now);
|
|
640
|
-
const winner = this.#database.prepare('SELECT * FROM evolution_proposals WHERE idempotency_key = ?')
|
|
641
|
-
.get(key);
|
|
642
|
-
let winnerIntentMatches = false;
|
|
1425
|
+
: mutation.op === 'owner-undo'
|
|
1426
|
+
? mutation.scopeKey
|
|
1427
|
+
: (this.getRule(mutation.ruleId)?.scopeKey ?? legacyEvolutionScope);
|
|
1428
|
+
let winner;
|
|
1429
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
643
1430
|
try {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
1431
|
+
winner = this.#database.prepare('SELECT * FROM evolution_proposals WHERE idempotency_key = ?')
|
|
1432
|
+
.get(key);
|
|
1433
|
+
if (winner === undefined) {
|
|
1434
|
+
// Candidate selection happens before this method is called. Recompute the
|
|
1435
|
+
// complete frozen window under the writer lock so a concurrent task
|
|
1436
|
+
// revision cannot create a proposal from stale evidence.
|
|
1437
|
+
if (!this.#proposalEvidenceMatches(mutation)) {
|
|
1438
|
+
throw new EvolutionStoreError('version-conflict', 'proposal evidence changed before the immutable proposal could be created');
|
|
1439
|
+
}
|
|
1440
|
+
this.#database.prepare(`
|
|
1441
|
+
INSERT INTO evolution_proposals(
|
|
1442
|
+
id, policy_proposal_id, idempotency_key, requester, principal, scope_key, mutation_hash,
|
|
1443
|
+
mutation_json, creation_intent_json, settlement_expectation_json,
|
|
1444
|
+
status, expires_at, created_at, updated_at, version)
|
|
1445
|
+
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?, 1)
|
|
1446
|
+
`).run(id, key, requester, principal, scopeKey, hash, JSON.stringify(mutation), creationIntentJson, input.expiresAt, now, now);
|
|
1447
|
+
winner = this.#database.prepare('SELECT * FROM evolution_proposals WHERE idempotency_key = ?')
|
|
1448
|
+
.get(key);
|
|
1449
|
+
}
|
|
1450
|
+
let winnerIntentMatches = false;
|
|
1451
|
+
try {
|
|
1452
|
+
winnerIntentMatches = digest(this.#proposalIntent(this.#validateMutation(JSON.parse(winner.mutation_json)))) === digest(this.#proposalIntent(normalized));
|
|
1453
|
+
}
|
|
1454
|
+
catch {
|
|
1455
|
+
winnerIntentMatches = false;
|
|
1456
|
+
}
|
|
1457
|
+
if (!winnerIntentMatches || winner.requester !== requester
|
|
1458
|
+
|| winner.principal !== principal || winner.scope_key !== scopeKey
|
|
1459
|
+
|| winner.creation_intent_json !== creationIntentJson) {
|
|
1460
|
+
throw new EvolutionStoreError('idempotency-conflict', 'proposal idempotency key was reused with different content');
|
|
1461
|
+
}
|
|
1462
|
+
this.#database.exec('COMMIT');
|
|
648
1463
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
throw new EvolutionStoreError('idempotency-conflict', 'proposal idempotency key was reused with different content');
|
|
1464
|
+
catch (error) {
|
|
1465
|
+
this.#database.exec('ROLLBACK');
|
|
1466
|
+
throw error;
|
|
653
1467
|
}
|
|
654
1468
|
return proposal(winner);
|
|
655
1469
|
}
|
|
656
1470
|
attachPolicy(proposalId, policyProposalId, expectation) {
|
|
657
1471
|
const policyId = this.#bounded(policyProposalId, 'policyProposalId', 200);
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1472
|
+
let attached;
|
|
1473
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1474
|
+
try {
|
|
1475
|
+
const initial = this.#requireProposal(proposalId);
|
|
1476
|
+
let expectationJson = null;
|
|
1477
|
+
if (expectation !== undefined) {
|
|
1478
|
+
const intent = initial.creation_intent_json === null
|
|
1479
|
+
? undefined
|
|
1480
|
+
: JSON.parse(initial.creation_intent_json);
|
|
1481
|
+
if (expectation.proposalId !== policyId
|
|
1482
|
+
|| expectation.requester !== initial.requester
|
|
1483
|
+
|| expectation.principal !== initial.principal
|
|
1484
|
+
|| !Number.isSafeInteger(expectation.expiresAt) || expectation.expiresAt < 0
|
|
1485
|
+
|| expectation.expectedVersion !== 1
|
|
1486
|
+
|| intent === undefined
|
|
1487
|
+
|| expectation.action !== intent.action
|
|
1488
|
+
|| expectation.resource.kind !== intent.resource.kind
|
|
1489
|
+
|| expectation.resource.id !== intent.resource.id
|
|
1490
|
+
|| expectation.summary !== intent.summary
|
|
1491
|
+
|| expectation.diff !== intent.diff) {
|
|
1492
|
+
throw new EvolutionStoreError('invalid-input', 'settlement expectation does not match local creation intent');
|
|
1493
|
+
}
|
|
1494
|
+
expectationJson = JSON.stringify(expectation);
|
|
676
1495
|
}
|
|
677
|
-
|
|
1496
|
+
this.#database.prepare(`
|
|
1497
|
+
UPDATE evolution_proposals
|
|
1498
|
+
SET policy_proposal_id = ?, settlement_expectation_json = ?,
|
|
1499
|
+
expires_at = ?, updated_at = ?
|
|
1500
|
+
WHERE id = ? AND policy_proposal_id IS NULL
|
|
1501
|
+
`).run(policyId, expectationJson, expectation?.expiresAt ?? initial.expires_at, this.#now(), proposalId);
|
|
1502
|
+
attached = this.#requireProposal(proposalId);
|
|
1503
|
+
if (attached.policy_proposal_id !== policyId
|
|
1504
|
+
|| attached.settlement_expectation_json !== expectationJson) {
|
|
1505
|
+
throw new EvolutionStoreError('invalid-state', 'proposal is already attached to a different policy proposal');
|
|
1506
|
+
}
|
|
1507
|
+
// Projection invalidation may have won while Policy was creating the
|
|
1508
|
+
// external card. Attach the exact id anyway and atomically create its
|
|
1509
|
+
// terminal application receipt so the card is updated in place.
|
|
1510
|
+
this.#recordEvolutionApplicationReceipt(attached);
|
|
1511
|
+
this.#database.exec('COMMIT');
|
|
678
1512
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
expires_at = ?, updated_at = ?
|
|
683
|
-
WHERE id = ? AND policy_proposal_id IS NULL
|
|
684
|
-
`).run(policyId, expectationJson, expectation?.expiresAt ?? initial.expires_at, this.#now(), proposalId);
|
|
685
|
-
const attached = this.#requireProposal(proposalId);
|
|
686
|
-
if (attached.policy_proposal_id !== policyId
|
|
687
|
-
|| attached.settlement_expectation_json !== expectationJson) {
|
|
688
|
-
throw new EvolutionStoreError('invalid-state', 'proposal is already attached to a different policy proposal');
|
|
1513
|
+
catch (error) {
|
|
1514
|
+
this.#database.exec('ROLLBACK');
|
|
1515
|
+
throw error;
|
|
689
1516
|
}
|
|
690
1517
|
return proposal(attached);
|
|
691
1518
|
}
|
|
@@ -694,6 +1521,11 @@ export class EvolutionStore {
|
|
|
694
1521
|
.get(proposalId);
|
|
695
1522
|
return row === undefined ? undefined : proposal(row);
|
|
696
1523
|
}
|
|
1524
|
+
getProposalByIdempotencyKey(idempotencyKey) {
|
|
1525
|
+
const key = this.#bounded(idempotencyKey, 'idempotencyKey', 200);
|
|
1526
|
+
const row = this.#database.prepare('SELECT * FROM evolution_proposals WHERE idempotency_key = ?').get(key);
|
|
1527
|
+
return row === undefined ? undefined : proposal(row);
|
|
1528
|
+
}
|
|
697
1529
|
listPendingProposals(limit) {
|
|
698
1530
|
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
|
|
699
1531
|
throw new EvolutionStoreError('invalid-input', 'pending proposal limit must be between 1 and 1000');
|
|
@@ -704,13 +1536,128 @@ export class EvolutionStore {
|
|
|
704
1536
|
`).all(limit);
|
|
705
1537
|
return rows.map(row => proposal(row));
|
|
706
1538
|
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Recover cross-database creation intents that may already own a Policy card
|
|
1541
|
+
* even when local evidence invalidation made the proposal terminal first.
|
|
1542
|
+
*/
|
|
1543
|
+
listUnattachedProposalIntents(limit) {
|
|
1544
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
|
|
1545
|
+
throw new EvolutionStoreError('invalid-input', 'unattached proposal limit must be between 1 and 1000');
|
|
1546
|
+
}
|
|
1547
|
+
const rows = this.#database.prepare(`
|
|
1548
|
+
SELECT * FROM evolution_proposals
|
|
1549
|
+
WHERE policy_proposal_id IS NULL AND creation_intent_json IS NOT NULL
|
|
1550
|
+
ORDER BY updated_at, id LIMIT ?
|
|
1551
|
+
`).all(limit);
|
|
1552
|
+
return rows.map(row => proposal(row));
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Policy has authoritatively confirmed that an expired unattached intent did
|
|
1556
|
+
* not create a proposal. Drop only that now-useless recovery payload so the
|
|
1557
|
+
* terminal local conflict is not rediscovered forever.
|
|
1558
|
+
*/
|
|
1559
|
+
completeAbandonedProposalRecovery(proposalId) {
|
|
1560
|
+
const id = this.#opaque(proposalId, 'proposalId', 200);
|
|
1561
|
+
const current = this.#requireProposal(id);
|
|
1562
|
+
if (current.policy_proposal_id !== null || current.status === 'pending') {
|
|
1563
|
+
throw new EvolutionStoreError('invalid-state', 'only a terminal unattached proposal can complete abandoned recovery');
|
|
1564
|
+
}
|
|
1565
|
+
if (current.creation_intent_json === null)
|
|
1566
|
+
return proposal(current);
|
|
1567
|
+
const updated = this.#database.prepare(`
|
|
1568
|
+
UPDATE evolution_proposals SET creation_intent_json = NULL
|
|
1569
|
+
WHERE id = ? AND policy_proposal_id IS NULL AND status <> 'pending'
|
|
1570
|
+
AND creation_intent_json IS NOT NULL
|
|
1571
|
+
`).run(id);
|
|
1572
|
+
if (updated.changes !== 1) {
|
|
1573
|
+
throw new EvolutionStoreError('invalid-state', 'abandoned proposal recovery lost its update');
|
|
1574
|
+
}
|
|
1575
|
+
return proposal(this.#requireProposal(id));
|
|
1576
|
+
}
|
|
1577
|
+
getEvolutionApplicationReceipt(localProposalId) {
|
|
1578
|
+
const id = this.#opaque(localProposalId, 'localProposalId', 200);
|
|
1579
|
+
const row = this.#database.prepare(`
|
|
1580
|
+
SELECT * FROM evolution_application_receipts WHERE local_proposal_id = ?
|
|
1581
|
+
`).get(id);
|
|
1582
|
+
return row === undefined ? undefined : evolutionApplicationReceipt(row);
|
|
1583
|
+
}
|
|
1584
|
+
/** Independent terminal-presentation outbox; it never scans pending proposals. */
|
|
1585
|
+
listPendingEvolutionApplicationReceipts(limit) {
|
|
1586
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
|
|
1587
|
+
throw new EvolutionStoreError('invalid-input', 'application outbox limit must be between 1 and 1000');
|
|
1588
|
+
}
|
|
1589
|
+
const rows = this.#database.prepare(`
|
|
1590
|
+
SELECT receipt.*, outbox.state, outbox.attempt_count, outbox.updated_at,
|
|
1591
|
+
outbox.published_at, outbox.last_error
|
|
1592
|
+
FROM evolution_application_outbox outbox
|
|
1593
|
+
JOIN evolution_application_receipts receipt
|
|
1594
|
+
ON receipt.local_proposal_id = outbox.local_proposal_id
|
|
1595
|
+
WHERE outbox.state = 'pending'
|
|
1596
|
+
ORDER BY outbox.updated_at, outbox.local_proposal_id
|
|
1597
|
+
LIMIT ?
|
|
1598
|
+
`).all(limit);
|
|
1599
|
+
return rows.map(row => evolutionApplicationOutbox(row));
|
|
1600
|
+
}
|
|
1601
|
+
settleEvolutionApplicationPublication(input) {
|
|
1602
|
+
const localProposalId = this.#opaque(input.localProposalId, 'localProposalId', 200);
|
|
1603
|
+
if (!/^[a-f0-9]{64}$/u.test(input.receiptDigest)) {
|
|
1604
|
+
throw new EvolutionStoreError('invalid-input', 'application receipt digest is invalid');
|
|
1605
|
+
}
|
|
1606
|
+
if (input.outcome !== 'published' && input.outcome !== 'retry') {
|
|
1607
|
+
throw new EvolutionStoreError('invalid-input', 'application publication outcome is invalid');
|
|
1608
|
+
}
|
|
1609
|
+
const error = input.error === undefined
|
|
1610
|
+
? undefined
|
|
1611
|
+
: this.#rawBounded(input.error, 'application publication error', 500);
|
|
1612
|
+
if (input.outcome === 'published' && error !== undefined) {
|
|
1613
|
+
throw new EvolutionStoreError('invalid-input', 'published application receipt cannot carry an error');
|
|
1614
|
+
}
|
|
1615
|
+
const now = this.#now();
|
|
1616
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1617
|
+
try {
|
|
1618
|
+
const receipt = this.#database.prepare(`
|
|
1619
|
+
SELECT * FROM evolution_application_receipts WHERE local_proposal_id = ?
|
|
1620
|
+
`).get(localProposalId);
|
|
1621
|
+
if (receipt === undefined || receipt.receipt_digest !== input.receiptDigest) {
|
|
1622
|
+
throw new EvolutionStoreError('idempotency-conflict', 'application publication receipt changed');
|
|
1623
|
+
}
|
|
1624
|
+
const current = this.#database.prepare(`
|
|
1625
|
+
SELECT * FROM evolution_application_outbox WHERE local_proposal_id = ?
|
|
1626
|
+
`).get(localProposalId);
|
|
1627
|
+
if (current === undefined) {
|
|
1628
|
+
throw new EvolutionStoreError('invalid-state', 'application receipt has no durable outbox row');
|
|
1629
|
+
}
|
|
1630
|
+
if (current.state !== 'published') {
|
|
1631
|
+
this.#database.prepare(`
|
|
1632
|
+
UPDATE evolution_application_outbox
|
|
1633
|
+
SET state = ?, attempt_count = attempt_count + 1, updated_at = ?,
|
|
1634
|
+
published_at = ?, last_error = ?
|
|
1635
|
+
WHERE local_proposal_id = ? AND state = 'pending'
|
|
1636
|
+
`).run(input.outcome === 'published' ? 'published' : 'pending', now, input.outcome === 'published' ? now : null, error ?? null, localProposalId);
|
|
1637
|
+
}
|
|
1638
|
+
this.#database.exec('COMMIT');
|
|
1639
|
+
}
|
|
1640
|
+
catch (failure) {
|
|
1641
|
+
this.#database.exec('ROLLBACK');
|
|
1642
|
+
throw failure;
|
|
1643
|
+
}
|
|
1644
|
+
const row = this.#database.prepare(`
|
|
1645
|
+
SELECT receipt.*, outbox.state, outbox.attempt_count, outbox.updated_at,
|
|
1646
|
+
outbox.published_at, outbox.last_error
|
|
1647
|
+
FROM evolution_application_outbox outbox
|
|
1648
|
+
JOIN evolution_application_receipts receipt
|
|
1649
|
+
ON receipt.local_proposal_id = outbox.local_proposal_id
|
|
1650
|
+
WHERE outbox.local_proposal_id = ?
|
|
1651
|
+
`).get(localProposalId);
|
|
1652
|
+
return evolutionApplicationOutbox(row);
|
|
1653
|
+
}
|
|
707
1654
|
/** Move an inspected-but-still-pending row behind its peers, durably. */
|
|
708
1655
|
deferPendingProposal(proposalId) {
|
|
709
1656
|
const now = this.#now();
|
|
710
1657
|
this.#database.prepare(`
|
|
711
1658
|
UPDATE evolution_proposals
|
|
712
1659
|
SET updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
|
|
713
|
-
WHERE id = ?
|
|
1660
|
+
WHERE id = ?
|
|
714
1661
|
`).run(now, now, proposalId);
|
|
715
1662
|
}
|
|
716
1663
|
/**
|
|
@@ -728,6 +1675,7 @@ export class EvolutionStore {
|
|
|
728
1675
|
const row = this.#requireProposal(input.proposalId);
|
|
729
1676
|
if (row.status !== 'pending') {
|
|
730
1677
|
replayed = true;
|
|
1678
|
+
this.#recordEvolutionApplicationReceipt(row);
|
|
731
1679
|
this.#database.exec('COMMIT');
|
|
732
1680
|
}
|
|
733
1681
|
else {
|
|
@@ -788,6 +1736,7 @@ export class EvolutionStore {
|
|
|
788
1736
|
if (updated.changes !== 1) {
|
|
789
1737
|
throw new EvolutionStoreError('invalid-state', 'pending proposal settlement lost its transaction lock');
|
|
790
1738
|
}
|
|
1739
|
+
this.#recordEvolutionApplicationReceipt(this.#requireProposal(input.proposalId));
|
|
791
1740
|
this.#database.exec('COMMIT');
|
|
792
1741
|
}
|
|
793
1742
|
}
|
|
@@ -802,10 +1751,175 @@ export class EvolutionStore {
|
|
|
802
1751
|
rule: settled.result_rule_id === null ? undefined : this.getRule(settled.result_rule_id),
|
|
803
1752
|
};
|
|
804
1753
|
}
|
|
1754
|
+
#backfillEvolutionApplicationReceipts() {
|
|
1755
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1756
|
+
try {
|
|
1757
|
+
const rows = this.#database.prepare(`
|
|
1758
|
+
SELECT proposal.* FROM evolution_proposals proposal
|
|
1759
|
+
LEFT JOIN evolution_application_receipts receipt
|
|
1760
|
+
ON receipt.local_proposal_id = proposal.id
|
|
1761
|
+
WHERE proposal.status <> 'pending' AND proposal.policy_proposal_id IS NOT NULL
|
|
1762
|
+
AND receipt.local_proposal_id IS NULL
|
|
1763
|
+
ORDER BY proposal.updated_at, proposal.id
|
|
1764
|
+
`).all();
|
|
1765
|
+
for (const row of rows)
|
|
1766
|
+
this.#recordEvolutionApplicationReceipt(row);
|
|
1767
|
+
this.#database.exec('COMMIT');
|
|
1768
|
+
}
|
|
1769
|
+
catch (error) {
|
|
1770
|
+
this.#database.exec('ROLLBACK');
|
|
1771
|
+
throw error;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
/** Must be called while the proposal/rule writer transaction is held. */
|
|
1775
|
+
#recordEvolutionApplicationReceipt(row) {
|
|
1776
|
+
if (row.status === 'pending' || row.policy_proposal_id === null)
|
|
1777
|
+
return;
|
|
1778
|
+
// The receipt describes the atomic application result at the proposal's
|
|
1779
|
+
// terminal linearization point. A later authoritative evidence correction
|
|
1780
|
+
// may legitimately retire that rule, so terminal replay must not rebuild
|
|
1781
|
+
// the old receipt from the rule's newer state. Keep the immutable winner,
|
|
1782
|
+
// but still bind it to this exact local/Policy proposal pair so a swapped or
|
|
1783
|
+
// forged receipt cannot be accepted during recovery.
|
|
1784
|
+
const existing = this.#database.prepare(`
|
|
1785
|
+
SELECT * FROM evolution_application_receipts WHERE local_proposal_id = ?
|
|
1786
|
+
`).get(row.id);
|
|
1787
|
+
if (existing !== undefined) {
|
|
1788
|
+
if (existing.local_proposal_id !== row.id
|
|
1789
|
+
|| existing.policy_proposal_id !== row.policy_proposal_id) {
|
|
1790
|
+
throw new EvolutionStoreError('idempotency-conflict', 'application receipt proposal binding changed after settlement');
|
|
1791
|
+
}
|
|
1792
|
+
const immutable = {
|
|
1793
|
+
schemaVersion: 1,
|
|
1794
|
+
localProposalId: existing.local_proposal_id,
|
|
1795
|
+
policyProposalId: existing.policy_proposal_id,
|
|
1796
|
+
applicationStatus: existing.application_status,
|
|
1797
|
+
operation: existing.operation,
|
|
1798
|
+
terminalAt: existing.terminal_at,
|
|
1799
|
+
revision: existing.revision,
|
|
1800
|
+
...(existing.rule_id === null ? {} : { ruleId: existing.rule_id }),
|
|
1801
|
+
...(existing.resulting_rule_version === null
|
|
1802
|
+
? {} : { resultingRuleVersion: existing.resulting_rule_version }),
|
|
1803
|
+
...(existing.rule_status === null ? {} : { ruleStatus: existing.rule_status }),
|
|
1804
|
+
};
|
|
1805
|
+
if (existing.receipt_digest
|
|
1806
|
+
!== digest(['evolution-application-receipt/v1', immutable])) {
|
|
1807
|
+
throw new EvolutionStoreError('idempotency-conflict', 'application receipt payload changed after settlement');
|
|
1808
|
+
}
|
|
1809
|
+
this.#database.prepare(`
|
|
1810
|
+
INSERT INTO evolution_application_outbox(
|
|
1811
|
+
local_proposal_id, state, attempt_count, updated_at, published_at, last_error)
|
|
1812
|
+
VALUES (?, 'pending', 0, ?, NULL, NULL)
|
|
1813
|
+
ON CONFLICT(local_proposal_id) DO NOTHING
|
|
1814
|
+
`).run(row.id, existing.terminal_at);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const operation = this.#evolutionApplicationOperation(row);
|
|
1818
|
+
// An ancient or externally corrupted row with no recoverable operation is
|
|
1819
|
+
// not safe to present. Normal settlements always have a validated operation.
|
|
1820
|
+
if (operation === undefined)
|
|
1821
|
+
return;
|
|
1822
|
+
const applicationStatus = row.status === 'approved' ? 'applied' : row.status;
|
|
1823
|
+
if (applicationStatus !== 'applied' && applicationStatus !== 'conflicted'
|
|
1824
|
+
&& applicationStatus !== 'expired' && applicationStatus !== 'rejected')
|
|
1825
|
+
return;
|
|
1826
|
+
let targetRuleId = row.result_rule_id ?? undefined;
|
|
1827
|
+
if (targetRuleId === undefined) {
|
|
1828
|
+
try {
|
|
1829
|
+
const raw = JSON.parse(row.mutation_json);
|
|
1830
|
+
if (typeof raw.ruleId === 'string')
|
|
1831
|
+
targetRuleId = raw.ruleId;
|
|
1832
|
+
}
|
|
1833
|
+
catch {
|
|
1834
|
+
// The operation may still be recoverable from the creation intent.
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
const targetRule = targetRuleId === undefined
|
|
1838
|
+
? undefined
|
|
1839
|
+
: this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
|
|
1840
|
+
.get(targetRuleId);
|
|
1841
|
+
if (applicationStatus === 'applied' && targetRule === undefined) {
|
|
1842
|
+
throw new EvolutionStoreError('invalid-state', 'applied evolution proposal has no exact resulting rule state');
|
|
1843
|
+
}
|
|
1844
|
+
const canonical = {
|
|
1845
|
+
schemaVersion: 1,
|
|
1846
|
+
localProposalId: row.id,
|
|
1847
|
+
policyProposalId: row.policy_proposal_id,
|
|
1848
|
+
applicationStatus,
|
|
1849
|
+
operation,
|
|
1850
|
+
terminalAt: row.updated_at,
|
|
1851
|
+
revision: Math.max(2, row.version),
|
|
1852
|
+
...(targetRuleId === undefined ? {} : { ruleId: targetRuleId }),
|
|
1853
|
+
...(targetRule === undefined ? {} : {
|
|
1854
|
+
resultingRuleVersion: targetRule.version,
|
|
1855
|
+
ruleStatus: targetRule.status,
|
|
1856
|
+
}),
|
|
1857
|
+
};
|
|
1858
|
+
const receiptDigest = digest(['evolution-application-receipt/v1', canonical]);
|
|
1859
|
+
this.#database.prepare(`
|
|
1860
|
+
INSERT INTO evolution_application_receipts(
|
|
1861
|
+
local_proposal_id, policy_proposal_id, application_status, operation,
|
|
1862
|
+
terminal_at, receipt_digest, revision, rule_id, resulting_rule_version, rule_status)
|
|
1863
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1864
|
+
ON CONFLICT(local_proposal_id) DO NOTHING
|
|
1865
|
+
`).run(canonical.localProposalId, canonical.policyProposalId, canonical.applicationStatus, canonical.operation, canonical.terminalAt, receiptDigest, canonical.revision, targetRuleId ?? null, targetRule?.version ?? null, targetRule?.status ?? null);
|
|
1866
|
+
const saved = this.#database.prepare(`
|
|
1867
|
+
SELECT * FROM evolution_application_receipts WHERE local_proposal_id = ?
|
|
1868
|
+
`).get(row.id);
|
|
1869
|
+
const expected = evolutionApplicationReceipt({
|
|
1870
|
+
local_proposal_id: canonical.localProposalId,
|
|
1871
|
+
policy_proposal_id: canonical.policyProposalId,
|
|
1872
|
+
application_status: canonical.applicationStatus,
|
|
1873
|
+
operation: canonical.operation,
|
|
1874
|
+
terminal_at: canonical.terminalAt,
|
|
1875
|
+
receipt_digest: receiptDigest,
|
|
1876
|
+
revision: canonical.revision,
|
|
1877
|
+
rule_id: targetRuleId ?? null,
|
|
1878
|
+
resulting_rule_version: targetRule?.version ?? null,
|
|
1879
|
+
rule_status: targetRule?.status ?? null,
|
|
1880
|
+
});
|
|
1881
|
+
if (JSON.stringify(evolutionApplicationReceipt(saved)) !== JSON.stringify(expected)) {
|
|
1882
|
+
throw new EvolutionStoreError('idempotency-conflict', 'application receipt changed after settlement');
|
|
1883
|
+
}
|
|
1884
|
+
this.#database.prepare(`
|
|
1885
|
+
INSERT INTO evolution_application_outbox(
|
|
1886
|
+
local_proposal_id, state, attempt_count, updated_at, published_at, last_error)
|
|
1887
|
+
VALUES (?, 'pending', 0, ?, NULL, NULL)
|
|
1888
|
+
ON CONFLICT(local_proposal_id) DO NOTHING
|
|
1889
|
+
`).run(row.id, row.updated_at);
|
|
1890
|
+
}
|
|
1891
|
+
#evolutionApplicationOperation(row) {
|
|
1892
|
+
try {
|
|
1893
|
+
const mutation = JSON.parse(row.mutation_json);
|
|
1894
|
+
if (mutation.op === 'adopt' || mutation.op === 'owner-undo' || mutation.op === 'retire') {
|
|
1895
|
+
return mutation.op;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
catch {
|
|
1899
|
+
// Fall through to the immutable Policy creation intent.
|
|
1900
|
+
}
|
|
1901
|
+
try {
|
|
1902
|
+
const intent = row.creation_intent_json === null
|
|
1903
|
+
? undefined
|
|
1904
|
+
: JSON.parse(row.creation_intent_json);
|
|
1905
|
+
if (intent?.action === 'evolution.adopt')
|
|
1906
|
+
return 'adopt';
|
|
1907
|
+
if (intent?.action === 'evolution.owner-undo')
|
|
1908
|
+
return 'owner-undo';
|
|
1909
|
+
if (intent?.action === 'evolution.retire')
|
|
1910
|
+
return 'retire';
|
|
1911
|
+
}
|
|
1912
|
+
catch {
|
|
1913
|
+
return undefined;
|
|
1914
|
+
}
|
|
1915
|
+
return undefined;
|
|
1916
|
+
}
|
|
805
1917
|
#applyMutation(mutation, proposalScopeKey, now) {
|
|
806
1918
|
if (mutation.op === 'adopt') {
|
|
807
1919
|
const ruleId = mutation.ruleId;
|
|
808
|
-
if (ruleId === undefined || mutation.input.scopeKey !== proposalScopeKey
|
|
1920
|
+
if (ruleId === undefined || mutation.input.scopeKey !== proposalScopeKey
|
|
1921
|
+
|| (mutation.evidence !== undefined
|
|
1922
|
+
&& !this.#adoptionEvidenceMatches(mutation, mutation.evidence))) {
|
|
809
1923
|
return { status: 'conflicted', ruleId: undefined };
|
|
810
1924
|
}
|
|
811
1925
|
// A second active rule for the same situation would make injected guidance
|
|
@@ -825,16 +1939,24 @@ export class EvolutionStore {
|
|
|
825
1939
|
}
|
|
826
1940
|
const existing = this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
|
|
827
1941
|
.get(mutation.ruleId);
|
|
1942
|
+
const evidenceMismatch = mutation.op === 'retire' && existing !== undefined && (mutation.scopeKey !== existing.scope_key
|
|
1943
|
+
|| mutation.situation !== existing.situation
|
|
1944
|
+
|| mutation.guidance !== existing.guidance
|
|
1945
|
+
|| mutation.generation !== existing.generation
|
|
1946
|
+
|| mutation.evaluation.scopeKey !== existing.scope_key
|
|
1947
|
+
|| mutation.evaluation.situation !== existing.situation
|
|
1948
|
+
|| mutation.baseline.scopeKey !== existing.scope_key
|
|
1949
|
+
|| mutation.baseline.situation !== existing.situation
|
|
1950
|
+
|| mutation.baseline.failures !== existing.baseline_failures
|
|
1951
|
+
|| mutation.baseline.total !== existing.baseline_total
|
|
1952
|
+
|| !this.#retirementEvidenceMatches(mutation, existing));
|
|
828
1953
|
if (existing === undefined || existing.scope_key !== proposalScopeKey || existing.status !== 'active'
|
|
829
1954
|
|| existing.version !== mutation.expectedVersion
|
|
830
|
-
|| (mutation.
|
|
831
|
-
|
|
832
|
-
|| mutation.
|
|
833
|
-
|| mutation.
|
|
834
|
-
|
|
835
|
-
|| mutation.baseline.failures !== existing.baseline_failures
|
|
836
|
-
|| mutation.baseline.total !== existing.baseline_total
|
|
837
|
-
|| !this.#retirementEvidenceSamplesMatch(mutation, existing)))) {
|
|
1955
|
+
|| (mutation.op === 'owner-undo' && (mutation.scopeKey !== proposalScopeKey
|
|
1956
|
+
|| mutation.situation !== existing.situation
|
|
1957
|
+
|| mutation.guidance !== existing.guidance
|
|
1958
|
+
|| mutation.generation !== existing.generation))
|
|
1959
|
+
|| evidenceMismatch) {
|
|
838
1960
|
return { status: 'conflicted', ruleId: undefined };
|
|
839
1961
|
}
|
|
840
1962
|
const version = existing.version + 1;
|
|
@@ -843,10 +1965,198 @@ export class EvolutionStore {
|
|
|
843
1965
|
SET status = 'retired', retired_reason = ?, updated_at = ?, version = ?
|
|
844
1966
|
WHERE id = ? AND version = ?
|
|
845
1967
|
`).run(mutation.reason, now, version, mutation.ruleId, mutation.expectedVersion);
|
|
846
|
-
this.#audit('retire', mutation.ruleId, version, now);
|
|
1968
|
+
this.#audit(mutation.op === 'owner-undo' ? 'owner-undo' : 'retire', mutation.ruleId, version, now);
|
|
847
1969
|
return { status: 'approved', ruleId: mutation.ruleId };
|
|
848
1970
|
}
|
|
849
|
-
#
|
|
1971
|
+
#normalizeSupervisedGrowthAnalystEvidence(scopeKey, candidate, evidenceWindow) {
|
|
1972
|
+
this.#requireWindow(evidenceWindow);
|
|
1973
|
+
if (candidate.kind !== 'adopt' || candidate.scopeKey !== scopeKey) {
|
|
1974
|
+
throw new EvolutionStoreError('invalid-input', 'analyst review requires one scoped adoption candidate');
|
|
1975
|
+
}
|
|
1976
|
+
const stats = this.#validateStats(candidate.stats, 'analyst candidate');
|
|
1977
|
+
const situation = this.#situation(candidate.situation);
|
|
1978
|
+
if (stats.scopeKey !== scopeKey || stats.situation !== situation
|
|
1979
|
+
|| candidate.evidenceTotal !== stats.total || evidenceWindow < candidate.evidenceTotal) {
|
|
1980
|
+
throw new EvolutionStoreError('invalid-input', 'analyst candidate statistics are inconsistent');
|
|
1981
|
+
}
|
|
1982
|
+
const reference = this.#validateEvidenceReference({
|
|
1983
|
+
sampleEpisodeIds: candidate.evidence.map(entry => entry.episodeId),
|
|
1984
|
+
digest: candidate.evidenceDigest,
|
|
1985
|
+
total: candidate.evidenceTotal,
|
|
1986
|
+
window: evidenceWindow,
|
|
1987
|
+
scopeWatermark: candidate.scopeWatermark,
|
|
1988
|
+
taskRevisions: candidate.taskRevisions,
|
|
1989
|
+
}, stats.total);
|
|
1990
|
+
if (candidate.evidence.length !== reference.sampleEpisodeIds.length) {
|
|
1991
|
+
throw new EvolutionStoreError('invalid-input', 'analyst evidence projection is incomplete');
|
|
1992
|
+
}
|
|
1993
|
+
const evidence = candidate.evidence.map((entry, index) => {
|
|
1994
|
+
const episodeId = reference.sampleEpisodeIds[index];
|
|
1995
|
+
if (entry.episodeId !== episodeId
|
|
1996
|
+
|| (entry.outcome !== 'succeeded' && entry.outcome !== 'failed')
|
|
1997
|
+
|| (entry.evidenceKind !== 'objective' && entry.evidenceKind !== 'verification')
|
|
1998
|
+
|| !Number.isSafeInteger(entry.occurredAt) || entry.occurredAt < 0) {
|
|
1999
|
+
throw new EvolutionStoreError('invalid-input', 'analyst evidence sample is invalid');
|
|
2000
|
+
}
|
|
2001
|
+
return Object.freeze({
|
|
2002
|
+
episodeId,
|
|
2003
|
+
outcome: entry.outcome,
|
|
2004
|
+
evidenceKind: entry.evidenceKind,
|
|
2005
|
+
evidenceRef: this.#opaque(entry.evidenceRef, 'evidenceRef', 500),
|
|
2006
|
+
detail: this.#bounded(entry.detail, 'detail', this.#maxDetailBytes),
|
|
2007
|
+
occurredAt: entry.occurredAt,
|
|
2008
|
+
});
|
|
2009
|
+
});
|
|
2010
|
+
return Object.freeze({
|
|
2011
|
+
contractVersion: SUPERVISED_GROWTH_ANALYST_CONTRACT_VERSION,
|
|
2012
|
+
situation,
|
|
2013
|
+
failures: stats.failures,
|
|
2014
|
+
total: stats.total,
|
|
2015
|
+
evidenceDigest: reference.digest,
|
|
2016
|
+
evidenceTotal: reference.total,
|
|
2017
|
+
evidenceWindow,
|
|
2018
|
+
sampleEpisodeIds: reference.sampleEpisodeIds,
|
|
2019
|
+
evidence: Object.freeze(evidence),
|
|
2020
|
+
scopeWatermark: reference.scopeWatermark,
|
|
2021
|
+
taskRevisions: reference.taskRevisions,
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
#supervisedGrowthAnalystEvidenceMatches(scopeKey, evidence) {
|
|
2025
|
+
const mutation = {
|
|
2026
|
+
op: 'adopt',
|
|
2027
|
+
input: {
|
|
2028
|
+
scopeKey,
|
|
2029
|
+
situation: evidence.situation,
|
|
2030
|
+
guidance: 'Pending supervised analyst guidance.',
|
|
2031
|
+
},
|
|
2032
|
+
baseline: {
|
|
2033
|
+
scopeKey,
|
|
2034
|
+
situation: evidence.situation,
|
|
2035
|
+
failures: evidence.failures,
|
|
2036
|
+
total: evidence.total,
|
|
2037
|
+
},
|
|
2038
|
+
evidence: {
|
|
2039
|
+
sampleEpisodeIds: evidence.sampleEpisodeIds,
|
|
2040
|
+
digest: evidence.evidenceDigest,
|
|
2041
|
+
total: evidence.evidenceTotal,
|
|
2042
|
+
window: evidence.evidenceWindow,
|
|
2043
|
+
scopeWatermark: evidence.scopeWatermark,
|
|
2044
|
+
taskRevisions: evidence.taskRevisions,
|
|
2045
|
+
},
|
|
2046
|
+
};
|
|
2047
|
+
return this.#proposalEvidenceMatches(mutation);
|
|
2048
|
+
}
|
|
2049
|
+
#sameSupervisedGrowthAnalystReview(row, evidence, sampleJson, evidenceJson, taskRevisionsJson) {
|
|
2050
|
+
return row.contract_version === evidence.contractVersion
|
|
2051
|
+
&& row.situation === evidence.situation
|
|
2052
|
+
&& row.failures === evidence.failures
|
|
2053
|
+
&& row.total === evidence.total
|
|
2054
|
+
&& row.evidence_digest === evidence.evidenceDigest
|
|
2055
|
+
&& row.evidence_total === evidence.evidenceTotal
|
|
2056
|
+
&& row.evidence_window === evidence.evidenceWindow
|
|
2057
|
+
&& row.sample_episode_ids_json === sampleJson
|
|
2058
|
+
&& row.evidence_json === evidenceJson
|
|
2059
|
+
&& row.scope_watermark === evidence.scopeWatermark
|
|
2060
|
+
&& row.task_revisions_json === taskRevisionsJson;
|
|
2061
|
+
}
|
|
2062
|
+
#supervisedGrowthAnalystMutationMatches(mutation, evidence) {
|
|
2063
|
+
return mutation.input.scopeKey !== ''
|
|
2064
|
+
&& mutation.input.situation === evidence.situation
|
|
2065
|
+
&& mutation.baseline.scopeKey === mutation.input.scopeKey
|
|
2066
|
+
&& mutation.baseline.situation === evidence.situation
|
|
2067
|
+
&& mutation.baseline.failures === evidence.failures
|
|
2068
|
+
&& mutation.baseline.total === evidence.total
|
|
2069
|
+
&& mutation.evidence !== undefined
|
|
2070
|
+
&& mutation.evidence.digest === evidence.evidenceDigest
|
|
2071
|
+
&& mutation.evidence.total === evidence.evidenceTotal
|
|
2072
|
+
&& mutation.evidence.window === evidence.evidenceWindow
|
|
2073
|
+
&& mutation.evidence.scopeWatermark === evidence.scopeWatermark
|
|
2074
|
+
&& mutation.evidence.taskRevisions !== undefined
|
|
2075
|
+
&& this.#sameTaskRevisions(mutation.evidence.taskRevisions, evidence.taskRevisions)
|
|
2076
|
+
&& this.#sameEvidenceSamples(evidence.evidence, mutation.evidence.sampleEpisodeIds);
|
|
2077
|
+
}
|
|
2078
|
+
#supervisedGrowthAnalystProposalMatches(row, evidence) {
|
|
2079
|
+
let mutation;
|
|
2080
|
+
try {
|
|
2081
|
+
mutation = this.#validateStoredMutation(JSON.parse(row.mutation_json));
|
|
2082
|
+
}
|
|
2083
|
+
catch {
|
|
2084
|
+
return false;
|
|
2085
|
+
}
|
|
2086
|
+
if (mutation.op !== 'adopt' || mutation.evidence === undefined
|
|
2087
|
+
|| row.scope_key !== mutation.input.scopeKey
|
|
2088
|
+
|| !this.#supervisedGrowthAnalystMutationMatches(mutation, evidence))
|
|
2089
|
+
return false;
|
|
2090
|
+
const expectedKey = supervisedGrowthAnalystProposalIdempotencyKey({
|
|
2091
|
+
scopeKey: row.scope_key,
|
|
2092
|
+
situation: evidence.situation,
|
|
2093
|
+
evidenceDigest: evidence.evidenceDigest,
|
|
2094
|
+
evidenceTotal: evidence.evidenceTotal,
|
|
2095
|
+
contractVersion: evidence.contractVersion,
|
|
2096
|
+
});
|
|
2097
|
+
return row.idempotency_key === expectedKey && row.mutation_hash === digest(mutation);
|
|
2098
|
+
}
|
|
2099
|
+
#analystReviewToken(value) {
|
|
2100
|
+
const token = this.#opaque(value, 'reviewToken', 200);
|
|
2101
|
+
if (!/^analyst-review-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
|
|
2102
|
+
.test(token)) {
|
|
2103
|
+
throw new EvolutionStoreError('invalid-input', 'analyst review token is invalid');
|
|
2104
|
+
}
|
|
2105
|
+
return token;
|
|
2106
|
+
}
|
|
2107
|
+
#proposalEvidenceMatches(mutation) {
|
|
2108
|
+
if (mutation.op === 'owner-undo')
|
|
2109
|
+
return true;
|
|
2110
|
+
if (mutation.op === 'adopt') {
|
|
2111
|
+
return mutation.evidence === undefined
|
|
2112
|
+
|| this.#adoptionEvidenceMatches(mutation, mutation.evidence);
|
|
2113
|
+
}
|
|
2114
|
+
const active = this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
|
|
2115
|
+
.get(mutation.ruleId);
|
|
2116
|
+
return active !== undefined && this.#retirementEvidenceMatches(mutation, active);
|
|
2117
|
+
}
|
|
2118
|
+
#adoptionEvidenceMatches(mutation, evidence) {
|
|
2119
|
+
if (evidence.window !== undefined) {
|
|
2120
|
+
if (this.activeRule(mutation.input.scopeKey, mutation.input.situation) !== undefined)
|
|
2121
|
+
return false;
|
|
2122
|
+
const episodes = this.#adoptionEpisodes(mutation.input.scopeKey, mutation.input.situation, evidence.window);
|
|
2123
|
+
const stats = this.#statsFromEpisodes(mutation.input.scopeKey, mutation.input.situation, episodes);
|
|
2124
|
+
const current = this.#candidateEvidence(episodes, evidence.sampleEpisodeIds.length);
|
|
2125
|
+
return stats.failures === mutation.baseline.failures
|
|
2126
|
+
&& stats.total === mutation.baseline.total
|
|
2127
|
+
&& current.evidenceTotal === evidence.total
|
|
2128
|
+
&& current.evidenceDigest === evidence.digest
|
|
2129
|
+
&& (evidence.scopeWatermark === undefined
|
|
2130
|
+
|| current.scopeWatermark === evidence.scopeWatermark)
|
|
2131
|
+
&& (evidence.taskRevisions === undefined
|
|
2132
|
+
|| this.#sameTaskRevisions(current.taskRevisions, evidence.taskRevisions))
|
|
2133
|
+
&& this.#sameEvidenceSamples(current.evidence, evidence.sampleEpisodeIds);
|
|
2134
|
+
}
|
|
2135
|
+
const ids = evidence.sampleEpisodeIds;
|
|
2136
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
2137
|
+
const rows = this.#database.prepare(`
|
|
2138
|
+
SELECT * FROM evolution_episodes WHERE id IN (${placeholders})
|
|
2139
|
+
`).all(...ids);
|
|
2140
|
+
return rows.length === ids.length && rows.every(row => row.scope_key === mutation.input.scopeKey
|
|
2141
|
+
&& row.situation === mutation.input.situation && row.trust === 'trusted'
|
|
2142
|
+
&& row.learning_eligible === 1 && row.rule_id === null);
|
|
2143
|
+
}
|
|
2144
|
+
#retirementEvidenceMatches(mutation, active) {
|
|
2145
|
+
if (mutation.evidence.window !== undefined) {
|
|
2146
|
+
const currentRule = rule(active);
|
|
2147
|
+
const episodes = this.#evaluationEpisodes(currentRule, mutation.evidence.window);
|
|
2148
|
+
const stats = this.#statsFromEpisodes(active.scope_key, active.situation, episodes);
|
|
2149
|
+
const current = this.#candidateEvidence(episodes, mutation.evidence.sampleEpisodeIds.length);
|
|
2150
|
+
return stats.failures === mutation.evaluation.failures
|
|
2151
|
+
&& stats.total === mutation.evaluation.total
|
|
2152
|
+
&& current.evidenceTotal === mutation.evidence.total
|
|
2153
|
+
&& current.evidenceDigest === mutation.evidence.digest
|
|
2154
|
+
&& (mutation.evidence.scopeWatermark === undefined
|
|
2155
|
+
|| current.scopeWatermark === mutation.evidence.scopeWatermark)
|
|
2156
|
+
&& (mutation.evidence.taskRevisions === undefined
|
|
2157
|
+
|| this.#sameTaskRevisions(current.taskRevisions, mutation.evidence.taskRevisions))
|
|
2158
|
+
&& this.#sameEvidenceSamples(current.evidence, mutation.evidence.sampleEpisodeIds);
|
|
2159
|
+
}
|
|
850
2160
|
const ids = mutation.evidence.sampleEpisodeIds;
|
|
851
2161
|
const placeholders = ids.map(() => '?').join(',');
|
|
852
2162
|
const rows = this.#database.prepare(`
|
|
@@ -854,9 +2164,18 @@ export class EvolutionStore {
|
|
|
854
2164
|
`).all(...ids);
|
|
855
2165
|
return rows.length === ids.length && rows.every(row => row.scope_key === active.scope_key
|
|
856
2166
|
&& row.situation === active.situation && row.trust === 'trusted'
|
|
2167
|
+
&& row.learning_eligible === 1
|
|
857
2168
|
&& row.rule_id === active.id && row.guidance_version === active.generation
|
|
858
2169
|
&& row.occurred_at > active.adopted_at);
|
|
859
2170
|
}
|
|
2171
|
+
#sameEvidenceSamples(current, expectedIds) {
|
|
2172
|
+
return current.length === expectedIds.length
|
|
2173
|
+
&& current.every((entry, index) => entry.episodeId === expectedIds[index]);
|
|
2174
|
+
}
|
|
2175
|
+
#sameTaskRevisions(current, expected) {
|
|
2176
|
+
return current.length === expected.length
|
|
2177
|
+
&& current.every((entry, index) => JSON.stringify(entry) === JSON.stringify(expected[index]));
|
|
2178
|
+
}
|
|
860
2179
|
#audit(operation, ruleId, resultVersion, now) {
|
|
861
2180
|
this.#database.prepare(`
|
|
862
2181
|
INSERT INTO evolution_audit(idempotency_key, operation, rule_id, result_version, occurred_at)
|
|
@@ -899,20 +2218,41 @@ export class EvolutionStore {
|
|
|
899
2218
|
if (!Number.isSafeInteger(mutation.expectedVersion) || mutation.expectedVersion < 1) {
|
|
900
2219
|
throw new EvolutionStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
901
2220
|
}
|
|
902
|
-
const ruleId = this.#
|
|
2221
|
+
const ruleId = this.#serverRuleId(mutation.ruleId);
|
|
2222
|
+
if (mutation.op === 'owner-undo') {
|
|
2223
|
+
return {
|
|
2224
|
+
op: 'owner-undo',
|
|
2225
|
+
scopeKey: this.#scopeKey(mutation.scopeKey),
|
|
2226
|
+
ruleId,
|
|
2227
|
+
situation: this.#situation(mutation.situation),
|
|
2228
|
+
guidance: this.#bounded(mutation.guidance, 'guidance', this.#maxGuidanceBytes),
|
|
2229
|
+
generation: this.#positiveVersion(mutation.generation, 'generation'),
|
|
2230
|
+
expectedVersion: mutation.expectedVersion,
|
|
2231
|
+
reason: this.#bounded(mutation.reason, 'reason', this.#maxDetailBytes),
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
903
2234
|
if (mutation.evaluation === undefined || mutation.baseline === undefined
|
|
904
2235
|
|| mutation.evidence === undefined) {
|
|
905
2236
|
throw new EvolutionStoreError('invalid-input', 'retirement evidence snapshot must be complete');
|
|
906
2237
|
}
|
|
907
2238
|
const evaluation = this.#validateStats(mutation.evaluation, 'retirement evaluation');
|
|
908
2239
|
const baseline = this.#validateStats(mutation.baseline, 'retirement baseline');
|
|
909
|
-
|
|
2240
|
+
const scopeKey = this.#scopeKey(mutation.scopeKey);
|
|
2241
|
+
const situation = this.#situation(mutation.situation);
|
|
2242
|
+
const guidance = this.#bounded(mutation.guidance, 'guidance', this.#maxGuidanceBytes);
|
|
2243
|
+
const generation = this.#positiveVersion(mutation.generation, 'generation');
|
|
2244
|
+
if (evaluation.scopeKey !== baseline.scopeKey || evaluation.situation !== baseline.situation
|
|
2245
|
+
|| evaluation.scopeKey !== scopeKey || evaluation.situation !== situation) {
|
|
910
2246
|
throw new EvolutionStoreError('invalid-input', 'retirement evaluation and baseline must match');
|
|
911
2247
|
}
|
|
912
2248
|
const evidence = this.#validateEvidenceReference(mutation.evidence, evaluation.total);
|
|
913
2249
|
return {
|
|
914
2250
|
op: 'retire',
|
|
2251
|
+
scopeKey,
|
|
915
2252
|
ruleId,
|
|
2253
|
+
situation,
|
|
2254
|
+
guidance,
|
|
2255
|
+
generation,
|
|
916
2256
|
expectedVersion: mutation.expectedVersion,
|
|
917
2257
|
reason: this.#bounded(mutation.reason, 'reason', this.#maxDetailBytes),
|
|
918
2258
|
evaluation,
|
|
@@ -922,7 +2262,7 @@ export class EvolutionStore {
|
|
|
922
2262
|
}
|
|
923
2263
|
#validateStoredMutation(mutation) {
|
|
924
2264
|
const normalized = this.#validateMutation(mutation);
|
|
925
|
-
if (normalized.op
|
|
2265
|
+
if (normalized.op !== 'adopt')
|
|
926
2266
|
return normalized;
|
|
927
2267
|
const ruleId = mutation.op === 'adopt' && mutation.ruleId !== undefined
|
|
928
2268
|
? this.#serverRuleId(mutation.ruleId)
|
|
@@ -933,7 +2273,7 @@ export class EvolutionStore {
|
|
|
933
2273
|
return { ...normalized, ruleId };
|
|
934
2274
|
}
|
|
935
2275
|
#proposalIntent(mutation) {
|
|
936
|
-
if (mutation.op
|
|
2276
|
+
if (mutation.op !== 'adopt' || mutation.ruleId === undefined)
|
|
937
2277
|
return mutation;
|
|
938
2278
|
const { ruleId: _ruleId, ...intent } = mutation;
|
|
939
2279
|
return intent;
|
|
@@ -946,6 +2286,12 @@ export class EvolutionStore {
|
|
|
946
2286
|
}
|
|
947
2287
|
return ruleId;
|
|
948
2288
|
}
|
|
2289
|
+
#positiveVersion(value, field) {
|
|
2290
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 1_000_000_000) {
|
|
2291
|
+
throw new EvolutionStoreError('invalid-input', `${field} must be a positive safe integer`);
|
|
2292
|
+
}
|
|
2293
|
+
return value;
|
|
2294
|
+
}
|
|
949
2295
|
#optionalServerRuleId(value) {
|
|
950
2296
|
const normalized = value.normalize('NFC').trim();
|
|
951
2297
|
const pattern = /^rule-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
@@ -980,10 +2326,51 @@ export class EvolutionStore {
|
|
|
980
2326
|
if (new Set(sampleEpisodeIds).size !== sampleEpisodeIds.length) {
|
|
981
2327
|
throw new EvolutionStoreError('invalid-input', 'evidence sample IDs must be unique');
|
|
982
2328
|
}
|
|
2329
|
+
const window = input.window;
|
|
2330
|
+
if (window !== undefined)
|
|
2331
|
+
this.#requireWindow(window);
|
|
2332
|
+
const hasScopeWatermark = input.scopeWatermark !== undefined;
|
|
2333
|
+
const hasTaskRevisions = input.taskRevisions !== undefined;
|
|
2334
|
+
if (hasScopeWatermark !== hasTaskRevisions) {
|
|
2335
|
+
throw new EvolutionStoreError('invalid-input', 'evidence scope watermark and task revisions must be frozen together');
|
|
2336
|
+
}
|
|
2337
|
+
let scopeWatermark;
|
|
2338
|
+
let taskRevisions;
|
|
2339
|
+
if (hasScopeWatermark && hasTaskRevisions) {
|
|
2340
|
+
scopeWatermark = this.#positiveVersion(input.scopeWatermark, 'evidence scopeWatermark');
|
|
2341
|
+
if (!Array.isArray(input.taskRevisions) || input.taskRevisions.length !== input.total) {
|
|
2342
|
+
throw new EvolutionStoreError('invalid-input', 'evidence task revisions must cover the complete evidence window');
|
|
2343
|
+
}
|
|
2344
|
+
const seen = new Set();
|
|
2345
|
+
taskRevisions = Object.freeze(input.taskRevisions.map((entry, index) => {
|
|
2346
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)
|
|
2347
|
+
|| (entry.subjectKind !== 'automation-run' && entry.subjectKind !== 'outcome')
|
|
2348
|
+
|| entry.disposition !== 'upsert'
|
|
2349
|
+
|| !/^[a-f0-9]{64}$/u.test(entry.digest)) {
|
|
2350
|
+
throw new EvolutionStoreError('invalid-input', `evidence task revision ${index} is invalid`);
|
|
2351
|
+
}
|
|
2352
|
+
const subjectRef = this.#opaque(entry.subjectRef, 'task subjectRef', 1_000);
|
|
2353
|
+
const identity = JSON.stringify([entry.subjectKind, subjectRef]);
|
|
2354
|
+
if (seen.has(identity)) {
|
|
2355
|
+
throw new EvolutionStoreError('invalid-input', 'evidence task revisions contain a duplicate');
|
|
2356
|
+
}
|
|
2357
|
+
seen.add(identity);
|
|
2358
|
+
return Object.freeze({
|
|
2359
|
+
subjectKind: entry.subjectKind,
|
|
2360
|
+
subjectRef,
|
|
2361
|
+
version: this.#positiveVersion(entry.version, 'task revision version'),
|
|
2362
|
+
digest: entry.digest,
|
|
2363
|
+
disposition: 'upsert',
|
|
2364
|
+
});
|
|
2365
|
+
}));
|
|
2366
|
+
}
|
|
983
2367
|
return Object.freeze({
|
|
984
2368
|
sampleEpisodeIds: Object.freeze(sampleEpisodeIds),
|
|
985
2369
|
digest: input.digest,
|
|
986
2370
|
total: input.total,
|
|
2371
|
+
...(window === undefined ? {} : { window }),
|
|
2372
|
+
...(scopeWatermark === undefined ? {} : { scopeWatermark }),
|
|
2373
|
+
...(taskRevisions === undefined ? {} : { taskRevisions }),
|
|
987
2374
|
});
|
|
988
2375
|
}
|
|
989
2376
|
#validateCreationIntent(input, expected) {
|
|
@@ -1011,12 +2398,11 @@ export class EvolutionStore {
|
|
|
1011
2398
|
if (input.dispatch.principal !== expected.principal) {
|
|
1012
2399
|
throw new EvolutionStoreError('invalid-input', 'Policy dispatch is bound to another principal');
|
|
1013
2400
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
};
|
|
2401
|
+
this.#rawBounded(input.dispatch.sourceId, 'dispatch sourceId', 500);
|
|
2402
|
+
this.#rawBounded(input.dispatch.bindingId, 'dispatch bindingId', 500);
|
|
2403
|
+
this.#rawBounded(input.dispatch.workspace, 'dispatch workspace', 4_096);
|
|
2404
|
+
this.#rawBounded(input.dispatch.principal, 'dispatch principal', 500);
|
|
2405
|
+
intent.dispatch = approvalDispatch(JSON.parse(JSON.stringify(input.dispatch)), 'invalid-input', false);
|
|
1020
2406
|
}
|
|
1021
2407
|
return intent;
|
|
1022
2408
|
}
|