@dsh-enhanced/personal-memory 0.1.6 → 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 +2 -1
- 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 +17 -0
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +284 -12
- 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 +806 -106
- package/lib/store.js.map +1 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +34 -0
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +79 -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,
|
|
@@ -205,7 +370,23 @@ export class MemoryStore {
|
|
|
205
370
|
const normalizedQuery = normalizeContent(request.query).normalize('NFKC').toLocaleLowerCase('en-US');
|
|
206
371
|
const queryTokens = tokenizeMemory(normalizedQuery);
|
|
207
372
|
const queryTokenSet = new Set(queryTokens);
|
|
208
|
-
const
|
|
373
|
+
const validKinds = new Set(['experience', 'fact', 'instruction', 'preference']);
|
|
374
|
+
const validTrusts = new Set(['agent-observed', 'external', 'user-confirmed']);
|
|
375
|
+
const validSensitivities = new Set(['private', 'sensitive']);
|
|
376
|
+
const requireFilter = (values, allowed, label) => {
|
|
377
|
+
if (values === undefined)
|
|
378
|
+
return undefined;
|
|
379
|
+
if (!Array.isArray(values) || values.length === 0 || values.some(value => !allowed.has(value))) {
|
|
380
|
+
throw new MemoryStoreError('invalid-entry', `memory search ${label} filter is invalid`);
|
|
381
|
+
}
|
|
382
|
+
return new Set(values);
|
|
383
|
+
};
|
|
384
|
+
const kinds = requireFilter(request.kinds, validKinds, 'kind');
|
|
385
|
+
const trusts = requireFilter(request.trusts, validTrusts, 'trust');
|
|
386
|
+
const sensitivities = requireFilter(request.sensitivities, validSensitivities, 'sensitivity');
|
|
387
|
+
const records = this.#visibleRecords(context).filter(record => (kinds === undefined || kinds.has(record.kind))
|
|
388
|
+
&& (trusts === undefined || trusts.has(record.trust))
|
|
389
|
+
&& (sensitivities === undefined || sensitivities.has(record.sensitivity)));
|
|
209
390
|
const candidates = records.flatMap((record) => {
|
|
210
391
|
const recordTokens = this.#tokens(record.id);
|
|
211
392
|
const matchedTokens = recordTokens.filter(token => queryTokenSet.has(token));
|
|
@@ -300,9 +481,9 @@ export class MemoryStore {
|
|
|
300
481
|
records: Object.freeze(records),
|
|
301
482
|
});
|
|
302
483
|
}
|
|
303
|
-
normalizeMutation(mutation, options
|
|
484
|
+
normalizeMutation(mutation, options) {
|
|
304
485
|
const preflight = options.preflight !== false;
|
|
305
|
-
const columns = normalizeIdentity(mutation.identity);
|
|
486
|
+
const columns = normalizeIdentity(mutation.identity, options.namespace);
|
|
306
487
|
const identity = publicIdentity(columns);
|
|
307
488
|
if (mutation.op === 'add') {
|
|
308
489
|
const entry = this.#publicEntry(this.#validateEntry(mutation.entry));
|
|
@@ -344,10 +525,14 @@ export class MemoryStore {
|
|
|
344
525
|
}
|
|
345
526
|
prepareProposalIntent(input) {
|
|
346
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);
|
|
347
532
|
const diff = JSON.stringify(input.mutation, null, 2);
|
|
348
533
|
if (input.proposalId.trim() === '' || input.idempotencyKey.trim() === ''
|
|
349
534
|
|| input.requester.trim() === '' || input.principal.trim() === ''
|
|
350
|
-
||
|
|
535
|
+
|| input.mutationHash !== hashMemoryProposalIntent(input)
|
|
351
536
|
|| !Number.isSafeInteger(input.ttlMs) || input.ttlMs <= 0
|
|
352
537
|
|| Buffer.byteLength(`personal-memory:${input.idempotencyKey}`, 'utf8') > 512
|
|
353
538
|
|| Buffer.byteLength(input.requester, 'utf8') > 512
|
|
@@ -355,6 +540,9 @@ export class MemoryStore {
|
|
|
355
540
|
|| Buffer.byteLength(diff, 'utf8') > APPROVAL_DISPLAY_BUDGET.maxDiffBytes) {
|
|
356
541
|
throw new MemoryStoreError('invalid-entry', 'proposal intent fields are invalid');
|
|
357
542
|
}
|
|
543
|
+
if (input.namespace.principalDigest !== memoryPrincipalDigest(input.principal)) {
|
|
544
|
+
throw new MemoryStoreError('invalid-identity', 'proposal principal does not match its owner namespace');
|
|
545
|
+
}
|
|
358
546
|
if (input.dispatch !== undefined
|
|
359
547
|
&& (input.dispatch.sourceId.trim() === '' || input.dispatch.bindingId.trim() === ''
|
|
360
548
|
|| !isAbsolute(input.dispatch.workspace) || input.dispatch.principal !== input.principal
|
|
@@ -365,10 +553,20 @@ export class MemoryStore {
|
|
|
365
553
|
throw new MemoryStoreError('invalid-entry', 'proposal intent dispatch route is invalid');
|
|
366
554
|
}
|
|
367
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
|
+
}
|
|
368
566
|
// The proposal and its creation intent live in separate tables. Resolve
|
|
369
567
|
// both under the same write lock so another process cannot attach a local
|
|
370
568
|
// proposal between an absence check and this intent insert.
|
|
371
|
-
const proposalByKey = this.#proposalByIdempotencyKey(input.idempotencyKey);
|
|
569
|
+
const proposalByKey = this.#proposalByIdempotencyKey(namespace.namespaceKey, input.idempotencyKey);
|
|
372
570
|
const proposalById = this.#proposal(input.proposalId);
|
|
373
571
|
if (proposalByKey !== undefined || proposalById !== undefined) {
|
|
374
572
|
const existing = proposalByKey ?? proposalById;
|
|
@@ -376,8 +574,9 @@ export class MemoryStore {
|
|
|
376
574
|
// possible aliases before returning/raising outside the transaction so
|
|
377
575
|
// poison work cannot permanently occupy the reconcile lane.
|
|
378
576
|
this.#database.prepare(`
|
|
379
|
-
DELETE FROM memory_proposal_intents
|
|
380
|
-
|
|
577
|
+
DELETE FROM memory_proposal_intents
|
|
578
|
+
WHERE id = ? OR (namespace_key = ? AND idempotency_key = ?)
|
|
579
|
+
`).run(input.proposalId, namespace.namespaceKey, input.idempotencyKey);
|
|
381
580
|
const sameRow = proposalByKey === undefined || proposalById === undefined
|
|
382
581
|
|| proposalByKey.id === proposalById.id;
|
|
383
582
|
if (!sameRow || !this.#proposalMatchesPrepareInput(existing, input, mutationJson)) {
|
|
@@ -385,7 +584,7 @@ export class MemoryStore {
|
|
|
385
584
|
}
|
|
386
585
|
return Object.freeze({ kind: 'proposal', proposal: this.#toProposal(existing) });
|
|
387
586
|
}
|
|
388
|
-
const intentByKey = this.#proposalIntentByIdempotencyKey(input.idempotencyKey);
|
|
587
|
+
const intentByKey = this.#proposalIntentByIdempotencyKey(namespace.namespaceKey, input.idempotencyKey);
|
|
389
588
|
const intentById = this.#proposalIntent(input.proposalId);
|
|
390
589
|
if (intentByKey !== undefined || intentById !== undefined) {
|
|
391
590
|
const existing = intentByKey ?? intentById;
|
|
@@ -402,21 +601,25 @@ export class MemoryStore {
|
|
|
402
601
|
}
|
|
403
602
|
// Keep the mutation preflight in the same transaction as the absence
|
|
404
603
|
// checks. Final application still repeats every CAS/duplicate check.
|
|
405
|
-
const preflight = this.normalizeMutation(input.mutation);
|
|
604
|
+
const preflight = this.normalizeMutation(input.mutation, { namespace: input.namespace });
|
|
406
605
|
if (JSON.stringify(preflight) !== mutationJson) {
|
|
407
606
|
throw new MemoryStoreError('invalid-entry', 'proposal mutation is not canonical');
|
|
408
607
|
}
|
|
409
608
|
const now = this.#now();
|
|
410
|
-
|
|
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)) {
|
|
411
613
|
throw new MemoryStoreError('invalid-entry', 'proposal intent deadline exceeds the safe timestamp range');
|
|
412
614
|
}
|
|
413
615
|
this.#database.prepare(`
|
|
414
616
|
INSERT INTO memory_proposal_intents(
|
|
415
|
-
id,
|
|
416
|
-
|
|
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,
|
|
417
620
|
created_at, updated_at
|
|
418
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
419
|
-
`).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);
|
|
420
623
|
return Object.freeze({
|
|
421
624
|
kind: 'intent',
|
|
422
625
|
intent: this.#toProposalIntent(this.#requiredProposalIntent(input.proposalId)),
|
|
@@ -431,7 +634,9 @@ export class MemoryStore {
|
|
|
431
634
|
listProposalIntents(limit) {
|
|
432
635
|
this.#validateProposalListLimit(limit);
|
|
433
636
|
const rows = this.#database.prepare(`
|
|
434
|
-
SELECT * FROM memory_proposal_intents
|
|
637
|
+
SELECT * FROM memory_proposal_intents
|
|
638
|
+
WHERE namespace_mode <> 'legacy-quarantine'
|
|
639
|
+
ORDER BY updated_at, id LIMIT ?
|
|
435
640
|
`).all(limit);
|
|
436
641
|
return rows.map(row => this.#toProposalIntent(row));
|
|
437
642
|
}
|
|
@@ -444,7 +649,7 @@ export class MemoryStore {
|
|
|
444
649
|
`).run(now, now, proposalId);
|
|
445
650
|
}
|
|
446
651
|
/** Fail closed after Policy atomically abandons or rejects exact recovery. */
|
|
447
|
-
conflictProposalIntent(proposalId) {
|
|
652
|
+
conflictProposalIntent(proposalId, promotion) {
|
|
448
653
|
return this.#transaction(() => {
|
|
449
654
|
const existing = this.#proposal(proposalId);
|
|
450
655
|
if (existing !== undefined) {
|
|
@@ -454,29 +659,44 @@ export class MemoryStore {
|
|
|
454
659
|
this.#database.prepare(`
|
|
455
660
|
DELETE FROM memory_proposal_intents WHERE id = ? AND idempotency_key = ?
|
|
456
661
|
`).run(existing.id, existing.idempotency_key);
|
|
457
|
-
|
|
662
|
+
const proposal = this.#toProposal(existing);
|
|
663
|
+
if (promotion !== undefined)
|
|
664
|
+
this.#enqueuePromotionResult(proposal, undefined, promotion);
|
|
665
|
+
return { proposal, replayed: true };
|
|
458
666
|
}
|
|
459
667
|
const intent = this.#requiredProposalIntent(proposalId);
|
|
460
|
-
const expiresAt = intent.
|
|
668
|
+
const expiresAt = intent.not_after;
|
|
461
669
|
if (!Number.isSafeInteger(expiresAt)) {
|
|
462
670
|
throw new MemoryStoreError('invalid-entry', 'proposal intent expiry exceeds the safe timestamp range');
|
|
463
671
|
}
|
|
464
|
-
|
|
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;
|
|
465
679
|
});
|
|
466
680
|
}
|
|
467
681
|
saveProposal(input) {
|
|
468
|
-
|
|
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) {
|
|
469
686
|
throw new MemoryStoreError('invalid-entry', 'proposal expiry and version must be safe integers');
|
|
470
687
|
}
|
|
471
688
|
return this.#transaction(() => {
|
|
472
|
-
const existing = this.#proposalByIdempotencyKey(input.idempotencyKey);
|
|
689
|
+
const existing = this.#proposalByIdempotencyKey(namespace.namespaceKey, input.idempotencyKey);
|
|
473
690
|
if (existing !== undefined) {
|
|
474
691
|
const same = existing.id === input.proposalId
|
|
475
692
|
&& existing.policy_proposal_id === input.policyProposalId
|
|
476
693
|
&& existing.requester === input.requester
|
|
477
694
|
&& existing.principal === input.principal
|
|
695
|
+
&& existing.namespace_key === namespace.namespaceKey
|
|
478
696
|
&& existing.mutation_hash === input.mutationHash
|
|
479
|
-
&& existing.
|
|
697
|
+
&& existing.not_after === input.notAfter
|
|
698
|
+
&& existing.expires_at === input.expiresAt
|
|
699
|
+
&& existing.promotion_json === (promotion === undefined ? null : JSON.stringify(promotion));
|
|
480
700
|
if (!same)
|
|
481
701
|
throw new MemoryStoreError('idempotency-conflict', 'proposal key was used for another mutation');
|
|
482
702
|
this.#database.prepare('DELETE FROM memory_proposal_intents WHERE id = ?').run(input.proposalId);
|
|
@@ -485,10 +705,12 @@ export class MemoryStore {
|
|
|
485
705
|
const now = this.#now();
|
|
486
706
|
this.#database.prepare(`
|
|
487
707
|
INSERT INTO memory_proposals(
|
|
488
|
-
id,
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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);
|
|
492
714
|
this.#database.prepare('DELETE FROM memory_proposal_intents WHERE id = ?').run(input.proposalId);
|
|
493
715
|
return { proposal: this.#toProposal(this.#requiredProposal(input.proposalId)), replayed: false };
|
|
494
716
|
});
|
|
@@ -504,7 +726,8 @@ export class MemoryStore {
|
|
|
504
726
|
listPendingProposals(limit) {
|
|
505
727
|
this.#validateProposalListLimit(limit);
|
|
506
728
|
const rows = this.#database.prepare(`
|
|
507
|
-
SELECT * FROM memory_proposals
|
|
729
|
+
SELECT * FROM memory_proposals
|
|
730
|
+
WHERE namespace_mode <> 'legacy-quarantine' AND status = 'pending'
|
|
508
731
|
ORDER BY updated_at, id LIMIT ?
|
|
509
732
|
`).all(limit);
|
|
510
733
|
return rows.map(row => this.#toProposal(row));
|
|
@@ -517,14 +740,211 @@ export class MemoryStore {
|
|
|
517
740
|
WHERE id = ? AND status = 'pending'
|
|
518
741
|
`).run(now, now, proposalId);
|
|
519
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
|
+
}
|
|
520
930
|
settleProposal(input) {
|
|
521
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
|
+
});
|
|
522
939
|
if (existing.status !== 'pending') {
|
|
523
|
-
|
|
940
|
+
const settled = {
|
|
524
941
|
proposal: this.#toProposal(existing),
|
|
525
942
|
...this.#proposalRecord(existing),
|
|
526
943
|
replayed: true,
|
|
527
944
|
};
|
|
945
|
+
if (promotion !== undefined)
|
|
946
|
+
this.#transaction(() => this.#enqueuePromotionResult(settled.proposal, settled.record, promotion));
|
|
947
|
+
return settled;
|
|
528
948
|
}
|
|
529
949
|
if (input.policyStatus !== 'approved') {
|
|
530
950
|
return this.#transaction(() => {
|
|
@@ -542,8 +962,11 @@ export class MemoryStore {
|
|
|
542
962
|
UPDATE memory_proposals SET status = ?, updated_at = ?, version = ?
|
|
543
963
|
WHERE id = ? AND status = 'pending'
|
|
544
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);
|
|
545
968
|
return {
|
|
546
|
-
proposal
|
|
969
|
+
proposal,
|
|
547
970
|
replayed: false,
|
|
548
971
|
};
|
|
549
972
|
});
|
|
@@ -561,14 +984,18 @@ export class MemoryStore {
|
|
|
561
984
|
const record = this.#applyMutationInCurrentTransaction({
|
|
562
985
|
...mutation,
|
|
563
986
|
idempotencyKey: `memory-proposal:${current.id}`,
|
|
987
|
+
namespace: namespaceFromRow(current),
|
|
564
988
|
});
|
|
565
989
|
this.#database.prepare(`
|
|
566
990
|
UPDATE memory_proposals
|
|
567
991
|
SET status = 'approved', result_memory_id = ?, updated_at = ?, version = ?
|
|
568
992
|
WHERE id = ? AND status = 'pending'
|
|
569
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);
|
|
570
997
|
return {
|
|
571
|
-
proposal
|
|
998
|
+
proposal,
|
|
572
999
|
record,
|
|
573
1000
|
replayed: false,
|
|
574
1001
|
};
|
|
@@ -588,6 +1015,8 @@ export class MemoryStore {
|
|
|
588
1015
|
`).run(this.#now(), input.policyVersion, input.proposalId);
|
|
589
1016
|
}
|
|
590
1017
|
const conflicted = this.#requiredProposal(input.proposalId);
|
|
1018
|
+
if (promotion !== undefined)
|
|
1019
|
+
this.#enqueuePromotionResult(this.#toProposal(conflicted), undefined, promotion);
|
|
591
1020
|
return { proposal: this.#toProposal(conflicted), replayed: false };
|
|
592
1021
|
});
|
|
593
1022
|
}
|
|
@@ -596,11 +1025,12 @@ export class MemoryStore {
|
|
|
596
1025
|
return this.#transaction(() => this.#applyMutationInCurrentTransaction(mutation));
|
|
597
1026
|
}
|
|
598
1027
|
#applyMutationInCurrentTransaction(mutation) {
|
|
599
|
-
const identity = normalizeIdentity(mutation.identity);
|
|
1028
|
+
const identity = normalizeIdentity(mutation.identity, mutation.namespace);
|
|
600
1029
|
const hash = hashMemoryMutation(this.#withoutIdempotencyKey(mutation));
|
|
601
1030
|
const prior = this.#database.prepare(`
|
|
602
|
-
SELECT mutation_hash, memory_id FROM memory_audit
|
|
603
|
-
|
|
1031
|
+
SELECT mutation_hash, memory_id FROM memory_audit
|
|
1032
|
+
WHERE namespace_key = ? AND idempotency_key = ?
|
|
1033
|
+
`).get(identity.namespaceKey, mutation.idempotencyKey);
|
|
604
1034
|
if (prior !== undefined) {
|
|
605
1035
|
if (prior.mutation_hash !== hash) {
|
|
606
1036
|
throw new MemoryStoreError('idempotency-conflict', 'idempotency key was used for another mutation');
|
|
@@ -617,9 +1047,11 @@ export class MemoryStore {
|
|
|
617
1047
|
: this.#remove(identity, mutation.id, mutation.expectedVersion);
|
|
618
1048
|
this.#database.prepare(`
|
|
619
1049
|
INSERT INTO memory_audit(
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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());
|
|
623
1055
|
return result;
|
|
624
1056
|
}
|
|
625
1057
|
#add(identity, input) {
|
|
@@ -630,20 +1062,22 @@ export class MemoryStore {
|
|
|
630
1062
|
const now = this.#now();
|
|
631
1063
|
this.#database.prepare(`
|
|
632
1064
|
INSERT INTO memory_records(
|
|
633
|
-
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,
|
|
634
1068
|
sensitivity, trust, confidence, provenance_json, supersedes, expires_at,
|
|
635
1069
|
status, created_at, updated_at, version
|
|
636
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1)
|
|
637
|
-
`).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);
|
|
638
1072
|
this.#replaceTokens(id, entry.content);
|
|
639
1073
|
return this.#toRecord(this.#selectRecord(identity, id));
|
|
640
1074
|
}
|
|
641
1075
|
#assertRecordCapacity(identity) {
|
|
642
1076
|
const count = this.#database.prepare(`
|
|
643
1077
|
SELECT COUNT(*) AS count FROM memory_records
|
|
644
|
-
WHERE owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
1078
|
+
WHERE namespace_key = ? AND owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
645
1079
|
AND status = 'active' AND (expires_at IS NULL OR expires_at > ?)
|
|
646
|
-
`).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());
|
|
647
1081
|
if (count.count >= this.#maxRecordsPerIdentity) {
|
|
648
1082
|
throw new MemoryStoreError('record-limit', 'memory identity record limit is reached');
|
|
649
1083
|
}
|
|
@@ -717,12 +1151,12 @@ export class MemoryStore {
|
|
|
717
1151
|
#assertNoDuplicate(identity, hash, excludedId) {
|
|
718
1152
|
const duplicate = this.#database.prepare(`
|
|
719
1153
|
SELECT id FROM memory_records
|
|
720
|
-
WHERE owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
1154
|
+
WHERE namespace_key = ? AND owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
721
1155
|
AND content_hash = ? AND status = 'active'
|
|
722
1156
|
AND (expires_at IS NULL OR expires_at > ?)
|
|
723
1157
|
AND (? IS NULL OR id <> ?)
|
|
724
1158
|
LIMIT 1
|
|
725
|
-
`).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);
|
|
726
1160
|
if (duplicate !== undefined)
|
|
727
1161
|
throw new MemoryStoreError('duplicate-content', 'active memory content already exists');
|
|
728
1162
|
}
|
|
@@ -764,8 +1198,8 @@ export class MemoryStore {
|
|
|
764
1198
|
#selectRecord(identity, id) {
|
|
765
1199
|
return this.#database.prepare(`
|
|
766
1200
|
SELECT * FROM memory_records
|
|
767
|
-
WHERE id = ? AND owner = ? AND scope = ? AND workspace = ? AND agent_preset = ?
|
|
768
|
-
`).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);
|
|
769
1203
|
}
|
|
770
1204
|
#validateProposalListLimit(limit) {
|
|
771
1205
|
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
|
|
@@ -776,9 +1210,10 @@ export class MemoryStore {
|
|
|
776
1210
|
return this.#database.prepare('SELECT * FROM memory_proposal_intents WHERE id = ?')
|
|
777
1211
|
.get(id);
|
|
778
1212
|
}
|
|
779
|
-
#proposalIntentByIdempotencyKey(key) {
|
|
780
|
-
return this.#database.prepare(
|
|
781
|
-
|
|
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);
|
|
782
1217
|
}
|
|
783
1218
|
#requiredProposalIntent(id) {
|
|
784
1219
|
const intent = this.#proposalIntent(id);
|
|
@@ -787,72 +1222,70 @@ export class MemoryStore {
|
|
|
787
1222
|
return intent;
|
|
788
1223
|
}
|
|
789
1224
|
#materializeProposalIntentConflict(intent, expiresAt, now) {
|
|
790
|
-
const dispatch = intent.
|
|
1225
|
+
const dispatch = intent.dispatch_json === null
|
|
791
1226
|
? undefined
|
|
792
|
-
:
|
|
793
|
-
sourceId: intent.dispatch_source_id,
|
|
794
|
-
bindingId: intent.dispatch_binding_id,
|
|
795
|
-
workspace: intent.dispatch_workspace,
|
|
796
|
-
principal: intent.dispatch_principal,
|
|
797
|
-
};
|
|
1227
|
+
: JSON.parse(intent.dispatch_json);
|
|
798
1228
|
this.#database.prepare(`
|
|
799
1229
|
INSERT INTO memory_proposals(
|
|
800
|
-
id,
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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);
|
|
804
1236
|
this.#database.prepare('DELETE FROM memory_proposal_intents WHERE id = ?').run(intent.id);
|
|
805
1237
|
return { proposal: this.#toProposal(this.#requiredProposal(intent.id)), replayed: false };
|
|
806
1238
|
}
|
|
807
1239
|
#intentDispatchMatches(row, dispatch) {
|
|
808
|
-
|
|
809
|
-
return row.dispatch_source_id === null
|
|
810
|
-
&& row.dispatch_binding_id === null
|
|
811
|
-
&& row.dispatch_workspace === null
|
|
812
|
-
&& row.dispatch_principal === null;
|
|
813
|
-
return row.dispatch_source_id === dispatch.sourceId
|
|
814
|
-
&& row.dispatch_binding_id === dispatch.bindingId
|
|
815
|
-
&& row.dispatch_workspace === dispatch.workspace
|
|
816
|
-
&& row.dispatch_principal === dispatch.principal;
|
|
1240
|
+
return row.dispatch_json === (dispatch === undefined ? null : stableJson(dispatch));
|
|
817
1241
|
}
|
|
818
1242
|
#proposalMatchesPrepareInput(row, input, mutationJson) {
|
|
819
1243
|
return row.id === input.proposalId
|
|
820
1244
|
&& row.idempotency_key === input.idempotencyKey
|
|
821
1245
|
&& row.requester === input.requester
|
|
822
1246
|
&& row.principal === input.principal
|
|
1247
|
+
&& namespaceMatchesRow(row, input.namespace)
|
|
823
1248
|
&& row.mutation_json === mutationJson
|
|
824
|
-
&&
|
|
825
|
-
|
|
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)));
|
|
826
1254
|
}
|
|
827
1255
|
#intentMatchesPrepareInput(row, input, mutationJson) {
|
|
828
1256
|
return row.id === input.proposalId
|
|
829
1257
|
&& row.idempotency_key === input.idempotencyKey
|
|
830
1258
|
&& row.requester === input.requester
|
|
831
1259
|
&& row.principal === input.principal
|
|
1260
|
+
&& namespaceMatchesRow(row, input.namespace)
|
|
832
1261
|
&& row.mutation_json === mutationJson
|
|
833
|
-
&&
|
|
834
|
-
|| row.mutation_hash === hashMemoryMutation(input.mutation))
|
|
1262
|
+
&& row.mutation_hash === input.mutationHash
|
|
835
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)))
|
|
836
1268
|
&& this.#intentDispatchMatches(row, input.dispatch);
|
|
837
1269
|
}
|
|
838
1270
|
#toProposalIntent(row) {
|
|
839
|
-
const dispatch = row.
|
|
1271
|
+
const dispatch = row.dispatch_json === null
|
|
840
1272
|
? undefined
|
|
841
|
-
: Object.freeze(
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
principal: row.dispatch_principal,
|
|
846
|
-
});
|
|
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));
|
|
847
1277
|
return Object.freeze({
|
|
848
1278
|
proposalId: row.id,
|
|
849
1279
|
idempotencyKey: row.idempotency_key,
|
|
850
1280
|
requester: row.requester,
|
|
851
1281
|
principal: row.principal,
|
|
1282
|
+
namespace: namespaceFromRow(row),
|
|
852
1283
|
mutationHash: row.mutation_hash,
|
|
853
1284
|
mutation: Object.freeze(JSON.parse(row.mutation_json)),
|
|
854
1285
|
ttlMs: row.ttl_ms,
|
|
1286
|
+
notAfter: row.not_after,
|
|
855
1287
|
...(dispatch === undefined ? {} : { dispatch }),
|
|
1288
|
+
...(promotion === undefined ? {} : { promotion }),
|
|
856
1289
|
createdAt: row.created_at,
|
|
857
1290
|
updatedAt: row.updated_at,
|
|
858
1291
|
});
|
|
@@ -861,9 +1294,10 @@ export class MemoryStore {
|
|
|
861
1294
|
return this.#database.prepare('SELECT * FROM memory_proposals WHERE id = ?')
|
|
862
1295
|
.get(id);
|
|
863
1296
|
}
|
|
864
|
-
#proposalByIdempotencyKey(key) {
|
|
865
|
-
return this.#database.prepare(
|
|
866
|
-
|
|
1297
|
+
#proposalByIdempotencyKey(namespaceKey, key) {
|
|
1298
|
+
return this.#database.prepare(`
|
|
1299
|
+
SELECT * FROM memory_proposals WHERE namespace_key = ? AND idempotency_key = ?
|
|
1300
|
+
`).get(namespaceKey, key);
|
|
867
1301
|
}
|
|
868
1302
|
#requiredProposal(id) {
|
|
869
1303
|
const proposal = this.#proposal(id);
|
|
@@ -871,13 +1305,273 @@ export class MemoryStore {
|
|
|
871
1305
|
throw new MemoryStoreError('not-found', 'memory proposal was not found');
|
|
872
1306
|
return proposal;
|
|
873
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
|
+
}
|
|
874
1437
|
#proposalRecord(row) {
|
|
875
1438
|
if (row.result_memory_id === null)
|
|
876
1439
|
return {};
|
|
877
1440
|
const proposal = this.#toProposal(row);
|
|
878
|
-
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);
|
|
879
1442
|
return record === undefined ? {} : { record: this.#toRecord(record) };
|
|
880
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
|
+
}
|
|
881
1575
|
#toProposal(row) {
|
|
882
1576
|
return Object.freeze({
|
|
883
1577
|
proposalId: row.id,
|
|
@@ -885,20 +1579,25 @@ export class MemoryStore {
|
|
|
885
1579
|
idempotencyKey: row.idempotency_key,
|
|
886
1580
|
requester: row.requester,
|
|
887
1581
|
principal: row.principal,
|
|
1582
|
+
namespace: namespaceFromRow(row),
|
|
888
1583
|
mutationHash: row.mutation_hash,
|
|
889
1584
|
mutation: Object.freeze(JSON.parse(row.mutation_json)),
|
|
890
1585
|
status: row.status,
|
|
1586
|
+
notAfter: row.not_after,
|
|
891
1587
|
expiresAt: row.expires_at,
|
|
892
1588
|
version: row.version,
|
|
893
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)) }),
|
|
894
1593
|
});
|
|
895
1594
|
}
|
|
896
1595
|
#visibleRecords(context) {
|
|
897
1596
|
return [
|
|
898
|
-
...this.list({ owner: 'user', scope: 'user-global' }),
|
|
899
|
-
...this.list({ owner: 'user', scope: 'workspace', workspace: context.workspace }),
|
|
900
|
-
...this.list({ owner: 'agent', scope: 'user-global', agentPreset: context.agentPreset }),
|
|
901
|
-
...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, {
|
|
902
1601
|
owner: 'agent',
|
|
903
1602
|
scope: 'workspace',
|
|
904
1603
|
workspace: context.workspace,
|
|
@@ -926,6 +1625,7 @@ export class MemoryStore {
|
|
|
926
1625
|
#toRecord(row) {
|
|
927
1626
|
return Object.freeze({
|
|
928
1627
|
id: row.id,
|
|
1628
|
+
namespace: namespaceFromRow(row),
|
|
929
1629
|
owner: row.owner,
|
|
930
1630
|
scope: row.scope,
|
|
931
1631
|
...(row.workspace === '' ? {} : { workspace: row.workspace }),
|