@dsh-enhanced/assistant-evolution 0.1.3

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 ADDED
@@ -0,0 +1,886 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { openEvolutionDatabase } from './sqlite.js';
3
+ import { evolutionMutationReview } from './review.js';
4
+ import { legacyEvolutionScope } from './types.js';
5
+ export class EvolutionStoreError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = 'EvolutionStoreError';
11
+ }
12
+ }
13
+ function digest(value) {
14
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
15
+ }
16
+ function episode(row) {
17
+ return Object.freeze({
18
+ id: row.id,
19
+ scopeKey: row.scope_key,
20
+ situation: row.situation,
21
+ outcome: row.outcome,
22
+ detail: row.detail,
23
+ source: row.source,
24
+ trust: row.trust,
25
+ ruleId: row.rule_id ?? undefined,
26
+ guidanceVersion: row.guidance_version ?? undefined,
27
+ claimedRuleId: row.claimed_rule_id ?? undefined,
28
+ occurredAt: row.occurred_at,
29
+ });
30
+ }
31
+ function rule(row) {
32
+ return Object.freeze({
33
+ id: row.id,
34
+ scopeKey: row.scope_key,
35
+ situation: row.situation,
36
+ guidance: row.guidance,
37
+ status: row.status,
38
+ baselineFailures: row.baseline_failures,
39
+ baselineTotal: row.baseline_total,
40
+ adoptedAt: row.adopted_at,
41
+ updatedAt: row.updated_at,
42
+ retiredReason: row.retired_reason ?? undefined,
43
+ version: row.version,
44
+ generation: row.generation,
45
+ });
46
+ }
47
+ function proposal(row) {
48
+ return Object.freeze({
49
+ proposalId: row.id,
50
+ policyProposalId: row.policy_proposal_id ?? undefined,
51
+ idempotencyKey: row.idempotency_key,
52
+ requester: row.requester,
53
+ principal: row.principal,
54
+ scopeKey: row.scope_key,
55
+ mutationHash: row.mutation_hash,
56
+ mutation: JSON.parse(row.mutation_json),
57
+ creationIntent: row.creation_intent_json === null
58
+ ? undefined
59
+ : JSON.parse(row.creation_intent_json),
60
+ status: row.status,
61
+ expiresAt: row.expires_at,
62
+ resultRuleId: row.result_rule_id ?? undefined,
63
+ settlementExpectation: row.settlement_expectation_json === null
64
+ ? undefined
65
+ : JSON.parse(row.settlement_expectation_json),
66
+ version: row.version,
67
+ });
68
+ }
69
+ function guidanceExposure(row) {
70
+ return Object.freeze({
71
+ sessionId: row.session_id,
72
+ scopeKey: row.scope_key,
73
+ situation: row.situation,
74
+ ruleId: row.rule_id,
75
+ guidanceVersion: row.guidance_version,
76
+ exposedAt: row.exposed_at,
77
+ });
78
+ }
79
+ /**
80
+ * Durable evidence and rule ledger.
81
+ *
82
+ * Two invariants matter most. First, a rule is data, not authority: nothing here
83
+ * can widen what a tool call may do, because assistant-policy is consulted
84
+ * separately and never reads this database. Second, adoption is never automatic —
85
+ * the store can only *propose* a change, so an owner decision always stands
86
+ * between an observation and a behavioural change.
87
+ */
88
+ export class EvolutionStore {
89
+ #database;
90
+ #now;
91
+ #maxSituationBytes;
92
+ #maxGuidanceBytes;
93
+ #maxDetailBytes;
94
+ constructor(options) {
95
+ this.#database = openEvolutionDatabase(options.path);
96
+ this.#now = options.now ?? Date.now;
97
+ this.#maxSituationBytes = options.maxSituationBytes ?? 200;
98
+ this.#maxGuidanceBytes = options.maxGuidanceBytes ?? 2_048;
99
+ this.#maxDetailBytes = options.maxDetailBytes ?? 1_024;
100
+ }
101
+ close() {
102
+ this.#database.close();
103
+ }
104
+ /** Record one observed outcome. Idempotent on `idempotencyKey`. */
105
+ recordEpisode(input) {
106
+ const scopeKey = this.#scopeKey(input.scopeKey);
107
+ const situation = this.#situation(input.situation);
108
+ const detail = this.#bounded(input.detail, 'detail', this.#maxDetailBytes);
109
+ const key = this.#bounded(input.idempotencyKey, 'idempotencyKey', 200);
110
+ if (!Number.isSafeInteger(input.occurredAt) || input.occurredAt < 0) {
111
+ throw new EvolutionStoreError('invalid-input', 'occurredAt must be a non-negative safe integer');
112
+ }
113
+ if (!['trusted', 'self-reported', 'legacy'].includes(input.trust)) {
114
+ throw new EvolutionStoreError('invalid-input', 'trust is invalid');
115
+ }
116
+ if (scopeKey === legacyEvolutionScope && input.trust !== 'legacy') {
117
+ throw new EvolutionStoreError('invalid-input', 'legacy scope may only contain quarantined evidence');
118
+ }
119
+ const trustedRuleId = input.trust === 'trusted' && input.ruleId !== undefined
120
+ ? this.#bounded(input.ruleId, 'ruleId', 200)
121
+ : undefined;
122
+ let guidanceVersion;
123
+ if (input.guidanceVersion !== undefined) {
124
+ if (trustedRuleId === undefined || !Number.isSafeInteger(input.guidanceVersion)
125
+ || input.guidanceVersion < 1 || input.guidanceVersion > 1_000_000_000) {
126
+ throw new EvolutionStoreError('invalid-input', 'guidanceVersion requires trusted rule attribution and must be a positive safe integer');
127
+ }
128
+ guidanceVersion = input.guidanceVersion;
129
+ }
130
+ const claimedRuleId = input.trust === 'trusted'
131
+ ? (input.claimedRuleId === undefined ? undefined : this.#bounded(input.claimedRuleId, 'claimedRuleId', 200))
132
+ : (input.claimedRuleId ?? input.ruleId) === undefined
133
+ ? undefined
134
+ : this.#bounded((input.claimedRuleId ?? input.ruleId), 'claimedRuleId', 200);
135
+ const id = `episode-${randomUUID()}`;
136
+ this.#database.prepare(`
137
+ INSERT INTO evolution_episodes(
138
+ id, idempotency_key, scope_key, situation, outcome, detail, source,
139
+ trust, rule_id, guidance_version, claimed_rule_id, occurred_at)
140
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
141
+ ON CONFLICT(idempotency_key) DO NOTHING
142
+ `).run(id, key, scopeKey, situation, input.outcome, detail, input.source, input.trust, trustedRuleId ?? null, guidanceVersion ?? null, claimedRuleId ?? null, input.occurredAt);
143
+ const winner = this.#database.prepare('SELECT * FROM evolution_episodes WHERE idempotency_key = ?')
144
+ .get(key);
145
+ // Replay must be exact; a reused key with different content is a caller bug.
146
+ if (winner.scope_key !== scopeKey || winner.situation !== situation
147
+ || winner.outcome !== input.outcome || winner.detail !== detail
148
+ || winner.source !== input.source || winner.trust !== input.trust
149
+ || (winner.rule_id ?? undefined) !== trustedRuleId
150
+ || (winner.guidance_version ?? undefined) !== guidanceVersion
151
+ || (winner.claimed_rule_id ?? undefined) !== claimedRuleId
152
+ || winner.occurred_at !== input.occurredAt) {
153
+ throw new EvolutionStoreError('idempotency-conflict', 'episode idempotency key was reused with different content');
154
+ }
155
+ return episode(winner);
156
+ }
157
+ /** Failure counts for one situation over the most recent `window` episodes. */
158
+ stats(scopeKey, situation, window) {
159
+ const scope = this.#scopeKey(scopeKey);
160
+ const label = this.#situation(situation);
161
+ this.#requireWindow(window);
162
+ const row = this.#database.prepare(`
163
+ SELECT COUNT(*) AS total, SUM(outcome = 'failed') AS failures FROM (
164
+ SELECT outcome FROM evolution_episodes
165
+ WHERE scope_key = ? AND situation = ? AND trust = 'trusted' AND rule_id IS NULL
166
+ ORDER BY occurred_at DESC, id DESC LIMIT ?
167
+ )
168
+ `).get(scope, label, window);
169
+ return Object.freeze({ scopeKey: scope, situation: label, failures: row.failures ?? 0, total: row.total });
170
+ }
171
+ listRules(scopeKey, status) {
172
+ const scope = this.#scopeKey(scopeKey);
173
+ const rows = this.#database.prepare(`
174
+ SELECT * FROM evolution_rules
175
+ WHERE scope_key = ? AND (? IS NULL OR status = ?)
176
+ ORDER BY situation, adopted_at
177
+ `).all(scope, status ?? null, status ?? null);
178
+ return rows.map(row => rule(row));
179
+ }
180
+ getRule(ruleId) {
181
+ const row = this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
182
+ .get(ruleId);
183
+ return row === undefined ? undefined : rule(row);
184
+ }
185
+ activeRule(scopeKey, situation) {
186
+ const row = this.#database.prepare("SELECT * FROM evolution_rules WHERE scope_key = ? AND situation = ? AND status = 'active'").get(this.#scopeKey(scopeKey), this.#situation(situation));
187
+ return row === undefined ? undefined : rule(row);
188
+ }
189
+ nextGeneration(scopeKey, situation) {
190
+ const row = this.#database.prepare(`
191
+ SELECT MAX(generation) AS generation FROM evolution_rules
192
+ WHERE scope_key = ? AND situation = ?
193
+ `).get(this.#scopeKey(scopeKey), this.#situation(situation));
194
+ return (row.generation ?? 0) + 1;
195
+ }
196
+ /** Persist proof only after the corresponding guidance injection returned. */
197
+ recordGuidanceExposure(input) {
198
+ const sessionId = this.#opaque(input.sessionId, 'sessionId', 500);
199
+ const scopeKey = this.#scopeKey(input.scopeKey);
200
+ const situation = this.#situation(input.situation);
201
+ const ruleId = this.#bounded(input.ruleId, 'ruleId', 200);
202
+ if (!Number.isSafeInteger(input.guidanceVersion) || input.guidanceVersion < 1
203
+ || input.guidanceVersion > 1_000_000_000) {
204
+ throw new EvolutionStoreError('invalid-input', 'guidanceVersion must be a positive safe integer');
205
+ }
206
+ const rule = this.getRule(ruleId);
207
+ if (rule === undefined || rule.scopeKey !== scopeKey || rule.situation !== situation
208
+ || rule.generation !== input.guidanceVersion) {
209
+ throw new EvolutionStoreError('invalid-state', 'guidance exposure does not match an immutable scoped rule');
210
+ }
211
+ const exposedAt = this.#now();
212
+ this.#database.prepare(`
213
+ INSERT INTO evolution_guidance_exposures(
214
+ session_id, scope_key, situation, rule_id, guidance_version, exposed_at)
215
+ VALUES (?, ?, ?, ?, ?, ?)
216
+ ON CONFLICT(session_id, scope_key, rule_id, guidance_version) DO NOTHING
217
+ `).run(sessionId, scopeKey, situation, ruleId, input.guidanceVersion, exposedAt);
218
+ const row = this.#database.prepare(`
219
+ SELECT * FROM evolution_guidance_exposures
220
+ WHERE session_id = ? AND scope_key = ? AND rule_id = ? AND guidance_version = ?
221
+ `).get(sessionId, scopeKey, ruleId, input.guidanceVersion);
222
+ if (row.situation !== situation) {
223
+ throw new EvolutionStoreError('idempotency-conflict', 'guidance exposure identity was reused for another situation');
224
+ }
225
+ return guidanceExposure(row);
226
+ }
227
+ hasGuidanceExposure(sessionId, scopeKey, ruleId, guidanceVersion) {
228
+ const row = this.#database.prepare(`
229
+ SELECT 1 AS present FROM evolution_guidance_exposures
230
+ WHERE session_id = ? AND scope_key = ? AND rule_id = ? AND guidance_version = ?
231
+ `).get(this.#opaque(sessionId, 'sessionId', 500), this.#scopeKey(scopeKey), this.#bounded(ruleId, 'ruleId', 200), guidanceVersion);
232
+ return row !== undefined;
233
+ }
234
+ /** Return only an unambiguous exact-situation receipt for one session. */
235
+ captureGuidanceExposure(sessionId, scopeKey, situation) {
236
+ const rows = this.#database.prepare(`
237
+ SELECT * FROM evolution_guidance_exposures
238
+ WHERE session_id = ? AND scope_key = ? AND situation = ?
239
+ ORDER BY exposed_at, rule_id, guidance_version
240
+ LIMIT 2
241
+ `).all(this.#opaque(sessionId, 'sessionId', 500), this.#scopeKey(scopeKey), this.#situation(situation));
242
+ return rows.length === 1 ? guidanceExposure(rows[0]) : undefined;
243
+ }
244
+ /**
245
+ * Compute candidate rule changes from recorded evidence.
246
+ *
247
+ * This is the only place learning is inferred, and it deliberately stops at a
248
+ * *candidate*. Two directions are detected:
249
+ *
250
+ * - `adopt`: a situation fails often enough, with a large enough sample, and has
251
+ * no active rule yet.
252
+ * - `retire`: an active rule's situation is not doing better than the baseline
253
+ * it was adopted against, so the rule is not earning its place.
254
+ *
255
+ * A rule is never revised in place. Revision would silently rewrite behaviour
256
+ * under an old approval, so a change is always retire-then-adopt, each with its
257
+ * own decision.
258
+ */
259
+ candidates(options) {
260
+ const scopeKey = this.#scopeKey(options.scopeKey);
261
+ this.#requireWindow(options.window);
262
+ if (!Number.isSafeInteger(options.minSample) || options.minSample < 1) {
263
+ throw new EvolutionStoreError('invalid-input', 'minSample must be a positive safe integer');
264
+ }
265
+ for (const [field, value] of [
266
+ ['adoptFailureRate', options.adoptFailureRate],
267
+ ['retireFailureRate', options.retireFailureRate],
268
+ ]) {
269
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
270
+ throw new EvolutionStoreError('invalid-input', `${field} must be within 0 and 1`);
271
+ }
272
+ }
273
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100) {
274
+ throw new EvolutionStoreError('invalid-input', 'candidate limit must be between 1 and 100');
275
+ }
276
+ const evidenceSampleLimit = options.evidenceSampleLimit ?? 8;
277
+ if (!Number.isSafeInteger(evidenceSampleLimit)
278
+ || evidenceSampleLimit < 1 || evidenceSampleLimit > 50) {
279
+ throw new EvolutionStoreError('invalid-input', 'evidence sample limit must be between 1 and 50');
280
+ }
281
+ const situations = this.#database.prepare(`
282
+ SELECT DISTINCT situation FROM evolution_episodes
283
+ WHERE scope_key = ? AND trust = 'trusted'
284
+ ORDER BY situation
285
+ `).all(scopeKey);
286
+ const candidates = [];
287
+ for (const { situation } of situations) {
288
+ if (candidates.length >= options.limit)
289
+ break;
290
+ const active = this.activeRule(scopeKey, situation);
291
+ const episodes = active === undefined
292
+ ? this.#adoptionEpisodes(scopeKey, situation, options.window)
293
+ : this.#evaluationEpisodes(active, options.window);
294
+ const stats = this.#statsFromEpisodes(scopeKey, situation, episodes);
295
+ if (stats.total < options.minSample)
296
+ continue;
297
+ const provenance = this.#candidateEvidence(episodes, evidenceSampleLimit);
298
+ const rate = stats.failures / stats.total;
299
+ if (active === undefined) {
300
+ if (rate < options.adoptFailureRate)
301
+ continue;
302
+ candidates.push(Object.freeze({
303
+ scopeKey,
304
+ situation,
305
+ kind: 'adopt',
306
+ stats,
307
+ ...provenance,
308
+ rationale: `${stats.failures}/${stats.total} recent attempts failed and no rule is active`,
309
+ }));
310
+ continue;
311
+ }
312
+ const retirement = this.#retirementCandidateFromRule(active, options.window, options.minSample, options.retireFailureRate, evidenceSampleLimit);
313
+ if (retirement !== undefined)
314
+ candidates.push(retirement);
315
+ }
316
+ return candidates;
317
+ }
318
+ /** Compute the candidate for one exact active rule, independent of list limits. */
319
+ retirementCandidate(options) {
320
+ const scopeKey = this.#scopeKey(options.scopeKey);
321
+ const ruleId = this.#bounded(options.ruleId, 'ruleId', 200);
322
+ this.#requireWindow(options.window);
323
+ if (!Number.isSafeInteger(options.minSample) || options.minSample < 1) {
324
+ throw new EvolutionStoreError('invalid-input', 'minSample must be a positive safe integer');
325
+ }
326
+ if (!Number.isFinite(options.retireFailureRate)
327
+ || options.retireFailureRate < 0 || options.retireFailureRate > 1) {
328
+ throw new EvolutionStoreError('invalid-input', 'retireFailureRate must be within 0 and 1');
329
+ }
330
+ const evidenceSampleLimit = options.evidenceSampleLimit ?? 8;
331
+ if (!Number.isSafeInteger(evidenceSampleLimit)
332
+ || evidenceSampleLimit < 1 || evidenceSampleLimit > 50) {
333
+ throw new EvolutionStoreError('invalid-input', 'evidence sample limit must be between 1 and 50');
334
+ }
335
+ const active = this.getRule(ruleId);
336
+ if (active === undefined || active.scopeKey !== scopeKey || active.status !== 'active')
337
+ return undefined;
338
+ return this.#retirementCandidateFromRule(active, options.window, options.minSample, options.retireFailureRate, evidenceSampleLimit);
339
+ }
340
+ #retirementCandidateFromRule(active, window, minSample, retireFailureRate, evidenceSampleLimit) {
341
+ const episodes = this.#evaluationEpisodes(active, window);
342
+ const stats = this.#statsFromEpisodes(active.scopeKey, active.situation, episodes);
343
+ if (stats.total < minSample)
344
+ return undefined;
345
+ const rate = stats.failures / stats.total;
346
+ // An active rule must beat the baseline it was adopted against. Judging it
347
+ // against a fixed threshold alone would keep a rule that made things worse.
348
+ const baselineRate = active.baselineFailures / active.baselineTotal;
349
+ if (rate < retireFailureRate && rate <= baselineRate)
350
+ return undefined;
351
+ const provenance = this.#candidateEvidence(episodes, evidenceSampleLimit);
352
+ return Object.freeze({
353
+ scopeKey: active.scopeKey,
354
+ situation: active.situation,
355
+ kind: 'retire',
356
+ stats,
357
+ ruleId: active.id,
358
+ ...provenance,
359
+ baseline: Object.freeze({
360
+ scopeKey: active.scopeKey,
361
+ situation: active.situation,
362
+ failures: active.baselineFailures,
363
+ total: active.baselineTotal,
364
+ }),
365
+ rationale: `${stats.failures}/${stats.total} recent attempts failed against a `
366
+ + `${active.baselineFailures}/${active.baselineTotal} baseline, so the active rule is not helping`,
367
+ });
368
+ }
369
+ #adoptionEpisodes(scopeKey, situation, window) {
370
+ const retired = this.#database.prepare(`
371
+ SELECT updated_at FROM evolution_rules
372
+ WHERE scope_key = ? AND situation = ? AND status = 'retired'
373
+ ORDER BY generation DESC LIMIT 1
374
+ `).get(scopeKey, situation);
375
+ if (retired === undefined) {
376
+ return this.#database.prepare(`
377
+ SELECT * FROM evolution_episodes
378
+ WHERE scope_key = ? AND situation = ? AND trust = 'trusted' AND rule_id IS NULL
379
+ ORDER BY occurred_at DESC, id DESC LIMIT ?
380
+ `).all(scopeKey, situation, window);
381
+ }
382
+ return this.#database.prepare(`
383
+ SELECT * FROM evolution_episodes
384
+ WHERE scope_key = ? AND situation = ? AND trust = 'trusted'
385
+ AND rule_id IS NULL AND occurred_at > ?
386
+ ORDER BY occurred_at DESC, id DESC LIMIT ?
387
+ `).all(scopeKey, situation, retired.updated_at, window);
388
+ }
389
+ #evaluationEpisodes(active, window) {
390
+ return this.#database.prepare(`
391
+ SELECT * FROM evolution_episodes
392
+ WHERE scope_key = ? AND situation = ? AND trust = 'trusted'
393
+ AND rule_id = ? AND guidance_version = ? AND occurred_at > ?
394
+ ORDER BY occurred_at DESC, id DESC LIMIT ?
395
+ `).all(active.scopeKey, active.situation, active.id, active.generation, active.adoptedAt, window);
396
+ }
397
+ #statsFromEpisodes(scopeKey, situation, episodes) {
398
+ return Object.freeze({
399
+ scopeKey,
400
+ situation,
401
+ failures: episodes.filter(row => row.outcome === 'failed').length,
402
+ total: episodes.length,
403
+ });
404
+ }
405
+ #candidateEvidence(episodes, sampleLimit) {
406
+ const evidence = episodes.slice(0, sampleLimit).map(row => Object.freeze({
407
+ episodeId: row.id,
408
+ outcome: row.outcome,
409
+ detail: row.detail,
410
+ occurredAt: row.occurred_at,
411
+ }));
412
+ // Hash the complete exact query window, including the fields that establish
413
+ // scope, trust and attribution. Only a bounded projection is model-visible.
414
+ const evidenceDigest = digest(episodes.map(row => ({
415
+ episodeId: row.id,
416
+ scopeKey: row.scope_key,
417
+ situation: row.situation,
418
+ outcome: row.outcome,
419
+ detail: row.detail,
420
+ source: row.source,
421
+ trust: row.trust,
422
+ ruleId: row.rule_id,
423
+ guidanceVersion: row.guidance_version,
424
+ occurredAt: row.occurred_at,
425
+ })));
426
+ return Object.freeze({
427
+ evidence: Object.freeze(evidence),
428
+ evidenceDigest,
429
+ evidenceTotal: episodes.length,
430
+ });
431
+ }
432
+ // ---- proposals -----------------------------------------------------------
433
+ createProposal(input) {
434
+ const key = this.#bounded(input.idempotencyKey, 'idempotencyKey', 200);
435
+ const normalized = this.#validateMutation(input.mutation);
436
+ const requester = this.#bounded(input.requester, 'requester', 200);
437
+ const principal = this.#bounded(input.principal, 'principal', 500);
438
+ if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt < 0) {
439
+ throw new EvolutionStoreError('invalid-input', 'proposal expiresAt must be a non-negative safe integer');
440
+ }
441
+ const creationIntent = input.creationIntent === undefined
442
+ ? undefined
443
+ : this.#validateCreationIntent(input.creationIntent, { key, requester, principal });
444
+ const creationIntentJson = creationIntent === undefined ? null : JSON.stringify(creationIntent);
445
+ const now = this.#now();
446
+ const id = `evolution-proposal-${randomUUID()}`;
447
+ const mutation = normalized.op === 'adopt' && normalized.ruleId === undefined
448
+ ? { ...normalized, ruleId: `rule-${randomUUID()}` }
449
+ : normalized;
450
+ const hash = digest(mutation);
451
+ const scopeKey = mutation.op === 'adopt'
452
+ ? mutation.input.scopeKey
453
+ : (this.getRule(mutation.ruleId)?.scopeKey ?? legacyEvolutionScope);
454
+ this.#database.prepare(`
455
+ INSERT INTO evolution_proposals(
456
+ id, policy_proposal_id, idempotency_key, requester, principal, scope_key, mutation_hash,
457
+ mutation_json, creation_intent_json, settlement_expectation_json,
458
+ status, expires_at, created_at, updated_at, version)
459
+ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?, 1)
460
+ ON CONFLICT(idempotency_key) DO NOTHING
461
+ `).run(id, key, requester, principal, scopeKey, hash, JSON.stringify(mutation), creationIntentJson, input.expiresAt, now, now);
462
+ const winner = this.#database.prepare('SELECT * FROM evolution_proposals WHERE idempotency_key = ?')
463
+ .get(key);
464
+ let winnerIntentMatches = false;
465
+ try {
466
+ winnerIntentMatches = digest(this.#proposalIntent(this.#validateMutation(JSON.parse(winner.mutation_json)))) === digest(this.#proposalIntent(normalized));
467
+ }
468
+ catch {
469
+ winnerIntentMatches = false;
470
+ }
471
+ if (!winnerIntentMatches || winner.requester !== requester
472
+ || winner.principal !== principal || winner.scope_key !== scopeKey
473
+ || winner.creation_intent_json !== creationIntentJson) {
474
+ throw new EvolutionStoreError('idempotency-conflict', 'proposal idempotency key was reused with different content');
475
+ }
476
+ return proposal(winner);
477
+ }
478
+ attachPolicy(proposalId, policyProposalId, expectation) {
479
+ const policyId = this.#bounded(policyProposalId, 'policyProposalId', 200);
480
+ const initial = this.#requireProposal(proposalId);
481
+ let expectationJson = null;
482
+ if (expectation !== undefined) {
483
+ const intent = initial.creation_intent_json === null
484
+ ? undefined
485
+ : JSON.parse(initial.creation_intent_json);
486
+ if (expectation.proposalId !== policyId
487
+ || expectation.requester !== initial.requester
488
+ || expectation.principal !== initial.principal
489
+ || !Number.isSafeInteger(expectation.expiresAt) || expectation.expiresAt < 0
490
+ || expectation.expectedVersion !== 1
491
+ || intent === undefined
492
+ || expectation.action !== intent.action
493
+ || expectation.resource.kind !== intent.resource.kind
494
+ || expectation.resource.id !== intent.resource.id
495
+ || expectation.summary !== intent.summary
496
+ || expectation.diff !== intent.diff) {
497
+ throw new EvolutionStoreError('invalid-input', 'settlement expectation does not match local creation intent');
498
+ }
499
+ expectationJson = JSON.stringify(expectation);
500
+ }
501
+ this.#database.prepare(`
502
+ UPDATE evolution_proposals
503
+ SET policy_proposal_id = ?, settlement_expectation_json = ?,
504
+ expires_at = ?, updated_at = ?
505
+ WHERE id = ? AND policy_proposal_id IS NULL
506
+ `).run(policyId, expectationJson, expectation?.expiresAt ?? initial.expires_at, this.#now(), proposalId);
507
+ const attached = this.#requireProposal(proposalId);
508
+ if (attached.policy_proposal_id !== policyId
509
+ || attached.settlement_expectation_json !== expectationJson) {
510
+ throw new EvolutionStoreError('invalid-state', 'proposal is already attached to a different policy proposal');
511
+ }
512
+ return proposal(attached);
513
+ }
514
+ getProposal(proposalId) {
515
+ const row = this.#database.prepare('SELECT * FROM evolution_proposals WHERE id = ?')
516
+ .get(proposalId);
517
+ return row === undefined ? undefined : proposal(row);
518
+ }
519
+ listPendingProposals(limit) {
520
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
521
+ throw new EvolutionStoreError('invalid-input', 'pending proposal limit must be between 1 and 1000');
522
+ }
523
+ const rows = this.#database.prepare(`
524
+ SELECT * FROM evolution_proposals WHERE status = 'pending'
525
+ ORDER BY updated_at, id LIMIT ?
526
+ `).all(limit);
527
+ return rows.map(row => proposal(row));
528
+ }
529
+ /** Move an inspected-but-still-pending row behind its peers, durably. */
530
+ deferPendingProposal(proposalId) {
531
+ const now = this.#now();
532
+ this.#database.prepare(`
533
+ UPDATE evolution_proposals
534
+ SET updated_at = CASE WHEN updated_at >= ? THEN updated_at + 1 ELSE ? END
535
+ WHERE id = ? AND status = 'pending'
536
+ `).run(now, now, proposalId);
537
+ }
538
+ /**
539
+ * Apply one already-terminal policy status inside a single transaction.
540
+ *
541
+ * Both the rule change and the proposal's own settlement commit together, so a
542
+ * crash can never leave an approved-but-unapplied rule or an applied-but-pending
543
+ * proposal. Conflicts downgrade to `conflicted` rather than discarding the
544
+ * owner's decision.
545
+ */
546
+ settleProposal(input) {
547
+ let replayed = false;
548
+ this.#database.exec('BEGIN IMMEDIATE');
549
+ try {
550
+ const row = this.#requireProposal(input.proposalId);
551
+ if (row.status !== 'pending') {
552
+ replayed = true;
553
+ this.#database.exec('COMMIT');
554
+ }
555
+ else {
556
+ let securityConflict = input.securityConflict === true;
557
+ if (!securityConflict && (input.policyStatus === undefined
558
+ || !Number.isSafeInteger(input.policyVersion)
559
+ || input.policyVersion <= row.version)) {
560
+ throw new EvolutionStoreError('invalid-input', 'policy settlement must advance the proposal version');
561
+ }
562
+ let mutation;
563
+ if (!securityConflict) {
564
+ try {
565
+ mutation = this.#validateStoredMutation(JSON.parse(row.mutation_json));
566
+ securityConflict = digest(mutation) !== row.mutation_hash;
567
+ }
568
+ catch {
569
+ securityConflict = true;
570
+ }
571
+ }
572
+ if (!securityConflict) {
573
+ let persistedExpectation;
574
+ try {
575
+ persistedExpectation = row.settlement_expectation_json === null
576
+ ? undefined
577
+ : JSON.parse(row.settlement_expectation_json);
578
+ }
579
+ catch {
580
+ securityConflict = true;
581
+ }
582
+ const suppliedExpectation = input.reviewExpectation;
583
+ if (!securityConflict && (persistedExpectation === undefined) !== (suppliedExpectation === undefined)) {
584
+ securityConflict = true;
585
+ }
586
+ if (!securityConflict && persistedExpectation !== undefined && suppliedExpectation !== undefined) {
587
+ const review = evolutionMutationReview(mutation);
588
+ securityConflict = JSON.stringify(persistedExpectation) !== JSON.stringify(suppliedExpectation)
589
+ || review.action !== suppliedExpectation.action
590
+ || review.resource.kind !== suppliedExpectation.resource.kind
591
+ || review.resource.id !== suppliedExpectation.resource.id
592
+ || review.summary !== suppliedExpectation.summary
593
+ || review.diff !== suppliedExpectation.diff;
594
+ }
595
+ }
596
+ const now = this.#now();
597
+ let status = securityConflict ? 'conflicted' : input.policyStatus;
598
+ let ruleId;
599
+ if (!securityConflict && input.policyStatus === 'approved') {
600
+ const applied = this.#applyMutation(mutation, row.scope_key, now);
601
+ status = applied.status;
602
+ ruleId = applied.ruleId;
603
+ }
604
+ const version = securityConflict ? row.version + 1 : input.policyVersion;
605
+ const updated = this.#database.prepare(`
606
+ UPDATE evolution_proposals
607
+ SET status = ?, result_rule_id = ?, updated_at = ?, version = ?
608
+ WHERE id = ? AND status = 'pending'
609
+ `).run(status, ruleId ?? null, now, version, input.proposalId);
610
+ if (updated.changes !== 1) {
611
+ throw new EvolutionStoreError('invalid-state', 'pending proposal settlement lost its transaction lock');
612
+ }
613
+ this.#database.exec('COMMIT');
614
+ }
615
+ }
616
+ catch (error) {
617
+ this.#database.exec('ROLLBACK');
618
+ throw error;
619
+ }
620
+ const settled = this.#requireProposal(input.proposalId);
621
+ return {
622
+ proposal: proposal(settled),
623
+ replayed,
624
+ rule: settled.result_rule_id === null ? undefined : this.getRule(settled.result_rule_id),
625
+ };
626
+ }
627
+ #applyMutation(mutation, proposalScopeKey, now) {
628
+ if (mutation.op === 'adopt') {
629
+ const ruleId = mutation.ruleId;
630
+ if (ruleId === undefined || mutation.input.scopeKey !== proposalScopeKey) {
631
+ return { status: 'conflicted', ruleId: undefined };
632
+ }
633
+ // A second active rule for the same situation would make injected guidance
634
+ // self-contradictory, so the unique index is treated as a conflict.
635
+ const conflict = this.#database.prepare("SELECT id FROM evolution_rules WHERE scope_key = ? AND situation = ? AND status = 'active'").get(mutation.input.scopeKey, mutation.input.situation);
636
+ if (conflict !== undefined)
637
+ return { status: 'conflicted', ruleId: undefined };
638
+ const generation = this.nextGeneration(mutation.input.scopeKey, mutation.input.situation);
639
+ this.#database.prepare(`
640
+ INSERT INTO evolution_rules(
641
+ id, scope_key, situation, guidance, status, baseline_failures, baseline_total,
642
+ adopted_at, updated_at, retired_reason, version, generation)
643
+ VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, NULL, 1, ?)
644
+ `).run(ruleId, mutation.input.scopeKey, mutation.input.situation, mutation.input.guidance, mutation.baseline.failures, mutation.baseline.total, now, now, generation);
645
+ this.#audit('adopt', ruleId, 1, now);
646
+ return { status: 'approved', ruleId };
647
+ }
648
+ const existing = this.#database.prepare('SELECT * FROM evolution_rules WHERE id = ?')
649
+ .get(mutation.ruleId);
650
+ if (existing === undefined || existing.scope_key !== proposalScopeKey || existing.status !== 'active'
651
+ || existing.version !== mutation.expectedVersion
652
+ || (mutation.evaluation !== undefined && mutation.baseline !== undefined
653
+ && mutation.evidence !== undefined && (mutation.evaluation.scopeKey !== existing.scope_key
654
+ || mutation.evaluation.situation !== existing.situation
655
+ || mutation.baseline.scopeKey !== existing.scope_key
656
+ || mutation.baseline.situation !== existing.situation
657
+ || mutation.baseline.failures !== existing.baseline_failures
658
+ || mutation.baseline.total !== existing.baseline_total
659
+ || !this.#retirementEvidenceSamplesMatch(mutation, existing)))) {
660
+ return { status: 'conflicted', ruleId: undefined };
661
+ }
662
+ const version = existing.version + 1;
663
+ this.#database.prepare(`
664
+ UPDATE evolution_rules
665
+ SET status = 'retired', retired_reason = ?, updated_at = ?, version = ?
666
+ WHERE id = ? AND version = ?
667
+ `).run(mutation.reason, now, version, mutation.ruleId, mutation.expectedVersion);
668
+ this.#audit('retire', mutation.ruleId, version, now);
669
+ return { status: 'approved', ruleId: mutation.ruleId };
670
+ }
671
+ #retirementEvidenceSamplesMatch(mutation, active) {
672
+ const ids = mutation.evidence.sampleEpisodeIds;
673
+ const placeholders = ids.map(() => '?').join(',');
674
+ const rows = this.#database.prepare(`
675
+ SELECT * FROM evolution_episodes WHERE id IN (${placeholders})
676
+ `).all(...ids);
677
+ return rows.length === ids.length && rows.every(row => row.scope_key === active.scope_key
678
+ && row.situation === active.situation && row.trust === 'trusted'
679
+ && row.rule_id === active.id && row.guidance_version === active.generation
680
+ && row.occurred_at > active.adopted_at);
681
+ }
682
+ #audit(operation, ruleId, resultVersion, now) {
683
+ this.#database.prepare(`
684
+ INSERT INTO evolution_audit(idempotency_key, operation, rule_id, result_version, occurred_at)
685
+ VALUES (?, ?, ?, ?, ?)
686
+ `).run(`${operation}:${ruleId}:${resultVersion}`, operation, ruleId, resultVersion, now);
687
+ }
688
+ #validateMutation(mutation) {
689
+ if (mutation.op === 'adopt') {
690
+ const scopeKey = this.#scopeKey(mutation.input.scopeKey);
691
+ const situation = this.#situation(mutation.input.situation);
692
+ const guidance = this.#bounded(mutation.input.guidance, 'guidance', this.#maxGuidanceBytes);
693
+ if (!Number.isSafeInteger(mutation.baseline.total) || mutation.baseline.total < 1
694
+ || !Number.isSafeInteger(mutation.baseline.failures) || mutation.baseline.failures < 0
695
+ || mutation.baseline.failures > mutation.baseline.total) {
696
+ throw new EvolutionStoreError('invalid-input', 'baseline must be a coherent failure count over a positive sample');
697
+ }
698
+ if (mutation.baseline.scopeKey !== scopeKey
699
+ || this.#situation(mutation.baseline.situation) !== situation) {
700
+ throw new EvolutionStoreError('invalid-input', 'baseline scope and situation must match the adopted rule');
701
+ }
702
+ const evidence = mutation.evidence === undefined
703
+ ? undefined
704
+ : this.#validateEvidenceReference(mutation.evidence, mutation.baseline.total);
705
+ const ruleId = mutation.ruleId === undefined
706
+ ? undefined
707
+ : this.#optionalServerRuleId(mutation.ruleId);
708
+ return {
709
+ op: 'adopt',
710
+ input: { scopeKey, situation, guidance },
711
+ baseline: {
712
+ scopeKey,
713
+ situation,
714
+ failures: mutation.baseline.failures,
715
+ total: mutation.baseline.total,
716
+ },
717
+ ...(evidence === undefined ? {} : { evidence }),
718
+ ...(ruleId === undefined ? {} : { ruleId }),
719
+ };
720
+ }
721
+ if (!Number.isSafeInteger(mutation.expectedVersion) || mutation.expectedVersion < 1) {
722
+ throw new EvolutionStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
723
+ }
724
+ const ruleId = this.#bounded(mutation.ruleId, 'ruleId', 200);
725
+ if (mutation.evaluation === undefined || mutation.baseline === undefined
726
+ || mutation.evidence === undefined) {
727
+ throw new EvolutionStoreError('invalid-input', 'retirement evidence snapshot must be complete');
728
+ }
729
+ const evaluation = this.#validateStats(mutation.evaluation, 'retirement evaluation');
730
+ const baseline = this.#validateStats(mutation.baseline, 'retirement baseline');
731
+ if (evaluation.scopeKey !== baseline.scopeKey || evaluation.situation !== baseline.situation) {
732
+ throw new EvolutionStoreError('invalid-input', 'retirement evaluation and baseline must match');
733
+ }
734
+ const evidence = this.#validateEvidenceReference(mutation.evidence, evaluation.total);
735
+ return {
736
+ op: 'retire',
737
+ ruleId,
738
+ expectedVersion: mutation.expectedVersion,
739
+ reason: this.#bounded(mutation.reason, 'reason', this.#maxDetailBytes),
740
+ evaluation,
741
+ baseline,
742
+ evidence,
743
+ };
744
+ }
745
+ #validateStoredMutation(mutation) {
746
+ const normalized = this.#validateMutation(mutation);
747
+ if (normalized.op === 'retire')
748
+ return normalized;
749
+ const ruleId = mutation.op === 'adopt' && mutation.ruleId !== undefined
750
+ ? this.#serverRuleId(mutation.ruleId)
751
+ : undefined;
752
+ if (ruleId === undefined) {
753
+ throw new EvolutionStoreError('invalid-input', 'stored adoption mutation requires a server-issued rule UUID');
754
+ }
755
+ return { ...normalized, ruleId };
756
+ }
757
+ #proposalIntent(mutation) {
758
+ if (mutation.op === 'retire' || mutation.ruleId === undefined)
759
+ return mutation;
760
+ const { ruleId: _ruleId, ...intent } = mutation;
761
+ return intent;
762
+ }
763
+ #serverRuleId(value) {
764
+ const ruleId = this.#bounded(value, 'ruleId', 200);
765
+ 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;
766
+ if (!pattern.test(ruleId)) {
767
+ throw new EvolutionStoreError('invalid-input', 'ruleId must be a server-issued rule UUID');
768
+ }
769
+ return ruleId;
770
+ }
771
+ #optionalServerRuleId(value) {
772
+ const normalized = value.normalize('NFC').trim();
773
+ 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;
774
+ return pattern.test(normalized) ? this.#serverRuleId(normalized) : undefined;
775
+ }
776
+ #validateStats(input, field) {
777
+ const scopeKey = this.#scopeKey(input.scopeKey);
778
+ const situation = this.#situation(input.situation);
779
+ if (!Number.isSafeInteger(input.total) || input.total < 1
780
+ || !Number.isSafeInteger(input.failures) || input.failures < 0
781
+ || input.failures > input.total) {
782
+ throw new EvolutionStoreError('invalid-input', `${field} must be coherent over a positive sample`);
783
+ }
784
+ return Object.freeze({ scopeKey, situation, failures: input.failures, total: input.total });
785
+ }
786
+ #validateEvidenceReference(input, baselineTotal) {
787
+ if (!Number.isSafeInteger(input.total) || input.total < 1 || input.total !== baselineTotal) {
788
+ throw new EvolutionStoreError('invalid-input', 'evidence total must equal the positive baseline sample size');
789
+ }
790
+ if (!/^[a-f0-9]{64}$/u.test(input.digest)) {
791
+ throw new EvolutionStoreError('invalid-input', 'evidence digest must be a lowercase SHA-256 digest');
792
+ }
793
+ if (!Array.isArray(input.sampleEpisodeIds) || input.sampleEpisodeIds.length < 1
794
+ || input.sampleEpisodeIds.length > 50 || input.sampleEpisodeIds.length > input.total) {
795
+ throw new EvolutionStoreError('invalid-input', 'evidence sample IDs must be bounded by 1..50 and total');
796
+ }
797
+ const sampleEpisodeIds = input.sampleEpisodeIds.map(id => this.#bounded(id, 'episodeId', 200));
798
+ const episodeIdPattern = /^episode-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
799
+ if (sampleEpisodeIds.some(id => !episodeIdPattern.test(id))) {
800
+ throw new EvolutionStoreError('invalid-input', 'evidence sample IDs must be server-issued episode UUIDs');
801
+ }
802
+ if (new Set(sampleEpisodeIds).size !== sampleEpisodeIds.length) {
803
+ throw new EvolutionStoreError('invalid-input', 'evidence sample IDs must be unique');
804
+ }
805
+ return Object.freeze({
806
+ sampleEpisodeIds: Object.freeze(sampleEpisodeIds),
807
+ digest: input.digest,
808
+ total: input.total,
809
+ });
810
+ }
811
+ #validateCreationIntent(input, expected) {
812
+ if (input.idempotencyKey !== expected.key || input.requester !== expected.requester
813
+ || input.principal !== expected.principal) {
814
+ throw new EvolutionStoreError('invalid-input', 'Policy creation intent identity does not match local proposal');
815
+ }
816
+ if (!Number.isSafeInteger(input.ttlMs) || input.ttlMs < 1) {
817
+ throw new EvolutionStoreError('invalid-input', 'Policy creation intent ttlMs must be positive');
818
+ }
819
+ const intent = {
820
+ idempotencyKey: this.#rawBounded(input.idempotencyKey, 'creation idempotencyKey', 200),
821
+ requester: this.#rawBounded(input.requester, 'creation requester', 200),
822
+ principal: this.#rawBounded(input.principal, 'creation principal', 500),
823
+ action: this.#rawBounded(input.action, 'creation action', 200),
824
+ resource: {
825
+ kind: this.#rawBounded(input.resource.kind, 'creation resource kind', 200),
826
+ id: this.#rawBounded(input.resource.id, 'creation resource id', 2_048),
827
+ },
828
+ summary: this.#rawBounded(input.summary, 'creation summary', 120),
829
+ diff: this.#rawBounded(input.diff, 'creation diff', 64 * 1_024, true),
830
+ ttlMs: input.ttlMs,
831
+ };
832
+ if (input.dispatch !== undefined) {
833
+ if (input.dispatch.principal !== expected.principal) {
834
+ throw new EvolutionStoreError('invalid-input', 'Policy dispatch is bound to another principal');
835
+ }
836
+ intent.dispatch = {
837
+ sourceId: this.#rawBounded(input.dispatch.sourceId, 'dispatch sourceId', 500),
838
+ bindingId: this.#rawBounded(input.dispatch.bindingId, 'dispatch bindingId', 500),
839
+ workspace: this.#rawBounded(input.dispatch.workspace, 'dispatch workspace', 4_096),
840
+ principal: this.#rawBounded(input.dispatch.principal, 'dispatch principal', 500),
841
+ };
842
+ }
843
+ return intent;
844
+ }
845
+ #requireProposal(proposalId) {
846
+ const row = this.#database.prepare('SELECT * FROM evolution_proposals WHERE id = ?')
847
+ .get(proposalId);
848
+ if (row === undefined)
849
+ throw new EvolutionStoreError('not-found', 'evolution proposal was not found');
850
+ return row;
851
+ }
852
+ #requireWindow(window) {
853
+ if (!Number.isSafeInteger(window) || window < 1 || window > 10_000) {
854
+ throw new EvolutionStoreError('invalid-input', 'window must be between 1 and 10000');
855
+ }
856
+ }
857
+ #situation(value) {
858
+ return this.#bounded(value, 'situation', this.#maxSituationBytes);
859
+ }
860
+ #scopeKey(value) {
861
+ return this.#bounded(value, 'scopeKey', 2_048);
862
+ }
863
+ #bounded(value, field, maximum) {
864
+ const normalized = value.normalize('NFC').trim();
865
+ if (normalized === '' || Buffer.byteLength(normalized, 'utf8') > maximum) {
866
+ throw new EvolutionStoreError('invalid-input', `${field} must be non-empty and within ${maximum} bytes`);
867
+ }
868
+ return normalized;
869
+ }
870
+ #rawBounded(value, field, maximum, allowEmpty = false) {
871
+ if (typeof value !== 'string' || (!allowEmpty && value.trim() === '')
872
+ || Buffer.byteLength(value, 'utf8') > maximum) {
873
+ throw new EvolutionStoreError('invalid-input', `${field} must be within ${maximum} bytes`);
874
+ }
875
+ return value;
876
+ }
877
+ #opaque(value, field, maximum) {
878
+ if (typeof value !== 'string' || value === '' || value.trim() !== value
879
+ || Buffer.byteLength(value, 'utf8') > maximum) {
880
+ throw new EvolutionStoreError('invalid-input', `${field} must be an exact non-empty identifier within ${maximum} bytes`);
881
+ }
882
+ return value;
883
+ }
884
+ }
885
+ export { digest as evolutionDigest };
886
+ //# sourceMappingURL=store.js.map