@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/README.md +50 -11
- package/cordis.patch.yml +3 -0
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +7 -6
- package/lib/catalog.js.map +1 -1
- package/lib/index.d.ts +4 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -2
- package/lib/index.js.map +1 -1
- package/lib/service.d.ts +104 -3
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +794 -29
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +11 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +643 -2
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +233 -8
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2581 -85
- package/lib/store.js.map +1 -1
- package/lib/types.d.ts +1 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/package.json +7 -5
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
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1497
|
+
const replayed = signalFromRow(replay);
|
|
1498
|
+
storedSignals.push(replayed);
|
|
1499
|
+
storedSignalsByIndex[index] = replayed;
|
|
1500
|
+
storedSignalRows[index] = replay;
|
|
178
1501
|
continue;
|
|
179
1502
|
}
|
|
180
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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,
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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');
|