@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/LICENSE +21 -0
- package/README.md +120 -0
- package/cordis.patch.yml +20 -0
- package/lib/guidance.d.ts +29 -0
- package/lib/guidance.d.ts.map +1 -0
- package/lib/guidance.js +59 -0
- package/lib/guidance.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/lib/review.d.ts +13 -0
- package/lib/review.d.ts.map +1 -0
- package/lib/review.js +47 -0
- package/lib/review.js.map +1 -0
- package/lib/service.d.ts +148 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +554 -0
- package/lib/service.js.map +1 -0
- package/lib/sqlite.d.ts +8 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +253 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/store.d.ts +116 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +886 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts +4 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +206 -0
- package/lib/tools.js.map +1 -0
- package/lib/types.d.ts +215 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +3 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +81 -0
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
|