@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/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
- function normalizeIdentity(identity) {
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 { workspace: context.workspace, agentPreset: context.agentPreset };
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 === undefined
86
- ? null
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 WHERE status = 'pending')
190
- + (SELECT COUNT(*) FROM memory_proposal_intents) AS count
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 records = this.#visibleRecords(context);
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
- || !/^[0-9a-f]{64}$/u.test(input.mutationHash)
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 WHERE id = ? OR idempotency_key = ?
380
- `).run(input.proposalId, input.idempotencyKey);
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
- if (!Number.isSafeInteger(now) || !Number.isSafeInteger(now + input.ttlMs)) {
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, idempotency_key, requester, principal, mutation_hash, mutation_json, ttl_ms,
416
- dispatch_source_id, dispatch_binding_id, dispatch_workspace, dispatch_principal,
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, input.ttlMs, input.dispatch?.sourceId ?? null, input.dispatch?.bindingId ?? null, input.dispatch?.workspace ?? null, input.dispatch?.principal ?? null, now, now);
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 ORDER BY updated_at, id LIMIT ?
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
- return { proposal: this.#toProposal(existing), replayed: true };
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.created_at + intent.ttl_ms;
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
- return this.#materializeProposalIntentConflict(intent, expiresAt, this.#now());
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
- if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.version) || input.version <= 0) {
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.expires_at === input.expiresAt;
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, policy_proposal_id, idempotency_key, requester, principal,
489
- mutation_hash, mutation_json, status, expires_at, created_at, updated_at, version
490
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
491
- `).run(input.proposalId, input.policyProposalId, input.idempotencyKey, input.requester, input.principal, input.mutationHash, JSON.stringify(input.mutation), input.expiresAt, now, now, input.version);
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 WHERE status = 'pending'
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
- return {
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: this.#toProposal(this.#requiredProposal(input.proposalId)),
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: this.#toProposal(this.#requiredProposal(input.proposalId)),
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 WHERE idempotency_key = ?
603
- `).get(mutation.idempotencyKey);
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
- idempotency_key, mutation_hash, operation, memory_id, result_version, occurred_at
621
- ) VALUES (?, ?, ?, ?, ?, ?)
622
- `).run(mutation.idempotencyKey, hash, mutation.op, result.id, result.version, this.#now());
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, owner, scope, workspace, agent_preset, kind, content, content_hash,
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('SELECT * FROM memory_proposal_intents WHERE idempotency_key = ?')
781
- .get(key);
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.dispatch_source_id === null
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, policy_proposal_id, idempotency_key, requester, principal,
801
- mutation_hash, mutation_json, status, expires_at, created_at, updated_at, version
802
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'conflicted', ?, ?, ?, 2)
803
- `).run(intent.id, missingPolicyProposalId(intent.id, intent.ttl_ms, dispatch), intent.idempotency_key, intent.requester, intent.principal, intent.mutation_hash, intent.mutation_json, expiresAt, intent.created_at, now);
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
- if (dispatch === undefined)
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
- && (row.mutation_hash === input.mutationHash
825
- || row.mutation_hash === hashMemoryMutation(input.mutation));
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
- && (row.mutation_hash === input.mutationHash
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.dispatch_source_id === null
1271
+ const dispatch = row.dispatch_json === null
840
1272
  ? undefined
841
- : Object.freeze({
842
- sourceId: row.dispatch_source_id,
843
- bindingId: row.dispatch_binding_id,
844
- workspace: row.dispatch_workspace,
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('SELECT * FROM memory_proposals WHERE idempotency_key = ?')
866
- .get(key);
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 }),