@dsh-enhanced/assistant-evolution 0.1.6 → 0.1.12
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 +70 -17
- package/cordis.patch.yml +4 -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 +236 -12
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +947 -42
- 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 +508 -7
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +136 -3
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +1672 -108
- package/lib/store.js.map +1 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +222 -1
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +211 -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,
|
|
@@ -76,6 +152,110 @@ function guidanceExposure(row) {
|
|
|
76
152
|
exposedAt: row.exposed_at,
|
|
77
153
|
});
|
|
78
154
|
}
|
|
155
|
+
function autonomousRollback(row, situation) {
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
scopeKey: row.scope_key,
|
|
158
|
+
ruleId: row.rule_id,
|
|
159
|
+
expectedVersion: row.expected_version,
|
|
160
|
+
resultVersion: row.result_version,
|
|
161
|
+
risk: row.risk,
|
|
162
|
+
reason: row.reason,
|
|
163
|
+
evaluation: Object.freeze({
|
|
164
|
+
scopeKey: row.scope_key,
|
|
165
|
+
situation,
|
|
166
|
+
failures: row.evaluation_failures,
|
|
167
|
+
total: row.evaluation_total,
|
|
168
|
+
}),
|
|
169
|
+
baseline: Object.freeze({
|
|
170
|
+
scopeKey: row.scope_key,
|
|
171
|
+
situation,
|
|
172
|
+
failures: row.baseline_failures,
|
|
173
|
+
total: row.baseline_total,
|
|
174
|
+
}),
|
|
175
|
+
evidence: Object.freeze({
|
|
176
|
+
sampleEpisodeIds: Object.freeze(JSON.parse(row.sample_episode_ids_json)),
|
|
177
|
+
digest: row.evidence_digest,
|
|
178
|
+
total: row.evidence_total,
|
|
179
|
+
}),
|
|
180
|
+
occurredAt: row.occurred_at,
|
|
181
|
+
});
|
|
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
|
+
}
|
|
79
259
|
/**
|
|
80
260
|
* Durable evidence and rule ledger.
|
|
81
261
|
*
|
|
@@ -97,10 +277,80 @@ export class EvolutionStore {
|
|
|
97
277
|
this.#maxSituationBytes = options.maxSituationBytes ?? 200;
|
|
98
278
|
this.#maxGuidanceBytes = options.maxGuidanceBytes ?? 2_048;
|
|
99
279
|
this.#maxDetailBytes = options.maxDetailBytes ?? 1_024;
|
|
280
|
+
this.#backfillEvolutionApplicationReceipts();
|
|
100
281
|
}
|
|
101
282
|
close() {
|
|
102
283
|
this.#database.close();
|
|
103
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Return aggregate operational state only. The query deliberately projects no
|
|
287
|
+
* situation, scope, guidance, detail, principal, path, or proposal body.
|
|
288
|
+
*/
|
|
289
|
+
health() {
|
|
290
|
+
const row = this.#database.prepare(`
|
|
291
|
+
SELECT
|
|
292
|
+
(SELECT COUNT(*) FROM evolution_rules WHERE status = 'active') AS active_rules,
|
|
293
|
+
(SELECT COUNT(*) FROM evolution_rules WHERE status = 'retired') AS retired_rules,
|
|
294
|
+
(SELECT COUNT(*) FROM evolution_proposals WHERE status = 'pending') AS pending_proposals,
|
|
295
|
+
(SELECT COUNT(*) FROM evolution_proposals WHERE status = 'conflicted') AS conflicted_proposals,
|
|
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,
|
|
303
|
+
(SELECT COUNT(*) FROM evolution_episodes
|
|
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,
|
|
307
|
+
(SELECT COALESCE(MAX(occurred_at), 0) FROM evolution_episodes
|
|
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,
|
|
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
|
|
333
|
+
`).get();
|
|
334
|
+
return Object.freeze({
|
|
335
|
+
activeRules: row.active_rules,
|
|
336
|
+
retiredRules: row.retired_rules,
|
|
337
|
+
pendingProposals: row.pending_proposals,
|
|
338
|
+
conflictedProposals: row.conflicted_proposals,
|
|
339
|
+
trustedEpisodes: row.trusted_episodes,
|
|
340
|
+
qualityEligibleEpisodes: row.quality_eligible_episodes,
|
|
341
|
+
operationalEpisodes: row.operational_episodes,
|
|
342
|
+
legacyQuarantinedEpisodes: row.legacy_quarantined_episodes,
|
|
343
|
+
unattributedTrustedEpisodes: row.unattributed_trusted_episodes,
|
|
344
|
+
unattributedQualityEligibleEpisodes: row.unattributed_quality_eligible_episodes,
|
|
345
|
+
lastTrustedEpisodeAt: row.last_trusted_episode_at,
|
|
346
|
+
lastQualityEligibleEpisodeAt: row.last_quality_eligible_episode_at,
|
|
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,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
104
354
|
/** Record one observed outcome. Idempotent on `idempotencyKey`. */
|
|
105
355
|
recordEpisode(input) {
|
|
106
356
|
const scopeKey = this.#scopeKey(input.scopeKey);
|
|
@@ -113,9 +363,36 @@ export class EvolutionStore {
|
|
|
113
363
|
if (!['trusted', 'self-reported', 'legacy'].includes(input.trust)) {
|
|
114
364
|
throw new EvolutionStoreError('invalid-input', 'trust is invalid');
|
|
115
365
|
}
|
|
366
|
+
if (!['operational', 'objective', 'verification', 'legacy-unknown'].includes(input.evidenceKind)) {
|
|
367
|
+
throw new EvolutionStoreError('invalid-input', 'evidenceKind is invalid');
|
|
368
|
+
}
|
|
116
369
|
if (scopeKey === legacyEvolutionScope && input.trust !== 'legacy') {
|
|
117
370
|
throw new EvolutionStoreError('invalid-input', 'legacy scope may only contain quarantined evidence');
|
|
118
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;
|
|
119
396
|
const trustedRuleId = input.trust === 'trusted' && input.ruleId !== undefined
|
|
120
397
|
? this.#bounded(input.ruleId, 'ruleId', 200)
|
|
121
398
|
: undefined;
|
|
@@ -132,29 +409,363 @@ export class EvolutionStore {
|
|
|
132
409
|
: (input.claimedRuleId ?? input.ruleId) === undefined
|
|
133
410
|
? undefined
|
|
134
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
|
+
};
|
|
135
428
|
const id = `episode-${randomUUID()}`;
|
|
136
429
|
this.#database.prepare(`
|
|
137
430
|
INSERT INTO evolution_episodes(
|
|
138
431
|
id, idempotency_key, scope_key, situation, outcome, detail, source,
|
|
139
|
-
trust,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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. */
|
|
158
769
|
stats(scopeKey, situation, window) {
|
|
159
770
|
const scope = this.#scopeKey(scopeKey);
|
|
160
771
|
const label = this.#situation(situation);
|
|
@@ -162,7 +773,7 @@ export class EvolutionStore {
|
|
|
162
773
|
const row = this.#database.prepare(`
|
|
163
774
|
SELECT COUNT(*) AS total, SUM(outcome = 'failed') AS failures FROM (
|
|
164
775
|
SELECT outcome FROM evolution_episodes
|
|
165
|
-
WHERE scope_key = ? AND situation = ? AND
|
|
776
|
+
WHERE scope_key = ? AND situation = ? AND learning_eligible = 1 AND rule_id IS NULL
|
|
166
777
|
ORDER BY occurred_at DESC, id DESC LIMIT ?
|
|
167
778
|
)
|
|
168
779
|
`).get(scope, label, window);
|
|
@@ -182,6 +793,17 @@ export class EvolutionStore {
|
|
|
182
793
|
.get(ruleId);
|
|
183
794
|
return row === undefined ? undefined : rule(row);
|
|
184
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
|
+
}
|
|
185
807
|
activeRule(scopeKey, situation) {
|
|
186
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));
|
|
187
809
|
return row === undefined ? undefined : rule(row);
|
|
@@ -231,6 +853,13 @@ export class EvolutionStore {
|
|
|
231
853
|
`).get(this.#opaque(sessionId, 'sessionId', 500), this.#scopeKey(scopeKey), this.#bounded(ruleId, 'ruleId', 200), guidanceVersion);
|
|
232
854
|
return row !== undefined;
|
|
233
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
|
+
}
|
|
234
863
|
/** Return only an unambiguous exact-situation receipt for one session. */
|
|
235
864
|
captureGuidanceExposure(sessionId, scopeKey, situation) {
|
|
236
865
|
const rows = this.#database.prepare(`
|
|
@@ -280,7 +909,7 @@ export class EvolutionStore {
|
|
|
280
909
|
}
|
|
281
910
|
const situations = this.#database.prepare(`
|
|
282
911
|
SELECT DISTINCT situation FROM evolution_episodes
|
|
283
|
-
WHERE scope_key = ? AND
|
|
912
|
+
WHERE scope_key = ? AND learning_eligible = 1
|
|
284
913
|
ORDER BY situation
|
|
285
914
|
`).all(scopeKey);
|
|
286
915
|
const candidates = [];
|
|
@@ -337,6 +966,301 @@ export class EvolutionStore {
|
|
|
337
966
|
return undefined;
|
|
338
967
|
return this.#retirementCandidateFromRule(active, options.window, options.minSample, options.retireFailureRate, evidenceSampleLimit);
|
|
339
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
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Retire one exact active guidance generation through the narrow autonomous
|
|
1145
|
+
* rollback lane.
|
|
1146
|
+
*
|
|
1147
|
+
* Eligibility is recomputed after `BEGIN IMMEDIATE`, then the rule update,
|
|
1148
|
+
* immutable evidence receipt and audit row commit together. The caller owns
|
|
1149
|
+
* only the exact target and expected version; risk, reason and evidence are
|
|
1150
|
+
* Host-derived. A replay of that exact tuple returns the durable winner.
|
|
1151
|
+
*/
|
|
1152
|
+
rollbackRule(options) {
|
|
1153
|
+
const scopeKey = this.#scopeKey(options.scopeKey);
|
|
1154
|
+
const ruleId = this.#serverRuleId(options.ruleId);
|
|
1155
|
+
if (!Number.isSafeInteger(options.expectedVersion) || options.expectedVersion < 1
|
|
1156
|
+
|| options.expectedVersion > 1_000_000_000) {
|
|
1157
|
+
throw new EvolutionStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
1158
|
+
}
|
|
1159
|
+
this.#requireWindow(options.window);
|
|
1160
|
+
if (!Number.isSafeInteger(options.minSample) || options.minSample < 1) {
|
|
1161
|
+
throw new EvolutionStoreError('invalid-input', 'minSample must be a positive safe integer');
|
|
1162
|
+
}
|
|
1163
|
+
if (!Number.isFinite(options.retireFailureRate)
|
|
1164
|
+
|| options.retireFailureRate < 0 || options.retireFailureRate > 1) {
|
|
1165
|
+
throw new EvolutionStoreError('invalid-input', 'retireFailureRate must be within 0 and 1');
|
|
1166
|
+
}
|
|
1167
|
+
const evidenceSampleLimit = options.evidenceSampleLimit ?? 8;
|
|
1168
|
+
if (!Number.isSafeInteger(evidenceSampleLimit)
|
|
1169
|
+
|| evidenceSampleLimit < 1 || evidenceSampleLimit > 50) {
|
|
1170
|
+
throw new EvolutionStoreError('invalid-input', 'evidence sample limit must be between 1 and 50');
|
|
1171
|
+
}
|
|
1172
|
+
const expectedVersion = options.expectedVersion;
|
|
1173
|
+
const idempotencyKey = `rollback:${digest([scopeKey, ruleId, expectedVersion])}`;
|
|
1174
|
+
let storedRollback;
|
|
1175
|
+
let storedRule;
|
|
1176
|
+
let replayed = false;
|
|
1177
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
1178
|
+
try {
|
|
1179
|
+
const previous = this.#database.prepare(`
|
|
1180
|
+
SELECT * FROM evolution_autonomous_rollbacks WHERE idempotency_key = ?
|
|
1181
|
+
`).get(idempotencyKey);
|
|
1182
|
+
if (previous !== undefined) {
|
|
1183
|
+
const existing = this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
|
|
1184
|
+
.get(ruleId);
|
|
1185
|
+
if (previous.scope_key !== scopeKey || previous.rule_id !== ruleId
|
|
1186
|
+
|| previous.expected_version !== expectedVersion || existing === undefined
|
|
1187
|
+
|| existing.scope_key !== scopeKey || existing.status !== 'retired'
|
|
1188
|
+
|| existing.version !== previous.result_version
|
|
1189
|
+
|| existing.retired_reason !== previous.reason) {
|
|
1190
|
+
throw new EvolutionStoreError('invalid-state', 'autonomous rollback receipt does not match the rule');
|
|
1191
|
+
}
|
|
1192
|
+
storedRollback = autonomousRollback(previous, existing.situation);
|
|
1193
|
+
storedRule = rule(existing);
|
|
1194
|
+
replayed = true;
|
|
1195
|
+
this.#database.exec('COMMIT');
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
const existing = this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
|
|
1199
|
+
.get(ruleId);
|
|
1200
|
+
if (existing === undefined || existing.scope_key !== scopeKey) {
|
|
1201
|
+
throw new EvolutionStoreError('not-found', 'evolution rule was not found in the Agent scope');
|
|
1202
|
+
}
|
|
1203
|
+
if (existing.status !== 'active') {
|
|
1204
|
+
throw new EvolutionStoreError('invalid-state', 'evolution rule is not active');
|
|
1205
|
+
}
|
|
1206
|
+
if (existing.version !== expectedVersion) {
|
|
1207
|
+
throw new EvolutionStoreError('version-conflict', 'expectedVersion does not match the active rule');
|
|
1208
|
+
}
|
|
1209
|
+
const active = rule(existing);
|
|
1210
|
+
const episodes = this.#evaluationEpisodes(active, options.window);
|
|
1211
|
+
const evaluation = this.#statsFromEpisodes(scopeKey, existing.situation, episodes);
|
|
1212
|
+
const baseline = Object.freeze({
|
|
1213
|
+
scopeKey,
|
|
1214
|
+
situation: existing.situation,
|
|
1215
|
+
failures: existing.baseline_failures,
|
|
1216
|
+
total: existing.baseline_total,
|
|
1217
|
+
});
|
|
1218
|
+
const failureRate = evaluation.failures / evaluation.total;
|
|
1219
|
+
const didNotImprove = evaluation.failures * baseline.total
|
|
1220
|
+
>= baseline.failures * evaluation.total;
|
|
1221
|
+
if (evaluation.total < options.minSample
|
|
1222
|
+
|| failureRate < options.retireFailureRate || !didNotImprove) {
|
|
1223
|
+
throw new EvolutionStoreError('invalid-state', 'autonomous rollback requires sufficient exact trusted post-exposure regression evidence');
|
|
1224
|
+
}
|
|
1225
|
+
const provenance = this.#candidateEvidence(episodes, evidenceSampleLimit);
|
|
1226
|
+
const resultVersion = expectedVersion + 1;
|
|
1227
|
+
const reason = 'Automatic low-risk rollback: exact trusted post-exposure failures '
|
|
1228
|
+
+ `${evaluation.failures}/${evaluation.total} did not improve on adoption baseline `
|
|
1229
|
+
+ `${baseline.failures}/${baseline.total}.`;
|
|
1230
|
+
const now = this.#now();
|
|
1231
|
+
const updated = this.#database.prepare(`
|
|
1232
|
+
UPDATE evolution_rules
|
|
1233
|
+
SET status = 'retired', retired_reason = ?, updated_at = ?, version = ?
|
|
1234
|
+
WHERE id = ? AND scope_key = ? AND status = 'active' AND version = ?
|
|
1235
|
+
`).run(reason, now, resultVersion, ruleId, scopeKey, expectedVersion);
|
|
1236
|
+
if (updated.changes !== 1) {
|
|
1237
|
+
throw new EvolutionStoreError('version-conflict', 'active rule changed during autonomous rollback');
|
|
1238
|
+
}
|
|
1239
|
+
this.#database.prepare(`
|
|
1240
|
+
INSERT INTO evolution_autonomous_rollbacks(
|
|
1241
|
+
idempotency_key, scope_key, rule_id, expected_version, result_version,
|
|
1242
|
+
risk, reason, evaluation_failures, evaluation_total,
|
|
1243
|
+
baseline_failures, baseline_total, evidence_digest, evidence_total,
|
|
1244
|
+
sample_episode_ids_json, occurred_at)
|
|
1245
|
+
VALUES (?, ?, ?, ?, ?, 'low', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1246
|
+
`).run(idempotencyKey, scopeKey, ruleId, expectedVersion, resultVersion, reason, evaluation.failures, evaluation.total, baseline.failures, baseline.total, provenance.evidenceDigest, provenance.evidenceTotal, JSON.stringify(provenance.evidence.map(entry => entry.episodeId)), now);
|
|
1247
|
+
this.#audit('rollback', ruleId, resultVersion, now);
|
|
1248
|
+
const persisted = this.#database.prepare(`
|
|
1249
|
+
SELECT * FROM evolution_autonomous_rollbacks WHERE idempotency_key = ?
|
|
1250
|
+
`).get(idempotencyKey);
|
|
1251
|
+
const updatedRule = this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
|
|
1252
|
+
.get(ruleId);
|
|
1253
|
+
storedRollback = autonomousRollback(persisted, updatedRule.situation);
|
|
1254
|
+
storedRule = rule(updatedRule);
|
|
1255
|
+
this.#database.exec('COMMIT');
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
catch (error) {
|
|
1259
|
+
this.#database.exec('ROLLBACK');
|
|
1260
|
+
throw error;
|
|
1261
|
+
}
|
|
1262
|
+
return { rollback: storedRollback, rule: storedRule, replayed };
|
|
1263
|
+
}
|
|
340
1264
|
#retirementCandidateFromRule(active, window, minSample, retireFailureRate, evidenceSampleLimit) {
|
|
341
1265
|
const episodes = this.#evaluationEpisodes(active, window);
|
|
342
1266
|
const stats = this.#statsFromEpisodes(active.scopeKey, active.situation, episodes);
|
|
@@ -374,24 +1298,44 @@ export class EvolutionStore {
|
|
|
374
1298
|
`).get(scopeKey, situation);
|
|
375
1299
|
if (retired === undefined) {
|
|
376
1300
|
return this.#database.prepare(`
|
|
377
|
-
SELECT
|
|
378
|
-
|
|
379
|
-
|
|
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 ?
|
|
380
1311
|
`).all(scopeKey, situation, window);
|
|
381
1312
|
}
|
|
382
1313
|
return this.#database.prepare(`
|
|
383
|
-
SELECT
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 ?
|
|
387
1325
|
`).all(scopeKey, situation, retired.updated_at, window);
|
|
388
1326
|
}
|
|
389
1327
|
#evaluationEpisodes(active, window) {
|
|
390
1328
|
return this.#database.prepare(`
|
|
391
|
-
SELECT
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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 ?
|
|
395
1339
|
`).all(active.scopeKey, active.situation, active.id, active.generation, active.adoptedAt, window);
|
|
396
1340
|
}
|
|
397
1341
|
#statsFromEpisodes(scopeKey, situation, episodes) {
|
|
@@ -403,9 +1347,28 @@ export class EvolutionStore {
|
|
|
403
1347
|
});
|
|
404
1348
|
}
|
|
405
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
|
+
}));
|
|
406
1367
|
const evidence = episodes.slice(0, sampleLimit).map(row => Object.freeze({
|
|
407
1368
|
episodeId: row.id,
|
|
408
1369
|
outcome: row.outcome,
|
|
1370
|
+
evidenceKind: row.evidence_kind,
|
|
1371
|
+
evidenceRef: row.evidence_ref,
|
|
409
1372
|
detail: row.detail,
|
|
410
1373
|
occurredAt: row.occurred_at,
|
|
411
1374
|
}));
|
|
@@ -419,14 +1382,23 @@ export class EvolutionStore {
|
|
|
419
1382
|
detail: row.detail,
|
|
420
1383
|
source: row.source,
|
|
421
1384
|
trust: row.trust,
|
|
1385
|
+
evidenceKind: row.evidence_kind,
|
|
1386
|
+
evidenceRef: row.evidence_ref,
|
|
1387
|
+
learningEligible: row.learning_eligible,
|
|
422
1388
|
ruleId: row.rule_id,
|
|
423
1389
|
guidanceVersion: row.guidance_version,
|
|
424
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,
|
|
425
1395
|
})));
|
|
426
1396
|
return Object.freeze({
|
|
427
1397
|
evidence: Object.freeze(evidence),
|
|
428
1398
|
evidenceDigest,
|
|
429
1399
|
evidenceTotal: episodes.length,
|
|
1400
|
+
scopeWatermark: watermark.watermark,
|
|
1401
|
+
taskRevisions: Object.freeze(taskRevisions),
|
|
430
1402
|
});
|
|
431
1403
|
}
|
|
432
1404
|
// ---- proposals -----------------------------------------------------------
|
|
@@ -450,64 +1422,97 @@ export class EvolutionStore {
|
|
|
450
1422
|
const hash = digest(mutation);
|
|
451
1423
|
const scopeKey = mutation.op === 'adopt'
|
|
452
1424
|
? mutation.input.scopeKey
|
|
453
|
-
:
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
status, expires_at, created_at, updated_at, version)
|
|
459
|
-
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?, 1)
|
|
460
|
-
ON CONFLICT(idempotency_key) DO NOTHING
|
|
461
|
-
`).run(id, key, requester, principal, scopeKey, hash, JSON.stringify(mutation), creationIntentJson, input.expiresAt, now, now);
|
|
462
|
-
const winner = this.#database.prepare('SELECT * FROM evolution_proposals WHERE idempotency_key = ?')
|
|
463
|
-
.get(key);
|
|
464
|
-
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');
|
|
465
1430
|
try {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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');
|
|
470
1463
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
throw new EvolutionStoreError('idempotency-conflict', 'proposal idempotency key was reused with different content');
|
|
1464
|
+
catch (error) {
|
|
1465
|
+
this.#database.exec('ROLLBACK');
|
|
1466
|
+
throw error;
|
|
475
1467
|
}
|
|
476
1468
|
return proposal(winner);
|
|
477
1469
|
}
|
|
478
1470
|
attachPolicy(proposalId, policyProposalId, expectation) {
|
|
479
1471
|
const policyId = this.#bounded(policyProposalId, 'policyProposalId', 200);
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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);
|
|
1495
|
+
}
|
|
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');
|
|
500
1512
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
expires_at = ?, updated_at = ?
|
|
505
|
-
WHERE id = ? AND policy_proposal_id IS NULL
|
|
506
|
-
`).run(policyId, expectationJson, expectation?.expiresAt ?? initial.expires_at, this.#now(), proposalId);
|
|
507
|
-
const attached = this.#requireProposal(proposalId);
|
|
508
|
-
if (attached.policy_proposal_id !== policyId
|
|
509
|
-
|| attached.settlement_expectation_json !== expectationJson) {
|
|
510
|
-
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;
|
|
511
1516
|
}
|
|
512
1517
|
return proposal(attached);
|
|
513
1518
|
}
|
|
@@ -516,6 +1521,11 @@ export class EvolutionStore {
|
|
|
516
1521
|
.get(proposalId);
|
|
517
1522
|
return row === undefined ? undefined : proposal(row);
|
|
518
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
|
+
}
|
|
519
1529
|
listPendingProposals(limit) {
|
|
520
1530
|
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
|
|
521
1531
|
throw new EvolutionStoreError('invalid-input', 'pending proposal limit must be between 1 and 1000');
|
|
@@ -526,13 +1536,128 @@ export class EvolutionStore {
|
|
|
526
1536
|
`).all(limit);
|
|
527
1537
|
return rows.map(row => proposal(row));
|
|
528
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
|
+
}
|
|
529
1654
|
/** Move an inspected-but-still-pending row behind its peers, durably. */
|
|
530
1655
|
deferPendingProposal(proposalId) {
|
|
531
1656
|
const now = this.#now();
|
|
532
1657
|
this.#database.prepare(`
|
|
533
1658
|
UPDATE evolution_proposals
|
|
534
1659
|
SET updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
|
|
535
|
-
WHERE id = ?
|
|
1660
|
+
WHERE id = ?
|
|
536
1661
|
`).run(now, now, proposalId);
|
|
537
1662
|
}
|
|
538
1663
|
/**
|
|
@@ -550,6 +1675,7 @@ export class EvolutionStore {
|
|
|
550
1675
|
const row = this.#requireProposal(input.proposalId);
|
|
551
1676
|
if (row.status !== 'pending') {
|
|
552
1677
|
replayed = true;
|
|
1678
|
+
this.#recordEvolutionApplicationReceipt(row);
|
|
553
1679
|
this.#database.exec('COMMIT');
|
|
554
1680
|
}
|
|
555
1681
|
else {
|
|
@@ -610,6 +1736,7 @@ export class EvolutionStore {
|
|
|
610
1736
|
if (updated.changes !== 1) {
|
|
611
1737
|
throw new EvolutionStoreError('invalid-state', 'pending proposal settlement lost its transaction lock');
|
|
612
1738
|
}
|
|
1739
|
+
this.#recordEvolutionApplicationReceipt(this.#requireProposal(input.proposalId));
|
|
613
1740
|
this.#database.exec('COMMIT');
|
|
614
1741
|
}
|
|
615
1742
|
}
|
|
@@ -624,10 +1751,175 @@ export class EvolutionStore {
|
|
|
624
1751
|
rule: settled.result_rule_id === null ? undefined : this.getRule(settled.result_rule_id),
|
|
625
1752
|
};
|
|
626
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
|
+
}
|
|
627
1917
|
#applyMutation(mutation, proposalScopeKey, now) {
|
|
628
1918
|
if (mutation.op === 'adopt') {
|
|
629
1919
|
const ruleId = mutation.ruleId;
|
|
630
|
-
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))) {
|
|
631
1923
|
return { status: 'conflicted', ruleId: undefined };
|
|
632
1924
|
}
|
|
633
1925
|
// A second active rule for the same situation would make injected guidance
|
|
@@ -647,16 +1939,24 @@ export class EvolutionStore {
|
|
|
647
1939
|
}
|
|
648
1940
|
const existing = this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
|
|
649
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));
|
|
650
1953
|
if (existing === undefined || existing.scope_key !== proposalScopeKey || existing.status !== 'active'
|
|
651
1954
|
|| existing.version !== mutation.expectedVersion
|
|
652
|
-
|| (mutation.
|
|
653
|
-
|
|
654
|
-
|| mutation.
|
|
655
|
-
|| mutation.
|
|
656
|
-
|
|
657
|
-
|| mutation.baseline.failures !== existing.baseline_failures
|
|
658
|
-
|| mutation.baseline.total !== existing.baseline_total
|
|
659
|
-
|| !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) {
|
|
660
1960
|
return { status: 'conflicted', ruleId: undefined };
|
|
661
1961
|
}
|
|
662
1962
|
const version = existing.version + 1;
|
|
@@ -665,10 +1965,198 @@ export class EvolutionStore {
|
|
|
665
1965
|
SET status = 'retired', retired_reason = ?, updated_at = ?, version = ?
|
|
666
1966
|
WHERE id = ? AND version = ?
|
|
667
1967
|
`).run(mutation.reason, now, version, mutation.ruleId, mutation.expectedVersion);
|
|
668
|
-
this.#audit('retire', mutation.ruleId, version, now);
|
|
1968
|
+
this.#audit(mutation.op === 'owner-undo' ? 'owner-undo' : 'retire', mutation.ruleId, version, now);
|
|
669
1969
|
return { status: 'approved', ruleId: mutation.ruleId };
|
|
670
1970
|
}
|
|
671
|
-
#
|
|
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
|
+
}
|
|
672
2160
|
const ids = mutation.evidence.sampleEpisodeIds;
|
|
673
2161
|
const placeholders = ids.map(() => '?').join(',');
|
|
674
2162
|
const rows = this.#database.prepare(`
|
|
@@ -676,9 +2164,18 @@ export class EvolutionStore {
|
|
|
676
2164
|
`).all(...ids);
|
|
677
2165
|
return rows.length === ids.length && rows.every(row => row.scope_key === active.scope_key
|
|
678
2166
|
&& row.situation === active.situation && row.trust === 'trusted'
|
|
2167
|
+
&& row.learning_eligible === 1
|
|
679
2168
|
&& row.rule_id === active.id && row.guidance_version === active.generation
|
|
680
2169
|
&& row.occurred_at > active.adopted_at);
|
|
681
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
|
+
}
|
|
682
2179
|
#audit(operation, ruleId, resultVersion, now) {
|
|
683
2180
|
this.#database.prepare(`
|
|
684
2181
|
INSERT INTO evolution_audit(idempotency_key, operation, rule_id, result_version, occurred_at)
|
|
@@ -721,20 +2218,41 @@ export class EvolutionStore {
|
|
|
721
2218
|
if (!Number.isSafeInteger(mutation.expectedVersion) || mutation.expectedVersion < 1) {
|
|
722
2219
|
throw new EvolutionStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
723
2220
|
}
|
|
724
|
-
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
|
+
}
|
|
725
2234
|
if (mutation.evaluation === undefined || mutation.baseline === undefined
|
|
726
2235
|
|| mutation.evidence === undefined) {
|
|
727
2236
|
throw new EvolutionStoreError('invalid-input', 'retirement evidence snapshot must be complete');
|
|
728
2237
|
}
|
|
729
2238
|
const evaluation = this.#validateStats(mutation.evaluation, 'retirement evaluation');
|
|
730
2239
|
const baseline = this.#validateStats(mutation.baseline, 'retirement baseline');
|
|
731
|
-
|
|
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) {
|
|
732
2246
|
throw new EvolutionStoreError('invalid-input', 'retirement evaluation and baseline must match');
|
|
733
2247
|
}
|
|
734
2248
|
const evidence = this.#validateEvidenceReference(mutation.evidence, evaluation.total);
|
|
735
2249
|
return {
|
|
736
2250
|
op: 'retire',
|
|
2251
|
+
scopeKey,
|
|
737
2252
|
ruleId,
|
|
2253
|
+
situation,
|
|
2254
|
+
guidance,
|
|
2255
|
+
generation,
|
|
738
2256
|
expectedVersion: mutation.expectedVersion,
|
|
739
2257
|
reason: this.#bounded(mutation.reason, 'reason', this.#maxDetailBytes),
|
|
740
2258
|
evaluation,
|
|
@@ -744,7 +2262,7 @@ export class EvolutionStore {
|
|
|
744
2262
|
}
|
|
745
2263
|
#validateStoredMutation(mutation) {
|
|
746
2264
|
const normalized = this.#validateMutation(mutation);
|
|
747
|
-
if (normalized.op
|
|
2265
|
+
if (normalized.op !== 'adopt')
|
|
748
2266
|
return normalized;
|
|
749
2267
|
const ruleId = mutation.op === 'adopt' && mutation.ruleId !== undefined
|
|
750
2268
|
? this.#serverRuleId(mutation.ruleId)
|
|
@@ -755,7 +2273,7 @@ export class EvolutionStore {
|
|
|
755
2273
|
return { ...normalized, ruleId };
|
|
756
2274
|
}
|
|
757
2275
|
#proposalIntent(mutation) {
|
|
758
|
-
if (mutation.op
|
|
2276
|
+
if (mutation.op !== 'adopt' || mutation.ruleId === undefined)
|
|
759
2277
|
return mutation;
|
|
760
2278
|
const { ruleId: _ruleId, ...intent } = mutation;
|
|
761
2279
|
return intent;
|
|
@@ -768,6 +2286,12 @@ export class EvolutionStore {
|
|
|
768
2286
|
}
|
|
769
2287
|
return ruleId;
|
|
770
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
|
+
}
|
|
771
2295
|
#optionalServerRuleId(value) {
|
|
772
2296
|
const normalized = value.normalize('NFC').trim();
|
|
773
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;
|
|
@@ -802,10 +2326,51 @@ export class EvolutionStore {
|
|
|
802
2326
|
if (new Set(sampleEpisodeIds).size !== sampleEpisodeIds.length) {
|
|
803
2327
|
throw new EvolutionStoreError('invalid-input', 'evidence sample IDs must be unique');
|
|
804
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
|
+
}
|
|
805
2367
|
return Object.freeze({
|
|
806
2368
|
sampleEpisodeIds: Object.freeze(sampleEpisodeIds),
|
|
807
2369
|
digest: input.digest,
|
|
808
2370
|
total: input.total,
|
|
2371
|
+
...(window === undefined ? {} : { window }),
|
|
2372
|
+
...(scopeWatermark === undefined ? {} : { scopeWatermark }),
|
|
2373
|
+
...(taskRevisions === undefined ? {} : { taskRevisions }),
|
|
809
2374
|
});
|
|
810
2375
|
}
|
|
811
2376
|
#validateCreationIntent(input, expected) {
|
|
@@ -833,12 +2398,11 @@ export class EvolutionStore {
|
|
|
833
2398
|
if (input.dispatch.principal !== expected.principal) {
|
|
834
2399
|
throw new EvolutionStoreError('invalid-input', 'Policy dispatch is bound to another principal');
|
|
835
2400
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
};
|
|
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);
|
|
842
2406
|
}
|
|
843
2407
|
return intent;
|
|
844
2408
|
}
|