@dreamforgestudiollc/emet 0.0.0-stage → 0.2.6

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.
Files changed (114) hide show
  1. package/.env.example +189 -0
  2. package/CHANGELOG.md +360 -0
  3. package/CHARTER.md +137 -0
  4. package/LICENSE +22 -0
  5. package/README.md +868 -2
  6. package/docs/AUTHORSHIP.md +26 -0
  7. package/docs/BENCHMARKS.md +261 -0
  8. package/docs/BOOTSTRAP.md +127 -0
  9. package/docs/BOTS.md +83 -0
  10. package/docs/COVERAGE.md +64 -0
  11. package/docs/DEPLOY.md +251 -0
  12. package/docs/DISCIPLINES.md +29 -0
  13. package/docs/GUARDRAILS-AND-MONORAILS.md +108 -0
  14. package/docs/GUIDED-SETUP.md +301 -0
  15. package/docs/PERMISSIONS.md +121 -0
  16. package/docs/ROADMAP.md +62 -0
  17. package/docs/SETUP.md +265 -0
  18. package/docs/SUBKEYS.md +59 -0
  19. package/gems/artifact-index.json +12 -0
  20. package/gems/bot-team-backup/floor.json +15 -0
  21. package/gems/bot-team-backup/gem.json +43 -0
  22. package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
  23. package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
  24. package/gems/fieldnote/floor.json +14 -0
  25. package/gems/fieldnote/gem.json +70 -0
  26. package/gems/fieldnote/runes/fieldnote.js +4 -0
  27. package/gems/lean-six-sigma/CHANGELOG.md +10 -0
  28. package/gems/lean-six-sigma/README.md +92 -0
  29. package/gems/lean-six-sigma/floor.json +31 -0
  30. package/gems/lean-six-sigma/gem.json +1296 -0
  31. package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
  32. package/gems/lean-six-sigma/guides/BELTS.md +52 -0
  33. package/gems/lean-six-sigma/guides/DMADV.md +56 -0
  34. package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
  35. package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
  36. package/gems/lean-six-sigma/guides/LEAN.md +97 -0
  37. package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
  38. package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
  39. package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
  40. package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
  41. package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
  42. package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
  43. package/gems/neurendipity/floor.json +65 -0
  44. package/gems/neurendipity/gem.json +42 -0
  45. package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
  46. package/gems/neurendipity/guides/NEURODIVERGENT_SUPPORT.md +287 -0
  47. package/guides/README.md +3 -0
  48. package/package.json +76 -4
  49. package/server.json +79 -0
  50. package/src/aleph.js +395 -0
  51. package/src/annotations.js +170 -0
  52. package/src/approve-throttle.js +54 -0
  53. package/src/atlas-role.js +58 -0
  54. package/src/attribution.js +555 -0
  55. package/src/conformance.js +688 -0
  56. package/src/content_analyzer.js +450 -0
  57. package/src/database.js +812 -0
  58. package/src/disciplines.js +26 -0
  59. package/src/dispatch.js +382 -0
  60. package/src/env.js +97 -0
  61. package/src/floor.js +228 -0
  62. package/src/gaps.js +509 -0
  63. package/src/gem-check.js +1359 -0
  64. package/src/guides.js +75 -0
  65. package/src/host-reach.js +23 -0
  66. package/src/http-remote.js +313 -0
  67. package/src/index.js +397 -0
  68. package/src/invites.js +167 -0
  69. package/src/layers.js +449 -0
  70. package/src/lessons-to-checks.js +366 -0
  71. package/src/logger.js +300 -0
  72. package/src/metadata.js +61 -0
  73. package/src/module-path.js +32 -0
  74. package/src/mongo-connect.js +91 -0
  75. package/src/named-items.js +208 -0
  76. package/src/oauth-provider.js +838 -0
  77. package/src/owner-approvals.js +281 -0
  78. package/src/persons.js +136 -0
  79. package/src/provenance.js +257 -0
  80. package/src/redact.js +237 -0
  81. package/src/request-log.js +124 -0
  82. package/src/rune-dispatch.js +360 -0
  83. package/src/rune-registry.js +71 -0
  84. package/src/rune-scope.js +42 -0
  85. package/src/scopes.js +100 -0
  86. package/src/security-headers.js +26 -0
  87. package/src/session-graph.js +947 -0
  88. package/src/session.js +1097 -0
  89. package/src/setup.js +1580 -0
  90. package/src/socket.js +545 -0
  91. package/src/startup-pages.js +371 -0
  92. package/src/startup-payload.js +129 -0
  93. package/src/subkey-store.js +357 -0
  94. package/src/subkeys.js +947 -0
  95. package/src/tools.js +4508 -0
  96. package/src/transcript-session.js +106 -0
  97. package/src/validation.js +1187 -0
  98. package/src/version.js +2 -0
  99. package/templates/BOARD.md +72 -0
  100. package/templates/BOTHANDOFF.md +57 -0
  101. package/templates/CHARACTER.md +30 -0
  102. package/templates/HANDOFF.md +63 -0
  103. package/templates/IDENTITY.md +29 -0
  104. package/templates/INDEX.md +82 -0
  105. package/templates/INSTALL.md +29 -0
  106. package/templates/PROCEDURE.md +39 -0
  107. package/templates/ROSTER.md +43 -0
  108. package/templates/SEED.md +65 -0
  109. package/templates/STAGING.md +37 -0
  110. package/templates/STANDARD.md +99 -0
  111. package/templates/THREAD.md +43 -0
  112. package/templates/USER.md +32 -0
  113. package/verification/load-gate/corpus.json +1765 -0
  114. package/verification/load-gate/load-gate.mjs +1246 -0
package/src/tools.js ADDED
@@ -0,0 +1,4508 @@
1
+ /**
2
+ * EMET - Enduring Memory & Epistemic Tiers
3
+ * Copyright (c) 2026 Dreamforge Studio LLC
4
+ *
5
+ * Derived from CASCADE Memory System
6
+ * Copyright (c) 2025-2026 CIPS Corp (C.I.P.S. LLC)
7
+ * Licensed under the MIT License - see LICENSE
8
+ *
9
+ * https://github.com/For-Sunny/cascade-memory-enterprise
10
+ *
11
+ * Tools Module - MCP tool definitions and handlers
12
+ */
13
+
14
+ import { connectMongo } from './mongo-connect.js';
15
+ import { resolveModulePath } from './module-path.js';
16
+ import { replacedByOf } from './setup.js';
17
+ import { currentActor, rawActor, writerStamp, boundSourceTag, declaredAttribution, LOCAL_SUBJECT } from './persons.js';
18
+ import { approvedTags, APPROVALS_DOC } from './attribution.js';
19
+ import { sealAttestation, frozenAttestation } from './rune-dispatch.js';
20
+
21
+ /** Approvals record io for attribution.js (server-kept doc, written as emet-server). */
22
+ export const approvalsDeps = {
23
+ readDoc: (id) => readDoc(id),
24
+ // Compare-and-set on the stored version (the reviewer 3). mutateApprovals serializes
25
+ // writers in this process; this check catches a writer elsewhere.
26
+ writeDoc: async (id, content, expectedVersion) => {
27
+ // Atomic across processes: writeDoc filters the update on { doc_id, version: expected }.
28
+ return writeDoc(id, content, 'emet-server', null, null, { serverProvision: true, approvalsRecord: true, expectedVersion: expectedVersion ?? null });
29
+ }
30
+ };
31
+ import {
32
+ ValidationError,
33
+ VALID_LAYERS,
34
+ CONTENT_LIMITS,
35
+ NUMERIC_LIMITS,
36
+ validateLayer,
37
+ validateContent,
38
+ validateMetadata,
39
+ relocatedFields,
40
+ isPlainObject,
41
+ validateLimit,
42
+ validateQueryOptions,
43
+ RECOGNISED_FILTER_KEYS,
44
+ MARKER_FIELDS
45
+ } from './validation.js';
46
+ // The layer charter is the vocabulary home (2026-09-07): which layers may be
47
+ // revised, the importance band, and whether a provenance pointer is required
48
+ // are all read from it, not from constants here.
49
+ import { policyFor, bandFor, resolveImportance, FALLBACK_LAYER } from './layers.js';
50
+ // The full routing decision (confidence, signals, emotional intensity) is kept
51
+ // on every write so the receipt can say how the entry was placed (B1/B2).
52
+ import { ContentAnalyzer } from './content_analyzer.js';
53
+ const ROUTER = new ContentAnalyzer();
54
+ // Template validation (STANDARD 9). Runs inside writeDoc() on every document
55
+ // write, with no argument that can switch it off (9a L2). It marks; it never
56
+ // rejects, alters, or blocks a write.
57
+ import {
58
+ validateDocument, templateFor, isGovernedByShippedCopy, isDropDoc, buildAcceptance,
59
+ loadShipped, parseTemplate, checkRecord, VERDICT as CONFORMANCE_VERDICT
60
+ } from './conformance.js';
61
+ // Shipped guides (2026-09-28): read on demand; the release copy governs.
62
+ import { readShippedGuide } from './guides.js';
63
+
64
+ import {
65
+ EmetError,
66
+ DatabaseError,
67
+ ErrorCodes,
68
+ StatusCodes,
69
+ MEMORY_LAYERS,
70
+ MONGODB_DB,
71
+ COLLECTION_PREFIX,
72
+ determineLayer,
73
+ containsRegex,
74
+ sortFor,
75
+ buildRankPipeline,
76
+ buildMemoryFilter,
77
+ sanitizeErrorMessage,
78
+ sanitizeDetails
79
+ } from './database.js';
80
+
81
+ // MongoDB for Tier 2 transcript storage
82
+ import { setting } from './env.js';
83
+ // The session graph's governed-tool list (2026-09-29): one list, used for the schemas below.
84
+ import { GRAPH_WRITE_TOOLS, isBotTag, mainRecordIds, botRecordDir } from './session-graph.js';
85
+ // Startup page completion (2026-10-03): which startup pages a host never fetched, read at session open.
86
+ import { pageCompletion, noteOpenMissing, cachedLoad } from './startup-pages.js';
87
+ // Secret redaction (2026-09-07). Every write path below passes its content
88
+ // through this before the digest is computed, so the stored sha256 is the
89
+ // digest of what was stored. The result of each write reports `redacted` -
90
+ // the count of masked spans - because a control that acts silently is a
91
+ // summary, and the record must show that something was removed.
92
+ import { redactWithReport, redactDeep } from './redact.js';
93
+ import { EMET_VERSION } from './version.js';
94
+
95
+ // MongoDB connection URI — must be set via MONGODB_URI environment variable in .mcp.json
96
+ const MONGODB_URI = process.env.MONGODB_URI;
97
+ if (!MONGODB_URI) {
98
+ console.error("[EMET] WARNING: MONGODB_URI not set — MongoDB tools will fail");
99
+ }
100
+
101
+ // ============================================
102
+ // RATE LIMITING
103
+ // ============================================
104
+
105
+ /**
106
+ * Rate Limiter Configuration
107
+ */
108
+ export const RATE_LIMIT_CONFIG = {
109
+ GLOBAL_WINDOW_MS: 60000,
110
+ GLOBAL_MAX_REQUESTS: 300,
111
+ TOOL_WINDOW_MS: 60000,
112
+ TOOL_MAX_REQUESTS: {
113
+ recall: 120,
114
+ semantic_recall: 120,
115
+ query_layer: 100,
116
+ emet_status: 30,
117
+ save_to_layer: 60
118
+ },
119
+ DEFAULT_TOOL_MAX: 60,
120
+ CLEANUP_INTERVAL_MS: 300000
121
+ };
122
+
123
+ /**
124
+ * Rate Limit Error class
125
+ */
126
+ export class RateLimitError extends Error {
127
+ constructor(message, retryAfterMs = 60000) {
128
+ super(message);
129
+ this.name = 'RateLimitError';
130
+ this.retryAfterMs = retryAfterMs;
131
+ this.statusCode = 429;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * In-Memory Rate Limiter
137
+ */
138
+ export class RateLimiter {
139
+ constructor(logger = null) {
140
+ this.buckets = new Map();
141
+ this.logger = logger;
142
+
143
+ this.cleanupInterval = setInterval(() => this.cleanup(), RATE_LIMIT_CONFIG.CLEANUP_INTERVAL_MS);
144
+
145
+ if (this.cleanupInterval.unref) {
146
+ this.cleanupInterval.unref();
147
+ }
148
+
149
+ this.log('debug', 'Rate limiter initialized', {
150
+ globalLimit: RATE_LIMIT_CONFIG.GLOBAL_MAX_REQUESTS,
151
+ cleanupIntervalMs: RATE_LIMIT_CONFIG.CLEANUP_INTERVAL_MS
152
+ });
153
+ }
154
+
155
+ log(level, message, context = {}) {
156
+ if (this.logger) {
157
+ this.logger[level](message, context);
158
+ }
159
+ }
160
+
161
+ _bucket(key) {
162
+ const k = key || 'stdio';
163
+ let bucket = this.buckets.get(k);
164
+ if (!bucket) {
165
+ bucket = { global: [], tools: new Map() };
166
+ this.buckets.set(k, bucket);
167
+ }
168
+ return bucket;
169
+ }
170
+
171
+ checkLimit(toolName, bucketKey = 'stdio') {
172
+ const now = Date.now();
173
+ this.pruneExpired(now);
174
+ const bucket = this._bucket(bucketKey);
175
+
176
+ const globalWindowStart = now - RATE_LIMIT_CONFIG.GLOBAL_WINDOW_MS;
177
+ const globalCount = bucket.global.filter(ts => ts > globalWindowStart).length;
178
+
179
+ if (globalCount >= RATE_LIMIT_CONFIG.GLOBAL_MAX_REQUESTS) {
180
+ const oldestInWindow = Math.min(...bucket.global.filter(ts => ts > globalWindowStart));
181
+ const retryAfterMs = (oldestInWindow + RATE_LIMIT_CONFIG.GLOBAL_WINDOW_MS) - now;
182
+
183
+ return {
184
+ allowed: false,
185
+ reason: `Global rate limit exceeded: ${globalCount}/${RATE_LIMIT_CONFIG.GLOBAL_MAX_REQUESTS} requests per minute`,
186
+ retryAfterMs: Math.max(retryAfterMs, 1000)
187
+ };
188
+ }
189
+
190
+ const toolWindowStart = now - RATE_LIMIT_CONFIG.TOOL_WINDOW_MS;
191
+ const toolTimestamps = bucket.tools.get(toolName) || [];
192
+ const toolCount = toolTimestamps.filter(ts => ts > toolWindowStart).length;
193
+ const toolMax = RATE_LIMIT_CONFIG.TOOL_MAX_REQUESTS[toolName] || RATE_LIMIT_CONFIG.DEFAULT_TOOL_MAX;
194
+
195
+ if (toolCount >= toolMax) {
196
+ const oldestToolInWindow = Math.min(...toolTimestamps.filter(ts => ts > toolWindowStart));
197
+ const retryAfterMs = (oldestToolInWindow + RATE_LIMIT_CONFIG.TOOL_WINDOW_MS) - now;
198
+
199
+ return {
200
+ allowed: false,
201
+ reason: `Tool '${toolName}' rate limit exceeded: ${toolCount}/${toolMax} requests per minute`,
202
+ retryAfterMs: Math.max(retryAfterMs, 1000)
203
+ };
204
+ }
205
+
206
+ return { allowed: true };
207
+ }
208
+
209
+ recordRequest(toolName, bucketKey = 'stdio') {
210
+ const now = Date.now();
211
+ const bucket = this._bucket(bucketKey);
212
+ bucket.global.push(now);
213
+
214
+ if (!bucket.tools.has(toolName)) {
215
+ bucket.tools.set(toolName, []);
216
+ }
217
+ bucket.tools.get(toolName).push(now);
218
+ }
219
+
220
+ pruneExpired(now = Date.now()) {
221
+ const globalCutoff = now - RATE_LIMIT_CONFIG.GLOBAL_WINDOW_MS;
222
+ const toolCutoff = now - RATE_LIMIT_CONFIG.TOOL_WINDOW_MS;
223
+
224
+ for (const [key, bucket] of this.buckets) {
225
+ bucket.global = bucket.global.filter(ts => ts > globalCutoff);
226
+ for (const [toolName, timestamps] of bucket.tools.entries()) {
227
+ const pruned = timestamps.filter(ts => ts > toolCutoff);
228
+ if (pruned.length === 0) bucket.tools.delete(toolName);
229
+ else bucket.tools.set(toolName, pruned);
230
+ }
231
+ if (bucket.global.length === 0 && bucket.tools.size === 0) this.buckets.delete(key);
232
+ }
233
+ }
234
+
235
+ cleanup() {
236
+ const count = (map) => {
237
+ let n = 0;
238
+ for (const bucket of map.values()) {
239
+ n += bucket.global.length + Array.from(bucket.tools.values()).reduce((sum, arr) => sum + arr.length, 0);
240
+ }
241
+ return n;
242
+ };
243
+ const before = count(this.buckets);
244
+ this.pruneExpired();
245
+ const after = count(this.buckets);
246
+
247
+ if (before !== after) {
248
+ this.log('debug', 'Rate limiter cleanup completed', {
249
+ entriesBefore: before,
250
+ entriesAfter: after,
251
+ entriesRemoved: before - after
252
+ });
253
+ }
254
+ }
255
+
256
+ getStatus(bucketKey = 'stdio') {
257
+ const now = Date.now();
258
+ const globalWindowStart = now - RATE_LIMIT_CONFIG.GLOBAL_WINDOW_MS;
259
+ const bucket = this.buckets.get(bucketKey || 'stdio') || { global: [], tools: new Map() };
260
+
261
+ const status = {
262
+ bucket: bucketKey || 'stdio',
263
+ global: {
264
+ current: bucket.global.filter(ts => ts > globalWindowStart).length,
265
+ limit: RATE_LIMIT_CONFIG.GLOBAL_MAX_REQUESTS,
266
+ windowMs: RATE_LIMIT_CONFIG.GLOBAL_WINDOW_MS
267
+ },
268
+ tools: {}
269
+ };
270
+
271
+ const toolWindowStart = now - RATE_LIMIT_CONFIG.TOOL_WINDOW_MS;
272
+ for (const [toolName, timestamps] of bucket.tools.entries()) {
273
+ const count = timestamps.filter(ts => ts > toolWindowStart).length;
274
+ const limit = RATE_LIMIT_CONFIG.TOOL_MAX_REQUESTS[toolName] || RATE_LIMIT_CONFIG.DEFAULT_TOOL_MAX;
275
+ status.tools[toolName] = { current: count, limit };
276
+ }
277
+
278
+ return status;
279
+ }
280
+
281
+ stop() {
282
+ if (this.cleanupInterval) {
283
+ clearInterval(this.cleanupInterval);
284
+ this.cleanupInterval = null;
285
+ }
286
+ }
287
+ }
288
+
289
+ /** Stdio shares one bucket per process. HTTP is per person and client id. */
290
+ export function rateBucket(actor) {
291
+ if (!actor || actor.transport !== 'http') return 'stdio';
292
+ return `${actor.personId || 'anonymous'}|${actor.clientId || 'anonymous'}`;
293
+ }
294
+
295
+ // ============================================
296
+ // ERROR HANDLING
297
+ // ============================================
298
+
299
+ /**
300
+ * Wrap ValidationError to match EmetError interface
301
+ */
302
+ function wrapValidationError(error) {
303
+ return {
304
+ success: false,
305
+ error: {
306
+ code: ErrorCodes.VALIDATION_ERROR,
307
+ message: sanitizeErrorMessage(error.message),
308
+ statusCode: StatusCodes.BAD_REQUEST,
309
+ timestamp: Date.now(),
310
+ field: error.field || 'unknown',
311
+ validationMessage: sanitizeErrorMessage(error.validationMessage || error.message)
312
+ }
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Central error handler for all tool operations
318
+ */
319
+ export function handleError(error, toolName, logger = null) {
320
+ let errorResponse;
321
+
322
+ if (error instanceof EmetError) {
323
+ errorResponse = error.toSafeJSON();
324
+ } else if (error instanceof ValidationError) {
325
+ errorResponse = wrapValidationError(error);
326
+ } else if (error instanceof RateLimitError) {
327
+ errorResponse = {
328
+ success: false,
329
+ error: {
330
+ code: ErrorCodes.RATE_LIMIT_EXCEEDED,
331
+ message: sanitizeErrorMessage(error.message),
332
+ statusCode: StatusCodes.RATE_LIMITED,
333
+ timestamp: Date.now(),
334
+ retryAfterMs: error.retryAfterMs,
335
+ retryAfterSeconds: Math.ceil(error.retryAfterMs / 1000)
336
+ }
337
+ };
338
+ } else if (error && error.code && error.code.startsWith('SQLITE')) {
339
+ errorResponse = {
340
+ success: false,
341
+ error: {
342
+ code: ErrorCodes.DATABASE_ERROR,
343
+ message: 'Database operation failed',
344
+ statusCode: StatusCodes.INTERNAL_ERROR,
345
+ timestamp: Date.now(),
346
+ details: {
347
+ sqliteCode: error.code
348
+ }
349
+ }
350
+ };
351
+ } else {
352
+ errorResponse = {
353
+ success: false,
354
+ error: {
355
+ code: ErrorCodes.INTERNAL_ERROR,
356
+ message: sanitizeErrorMessage(error?.message || 'Unknown error'),
357
+ statusCode: StatusCodes.INTERNAL_ERROR,
358
+ timestamp: Date.now()
359
+ }
360
+ };
361
+ }
362
+
363
+ errorResponse.error.tool = toolName;
364
+
365
+ // Read at call time. Never attach a stack on the HTTP server.
366
+ if (process.env.DEBUG === 'true' && error?.stack && currentActor().transport !== 'http') {
367
+ errorResponse.error._debug = {
368
+ originalMessage: error.message,
369
+ stack: error.stack.split('\n').slice(0, 5).join('\n')
370
+ };
371
+ }
372
+
373
+ if (logger) {
374
+ logger.error('Tool error occurred', {
375
+ tool: toolName,
376
+ errorName: error?.name || 'Error',
377
+ errorCode: error?.code || 'UNKNOWN',
378
+ error
379
+ });
380
+ }
381
+
382
+ return {
383
+ content: [{
384
+ type: "text",
385
+ text: JSON.stringify(errorResponse)
386
+ }],
387
+ isError: true
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Create success response with consistent format
393
+ */
394
+ export function createSuccessResponse(data, toolName) {
395
+ // A LIST payload can carry an advisory the caller needs to SEE - an ignored
396
+ // filter key, say - and an array has nowhere to put one: JSON.stringify drops
397
+ // non-index properties, and wrapping the array in an object would break every
398
+ // caller that reads `data` as a list. So a tool attaches advisories with
399
+ // attachWarnings() and they are hoisted here, beside `data`.
400
+ //
401
+ // Restricted to arrays deliberately. Object payloads such as the write
402
+ // receipt already carry their own `warnings` field INSIDE `data`; hoisting
403
+ // that one would duplicate it and change a shape callers already read.
404
+ const warnings = Array.isArray(data) && Array.isArray(data.warnings) && data.warnings.length > 0
405
+ ? data.warnings
406
+ : null;
407
+
408
+ return {
409
+ content: [{
410
+ type: "text",
411
+ text: JSON.stringify({
412
+ success: true,
413
+ tool: toolName,
414
+ timestamp: Date.now(),
415
+ ...(warnings ? { warnings } : {}),
416
+ data
417
+ })
418
+ // Compact on purpose (code audit 2026-09-27, 2.6): the two-space indent was
419
+ // ~12% of every response - 9,400 characters of the initialize payload -
420
+ // and hosts parse JSON; nobody reads the whitespace.
421
+ }]
422
+ };
423
+ }
424
+
425
+ /**
426
+ * Attach caller-facing advisories to an array payload without changing what the
427
+ * array itself serialises to. Non-enumerable, so JSON.stringify(list) is
428
+ * unaffected and any code iterating the list sees exactly what it saw before;
429
+ * createSuccessResponse is what lifts them into the response envelope.
430
+ * No-op unless both arguments are arrays and there is something to say.
431
+ */
432
+ export function attachWarnings(list, warnings) {
433
+ if (!Array.isArray(list) || !Array.isArray(warnings) || warnings.length === 0) {
434
+ return list;
435
+ }
436
+ Object.defineProperty(list, 'warnings', {
437
+ value: warnings,
438
+ enumerable: false,
439
+ configurable: true
440
+ });
441
+ return list;
442
+ }
443
+
444
+ // ============================================
445
+ // MEMORY OPERATIONS
446
+ // ============================================
447
+
448
+ /**
449
+ * Save a memory record.
450
+ */
451
+ export async function saveMemory(dbManager, content, layer = null, metadata = {}, logger = null, auditOperation = null, options = {}) {
452
+ const startTime = Date.now();
453
+ const requestId = logger?._generateRequestId?.() || `req-${Date.now()}`;
454
+
455
+ try {
456
+ // Redact BEFORE validation and BEFORE the digest: what is stored, hashed
457
+ // and read back is the scrubbed text. The count travels to the result.
458
+ const redaction = redactWithReport(content);
459
+ const validatedContent = validateContent(redaction.text);
460
+ const validatedLayer = layer ? validateLayer(layer) : null;
461
+
462
+ // The tier-1 -> tier-2 pointer is pulled out BEFORE metadata validation and
463
+ // stored as top-level columns, not inside metadata. When this was written
464
+ // metadata was a JSON string the store could not filter into; it is a
465
+ // subdocument now (EMET ref 6), but the pointer stays top-level because it is
466
+ // indexed there and the reverse lookup depends on that index.
467
+ const { derived_from: rawProvenance, ...restMetadata } = metadata || {};
468
+ let provenance = validateProvenance(rawProvenance);
469
+ const metaRedaction = redactDeep(restMetadata);
470
+ redaction.hits += metaRedaction.hits;
471
+ const incoming = { ...(metaRedaction.value || {}) };
472
+ // The accountability markers are the door's verdict, not the caller's
473
+ // claim: whatever the caller sent under these names is dropped and the
474
+ // door writes what it actually found.
475
+ for (const marker of MARKER_FIELDS) delete incoming[marker];
476
+ // A null importance / emotional_intensity is "not supplied", not a value.
477
+ // Left in place, the validator would turn a null importance into its own
478
+ // fixed 0.7 and the charter band below would never be consulted.
479
+ if (incoming.importance === null || incoming.importance === undefined) delete incoming.importance;
480
+ if (incoming.emotional_intensity === null || incoming.emotional_intensity === undefined) delete incoming.emotional_intensity;
481
+ // Bucket words are resolved after the layer is known. Pull them off so
482
+ // validateMetadata does not reject a string on the numeric field.
483
+ const rawImportance = incoming.importance;
484
+ const rawBucket = incoming.importance_bucket;
485
+ const rawDegree = incoming.importance_degree;
486
+ delete incoming.importance;
487
+ delete incoming.importance_bucket;
488
+ delete incoming.importance_degree;
489
+ // E3: absent (undefined) is the only condition that earns the marker. An
490
+ // explicit null is the caller saying "unknown", which is an answer.
491
+ const assertionOriginAbsent = incoming.assertion_origin === undefined;
492
+ const validatedMetadata = validateMetadata(incoming);
493
+ // A6: what the whitelist moved under metadata.custom is reported in the
494
+ // receipt, so a field that landed somewhere other than where the caller put
495
+ // it is visible at write time instead of discovered at read time.
496
+ const relocatedToCustom = relocatedFields(validatedMetadata);
497
+ const warnings = [];
498
+
499
+ // B1: the full routing decision is computed on every write, whether or not
500
+ // the caller named a layer, so the receipt can say how the entry was placed.
501
+ // B5 (decided 2026-09-07): an unlabelled write is NOT placed by either
502
+ // router. Both were measured against the record (keyword 41.4%, analyzer
503
+ // 36.1%) and neither is fit to put material in a durable layer by guess.
504
+ // It lands in the charter's fallback layer, flagged routing.method
505
+ // 'fallback', and both routers' opinions are recorded beside it so the
506
+ // decision is reviewable. Naming a layer is the norm; the receipt says so.
507
+ const decision = ROUTER.analyze(validatedContent, {});
508
+ const keywordLayer = determineLayer(validatedContent, validatedMetadata);
509
+ const targetLayer = validatedLayer || FALLBACK_LAYER;
510
+ // Lesson fields are refused at the door when the reader would never count
511
+ // them (2026-09-29, procedural ref 397): a malformed key, a key off the meta
512
+ // layer, or a disposition that settles nothing. Checked on the caller's
513
+ // metadata as sent, before anything is written.
514
+ {
515
+ const { checkLessonFields } = await import('./lessons-to-checks.js');
516
+ const lessonProblem = checkLessonFields(targetLayer, incoming);
517
+ if (lessonProblem) throw new ValidationError('metadata.lesson', `${lessonProblem} Nothing was written.`);
518
+ }
519
+ if (!validatedLayer) {
520
+ warnings.push(
521
+ `no layer given: landed in ${FALLBACK_LAYER} (fallback). Name the layer - ` +
522
+ `the routers are recorded in routing but do not place entries.`
523
+ );
524
+ }
525
+
526
+ // A3: the source tag is decided HERE and nowhere else.
527
+ // Stdio: the caller's tag wins; the configured default is used and marked;
528
+ // nothing at all is marked too.
529
+ // HTTP: the tag bound at approval wins. A token issued before tags were
530
+ // stored (no bound tag) may use the caller's tag only when that tag is an
531
+ // exact member of EMET_SOURCE_TAGS. The shared default is not applied there.
532
+ const sourceDecision = resolvedSourceTag(validatedMetadata.source);
533
+ if (sourceDecision.from === 'caller') {
534
+ const defaultTag = setting('SOURCE_TAG');
535
+ if (sourceDecision.tag) {
536
+ validatedMetadata.source = sourceDecision.tag;
537
+ } else if (typeof defaultTag === 'string' && defaultTag.trim() !== '') {
538
+ validatedMetadata.source = defaultTag;
539
+ validatedMetadata.source_defaulted = true;
540
+ } else {
541
+ validatedMetadata.source = null;
542
+ validatedMetadata.source_missing = true;
543
+ }
544
+ } else {
545
+ validatedMetadata.source = sourceDecision.tag;
546
+ if (sourceDecision.from === 'declared' || sourceDecision.from === 'auto' || sourceDecision.from === 'subkey') {
547
+ validatedMetadata.source_basis = sourceDecision.from;
548
+ if (sourceDecision.login) validatedMetadata.source_login = sourceDecision.login;
549
+ if (sourceDecision.from === 'subkey' && sourceDecision.key_id) validatedMetadata.key_id = sourceDecision.key_id;
550
+ }
551
+ delete validatedMetadata.source_defaulted;
552
+ if (sourceDecision.tag) delete validatedMetadata.source_missing;
553
+ else validatedMetadata.source_missing = true;
554
+ }
555
+ // Registry check: only when a registry is configured (a fresh install has
556
+ // none yet), and only for a tag that exists - a missing tag is already marked.
557
+ const registry = String(setting('SOURCE_TAGS') ?? '')
558
+ .split(',').map((s) => s.trim()).filter((s) => s !== '');
559
+ if (registry.length > 0 && typeof validatedMetadata.source === 'string' &&
560
+ !registry.includes(validatedMetadata.source)) {
561
+ validatedMetadata.source_unregistered = true;
562
+ }
563
+ // Person comes from the login, never from the caller's claim. Stdio is
564
+ // one fixed local subject. A caller client_id never survives in custom.
565
+ {
566
+ const stamp = writerStamp();
567
+ validatedMetadata.person_id = stamp.person_id;
568
+ delete validatedMetadata.client_id;
569
+ if (validatedMetadata.custom && typeof validatedMetadata.custom === 'object') {
570
+ delete validatedMetadata.custom.client_id;
571
+ delete validatedMetadata.custom.person_id;
572
+ if (Object.keys(validatedMetadata.custom).length === 0) delete validatedMetadata.custom;
573
+ }
574
+ validatedMetadata.client_id = stamp.client_id;
575
+ }
576
+ await sealAttestation(validatedMetadata, {
577
+ session_id: options && options.session_id,
578
+ inherited: options && options.inheritedAttestation === true,
579
+ db: dbManager && dbManager.db,
580
+ dbManager
581
+ });
582
+
583
+ // A4: importance is a named bucket. The float is the bucket midpoint
584
+ // unless the caller named a degree (low/mid/high) or still sent a number.
585
+ const band = bandFor(targetLayer);
586
+ const resolved = resolveImportance(targetLayer, rawImportance, {
587
+ importance_bucket: rawBucket,
588
+ importance_degree: rawDegree
589
+ });
590
+ validatedMetadata.importance = resolved.importance;
591
+ if (resolved.bucket) validatedMetadata.importance_bucket = resolved.bucket;
592
+ if (resolved.degree) validatedMetadata.importance_degree = resolved.degree;
593
+ if (resolved.defaulted) validatedMetadata.importance_defaulted = true;
594
+ if (resolved.importance < band.min || resolved.importance > band.max) {
595
+ warnings.push(`importance ${resolved.importance} is outside the ${targetLayer} band ${band.min}–${band.max}`);
596
+ }
597
+
598
+ // A5: the charter says whether this layer wants a provenance pointer.
599
+ // 2026-09-29 (procedural ref 398): the pointer is STAMPED when the caller names
600
+ // its transcript session, and a layer whose charter REQUIRES provenance
601
+ // (semantic, procedural) REFUSES a write that has no source at all. The
602
+ // transport is stateless, so the server never guesses which host's session
603
+ // a write belongs to - the caller names it (save_to_layer `session_id`),
604
+ // and the server reads that session's own counter. The stamped exchange is
605
+ // the one in progress: the highest stored index plus one, which the append
606
+ // after the reply stores. Until it lands the pointer is pending, and
607
+ // emet_gaps reports one that never does.
608
+ let provenanceStamped = false;
609
+ if (!provenance && options && options.session_id !== undefined && options.session_id !== null) {
610
+ const sid = typeof options.session_id === 'string' ? options.session_id.trim() : '';
611
+ if (!sid || sid.length > 200) throw new ValidationError('session_id', 'must be the non-empty transcript session id from emet_session_open. Nothing was written.');
612
+ if (typeof dbManager.transcriptPosition !== 'function') throw new ValidationError('session_id', 'this store cannot read transcript positions, so the pointer cannot be stamped - pass derived_from instead. Nothing was written.');
613
+ const pos = await dbManager.transcriptPosition(sid);
614
+ const ex = pos.highest + 1;
615
+ provenance = validateProvenance({ session_id: pos.base, exchange_start: ex, exchange_end: ex });
616
+ provenanceStamped = true;
617
+ }
618
+ const provenancePolicy = policyFor(targetLayer).provenance;
619
+ if (!provenance && provenancePolicy === 'required') {
620
+ throw new ValidationError('derived_from',
621
+ `${targetLayer} entries must carry their source. Pass session_id (your transcript session from ` +
622
+ `emet_session_open - the server stamps the exchange), or derived_from {session_id, exchange_start, ` +
623
+ `exchange_end}, or derived_from {entries: [ids]} for an entry consolidated from other entries. Nothing was written.`);
624
+ }
625
+ if (!provenance && provenancePolicy === 'encouraged') {
626
+ validatedMetadata.provenance_missing = true;
627
+ }
628
+
629
+ // E3: the door never infers the origin; it only records that none was given.
630
+ if (assertionOriginAbsent) {
631
+ validatedMetadata.assertion_origin = null;
632
+ validatedMetadata.assertion_origin_missing = true;
633
+ }
634
+ {
635
+ const { CONTENT_ORIGINS } = await import('./validation.js');
636
+ const given = validatedMetadata.content_origin;
637
+ if (typeof given === 'string' && CONTENT_ORIGINS.includes(given)) {
638
+ validatedMetadata.content_origin = given;
639
+ } else if (validatedMetadata.assertion_origin === 'imported') {
640
+ validatedMetadata.content_origin = 'external';
641
+ } else if (validatedMetadata.assertion_origin === 'assistant') {
642
+ validatedMetadata.content_origin = 'assistant';
643
+ } else if (validatedMetadata.assertion_origin === 'user' || validatedMetadata.assertion_origin === 'consolidated') {
644
+ validatedMetadata.content_origin = 'self';
645
+ } else {
646
+ validatedMetadata.content_origin = null;
647
+ }
648
+ }
649
+
650
+ // E4: the redaction count lives in the stored metadata as well as the
651
+ // receipt, so "which host keeps pasting secrets" is a query, not a memory.
652
+ if (redaction.hits > 0) validatedMetadata.redacted_spans = redaction.hits;
653
+
654
+ // B2: emotional intensity - caller, then analyzer, then the fixed baseline.
655
+ let emotionalIntensity;
656
+ let emotionalIntensitySource;
657
+ if (validatedMetadata.emotional_intensity !== undefined) {
658
+ emotionalIntensity = validatedMetadata.emotional_intensity;
659
+ emotionalIntensitySource = 'caller';
660
+ } else if (Number.isFinite(decision.emotional_intensity)) {
661
+ emotionalIntensity = decision.emotional_intensity;
662
+ emotionalIntensitySource = 'analyzer';
663
+ } else {
664
+ emotionalIntensity = 0.5;
665
+ emotionalIntensitySource = 'default';
666
+ }
667
+
668
+ // B1: the routing record. A caller-supplied routing object is kept as-is.
669
+ if (!isPlainObject(validatedMetadata.routing)) {
670
+ validatedMetadata.routing = validatedLayer
671
+ ? {
672
+ method: 'explicit',
673
+ layer: targetLayer,
674
+ analyzer_layer: decision.layer,
675
+ confidence: decision.confidence,
676
+ emotional_intensity_source: emotionalIntensitySource
677
+ }
678
+ : {
679
+ method: 'fallback',
680
+ layer: targetLayer,
681
+ keyword_layer: keywordLayer,
682
+ analyzer_layer: decision.layer,
683
+ confidence: decision.confidence,
684
+ signals: Object.keys(decision.signals || {}).slice(0, 8),
685
+ technical_density: decision.technical_density,
686
+ emotional_intensity_source: emotionalIntensitySource
687
+ };
688
+ }
689
+
690
+ logger?.debug('Saving memory', {
691
+ requestId,
692
+ layer: targetLayer,
693
+ contentLength: validatedContent.length,
694
+ hasMetadata: Object.keys(validatedMetadata).length > 0
695
+ });
696
+
697
+ await dbManager.getConnection(targetLayer);
698
+
699
+ const timestamp = Date.now() / 1000;
700
+ // Decided above (A4): the caller's value or the charter midpoint, marked.
701
+ const importance = validatedMetadata.importance;
702
+
703
+ const columns = provenanceColumns(provenance);
704
+ // EMET ref 12 (2026-09-05): the digest is computed BEFORE the write and stored
705
+ // as a column, not only compared in the read-back receipt. Without it the
706
+ // aleph claim held for exactly one instant - verified at write, never again.
707
+ const contentSha256 = digest(validatedContent);
708
+ let prevSha256 = null;
709
+ try {
710
+ const head = await dbManager.collectionFor(targetLayer)
711
+ .find({ sha256: { $exists: true, $nin: [null, ''] } })
712
+ .sort({ id: -1 }).limit(1).toArray();
713
+ prevSha256 = (head[0] && head[0].sha256) || null;
714
+ } catch { prevSha256 = null; }
715
+ const { chainDigest } = await import('./aleph.js');
716
+ const chainSha256 = chainDigest(prevSha256, contentSha256);
717
+ // C1 (2026-09-07): the metadata is attested too, in its own column, over
718
+ // its canonical JSON - computed AFTER every marker above has been added,
719
+ // so the digest covers the metadata as stored, not as received.
720
+ const metadataSha256 = digestMetadata(validatedMetadata);
721
+ const signingKey = setting('SIGNING_KEY') || null;
722
+ const signature = signDigests(contentSha256, metadataSha256, signingKey);
723
+ // The row as a document (stage 1 of the write-path rewrite, 2026-09-27).
724
+ // `event` (a mirror of content) was retired with the SQL read path,
725
+ // 2026-09-28; older rows keep theirs, untouched.
726
+ const row = {
727
+ timestamp,
728
+ content: validatedContent,
729
+ context: validatedMetadata.context || '',
730
+ emotional_intensity: emotionalIntensity,
731
+ importance,
732
+ // EMET ref 6 (2026-09-05): stored as a real subdocument, not a JSON string.
733
+ // The promoted columns below stay because they are indexed and the vector
734
+ // filter reads them.
735
+ metadata: validatedMetadata,
736
+ // Promoted top-level columns (2026-08-24): what the liveness predicate reads.
737
+ superseded_by: null,
738
+ valid_until: _extractValidUntil(validatedMetadata),
739
+ // is_live stays true when a row is superseded. The end of an entry is
740
+ // superseded_at plus superseded_by (or valid_until when it expires).
741
+ // A false value is a historical mark from before this rule; reads still
742
+ // return that row and say it has ended. Nothing is hidden for age.
743
+ is_live: true,
744
+ // Provenance. Explicit nulls rather than absent fields: "no pointer" and
745
+ // "this field was never part of the schema" must not look the same.
746
+ source_session_id: columns.source_session_id,
747
+ source_exchange_start: columns.source_exchange_start,
748
+ source_exchange_end: columns.source_exchange_end,
749
+ derived_from_ids: columns.derived_from_ids,
750
+ sha256: contentSha256,
751
+ metadata_sha256: metadataSha256,
752
+ signature,
753
+ prev_sha256: prevSha256,
754
+ chain_sha256: chainSha256
755
+ };
756
+
757
+ // Option A correction back-pointer (Alex, 2026-10-02, procedural ref 405 item 4): a new entry may name the
758
+ // entry it partly corrects. The parent must exist in the same layer, and is checked BEFORE the insert so a
759
+ // refused correction writes nothing.
760
+ if (validatedMetadata.corrects !== undefined && validatedMetadata.corrects !== null) {
761
+ const parent = await dbManager.readMemory(targetLayer, validatedMetadata.corrects);
762
+ if (!parent) {
763
+ throw new ValidationError('corrects', `No entry with id ${validatedMetadata.corrects} in layer '${targetLayer}' - a correction names an entry in its own layer. Nothing was written.`);
764
+ }
765
+ }
766
+
767
+ const memoryId = await dbManager.insertMemory(targetLayer, row);
768
+
769
+ // aleph: read the entry back and compare before reporting it saved.
770
+ // Until this existed, save_to_layer returned an id the caller had no reason
771
+ // to doubt and no way to check, while the README promised every write was
772
+ // verified. The id is checked by CONTENT rather than existence because the
773
+ // dangerous failure is an id collision - a counter read from the wrong
774
+ // collection restarts at 1 and silently overwrites real entries.
775
+ const stored = await dbManager.readMemory(targetLayer, memoryId);
776
+ const receipt = verifyLayerEntry(stored, { id: memoryId, content: validatedContent, layer: targetLayer, metadata: validatedMetadata });
777
+ const correction = (validatedMetadata.corrects !== undefined && validatedMetadata.corrects !== null)
778
+ ? await markCorrectedBy(dbManager, targetLayer, validatedMetadata.corrects, memoryId)
779
+ : null;
780
+ // the reviewer N4: a correction whose parent could not be marked is still saved,
781
+ // but the caller is told in words, not only by a boolean.
782
+ if (correction && correction.marked !== true) {
783
+ warnings.push(`corrects: ${correction.parent_id} - the parent entry was not marked corrected_by (missing in ${targetLayer}, or the mark did not hold); the correction is saved, the link is one-sided until the parent is marked`);
784
+ }
785
+
786
+ const durationMs = Date.now() - startTime;
787
+
788
+ if (logger && auditOperation) {
789
+ logger.audit(auditOperation.MEMORY_SAVE, {
790
+ requestId,
791
+ layer: targetLayer,
792
+ memoryId,
793
+ contentLength: validatedContent.length,
794
+ importance,
795
+ emotionalIntensity,
796
+ durationMs,
797
+ success: true
798
+ });
799
+ }
800
+
801
+ logger?.info('Memory saved successfully', {
802
+ requestId,
803
+ layer: targetLayer,
804
+ memoryId,
805
+ durationMs
806
+ });
807
+
808
+ // C3: the receipt is the fixture. One shape on both transports, keys in a
809
+ // fixed order, and it says what happened to the write: every marker the
810
+ // door set is echoed here, and `warnings` is always an array so a clean
811
+ // write and a flagged write have the same shape.
812
+ const md = validatedMetadata;
813
+ return {
814
+ layer: targetLayer,
815
+ id: memoryId,
816
+ timestamp,
817
+ verified: true,
818
+ sha256: receipt.sha256,
819
+ // digest_stored: the read-back saw a sha256 COLUMN on the row and it agreed
820
+ // with the content. false means the row has no column (pre-#12 build) - the
821
+ // receipt above was computed, not read. The receipt is its own witness.
822
+ digest_stored: receipt.digest_stored === true,
823
+ // C1: the metadata digest, attested in its own column and read back.
824
+ metadata_sha256: receipt.metadata_sha256,
825
+ metadata_digest_stored: receipt.metadata_digest_stored === true,
826
+ signed: signatureIntegrityOf(stored, signingKey),
827
+ signature_stored: typeof stored?.signature === 'string' && stored.signature.length > 0,
828
+ // Number of secret spans masked before storage. Zero is the normal case;
829
+ // anything else is worth the caller's attention.
830
+ redacted: redaction.hits,
831
+ provenance: provenance || null,
832
+ ...(provenanceStamped ? { provenance_stamped: 'from the named transcript session; the exchange is the one in progress, stored by the append after this reply' } : {}),
833
+ ...(md.provenance_missing ? { provenance_missing: true } : {}),
834
+ source: md.source,
835
+ ...(md.source_defaulted ? { source_defaulted: true } : {}),
836
+ ...(md.source_unregistered ? { source_unregistered: true } : {}),
837
+ ...(md.source_missing ? { source_missing: true } : {}),
838
+ assertion_origin: md.assertion_origin,
839
+ ...(md.assertion_origin_missing ? { assertion_origin_missing: true } : {}),
840
+ importance,
841
+ ...(md.importance_defaulted ? { importance_defaulted: true } : {}),
842
+ routing: md.routing,
843
+ ...(correction ? { corrects: correction.parent_id, parent_marked: correction.marked } : {}),
844
+ // Present only when the whitelist moved something: an absent key means
845
+ // every field landed where the caller put it.
846
+ ...(relocatedToCustom.length > 0 ? { relocated_to_custom: relocatedToCustom } : {}),
847
+ warnings
848
+ };
849
+ } catch (error) {
850
+ const durationMs = Date.now() - startTime;
851
+
852
+ if (logger && auditOperation) {
853
+ logger.audit(auditOperation.MEMORY_SAVE, {
854
+ requestId,
855
+ layer: layer || 'auto',
856
+ contentLength: content ? content.length : 0,
857
+ durationMs,
858
+ success: false,
859
+ errorCode: error.code || 'UNKNOWN',
860
+ errorMessage: error.message
861
+ });
862
+ }
863
+
864
+ logger?.error('Failed to save memory', {
865
+ requestId,
866
+ error,
867
+ layer: layer || 'auto',
868
+ durationMs
869
+ });
870
+
871
+ // AlephError is an EmetError, so it already propagates unchanged here - which
872
+ // is deliberate. "The store does not contain what it should" must never be
873
+ // flattened into "the save failed", because the two lead to opposite actions.
874
+ if (error instanceof EmetError || error instanceof ValidationError) {
875
+ throw error;
876
+ }
877
+
878
+ throw new DatabaseError(
879
+ 'Failed to save memory',
880
+ 'save_memory',
881
+ { layer: layer || 'auto' }
882
+ );
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Recall memories by keyword.
888
+ */
889
+ // valid_until is an end date, not a hide. recall, query_layer and semantic_recall
890
+ // return an expired row marked ended, with valid_until as that date. The current
891
+ // entry is listed first. Nothing is dropped for age.
892
+ /** Pull valid_until out of metadata for promotion to a top-level column. */
893
+ function _extractValidUntil(metadata) {
894
+ if (!metadata || typeof metadata !== 'object') return null;
895
+ const vu = (metadata.valid_until !== undefined && metadata.valid_until !== null)
896
+ ? metadata.valid_until
897
+ : (metadata.custom && metadata.custom.valid_until !== undefined ? metadata.custom.valid_until : null);
898
+ const n = Number(vu);
899
+ return (vu === null || vu === undefined || Number.isNaN(n)) ? null : n;
900
+ }
901
+
902
+ function isMemoryExpired(metadata, nowSec = Date.now() / 1000) {
903
+ const md = parseMetadata(metadata);
904
+ const vu = (md.valid_until !== undefined && md.valid_until !== null) ? md.valid_until
905
+ : (md.custom && md.custom.valid_until !== undefined ? md.custom.valid_until : null);
906
+ if (vu === null || vu === undefined) return false;
907
+ const n = Number(vu);
908
+ if (!Number.isFinite(n) || n <= 0) return false;
909
+ const expirySec = n > 1e12 ? n / 1000 : n; // tolerate milliseconds
910
+ return expirySec < nowSec;
911
+ }
912
+
913
+ // ============================================
914
+ // SUPERSESSION
915
+ // A revision is a NEW entry that declares what it replaces (metadata.supersedes = <id>);
916
+ // the parent entry is marked metadata.superseded_by = <new id>. Nothing is edited or
917
+ // deleted - retention plus obsolescence marking (ISO 7.5.3 pattern; write_doc
918
+ // versioning is the template).
919
+ //
920
+ // READ PATHS RETURN SUPERSEDED ENTRIES, MARKED. User's decision 2026-09-13:
921
+ // "a parent should still surface to give context to the revision". Retention plus
922
+ // obsolescence marking means the superseded record is IDENTIFIED, not hidden -
923
+ // hiding it by default meant a revision arrived with no sight of what it revised,
924
+ // and an entry that recorded a belief at a time stopped standing as the record of
925
+ // it. include_superseded: false does not hide the parent. The current entry is
926
+ // listed first; the parent stays, marked with its end date and what replaced it.
927
+ // Episodic revision is PROHIBITED - events are immutable.
928
+ // ============================================
929
+
930
+ /** The id of the entry that replaced this one, or null. */
931
+ function supersededByOf(memoryOrMetadata) {
932
+ const row = memoryOrMetadata && memoryOrMetadata.metadata !== undefined ? memoryOrMetadata : null;
933
+ const md = parseMetadata(row ? row.metadata : memoryOrMetadata);
934
+ let sb = (md.superseded_by !== undefined && md.superseded_by !== null) ? md.superseded_by
935
+ : (md.custom && md.custom.superseded_by !== undefined && md.custom.superseded_by !== null ? md.custom.superseded_by : null);
936
+ // The promoted top-level column is authoritative where it is present: it is what
937
+ // the store filters on, and a row can carry it without the metadata copy.
938
+ if ((sb === null || sb === undefined) && row && row.superseded_by !== undefined && row.superseded_by !== null) {
939
+ sb = row.superseded_by;
940
+ }
941
+ if (sb === null || sb === undefined) return null;
942
+ const n = Number(sb);
943
+ return Number.isFinite(n) && n > 0 ? n : null;
944
+ }
945
+
946
+ function isMemorySuperseded(metadata) {
947
+ return supersededByOf(metadata) !== null;
948
+ }
949
+
950
+ /**
951
+ * Walk superseded_by from an entry to the live end of its chain.
952
+ * Returns { live, ids } - ids is every id walked, starting with the entry
953
+ * given, ending with the live one. Bounded so a cycle (a broken_supersession
954
+ * gap) terminates instead of spinning; the walk stops at the first id that
955
+ * cannot be read and reports what it reached.
956
+ */
957
+ export async function resolveSupersessionChain(read, entry, maxHops = 50) {
958
+ const ids = [entry.id];
959
+ let current = entry;
960
+ for (let hop = 0; hop < maxHops; hop++) {
961
+ const next = supersededByOf(current);
962
+ if (next === null || next === undefined) return { live: current, ids };
963
+ if (ids.includes(Number(next))) return { live: null, ids, cycle: true };
964
+ const row = await read(Number(next));
965
+ if (!row) return { live: null, ids, missing: Number(next) };
966
+ ids.push(row.id);
967
+ current = row;
968
+ }
969
+ return { live: null, ids, truncated: true };
970
+ }
971
+
972
+ /**
973
+ * Option A correction back-pointer (Alex, 2026-10-02, procedural ref 405 item 4; the tester's sketch in his
974
+ * second read of episodic ref 604). Episodic entries are immutable events, so a PARTIAL correction cannot
975
+ * supersede its parent: supersession would put an end date on the whole parent and present every item
976
+ * in it as replaced. Instead the new entry carries metadata.corrects = <parent id>, and the parent is MARKED:
977
+ * its corrected_by array gains the new id. The parent's content, digest and is_live are untouched; its
978
+ * metadata is re-attested in the same write (as reviseMemory does for superseded_by), then read back.
979
+ * Sanctioned in tests/write-path.test.js on the same grounds as reviseMemory and retireDoc: a mark on an
980
+ * existing row, never a content write or a removal.
981
+ */
982
+ /**
983
+ * The re-attestation $set for a sanctioned metadata mark (supersession, a
984
+ * corrected_by mark): the new metadata digest and, when a signing key is set
985
+ * and the row's existing signature verifies against its metadata before the
986
+ * marks (aleph.js signatureCoversPriorMetadata), a fresh signature over the
987
+ * same content digest and the new metadata digest (2026-10-02, the tester's
988
+ * finding: the marks left signed:false behind). With no key, or a signature
989
+ * that does not verify, the signature is left exactly as it is: never re-signed
990
+ * blindly, so a row changed outside the sanctioned path keeps reading
991
+ * signed:false.
992
+ */
993
+ export function resignedAttestation(row, newMetadata) {
994
+ const metadataSha256 = digestMetadata(newMetadata);
995
+ const set = { metadata_sha256: metadataSha256 };
996
+ const key = setting('SIGNING_KEY') || null;
997
+ if (key && signatureCoversPriorMetadata(row, key)) set.signature = signDigests(row.sha256, metadataSha256, key);
998
+ return set;
999
+ }
1000
+
1001
+ export async function markCorrectedBy(dbManager, layer, parentId, childId) {
1002
+ await dbManager.getConnection(layer);
1003
+ const col = dbManager.collectionFor(layer);
1004
+ const parent = await col.findOne({ id: parentId });
1005
+ if (!parent) return { parent_id: parentId, marked: false };
1006
+ const cid = Number(childId);
1007
+ // Race-safe (the reviewer N3 / the tester, 2026-10-02): the id is ADDED atomically
1008
+ // ($addToSet on the column and on the metadata copy), never written back
1009
+ // from a stale read, so two simultaneous corrections of one parent both
1010
+ // land. A legacy string-metadata row falls back to read-modify-write.
1011
+ if (parent.metadata && typeof parent.metadata === 'object') {
1012
+ await col.updateOne({ id: parentId }, { $addToSet: { corrected_by: cid, 'metadata.corrected_by': cid } });
1013
+ } else {
1014
+ const md = parseMetadata(parent.metadata);
1015
+ const list = Array.isArray(md.corrected_by) ? md.corrected_by.map(Number) : [];
1016
+ if (!list.includes(cid)) list.push(cid);
1017
+ md.corrected_by = list;
1018
+ await col.updateOne({ id: parentId }, { $set: { metadata: md, corrected_by: list } });
1019
+ }
1020
+ // Re-attest from what is actually stored. The digest is set only if the
1021
+ // stored metadata is still exactly the metadata it was computed from: the
1022
+ // compare-and-set filter is the WHOLE metadata value, not only corrected_by
1023
+ // (the tester, 2026-10-02), so a concurrent change to any metadata field in
1024
+ // between (another mark, a superseded_by, anything) makes the filter miss,
1025
+ // and the loop re-reads and re-digests. The last re-attest therefore always
1026
+ // covers what is stored. The value compared is the one just read back, so
1027
+ // MongoDB's order-sensitive embedded-document equality matches it as stored.
1028
+ let back = null;
1029
+ for (let attempt = 0; attempt < 5; attempt++) {
1030
+ back = await col.findOne({ id: parentId });
1031
+ if (!back) break;
1032
+ const md = parseMetadata(back.metadata);
1033
+ const filter = { id: parentId, metadata: back.metadata };
1034
+ const res = await col.updateOne(filter, { $set: resignedAttestation(back, md) });
1035
+ if (res && res.matchedCount === 0) continue;
1036
+ back = await col.findOne({ id: parentId });
1037
+ break;
1038
+ }
1039
+ const backMd = back ? parseMetadata(back.metadata) : {};
1040
+ const marked = !!(back && Array.isArray(backMd.corrected_by) && backMd.corrected_by.map(Number).includes(cid)
1041
+ && metadataIntegrityOf(back) === true && back.content === parent.content);
1042
+ return { parent_id: parentId, marked };
1043
+ }
1044
+
1045
+ const correctedByOf = (row) => {
1046
+ const md = parseMetadata(row && row.metadata) || {};
1047
+ const v = Array.isArray(row && row.corrected_by) ? row.corrected_by : md.corrected_by;
1048
+ return Array.isArray(v) && v.length ? v.map(Number) : [];
1049
+ };
1050
+
1051
+ export async function reviseMemory(dbManager, layer, target_id, content, metadata = {}, logger = null) {
1052
+ try {
1053
+ const validatedLayer = validateLayer(layer, true);
1054
+ // Which layers may be revised is the charter's call (layers.js), not a
1055
+ // string comparison here. Today only episodic says false.
1056
+ if (policyFor(validatedLayer).revisable === false) {
1057
+ throw new ValidationError('layer', 'Episodic revision is prohibited - events are immutable history. Save a new episodic entry instead.');
1058
+ }
1059
+ const targetId = Number(target_id);
1060
+ if (!Number.isFinite(targetId) || !Number.isInteger(targetId) || targetId <= 0) {
1061
+ throw new ValidationError('target_id', 'target_id must be a positive integer');
1062
+ }
1063
+ const validatedContent = validateContent(content);
1064
+ // A7: metadata is validated exactly ONCE, inside saveMemory, with
1065
+ // `supersedes` already present as a first-class field. Validating here and
1066
+ // again there is what used to relocate it under custom on the second pass.
1067
+ if (metadata !== null && metadata !== undefined && !isPlainObject(metadata)) {
1068
+ throw new ValidationError('metadata', 'Metadata must be an object', typeof metadata);
1069
+ }
1070
+ await dbManager.getConnection(validatedLayer);
1071
+ const col = dbManager.collectionFor(validatedLayer);
1072
+
1073
+ const target = await col.findOne({ id: targetId });
1074
+ if (!target) {
1075
+ throw new ValidationError('target_id', `No entry with id ${targetId} in layer '${validatedLayer}'`);
1076
+ }
1077
+ const targetMd = parseMetadata(target.metadata);
1078
+ if (isMemorySuperseded(targetMd)) {
1079
+ throw new ValidationError('target_id', `Entry ${targetId} is already superseded by ${targetMd.superseded_by} - revise the CURRENT entry, not the archived one`);
1080
+ }
1081
+ // 2026-09-29 (procedural ref 398): a revision with no source of its own cites
1082
+ // the entry it revises, so revising a semantic or procedural entry is never
1083
+ // refused for want of a pointer. A caller-supplied derived_from wins.
1084
+ // Attestation copied from the parent wins over a caller value. A different
1085
+ // value was already refused.
1086
+ const priorAttestation = frozenAttestation(targetMd, metadata);
1087
+ const revisionMetadata = {
1088
+ ...(metadata || {}),
1089
+ ...(priorAttestation || {}),
1090
+ supersedes: targetId,
1091
+ ...((metadata && metadata.derived_from) ? {} : { derived_from: { entries: [targetId] } })
1092
+ };
1093
+
1094
+ const saved = await saveMemory(dbManager, validatedContent, validatedLayer, revisionMetadata, logger, null, {
1095
+ inheritedAttestation: Boolean(priorAttestation)
1096
+ });
1097
+
1098
+ targetMd.superseded_by = saved.id;
1099
+ targetMd.superseded_at = saved.timestamp;
1100
+ // Write BOTH the metadata copy and the promoted top-level column. The
1101
+ // top-level one is what a reader follows (indexed); the metadata copy
1102
+ // stays for audit. Always written as a subdocument - a parent still
1103
+ // carrying the legacy string shape is converted by this touch (EMET ref 6).
1104
+ // is_live is left as it was. Supersession is the end date and the link
1105
+ // to the new entry, not a flag that hides the old one.
1106
+ await col.updateOne({ id: targetId }, { $set: {
1107
+ metadata: targetMd,
1108
+ superseded_by: saved.id,
1109
+ superseded_at: saved.timestamp,
1110
+ // C1: marking a parent is the ONE sanctioned mutation of stored metadata,
1111
+ // so the parent is re-attested in the same write. Without this every
1112
+ // superseded row would read as a metadata mismatch from then on. With a
1113
+ // signing key, a parent whose signature verified before the mark is
1114
+ // re-signed in the same write (resignedAttestation), so it does not read
1115
+ // signed:false; the check runs on the row as read, before targetMd's marks.
1116
+ ...resignedAttestation(target, targetMd)
1117
+ } });
1118
+
1119
+ // Read-back verification, both sides (verification over performance)
1120
+ const newDoc = await col.findOne({ id: saved.id });
1121
+ const parentDoc = await col.findOne({ id: targetId });
1122
+ const parentMd = parentDoc ? parseMetadata(parentDoc.metadata) : null;
1123
+ const topLevelOk = !!(parentDoc
1124
+ && Number(parentDoc.superseded_by) === Number(saved.id)
1125
+ && parentDoc.superseded_at === saved.timestamp
1126
+ && parentDoc.is_live !== false);
1127
+ // C1: the parent's re-attestation must read back true, or the revision is
1128
+ // reported unverified - a parent that fails its own metadata digest after
1129
+ // being marked is exactly the inconsistency the column exists to expose.
1130
+ const parentMetaOk = metadataIntegrityOf(parentDoc) === true;
1131
+ const verified = !!(newDoc && parentMd && Number(parentMd.superseded_by) === Number(saved.id) && topLevelOk && parentMetaOk);
1132
+
1133
+ logger?.info('Memory revised via supersession', { layer: validatedLayer, supersedes: targetId, newId: saved.id, verified });
1134
+ // C3: the same receipt saveMemory produced, plus what supersession added.
1135
+ // `verified` here covers both sides (child stored, parent marked).
1136
+ return {
1137
+ ...saved,
1138
+ verified,
1139
+ supersedes: targetId,
1140
+ parent_marked_superseded: verified
1141
+ };
1142
+ } catch (error) {
1143
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
1144
+ throw new DatabaseError(`Failed to revise memory: ${error.message}`, 'revise_memory');
1145
+ }
1146
+ }
1147
+
1148
+ /**
1149
+ * How many rows a recall or layer query keeps after the store has ranked them.
1150
+ * The store sorts by the same keys as the page (current before ended, then
1151
+ * keyword hits and importance, or time when the query asks for the latest),
1152
+ * and only then applies this bound. Same floor semantic recall uses for its
1153
+ * candidate set: 20 times the page, and at least 200.
1154
+ */
1155
+ export const RECALL_CANDIDATE_FLOOR = 200;
1156
+ export const RECALL_CANDIDATE_FACTOR = 20;
1157
+ export function candidateBound(limit) {
1158
+ const page = Number(limit);
1159
+ const n = Number.isFinite(page) && page > 0 ? page : 10;
1160
+ return Math.max(n * RECALL_CANDIDATE_FACTOR, RECALL_CANDIDATE_FLOOR);
1161
+ }
1162
+
1163
+ function queryTokens(query) {
1164
+ return String(query ?? '').trim().split(/\s+/).filter((k) => k.length > 0).slice(0, 20);
1165
+ }
1166
+
1167
+ /** Whole-token latest / newest / most recent. latest-budget is one token, so it is not this. */
1168
+ function latestPhraseSpan(parts) {
1169
+ for (let i = 0; i < parts.length; i++) {
1170
+ if (/^(latest|newest)$/i.test(parts[i])) return [i, i + 1];
1171
+ if (/^most$/i.test(parts[i]) && /^recent$/i.test(parts[i + 1] || '')) return [i, i + 2];
1172
+ }
1173
+ return null;
1174
+ }
1175
+
1176
+ /** "not latest" and "not the latest" (and the newest / most recent forms). */
1177
+ function phraseIsNegated(parts, start) {
1178
+ const prev = parts[start - 1];
1179
+ const before = parts[start - 2];
1180
+ if (prev && /^not$/i.test(prev)) return true;
1181
+ if (prev && /^the$/i.test(prev) && before && /^not$/i.test(before)) return true;
1182
+ return false;
1183
+ }
1184
+
1185
+ /**
1186
+ * Natural-language time order. A standalone latest, newest, or most recent
1187
+ * reorders rows that already match the other words. It does not match inside
1188
+ * a hyphenated token, and "not the latest" does not ask for time order.
1189
+ * With no other words left, the word stays an ordinary keyword and this is false.
1190
+ * Pass order: 'latest' when time order should not depend on the wording.
1191
+ */
1192
+ export function queryAsksForLatest(query) {
1193
+ const parts = queryTokens(query);
1194
+ const span = latestPhraseSpan(parts);
1195
+ if (!span || phraseIsNegated(parts, span[0])) return false;
1196
+ const rest = parts.filter((_, i) => i < span[0] || i >= span[1]);
1197
+ return rest.length > 0;
1198
+ }
1199
+
1200
+ /** Words the match set is built from. A latest phrase is removed only when other words remain. */
1201
+ export function recallKeywords(query) {
1202
+ const parts = queryTokens(query);
1203
+ const span = latestPhraseSpan(parts);
1204
+ if (!span || phraseIsNegated(parts, span[0])) return parts;
1205
+ const rest = parts.filter((_, i) => i < span[0] || i >= span[1]);
1206
+ return rest.length > 0 ? rest : parts;
1207
+ }
1208
+
1209
+ function wantsTimeOrder(query, options) {
1210
+ return !!(options && options.order === 'latest') || queryAsksForLatest(query);
1211
+ }
1212
+
1213
+ /** How many of the keywords appear in content or context. One count per keyword.
1214
+ * Used only when the store did not already compute `_hits`. The store path
1215
+ * keeps that count: JavaScript `RegExp` `i` does not fold ẞ and ß. */
1216
+ export function keywordHitCount(memory, keywords) {
1217
+ const content = memory && memory.content ? String(memory.content) : '';
1218
+ const context = memory && memory.context ? String(memory.context) : '';
1219
+ let n = 0;
1220
+ for (const k of keywords) {
1221
+ const spec = containsRegex(k);
1222
+ const re = new RegExp(spec.$regex, spec.$options);
1223
+ if (re.test(content) || re.test(context)) n += 1;
1224
+ }
1225
+ return n;
1226
+ }
1227
+
1228
+ function finiteNumber(value) {
1229
+ const n = Number(value);
1230
+ return Number.isFinite(n) ? n : null;
1231
+ }
1232
+
1233
+ /**
1234
+ * End date and replacement, for a row that is still returned.
1235
+ * ended is true when a later entry replaced it, valid_until has passed,
1236
+ * or an older write set is_live false. The row is not dropped.
1237
+ */
1238
+ function columnExpiry(value) {
1239
+ const n = Number(value);
1240
+ if (value == null || !Number.isFinite(n) || n <= 0) return { validUntil: null, expired: false };
1241
+ const expirySec = n > 1e12 ? n / 1000 : n;
1242
+ return { validUntil: n, expired: expirySec < Date.now() / 1000 };
1243
+ }
1244
+
1245
+ export function endMarks(memory) {
1246
+ const md = parseMetadata(memory && memory.metadata) || {};
1247
+ const supersededBy = supersededByOf(memory);
1248
+ const supersededAt = finiteNumber(memory && memory.superseded_at != null ? memory.superseded_at : md.superseded_at);
1249
+ const fromMetadata = _extractValidUntil(md);
1250
+ const stamped = fromMetadata != null
1251
+ ? { validUntil: fromMetadata, expired: isMemoryExpired(md) }
1252
+ : columnExpiry(memory && memory.valid_until);
1253
+ const validUntil = stamped.validUntil;
1254
+ const expired = stamped.expired;
1255
+ const historicalHide = !!(memory && memory.is_live === false);
1256
+ const ended = supersededBy !== null || expired || historicalHide;
1257
+ let end_date = null;
1258
+ if (supersededBy !== null) end_date = supersededAt;
1259
+ else if (expired) end_date = validUntil;
1260
+ return {
1261
+ superseded_by: supersededBy,
1262
+ superseded_at: supersededBy !== null ? supersededAt : null,
1263
+ is_superseded: supersededBy !== null,
1264
+ valid_until: validUntil,
1265
+ is_expired: expired,
1266
+ ended,
1267
+ end_date,
1268
+ replaced_by: supersededBy
1269
+ };
1270
+ }
1271
+
1272
+ function importanceRank(row) {
1273
+ const n = Number(row && row.importance);
1274
+ return Number.isFinite(n) ? n : 0;
1275
+ }
1276
+
1277
+ /** Store `_ended` when the row came from the rank pipeline. Otherwise the marked flag. */
1278
+ function endedRank(row) {
1279
+ if (row && row._endedRank != null) return row._endedRank;
1280
+ return row && row.ended ? 1 : 0;
1281
+ }
1282
+
1283
+ /** Current entries first, then more keyword hits, then higher importance. Id breaks ties. Not time. */
1284
+ export function compareByRelevance(a, b) {
1285
+ const ae = endedRank(a);
1286
+ const be = endedRank(b);
1287
+ if (ae !== be) return ae - be;
1288
+ const ah = a && a.hits ? a.hits : 0;
1289
+ const bh = b && b.hits ? b.hits : 0;
1290
+ if (ah !== bh) return bh - ah;
1291
+ const ai = importanceRank(a);
1292
+ const bi = importanceRank(b);
1293
+ if (ai !== bi) return bi - ai;
1294
+ return (Number(a && a.id) || 0) - (Number(b && b.id) || 0);
1295
+ }
1296
+
1297
+ /** Current entries first, then newest timestamp. Used only for an explicit latest request. */
1298
+ export function compareByRecency(a, b) {
1299
+ const ae = endedRank(a);
1300
+ const be = endedRank(b);
1301
+ if (ae !== be) return ae - be;
1302
+ const at = Number(a && a.timestamp) || 0;
1303
+ const bt = Number(b && b.timestamp) || 0;
1304
+ if (at !== bt) return bt - at;
1305
+ return (Number(a && a.id) || 0) - (Number(b && b.id) || 0);
1306
+ }
1307
+
1308
+ /**
1309
+ * Default: the ranked list, cut at `limit` (current entries already sit first).
1310
+ * history: true returns ended rows only, up to the same limit, so a full page
1311
+ * of current matches cannot make the ended ones unreachable.
1312
+ */
1313
+ export function applyReadLimit(rows, limit, history) {
1314
+ const cap = Number(limit);
1315
+ const n = Number.isFinite(cap) && cap > 0 ? cap : rows.length;
1316
+ const pool = history === true ? rows.filter((r) => endedRank(r) === 1) : rows;
1317
+ return pool.slice(0, n);
1318
+ }
1319
+
1320
+ function recalledRow(memory, layer, extra = {}) {
1321
+ return {
1322
+ ...(layer ? { layer } : {}),
1323
+ id: memory.id,
1324
+ timestamp: memory.timestamp,
1325
+ content: memory.content,
1326
+ context: memory.context,
1327
+ importance: memory.importance,
1328
+ emotional_intensity: memory.emotional_intensity,
1329
+ metadata: parseMetadata(memory.metadata),
1330
+ ...endMarks(memory),
1331
+ corrected_by: correctedByOf(memory),
1332
+ integrity: integrityOf(memory),
1333
+ metadata_integrity: metadataIntegrityOf(memory),
1334
+ signed: signatureIntegrityOf(memory, setting('SIGNING_KEY') || null),
1335
+ ...extra
1336
+ };
1337
+ }
1338
+
1339
+ /** Layer query order. Current entries come first in every order, including timestamp. */
1340
+ export function compareQueryRows(a, b, orderBy) {
1341
+ const spec = sortFor(orderBy);
1342
+ const column = Object.keys(spec)[0];
1343
+ const dir = spec[column];
1344
+ const ae = endedRank(a);
1345
+ const be = endedRank(b);
1346
+ if (ae !== be) return ae - be;
1347
+ const av = a ? a[column] : undefined;
1348
+ const bv = b ? b[column] : undefined;
1349
+ if (av !== bv) {
1350
+ const aNull = av == null;
1351
+ const bNull = bv == null;
1352
+ if (aNull || bNull) {
1353
+ if (!(aNull && bNull)) {
1354
+ const nullsLow = aNull ? -1 : 1;
1355
+ return dir === 1 ? nullsLow : -nullsLow;
1356
+ }
1357
+ } else if (av > bv) return dir === 1 ? 1 : -1;
1358
+ else return dir === 1 ? -1 : 1;
1359
+ }
1360
+ return (Number(a && a.id) || 0) - (Number(b && b.id) || 0);
1361
+ }
1362
+
1363
+ export function collectSemanticHits(docs, layer) {
1364
+ const out = [];
1365
+ for (const d of docs || []) out.push(recalledRow(d, layer, { score: d.score }));
1366
+ return out;
1367
+ }
1368
+
1369
+ /** Vector score is the relevance. Importance breaks a score tie. Time only if the query asks. */
1370
+ export function rankSemanticHits(rows, query) {
1371
+ if (queryAsksForLatest(query)) return [...rows].sort(compareByRecency);
1372
+ return [...rows].sort((a, b) => {
1373
+ const ae = a && a.ended ? 1 : 0;
1374
+ const be = b && b.ended ? 1 : 0;
1375
+ if (ae !== be) return ae - be;
1376
+ const as = Number(a && a.score) || 0;
1377
+ const bs = Number(b && b.score) || 0;
1378
+ if (as !== bs) return bs - as;
1379
+ const ai = importanceRank(a);
1380
+ const bi = importanceRank(b);
1381
+ if (ai !== bi) return bi - ai;
1382
+ return (Number(a && a.id) || 0) - (Number(b && b.id) || 0);
1383
+ });
1384
+ }
1385
+
1386
+ export async function recallMemories(dbManager, query, layer = null, limit = 10, logger = null, auditOperation = null, options = null) {
1387
+ const startTime = Date.now();
1388
+ const requestId = logger?._generateRequestId?.() || `req-${Date.now()}`;
1389
+
1390
+ try {
1391
+ if (query === null || query === undefined || (typeof query === 'string' && query.trim() === '')) {
1392
+ throw new ValidationError('query', 'Query is required');
1393
+ }
1394
+ if (typeof query !== 'string') {
1395
+ throw new ValidationError('query', 'Query must be a string');
1396
+ }
1397
+ if (query.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
1398
+ throw new ValidationError('query', `Query exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH} characters`);
1399
+ }
1400
+ const validatedQuery = query.trim();
1401
+ const validatedLayer = layer ? validateLayer(layer) : null;
1402
+ const validatedLimit = validateLimit(limit);
1403
+ if (options && options.order != null && options.order !== 'latest') {
1404
+ throw new ValidationError('order', "order must be 'latest' when it is set");
1405
+ }
1406
+
1407
+ logger?.debug('Recalling memories', {
1408
+ requestId,
1409
+ queryLength: validatedQuery.length,
1410
+ layer: validatedLayer || 'all',
1411
+ limit: validatedLimit
1412
+ });
1413
+
1414
+ const layers = validatedLayer ? [validatedLayer] : Object.keys(MEMORY_LAYERS);
1415
+ const results = [];
1416
+
1417
+ // order: 'latest' is the explicit time order. A standalone latest phrase
1418
+ // only reorders rows that match the other words. It never widens the match
1419
+ // set, and a bare "latest" stays a keyword.
1420
+ const asksForLatest = wantsTimeOrder(validatedQuery, options);
1421
+ const keywords = recallKeywords(validatedQuery);
1422
+ const keywordFilter = keywords.length === 0
1423
+ ? null
1424
+ : { $or: keywords.flatMap((k) => [{ content: containsRegex(k) }, { context: containsRegex(k) }]) };
1425
+ // Rank in the store, then limit. A column sort before the limit would
1426
+ // drop a better keyword match, or a current row behind newer ended ones.
1427
+ const bound = candidateBound(validatedLimit);
1428
+ const history = !!(options && options.history === true);
1429
+
1430
+ for (const currentLayer of layers) {
1431
+ await dbManager.getConnection(currentLayer);
1432
+
1433
+ const memories = keywordFilter === null ? [] : await dbManager.aggregateMemories(
1434
+ currentLayer,
1435
+ buildRankPipeline({
1436
+ filter: keywordFilter,
1437
+ keywords,
1438
+ latest: asksForLatest,
1439
+ bound,
1440
+ history,
1441
+ kind: 'recall'
1442
+ })
1443
+ );
1444
+
1445
+ for (const memory of memories) {
1446
+ // The pipeline's _hits is the count the window was ranked with.
1447
+ // A JavaScript recount folds a different set of letters and can drop
1448
+ // every row the store kept. keywordHitCount is only for a row that
1449
+ // did not come from that pipeline.
1450
+ const fromStore = memory._hits != null;
1451
+ const hits = fromStore
1452
+ ? (Number(memory._hits) || 0)
1453
+ : (keywords.length === 0 ? 0 : keywordHitCount(memory, keywords));
1454
+ if (!fromStore && keywords.length > 0 && hits === 0) continue;
1455
+ const row = recalledRow(memory, currentLayer);
1456
+ row.hits = hits;
1457
+ if (memory._ended != null) row._endedRank = Number(memory._ended) === 1 ? 1 : 0;
1458
+ results.push(row);
1459
+ }
1460
+ }
1461
+
1462
+ results.sort(asksForLatest ? compareByRecency : compareByRelevance);
1463
+ const finalResults = applyReadLimit(results, validatedLimit, options && options.history === true);
1464
+ for (const row of finalResults) {
1465
+ delete row.hits;
1466
+ delete row._endedRank;
1467
+ }
1468
+ const durationMs = Date.now() - startTime;
1469
+
1470
+ if (logger && auditOperation) {
1471
+ logger.audit(auditOperation.MEMORY_RECALL, {
1472
+ requestId,
1473
+ queryLength: validatedQuery.length,
1474
+ layer: validatedLayer || 'all',
1475
+ layersSearched: layers.length,
1476
+ limit: validatedLimit,
1477
+ resultsFound: finalResults.length,
1478
+ durationMs,
1479
+ success: true
1480
+ });
1481
+ }
1482
+
1483
+ logger?.info('Memories recalled successfully', {
1484
+ requestId,
1485
+ resultsFound: finalResults.length,
1486
+ durationMs
1487
+ });
1488
+
1489
+ return finalResults;
1490
+ } catch (error) {
1491
+ const durationMs = Date.now() - startTime;
1492
+
1493
+ if (logger && auditOperation) {
1494
+ logger.audit(auditOperation.MEMORY_RECALL, {
1495
+ requestId,
1496
+ queryLength: query ? query.length : 0,
1497
+ layer: layer || 'all',
1498
+ durationMs,
1499
+ success: false,
1500
+ errorCode: error.code || 'UNKNOWN',
1501
+ errorMessage: error.message
1502
+ });
1503
+ }
1504
+
1505
+ logger?.error('Failed to recall memories', {
1506
+ requestId,
1507
+ error,
1508
+ layer: layer || 'all',
1509
+ durationMs
1510
+ });
1511
+
1512
+ if (error instanceof EmetError || error instanceof ValidationError) {
1513
+ throw error;
1514
+ }
1515
+
1516
+ throw new DatabaseError(
1517
+ 'Failed to recall memories',
1518
+ 'recall_memories',
1519
+ { layer: layer || 'all' }
1520
+ );
1521
+ }
1522
+ }
1523
+
1524
+ /**
1525
+ * Query specific layer
1526
+ */
1527
+ export async function queryLayer(dbManager, layer, options = {}, logger = null, auditOperation = null) {
1528
+ const startTime = Date.now();
1529
+ const requestId = logger?._generateRequestId?.() || `req-${Date.now()}`;
1530
+
1531
+ try {
1532
+ const validatedLayer = validateLayer(layer, true);
1533
+ const validatedOptions = validateQueryOptions(options);
1534
+ // Superseded and expired rows are returned, marked. include_superseded: false
1535
+ // does not hide them. Current entries are ordered first in every order,
1536
+ // including timestamp. history: true returns the ended rows on their own.
1537
+
1538
+ logger?.debug('Querying layer', {
1539
+ requestId,
1540
+ layer: validatedLayer,
1541
+ hasFilters: !!validatedOptions.filters,
1542
+ limit: validatedOptions.limit
1543
+ });
1544
+
1545
+ await dbManager.getConnection(validatedLayer);
1546
+ const limit = validatedOptions.limit;
1547
+ let filter = {};
1548
+
1549
+ if (validatedOptions._deprecated_where_used) {
1550
+ logger?.warn('Deprecated WHERE clause usage', {
1551
+ requestId,
1552
+ message: 'Arbitrary WHERE clauses are deprecated for security. Use structured filters instead.'
1553
+ });
1554
+ }
1555
+
1556
+ // An unrecognised filter key is dropped by the whitelist in validateFilters,
1557
+ // leaving the query unfiltered - which reads to the caller as a broken
1558
+ // filter rather than a rejected argument. Say so instead of swallowing it.
1559
+ if (validatedOptions._unknown_filter_keys) {
1560
+ logger?.warn('Unrecognised filter keys ignored', {
1561
+ requestId,
1562
+ layer: validatedLayer,
1563
+ ignored: validatedOptions._unknown_filter_keys,
1564
+ recognised: RECOGNISED_FILTER_KEYS,
1565
+ message: 'These filter keys are not recognised and were ignored; the query did not filter on them.'
1566
+ });
1567
+ }
1568
+
1569
+ filter = buildMemoryFilter(validatedOptions.filters);
1570
+
1571
+ if (!validatedOptions.filters && validatedOptions.params && Array.isArray(validatedOptions.params) && validatedOptions.params.length > 0) {
1572
+ const searchTerm = validatedOptions.params[0];
1573
+ if (typeof searchTerm === 'string') {
1574
+ if (searchTerm.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
1575
+ throw new ValidationError('params[0]', `Search term exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH}`);
1576
+ }
1577
+ filter = { $or: [{ content: containsRegex(searchTerm) }, { context: containsRegex(searchTerm) }] };
1578
+ }
1579
+ }
1580
+
1581
+
1582
+ // Rank in the store with the same keys as the page, then limit. Ended rows
1583
+ // stay in the window; they are not dropped for being expired.
1584
+ const memories = await dbManager.aggregateMemories(validatedLayer, buildRankPipeline({
1585
+ filter,
1586
+ orderBy: validatedOptions.order_by,
1587
+ bound: candidateBound(limit),
1588
+ history: !!(options && options.history === true),
1589
+ kind: 'query'
1590
+ }));
1591
+
1592
+ // CHAIN RESOLUTION (2026-09-15, the consolidated revision). A lookup BY ID
1593
+ // that lands on a superseded entry also returns the live end of its chain,
1594
+ // marked, so a cross-reference written against any id in the chain keeps
1595
+ // resolving after every later revision. Before this, every revise_memory
1596
+ // minted a new id and a hand-kept name->id index had to be revised in the
1597
+ // same pass; the index was the reminder standing in for the control.
1598
+ const requestedId = validatedOptions.filters && validatedOptions.filters.id !== undefined ? Number(validatedOptions.filters.id) : null;
1599
+ if (requestedId !== null && memories.length === 1 && supersededByOf(memories[0]) !== null) {
1600
+ const chain = await resolveSupersessionChain((id) => dbManager.readMemory(validatedLayer, id), memories[0]);
1601
+ if (chain.live && !memories.some(m => m.id === chain.live.id)) {
1602
+ chain.live._resolved_via = { requested_id: requestedId, chain: chain.ids };
1603
+ memories.unshift(chain.live);
1604
+ }
1605
+ }
1606
+
1607
+ const results = memories.map(m => {
1608
+ const row = recalledRow(m, null, {
1609
+ sha256: m.sha256 || null,
1610
+ prev_sha256: m.prev_sha256 || null,
1611
+ chain_sha256: m.chain_sha256 || null,
1612
+ chain: chainIntegrityOf(m),
1613
+ // Present only on the live entry a chain lookup resolved to: which id was
1614
+ // asked for and every id walked to get here, oldest first.
1615
+ ...(m._resolved_via ? { resolved_via: m._resolved_via } : {})
1616
+ });
1617
+ if (m._ended != null) row._endedRank = Number(m._ended) === 1 ? 1 : 0;
1618
+ return row;
1619
+ });
1620
+ results.sort((a, b) => compareQueryRows(a, b, validatedOptions.order_by));
1621
+ const via = results.findIndex((row) => row.resolved_via);
1622
+ if (via > 0) {
1623
+ const [resolved] = results.splice(via, 1);
1624
+ results.unshift(resolved);
1625
+ }
1626
+ const ranked = applyReadLimit(results, limit, options && options.history === true);
1627
+ for (const row of ranked) delete row._endedRank;
1628
+ // Keep the name `results` for the audit log and the warning attach below.
1629
+ results.length = 0;
1630
+ results.push(...ranked);
1631
+
1632
+ const durationMs = Date.now() - startTime;
1633
+
1634
+ if (logger && auditOperation) {
1635
+ logger.audit(auditOperation.MEMORY_QUERY, {
1636
+ requestId,
1637
+ layer: validatedLayer,
1638
+ filterCount: validatedOptions.filters ? Object.keys(validatedOptions.filters).length : 0,
1639
+ limit,
1640
+ orderBy: validatedOptions.order_by || 'importance DESC',
1641
+ resultsFound: results.length,
1642
+ durationMs,
1643
+ success: true
1644
+ });
1645
+ }
1646
+
1647
+ logger?.info('Layer query completed', {
1648
+ requestId,
1649
+ layer: validatedLayer,
1650
+ resultsFound: results.length,
1651
+ durationMs
1652
+ });
1653
+
1654
+ // The caller cannot read a server-side log line, and on the HTTP transport
1655
+ // there is not even one to read - that transport's logger discards warn().
1656
+ // So the ignored keys ride back in the response envelope, where the person
1657
+ // who wrote the bad filter will actually see them. This is the surface that
1658
+ // would have caught the 2026-09-08 phantom defect as it happened (ref 259).
1659
+ if (validatedOptions._unknown_filter_keys) {
1660
+ attachWarnings(results, [
1661
+ `Ignored unrecognised filter key(s): ${validatedOptions._unknown_filter_keys.join(', ')}. `
1662
+ + `The query did NOT filter on them. `
1663
+ + `Recognised filter keys: ${RECOGNISED_FILTER_KEYS.join(', ')}.`
1664
+ ]);
1665
+ }
1666
+
1667
+ return results;
1668
+ } catch (error) {
1669
+ const durationMs = Date.now() - startTime;
1670
+
1671
+ if (logger && auditOperation) {
1672
+ logger.audit(auditOperation.MEMORY_QUERY, {
1673
+ requestId,
1674
+ layer: layer || 'unknown',
1675
+ durationMs,
1676
+ success: false,
1677
+ errorCode: error.code || 'UNKNOWN',
1678
+ errorMessage: error.message
1679
+ });
1680
+ }
1681
+
1682
+ logger?.error('Failed to query layer', {
1683
+ requestId,
1684
+ error,
1685
+ layer: layer || 'unknown',
1686
+ durationMs
1687
+ });
1688
+
1689
+ if (error instanceof EmetError || error instanceof ValidationError) {
1690
+ throw error;
1691
+ }
1692
+
1693
+ throw new DatabaseError(
1694
+ 'Failed to query layer',
1695
+ 'query_layer',
1696
+ { layer: layer || 'unknown' }
1697
+ );
1698
+ }
1699
+ }
1700
+
1701
+ /**
1702
+ * Get server status.
1703
+ */
1704
+ export async function getStatus(dbManager, logger = null) {
1705
+ try {
1706
+ const status = {
1707
+ database: `mongodb:${MONGODB_DB}`,
1708
+ storage_backend: 'mongodb',
1709
+ system: 'EMET',
1710
+ version: EMET_VERSION,
1711
+ layers: {},
1712
+ total_memories: 0,
1713
+ health: 'healthy'
1714
+ };
1715
+
1716
+ for (const layer of Object.keys(MEMORY_LAYERS)) {
1717
+ try {
1718
+ await dbManager.getConnection(layer);
1719
+ const count = (await dbManager.countMemories(layer)) || 0;
1720
+
1721
+ status.layers[layer] = {
1722
+ status: 'connected',
1723
+ count,
1724
+ path: `mongodb:${MONGODB_DB}.${COLLECTION_PREFIX}${layer}`
1725
+ };
1726
+ status.total_memories += count;
1727
+ } catch (error) {
1728
+ status.layers[layer] = { status: 'error', error: error.message };
1729
+ status.health = 'degraded';
1730
+ }
1731
+ }
1732
+
1733
+ logger?.info(`EMET status: ${status.total_memories} total memories, health: ${status.health}`);
1734
+
1735
+ return status;
1736
+ } catch (error) {
1737
+ logger?.error('Error getting system status:', { error });
1738
+
1739
+ if (error instanceof EmetError || error instanceof ValidationError) {
1740
+ throw error;
1741
+ }
1742
+
1743
+ throw new DatabaseError(
1744
+ 'Failed to get system status',
1745
+ 'emet_status'
1746
+ );
1747
+ }
1748
+ }
1749
+
1750
+ /**
1751
+ * Query session transcripts - the live tier-2 store AND the legacy archive.
1752
+ *
1753
+ * Two collections hold word-for-word dialog. `transcripts` is the live store
1754
+ * written by save_transcript (one document per session). `exchanges` is an
1755
+ * archive that stopped receiving writes around June 2026 (one document per
1756
+ * exchange, imported from earlier hosts). Until 2026-09 the archive had its own
1757
+ * reader, query_exchanges; folding it in here means one reader for tier 2 and
1758
+ * lets the five overlapping tools go (the user's decision, 2026-09-14).
1759
+ *
1760
+ * Nothing is migrated. Archive rows are grouped by session_id into the same
1761
+ * session shape the live store returns, every result carries `source` naming
1762
+ * the collection it came from, and the two sets are merged in session_start
1763
+ * order. A session_id filter applies to both.
1764
+ */
1765
+ export async function queryTranscripts(timestamp_start, timestamp_end, session_id = null, limit = 50, logger = null) {
1766
+ const startTime = Date.now();
1767
+ try {
1768
+ if (typeof timestamp_start !== 'number' || typeof timestamp_end !== 'number')
1769
+ throw new ValidationError('timestamp_start and timestamp_end must be numbers (Unix timestamps)');
1770
+ if (timestamp_start >= timestamp_end)
1771
+ throw new ValidationError('timestamp_start must be less than timestamp_end');
1772
+ // Security audit 2026-09-29: session_id reached the query unchecked, so an
1773
+ // object such as {"$ne": null} was an operator, not an id. Every other
1774
+ // doc_id / session_id door already refused a non-string.
1775
+ if (session_id !== null && session_id !== undefined && typeof session_id !== 'string')
1776
+ throw new ValidationError('session_id', 'must be a string');
1777
+ const effectiveLimit = Math.min(Math.max(1, limit || 50), 500);
1778
+ const client = await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10 });
1779
+ const db = client.db(MONGODB_DB);
1780
+ const windowStart = new Date(timestamp_start * 1000);
1781
+ const windowEnd = new Date(timestamp_end * 1000);
1782
+
1783
+ // Live store: one document per session, filtered by session_start.
1784
+ const liveQuery = { _type: { $ne: 'architecture_note' }, session_start: { $gte: windowStart, $lte: windowEnd } };
1785
+ if (session_id) liveQuery.session_id = session_id;
1786
+ const live = await db.collection('transcripts').find(liveQuery).sort({ session_start: 1 }).limit(effectiveLimit).toArray();
1787
+ for (const row of live) row.source = 'transcripts';
1788
+
1789
+ // Archive: one document per exchange. A session is in the window if any of
1790
+ // its exchanges is (rows carry session_start, but the CLI-era rows have a
1791
+ // session_start a month before their timestamps, so the row time is the
1792
+ // honest key). Every row of a matched session is returned, so a session is
1793
+ // never returned with a hole in it.
1794
+ const archived = await archivedSessions(db, windowStart, windowEnd, session_id, effectiveLimit);
1795
+
1796
+ await client.close();
1797
+ const merged = [...live, ...archived]
1798
+ .sort((a, b) => new Date(a.session_start) - new Date(b.session_start))
1799
+ .slice(0, effectiveLimit);
1800
+ const duration = Date.now() - startTime;
1801
+ return {
1802
+ count: merged.length,
1803
+ sessions: merged,
1804
+ sources: { transcripts: live.length, exchanges: archived.length },
1805
+ query: { timestamp_start, timestamp_end, session_id: session_id || 'all', limit: effectiveLimit },
1806
+ performance: { duration_ms: duration }
1807
+ };
1808
+ } catch (error) {
1809
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
1810
+ throw new DatabaseError(`Failed to query transcripts: ${error.message}`, 'query_transcripts');
1811
+ }
1812
+ }
1813
+
1814
+ /**
1815
+ * Group archive exchanges into sessions. Exported for its test; not a tool.
1816
+ * Pure over its inputs apart from the two reads, so the grouping can be tested
1817
+ * against rows without a database - see groupArchivedExchanges.
1818
+ */
1819
+ async function archivedSessions(db, windowStart, windowEnd, session_id, limit) {
1820
+ const col = db.collection('exchanges');
1821
+ const rowFilter = { timestamp: { $gte: windowStart, $lte: windowEnd } };
1822
+ if (session_id) rowFilter.session_id = session_id;
1823
+ const ids = await col.distinct('session_id', rowFilter);
1824
+ if (ids.length === 0) return [];
1825
+ const rows = await col.find({ session_id: { $in: ids } }).sort({ session_id: 1, exchange_index: 1, timestamp: 1 }).toArray();
1826
+ return groupArchivedExchanges(rows).slice(0, limit);
1827
+ }
1828
+
1829
+ /**
1830
+ * The archive's rows, grouped by session_id into the live store's session
1831
+ * shape. Exchange indices are kept as stored (the archive counts from 0; the
1832
+ * live store from 1) - renumbering would be a rewrite of the record.
1833
+ */
1834
+ export function groupArchivedExchanges(rows) {
1835
+ const bySession = new Map();
1836
+ for (const r of rows) {
1837
+ const key = r.session_id || `unkeyed_${r.conversation_uuid || r.channel || 'archive'}`;
1838
+ let s = bySession.get(key);
1839
+ if (!s) {
1840
+ s = {
1841
+ session_id: key,
1842
+ source: 'exchanges',
1843
+ channel: r.channel || null,
1844
+ conversation_name: r.conversation_name || null,
1845
+ conversation_uuid: r.conversation_uuid || null,
1846
+ session_start: r.session_start || r.timestamp,
1847
+ session_end: r.timestamp,
1848
+ exchange_count: 0,
1849
+ exchanges: []
1850
+ };
1851
+ bySession.set(key, s);
1852
+ }
1853
+ if (r.session_start && new Date(r.session_start) < new Date(s.session_start)) s.session_start = r.session_start;
1854
+ if (r.timestamp && new Date(r.timestamp) > new Date(s.session_end)) s.session_end = r.timestamp;
1855
+ s.exchanges.push({
1856
+ exchange_index: r.exchange_index,
1857
+ user_message: r.user_message,
1858
+ assistant_message: r.assistant_message,
1859
+ timestamp: r.timestamp
1860
+ });
1861
+ s.exchange_count = s.exchanges.length;
1862
+ }
1863
+ const out = [...bySession.values()];
1864
+ for (const s of out) s.exchanges.sort((a, b) => (a.exchange_index ?? 0) - (b.exchange_index ?? 0));
1865
+ return out.sort((a, b) => new Date(a.session_start) - new Date(b.session_start));
1866
+ }
1867
+
1868
+ /** One message field, and the whole transcript request. */
1869
+ export const EXCHANGE_FIELD_MAX = 32000;
1870
+ export const EXCHANGE_REQUEST_MAX = 256000;
1871
+
1872
+ export function assertExchangeBudget(parts) {
1873
+ let total = 0;
1874
+ for (const part of parts) {
1875
+ const text = String(part ?? '');
1876
+ if (text.length > EXCHANGE_FIELD_MAX) {
1877
+ throw new ValidationError(`an exchange field exceeds ${EXCHANGE_FIELD_MAX} characters. Nothing was written.`);
1878
+ }
1879
+ total += text.length;
1880
+ }
1881
+ if (total > EXCHANGE_REQUEST_MAX) {
1882
+ throw new ValidationError(`this transcript request exceeds ${EXCHANGE_REQUEST_MAX} characters. Nothing was written.`);
1883
+ }
1884
+ }
1885
+
1886
+ /**
1887
+ * Save a full session transcript to the transcripts collection.
1888
+ * Hardcoded rule: full word-for-word dialog only - no summaries.
1889
+ * The caller passes exchanges. The server does not read a file path.
1890
+ * Every host writes here, so continuity is shared.
1891
+ */
1892
+ export async function saveTranscript(session_id, session_start, session_end, exchanges = null, channel = null, logger = null) {
1893
+ const startTime = Date.now();
1894
+ const requestId = logger?._generateRequestId?.() || `req-${Date.now()}`;
1895
+
1896
+ try {
1897
+ if (!session_id || typeof session_id !== 'string') {
1898
+ throw new ValidationError('session_id must be a non-empty string');
1899
+ }
1900
+ if (typeof session_start !== 'number') {
1901
+ throw new ValidationError('session_start must be a Unix timestamp (number)');
1902
+ }
1903
+
1904
+ const resolvedChannel = channel || 'claude_project';
1905
+ if (!Array.isArray(exchanges) || exchanges.length === 0) {
1906
+ throw new ValidationError('exchanges must be a non-empty array');
1907
+ }
1908
+ assertExchangeBudget(exchanges.flatMap((ex) => [ex && ex.user_message, ex && ex.assistant_message]));
1909
+ const resolvedExchanges = exchanges;
1910
+
1911
+ const client = await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10 });
1912
+ const db = client.db(MONGODB_DB);
1913
+ const col = db.collection('transcripts');
1914
+ try { await col.createIndex({ 'exchanges.client_id': 1 }, { sparse: true }); } catch { /* an index miss never blocks the write */ }
1915
+
1916
+ const existingCount = await col.countDocuments();
1917
+ if (existingCount === 0) {
1918
+ await col.insertOne({
1919
+ _type: 'architecture_note',
1920
+ created_at: new Date(),
1921
+ note: 'Full session dialog, stored verbatim. Search by timestamp window.'
1922
+ });
1923
+ }
1924
+
1925
+ // Verbatim means every word the user said, not every secret they
1926
+ // pasted. A masked span is visible in the record as '*****'; the rest of
1927
+ // the exchange is untouched. This is the one edit the verbatim rule allows.
1928
+ let redactedSpans = 0;
1929
+ const scrub = (s) => { const r = redactWithReport(s || ''); redactedSpans += r.hits; return r.text; };
1930
+
1931
+ const sessionDoc = {
1932
+ session_id,
1933
+ channel: resolvedChannel,
1934
+ session_start: new Date(session_start * 1000),
1935
+ session_end: session_end ? new Date(session_end * 1000) : new Date(),
1936
+ exchange_count: resolvedExchanges.length,
1937
+ exchanges: resolvedExchanges.map((ex, i) => stampExchange(storedExchange(ex, i, scrub), resolvedChannel)),
1938
+ created_at: new Date()
1939
+ };
1940
+ if (redactedSpans > 0) sessionDoc.redacted_spans = redactedSpans;
1941
+ const timestampsDefaulted = sessionDoc.exchanges.filter(e => e.timestamp_defaulted).length;
1942
+
1943
+ const result = await col.insertOne(sessionDoc);
1944
+ await client.close();
1945
+
1946
+ const duration = Date.now() - startTime;
1947
+ return {
1948
+ success: true,
1949
+ session_id,
1950
+ channel: resolvedChannel,
1951
+ inserted_id: result.insertedId.toString(),
1952
+ exchange_count: resolvedExchanges.length,
1953
+ redacted: redactedSpans,
1954
+ ...(timestampsDefaulted > 0 ? { timestamps_defaulted: timestampsDefaulted } : {}),
1955
+ performance: { duration_ms: duration }
1956
+ };
1957
+
1958
+ } catch (error) {
1959
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
1960
+ throw new DatabaseError(`Failed to save session transcript: ${error.message}`, 'save_transcript');
1961
+ }
1962
+ }
1963
+
1964
+ /**
1965
+ * One exchange as stored, for both transcript doors (save and revise).
1966
+ *
1967
+ * A missing timestamp is still filled with the save time - the record needs a
1968
+ * Date - but it is now MARKED `timestamp_defaulted: true`, so the fill is
1969
+ * visible and queryable instead of indistinguishable from an observed time.
1970
+ * Found 2026-09-10: two exchanges saved without timestamps read back as if both
1971
+ * had happened at the save instant, with nothing to say otherwise. Same
1972
+ * principle as the layer door - a missing field is written as a marker, never
1973
+ * silently filled - applied to the one path that still filled silently.
1974
+ * Semantics otherwise unchanged: a falsy timestamp is treated as absent.
1975
+ */
1976
+ export function storedExchange(ex, i, scrub, now = new Date()) {
1977
+ const observed = Boolean(ex.timestamp);
1978
+ return {
1979
+ exchange_index: ex.exchange_index !== undefined ? ex.exchange_index : i,
1980
+ user_message: scrub(ex.user_message),
1981
+ assistant_message: scrub(ex.assistant_message),
1982
+ timestamp: observed ? new Date(ex.timestamp * 1000) : now,
1983
+ ...(observed ? {} : { timestamp_defaulted: true })
1984
+ };
1985
+ }
1986
+
1987
+ function stampExchange(row, source) {
1988
+ const stamp = writerStamp();
1989
+ return {
1990
+ ...row,
1991
+ source: source || setting('SOURCE_TAG') || null,
1992
+ person_id: stamp.person_id,
1993
+ client_id: stamp.client_id
1994
+ };
1995
+ }
1996
+
1997
+ /** Store wait for the boot drop list. Tests set EMET_DROP_LIST_TIMEOUT_MS short so a missing store does not stall the suite. */
1998
+ function dropListTimeoutMs() {
1999
+ const n = Number(process.env.EMET_DROP_LIST_TIMEOUT_MS);
2000
+ return Number.isFinite(n) && n > 0 ? n : 1500;
2001
+ }
2002
+
2003
+ /** Unhandled drops from the store. Used when the startup cache has no list. */
2004
+ async function unhandledDropIds() {
2005
+ const client = await Promise.race([
2006
+ connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: dropListTimeoutMs(), maxPoolSize: 10 }),
2007
+ new Promise((_, reject) => setTimeout(() => reject(new Error('unhandled drop list timed out')), dropListTimeoutMs()))
2008
+ ]);
2009
+ try {
2010
+ const open = await client.db(MONGODB_DB).collection('documents')
2011
+ .find({ doc_id: { $regex: '^drops/' }, retired: { $ne: true } }, { projection: { doc_id: 1 } })
2012
+ .sort({ updated_at: -1 })
2013
+ .toArray();
2014
+ return open.map((d) => d.doc_id).filter((id) => typeof id === 'string');
2015
+ } finally { await client.close(); }
2016
+ }
2017
+
2018
+ /** New York calendar day, the drop-cap boundary. */
2019
+ function nyDay(date) {
2020
+ return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
2021
+ }
2022
+ /**
2023
+ * 20/day bucket. Stdio is one bucket for the process. HTTP with no OAuth
2024
+ * client stays http:visitor, and a caller-supplied source never moves it.
2025
+ * An approval-bound source is 20 per source per New York day. Any other
2026
+ * OAuth client is its own bucket.
2027
+ */
2028
+ function dropBucket(clientId, _source) {
2029
+ const actor = currentActor();
2030
+ if (actor.transport === 'http' || clientId === 'http:visitor' || clientId === null) {
2031
+ if (!clientId || clientId === 'http:visitor') return 'http:visitor';
2032
+ const bound = boundSourceTag();
2033
+ if (bound) return `source:${bound}`;
2034
+ return `client:${clientId}`;
2035
+ }
2036
+ if (!clientId || clientId === 'stdio') return 'stdio';
2037
+ const bound = boundSourceTag();
2038
+ if (bound) return `source:${bound}`;
2039
+ return `client:${clientId}`;
2040
+ }
2041
+
2042
+ /** Drops this open must stamp. Always from the store, so an expired or evicted startup cache cannot stamp an empty list. Never caller input. */
2043
+ export async function dropsSeenForOpen(load_id) {
2044
+ try {
2045
+ return await unhandledDropIds();
2046
+ } catch (e) {
2047
+ const boot = cachedLoad(load_id);
2048
+ const cached = boot && Array.isArray(boot.unhandled_drops) ? boot.unhandled_drops.filter((id) => typeof id === 'string') : [];
2049
+ if (cached.length) return cached;
2050
+ throw new DatabaseError(
2051
+ `emet_session_open could not load unhandled drops from the store, so no session was opened: ${e && e.message}. Nothing was stamped.`,
2052
+ 'emet_session_open'
2053
+ );
2054
+ }
2055
+ }
2056
+
2057
+ export async function sessionOpen({ slug, load_id } = {}, logger = null, dbManager = null, now = new Date()) {
2058
+ const { sessionBaseId, sessionTimeZone, partId, firstUnclosedBase } = await import('./transcript-session.js');
2059
+ const { sessionStatus } = await import('./session-graph.js');
2060
+ const tz = sessionTimeZone();
2061
+ const requested = sessionBaseId(now, slug || 'session', tz.zone);
2062
+ // A closed session is never reopened (2026-10-01): a closed id is replaced by
2063
+ // the next free <id>-2, -3, ... and the response says so. If the session
2064
+ // state cannot be read, nothing is opened - an unchecked id could be a
2065
+ // closed one.
2066
+ let resolved;
2067
+ try {
2068
+ resolved = await firstUnclosedBase(requested, (b) => sessionStatus(b, dbManager));
2069
+ } catch (e) {
2070
+ if (e && e.allClosed) throw new ValidationError('slug', `${e.message} Nothing was opened.`);
2071
+ throw new DatabaseError(`emet_session_open could not check whether ${requested} is already closed, so no session was opened: ${e && e.message}. ` +
2072
+ 'Call emet_session_open again with the same slug and load_id.', 'emet_session_open');
2073
+ }
2074
+ const base = resolved.base;
2075
+ const session_id = partId(base, 1);
2076
+ const suffixed = base !== requested;
2077
+ const phrase = (x) => (x.reason === 'closed' ? `${x.id} is already closed` : `${x.id} is ${x.reason}`);
2078
+ const notice = suffixed
2079
+ ? `${resolved.skipped.map(phrase).join('; ')}, so this session was opened as ${base} instead. ` +
2080
+ (resolved.closed.length
2081
+ ? 'A closed session is never reopened: nothing was written to it and nothing was merged into it.'
2082
+ : 'Nothing was merged into another session.')
2083
+ : null;
2084
+ logger?.info?.('transcript session open', { session_id, ...(suffixed ? { requested } : {}) });
2085
+ const session_handle = await subkeyHandleForOpen(rawActor(), base, now);
2086
+ // Startup page completion (2026-10-03). Through dispatch, an open with no
2087
+ // load_id or with pages missing is refused before this runs (the
2088
+ // startup_pages gate); a direct call is told what was checked.
2089
+ const pages = startupPagesCheck(load_id, logger, session_id);
2090
+ // Opened with pages missing: a page fetched from now on is recorded as late,
2091
+ // under the label the open's lab row carries (the id asked for).
2092
+ if (pages && pages.checked && pages.complete === false) {
2093
+ try { noteOpenMissing(pages.load_id, requested, pages.missing); } catch { /* never fails the open */ }
2094
+ }
2095
+ // Server-side, from the stored drop list the board session must see.
2096
+ // Never args.drops_seen. A cache miss loads the store. A failed load does
2097
+ // not stamp an empty seen-list (that would fail open): the open stands,
2098
+ // and a board edit is refused until the list is known.
2099
+ let drops_seen = [];
2100
+ let drops_unverified = false;
2101
+ try {
2102
+ drops_seen = await dropsSeenForOpen(load_id);
2103
+ } catch (e) {
2104
+ drops_seen = null;
2105
+ drops_unverified = true;
2106
+ }
2107
+ return {
2108
+ session_id,
2109
+ base,
2110
+ ...(session_handle ? { session_handle } : {}),
2111
+ drops_seen,
2112
+ ...(drops_unverified ? { drops_unverified: true } : {}),
2113
+ next_exchange_index: 1,
2114
+ time_zone: tz.zone,
2115
+ ...(tz.note ? { time_zone_note: tz.note } : {}),
2116
+ ...(suffixed ? {
2117
+ suffix_applied: { requested, closed: resolved.closed, skipped: resolved.skipped, opened_as: base, reason: 'closed sessions are never reopened, and a suffixed id is never shared' },
2118
+ notice
2119
+ } : {}),
2120
+ ...(pages ? { startup_pages: pages } : {}),
2121
+ instructions:
2122
+ (suffixed ? `Tell the user, in one line: ${notice} ` : '') +
2123
+ (pages && pages.notice ? `${pages.notice} ` : '') +
2124
+ 'After EVERY reply, call emet_transcript_append with this session_id, the user message, and your reply. One exchange per call. Do not wait until the session ends. Do not reconstruct earlier turns.'
2125
+ };
2126
+ }
2127
+
2128
+ /** Which pages of a startup load were never served (the session graph's startup_pages gate reads it here). */
2129
+ export function startupPageCompletion(loadId = null) {
2130
+ return pageCompletion(loadId);
2131
+ }
2132
+
2133
+ const LOAD_ID_ECHO_MAX = 64;
2134
+ export const NO_LOAD_ID_NOTICE =
2135
+ 'Startup pages not checked: no load_id. Pass load_id from your startup pages (startup_page.load_id on every ' +
2136
+ 'emet_initialize page) to emet_session_open, so the check runs on your own startup.';
2137
+ /** What emet_session_open says about the startup pages: null when there is nothing to say. Never throws. */
2138
+ export function startupPagesCheck(loadId, logger = null, sessionId = null, now = Date.now()) {
2139
+ // No load_id (2026-10-03): the open is not tied to a startup, so nothing was
2140
+ // checked - say so, never stay silent, and never check the latest startup
2141
+ // this server built in its place (it may be another host's).
2142
+ if (!loadId || (typeof loadId === 'string' && !loadId.trim())) {
2143
+ return { load_id: null, checked: false, reason: 'no load_id', notice: NO_LOAD_ID_NOTICE };
2144
+ }
2145
+ let c;
2146
+ try { c = pageCompletion(loadId, now); } catch { return null; }
2147
+ if (!c) {
2148
+ // A host-supplied id is echoed back capped, so a bogus one cannot bloat the reply.
2149
+ let echoed;
2150
+ try { echoed = String(loadId); } catch { return null; }
2151
+ const cps = Array.from(echoed); // whole characters: never a lone surrogate in the echo
2152
+ if (cps.length > LOAD_ID_ECHO_MAX) echoed = `${cps.slice(0, LOAD_ID_ECHO_MAX).join('')}…`;
2153
+ return { load_id: echoed, checked: false, reason: 'no record of that startup load on this server in the last 60 minutes (it expired, or the server restarted)' };
2154
+ }
2155
+ if (c.complete) return { load_id: c.load_id, checked: true, complete: true };
2156
+ const list = c.missing.join(', ');
2157
+ const notice = `Startup pages ${list} of ${c.of} (load ${c.load_id}) were never fetched, so you are working from part of your memory. ` +
2158
+ `Fetch each now with emet_initialize {"page": N, "load_id": "${c.load_id}"} before you rely on memory; if the server says the startup changed, ` +
2159
+ 'call emet_initialize {} and read every page. This session was opened; nothing was blocked.';
2160
+ try { logger?.warn?.('session opened with startup pages missing', { session_id: sessionId, load_id: c.load_id, missing: c.missing, of: c.of }); } catch { /* a failing logger never fails the open */ }
2161
+ return { load_id: c.load_id, checked: true, complete: false, of: c.of, served: c.served, missing: c.missing, notice };
2162
+ }
2163
+
2164
+ /** The one-time session handle for a sub-key open. Null when this open did not present a key. Never logged. */
2165
+ async function subkeyHandleForOpen(actor, base, now) {
2166
+ if (!actor || setting('SUBKEYS') !== '1') return null;
2167
+ const { takeOpenToken, commitLiveSession } = await import('./subkeys.js');
2168
+ if (!takeOpenToken(actor)) return null;
2169
+ try {
2170
+ return await commitLiveSession(approvalsDeps, actor, base, now);
2171
+ } catch (e) {
2172
+ if (e && e.attributionRefusal) throw new ValidationError('subkey', e.message);
2173
+ throw e;
2174
+ }
2175
+ }
2176
+
2177
+ export async function transcriptAppend(args = {}, logger = null) {
2178
+ const session_id = args.session_id;
2179
+ const user_message = args.user_message;
2180
+ const assistant_message = args.assistant_message;
2181
+ const exchange_index = args.exchange_index;
2182
+ const session_start = args.session_start;
2183
+ const channel = args.channel;
2184
+ redactWithReport(String(user_message || '') + String(assistant_message || ''));
2185
+ if (!session_id || typeof session_id !== 'string') {
2186
+ throw new ValidationError('session_id is required');
2187
+ }
2188
+ if (typeof user_message !== 'string' || typeof assistant_message !== 'string') {
2189
+ throw new ValidationError('user_message and assistant_message must be strings');
2190
+ }
2191
+ assertExchangeBudget([user_message, assistant_message]);
2192
+ const {
2193
+ partId, partNumber, baseFromPart, nextExchangeIndex, shouldStartNewPart, EXCHANGES_PER_PART
2194
+ } = await import('./transcript-session.js');
2195
+
2196
+ const client = await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10 });
2197
+ try {
2198
+ const col = client.db(MONGODB_DB).collection('transcripts');
2199
+ let part = session_id;
2200
+ let live = await col.findOne({ session_id: part, superseded_by: { $in: [null, undefined] } })
2201
+ || await col.findOne({ session_id: part });
2202
+
2203
+ if (live && shouldStartNewPart(live.exchange_count || (live.exchanges || []).length)) {
2204
+ const n = partNumber(part) + 1;
2205
+ part = partId(baseFromPart(part), n);
2206
+ live = await col.findOne({ session_id: part, superseded_by: { $in: [null, undefined] } });
2207
+ }
2208
+
2209
+ const exchanges = (live && live.exchanges) || [];
2210
+ const idx = Number.isFinite(exchange_index) ? exchange_index : nextExchangeIndex(exchanges);
2211
+ if (exchanges.some((e) => Number(e.exchange_index) === idx)) {
2212
+ return { session_id: part, exchange_index: idx, already_present: true, exchange_count: exchanges.length };
2213
+ }
2214
+
2215
+ const scrub = (s) => redactWithReport(s || '').text;
2216
+ const stamp = writerStamp();
2217
+ const row = {
2218
+ ...storedExchange({
2219
+ exchange_index: idx,
2220
+ user_message,
2221
+ assistant_message,
2222
+ timestamp: Date.now() / 1000
2223
+ }, idx - 1, scrub),
2224
+ source: channel || setting('SOURCE_TAG') || null,
2225
+ person_id: stamp.person_id,
2226
+ client_id: stamp.client_id
2227
+ };
2228
+ const attr = declaredAttribution();
2229
+ if (attr && attr.basis === 'subkey' && attr.keyId) {
2230
+ row.key_id = attr.keyId;
2231
+ row.source_basis = 'subkey';
2232
+ }
2233
+
2234
+ // Build 1 (2026-09-27): the first append of a session must carry the
2235
+ // measured machine line, or say there is no local shell. The rule lived in
2236
+ // host instructions and was skipped twice on 2026-09-26; the server now
2237
+ // refuses to start a transcript without it. Known names come from the
2238
+ // user's install notes; with none, the check is reported as not enforced.
2239
+ let machineCheck = null;
2240
+ if (!live && partNumber(part) === 1 && idx === 1) {
2241
+ const { knownMachinesFromInstallNotes, machineLineCheck } = await import('./lessons-to-checks.js');
2242
+ const { installDoc } = await import('./setup.js');
2243
+ const notes = await client.db(MONGODB_DB).collection('documents')
2244
+ .findOne({ doc_id: installDoc(), retired: { $ne: true } }, { projection: { content: 1 } });
2245
+ machineCheck = machineLineCheck(assistant_message, knownMachinesFromInstallNotes(notes && notes.content));
2246
+ if (!machineCheck.ok) throw new ValidationError(machineCheck.reason);
2247
+ }
2248
+
2249
+ if (!live) {
2250
+ const start = typeof session_start === 'number' ? session_start : Date.now() / 1000;
2251
+ await col.insertOne({
2252
+ session_id: part,
2253
+ channel: channel || setting('SOURCE_TAG') || 'mcp',
2254
+ session_start: new Date(start * 1000),
2255
+ session_end: new Date(),
2256
+ exchange_count: 1,
2257
+ exchanges: [row],
2258
+ created_at: new Date()
2259
+ });
2260
+ return {
2261
+ session_id: part, exchange_index: idx, exchange_count: 1, rolled: part !== session_id,
2262
+ ...(machineCheck ? { machine_line: machineCheck.enforced ? `checked: ${machineCheck.matched}` : machineCheck.reason } : {})
2263
+ };
2264
+ }
2265
+
2266
+ await col.updateOne(
2267
+ { _id: live._id },
2268
+ { $push: { exchanges: row }, $inc: { exchange_count: 1 }, $set: { session_end: new Date() } }
2269
+ );
2270
+ logger?.info?.('transcript append', { session_id: part, exchange_index: idx });
2271
+ return {
2272
+ session_id: part,
2273
+ exchange_index: idx,
2274
+ exchange_count: (live.exchange_count || exchanges.length) + 1,
2275
+ rolled: part !== session_id,
2276
+ next_part_after: EXCHANGES_PER_PART
2277
+ };
2278
+ } finally {
2279
+ await client.close();
2280
+ }
2281
+ }
2282
+
2283
+ /**
2284
+ * Supersede a stored transcript part with a corrected one.
2285
+ *
2286
+ * Everything else in this store is non-destructive: layer entries supersede,
2287
+ * documents version with the prior copy archived. Transcripts had no such path,
2288
+ * so a part written with a mispaired or missing exchange could only be annotated
2289
+ * from the outside and left standing - which is how a record ends up complete in
2290
+ * shape and wrong in content.
2291
+ *
2292
+ * This does NOT edit or delete. It writes a NEW document under the same
2293
+ * session_id carrying an incremented revision and a pointer to its parent, and
2294
+ * marks the parent superseded. Both remain readable; readers take the live one.
2295
+ * The reason is REQUIRED - a correction with no stated cause is a silent rewrite
2296
+ * wearing a version number, and the point of keeping the parent is that someone
2297
+ * can see what changed and why.
2298
+ */
2299
+ export async function reviseTranscript(session_id, exchanges, reason, session_start = null, session_end = null, channel = null, logger = null) {
2300
+ const startTime = Date.now();
2301
+ try {
2302
+ if (!session_id || typeof session_id !== 'string')
2303
+ throw new ValidationError('session_id must be a non-empty string');
2304
+ if (!Array.isArray(exchanges) || exchanges.length === 0)
2305
+ throw new ValidationError('exchanges must be a non-empty array - a revision replaces the whole part, not a field within it');
2306
+ if (!reason || typeof reason !== 'string' || reason.trim().length < 10)
2307
+ throw new ValidationError('reason is required and must describe what was wrong - at least 10 characters. An unexplained correction is a silent rewrite.');
2308
+
2309
+ const client = await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10 });
2310
+ const db = client.db(MONGODB_DB);
2311
+ const col = db.collection('transcripts');
2312
+
2313
+ // The live document is the one nothing supersedes.
2314
+ const parent = await col.findOne({ session_id, superseded_by: { $in: [null, undefined] } })
2315
+ || await col.findOne({ session_id });
2316
+ if (!parent) {
2317
+ await client.close();
2318
+ throw new ValidationError(`No transcript found under session_id "${session_id}". Use save_transcript to write a new part; revise_transcript only corrects one that exists.`);
2319
+ }
2320
+
2321
+ const revision = (typeof parent.revision === 'number' ? parent.revision : 1) + 1;
2322
+ let redactedSpans = 0;
2323
+ const scrub = (s) => { const r = redactWithReport(s || ''); redactedSpans += r.hits; return r.text; };
2324
+ const revised = {
2325
+ session_id,
2326
+ channel: channel || parent.channel || null,
2327
+ session_start: session_start ? new Date(session_start * 1000) : parent.session_start,
2328
+ session_end: session_end ? new Date(session_end * 1000) : parent.session_end,
2329
+ exchange_count: exchanges.length,
2330
+ exchanges: exchanges.map((ex, i) => stampExchange(storedExchange(ex, i, scrub), channel || parent.channel)),
2331
+ ...(redactedSpans > 0 ? { redacted_spans: redactedSpans } : {}),
2332
+ revision,
2333
+ supersedes: parent._id,
2334
+ superseded_by: null,
2335
+ revision_reason: reason.trim(),
2336
+ revised_at: new Date(),
2337
+ created_at: new Date()
2338
+ };
2339
+
2340
+ const result = await col.insertOne(revised);
2341
+ await col.updateOne({ _id: parent._id }, { $set: { superseded_by: result.insertedId } });
2342
+
2343
+ // Read back rather than trust the write, and confirm the parent survived -
2344
+ // a supersession that lost the original would be a deletion with extra steps.
2345
+ const stored = await col.findOne({ _id: result.insertedId });
2346
+ const parentAfter = await col.findOne({ _id: parent._id });
2347
+ await client.close();
2348
+
2349
+ const verified = Boolean(stored)
2350
+ && stored.exchange_count === exchanges.length
2351
+ && Array.isArray(stored.exchanges)
2352
+ && stored.exchanges.length === exchanges.length
2353
+ && Boolean(parentAfter)
2354
+ && String(parentAfter.superseded_by) === String(result.insertedId);
2355
+
2356
+ return {
2357
+ success: true,
2358
+ verified,
2359
+ session_id,
2360
+ revision,
2361
+ inserted_id: result.insertedId.toString(),
2362
+ supersedes_id: parent._id.toString(),
2363
+ parent_retained: Boolean(parentAfter),
2364
+ parent_marked_superseded: Boolean(parentAfter && parentAfter.superseded_by),
2365
+ exchange_count: exchanges.length,
2366
+ ...(revised.exchanges.some(e => e.timestamp_defaulted)
2367
+ ? { timestamps_defaulted: revised.exchanges.filter(e => e.timestamp_defaulted).length } : {}),
2368
+ previous_exchange_count: parent.exchange_count ?? null,
2369
+ redacted: redactedSpans,
2370
+ revision_reason: reason.trim(),
2371
+ note: verified
2372
+ ? 'The prior version is retained and marked superseded. Readers take the revision; the original stays auditable.'
2373
+ : 'Write completed but read-back did not confirm. Do not report this as corrected - check the collection.',
2374
+ performance: { duration_ms: Date.now() - startTime }
2375
+ };
2376
+ } catch (error) {
2377
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
2378
+ throw new DatabaseError(`Failed to revise transcript: ${error.message}`, 'revise_transcript');
2379
+ }
2380
+ }
2381
+
2382
+ // ============================================
2383
+ // DOCUMENT STORE - versioned continuity documents
2384
+ // Added 2026-07-18: HANDOFF/STATE/threads/bootstrap served interface-independently
2385
+ // ============================================
2386
+
2387
+ async function _docsClient() {
2388
+ const client = await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10 });
2389
+ const db = client.db(MONGODB_DB);
2390
+ const col = db.collection('documents');
2391
+ const hist = db.collection('documents_history');
2392
+ if (!docIndexesReady) {
2393
+ try {
2394
+ await col.createIndex({ person_id: 1 }, { sparse: true });
2395
+ await col.createIndex({ client_id: 1 }, { sparse: true });
2396
+ await hist.createIndex({ person_id: 1 }, { sparse: true });
2397
+ await hist.createIndex({ client_id: 1 }, { sparse: true });
2398
+ docIndexesReady = true;
2399
+ } catch { /* an index miss never blocks the write */ }
2400
+ }
2401
+ return { client, col, hist };
2402
+ }
2403
+ let docIndexesReady = false;
2404
+
2405
+ /**
2406
+ * The default page for a read_doc TOOL call, in bytes (2026-09-26; bytes and
2407
+ * 18,000 since 2026-10-03). A host that caps tool output returned a
2408
+ * 69,000-character board as an overflow file rather than as the board, and a
2409
+ * host with no file fallback would simply lose it. Hosts cap the result they
2410
+ * RECEIVE (commonly 20,000 bytes), and that is the page as JSON text - every
2411
+ * newline, quote and backslash costs a byte more there - plus the envelope
2412
+ * (sha256, version, conformance, the page note). So a page is cut where its
2413
+ * JSON-escaped bytes plus READ_DOC_ENVELOPE_SPARE would pass the limit. The
2414
+ * tool pages at this size unless the caller asks otherwise; `limit: 0` returns
2415
+ * the whole document. Internal callers pass no page and always get the whole
2416
+ * document - patch_doc and friends must never operate on a slice.
2417
+ */
2418
+ export const READ_DOC_PAGE_DEFAULT = Number(setting('READ_DOC_PAGE', 18000)) || 18000;
2419
+
2420
+ /** Bytes of a page's limit kept for the read_doc result's envelope (2026-10-03). */
2421
+ export const READ_DOC_ENVELOPE_SPARE = 1500;
2422
+
2423
+ /** The bytes a string takes as JSON text, quotes included: what a host receives for it. */
2424
+ export function jsonEscapedBytes(s) {
2425
+ return Buffer.byteLength(JSON.stringify(s), 'utf8');
2426
+ }
2427
+
2428
+ /** UTF-8 length of one code point (a lone surrogate encodes as U+FFFD: 3 bytes). */
2429
+ function utf8Length(cp) {
2430
+ return cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
2431
+ }
2432
+
2433
+ /** JSON-escaped length of one code point, as JSON.stringify writes it (a lone surrogate is \uXXXX: 6 bytes). */
2434
+ function jsonLength(cp) {
2435
+ if (cp === 0x22 || cp === 0x5c || cp === 0x08 || cp === 0x09 || cp === 0x0a || cp === 0x0c || cp === 0x0d) return 2;
2436
+ if (cp < 0x20) return 6;
2437
+ if (cp >= 0xd800 && cp <= 0xdfff) return 6;
2438
+ return utf8Length(cp);
2439
+ }
2440
+
2441
+ /**
2442
+ * Cut one page from a document's content. Returns the content and, when the
2443
+ * page is not the whole document, a `page` block saying where it sits.
2444
+ *
2445
+ * `offset`, `next_offset`, `returned` and `size` are UTF-8 BYTES of the whole
2446
+ * document. `limit` bounds what the host receives: a page ends before its
2447
+ * JSON-escaped bytes (jsonEscapedBytes, quotes included) plus
2448
+ * READ_DOC_ENVELOPE_SPARE would pass `limit`; `returned_escaped` is that
2449
+ * escaped size. A page never splits a character (a code point), and
2450
+ * `next_offset` is always the byte where the next character starts. An `offset` that falls inside a character is moved back to
2451
+ * that character's first byte, so nothing is skipped. A `limit` smaller than
2452
+ * the character at `offset` still returns that one character, so paging always
2453
+ * moves forward. Joining the pages in order gives back the document exactly.
2454
+ */
2455
+ export function pageContent(full, offset = 0, limit = null) {
2456
+ const text = String(full ?? '');
2457
+ const n = text.length;
2458
+ const unitsAt = (i) => {
2459
+ const cp = text.codePointAt(i);
2460
+ return { bytes: utf8Length(cp), json: jsonLength(cp), units: cp > 0xffff ? 2 : 1 };
2461
+ };
2462
+ let size = 0;
2463
+ for (let i = 0; i < n;) { const c = unitsAt(i); size += c.bytes; i += c.units; }
2464
+ const want = Math.max(0, Math.min(Number(offset) || 0, size));
2465
+ const lim = limit === null || limit === undefined ? 0 : Math.max(0, Number(limit) || 0);
2466
+ // Start: the first byte of the character holding byte `want`.
2467
+ let i = 0; let start = 0;
2468
+ while (i < n) { const c = unitsAt(i); if (start + c.bytes > want) break; start += c.bytes; i += c.units; }
2469
+ // End: the last whole character whose JSON-escaped page, plus the envelope
2470
+ // spare, stays within `lim` (at least one character, so paging moves forward).
2471
+ let j = i; let end = start;
2472
+ if (lim === 0) { j = n; end = size; } else {
2473
+ let escaped = 2; // the quotes
2474
+ while (j < n) {
2475
+ const c = unitsAt(j);
2476
+ if (escaped + c.json + READ_DOC_ENVELOPE_SPARE > lim && j > i) break;
2477
+ escaped += c.json; end += c.bytes; j += c.units;
2478
+ }
2479
+ }
2480
+ const content = text.slice(i, j);
2481
+ if (start === 0 && end === size) return { content, page: null };
2482
+ return {
2483
+ content,
2484
+ page: {
2485
+ unit: 'bytes',
2486
+ offset: start,
2487
+ returned: end - start,
2488
+ returned_escaped: jsonEscapedBytes(content),
2489
+ envelope_spare: READ_DOC_ENVELOPE_SPARE,
2490
+ size,
2491
+ size_bytes: size,
2492
+ next_offset: end < size ? end : null,
2493
+ note: end < size
2494
+ ? 'Partial: this is one page of the document. Call read_doc again with offset = next_offset for the rest, and pass expected_version (this result\'s version) so a change between pages is refused rather than mixed. sha256 is the WHOLE document\'s digest.'
2495
+ : 'Final page. sha256 is the WHOLE document\'s digest.'
2496
+ }
2497
+ };
2498
+ }
2499
+
2500
+ /**
2501
+ * The version pin for a paged read (2026-10-03). A caller reading page 2 onward
2502
+ * passes the `version` (or `sha256`) its first page returned; if the document
2503
+ * was rewritten in between, the read is refused, so pages of two versions are
2504
+ * never joined. A shipped guide has no version: pin it by sha256. Throws a
2505
+ * ValidationError naming what changed; returns nothing when the pin holds.
2506
+ */
2507
+ export function checkReadPin(doc_id, current, opts = {}) {
2508
+ const wantV = opts.expected_version;
2509
+ const wantS = opts.expected_sha256;
2510
+ const hasV = wantV !== undefined && wantV !== null && wantV !== '';
2511
+ const hasS = typeof wantS === 'string' && wantS.trim() !== '';
2512
+ if (!hasV && !hasS) return;
2513
+ if (hasV && !(Number.isInteger(Number(wantV)) && Number(wantV) >= 1)) {
2514
+ throw new ValidationError('expected_version', 'expected_version is the whole-number `version` an earlier page of this document returned.');
2515
+ }
2516
+ const vChanged = hasV && current.version !== undefined && current.version !== null && Number(wantV) !== Number(current.version);
2517
+ const sChanged = hasS && String(current.sha256 || '').toLowerCase() !== wantS.trim().toLowerCase();
2518
+ if (!vChanged && !sChanged) return;
2519
+ const was = [hasV ? `version ${Number(wantV)}` : null, hasS ? `sha256 ${wantS.trim().slice(0, 12)}...` : null].filter(Boolean).join(', ');
2520
+ const now = [current.version !== undefined && current.version !== null ? `version ${current.version}` : null,
2521
+ current.sha256 ? `sha256 ${String(current.sha256).slice(0, 12)}...` : null].filter(Boolean).join(', ');
2522
+ throw new ValidationError('expected_version',
2523
+ `${doc_id}: document changed - restart from offset 0. You read earlier pages of ${was}; it is now ${now}. ` +
2524
+ 'Pages of two versions are never joined, so nothing was returned. Call read_doc again with offset 0 and no expected_version, ' +
2525
+ 'discard the pages you already have, and read every page of the new version.');
2526
+ }
2527
+
2528
+ export async function readDoc(doc_id, logger = null, opts = {}) {
2529
+ try {
2530
+ if (!doc_id || typeof doc_id !== 'string') throw new ValidationError('doc_id must be a non-empty string');
2531
+ const guide = readShippedGuide(doc_id);
2532
+ if (guide) {
2533
+ checkReadPin(doc_id, guide, opts);
2534
+ const { content, page } = pageContent(guide.content, opts.offset ?? 0, opts.limit ?? null);
2535
+ return { ...guide, content, size_unit: 'characters', size_bytes: Buffer.byteLength(guide.content, 'utf8'), ...(page ? { page } : {}) };
2536
+ }
2537
+ const { client, col } = await _docsClient();
2538
+ try {
2539
+ const doc = await col.findOne({ doc_id });
2540
+ if (!doc) throw new ValidationError(`Document not found: ${doc_id}`);
2541
+ checkReadPin(doc_id, doc, opts);
2542
+ const { content, page } = pageContent(doc.content, opts.offset ?? 0, opts.limit ?? null);
2543
+ // A retired document is still returned in full - retain and mark, never
2544
+ // withhold. The caller is TOLD it is obsolete so it is not used as
2545
+ // current, which is the whole of "prevent unintended use".
2546
+ return { doc_id: doc.doc_id, content, sha256: doc.sha256, version: doc.version,
2547
+ updated_at: doc.updated_at, updated_by: doc.updated_by || null,
2548
+ client_id: doc.client_id || null, session_id: doc.session_id || null,
2549
+ ...(doc.key_id ? { key_id: doc.key_id } : {}),
2550
+ ...(doc.source_basis ? { source_basis: doc.source_basis } : {}),
2551
+ path_hint: doc.path_hint || null, size: doc.content.length,
2552
+ size_unit: 'characters', size_bytes: Buffer.byteLength(doc.content, 'utf8'),
2553
+ ...(page ? { page } : {}),
2554
+ // The conformance verdict travels with the document the same way
2555
+ // `integrity` travels with a layer row: a reader is told what the
2556
+ // record knows about itself rather than having to ask separately.
2557
+ // null on documents written before validation existed - a dated
2558
+ // boundary, not a defect.
2559
+ conformance: doc.conformance || null,
2560
+ signed: signatureIntegrityOf(doc, setting('SIGNING_KEY') || null),
2561
+ ...(doc.retired === true ? {
2562
+ retired: true, retired_at: doc.retired_at || null,
2563
+ retired_by: doc.retired_by || null,
2564
+ retired_reason: doc.retired_reason || null,
2565
+ note: 'This document is marked OBSOLETE. It is retained in full and readable, but it is not current - do not act on it as live state.'
2566
+ } : {}) };
2567
+ } finally { await client.close(); }
2568
+ } catch (error) {
2569
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
2570
+ throw new DatabaseError(`Failed to read document: ${error.message}`, 'read_doc');
2571
+ }
2572
+ }
2573
+
2574
+ /**
2575
+ * Read a retained revision of a document, or list the revisions retained.
2576
+ *
2577
+ * Every write archives the prior copy to documents_history (commitment 2:
2578
+ * nothing is deleted). Until 2026-09-15 nothing could read one back - the
2579
+ * retention held but an audit asking "what did this say at version N" had no
2580
+ * tool to answer with. Read-only; the write-path invariant test confirms it.
2581
+ */
2582
+ export async function readDocHistory(doc_id, version = null, logger = null) {
2583
+ try {
2584
+ if (!doc_id || typeof doc_id !== 'string') throw new ValidationError('doc_id must be a non-empty string');
2585
+ if (version !== null && version !== undefined && (!Number.isInteger(version) || version < 1)) throw new ValidationError('version must be a positive integer');
2586
+ const { client, col, hist } = await _docsClient();
2587
+ try {
2588
+ const current = await col.findOne({ doc_id });
2589
+ if (version === null || version === undefined) {
2590
+ const retained = await hist.find({ doc_id }, { projection: { content: 0 } }).sort({ version: 1 }).toArray();
2591
+ const rows = retained.map(r => ({ version: r.version, sha256: r.sha256 || null, size: r.size ?? null,
2592
+ updated_at: r.updated_at || null, updated_by: r.updated_by || null, client_id: r.client_id || null, session_id: r.session_id || null, archived_at: r.archived_at || null,
2593
+ ...(r.key_id ? { key_id: r.key_id } : {}),
2594
+ ...(r.source_basis ? { source_basis: r.source_basis } : {}),
2595
+ conformance: r.conformance ? r.conformance.verdict : null }));
2596
+ return { doc_id, current_version: current ? current.version : null, retained_count: rows.length, retained: rows,
2597
+ note: current ? null : 'No current document by this id; only retained revisions, if any.' };
2598
+ }
2599
+ if (current && current.version === version) {
2600
+ return { doc_id, version, is_current: true, content: current.content, sha256: current.sha256, size: current.content.length,
2601
+ updated_at: current.updated_at, updated_by: current.updated_by || null,
2602
+ client_id: current.client_id || null, session_id: current.session_id || null,
2603
+ ...(current.key_id ? { key_id: current.key_id } : {}),
2604
+ ...(current.source_basis ? { source_basis: current.source_basis } : {}),
2605
+ conformance: current.conformance || null };
2606
+ }
2607
+ // More than one archived row can carry a version when a write was retried;
2608
+ // the most recently archived is the one the store last held at that number.
2609
+ const row = await hist.find({ doc_id, version }).sort({ archived_at: -1 }).limit(1).next();
2610
+ if (!row) throw new ValidationError(`No retained revision ${version} of ${doc_id}`);
2611
+ const integrity = row.sha256 ? (digest(row.content) === row.sha256) : 'unattested';
2612
+ return { doc_id, version, is_current: false, content: row.content, sha256: row.sha256 || null, size: row.content.length,
2613
+ updated_at: row.updated_at || null, updated_by: row.updated_by || null, archived_at: row.archived_at || null,
2614
+ client_id: row.client_id || null, session_id: row.session_id || null,
2615
+ ...(row.key_id ? { key_id: row.key_id } : {}),
2616
+ ...(row.source_basis ? { source_basis: row.source_basis } : {}),
2617
+ conformance: row.conformance || null,
2618
+ // Re-hashed on read, as a layer row is: a retained revision that
2619
+ // does not match its own digest is returned and flagged, never hidden.
2620
+ integrity };
2621
+ } finally { await client.close(); }
2622
+ } catch (error) {
2623
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
2624
+ throw new DatabaseError(`Failed to read document history: ${error.message}`, 'read_doc_history');
2625
+ }
2626
+ }
2627
+
2628
+ /**
2629
+ * Records are superseded, never amended (2026-09-26, Alex: "enforce
2630
+ * supersession ISO practices"). ISO 9001 7.5.3 / ISO 13485 4.2.5 separate
2631
+ * DOCUMENTS, which are revised under control, from RECORDS, which are evidence
2632
+ * of what happened and are not altered. The handoff is a record: it attests to
2633
+ * one session's close. So:
2634
+ * - patch_doc is refused on a record - no in-place amendment;
2635
+ * - a new revision goes through write_doc WHOLE and must name the revision it
2636
+ * supersedes (`supersedes`), which must be the one currently in force;
2637
+ * - the prior revision is retained in documents_history and marked obsolete
2638
+ * there (superseded_by_version), as it is for every document.
2639
+ * The record set is the handoff plus any ids in EMET_RECORD_DOCS (comma-list).
2640
+ */
2641
+ export function recordDocIds() {
2642
+ const extra = String(setting('RECORD_DOCS', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
2643
+ return [setting('HANDOFF_DOC', 'HANDOFF.md'), ...extra];
2644
+ }
2645
+ export function isRecordDoc(doc_id) {
2646
+ return recordDocIds().includes(doc_id);
2647
+ }
2648
+ /** Every document that is not a record, a drop, or a shipped copy. */
2649
+ export function isWorkingDoc(doc_id) {
2650
+ return !isRecordDoc(doc_id) && !isDropDoc(doc_id) && !isGovernedByShippedCopy(doc_id);
2651
+ }
2652
+ function stateDoc() {
2653
+ return setting('STATE_DOC', 'STATE.md');
2654
+ }
2655
+ function _refuseRecordAmendment(doc_id, tool) {
2656
+ if (isRecordDoc(doc_id)) {
2657
+ throw new ValidationError(
2658
+ `${tool}: ${doc_id} is a record. Records are superseded, not amended (ISO 9001 7.5.3 / 13485 4.2.5). ` +
2659
+ `Issue a new revision with write_doc - the whole record, with \`supersedes\` set to the version now in force. ` +
2660
+ `The prior revision is retained and marked obsolete. Nothing was changed.`
2661
+ );
2662
+ }
2663
+ }
2664
+
2665
+ /**
2666
+ * The main records are the hub's (C1, 2026-10-01; review note section
2667
+ * 3.5). write_doc and patch_doc refuse the main HANDOFF.md or STATE.md when the
2668
+ * write's source is a bot tag (bots/<tag>/HANDOFF.md listed in
2669
+ * EMET_RECORD_DOCS), and nothing is written. So do the other doors that can
2670
+ * change a main record (the tester's F1 on PR 2): retire_doc, rename_doc (from
2671
+ * or onto one) and accept_nonconformance - retire and rename set retired_by,
2672
+ * not updated_by, so the close alone could not see them. The source is the one the door
2673
+ * stamps as updated_by: the caller's, else EMET_SOURCE_TAG. A records rule like
2674
+ * the record-amendment refusal above, so it applies in every graph mode.
2675
+ *
2676
+ * Two further guards on the same doors (2026-10-01, the owner's 4:17 PM approval to
2677
+ * make the bot tags fully functional; the tester's review of PR 3):
2678
+ * - REGISTERED HOSTS ONLY on the main records. With a source registry
2679
+ * configured (EMET_SOURCE_TAGS, parsed as save_to_layer parses it), the
2680
+ * main handoff and board take writes only from a registered tag. An
2681
+ * unlisted or misspelled bot tag, a write naming no source (which falls
2682
+ * back to EMET_SOURCE_TAG), and cloud-scheduled-* runs are refused - the
2683
+ * transcript registry's cloud-scheduled exception is deliberately not
2684
+ * copied: unattended runs do not write the hub's records. With no registry
2685
+ * (a fresh install) nothing changes.
2686
+ * - EACH BOT IN ITS OWN DIRECTORY. A bot tag writes under bots/ only inside
2687
+ * bots/<its own tag>/: not another bot's handoff, not bots/ROSTER.md.
2688
+ * Hosts (the hub) keep writing anywhere under bots/.
2689
+ */
2690
+ export function sourceRegistry() {
2691
+ const env = String(setting('SOURCE_TAGS') ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '');
2692
+ // Attribution v2: tags approved in emet/CLIENT_APPROVALS.md count as
2693
+ // registered once a registry exists (a fresh install with no registry stays open).
2694
+ return env.length ? [...new Set([...env, ...approvedTags()])] : env;
2695
+ }
2696
+
2697
+ /**
2698
+ * Attribution v2 (the reviewer A3): a declared tag never gains rights beyond the login's
2699
+ * tag. A guard is run for every tag returned here and the write must pass all.
2700
+ */
2701
+ export function rightsTags(tag) {
2702
+ const d = declaredAttribution();
2703
+ if (d && d.basis === 'subkey' && d.effective && tag === d.effective && d.login && d.login !== tag) return [tag, d.login];
2704
+ if (d && d.declared && tag === d.declared && d.login && d.login !== tag) return [tag, d.login];
2705
+ return [tag];
2706
+ }
2707
+
2708
+ /**
2709
+ * The tag a write is stamped with.
2710
+ * `from: 'bound'` — HTTP, the tag stored at approval. The caller's tag is ignored.
2711
+ * `from: 'legacy'` — HTTP, the token has no bound tag. The caller's tag is kept
2712
+ * only when it is an exact member of EMET_SOURCE_TAGS. An empty registry has no
2713
+ * members, so the tag stays unset.
2714
+ * `from: 'caller'` — stdio. The caller tag is returned as given; saveMemory may
2715
+ * still apply EMET_SOURCE_TAG when it is empty.
2716
+ */
2717
+ export function resolvedSourceTag(caller) {
2718
+ const bound = boundSourceTag();
2719
+ const tag = typeof caller === 'string' ? caller.trim() : '';
2720
+ if (bound === undefined) return { tag: tag || null, from: 'caller' };
2721
+ // Attribution v2: the per-request tag decided by attribution.preflight is the
2722
+ // stamp (declared, or generated for a login with none). Rights stay the
2723
+ // login's: see rightsTags().
2724
+ const d = declaredAttribution();
2725
+ if (d && d.basis === 'subkey' && d.effective) return { tag: d.effective, from: 'subkey', login: bound || null, key_id: d.keyId || null };
2726
+ if (d && d.declared && d.basis === 'declared' && tag === d.declared) return { tag, from: 'declared', login: bound || null };
2727
+ if (typeof bound === 'string' && bound) return { tag: bound, from: 'bound' };
2728
+ if (tag && sourceRegistry().includes(tag)) return { tag, from: 'legacy' };
2729
+ if (d && d.basis === 'auto' && d.declared) return { tag: d.declared, from: 'auto' };
2730
+ return { tag: null, from: 'legacy' };
2731
+ }
2732
+
2733
+ // Look-alike doc ids (the reviewer's recheck d1-d5, 2026-10-02): a `.` or `..` segment,
2734
+ // a leading `/`, or a `bots/` prefix in the wrong case would let a write pass
2735
+ // the prefix checks below while naming a record they are meant to fence (d1:
2736
+ // bots/reviewer-bot/../intake-bot/HANDOFF.md passed the own-dir check). Doc
2737
+ // ids are flat strings, never resolved as paths, so these shapes are refused
2738
+ // outright, before any guard, on every door that calls this.
2739
+ //
2740
+ // Hardening (the tester's scope on the doc-id fix, 2026-10-02): also refused are
2741
+ // control and invisible-format characters (\p{Cc}, \p{Cf}) anywhere; leading or
2742
+ // trailing whitespace on the id or on any segment; a backslash; the `/`, `.`/`..`
2743
+ // and `bots/` checks run on the NFKC form too (so full-width dots and slashes,
2744
+ // the two-dot leader and full-width letters fold first); a first segment that
2745
+ // folds to `bots` must be exactly the ASCII `bots`; and a case or Unicode variant
2746
+ // of a main-record id (HANDOFF.md -> handoff.md). No confusables table: a
2747
+ // Cyrillic look-alike is a different id and falls to the ordinary guards. `%2e`
2748
+ // is not decoded: ids are never URL-decoded, so it is just three characters.
2749
+ const FOLD = (s) => s.normalize('NFKC').toLowerCase();
2750
+ export function lookalikeDocIdReason(doc_id) {
2751
+ if (typeof doc_id !== 'string') return null;
2752
+ if (/[\p{Cc}\p{Cf}]/u.test(doc_id)) return 'has a control or invisible formatting character';
2753
+ if (doc_id.includes('\\')) return 'has a backslash';
2754
+ if (doc_id !== doc_id.trim() || doc_id.split('/').some((seg) => seg !== seg.trim())) {
2755
+ return 'has leading or trailing whitespace on the id or on a segment';
2756
+ }
2757
+ const nfkc = doc_id.normalize('NFKC');
2758
+ if (doc_id.startsWith('/')) return 'starts with "/"';
2759
+ if (nfkc.startsWith('/')) return 'starts with a character that normalises to "/"';
2760
+ if (doc_id.split('/').some((seg) => seg === '.' || seg === '..')) return 'has a "." or ".." segment';
2761
+ if (nfkc.split('/').some((seg) => seg === '.' || seg === '..')) return 'has a segment that normalises to "." or ".."';
2762
+ const first = doc_id.split('/')[0];
2763
+ const firstFolded = FOLD(nfkc.split('/')[0]);
2764
+ if (doc_id.slice(0, 5).toLowerCase() === 'bots/' && !doc_id.startsWith('bots/')) {
2765
+ return `starts with "${doc_id.slice(0, 5)}" - the bot records folder is "bots/", lower case`;
2766
+ }
2767
+ if (firstFolded === 'bots' && first !== 'bots') {
2768
+ return `starts with "${first}", which folds to "bots" - the bot records folder is the ASCII "bots", lower case`;
2769
+ }
2770
+ const mains = mainRecordIds();
2771
+ if (!mains.includes(doc_id) && mains.some((m) => typeof m === 'string' && FOLD(m) === FOLD(doc_id))) {
2772
+ return 'is a case or Unicode variant of a main record id';
2773
+ }
2774
+ return null;
2775
+ }
2776
+
2777
+ export function refuseLookalikeDocId(doc_id, tool) {
2778
+ const why = lookalikeDocIdReason(doc_id);
2779
+ if (why) {
2780
+ throw new ValidationError(
2781
+ `${tool}: doc_id ${JSON.stringify(doc_id)} ${why}. Doc ids are not paths and are not resolved; write the id ` +
2782
+ `exactly as it is meant to be stored. Nothing was written.`
2783
+ );
2784
+ }
2785
+ }
2786
+
2787
+ // Cleanup path (the tester's scope): a look-alike id may already exist in a store
2788
+ // written before these checks. retire_doc, and rename_doc FROM such an id, are let
2789
+ // through for a registered host tag only: EMET_SOURCE_TAGS set, the tag listed,
2790
+ // and not a bot tag. Never for a write, a patch, a rename onto it, or a bot.
2791
+ function mayCleanUpLookalike(tag) {
2792
+ const registry = sourceRegistry();
2793
+ return !!tag && !isBotTag(tag) && registry.length > 0 && registry.includes(tag);
2794
+ }
2795
+
2796
+ export function refuseBotMainRecord(doc_id, updated_by, tool, options = {}) {
2797
+ for (const t of rightsTags(updated_by)) refuseBotMainRecordOne(doc_id, t, tool, options);
2798
+ }
2799
+ function refuseBotMainRecordOne(doc_id, updated_by, tool, options = {}) {
2800
+ const tag = updated_by || setting('SOURCE_TAG') || null;
2801
+ // The client-approvals record is server-only (the reviewer 4): no host writes,
2802
+ // patches, retires, renames, restores or accepts it.
2803
+ if (typeof doc_id === 'string' && FOLD(doc_id.normalize('NFKC')) === FOLD(APPROVALS_DOC) && !(options && options.approvalsRecord === true)) {
2804
+ throw new ValidationError(`${tool}: ${doc_id} is kept by the server (client approvals). Decide clients with emet_client_approve. Nothing was written.`);
2805
+ }
2806
+ // A3 without a registry (the reviewer re-review): a declared or generated tag writes
2807
+ // under bots/ only inside bots/<its own tag>/ and only once approved.
2808
+ {
2809
+ const d = declaredAttribution();
2810
+ if (d && d.declared && tag === d.declared && (d.basis === 'declared' || d.basis === 'auto') &&
2811
+ typeof doc_id === 'string' && doc_id.startsWith('bots/')) {
2812
+ const approved = approvedTags().includes(tag) || sourceRegistry().includes(tag);
2813
+ if (!doc_id.startsWith(botRecordDir(tag)) || !approved) {
2814
+ throw new ValidationError(`${tool}: ${doc_id} is a bot record and "${tag}" is a ${approved ? 'declared tag outside its own folder' : 'declared tag not yet approved'} - a declared tag writes only bots/${tag}/ once approved. Nothing was written.`);
2815
+ }
2816
+ }
2817
+ }
2818
+ if (!(options.cleanup === true && mayCleanUpLookalike(tag))) refuseLookalikeDocId(doc_id, tool);
2819
+ const bot = !!tag && isBotTag(tag);
2820
+ if (bot && typeof doc_id === 'string' && doc_id.startsWith('bots/') && !doc_id.startsWith(botRecordDir(tag))) {
2821
+ throw new ValidationError(
2822
+ `${tool}: ${doc_id} is outside ${botRecordDir(tag)} and ${tag} is a bot tag - a bot writes only its own ` +
2823
+ `${botRecordDir(tag)} records; other bots' records and the roster are not its to change. ` +
2824
+ `Hand it to the hub instead. Nothing was written.`
2825
+ );
2826
+ }
2827
+ // Registry guard on bots/ too (the tester's PR 5 review; the reviewer's fix (a), 2026-10-02): with EMET_SOURCE_TAGS
2828
+ // set, a non-bot write under bots/ is refused unless its tag is registered; a missing source counts as
2829
+ // unregistered. Without this, a misspelled or unregistered tag is not a bot tag, so the cross-bot guard
2830
+ // above never fires and it can write any bot's records or the roster. A listed bot is already fenced to
2831
+ // its own bots/<tag>/ above; registered hosts (the hub) still write anywhere under bots/.
2832
+ const registry = sourceRegistry();
2833
+ if (!bot && registry.length && typeof doc_id === 'string' && doc_id.startsWith('bots/') && !(tag && registry.includes(tag))) {
2834
+ throw new ValidationError(
2835
+ `${tool}: ${doc_id} is a bot record and ${tag ? `"${tag}" is not a registered source tag` : 'this write names no source tag'} - ` +
2836
+ `only a registered host or the bot itself writes under bots/. Nothing was written.`
2837
+ );
2838
+ }
2839
+ if (!mainRecordIds().includes(doc_id)) return;
2840
+ if (bot) {
2841
+ throw new ValidationError(
2842
+ `${tool}: ${doc_id} is a main record and ${tag} is a bot tag - a bot session closes against ` +
2843
+ `${botRecordDir(tag)} records; the main handoff and board are the hub's. Write the bot's own ` +
2844
+ `${botRecordDir(tag)}HANDOFF.md instead. Nothing was written.`
2845
+ );
2846
+ }
2847
+ if (options && options.serverProvision === true) return;
2848
+ if (registry.length && !(tag && registry.includes(tag))) {
2849
+ throw new ValidationError(
2850
+ `${tool}: ${doc_id} is a main record and ` +
2851
+ `${tag ? `"${tag}" is not a registered source tag` : 'this write names no source tag'} - only a registered ` +
2852
+ `host writes the main handoff and board. Pass the host's registered \`source\` (EMET_SOURCE_TAGS). Nothing was written.`
2853
+ );
2854
+ }
2855
+ }
2856
+
2857
+ /**
2858
+ * retire_doc and rename_doc are not bot tools at all (Alex, 2026-10-01 4:27 PM
2859
+ * ET, decision 1, as relayed: "retire_doc isn't needed for bots: keep it
2860
+ * owner/hub only and out of every bot profile"; procedural ref 407; stage 1 of the
2861
+ * ref 358 startup amendment, admitted by the tester 2026-10-02). A profile is not a
2862
+ * security boundary, so the door refuses them under ANY bot tag, for every
2863
+ * doc_id - not only the main records refuseBotMainRecord covers. ref 407(1)
2864
+ * covers retire_doc only; rename_doc stays owner-only under procedural ref 409
2865
+ * (owner-confirmed 2026-10-02 the recorded time: every other owner-only tool is
2866
+ * unchanged), and it retires its old id internally, so it is refused too. The tag is the
2867
+ * one the door would stamp: the caller's, else EMET_SOURCE_TAG. Hosts and the
2868
+ * hub have no bots/<tag>/HANDOFF.md record and are unaffected. Nothing is
2869
+ * written when refused.
2870
+ */
2871
+ export function refuseBotRetireRename(tag, tool) {
2872
+ for (const t of rightsTags(tag)) refuseBotRetireRenameOne(t, tool);
2873
+ }
2874
+ function refuseBotRetireRenameOne(tag, tool) {
2875
+ const t = tag || setting('SOURCE_TAG') || null;
2876
+ if (!t || !isBotTag(t)) return;
2877
+ throw new ValidationError(
2878
+ 'source',
2879
+ `${tool} is not a bot tool: ${t} is a bot tag, and ` +
2880
+ (tool === 'retire_doc'
2881
+ ? `retiring documents is the owner's and the hub's (decision 1, procedural ref 407 item 1)`
2882
+ : `renaming documents stays owner-only (procedural ref 409)`) +
2883
+ `. Ask the hub. Nothing was changed.`
2884
+ );
2885
+ }
2886
+
2887
+ export async function writeDoc(doc_id, rawContent, updated_by = null, path_hint = null, logger = null, options = {}) {
2888
+ try {
2889
+ if (!doc_id || typeof doc_id !== 'string') throw new ValidationError('doc_id must be a non-empty string');
2890
+ // write_doc, patch_doc (which lands here) and rename_doc's copy (reissued_from).
2891
+ refuseBotMainRecord(doc_id, updated_by, options && options.reissued_from ? 'rename_doc' : 'write_doc', options && options.serverProvision === true ? { serverProvision: true, ...(options.approvalsRecord === true ? { approvalsRecord: true } : {}) } : {});
2892
+ if (typeof rawContent !== 'string' || rawContent.length === 0) throw new ValidationError('content must be a non-empty string');
2893
+ if (rawContent.length > 2000000) throw new ValidationError('content exceeds 2MB limit');
2894
+ // patch_doc lands here too, so one
2895
+ // scrub covers every document write. The digest is of the stored text.
2896
+ const redaction = redactWithReport(rawContent);
2897
+ const content = redaction.text;
2898
+ const sha256 = digest(content);
2899
+ const signingKey = setting('SIGNING_KEY') || null;
2900
+ const signature = signDigests(sha256, '', signingKey);
2901
+ const { client, col, hist } = await _docsClient();
2902
+ try {
2903
+ const prev = await col.findOne({ doc_id });
2904
+ const version = (prev && prev.version ? prev.version : 0) + 1;
2905
+ // Compare-and-set for the client-approvals record (the reviewer round 3): checked
2906
+ // here before anything is archived, and again atomically in the update filter.
2907
+ const casOn = options && options.approvalsRecord === true && Object.prototype.hasOwnProperty.call(options, 'expectedVersion');
2908
+ const casConflict = () => { const e = new ValidationError(`${doc_id} changed under this write (compare-and-set). Nothing was written.`); e.versionConflict = true; return e; };
2909
+ if (casOn && ((prev ? prev.version : null) ?? null) !== (options.expectedVersion ?? null)) throw casConflict();
2910
+ // A record in force is superseded only by a revision that names it.
2911
+ // Checked before anything is archived, so a refused write leaves no trace.
2912
+ if (prev && isRecordDoc(doc_id) && options && options.restore_of && (options.supersedes === undefined || options.supersedes === null)) {
2913
+ options = { ...options, supersedes: prev.version };
2914
+ }
2915
+ if (prev && isRecordDoc(doc_id)) {
2916
+ const named = options && options.supersedes;
2917
+ if (named === undefined || named === null) {
2918
+ throw new ValidationError(
2919
+ `write_doc: ${doc_id} is a record and v${prev.version} is in force. A new revision must name what it ` +
2920
+ `supersedes - pass supersedes: ${prev.version}. Nothing was changed.`
2921
+ );
2922
+ }
2923
+ if (Number(named) !== prev.version) {
2924
+ throw new ValidationError(
2925
+ `write_doc: ${doc_id} - supersedes names v${named}, but v${prev.version} is in force. Re-read the record ` +
2926
+ `and supersede the current revision. Nothing was changed.`
2927
+ );
2928
+ }
2929
+ const reason = options && options.reason;
2930
+ if (typeof reason !== 'string' || reason.trim().length < 10) {
2931
+ throw new ValidationError(
2932
+ `write_doc: ${doc_id} is a record. A new revision needs reason (at least 10 characters) as well as supersedes. Nothing was changed.`
2933
+ );
2934
+ }
2935
+ }
2936
+ const registry = sourceRegistry();
2937
+ const serverProvision = options && options.serverProvision === true;
2938
+ // Attribution v2 (A3): the declared tag and the login's tag must both pass.
2939
+ const passes = (t) => registry.length === 0 || registry.includes(t || '') || (isBotTag(t) && doc_id.startsWith(`bots/${t}/`));
2940
+ if (!rightsTags(updated_by).every(passes) && !serverProvision && !(isDropDoc(doc_id) && !prev)) {
2941
+ throw new ValidationError(`write_doc: ${updated_by || 'no source'} is not a registered source. This session may read, and may write a new drop only. Nothing was written.`);
2942
+ }
2943
+ if (isDropDoc(doc_id) && !prev) {
2944
+ const { writerStamp } = await import('./persons.js');
2945
+ const bucket = dropBucket(writerStamp().client_id, updated_by);
2946
+ const day = nyDay(new Date());
2947
+ const same = (await col.find({ doc_id: { $regex: '^drops/' } }).toArray()).filter((d) => (d.drop_bucket || dropBucket(d.client_id, d.updated_by)) === bucket && nyDay(d.updated_at instanceof Date ? d.updated_at : new Date(d.updated_at || 0)) === day);
2948
+ if (same.length >= 20) throw new ValidationError(`write_doc: this connected app already wrote 20 drops today. Nothing was written.`);
2949
+ options = { ...options, drop_bucket: bucket };
2950
+ }
2951
+ const floorExempt = options && options.provision;
2952
+ const internal = options && (options.provision || options.edited || options.setupReissue || options.restore_of || (options.approvalsRecord === true && options.serverProvision === true && doc_id === APPROVALS_DOC));
2953
+ if (options && options.provision && prev && doc_id === stateDoc()) {
2954
+ throw new ValidationError('The board already exists. Provision does not replace it. Nothing was written.');
2955
+ }
2956
+ if (doc_id === stateDoc() && !(options && (options.provision || options.edited || options.restore_of))) {
2957
+ throw new ValidationError(prev
2958
+ ? 'The board is never written. Edit it with patch_doc, with `reason` (and `origin` for an added row). Nothing was written.'
2959
+ : 'The board is provisioned by the server at setup (emet_setup_complete). Nothing was written.');
2960
+ }
2961
+ if (prev && isWorkingDoc(doc_id) && !internal) {
2962
+ throw new ValidationError(
2963
+ `write_doc: ${doc_id} exists, so it is edited, not rewritten. Use patch_doc with \`reason\`. Nothing was written.`
2964
+ );
2965
+ }
2966
+ if (isDropDoc(doc_id) && (prev || (options && options.restore_of))) {
2967
+ throw new ValidationError(
2968
+ `write_doc: ${doc_id} is a drop and already exists, retired drops included. A drop is never patched or restored. Use a new id. Nothing was written.`
2969
+ );
2970
+ }
2971
+ if (isGovernedByShippedCopy(doc_id) && !(options && options.provision)) {
2972
+ const { readFileSync } = await import('node:fs');
2973
+ const shippedPath = resolveModulePath(import.meta.url, ['..', doc_id]);
2974
+ let shipped = '';
2975
+ try { shipped = readFileSync(shippedPath, 'utf8'); } catch { shipped = ''; }
2976
+ if (content !== shipped) {
2977
+ throw new ValidationError(`write_doc: ${doc_id} is governed by the copy shipped with this release. Only that text may be stored. Nothing was written.`);
2978
+ }
2979
+ }
2980
+ const sid = (options && options.session_id) || null;
2981
+ const base = String(sid || '').replace(/_verbatim_part\d+$/, '').trim() || null;
2982
+ const baselines = prev && prev.size_baselines && typeof prev.size_baselines === 'object' ? { ...prev.size_baselines } : {};
2983
+ let baseline;
2984
+ if (base && Number.isInteger(baselines[base])) baseline = baselines[base];
2985
+ else {
2986
+ baseline = prev ? String(prev.content || '').length : content.length;
2987
+ if (base) baselines[base] = baseline;
2988
+ }
2989
+ if (prev && !floorExempt && baseline > 0 && content.length * 2 < baseline) {
2990
+ const pct = Math.floor((content.length / baseline) * 100);
2991
+ throw new ValidationError(
2992
+ `This change would leave ${doc_id} at ${pct}% of its size when this session first touched it. Condensing below half is not done on the EMET server. Nothing was written.`
2993
+ );
2994
+ }
2995
+ // Cross-session floor. The per-session floor is measured from this
2996
+ // session's first touch, so a new session can shrink a document once.
2997
+ // It cannot shrink it again below half of the largest size stored in
2998
+ // the last 24 hours. The window is a security floor, not memory decay.
2999
+ const CROSS_SESSION_FLOOR_MS = 24 * 60 * 60 * 1000;
3000
+ const nowMs = Date.now();
3001
+ const recentSamples = (prev && Array.isArray(prev.size_samples) ? prev.size_samples : [])
3002
+ .filter((s) => s && typeof s.size === 'number' && s.at && (nowMs - new Date(s.at).getTime()) <= CROSS_SESSION_FLOOR_MS);
3003
+ let peak = 0;
3004
+ for (const s of recentSamples) peak = Math.max(peak, s.size);
3005
+ if (prev && prev.updated_at && (nowMs - new Date(prev.updated_at).getTime()) <= CROSS_SESSION_FLOOR_MS) {
3006
+ peak = Math.max(peak, String(prev.content || '').length);
3007
+ }
3008
+ if (prev && !floorExempt && peak > 0 && content.length * 2 < peak) {
3009
+ const pct = Math.floor((content.length / peak) * 100);
3010
+ throw new ValidationError(
3011
+ `This change would leave ${doc_id} at ${pct}% of its largest size in the last 24 hours. A write may not fall below half of that peak. Nothing was written.`
3012
+ );
3013
+ }
3014
+ const sizeSamples = recentSamples.slice();
3015
+ if (prev) sizeSamples.push({ size: String(prev.content || '').length, at: prev.updated_at || new Date() });
3016
+ if (prev) {
3017
+ const { _id, size_baselines: _liveBaselines, ...rest } = prev;
3018
+ // Retained and marked obsolete (ISO 7.5.3 / 4.2.5): the archived copy
3019
+ // says which revision replaced it. Baselines stay on the live row only.
3020
+ await hist.insertOne({ ...rest, archived_at: new Date(), superseded_by_version: version });
3021
+ }
3022
+ const now = new Date();
3023
+ // Validation runs HERE: after redaction, on the exact text that will be
3024
+ // stored, with the previous revision already in hand for the
3025
+ // carry-forward comparison. It marks and never rejects (ref 304), so its
3026
+ // result is a field on the row rather than a branch in this function.
3027
+ // Writing the field whole also enforces L6 - the fresh object carries no
3028
+ // acceptance, so an acceptance covers the one revision it was given to.
3029
+ const conformance = validateDocument(doc_id, content, prev ? prev.content : null);
3030
+ // L5, set only when renameDoc reaches this door. The destination passes
3031
+ // validation as "untemplated" when no template maps to it - which is the
3032
+ // evasion rather than the answer if the id it came FROM was governed.
3033
+ // Marked here, inside the one door, rather than by a second write after
3034
+ // the fact: a marker applied by a separate updateOne would be exactly the
3035
+ // kind of side entrance the write-path invariant exists to prevent.
3036
+ const from = options && options.reissued_from;
3037
+ if (from && templateFor(from) && !templateFor(doc_id)) {
3038
+ conformance.verdict = CONFORMANCE_VERDICT.NONCONFORMING;
3039
+ conformance.failures = [...(conformance.failures || []),
3040
+ `reissued from ${from}, which ${templateFor(from)} governs, to ${doc_id}, which no template governs (L5)`];
3041
+ }
3042
+ const writtenRow = {
3043
+ doc_id, content, sha256, signature, version, size: content.length, updated_at: now,
3044
+ conformance,
3045
+ supersedes_version: prev ? prev.version : null,
3046
+ updated_by: updated_by || (isDropDoc(doc_id) ? 'visitor' : (setting('SOURCE_TAG') || null)),
3047
+ edit_reason: (options && options.reason) || null,
3048
+ edit_origin: (options && options.origin) || null,
3049
+ person_id: (await import('./persons.js').then((m) => m.writerStamp())).person_id,
3050
+ client_id: (await import('./persons.js').then((m) => m.writerStamp())).client_id,
3051
+ session_id: sid,
3052
+ restored_from_version: (options && options.restore_of) || null,
3053
+ size_at_session_start: baseline,
3054
+ size_session_id: base,
3055
+ size_baselines: baselines,
3056
+ size_samples: typeof sizeSamples !== 'undefined' ? sizeSamples : (prev && prev.size_samples) || [],
3057
+ drop_bucket: (options && options.drop_bucket) || (prev && prev.drop_bucket) || null,
3058
+ path_hint: path_hint || (prev && prev.path_hint) || null,
3059
+ // Writing a retired document brings it back into the working view.
3060
+ // Without this a retired id would take writes and stay invisible, which
3061
+ // is a trap; it is also the un-retire path, so retirement is not a
3062
+ // one-way door.
3063
+ retired: false,
3064
+ size_note: 'baseline is the size when this session first touched the document'
3065
+ };
3066
+ const attr = declaredAttribution();
3067
+ if (attr && attr.basis === 'subkey' && attr.keyId) {
3068
+ writtenRow.key_id = attr.keyId;
3069
+ writtenRow.source_basis = 'subkey';
3070
+ }
3071
+ if (options && options.provision && doc_id === stateDoc()) {
3072
+ const ins = await col.updateOne({ doc_id }, { $setOnInsert: writtenRow }, { upsert: true });
3073
+ if (ins && ins.upsertedCount === 0) {
3074
+ throw new ValidationError('The board already exists. Provision does not replace it. Nothing was written.');
3075
+ }
3076
+ } else if (casOn) {
3077
+ let res;
3078
+ try {
3079
+ res = options.expectedVersion == null
3080
+ // the reviewer delta M1: documents.doc_id has no unique index, so the first
3081
+ // create keys on a deterministic _id; _id is always unique, so a
3082
+ // second racing insert matches it (or hits a duplicate key) and loses.
3083
+ ? await col.updateOne({ _id: `${APPROVALS_DOC}#current` }, { $setOnInsert: writtenRow }, { upsert: true })
3084
+ : await col.updateOne({ doc_id, version: options.expectedVersion }, { $set: writtenRow }, { upsert: false });
3085
+ } catch (e) {
3086
+ if (e && (e.code === 11000 || /duplicate key/i.test(String(e.message)))) throw casConflict();
3087
+ throw e;
3088
+ }
3089
+ const won = options.expectedVersion == null ? (res && res.upsertedCount === 1) : (res && res.matchedCount === 1);
3090
+ if (!won) throw casConflict();
3091
+ } else {
3092
+ await col.updateOne({ doc_id }, { $set: writtenRow }, { upsert: true });
3093
+ }
3094
+ // aleph: the write is not done because the call returned.
3095
+ const check = await col.findOne({ doc_id });
3096
+ verifyDocument(check, { doc_id, sha256, version });
3097
+ return { doc_id, sha256, version, size: content.length, updated_at: now, verified: true,
3098
+ signed: signatureIntegrityOf(check, signingKey),
3099
+ signature_stored: typeof check?.signature === 'string' && check.signature.length > 0,
3100
+ redacted: redaction.hits, conformance };
3101
+ } finally { await client.close(); }
3102
+ } catch (error) {
3103
+ if (error && error.versionConflict) throw error;
3104
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3105
+ throw new DatabaseError(`Failed to write document: ${error.message}`, 'write_doc');
3106
+ }
3107
+ }
3108
+
3109
+ export async function listDocs(prefix = null, logger = null, include_retired = false) {
3110
+ try {
3111
+ const { client, col } = await _docsClient();
3112
+ try {
3113
+ const q = prefix ? { doc_id: { $regex: '^' + prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } } : {};
3114
+ // Retired documents stay in the list. Current documents come first.
3115
+ // include_retired is accepted for older callers and does not hide anything.
3116
+ logger?.debug?.('Listing documents', { include_retired: include_retired === true });
3117
+ const docs = await col.find(q, { projection: { content: 0 } }).toArray();
3118
+ docs.sort((a, b) => {
3119
+ const ar = a.retired === true ? 1 : 0;
3120
+ const br = b.retired === true ? 1 : 0;
3121
+ if (ar !== br) return ar - br;
3122
+ return String(a.doc_id).localeCompare(String(b.doc_id));
3123
+ });
3124
+ const retiredCount = docs.filter(d => d.retired === true).length;
3125
+ return { count: docs.length, include_retired: true,
3126
+ retired_shown: retiredCount,
3127
+ documents: docs.map(d => {
3128
+ const replaced_by = d.retired === true ? replacedByOf(d) : null;
3129
+ return {
3130
+ doc_id: d.doc_id, sha256: d.sha256, version: d.version, size: d.size !== undefined ? d.size : null,
3131
+ updated_at: d.updated_at, updated_by: d.updated_by || null, path_hint: d.path_hint || null,
3132
+ ...(d.conformance ? { conformance: d.conformance.verdict,
3133
+ ...(d.conformance.failures && d.conformance.failures.length ? { nonconformances: d.conformance.failures.length } : {}) } : {}),
3134
+ ...(d.retired === true ? { retired: true, retired_at: d.retired_at || null, retired_reason: d.retired_reason || null, replaced_by } : {}) };
3135
+ }) };
3136
+ } finally { await client.close(); }
3137
+ } catch (error) {
3138
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3139
+ throw new DatabaseError(`Failed to list documents: ${error.message}`, 'list_docs');
3140
+ }
3141
+ }
3142
+
3143
+ // ============================================
3144
+ // DOC-STORE LIFECYCLE (added 2026-09-08)
3145
+ //
3146
+ // ⭐ THERE IS NO DELETE, AND THERE IS NOT GOING TO BE ONE. the user's decision,
3147
+ // 2026-09-08, on ISO grounds: document control is retain-and-mark-obsolete, not
3148
+ // remove. ISO 7.5.3's requirement is to issue the revision, mark the prior copy
3149
+ // obsolete, RETAIN it, and prevent its unintended use. Removal fails the third
3150
+ // clause outright, and "recoverable from the history collection" does not fix
3151
+ // that - a record you must reconstruct from an archive is not a retained record.
3152
+ //
3153
+ // The layers have always worked this way: entries are superseded, never edited
3154
+ // and never deleted, and episodic events are immutable. The document store was
3155
+ // the one place that lacked the mechanism, and the honest fix is to give it the
3156
+ // SAME one rather than to give it a delete verb.
3157
+ //
3158
+ // So: retire_doc marks a document obsolete. The content stays exactly where it
3159
+ // is, at its own id, readable forever. list_docs and the startup scan keep it,
3160
+ // after the current documents, marked with retired_at and what replaced it.
3161
+ // That is the mark the seed needed, and it never needed a removal to solve.
3162
+ //
3163
+ // An earlier draft of this section deleted rows and archived them first. It was
3164
+ // written from seeds/cascade-tools-js-improvements.md §C, which proposed
3165
+ // delete_doc / rename_doc - a proposal authored by an instance, never decided by
3166
+ // the user. It is exactly the audit candidate the meta-layer index has open: content
3167
+ // in the record that reads as the user's and is not.
3168
+ // ============================================
3169
+
3170
+ /**
3171
+ * Mark a document obsolete. Nothing is removed and nothing is archived, because
3172
+ * nothing is lost: the row keeps its id, its content, its version and its
3173
+ * digest. list_docs and startup keep it, marked, after the current documents.
3174
+ *
3175
+ * This is the document-store analogue of revise_memory marking a parent
3176
+ * superseded, and it is a sanctioned write for the same reason: it is a
3177
+ * supersession marker, not a content write, and it has exactly one door.
3178
+ * Reversible by writing the document again - write_doc clears the flag.
3179
+ */
3180
+ export async function retireDoc(doc_id, reason = null, retired_by = null, logger = null, options = {}) {
3181
+ try {
3182
+ if (!doc_id || typeof doc_id !== 'string') throw new ValidationError('doc_id must be a non-empty string');
3183
+ refuseBotMainRecord(doc_id, retired_by, 'retire_doc', { cleanup: true });
3184
+ refuseBotRetireRename(retired_by, 'retire_doc');
3185
+ if (doc_id === stateDoc()) throw new ValidationError('retire_doc: the board is never retired. Edit it with patch_doc and a reason. Nothing was changed.');
3186
+ if (!isDropDoc(doc_id) && !(options && options.fromRename)) throw new ValidationError(`retire_doc: ${doc_id} is not a drop. Working documents are edited, not retired. Nothing was changed.`);
3187
+ if (typeof reason !== 'string' || reason.trim().length < 10) throw new ValidationError('retire_doc needs reason (at least 10 characters), naming where the drop went. Nothing was changed.');
3188
+
3189
+ const { client, col } = await _docsClient();
3190
+ try {
3191
+ const doc = await col.findOne({ doc_id });
3192
+ if (!doc) throw new ValidationError(`Document not found: ${doc_id}`);
3193
+ if (doc.retired) {
3194
+ return { doc_id, retired: true, already_retired: true, verified: true,
3195
+ retired_at: doc.retired_at, retired_reason: doc.retired_reason || null,
3196
+ version: doc.version, sha256: doc.sha256 };
3197
+ }
3198
+
3199
+ const now = new Date();
3200
+ await col.updateOne({ doc_id }, { $set: {
3201
+ retired: true,
3202
+ retired_at: now,
3203
+ retired_by: retired_by || setting('SOURCE_TAG') || null,
3204
+ retired_reason: reason || null,
3205
+ retired_person_id: (await import('./persons.js').then((m) => m.currentPersonId())) || null,
3206
+ retired_client_id: (await import('./persons.js').then((m) => m.currentClientId())) || 'stdio',
3207
+ retired_session_id: (options && options.session_id) || null
3208
+ } });
3209
+
3210
+ // aleph: the mark is not set because the call returned. Content and
3211
+ // digest are re-checked too - retiring must not touch either.
3212
+ const check = await col.findOne({ doc_id });
3213
+ if (!check || check.retired !== true) {
3214
+ throw new DatabaseError(`Retirement mark did not persist: ${doc_id}`, 'retire_doc');
3215
+ }
3216
+ if (check.sha256 !== doc.sha256 || check.version !== doc.version) {
3217
+ throw new DatabaseError(`Retiring altered the document: ${doc_id}`, 'retire_doc');
3218
+ }
3219
+
3220
+ // L5 - retirement is not an escape hatch. retireDoc is the one document
3221
+ // write that deliberately does NOT pass through writeDoc(): it changes
3222
+ // visibility, never content, and re-running the whole content pipeline
3223
+ // for a flag would be a second door for no gain. That is exactly why the
3224
+ // marker has to be set HERE rather than assumed to have been set there.
3225
+ // Nothing is blocked - the retirement stands and carries its mark.
3226
+ let conformance = null;
3227
+ if (isGovernedByShippedCopy(doc_id) || templateFor(doc_id)) {
3228
+ conformance = {
3229
+ template: templateFor(doc_id), template_status: null,
3230
+ verdict: CONFORMANCE_VERDICT.NONCONFORMING,
3231
+ failures: [`${doc_id} is a controlled document and has been retired; the checks that governed it stop here (L5)`],
3232
+ warnings: [], checked_at: new Date().toISOString()
3233
+ };
3234
+ await col.updateOne({ doc_id }, { $set: { conformance } });
3235
+ }
3236
+
3237
+ return { doc_id, retired: true, verified: true, retired_at: now, conformance,
3238
+ retired_reason: reason || null, version: check.version,
3239
+ sha256: check.sha256, content_unchanged: true,
3240
+ note: 'Content retained at this id. list_docs keeps it after the current documents, marked with retired_at and, when a rename replaced it, what replaced it. read_doc still returns it. Nothing was deleted.' };
3241
+ } finally { await client.close(); }
3242
+ } catch (error) {
3243
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3244
+ throw new DatabaseError(`Failed to retire document: ${error.message}`, 'retire_doc');
3245
+ }
3246
+ }
3247
+
3248
+ export async function renameDoc(old_id, new_id, updated_by = null, logger = null, reason = null, options = {}) {
3249
+ try {
3250
+ if (!old_id || typeof old_id !== 'string') throw new ValidationError('old_id must be a non-empty string');
3251
+ if (!new_id || typeof new_id !== 'string') throw new ValidationError('new_id must be a non-empty string');
3252
+ if (old_id === new_id) throw new ValidationError('old_id and new_id name the same document');
3253
+ refuseBotMainRecord(old_id, updated_by, 'rename_doc', { cleanup: true });
3254
+ refuseBotMainRecord(new_id, updated_by, 'rename_doc');
3255
+ refuseBotRetireRename(updated_by, 'rename_doc');
3256
+ if (typeof reason !== 'string' || reason.trim().length < 10) throw new ValidationError('rename_doc needs reason (at least 10 characters). Nothing was changed.');
3257
+ if (old_id === stateDoc() || new_id === stateDoc()) throw new ValidationError('rename_doc: the board is never renamed. Nothing was changed.');
3258
+ if (isDropDoc(old_id) || isDropDoc(new_id)) throw new ValidationError('rename_doc: a drop is write-once. Use a new id. Nothing was changed.');
3259
+
3260
+ let content, path_hint;
3261
+ const { client, col } = await _docsClient();
3262
+ try {
3263
+ const source = await col.findOne({ doc_id: old_id });
3264
+ if (!source) throw new ValidationError(`Document not found: ${old_id}`);
3265
+ // Never clobber. A rename onto an occupied id is a MERGE decision, and a
3266
+ // merge is not something to guess at on the caller's behalf.
3267
+ const occupied = await col.findOne({ doc_id: new_id });
3268
+ if (occupied) {
3269
+ throw new ValidationError(`Destination already exists: ${new_id} (version ${occupied.version}). Rename refused; nothing was changed.`);
3270
+ }
3271
+ content = source.content;
3272
+ path_hint = source.path_hint || null;
3273
+ } finally { await client.close(); }
3274
+
3275
+ // One write path: the new id is created through writeDoc, so it inherits
3276
+ // the same redaction, digest, versioning and sha256 read-back as any other
3277
+ // write rather than getting a second, parallel implementation.
3278
+ // `reissued_from` carries the L5 question to the door, so the mark is set
3279
+ // by the same write that creates the document rather than by a second one.
3280
+ const written = await writeDoc(new_id, content, updated_by, path_hint, logger, { reissued_from: old_id, reason, session_id: options && options.session_id });
3281
+
3282
+ // Ordering is deliberate: the copy exists and is verified BEFORE the old id
3283
+ // is marked obsolete. Nothing is removed at any point - a "rename" here is a
3284
+ // reissue plus an obsolescence mark, which is what document control actually
3285
+ // asks for. The old id keeps its content and stays readable at its own id.
3286
+ const retired = await retireDoc(old_id, `Reissued as ${new_id}`, updated_by, logger, { fromRename: true });
3287
+
3288
+ return {
3289
+ from: old_id, to: new_id, verified: written.verified === true && retired.verified === true,
3290
+ conformance: written.conformance || null,
3291
+ sha256: written.sha256, version: written.version, size: written.size,
3292
+ redacted: written.redacted,
3293
+ old_id_retired: true, old_id_retired_at: retired.retired_at,
3294
+ old_content_retained: true,
3295
+ note: `${old_id} is retained and readable at its own id, marked obsolete, and list_docs keeps it after the current documents with retired_at and replaced_by ${new_id}. Nothing was deleted.`
3296
+ };
3297
+ } catch (error) {
3298
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3299
+ throw new DatabaseError(`Failed to rename document: ${error.message}`, 'rename_doc');
3300
+ }
3301
+ }
3302
+
3303
+ async function unretiredBootDrops(session_id) {
3304
+ if (!session_id || typeof session_id !== 'string' || !String(session_id).trim()) {
3305
+ throw new ValidationError('a board edit or restore needs session_id from emet_session_open, with a verified drop list. Nothing was written.');
3306
+ }
3307
+ const base = String(session_id).replace(/_verbatim_part\d+$/, '').trim();
3308
+ if (!base) {
3309
+ throw new ValidationError('a board edit or restore needs session_id from emet_session_open, with a verified drop list. Nothing was written.');
3310
+ }
3311
+ const { COLLECTION_PREFIX } = await import('./database.js');
3312
+ const client = await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: dropListTimeoutMs(), maxPoolSize: 10 });
3313
+ try {
3314
+ const db = client.db(MONGODB_DB);
3315
+ const row = await db.collection(`${COLLECTION_PREFIX}sessions`).findOne({ _id: base });
3316
+ if (!row || row.drops_unverified === true || !Array.isArray(row.drops_seen)) {
3317
+ throw new ValidationError('unhandled drops could not be loaded at this session boot, so the board cannot be edited or restored until the list is known. Nothing was written.');
3318
+ }
3319
+ const listed = row.drops_seen;
3320
+ if (!listed.length) return [];
3321
+ const open = await db.collection('documents').find({ doc_id: { $in: listed }, retired: { $ne: true } }).toArray();
3322
+ return open.map((d) => d.doc_id);
3323
+ } finally { await client.close(); }
3324
+ }
3325
+
3326
+ export async function restoreDoc(doc_id, version, updated_by = null, logger = null, reason = null, options = {}) {
3327
+ refuseBotMainRecord(doc_id, updated_by, 'restore_doc');
3328
+ refuseBotRetireRename(updated_by, 'restore_doc');
3329
+ if (typeof reason !== 'string' || reason.trim().length < 10) throw new ValidationError('restore_doc needs reason (at least 10 characters). Nothing was written.');
3330
+ if (!updated_by) throw new ValidationError('restore_doc needs a source. Nothing was written.');
3331
+ if (isDropDoc(doc_id)) throw new ValidationError('restore_doc: a drop is never restored. Nothing was written.');
3332
+ if (isRecordDoc(doc_id)) throw new ValidationError('restore_doc: a record is not restored. Records are superseded via revise_memory, not restored. Nothing was written.');
3333
+ if (isGovernedByShippedCopy(doc_id)) throw new ValidationError('restore_doc: a shipped copy is not restored. Nothing was written.');
3334
+ const allowed = (setting('RESTORE_TAGS') || setting('SOURCE_TAG') || '').split(',').map((s) => s.trim()).filter(Boolean);
3335
+ if (!allowed.includes(updated_by)) throw new ValidationError(`restore_doc: ${updated_by} is not in EMET_RESTORE_TAGS. Nothing was written.`);
3336
+ const prior = await readDocHistory(doc_id, version, logger);
3337
+ if (!prior || typeof prior.content !== 'string') throw new ValidationError(`No retained revision ${version} of ${doc_id}`);
3338
+ if (prior.integrity !== true) throw new ValidationError(`restore_doc: retained revision ${version} of ${doc_id} does not match its digest. Nothing was written.`);
3339
+ const verdict = prior.conformance && prior.conformance.verdict;
3340
+ const accepted = prior.conformance && prior.conformance.accepted;
3341
+ if (!verdict) {
3342
+ if (templateFor(doc_id)) throw new ValidationError(`restore_doc: ${doc_id} is templated and the retained revision has no conformance verdict. Only an untemplated document can be restored without a verdict. Nothing was written.`);
3343
+ } else if (verdict !== 'conforms' && verdict !== 'untemplated' && !accepted) {
3344
+ throw new ValidationError(`restore_doc: retained revision ${version} of ${doc_id} was not conforming. Nothing was written.`);
3345
+ }
3346
+ const current = await readDoc(doc_id, logger).catch(() => null);
3347
+ const checked = validateDocument(doc_id, prior.content, current && current.content);
3348
+ const lost = (checked.failures || []).filter((f) => /no disposition/.test(f));
3349
+ if (doc_id === stateDoc() && (await unretiredBootDrops(options && options.session_id)).length) {
3350
+ throw new ValidationError('restore_doc: unhandled drops were listed at this session boot and must be retired with retire_doc before the board is restored. Nothing was written.');
3351
+ }
3352
+ const written = await writeDoc(doc_id, prior.content, updated_by, null, logger, { restore_of: version, reason, session_id: options && options.session_id, supersedes: current && current.version });
3353
+ return { ...written, rows_lost: lost, restored_from_version: version };
3354
+ }
3355
+
3356
+ // ============================================
3357
+ // TEMPLATE VALIDATION - freestanding (STANDARD 9)
3358
+ // ============================================
3359
+ //
3360
+ // The same check the door runs, callable without writing anything. Three uses,
3361
+ // all of them the reason it exists separately: auditing records written before
3362
+ // validation existed, counting what adoption will mark BEFORE it marks it, and
3363
+ // checking a new local template against the standard before anyone fills it.
3364
+
3365
+ export async function validateDoc(options = {}, logger = null) {
3366
+ const { doc_id = null, content = null, template_id = null } = options || {};
3367
+ try {
3368
+ if (!doc_id && !content) {
3369
+ throw new ValidationError(
3370
+ 'Give a doc_id to check a stored document, or content (with template_id) to check text that is not stored yet.'
3371
+ );
3372
+ }
3373
+
3374
+ if (content) {
3375
+ const tpl_id = template_id || (doc_id ? templateFor(doc_id) : null);
3376
+ if (!tpl_id) {
3377
+ return { checked: doc_id || '(content)', template: null, verdict: CONFORMANCE_VERDICT.UNTEMPLATED,
3378
+ failures: [], warnings: ['No template_id was given and no map entry matches, so nothing governs this content.'] };
3379
+ }
3380
+ const template = loadShipped(tpl_id);
3381
+ if (!template) throw new ValidationError(`No template ${tpl_id} ships with this release.`);
3382
+ const r = checkRecord(content, template, { doc_id });
3383
+ return { checked: doc_id || '(content)', template: tpl_id, template_status: template.status,
3384
+ verdict: r.failures.length ? CONFORMANCE_VERDICT.NONCONFORMING : CONFORMANCE_VERDICT.CONFORMS,
3385
+ failures: r.failures, warnings: r.warnings, stored: false,
3386
+ note: 'Checked against the copy shipped with this release (L1). Nothing was written.' };
3387
+ }
3388
+
3389
+ const { client, col, hist } = await _docsClient();
3390
+ try {
3391
+ const doc = await col.findOne({ doc_id });
3392
+ if (!doc) throw new ValidationError(`Document not found: ${doc_id}`);
3393
+ // The carry-forward check needs the revision before this one, which is
3394
+ // exactly what the history collection archived when this one was written.
3395
+ const prior = await hist.find({ doc_id }).sort({ version: -1 }).limit(1).toArray();
3396
+ const result = validateDocument(doc_id, doc.content, prior.length ? prior[0].content : null);
3397
+ return {
3398
+ checked: doc_id, version: doc.version, stored: false,
3399
+ compared_against_revision: prior.length ? prior[0].version : null,
3400
+ ...result,
3401
+ stored_verdict: doc.conformance ? doc.conformance.verdict : null,
3402
+ note: 'Nothing was written. To record a disposition for a standing marker, use accept_nonconformance; to clear one, revise the document so it passes.'
3403
+ };
3404
+ } finally { await client.close(); }
3405
+ } catch (error) {
3406
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3407
+ throw new DatabaseError(`Failed to validate document: ${error.message}`, 'validate_doc');
3408
+ }
3409
+ }
3410
+
3411
+ /**
3412
+ * Record the written disposition STANDARD 9 requires for a nonconformance that
3413
+ * will not be corrected. The marker is KEPT, with the acceptance beside it -
3414
+ * never deleted, and never silently cleared. It covers the one revision it
3415
+ * names (L6); the next revision is validated fresh.
3416
+ */
3417
+ export async function acceptNonconformance(doc_id, reason, accepted_by = null, logger = null, options = {}) {
3418
+ try {
3419
+ refuseBotMainRecord(doc_id, accepted_by, 'accept_nonconformance');
3420
+ if (!doc_id || typeof doc_id !== 'string') throw new ValidationError('doc_id must be a non-empty string');
3421
+ if (typeof reason !== 'string' || reason.trim().length < 10) {
3422
+ throw new ValidationError(
3423
+ 'An acceptance needs a written reason of at least 10 characters. It is the disposition of ' +
3424
+ 'record for a nonconformance and is what an audit reads later - the same rule revise_transcript uses.'
3425
+ );
3426
+ }
3427
+ const { client, col } = await _docsClient();
3428
+ try {
3429
+ const doc = await col.findOne({ doc_id });
3430
+ if (!doc) throw new ValidationError(`Document not found: ${doc_id}`);
3431
+ const current = doc.conformance;
3432
+ if (!current || current.verdict !== CONFORMANCE_VERDICT.NONCONFORMING) {
3433
+ throw new ValidationError(
3434
+ `${doc_id} version ${doc.version} carries no nonconformance marker, so there is nothing to accept. ` +
3435
+ `Its verdict is "${current ? current.verdict : 'not yet checked'}".`
3436
+ );
3437
+ }
3438
+ const { currentPersonId, currentClientId } = await import('./persons.js');
3439
+ const accepted = buildAcceptance({
3440
+ reason, by: accepted_by || setting('SOURCE_TAG') || null, version: doc.version,
3441
+ person_id: currentPersonId() || null,
3442
+ client_id: currentClientId() || 'stdio',
3443
+ session_id: (options && options.session_id) || null
3444
+ });
3445
+ await col.updateOne({ doc_id }, { $set: { 'conformance.accepted': accepted } });
3446
+ // aleph: the disposition is not recorded because the call returned.
3447
+ const check = await col.findOne({ doc_id });
3448
+ const stored = check && check.conformance && check.conformance.accepted;
3449
+ if (!stored || stored.reason !== accepted.reason || Number(stored.covers_version) !== Number(doc.version)) {
3450
+ throw new DatabaseError(`The acceptance did not persist on ${doc_id}.`, 'accept_nonconformance');
3451
+ }
3452
+ return {
3453
+ doc_id, verified: true, version: doc.version, covers_version: doc.version,
3454
+ accepted, failures_accepted: current.failures || [],
3455
+ note: 'The marker is retained with this acceptance beside it. It covers this revision only - the next write is validated fresh, and accepted markers stay counted at session start, separately from open ones.'
3456
+ };
3457
+ } finally { await client.close(); }
3458
+ } catch (error) {
3459
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3460
+ throw new DatabaseError(`Failed to accept nonconformance: ${error.message}`, 'accept_nonconformance');
3461
+ }
3462
+ }
3463
+
3464
+ // ============================================
3465
+ // DOC-STORE PARTIAL UPDATES (added 2026-08-24)
3466
+ // Server-side read-modify-write. The whole point: existing content never
3467
+ // crosses the wire, so document size stops being bounded by an MCP client's
3468
+ // context/output budget. All three delegate the actual write to writeDoc(),
3469
+ // which owns archiving to documents_history, version increment, and the
3470
+ // sha256 read-back -- one write path, not four.
3471
+ // ============================================
3472
+
3473
+ function _assertStr(v, name) {
3474
+ if (typeof v !== 'string' || v.length === 0) {
3475
+ throw new ValidationError(`${name} must be a non-empty string`);
3476
+ }
3477
+ }
3478
+
3479
+ function _assertVersion(cur, expected_version, tool) {
3480
+ if (expected_version === undefined || expected_version === null) return;
3481
+ if (cur.version !== expected_version) {
3482
+ throw new ValidationError(
3483
+ `${tool}: version conflict - document is at v${cur.version}, caller expected v${expected_version}. Re-read and retry.`
3484
+ );
3485
+ }
3486
+ }
3487
+
3488
+ /** Find `needle` in `hay`; require exactly one occurrence. Returns [before, after]. */
3489
+ function _splitUnique(hay, needle, tool, what, doc_id) {
3490
+ const parts = hay.split(needle);
3491
+ const n = parts.length - 1;
3492
+ if (n === 0) throw new ValidationError(`${tool}: ${what} not found in ${doc_id}`);
3493
+ if (n > 1) {
3494
+ throw new ValidationError(
3495
+ `${tool}: ${what} matches ${n} times in ${doc_id} - must match exactly once. Add surrounding context to disambiguate.`
3496
+ );
3497
+ }
3498
+ return [parts[0], parts[1]];
3499
+ }
3500
+
3501
+ export async function patchDoc(doc_id, old_str, new_str, updated_by = null, expected_version = null, logger = null, options = {}) {
3502
+ try {
3503
+ _assertStr(doc_id, 'doc_id');
3504
+ refuseBotMainRecord(doc_id, updated_by, 'patch_doc');
3505
+ _refuseRecordAmendment(doc_id, 'patch_doc');
3506
+ if (!(Array.isArray(options.edits) && options.edits.length)) _assertStr(old_str, 'old_str');
3507
+ const reason = options && options.reason;
3508
+ if (typeof reason !== 'string' || reason.trim().length < 10) {
3509
+ throw new ValidationError('patch_doc needs reason (at least 10 characters). Nothing was written.');
3510
+ }
3511
+ const cur = await readDoc(doc_id, logger);
3512
+ _assertVersion(cur, expected_version, 'patch_doc');
3513
+ if (isDropDoc(doc_id)) throw new ValidationError('patch_doc: a drop is write-once. Use a new id. Nothing was written.');
3514
+ const edits = Array.isArray(options.edits) && options.edits.length
3515
+ ? options.edits
3516
+ : [{ old_str, new_str }];
3517
+ for (const edit of edits) {
3518
+ if (typeof edit.old_str !== 'string' || edit.old_str.length === 0) throw new ValidationError('old_str must be a non-empty string');
3519
+ if (typeof edit.new_str !== 'string') throw new ValidationError('new_str must be a string (empty string allowed = delete)');
3520
+ _splitUnique(cur.content, edit.old_str, 'patch_doc', 'old_str', doc_id);
3521
+ }
3522
+ const spans = edits.map((edit) => {
3523
+ const at = cur.content.indexOf(edit.old_str);
3524
+ return { at, len: edit.old_str.length, new_str: edit.new_str };
3525
+ });
3526
+ const ordered = [...spans].sort((a, b) => a.at - b.at);
3527
+ for (let i = 1; i < ordered.length; i++) {
3528
+ if (ordered[i].at < ordered[i - 1].at + ordered[i - 1].len) throw new ValidationError('patch_doc: edits overlap. Nothing was written.');
3529
+ }
3530
+ let next = cur.content;
3531
+ for (const span of [...spans].sort((a, b) => b.at - a.at)) next = next.slice(0, span.at) + span.new_str + next.slice(span.at + span.len);
3532
+ if (doc_id === stateDoc()) {
3533
+ if ((await unretiredBootDrops(options && options.session_id)).length) throw new ValidationError('patch_doc: unhandled drops were listed at this session boot and must be retired with retire_doc before the board is edited. Nothing was written.');
3534
+ const ids = (text) => new Set([...String(text || '').matchAll(/\bB-\d+\b/g)].map((m) => m[0]));
3535
+ const added = [...ids(next)].filter((id) => !ids(cur.content).has(id));
3536
+ if (added.length && !(options && options.origin)) throw new ValidationError('patch_doc: an added board row needs origin. Nothing was written.');
3537
+ const checked = validateDocument(doc_id, next, cur.content);
3538
+ if (checked && checked.verdict === 'nonconforming') {
3539
+ throw new ValidationError(`patch_doc: the board edit is nonconforming. ${(checked.failures || []).join('; ')} Nothing was written.`);
3540
+ }
3541
+ }
3542
+ const res = await writeDoc(doc_id, next, updated_by, null, logger, { edited: true, reason, origin: options.origin || null, session_id: options.session_id || null });
3543
+ return { ...res, previous_version: cur.version, replaced: edits.length,
3544
+ size_delta: next.length - cur.content.length };
3545
+ } catch (error) {
3546
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3547
+ throw new DatabaseError(`Failed to patch document: ${error.message}`, 'patch_doc');
3548
+ }
3549
+ }
3550
+
3551
+ // Restored 2026-09-19: both constants were dropped in commit-685d822 while the function
3552
+ // kept referencing them, which made every semantic_recall call throw
3553
+ // "SEMANTIC_RECALL_LAYERS is not defined".
3554
+ const SEMANTIC_RECALL_LAYERS = ['episodic', 'semantic', 'procedural', 'meta', 'identity', 'working'];
3555
+ const SEMANTIC_RECALL_INDEX = process.env.EMET_VECTOR_INDEX || 'emet_content_auto';
3556
+
3557
+ export async function semanticRecall(query, layer = null, limit = 10, logger = null, options = null, dbManager = null) {
3558
+ try {
3559
+ if (!query || typeof query !== 'string' || query.trim() === '') {
3560
+ throw new ValidationError('query', 'query must be a non-empty string');
3561
+ }
3562
+ if (query.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
3563
+ throw new ValidationError('query', `query exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH}`);
3564
+ }
3565
+ const validatedLimit = validateLimit(limit);
3566
+ const layers = layer ? [validateLayer(layer)] : SEMANTIC_RECALL_LAYERS;
3567
+ // vectorRecall is the in-memory stand-in. Production has no such method
3568
+ // and uses Atlas. The stand-in lets a test see history and rank without a
3569
+ // vector index. It does not change what Atlas returns.
3570
+ const useStandIn = !!(dbManager && typeof dbManager.vectorRecall === 'function');
3571
+
3572
+ const client = useStandIn ? null : await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10 });
3573
+ try {
3574
+ const db = client ? client.db(MONGODB_DB) : null;
3575
+ const results = [];
3576
+ for (const l of layers) {
3577
+ if (useStandIn) {
3578
+ const docs = await dbManager.vectorRecall(l, query.trim(), validatedLimit);
3579
+ results.push(...collectSemanticHits(docs, l));
3580
+ continue;
3581
+ }
3582
+ const col = db.collection(`${COLLECTION_PREFIX}${l}`);
3583
+ const docs = await col.aggregate([
3584
+ { $vectorSearch: {
3585
+ index: SEMANTIC_RECALL_INDEX,
3586
+ path: 'content',
3587
+ query: query.trim(),
3588
+ numCandidates: candidateBound(validatedLimit),
3589
+ limit: validatedLimit,
3590
+ // THE INDEX-LEVEL PRE-FILTER ON is_live WAS REMOVED 2026-09-13.
3591
+ // It excluded superseded rows from Atlas before ranking, which is
3592
+ // exactly what the user's decision reverses: a parent surfaces so
3593
+ // a revision is read in the context of what it revised.
3594
+ //
3595
+ // THE COST, STATED RATHER THAN DISCOVERED LATER: this is a ranked
3596
+ // top-N path, so both halves of a supersession chain can now occupy
3597
+ // slots that previously held two distinct entries. That is the price
3598
+ // of the context, and it is a real one on this path in a way it is
3599
+ // not on the unranked paths. If it bites, the fix is to rank the
3600
+ // chain as one and return the live entry with its parent attached -
3601
+ // NOT to quietly re-hide the parent.
3602
+ //
3603
+ // is_live remains a `filter` field on the configured vector index on
3604
+ // all six layers; nothing was removed from the index. Retained note:
3605
+ // {superseded_by: {$eq: null}} returns ZERO rows here because vector
3606
+ // filters do not match null, which is why the boolean exists at all.
3607
+ } },
3608
+ { $project: { _id: 0, id: 1, timestamp: 1, content: 1, context: 1,
3609
+ importance: 1, emotional_intensity: 1, metadata: 1, sha256: 1, metadata_sha256: 1,
3610
+ superseded_by: 1, superseded_at: 1, valid_until: 1, is_live: 1,
3611
+ score: { $meta: 'vectorSearchScore' } } }
3612
+ ]).toArray();
3613
+ // Expired and superseded hits stay. collectSemanticHits marks them;
3614
+ // it does not drop them. Current entries rank ahead of ended ones.
3615
+ results.push(...collectSemanticHits(docs, l));
3616
+ }
3617
+ const ranked = rankSemanticHits(results, query);
3618
+ const top = applyReadLimit(ranked, validatedLimit, options && options.history === true);
3619
+ logger?.info('Semantic recall completed', { layers: layers.length, resultsFound: top.length });
3620
+ return { count: top.length, query: query.trim(), layers_searched: layers, results: top };
3621
+ } finally { if (client) await client.close(); }
3622
+ } catch (error) {
3623
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3624
+ throw new DatabaseError(`Failed semantic recall: ${error.message}`, 'semantic_recall');
3625
+ }
3626
+ }
3627
+
3628
+
3629
+
3630
+ // ============================================
3631
+ // TOOL DEFINITIONS
3632
+ // ============================================
3633
+
3634
+ /**
3635
+ * MCP tool definitions
3636
+ */
3637
+
3638
+ // ============================================
3639
+ // CORPUS RECALL (Stage B, 2026-07-27)
3640
+ // Semantic search over an optional corpus collection you populate yourself.
3641
+ // ============================================
3642
+
3643
+ const CORPUS_RECALL_INDEX = process.env.EMET_CORPUS_INDEX || 'corpus_content_auto';
3644
+ // Corpus names are whatever you populate. Empty by default - this tool returns
3645
+ // nothing until you load documents into the corpus collection yourself.
3646
+ const CORPUS_VALUES = (process.env.EMET_CORPORA || '').split(',').map(s => s.trim()).filter(Boolean);
3647
+
3648
+ export async function corpusRecall(query, corpus = null, limit = 5, logger = null) {
3649
+ try {
3650
+ if (!query || typeof query !== 'string' || query.trim() === '') {
3651
+ throw new ValidationError('query', 'query must be a non-empty string');
3652
+ }
3653
+ if (query.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
3654
+ throw new ValidationError('query', `query exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH}`);
3655
+ }
3656
+ const validatedLimit = Math.min(Math.max(parseInt(limit) || 5, 1), 25);
3657
+ // An empty EMET_CORPORA means "unrestricted", not "nothing allowed".
3658
+ if (corpus && CORPUS_VALUES.length && !CORPUS_VALUES.includes(corpus)) {
3659
+ throw new ValidationError('corpus', `corpus must be one of: ${CORPUS_VALUES.join(', ')}`);
3660
+ }
3661
+ const client = await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10 });
3662
+ try {
3663
+ const db = client.db(MONGODB_DB);
3664
+ const col = db.collection(process.env.EMET_CORPUS_COLLECTION || 'corpus_chunks');
3665
+ // Native corpus filter on the index (filter field added 2026-07-27); dedupe post-search.
3666
+ const fetchN = validatedLimit * 3;
3667
+ const vs = {
3668
+ index: CORPUS_RECALL_INDEX,
3669
+ path: 'content',
3670
+ query: query.trim(),
3671
+ numCandidates: Math.max(fetchN * 20, 300),
3672
+ limit: fetchN
3673
+ };
3674
+ if (corpus) vs.filter = { corpus: { $eq: corpus } };
3675
+ const docs = await col.aggregate([
3676
+ { $vectorSearch: vs },
3677
+ { $project: { _id: 1, corpus: 1, video_id: 1, video_title: 1, url: 1, chunk_seq: 1,
3678
+ start_sec: 1, content: 1, epistemic: 1, work: 1, story_num: 1, topic: 1,
3679
+ score: { $meta: 'vectorSearchScore' } } }
3680
+ ]).toArray();
3681
+ const filtered = corpus ? docs.filter(d => d.corpus === corpus) : docs;
3682
+ const perKey = {}; const out = [];
3683
+ for (const d of filtered) {
3684
+ const key = `${d.corpus}:${d.video_id || d.work || d.video_title}`;
3685
+ perKey[key] = (perKey[key] || 0) + 1;
3686
+ if (perKey[key] <= 2) out.push(d);
3687
+ if (out.length >= validatedLimit) break;
3688
+ }
3689
+ const results = out.map(d => ({
3690
+ corpus: d.corpus,
3691
+ title: d.video_title,
3692
+ url: d.url ? (d.start_sec != null ? `${d.url}&t=${Math.floor(d.start_sec)}s` : d.url) : null,
3693
+ start_sec: d.start_sec != null ? d.start_sec : null,
3694
+ epistemic: d.epistemic || null,
3695
+ score: d.score,
3696
+ content: d.content,
3697
+ chunk_id: d._id
3698
+ }));
3699
+ logger?.info('Corpus recall completed', { corpus: corpus || 'all', resultsFound: results.length });
3700
+ return { count: results.length, query: query.trim(), corpus: corpus || 'all', results };
3701
+ } finally { await client.close(); }
3702
+ } catch (error) {
3703
+ if (error instanceof ValidationError || error instanceof EmetError) throw error;
3704
+ throw new DatabaseError(`Failed corpus recall: ${error.message}`, 'corpus_recall');
3705
+ }
3706
+ }
3707
+
3708
+ import { digest, verifyDocument, verifyLayerEntry, integrityOf, digestMetadata, metadataIntegrityOf, signDigests, signatureIntegrityOf, signatureCoversPriorMetadata, chainIntegrityOf, AlephError } from './aleph.js';
3709
+ import { parseMetadata } from './metadata.js';
3710
+ export { parseMetadata, isLegacyMetadataString, isMetadataPath } from './metadata.js';
3711
+ import { validateProvenance, provenanceColumns } from './provenance.js';
3712
+ export { validateProvenance, provenanceColumns, resolveProvenance, crossCheckProvenance } from './provenance.js';
3713
+ export { digest, verifyDocument, verifyLayerEntry, integrityOf, canonicalJson, digestMetadata, metadataIntegrityOf, signDigests, signatureIntegrityOf, signatureCoversPriorMetadata, AlephError } from './aleph.js';
3714
+ import { annotate } from './annotations.js';
3715
+ export { sessionClose, CLOSE_PROTOCOL } from './session.js';
3716
+ export { findGaps, gapCounts, GAP_CLASSES } from './gaps.js';
3717
+ export { TOOL_ANNOTATIONS } from './annotations.js';
3718
+
3719
+ export { DISCIPLINE_SUMMARY } from './disciplines.js';
3720
+ export { FLOOR, FLOOR_REASSERTION, FLOOR_SECTIONS, PRECEDENCE, readFloor } from './floor.js';
3721
+
3722
+ export {
3723
+ initialize,
3724
+ setupStatus,
3725
+ setupComplete,
3726
+ verifyConnection,
3727
+ SETUP_INTERVIEW
3728
+ } from './setup.js';
3729
+
3730
+ /**
3731
+ * Who may mint a guest or member code. The local stdio actor has no person id
3732
+ * and is the owner. HTTP still requires a loaded person whose role is owner.
3733
+ * An in-process actor that names a person is checked the same way.
3734
+ */
3735
+ async function codeCreator(dbManager, refusal) {
3736
+ const { currentActor, getPerson } = await import('./persons.js');
3737
+ const actor = currentActor();
3738
+ if (actor.transport === 'stdio' || (actor.transport !== 'http' && !actor.personId)) {
3739
+ return actor.personId || LOCAL_SUBJECT;
3740
+ }
3741
+ if (!actor.personId) throw new ValidationError(refusal);
3742
+ const person = await getPerson(dbManager.db, actor.personId);
3743
+ if (!person || person.role !== 'owner') throw new ValidationError(refusal);
3744
+ return actor.personId;
3745
+ }
3746
+
3747
+ export async function inviteCreate(dbManager, { display_name, scopes, days, source } = {}, logger = null) {
3748
+ if (!display_name || !String(display_name).trim()) {
3749
+ throw new ValidationError('display_name is required — the name the guest will be known by');
3750
+ }
3751
+ await dbManager.getConnection('working');
3752
+ const { createInvite, INVITES_FROZEN, INVITES_FROZEN_REASON } = await import('./invites.js');
3753
+ if (INVITES_FROZEN) throw new ValidationError(INVITES_FROZEN_REASON);
3754
+ const { registeredSourceTags } = await import('./setup.js');
3755
+ const actor = await codeCreator(dbManager, 'Only the owner can create invites');
3756
+ const requested = Array.isArray(scopes) ? scopes : ['emet.read'];
3757
+ if (requested.length !== 1 || requested[0] !== 'emet.read') {
3758
+ throw new ValidationError('A guest invite accepts only emet.read');
3759
+ }
3760
+ const registry = registeredSourceTags();
3761
+ const tag = typeof source === 'string' ? source.trim() : '';
3762
+ if (registry.length && tag && !registry.includes(tag)) {
3763
+ throw new ValidationError('source', 'A guest invite source must be a registered tag. Nothing was created.');
3764
+ }
3765
+ const out = await createInvite(dbManager.db, {
3766
+ created_by: actor,
3767
+ display_name,
3768
+ scopes: ['emet.read'],
3769
+ days: days || 14,
3770
+ source_tag: tag || null
3771
+ });
3772
+ logger?.info?.('Invite created', { display_name: out.display_name });
3773
+ return {
3774
+ ...out,
3775
+ note: 'Show the code once. It is not stored in the clear. The guest pastes it on the connect screen instead of the passphrase.'
3776
+ };
3777
+ }
3778
+
3779
+ export async function memberAccessCreate(dbManager, { display_name, days, source } = {}, logger = null) {
3780
+ if (!display_name || !String(display_name).trim()) {
3781
+ throw new ValidationError('display_name is required \u2014 the name the member will be known by');
3782
+ }
3783
+ await dbManager.getConnection('working');
3784
+ const { createMemberAccess, MEMBER_ACCESS_FROZEN, MEMBER_ACCESS_FROZEN_REASON } = await import('./invites.js');
3785
+ if (MEMBER_ACCESS_FROZEN) throw new ValidationError(MEMBER_ACCESS_FROZEN_REASON);
3786
+ const { registeredSourceTags } = await import('./setup.js');
3787
+ const actor = await codeCreator(dbManager, 'Only the owner can create EMET Member Access');
3788
+ const registry = registeredSourceTags();
3789
+ const tag = typeof source === 'string' ? source.trim() : '';
3790
+ if (registry.length && tag && !registry.includes(tag)) {
3791
+ throw new ValidationError('source', 'A member code source must be a registered tag. Nothing was created.');
3792
+ }
3793
+ const out = await createMemberAccess(dbManager.db, { created_by: actor, display_name, days: days || 14, source_tag: tag || null });
3794
+ logger?.info?.('EMET Member Access created', { display_name: out.display_name });
3795
+ return {
3796
+ ...out,
3797
+ label: 'EMET Member Access',
3798
+ note: 'Show the code once. It is not stored in the clear. This is not EMET Guest Access. The person pastes it on the connect screen. They get a new person id with read and write access, never the owner identity.'
3799
+ };
3800
+ }
3801
+
3802
+ // Raw tool definitions. Exported as TOOLS below, with annotations attached.
3803
+ const TOOL_DEFINITIONS = [
3804
+ {
3805
+ name: "emet_initialize",
3806
+ description: "START HERE. Call this FIRST in every session, before answering anything that might have prior context, and always when the user opens with 'initialize', 'start', 'begin', 'hello' or similar. It is the only setup step a user should ever have to know about. A large startup comes in pages: read `page_notice` first and call again with the page it names until the last page, before replying. If this memory store is new it returns a short interview to run conversationally; if it is established it returns who you are, how you speak (`character`, when the store keeps one - authoritative on voice for the whole session), who you are serving, and where things were left. The user never needs to know which case applied.",
3807
+ inputSchema: {
3808
+ type: "object",
3809
+ properties: {
3810
+ recent_limit: { type: "number", description: "How many recent context entries to return (default 5)." },
3811
+ page: { description: "The startup is served in pages when it is larger than one result (each page fits under 20,000 bytes). Omit for page 1; every page but the last says which page to ask for next - call again with that page, before replying, until a page says it is the last. \"all\" returns the whole startup in one result, for a host with no size limit." },
3812
+ load_id: { type: "string", description: "The load_id your startup returned in `startup_page.load_id`: every emet_initialize result carries one (each page of a paged startup, the whole startup with page \"all\" or when it fits one result, and a store that is not set up yet). Pass it with every later page so all pages come from the same startup, and pass the same load_id to emet_session_open so its startup page check runs on this startup." },
3813
+ source: { type: "string", description: "Your own source tag (the bot or host you are). The startup then reports the tag your writes will carry and its approval state." }
3814
+ }
3815
+ }
3816
+ },
3817
+ {
3818
+ name: "emet_status",
3819
+ description: "Diagnostic in one call (replaces emet_setup_status, get_status and emet_verify_connection, 2026-09-27). Reports environment, database, collections, vector index, whether identity and user documents exist, and per-layer counts and health. Pass probe: true to also prove the write path - connect, write a probe document, read the same value back, remove it; a read alone cannot reveal a broken write path. Prefer emet_initialize for a normal session start.",
3820
+ inputSchema: {
3821
+ type: "object",
3822
+ properties: {
3823
+ probe: { type: "boolean", description: "Also write and read back a probe document (default false). Use after a deploy or a configuration change." },
3824
+ source: { type: "string", description: "Your own source tag. The status then reports the tag your writes will carry and its approval state." }
3825
+ }
3826
+ }
3827
+ },
3828
+ {
3829
+ name: "emet_setup_complete",
3830
+ description: "First-time setup, the owner's only: record the user's interview answers as bootstrap/IDENTITY.md, bootstrap/CHARACTER.md and bootstrap/USER.md and as an identity-layer entry, and create the owner person. Every write is read-back verified. Refused under a bot tag. On a store that is already set up it is refused unless reissue is true (the owner's explicit word): a re-run writes new versions of the user's identity documents; prior versions are archived, never destroyed. answers.more_than_one_bot is optional. Absent or no leaves sub-keys off. yes writes EMET_SUBKEYS=1, a generated pepper and EMET_SUBKEY_PEPPER_VERSION=1 into .env only when that file is the config source, after the store writes succeed, and does not turn the flag on in this process. A hosted server refuses yes and returns the three variables to set, then redeploy. See docs/SUBKEYS.md. answers.bot_team_backup is optional. Yes or no, in the user's words, the same way as more_than_one_bot. Yes and Yes. record the flag. yes is the recommended choice. Absent or no leaves bot-team backup off. Anything else, including a non-string, is refused before a write. Setup does not copy prompts or settings.",
3831
+ inputSchema: {
3832
+ type: "object",
3833
+ properties: {
3834
+ answers: {
3835
+ type: "object",
3836
+ description: "Answers keyed exactly as the interview questions returned by emet_initialize.",
3837
+ properties: {
3838
+ assistant_name: { type: "string" },
3839
+ assistant_character: { type: "string" },
3840
+ user_name: { type: "string" },
3841
+ user_context: { type: "string" },
3842
+ user_preferences: { type: "string" },
3843
+ source_tag: { type: "string" },
3844
+ more_than_one_bot: { type: "string", description: "Optional. yes or no, in the user's words. Absent or no leaves sub-keys off. yes turns them on only when this server reads .env. Do not invent this answer." },
3845
+ bot_team_backup: { type: "string", description: "Optional. Yes or no, in the user's words. Yes and Yes. count as yes. yes is the recommended choice and records bot-team backup as on. Absent or no leaves it off. A non-string is refused. Do not invent this answer." }
3846
+ },
3847
+ required: ["assistant_name", "assistant_character", "user_name", "user_context"]
3848
+ },
3849
+ reissue: { type: "boolean", description: "Only on the owner's explicit word: re-run setup on a store that is already set up. Without it, a re-run is refused and nothing is written." },
3850
+ source: { type: "string", description: "Your source tag. Setup is refused under a bot tag." }
3851
+ },
3852
+ required: ["answers"]
3853
+ }
3854
+ },
3855
+ {
3856
+ name: "corpus_recall",
3857
+ description: "Semantic search over an optional supplementary knowledge corpus that the user has loaded separately from memory. Returns nothing unless a corpus has been populated; this is not where session memory lives.",
3858
+ inputSchema: {
3859
+ type: "object",
3860
+ properties: {
3861
+ query: {
3862
+ type: "string",
3863
+ description: "Natural-language query (raw text; concept match, phrased however the experience is phrased)"
3864
+ },
3865
+ corpus: {
3866
+ type: "string",
3867
+ description: "Optional: restrict to one corpus name"
3868
+ },
3869
+ limit: {
3870
+ type: "number",
3871
+ description: "Max results (default 5, max 25)"
3872
+ }
3873
+ },
3874
+ required: ["query"]
3875
+ }
3876
+ },
3877
+
3878
+ {
3879
+ name: "recall",
3880
+ description: "Search memories by keyword. Ranked by how many of your words match, then by importance. Current entries come first in every order. Pass order: 'latest' for time order. A standalone latest, newest, or most recent, together with other words, also asks for time order: it reorders those matches and does not change which rows match. A hyphenated word such as latest-budget, a negated 'not the latest', and a bare 'latest' are ordinary keywords. Expired and superseded entries stay in the result, marked with their end date and what replaced them. Pass history: true to read ended entries when current ones fill the limit.",
3881
+ inputSchema: {
3882
+ type: "object",
3883
+ properties: {
3884
+ query: {
3885
+ type: "string",
3886
+ description: "Search query to match against memory content. order: 'latest' is how you ask for time order. A standalone latest, newest, or most recent reorders the other words' matches."
3887
+ },
3888
+ layer: {
3889
+ type: "string",
3890
+ enum: ["episodic", "semantic", "procedural", "meta", "identity", "working"],
3891
+ description: "Optional: Search only in specific layer"
3892
+ },
3893
+ limit: {
3894
+ type: "number",
3895
+ description: "Maximum number of results to return (default: 10)"
3896
+ },
3897
+ history: {
3898
+ type: "boolean",
3899
+ description: "Return ended entries (superseded or expired), marked, up to limit. Default false: ended entries are included after current ones until limit."
3900
+ },
3901
+ order: {
3902
+ type: "string",
3903
+ enum: ["latest"],
3904
+ description: "Pass 'latest' for time order: current matches first, then newer ones. This does not change which rows match the query."
3905
+ }
3906
+ },
3907
+ required: ["query"]
3908
+ }
3909
+ },
3910
+ {
3911
+ name: "query_layer",
3912
+ description: "Query a specific memory layer with structured filters",
3913
+ inputSchema: {
3914
+ type: "object",
3915
+ properties: {
3916
+ layer: {
3917
+ type: "string",
3918
+ enum: ["episodic", "semantic", "procedural", "meta", "identity", "working"],
3919
+ description: "Memory layer to query"
3920
+ },
3921
+ options: {
3922
+ type: "object",
3923
+ description: "Query options with structured filters",
3924
+ properties: {
3925
+ filters: {
3926
+ type: "object",
3927
+ description: "Structured filter conditions (safe parameterized queries)",
3928
+ properties: {
3929
+ importance_min: { type: "number", description: "Minimum importance (0-1)" },
3930
+ importance_max: { type: "number", description: "Maximum importance (0-1)" },
3931
+ importance_bucket: { type: "string", description: "Named bucket: constitutive, durable, binding, event, or scratch. Maps to that band's numeric range so older rows still match." },
3932
+ emotional_intensity_min: { type: "number", description: "Minimum emotional intensity (0-1)" },
3933
+ emotional_intensity_max: { type: "number", description: "Maximum emotional intensity (0-1)" },
3934
+ timestamp_after: { type: "number", description: "Unix timestamp - memories after this time" },
3935
+ timestamp_before: { type: "number", description: "Unix timestamp - memories before this time" },
3936
+ content_contains: { type: "string", description: "Text to search in content/event" },
3937
+ context_contains: { type: "string", description: "Text to search in context" },
3938
+ id: { type: "number", description: "Exact memory ID" }
3939
+ }
3940
+ },
3941
+ limit: { type: "number", description: "Max results (1-1000, default: 10)" },
3942
+ order_by: { type: "string", description: "Sort field and direction, e.g. 'importance DESC' or 'timestamp DESC'. Fields: id, timestamp, importance, emotional_intensity, content, context. Default is importance descending. Current entries come first in every order, including timestamp. Anything else falls back to importance descending." },
3943
+ include_superseded: { type: "boolean", description: "Accepted, and it does not hide anything. Superseded and expired entries are always returned, marked with their end date and what replaced them. Current entries are listed first." },
3944
+ history: { type: "boolean", description: "Return ended entries only, up to limit, so a page full of current entries cannot leave them out. Default false: ended entries follow the current ones until limit." }
3945
+ }
3946
+ }
3947
+ },
3948
+ required: ["layer"]
3949
+ }
3950
+ },
3951
+ {
3952
+ name: "save_to_layer",
3953
+ description: "Save memory to a specific layer with full control over metadata. PASS session_id - your transcript session from emet_session_open - on every save made inside a session: the server stamps derived_from with the exchange in progress. Semantic and procedural entries are REFUSED without a source (session_id, or metadata.derived_from); nothing is written when refused.",
3954
+ inputSchema: {
3955
+ type: "object",
3956
+ properties: {
3957
+ session_id: {
3958
+ type: "string",
3959
+ description: "Your transcript session id (from emet_session_open). The server reads that session's counter and stamps derived_from {session_id, exchange_start, exchange_end} with the exchange in progress. Ignored when metadata.derived_from is given. Required in practice for semantic and procedural saves made in a session."
3960
+ },
3961
+ layer: {
3962
+ type: "string",
3963
+ enum: ["episodic", "semantic", "procedural", "meta", "identity", "working"],
3964
+ description: "Target memory layer"
3965
+ },
3966
+ content: {
3967
+ type: "string",
3968
+ description: "Memory content to save"
3969
+ },
3970
+ metadata: {
3971
+ type: "object",
3972
+ description: "Full metadata control",
3973
+ properties: {
3974
+ importance: { description: "Bucket word (constitutive|durable|binding|event|scratch), optional :low|:mid|:high, or a number kept for compatibility." },
3975
+ importance_bucket: { type: "string", description: "constitutive|durable|binding|event|scratch" },
3976
+ importance_degree: { type: "string", description: "low|mid|high — only when degree inside the bucket matters" },
3977
+ emotional_intensity: { type: "number" },
3978
+ context: { type: "string" },
3979
+ source: { type: "string", description: "Which host produced this write. A shared default tag is ambiguity, not attribution." },
3980
+ tags: { type: "array", items: { type: "string" } },
3981
+ valid_until: { type: "number", description: "Unix seconds. After this the entry is marked ended and stays in recall and queries, with this date as its end date. It is not hidden and it is not deleted." },
3982
+ derived_from: {
3983
+ type: "object",
3984
+ description: "Where this entry came from, so the claim can be checked later. Either the transcript span it was drawn from - {session_id, exchange_start, exchange_end} - or, for an entry consolidated from other entries, {entries: [id, ...]}. One or the other, never both. REQUIRED on semantic and procedural entries unless session_id is passed (the server then stamps it): an assertion whose source cannot be reached is a claim the system merely happens to hold. Never guess - a nearest-timestamp guess recorded as a citation is worse than no citation."
3985
+ }
3986
+ }
3987
+ }
3988
+ },
3989
+ required: ["layer", "content"]
3990
+ }
3991
+ },
3992
+ {
3993
+ name: "revise_memory",
3994
+ description: "Supersede an existing entry with a revised one (supersession, not mutation). Creates a NEW entry carrying metadata.supersedes=<target_id> and marks the target with superseded_by and superseded_at. The old entry stays readable: it is not hidden and is_live is not cleared. Recall, query_layer and semantic_recall return it marked with that end date and the id of the new entry. The current entry is listed first. Episodic revision is prohibited - events are immutable.",
3995
+ inputSchema: {
3996
+ type: "object",
3997
+ properties: {
3998
+ layer: {
3999
+ type: "string",
4000
+ enum: ["semantic", "procedural", "meta", "identity", "working"],
4001
+ description: "Layer of the entry being revised (episodic is prohibited)"
4002
+ },
4003
+ target_id: {
4004
+ type: "number",
4005
+ description: "ID of the entry this revision supersedes"
4006
+ },
4007
+ content: {
4008
+ type: "string",
4009
+ description: "Full revised content - must stand alone without the superseded entry"
4010
+ },
4011
+ metadata: {
4012
+ type: "object",
4013
+ description: "Metadata for the new entry (supersedes is set automatically; include source tag)"
4014
+ }
4015
+ },
4016
+ required: ["layer", "target_id", "content"]
4017
+ }
4018
+ },
4019
+ {
4020
+ name: "revise_transcript",
4021
+ description: "Supersede a stored transcript part with a corrected one. Nothing is edited or deleted: a NEW document is written under the same session_id with an incremented revision and a pointer to its parent, and the parent is marked superseded. Use when a saved part has a mispaired, missing or wrong exchange. The reason parameter is required - an unexplained correction is a silent rewrite. To ADD a new part, use save_transcript instead. Allowed on a closed session (a correction deletes nothing), but there a revision may only correct the exchanges the part already holds - never add new ones - and must carry exactly the same exchange numbers (none dropped or repeated; numbers sent as text are stored as numbers).",
4022
+ inputSchema: {
4023
+ type: "object",
4024
+ properties: {
4025
+ session_id: {
4026
+ type: "string",
4027
+ description: "The session_id of the part to correct, e.g. '2026-09-04_x_verbatim_part14'. Must already exist."
4028
+ },
4029
+ exchanges: {
4030
+ type: "array",
4031
+ description: "The COMPLETE corrected exchange list for this part, verbatim. A revision replaces the whole part.",
4032
+ items: {
4033
+ type: "object",
4034
+ properties: {
4035
+ exchange_index: { type: "number" },
4036
+ user_message: { type: "string" },
4037
+ assistant_message: { type: "string" },
4038
+ timestamp: { type: "number" }
4039
+ }
4040
+ }
4041
+ },
4042
+ reason: {
4043
+ type: "string",
4044
+ description: "What was wrong and how this differs. Required, minimum 10 characters. This is the audit record of the correction."
4045
+ },
4046
+ session_start: { type: "number", description: "Optional. Defaults to the parent's." },
4047
+ session_end: { type: "number", description: "Optional. Defaults to the parent's." },
4048
+ source: { type: "string", description: "Source tag - which host produced this write. Defaults to the parent's." },
4049
+ channel: { type: "string", description: "Alias of `source` (the older name, kept one release). Prefer `source`." }
4050
+ },
4051
+ required: ["session_id", "exchanges", "reason"]
4052
+ }
4053
+ },
4054
+ {
4055
+ name: "query_transcripts",
4056
+ description: "Retrieve session transcripts - the second tier, word for word. Reads the live store written by save_transcript AND the legacy archive (exchanges written by earlier hosts, June 2025 to June 2026), grouped into the same session shape; every result carries `source` naming the collection it came from. Nothing is migrated between them. Use it when a layer entry is relevant but thin: the transcript around its timestamp holds the depth the layer compressed out.",
4057
+ inputSchema: {
4058
+ type: "object",
4059
+ properties: {
4060
+ timestamp_start: {
4061
+ type: "number",
4062
+ description: "Unix timestamp - start of time range (filters by session_start)"
4063
+ },
4064
+ timestamp_end: {
4065
+ type: "number",
4066
+ description: "Unix timestamp - end of time range (filters by session_start)"
4067
+ },
4068
+ session_id: {
4069
+ type: "string",
4070
+ description: "Optional: Filter by specific session ID (e.g. '2026-04-16_001')"
4071
+ },
4072
+ limit: {
4073
+ type: "number",
4074
+ description: "Maximum number of sessions to return (default: 50, max: 500)"
4075
+ }
4076
+ },
4077
+ required: ["timestamp_start", "timestamp_end"]
4078
+ }
4079
+ },
4080
+ {
4081
+ name: "save_transcript",
4082
+ description: "FALLBACK. The primary path is emet_session_open then emet_transcript_append after every reply; use this only where a host cannot append per reply. Saves the full word-for-word dialog of a session - full transcripts only, no summaries. Pass exchanges in the call. The server does not read a file. Refused for a session that was closed complete.",
4083
+ inputSchema: {
4084
+ type: "object",
4085
+ properties: {
4086
+ session_id: {
4087
+ type: "string",
4088
+ description: "Unique session identifier, e.g. 'claude_project_2026-05-23_001'"
4089
+ },
4090
+ session_start: {
4091
+ type: "number",
4092
+ description: "Unix timestamp — session start"
4093
+ },
4094
+ session_end: {
4095
+ type: "number",
4096
+ description: "Unix timestamp — session end (optional, defaults to now)"
4097
+ },
4098
+ exchanges: {
4099
+ type: "array",
4100
+ description: "Claude.ai Project mode: full array of exchange objects for this session",
4101
+ items: {
4102
+ type: "object",
4103
+ properties: {
4104
+ exchange_index: { type: "number" },
4105
+ user_message: { type: "string" },
4106
+ assistant_message: { type: "string" },
4107
+ timestamp: { type: "number", description: "Optional Unix timestamp for this exchange" }
4108
+ },
4109
+ required: ["user_message", "assistant_message"]
4110
+ }
4111
+ },
4112
+ source: { type: "string", description: "Source tag - which host produced this write. One name on every write since 2026-09-27." },
4113
+ channel: { type: "string", description: "Alias of `source` (the older name, kept one release). Prefer `source`." }
4114
+ },
4115
+ required: ["session_id", "session_start", "exchanges"]
4116
+ }
4117
+ },
4118
+ {
4119
+ name: "emet_session_open",
4120
+ description: "Open a transcript session. Returns session_id, dated in the configured local time zone (EMET_TIMEZONE, default America/New_York). A slug whose id is already CLOSED is never reopened: the session opens as <id>-2, -3, ... and the result's `notice` says so. Pass load_id from your startup pages (startup_page.load_id on every emet_initialize page) so the startup page check runs on your own startup; an open without load_id, or with a page of that startup never fetched, is refused (the refusal says how to pass). After every reply, call emet_transcript_append with that id and the one exchange that just happened. Do not dump the whole chat at close.",
4121
+ inputSchema: {
4122
+ type: "object",
4123
+ properties: {
4124
+ slug: { type: "string", description: "Short name for the session, e.g. grok-init" },
4125
+ load_id: { type: "string", description: "Required for the startup page check (by policy; the schema leaves it optional so older hosts can still open): the load_id from `startup_page` on your emet_initialize pages - page 1 returns it, every page carries it, and the last page names it. An open without load_id, or with a page of that load known not fetched, is refused before anything is written (gate startup_pages); the refusal names the exact calls that pass." }
4126
+ }
4127
+ }
4128
+ },
4129
+ {
4130
+ name: "emet_transcript_append",
4131
+ description: "Append ONE exchange to the session transcript. Call after every reply. Idempotent on exchange_index. Rolls to a new part after 8 exchanges. After a complete close the session takes exactly ONE more append - its closing exchange, numbered next, within EMET_CLOSE_GRACE_MINUTES (default 5) of the close; it is written into the session's last part whatever session_id it names, and the result's `closing_exchange` says it was accepted and that the session is now locked. A retry of that exchange number is answered as already saved and writes nothing. exchange_index, when given, must be a whole number of 1 or more (a number or its text). Any other append to a closed session is refused and nothing is written; that exchange belongs in the next session you open the usual way. THE FIRST APPEND OF A SESSION IS CHECKED: its assistant_message must carry the measured machine line - a machine name from the install notes plus the clock reading, as the shell returned them - or the words `no local shell MCP`. Without one of those the append is refused and the transcript does not start.",
4132
+ inputSchema: {
4133
+ type: "object",
4134
+ properties: {
4135
+ session_id: { type: "string" },
4136
+ user_message: { type: "string" },
4137
+ assistant_message: { type: "string" },
4138
+ exchange_index: { type: "number" },
4139
+ session_start: { type: "number" },
4140
+ source: { type: "string", description: "Source tag - which host produced this write. One name on every write since 2026-09-27." },
4141
+ channel: { type: "string", description: "Alias of `source` (the older name, kept one release). Prefer `source`." }
4142
+ },
4143
+ required: ["session_id", "user_message", "assistant_message"]
4144
+ }
4145
+ },
4146
+ {
4147
+ name: "read_doc",
4148
+ description: "Read a continuity document from the versioned document store. A document longer than one page (18,000 UTF-8 bytes by default) comes back in pages: the result then carries `page` with `next_offset` - call again with that offset (and `expected_version` set to the first page's `version`) until `next_offset` is null, and do not act on a document you have only part of. If the document changes between pages the read is refused: restart from offset 0. `sha256` and `size` (characters; `size_bytes` in UTF-8 bytes) always describe the WHOLE document.",
4149
+ inputSchema: { type: "object", properties: {
4150
+ doc_id: { type: "string", description: "Document id, e.g. 'STATE.md', 'HANDOFF.md', 'threads/pcb-laser-board.md', 'bootstrap/IDENTITY.md'" },
4151
+ offset: { type: "number", description: "Optional: UTF-8 byte offset to start from (default 0). Use the previous page's `page.next_offset`." },
4152
+ limit: { type: "number", description: "Optional: page size in bytes as the host receives it (default 18,000): a page ends before its JSON-escaped text plus 1,500 bytes kept for the result envelope would pass it, and never splits a character. 0 returns the whole document in one result." },
4153
+ expected_version: { type: "number", description: "Optional: the `version` your first page returned. Pass it with every later page; if the document changed in between, the read is refused (restart from offset 0) so pages of two versions are never joined." },
4154
+ expected_sha256: { type: "string", description: "Optional: the `sha256` your first page returned - the same pin as expected_version, and the only pin for a shipped guide (guides have no version)." }
4155
+ }, required: ["doc_id"] }
4156
+ },
4157
+ {
4158
+ name: "read_doc_history",
4159
+ description: "Read a RETAINED revision of a document, or list the revisions retained for it. Every write archives the prior copy (nothing is deleted); this is the reader for that archive. Without a version: the list of retained revisions with their digests and dates. With a version: that revision's full content, re-hashed against its stored digest. Read-only. Use it for an audit question - what did this document say at version N - or to compare a document with what it replaced.",
4160
+ inputSchema: { type: "object", properties: {
4161
+ doc_id: { type: "string", description: "Document id, e.g. 'CHARTER.md', 'STATE.md'" },
4162
+ version: { type: "number", description: "Optional: the revision to read. Omit to list what is retained." }
4163
+ }, required: ["doc_id"] }
4164
+ },
4165
+ {
4166
+ name: "write_doc",
4167
+ description: "Write or update a continuity document in the document store (versioned, prior copy archived to documents_history and marked obsolete, sha256 read-back verified). RECORDS - the handoff (HANDOFF.md) and any id in EMET_RECORD_DOCS - are superseded, never amended: patch_doc is refused on them, and a new revision is written here WHOLE with `supersedes` set to the version currently in force (ISO 9001 7.5.3 / 13485 4.2.5).",
4168
+ inputSchema: { type: "object", properties: {
4169
+ doc_id: { type: "string" },
4170
+ content: { type: "string" },
4171
+ supersedes: { type: "number", description: "Required when writing a new revision of a RECORD (the handoff): the version currently in force, which this revision supersedes. Ignored for ordinary documents." },
4172
+ reason: { type: "string", description: "Required on a record revision (at least 10 characters). Stored as edit_reason." },
4173
+ session_id: { type: "string", description: "Transcript session id. The floor is measured from the size when this session first touched the document." },
4174
+ source: { type: "string", description: "Source tag - which host produced this write. One name on every write since 2026-09-27." },
4175
+ updated_by: { type: "string", description: "Alias of `source` (the older name, kept one release). Prefer `source`." }
4176
+ }, required: ["doc_id", "content"] }
4177
+ },
4178
+ {
4179
+ name: "list_docs",
4180
+ description: "List continuity documents (metadata only; optional doc_id prefix filter, e.g. 'threads/'). Current documents come first. Retired documents stay in the list, marked with retired_at and, when a rename replaced them, what replaced them.",
4181
+ inputSchema: { type: "object", properties: {
4182
+ prefix: { type: "string" },
4183
+ include_retired: { type: "boolean", description: "Accepted. Retired documents are always listed, after the current ones. This flag does not hide them." }
4184
+ } }
4185
+ },
4186
+ {
4187
+ name: "retire_doc",
4188
+ description: "Mark a continuity document OBSOLETE. Nothing is deleted: the document keeps its id, its full content, its version and its digest, and read_doc still returns it, flagged retired, with the reason. list_docs and the startup scan keep it too, after the current documents, with retired_at and what replaced it. A retired drop is not an unhandled drop. This is document control as ISO 7.5.3 describes it - issue the revision, mark the prior copy obsolete, retain it - and it is the document-store counterpart of revise_memory superseding a layer entry. THE STORE HAS NO DELETE, BY DESIGN. Use for genuine litter: tombstones, merged staging documents, test scratch. Reversible: write_doc on the same id brings it back into the working view.",
4189
+ inputSchema: { type: "object", properties: {
4190
+ doc_id: { type: "string", description: "Document id to mark obsolete." },
4191
+ reason: { type: "string", description: "Why it is obsolete. Required, at least 10 characters. Drops only." },
4192
+ session_id: { type: "string", description: "Transcript session id." },
4193
+ source: { type: "string", description: "Source tag - which host produced this write. One name on every write since 2026-09-27." },
4194
+ retired_by: { type: "string", description: "Alias of `source` (the older name, kept one release). Prefer `source`." }
4195
+ }, required: ["doc_id", "reason"] }
4196
+ },
4197
+ {
4198
+ name: "rename_doc",
4199
+ description: "Reissue a continuity document under a new doc_id. The destination is written first through the normal write path (same redaction, digest, versioning and sha256 read-back), and only then is the old id marked obsolete with retire_doc. NOTHING IS DELETED: the old id keeps its content and stays readable at its own id, flagged retired with the reason 'Reissued as <new_id>'. list_docs keeps it after the current documents and names the new id in replaced_by. No tombstone document is needed, because the retired original IS the tombstone and it still carries the real content. Refuses if the destination already exists, because a rename onto an occupied id is a merge decision and merges are not guessed.",
4200
+ inputSchema: { type: "object", properties: {
4201
+ old_id: { type: "string", description: "Existing document id to reissue from. It is retired, not removed." },
4202
+ new_id: { type: "string", description: "Destination document id. Must not already exist." },
4203
+ reason: { type: "string", description: "Why the document is reissued. Required, at least 10 characters." },
4204
+ session_id: { type: "string", description: "Transcript session id." },
4205
+ source: { type: "string", description: "Source tag - which host produced this write. One name on every write since 2026-09-27." },
4206
+ updated_by: { type: "string", description: "Alias of `source` (the older name, kept one release). Prefer `source`." }
4207
+ }, required: ["old_id", "new_id", "reason"] }
4208
+ },
4209
+ {
4210
+ name: "restore_doc",
4211
+ description: "Write a retained revision back as the current document. The retained row is copied through the normal write path, with a reason. Nothing is deleted.",
4212
+ inputSchema: { type: "object", properties: {
4213
+ doc_id: { type: "string", description: "Document id to restore." },
4214
+ version: { type: "integer", description: "Retained revision number to write back." },
4215
+ reason: { type: "string", description: "Why this revision is restored. At least 10 characters." },
4216
+ session_id: { type: "string", description: "Transcript session id. Required on Path 2." },
4217
+ source: { type: "string", description: "Source tag. Must be in EMET_RESTORE_TAGS. A missing source is refused." }
4218
+ }, required: ["doc_id", "version", "reason", "source"] }
4219
+ },
4220
+ {
4221
+ name: "validate_doc",
4222
+ description: "Check a controlled document against the template that governs it, WITHOUT writing anything. The same check the write path runs on every document write - this is the freestanding form, for auditing records written before validation existed, for counting what adoption will mark before it marks anything, and for checking new content before it is stored. Pass doc_id to check a stored document (the previous revision is pulled from the archive so the carry-forward check runs). Pass content, with template_id or a doc_id the map covers, to check text that is not stored. Checks FORM and CONTINUITY only: sections present, in order and filled; no placeholder left unfilled; NOT COMPLETED carrying a reason; a board's counts matching its rows; every row on the previous revision still present or dispositioned. It cannot check whether an answer is true, or whether a 'none' states its scope honestly - those stay with the author and the reader.",
4223
+ inputSchema: { type: "object", properties: {
4224
+ doc_id: { type: "string", description: "Stored document to check. Alone, it is read from the store and compared against its own previous revision." },
4225
+ content: { type: "string", description: "Text to check instead of a stored document. Requires template_id, or a doc_id the map covers." },
4226
+ template_id: { type: "string", description: "Template to check against, e.g. 'templates/BOARD.md'. Read from the copy shipped with this release, never the store copy." }
4227
+ } }
4228
+ },
4229
+ {
4230
+ name: "accept_nonconformance",
4231
+ description: "Record the written disposition for a nonconformance that will not be corrected. A marker is never cleared silently: it leaves either because a later revision passes, or because someone accepts it in writing here. The marker is RETAINED with the acceptance beside it, and the acceptance covers only the revision it names - the next write is validated fresh, and accepted markers stay counted at session start, separately from open ones. The reason must be at least 10 characters: it is what an audit reads later.",
4232
+ inputSchema: { type: "object", properties: {
4233
+ doc_id: { type: "string", description: "Document carrying the open marker." },
4234
+ reason: { type: "string", description: "Why this nonconformance is accepted rather than corrected. At least 10 characters." },
4235
+ source: { type: "string", description: "Source tag - which host produced this write. One name on every write since 2026-09-27." },
4236
+ session_id: { type: "string", description: "Transcript session id." },
4237
+ accepted_by: { type: "string", description: "Alias of `source` (the older name, kept one release). Prefer `source`." }
4238
+ }, required: ["doc_id", "reason"] }
4239
+ },
4240
+ {
4241
+ name: "patch_doc",
4242
+ description: "Replace one exact substring in a document (str_replace semantics) without resending the whole file. THE PREFERRED WAY TO MAKE SMALL EDITS to large docs like STATE.md. old_str must match EXACTLY ONCE - zero or multiple matches are rejected rather than guessed. Pass new_str as an empty string to delete. Same versioning, archiving, and sha256 read-back as write_doc.",
4243
+ inputSchema: { type: "object", properties: {
4244
+ doc_id: { type: "string" },
4245
+ old_str: { type: "string", description: "Exact text to replace. Must occur exactly once in the current text. Omit when `edits` is set." },
4246
+ new_str: { type: "string", description: "Replacement text. Empty string deletes the matched text." },
4247
+ reason: { type: "string", description: "Required, at least 10 characters. A removed or closed board row says why." },
4248
+ origin: { type: "string", description: "Required when a board edit adds a row id. Names the session, chat or drop the row came from." },
4249
+ edits: { type: "array", description: "Several replacements in one revision. Each old_str must match the current text exactly once. Applied together, so a failed edit writes nothing.", items: { type: "object" } },
4250
+ session_id: { type: "string", description: "Transcript session id. The floor is measured from the size when this session first touched the document." },
4251
+ source: { type: "string", description: "Source tag - which host produced this write. One name on every write since 2026-09-27." },
4252
+ updated_by: { type: "string", description: "Alias of `source` (the older name, kept one release). Prefer `source`." },
4253
+ expected_version: { type: "number", description: "Optional optimistic-concurrency guard." }
4254
+ }, required: ["doc_id", "reason"] }
4255
+ },
4256
+ {
4257
+ name: "semantic_recall",
4258
+ description: "Semantic (meaning-based) memory search across all six memory layers via Atlas Vector Search autoEmbed. Finds conceptually related memories even without keyword overlap. Complements keyword-based recall/query_layer.",
4259
+ inputSchema: { type: "object", properties: {
4260
+ query: { type: "string", description: "Natural-language query - embedded automatically server-side (voyage-4)" },
4261
+ layer: { type: "string", enum: ["episodic", "semantic", "procedural", "meta", "identity", "working"], description: "Optional: restrict to one layer" },
4262
+ limit: { type: "number", description: "Max results (default 10)" },
4263
+ history: { type: "boolean", description: "Return ended entries only, up to limit. Default false: current matches come first and ended matches follow until limit, marked with their end date." }
4264
+ }, required: ["query"] }
4265
+ },
4266
+ {
4267
+ name: "emet_gaps",
4268
+ description: "The record's honesty about its own holes, as a query. Read-only; identifies and never fills. Counts, with the ids behind them, for each gap class: unsourced assertions, unattributed inferences, ambiguous attribution (by tag and by session), uncertain routing, unattestable rows (pre-2026-09-05 boundary), digest mismatches (the alarm - expect zero), pointer disagreements against the transcript store, broken supersession, expired-but-live rows, consolidation candidates (tag families; a list for a person), and redacted spans by source. Call it when asked how trustworthy the record is, before a consolidation pass, or after any migration. Closing a gap is a person's decision: revise_memory with a real derived_from where the source is knowable; otherwise the gap stands as a fact.",
4269
+ inputSchema: {
4270
+ type: "object",
4271
+ properties: {
4272
+ layer: { type: "string", enum: ["episodic", "semantic", "procedural", "meta", "identity", "working"], description: "Restrict to one layer. Default: all six." },
4273
+ since: { type: "number", description: "Unix seconds; only entries with timestamp >= since. Default: the whole store." },
4274
+ limit: { type: "number", description: "Max ids returned per class (1-500, default 50). Counts are always complete." }
4275
+ }
4276
+ }
4277
+ },
4278
+ {
4279
+ name: "emet_floor",
4280
+ description: "Read the floor: the standing description of how this assistant engages with the person using it. It ships in the server code, so setup cannot overwrite it and no install can lose it - the character document holds the user's modifiers on top of it. emet_initialize already returns the whole floor at session start; call this to read it again without re-running initialize. Use `compact: true` for the short form partway through a long session, when the start of the conversation is far behind. Pass `gem` to read one installed GEM's floor; reading does not turn that GEM on. Read-only, and re-reading is not the same as being bound by it.",
4281
+ inputSchema: {
4282
+ type: "object",
4283
+ properties: {
4284
+ compact: { type: "boolean", description: "Return the short re-assertion form instead of the whole floor." },
4285
+ section: { type: "string", description: "Return one section only, e.g. 'voice'. Call with no arguments to see the section names." },
4286
+ gem: { type: "string", description: "Read one installed GEM's floor by id, for example 'neurendipity'. Reading does not turn the GEM on. Omit it to read the core floor." }
4287
+ }
4288
+ }
4289
+ },
4290
+ {
4291
+ name: "emet_client_approve",
4292
+ description: "Approve or deny a new client (a bot, or a single-instance AI such as Claude or ChatGPT) that EMET flagged on its first write. A new client is read-limited until approved. Only the owner, or the chief-of-staff bot named by EMET_COS_TAG, may decide. Pending clients are listed in emet_initialize and emet_status as client_approvals.",
4293
+ inputSchema: {
4294
+ type: "object",
4295
+ properties: {
4296
+ tag: { type: "string", description: "The pending client's tag, as listed in client_approvals.pending" },
4297
+ decision: { type: "string", enum: ["approve", "deny"] },
4298
+ reason: { type: "string", description: "Optional note kept with the decision" },
4299
+ as: { type: "string", enum: ["rename", "add"], description: "Required when the client_id already has approved tags: add keeps them all (several bots on one shared client); rename retires one" },
4300
+ from: { type: "string", description: "With as=rename on a client that has several approved tags: the tag being replaced" }
4301
+ },
4302
+ required: ["tag", "decision"]
4303
+ }
4304
+ },
4305
+ {
4306
+ name: "emet_invite_create",
4307
+ description: "EMET Guest Access: create a one-time code for a guest. They paste it on the connect screen instead of the passphrase. Default access is read-only. Only the owner can create a guest code. EMET Member Access is a different door.",
4308
+ inputSchema: {
4309
+ type: "object",
4310
+ properties: {
4311
+ display_name: { type: "string", description: "Name the guest will be known by in this store" },
4312
+ scopes: { type: "array", items: { type: "string", enum: ["emet.read"] }, description: "Guest invites accept only emet.read." },
4313
+ days: { type: "number", description: "Days until the code expires. Default 14." },
4314
+ source: { type: "string", description: "Source tag bound to this guest at approval. Exact membership of EMET_SOURCE_TAGS when a registry is set." }
4315
+ },
4316
+ required: ["display_name"]
4317
+ }
4318
+ },
4319
+ {
4320
+ name: "emet_member_access_create",
4321
+ description: "EMET Member Access: create a one-time code for a new person with read and write access. They paste it on the connect screen instead of the passphrase. This is not EMET Guest Access, and it never makes them the owner. Only the owner can create it. Off unless EMET_MEMBER_ACCESS=1.",
4322
+ inputSchema: {
4323
+ type: "object",
4324
+ properties: {
4325
+ display_name: { type: "string", description: "Name this person will be known by in this store. A name never selects an existing person." },
4326
+ days: { type: "number", description: "Days until the code expires. Default 14." },
4327
+ source: { type: "string", description: "Source tag bound to this member at approval. Exact membership of EMET_SOURCE_TAGS when a registry is set. Required for this member to write documents over HTTP while a registry is set." }
4328
+ },
4329
+ required: ["display_name"]
4330
+ }
4331
+ },
4332
+ {
4333
+ name: "emet_session_close",
4334
+ description: "Call this when the session is ending - the user says goodbye, asks you to wrap up, mentions restarting or opening a new session, or you are about to run out of room. An acknowledgement of the turn just completed - thanks, great, perfect - is not a session end; if it is ambiguous, ask rather than close. Checks whether this session's work has actually been written to the store and reports exactly what is missing. It does not write anything itself. RECEIPTS ARE REQUIRED: pass `receipts` naming what you wrote and read back for each close step - working documents, the episodic session record id, the board and its version, the transcript part ids and highest exchange index as query_transcripts returned them, and the handoff version (written last). Each receipt is compared with the store; omitted or mismatched receipts, a handoff that was not the last write, or a transcript no layer entry cites (derived_from) return state 'blocked'. Each missing line carries a `scope`: touch only what it names. THREE blocked closes on one session return state 'escalate': stop retrying, say the `speak` lines, and hand the user the list - the counter does not reset on a changed receipt, only with a new session. Say every line of the returned `speak` to the user, in order - a wrap that leaves it out is a failed close. Never tell the user their session is saved unless this returns state 'complete'.",
4335
+ inputSchema: {
4336
+ type: "object",
4337
+ properties: {
4338
+ receipts: {
4339
+ type: "object",
4340
+ description: "Required for state 'complete'. One entry per close step, taken from what the tools returned this session - not from recall.",
4341
+ properties: {
4342
+ working_docs: { type: "array", items: { type: "string" }, description: "Document ids updated this session before the handoff, e.g. ['threads/emet-publication.md']. [] if none." },
4343
+ episodic_id: { type: "number", description: "Id of the episodic session record, as save_to_layer returned it and query_layer read it back." },
4344
+ board_doc: { type: "string", description: "Board document id, usually 'STATE.md'." },
4345
+ board_version: { type: "number", description: "Board version after this session's update." },
4346
+ transcript_session_id: { type: "string", description: "Base transcript session id." },
4347
+ transcript_parts: { type: "array", items: { type: "string" }, description: "Part ids query_transcripts returned for this session." },
4348
+ transcript_exchanges: { type: "number", description: "Highest exchange index query_transcripts returned." },
4349
+ handoff_version: { type: "number", description: "Version of the handoff, written last." },
4350
+ handoff_doc: { type: "string", description: "Bot sessions only: the bot's own handoff record, bots/<source tag>/HANDOFF.md. A bot session needs no board receipt and never names the main HANDOFF.md or STATE.md." }
4351
+ }
4352
+ },
4353
+ session_id: { type: "string", description: "The session_id used (or to be used) for this session's transcript, e.g. '2026-09-03_short-description'. Without it the transcript cannot be checked; receipts.transcript_session_id is used when this is omitted." },
4354
+ session_start: { type: "number", description: "Unix seconds when the session began. Defaults to 12 hours ago, which makes the answer approximate." },
4355
+ thread_docs: { type: "array", items: { type: "string" }, description: "Additional document ids this session should have updated, e.g. ['threads/some-project.md']." },
4356
+ source: { type: "string", description: "Your host's source tag: the registered tag this session was written under. The close is never refused for it; an unregistered tag, one that did not write this session, or none (with no server default) gets a warning that the close is not attributed." }
4357
+ }
4358
+ }
4359
+ }
4360
+ ];
4361
+
4362
+ /** Registered only when EMET_SUBKEYS=1. Flag off, a host's tool list does not include them. */
4363
+ const SUBKEY_TOOL_DEFINITIONS = [
4364
+ {
4365
+ name: "emet_subkey_request",
4366
+ description: "Request a delegated client sub-key for one registered source tag. Only an authenticated HTTP login of the owner may call it, and only when EMET_SUBKEYS=1. The result carries a one-time claim nonce. The server stores only its HMAC. Approve or deny the request with emet_client_approve from the owner's own login.",
4367
+ inputSchema: { type: "object", properties: {
4368
+ tag: { type: "string", description: "The source tag this key will stamp. An exact member of EMET_SOURCE_TAGS when a registry is set." }
4369
+ }, required: ["tag"] }
4370
+ },
4371
+ {
4372
+ name: "emet_subkey_claim",
4373
+ description: "Claim an approved sub-key with the key_id and the one-time nonce from emet_subkey_request. The result carries the emk_ key once. A second claim, a wrong nonce, or a missing pepper is refused. The bot keeps the key as a local secret; it is never written into EMET content.",
4374
+ inputSchema: { type: "object", properties: {
4375
+ key_id: { type: "string", description: "The key_id from emet_subkey_request." },
4376
+ nonce: { type: "string", description: "The one-time emn_ nonce from emet_subkey_request." }
4377
+ }, required: ["key_id", "nonce"] }
4378
+ },
4379
+ {
4380
+ name: "emet_subkey_revoke",
4381
+ description: "Revoke a client sub-key by tag or key_id. The owner's own login can always revoke. The key record is kept. A later write with its session handle is refused, and earlier writes stay as written.",
4382
+ inputSchema: { type: "object", properties: {
4383
+ tag: { type: "string", description: "Revoke the live key for this tag. Pass tag or key_id." },
4384
+ key_id: { type: "string", description: "Revoke this key. Pass tag or key_id." },
4385
+ reason: { type: "string", description: "Why it was revoked. Kept on the record." }
4386
+ } }
4387
+ },
4388
+ {
4389
+ name: "emet_subkey_end_session",
4390
+ description: "End a sub-key's live session without revoking the key. Owner only. Use it when the single live session is stuck and the key should stay usable. The old session handle stops working; the bot opens a new session with the same key.",
4391
+ inputSchema: { type: "object", properties: {
4392
+ tag: { type: "string", description: "The tag whose live session to end. Pass tag or key_id." },
4393
+ key_id: { type: "string", description: "The key whose live session to end. Pass tag or key_id." },
4394
+ reason: { type: "string", description: "Why the session was ended. Kept on the record." }
4395
+ } }
4396
+ }
4397
+ ];
4398
+
4399
+ /**
4400
+ * The tool list as hosts receive it, with MCP annotations attached.
4401
+ *
4402
+ * annotate() throws if any tool is missing an entry, so a new tool cannot reach
4403
+ * a host unannotated - which would make it look, to any host that asks before
4404
+ * acting, exactly as risky as an arbitrary shell command.
4405
+ */
4406
+ /**
4407
+ * The session graph's two inputs on every write tool (2026-09-29, board 120):
4408
+ * `session_id` names the session the write belongs to, `exchange` the turn.
4409
+ * Added here, once, rather than hand-copied into each schema; a schema that
4410
+ * already declares one keeps its own description.
4411
+ */
4412
+ const GRAPH_INPUTS = {
4413
+ session_id: { type: 'string', description: 'Your transcript session id from emet_session_open. The session graph reads this session\'s state before the write and returns `session_graph.next` - the next step. A governed write with no session is refused (open one with emet_session_open and load_id).' },
4414
+ exchange: { type: 'number', description: 'The exchange (turn) this write belongs to, counting from 1. A write for exchange n while exchange n-1 was never appended is refused by the session graph until n-1 is appended.' }
4415
+ };
4416
+ for (const t of TOOL_DEFINITIONS) {
4417
+ // One list of governed tools: session-graph.js. The append already carries
4418
+ // session_id and exchange_index, its own names for the same two inputs.
4419
+ if (!GRAPH_WRITE_TOOLS.has(t.name) || t.name === 'emet_transcript_append' || !t.inputSchema) continue;
4420
+ t.inputSchema.properties = t.inputSchema.properties || {};
4421
+ for (const [k, v] of Object.entries(GRAPH_INPUTS)) if (!t.inputSchema.properties[k]) t.inputSchema.properties[k] = v;
4422
+ }
4423
+ const SESSION_HANDLE_INPUT = {
4424
+ type: 'string',
4425
+ description: 'Required on every write in a session opened with a sub-key. The session_handle emet_session_open returned once. It is not repeated in receipts, logs, or session_graph.'
4426
+ };
4427
+ const SUBKEY_OPEN_INPUT = {
4428
+ type: 'string',
4429
+ description: 'The bot\'s emk_ sub-key. Presented once here. The result\'s session_handle is what later writes in this session pass.'
4430
+ };
4431
+ const SUBKEY_CLOSE_INPUT = {
4432
+ type: 'string',
4433
+ description: 'The emk_ sub-key that opened this session. A close releases that sub-key session only when this and session_handle are the ones that opened it. The handle is checked against receipts.transcript_session_id.'
4434
+ };
4435
+
4436
+ function cloneTool(t) {
4437
+ const schema = t.inputSchema ? {
4438
+ ...t.inputSchema,
4439
+ properties: { ...(t.inputSchema.properties || {}) },
4440
+ ...(Array.isArray(t.inputSchema.required) ? { required: [...t.inputSchema.required] } : {})
4441
+ } : t.inputSchema;
4442
+ return { ...t, inputSchema: schema };
4443
+ }
4444
+
4445
+ let toolCache = { flag: null, tools: null };
4446
+
4447
+ /** The tool list a host receives. The four sub-key tools and their schema fields exist only when EMET_SUBKEYS=1. */
4448
+ export function hostTools() {
4449
+ const on = setting('SUBKEYS') === '1';
4450
+ if (toolCache.tools && toolCache.flag === on) return toolCache.tools;
4451
+ const defs = TOOL_DEFINITIONS.map(cloneTool);
4452
+ if (on) {
4453
+ const open = defs.find((t) => t.name === 'emet_session_open');
4454
+ if (open && open.inputSchema) open.inputSchema.properties.subkey = SUBKEY_OPEN_INPUT;
4455
+ const close = defs.find((t) => t.name === 'emet_session_close');
4456
+ if (close && close.inputSchema) close.inputSchema.properties.subkey = SUBKEY_CLOSE_INPUT;
4457
+ for (const t of defs) {
4458
+ const props = t.inputSchema && t.inputSchema.properties;
4459
+ if (!props || !props.session_id || props.session_handle) continue;
4460
+ props.session_handle = SESSION_HANDLE_INPUT;
4461
+ }
4462
+ defs.push(...SUBKEY_TOOL_DEFINITIONS.map(cloneTool));
4463
+ }
4464
+ const tools = annotate(defs);
4465
+ toolCache = { flag: on, tools };
4466
+ return tools;
4467
+ }
4468
+
4469
+ /** Live view of hostTools(), so a test that flips EMET_SUBKEYS sees the list for that flag. */
4470
+ export const TOOLS = new Proxy([], {
4471
+ get(_target, prop) {
4472
+ const list = hostTools();
4473
+ if (prop === Symbol.iterator) return list[Symbol.iterator].bind(list);
4474
+ const value = list[prop];
4475
+ return typeof value === 'function' ? value.bind(list) : value;
4476
+ }
4477
+ });
4478
+
4479
+
4480
+ // ============================================
4481
+ // EXPORTS
4482
+ // ============================================
4483
+
4484
+ export default {
4485
+ // Rate limiting
4486
+ RATE_LIMIT_CONFIG,
4487
+ RateLimitError,
4488
+ RateLimiter,
4489
+
4490
+ // Error handling
4491
+ handleError,
4492
+ createSuccessResponse,
4493
+
4494
+ // Memory operations
4495
+ saveMemory,
4496
+ recallMemories,
4497
+ semanticRecall,
4498
+ queryLayer,
4499
+ getStatus,
4500
+ queryTranscripts,
4501
+ reviseTranscript,
4502
+ saveTranscript,
4503
+ sessionOpen,
4504
+ transcriptAppend,
4505
+
4506
+ // Tool definitions
4507
+ TOOLS
4508
+ };