@shadowforge0/aquifer-memory 1.8.1 → 1.9.0

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.
@@ -1,12 +1,53 @@
1
1
  'use strict';
2
2
 
3
3
  function splitScopePath(value) {
4
- if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean);
4
+ if (Array.isArray(value)) {
5
+ const parts = value.map(v => String(v).trim()).filter(Boolean);
6
+ return parts.length > 0 ? parts : null;
7
+ }
5
8
  if (typeof value !== 'string') return null;
6
9
  const parts = value.split(',').map(v => v.trim()).filter(Boolean);
7
10
  return parts.length > 0 ? parts : null;
8
11
  }
9
12
 
13
+ function uniqueList(values = []) {
14
+ const seen = new Set();
15
+ const out = [];
16
+ for (const value of values) {
17
+ const key = String(value || '').trim();
18
+ if (!key || seen.has(key)) continue;
19
+ seen.add(key);
20
+ out.push(key);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ function normalizeScopeList(value) {
26
+ return uniqueList(splitScopePath(value) || []);
27
+ }
28
+
29
+ function requestedScopeKeys(opts = {}) {
30
+ const fromPath = normalizeScopeList(opts.activeScopePath);
31
+ const keys = [];
32
+ if (fromPath.length > 0) keys.push(...fromPath);
33
+ if (opts.activeScopeKey) keys.push(opts.activeScopeKey);
34
+ if (opts.scopeKey) keys.push(opts.scopeKey);
35
+ if (opts.resolvedScopeKey) keys.push(opts.resolvedScopeKey);
36
+ if (opts.scopeKeys) keys.push(...normalizeScopeList(opts.scopeKeys));
37
+ return uniqueList(keys);
38
+ }
39
+
40
+ function assertAllowedScopeRequest(opts = {}) {
41
+ const allowed = normalizeScopeList(opts.allowedScopeKeys);
42
+ if (allowed.length === 0) return;
43
+ const allowedSet = new Set(allowed);
44
+ const requested = requestedScopeKeys(opts);
45
+ const denied = requested.filter(key => !allowedSet.has(key));
46
+ if (denied.length > 0) {
47
+ throw new Error(`Requested memory scope is outside allowedScopeKeys: ${denied.join(', ')}`);
48
+ }
49
+ }
50
+
10
51
  function hasEvidenceBoundary(opts = {}) {
11
52
  return Boolean(
12
53
  opts.agentId
@@ -89,7 +130,13 @@ function normalizeCuratedRecallRow(row = {}) {
89
130
  function createMemoryServingRuntime(memoryCfg = {}, env = process.env) {
90
131
  const servingMode = memoryCfg.servingMode || env.AQUIFER_MEMORY_SERVING_MODE || 'legacy';
91
132
  const defaultActiveScopeKey = memoryCfg.activeScopeKey || null;
92
- const defaultActiveScopePath = splitScopePath(memoryCfg.activeScopePath || null);
133
+ const configuredActiveScopePath = splitScopePath(memoryCfg.activeScopePath || null);
134
+ const defaultActiveScopePath = configuredActiveScopePath
135
+ || uniqueList(['global', defaultActiveScopeKey].filter(Boolean));
136
+ const configuredAllowedScopeKeys = normalizeScopeList(memoryCfg.allowedScopeKeys || env.AQUIFER_MEMORY_ALLOWED_SCOPE_KEYS || null);
137
+ const defaultAllowedScopeKeys = configuredAllowedScopeKeys.length > 0
138
+ ? configuredAllowedScopeKeys
139
+ : defaultActiveScopePath;
93
140
 
94
141
  function resolveMode(opts = {}) {
95
142
  const mode = opts.memoryMode || opts.servingMode || servingMode;
@@ -100,12 +147,18 @@ function createMemoryServingRuntime(memoryCfg = {}, env = process.env) {
100
147
 
101
148
  function withDefaultScope(opts = {}) {
102
149
  const next = { ...opts };
103
- if (!next.activeScopePath && defaultActiveScopePath) next.activeScopePath = defaultActiveScopePath;
150
+ const scopeIdOnly = next.scopeId && !next.activeScopePath && !next.activeScopeKey;
151
+ if (!next.allowedScopeKeys && defaultAllowedScopeKeys) {
152
+ next.allowedScopeKeys = defaultAllowedScopeKeys;
153
+ }
154
+ if (!scopeIdOnly && !next.activeScopePath && defaultActiveScopePath) next.activeScopePath = defaultActiveScopePath;
104
155
  if (Array.isArray(next.activeScopePath) && next.activeScopePath.length > 0) {
105
156
  if (!next.activeScopeKey) next.activeScopeKey = next.activeScopePath[next.activeScopePath.length - 1];
157
+ assertAllowedScopeRequest(next);
106
158
  return next;
107
159
  }
108
- if (!next.activeScopeKey && defaultActiveScopeKey) next.activeScopeKey = defaultActiveScopeKey;
160
+ if (!scopeIdOnly && !next.activeScopeKey && defaultActiveScopeKey) next.activeScopeKey = defaultActiveScopeKey;
161
+ assertAllowedScopeRequest(next);
109
162
  return next;
110
163
  }
111
164
 
@@ -114,6 +167,7 @@ function createMemoryServingRuntime(memoryCfg = {}, env = process.env) {
114
167
  assertCuratedRecallOpts,
115
168
  defaultActiveScopeKey,
116
169
  defaultActiveScopePath,
170
+ defaultAllowedScopeKeys,
117
171
  hasEvidenceBoundary,
118
172
  normalizeCuratedRecallRow,
119
173
  resolveMode,
@@ -123,10 +177,13 @@ function createMemoryServingRuntime(memoryCfg = {}, env = process.env) {
123
177
  }
124
178
 
125
179
  module.exports = {
180
+ assertAllowedScopeRequest,
126
181
  assertCuratedBootstrapOpts,
127
182
  assertCuratedRecallOpts,
128
183
  createMemoryServingRuntime,
129
184
  hasEvidenceBoundary,
130
185
  normalizeCuratedRecallRow,
186
+ normalizeScopeList,
187
+ requestedScopeKeys,
131
188
  splitScopePath,
132
189
  };
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+
3
+ function parseJsonObject(value, fallback = {}) {
4
+ if (value === null || value === undefined) return fallback;
5
+ if (typeof value === 'string') {
6
+ try {
7
+ const parsed = JSON.parse(value);
8
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback;
9
+ } catch {
10
+ return fallback;
11
+ }
12
+ }
13
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : fallback;
14
+ }
15
+
16
+ function rowTime(value) {
17
+ if (!value) return null;
18
+ const parsed = new Date(value);
19
+ return isNaN(parsed.getTime()) ? String(value) : parsed.toISOString();
20
+ }
21
+
22
+ function summarizeCompactionRun(row = {}) {
23
+ const output = parseJsonObject(row.output, {});
24
+ const plan = output.plan || output;
25
+ const candidates = Array.isArray(plan.candidates) ? plan.candidates : [];
26
+ const statusUpdates = Array.isArray(plan.statusUpdates || plan.status_updates)
27
+ ? (plan.statusUpdates || plan.status_updates)
28
+ : [];
29
+ return {
30
+ id: row.id,
31
+ kind: 'compaction',
32
+ status: row.status,
33
+ cadence: row.cadence,
34
+ periodStart: row.period_start,
35
+ periodEnd: row.period_end,
36
+ policyVersion: row.policy_version || null,
37
+ workerId: row.worker_id || null,
38
+ claimedAt: rowTime(row.claimed_at),
39
+ leaseExpiresAt: rowTime(row.lease_expires_at),
40
+ appliedAt: rowTime(row.applied_at),
41
+ reclaimedAt: rowTime(row.reclaimed_at),
42
+ error: row.error || null,
43
+ sourceCoverage: parseJsonObject(row.source_coverage, {}),
44
+ outputCoverage: parseJsonObject(row.output_coverage, {}),
45
+ candidateCount: candidates.length,
46
+ statusUpdateCount: statusUpdates.length,
47
+ createdAt: rowTime(row.created_at),
48
+ updatedAt: rowTime(row.updated_at),
49
+ };
50
+ }
51
+
52
+ function summarizeCheckpointRun(row = {}, sourceCount = 0) {
53
+ const payload = parseJsonObject(row.checkpoint_payload, {});
54
+ return {
55
+ id: row.id,
56
+ kind: 'checkpoint',
57
+ status: row.status,
58
+ scopeId: row.scope_id,
59
+ checkpointKey: row.checkpoint_key,
60
+ fromFinalizationIdExclusive: row.from_finalization_id_exclusive,
61
+ toFinalizationIdInclusive: row.to_finalization_id_inclusive,
62
+ windowStart: rowTime(row.window_start),
63
+ windowEnd: rowTime(row.window_end),
64
+ claimedAt: rowTime(row.claimed_at),
65
+ finalizedAt: rowTime(row.finalized_at),
66
+ error: row.error || null,
67
+ sourceCount,
68
+ checkpointPayloadKeys: Object.keys(payload).sort(),
69
+ createdAt: rowTime(row.created_at),
70
+ updatedAt: rowTime(row.updated_at),
71
+ };
72
+ }
73
+
74
+ function sourceValue(row = {}, key) {
75
+ const metadata = parseJsonObject(row.metadata, {});
76
+ return row[key] ?? metadata[key] ?? null;
77
+ }
78
+
79
+ function tableMissingError(error) {
80
+ return error && error.code === '42P01';
81
+ }
82
+
83
+ function createOperatorObservability({ pool, schema, defaultTenantId = 'default' }) {
84
+ async function compactionStatus(input = {}) {
85
+ const tenantId = input.tenantId || defaultTenantId;
86
+ const limit = Math.max(1, Math.min(50, input.limit || 10));
87
+ try {
88
+ const [latest, stale, counts] = await Promise.all([
89
+ pool.query(
90
+ `SELECT *
91
+ FROM ${schema}.compaction_runs
92
+ WHERE tenant_id = $1
93
+ ORDER BY COALESCE(applied_at, created_at) DESC, id DESC
94
+ LIMIT $2`,
95
+ [tenantId, limit],
96
+ ),
97
+ pool.query(
98
+ `SELECT *
99
+ FROM ${schema}.compaction_runs
100
+ WHERE tenant_id = $1
101
+ AND status = 'applying'
102
+ AND lease_expires_at IS NOT NULL
103
+ AND lease_expires_at < transaction_timestamp()
104
+ ORDER BY lease_expires_at ASC, id ASC
105
+ LIMIT $2`,
106
+ [tenantId, limit],
107
+ ),
108
+ pool.query(
109
+ `SELECT status, COUNT(*)::int AS count
110
+ FROM ${schema}.compaction_runs
111
+ WHERE tenant_id = $1
112
+ GROUP BY status`,
113
+ [tenantId],
114
+ ),
115
+ ]);
116
+ return {
117
+ available: true,
118
+ latest: latest.rows.map(summarizeCompactionRun),
119
+ staleClaims: stale.rows.map(summarizeCompactionRun),
120
+ statusCounts: Object.fromEntries(counts.rows.map(row => [row.status, row.count])),
121
+ };
122
+ } catch (error) {
123
+ if (tableMissingError(error)) {
124
+ return { available: false, latest: [], staleClaims: [], statusCounts: {}, error: 'compaction_runs table is missing' };
125
+ }
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ async function checkpointStatus(input = {}) {
131
+ const tenantId = input.tenantId || defaultTenantId;
132
+ const limit = Math.max(1, Math.min(50, input.limit || 10));
133
+ try {
134
+ const [latest, counts] = await Promise.all([
135
+ pool.query(
136
+ `SELECT c.*, COUNT(s.id)::int AS source_count
137
+ FROM ${schema}.checkpoint_runs c
138
+ LEFT JOIN ${schema}.checkpoint_run_sources s
139
+ ON s.tenant_id = c.tenant_id
140
+ AND s.checkpoint_run_id = c.id
141
+ WHERE c.tenant_id = $1
142
+ GROUP BY c.id
143
+ ORDER BY c.updated_at DESC, c.id DESC
144
+ LIMIT $2`,
145
+ [tenantId, limit],
146
+ ),
147
+ pool.query(
148
+ `SELECT status, COUNT(*)::int AS count
149
+ FROM ${schema}.checkpoint_runs
150
+ WHERE tenant_id = $1
151
+ GROUP BY status`,
152
+ [tenantId],
153
+ ),
154
+ ]);
155
+ return {
156
+ available: true,
157
+ latest: latest.rows.map(row => summarizeCheckpointRun(row, row.source_count || 0)),
158
+ statusCounts: Object.fromEntries(counts.rows.map(row => [row.status, row.count])),
159
+ };
160
+ } catch (error) {
161
+ if (tableMissingError(error)) {
162
+ return { available: false, latest: [], statusCounts: {}, error: 'checkpoint_runs table is missing' };
163
+ }
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ async function status(input = {}) {
169
+ const [compaction, checkpoint] = await Promise.all([
170
+ compactionStatus(input),
171
+ checkpointStatus(input),
172
+ ]);
173
+ return {
174
+ readOnly: true,
175
+ compaction,
176
+ checkpoint,
177
+ };
178
+ }
179
+
180
+ async function inspect(input = {}) {
181
+ const tenantId = input.tenantId || defaultTenantId;
182
+ const runId = input.runId || input.id;
183
+ if (!runId) throw new Error('runId is required');
184
+ const kind = input.kind || 'compaction';
185
+ if (kind === 'compaction') {
186
+ const result = await pool.query(
187
+ `SELECT *
188
+ FROM ${schema}.compaction_runs
189
+ WHERE tenant_id = $1
190
+ AND id = $2
191
+ LIMIT 1`,
192
+ [tenantId, runId],
193
+ );
194
+ if (!result.rows[0]) throw new Error('Compaction run not found');
195
+ return {
196
+ readOnly: true,
197
+ run: summarizeCompactionRun(result.rows[0]),
198
+ };
199
+ }
200
+ if (kind === 'checkpoint') {
201
+ const [run, sources] = await Promise.all([
202
+ pool.query(
203
+ `SELECT *
204
+ FROM ${schema}.checkpoint_runs
205
+ WHERE tenant_id = $1
206
+ AND id = $2
207
+ LIMIT 1`,
208
+ [tenantId, runId],
209
+ ),
210
+ pool.query(
211
+ `SELECT id, finalization_id, source_index, session_id, transcript_hash, finalized_at, metadata
212
+ FROM ${schema}.checkpoint_run_sources
213
+ WHERE tenant_id = $1
214
+ AND checkpoint_run_id = $2
215
+ ORDER BY source_index ASC, id ASC`,
216
+ [tenantId, runId],
217
+ ),
218
+ ]);
219
+ if (!run.rows[0]) throw new Error('Checkpoint run not found');
220
+ return {
221
+ readOnly: true,
222
+ run: summarizeCheckpointRun(run.rows[0], sources.rows.length),
223
+ sources: sources.rows.map(row => ({
224
+ id: row.id,
225
+ finalizationId: row.finalization_id,
226
+ sourceIndex: row.source_index,
227
+ sessionId: row.session_id,
228
+ agentId: sourceValue(row, 'agentId') || sourceValue(row, 'agent_id'),
229
+ source: sourceValue(row, 'source'),
230
+ status: sourceValue(row, 'status'),
231
+ transcriptHashPrefix: row.transcript_hash ? String(row.transcript_hash).slice(0, 12) : null,
232
+ finalizedAt: rowTime(row.finalized_at),
233
+ })),
234
+ };
235
+ }
236
+ throw new Error('kind must be compaction or checkpoint');
237
+ }
238
+
239
+ return {
240
+ status,
241
+ inspect,
242
+ };
243
+ }
244
+
245
+ module.exports = {
246
+ createOperatorObservability,
247
+ summarizeCompactionRun,
248
+ summarizeCheckpointRun,
249
+ };
@@ -81,6 +81,18 @@ const MIGRATION_PLAN = [
81
81
  { table: 'finalization_candidates', column: 'candidate_hash' },
82
82
  ],
83
83
  },
84
+ {
85
+ id: '019-v1-memory-review-resolutions',
86
+ file: '019-v1-memory-review-resolutions.sql',
87
+ always: true,
88
+ signature: [
89
+ 'memory_review_resolutions',
90
+ { index: 'idx_memory_review_resolutions_memory_latest' },
91
+ { index: 'idx_memory_review_resolutions_canonical_latest' },
92
+ { index: 'idx_memory_review_resolutions_defer_until' },
93
+ { index: 'idx_feedback_memory_review_latest' },
94
+ ],
95
+ },
84
96
  ];
85
97
 
86
98
  function createPostgresMigrationRuntime(opts = {}) {
@@ -296,6 +308,7 @@ function createPostgresMigrationRuntime(opts = {}) {
296
308
  ['016-v1-evidence-ref-multi-item.sql', '016-v1-evidence-ref-multi-item'],
297
309
  ['017-v1-memory-record-embeddings.sql', '017-v1-memory-record-embeddings'],
298
310
  ['018-v1-finalization-candidate-envelope.sql', '018-v1-finalization-candidate-envelope'],
311
+ ['019-v1-memory-review-resolutions.sql', '019-v1-memory-review-resolutions'],
299
312
  ]) {
300
313
  await client.query(loadSql(migration[0], schema));
301
314
  ddlExecuted.push(migration[1]);
@@ -6,6 +6,7 @@ const { createMemoryRecords } = require('./memory-records');
6
6
  const { createMemoryPromotion } = require('./memory-promotion');
7
7
  const { sanitizeSummaryResult } = require('./memory-safety-gate');
8
8
  const { buildFinalizationReview, buildSessionStartContext } = require('./finalization-review');
9
+ const { buildFinalizationInspection } = require('./finalization-inspector');
9
10
 
10
11
  function qi(identifier) { return `"${identifier}"`; }
11
12
 
@@ -15,6 +16,13 @@ function requireField(obj, field) {
15
16
  }
16
17
  }
17
18
 
19
+ function finalizationReadError(error) {
20
+ if (error && error.code === '42P01') {
21
+ return new Error('Finalization observability tables are not available. Run `aquifer migrate`, then retry this read-only command.');
22
+ }
23
+ return error;
24
+ }
25
+
18
26
  function hasStructuredContent(value) {
19
27
  return value && typeof value === 'object' && Object.keys(value).length > 0;
20
28
  }
@@ -190,7 +198,73 @@ function createSessionFinalization({
190
198
 
191
199
  async function list(input = {}) {
192
200
  const tenantId = input.tenantId || defaultTenantId || 'default';
193
- return storage.listSessionFinalizations(pool, input, { schema, tenantId });
201
+ try {
202
+ return await storage.listSessionFinalizations(pool, input, { schema, tenantId });
203
+ } catch (error) {
204
+ throw finalizationReadError(error);
205
+ }
206
+ }
207
+
208
+ async function inspect(input = {}) {
209
+ const tenantId = input.tenantId || defaultTenantId || 'default';
210
+ let row = null;
211
+
212
+ try {
213
+ if (input.id) {
214
+ row = await storage.getSessionFinalizationById(pool, {
215
+ tenantId,
216
+ id: input.id,
217
+ }, { schema, tenantId });
218
+ } else {
219
+ requireField(input, 'sessionId');
220
+ requireField(input, 'agentId');
221
+ requireField(input, 'source');
222
+ const phase = input.phase || 'curated_memory_v1';
223
+ if (input.transcriptHash) {
224
+ row = await storage.getSessionFinalization(pool, {
225
+ tenantId,
226
+ sessionId: input.sessionId,
227
+ agentId: input.agentId,
228
+ source: input.source,
229
+ transcriptHash: input.transcriptHash,
230
+ phase,
231
+ }, { schema, tenantId });
232
+ } else {
233
+ const rows = await storage.listSessionFinalizations(pool, {
234
+ tenantId,
235
+ sessionId: input.sessionId,
236
+ agentId: input.agentId,
237
+ source: input.source,
238
+ phase,
239
+ limit: 2,
240
+ }, { schema, tenantId });
241
+ if (rows.length > 1) {
242
+ const matches = rows.map(match => {
243
+ const hash = match.transcript_hash ? String(match.transcript_hash).slice(0, 12) : '?';
244
+ const updatedAt = match.updated_at || '?';
245
+ return `#${match.id} status=${match.status} phase=${match.phase} hash=${hash} updated=${updatedAt}`;
246
+ }).join('; ');
247
+ throw new Error(`Multiple finalizations matched. Add --transcript-hash or --id to inspect one row. Matches: ${matches}`);
248
+ }
249
+ row = rows[0] || null;
250
+ }
251
+ }
252
+
253
+ if (!row) throw new Error('Finalization not found');
254
+ const [candidates, lineage] = await Promise.all([
255
+ storage.listFinalizationCandidates(pool, {
256
+ tenantId,
257
+ finalizationId: row.id,
258
+ }, { schema, tenantId }),
259
+ storage.getFinalizationLineageSummary(pool, {
260
+ tenantId,
261
+ finalizationId: row.id,
262
+ }, { schema, tenantId }),
263
+ ]);
264
+ return buildFinalizationInspection(row, candidates, lineage);
265
+ } catch (error) {
266
+ throw finalizationReadError(error);
267
+ }
194
268
  }
195
269
 
196
270
  async function updateStatus(input = {}) {
@@ -436,6 +510,7 @@ function createSessionFinalization({
436
510
  createTask,
437
511
  get,
438
512
  list,
513
+ inspect,
439
514
  updateStatus,
440
515
  finalizeSession,
441
516
  };
package/core/storage.js CHANGED
@@ -614,6 +614,20 @@ async function getSessionFinalization(pool, input = {}, { schema, tenantId: defa
614
614
  return result.rows[0] || null;
615
615
  }
616
616
 
617
+ async function getSessionFinalizationById(pool, input = {}, { schema, tenantId: defaultTenantId } = {}) {
618
+ requireField(input, 'id');
619
+ const tenantId = input.tenantId || defaultTenantId || 'default';
620
+ const result = await pool.query(
621
+ `SELECT *
622
+ FROM ${qi(schema)}.session_finalizations
623
+ WHERE tenant_id = $1
624
+ AND id = $2
625
+ LIMIT 1`,
626
+ [tenantId, input.id]
627
+ );
628
+ return result.rows[0] || null;
629
+ }
630
+
617
631
  async function updateSessionFinalizationStatus(pool, input = {}, { schema, tenantId: defaultTenantId } = {}) {
618
632
  const status = normalizeFinalizationStatus(input.status);
619
633
  const tenantId = input.tenantId || defaultTenantId || 'default';
@@ -660,38 +674,137 @@ async function updateSessionFinalizationStatus(pool, input = {}, { schema, tenan
660
674
 
661
675
  async function listSessionFinalizations(pool, input = {}, { schema, tenantId: defaultTenantId } = {}) {
662
676
  const tenantId = input.tenantId || defaultTenantId || 'default';
677
+ const table = `${qi(schema)}.session_finalizations`;
663
678
  const params = [tenantId];
664
- const where = [`tenant_id = $1`];
679
+ const where = [`${table}.tenant_id = $1`];
665
680
  if (input.host) {
666
681
  params.push(input.host);
667
- where.push(`host = $${params.length}`);
682
+ where.push(`${table}.host = $${params.length}`);
668
683
  }
669
684
  if (input.status) {
670
685
  const statuses = Array.isArray(input.status) ? input.status : [input.status];
671
686
  for (const status of statuses) normalizeFinalizationStatus(status);
672
687
  params.push(statuses);
673
- where.push(`status = ANY($${params.length}::text[])`);
688
+ where.push(`${table}.status = ANY($${params.length}::text[])`);
674
689
  }
675
690
  if (input.agentId) {
676
691
  params.push(input.agentId);
677
- where.push(`agent_id = $${params.length}`);
692
+ where.push(`${table}.agent_id = $${params.length}`);
678
693
  }
679
694
  if (input.source) {
680
695
  params.push(input.source);
681
- where.push(`source = $${params.length}`);
696
+ where.push(`${table}.source = $${params.length}`);
697
+ }
698
+ if (input.sessionId) {
699
+ params.push(input.sessionId);
700
+ where.push(`${table}.session_id = $${params.length}`);
701
+ }
702
+ if (input.transcriptHash) {
703
+ params.push(input.transcriptHash);
704
+ where.push(`${table}.transcript_hash = $${params.length}`);
705
+ }
706
+ if (input.phase) {
707
+ params.push(input.phase);
708
+ where.push(`${table}.phase = $${params.length}`);
709
+ }
710
+ if (input.mode) {
711
+ params.push(normalizeFinalizationMode(input.mode));
712
+ where.push(`${table}.mode = $${params.length}`);
682
713
  }
683
714
  params.push(Math.max(1, Math.min(200, input.limit || 50)));
684
715
  const result = await pool.query(
685
- `SELECT *
686
- FROM ${qi(schema)}.session_finalizations
716
+ `SELECT
717
+ ${table}.id,
718
+ ${table}.tenant_id,
719
+ ${table}.source,
720
+ ${table}.host,
721
+ ${table}.agent_id,
722
+ ${table}.session_id,
723
+ ${table}.transcript_hash,
724
+ ${table}.phase,
725
+ ${table}.mode,
726
+ ${table}.status,
727
+ ${table}.finalizer_model,
728
+ ${table}.scope_kind,
729
+ ${table}.scope_key,
730
+ ${table}.context_key,
731
+ ${table}.topic_key,
732
+ ${table}.candidate_envelope_hash,
733
+ ${table}.candidate_envelope_version,
734
+ ${table}.error,
735
+ ${table}.claimed_at,
736
+ ${table}.finalized_at,
737
+ ${table}.created_at,
738
+ ${table}.updated_at,
739
+ (
740
+ SELECT COUNT(*)::int
741
+ FROM ${qi(schema)}.finalization_candidates fc
742
+ WHERE fc.tenant_id = ${table}.tenant_id
743
+ AND fc.finalization_id = ${table}.id
744
+ ) AS candidate_count
745
+ FROM ${table}
687
746
  WHERE ${where.join(' AND ')}
688
- ORDER BY updated_at DESC, id DESC
747
+ ORDER BY ${table}.updated_at DESC, ${table}.id DESC
689
748
  LIMIT $${params.length}`,
690
749
  params
691
750
  );
692
751
  return result.rows;
693
752
  }
694
753
 
754
+ async function getFinalizationLineageSummary(pool, input = {}, { schema, tenantId: defaultTenantId } = {}) {
755
+ requireField(input, 'finalizationId');
756
+ const tenantId = input.tenantId || defaultTenantId || 'default';
757
+ const result = await pool.query(
758
+ `SELECT
759
+ COALESCE((
760
+ SELECT array_agg(id ORDER BY id)
761
+ FROM ${qi(schema)}.memory_records
762
+ WHERE tenant_id = $1
763
+ AND created_by_finalization_id = $2
764
+ ), ARRAY[]::bigint[]) AS memory_record_ids,
765
+ COALESCE((
766
+ SELECT array_agg(id ORDER BY id)
767
+ FROM ${qi(schema)}.fact_assertions_v1
768
+ WHERE tenant_id = $1
769
+ AND created_by_finalization_id = $2
770
+ ), ARRAY[]::bigint[]) AS fact_assertion_ids,
771
+ COALESCE((
772
+ SELECT COUNT(*)::int
773
+ FROM ${qi(schema)}.evidence_refs
774
+ WHERE tenant_id = $1
775
+ AND created_by_finalization_id = $2
776
+ ), 0) AS evidence_ref_count,
777
+ COALESCE((
778
+ SELECT COUNT(*)::int
779
+ FROM ${qi(schema)}.evidence_items
780
+ WHERE tenant_id = $1
781
+ AND created_by_finalization_id = $2
782
+ ), 0) AS evidence_item_count`,
783
+ [tenantId, input.finalizationId]
784
+ );
785
+ const row = result.rows[0] || {};
786
+ return {
787
+ memoryRecordIds: row.memory_record_ids || [],
788
+ factAssertionIds: row.fact_assertion_ids || [],
789
+ evidenceRefCount: row.evidence_ref_count || 0,
790
+ evidenceItemCount: row.evidence_item_count || 0,
791
+ };
792
+ }
793
+
794
+ async function listFinalizationCandidates(pool, input = {}, { schema, tenantId: defaultTenantId } = {}) {
795
+ requireField(input, 'finalizationId');
796
+ const tenantId = input.tenantId || defaultTenantId || 'default';
797
+ const result = await pool.query(
798
+ `SELECT *
799
+ FROM ${qi(schema)}.finalization_candidates
800
+ WHERE tenant_id = $1
801
+ AND finalization_id = $2
802
+ ORDER BY candidate_index ASC, id ASC`,
803
+ [tenantId, input.finalizationId]
804
+ );
805
+ return result.rows;
806
+ }
807
+
695
808
  function candidateText(candidate = {}) {
696
809
  if (typeof candidate === 'string') return candidate.trim();
697
810
  const payload = candidate.payload && typeof candidate.payload === 'object' ? candidate.payload : null;
@@ -1236,8 +1349,11 @@ module.exports = {
1236
1349
  recordAccess,
1237
1350
  upsertSessionFinalization,
1238
1351
  getSessionFinalization,
1352
+ getSessionFinalizationById,
1239
1353
  updateSessionFinalizationStatus,
1240
1354
  listSessionFinalizations,
1355
+ getFinalizationLineageSummary,
1356
+ listFinalizationCandidates,
1241
1357
  upsertCheckpointRun,
1242
1358
  updateCheckpointRunStatus,
1243
1359
  listCheckpointRuns,