@dsh-enhanced/preference-learning 0.1.7

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,769 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { catalogSelection, preferenceCatalog, renderCatalogPreference, } from './catalog.js';
4
+ import { openPreferenceDatabase, preferenceSchemaVersion } from './sqlite.js';
5
+ import { preferenceActorTrustLevels, preferenceInterpretationTrustLevels, preferenceRollbackReasons, preferenceSignalSources, preferenceSignalStances, } from './types.js';
6
+ export class PreferenceStoreError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.code = code;
11
+ this.name = 'PreferenceStoreError';
12
+ }
13
+ }
14
+ const actorWeights = Object.freeze({
15
+ 'owner-authenticated': 400,
16
+ 'delegated-authenticated': 250,
17
+ 'system-attested': 150,
18
+ unverified: 25,
19
+ });
20
+ const interpretationWeights = Object.freeze({
21
+ 'explicit-selection': 400,
22
+ 'typed-feedback': 300,
23
+ 'behavioral-inference': 100,
24
+ 'model-inference': 40,
25
+ });
26
+ function boundedText(value, label, maxBytes) {
27
+ if (typeof value !== 'string')
28
+ throw new PreferenceStoreError('invalid-input', `${label} must be a string`);
29
+ const normalized = value.normalize('NFC').trim();
30
+ if (normalized === '' || Buffer.byteLength(normalized, 'utf8') > maxBytes) {
31
+ throw new PreferenceStoreError('invalid-input', `${label} must contain 1-${maxBytes} UTF-8 bytes`);
32
+ }
33
+ return normalized;
34
+ }
35
+ function enumValue(value, values, label) {
36
+ if (typeof value !== 'string' || !values.includes(value)) {
37
+ throw new PreferenceStoreError('invalid-input', `${label} is not an allowed value`);
38
+ }
39
+ return value;
40
+ }
41
+ function safeTimestamp(value, label) {
42
+ if (!Number.isSafeInteger(value) || value < 0) {
43
+ throw new PreferenceStoreError('invalid-input', `${label} must be a non-negative safe integer`);
44
+ }
45
+ return value;
46
+ }
47
+ export function canonicalPreferenceScope(input) {
48
+ const workspace = boundedText(input.workspace, 'scope.workspace', 4_096);
49
+ if (!isAbsolute(workspace))
50
+ throw new PreferenceStoreError('invalid-input', 'scope.workspace must be absolute');
51
+ const scope = Object.freeze({
52
+ workspace: resolve(workspace),
53
+ preset: boundedText(input.preset, 'scope.preset', 200),
54
+ });
55
+ const scopeKey = JSON.stringify([scope.workspace, scope.preset]);
56
+ return {
57
+ scope,
58
+ scopeKey,
59
+ scopeDigest: createHash('sha256').update(`preference-scope-v1\0${scopeKey}`).digest('hex'),
60
+ };
61
+ }
62
+ function payloadHash(value) {
63
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
64
+ }
65
+ function signalFromRow(row) {
66
+ return Object.freeze({
67
+ id: row.id,
68
+ scope: Object.freeze({ workspace: row.workspace, preset: row.preset }),
69
+ preferenceKey: row.preference_key,
70
+ candidateValue: row.candidate_value,
71
+ riskTier: row.risk_tier,
72
+ stance: row.stance,
73
+ actorTrust: row.actor_trust,
74
+ interpretationTrust: row.interpretation_trust,
75
+ source: row.source,
76
+ occurredAt: row.occurred_at,
77
+ idempotencyKey: row.idempotency_key,
78
+ recordedAt: row.recorded_at,
79
+ });
80
+ }
81
+ function hypothesisFromRow(row) {
82
+ return Object.freeze({
83
+ id: row.id,
84
+ scope: Object.freeze({ workspace: row.workspace, preset: row.preset }),
85
+ preferenceKey: row.preference_key,
86
+ candidateValue: row.candidate_value,
87
+ riskTier: row.risk_tier,
88
+ claimState: row.claim_state,
89
+ effectState: row.effect_state,
90
+ confidenceBps: row.confidence_bps,
91
+ contradictionBps: row.contradiction_bps,
92
+ supportingSignals: row.supporting_signals,
93
+ contradictingSignals: row.contradicting_signals,
94
+ evidenceMass: row.evidence_mass,
95
+ expiresAt: row.expires_at,
96
+ activatedAt: row.activated_at ?? undefined,
97
+ rolledBackAt: row.rolled_back_at ?? undefined,
98
+ version: row.version,
99
+ createdAt: row.created_at,
100
+ updatedAt: row.updated_at,
101
+ });
102
+ }
103
+ function hypothesisId(scopeKey, preferenceKey, candidateValue) {
104
+ return `pref-hyp-${createHash('sha256')
105
+ .update(`preference-hypothesis-v1\0${scopeKey}\0${preferenceKey}\0${candidateValue}`)
106
+ .digest('hex')}`;
107
+ }
108
+ function assertSignalProvenance(input) {
109
+ if (input.source === 'direct-owner-feedback') {
110
+ if (input.actorTrust !== 'owner-authenticated') {
111
+ throw new PreferenceStoreError('invalid-input', 'direct-owner-feedback requires owner-authenticated actor trust');
112
+ }
113
+ if (!['explicit-selection', 'typed-feedback'].includes(input.interpretationTrust)) {
114
+ throw new PreferenceStoreError('invalid-input', 'direct-owner-feedback requires explicit or typed interpretation');
115
+ }
116
+ }
117
+ if (input.source === 'signed-ui-feedback'
118
+ && !['owner-authenticated', 'delegated-authenticated'].includes(input.actorTrust)) {
119
+ throw new PreferenceStoreError('invalid-input', 'signed-ui-feedback requires authenticated actor trust');
120
+ }
121
+ }
122
+ /**
123
+ * Only an authenticated owner's explicit, typed feedback can unlock an
124
+ * automatic T1 effect. Lower-trust observations remain useful shadow evidence
125
+ * and contradictions, but no amount of repetition can promote them by itself.
126
+ */
127
+ function isActivationEligibleSignal(signal) {
128
+ return signal.actor_trust === 'owner-authenticated'
129
+ && ['explicit-selection', 'typed-feedback'].includes(signal.interpretation_trust)
130
+ && ['direct-owner-feedback', 'signed-ui-feedback'].includes(signal.source);
131
+ }
132
+ export class PreferenceStore {
133
+ #database;
134
+ #now;
135
+ #options;
136
+ #closed = false;
137
+ constructor(options) {
138
+ this.#database = openPreferenceDatabase(options.path);
139
+ this.#now = options.now ?? Date.now;
140
+ this.#options = Object.freeze({
141
+ signalTtlMs: options.signalTtlMs,
142
+ hypothesisTtlMs: options.hypothesisTtlMs,
143
+ minSignalsForActivation: options.minSignalsForActivation,
144
+ minConfidenceBps: options.minConfidenceBps,
145
+ maxContradictionBps: options.maxContradictionBps,
146
+ maxActiveOverlays: options.maxActiveOverlays,
147
+ maxReviewHypotheses: options.maxReviewHypotheses,
148
+ maxOverlayBytes: options.maxOverlayBytes,
149
+ });
150
+ }
151
+ appendSignal(input) {
152
+ return this.appendSignals([input])[0];
153
+ }
154
+ /** Atomically record one small producer batch and reconcile each affected key once. */
155
+ appendSignals(inputs) {
156
+ this.#assertOpen();
157
+ if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > 16) {
158
+ throw new PreferenceStoreError('invalid-input', 'preference signal batch must contain 1-16 entries');
159
+ }
160
+ const now = this.#now();
161
+ const normalizedBatch = inputs.map(input => this.#normalizeSignal(input, now));
162
+ const uniqueIdempotency = new Set(normalizedBatch.map(input => input.idempotencyDigest));
163
+ if (uniqueIdempotency.size !== normalizedBatch.length) {
164
+ throw new PreferenceStoreError('invalid-input', 'preference signal batch contains duplicate idempotency keys');
165
+ }
166
+ this.#database.exec('BEGIN IMMEDIATE');
167
+ try {
168
+ const storedSignals = [];
169
+ const affected = new Map();
170
+ for (const normalized of normalizedBatch) {
171
+ const hash = payloadHash(normalized);
172
+ const replay = this.#database.prepare('SELECT * FROM preference_signals WHERE idempotency_key = ?').get(normalized.idempotencyDigest);
173
+ if (replay !== undefined) {
174
+ if (replay.payload_hash !== hash) {
175
+ throw new PreferenceStoreError('idempotency-conflict', 'preference signal idempotency key was reused with different content');
176
+ }
177
+ storedSignals.push(signalFromRow(replay));
178
+ continue;
179
+ }
180
+ const forgottenThrough = this.#forgottenThrough(normalized.scopeDigest);
181
+ if (forgottenThrough !== undefined && normalized.occurredAt <= forgottenThrough) {
182
+ throw new PreferenceStoreError('scope-forgotten', 'signal predates the durable scope forget boundary');
183
+ }
184
+ const id = `pref-sig-${randomUUID()}`;
185
+ this.#database.prepare(`
186
+ INSERT INTO preference_signals(
187
+ id, idempotency_key, payload_hash, scope_key, workspace, preset,
188
+ preference_key, candidate_value, risk_tier, stance, actor_trust,
189
+ interpretation_trust, source, occurred_at, recorded_at
190
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
191
+ `).run(id, normalized.idempotencyDigest, hash, normalized.scopeKey, normalized.scope.workspace, normalized.scope.preset, normalized.preferenceKey, normalized.candidateValue, normalized.riskTier, normalized.stance, normalized.actorTrust, normalized.interpretationTrust, normalized.source, normalized.occurredAt, now);
192
+ const stored = this.#database.prepare('SELECT * FROM preference_signals WHERE id = ?')
193
+ .get(id);
194
+ storedSignals.push(signalFromRow(stored));
195
+ if (normalized.riskTier === 'T1' || normalized.riskTier === 'T2') {
196
+ affected.set(`${normalized.scopeKey}\0${normalized.preferenceKey}`, {
197
+ scopeKey: normalized.scopeKey,
198
+ scope: normalized.scope,
199
+ preferenceKey: normalized.preferenceKey,
200
+ trigger: stored,
201
+ });
202
+ }
203
+ }
204
+ for (const entry of affected.values()) {
205
+ this.#reconcileKey(entry.scopeKey, entry.scope, entry.preferenceKey, now, entry.trigger);
206
+ }
207
+ this.#database.exec('COMMIT');
208
+ return storedSignals;
209
+ }
210
+ catch (error) {
211
+ this.#database.exec('ROLLBACK');
212
+ throw error;
213
+ }
214
+ }
215
+ list(scopeInput, limit = this.#options.maxReviewHypotheses) {
216
+ this.#assertOpen();
217
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.#options.maxReviewHypotheses) {
218
+ throw new PreferenceStoreError('invalid-input', `hypothesis limit must be between 1 and ${this.#options.maxReviewHypotheses}`);
219
+ }
220
+ const canonical = canonicalPreferenceScope(scopeInput);
221
+ this.#refreshScope(canonical.scopeKey, canonical.scope, this.#now());
222
+ const rows = this.#database.prepare(`
223
+ SELECT * FROM preference_hypotheses
224
+ WHERE scope_key = ?
225
+ ORDER BY CASE effect_state
226
+ WHEN 'active' THEN 0 WHEN 'shadow' THEN 1 WHEN 'inactive' THEN 2
227
+ WHEN 'suppressed' THEN 3 ELSE 4 END,
228
+ confidence_bps DESC, updated_at DESC, id DESC
229
+ LIMIT ?
230
+ `).all(canonical.scopeKey, limit);
231
+ return rows.map(hypothesisFromRow);
232
+ }
233
+ get(scopeInput, hypothesisIdInput) {
234
+ this.#assertOpen();
235
+ const canonical = canonicalPreferenceScope(scopeInput);
236
+ const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
237
+ this.#refreshScope(canonical.scopeKey, canonical.scope, this.#now());
238
+ const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
239
+ return row === undefined ? undefined : hypothesisFromRow(row);
240
+ }
241
+ activate(scopeInput, hypothesisIdInput, expectedVersion) {
242
+ this.#assertOpen();
243
+ const canonical = canonicalPreferenceScope(scopeInput);
244
+ const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
245
+ if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) {
246
+ throw new PreferenceStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
247
+ }
248
+ const now = this.#now();
249
+ this.#database.exec('BEGIN IMMEDIATE');
250
+ try {
251
+ this.#refreshScopeInTransaction(canonical.scopeKey, canonical.scope, now);
252
+ const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
253
+ if (row === undefined)
254
+ throw new PreferenceStoreError('not-found', 'preference hypothesis was not found');
255
+ if (row.version !== expectedVersion)
256
+ throw new PreferenceStoreError('conflict', 'hypothesis version changed');
257
+ if (row.risk_tier !== 'T1') {
258
+ throw new PreferenceStoreError('forbidden-tier', 'only Host-catalog T1 hypotheses can activate automatically');
259
+ }
260
+ if (row.claim_state !== 'tentative' || row.effect_state !== 'shadow') {
261
+ throw new PreferenceStoreError('not-ready', 'hypothesis is not in an activatable shadow state');
262
+ }
263
+ if (row.expires_at <= now
264
+ || row.supporting_signals < this.#options.minSignalsForActivation
265
+ || row.confidence_bps < this.#options.minConfidenceBps
266
+ || row.contradiction_bps > this.#options.maxContradictionBps) {
267
+ throw new PreferenceStoreError('not-ready', 'hypothesis has insufficient current evidence');
268
+ }
269
+ const latestSelection = this.#latestActivationEligibleSignal(canonical.scopeKey, row.preference_key, now);
270
+ if (latestSelection?.stance !== 'support'
271
+ || latestSelection.candidate_value !== row.candidate_value) {
272
+ throw new PreferenceStoreError('not-ready', 'hypothesis is not the owner\'s latest explicit selection');
273
+ }
274
+ const activeCount = this.#database.prepare(`
275
+ SELECT COUNT(*) AS count FROM preference_hypotheses
276
+ WHERE scope_key = ? AND effect_state = 'active'
277
+ `).get(canonical.scopeKey).count;
278
+ if (activeCount >= this.#options.maxActiveOverlays) {
279
+ throw new PreferenceStoreError('not-ready', 'scope reached the active preference hard cap');
280
+ }
281
+ const sameKey = this.#database.prepare(`
282
+ SELECT id FROM preference_hypotheses
283
+ WHERE scope_key = ? AND preference_key = ? AND effect_state = 'active'
284
+ `).get(canonical.scopeKey, row.preference_key);
285
+ if (sameKey !== undefined)
286
+ throw new PreferenceStoreError('conflict', 'another value for this key is active');
287
+ const nextVersion = row.version + 1;
288
+ this.#database.prepare(`
289
+ UPDATE preference_hypotheses
290
+ SET effect_state = 'active', activated_at = ?, updated_at = ?, version = ?
291
+ WHERE id = ? AND version = ?
292
+ `).run(now, now, nextVersion, row.id, row.version);
293
+ this.#transition(row, row.claim_state, 'active', 'activated', nextVersion, now);
294
+ const updated = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ?')
295
+ .get(row.id);
296
+ this.#database.exec('COMMIT');
297
+ return hypothesisFromRow(updated);
298
+ }
299
+ catch (error) {
300
+ this.#database.exec('ROLLBACK');
301
+ throw error;
302
+ }
303
+ }
304
+ rollback(scopeInput, hypothesisIdInput, expectedVersion, reasonInput) {
305
+ this.#assertOpen();
306
+ const canonical = canonicalPreferenceScope(scopeInput);
307
+ const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
308
+ const reason = enumValue(reasonInput, preferenceRollbackReasons, 'reason');
309
+ if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) {
310
+ throw new PreferenceStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
311
+ }
312
+ const now = this.#now();
313
+ this.#database.exec('BEGIN IMMEDIATE');
314
+ try {
315
+ const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
316
+ if (row === undefined)
317
+ throw new PreferenceStoreError('not-found', 'preference hypothesis was not found');
318
+ if (row.version !== expectedVersion)
319
+ throw new PreferenceStoreError('conflict', 'hypothesis version changed');
320
+ if (row.effect_state !== 'active' && row.effect_state !== 'shadow') {
321
+ throw new PreferenceStoreError('not-ready', 'only a shadow or active hypothesis can be rolled back');
322
+ }
323
+ const nextVersion = row.version + 1;
324
+ this.#database.prepare(`
325
+ UPDATE preference_hypotheses SET
326
+ claim_state = 'rejected', effect_state = 'rolled-back', rolled_back_at = ?, updated_at = ?, version = ?
327
+ WHERE id = ? AND version = ?
328
+ `).run(now, now, nextVersion, row.id, row.version);
329
+ this.#transition(row, 'rejected', 'rolled-back', reason, nextVersion, now);
330
+ const updated = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ?')
331
+ .get(row.id);
332
+ this.#database.exec('COMMIT');
333
+ return hypothesisFromRow(updated);
334
+ }
335
+ catch (error) {
336
+ this.#database.exec('ROLLBACK');
337
+ throw error;
338
+ }
339
+ }
340
+ overlay(scopeInput) {
341
+ this.#assertOpen();
342
+ const canonical = canonicalPreferenceScope(scopeInput);
343
+ this.#refreshScope(canonical.scopeKey, canonical.scope, this.#now());
344
+ const rows = this.#database.prepare(`
345
+ SELECT * FROM preference_hypotheses
346
+ WHERE scope_key = ? AND effect_state = 'active' AND risk_tier = 'T1'
347
+ ORDER BY preference_key ASC, id ASC
348
+ LIMIT ?
349
+ `).all(canonical.scopeKey, this.#options.maxActiveOverlays);
350
+ if (rows.length === 0)
351
+ return undefined;
352
+ const lines = rows.map(row => `- ${renderCatalogPreference(row.preference_key, row.candidate_value)}`);
353
+ const rendered = [
354
+ '<tentative_preference_overlay>',
355
+ 'These low-risk preferences are tentative, scope-local, and subordinate to the current request.',
356
+ ...lines,
357
+ '</tentative_preference_overlay>',
358
+ ].join('\n');
359
+ if (Buffer.byteLength(rendered, 'utf8') > this.#options.maxOverlayBytes) {
360
+ throw new PreferenceStoreError('invalid-input', 'catalog overlay exceeds the configured hard byte cap');
361
+ }
362
+ return rendered;
363
+ }
364
+ forgetScope(scopeInput, idempotencyKeyInput) {
365
+ this.#assertOpen();
366
+ const canonical = canonicalPreferenceScope(scopeInput);
367
+ const rawIdempotencyKey = boundedText(idempotencyKeyInput, 'idempotencyKey', 200);
368
+ const idempotencyKey = `pref-forget-idem-${createHash('sha256')
369
+ .update(`preference-forget-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
370
+ const now = this.#now();
371
+ const forgottenThrough = now;
372
+ const hash = payloadHash({ scopeDigest: canonical.scopeDigest, idempotencyKey });
373
+ let result;
374
+ this.#database.exec('BEGIN IMMEDIATE');
375
+ try {
376
+ const replay = this.#database.prepare(`
377
+ SELECT payload_hash, forgotten_through, deleted_signals, deleted_hypotheses
378
+ FROM preference_scope_tombstones WHERE idempotency_key = ?
379
+ `).get(idempotencyKey);
380
+ if (replay !== undefined) {
381
+ if (replay.payload_hash !== hash) {
382
+ throw new PreferenceStoreError('idempotency-conflict', 'forget idempotency key was reused');
383
+ }
384
+ this.#database.exec('COMMIT');
385
+ result = Object.freeze({
386
+ replayed: true,
387
+ forgottenThrough: replay.forgotten_through,
388
+ deletedSignals: replay.deleted_signals,
389
+ deletedHypotheses: replay.deleted_hypotheses,
390
+ });
391
+ }
392
+ else {
393
+ const hypothesisIds = this.#database.prepare('SELECT id FROM preference_hypotheses WHERE scope_key = ?').all(canonical.scopeKey);
394
+ for (const entry of hypothesisIds) {
395
+ this.#database.prepare('DELETE FROM preference_transitions WHERE hypothesis_id = ?').run(entry.id);
396
+ }
397
+ const deletedHypotheses = Number(this.#database.prepare('DELETE FROM preference_hypotheses WHERE scope_key = ?').run(canonical.scopeKey).changes);
398
+ const deletedSignals = Number(this.#database.prepare('DELETE FROM preference_signals WHERE scope_key = ?').run(canonical.scopeKey).changes);
399
+ this.#database.prepare(`
400
+ INSERT INTO preference_scope_tombstones(
401
+ id, scope_digest, idempotency_key, payload_hash, forgotten_through,
402
+ recorded_at, deleted_signals, deleted_hypotheses
403
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
404
+ `).run(`pref-forget-${randomUUID()}`, canonical.scopeDigest, idempotencyKey, hash, forgottenThrough, now, deletedSignals, deletedHypotheses);
405
+ this.#database.exec('COMMIT');
406
+ result = Object.freeze({
407
+ replayed: false,
408
+ forgottenThrough,
409
+ deletedSignals,
410
+ deletedHypotheses,
411
+ });
412
+ }
413
+ }
414
+ catch (error) {
415
+ this.#database.exec('ROLLBACK');
416
+ throw error;
417
+ }
418
+ // A logical DELETE is not a privacy boundary while old pages remain in
419
+ // WAL. Secure-delete wipes the database pages; TRUNCATE proves those
420
+ // deletion frames have also been checkpointed before success is reported.
421
+ this.#truncateWalForPrivacy();
422
+ return result;
423
+ }
424
+ health() {
425
+ this.#assertOpen();
426
+ const signals = this.#database.prepare('SELECT COUNT(*) AS count, MAX(recorded_at) AS last FROM preference_signals').get();
427
+ const hypotheses = this.#database.prepare(`
428
+ SELECT
429
+ COUNT(*) AS total,
430
+ SUM(CASE WHEN effect_state = 'active' THEN 1 ELSE 0 END) AS active,
431
+ SUM(CASE WHEN effect_state = 'shadow' THEN 1 ELSE 0 END) AS shadow,
432
+ SUM(CASE WHEN claim_state = 'proposed' THEN 1 ELSE 0 END) AS proposed,
433
+ SUM(CASE WHEN effect_state = 'rolled-back' THEN 1 ELSE 0 END) AS rolled_back,
434
+ SUM(CASE WHEN claim_state = 'expired' THEN 1 ELSE 0 END) AS expired
435
+ FROM preference_hypotheses
436
+ `).get();
437
+ return Object.freeze({
438
+ ready: true,
439
+ enabled: true,
440
+ schemaVersion: preferenceSchemaVersion,
441
+ signals: signals.count,
442
+ hypotheses: hypotheses.total,
443
+ active: hypotheses.active ?? 0,
444
+ shadow: hypotheses.shadow ?? 0,
445
+ proposed: hypotheses.proposed ?? 0,
446
+ rolledBack: hypotheses.rolled_back ?? 0,
447
+ expired: hypotheses.expired ?? 0,
448
+ lastRecordedAt: signals.last ?? undefined,
449
+ });
450
+ }
451
+ /** Delete one bounded batch whose evidence-retention window has elapsed. */
452
+ maintain(limit = 500) {
453
+ this.#assertOpen();
454
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
455
+ throw new PreferenceStoreError('invalid-input', 'maintenance limit must be between 1 and 5000');
456
+ }
457
+ const threshold = Math.max(0, this.#now() - this.#options.signalTtlMs);
458
+ let deletedSignals;
459
+ this.#database.exec('BEGIN IMMEDIATE');
460
+ try {
461
+ deletedSignals = Number(this.#database.prepare(`
462
+ DELETE FROM preference_signals
463
+ WHERE id IN (
464
+ SELECT id FROM preference_signals
465
+ WHERE occurred_at < ?
466
+ ORDER BY occurred_at ASC, id ASC
467
+ LIMIT ?
468
+ )
469
+ `).run(threshold, limit).changes);
470
+ this.#database.exec('COMMIT');
471
+ }
472
+ catch (error) {
473
+ this.#database.exec('ROLLBACK');
474
+ throw error;
475
+ }
476
+ // Run this even when the current batch is empty: a previous committed
477
+ // deletion may have reported privacy-purge-pending because a reader held
478
+ // the WAL, and the next maintenance pass must finish that purge.
479
+ this.#truncateWalForPrivacy();
480
+ return Object.freeze({ deletedSignals });
481
+ }
482
+ close() {
483
+ if (this.#closed)
484
+ return;
485
+ this.#closed = true;
486
+ this.#database.close();
487
+ }
488
+ #normalizeSignal(input, now) {
489
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
490
+ throw new PreferenceStoreError('invalid-input', 'preference signal must be an object');
491
+ }
492
+ const canonical = canonicalPreferenceScope(input.scope);
493
+ let selection;
494
+ try {
495
+ selection = catalogSelection(input.preferenceKey, input.candidateValue);
496
+ }
497
+ catch (error) {
498
+ throw new PreferenceStoreError('invalid-input', error instanceof Error ? error.message : String(error));
499
+ }
500
+ if (selection.riskTier === 'T3') {
501
+ throw new PreferenceStoreError('forbidden-tier', 'T3 controls are Host-defined and cannot become evidence');
502
+ }
503
+ const occurredAt = safeTimestamp(input.occurredAt, 'occurredAt');
504
+ if (occurredAt > now) {
505
+ throw new PreferenceStoreError('invalid-input', 'occurredAt must not be in the future');
506
+ }
507
+ if (now - occurredAt > this.#options.signalTtlMs) {
508
+ throw new PreferenceStoreError('invalid-input', 'signal is older than the evidence retention window');
509
+ }
510
+ const normalized = {
511
+ ...canonical,
512
+ preferenceKey: selection.key,
513
+ candidateValue: selection.value,
514
+ riskTier: selection.riskTier,
515
+ stance: enumValue(input.stance, preferenceSignalStances, 'stance'),
516
+ actorTrust: enumValue(input.actorTrust, preferenceActorTrustLevels, 'actorTrust'),
517
+ interpretationTrust: enumValue(input.interpretationTrust, preferenceInterpretationTrustLevels, 'interpretationTrust'),
518
+ source: enumValue(input.source, preferenceSignalSources, 'source'),
519
+ occurredAt,
520
+ idempotencyDigest: `pref-idem-${createHash('sha256')
521
+ .update(`preference-signal-idempotency-v1\0${boundedText(input.idempotencyKey, 'idempotencyKey', 200)}`)
522
+ .digest('hex')}`,
523
+ };
524
+ assertSignalProvenance(normalized);
525
+ return normalized;
526
+ }
527
+ #refreshScope(scopeKey, scope, now) {
528
+ this.#database.exec('BEGIN IMMEDIATE');
529
+ try {
530
+ this.#refreshScopeInTransaction(scopeKey, scope, now);
531
+ this.#database.exec('COMMIT');
532
+ }
533
+ catch (error) {
534
+ this.#database.exec('ROLLBACK');
535
+ throw error;
536
+ }
537
+ }
538
+ #refreshScopeInTransaction(scopeKey, scope, now) {
539
+ const keys = this.#database.prepare(`
540
+ SELECT DISTINCT preference_key FROM preference_hypotheses WHERE scope_key = ?
541
+ UNION
542
+ SELECT DISTINCT preference_key FROM preference_signals
543
+ WHERE scope_key = ? AND risk_tier IN ('T1', 'T2')
544
+ `).all(scopeKey, scopeKey);
545
+ for (const { preference_key: key } of keys)
546
+ this.#reconcileKey(scopeKey, scope, key, now);
547
+ }
548
+ #reconcileKey(scopeKey, scope, key, now, triggerSignal) {
549
+ const signals = this.#database.prepare(`
550
+ SELECT * FROM preference_signals
551
+ WHERE scope_key = ? AND preference_key = ? AND occurred_at >= ?
552
+ ORDER BY occurred_at ASC, id ASC
553
+ `).all(scopeKey, key, Math.max(0, now - this.#options.signalTtlMs));
554
+ const entry = preferenceCatalog[key];
555
+ if (entry.riskTier !== 'T1' && entry.riskTier !== 'T2')
556
+ return;
557
+ const existing = this.#database.prepare(`
558
+ SELECT * FROM preference_hypotheses WHERE scope_key = ? AND preference_key = ?
559
+ `).all(scopeKey, key);
560
+ const latestSelection = this.#latestActivationEligibleSignal(scopeKey, key, now);
561
+ const candidates = new Set(existing.map(row => row.candidate_value));
562
+ for (const signal of signals)
563
+ if (signal.stance === 'support')
564
+ candidates.add(signal.candidate_value);
565
+ for (const candidate of candidates) {
566
+ const evidence = this.#evidenceForCandidate(signals, candidate, now);
567
+ const latestSupportingAt = signals.reduce((latest, signal) => {
568
+ if (signal.stance !== 'support' || signal.candidate_value !== candidate)
569
+ return latest;
570
+ return latest === undefined ? signal.occurred_at : Math.max(latest, signal.occurred_at);
571
+ }, undefined);
572
+ const latestEligibleSupportingAt = signals.reduce((latest, signal) => {
573
+ if (!isActivationEligibleSignal(signal)
574
+ || signal.stance !== 'support'
575
+ || signal.candidate_value !== candidate)
576
+ return latest;
577
+ return latest === undefined ? signal.occurred_at : Math.max(latest, signal.occurred_at);
578
+ }, undefined);
579
+ const prior = existing.find(row => row.candidate_value === candidate);
580
+ if (prior === undefined) {
581
+ if (latestSupportingAt === undefined)
582
+ continue;
583
+ const expiryAnchor = entry.riskTier === 'T1'
584
+ ? latestEligibleSupportingAt ?? latestSupportingAt
585
+ : latestSupportingAt;
586
+ const expiresAt = expiryAnchor + this.#options.hypothesisTtlMs;
587
+ const expired = expiresAt <= now;
588
+ const claimState = expired
589
+ ? 'expired'
590
+ : entry.riskTier === 'T2' ? 'proposed' : 'tentative';
591
+ const effectState = expired
592
+ ? 'inactive'
593
+ : entry.riskTier === 'T2' ? 'inactive' : 'shadow';
594
+ const id = hypothesisId(scopeKey, key, candidate);
595
+ this.#database.prepare(`
596
+ INSERT INTO preference_hypotheses(
597
+ id, scope_key, workspace, preset, preference_key, candidate_value, risk_tier,
598
+ claim_state, effect_state, confidence_bps, contradiction_bps,
599
+ supporting_signals, contradicting_signals, evidence_mass, expires_at,
600
+ activated_at, rolled_back_at, version, created_at, updated_at
601
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 1, ?, ?)
602
+ `).run(id, scopeKey, scope.workspace, scope.preset, key, candidate, entry.riskTier, claimState, effectState, evidence.confidenceBps, evidence.contradictionBps, evidence.supportingSignals, evidence.contradictingSignals, evidence.mass, expiresAt, now, now);
603
+ this.#database.prepare(`
604
+ INSERT INTO preference_transitions(
605
+ hypothesis_id, from_claim_state, to_claim_state, from_effect_state,
606
+ to_effect_state, reason, version, occurred_at
607
+ ) VALUES (?, NULL, ?, NULL, ?, 'created', 1, ?)
608
+ `).run(id, claimState, effectState, now);
609
+ continue;
610
+ }
611
+ const expiryAnchor = entry.riskTier === 'T1'
612
+ ? latestEligibleSupportingAt ?? latestSupportingAt
613
+ : latestSupportingAt;
614
+ const expiresAt = expiryAnchor === undefined
615
+ ? prior.expires_at
616
+ : expiryAnchor + this.#options.hypothesisTtlMs;
617
+ let claimState = prior.claim_state;
618
+ let effectState = prior.effect_state;
619
+ let activatedAt = prior.activated_at;
620
+ let rolledBackAt = prior.rolled_back_at;
621
+ let version = prior.version;
622
+ let transitionReason;
623
+ const ownerCorrection = triggerSignal !== undefined
624
+ && latestSelection?.id === triggerSignal.id
625
+ && isActivationEligibleSignal(triggerSignal)
626
+ && ((triggerSignal.stance === 'support' && triggerSignal.candidate_value !== candidate)
627
+ || (triggerSignal.stance === 'contradict' && triggerSignal.candidate_value === candidate));
628
+ const ownerReselected = triggerSignal !== undefined
629
+ && latestSelection?.id === triggerSignal.id
630
+ && isActivationEligibleSignal(triggerSignal)
631
+ && triggerSignal.stance === 'support'
632
+ && triggerSignal.candidate_value === candidate;
633
+ if (!['rejected', 'expired'].includes(claimState) && expiresAt <= now) {
634
+ claimState = 'expired';
635
+ effectState = 'inactive';
636
+ version += 1;
637
+ transitionReason = 'expired';
638
+ }
639
+ else if (effectState === 'active' && ownerCorrection) {
640
+ claimState = 'rejected';
641
+ effectState = 'rolled-back';
642
+ rolledBackAt = now;
643
+ version += 1;
644
+ transitionReason = 'owner-rejected';
645
+ }
646
+ else if (entry.riskTier === 'T1'
647
+ && ['rejected', 'expired'].includes(claimState)
648
+ && ownerReselected
649
+ && expiresAt > now) {
650
+ claimState = 'tentative';
651
+ effectState = 'shadow';
652
+ activatedAt = null;
653
+ rolledBackAt = null;
654
+ version += 1;
655
+ transitionReason = 'evidence-updated';
656
+ }
657
+ const changed = prior.confidence_bps !== evidence.confidenceBps
658
+ || prior.contradiction_bps !== evidence.contradictionBps
659
+ || prior.supporting_signals !== evidence.supportingSignals
660
+ || prior.contradicting_signals !== evidence.contradictingSignals
661
+ || prior.evidence_mass !== evidence.mass
662
+ || prior.expires_at !== expiresAt
663
+ || prior.claim_state !== claimState
664
+ || prior.effect_state !== effectState
665
+ || prior.activated_at !== activatedAt
666
+ || prior.rolled_back_at !== rolledBackAt;
667
+ if (!changed)
668
+ continue;
669
+ if (transitionReason === undefined) {
670
+ version += 1;
671
+ transitionReason = 'evidence-updated';
672
+ }
673
+ this.#database.prepare(`
674
+ UPDATE preference_hypotheses SET
675
+ claim_state = ?, effect_state = ?, confidence_bps = ?, contradiction_bps = ?,
676
+ supporting_signals = ?, contradicting_signals = ?, evidence_mass = ?, expires_at = ?,
677
+ activated_at = ?, rolled_back_at = ?, version = ?, updated_at = ?
678
+ WHERE id = ?
679
+ `).run(claimState, effectState, evidence.confidenceBps, evidence.contradictionBps, evidence.supportingSignals, evidence.contradictingSignals, evidence.mass, expiresAt, activatedAt, rolledBackAt, version, now, prior.id);
680
+ this.#transition(prior, claimState, effectState, transitionReason, version, now);
681
+ }
682
+ }
683
+ #evidenceForCandidate(signals, candidate, now) {
684
+ let positive = 0;
685
+ let negative = 0;
686
+ let observedMass = 0;
687
+ let supportingSignals = 0;
688
+ let contradictingSignals = 0;
689
+ for (const signal of signals) {
690
+ const age = Math.max(0, now - signal.occurred_at);
691
+ if (age >= this.#options.signalTtlMs)
692
+ continue;
693
+ const base = Math.floor(actorWeights[signal.actor_trust] * interpretationWeights[signal.interpretation_trust] / 100);
694
+ const decayPermille = Math.floor((this.#options.signalTtlMs - age) * 1_000 / this.#options.signalTtlMs);
695
+ const weight = Math.floor(base * decayPermille / 1_000);
696
+ if (weight <= 0)
697
+ continue;
698
+ observedMass += weight;
699
+ if (!isActivationEligibleSignal(signal))
700
+ continue;
701
+ const supports = signal.candidate_value === candidate && signal.stance === 'support';
702
+ const contradicts = (signal.candidate_value === candidate && signal.stance === 'contradict')
703
+ || (signal.candidate_value !== candidate && signal.stance === 'support');
704
+ if (supports) {
705
+ positive += weight;
706
+ supportingSignals += 1;
707
+ }
708
+ else if (contradicts) {
709
+ negative += weight;
710
+ contradictingSignals += 1;
711
+ }
712
+ }
713
+ const decisionMass = positive + negative;
714
+ const rawConfidenceBps = decisionMass === 0 ? 0 : Math.floor(positive * 10_000 / decisionMass);
715
+ const coverageTarget = this.#options.minSignalsForActivation * 1_200;
716
+ const coverageBps = Math.min(10_000, Math.floor(decisionMass * 10_000 / coverageTarget));
717
+ return {
718
+ mass: observedMass,
719
+ supportingSignals,
720
+ contradictingSignals,
721
+ confidenceBps: Math.floor(rawConfidenceBps * coverageBps / 10_000),
722
+ contradictionBps: decisionMass === 0 ? 0 : Math.floor(negative * 10_000 / decisionMass),
723
+ };
724
+ }
725
+ #latestActivationEligibleSignal(scopeKey, key, now) {
726
+ return this.#database.prepare(`
727
+ SELECT * FROM preference_signals
728
+ WHERE scope_key = ? AND preference_key = ? AND occurred_at >= ?
729
+ AND actor_trust = 'owner-authenticated'
730
+ AND interpretation_trust IN ('explicit-selection', 'typed-feedback')
731
+ AND source IN ('direct-owner-feedback', 'signed-ui-feedback')
732
+ ORDER BY occurred_at DESC, recorded_at DESC, rowid DESC
733
+ LIMIT 1
734
+ `).get(scopeKey, key, Math.max(0, now - this.#options.signalTtlMs));
735
+ }
736
+ #transition(prior, claimState, effectState, reason, version, occurredAt) {
737
+ this.#database.prepare(`
738
+ INSERT INTO preference_transitions(
739
+ hypothesis_id, from_claim_state, to_claim_state, from_effect_state,
740
+ to_effect_state, reason, version, occurred_at
741
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
742
+ `).run(prior.id, prior.claim_state, claimState, prior.effect_state, effectState, reason, version, occurredAt);
743
+ }
744
+ #forgottenThrough(scopeDigest) {
745
+ const row = this.#database.prepare(`
746
+ SELECT MAX(forgotten_through) AS forgotten_through
747
+ FROM preference_scope_tombstones WHERE scope_digest = ?
748
+ `).get(scopeDigest);
749
+ return row.forgotten_through ?? undefined;
750
+ }
751
+ #assertOpen() {
752
+ if (this.#closed)
753
+ throw new PreferenceStoreError('disabled', 'preference store is closed');
754
+ }
755
+ #truncateWalForPrivacy() {
756
+ try {
757
+ const checkpoint = this.#database.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
758
+ if (checkpoint.busy !== 0) {
759
+ throw new PreferenceStoreError('privacy-purge-pending', 'preference data is logically forgotten but the WAL privacy purge is still busy; retry the exact request');
760
+ }
761
+ }
762
+ catch (error) {
763
+ if (error instanceof PreferenceStoreError)
764
+ throw error;
765
+ throw new PreferenceStoreError('privacy-purge-pending', 'preference data is logically forgotten but its WAL privacy purge could not be verified; retry the exact request');
766
+ }
767
+ }
768
+ }
769
+ //# sourceMappingURL=store.js.map