@dsh-enhanced/preference-learning 0.1.7 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/store.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
+ import { PREFERENCE_MEMORY_PROMOTION_KEY, PREFERENCE_MEMORY_PROMOTION_RENDERER_ID, PREFERENCE_MEMORY_PROMOTION_VALUE, validatePreferenceMemoryPromotionRequest, validatePreferenceMemoryPromotionCancellationReceipt, validatePreferenceMemoryPromotionCancellationRequest, validatePreferenceMemoryPromotionResult, validatePreferenceMemoryPromotionSubmissionReceipt, withPreferenceMemoryPromotionRequestDigest, withPreferenceMemoryPromotionCancellationDigest, } from '@dsh-enhanced/assistant-growth-contract';
3
4
  import { catalogSelection, preferenceCatalog, renderCatalogPreference, } from './catalog.js';
4
- import { openPreferenceDatabase, preferenceSchemaVersion } from './sqlite.js';
5
+ import { openPreferenceDatabase, preferencePromotionCancellationUpgradeBindingDigest, preferenceSchemaVersion, } from './sqlite.js';
5
6
  import { preferenceActorTrustLevels, preferenceInterpretationTrustLevels, preferenceRollbackReasons, preferenceSignalSources, preferenceSignalStances, } from './types.js';
6
7
  export class PreferenceStoreError extends Error {
7
8
  code;
@@ -44,6 +45,36 @@ function safeTimestamp(value, label) {
44
45
  }
45
46
  return value;
46
47
  }
48
+ export function canonicalPreferenceAdmissionCursor(value) {
49
+ if (typeof value !== 'object' || value === null) {
50
+ throw new PreferenceStoreError('invalid-input', 'admission cursor is invalid');
51
+ }
52
+ const epoch = boundedText(value.epoch, 'admissionCursor.epoch', 32);
53
+ if (!/^[0-9a-f]{32}$/u.test(epoch)) {
54
+ throw new PreferenceStoreError('invalid-input', 'admissionCursor.epoch must be a 32-character lowercase hexadecimal identifier');
55
+ }
56
+ if (!Number.isSafeInteger(value.sequence) || value.sequence < 1) {
57
+ throw new PreferenceStoreError('invalid-input', 'admissionCursor.sequence must be a positive safe integer');
58
+ }
59
+ return Object.freeze({ epoch, sequence: value.sequence });
60
+ }
61
+ function preferencePrincipalDigest(value) {
62
+ const principalId = boundedText(value, 'principalId', 500);
63
+ return createHash('sha256')
64
+ .update('preference-scope-principal-v1\0')
65
+ .update(principalId)
66
+ .digest('hex');
67
+ }
68
+ function canonicalPreferencePrincipalLineage(value) {
69
+ if (typeof value !== 'object' || value === null) {
70
+ throw new PreferenceStoreError('invalid-input', 'principal lineage is invalid');
71
+ }
72
+ const principalRecordId = boundedText(value.principalRecordId, 'principalRecordId', 500);
73
+ if (!Number.isSafeInteger(value.principalVersion) || value.principalVersion < 1) {
74
+ throw new PreferenceStoreError('invalid-input', 'principalVersion must be a positive safe integer');
75
+ }
76
+ return Object.freeze({ principalRecordId, principalVersion: value.principalVersion });
77
+ }
47
78
  export function canonicalPreferenceScope(input) {
48
79
  const workspace = boundedText(input.workspace, 'scope.workspace', 4_096);
49
80
  if (!isAbsolute(workspace))
@@ -62,6 +93,34 @@ export function canonicalPreferenceScope(input) {
62
93
  function payloadHash(value) {
63
94
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
64
95
  }
96
+ function exactT1PreferenceKey(value) {
97
+ if (typeof value !== 'string' || !Object.hasOwn(preferenceCatalog, value)) {
98
+ throw new PreferenceStoreError('invalid-input', 'preferenceKey is not an exact Host catalog key');
99
+ }
100
+ const key = value;
101
+ if (preferenceCatalog[key].riskTier !== 'T1') {
102
+ throw new PreferenceStoreError('forbidden-tier', 'owner rollback only accepts Host catalog T1 keys');
103
+ }
104
+ return key;
105
+ }
106
+ function explanationFromRow(row) {
107
+ const key = exactT1PreferenceKey(row.preference_key);
108
+ try {
109
+ catalogSelection(key, row.candidate_value);
110
+ }
111
+ catch {
112
+ throw new PreferenceStoreError('conflict', 'T1 explanation row is outside the Host catalog');
113
+ }
114
+ return Object.freeze({
115
+ key,
116
+ value: row.candidate_value,
117
+ state: row.effect_state,
118
+ version: row.version,
119
+ supportingSignals: row.supporting_signals,
120
+ contradictingSignals: row.contradicting_signals,
121
+ evidenceMass: row.evidence_mass,
122
+ });
123
+ }
65
124
  function signalFromRow(row) {
66
125
  return Object.freeze({
67
126
  id: row.id,
@@ -100,6 +159,89 @@ function hypothesisFromRow(row) {
100
159
  updatedAt: row.updated_at,
101
160
  });
102
161
  }
162
+ function promotionRequestFromRow(row) {
163
+ return validatePreferenceMemoryPromotionRequest({
164
+ contractVersion: 1,
165
+ promotionId: row.promotion_id,
166
+ promotionGeneration: row.promotion_generation,
167
+ requestDigest: row.request_digest,
168
+ idempotencyKey: row.idempotency_key,
169
+ scope: { workspace: row.workspace, preset: row.preset },
170
+ principalId: row.principal_id,
171
+ principalLineage: {
172
+ principalRecordId: row.principal_lineage_id,
173
+ principalVersion: row.principal_lineage_version,
174
+ },
175
+ ownerGeneration: row.owner_generation,
176
+ hypothesis: {
177
+ id: row.hypothesis_id,
178
+ key: row.hypothesis_key,
179
+ value: row.hypothesis_value,
180
+ version: row.hypothesis_version,
181
+ confidenceBps: row.confidence_bps,
182
+ contradictionBps: row.contradiction_bps,
183
+ supportingSignals: row.supporting_signals,
184
+ distinctSignalSources: row.distinct_signal_sources,
185
+ evidenceMass: row.evidence_mass,
186
+ },
187
+ rendererId: row.renderer_id,
188
+ observedAt: row.observed_at,
189
+ deadlineAt: row.deadline_at,
190
+ });
191
+ }
192
+ function promotionFromRow(row) {
193
+ return Object.freeze({
194
+ request: promotionRequestFromRow(row),
195
+ state: row.state,
196
+ ...(row.memory_proposal_id === null ? {} : { memoryProposalId: row.memory_proposal_id }),
197
+ ...(row.submission_receipt_digest === null
198
+ ? {} : { submissionReceiptDigest: row.submission_receipt_digest }),
199
+ ...(row.terminal_result_version === null
200
+ ? {} : { terminalResultVersion: row.terminal_result_version }),
201
+ ...(row.terminal_receipt_digest === null
202
+ ? {} : { terminalReceiptDigest: row.terminal_receipt_digest }),
203
+ createdAt: row.created_at,
204
+ updatedAt: row.updated_at,
205
+ });
206
+ }
207
+ function promotionResultFromRow(row) {
208
+ const common = {
209
+ contractVersion: 1,
210
+ promotionId: row.promotion_id,
211
+ promotionGeneration: row.promotion_generation,
212
+ requestDigest: row.request_digest,
213
+ resultVersion: row.result_version,
214
+ status: row.status,
215
+ occurredAt: row.occurred_at,
216
+ receiptDigest: row.receipt_digest,
217
+ };
218
+ if (row.status === 'confirmed') {
219
+ return validatePreferenceMemoryPromotionResult({
220
+ ...common,
221
+ memoryProposalId: row.memory_proposal_id,
222
+ memoryProposalVersion: row.memory_proposal_version,
223
+ memoryRecordId: row.memory_record_id,
224
+ memoryRecordVersion: row.memory_record_version,
225
+ memoryRecordDigest: row.memory_record_digest,
226
+ });
227
+ }
228
+ if (row.status === 'rejected') {
229
+ return validatePreferenceMemoryPromotionResult({
230
+ ...common,
231
+ rejectionKind: row.rejection_kind,
232
+ memoryProposalId: row.memory_proposal_id,
233
+ memoryProposalVersion: row.memory_proposal_version,
234
+ });
235
+ }
236
+ if (row.status === 'expired' || row.status === 'conflicted') {
237
+ return validatePreferenceMemoryPromotionResult({
238
+ ...common,
239
+ memoryProposalId: row.memory_proposal_id,
240
+ memoryProposalVersion: row.memory_proposal_version,
241
+ });
242
+ }
243
+ return validatePreferenceMemoryPromotionResult(common);
244
+ }
103
245
  function hypothesisId(scopeKey, preferenceKey, candidateValue) {
104
246
  return `pref-hyp-${createHash('sha256')
105
247
  .update(`preference-hypothesis-v1\0${scopeKey}\0${preferenceKey}\0${candidateValue}`)
@@ -118,16 +260,22 @@ function assertSignalProvenance(input) {
118
260
  && !['owner-authenticated', 'delegated-authenticated'].includes(input.actorTrust)) {
119
261
  throw new PreferenceStoreError('invalid-input', 'signed-ui-feedback requires authenticated actor trust');
120
262
  }
263
+ if (input.source === 'delivery-observation' && input.actorTrust === 'owner-authenticated'
264
+ && input.interpretationTrust !== 'behavioral-inference') {
265
+ throw new PreferenceStoreError('invalid-input', 'owner delivery-observation requires behavioral interpretation');
266
+ }
121
267
  }
122
268
  /**
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.
269
+ * Only Delivery-attested owner evidence can unlock an automatic T1 effect.
270
+ * Behavioral evidence is limited to the private completed-turn lane; the
271
+ * public observation surface is always system-attested and remains ineligible.
126
272
  */
127
273
  function isActivationEligibleSignal(signal) {
128
274
  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);
275
+ && ((['explicit-selection', 'typed-feedback'].includes(signal.interpretation_trust)
276
+ && ['direct-owner-feedback', 'signed-ui-feedback'].includes(signal.source))
277
+ || (signal.interpretation_trust === 'behavioral-inference'
278
+ && signal.source === 'delivery-observation'));
131
279
  }
132
280
  export class PreferenceStore {
133
281
  #database;
@@ -141,18 +289,1163 @@ export class PreferenceStore {
141
289
  signalTtlMs: options.signalTtlMs,
142
290
  hypothesisTtlMs: options.hypothesisTtlMs,
143
291
  minSignalsForActivation: options.minSignalsForActivation,
292
+ minBehavioralSignalsForActivation: options.minBehavioralSignalsForActivation ?? 6,
144
293
  minConfidenceBps: options.minConfidenceBps,
145
294
  maxContradictionBps: options.maxContradictionBps,
146
295
  maxActiveOverlays: options.maxActiveOverlays,
147
296
  maxReviewHypotheses: options.maxReviewHypotheses,
148
297
  maxOverlayBytes: options.maxOverlayBytes,
298
+ minSignalsForPromotion: options.minSignalsForPromotion ?? 3,
299
+ minConfidenceBpsForPromotion: options.minConfidenceBpsForPromotion ?? 8_500,
300
+ maxContradictionBpsForPromotion: options.maxContradictionBpsForPromotion ?? 1_500,
301
+ promotionProposalTtlMs: options.promotionProposalTtlMs ?? 604_800_000,
149
302
  });
150
303
  }
151
304
  appendSignal(input) {
152
305
  return this.appendSignals([input])[0];
153
306
  }
307
+ /**
308
+ * Materialize every currently eligible low-sensitivity T2 hypothesis into a
309
+ * durable promotion plus proposal outbox row in the same SQLite transaction.
310
+ * Eligibility requires repeated evidence from at least two independent
311
+ * interpretation/source channels; one signal can never emit a proposal.
312
+ */
313
+ enqueueEligibleMemoryPromotions(scopeInput, principalIdInput, owner) {
314
+ this.#assertOpen();
315
+ const canonical = canonicalPreferenceScope(scopeInput);
316
+ const principalId = boundedText(principalIdInput, 'principalId', 500);
317
+ const principalDigest = preferencePrincipalDigest(principalId);
318
+ if (principalDigest !== owner.principalDigest) {
319
+ throw new PreferenceStoreError('conflict', 'promotion principal does not match owner fence');
320
+ }
321
+ const now = this.#now();
322
+ const created = [];
323
+ this.#database.exec('BEGIN IMMEDIATE');
324
+ try {
325
+ this.#assertScopePrincipalInTransaction(canonical, owner);
326
+ this.#assertScopeLearningActiveInTransaction(canonical);
327
+ this.#refreshScopeInTransaction(canonical.scopeKey, canonical.scope, now);
328
+ const candidates = this.#database.prepare(`
329
+ SELECT hypothesis.*, (
330
+ SELECT COUNT(DISTINCT signal.interpretation_trust || ':' || signal.source)
331
+ FROM preference_signals signal
332
+ WHERE signal.scope_key = hypothesis.scope_key
333
+ AND signal.preference_key = hypothesis.preference_key
334
+ AND signal.candidate_value = hypothesis.candidate_value
335
+ AND signal.stance = 'support'
336
+ AND signal.actor_trust = 'owner-authenticated'
337
+ AND signal.interpretation_trust IN ('explicit-selection', 'typed-feedback')
338
+ AND signal.source IN ('direct-owner-feedback', 'signed-ui-feedback')
339
+ AND signal.occurred_at >= ?
340
+ ) AS distinct_signal_sources
341
+ FROM preference_hypotheses hypothesis
342
+ WHERE hypothesis.scope_key = ?
343
+ AND hypothesis.preference_key = ? AND hypothesis.candidate_value = ?
344
+ AND hypothesis.risk_tier = 'T2' AND hypothesis.claim_state = 'proposed'
345
+ AND hypothesis.effect_state = 'inactive' AND hypothesis.expires_at > ?
346
+ AND hypothesis.supporting_signals >= ?
347
+ AND hypothesis.confidence_bps >= ?
348
+ AND hypothesis.contradiction_bps <= ?
349
+ ORDER BY hypothesis.updated_at, hypothesis.id
350
+ `).all(Math.max(0, now - this.#options.signalTtlMs), canonical.scopeKey, PREFERENCE_MEMORY_PROMOTION_KEY, PREFERENCE_MEMORY_PROMOTION_VALUE, now, this.#options.minSignalsForPromotion, this.#options.minConfidenceBpsForPromotion, this.#options.maxContradictionBpsForPromotion);
351
+ for (const row of candidates) {
352
+ // Multiple copies of the same typed feedback path are correlated. The
353
+ // first release demands at least two separately attested channels.
354
+ if (row.distinct_signal_sources < 2)
355
+ continue;
356
+ const prior = this.#database.prepare(`
357
+ SELECT * FROM preference_memory_promotions
358
+ WHERE scope_key = ? AND hypothesis_id = ?
359
+ ORDER BY promotion_generation DESC LIMIT 1
360
+ `).get(canonical.scopeKey, row.id);
361
+ if (prior !== undefined && prior.hypothesis_version === row.version
362
+ && prior.owner_generation === owner.generation
363
+ && prior.principal_lineage_id === owner.principalLineageId
364
+ && prior.principal_lineage_version === owner.principalLineageVersion
365
+ && prior.state !== 'cancelled')
366
+ continue;
367
+ const promotionId = `pref-promotion-${createHash('sha256')
368
+ .update(`preference-memory-promotion-v1\0${canonical.scopeKey}\0${row.id}`)
369
+ .digest('hex')}`;
370
+ const generationFloor = this.#database.prepare(`
371
+ SELECT MAX(promotion_generation) AS generation FROM (
372
+ SELECT promotion_generation FROM preference_memory_promotions
373
+ WHERE promotion_id = ?
374
+ UNION ALL
375
+ SELECT promotion_generation FROM preference_memory_promotion_cancellations
376
+ WHERE promotion_id = ?
377
+ )
378
+ `).get(promotionId, promotionId);
379
+ const promotionGeneration = (generationFloor.generation ?? 0) + 1;
380
+ const idempotencyKey = `preference-memory-promotion:${promotionId}:g${promotionGeneration}`;
381
+ const deadlineAt = Math.min(row.expires_at, now + this.#options.promotionProposalTtlMs);
382
+ if (deadlineAt <= now)
383
+ continue;
384
+ const request = validatePreferenceMemoryPromotionRequest(withPreferenceMemoryPromotionRequestDigest({
385
+ contractVersion: 1,
386
+ promotionId,
387
+ promotionGeneration,
388
+ idempotencyKey,
389
+ scope: { workspace: row.workspace, preset: row.preset },
390
+ principalId,
391
+ principalLineage: {
392
+ principalRecordId: owner.principalLineageId,
393
+ principalVersion: owner.principalLineageVersion,
394
+ },
395
+ ownerGeneration: owner.generation,
396
+ hypothesis: {
397
+ id: row.id,
398
+ key: PREFERENCE_MEMORY_PROMOTION_KEY,
399
+ value: PREFERENCE_MEMORY_PROMOTION_VALUE,
400
+ version: row.version,
401
+ confidenceBps: row.confidence_bps,
402
+ contradictionBps: row.contradiction_bps,
403
+ supportingSignals: row.supporting_signals,
404
+ distinctSignalSources: row.distinct_signal_sources,
405
+ evidenceMass: row.evidence_mass,
406
+ },
407
+ rendererId: PREFERENCE_MEMORY_PROMOTION_RENDERER_ID,
408
+ observedAt: now,
409
+ deadlineAt,
410
+ }));
411
+ this.#database.prepare(`
412
+ INSERT INTO preference_memory_promotions(
413
+ promotion_id, promotion_generation, scope_key, scope_digest, workspace, preset,
414
+ principal_id, principal_digest, principal_lineage_id, principal_lineage_version,
415
+ owner_generation, hypothesis_id, hypothesis_key, hypothesis_value,
416
+ hypothesis_version, confidence_bps, contradiction_bps, supporting_signals,
417
+ distinct_signal_sources, evidence_mass, renderer_id, observed_at, deadline_at,
418
+ request_digest, idempotency_key, state, created_at, updated_at
419
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
420
+ 'pending', ?, ?)
421
+ `).run(request.promotionId, request.promotionGeneration, canonical.scopeKey, canonical.scopeDigest, request.scope.workspace, request.scope.preset, request.principalId, principalDigest, request.principalLineage.principalRecordId, request.principalLineage.principalVersion, request.ownerGeneration, request.hypothesis.id, request.hypothesis.key, request.hypothesis.value, request.hypothesis.version, request.hypothesis.confidenceBps, request.hypothesis.contradictionBps, request.hypothesis.supportingSignals, request.hypothesis.distinctSignalSources, request.hypothesis.evidenceMass, request.rendererId, request.observedAt, request.deadlineAt, request.requestDigest, request.idempotencyKey, now, now);
422
+ this.#database.prepare(`
423
+ INSERT INTO preference_memory_promotion_outbox(
424
+ promotion_id, promotion_generation, state, attempt_count, next_attempt_at,
425
+ submitted_at, last_error, updated_at
426
+ ) VALUES (?, ?, 'pending', 0, ?, NULL, NULL, ?)
427
+ `).run(request.promotionId, request.promotionGeneration, now, now);
428
+ const inserted = this.#database.prepare(`
429
+ SELECT * FROM preference_memory_promotions
430
+ WHERE promotion_id = ? AND promotion_generation = ?
431
+ `).get(request.promotionId, request.promotionGeneration);
432
+ created.push(promotionFromRow(inserted));
433
+ }
434
+ this.#database.exec('COMMIT');
435
+ return Object.freeze(created);
436
+ }
437
+ catch (error) {
438
+ this.#database.exec('ROLLBACK');
439
+ throw error;
440
+ }
441
+ }
442
+ listPendingMemoryPromotions(limit) {
443
+ this.#assertOpen();
444
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
445
+ throw new PreferenceStoreError('invalid-input', 'promotion outbox limit must be between 1 and 1000');
446
+ }
447
+ const rows = this.#database.prepare(`
448
+ SELECT promotion.*, outbox.state AS outbox_state, outbox.attempt_count,
449
+ outbox.next_attempt_at, outbox.last_error
450
+ FROM preference_memory_promotion_outbox outbox
451
+ JOIN preference_memory_promotions promotion
452
+ ON promotion.promotion_id = outbox.promotion_id
453
+ AND promotion.promotion_generation = outbox.promotion_generation
454
+ WHERE outbox.state IN ('pending', 'retry_wait') AND outbox.next_attempt_at <= ?
455
+ ORDER BY outbox.next_attempt_at, outbox.updated_at, promotion.promotion_id
456
+ LIMIT ?
457
+ `).all(this.#now(), limit);
458
+ return Object.freeze(rows.map((row) => {
459
+ const stored = promotionFromRow(row);
460
+ return Object.freeze({
461
+ request: stored.request,
462
+ state: row.outbox_state === 'retry_wait' ? 'retry_wait' : 'pending',
463
+ ...(stored.memoryProposalId === undefined ? {} : { memoryProposalId: stored.memoryProposalId }),
464
+ ...(stored.submissionReceiptDigest === undefined
465
+ ? {} : { submissionReceiptDigest: stored.submissionReceiptDigest }),
466
+ ...(stored.terminalResultVersion === undefined
467
+ ? {} : { terminalResultVersion: stored.terminalResultVersion }),
468
+ ...(stored.terminalReceiptDigest === undefined
469
+ ? {} : { terminalReceiptDigest: stored.terminalReceiptDigest }),
470
+ createdAt: stored.createdAt,
471
+ updatedAt: stored.updatedAt,
472
+ attemptCount: row.attempt_count,
473
+ nextAttemptAt: row.next_attempt_at,
474
+ ...(row.last_error === null ? {} : { lastError: row.last_error }),
475
+ });
476
+ }));
477
+ }
478
+ settleMemoryPromotionSubmission(input) {
479
+ this.#assertOpen();
480
+ const request = validatePreferenceMemoryPromotionRequest(input.request);
481
+ if ((input.receipt === undefined) === (input.retryError === undefined)) {
482
+ throw new PreferenceStoreError('invalid-input', 'promotion submission needs exactly one outcome');
483
+ }
484
+ const receipt = input.receipt === undefined
485
+ ? undefined
486
+ : validatePreferenceMemoryPromotionSubmissionReceipt(input.receipt, request);
487
+ const retryError = input.retryError === undefined
488
+ ? undefined
489
+ : boundedText(input.retryError, 'promotion retry error', 500);
490
+ const now = this.#now();
491
+ this.#database.exec('BEGIN IMMEDIATE');
492
+ try {
493
+ const current = this.#promotionRow(request.promotionId, request.promotionGeneration);
494
+ if (current.request_digest !== request.requestDigest) {
495
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion request identity changed');
496
+ }
497
+ if (current.state === 'cancelled' || this.#isPromotionTerminal(current.state)) {
498
+ this.#database.exec('COMMIT');
499
+ return promotionFromRow(current);
500
+ }
501
+ const outbox = this.#database.prepare(`
502
+ SELECT state, attempt_count FROM preference_memory_promotion_outbox
503
+ WHERE promotion_id = ? AND promotion_generation = ?
504
+ `).get(request.promotionId, request.promotionGeneration);
505
+ if (outbox === undefined)
506
+ throw new PreferenceStoreError('conflict', 'promotion has no outbox row');
507
+ if (outbox.state === 'submitted') {
508
+ if (receipt === undefined
509
+ || current.memory_proposal_id !== receipt.memoryProposalId
510
+ || current.submission_receipt_digest !== receipt.receiptDigest) {
511
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion submission replay changed');
512
+ }
513
+ }
514
+ else if (receipt !== undefined) {
515
+ this.#database.prepare(`
516
+ UPDATE preference_memory_promotions
517
+ SET state = 'submitted', memory_proposal_id = ?, submission_receipt_digest = ?, updated_at = ?
518
+ WHERE promotion_id = ? AND promotion_generation = ?
519
+ AND request_digest = ? AND state IN ('pending', 'retry_wait')
520
+ `).run(receipt.memoryProposalId, receipt.receiptDigest, now, request.promotionId, request.promotionGeneration, request.requestDigest);
521
+ this.#database.prepare(`
522
+ UPDATE preference_memory_promotion_outbox
523
+ SET state = 'submitted', attempt_count = attempt_count + 1, submitted_at = ?,
524
+ last_error = NULL, updated_at = ?
525
+ WHERE promotion_id = ? AND promotion_generation = ?
526
+ AND state IN ('pending', 'retry_wait')
527
+ `).run(now, now, request.promotionId, request.promotionGeneration);
528
+ }
529
+ else {
530
+ const attempt = outbox.attempt_count + 1;
531
+ const delay = Math.min(60_000, 250 * (2 ** Math.min(attempt - 1, 8)));
532
+ this.#database.prepare(`
533
+ UPDATE preference_memory_promotion_outbox
534
+ SET state = 'retry_wait', attempt_count = ?, next_attempt_at = ?,
535
+ last_error = ?, updated_at = ?
536
+ WHERE promotion_id = ? AND promotion_generation = ?
537
+ AND state IN ('pending', 'retry_wait')
538
+ `).run(attempt, now + delay, retryError, now, request.promotionId, request.promotionGeneration);
539
+ }
540
+ this.#database.exec('COMMIT');
541
+ return promotionFromRow(this.#promotionRow(request.promotionId, request.promotionGeneration));
542
+ }
543
+ catch (error) {
544
+ this.#database.exec('ROLLBACK');
545
+ throw error;
546
+ }
547
+ }
548
+ /**
549
+ * Project one branded Personal Memory terminal result. The projection and
550
+ * immutable result receipt commit together. A committed terminal receipt is
551
+ * authoritative for exact replay/conflict checks; otherwise a cancellation
552
+ * tombstone wins over a delayed result, preventing owner ABA or forget
553
+ * resurrection.
554
+ */
555
+ projectMemoryPromotionResult(input) {
556
+ this.#assertOpen();
557
+ const result = validatePreferenceMemoryPromotionResult(input);
558
+ const now = this.#now();
559
+ if (result.occurredAt > now) {
560
+ throw new PreferenceStoreError('invalid-input', 'promotion result occurredAt is in the future');
561
+ }
562
+ this.#database.exec('BEGIN IMMEDIATE');
563
+ try {
564
+ const cancellation = this.#database.prepare(`
565
+ SELECT request_digest, reason, state FROM preference_memory_promotion_cancellations
566
+ WHERE promotion_id = ? AND promotion_generation = ?
567
+ `).get(result.promotionId, result.promotionGeneration);
568
+ if (cancellation !== undefined && cancellation.reason !== 'superseded') {
569
+ if (cancellation.request_digest !== result.requestDigest) {
570
+ throw new PreferenceStoreError('idempotency-conflict', 'cancelled promotion identity changed');
571
+ }
572
+ if (cancellation.state !== 'cancelled') {
573
+ throw new PreferenceStoreError('conflict', 'promotion compensation is not complete');
574
+ }
575
+ this.#database.exec('COMMIT');
576
+ return Object.freeze({
577
+ contractVersion: 1,
578
+ promotionId: result.promotionId,
579
+ promotionGeneration: result.promotionGeneration,
580
+ resultVersion: result.resultVersion,
581
+ receiptDigest: result.receiptDigest,
582
+ outcome: 'cancelled',
583
+ });
584
+ }
585
+ const existing = this.#database.prepare(`
586
+ SELECT * FROM preference_memory_promotion_results
587
+ WHERE promotion_id = ? AND promotion_generation = ?
588
+ `).get(result.promotionId, result.promotionGeneration);
589
+ if (existing !== undefined) {
590
+ const stored = promotionResultFromRow(existing);
591
+ if (stored.resultVersion !== result.resultVersion
592
+ || stored.requestDigest !== result.requestDigest
593
+ || stored.receiptDigest !== result.receiptDigest) {
594
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion terminal result changed');
595
+ }
596
+ this.#database.exec('COMMIT');
597
+ return Object.freeze({
598
+ contractVersion: 1,
599
+ promotionId: result.promotionId,
600
+ promotionGeneration: result.promotionGeneration,
601
+ resultVersion: result.resultVersion,
602
+ receiptDigest: result.receiptDigest,
603
+ outcome: 'replayed',
604
+ });
605
+ }
606
+ const promotion = this.#database.prepare(`
607
+ SELECT * FROM preference_memory_promotions
608
+ WHERE promotion_id = ? AND promotion_generation = ?
609
+ `).get(result.promotionId, result.promotionGeneration);
610
+ if (promotion === undefined) {
611
+ throw new PreferenceStoreError('not-found', 'promotion result has no request ledger entry');
612
+ }
613
+ validatePreferenceMemoryPromotionResult(result, promotionRequestFromRow(promotion));
614
+ if (promotion.state === 'cancelled') {
615
+ this.#recordPromotionCancellationInTransaction(promotion, 'superseded', now);
616
+ this.#database.exec('COMMIT');
617
+ return Object.freeze({
618
+ contractVersion: 1,
619
+ promotionId: result.promotionId,
620
+ promotionGeneration: result.promotionGeneration,
621
+ resultVersion: result.resultVersion,
622
+ receiptDigest: result.receiptDigest,
623
+ outcome: 'cancelled',
624
+ });
625
+ }
626
+ if (this.#isPromotionTerminal(promotion.state)) {
627
+ throw new PreferenceStoreError('conflict', 'promotion terminal state has no matching receipt');
628
+ }
629
+ if (promotion.state !== 'submitted'
630
+ || promotion.memory_proposal_id === null
631
+ || promotion.submission_receipt_digest === null) {
632
+ throw new PreferenceStoreError('conflict', 'promotion result arrived before request submission');
633
+ }
634
+ if (result.status !== 'stale-owner'
635
+ && result.memoryProposalId !== promotion.memory_proposal_id) {
636
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion result changed Memory proposal identity');
637
+ }
638
+ const currentOwner = this.#database.prepare(`
639
+ SELECT 1 AS present FROM preference_scope_principals
640
+ WHERE scope_key = ? AND principal_digest = ?
641
+ AND principal_lineage_id = ? AND principal_lineage_version = ?
642
+ AND generation = ? AND purge_pending = 0 AND admission_cursor_epoch IS NOT NULL
643
+ `).get(promotion.scope_key, promotion.principal_digest, promotion.principal_lineage_id, promotion.principal_lineage_version, promotion.owner_generation);
644
+ if (currentOwner === undefined) {
645
+ this.#cancelPromotionInTransaction(promotion, 'owner-rotated', now);
646
+ this.#database.exec('COMMIT');
647
+ return Object.freeze({
648
+ contractVersion: 1,
649
+ promotionId: result.promotionId,
650
+ promotionGeneration: result.promotionGeneration,
651
+ resultVersion: result.resultVersion,
652
+ receiptDigest: result.receiptDigest,
653
+ outcome: 'cancelled',
654
+ });
655
+ }
656
+ if (result.status === 'confirmed' || result.status === 'rejected') {
657
+ const hypothesis = this.#database.prepare(`
658
+ SELECT * FROM preference_hypotheses
659
+ WHERE id = ? AND scope_key = ?
660
+ `).get(promotion.hypothesis_id, promotion.scope_key);
661
+ if (hypothesis === undefined || hypothesis.version !== promotion.hypothesis_version
662
+ || hypothesis.preference_key !== promotion.hypothesis_key
663
+ || hypothesis.candidate_value !== promotion.hypothesis_value
664
+ || hypothesis.risk_tier !== 'T2' || hypothesis.effect_state !== 'inactive'
665
+ || hypothesis.claim_state !== 'proposed') {
666
+ throw new PreferenceStoreError('conflict', 'promotion result no longer matches the exact proposed hypothesis');
667
+ }
668
+ const nextVersion = hypothesis.version + 1;
669
+ const claimState = result.status;
670
+ const changedHypothesis = this.#database.prepare(`
671
+ UPDATE preference_hypotheses
672
+ SET claim_state = ?, version = ?, updated_at = ?
673
+ WHERE id = ? AND scope_key = ? AND version = ?
674
+ AND claim_state = 'proposed' AND effect_state = 'inactive'
675
+ `).run(claimState, nextVersion, now, hypothesis.id, hypothesis.scope_key, hypothesis.version);
676
+ if (changedHypothesis.changes !== 1) {
677
+ throw new PreferenceStoreError('conflict', 'promotion hypothesis projection lost its CAS');
678
+ }
679
+ this.#transition(hypothesis, claimState, 'inactive', result.status === 'rejected' ? 'owner-rejected' : 'evidence-updated', nextVersion, now);
680
+ }
681
+ const confirmed = result.status === 'confirmed' ? result : undefined;
682
+ const rejected = result.status === 'rejected' ? result : undefined;
683
+ const failed = result.status === 'expired' || result.status === 'conflicted' ? result : undefined;
684
+ this.#database.prepare(`
685
+ INSERT INTO preference_memory_promotion_results(
686
+ promotion_id, promotion_generation, result_version, request_digest, receipt_digest,
687
+ status, rejection_kind, memory_proposal_id, memory_proposal_version,
688
+ memory_record_id, memory_record_version, memory_record_digest, occurred_at, projected_at
689
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
690
+ `).run(result.promotionId, result.promotionGeneration, result.resultVersion, result.requestDigest, result.receiptDigest, result.status, rejected?.rejectionKind ?? null, confirmed?.memoryProposalId ?? rejected?.memoryProposalId
691
+ ?? failed?.memoryProposalId ?? null, confirmed?.memoryProposalVersion ?? rejected?.memoryProposalVersion
692
+ ?? failed?.memoryProposalVersion ?? null, confirmed?.memoryRecordId ?? null, confirmed?.memoryRecordVersion ?? null, confirmed?.memoryRecordDigest ?? null, result.occurredAt, now);
693
+ const changed = this.#database.prepare(`
694
+ UPDATE preference_memory_promotions
695
+ SET state = ?, terminal_result_version = ?, terminal_receipt_digest = ?, updated_at = ?
696
+ WHERE promotion_id = ? AND promotion_generation = ? AND state = 'submitted'
697
+ AND request_digest = ?
698
+ `).run(result.status, result.resultVersion, result.receiptDigest, now, result.promotionId, result.promotionGeneration, result.requestDigest);
699
+ if (changed.changes !== 1) {
700
+ throw new PreferenceStoreError('conflict', 'promotion state changed during result projection');
701
+ }
702
+ this.#database.exec('COMMIT');
703
+ return Object.freeze({
704
+ contractVersion: 1,
705
+ promotionId: result.promotionId,
706
+ promotionGeneration: result.promotionGeneration,
707
+ resultVersion: result.resultVersion,
708
+ receiptDigest: result.receiptDigest,
709
+ outcome: 'applied',
710
+ });
711
+ }
712
+ catch (error) {
713
+ this.#database.exec('ROLLBACK');
714
+ throw error;
715
+ }
716
+ }
717
+ getMemoryPromotion(promotionIdInput, promotionGeneration) {
718
+ this.#assertOpen();
719
+ const promotionId = boundedText(promotionIdInput, 'promotionId', 500);
720
+ if (!Number.isSafeInteger(promotionGeneration) || promotionGeneration < 1) {
721
+ throw new PreferenceStoreError('invalid-input', 'promotionGeneration is invalid');
722
+ }
723
+ const row = this.#database.prepare(`
724
+ SELECT * FROM preference_memory_promotions
725
+ WHERE promotion_id = ? AND promotion_generation = ?
726
+ `).get(promotionId, promotionGeneration);
727
+ return row === undefined ? undefined : promotionFromRow(row);
728
+ }
729
+ listPendingMemoryPromotionCancellations(limit) {
730
+ this.#assertOpen();
731
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
732
+ throw new PreferenceStoreError('invalid-input', 'promotion cancellation limit must be between 1 and 1000');
733
+ }
734
+ const rows = this.#database.prepare(`
735
+ SELECT * FROM preference_memory_promotion_cancellations
736
+ WHERE state IN ('pending', 'retry_wait') AND next_attempt_at <= ?
737
+ ORDER BY next_attempt_at, updated_at, promotion_id LIMIT ?
738
+ `).all(this.#now(), limit);
739
+ return Object.freeze(rows.map(row => {
740
+ const request = validatePreferenceMemoryPromotionCancellationRequest({
741
+ contractVersion: 1,
742
+ promotionId: row.promotion_id,
743
+ promotionGeneration: row.promotion_generation,
744
+ requestDigest: row.request_digest,
745
+ principalLineage: {
746
+ principalRecordId: row.principal_lineage_id,
747
+ principalVersion: row.principal_lineage_version,
748
+ },
749
+ ownerGeneration: row.owner_generation,
750
+ reason: row.reason,
751
+ occurredAt: row.cancelled_at,
752
+ cancellationDigest: row.cancellation_digest,
753
+ });
754
+ if (row.upgrade_binding_digest !== this.#cancellationUpgradeBindingDigest(request)) {
755
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion cancellation binding changed');
756
+ }
757
+ return request;
758
+ }));
759
+ }
760
+ settleMemoryPromotionCancellation(input) {
761
+ this.#assertOpen();
762
+ const request = validatePreferenceMemoryPromotionCancellationRequest(input.request);
763
+ if ((input.receipt === undefined) === (input.retryError === undefined)) {
764
+ throw new PreferenceStoreError('invalid-input', 'promotion cancellation needs exactly one outcome');
765
+ }
766
+ const receipt = input.receipt === undefined
767
+ ? undefined
768
+ : validatePreferenceMemoryPromotionCancellationReceipt(input.receipt, request);
769
+ const retryError = input.retryError === undefined
770
+ ? undefined
771
+ : boundedText(input.retryError, 'promotion cancellation retry error', 500);
772
+ if (receipt?.outcome === 'already-confirmed' && request.reason !== 'superseded') {
773
+ throw new PreferenceStoreError('conflict', 'privacy cancellation requires Memory compensation');
774
+ }
775
+ const now = this.#now();
776
+ const current = this.#database.prepare(`
777
+ SELECT * FROM preference_memory_promotion_cancellations
778
+ WHERE promotion_id = ? AND promotion_generation = ?
779
+ `).get(request.promotionId, request.promotionGeneration);
780
+ if (current === undefined || current.request_digest !== request.requestDigest
781
+ || current.cancellation_digest !== request.cancellationDigest) {
782
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion cancellation identity changed');
783
+ }
784
+ const upgradeBindingDigest = this.#cancellationUpgradeBindingDigest(request);
785
+ if (current.upgrade_binding_digest !== upgradeBindingDigest) {
786
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion cancellation binding changed');
787
+ }
788
+ if (current.state === 'cancelled' || current.state === 'already-confirmed') {
789
+ if (receipt !== undefined && current.receipt_digest !== receipt.receiptDigest) {
790
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion cancellation receipt changed');
791
+ }
792
+ return;
793
+ }
794
+ if (receipt !== undefined) {
795
+ const changed = this.#database.prepare(`
796
+ UPDATE preference_memory_promotion_cancellations
797
+ SET state = ?, attempt_count = attempt_count + 1, receipt_digest = ?,
798
+ principal_lineage_id = ?, principal_lineage_version = 1, owner_generation = 1,
799
+ last_error = NULL, updated_at = ?
800
+ WHERE promotion_id = ? AND promotion_generation = ?
801
+ AND state IN ('pending', 'retry_wait') AND upgrade_binding_digest = ?
802
+ `).run(receipt.outcome === 'already-confirmed' ? 'already-confirmed' : 'cancelled', receipt.receiptDigest, `redacted-${createHash('sha256').update(request.principalLineage.principalRecordId).digest('hex')}`, now, request.promotionId, request.promotionGeneration, upgradeBindingDigest);
803
+ if (changed.changes !== 1) {
804
+ throw new PreferenceStoreError('conflict', 'promotion cancellation changed during settlement');
805
+ }
806
+ return;
807
+ }
808
+ const attempt = current.attempt_count + 1;
809
+ const delay = Math.min(60_000, 250 * (2 ** Math.min(attempt - 1, 8)));
810
+ this.#database.prepare(`
811
+ UPDATE preference_memory_promotion_cancellations
812
+ SET state = 'retry_wait', attempt_count = ?, next_attempt_at = ?,
813
+ last_error = ?, updated_at = ?
814
+ WHERE promotion_id = ? AND promotion_generation = ?
815
+ AND state IN ('pending', 'retry_wait') AND upgrade_binding_digest = ?
816
+ `).run(attempt, now + delay, retryError, now, request.promotionId, request.promotionGeneration, upgradeBindingDigest);
817
+ }
818
+ /**
819
+ * Monotonic content-free owner fence for a Delivery scope. A delayed event
820
+ * from an older principal is acknowledged as ignored instead of reclaiming
821
+ * the scope after a newer owner has started using it.
822
+ */
823
+ ensureScopePrincipal(scopeInput, principalIdInput, occurredAtInput, lineageInput, admissionCursorInput) {
824
+ this.#assertOpen();
825
+ const canonical = canonicalPreferenceScope(scopeInput);
826
+ const principalDigest = preferencePrincipalDigest(principalIdInput);
827
+ const occurredAt = safeTimestamp(occurredAtInput, 'occurredAt');
828
+ const lineage = canonicalPreferencePrincipalLineage(lineageInput);
829
+ const admissionCursor = canonicalPreferenceAdmissionCursor(admissionCursorInput);
830
+ const now = this.#now();
831
+ let result;
832
+ let purgeGeneration;
833
+ this.#database.exec('BEGIN IMMEDIATE');
834
+ try {
835
+ const current = this.#database.prepare(`
836
+ SELECT principal_digest, principal_lineage_id, principal_lineage_version,
837
+ generation, claimed_at, purge_pending, admission_cursor_epoch,
838
+ lineage_claim_sequence, admission_high_water, learning_paused, paused_at
839
+ FROM preference_scope_principals WHERE scope_key = ?
840
+ `).get(canonical.scopeKey);
841
+ if (current !== undefined && current.principal_digest === principalDigest
842
+ && current.principal_lineage_id === lineage.principalRecordId
843
+ && current.principal_lineage_version === lineage.principalVersion
844
+ && current.admission_cursor_epoch === admissionCursor.epoch) {
845
+ purgeGeneration = current.purge_pending === 1 ? current.generation : undefined;
846
+ result = Object.freeze({
847
+ accepted: true,
848
+ scopeKey: canonical.scopeKey,
849
+ principalDigest,
850
+ principalLineageId: lineage.principalRecordId,
851
+ principalLineageVersion: lineage.principalVersion,
852
+ generation: current.generation,
853
+ reset: false,
854
+ });
855
+ }
856
+ else if (current !== undefined && current.admission_cursor_epoch === null) {
857
+ // Schema v5 had no owner-row lineage and schema v6 had no durable Inbox
858
+ // order. Neither can prove that its evidence predates a pause/forget or
859
+ // belongs to this exact Delivery instance. The first v7 claim is always
860
+ // a privacy reset, even when the external principal and lineage happen
861
+ // to match.
862
+ const preservePaused = current.principal_digest === principalDigest
863
+ && current.principal_lineage_id === lineage.principalRecordId
864
+ && current.principal_lineage_version === lineage.principalVersion
865
+ && current.learning_paused === 1;
866
+ this.#deleteScopeDataInTransaction(canonical.scopeKey, canonical.scopeDigest);
867
+ const generation = current.generation + 1;
868
+ const changed = this.#database.prepare(`
869
+ UPDATE preference_scope_principals
870
+ SET scope_digest = ?, principal_digest = ?, generation = ?, claimed_at = ?, updated_at = ?,
871
+ principal_lineage_id = ?, principal_lineage_version = ?,
872
+ purge_pending = 1,
873
+ learning_paused = ?, paused_at = ?,
874
+ control_version = control_version + 1, ignore_events_through = -1,
875
+ admission_cursor_epoch = ?, lineage_claim_sequence = ?,
876
+ admission_high_water = NULL, admission_high_water_kind = NULL,
877
+ ignore_events_through_sequence = NULL
878
+ WHERE scope_key = ? AND generation = ? AND admission_cursor_epoch IS NULL
879
+ `).run(canonical.scopeDigest, principalDigest, generation, occurredAt, now, lineage.principalRecordId, lineage.principalVersion, preservePaused ? 1 : 0, preservePaused ? current.paused_at : null, admissionCursor.epoch, admissionCursor.sequence, canonical.scopeKey, current.generation);
880
+ if (changed.changes !== 1) {
881
+ throw new PreferenceStoreError('conflict', 'legacy scope principal adoption lost its generation');
882
+ }
883
+ purgeGeneration = generation;
884
+ result = Object.freeze({
885
+ accepted: true,
886
+ scopeKey: canonical.scopeKey,
887
+ principalDigest,
888
+ principalLineageId: lineage.principalRecordId,
889
+ principalLineageVersion: lineage.principalVersion,
890
+ generation,
891
+ reset: true,
892
+ });
893
+ }
894
+ else if (current !== undefined
895
+ && current.principal_digest === principalDigest
896
+ && current.principal_lineage_id === lineage.principalRecordId
897
+ && current.principal_lineage_version === lineage.principalVersion) {
898
+ // An epoch change for one exact lineage is impossible in the Delivery
899
+ // contract. Never sort opaque epochs or silently rebind it.
900
+ result = Object.freeze({
901
+ accepted: false,
902
+ scopeKey: canonical.scopeKey,
903
+ principalDigest: current.principal_digest,
904
+ principalLineageId: current.principal_lineage_id ?? 'legacy-owner-lineage',
905
+ principalLineageVersion: current.principal_lineage_version ?? 1,
906
+ generation: current.generation,
907
+ reset: false,
908
+ });
909
+ }
910
+ else if (current !== undefined
911
+ && current.admission_cursor_epoch === admissionCursor.epoch
912
+ && admissionCursor.sequence <= Math.max(current.lineage_claim_sequence ?? 0, current.admission_high_water ?? 0)) {
913
+ result = Object.freeze({
914
+ accepted: false,
915
+ scopeKey: canonical.scopeKey,
916
+ principalDigest: current.principal_digest,
917
+ principalLineageId: current.principal_lineage_id ?? 'legacy-owner-lineage',
918
+ principalLineageVersion: current.principal_lineage_version ?? 1,
919
+ generation: current.generation,
920
+ reset: false,
921
+ });
922
+ }
923
+ else {
924
+ this.#deleteScopeDataInTransaction(canonical.scopeKey, canonical.scopeDigest);
925
+ const generation = (current?.generation ?? 0) + 1;
926
+ if (current === undefined) {
927
+ this.#database.prepare(`
928
+ INSERT INTO preference_scope_principals(
929
+ scope_key, scope_digest, principal_digest, generation,
930
+ claimed_at, purge_pending, updated_at,
931
+ principal_lineage_id, principal_lineage_version,
932
+ learning_paused, paused_at, control_version, ignore_events_through,
933
+ admission_cursor_epoch, lineage_claim_sequence,
934
+ admission_high_water, admission_high_water_kind,
935
+ ignore_events_through_sequence
936
+ ) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, 0, NULL, 1, -1, ?, ?, NULL, NULL, NULL)
937
+ `).run(canonical.scopeKey, canonical.scopeDigest, principalDigest, generation, occurredAt, now, lineage.principalRecordId, lineage.principalVersion, admissionCursor.epoch, admissionCursor.sequence);
938
+ }
939
+ else {
940
+ this.#database.prepare(`
941
+ UPDATE preference_scope_principals
942
+ SET scope_digest = ?, principal_digest = ?, generation = ?,
943
+ claimed_at = ?, purge_pending = 1, updated_at = ?,
944
+ principal_lineage_id = ?, principal_lineage_version = ?,
945
+ learning_paused = 0, paused_at = NULL,
946
+ control_version = control_version + 1, ignore_events_through = -1,
947
+ admission_cursor_epoch = ?, lineage_claim_sequence = ?,
948
+ admission_high_water = NULL, admission_high_water_kind = NULL,
949
+ ignore_events_through_sequence = NULL
950
+ WHERE scope_key = ? AND generation = ? AND principal_digest = ?
951
+ `).run(canonical.scopeDigest, principalDigest, generation, occurredAt, now, lineage.principalRecordId, lineage.principalVersion, admissionCursor.epoch, admissionCursor.sequence, canonical.scopeKey, current.generation, current.principal_digest);
952
+ }
953
+ purgeGeneration = generation;
954
+ result = Object.freeze({
955
+ accepted: true,
956
+ scopeKey: canonical.scopeKey,
957
+ principalDigest,
958
+ principalLineageId: lineage.principalRecordId,
959
+ principalLineageVersion: lineage.principalVersion,
960
+ generation,
961
+ reset: current !== undefined,
962
+ });
963
+ }
964
+ this.#database.exec('COMMIT');
965
+ }
966
+ catch (error) {
967
+ this.#database.exec('ROLLBACK');
968
+ throw error;
969
+ }
970
+ if (purgeGeneration !== undefined) {
971
+ this.#truncateWalForPrivacy();
972
+ this.#database.exec('BEGIN IMMEDIATE');
973
+ try {
974
+ const cleared = this.#database.prepare(`
975
+ UPDATE preference_scope_principals
976
+ SET purge_pending = 0, updated_at = ?
977
+ WHERE scope_key = ? AND principal_digest = ? AND generation = ? AND purge_pending = 1
978
+ `).run(now, canonical.scopeKey, principalDigest, purgeGeneration);
979
+ if (cleared.changes !== 1) {
980
+ throw new PreferenceStoreError('conflict', 'scope principal purge lost its exact generation');
981
+ }
982
+ this.#database.exec('COMMIT');
983
+ }
984
+ catch (error) {
985
+ this.#database.exec('ROLLBACK');
986
+ throw error;
987
+ }
988
+ }
989
+ return result;
990
+ }
991
+ scopePrincipalMatches(scopeInput, principalIdInput) {
992
+ this.#assertOpen();
993
+ const canonical = canonicalPreferenceScope(scopeInput);
994
+ const principalDigest = preferencePrincipalDigest(principalIdInput);
995
+ return this.#database.prepare(`
996
+ SELECT 1 AS present FROM preference_scope_principals
997
+ WHERE scope_key = ? AND principal_digest = ? AND purge_pending = 0
998
+ AND admission_cursor_epoch IS NOT NULL
999
+ `).get(canonical.scopeKey, principalDigest) !== undefined;
1000
+ }
1001
+ scopePrincipalFence(scopeInput, principalIdInput, lineageInput, expectedGeneration) {
1002
+ this.#assertOpen();
1003
+ const canonical = canonicalPreferenceScope(scopeInput);
1004
+ const principalDigest = preferencePrincipalDigest(principalIdInput);
1005
+ const lineage = canonicalPreferencePrincipalLineage(lineageInput);
1006
+ if (expectedGeneration !== undefined
1007
+ && (!Number.isSafeInteger(expectedGeneration) || expectedGeneration < 1)) {
1008
+ throw new PreferenceStoreError('invalid-input', 'expected owner generation is invalid');
1009
+ }
1010
+ const row = this.#database.prepare(`
1011
+ SELECT generation FROM preference_scope_principals
1012
+ WHERE scope_key = ? AND principal_digest = ?
1013
+ AND principal_lineage_id = ? AND principal_lineage_version = ?
1014
+ AND purge_pending = 0 AND admission_cursor_epoch IS NOT NULL
1015
+ `).get(canonical.scopeKey, principalDigest, lineage.principalRecordId, lineage.principalVersion);
1016
+ if (row === undefined || (expectedGeneration !== undefined && row.generation !== expectedGeneration)) {
1017
+ return undefined;
1018
+ }
1019
+ return Object.freeze({
1020
+ scopeKey: canonical.scopeKey,
1021
+ principalDigest,
1022
+ principalLineageId: lineage.principalRecordId,
1023
+ principalLineageVersion: lineage.principalVersion,
1024
+ generation: row.generation,
1025
+ });
1026
+ }
1027
+ scopeLearningStatus(scopeInput, owner) {
1028
+ this.#assertOpen();
1029
+ const canonical = canonicalPreferenceScope(scopeInput);
1030
+ this.#database.exec('BEGIN IMMEDIATE');
1031
+ try {
1032
+ const status = this.#scopeLearningStatusInTransaction(canonical, owner);
1033
+ this.#database.exec('COMMIT');
1034
+ return status;
1035
+ }
1036
+ catch (error) {
1037
+ this.#database.exec('ROLLBACK');
1038
+ throw error;
1039
+ }
1040
+ }
1041
+ scopeAcceptsEvent(scopeInput, owner, admissionCursorInput) {
1042
+ this.#assertOpen();
1043
+ const canonical = canonicalPreferenceScope(scopeInput);
1044
+ const admissionCursor = canonicalPreferenceAdmissionCursor(admissionCursorInput);
1045
+ this.#database.exec('BEGIN IMMEDIATE');
1046
+ try {
1047
+ const accepted = this.#admitEventCursorInTransaction(canonical, owner, admissionCursor);
1048
+ this.#database.exec('COMMIT');
1049
+ return accepted;
1050
+ }
1051
+ catch (error) {
1052
+ this.#database.exec('ROLLBACK');
1053
+ throw error;
1054
+ }
1055
+ }
1056
+ setScopeLearningPaused(scopeInput, owner, paused, admissionCursorInput, occurredAtInput, idempotencyKeyInput) {
1057
+ this.#assertOpen();
1058
+ const canonical = canonicalPreferenceScope(scopeInput);
1059
+ const occurredAt = safeTimestamp(occurredAtInput, 'occurredAt');
1060
+ const admissionCursor = canonicalPreferenceAdmissionCursor(admissionCursorInput);
1061
+ const rawIdempotencyKey = boundedText(idempotencyKeyInput, 'idempotencyKey', 500);
1062
+ const idempotencyKey = `pref-owner-control-${createHash('sha256')
1063
+ .update(`preference-owner-control-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
1064
+ const action = paused ? 'pause' : 'resume';
1065
+ const hash = payloadHash({
1066
+ scopeDigest: canonical.scopeDigest,
1067
+ principalDigest: owner.principalDigest,
1068
+ principalLineageId: owner.principalLineageId,
1069
+ principalLineageVersion: owner.principalLineageVersion,
1070
+ generation: owner.generation,
1071
+ action,
1072
+ admissionCursor,
1073
+ });
1074
+ this.#database.exec('BEGIN IMMEDIATE');
1075
+ try {
1076
+ this.#assertScopePrincipalInTransaction(canonical, owner);
1077
+ const replay = this.#database.prepare(`
1078
+ SELECT * FROM preference_owner_control_receipts
1079
+ WHERE idempotency_key = ?
1080
+ `).get(idempotencyKey);
1081
+ if (replay !== undefined) {
1082
+ if (replay.payload_hash !== hash) {
1083
+ throw new PreferenceStoreError('idempotency-conflict', 'learning control idempotency key was reused');
1084
+ }
1085
+ const state = this.#ownerControlStateFromReceipt(replay);
1086
+ this.#database.exec('COMMIT');
1087
+ return Object.freeze({ applied: replay.result_applied === 1, replayed: true, state });
1088
+ }
1089
+ const current = this.#database.prepare(`
1090
+ SELECT admission_cursor_epoch, lineage_claim_sequence,
1091
+ admission_high_water, admission_high_water_kind,
1092
+ ignore_events_through_sequence, learning_paused, control_version
1093
+ FROM preference_scope_principals WHERE scope_key = ?
1094
+ `).get(canonical.scopeKey);
1095
+ const fresh = this.#controlCursorIsFresh(current, admissionCursor);
1096
+ const controlVersion = fresh ? current.control_version + 1 : current.control_version;
1097
+ const ignoreEventsThroughSequence = fresh
1098
+ ? Math.max(current.ignore_events_through_sequence ?? 0, admissionCursor.sequence)
1099
+ : current.ignore_events_through_sequence;
1100
+ if (fresh) {
1101
+ const changed = this.#database.prepare(`
1102
+ UPDATE preference_scope_principals
1103
+ SET learning_paused = ?, paused_at = ?, control_version = ?,
1104
+ ignore_events_through = MAX(ignore_events_through, ?),
1105
+ ignore_events_through_sequence = ?, admission_high_water = ?,
1106
+ admission_high_water_kind = 'control', updated_at = ?
1107
+ WHERE scope_key = ? AND principal_digest = ?
1108
+ AND principal_lineage_id = ? AND principal_lineage_version = ?
1109
+ AND generation = ? AND purge_pending = 0
1110
+ AND admission_cursor_epoch = ? AND admission_high_water IS ?
1111
+ `).run(paused ? 1 : 0, paused ? occurredAt : null, controlVersion, occurredAt, ignoreEventsThroughSequence, admissionCursor.sequence, this.#now(), canonical.scopeKey, owner.principalDigest, owner.principalLineageId, owner.principalLineageVersion, owner.generation, admissionCursor.epoch, current.admission_high_water);
1112
+ if (changed.changes !== 1) {
1113
+ throw new PreferenceStoreError('conflict', 'learning control owner cursor changed');
1114
+ }
1115
+ }
1116
+ const state = this.#scopeLearningStatusInTransaction(canonical, owner, false);
1117
+ this.#insertOwnerControlReceipt({
1118
+ idempotencyKey,
1119
+ hash,
1120
+ canonical,
1121
+ owner,
1122
+ action,
1123
+ admissionCursor,
1124
+ applied: fresh,
1125
+ state,
1126
+ occurredAt,
1127
+ });
1128
+ this.#database.exec('COMMIT');
1129
+ return Object.freeze({ applied: fresh, replayed: false, state });
1130
+ }
1131
+ catch (error) {
1132
+ this.#database.exec('ROLLBACK');
1133
+ throw error;
1134
+ }
1135
+ }
1136
+ recordScopeLearningStatus(scopeInput, owner, admissionCursorInput, occurredAtInput, idempotencyKeyInput) {
1137
+ this.#assertOpen();
1138
+ const canonical = canonicalPreferenceScope(scopeInput);
1139
+ const admissionCursor = canonicalPreferenceAdmissionCursor(admissionCursorInput);
1140
+ const occurredAt = safeTimestamp(occurredAtInput, 'occurredAt');
1141
+ const rawIdempotencyKey = boundedText(idempotencyKeyInput, 'idempotencyKey', 500);
1142
+ const idempotencyKey = `pref-owner-control-${createHash('sha256')
1143
+ .update(`preference-owner-control-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
1144
+ const hash = payloadHash({
1145
+ scopeDigest: canonical.scopeDigest,
1146
+ principalDigest: owner.principalDigest,
1147
+ principalLineageId: owner.principalLineageId,
1148
+ principalLineageVersion: owner.principalLineageVersion,
1149
+ generation: owner.generation,
1150
+ action: 'status',
1151
+ admissionCursor,
1152
+ });
1153
+ this.#database.exec('BEGIN IMMEDIATE');
1154
+ try {
1155
+ this.#assertScopePrincipalInTransaction(canonical, owner);
1156
+ const replay = this.#database.prepare(`
1157
+ SELECT * FROM preference_owner_control_receipts WHERE idempotency_key = ?
1158
+ `).get(idempotencyKey);
1159
+ if (replay !== undefined) {
1160
+ if (replay.payload_hash !== hash) {
1161
+ throw new PreferenceStoreError('idempotency-conflict', 'learning control idempotency key was reused');
1162
+ }
1163
+ const state = this.#ownerControlStateFromReceipt(replay);
1164
+ this.#database.exec('COMMIT');
1165
+ return Object.freeze({ applied: replay.result_applied === 1, replayed: true, state });
1166
+ }
1167
+ const current = this.#scopeAdmissionRowInTransaction(canonical.scopeKey);
1168
+ if (!this.#readCursorCanObserveCurrent(current, admissionCursor)) {
1169
+ const state = this.#scopeLearningStatusInTransaction(canonical, owner, false);
1170
+ this.#database.exec('COMMIT');
1171
+ return Object.freeze({ applied: false, replayed: false, state });
1172
+ }
1173
+ // Status is an authenticated read, not an ordering barrier. Advancing
1174
+ // the admission high-water here could discard an older Agent turn that
1175
+ // was already running but had not yet committed its projection.
1176
+ const state = this.#scopeLearningStatusInTransaction(canonical, owner, false);
1177
+ this.#insertOwnerControlReceipt({
1178
+ idempotencyKey,
1179
+ hash,
1180
+ canonical,
1181
+ owner,
1182
+ action: 'status',
1183
+ admissionCursor,
1184
+ applied: true,
1185
+ state,
1186
+ occurredAt,
1187
+ });
1188
+ this.#database.exec('COMMIT');
1189
+ return Object.freeze({ applied: true, replayed: false, state });
1190
+ }
1191
+ catch (error) {
1192
+ this.#database.exec('ROLLBACK');
1193
+ throw error;
1194
+ }
1195
+ }
1196
+ /** Receipt-backed, content-free T1 ledger view for one exact owner lineage. */
1197
+ explainScopeLearning(scopeInput, owner, admissionCursorInput, occurredAtInput, idempotencyKeyInput) {
1198
+ this.#assertOpen();
1199
+ const canonical = canonicalPreferenceScope(scopeInput);
1200
+ const admissionCursor = canonicalPreferenceAdmissionCursor(admissionCursorInput);
1201
+ const occurredAt = safeTimestamp(occurredAtInput, 'occurredAt');
1202
+ const rawIdempotencyKey = boundedText(idempotencyKeyInput, 'idempotencyKey', 500);
1203
+ const idempotencyKey = `pref-owner-control-${createHash('sha256')
1204
+ .update(`preference-owner-control-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
1205
+ const hash = payloadHash({
1206
+ scopeDigest: canonical.scopeDigest,
1207
+ principalDigest: owner.principalDigest,
1208
+ principalLineageId: owner.principalLineageId,
1209
+ principalLineageVersion: owner.principalLineageVersion,
1210
+ generation: owner.generation,
1211
+ action: 'explain',
1212
+ admissionCursor,
1213
+ });
1214
+ this.#database.exec('BEGIN IMMEDIATE');
1215
+ try {
1216
+ this.#assertScopePrincipalInTransaction(canonical, owner);
1217
+ const replay = this.#database.prepare(`
1218
+ SELECT * FROM preference_owner_control_receipts WHERE idempotency_key = ?
1219
+ `).get(idempotencyKey);
1220
+ if (replay !== undefined) {
1221
+ if (replay.payload_hash !== hash || replay.action !== 'explain') {
1222
+ throw new PreferenceStoreError('idempotency-conflict', 'learning explain idempotency key was reused');
1223
+ }
1224
+ const explanation = this.#ownerControlExplanationFromReceipt(replay);
1225
+ const state = this.#ownerControlStateFromReceipt(replay);
1226
+ this.#database.exec('COMMIT');
1227
+ return Object.freeze({ applied: replay.result_applied === 1, replayed: true, state, explanation });
1228
+ }
1229
+ const current = this.#scopeAdmissionRowInTransaction(canonical.scopeKey);
1230
+ if (!this.#readCursorCanObserveCurrent(current, admissionCursor)) {
1231
+ const state = this.#scopeLearningStatusInTransaction(canonical, owner, false);
1232
+ this.#database.exec('COMMIT');
1233
+ return Object.freeze({ applied: false, replayed: false, state, explanation: Object.freeze([]) });
1234
+ }
1235
+ // Deliberately do not refresh/reconcile hypotheses here. Explain is a
1236
+ // ledger read and must not advance cursors or mutate preference state.
1237
+ const rows = this.#database.prepare(`
1238
+ SELECT * FROM preference_hypotheses
1239
+ WHERE scope_key = ? AND risk_tier = 'T1'
1240
+ ORDER BY preference_key ASC, candidate_value ASC, id ASC
1241
+ `).all(canonical.scopeKey);
1242
+ const explanation = Object.freeze(rows.map(explanationFromRow));
1243
+ const state = this.#scopeLearningStatusInTransaction(canonical, owner, false);
1244
+ this.#insertOwnerControlReceipt({
1245
+ idempotencyKey,
1246
+ hash,
1247
+ canonical,
1248
+ owner,
1249
+ action: 'explain',
1250
+ admissionCursor,
1251
+ applied: true,
1252
+ state,
1253
+ explanation,
1254
+ occurredAt,
1255
+ });
1256
+ this.#database.exec('COMMIT');
1257
+ return Object.freeze({ applied: true, replayed: false, state, explanation });
1258
+ }
1259
+ catch (error) {
1260
+ this.#database.exec('ROLLBACK');
1261
+ throw error;
1262
+ }
1263
+ }
1264
+ /** Receipt-backed, stable export snapshot for the exact current owner scope. */
1265
+ exportScopeLearning(scopeInput, owner, admissionCursorInput, occurredAtInput, idempotencyKeyInput) {
1266
+ this.#assertOpen();
1267
+ const canonical = canonicalPreferenceScope(scopeInput);
1268
+ const admissionCursor = canonicalPreferenceAdmissionCursor(admissionCursorInput);
1269
+ const occurredAt = safeTimestamp(occurredAtInput, 'occurredAt');
1270
+ const rawIdempotencyKey = boundedText(idempotencyKeyInput, 'idempotencyKey', 500);
1271
+ const idempotencyKey = `pref-owner-control-${createHash('sha256')
1272
+ .update(`preference-owner-control-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
1273
+ const hash = payloadHash({
1274
+ scopeDigest: canonical.scopeDigest,
1275
+ principalDigest: owner.principalDigest,
1276
+ principalLineageId: owner.principalLineageId,
1277
+ principalLineageVersion: owner.principalLineageVersion,
1278
+ generation: owner.generation,
1279
+ action: 'export',
1280
+ format: 'dsh-preference-learning',
1281
+ version: 1,
1282
+ admissionCursor,
1283
+ });
1284
+ this.#database.exec('BEGIN IMMEDIATE');
1285
+ try {
1286
+ this.#assertScopePrincipalInTransaction(canonical, owner);
1287
+ const replay = this.#database.prepare(`
1288
+ SELECT * FROM preference_owner_control_receipts WHERE idempotency_key = ?
1289
+ `).get(idempotencyKey);
1290
+ if (replay !== undefined) {
1291
+ if (replay.payload_hash !== hash || replay.action !== 'export') {
1292
+ throw new PreferenceStoreError('idempotency-conflict', 'learning export idempotency key was reused');
1293
+ }
1294
+ const records = this.#ownerControlExplanationFromReceipt(replay);
1295
+ const state = this.#ownerControlStateFromReceipt(replay);
1296
+ this.#database.exec('COMMIT');
1297
+ return Object.freeze({ applied: replay.result_applied === 1, replayed: true, state, records });
1298
+ }
1299
+ const current = this.#scopeAdmissionRowInTransaction(canonical.scopeKey);
1300
+ if (!this.#readCursorCanObserveCurrent(current, admissionCursor)) {
1301
+ const state = this.#scopeLearningStatusInTransaction(canonical, owner, false);
1302
+ this.#database.exec('COMMIT');
1303
+ return Object.freeze({ applied: false, replayed: false, state, records: Object.freeze([]) });
1304
+ }
1305
+ // Like explain, export is a ledger read. It neither reconciles state nor
1306
+ // advances the admission high-water for already-running earlier turns.
1307
+ const rows = this.#database.prepare(`
1308
+ SELECT * FROM preference_hypotheses
1309
+ WHERE scope_key = ? AND risk_tier = 'T1'
1310
+ ORDER BY preference_key ASC, candidate_value ASC, id ASC
1311
+ `).all(canonical.scopeKey);
1312
+ const records = Object.freeze(rows.map(explanationFromRow));
1313
+ const state = this.#scopeLearningStatusInTransaction(canonical, owner, false);
1314
+ this.#insertOwnerControlReceipt({
1315
+ idempotencyKey,
1316
+ hash,
1317
+ canonical,
1318
+ owner,
1319
+ action: 'export',
1320
+ admissionCursor,
1321
+ applied: true,
1322
+ state,
1323
+ explanation: records,
1324
+ occurredAt,
1325
+ });
1326
+ this.#database.exec('COMMIT');
1327
+ return Object.freeze({ applied: true, replayed: false, state, records });
1328
+ }
1329
+ catch (error) {
1330
+ this.#database.exec('ROLLBACK');
1331
+ throw error;
1332
+ }
1333
+ }
1334
+ /** Atomically fence older projections and CAS-roll back the active value for one T1 key. */
1335
+ rollbackScopeLearningKey(scopeInput, owner, preferenceKeyInput, admissionCursorInput, occurredAtInput, idempotencyKeyInput) {
1336
+ this.#assertOpen();
1337
+ const canonical = canonicalPreferenceScope(scopeInput);
1338
+ const preferenceKey = exactT1PreferenceKey(preferenceKeyInput);
1339
+ const admissionCursor = canonicalPreferenceAdmissionCursor(admissionCursorInput);
1340
+ const occurredAt = safeTimestamp(occurredAtInput, 'occurredAt');
1341
+ const rawIdempotencyKey = boundedText(idempotencyKeyInput, 'idempotencyKey', 500);
1342
+ const idempotencyKey = `pref-owner-control-${createHash('sha256')
1343
+ .update(`preference-owner-control-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
1344
+ const hash = payloadHash({
1345
+ scopeDigest: canonical.scopeDigest,
1346
+ principalDigest: owner.principalDigest,
1347
+ principalLineageId: owner.principalLineageId,
1348
+ principalLineageVersion: owner.principalLineageVersion,
1349
+ generation: owner.generation,
1350
+ action: 'rollback',
1351
+ preferenceKey,
1352
+ admissionCursor,
1353
+ });
1354
+ this.#database.exec('BEGIN IMMEDIATE');
1355
+ try {
1356
+ this.#assertScopePrincipalInTransaction(canonical, owner);
1357
+ const replay = this.#database.prepare(`
1358
+ SELECT * FROM preference_owner_control_receipts WHERE idempotency_key = ?
1359
+ `).get(idempotencyKey);
1360
+ if (replay !== undefined) {
1361
+ if (replay.payload_hash !== hash || replay.action !== 'rollback'
1362
+ || replay.target_preference_key !== preferenceKey) {
1363
+ throw new PreferenceStoreError('idempotency-conflict', 'learning rollback idempotency key was reused');
1364
+ }
1365
+ const state = this.#ownerControlStateFromReceipt(replay);
1366
+ this.#database.exec('COMMIT');
1367
+ return Object.freeze({
1368
+ applied: replay.result_applied === 1,
1369
+ replayed: true,
1370
+ state,
1371
+ rolledBack: replay.result_rolled_back === 1,
1372
+ ...(replay.result_rolled_back_version === null
1373
+ ? {}
1374
+ : { rolledBackVersion: replay.result_rolled_back_version }),
1375
+ });
1376
+ }
1377
+ const current = this.#scopeAdmissionRowInTransaction(canonical.scopeKey);
1378
+ const fresh = this.#controlCursorIsFresh(current, admissionCursor);
1379
+ let rolledBack = false;
1380
+ let rolledBackVersion;
1381
+ if (fresh) {
1382
+ const active = this.#database.prepare(`
1383
+ SELECT * FROM preference_hypotheses
1384
+ WHERE scope_key = ? AND preference_key = ?
1385
+ AND risk_tier = 'T1' AND effect_state = 'active'
1386
+ `).get(canonical.scopeKey, preferenceKey);
1387
+ if (active !== undefined) {
1388
+ rolledBackVersion = active.version + 1;
1389
+ const changed = this.#database.prepare(`
1390
+ UPDATE preference_hypotheses SET
1391
+ claim_state = 'rejected', effect_state = 'rolled-back',
1392
+ rolled_back_at = ?, updated_at = ?, version = ?
1393
+ WHERE id = ? AND scope_key = ? AND preference_key = ?
1394
+ AND version = ? AND effect_state = 'active'
1395
+ `).run(occurredAt, occurredAt, rolledBackVersion, active.id, canonical.scopeKey, preferenceKey, active.version);
1396
+ if (changed.changes !== 1) {
1397
+ throw new PreferenceStoreError('conflict', 'learning rollback hypothesis CAS lost');
1398
+ }
1399
+ this.#transition(active, 'rejected', 'rolled-back', 'owner-rejected', rolledBackVersion, occurredAt);
1400
+ rolledBack = true;
1401
+ }
1402
+ const controlVersion = current.control_version + 1;
1403
+ const ignoreEventsThroughSequence = Math.max(current.ignore_events_through_sequence ?? 0, admissionCursor.sequence);
1404
+ const changed = this.#database.prepare(`
1405
+ UPDATE preference_scope_principals
1406
+ SET control_version = ?, ignore_events_through = MAX(ignore_events_through, ?),
1407
+ ignore_events_through_sequence = ?, admission_high_water = ?,
1408
+ admission_high_water_kind = 'control', updated_at = ?
1409
+ WHERE scope_key = ? AND principal_digest = ?
1410
+ AND principal_lineage_id = ? AND principal_lineage_version = ?
1411
+ AND generation = ? AND purge_pending = 0
1412
+ AND admission_cursor_epoch = ? AND admission_high_water IS ?
1413
+ `).run(controlVersion, occurredAt, ignoreEventsThroughSequence, admissionCursor.sequence, this.#now(), canonical.scopeKey, owner.principalDigest, owner.principalLineageId, owner.principalLineageVersion, owner.generation, admissionCursor.epoch, current.admission_high_water);
1414
+ if (changed.changes !== 1) {
1415
+ throw new PreferenceStoreError('conflict', 'learning rollback owner cursor changed');
1416
+ }
1417
+ }
1418
+ const state = this.#scopeLearningStatusInTransaction(canonical, owner, false);
1419
+ this.#insertOwnerControlReceipt({
1420
+ idempotencyKey,
1421
+ hash,
1422
+ canonical,
1423
+ owner,
1424
+ action: 'rollback',
1425
+ preferenceKey,
1426
+ admissionCursor,
1427
+ applied: fresh,
1428
+ state,
1429
+ rolledBack,
1430
+ ...(rolledBackVersion === undefined ? {} : { rolledBackVersion }),
1431
+ occurredAt,
1432
+ });
1433
+ this.#database.exec('COMMIT');
1434
+ return Object.freeze({
1435
+ applied: fresh,
1436
+ replayed: false,
1437
+ state,
1438
+ rolledBack,
1439
+ ...(rolledBackVersion === undefined ? {} : { rolledBackVersion }),
1440
+ });
1441
+ }
1442
+ catch (error) {
1443
+ this.#database.exec('ROLLBACK');
1444
+ throw error;
1445
+ }
1446
+ }
154
1447
  /** Atomically record one small producer batch and reconcile each affected key once. */
155
- appendSignals(inputs) {
1448
+ appendSignals(inputs, options = {}) {
156
1449
  this.#assertOpen();
157
1450
  if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > 16) {
158
1451
  throw new PreferenceStoreError('invalid-input', 'preference signal batch must contain 1-16 entries');
@@ -163,21 +1456,55 @@ export class PreferenceStore {
163
1456
  if (uniqueIdempotency.size !== normalizedBatch.length) {
164
1457
  throw new PreferenceStoreError('invalid-input', 'preference signal batch contains duplicate idempotency keys');
165
1458
  }
1459
+ const exactCorrections = options.exactCorrections ?? [];
1460
+ const exactByIndex = new Map();
1461
+ for (const correction of exactCorrections) {
1462
+ if (!Number.isSafeInteger(correction.signalIndex) || correction.signalIndex < 0
1463
+ || correction.signalIndex >= normalizedBatch.length || exactByIndex.has(correction.signalIndex)) {
1464
+ throw new PreferenceStoreError('invalid-input', 'exact correction signal index is invalid');
1465
+ }
1466
+ exactByIndex.set(correction.signalIndex, Object.freeze({
1467
+ signalIndex: correction.signalIndex,
1468
+ sourceInboxId: boundedText(correction.sourceInboxId, 'sourceInboxId', 500),
1469
+ replyOutboxId: boundedText(correction.replyOutboxId, 'replyOutboxId', 500),
1470
+ }));
1471
+ }
166
1472
  this.#database.exec('BEGIN IMMEDIATE');
167
1473
  try {
1474
+ let ownerAdmissionAccepted = true;
1475
+ if (options.ownerFence !== undefined) {
1476
+ const canonical = canonicalPreferenceScope(normalizedBatch[0].scope);
1477
+ if (normalizedBatch.some(signal => signal.scopeKey !== canonical.scopeKey)) {
1478
+ throw new PreferenceStoreError('conflict', 'owner-fenced signal batch spans multiple scopes');
1479
+ }
1480
+ if (options.admissionCursor === undefined) {
1481
+ throw new PreferenceStoreError('invalid-input', 'owner-fenced signal batches require a Delivery admission cursor');
1482
+ }
1483
+ ownerAdmissionAccepted = this.#admitEventCursorInTransaction(canonical, options.ownerFence, canonicalPreferenceAdmissionCursor(options.admissionCursor));
1484
+ }
168
1485
  const storedSignals = [];
1486
+ const storedSignalsByIndex = [];
1487
+ const storedSignalRows = [];
169
1488
  const affected = new Map();
170
- for (const normalized of normalizedBatch) {
1489
+ for (let index = 0; index < normalizedBatch.length; index += 1) {
1490
+ const normalized = normalizedBatch[index];
171
1491
  const hash = payloadHash(normalized);
172
1492
  const replay = this.#database.prepare('SELECT * FROM preference_signals WHERE idempotency_key = ?').get(normalized.idempotencyDigest);
173
1493
  if (replay !== undefined) {
174
1494
  if (replay.payload_hash !== hash) {
175
1495
  throw new PreferenceStoreError('idempotency-conflict', 'preference signal idempotency key was reused with different content');
176
1496
  }
177
- storedSignals.push(signalFromRow(replay));
1497
+ const replayed = signalFromRow(replay);
1498
+ storedSignals.push(replayed);
1499
+ storedSignalsByIndex[index] = replayed;
1500
+ storedSignalRows[index] = replay;
178
1501
  continue;
179
1502
  }
180
- const forgottenThrough = this.#forgottenThrough(normalized.scopeDigest);
1503
+ if (!ownerAdmissionAccepted)
1504
+ continue;
1505
+ const forgottenThrough = options.ownerFence === undefined
1506
+ ? this.#forgottenThrough(normalized.scopeDigest)
1507
+ : undefined;
181
1508
  if (forgottenThrough !== undefined && normalized.occurredAt <= forgottenThrough) {
182
1509
  throw new PreferenceStoreError('scope-forgotten', 'signal predates the durable scope forget boundary');
183
1510
  }
@@ -192,17 +1519,33 @@ export class PreferenceStore {
192
1519
  const stored = this.#database.prepare('SELECT * FROM preference_signals WHERE id = ?')
193
1520
  .get(id);
194
1521
  storedSignals.push(signalFromRow(stored));
1522
+ storedSignalsByIndex[index] = signalFromRow(stored);
1523
+ storedSignalRows[index] = stored;
195
1524
  if (normalized.riskTier === 'T1' || normalized.riskTier === 'T2') {
196
1525
  affected.set(`${normalized.scopeKey}\0${normalized.preferenceKey}`, {
197
1526
  scopeKey: normalized.scopeKey,
198
1527
  scope: normalized.scope,
199
1528
  preferenceKey: normalized.preferenceKey,
200
1529
  trigger: stored,
1530
+ allowGlobalOwnerCorrection: !exactByIndex.has(index),
201
1531
  });
202
1532
  }
203
1533
  }
204
1534
  for (const entry of affected.values()) {
205
- this.#reconcileKey(entry.scopeKey, entry.scope, entry.preferenceKey, now, entry.trigger);
1535
+ this.#reconcileKey(entry.scopeKey, entry.scope, entry.preferenceKey, now, entry.trigger, entry.allowGlobalOwnerCorrection);
1536
+ }
1537
+ for (const [index, correction] of exactByIndex) {
1538
+ const signal = storedSignalRows[index];
1539
+ const stored = storedSignalsByIndex[index];
1540
+ if (signal === undefined || stored === undefined)
1541
+ continue;
1542
+ this.#applyExposureCorrectionInTransaction({
1543
+ signal,
1544
+ sourceInboxId: correction.sourceInboxId,
1545
+ replyOutboxId: correction.replyOutboxId,
1546
+ occurredAt: stored.occurredAt,
1547
+ now,
1548
+ });
206
1549
  }
207
1550
  this.#database.exec('COMMIT');
208
1551
  return storedSignals;
@@ -212,96 +1555,323 @@ export class PreferenceStore {
212
1555
  throw error;
213
1556
  }
214
1557
  }
215
- list(scopeInput, limit = this.#options.maxReviewHypotheses) {
1558
+ list(scopeInput, limit = this.#options.maxReviewHypotheses, principalIdInput, lineageInput) {
216
1559
  this.#assertOpen();
217
1560
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.#options.maxReviewHypotheses) {
218
1561
  throw new PreferenceStoreError('invalid-input', `hypothesis limit must be between 1 and ${this.#options.maxReviewHypotheses}`);
219
1562
  }
220
1563
  const canonical = canonicalPreferenceScope(scopeInput);
1564
+ const principalDigest = principalIdInput === undefined
1565
+ ? undefined
1566
+ : preferencePrincipalDigest(principalIdInput);
1567
+ if ((principalDigest === undefined) !== (lineageInput === undefined)) {
1568
+ throw new PreferenceStoreError('invalid-input', 'scoped preference listing requires both external principal and exact lineage');
1569
+ }
1570
+ const lineage = lineageInput === undefined
1571
+ ? undefined
1572
+ : canonicalPreferencePrincipalLineage(lineageInput);
221
1573
  this.#refreshScope(canonical.scopeKey, canonical.scope, this.#now());
222
1574
  const rows = this.#database.prepare(`
223
- SELECT * FROM preference_hypotheses
224
- WHERE scope_key = ?
1575
+ SELECT hypothesis.* FROM preference_hypotheses AS hypothesis
1576
+ WHERE hypothesis.scope_key = ?
1577
+ AND (? IS NULL OR EXISTS (
1578
+ SELECT 1 FROM preference_scope_principals AS owner
1579
+ WHERE owner.scope_key = hypothesis.scope_key
1580
+ AND owner.principal_digest = ? AND owner.purge_pending = 0
1581
+ AND (? IS NULL OR (
1582
+ owner.principal_lineage_id = ? AND owner.principal_lineage_version = ?
1583
+ ))
1584
+ AND owner.admission_cursor_epoch IS NOT NULL
1585
+ ))
225
1586
  ORDER BY CASE effect_state
226
1587
  WHEN 'active' THEN 0 WHEN 'shadow' THEN 1 WHEN 'inactive' THEN 2
227
1588
  WHEN 'suppressed' THEN 3 ELSE 4 END,
228
1589
  confidence_bps DESC, updated_at DESC, id DESC
229
1590
  LIMIT ?
230
- `).all(canonical.scopeKey, limit);
1591
+ `).all(canonical.scopeKey, principalDigest ?? null, principalDigest ?? null, lineage?.principalRecordId ?? null, lineage?.principalRecordId ?? null, lineage?.principalVersion ?? null, limit);
231
1592
  return rows.map(hypothesisFromRow);
232
1593
  }
233
- get(scopeInput, hypothesisIdInput) {
1594
+ get(scopeInput, hypothesisIdInput, principalIdInput, lineageInput) {
234
1595
  this.#assertOpen();
235
1596
  const canonical = canonicalPreferenceScope(scopeInput);
236
1597
  const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
237
1598
  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);
1599
+ const principalDigest = principalIdInput === undefined
1600
+ ? undefined
1601
+ : preferencePrincipalDigest(principalIdInput);
1602
+ if ((principalDigest === undefined) !== (lineageInput === undefined)) {
1603
+ throw new PreferenceStoreError('invalid-input', 'scoped preference lookup requires both external principal and exact lineage');
1604
+ }
1605
+ const lineage = lineageInput === undefined
1606
+ ? undefined
1607
+ : canonicalPreferencePrincipalLineage(lineageInput);
1608
+ const row = this.#database.prepare(`
1609
+ SELECT hypothesis.* FROM preference_hypotheses AS hypothesis
1610
+ WHERE hypothesis.id = ? AND hypothesis.scope_key = ?
1611
+ AND (? IS NULL OR EXISTS (
1612
+ SELECT 1 FROM preference_scope_principals AS owner
1613
+ WHERE owner.scope_key = hypothesis.scope_key
1614
+ AND owner.principal_digest = ? AND owner.purge_pending = 0
1615
+ AND (? IS NULL OR (
1616
+ owner.principal_lineage_id = ? AND owner.principal_lineage_version = ?
1617
+ ))
1618
+ AND owner.admission_cursor_epoch IS NOT NULL
1619
+ ))
1620
+ `).get(id, canonical.scopeKey, principalDigest ?? null, principalDigest ?? null, lineage?.principalRecordId ?? null, lineage?.principalRecordId ?? null, lineage?.principalVersion ?? null);
239
1621
  return row === undefined ? undefined : hypothesisFromRow(row);
240
1622
  }
241
- activate(scopeInput, hypothesisIdInput, expectedVersion) {
1623
+ /**
1624
+ * Return the first hypothesis that would pass activate() at this instant.
1625
+ * The deterministic ordering is only a planning hint: activate() still
1626
+ * repeats every predicate under BEGIN IMMEDIATE and exact-version CAS.
1627
+ */
1628
+ activationCandidate(scopeInput, owner) {
1629
+ this.#assertOpen();
1630
+ const canonical = canonicalPreferenceScope(scopeInput);
1631
+ const now = this.#now();
1632
+ this.#database.exec('BEGIN IMMEDIATE');
1633
+ try {
1634
+ this.#assertScopePrincipalInTransaction(canonical, owner);
1635
+ this.#assertScopeLearningActiveInTransaction(canonical);
1636
+ this.#refreshScopeInTransaction(canonical.scopeKey, canonical.scope, now);
1637
+ const rows = this.#database.prepare(`
1638
+ SELECT * FROM preference_hypotheses
1639
+ WHERE scope_key = ?
1640
+ ORDER BY confidence_bps DESC, supporting_signals DESC, updated_at DESC, id ASC
1641
+ `).all(canonical.scopeKey);
1642
+ const row = rows.find(candidate => this.#activationError(candidate, canonical.scopeKey, now) === undefined);
1643
+ this.#database.exec('COMMIT');
1644
+ return row === undefined ? undefined : hypothesisFromRow(row);
1645
+ }
1646
+ catch (error) {
1647
+ this.#database.exec('ROLLBACK');
1648
+ throw error;
1649
+ }
1650
+ }
1651
+ activate(scopeInput, hypothesisIdInput, expectedVersion, owner) {
1652
+ this.#assertOpen();
1653
+ const canonical = canonicalPreferenceScope(scopeInput);
1654
+ const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
1655
+ if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) {
1656
+ throw new PreferenceStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
1657
+ }
1658
+ const now = this.#now();
1659
+ this.#database.exec('BEGIN IMMEDIATE');
1660
+ try {
1661
+ this.#assertScopePrincipalInTransaction(canonical, owner);
1662
+ this.#assertScopeLearningActiveInTransaction(canonical);
1663
+ this.#refreshScopeInTransaction(canonical.scopeKey, canonical.scope, now);
1664
+ const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
1665
+ if (row === undefined)
1666
+ throw new PreferenceStoreError('not-found', 'preference hypothesis was not found');
1667
+ if (row.version !== expectedVersion)
1668
+ throw new PreferenceStoreError('conflict', 'hypothesis version changed');
1669
+ const activationError = this.#activationError(row, canonical.scopeKey, now);
1670
+ if (activationError !== undefined)
1671
+ throw activationError;
1672
+ const nextVersion = row.version + 1;
1673
+ this.#database.prepare(`
1674
+ UPDATE preference_hypotheses
1675
+ SET effect_state = 'active', activated_at = ?, updated_at = ?, version = ?
1676
+ WHERE id = ? AND version = ?
1677
+ `).run(now, now, nextVersion, row.id, row.version);
1678
+ this.#transition(row, row.claim_state, 'active', 'activated', nextVersion, now);
1679
+ const updated = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ?')
1680
+ .get(row.id);
1681
+ this.#database.exec('COMMIT');
1682
+ return hypothesisFromRow(updated);
1683
+ }
1684
+ catch (error) {
1685
+ this.#database.exec('ROLLBACK');
1686
+ throw error;
1687
+ }
1688
+ }
1689
+ /**
1690
+ * Apply currently-ready catalog T1 hypotheses with the same exact CAS used
1691
+ * by the manual surface. The bounded loop is crash-replay safe: a later run
1692
+ * simply observes already-active rows and continues.
1693
+ */
1694
+ activateReady(scopeInput, limit = this.#options.maxActiveOverlays, ownerFence) {
1695
+ this.#assertOpen();
1696
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.#options.maxActiveOverlays) {
1697
+ throw new PreferenceStoreError('invalid-input', 'automatic activation limit is invalid');
1698
+ }
1699
+ const scope = canonicalPreferenceScope(scopeInput).scope;
1700
+ const activated = [];
1701
+ for (let index = 0; index < limit; index += 1) {
1702
+ const candidate = this.activationCandidate(scope, ownerFence);
1703
+ if (candidate === undefined)
1704
+ break;
1705
+ try {
1706
+ activated.push(this.activate(scope, candidate.id, candidate.version, ownerFence));
1707
+ }
1708
+ catch (error) {
1709
+ if (error instanceof PreferenceStoreError
1710
+ && ['conflict', 'not-ready'].includes(error.code))
1711
+ break;
1712
+ throw error;
1713
+ }
1714
+ }
1715
+ return activated;
1716
+ }
1717
+ /** Bounded restart/maintenance recovery for signals committed before activation. */
1718
+ activateReadyScopes(limit) {
1719
+ this.#assertOpen();
1720
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
1721
+ throw new PreferenceStoreError('invalid-input', 'automatic scope activation limit is invalid');
1722
+ }
1723
+ const rows = this.#database.prepare(`
1724
+ SELECT h.scope_key, h.workspace, h.preset,
1725
+ owner.principal_digest, owner.principal_lineage_id,
1726
+ owner.principal_lineage_version, owner.generation
1727
+ FROM preference_hypotheses h
1728
+ INNER JOIN preference_scope_principals owner
1729
+ ON owner.scope_key = h.scope_key AND owner.purge_pending = 0
1730
+ AND owner.learning_paused = 0 AND owner.admission_cursor_epoch IS NOT NULL
1731
+ WHERE h.risk_tier = 'T1' AND h.claim_state = 'tentative' AND h.effect_state = 'shadow'
1732
+ AND EXISTS (
1733
+ SELECT 1 FROM preference_signals s
1734
+ WHERE s.scope_key = h.scope_key AND s.actor_trust = 'owner-authenticated'
1735
+ AND ((s.interpretation_trust IN ('explicit-selection', 'typed-feedback')
1736
+ AND s.source IN ('direct-owner-feedback', 'signed-ui-feedback'))
1737
+ OR (s.interpretation_trust = 'behavioral-inference'
1738
+ AND s.source = 'delivery-observation'))
1739
+ )
1740
+ GROUP BY h.scope_key, h.workspace, h.preset,
1741
+ owner.principal_digest, owner.principal_lineage_id,
1742
+ owner.principal_lineage_version, owner.generation
1743
+ ORDER BY MAX(h.updated_at) DESC, h.scope_key ASC
1744
+ LIMIT ?
1745
+ `).all(limit);
1746
+ let activated = 0;
1747
+ for (const row of rows) {
1748
+ const scope = { workspace: row.workspace, preset: row.preset };
1749
+ const ownerFence = Object.freeze({
1750
+ scopeKey: row.scope_key,
1751
+ principalDigest: row.principal_digest,
1752
+ principalLineageId: row.principal_lineage_id,
1753
+ principalLineageVersion: row.principal_lineage_version,
1754
+ generation: row.generation,
1755
+ });
1756
+ try {
1757
+ activated += this.activateReady(scope, this.#options.maxActiveOverlays, ownerFence).length;
1758
+ }
1759
+ catch (error) {
1760
+ // A newer authenticated owner may rotate this scope after the bounded
1761
+ // scan but before activation. The exact generation check inside each
1762
+ // activation transaction makes that race a safe no-op for this pass.
1763
+ if (error instanceof PreferenceStoreError && error.code === 'conflict')
1764
+ continue;
1765
+ throw error;
1766
+ }
1767
+ }
1768
+ return activated;
1769
+ }
1770
+ /**
1771
+ * Activate one exact hypothesis and commit a fixed-run receipt in the same
1772
+ * writer transaction. A lost response can therefore be replayed after a
1773
+ * restart without relaxing the original evidence gate or CAS.
1774
+ */
1775
+ activateHostOnce(scopeInput, hypothesisIdInput, expectedVersion, operationIdInput, owner, newMutationEnabled = true) {
242
1776
  this.#assertOpen();
243
1777
  const canonical = canonicalPreferenceScope(scopeInput);
244
1778
  const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
245
1779
  if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) {
246
1780
  throw new PreferenceStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
247
1781
  }
1782
+ const operationId = boundedText(operationIdInput, 'operationId', 500);
1783
+ const idempotencyKey = `pref-host-activate-idem-${createHash('sha256')
1784
+ .update(`preference-host-activation-idempotency-v1\0${operationId}`).digest('hex')}`;
1785
+ const hash = payloadHash({
1786
+ scopeDigest: canonical.scopeDigest,
1787
+ hypothesisId: id,
1788
+ expectedVersion,
1789
+ idempotencyKey,
1790
+ ownerGeneration: owner.generation,
1791
+ principalLineageId: owner.principalLineageId,
1792
+ principalLineageVersion: owner.principalLineageVersion,
1793
+ });
248
1794
  const now = this.#now();
249
1795
  this.#database.exec('BEGIN IMMEDIATE');
250
1796
  try {
1797
+ // The current exact owner fence is always checked before looking up a
1798
+ // receipt. This prevents an A -> B -> A owner rotation from replaying an
1799
+ // old generation's success. Once that fence is proven, however, an
1800
+ // already-committed receipt is authoritative: pause/disable/readiness
1801
+ // changes happened after the mutation and must not turn a lost response
1802
+ // into an ambiguous Recovery result.
1803
+ this.#assertScopePrincipalInTransaction(canonical, owner);
1804
+ const prior = this.#database.prepare(`
1805
+ SELECT payload_hash, scope_digest, hypothesis_id, expected_version, result_version,
1806
+ owner_generation, principal_lineage_id, principal_lineage_version
1807
+ FROM preference_host_activation_receipts WHERE idempotency_key = ?
1808
+ `).get(idempotencyKey);
1809
+ if (prior !== undefined) {
1810
+ if (prior.payload_hash !== hash || prior.scope_digest !== canonical.scopeDigest
1811
+ || prior.hypothesis_id !== id || prior.expected_version !== expectedVersion
1812
+ || prior.owner_generation !== owner.generation
1813
+ || prior.principal_lineage_id !== owner.principalLineageId
1814
+ || prior.principal_lineage_version !== owner.principalLineageVersion) {
1815
+ throw new PreferenceStoreError('idempotency-conflict', 'Host activation operation identity was reused for another exact target');
1816
+ }
1817
+ this.#database.exec('COMMIT');
1818
+ return Object.freeze({
1819
+ hypothesisId: prior.hypothesis_id,
1820
+ expectedVersion: prior.expected_version,
1821
+ resultVersion: prior.result_version,
1822
+ ownerGeneration: prior.owner_generation,
1823
+ principalLineageId: prior.principal_lineage_id,
1824
+ principalLineageVersion: prior.principal_lineage_version,
1825
+ replayed: true,
1826
+ });
1827
+ }
1828
+ if (!newMutationEnabled) {
1829
+ throw new PreferenceStoreError('disabled', 'preference learning is disabled');
1830
+ }
1831
+ this.#assertScopeLearningActiveInTransaction(canonical);
251
1832
  this.#refreshScopeInTransaction(canonical.scopeKey, canonical.scope, now);
252
1833
  const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
253
1834
  if (row === undefined)
254
1835
  throw new PreferenceStoreError('not-found', 'preference hypothesis was not found');
255
1836
  if (row.version !== expectedVersion)
256
1837
  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(`
1838
+ const activationError = this.#activationError(row, canonical.scopeKey, now);
1839
+ if (activationError !== undefined)
1840
+ throw activationError;
1841
+ const resultVersion = row.version + 1;
1842
+ const updated = this.#database.prepare(`
289
1843
  UPDATE preference_hypotheses
290
1844
  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);
1845
+ WHERE id = ? AND scope_key = ? AND version = ?
1846
+ `).run(now, now, resultVersion, row.id, canonical.scopeKey, row.version);
1847
+ if (updated.changes !== 1) {
1848
+ throw new PreferenceStoreError('conflict', 'hypothesis activation CAS lost');
1849
+ }
1850
+ this.#transition(row, row.claim_state, 'active', 'activated', resultVersion, now);
1851
+ this.#database.prepare(`
1852
+ INSERT INTO preference_host_activation_receipts(
1853
+ idempotency_key, payload_hash, scope_digest, hypothesis_id,
1854
+ expected_version, result_version, occurred_at, owner_generation,
1855
+ principal_lineage_id, principal_lineage_version
1856
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1857
+ `).run(idempotencyKey, hash, canonical.scopeDigest, row.id, row.version, resultVersion, now, owner.generation, owner.principalLineageId, owner.principalLineageVersion);
296
1858
  this.#database.exec('COMMIT');
297
- return hypothesisFromRow(updated);
1859
+ return Object.freeze({
1860
+ hypothesisId: row.id,
1861
+ expectedVersion: row.version,
1862
+ resultVersion,
1863
+ ownerGeneration: owner.generation,
1864
+ principalLineageId: owner.principalLineageId,
1865
+ principalLineageVersion: owner.principalLineageVersion,
1866
+ replayed: false,
1867
+ });
298
1868
  }
299
1869
  catch (error) {
300
1870
  this.#database.exec('ROLLBACK');
301
1871
  throw error;
302
1872
  }
303
1873
  }
304
- rollback(scopeInput, hypothesisIdInput, expectedVersion, reasonInput) {
1874
+ rollback(scopeInput, hypothesisIdInput, expectedVersion, reasonInput, principalIdInput) {
305
1875
  this.#assertOpen();
306
1876
  const canonical = canonicalPreferenceScope(scopeInput);
307
1877
  const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
@@ -312,6 +1882,7 @@ export class PreferenceStore {
312
1882
  const now = this.#now();
313
1883
  this.#database.exec('BEGIN IMMEDIATE');
314
1884
  try {
1885
+ this.#assertScopePrincipalInTransaction(canonical, principalIdInput);
315
1886
  const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
316
1887
  if (row === undefined)
317
1888
  throw new PreferenceStoreError('not-found', 'preference hypothesis was not found');
@@ -338,17 +1909,71 @@ export class PreferenceStore {
338
1909
  }
339
1910
  }
340
1911
  overlay(scopeInput) {
1912
+ return this.overlaySnapshot(scopeInput).text;
1913
+ }
1914
+ overlaySnapshot(scopeInput, principalIdInput, lineageInput) {
341
1915
  this.#assertOpen();
342
1916
  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;
1917
+ const principalDigest = principalIdInput === undefined
1918
+ ? undefined
1919
+ : preferencePrincipalDigest(principalIdInput);
1920
+ const lineage = lineageInput === undefined
1921
+ ? undefined
1922
+ : canonicalPreferencePrincipalLineage(lineageInput);
1923
+ let rows;
1924
+ let ownerFence;
1925
+ this.#database.exec('BEGIN IMMEDIATE');
1926
+ try {
1927
+ if (principalDigest !== undefined) {
1928
+ const owner = this.#database.prepare(`
1929
+ SELECT generation, principal_lineage_id, principal_lineage_version,
1930
+ learning_paused, admission_cursor_epoch
1931
+ FROM preference_scope_principals
1932
+ WHERE scope_key = ? AND principal_digest = ? AND purge_pending = 0
1933
+ AND (? IS NULL OR (principal_lineage_id = ? AND principal_lineage_version = ?))
1934
+ `).get(canonical.scopeKey, principalDigest, lineage?.principalRecordId ?? null, lineage?.principalRecordId ?? null, lineage?.principalVersion ?? null);
1935
+ if (owner === undefined || owner.principal_lineage_id === null
1936
+ || owner.principal_lineage_version === null
1937
+ || owner.admission_cursor_epoch === null) {
1938
+ this.#database.exec('COMMIT');
1939
+ return Object.freeze({ text: undefined, hypotheses: Object.freeze([]) });
1940
+ }
1941
+ ownerFence = Object.freeze({
1942
+ scopeKey: canonical.scopeKey,
1943
+ principalDigest,
1944
+ principalLineageId: owner.principal_lineage_id,
1945
+ principalLineageVersion: owner.principal_lineage_version,
1946
+ generation: owner.generation,
1947
+ });
1948
+ if (owner.learning_paused === 1) {
1949
+ this.#database.exec('COMMIT');
1950
+ return Object.freeze({
1951
+ text: undefined,
1952
+ hypotheses: Object.freeze([]),
1953
+ ownerFence,
1954
+ });
1955
+ }
1956
+ }
1957
+ this.#refreshScopeInTransaction(canonical.scopeKey, canonical.scope, this.#now());
1958
+ rows = this.#database.prepare(`
1959
+ SELECT * FROM preference_hypotheses
1960
+ WHERE scope_key = ? AND effect_state = 'active' AND risk_tier = 'T1'
1961
+ ORDER BY preference_key ASC, id ASC
1962
+ LIMIT ?
1963
+ `).all(canonical.scopeKey, this.#options.maxActiveOverlays);
1964
+ this.#database.exec('COMMIT');
1965
+ }
1966
+ catch (error) {
1967
+ this.#database.exec('ROLLBACK');
1968
+ throw error;
1969
+ }
1970
+ if (rows.length === 0) {
1971
+ return Object.freeze({
1972
+ text: undefined,
1973
+ hypotheses: Object.freeze([]),
1974
+ ...(ownerFence === undefined ? {} : { ownerFence }),
1975
+ });
1976
+ }
352
1977
  const lines = rows.map(row => `- ${renderCatalogPreference(row.preference_key, row.candidate_value)}`);
353
1978
  const rendered = [
354
1979
  '<tentative_preference_overlay>',
@@ -359,30 +1984,310 @@ export class PreferenceStore {
359
1984
  if (Buffer.byteLength(rendered, 'utf8') > this.#options.maxOverlayBytes) {
360
1985
  throw new PreferenceStoreError('invalid-input', 'catalog overlay exceeds the configured hard byte cap');
361
1986
  }
362
- return rendered;
1987
+ return Object.freeze({
1988
+ text: rendered,
1989
+ hypotheses: Object.freeze(rows.map(hypothesisFromRow)),
1990
+ ...(ownerFence === undefined ? {} : { ownerFence }),
1991
+ });
1992
+ }
1993
+ recordExposure(input) {
1994
+ this.#assertOpen();
1995
+ const canonical = canonicalPreferenceScope(input.scope);
1996
+ const hypothesisIdInput = boundedText(input.hypothesisId, 'hypothesisId', 200);
1997
+ const sessionId = boundedText(input.sessionId, 'sessionId', 500);
1998
+ const sourceEventId = boundedText(input.sourceEventId, 'sourceEventId', 500);
1999
+ if (!Number.isSafeInteger(input.hypothesisVersion) || input.hypothesisVersion < 1) {
2000
+ throw new PreferenceStoreError('invalid-input', 'hypothesisVersion must be a positive safe integer');
2001
+ }
2002
+ const idempotencyKey = `pref-exposure-${createHash('sha256')
2003
+ .update('preference-exposure-v1\0')
2004
+ .update(JSON.stringify([
2005
+ canonical.scopeDigest,
2006
+ hypothesisIdInput,
2007
+ input.hypothesisVersion,
2008
+ sessionId,
2009
+ sourceEventId,
2010
+ ]))
2011
+ .digest('hex')}`;
2012
+ const hash = payloadHash({
2013
+ scopeDigest: canonical.scopeDigest,
2014
+ hypothesisId: hypothesisIdInput,
2015
+ hypothesisVersion: input.hypothesisVersion,
2016
+ sessionId,
2017
+ sourceEventId,
2018
+ });
2019
+ const now = this.#now();
2020
+ this.#database.exec('BEGIN IMMEDIATE');
2021
+ try {
2022
+ this.#assertScopePrincipalInTransaction(canonical, input.ownerFence);
2023
+ this.#assertScopeLearningActiveInTransaction(canonical);
2024
+ const replay = this.#database.prepare(`
2025
+ SELECT payload_hash FROM preference_exposures WHERE idempotency_key = ?
2026
+ `).get(idempotencyKey);
2027
+ if (replay !== undefined) {
2028
+ if (replay.payload_hash !== hash) {
2029
+ throw new PreferenceStoreError('idempotency-conflict', 'preference exposure identity was reused');
2030
+ }
2031
+ this.#database.exec('COMMIT');
2032
+ return Object.freeze({ replayed: true });
2033
+ }
2034
+ const hypothesis = this.#database.prepare(`
2035
+ SELECT * FROM preference_hypotheses
2036
+ WHERE id = ? AND scope_key = ? AND version = ?
2037
+ AND risk_tier = 'T1' AND effect_state = 'active' AND expires_at > ?
2038
+ `).get(hypothesisIdInput, canonical.scopeKey, input.hypothesisVersion, now);
2039
+ if (hypothesis === undefined) {
2040
+ throw new PreferenceStoreError('conflict', 'exposure hypothesis is no longer the exact active version');
2041
+ }
2042
+ this.#database.prepare(`
2043
+ INSERT INTO preference_exposures(
2044
+ idempotency_key, payload_hash, scope_key, workspace, preset,
2045
+ hypothesis_id, hypothesis_version, session_id, source_event_id,
2046
+ source_inbox_id, reply_outbox_id, state, exposed_at, bound_at, expires_at
2047
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'pending', ?, NULL, ?)
2048
+ `).run(idempotencyKey, hash, canonical.scopeKey, canonical.scope.workspace, canonical.scope.preset, hypothesis.id, hypothesis.version, sessionId, sourceEventId, now, hypothesis.expires_at);
2049
+ this.#database.exec('COMMIT');
2050
+ return Object.freeze({ replayed: false });
2051
+ }
2052
+ catch (error) {
2053
+ this.#database.exec('ROLLBACK');
2054
+ throw error;
2055
+ }
2056
+ }
2057
+ bindExposure(input) {
2058
+ this.#assertOpen();
2059
+ const canonical = canonicalPreferenceScope(input.scope);
2060
+ const sessionId = boundedText(input.sessionId, 'sessionId', 500);
2061
+ const sourceEventId = boundedText(input.sourceEventId, 'sourceEventId', 500);
2062
+ const sourceInboxId = boundedText(input.sourceInboxId, 'sourceInboxId', 500);
2063
+ const replyOutboxId = boundedText(input.replyOutboxId, 'replyOutboxId', 500);
2064
+ const now = this.#now();
2065
+ this.#database.exec('BEGIN IMMEDIATE');
2066
+ try {
2067
+ if (input.ownerFence !== undefined) {
2068
+ if (input.admissionCursor === undefined) {
2069
+ throw new PreferenceStoreError('invalid-input', 'owner-fenced exposure binding requires a Delivery admission cursor');
2070
+ }
2071
+ if (!this.#admitEventCursorInTransaction(canonical, input.ownerFence, canonicalPreferenceAdmissionCursor(input.admissionCursor))) {
2072
+ this.#database.exec('COMMIT');
2073
+ return Object.freeze({ bound: 0, replayed: false });
2074
+ }
2075
+ }
2076
+ else {
2077
+ this.#assertScopePrincipalInTransaction(canonical, input.ownerFence);
2078
+ }
2079
+ this.#assertScopeLearningActiveInTransaction(canonical);
2080
+ const rows = this.#database.prepare(`
2081
+ SELECT source_inbox_id, reply_outbox_id, state FROM preference_exposures
2082
+ WHERE scope_key = ? AND session_id = ? AND source_event_id = ?
2083
+ `).all(canonical.scopeKey, sessionId, sourceEventId);
2084
+ const conflict = rows.some(row => row.state === 'bound'
2085
+ && (row.source_inbox_id !== sourceInboxId || row.reply_outbox_id !== replyOutboxId));
2086
+ if (conflict) {
2087
+ throw new PreferenceStoreError('idempotency-conflict', 'exposure turn was bound to another receipt');
2088
+ }
2089
+ const updated = this.#database.prepare(`
2090
+ UPDATE preference_exposures
2091
+ SET source_inbox_id = ?, reply_outbox_id = ?, state = 'bound', bound_at = ?
2092
+ WHERE scope_key = ? AND session_id = ? AND source_event_id = ? AND state = 'pending'
2093
+ `).run(sourceInboxId, replyOutboxId, now, canonical.scopeKey, sessionId, sourceEventId);
2094
+ this.#database.exec('COMMIT');
2095
+ return Object.freeze({ bound: Number(updated.changes), replayed: rows.length > 0 && updated.changes === 0 });
2096
+ }
2097
+ catch (error) {
2098
+ this.#database.exec('ROLLBACK');
2099
+ throw error;
2100
+ }
2101
+ }
2102
+ hasBoundExposure(input) {
2103
+ this.#assertOpen();
2104
+ const canonical = canonicalPreferenceScope(input.scope);
2105
+ const sourceInboxId = boundedText(input.sourceInboxId, 'sourceInboxId', 500);
2106
+ const replyOutboxId = boundedText(input.replyOutboxId, 'replyOutboxId', 500);
2107
+ return this.#database.prepare(`
2108
+ SELECT 1 AS present FROM preference_exposures
2109
+ WHERE scope_key = ? AND source_inbox_id = ? AND reply_outbox_id = ? AND state = 'bound'
2110
+ LIMIT 1
2111
+ `).get(canonical.scopeKey, sourceInboxId, replyOutboxId) !== undefined;
2112
+ }
2113
+ recordExposureCorrection(input) {
2114
+ this.#assertOpen();
2115
+ const canonical = canonicalPreferenceScope(input.scope);
2116
+ const signalId = boundedText(input.signalId, 'signalId', 200);
2117
+ const selection = catalogSelection(input.preferenceKey, input.candidateValue);
2118
+ const sourceInboxId = boundedText(input.sourceInboxId, 'sourceInboxId', 500);
2119
+ const replyOutboxId = boundedText(input.replyOutboxId, 'replyOutboxId', 500);
2120
+ const occurredAt = safeTimestamp(input.occurredAt, 'occurredAt');
2121
+ this.#database.exec('BEGIN IMMEDIATE');
2122
+ try {
2123
+ const signal = this.#database.prepare(`
2124
+ SELECT * FROM preference_signals WHERE id = ? AND scope_key = ?
2125
+ `).get(signalId, canonical.scopeKey);
2126
+ if (signal === undefined || signal.preference_key !== selection.key
2127
+ || signal.candidate_value !== selection.value || signal.occurred_at !== occurredAt) {
2128
+ throw new PreferenceStoreError('conflict', 'exposure correction signal identity changed');
2129
+ }
2130
+ const inserted = this.#applyExposureCorrectionInTransaction({
2131
+ signal,
2132
+ sourceInboxId,
2133
+ replyOutboxId,
2134
+ occurredAt,
2135
+ now: this.#now(),
2136
+ });
2137
+ this.#database.exec('COMMIT');
2138
+ return inserted;
2139
+ }
2140
+ catch (error) {
2141
+ this.#database.exec('ROLLBACK');
2142
+ throw error;
2143
+ }
2144
+ }
2145
+ #applyExposureCorrectionInTransaction(input) {
2146
+ if (!isActivationEligibleSignal(input.signal)
2147
+ || input.signal.interpretation_trust === 'behavioral-inference')
2148
+ return 0;
2149
+ const exposures = this.#database.prepare(`
2150
+ SELECT exposure.hypothesis_id, exposure.hypothesis_version
2151
+ FROM preference_exposures AS exposure
2152
+ JOIN preference_hypotheses AS hypothesis ON hypothesis.id = exposure.hypothesis_id
2153
+ WHERE exposure.scope_key = ? AND exposure.source_inbox_id = ?
2154
+ AND exposure.reply_outbox_id = ? AND exposure.state = 'bound'
2155
+ AND hypothesis.preference_key = ? AND hypothesis.candidate_value <> ?
2156
+ ORDER BY exposure.hypothesis_id, exposure.hypothesis_version
2157
+ `).all(input.signal.scope_key, input.sourceInboxId, input.replyOutboxId, input.signal.preference_key, input.signal.candidate_value);
2158
+ let inserted = 0;
2159
+ for (const exposure of exposures) {
2160
+ inserted += Number(this.#database.prepare(`
2161
+ INSERT OR IGNORE INTO preference_exposure_corrections(
2162
+ signal_id, hypothesis_id, hypothesis_version,
2163
+ source_inbox_id, reply_outbox_id, occurred_at
2164
+ ) VALUES (?, ?, ?, ?, ?, ?)
2165
+ `).run(input.signal.id, exposure.hypothesis_id, exposure.hypothesis_version, input.sourceInboxId, input.replyOutboxId, input.occurredAt).changes);
2166
+ const current = this.#database.prepare(`
2167
+ SELECT * FROM preference_hypotheses
2168
+ WHERE id = ? AND scope_key = ? AND effect_state = 'active'
2169
+ `).get(exposure.hypothesis_id, input.signal.scope_key);
2170
+ if (current === undefined || current.version < exposure.hypothesis_version)
2171
+ continue;
2172
+ const crossedLineage = this.#database.prepare(`
2173
+ SELECT 1 AS changed FROM preference_transitions
2174
+ WHERE hypothesis_id = ? AND version > ? AND reason <> 'evidence-updated'
2175
+ LIMIT 1
2176
+ `).get(current.id, exposure.hypothesis_version) !== undefined;
2177
+ if (crossedLineage)
2178
+ continue;
2179
+ const nextVersion = current.version + 1;
2180
+ const changed = this.#database.prepare(`
2181
+ UPDATE preference_hypotheses SET
2182
+ claim_state = 'rejected', effect_state = 'rolled-back',
2183
+ rolled_back_at = ?, updated_at = ?, version = ?
2184
+ WHERE id = ? AND scope_key = ? AND version = ? AND effect_state = 'active'
2185
+ `).run(input.now, input.now, nextVersion, current.id, input.signal.scope_key, current.version);
2186
+ if (changed.changes !== 1) {
2187
+ throw new PreferenceStoreError('conflict', 'exact exposure rollback CAS lost');
2188
+ }
2189
+ this.#transition(current, 'rejected', 'rolled-back', 'owner-rejected', nextVersion, input.now);
2190
+ }
2191
+ return inserted;
363
2192
  }
364
- forgetScope(scopeInput, idempotencyKeyInput) {
2193
+ forgetScope(scopeInput, idempotencyKeyInput, options) {
365
2194
  this.#assertOpen();
366
2195
  const canonical = canonicalPreferenceScope(scopeInput);
367
2196
  const rawIdempotencyKey = boundedText(idempotencyKeyInput, 'idempotencyKey', 200);
368
2197
  const idempotencyKey = `pref-forget-idem-${createHash('sha256')
369
2198
  .update(`preference-forget-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
370
2199
  const now = this.#now();
371
- const forgottenThrough = now;
2200
+ const occurredAt = options === undefined ? now : safeTimestamp(options.occurredAt, 'occurredAt');
2201
+ const admissionCursor = options === undefined
2202
+ ? undefined
2203
+ : canonicalPreferenceAdmissionCursor(options.admissionCursor);
2204
+ const forgottenThrough = Math.max(now, occurredAt);
372
2205
  const hash = payloadHash({ scopeDigest: canonical.scopeDigest, idempotencyKey });
2206
+ const ownerReceiptKey = options === undefined
2207
+ ? undefined
2208
+ : `pref-owner-control-${createHash('sha256')
2209
+ .update(`preference-owner-control-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
2210
+ const ownerReceiptHash = options === undefined
2211
+ ? undefined
2212
+ : payloadHash({
2213
+ scopeDigest: canonical.scopeDigest,
2214
+ principalDigest: options.ownerFence.principalDigest,
2215
+ principalLineageId: options.ownerFence.principalLineageId,
2216
+ principalLineageVersion: options.ownerFence.principalLineageVersion,
2217
+ generation: options.ownerFence.generation,
2218
+ action: 'forget',
2219
+ admissionCursor,
2220
+ });
373
2221
  let result;
2222
+ let privacyChanged = false;
374
2223
  this.#database.exec('BEGIN IMMEDIATE');
375
2224
  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);
2225
+ this.#assertScopePrincipalInTransaction(canonical, options?.ownerFence);
2226
+ if (options !== undefined) {
2227
+ const replay = this.#database.prepare(`
2228
+ SELECT * FROM preference_owner_control_receipts WHERE idempotency_key = ?
2229
+ `).get(ownerReceiptKey);
2230
+ if (replay !== undefined) {
2231
+ if (replay.payload_hash !== ownerReceiptHash) {
2232
+ throw new PreferenceStoreError('idempotency-conflict', 'forget idempotency key was reused');
2233
+ }
2234
+ this.#database.exec('COMMIT');
2235
+ result = Object.freeze({
2236
+ applied: replay.result_applied === 1,
2237
+ replayed: true,
2238
+ forgottenThrough: Math.max(0, replay.result_forgotten_through),
2239
+ deletedSignals: replay.result_deleted_signals,
2240
+ deletedHypotheses: replay.result_deleted_hypotheses,
2241
+ state: this.#ownerControlStateFromReceipt(replay),
2242
+ });
2243
+ privacyChanged = replay.result_applied === 1;
2244
+ if (privacyChanged)
2245
+ this.#truncateWalForPrivacy();
2246
+ return result;
2247
+ }
2248
+ const current = this.#scopeAdmissionRowInTransaction(canonical.scopeKey);
2249
+ const fresh = this.#controlCursorIsFresh(current, admissionCursor);
2250
+ if (!fresh) {
2251
+ const priorForget = this.#forgottenThrough(canonical.scopeDigest) ?? 0;
2252
+ const state = this.#scopeLearningStatusInTransaction(canonical, options.ownerFence);
2253
+ this.#insertOwnerControlReceipt({
2254
+ idempotencyKey: ownerReceiptKey,
2255
+ hash: ownerReceiptHash,
2256
+ canonical,
2257
+ owner: options.ownerFence,
2258
+ action: 'forget',
2259
+ admissionCursor: admissionCursor,
2260
+ applied: false,
2261
+ state,
2262
+ deletedSignals: 0,
2263
+ deletedHypotheses: 0,
2264
+ forgottenThrough: priorForget,
2265
+ occurredAt,
2266
+ });
2267
+ this.#database.exec('COMMIT');
2268
+ return Object.freeze({
2269
+ applied: false,
2270
+ replayed: false,
2271
+ forgottenThrough: priorForget,
2272
+ deletedSignals: 0,
2273
+ deletedHypotheses: 0,
2274
+ state,
2275
+ });
2276
+ }
2277
+ }
2278
+ const replay = options === undefined
2279
+ ? this.#database.prepare(`
2280
+ SELECT payload_hash, forgotten_through, deleted_signals, deleted_hypotheses
2281
+ FROM preference_scope_tombstones WHERE idempotency_key = ?
2282
+ `).get(idempotencyKey)
2283
+ : undefined;
380
2284
  if (replay !== undefined) {
381
2285
  if (replay.payload_hash !== hash) {
382
2286
  throw new PreferenceStoreError('idempotency-conflict', 'forget idempotency key was reused');
383
2287
  }
384
2288
  this.#database.exec('COMMIT');
385
2289
  result = Object.freeze({
2290
+ applied: true,
386
2291
  replayed: true,
387
2292
  forgottenThrough: replay.forgotten_through,
388
2293
  deletedSignals: replay.deleted_signals,
@@ -390,25 +2295,85 @@ export class PreferenceStore {
390
2295
  });
391
2296
  }
392
2297
  else {
2298
+ // A privacy reset must not leave historical snapshots or tombstones
2299
+ // that disclose the deleted counts/times. The exact current forget
2300
+ // replay was handled above; after this boundary only its new receipt
2301
+ // and tombstone may survive.
2302
+ this.#cancelScopePromotionsInTransaction(canonical.scopeKey, 'forget', now);
2303
+ this.#purgeScopePromotionDetailsInTransaction(canonical.scopeKey);
2304
+ this.#database.prepare('DELETE FROM preference_owner_control_receipts WHERE scope_digest = ?').run(canonical.scopeDigest);
2305
+ this.#database.prepare('DELETE FROM preference_scope_tombstones WHERE scope_digest = ?').run(canonical.scopeDigest);
2306
+ this.#database.prepare(`
2307
+ DELETE FROM preference_exposure_corrections
2308
+ WHERE signal_id IN (SELECT id FROM preference_signals WHERE scope_key = ?)
2309
+ OR hypothesis_id IN (SELECT id FROM preference_hypotheses WHERE scope_key = ?)
2310
+ `).run(canonical.scopeKey, canonical.scopeKey);
2311
+ this.#database.prepare('DELETE FROM preference_exposures WHERE scope_key = ?').run(canonical.scopeKey);
393
2312
  const hypothesisIds = this.#database.prepare('SELECT id FROM preference_hypotheses WHERE scope_key = ?').all(canonical.scopeKey);
394
2313
  for (const entry of hypothesisIds) {
395
2314
  this.#database.prepare('DELETE FROM preference_transitions WHERE hypothesis_id = ?').run(entry.id);
396
2315
  }
397
2316
  const deletedHypotheses = Number(this.#database.prepare('DELETE FROM preference_hypotheses WHERE scope_key = ?').run(canonical.scopeKey).changes);
398
2317
  const deletedSignals = Number(this.#database.prepare('DELETE FROM preference_signals WHERE scope_key = ?').run(canonical.scopeKey).changes);
2318
+ if (options !== undefined) {
2319
+ const current = this.#scopeAdmissionRowInTransaction(canonical.scopeKey);
2320
+ const changed = this.#database.prepare(`
2321
+ UPDATE preference_scope_principals
2322
+ SET ignore_events_through = MAX(ignore_events_through, ?),
2323
+ ignore_events_through_sequence = ?,
2324
+ admission_high_water = ?, admission_high_water_kind = 'control',
2325
+ control_version = control_version + 1, updated_at = ?
2326
+ WHERE scope_key = ? AND principal_digest = ?
2327
+ AND principal_lineage_id = ? AND principal_lineage_version = ?
2328
+ AND generation = ? AND purge_pending = 0
2329
+ AND admission_cursor_epoch = ? AND admission_high_water IS ?
2330
+ `).run(forgottenThrough, admissionCursor.sequence, admissionCursor.sequence, now, canonical.scopeKey, options.ownerFence.principalDigest, options.ownerFence.principalLineageId, options.ownerFence.principalLineageVersion, options.ownerFence.generation, admissionCursor.epoch, current.admission_high_water);
2331
+ if (changed.changes !== 1) {
2332
+ throw new PreferenceStoreError('conflict', 'forget owner fence changed');
2333
+ }
2334
+ }
399
2335
  this.#database.prepare(`
400
2336
  INSERT INTO preference_scope_tombstones(
401
2337
  id, scope_digest, idempotency_key, payload_hash, forgotten_through,
402
2338
  recorded_at, deleted_signals, deleted_hypotheses
403
2339
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
404
2340
  `).run(`pref-forget-${randomUUID()}`, canonical.scopeDigest, idempotencyKey, hash, forgottenThrough, now, deletedSignals, deletedHypotheses);
2341
+ if (options !== undefined) {
2342
+ const state = this.#scopeLearningStatusInTransaction(canonical, options.ownerFence);
2343
+ this.#insertOwnerControlReceipt({
2344
+ idempotencyKey: ownerReceiptKey,
2345
+ hash: ownerReceiptHash,
2346
+ canonical,
2347
+ owner: options.ownerFence,
2348
+ action: 'forget',
2349
+ admissionCursor: admissionCursor,
2350
+ applied: true,
2351
+ state,
2352
+ deletedSignals,
2353
+ deletedHypotheses,
2354
+ forgottenThrough,
2355
+ occurredAt,
2356
+ });
2357
+ result = Object.freeze({
2358
+ applied: true,
2359
+ replayed: false,
2360
+ forgottenThrough,
2361
+ deletedSignals,
2362
+ deletedHypotheses,
2363
+ state,
2364
+ });
2365
+ }
2366
+ else {
2367
+ result = Object.freeze({
2368
+ applied: true,
2369
+ replayed: false,
2370
+ forgottenThrough,
2371
+ deletedSignals,
2372
+ deletedHypotheses,
2373
+ });
2374
+ }
405
2375
  this.#database.exec('COMMIT');
406
- result = Object.freeze({
407
- replayed: false,
408
- forgottenThrough,
409
- deletedSignals,
410
- deletedHypotheses,
411
- });
2376
+ privacyChanged = true;
412
2377
  }
413
2378
  }
414
2379
  catch (error) {
@@ -418,7 +2383,8 @@ export class PreferenceStore {
418
2383
  // A logical DELETE is not a privacy boundary while old pages remain in
419
2384
  // WAL. Secure-delete wipes the database pages; TRUNCATE proves those
420
2385
  // deletion frames have also been checkpointed before success is reported.
421
- this.#truncateWalForPrivacy();
2386
+ if (privacyChanged || result.applied)
2387
+ this.#truncateWalForPrivacy();
422
2388
  return result;
423
2389
  }
424
2390
  health() {
@@ -467,6 +2433,24 @@ export class PreferenceStore {
467
2433
  LIMIT ?
468
2434
  )
469
2435
  `).run(threshold, limit).changes);
2436
+ this.#database.prepare(`
2437
+ DELETE FROM preference_exposures
2438
+ WHERE idempotency_key IN (
2439
+ SELECT idempotency_key FROM preference_exposures
2440
+ WHERE expires_at < ?
2441
+ ORDER BY expires_at ASC, idempotency_key ASC
2442
+ LIMIT ?
2443
+ )
2444
+ `).run(this.#now(), limit);
2445
+ this.#database.prepare(`
2446
+ DELETE FROM preference_exposure_corrections
2447
+ WHERE NOT EXISTS (
2448
+ SELECT 1 FROM preference_exposures e
2449
+ WHERE e.hypothesis_id = preference_exposure_corrections.hypothesis_id
2450
+ AND e.hypothesis_version = preference_exposure_corrections.hypothesis_version
2451
+ AND e.reply_outbox_id = preference_exposure_corrections.reply_outbox_id
2452
+ )
2453
+ `).run();
470
2454
  this.#database.exec('COMMIT');
471
2455
  }
472
2456
  catch (error) {
@@ -479,6 +2463,83 @@ export class PreferenceStore {
479
2463
  this.#truncateWalForPrivacy();
480
2464
  return Object.freeze({ deletedSignals });
481
2465
  }
2466
+ /**
2467
+ * Delete at most one expired signal from one exact Host scope. The operation
2468
+ * receipt commits with the deletion, so a crash/retry cannot consume a second
2469
+ * row under the same fixed-run step identity.
2470
+ */
2471
+ maintainScopeOnce(scopeInput, operationIdInput, owner) {
2472
+ this.#assertOpen();
2473
+ const canonical = canonicalPreferenceScope(scopeInput);
2474
+ const operationId = boundedText(operationIdInput, 'operationId', 500);
2475
+ const idempotencyKey = `pref-host-maint-idem-${createHash('sha256')
2476
+ .update(`preference-host-maintenance-idempotency-v1\0${operationId}`).digest('hex')}`;
2477
+ const hash = payloadHash({
2478
+ scopeDigest: canonical.scopeDigest,
2479
+ idempotencyKey,
2480
+ ownerGeneration: owner.generation,
2481
+ principalLineageId: owner.principalLineageId,
2482
+ principalLineageVersion: owner.principalLineageVersion,
2483
+ });
2484
+ const now = this.#now();
2485
+ const threshold = Math.max(0, now - this.#options.signalTtlMs);
2486
+ let result;
2487
+ this.#database.exec('BEGIN IMMEDIATE');
2488
+ try {
2489
+ this.#assertScopePrincipalInTransaction(canonical, owner);
2490
+ const prior = this.#database.prepare(`
2491
+ SELECT payload_hash, scope_digest, deleted_signals, owner_generation,
2492
+ principal_lineage_id, principal_lineage_version
2493
+ FROM preference_host_maintenance_receipts WHERE idempotency_key = ?
2494
+ `).get(idempotencyKey);
2495
+ if (prior !== undefined) {
2496
+ if (prior.payload_hash !== hash || prior.scope_digest !== canonical.scopeDigest
2497
+ || prior.owner_generation !== owner.generation
2498
+ || prior.principal_lineage_id !== owner.principalLineageId
2499
+ || prior.principal_lineage_version !== owner.principalLineageVersion) {
2500
+ throw new PreferenceStoreError('idempotency-conflict', 'Host maintenance operation identity was reused for another scope');
2501
+ }
2502
+ result = Object.freeze({
2503
+ deletedSignals: prior.deleted_signals,
2504
+ replayed: true,
2505
+ ownerGeneration: owner.generation,
2506
+ principalLineageId: owner.principalLineageId,
2507
+ principalLineageVersion: owner.principalLineageVersion,
2508
+ });
2509
+ }
2510
+ else {
2511
+ const deletedSignals = Number(this.#database.prepare(`
2512
+ DELETE FROM preference_signals
2513
+ WHERE id IN (
2514
+ SELECT id FROM preference_signals
2515
+ WHERE scope_key = ? AND occurred_at < ?
2516
+ ORDER BY occurred_at ASC, id ASC
2517
+ LIMIT 1
2518
+ )
2519
+ `).run(canonical.scopeKey, threshold).changes);
2520
+ this.#database.prepare(`
2521
+ INSERT INTO preference_host_maintenance_receipts(
2522
+ idempotency_key, payload_hash, scope_digest, deleted_signals, occurred_at,
2523
+ owner_generation, principal_lineage_id, principal_lineage_version
2524
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2525
+ `).run(idempotencyKey, hash, canonical.scopeDigest, deletedSignals, now, owner.generation, owner.principalLineageId, owner.principalLineageVersion);
2526
+ result = Object.freeze({
2527
+ deletedSignals,
2528
+ replayed: false,
2529
+ ownerGeneration: owner.generation,
2530
+ principalLineageId: owner.principalLineageId,
2531
+ principalLineageVersion: owner.principalLineageVersion,
2532
+ });
2533
+ }
2534
+ this.#database.exec('COMMIT');
2535
+ }
2536
+ catch (error) {
2537
+ this.#database.exec('ROLLBACK');
2538
+ throw error;
2539
+ }
2540
+ this.#truncateWalForPrivacy();
2541
+ return result;
2542
+ }
482
2543
  close() {
483
2544
  if (this.#closed)
484
2545
  return;
@@ -545,7 +2606,7 @@ export class PreferenceStore {
545
2606
  for (const { preference_key: key } of keys)
546
2607
  this.#reconcileKey(scopeKey, scope, key, now);
547
2608
  }
548
- #reconcileKey(scopeKey, scope, key, now, triggerSignal) {
2609
+ #reconcileKey(scopeKey, scope, key, now, triggerSignal, allowGlobalOwnerCorrection = true) {
549
2610
  const signals = this.#database.prepare(`
550
2611
  SELECT * FROM preference_signals
551
2612
  WHERE scope_key = ? AND preference_key = ? AND occurred_at >= ?
@@ -620,11 +2681,20 @@ export class PreferenceStore {
620
2681
  let rolledBackAt = prior.rolled_back_at;
621
2682
  let version = prior.version;
622
2683
  let transitionReason;
623
- const ownerCorrection = triggerSignal !== undefined
2684
+ const ownerCorrection = allowGlobalOwnerCorrection && triggerSignal !== undefined
624
2685
  && latestSelection?.id === triggerSignal.id
625
2686
  && isActivationEligibleSignal(triggerSignal)
2687
+ && triggerSignal.interpretation_trust !== 'behavioral-inference'
626
2688
  && ((triggerSignal.stance === 'support' && triggerSignal.candidate_value !== candidate)
627
2689
  || (triggerSignal.stance === 'contradict' && triggerSignal.candidate_value === candidate));
2690
+ const behavioralCorrection = triggerSignal !== undefined
2691
+ && latestSelection?.id === triggerSignal.id
2692
+ && triggerSignal.interpretation_trust === 'behavioral-inference'
2693
+ && triggerSignal.source === 'delivery-observation'
2694
+ && triggerSignal.stance === 'support'
2695
+ && triggerSignal.candidate_value !== candidate
2696
+ && this.#behavioralSupportingCount(scopeKey, key, triggerSignal.candidate_value, now) >= this.#options.minBehavioralSignalsForActivation
2697
+ && evidence.contradictionBps > this.#options.maxContradictionBps;
628
2698
  const ownerReselected = triggerSignal !== undefined
629
2699
  && latestSelection?.id === triggerSignal.id
630
2700
  && isActivationEligibleSignal(triggerSignal)
@@ -643,6 +2713,13 @@ export class PreferenceStore {
643
2713
  version += 1;
644
2714
  transitionReason = 'owner-rejected';
645
2715
  }
2716
+ else if (effectState === 'active' && behavioralCorrection) {
2717
+ claimState = 'rejected';
2718
+ effectState = 'rolled-back';
2719
+ rolledBackAt = now;
2720
+ version += 1;
2721
+ transitionReason = 'contradicted';
2722
+ }
646
2723
  else if (entry.riskTier === 'T1'
647
2724
  && ['rejected', 'expired'].includes(claimState)
648
2725
  && ownerReselected
@@ -678,6 +2755,24 @@ export class PreferenceStore {
678
2755
  WHERE id = ?
679
2756
  `).run(claimState, effectState, evidence.confidenceBps, evidence.contradictionBps, evidence.supportingSignals, evidence.contradictingSignals, evidence.mass, expiresAt, activatedAt, rolledBackAt, version, now, prior.id);
680
2757
  this.#transition(prior, claimState, effectState, transitionReason, version, now);
2758
+ if (entry.riskTier === 'T2') {
2759
+ const promotions = this.#database.prepare(`
2760
+ SELECT * FROM preference_memory_promotions
2761
+ WHERE scope_key = ? AND hypothesis_id = ?
2762
+ AND state IN ('pending', 'submitted')
2763
+ `).all(scopeKey, prior.id);
2764
+ for (const promotion of promotions) {
2765
+ if (claimState === 'expired') {
2766
+ this.#cancelPromotionInTransaction(promotion, 'superseded', now);
2767
+ }
2768
+ else if (candidate !== promotion.hypothesis_value
2769
+ || version !== promotion.hypothesis_version
2770
+ || evidence.confidenceBps < this.#options.minConfidenceBpsForPromotion
2771
+ || evidence.contradictionBps > this.#options.maxContradictionBpsForPromotion) {
2772
+ this.#cancelPromotionInTransaction(promotion, 'superseded', now);
2773
+ }
2774
+ }
2775
+ }
681
2776
  }
682
2777
  }
683
2778
  #evidenceForCandidate(signals, candidate, now) {
@@ -727,12 +2822,76 @@ export class PreferenceStore {
727
2822
  SELECT * FROM preference_signals
728
2823
  WHERE scope_key = ? AND preference_key = ? AND occurred_at >= ?
729
2824
  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
2825
+ AND ((interpretation_trust IN ('explicit-selection', 'typed-feedback')
2826
+ AND source IN ('direct-owner-feedback', 'signed-ui-feedback'))
2827
+ OR (interpretation_trust = 'behavioral-inference' AND source = 'delivery-observation'))
2828
+ ORDER BY CASE interpretation_trust
2829
+ WHEN 'explicit-selection' THEN 0
2830
+ WHEN 'typed-feedback' THEN 1
2831
+ ELSE 2
2832
+ END ASC, occurred_at DESC, recorded_at DESC, rowid DESC
733
2833
  LIMIT 1
734
2834
  `).get(scopeKey, key, Math.max(0, now - this.#options.signalTtlMs));
735
2835
  }
2836
+ /** Shared, transaction-local gate for both planning and activation. */
2837
+ #activationError(row, scopeKey, now) {
2838
+ if (row.risk_tier !== 'T1') {
2839
+ return new PreferenceStoreError('forbidden-tier', 'only Host-catalog T1 hypotheses can activate automatically');
2840
+ }
2841
+ if (row.claim_state !== 'tentative' || row.effect_state !== 'shadow') {
2842
+ return new PreferenceStoreError('not-ready', 'hypothesis is not in an activatable shadow state');
2843
+ }
2844
+ const latestSelection = this.#latestActivationEligibleSignal(scopeKey, row.preference_key, now);
2845
+ if (latestSelection?.stance !== 'support'
2846
+ || latestSelection.candidate_value !== row.candidate_value) {
2847
+ return new PreferenceStoreError('not-ready', 'hypothesis is not the owner\'s controlling selection');
2848
+ }
2849
+ const isExplicit = latestSelection.interpretation_trust === 'explicit-selection';
2850
+ const isBehavioral = latestSelection.interpretation_trust === 'behavioral-inference';
2851
+ const requiredSignals = isBehavioral
2852
+ ? this.#options.minBehavioralSignalsForActivation
2853
+ : this.#options.minSignalsForActivation;
2854
+ const supportingSignals = isBehavioral
2855
+ ? this.#behavioralSupportingCount(scopeKey, row.preference_key, row.candidate_value, now)
2856
+ : row.supporting_signals;
2857
+ if (row.expires_at <= now
2858
+ || (!isExplicit && supportingSignals < requiredSignals)
2859
+ || (!isExplicit && !isBehavioral && row.confidence_bps < this.#options.minConfidenceBps)
2860
+ || (!isExplicit && !isBehavioral && row.contradiction_bps > this.#options.maxContradictionBps)) {
2861
+ return new PreferenceStoreError('not-ready', 'hypothesis has insufficient current evidence');
2862
+ }
2863
+ const activeCount = this.#database.prepare(`
2864
+ SELECT COUNT(*) AS count FROM preference_hypotheses
2865
+ WHERE scope_key = ? AND effect_state = 'active'
2866
+ `).get(scopeKey).count;
2867
+ if (activeCount >= this.#options.maxActiveOverlays) {
2868
+ return new PreferenceStoreError('not-ready', 'scope reached the active preference hard cap');
2869
+ }
2870
+ const sameKey = this.#database.prepare(`
2871
+ SELECT id FROM preference_hypotheses
2872
+ WHERE scope_key = ? AND preference_key = ? AND effect_state = 'active'
2873
+ `).get(scopeKey, row.preference_key);
2874
+ if (sameKey !== undefined) {
2875
+ return new PreferenceStoreError('conflict', 'another value for this key is active');
2876
+ }
2877
+ return undefined;
2878
+ }
2879
+ #behavioralSupportingCount(scopeKey, key, candidateValue, now) {
2880
+ const rows = this.#database.prepare(`
2881
+ SELECT candidate_value, stance FROM preference_signals
2882
+ WHERE scope_key = ? AND preference_key = ? AND actor_trust = 'owner-authenticated'
2883
+ AND interpretation_trust = 'behavioral-inference'
2884
+ AND source = 'delivery-observation' AND occurred_at >= ?
2885
+ ORDER BY occurred_at DESC, recorded_at DESC, rowid DESC
2886
+ `).all(scopeKey, key, Math.max(0, now - this.#options.signalTtlMs));
2887
+ let streak = 0;
2888
+ for (const row of rows) {
2889
+ if (row.stance !== 'support' || row.candidate_value !== candidateValue)
2890
+ break;
2891
+ streak += 1;
2892
+ }
2893
+ return streak;
2894
+ }
736
2895
  #transition(prior, claimState, effectState, reason, version, occurredAt) {
737
2896
  this.#database.prepare(`
738
2897
  INSERT INTO preference_transitions(
@@ -748,6 +2907,343 @@ export class PreferenceStore {
748
2907
  `).get(scopeDigest);
749
2908
  return row.forgotten_through ?? undefined;
750
2909
  }
2910
+ #promotionRow(promotionId, promotionGeneration) {
2911
+ const row = this.#database.prepare(`
2912
+ SELECT * FROM preference_memory_promotions
2913
+ WHERE promotion_id = ? AND promotion_generation = ?
2914
+ `).get(promotionId, promotionGeneration);
2915
+ if (row === undefined)
2916
+ throw new PreferenceStoreError('not-found', 'promotion was not found');
2917
+ return row;
2918
+ }
2919
+ #isPromotionTerminal(state) {
2920
+ return ['confirmed', 'rejected', 'expired', 'conflicted', 'stale-owner'].includes(state);
2921
+ }
2922
+ #recordPromotionCancellationInTransaction(row, reason, now) {
2923
+ const request = validatePreferenceMemoryPromotionCancellationRequest(withPreferenceMemoryPromotionCancellationDigest({
2924
+ contractVersion: 1,
2925
+ promotionId: row.promotion_id,
2926
+ promotionGeneration: row.promotion_generation,
2927
+ requestDigest: row.request_digest,
2928
+ principalLineage: {
2929
+ principalRecordId: row.principal_lineage_id,
2930
+ principalVersion: row.principal_lineage_version,
2931
+ },
2932
+ ownerGeneration: row.owner_generation,
2933
+ reason,
2934
+ occurredAt: now,
2935
+ }));
2936
+ const upgradeBindingDigest = this.#cancellationUpgradeBindingDigest(request);
2937
+ const existing = this.#database.prepare(`
2938
+ SELECT * FROM preference_memory_promotion_cancellations
2939
+ WHERE promotion_id = ? AND promotion_generation = ?
2940
+ `).get(request.promotionId, request.promotionGeneration);
2941
+ if (existing !== undefined) {
2942
+ if (existing.upgrade_binding_digest !== upgradeBindingDigest) {
2943
+ throw new PreferenceStoreError('idempotency-conflict', 'promotion cancellation binding changed');
2944
+ }
2945
+ if (existing.reason === 'superseded' && request.reason !== 'superseded') {
2946
+ this.#database.prepare(`
2947
+ UPDATE preference_memory_promotion_cancellations SET
2948
+ principal_lineage_id = ?, principal_lineage_version = ?, owner_generation = ?,
2949
+ reason = ?, cancelled_at = ?, cancellation_digest = ?, state = 'pending',
2950
+ attempt_count = 0, next_attempt_at = ?, receipt_digest = NULL,
2951
+ last_error = NULL, updated_at = ?
2952
+ WHERE promotion_id = ? AND promotion_generation = ?
2953
+ AND reason = 'superseded' AND upgrade_binding_digest = ?
2954
+ `).run(request.principalLineage.principalRecordId, request.principalLineage.principalVersion, request.ownerGeneration, request.reason, request.occurredAt, request.cancellationDigest, now, now, request.promotionId, request.promotionGeneration, upgradeBindingDigest);
2955
+ }
2956
+ return;
2957
+ }
2958
+ this.#database.prepare(`
2959
+ INSERT INTO preference_memory_promotion_cancellations(
2960
+ promotion_id, promotion_generation, request_digest, principal_lineage_id,
2961
+ principal_lineage_version, owner_generation, reason, cancelled_at,
2962
+ cancellation_digest, state, attempt_count, next_attempt_at,
2963
+ upgrade_binding_digest, updated_at
2964
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)
2965
+ `).run(request.promotionId, request.promotionGeneration, request.requestDigest, request.principalLineage.principalRecordId, request.principalLineage.principalVersion, request.ownerGeneration, request.reason, request.occurredAt, request.cancellationDigest, now, upgradeBindingDigest, now);
2966
+ }
2967
+ #cancellationUpgradeBindingDigest(request) {
2968
+ return preferencePromotionCancellationUpgradeBindingDigest({
2969
+ promotionId: request.promotionId,
2970
+ promotionGeneration: request.promotionGeneration,
2971
+ requestDigest: request.requestDigest,
2972
+ principalLineageId: request.principalLineage.principalRecordId,
2973
+ principalLineageVersion: request.principalLineage.principalVersion,
2974
+ ownerGeneration: request.ownerGeneration,
2975
+ });
2976
+ }
2977
+ #cancelPromotionInTransaction(row, reason, now) {
2978
+ this.#recordPromotionCancellationInTransaction(row, reason, now);
2979
+ if (row.state === 'cancelled')
2980
+ return;
2981
+ if (this.#isPromotionTerminal(row.state))
2982
+ return;
2983
+ this.#database.prepare(`
2984
+ UPDATE preference_memory_promotions SET state = 'cancelled', updated_at = ?
2985
+ WHERE promotion_id = ? AND promotion_generation = ?
2986
+ AND state IN ('pending', 'submitted')
2987
+ `).run(now, row.promotion_id, row.promotion_generation);
2988
+ this.#database.prepare(`
2989
+ UPDATE preference_memory_promotion_outbox SET state = 'cancelled', updated_at = ?
2990
+ WHERE promotion_id = ? AND promotion_generation = ?
2991
+ AND state IN ('pending', 'retry_wait', 'submitted')
2992
+ `).run(now, row.promotion_id, row.promotion_generation);
2993
+ }
2994
+ #cancelScopePromotionsInTransaction(scopeKey, reason, now) {
2995
+ const rows = this.#database.prepare(`
2996
+ SELECT * FROM preference_memory_promotions
2997
+ WHERE scope_key = ? AND state IN ('pending', 'submitted', 'confirmed', 'cancelled')
2998
+ `).all(scopeKey);
2999
+ for (const row of rows)
3000
+ this.#cancelPromotionInTransaction(row, reason, now);
3001
+ }
3002
+ #purgeScopePromotionDetailsInTransaction(scopeKey) {
3003
+ this.#database.prepare(`
3004
+ DELETE FROM preference_memory_promotion_results
3005
+ WHERE (promotion_id, promotion_generation) IN (
3006
+ SELECT promotion_id, promotion_generation FROM preference_memory_promotions
3007
+ WHERE scope_key = ?
3008
+ )
3009
+ `).run(scopeKey);
3010
+ this.#database.prepare(`
3011
+ DELETE FROM preference_memory_promotion_outbox
3012
+ WHERE (promotion_id, promotion_generation) IN (
3013
+ SELECT promotion_id, promotion_generation FROM preference_memory_promotions
3014
+ WHERE scope_key = ?
3015
+ )
3016
+ `).run(scopeKey);
3017
+ this.#database.prepare('DELETE FROM preference_memory_promotions WHERE scope_key = ?').run(scopeKey);
3018
+ }
3019
+ #deleteScopeDataInTransaction(scopeKey, scopeDigest) {
3020
+ this.#cancelScopePromotionsInTransaction(scopeKey, 'owner-rotated', this.#now());
3021
+ // Request rows include private scope/principal material. Keep only opaque
3022
+ // cancellation tombstones so delayed Memory receipts can be ACK-cancelled.
3023
+ this.#purgeScopePromotionDetailsInTransaction(scopeKey);
3024
+ this.#database.prepare('DELETE FROM preference_owner_control_receipts WHERE scope_digest = ?').run(scopeDigest);
3025
+ this.#database.prepare('DELETE FROM preference_host_activation_receipts WHERE scope_digest = ?').run(scopeDigest);
3026
+ this.#database.prepare('DELETE FROM preference_host_maintenance_receipts WHERE scope_digest = ?').run(scopeDigest);
3027
+ this.#database.prepare(`
3028
+ DELETE FROM preference_exposure_corrections
3029
+ WHERE signal_id IN (SELECT id FROM preference_signals WHERE scope_key = ?)
3030
+ OR hypothesis_id IN (SELECT id FROM preference_hypotheses WHERE scope_key = ?)
3031
+ `).run(scopeKey, scopeKey);
3032
+ this.#database.prepare('DELETE FROM preference_exposures WHERE scope_key = ?').run(scopeKey);
3033
+ this.#database.prepare(`
3034
+ DELETE FROM preference_transitions
3035
+ WHERE hypothesis_id IN (SELECT id FROM preference_hypotheses WHERE scope_key = ?)
3036
+ `).run(scopeKey);
3037
+ this.#database.prepare('DELETE FROM preference_hypotheses WHERE scope_key = ?').run(scopeKey);
3038
+ this.#database.prepare('DELETE FROM preference_signals WHERE scope_key = ?').run(scopeKey);
3039
+ // Forget boundaries belong to the previous owner lineage. Retaining them
3040
+ // would reject the new owner's exact first Inbox event as "predating" its
3041
+ // own scope reset.
3042
+ this.#database.prepare('DELETE FROM preference_scope_tombstones WHERE scope_digest = ?').run(scopeDigest);
3043
+ }
3044
+ #scopeAdmissionRowInTransaction(scopeKey) {
3045
+ return this.#database.prepare(`
3046
+ SELECT admission_cursor_epoch, lineage_claim_sequence,
3047
+ admission_high_water, admission_high_water_kind,
3048
+ ignore_events_through_sequence, learning_paused, control_version
3049
+ FROM preference_scope_principals WHERE scope_key = ?
3050
+ `).get(scopeKey);
3051
+ }
3052
+ #controlCursorIsFresh(current, admissionCursor) {
3053
+ return current.admission_cursor_epoch === admissionCursor.epoch
3054
+ && admissionCursor.sequence >= (current.lineage_claim_sequence ?? 1)
3055
+ && admissionCursor.sequence > (current.admission_high_water ?? 0);
3056
+ }
3057
+ #readCursorCanObserveCurrent(current, admissionCursor) {
3058
+ return current.admission_cursor_epoch === admissionCursor.epoch
3059
+ && admissionCursor.sequence >= (current.lineage_claim_sequence ?? 1)
3060
+ && admissionCursor.sequence > (current.ignore_events_through_sequence ?? 0);
3061
+ }
3062
+ #admitEventCursorInTransaction(canonical, owner, admissionCursor) {
3063
+ this.#assertScopePrincipalInTransaction(canonical, owner);
3064
+ const current = this.#scopeAdmissionRowInTransaction(canonical.scopeKey);
3065
+ if (current.admission_cursor_epoch !== admissionCursor.epoch
3066
+ || admissionCursor.sequence < (current.lineage_claim_sequence ?? 1)
3067
+ || admissionCursor.sequence < (current.admission_high_water ?? 0)
3068
+ || (admissionCursor.sequence === current.admission_high_water
3069
+ && current.admission_high_water_kind !== 'event'))
3070
+ return false;
3071
+ if (admissionCursor.sequence > (current.admission_high_water ?? 0)) {
3072
+ const changed = this.#database.prepare(`
3073
+ UPDATE preference_scope_principals
3074
+ SET admission_high_water = ?, admission_high_water_kind = 'event', updated_at = ?
3075
+ WHERE scope_key = ? AND principal_digest = ?
3076
+ AND principal_lineage_id = ? AND principal_lineage_version = ?
3077
+ AND generation = ? AND purge_pending = 0
3078
+ AND admission_cursor_epoch = ? AND admission_high_water IS ?
3079
+ `).run(admissionCursor.sequence, this.#now(), canonical.scopeKey, owner.principalDigest, owner.principalLineageId, owner.principalLineageVersion, owner.generation, admissionCursor.epoch, current.admission_high_water);
3080
+ if (changed.changes !== 1) {
3081
+ throw new PreferenceStoreError('conflict', 'preference event admission cursor changed');
3082
+ }
3083
+ }
3084
+ return current.learning_paused === 0
3085
+ && admissionCursor.sequence > (current.ignore_events_through_sequence ?? 0);
3086
+ }
3087
+ #insertOwnerControlReceipt(input) {
3088
+ this.#database.prepare(`
3089
+ INSERT INTO preference_owner_control_receipts(
3090
+ idempotency_key, payload_hash, scope_digest, principal_digest, generation,
3091
+ action, target_preference_key, admission_cursor_epoch, admission_cursor_sequence, result_applied,
3092
+ result_paused, result_control_version, result_admission_high_water,
3093
+ result_ignore_events_through_sequence, result_signals, result_hypotheses,
3094
+ result_active_overlays, result_stored_active_overlays, result_shadow_hypotheses,
3095
+ result_deleted_signals, result_deleted_hypotheses,
3096
+ result_forgotten_through, result_explanation_json, result_rolled_back,
3097
+ result_rolled_back_version, occurred_at
3098
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3099
+ `).run(input.idempotencyKey, input.hash, input.canonical.scopeDigest, input.owner.principalDigest, input.owner.generation, input.action, input.preferenceKey ?? null, input.admissionCursor.epoch, input.admissionCursor.sequence, input.applied ? 1 : 0, input.state.mode === 'paused' ? 1 : 0, input.state.controlVersion, input.state.admissionHighWater?.sequence ?? null, input.state.ignoreEventsThrough?.sequence ?? null, input.state.signals, input.state.hypotheses, input.state.activeOverlays, input.state.storedActiveOverlays, input.state.shadowHypotheses, input.deletedSignals ?? 0, input.deletedHypotheses ?? 0, input.forgottenThrough ?? -1, input.explanation === undefined ? null : JSON.stringify(input.explanation), input.rolledBack === true ? 1 : 0, input.rolledBackVersion ?? null, input.occurredAt);
3100
+ }
3101
+ #ownerControlExplanationFromReceipt(receipt) {
3102
+ if (receipt.result_explanation_json === null) {
3103
+ throw new PreferenceStoreError('conflict', 'learning summary receipt is missing its snapshot');
3104
+ }
3105
+ let value;
3106
+ try {
3107
+ value = JSON.parse(receipt.result_explanation_json);
3108
+ }
3109
+ catch {
3110
+ throw new PreferenceStoreError('conflict', 'learning summary receipt snapshot is corrupt');
3111
+ }
3112
+ if (!Array.isArray(value)) {
3113
+ throw new PreferenceStoreError('conflict', 'learning summary receipt snapshot is corrupt');
3114
+ }
3115
+ const explanation = value.map(entry => {
3116
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
3117
+ throw new PreferenceStoreError('conflict', 'learning summary receipt snapshot is corrupt');
3118
+ }
3119
+ const row = entry;
3120
+ const key = exactT1PreferenceKey(row.key);
3121
+ try {
3122
+ catalogSelection(key, row.value);
3123
+ }
3124
+ catch {
3125
+ throw new PreferenceStoreError('conflict', 'learning summary receipt snapshot is corrupt');
3126
+ }
3127
+ if (!['active', 'inactive', 'rolled-back', 'shadow', 'suppressed'].includes(row.state ?? '')
3128
+ || !Number.isSafeInteger(row.version) || row.version < 1
3129
+ || [row.supportingSignals, row.contradictingSignals, row.evidenceMass]
3130
+ .some(count => !Number.isSafeInteger(count) || count < 0)) {
3131
+ throw new PreferenceStoreError('conflict', 'learning summary receipt snapshot is corrupt');
3132
+ }
3133
+ return Object.freeze({
3134
+ key,
3135
+ value: row.value,
3136
+ state: row.state,
3137
+ version: row.version,
3138
+ supportingSignals: row.supportingSignals,
3139
+ contradictingSignals: row.contradictingSignals,
3140
+ evidenceMass: row.evidenceMass,
3141
+ });
3142
+ });
3143
+ return Object.freeze(explanation);
3144
+ }
3145
+ #ownerControlStateFromReceipt(receipt) {
3146
+ return Object.freeze({
3147
+ mode: receipt.result_paused === 1 ? 'paused' : 'active',
3148
+ signals: receipt.result_signals,
3149
+ hypotheses: receipt.result_hypotheses,
3150
+ activeOverlays: receipt.result_active_overlays,
3151
+ storedActiveOverlays: receipt.result_stored_active_overlays,
3152
+ shadowHypotheses: receipt.result_shadow_hypotheses,
3153
+ controlVersion: receipt.result_control_version,
3154
+ admissionHighWater: receipt.result_admission_high_water === null
3155
+ ? undefined
3156
+ : Object.freeze({
3157
+ epoch: receipt.admission_cursor_epoch,
3158
+ sequence: receipt.result_admission_high_water,
3159
+ }),
3160
+ ignoreEventsThrough: receipt.result_ignore_events_through_sequence === null
3161
+ ? undefined
3162
+ : Object.freeze({
3163
+ epoch: receipt.admission_cursor_epoch,
3164
+ sequence: receipt.result_ignore_events_through_sequence,
3165
+ }),
3166
+ });
3167
+ }
3168
+ #assertScopePrincipalInTransaction(canonical, owner) {
3169
+ if (owner === undefined)
3170
+ return;
3171
+ const principalDigest = typeof owner === 'string'
3172
+ ? preferencePrincipalDigest(owner)
3173
+ : owner.principalDigest;
3174
+ const generation = typeof owner === 'string' ? undefined : owner.generation;
3175
+ if (typeof owner !== 'string'
3176
+ && (owner.scopeKey !== canonical.scopeKey
3177
+ || !/^[a-f0-9]{64}$/u.test(owner.principalDigest)
3178
+ || typeof owner.principalLineageId !== 'string'
3179
+ || owner.principalLineageId.length < 1
3180
+ || Buffer.byteLength(owner.principalLineageId) > 500
3181
+ || !Number.isSafeInteger(owner.principalLineageVersion)
3182
+ || owner.principalLineageVersion < 1
3183
+ || !Number.isSafeInteger(owner.generation) || owner.generation < 1)) {
3184
+ throw new PreferenceStoreError('conflict', 'scope principal fence is invalid');
3185
+ }
3186
+ const row = this.#database.prepare(`
3187
+ SELECT generation, principal_lineage_id, principal_lineage_version,
3188
+ admission_cursor_epoch
3189
+ FROM preference_scope_principals
3190
+ WHERE scope_key = ? AND principal_digest = ? AND purge_pending = 0
3191
+ `).get(canonical.scopeKey, principalDigest);
3192
+ if (row === undefined || row.admission_cursor_epoch === null
3193
+ || (generation !== undefined && (row.generation !== generation
3194
+ || row.principal_lineage_id !== owner.principalLineageId
3195
+ || row.principal_lineage_version !== owner.principalLineageVersion))) {
3196
+ throw new PreferenceStoreError('conflict', 'scope principal fence changed');
3197
+ }
3198
+ }
3199
+ #scopeLearningStatusInTransaction(canonical, owner, refresh = true) {
3200
+ this.#assertScopePrincipalInTransaction(canonical, owner);
3201
+ if (refresh)
3202
+ this.#refreshScopeInTransaction(canonical.scopeKey, canonical.scope, this.#now());
3203
+ const control = this.#database.prepare(`
3204
+ SELECT learning_paused, control_version, admission_cursor_epoch,
3205
+ admission_high_water, ignore_events_through_sequence
3206
+ FROM preference_scope_principals WHERE scope_key = ?
3207
+ `).get(canonical.scopeKey);
3208
+ const counts = this.#database.prepare(`
3209
+ SELECT
3210
+ (SELECT COUNT(*) FROM preference_signals WHERE scope_key = ?) AS signals,
3211
+ COUNT(*) AS hypotheses,
3212
+ SUM(CASE WHEN effect_state = 'active' THEN 1 ELSE 0 END) AS active,
3213
+ SUM(CASE WHEN effect_state = 'shadow' THEN 1 ELSE 0 END) AS shadow
3214
+ FROM preference_hypotheses WHERE scope_key = ?
3215
+ `).get(canonical.scopeKey, canonical.scopeKey);
3216
+ return Object.freeze({
3217
+ mode: control.learning_paused === 1 ? 'paused' : 'active',
3218
+ signals: counts.signals,
3219
+ hypotheses: counts.hypotheses,
3220
+ activeOverlays: control.learning_paused === 1 ? 0 : (counts.active ?? 0),
3221
+ storedActiveOverlays: counts.active ?? 0,
3222
+ shadowHypotheses: counts.shadow ?? 0,
3223
+ controlVersion: control.control_version,
3224
+ admissionHighWater: control.admission_high_water === null
3225
+ ? undefined
3226
+ : Object.freeze({
3227
+ epoch: control.admission_cursor_epoch,
3228
+ sequence: control.admission_high_water,
3229
+ }),
3230
+ ignoreEventsThrough: control.ignore_events_through_sequence === null
3231
+ ? undefined
3232
+ : Object.freeze({
3233
+ epoch: control.admission_cursor_epoch,
3234
+ sequence: control.ignore_events_through_sequence,
3235
+ }),
3236
+ });
3237
+ }
3238
+ #assertScopeLearningActiveInTransaction(canonical) {
3239
+ const row = this.#database.prepare(`
3240
+ SELECT learning_paused FROM preference_scope_principals
3241
+ WHERE scope_key = ? AND purge_pending = 0 AND admission_cursor_epoch IS NOT NULL
3242
+ `).get(canonical.scopeKey);
3243
+ if (row?.learning_paused === 1) {
3244
+ throw new PreferenceStoreError('learning-paused', 'preference learning is paused for this scope');
3245
+ }
3246
+ }
751
3247
  #assertOpen() {
752
3248
  if (this.#closed)
753
3249
  throw new PreferenceStoreError('disabled', 'preference store is closed');