@dsh-enhanced/personal-memory 0.1.7 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/promotion.d.ts +24 -0
- package/lib/promotion.d.ts.map +1 -0
- package/lib/promotion.js +147 -0
- package/lib/promotion.js.map +1 -0
- package/lib/proposals.d.ts +15 -2
- package/lib/proposals.d.ts.map +1 -1
- package/lib/proposals.js +109 -52
- package/lib/proposals.js.map +1 -1
- package/lib/service.d.ts +12 -0
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +263 -13
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +3 -3
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +316 -56
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +39 -6
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +789 -105
- package/lib/store.js.map +1 -1
- package/lib/types.d.ts +75 -1
- 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 +5 -4
package/lib/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { validatePreferenceMemoryPromotionCancellationRequest, withPreferenceMemoryPromotionCancellationReceiptDigest, withPreferenceMemoryPromotionResultDigest, } from '@dsh-enhanced/assistant-growth-contract';
|
|
3
4
|
import { APPROVAL_DISPLAY_BUDGET } from '@dsh-enhanced/assistant-policy';
|
|
4
5
|
import { MemoryDatabaseError, openMemoryDatabase } from './sqlite.js';
|
|
5
6
|
import { tokenizeMemory } from './tokenize.js';
|
|
@@ -11,7 +12,146 @@ export class MemoryStoreError extends Error {
|
|
|
11
12
|
this.name = 'MemoryStoreError';
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
const SHA_256 = /^[0-9a-f]{64}$/u;
|
|
16
|
+
function boundedText(value, label, maxBytes = 512) {
|
|
17
|
+
if (typeof value !== 'string' || value.normalize('NFC').trim() !== value || value === ''
|
|
18
|
+
|| Buffer.byteLength(value, 'utf8') > maxBytes) {
|
|
19
|
+
throw new MemoryStoreError('invalid-identity', `${label} is invalid`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export function memoryPrincipalDigest(principal) {
|
|
24
|
+
return createHash('sha256').update(boundedText(principal, 'memory principal', 4_096)).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
export function normalizeMemoryOwnerNamespace(namespace) {
|
|
27
|
+
if (typeof namespace !== 'object' || namespace === null || !SHA_256.test(namespace.principalDigest)) {
|
|
28
|
+
throw new MemoryStoreError('invalid-identity', 'memory owner namespace is invalid');
|
|
29
|
+
}
|
|
30
|
+
if (namespace.mode === 'delivery') {
|
|
31
|
+
if (!Number.isSafeInteger(namespace.principalVersion) || namespace.principalVersion < 1) {
|
|
32
|
+
throw new MemoryStoreError('invalid-identity', 'memory Delivery namespace version is invalid');
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
mode: 'delivery',
|
|
36
|
+
principalDigest: namespace.principalDigest,
|
|
37
|
+
principalRecordId: boundedText(namespace.principalRecordId, 'memory principal record id'),
|
|
38
|
+
principalVersion: namespace.principalVersion,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (namespace.mode === 'headless') {
|
|
42
|
+
if (!Number.isSafeInteger(namespace.lineageVersion) || namespace.lineageVersion < 1) {
|
|
43
|
+
throw new MemoryStoreError('invalid-identity', 'memory headless namespace version is invalid');
|
|
44
|
+
}
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
mode: 'headless',
|
|
47
|
+
principalDigest: namespace.principalDigest,
|
|
48
|
+
lineageId: boundedText(namespace.lineageId, 'memory headless lineage id'),
|
|
49
|
+
lineageVersion: namespace.lineageVersion,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
throw new MemoryStoreError('invalid-identity', 'memory owner namespace mode is invalid');
|
|
53
|
+
}
|
|
54
|
+
export function memoryOwnerNamespaceKey(namespace) {
|
|
55
|
+
const normalized = normalizeMemoryOwnerNamespace(namespace);
|
|
56
|
+
const durableIdentity = normalized.mode === 'delivery'
|
|
57
|
+
? {
|
|
58
|
+
mode: normalized.mode,
|
|
59
|
+
principalRecordId: normalized.principalRecordId,
|
|
60
|
+
principalVersion: normalized.principalVersion,
|
|
61
|
+
}
|
|
62
|
+
: {
|
|
63
|
+
mode: normalized.mode,
|
|
64
|
+
principalDigest: normalized.principalDigest,
|
|
65
|
+
lineageId: normalized.lineageId,
|
|
66
|
+
lineageVersion: normalized.lineageVersion,
|
|
67
|
+
};
|
|
68
|
+
return createHash('sha256').update(stableJson(durableIdentity)).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
function namespaceColumns(namespace) {
|
|
71
|
+
const normalized = normalizeMemoryOwnerNamespace(namespace);
|
|
72
|
+
return {
|
|
73
|
+
namespaceMode: normalized.mode,
|
|
74
|
+
namespaceKey: memoryOwnerNamespaceKey(normalized),
|
|
75
|
+
principalDigest: normalized.principalDigest,
|
|
76
|
+
principalRecordId: normalized.mode === 'delivery' ? normalized.principalRecordId : '',
|
|
77
|
+
principalVersion: normalized.mode === 'delivery' ? normalized.principalVersion : 0,
|
|
78
|
+
headlessLineageId: normalized.mode === 'headless' ? normalized.lineageId : '',
|
|
79
|
+
headlessLineageVersion: normalized.mode === 'headless' ? normalized.lineageVersion : 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function namespaceSqlValues(namespace) {
|
|
83
|
+
return [
|
|
84
|
+
namespace.namespaceMode,
|
|
85
|
+
namespace.namespaceKey,
|
|
86
|
+
namespace.principalDigest,
|
|
87
|
+
namespace.namespaceMode === 'delivery' ? namespace.principalRecordId : null,
|
|
88
|
+
namespace.namespaceMode === 'delivery' ? namespace.principalVersion : null,
|
|
89
|
+
namespace.namespaceMode === 'headless' ? namespace.headlessLineageId : null,
|
|
90
|
+
namespace.namespaceMode === 'headless' ? namespace.headlessLineageVersion : null,
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
function namespaceFromRow(row) {
|
|
94
|
+
if (row.namespace_mode === 'legacy-quarantine' || row.principal_digest === null) {
|
|
95
|
+
throw new MemoryStoreError('invalid-identity', 'legacy memory owner namespace is quarantined');
|
|
96
|
+
}
|
|
97
|
+
const namespace = row.namespace_mode === 'delivery'
|
|
98
|
+
? {
|
|
99
|
+
mode: 'delivery',
|
|
100
|
+
principalDigest: row.principal_digest,
|
|
101
|
+
principalRecordId: row.principal_record_id,
|
|
102
|
+
principalVersion: row.principal_version,
|
|
103
|
+
}
|
|
104
|
+
: {
|
|
105
|
+
mode: 'headless',
|
|
106
|
+
principalDigest: row.principal_digest,
|
|
107
|
+
lineageId: row.headless_lineage_id,
|
|
108
|
+
lineageVersion: row.headless_lineage_version,
|
|
109
|
+
};
|
|
110
|
+
const normalized = normalizeMemoryOwnerNamespace(namespace);
|
|
111
|
+
if (memoryOwnerNamespaceKey(normalized) !== row.namespace_key) {
|
|
112
|
+
throw new MemoryStoreError('invalid-identity', 'stored memory owner namespace digest is invalid');
|
|
113
|
+
}
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
function namespaceMatchesRow(row, namespace) {
|
|
117
|
+
return row.namespace_key === memoryOwnerNamespaceKey(namespace);
|
|
118
|
+
}
|
|
119
|
+
function stableValue(value) {
|
|
120
|
+
if (Array.isArray(value))
|
|
121
|
+
return value.map(stableValue);
|
|
122
|
+
if (typeof value !== 'object' || value === null)
|
|
123
|
+
return value;
|
|
124
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right, 'en'))
|
|
125
|
+
.map(([key, nested]) => [key, stableValue(nested)]));
|
|
126
|
+
}
|
|
127
|
+
function stableJson(value) {
|
|
128
|
+
return JSON.stringify(stableValue(value));
|
|
129
|
+
}
|
|
130
|
+
function validatePromotionReference(value) {
|
|
131
|
+
if (value === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (!Number.isSafeInteger(value.promotionGeneration) || value.promotionGeneration < 1
|
|
134
|
+
|| !Number.isSafeInteger(value.ownerGeneration) || value.ownerGeneration < 1
|
|
135
|
+
|| !SHA_256.test(value.requestDigest)
|
|
136
|
+
|| typeof value.scope !== 'object' || value.scope === null
|
|
137
|
+
|| !isAbsolute(value.scope.workspace)) {
|
|
138
|
+
throw new MemoryStoreError('invalid-entry', 'memory promotion reference is invalid');
|
|
139
|
+
}
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
promotionId: boundedText(value.promotionId, 'memory promotion id'),
|
|
142
|
+
promotionGeneration: value.promotionGeneration,
|
|
143
|
+
requestDigest: value.requestDigest,
|
|
144
|
+
ownerGeneration: value.ownerGeneration,
|
|
145
|
+
scope: Object.freeze({
|
|
146
|
+
workspace: value.scope.workspace,
|
|
147
|
+
preset: boundedText(value.scope.preset, 'memory promotion preset'),
|
|
148
|
+
}),
|
|
149
|
+
...(value.prePolicyStatus === undefined ? {} : {
|
|
150
|
+
prePolicyStatus: value.prePolicyStatus,
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function normalizeIdentity(identity, namespace) {
|
|
15
155
|
if (identity.owner !== 'user' && identity.owner !== 'agent') {
|
|
16
156
|
throw new MemoryStoreError('invalid-identity', 'memory owner must be user or agent');
|
|
17
157
|
}
|
|
@@ -35,6 +175,7 @@ function normalizeIdentity(identity) {
|
|
|
35
175
|
throw new MemoryStoreError('invalid-identity', 'user-owned memory must not carry an agent preset');
|
|
36
176
|
}
|
|
37
177
|
return {
|
|
178
|
+
...namespaceColumns(namespace),
|
|
38
179
|
owner: identity.owner,
|
|
39
180
|
scope: identity.scope,
|
|
40
181
|
workspace: identity.workspace ?? '',
|
|
@@ -51,7 +192,11 @@ function normalizeAgentContext(context) {
|
|
|
51
192
|
if (context.agentPreset.trim() === '') {
|
|
52
193
|
throw new MemoryStoreError('invalid-identity', 'memory search requires a non-empty agent preset');
|
|
53
194
|
}
|
|
54
|
-
return {
|
|
195
|
+
return {
|
|
196
|
+
workspace: context.workspace,
|
|
197
|
+
agentPreset: context.agentPreset,
|
|
198
|
+
namespace: normalizeMemoryOwnerNamespace(context.namespace),
|
|
199
|
+
};
|
|
55
200
|
}
|
|
56
201
|
function publicIdentity(identity) {
|
|
57
202
|
return {
|
|
@@ -81,16 +226,19 @@ function contentHash(content) {
|
|
|
81
226
|
export function hashMemoryMutation(mutation) {
|
|
82
227
|
return createHash('sha256').update(JSON.stringify(mutation)).digest('hex');
|
|
83
228
|
}
|
|
229
|
+
export function hashMemoryProposalIntent(input) {
|
|
230
|
+
return createHash('sha256').update(stableJson({
|
|
231
|
+
mutation: input.mutation,
|
|
232
|
+
namespace: normalizeMemoryOwnerNamespace(input.namespace),
|
|
233
|
+
promotion: validatePromotionReference(input.promotion) ?? null,
|
|
234
|
+
ttlMs: input.ttlMs,
|
|
235
|
+
notAfter: input.notAfter,
|
|
236
|
+
dispatch: input.dispatch ?? null,
|
|
237
|
+
})).digest('hex');
|
|
238
|
+
}
|
|
84
239
|
export function missingPolicyProposalId(proposalId, ttlMs, dispatch) {
|
|
85
|
-
const canonicalDispatch = dispatch
|
|
86
|
-
|
|
87
|
-
: {
|
|
88
|
-
sourceId: dispatch.sourceId,
|
|
89
|
-
bindingId: dispatch.bindingId,
|
|
90
|
-
workspace: dispatch.workspace,
|
|
91
|
-
principal: dispatch.principal,
|
|
92
|
-
};
|
|
93
|
-
const fingerprint = createHash('sha256').update(JSON.stringify({
|
|
240
|
+
const canonicalDispatch = dispatch ?? null;
|
|
241
|
+
const fingerprint = createHash('sha256').update(stableJson({
|
|
94
242
|
proposalId,
|
|
95
243
|
ttlMs,
|
|
96
244
|
dispatch: canonicalDispatch,
|
|
@@ -131,6 +279,20 @@ export class MemoryStore {
|
|
|
131
279
|
this.#closed = true;
|
|
132
280
|
this.#database.close();
|
|
133
281
|
}
|
|
282
|
+
resolveProposalNotAfter(namespace, idempotencyKey, ttlMs, requested) {
|
|
283
|
+
const key = memoryOwnerNamespaceKey(namespace);
|
|
284
|
+
const existing = this.#proposalByIdempotencyKey(key, idempotencyKey)
|
|
285
|
+
?? this.#proposalIntentByIdempotencyKey(key, idempotencyKey);
|
|
286
|
+
if (existing !== undefined)
|
|
287
|
+
return existing.not_after;
|
|
288
|
+
const now = this.#now();
|
|
289
|
+
const notAfter = requested ?? now + ttlMs;
|
|
290
|
+
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(ttlMs) || ttlMs <= 0
|
|
291
|
+
|| !Number.isSafeInteger(notAfter) || notAfter < 0 || notAfter > now + ttlMs) {
|
|
292
|
+
throw new MemoryStoreError('invalid-entry', 'proposal deadline is invalid');
|
|
293
|
+
}
|
|
294
|
+
return notAfter;
|
|
295
|
+
}
|
|
134
296
|
#transaction(operation) {
|
|
135
297
|
this.#database.exec('BEGIN IMMEDIATE');
|
|
136
298
|
try {
|
|
@@ -143,20 +305,20 @@ export class MemoryStore {
|
|
|
143
305
|
throw error;
|
|
144
306
|
}
|
|
145
307
|
}
|
|
146
|
-
get(identity, id) {
|
|
147
|
-
const columns = normalizeIdentity(identity);
|
|
308
|
+
get(namespace, identity, id) {
|
|
309
|
+
const columns = normalizeIdentity(identity, namespace);
|
|
148
310
|
const row = this.#selectRecord(columns, id);
|
|
149
311
|
if (row === undefined || row.status !== 'active' || this.#expired(row))
|
|
150
312
|
return undefined;
|
|
151
313
|
return this.#toRecord(row);
|
|
152
314
|
}
|
|
153
|
-
list(identity, options = {}) {
|
|
154
|
-
const columns = normalizeIdentity(identity);
|
|
315
|
+
list(namespace, identity, options = {}) {
|
|
316
|
+
const columns = normalizeIdentity(identity, namespace);
|
|
155
317
|
const rows = this.#database.prepare(`
|
|
156
318
|
SELECT * FROM memory_records
|
|
157
|
-
WHERE owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
319
|
+
WHERE namespace_key = ? AND owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
158
320
|
ORDER BY created_at ASC, id ASC
|
|
159
|
-
`).all(columns.owner, columns.scope, columns.workspace, columns.agentPreset);
|
|
321
|
+
`).all(columns.namespaceKey, columns.owner, columns.scope, columns.workspace, columns.agentPreset);
|
|
160
322
|
return rows
|
|
161
323
|
.filter(row => row.status === 'removed' ? options.includeRemoved === true : !this.#expired(row))
|
|
162
324
|
.map(row => this.#toRecord(row));
|
|
@@ -183,11 +345,14 @@ export class MemoryStore {
|
|
|
183
345
|
SUM(CASE WHEN status = 'removed' THEN 1 ELSE 0 END) AS removed_records,
|
|
184
346
|
SUM(CASE WHEN status = 'active' AND expires_at IS NOT NULL AND expires_at <= ? THEN 1 ELSE 0 END) AS expired_records
|
|
185
347
|
FROM memory_records
|
|
348
|
+
WHERE namespace_mode <> 'legacy-quarantine'
|
|
186
349
|
`).get(now, now);
|
|
187
350
|
const proposals = this.#database.prepare(`
|
|
188
351
|
SELECT
|
|
189
|
-
(SELECT COUNT(*) FROM memory_proposals
|
|
190
|
-
|
|
352
|
+
(SELECT COUNT(*) FROM memory_proposals
|
|
353
|
+
WHERE namespace_mode <> 'legacy-quarantine' AND status = 'pending')
|
|
354
|
+
+ (SELECT COUNT(*) FROM memory_proposal_intents
|
|
355
|
+
WHERE namespace_mode <> 'legacy-quarantine') AS count
|
|
191
356
|
`).get();
|
|
192
357
|
return {
|
|
193
358
|
activeRecords: row.active_records ?? 0,
|
|
@@ -316,9 +481,9 @@ export class MemoryStore {
|
|
|
316
481
|
records: Object.freeze(records),
|
|
317
482
|
});
|
|
318
483
|
}
|
|
319
|
-
normalizeMutation(mutation, options
|
|
484
|
+
normalizeMutation(mutation, options) {
|
|
320
485
|
const preflight = options.preflight !== false;
|
|
321
|
-
const columns = normalizeIdentity(mutation.identity);
|
|
486
|
+
const columns = normalizeIdentity(mutation.identity, options.namespace);
|
|
322
487
|
const identity = publicIdentity(columns);
|
|
323
488
|
if (mutation.op === 'add') {
|
|
324
489
|
const entry = this.#publicEntry(this.#validateEntry(mutation.entry));
|
|
@@ -360,10 +525,14 @@ export class MemoryStore {
|
|
|
360
525
|
}
|
|
361
526
|
prepareProposalIntent(input) {
|
|
362
527
|
const mutationJson = JSON.stringify(input.mutation);
|
|
528
|
+
const namespace = namespaceColumns(input.namespace);
|
|
529
|
+
const promotion = validatePromotionReference(input.promotion);
|
|
530
|
+
const promotionJson = promotion === undefined ? null : JSON.stringify(promotion);
|
|
531
|
+
const dispatchJson = input.dispatch === undefined ? null : stableJson(input.dispatch);
|
|
363
532
|
const diff = JSON.stringify(input.mutation, null, 2);
|
|
364
533
|
if (input.proposalId.trim() === '' || input.idempotencyKey.trim() === ''
|
|
365
534
|
|| input.requester.trim() === '' || input.principal.trim() === ''
|
|
366
|
-
||
|
|
535
|
+
|| input.mutationHash !== hashMemoryProposalIntent(input)
|
|
367
536
|
|| !Number.isSafeInteger(input.ttlMs) || input.ttlMs <= 0
|
|
368
537
|
|| Buffer.byteLength(`personal-memory:${input.idempotencyKey}`, 'utf8') > 512
|
|
369
538
|
|| Buffer.byteLength(input.requester, 'utf8') > 512
|
|
@@ -371,6 +540,9 @@ export class MemoryStore {
|
|
|
371
540
|
|| Buffer.byteLength(diff, 'utf8') > APPROVAL_DISPLAY_BUDGET.maxDiffBytes) {
|
|
372
541
|
throw new MemoryStoreError('invalid-entry', 'proposal intent fields are invalid');
|
|
373
542
|
}
|
|
543
|
+
if (input.namespace.principalDigest !== memoryPrincipalDigest(input.principal)) {
|
|
544
|
+
throw new MemoryStoreError('invalid-identity', 'proposal principal does not match its owner namespace');
|
|
545
|
+
}
|
|
374
546
|
if (input.dispatch !== undefined
|
|
375
547
|
&& (input.dispatch.sourceId.trim() === '' || input.dispatch.bindingId.trim() === ''
|
|
376
548
|
|| !isAbsolute(input.dispatch.workspace) || input.dispatch.principal !== input.principal
|
|
@@ -381,10 +553,20 @@ export class MemoryStore {
|
|
|
381
553
|
throw new MemoryStoreError('invalid-entry', 'proposal intent dispatch route is invalid');
|
|
382
554
|
}
|
|
383
555
|
return this.#transaction(() => {
|
|
556
|
+
if (promotion !== undefined) {
|
|
557
|
+
const cancellation = this.#promotionCancellation(promotion.promotionId, promotion.promotionGeneration);
|
|
558
|
+
if (cancellation !== undefined) {
|
|
559
|
+
this.#assertCancellationMatchesPromotion(cancellation, promotion, input.namespace);
|
|
560
|
+
return Object.freeze({
|
|
561
|
+
kind: 'cancelled',
|
|
562
|
+
receipt: this.#cancellationReceiptFromRow(cancellation, 'replayed'),
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
384
566
|
// The proposal and its creation intent live in separate tables. Resolve
|
|
385
567
|
// both under the same write lock so another process cannot attach a local
|
|
386
568
|
// proposal between an absence check and this intent insert.
|
|
387
|
-
const proposalByKey = this.#proposalByIdempotencyKey(input.idempotencyKey);
|
|
569
|
+
const proposalByKey = this.#proposalByIdempotencyKey(namespace.namespaceKey, input.idempotencyKey);
|
|
388
570
|
const proposalById = this.#proposal(input.proposalId);
|
|
389
571
|
if (proposalByKey !== undefined || proposalById !== undefined) {
|
|
390
572
|
const existing = proposalByKey ?? proposalById;
|
|
@@ -392,8 +574,9 @@ export class MemoryStore {
|
|
|
392
574
|
// possible aliases before returning/raising outside the transaction so
|
|
393
575
|
// poison work cannot permanently occupy the reconcile lane.
|
|
394
576
|
this.#database.prepare(`
|
|
395
|
-
DELETE FROM memory_proposal_intents
|
|
396
|
-
|
|
577
|
+
DELETE FROM memory_proposal_intents
|
|
578
|
+
WHERE id = ? OR (namespace_key = ? AND idempotency_key = ?)
|
|
579
|
+
`).run(input.proposalId, namespace.namespaceKey, input.idempotencyKey);
|
|
397
580
|
const sameRow = proposalByKey === undefined || proposalById === undefined
|
|
398
581
|
|| proposalByKey.id === proposalById.id;
|
|
399
582
|
if (!sameRow || !this.#proposalMatchesPrepareInput(existing, input, mutationJson)) {
|
|
@@ -401,7 +584,7 @@ export class MemoryStore {
|
|
|
401
584
|
}
|
|
402
585
|
return Object.freeze({ kind: 'proposal', proposal: this.#toProposal(existing) });
|
|
403
586
|
}
|
|
404
|
-
const intentByKey = this.#proposalIntentByIdempotencyKey(input.idempotencyKey);
|
|
587
|
+
const intentByKey = this.#proposalIntentByIdempotencyKey(namespace.namespaceKey, input.idempotencyKey);
|
|
405
588
|
const intentById = this.#proposalIntent(input.proposalId);
|
|
406
589
|
if (intentByKey !== undefined || intentById !== undefined) {
|
|
407
590
|
const existing = intentByKey ?? intentById;
|
|
@@ -418,21 +601,25 @@ export class MemoryStore {
|
|
|
418
601
|
}
|
|
419
602
|
// Keep the mutation preflight in the same transaction as the absence
|
|
420
603
|
// checks. Final application still repeats every CAS/duplicate check.
|
|
421
|
-
const preflight = this.normalizeMutation(input.mutation);
|
|
604
|
+
const preflight = this.normalizeMutation(input.mutation, { namespace: input.namespace });
|
|
422
605
|
if (JSON.stringify(preflight) !== mutationJson) {
|
|
423
606
|
throw new MemoryStoreError('invalid-entry', 'proposal mutation is not canonical');
|
|
424
607
|
}
|
|
425
608
|
const now = this.#now();
|
|
426
|
-
|
|
609
|
+
const notAfter = input.notAfter;
|
|
610
|
+
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(now + input.ttlMs)
|
|
611
|
+
|| !Number.isSafeInteger(notAfter) || notAfter < 0 || notAfter > now + input.ttlMs
|
|
612
|
+
|| (notAfter <= now && promotion?.prePolicyStatus === undefined)) {
|
|
427
613
|
throw new MemoryStoreError('invalid-entry', 'proposal intent deadline exceeds the safe timestamp range');
|
|
428
614
|
}
|
|
429
615
|
this.#database.prepare(`
|
|
430
616
|
INSERT INTO memory_proposal_intents(
|
|
431
|
-
id,
|
|
432
|
-
|
|
617
|
+
id, namespace_mode, namespace_key, principal_digest, principal_record_id, principal_version,
|
|
618
|
+
headless_lineage_id, headless_lineage_version, idempotency_key, requester,
|
|
619
|
+
principal, mutation_hash, mutation_json, promotion_json, ttl_ms, not_after, dispatch_json,
|
|
433
620
|
created_at, updated_at
|
|
434
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
435
|
-
`).run(input.proposalId, input.idempotencyKey, input.requester, input.principal, input.mutationHash, mutationJson,
|
|
621
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
622
|
+
`).run(input.proposalId, ...namespaceSqlValues(namespace), input.idempotencyKey, input.requester, input.principal, input.mutationHash, mutationJson, promotionJson, input.ttlMs, notAfter, dispatchJson, now, now);
|
|
436
623
|
return Object.freeze({
|
|
437
624
|
kind: 'intent',
|
|
438
625
|
intent: this.#toProposalIntent(this.#requiredProposalIntent(input.proposalId)),
|
|
@@ -447,7 +634,9 @@ export class MemoryStore {
|
|
|
447
634
|
listProposalIntents(limit) {
|
|
448
635
|
this.#validateProposalListLimit(limit);
|
|
449
636
|
const rows = this.#database.prepare(`
|
|
450
|
-
SELECT * FROM memory_proposal_intents
|
|
637
|
+
SELECT * FROM memory_proposal_intents
|
|
638
|
+
WHERE namespace_mode <> 'legacy-quarantine'
|
|
639
|
+
ORDER BY updated_at, id LIMIT ?
|
|
451
640
|
`).all(limit);
|
|
452
641
|
return rows.map(row => this.#toProposalIntent(row));
|
|
453
642
|
}
|
|
@@ -460,7 +649,7 @@ export class MemoryStore {
|
|
|
460
649
|
`).run(now, now, proposalId);
|
|
461
650
|
}
|
|
462
651
|
/** Fail closed after Policy atomically abandons or rejects exact recovery. */
|
|
463
|
-
conflictProposalIntent(proposalId) {
|
|
652
|
+
conflictProposalIntent(proposalId, promotion) {
|
|
464
653
|
return this.#transaction(() => {
|
|
465
654
|
const existing = this.#proposal(proposalId);
|
|
466
655
|
if (existing !== undefined) {
|
|
@@ -470,29 +659,44 @@ export class MemoryStore {
|
|
|
470
659
|
this.#database.prepare(`
|
|
471
660
|
DELETE FROM memory_proposal_intents WHERE id = ? AND idempotency_key = ?
|
|
472
661
|
`).run(existing.id, existing.idempotency_key);
|
|
473
|
-
|
|
662
|
+
const proposal = this.#toProposal(existing);
|
|
663
|
+
if (promotion !== undefined)
|
|
664
|
+
this.#enqueuePromotionResult(proposal, undefined, promotion);
|
|
665
|
+
return { proposal, replayed: true };
|
|
474
666
|
}
|
|
475
667
|
const intent = this.#requiredProposalIntent(proposalId);
|
|
476
|
-
const expiresAt = intent.
|
|
668
|
+
const expiresAt = intent.not_after;
|
|
477
669
|
if (!Number.isSafeInteger(expiresAt)) {
|
|
478
670
|
throw new MemoryStoreError('invalid-entry', 'proposal intent expiry exceeds the safe timestamp range');
|
|
479
671
|
}
|
|
480
|
-
|
|
672
|
+
const settled = this.#materializeProposalIntentConflict(intent, expiresAt, this.#now());
|
|
673
|
+
const terminal = promotion ?? (settled.proposal.promotion === undefined
|
|
674
|
+
? undefined
|
|
675
|
+
: settled.proposal.promotion);
|
|
676
|
+
if (terminal !== undefined)
|
|
677
|
+
this.#enqueuePromotionResult(settled.proposal, undefined, terminal);
|
|
678
|
+
return settled;
|
|
481
679
|
});
|
|
482
680
|
}
|
|
483
681
|
saveProposal(input) {
|
|
484
|
-
|
|
682
|
+
const namespace = namespaceColumns(input.namespace);
|
|
683
|
+
const promotion = validatePromotionReference(input.promotion);
|
|
684
|
+
if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.notAfter)
|
|
685
|
+
|| input.expiresAt > input.notAfter || !Number.isSafeInteger(input.version) || input.version <= 0) {
|
|
485
686
|
throw new MemoryStoreError('invalid-entry', 'proposal expiry and version must be safe integers');
|
|
486
687
|
}
|
|
487
688
|
return this.#transaction(() => {
|
|
488
|
-
const existing = this.#proposalByIdempotencyKey(input.idempotencyKey);
|
|
689
|
+
const existing = this.#proposalByIdempotencyKey(namespace.namespaceKey, input.idempotencyKey);
|
|
489
690
|
if (existing !== undefined) {
|
|
490
691
|
const same = existing.id === input.proposalId
|
|
491
692
|
&& existing.policy_proposal_id === input.policyProposalId
|
|
492
693
|
&& existing.requester === input.requester
|
|
493
694
|
&& existing.principal === input.principal
|
|
695
|
+
&& existing.namespace_key === namespace.namespaceKey
|
|
494
696
|
&& existing.mutation_hash === input.mutationHash
|
|
495
|
-
&& existing.
|
|
697
|
+
&& existing.not_after === input.notAfter
|
|
698
|
+
&& existing.expires_at === input.expiresAt
|
|
699
|
+
&& existing.promotion_json === (promotion === undefined ? null : JSON.stringify(promotion));
|
|
496
700
|
if (!same)
|
|
497
701
|
throw new MemoryStoreError('idempotency-conflict', 'proposal key was used for another mutation');
|
|
498
702
|
this.#database.prepare('DELETE FROM memory_proposal_intents WHERE id = ?').run(input.proposalId);
|
|
@@ -501,10 +705,12 @@ export class MemoryStore {
|
|
|
501
705
|
const now = this.#now();
|
|
502
706
|
this.#database.prepare(`
|
|
503
707
|
INSERT INTO memory_proposals(
|
|
504
|
-
id,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
708
|
+
id, namespace_mode, namespace_key, principal_digest, principal_record_id, principal_version,
|
|
709
|
+
headless_lineage_id, headless_lineage_version, policy_proposal_id, idempotency_key,
|
|
710
|
+
requester, principal, mutation_hash, mutation_json, promotion_json, status, not_after, expires_at,
|
|
711
|
+
created_at, updated_at, version
|
|
712
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
|
713
|
+
`).run(input.proposalId, ...namespaceSqlValues(namespace), input.policyProposalId, input.idempotencyKey, input.requester, input.principal, input.mutationHash, JSON.stringify(input.mutation), promotion === undefined ? null : JSON.stringify(promotion), input.notAfter, input.expiresAt, now, now, input.version);
|
|
508
714
|
this.#database.prepare('DELETE FROM memory_proposal_intents WHERE id = ?').run(input.proposalId);
|
|
509
715
|
return { proposal: this.#toProposal(this.#requiredProposal(input.proposalId)), replayed: false };
|
|
510
716
|
});
|
|
@@ -520,7 +726,8 @@ export class MemoryStore {
|
|
|
520
726
|
listPendingProposals(limit) {
|
|
521
727
|
this.#validateProposalListLimit(limit);
|
|
522
728
|
const rows = this.#database.prepare(`
|
|
523
|
-
SELECT * FROM memory_proposals
|
|
729
|
+
SELECT * FROM memory_proposals
|
|
730
|
+
WHERE namespace_mode <> 'legacy-quarantine' AND status = 'pending'
|
|
524
731
|
ORDER BY updated_at, id LIMIT ?
|
|
525
732
|
`).all(limit);
|
|
526
733
|
return rows.map(row => this.#toProposal(row));
|
|
@@ -533,14 +740,211 @@ export class MemoryStore {
|
|
|
533
740
|
WHERE id = ? AND status = 'pending'
|
|
534
741
|
`).run(now, now, proposalId);
|
|
535
742
|
}
|
|
743
|
+
listPendingPromotionResults(limit) {
|
|
744
|
+
this.#validateProposalListLimit(limit);
|
|
745
|
+
const rows = this.#database.prepare(`
|
|
746
|
+
SELECT * FROM memory_promotion_results
|
|
747
|
+
WHERE state = 'pending' ORDER BY updated_at, promotion_id, promotion_generation LIMIT ?
|
|
748
|
+
`).all(limit);
|
|
749
|
+
return rows.map(row => this.#toPromotionResult(row));
|
|
750
|
+
}
|
|
751
|
+
getPromotionResult(promotionId, promotionGeneration, resultVersion) {
|
|
752
|
+
if (!Number.isSafeInteger(promotionGeneration) || promotionGeneration < 1
|
|
753
|
+
|| !Number.isSafeInteger(resultVersion) || resultVersion < 1) {
|
|
754
|
+
throw new MemoryStoreError('invalid-entry', 'memory promotion result identity is invalid');
|
|
755
|
+
}
|
|
756
|
+
const row = this.#database.prepare(`
|
|
757
|
+
SELECT * FROM memory_promotion_results
|
|
758
|
+
WHERE promotion_id = ? AND promotion_generation = ? AND result_version = ?
|
|
759
|
+
`).get(boundedText(promotionId, 'memory promotion id'), promotionGeneration, resultVersion);
|
|
760
|
+
return row === undefined ? undefined : this.#toPromotionResult(row);
|
|
761
|
+
}
|
|
762
|
+
getProposalByPromotion(promotionId, promotionGeneration) {
|
|
763
|
+
if (!Number.isSafeInteger(promotionGeneration) || promotionGeneration < 1) {
|
|
764
|
+
throw new MemoryStoreError('invalid-entry', 'memory promotion identity is invalid');
|
|
765
|
+
}
|
|
766
|
+
const id = boundedText(promotionId, 'memory promotion id');
|
|
767
|
+
const match = (json) => {
|
|
768
|
+
if (json === null)
|
|
769
|
+
return undefined;
|
|
770
|
+
const reference = validatePromotionReference(JSON.parse(json));
|
|
771
|
+
return reference?.promotionId === id && reference.promotionGeneration === promotionGeneration
|
|
772
|
+
? reference
|
|
773
|
+
: undefined;
|
|
774
|
+
};
|
|
775
|
+
for (const row of this.#database.prepare(`
|
|
776
|
+
SELECT * FROM memory_proposals WHERE promotion_json IS NOT NULL ORDER BY updated_at, id
|
|
777
|
+
`).all()) {
|
|
778
|
+
const reference = match(row.promotion_json);
|
|
779
|
+
if (reference !== undefined)
|
|
780
|
+
return Object.freeze({
|
|
781
|
+
kind: 'proposal',
|
|
782
|
+
proposal: this.#toProposal(row),
|
|
783
|
+
requestDigest: reference.requestDigest,
|
|
784
|
+
namespace: namespaceFromRow(row),
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
for (const row of this.#database.prepare(`
|
|
788
|
+
SELECT * FROM memory_proposal_intents WHERE promotion_json IS NOT NULL ORDER BY updated_at, id
|
|
789
|
+
`).all()) {
|
|
790
|
+
const reference = match(row.promotion_json);
|
|
791
|
+
if (reference !== undefined)
|
|
792
|
+
return Object.freeze({
|
|
793
|
+
kind: 'intent',
|
|
794
|
+
proposal: this.#toProposalIntent(row),
|
|
795
|
+
requestDigest: reference.requestDigest,
|
|
796
|
+
namespace: namespaceFromRow(row),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
cancelPromotionBeforeOrAfterSubmit(input) {
|
|
802
|
+
const request = validatePreferenceMemoryPromotionCancellationRequest(input);
|
|
803
|
+
return this.#transaction(() => {
|
|
804
|
+
const existing = this.#promotionCancellation(request.promotionId, request.promotionGeneration);
|
|
805
|
+
if (existing !== undefined) {
|
|
806
|
+
const privacyEscalation = existing.reason === 'superseded' && request.reason !== 'superseded';
|
|
807
|
+
if (privacyEscalation) {
|
|
808
|
+
this.#assertCancellationTargetMatchesRequest(existing, request);
|
|
809
|
+
const result = this.#database.prepare(`
|
|
810
|
+
SELECT * FROM memory_promotion_results
|
|
811
|
+
WHERE promotion_id = ? AND promotion_generation = ?
|
|
812
|
+
`).get(request.promotionId, request.promotionGeneration);
|
|
813
|
+
if (result?.status === 'confirmed') {
|
|
814
|
+
this.#assertCancellationMatchesResult(request, result);
|
|
815
|
+
this.#compensateConfirmedPromotion(request, result);
|
|
816
|
+
}
|
|
817
|
+
const receipt = this.#cancellationReceipt(request, 'cancelled');
|
|
818
|
+
const now = this.#now();
|
|
819
|
+
this.#database.prepare(`
|
|
820
|
+
UPDATE memory_promotion_cancellations SET
|
|
821
|
+
cancellation_digest = ?, reason = ?, occurred_at = ?, receipt_digest = ?, updated_at = ?
|
|
822
|
+
WHERE promotion_id = ? AND promotion_generation = ? AND reason = 'superseded'
|
|
823
|
+
`).run(request.cancellationDigest, request.reason, request.occurredAt, receipt.receiptDigest, now, request.promotionId, request.promotionGeneration);
|
|
824
|
+
return Object.freeze({ outcome: 'cancelled', receipt });
|
|
825
|
+
}
|
|
826
|
+
this.#assertCancellationIdentityMatchesRequest(existing, request);
|
|
827
|
+
const validStoredReceipt = this.#validStoredCancellationReceipt(existing);
|
|
828
|
+
if (!validStoredReceipt) {
|
|
829
|
+
throw new MemoryStoreError('idempotency-conflict', 'promotion cancellation receipt changed');
|
|
830
|
+
}
|
|
831
|
+
const result = this.#database.prepare(`
|
|
832
|
+
SELECT * FROM memory_promotion_results
|
|
833
|
+
WHERE promotion_id = ? AND promotion_generation = ?
|
|
834
|
+
`).get(request.promotionId, request.promotionGeneration);
|
|
835
|
+
if (result?.status === 'confirmed' && request.reason !== 'superseded') {
|
|
836
|
+
this.#assertCancellationMatchesResult(request, result);
|
|
837
|
+
this.#compensateConfirmedPromotion(request, result);
|
|
838
|
+
}
|
|
839
|
+
const legacyPrivacyAlreadyConfirmed = request.reason !== 'superseded'
|
|
840
|
+
&& existing.receipt_digest === this.#cancellationReceiptFromRow(existing, 'already-confirmed').receiptDigest;
|
|
841
|
+
if (legacyPrivacyAlreadyConfirmed) {
|
|
842
|
+
if (result?.status !== 'confirmed') {
|
|
843
|
+
throw new MemoryStoreError('idempotency-conflict', 'legacy privacy cancellation lost its confirmed result');
|
|
844
|
+
}
|
|
845
|
+
const receipt = this.#cancellationReceipt(request, 'cancelled');
|
|
846
|
+
this.#database.prepare(`
|
|
847
|
+
UPDATE memory_promotion_cancellations SET receipt_digest = ?, updated_at = ?
|
|
848
|
+
WHERE promotion_id = ? AND promotion_generation = ? AND receipt_digest = ?
|
|
849
|
+
`).run(receipt.receiptDigest, this.#now(), request.promotionId, request.promotionGeneration, existing.receipt_digest);
|
|
850
|
+
return Object.freeze({ outcome: 'cancelled', receipt });
|
|
851
|
+
}
|
|
852
|
+
const receipt = this.#cancellationReceiptFromRow(existing, 'replayed');
|
|
853
|
+
return Object.freeze({ outcome: 'replayed', receipt });
|
|
854
|
+
}
|
|
855
|
+
const result = this.#database.prepare(`
|
|
856
|
+
SELECT * FROM memory_promotion_results
|
|
857
|
+
WHERE promotion_id = ? AND promotion_generation = ?
|
|
858
|
+
`).get(request.promotionId, request.promotionGeneration);
|
|
859
|
+
let outcome = 'cancelled';
|
|
860
|
+
if (result !== undefined) {
|
|
861
|
+
this.#assertCancellationMatchesResult(request, result);
|
|
862
|
+
if (result.status === 'confirmed') {
|
|
863
|
+
if (request.reason === 'superseded')
|
|
864
|
+
outcome = 'already-confirmed';
|
|
865
|
+
else
|
|
866
|
+
this.#compensateConfirmedPromotion(request, result);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const located = result?.status === 'confirmed'
|
|
870
|
+
? undefined
|
|
871
|
+
: this.getProposalByPromotion(request.promotionId, request.promotionGeneration);
|
|
872
|
+
if (located !== undefined) {
|
|
873
|
+
this.#assertCancellationMatchesProposal(request, located.proposal);
|
|
874
|
+
if (located.kind === 'proposal' && located.proposal.status === 'approved') {
|
|
875
|
+
throw new MemoryStoreError('idempotency-conflict', 'confirmed promotion lost its durable terminal result');
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
const terminal = located.kind === 'intent'
|
|
879
|
+
? this.#materializeProposalIntentConflict(this.#requiredProposalIntent(located.proposal.proposalId), located.proposal.notAfter, this.#now()).proposal
|
|
880
|
+
: this.#conflictPendingProposalInCurrentTransaction(located.proposal.proposalId);
|
|
881
|
+
if (terminal.promotion !== undefined) {
|
|
882
|
+
this.#enqueuePromotionResult(terminal, undefined, terminal.promotion);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const receipt = this.#cancellationReceipt(request, outcome);
|
|
887
|
+
const now = this.#now();
|
|
888
|
+
this.#database.prepare(`
|
|
889
|
+
INSERT INTO memory_promotion_cancellations(
|
|
890
|
+
promotion_id, promotion_generation, request_digest, principal_record_id, principal_version,
|
|
891
|
+
owner_generation, cancellation_digest, reason, occurred_at, receipt_digest, created_at, updated_at
|
|
892
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
893
|
+
`).run(request.promotionId, request.promotionGeneration, request.requestDigest, request.principalLineage.principalRecordId, request.principalLineage.principalVersion, request.ownerGeneration, request.cancellationDigest, request.reason, request.occurredAt, receipt.receiptDigest, now, now);
|
|
894
|
+
return Object.freeze({ outcome, receipt });
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
completePromotionResult(result) {
|
|
898
|
+
return this.#transaction(() => {
|
|
899
|
+
const current = this.#requiredExactPromotionResult(result);
|
|
900
|
+
if (current.state === 'completed')
|
|
901
|
+
return false;
|
|
902
|
+
const changed = this.#database.prepare(`
|
|
903
|
+
UPDATE memory_promotion_results SET state = 'completed', updated_at = ?
|
|
904
|
+
WHERE promotion_id = ? AND promotion_generation = ? AND result_version = ?
|
|
905
|
+
AND receipt_digest = ? AND state = 'pending' AND attempt_count = ?
|
|
906
|
+
`).run(this.#now(), result.promotionId, result.promotionGeneration, result.resultVersion, result.receiptDigest, result.attemptCount);
|
|
907
|
+
if (changed.changes !== 1)
|
|
908
|
+
throw new MemoryStoreError('version-conflict', 'promotion result changed');
|
|
909
|
+
return true;
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
deferPromotionResult(result, error) {
|
|
913
|
+
if (error !== undefined && Buffer.byteLength(error, 'utf8') > 2_048) {
|
|
914
|
+
throw new MemoryStoreError('invalid-entry', 'promotion delivery error is too large');
|
|
915
|
+
}
|
|
916
|
+
return this.#transaction(() => {
|
|
917
|
+
const current = this.#requiredExactPromotionResult(result);
|
|
918
|
+
if (current.state === 'completed')
|
|
919
|
+
return false;
|
|
920
|
+
const changed = this.#database.prepare(`
|
|
921
|
+
UPDATE memory_promotion_results SET attempt_count = attempt_count + 1, last_error = ?, updated_at = ?
|
|
922
|
+
WHERE promotion_id = ? AND promotion_generation = ? AND result_version = ?
|
|
923
|
+
AND receipt_digest = ? AND state = 'pending' AND attempt_count = ?
|
|
924
|
+
`).run(error ?? null, this.#now(), result.promotionId, result.promotionGeneration, result.resultVersion, result.receiptDigest, result.attemptCount);
|
|
925
|
+
if (changed.changes !== 1)
|
|
926
|
+
throw new MemoryStoreError('version-conflict', 'promotion result changed');
|
|
927
|
+
return true;
|
|
928
|
+
});
|
|
929
|
+
}
|
|
536
930
|
settleProposal(input) {
|
|
537
931
|
const existing = this.#requiredProposal(input.proposalId);
|
|
932
|
+
if (existing.namespace_mode === 'legacy-quarantine') {
|
|
933
|
+
throw new MemoryStoreError('invalid-identity', 'legacy memory proposal is quarantined');
|
|
934
|
+
}
|
|
935
|
+
const promotion = input.promotion
|
|
936
|
+
?? (existing.promotion_json === null ? undefined : {
|
|
937
|
+
...JSON.parse(existing.promotion_json),
|
|
938
|
+
});
|
|
538
939
|
if (existing.status !== 'pending') {
|
|
539
|
-
|
|
940
|
+
const settled = {
|
|
540
941
|
proposal: this.#toProposal(existing),
|
|
541
942
|
...this.#proposalRecord(existing),
|
|
542
943
|
replayed: true,
|
|
543
944
|
};
|
|
945
|
+
if (promotion !== undefined)
|
|
946
|
+
this.#transaction(() => this.#enqueuePromotionResult(settled.proposal, settled.record, promotion));
|
|
947
|
+
return settled;
|
|
544
948
|
}
|
|
545
949
|
if (input.policyStatus !== 'approved') {
|
|
546
950
|
return this.#transaction(() => {
|
|
@@ -558,8 +962,11 @@ export class MemoryStore {
|
|
|
558
962
|
UPDATE memory_proposals SET status = ?, updated_at = ?, version = ?
|
|
559
963
|
WHERE id = ? AND status = 'pending'
|
|
560
964
|
`).run(input.policyStatus, this.#now(), version, input.proposalId);
|
|
965
|
+
const proposal = this.#toProposal(this.#requiredProposal(input.proposalId));
|
|
966
|
+
if (promotion !== undefined)
|
|
967
|
+
this.#enqueuePromotionResult(proposal, undefined, promotion);
|
|
561
968
|
return {
|
|
562
|
-
proposal
|
|
969
|
+
proposal,
|
|
563
970
|
replayed: false,
|
|
564
971
|
};
|
|
565
972
|
});
|
|
@@ -577,14 +984,18 @@ export class MemoryStore {
|
|
|
577
984
|
const record = this.#applyMutationInCurrentTransaction({
|
|
578
985
|
...mutation,
|
|
579
986
|
idempotencyKey: `memory-proposal:${current.id}`,
|
|
987
|
+
namespace: namespaceFromRow(current),
|
|
580
988
|
});
|
|
581
989
|
this.#database.prepare(`
|
|
582
990
|
UPDATE memory_proposals
|
|
583
991
|
SET status = 'approved', result_memory_id = ?, updated_at = ?, version = ?
|
|
584
992
|
WHERE id = ? AND status = 'pending'
|
|
585
993
|
`).run(record.id, this.#now(), input.policyVersion, input.proposalId);
|
|
994
|
+
const proposal = this.#toProposal(this.#requiredProposal(input.proposalId));
|
|
995
|
+
if (promotion !== undefined)
|
|
996
|
+
this.#enqueuePromotionResult(proposal, record, promotion);
|
|
586
997
|
return {
|
|
587
|
-
proposal
|
|
998
|
+
proposal,
|
|
588
999
|
record,
|
|
589
1000
|
replayed: false,
|
|
590
1001
|
};
|
|
@@ -604,6 +1015,8 @@ export class MemoryStore {
|
|
|
604
1015
|
`).run(this.#now(), input.policyVersion, input.proposalId);
|
|
605
1016
|
}
|
|
606
1017
|
const conflicted = this.#requiredProposal(input.proposalId);
|
|
1018
|
+
if (promotion !== undefined)
|
|
1019
|
+
this.#enqueuePromotionResult(this.#toProposal(conflicted), undefined, promotion);
|
|
607
1020
|
return { proposal: this.#toProposal(conflicted), replayed: false };
|
|
608
1021
|
});
|
|
609
1022
|
}
|
|
@@ -612,11 +1025,12 @@ export class MemoryStore {
|
|
|
612
1025
|
return this.#transaction(() => this.#applyMutationInCurrentTransaction(mutation));
|
|
613
1026
|
}
|
|
614
1027
|
#applyMutationInCurrentTransaction(mutation) {
|
|
615
|
-
const identity = normalizeIdentity(mutation.identity);
|
|
1028
|
+
const identity = normalizeIdentity(mutation.identity, mutation.namespace);
|
|
616
1029
|
const hash = hashMemoryMutation(this.#withoutIdempotencyKey(mutation));
|
|
617
1030
|
const prior = this.#database.prepare(`
|
|
618
|
-
SELECT mutation_hash, memory_id FROM memory_audit
|
|
619
|
-
|
|
1031
|
+
SELECT mutation_hash, memory_id FROM memory_audit
|
|
1032
|
+
WHERE namespace_key = ? AND idempotency_key = ?
|
|
1033
|
+
`).get(identity.namespaceKey, mutation.idempotencyKey);
|
|
620
1034
|
if (prior !== undefined) {
|
|
621
1035
|
if (prior.mutation_hash !== hash) {
|
|
622
1036
|
throw new MemoryStoreError('idempotency-conflict', 'idempotency key was used for another mutation');
|
|
@@ -633,9 +1047,11 @@ export class MemoryStore {
|
|
|
633
1047
|
: this.#remove(identity, mutation.id, mutation.expectedVersion);
|
|
634
1048
|
this.#database.prepare(`
|
|
635
1049
|
INSERT INTO memory_audit(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1050
|
+
namespace_mode, namespace_key, principal_digest, principal_record_id, principal_version,
|
|
1051
|
+
headless_lineage_id, headless_lineage_version, idempotency_key, mutation_hash,
|
|
1052
|
+
operation, memory_id, result_version, occurred_at
|
|
1053
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1054
|
+
`).run(...namespaceSqlValues(identity), mutation.idempotencyKey, hash, mutation.op, result.id, result.version, this.#now());
|
|
639
1055
|
return result;
|
|
640
1056
|
}
|
|
641
1057
|
#add(identity, input) {
|
|
@@ -646,20 +1062,22 @@ export class MemoryStore {
|
|
|
646
1062
|
const now = this.#now();
|
|
647
1063
|
this.#database.prepare(`
|
|
648
1064
|
INSERT INTO memory_records(
|
|
649
|
-
id,
|
|
1065
|
+
id, namespace_mode, namespace_key, principal_digest, principal_record_id, principal_version,
|
|
1066
|
+
headless_lineage_id, headless_lineage_version,
|
|
1067
|
+
owner, scope, workspace, agent_preset, kind, content, content_hash,
|
|
650
1068
|
sensitivity, trust, confidence, provenance_json, supersedes, expires_at,
|
|
651
1069
|
status, created_at, updated_at, version
|
|
652
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1)
|
|
653
|
-
`).run(id, identity.owner, identity.scope, identity.workspace, identity.agentPreset, entry.kind, entry.content, entry.contentHash, entry.sensitivity, entry.trust, entry.confidence, JSON.stringify(entry.provenance), entry.supersedes ?? null, entry.expiresAt ?? null, now, now);
|
|
1070
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1)
|
|
1071
|
+
`).run(id, ...namespaceSqlValues(identity), identity.owner, identity.scope, identity.workspace, identity.agentPreset, entry.kind, entry.content, entry.contentHash, entry.sensitivity, entry.trust, entry.confidence, JSON.stringify(entry.provenance), entry.supersedes ?? null, entry.expiresAt ?? null, now, now);
|
|
654
1072
|
this.#replaceTokens(id, entry.content);
|
|
655
1073
|
return this.#toRecord(this.#selectRecord(identity, id));
|
|
656
1074
|
}
|
|
657
1075
|
#assertRecordCapacity(identity) {
|
|
658
1076
|
const count = this.#database.prepare(`
|
|
659
1077
|
SELECT COUNT(*) AS count FROM memory_records
|
|
660
|
-
WHERE owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
1078
|
+
WHERE namespace_key = ? AND owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
661
1079
|
AND status = 'active' AND (expires_at IS NULL OR expires_at > ?)
|
|
662
|
-
`).get(identity.owner, identity.scope, identity.workspace, identity.agentPreset, this.#now());
|
|
1080
|
+
`).get(identity.namespaceKey, identity.owner, identity.scope, identity.workspace, identity.agentPreset, this.#now());
|
|
663
1081
|
if (count.count >= this.#maxRecordsPerIdentity) {
|
|
664
1082
|
throw new MemoryStoreError('record-limit', 'memory identity record limit is reached');
|
|
665
1083
|
}
|
|
@@ -733,12 +1151,12 @@ export class MemoryStore {
|
|
|
733
1151
|
#assertNoDuplicate(identity, hash, excludedId) {
|
|
734
1152
|
const duplicate = this.#database.prepare(`
|
|
735
1153
|
SELECT id FROM memory_records
|
|
736
|
-
WHERE owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
1154
|
+
WHERE namespace_key = ? AND owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
737
1155
|
AND content_hash = ? AND status = 'active'
|
|
738
1156
|
AND (expires_at IS NULL OR expires_at > ?)
|
|
739
1157
|
AND (? IS NULL OR id <> ?)
|
|
740
1158
|
LIMIT 1
|
|
741
|
-
`).get(identity.owner, identity.scope, identity.workspace, identity.agentPreset, hash, this.#now(), excludedId ?? null, excludedId ?? null);
|
|
1159
|
+
`).get(identity.namespaceKey, identity.owner, identity.scope, identity.workspace, identity.agentPreset, hash, this.#now(), excludedId ?? null, excludedId ?? null);
|
|
742
1160
|
if (duplicate !== undefined)
|
|
743
1161
|
throw new MemoryStoreError('duplicate-content', 'active memory content already exists');
|
|
744
1162
|
}
|
|
@@ -780,8 +1198,8 @@ export class MemoryStore {
|
|
|
780
1198
|
#selectRecord(identity, id) {
|
|
781
1199
|
return this.#database.prepare(`
|
|
782
1200
|
SELECT * FROM memory_records
|
|
783
|
-
WHERE id = ? AND owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
784
|
-
`).get(id, identity.owner, identity.scope, identity.workspace, identity.agentPreset);
|
|
1201
|
+
WHERE id = ? AND namespace_key = ? AND owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
1202
|
+
`).get(id, identity.namespaceKey, identity.owner, identity.scope, identity.workspace, identity.agentPreset);
|
|
785
1203
|
}
|
|
786
1204
|
#validateProposalListLimit(limit) {
|
|
787
1205
|
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
|
|
@@ -792,9 +1210,10 @@ export class MemoryStore {
|
|
|
792
1210
|
return this.#database.prepare('SELECT * FROM memory_proposal_intents WHERE id = ?')
|
|
793
1211
|
.get(id);
|
|
794
1212
|
}
|
|
795
|
-
#proposalIntentByIdempotencyKey(key) {
|
|
796
|
-
return this.#database.prepare(
|
|
797
|
-
|
|
1213
|
+
#proposalIntentByIdempotencyKey(namespaceKey, key) {
|
|
1214
|
+
return this.#database.prepare(`
|
|
1215
|
+
SELECT * FROM memory_proposal_intents WHERE namespace_key = ? AND idempotency_key = ?
|
|
1216
|
+
`).get(namespaceKey, key);
|
|
798
1217
|
}
|
|
799
1218
|
#requiredProposalIntent(id) {
|
|
800
1219
|
const intent = this.#proposalIntent(id);
|
|
@@ -803,72 +1222,70 @@ export class MemoryStore {
|
|
|
803
1222
|
return intent;
|
|
804
1223
|
}
|
|
805
1224
|
#materializeProposalIntentConflict(intent, expiresAt, now) {
|
|
806
|
-
const dispatch = intent.
|
|
1225
|
+
const dispatch = intent.dispatch_json === null
|
|
807
1226
|
? undefined
|
|
808
|
-
:
|
|
809
|
-
sourceId: intent.dispatch_source_id,
|
|
810
|
-
bindingId: intent.dispatch_binding_id,
|
|
811
|
-
workspace: intent.dispatch_workspace,
|
|
812
|
-
principal: intent.dispatch_principal,
|
|
813
|
-
};
|
|
1227
|
+
: JSON.parse(intent.dispatch_json);
|
|
814
1228
|
this.#database.prepare(`
|
|
815
1229
|
INSERT INTO memory_proposals(
|
|
816
|
-
id,
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1230
|
+
id, namespace_mode, namespace_key, principal_digest, principal_record_id, principal_version,
|
|
1231
|
+
headless_lineage_id, headless_lineage_version, policy_proposal_id, idempotency_key,
|
|
1232
|
+
requester, principal, mutation_hash, mutation_json, promotion_json, status, not_after, expires_at,
|
|
1233
|
+
created_at, updated_at, version
|
|
1234
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'conflicted', ?, ?, ?, ?, 2)
|
|
1235
|
+
`).run(intent.id, intent.namespace_mode, intent.namespace_key, intent.principal_digest, intent.principal_record_id, intent.principal_version, intent.headless_lineage_id, intent.headless_lineage_version, missingPolicyProposalId(intent.id, intent.ttl_ms, dispatch), intent.idempotency_key, intent.requester, intent.principal, intent.mutation_hash, intent.mutation_json, intent.promotion_json, intent.not_after, expiresAt, intent.created_at, now);
|
|
820
1236
|
this.#database.prepare('DELETE FROM memory_proposal_intents WHERE id = ?').run(intent.id);
|
|
821
1237
|
return { proposal: this.#toProposal(this.#requiredProposal(intent.id)), replayed: false };
|
|
822
1238
|
}
|
|
823
1239
|
#intentDispatchMatches(row, dispatch) {
|
|
824
|
-
|
|
825
|
-
return row.dispatch_source_id === null
|
|
826
|
-
&& row.dispatch_binding_id === null
|
|
827
|
-
&& row.dispatch_workspace === null
|
|
828
|
-
&& row.dispatch_principal === null;
|
|
829
|
-
return row.dispatch_source_id === dispatch.sourceId
|
|
830
|
-
&& row.dispatch_binding_id === dispatch.bindingId
|
|
831
|
-
&& row.dispatch_workspace === dispatch.workspace
|
|
832
|
-
&& row.dispatch_principal === dispatch.principal;
|
|
1240
|
+
return row.dispatch_json === (dispatch === undefined ? null : stableJson(dispatch));
|
|
833
1241
|
}
|
|
834
1242
|
#proposalMatchesPrepareInput(row, input, mutationJson) {
|
|
835
1243
|
return row.id === input.proposalId
|
|
836
1244
|
&& row.idempotency_key === input.idempotencyKey
|
|
837
1245
|
&& row.requester === input.requester
|
|
838
1246
|
&& row.principal === input.principal
|
|
1247
|
+
&& namespaceMatchesRow(row, input.namespace)
|
|
839
1248
|
&& row.mutation_json === mutationJson
|
|
840
|
-
&&
|
|
841
|
-
|
|
1249
|
+
&& row.mutation_hash === input.mutationHash
|
|
1250
|
+
&& row.not_after === input.notAfter
|
|
1251
|
+
&& row.promotion_json === (input.promotion === undefined
|
|
1252
|
+
? null
|
|
1253
|
+
: JSON.stringify(validatePromotionReference(input.promotion)));
|
|
842
1254
|
}
|
|
843
1255
|
#intentMatchesPrepareInput(row, input, mutationJson) {
|
|
844
1256
|
return row.id === input.proposalId
|
|
845
1257
|
&& row.idempotency_key === input.idempotencyKey
|
|
846
1258
|
&& row.requester === input.requester
|
|
847
1259
|
&& row.principal === input.principal
|
|
1260
|
+
&& namespaceMatchesRow(row, input.namespace)
|
|
848
1261
|
&& row.mutation_json === mutationJson
|
|
849
|
-
&&
|
|
850
|
-
|| row.mutation_hash === hashMemoryMutation(input.mutation))
|
|
1262
|
+
&& row.mutation_hash === input.mutationHash
|
|
851
1263
|
&& row.ttl_ms === input.ttlMs
|
|
1264
|
+
&& row.not_after === input.notAfter
|
|
1265
|
+
&& row.promotion_json === (input.promotion === undefined
|
|
1266
|
+
? null
|
|
1267
|
+
: JSON.stringify(validatePromotionReference(input.promotion)))
|
|
852
1268
|
&& this.#intentDispatchMatches(row, input.dispatch);
|
|
853
1269
|
}
|
|
854
1270
|
#toProposalIntent(row) {
|
|
855
|
-
const dispatch = row.
|
|
1271
|
+
const dispatch = row.dispatch_json === null
|
|
856
1272
|
? undefined
|
|
857
|
-
: Object.freeze(
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
principal: row.dispatch_principal,
|
|
862
|
-
});
|
|
1273
|
+
: Object.freeze(JSON.parse(row.dispatch_json));
|
|
1274
|
+
const promotion = row.promotion_json === null
|
|
1275
|
+
? undefined
|
|
1276
|
+
: Object.freeze(JSON.parse(row.promotion_json));
|
|
863
1277
|
return Object.freeze({
|
|
864
1278
|
proposalId: row.id,
|
|
865
1279
|
idempotencyKey: row.idempotency_key,
|
|
866
1280
|
requester: row.requester,
|
|
867
1281
|
principal: row.principal,
|
|
1282
|
+
namespace: namespaceFromRow(row),
|
|
868
1283
|
mutationHash: row.mutation_hash,
|
|
869
1284
|
mutation: Object.freeze(JSON.parse(row.mutation_json)),
|
|
870
1285
|
ttlMs: row.ttl_ms,
|
|
1286
|
+
notAfter: row.not_after,
|
|
871
1287
|
...(dispatch === undefined ? {} : { dispatch }),
|
|
1288
|
+
...(promotion === undefined ? {} : { promotion }),
|
|
872
1289
|
createdAt: row.created_at,
|
|
873
1290
|
updatedAt: row.updated_at,
|
|
874
1291
|
});
|
|
@@ -877,9 +1294,10 @@ export class MemoryStore {
|
|
|
877
1294
|
return this.#database.prepare('SELECT * FROM memory_proposals WHERE id = ?')
|
|
878
1295
|
.get(id);
|
|
879
1296
|
}
|
|
880
|
-
#proposalByIdempotencyKey(key) {
|
|
881
|
-
return this.#database.prepare(
|
|
882
|
-
|
|
1297
|
+
#proposalByIdempotencyKey(namespaceKey, key) {
|
|
1298
|
+
return this.#database.prepare(`
|
|
1299
|
+
SELECT * FROM memory_proposals WHERE namespace_key = ? AND idempotency_key = ?
|
|
1300
|
+
`).get(namespaceKey, key);
|
|
883
1301
|
}
|
|
884
1302
|
#requiredProposal(id) {
|
|
885
1303
|
const proposal = this.#proposal(id);
|
|
@@ -887,13 +1305,273 @@ export class MemoryStore {
|
|
|
887
1305
|
throw new MemoryStoreError('not-found', 'memory proposal was not found');
|
|
888
1306
|
return proposal;
|
|
889
1307
|
}
|
|
1308
|
+
#promotionCancellation(id, generation) {
|
|
1309
|
+
return this.#database.prepare(`
|
|
1310
|
+
SELECT * FROM memory_promotion_cancellations
|
|
1311
|
+
WHERE promotion_id = ? AND promotion_generation = ?
|
|
1312
|
+
`).get(id, generation);
|
|
1313
|
+
}
|
|
1314
|
+
#assertCancellationMatchesPromotion(cancellation, promotion, namespace) {
|
|
1315
|
+
if (namespace.mode !== 'delivery'
|
|
1316
|
+
|| cancellation.request_digest !== promotion.requestDigest
|
|
1317
|
+
|| cancellation.principal_record_id !== namespace.principalRecordId
|
|
1318
|
+
|| cancellation.principal_version !== namespace.principalVersion
|
|
1319
|
+
|| cancellation.owner_generation !== promotion.ownerGeneration) {
|
|
1320
|
+
throw new MemoryStoreError('idempotency-conflict', 'cancelled promotion identity changed');
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
#assertCancellationIdentityMatchesRequest(cancellation, request) {
|
|
1324
|
+
if (cancellation.request_digest !== request.requestDigest
|
|
1325
|
+
|| cancellation.principal_record_id !== request.principalLineage.principalRecordId
|
|
1326
|
+
|| cancellation.principal_version !== request.principalLineage.principalVersion
|
|
1327
|
+
|| cancellation.owner_generation !== request.ownerGeneration
|
|
1328
|
+
|| cancellation.cancellation_digest !== request.cancellationDigest
|
|
1329
|
+
|| cancellation.reason !== request.reason
|
|
1330
|
+
|| cancellation.occurred_at !== request.occurredAt) {
|
|
1331
|
+
throw new MemoryStoreError('idempotency-conflict', 'promotion cancellation identity changed');
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
#assertCancellationTargetMatchesRequest(cancellation, request) {
|
|
1335
|
+
if (cancellation.request_digest !== request.requestDigest
|
|
1336
|
+
|| cancellation.principal_record_id !== request.principalLineage.principalRecordId
|
|
1337
|
+
|| cancellation.principal_version !== request.principalLineage.principalVersion
|
|
1338
|
+
|| cancellation.owner_generation !== request.ownerGeneration) {
|
|
1339
|
+
throw new MemoryStoreError('idempotency-conflict', 'promotion cancellation target changed');
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
#assertCancellationMatchesProposal(request, proposal) {
|
|
1343
|
+
const promotion = proposal.promotion;
|
|
1344
|
+
if (promotion === undefined || proposal.namespace.mode !== 'delivery'
|
|
1345
|
+
|| promotion.requestDigest !== request.requestDigest
|
|
1346
|
+
|| promotion.ownerGeneration !== request.ownerGeneration
|
|
1347
|
+
|| proposal.namespace.principalRecordId !== request.principalLineage.principalRecordId
|
|
1348
|
+
|| proposal.namespace.principalVersion !== request.principalLineage.principalVersion) {
|
|
1349
|
+
throw new MemoryStoreError('idempotency-conflict', 'promotion cancellation does not match its proposal');
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
#assertCancellationMatchesResult(request, result) {
|
|
1353
|
+
if (result.request_digest !== request.requestDigest
|
|
1354
|
+
|| result.principal_record_id !== request.principalLineage.principalRecordId
|
|
1355
|
+
|| result.principal_version !== request.principalLineage.principalVersion
|
|
1356
|
+
|| result.owner_generation !== request.ownerGeneration) {
|
|
1357
|
+
throw new MemoryStoreError('idempotency-conflict', 'promotion cancellation does not match its result');
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
#compensateConfirmedPromotion(request, result) {
|
|
1361
|
+
if (result.status !== 'confirmed' || result.memory_record_id === null
|
|
1362
|
+
|| result.memory_record_version === null || result.memory_record_digest === null) {
|
|
1363
|
+
throw new MemoryStoreError('idempotency-conflict', 'confirmed promotion result is incomplete');
|
|
1364
|
+
}
|
|
1365
|
+
const prior = this.#database.prepare(`
|
|
1366
|
+
SELECT * FROM memory_promotion_compensations
|
|
1367
|
+
WHERE promotion_id = ? AND promotion_generation = ?
|
|
1368
|
+
`).get(request.promotionId, request.promotionGeneration);
|
|
1369
|
+
if (prior !== undefined) {
|
|
1370
|
+
if (prior.request_digest !== request.requestDigest
|
|
1371
|
+
|| prior.cancellation_digest !== request.cancellationDigest
|
|
1372
|
+
|| prior.memory_proposal_id !== result.memory_proposal_id
|
|
1373
|
+
|| prior.memory_proposal_version !== result.memory_proposal_version
|
|
1374
|
+
|| prior.memory_record_id !== result.memory_record_id
|
|
1375
|
+
|| prior.memory_record_version !== result.memory_record_version
|
|
1376
|
+
|| prior.memory_record_digest !== result.memory_record_digest) {
|
|
1377
|
+
throw new MemoryStoreError('idempotency-conflict', 'promotion compensation identity changed');
|
|
1378
|
+
}
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const record = this.#database.prepare(`
|
|
1382
|
+
SELECT * FROM memory_records WHERE id = ? AND namespace_key = ?
|
|
1383
|
+
`).get(result.memory_record_id, result.namespace_key);
|
|
1384
|
+
if (record === undefined || record.version !== result.memory_record_version
|
|
1385
|
+
|| record.content_hash !== result.memory_record_digest || record.status !== 'active') {
|
|
1386
|
+
throw new MemoryStoreError('version-conflict', 'promotion-created Memory record changed before compensation');
|
|
1387
|
+
}
|
|
1388
|
+
const now = this.#now();
|
|
1389
|
+
const removed = this.#database.prepare(`
|
|
1390
|
+
UPDATE memory_records SET status = 'removed', updated_at = ?, version = version + 1
|
|
1391
|
+
WHERE id = ? AND namespace_key = ? AND version = ? AND content_hash = ? AND status = 'active'
|
|
1392
|
+
`).run(now, result.memory_record_id, result.namespace_key, result.memory_record_version, result.memory_record_digest);
|
|
1393
|
+
if (removed.changes !== 1) {
|
|
1394
|
+
throw new MemoryStoreError('version-conflict', 'promotion compensation lost its record CAS');
|
|
1395
|
+
}
|
|
1396
|
+
this.#database.prepare('DELETE FROM memory_tokens WHERE memory_id = ?').run(result.memory_record_id);
|
|
1397
|
+
this.#database.prepare(`
|
|
1398
|
+
INSERT INTO memory_promotion_compensations(
|
|
1399
|
+
promotion_id, promotion_generation, request_digest, cancellation_digest,
|
|
1400
|
+
memory_proposal_id, memory_proposal_version, memory_record_id,
|
|
1401
|
+
memory_record_version, memory_record_digest, removed_record_version, compensated_at
|
|
1402
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1403
|
+
`).run(request.promotionId, request.promotionGeneration, request.requestDigest, request.cancellationDigest, result.memory_proposal_id, result.memory_proposal_version, result.memory_record_id, result.memory_record_version, result.memory_record_digest, result.memory_record_version + 1, now);
|
|
1404
|
+
}
|
|
1405
|
+
#cancellationReceipt(input, outcome) {
|
|
1406
|
+
return withPreferenceMemoryPromotionCancellationReceiptDigest({
|
|
1407
|
+
contractVersion: 1,
|
|
1408
|
+
promotionId: input.promotionId,
|
|
1409
|
+
promotionGeneration: input.promotionGeneration,
|
|
1410
|
+
requestDigest: input.requestDigest,
|
|
1411
|
+
cancellationDigest: input.cancellationDigest,
|
|
1412
|
+
outcome,
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
#cancellationReceiptFromRow(row, outcome) {
|
|
1416
|
+
return this.#cancellationReceipt({
|
|
1417
|
+
promotionId: row.promotion_id,
|
|
1418
|
+
promotionGeneration: row.promotion_generation,
|
|
1419
|
+
requestDigest: row.request_digest,
|
|
1420
|
+
cancellationDigest: row.cancellation_digest,
|
|
1421
|
+
}, outcome);
|
|
1422
|
+
}
|
|
1423
|
+
#validStoredCancellationReceipt(row) {
|
|
1424
|
+
return row.receipt_digest === this.#cancellationReceiptFromRow(row, 'cancelled').receiptDigest
|
|
1425
|
+
|| row.receipt_digest === this.#cancellationReceiptFromRow(row, 'already-confirmed').receiptDigest;
|
|
1426
|
+
}
|
|
1427
|
+
#conflictPendingProposalInCurrentTransaction(proposalId) {
|
|
1428
|
+
const current = this.#requiredProposal(proposalId);
|
|
1429
|
+
if (current.status === 'pending') {
|
|
1430
|
+
this.#database.prepare(`
|
|
1431
|
+
UPDATE memory_proposals SET status = 'conflicted', updated_at = ?, version = version + 1
|
|
1432
|
+
WHERE id = ? AND status = 'pending'
|
|
1433
|
+
`).run(this.#now(), proposalId);
|
|
1434
|
+
}
|
|
1435
|
+
return this.#toProposal(this.#requiredProposal(proposalId));
|
|
1436
|
+
}
|
|
890
1437
|
#proposalRecord(row) {
|
|
891
1438
|
if (row.result_memory_id === null)
|
|
892
1439
|
return {};
|
|
893
1440
|
const proposal = this.#toProposal(row);
|
|
894
|
-
const record = this.#selectRecord(normalizeIdentity(proposal.mutation.identity), row.result_memory_id);
|
|
1441
|
+
const record = this.#selectRecord(normalizeIdentity(proposal.mutation.identity, proposal.namespace), row.result_memory_id);
|
|
895
1442
|
return record === undefined ? {} : { record: this.#toRecord(record) };
|
|
896
1443
|
}
|
|
1444
|
+
#enqueuePromotionResult(proposal, record, input) {
|
|
1445
|
+
const reference = validatePromotionReference(input);
|
|
1446
|
+
if (proposal.promotion !== undefined
|
|
1447
|
+
&& JSON.stringify(proposal.promotion) !== JSON.stringify(reference)) {
|
|
1448
|
+
throw new MemoryStoreError('idempotency-conflict', 'promotion settlement does not match its proposal');
|
|
1449
|
+
}
|
|
1450
|
+
if (proposal.status === 'pending') {
|
|
1451
|
+
throw new MemoryStoreError('invalid-entry', 'promotion result requires a terminal proposal');
|
|
1452
|
+
}
|
|
1453
|
+
const status = input.statusOverride
|
|
1454
|
+
?? (proposal.status === 'approved' ? 'confirmed' : proposal.status);
|
|
1455
|
+
if ((status === 'confirmed') !== (record !== undefined)) {
|
|
1456
|
+
throw new MemoryStoreError('invalid-entry', 'promotion terminal result is inconsistent');
|
|
1457
|
+
}
|
|
1458
|
+
const resultVersion = 1;
|
|
1459
|
+
const namespace = namespaceColumns(proposal.namespace);
|
|
1460
|
+
const existing = this.#database.prepare(`
|
|
1461
|
+
SELECT * FROM memory_promotion_results
|
|
1462
|
+
WHERE promotion_id = ? AND promotion_generation = ?
|
|
1463
|
+
`).get(reference.promotionId, reference.promotionGeneration);
|
|
1464
|
+
if (existing !== undefined) {
|
|
1465
|
+
if (existing.request_digest !== reference.requestDigest
|
|
1466
|
+
|| existing.memory_proposal_id !== proposal.proposalId
|
|
1467
|
+
|| existing.status !== status
|
|
1468
|
+
|| existing.memory_proposal_version !== proposal.version
|
|
1469
|
+
|| existing.namespace_key !== namespace.namespaceKey
|
|
1470
|
+
|| existing.memory_record_id !== (record?.id ?? null)
|
|
1471
|
+
|| existing.memory_record_version !== (record?.version ?? null)
|
|
1472
|
+
|| existing.memory_record_digest !== (record?.contentHash ?? null)) {
|
|
1473
|
+
throw new MemoryStoreError('idempotency-conflict', 'promotion result identity was reused');
|
|
1474
|
+
}
|
|
1475
|
+
return this.#toPromotionResult(existing);
|
|
1476
|
+
}
|
|
1477
|
+
const occurredAt = this.#now();
|
|
1478
|
+
const common = {
|
|
1479
|
+
contractVersion: 1,
|
|
1480
|
+
promotionId: reference.promotionId,
|
|
1481
|
+
promotionGeneration: reference.promotionGeneration,
|
|
1482
|
+
requestDigest: reference.requestDigest,
|
|
1483
|
+
resultVersion,
|
|
1484
|
+
occurredAt,
|
|
1485
|
+
};
|
|
1486
|
+
const wire = status === 'stale-owner'
|
|
1487
|
+
? { ...common, status }
|
|
1488
|
+
: status === 'rejected'
|
|
1489
|
+
? {
|
|
1490
|
+
...common, status, rejectionKind: 'owner-explicit',
|
|
1491
|
+
memoryProposalId: proposal.proposalId, memoryProposalVersion: proposal.version,
|
|
1492
|
+
}
|
|
1493
|
+
: status === 'confirmed'
|
|
1494
|
+
? {
|
|
1495
|
+
...common, status, memoryProposalId: proposal.proposalId,
|
|
1496
|
+
memoryProposalVersion: proposal.version,
|
|
1497
|
+
memoryRecordId: record.id,
|
|
1498
|
+
memoryRecordVersion: record.version,
|
|
1499
|
+
memoryRecordDigest: record.contentHash,
|
|
1500
|
+
}
|
|
1501
|
+
: {
|
|
1502
|
+
...common, status, memoryProposalId: proposal.proposalId,
|
|
1503
|
+
memoryProposalVersion: proposal.version,
|
|
1504
|
+
};
|
|
1505
|
+
const receipt = withPreferenceMemoryPromotionResultDigest(wire);
|
|
1506
|
+
const internal = {
|
|
1507
|
+
...receipt,
|
|
1508
|
+
namespace: proposal.namespace,
|
|
1509
|
+
memoryProposalId: proposal.proposalId,
|
|
1510
|
+
memoryProposalVersion: proposal.version,
|
|
1511
|
+
...(record === undefined ? {} : {
|
|
1512
|
+
memoryRecordId: record.id,
|
|
1513
|
+
memoryRecordVersion: record.version,
|
|
1514
|
+
memoryRecordDigest: record.contentHash,
|
|
1515
|
+
}),
|
|
1516
|
+
};
|
|
1517
|
+
this.#database.prepare(`
|
|
1518
|
+
INSERT INTO memory_promotion_results(
|
|
1519
|
+
promotion_id, promotion_generation, request_digest, namespace_mode, namespace_key, principal_digest,
|
|
1520
|
+
principal_record_id, principal_version, headless_lineage_id, headless_lineage_version, owner_generation,
|
|
1521
|
+
contract_version, result_version, status, memory_proposal_id, memory_proposal_version, occurred_at,
|
|
1522
|
+
receipt_digest, memory_record_id, memory_record_version, memory_record_digest, state, attempt_count,
|
|
1523
|
+
last_error, created_at, updated_at
|
|
1524
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, NULL, ?, ?)
|
|
1525
|
+
`).run(reference.promotionId, reference.promotionGeneration, reference.requestDigest, ...namespaceSqlValues(namespace), reference.ownerGeneration, resultVersion, status, proposal.proposalId, proposal.version, occurredAt, internal.receiptDigest, record?.id ?? null, record?.version ?? null, record?.contentHash ?? null, occurredAt, occurredAt);
|
|
1526
|
+
return this.#toPromotionResult(this.#database.prepare(`
|
|
1527
|
+
SELECT * FROM memory_promotion_results WHERE promotion_id = ? AND promotion_generation = ?
|
|
1528
|
+
`).get(reference.promotionId, reference.promotionGeneration));
|
|
1529
|
+
}
|
|
1530
|
+
#requiredExactPromotionResult(result) {
|
|
1531
|
+
const row = this.#database.prepare(`
|
|
1532
|
+
SELECT * FROM memory_promotion_results WHERE promotion_id = ? AND promotion_generation = ?
|
|
1533
|
+
`).get(result.promotionId, result.promotionGeneration);
|
|
1534
|
+
if (row === undefined)
|
|
1535
|
+
throw new MemoryStoreError('not-found', 'promotion result was not found');
|
|
1536
|
+
const current = this.#toPromotionResult(row);
|
|
1537
|
+
if (current.resultVersion !== result.resultVersion || current.receiptDigest !== result.receiptDigest
|
|
1538
|
+
|| current.attemptCount !== result.attemptCount) {
|
|
1539
|
+
throw new MemoryStoreError('version-conflict', 'promotion result changed');
|
|
1540
|
+
}
|
|
1541
|
+
return row;
|
|
1542
|
+
}
|
|
1543
|
+
#toPromotionResult(row) {
|
|
1544
|
+
const namespace = namespaceFromRow(row);
|
|
1545
|
+
return Object.freeze({
|
|
1546
|
+
contractVersion: 1,
|
|
1547
|
+
promotionId: row.promotion_id,
|
|
1548
|
+
promotionGeneration: row.promotion_generation,
|
|
1549
|
+
requestDigest: row.request_digest,
|
|
1550
|
+
scope: this.#promotionScope(row.memory_proposal_id),
|
|
1551
|
+
ownerGeneration: row.owner_generation,
|
|
1552
|
+
namespace,
|
|
1553
|
+
resultVersion: row.result_version,
|
|
1554
|
+
status: row.status,
|
|
1555
|
+
memoryProposalId: row.memory_proposal_id,
|
|
1556
|
+
memoryProposalVersion: row.memory_proposal_version,
|
|
1557
|
+
occurredAt: row.occurred_at,
|
|
1558
|
+
receiptDigest: row.receipt_digest,
|
|
1559
|
+
...(row.memory_record_id === null ? {} : { memoryRecordId: row.memory_record_id }),
|
|
1560
|
+
...(row.memory_record_version === null ? {} : { memoryRecordVersion: row.memory_record_version }),
|
|
1561
|
+
...(row.memory_record_digest === null ? {} : { memoryRecordDigest: row.memory_record_digest }),
|
|
1562
|
+
state: row.state,
|
|
1563
|
+
attemptCount: row.attempt_count,
|
|
1564
|
+
updatedAt: row.updated_at,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
#promotionScope(proposalId) {
|
|
1568
|
+
const row = this.#requiredProposal(proposalId);
|
|
1569
|
+
if (row.promotion_json === null) {
|
|
1570
|
+
throw new MemoryStoreError('invalid-entry', 'promotion result lost its durable proposal reference');
|
|
1571
|
+
}
|
|
1572
|
+
const reference = validatePromotionReference(JSON.parse(row.promotion_json));
|
|
1573
|
+
return reference.scope;
|
|
1574
|
+
}
|
|
897
1575
|
#toProposal(row) {
|
|
898
1576
|
return Object.freeze({
|
|
899
1577
|
proposalId: row.id,
|
|
@@ -901,20 +1579,25 @@ export class MemoryStore {
|
|
|
901
1579
|
idempotencyKey: row.idempotency_key,
|
|
902
1580
|
requester: row.requester,
|
|
903
1581
|
principal: row.principal,
|
|
1582
|
+
namespace: namespaceFromRow(row),
|
|
904
1583
|
mutationHash: row.mutation_hash,
|
|
905
1584
|
mutation: Object.freeze(JSON.parse(row.mutation_json)),
|
|
906
1585
|
status: row.status,
|
|
1586
|
+
notAfter: row.not_after,
|
|
907
1587
|
expiresAt: row.expires_at,
|
|
908
1588
|
version: row.version,
|
|
909
1589
|
...(row.result_memory_id === null ? {} : { resultMemoryId: row.result_memory_id }),
|
|
1590
|
+
...(row.promotion_json === null
|
|
1591
|
+
? {}
|
|
1592
|
+
: { promotion: Object.freeze(JSON.parse(row.promotion_json)) }),
|
|
910
1593
|
});
|
|
911
1594
|
}
|
|
912
1595
|
#visibleRecords(context) {
|
|
913
1596
|
return [
|
|
914
|
-
...this.list({ owner: 'user', scope: 'user-global' }),
|
|
915
|
-
...this.list({ owner: 'user', scope: 'workspace', workspace: context.workspace }),
|
|
916
|
-
...this.list({ owner: 'agent', scope: 'user-global', agentPreset: context.agentPreset }),
|
|
917
|
-
...this.list({
|
|
1597
|
+
...this.list(context.namespace, { owner: 'user', scope: 'user-global' }),
|
|
1598
|
+
...this.list(context.namespace, { owner: 'user', scope: 'workspace', workspace: context.workspace }),
|
|
1599
|
+
...this.list(context.namespace, { owner: 'agent', scope: 'user-global', agentPreset: context.agentPreset }),
|
|
1600
|
+
...this.list(context.namespace, {
|
|
918
1601
|
owner: 'agent',
|
|
919
1602
|
scope: 'workspace',
|
|
920
1603
|
workspace: context.workspace,
|
|
@@ -942,6 +1625,7 @@ export class MemoryStore {
|
|
|
942
1625
|
#toRecord(row) {
|
|
943
1626
|
return Object.freeze({
|
|
944
1627
|
id: row.id,
|
|
1628
|
+
namespace: namespaceFromRow(row),
|
|
945
1629
|
owner: row.owner,
|
|
946
1630
|
scope: row.scope,
|
|
947
1631
|
...(row.workspace === '' ? {} : { workspace: row.workspace }),
|