@dsh-enhanced/assistant-evolution 0.1.7 → 0.1.14

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