@dreamforgestudiollc/emet 0.0.0-stage → 0.2.4

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 (73) hide show
  1. package/.env.example +136 -0
  2. package/CHANGELOG.md +296 -0
  3. package/CHARTER.md +125 -0
  4. package/LICENSE +22 -0
  5. package/README.md +783 -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 +79 -0
  10. package/docs/COVERAGE.md +64 -0
  11. package/docs/DEPLOY.md +246 -0
  12. package/docs/DISCIPLINES.md +29 -0
  13. package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
  14. package/docs/GUIDED-SETUP.md +296 -0
  15. package/docs/PERMISSIONS.md +118 -0
  16. package/docs/ROADMAP.md +59 -0
  17. package/docs/SETUP.md +259 -0
  18. package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
  19. package/package.json +70 -4
  20. package/server.json +39 -0
  21. package/src/aleph.js +395 -0
  22. package/src/annotations.js +135 -0
  23. package/src/approve-throttle.js +54 -0
  24. package/src/atlas-role.js +58 -0
  25. package/src/conformance.js +688 -0
  26. package/src/content_analyzer.js +450 -0
  27. package/src/database.js +811 -0
  28. package/src/disciplines.js +26 -0
  29. package/src/dispatch.js +279 -0
  30. package/src/env.js +52 -0
  31. package/src/floor.js +187 -0
  32. package/src/gaps.js +476 -0
  33. package/src/guides.js +48 -0
  34. package/src/http-remote.js +269 -0
  35. package/src/index.js +390 -0
  36. package/src/invites.js +167 -0
  37. package/src/layers.js +449 -0
  38. package/src/lessons-to-checks.js +366 -0
  39. package/src/logger.js +299 -0
  40. package/src/metadata.js +61 -0
  41. package/src/module-path.js +32 -0
  42. package/src/mongo-connect.js +48 -0
  43. package/src/named-items.js +208 -0
  44. package/src/oauth-provider.js +838 -0
  45. package/src/persons.js +116 -0
  46. package/src/provenance.js +257 -0
  47. package/src/redact.js +231 -0
  48. package/src/request-log.js +124 -0
  49. package/src/scopes.js +97 -0
  50. package/src/security-headers.js +26 -0
  51. package/src/session-graph.js +945 -0
  52. package/src/session.js +1097 -0
  53. package/src/setup.js +1313 -0
  54. package/src/startup-pages.js +354 -0
  55. package/src/startup-payload.js +126 -0
  56. package/src/tools.js +4271 -0
  57. package/src/transcript-session.js +106 -0
  58. package/src/validation.js +1163 -0
  59. package/src/version.js +2 -0
  60. package/templates/BOARD.md +72 -0
  61. package/templates/BOTHANDOFF.md +57 -0
  62. package/templates/CHARACTER.md +30 -0
  63. package/templates/HANDOFF.md +63 -0
  64. package/templates/IDENTITY.md +29 -0
  65. package/templates/INDEX.md +82 -0
  66. package/templates/INSTALL.md +29 -0
  67. package/templates/PROCEDURE.md +39 -0
  68. package/templates/ROSTER.md +43 -0
  69. package/templates/SEED.md +65 -0
  70. package/templates/STAGING.md +37 -0
  71. package/templates/STANDARD.md +99 -0
  72. package/templates/THREAD.md +43 -0
  73. package/templates/USER.md +29 -0
@@ -0,0 +1,1163 @@
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
+ * Validation Module - Comprehensive validation for all MCP inputs
12
+ */
13
+
14
+ import { IMPORTANCE_BUCKETS } from './layers.js';
15
+
16
+ // ============================================================================
17
+ // CONSTANTS - Validation Limits
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Content length limits
22
+ */
23
+ export const CONTENT_LIMITS = {
24
+ MIN_CONTENT_LENGTH: 1,
25
+ MAX_CONTENT_LENGTH: 100000, // 100KB max content
26
+ MAX_CONTEXT_LENGTH: 10000, // 10KB max context
27
+ MAX_QUERY_LENGTH: 1000, // 1KB max query
28
+ MAX_METADATA_SIZE: 50000, // 50KB max metadata JSON
29
+ MAX_STRING_FIELD_LENGTH: 5000, // 5KB for general string fields
30
+ };
31
+
32
+ /**
33
+ * Numeric range limits
34
+ */
35
+ export const NUMERIC_LIMITS = {
36
+ // Importance and emotional intensity are 0-1 scales
37
+ MIN_IMPORTANCE: 0,
38
+ MAX_IMPORTANCE: 1,
39
+ MIN_EMOTIONAL_INTENSITY: 0,
40
+ MAX_EMOTIONAL_INTENSITY: 1,
41
+
42
+ // Query limits
43
+ MIN_LIMIT: 1,
44
+ MAX_LIMIT: 1000,
45
+ DEFAULT_LIMIT: 10,
46
+
47
+ // Timestamp ranges (Unix epoch)
48
+ MIN_TIMESTAMP: 0,
49
+ MAX_TIMESTAMP: 4102444800, // Year 2100
50
+
51
+ // ID ranges
52
+ MIN_ID: 1,
53
+ MAX_ID: Number.MAX_SAFE_INTEGER,
54
+ };
55
+
56
+ /**
57
+ * Valid memory layers - strict whitelist
58
+ */
59
+ export const VALID_LAYERS = Object.freeze([
60
+ 'episodic',
61
+ 'semantic',
62
+ 'procedural',
63
+ 'meta',
64
+ 'identity',
65
+ 'working'
66
+ ]);
67
+
68
+ /**
69
+ * Valid metadata field names - whitelist for safety
70
+ */
71
+ export const VALID_METADATA_FIELDS = Object.freeze([
72
+ 'importance',
73
+ 'emotional_intensity',
74
+ 'context',
75
+ 'layer',
76
+ 'tags',
77
+ 'source',
78
+ 'person_id',
79
+ 'session_id',
80
+ 'timestamp',
81
+ 'related_ids',
82
+ 'category',
83
+ 'priority',
84
+ 'expires_at',
85
+ // EMET's own vocabulary (2026-09-07). The whitelist above is inherited from
86
+ // upstream and did not know these, so reviseMemory's `supersedes` and every
87
+ // `valid_until` were silently relocated under `custom` - the whole origin of
88
+ // the "placement convention" and of the dual-path readers in tools.js.
89
+ // Nobody chose that; the whitelist did. These are first-class now.
90
+ 'supersedes', // positive integer: id of the entry this one replaces
91
+ 'corrects', // positive integer: id of the entry (same layer) this one partly corrects; the parent stays live
92
+ 'superseded_by', // positive integer or null: id of the entry that replaced this one
93
+ 'superseded_at', // Unix seconds
94
+ 'valid_until', // Unix seconds: an end date. The entry stays readable, marked ended.
95
+ 'valid_from', // Unix seconds: must not exceed valid_until when both present
96
+ 'routing', // plain object: the routing decision that placed the entry
97
+ 'source_defaulted', // boolean: the source tag came from the connector default
98
+ 'assertion_origin', // 'user' | 'assistant' | 'consolidated' | 'imported' | null
99
+ 'content_origin', // 'self' | 'assistant' | 'external' — who the TEXT came from
100
+ // Accountability markers (2026-09-07, receipt-as-fixture). Every field that
101
+ // is MISSING is written as a visible marker, never left absent: absence
102
+ // cannot be queried, "missing" can. Set by saveMemory, never by the caller.
103
+ 'source_missing', // boolean: no source given and no default configured
104
+ 'source_unregistered', // boolean: source is not in EMET_SOURCE_TAGS
105
+ 'importance_defaulted', // boolean: importance came from the layer's charter midpoint
106
+ 'importance_bucket', // constitutive|durable|binding|event|scratch
107
+ 'importance_degree', // low|mid|high — only when degree inside the bucket matters
108
+ 'provenance_missing', // boolean: no derived_from on a layer whose charter wants one
109
+ 'assertion_origin_missing', // boolean: assertion_origin was not supplied at all
110
+ 'custom' // Allow a 'custom' object for extensibility
111
+ ]);
112
+
113
+ /**
114
+ * The boolean accountability markers - validated identically.
115
+ */
116
+ export const MARKER_FIELDS = Object.freeze([
117
+ 'corrected_by', // option A (2026-10-02): written on the PARENT by markCorrectedBy, never by a caller
118
+ 'redacted_spans', // E4: written by the door from the scrubber's count, never by a caller
119
+ 'source_defaulted',
120
+ 'source_missing',
121
+ 'source_unregistered',
122
+ 'importance_defaulted',
123
+ 'provenance_missing',
124
+ 'assertion_origin_missing'
125
+ ]);
126
+
127
+ /**
128
+ * Valid values for metadata.assertion_origin (null is also accepted).
129
+ */
130
+ export const ASSERTION_ORIGINS = Object.freeze(['user', 'assistant', 'consolidated', 'imported']);
131
+ export const CONTENT_ORIGINS = Object.freeze(['self', 'assistant', 'external']);
132
+
133
+ // ============================================================================
134
+ // VALIDATION ERROR CLASS
135
+ // ============================================================================
136
+
137
+ /**
138
+ * Custom validation error with field information
139
+ */
140
+ export class ValidationError extends Error {
141
+ constructor(field, message, value = undefined) {
142
+ // One-argument calls pass the whole message as `field` (the second reader's fix (b), 2026-10-02): that used to
143
+ // render "Validation failed for '<message>': undefined".
144
+ if (message === undefined) { message = field; field = null; }
145
+ super(field === null ? message : `Validation failed for '${field}': ${message}`);
146
+ this.name = 'ValidationError';
147
+ this.field = field;
148
+ this.validationMessage = message;
149
+ // Don't expose the actual value in production for security
150
+ this.invalidValue = process.env.DEBUG === 'true' ? value : '[hidden]';
151
+ }
152
+
153
+ toJSON() {
154
+ return {
155
+ error: 'ValidationError',
156
+ field: this.field,
157
+ message: this.validationMessage
158
+ };
159
+ }
160
+ }
161
+
162
+ // ============================================================================
163
+ // TYPE VALIDATORS
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Check if value is a string
168
+ */
169
+ export function isString(value) {
170
+ return typeof value === 'string';
171
+ }
172
+
173
+ /**
174
+ * Check if value is a number (finite, not NaN)
175
+ */
176
+ export function isNumber(value) {
177
+ return typeof value === 'number' && Number.isFinite(value);
178
+ }
179
+
180
+ /**
181
+ * Check if value is an integer
182
+ */
183
+ export function isInteger(value) {
184
+ return Number.isInteger(value);
185
+ }
186
+
187
+ /**
188
+ * Check if value is a plain object
189
+ */
190
+ export function isPlainObject(value) {
191
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
192
+ }
193
+
194
+ /**
195
+ * Check if value is an array
196
+ */
197
+ export function isArray(value) {
198
+ return Array.isArray(value);
199
+ }
200
+
201
+ // ============================================================================
202
+ // CONTENT VALIDATORS
203
+ // ============================================================================
204
+
205
+ /**
206
+ * Validate content string
207
+ * @param {*} content - Content to validate
208
+ * @param {string} fieldName - Field name for error messages
209
+ * @returns {string} - Validated and trimmed content
210
+ * @throws {ValidationError}
211
+ */
212
+ export function validateContent(content, fieldName = 'content') {
213
+ if (content === null || content === undefined) {
214
+ throw new ValidationError(fieldName, 'Content is required');
215
+ }
216
+
217
+ if (!isString(content)) {
218
+ throw new ValidationError(fieldName, 'Content must be a string', typeof content);
219
+ }
220
+
221
+ const trimmed = content.trim();
222
+
223
+ if (trimmed.length < CONTENT_LIMITS.MIN_CONTENT_LENGTH) {
224
+ throw new ValidationError(fieldName, `Content must be at least ${CONTENT_LIMITS.MIN_CONTENT_LENGTH} character(s)`);
225
+ }
226
+
227
+ if (trimmed.length > CONTENT_LIMITS.MAX_CONTENT_LENGTH) {
228
+ throw new ValidationError(
229
+ fieldName,
230
+ `Content exceeds maximum length of ${CONTENT_LIMITS.MAX_CONTENT_LENGTH} characters (got ${trimmed.length})`
231
+ );
232
+ }
233
+
234
+ return trimmed;
235
+ }
236
+
237
+ /**
238
+ * Validate query string
239
+ * @param {*} query - Query to validate
240
+ * @returns {string} - Validated and trimmed query
241
+ * @throws {ValidationError}
242
+ */
243
+ export function validateQuery(query) {
244
+ if (query === null || query === undefined) {
245
+ throw new ValidationError('query', 'Query is required');
246
+ }
247
+
248
+ if (!isString(query)) {
249
+ throw new ValidationError('query', 'Query must be a string', typeof query);
250
+ }
251
+
252
+ const trimmed = query.trim();
253
+
254
+ if (trimmed.length < 1) {
255
+ throw new ValidationError('query', 'Query cannot be empty');
256
+ }
257
+
258
+ if (trimmed.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
259
+ throw new ValidationError(
260
+ 'query',
261
+ `Query exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH} characters (got ${trimmed.length})`
262
+ );
263
+ }
264
+
265
+ return trimmed;
266
+ }
267
+
268
+ /**
269
+ * Validate context string (optional field)
270
+ * @param {*} context - Context to validate
271
+ * @returns {string} - Validated context or empty string
272
+ * @throws {ValidationError}
273
+ */
274
+ export function validateContext(context) {
275
+ if (context === null || context === undefined || context === '') {
276
+ return '';
277
+ }
278
+
279
+ if (!isString(context)) {
280
+ throw new ValidationError('context', 'Context must be a string', typeof context);
281
+ }
282
+
283
+ if (context.length > CONTENT_LIMITS.MAX_CONTEXT_LENGTH) {
284
+ throw new ValidationError(
285
+ 'context',
286
+ `Context exceeds maximum length of ${CONTENT_LIMITS.MAX_CONTEXT_LENGTH} characters (got ${context.length})`
287
+ );
288
+ }
289
+
290
+ return context;
291
+ }
292
+
293
+ // ============================================================================
294
+ // LAYER VALIDATORS
295
+ // ============================================================================
296
+
297
+ /**
298
+ * Validate memory layer name (strict whitelist)
299
+ * @param {*} layer - Layer name to validate
300
+ * @param {boolean} required - Whether layer is required
301
+ * @returns {string|null} - Validated layer or null if not required and not provided
302
+ * @throws {ValidationError}
303
+ */
304
+ export function validateLayer(layer, required = false) {
305
+ if (layer === null || layer === undefined || layer === '') {
306
+ if (required) {
307
+ throw new ValidationError('layer', 'Layer is required');
308
+ }
309
+ return null;
310
+ }
311
+
312
+ if (!isString(layer)) {
313
+ throw new ValidationError('layer', 'Layer must be a string', typeof layer);
314
+ }
315
+
316
+ const normalizedLayer = layer.toLowerCase().trim();
317
+
318
+ if (!VALID_LAYERS.includes(normalizedLayer)) {
319
+ throw new ValidationError(
320
+ 'layer',
321
+ `Invalid layer '${normalizedLayer}'. Must be one of: ${VALID_LAYERS.join(', ')}`
322
+ );
323
+ }
324
+
325
+ return normalizedLayer;
326
+ }
327
+
328
+ // ============================================================================
329
+ // NUMERIC VALIDATORS
330
+ // ============================================================================
331
+
332
+ /**
333
+ * Validate a number is within a range
334
+ * @param {*} value - Value to validate
335
+ * @param {string} fieldName - Field name for error messages
336
+ * @param {number} min - Minimum value (inclusive)
337
+ * @param {number} max - Maximum value (inclusive)
338
+ * @param {number|null} defaultValue - Default if not provided
339
+ * @returns {number} - Validated number
340
+ * @throws {ValidationError}
341
+ */
342
+ export function validateNumericRange(value, fieldName, min, max, defaultValue = null) {
343
+ if (value === null || value === undefined) {
344
+ if (defaultValue !== null) {
345
+ return defaultValue;
346
+ }
347
+ throw new ValidationError(fieldName, 'Value is required');
348
+ }
349
+
350
+ // Handle string numbers (e.g., from JSON parsing edge cases)
351
+ let numValue = value;
352
+ if (isString(value)) {
353
+ numValue = parseFloat(value);
354
+ }
355
+
356
+ if (!isNumber(numValue)) {
357
+ throw new ValidationError(fieldName, 'Value must be a number', value);
358
+ }
359
+
360
+ if (numValue < min || numValue > max) {
361
+ throw new ValidationError(
362
+ fieldName,
363
+ `Value must be between ${min} and ${max} (got ${numValue})`
364
+ );
365
+ }
366
+
367
+ return numValue;
368
+ }
369
+
370
+ /**
371
+ * Validate importance field (0-1 scale)
372
+ */
373
+ export function validateImportance(value) {
374
+ return validateNumericRange(
375
+ value,
376
+ 'importance',
377
+ NUMERIC_LIMITS.MIN_IMPORTANCE,
378
+ NUMERIC_LIMITS.MAX_IMPORTANCE,
379
+ 0.7 // Default importance
380
+ );
381
+ }
382
+
383
+ /**
384
+ * Validate emotional intensity field (0-1 scale)
385
+ */
386
+ export function validateEmotionalIntensity(value) {
387
+ return validateNumericRange(
388
+ value,
389
+ 'emotional_intensity',
390
+ NUMERIC_LIMITS.MIN_EMOTIONAL_INTENSITY,
391
+ NUMERIC_LIMITS.MAX_EMOTIONAL_INTENSITY,
392
+ 0.5 // Default emotional intensity
393
+ );
394
+ }
395
+
396
+ /**
397
+ * Validate limit field (query results limit)
398
+ */
399
+ export function validateLimit(value) {
400
+ const limit = validateNumericRange(
401
+ value,
402
+ 'limit',
403
+ NUMERIC_LIMITS.MIN_LIMIT,
404
+ NUMERIC_LIMITS.MAX_LIMIT,
405
+ NUMERIC_LIMITS.DEFAULT_LIMIT
406
+ );
407
+ return Math.floor(limit); // Ensure integer
408
+ }
409
+
410
+ /**
411
+ * Validate timestamp field
412
+ */
413
+ export function validateTimestamp(value, fieldName = 'timestamp') {
414
+ if (value === null || value === undefined) {
415
+ return null; // Timestamps are optional in filters
416
+ }
417
+
418
+ return validateNumericRange(
419
+ value,
420
+ fieldName,
421
+ NUMERIC_LIMITS.MIN_TIMESTAMP,
422
+ NUMERIC_LIMITS.MAX_TIMESTAMP,
423
+ null
424
+ );
425
+ }
426
+
427
+ /**
428
+ * Validate ID field
429
+ */
430
+ export function validateId(value) {
431
+ if (value === null || value === undefined) {
432
+ return null; // IDs are optional in filters
433
+ }
434
+
435
+ const id = validateNumericRange(
436
+ value,
437
+ 'id',
438
+ NUMERIC_LIMITS.MIN_ID,
439
+ NUMERIC_LIMITS.MAX_ID,
440
+ null
441
+ );
442
+ return Math.floor(id); // Ensure integer
443
+ }
444
+
445
+ // ============================================================================
446
+ // METADATA VALIDATORS
447
+ // ============================================================================
448
+
449
+ /**
450
+ * Validate metadata object
451
+ * @param {*} metadata - Metadata to validate
452
+ * @returns {object} - Validated and sanitized metadata
453
+ * @throws {ValidationError}
454
+ */
455
+ export function validateMetadata(metadata) {
456
+ if (metadata === null || metadata === undefined) {
457
+ return {};
458
+ }
459
+
460
+ if (!isPlainObject(metadata)) {
461
+ throw new ValidationError('metadata', 'Metadata must be an object', typeof metadata);
462
+ }
463
+
464
+ // Check total size of metadata JSON
465
+ const metadataJson = JSON.stringify(metadata);
466
+ if (metadataJson.length > CONTENT_LIMITS.MAX_METADATA_SIZE) {
467
+ throw new ValidationError(
468
+ 'metadata',
469
+ `Metadata exceeds maximum size of ${CONTENT_LIMITS.MAX_METADATA_SIZE} bytes (got ${metadataJson.length})`
470
+ );
471
+ }
472
+
473
+ const sanitized = {};
474
+ const relocated = [];
475
+
476
+ for (const [key, value] of Object.entries(metadata)) {
477
+ // Validate field name against whitelist (or allow if it's under 'custom')
478
+ if (!VALID_METADATA_FIELDS.includes(key) && key !== 'custom') {
479
+ // Log warning but don't reject - place unknown fields under 'custom'.
480
+ // The name is recorded so the write receipt can say what moved (A6).
481
+ if (!sanitized.custom) {
482
+ sanitized.custom = {};
483
+ }
484
+ sanitized.custom[key] = value;
485
+ relocated.push(key);
486
+ continue;
487
+ }
488
+
489
+ // Validate specific known fields
490
+ switch (key) {
491
+ case 'importance':
492
+ sanitized.importance = validateImportance(value);
493
+ break;
494
+
495
+ case 'emotional_intensity':
496
+ sanitized.emotional_intensity = validateEmotionalIntensity(value);
497
+ break;
498
+
499
+ case 'context':
500
+ sanitized.context = validateContext(value);
501
+ break;
502
+
503
+ case 'layer':
504
+ sanitized.layer = validateLayer(value, false);
505
+ break;
506
+
507
+ case 'timestamp':
508
+ sanitized.timestamp = validateTimestamp(value);
509
+ break;
510
+
511
+ case 'tags':
512
+ sanitized.tags = validateTags(value);
513
+ break;
514
+
515
+ case 'source':
516
+ sanitized.source = validateStringField(value, 'source');
517
+ break;
518
+
519
+ case 'session_id':
520
+ // A2 (2026-09-07): a bare session_id is not provenance - it names a
521
+ // session without a span, which is the nearest-timestamp guess the
522
+ // provenance rule forbids. Rejected, with the door pointing at the field
523
+ // that carries a checkable pointer.
524
+ throw new ValidationError(
525
+ 'session_id',
526
+ 'metadata.session_id is not accepted: supply derived_from {session_id, exchange_start, exchange_end} ' +
527
+ '(a transcript span) or derived_from {entries: [...]} (a consolidation) instead',
528
+ typeof value
529
+ );
530
+
531
+ case 'category':
532
+ sanitized.category = validateStringField(value, 'category');
533
+ break;
534
+
535
+ case 'priority':
536
+ sanitized.priority = validateNumericRange(value, 'priority', 0, 100, 50);
537
+ break;
538
+
539
+ case 'expires_at': {
540
+ // A2 (2026-09-07): `valid_until` is canonical; `expires_at` is the
541
+ // upstream (CASCADE) spelling and is accepted as an alias of it. Two
542
+ // spellings with two values is a contradiction, not a choice.
543
+ const aliased = validateTimestamp(value, 'expires_at');
544
+ if (sanitized.valid_until !== undefined && sanitized.valid_until !== null &&
545
+ aliased !== null && sanitized.valid_until !== aliased) {
546
+ throw new ValidationError('expires_at', `expires_at (${aliased}) disagrees with valid_until (${sanitized.valid_until}); supply one`, typeof value);
547
+ }
548
+ if (sanitized.valid_until === undefined || sanitized.valid_until === null) sanitized.valid_until = aliased;
549
+ break;
550
+ }
551
+
552
+ case 'related_ids':
553
+ sanitized.related_ids = validateIdArray(value);
554
+ break;
555
+
556
+ // ---- EMET vocabulary (first-class, never relocated) ----
557
+ case 'supersedes':
558
+ sanitized.supersedes = validatePositiveInteger(value, 'supersedes');
559
+ break;
560
+
561
+ case 'corrects':
562
+ sanitized.corrects = validatePositiveInteger(value, 'corrects');
563
+ break;
564
+
565
+ case 'superseded_by':
566
+ sanitized.superseded_by = (value === null || value === undefined)
567
+ ? null
568
+ : validatePositiveInteger(value, 'superseded_by');
569
+ break;
570
+
571
+ case 'superseded_at':
572
+ sanitized.superseded_at = validateTimestamp(value, 'superseded_at');
573
+ break;
574
+
575
+ case 'valid_until': {
576
+ const canonical = validateTimestamp(value, 'valid_until');
577
+ // If expires_at was seen first, valid_until is already set from the alias.
578
+ if (sanitized.valid_until !== undefined && sanitized.valid_until !== null &&
579
+ canonical !== null && sanitized.valid_until !== canonical) {
580
+ throw new ValidationError('valid_until', `valid_until (${canonical}) disagrees with expires_at (${sanitized.valid_until}); supply one`, typeof value);
581
+ }
582
+ sanitized.valid_until = canonical;
583
+ break;
584
+ }
585
+
586
+ case 'valid_from':
587
+ sanitized.valid_from = validateTimestamp(value, 'valid_from');
588
+ break;
589
+
590
+ case 'routing':
591
+ if (value === null || value === undefined) {
592
+ sanitized.routing = null;
593
+ } else if (!isPlainObject(value)) {
594
+ throw new ValidationError('routing', 'Routing must be a plain object', typeof value);
595
+ } else {
596
+ sanitized.routing = value;
597
+ }
598
+ break;
599
+
600
+ case 'source_defaulted':
601
+ case 'source_missing':
602
+ case 'source_unregistered':
603
+ case 'importance_defaulted':
604
+ case 'provenance_missing':
605
+ case 'assertion_origin_missing':
606
+ if (typeof value !== 'boolean') {
607
+ throw new ValidationError(key, 'Value must be a boolean', typeof value);
608
+ }
609
+ sanitized[key] = value;
610
+ break;
611
+
612
+ case 'assertion_origin':
613
+ if (value === null || value === undefined) {
614
+ sanitized.assertion_origin = null;
615
+ } else if (!isString(value) || !ASSERTION_ORIGINS.includes(value)) {
616
+ throw new ValidationError(
617
+ 'assertion_origin',
618
+ `Invalid assertion_origin '${value}'. Must be one of: ${ASSERTION_ORIGINS.join(', ')} (or null)`
619
+ );
620
+ } else {
621
+ sanitized.assertion_origin = value;
622
+ }
623
+ break;
624
+
625
+ case 'custom':
626
+ // Allow custom object but validate its size
627
+ if (isPlainObject(value)) {
628
+ const customJson = JSON.stringify(value);
629
+ if (customJson.length <= CONTENT_LIMITS.MAX_METADATA_SIZE / 2) {
630
+ sanitized.custom = { ...sanitized.custom, ...value };
631
+ } else {
632
+ throw new ValidationError('custom', 'Custom metadata object is too large');
633
+ }
634
+ }
635
+ break;
636
+
637
+ default:
638
+ // Should not reach here due to whitelist check above
639
+ sanitized[key] = value;
640
+ }
641
+ }
642
+
643
+ // Cross-validation: a validity window cannot close before it opens
644
+ if (sanitized.valid_from !== undefined && sanitized.valid_from !== null &&
645
+ sanitized.valid_until !== undefined && sanitized.valid_until !== null) {
646
+ if (sanitized.valid_from > sanitized.valid_until) {
647
+ throw new ValidationError(
648
+ 'valid_from',
649
+ `valid_from (${sanitized.valid_from}) cannot be greater than valid_until (${sanitized.valid_until})`
650
+ );
651
+ }
652
+ }
653
+
654
+ if (relocated.length > 0) {
655
+ RELOCATED_FIELDS.set(sanitized, relocated);
656
+ }
657
+
658
+ return sanitized;
659
+ }
660
+
661
+ /**
662
+ * Names of the fields validateMetadata moved under `custom`, keyed by the
663
+ * object it returned. A WeakMap rather than a property on the result so the
664
+ * return shape is unchanged for every existing caller and nothing extra is
665
+ * ever serialized into the store.
666
+ */
667
+ const RELOCATED_FIELDS = new WeakMap();
668
+
669
+ /**
670
+ * Which top-level metadata fields validateMetadata relocated under `custom`.
671
+ * @param {object} validated - the object validateMetadata returned
672
+ * @returns {string[]} - field names, in input order; empty when nothing moved
673
+ */
674
+ export function relocatedFields(validated) {
675
+ const names = RELOCATED_FIELDS.get(validated);
676
+ return names ? [...names] : [];
677
+ }
678
+
679
+ /**
680
+ * Validate a positive integer id-like field (supersedes, superseded_by)
681
+ * @param {*} value - Value to validate
682
+ * @param {string} fieldName - Field name for error messages
683
+ * @returns {number} - Validated positive integer
684
+ * @throws {ValidationError}
685
+ */
686
+ export function validatePositiveInteger(value, fieldName) {
687
+ if (value === null || value === undefined) {
688
+ throw new ValidationError(fieldName, 'Value is required');
689
+ }
690
+
691
+ let numValue = value;
692
+ if (isString(value) && value.trim() !== '') {
693
+ numValue = Number(value);
694
+ }
695
+
696
+ if (!isNumber(numValue) || !isInteger(numValue) || numValue < NUMERIC_LIMITS.MIN_ID) {
697
+ throw new ValidationError(fieldName, 'Value must be a positive integer', value);
698
+ }
699
+
700
+ return numValue;
701
+ }
702
+
703
+ /**
704
+ * Validate tags array
705
+ * @param {*} tags - Tags to validate
706
+ * @returns {string[]} - Validated tags array
707
+ * @throws {ValidationError}
708
+ */
709
+ export function validateTags(tags) {
710
+ if (tags === null || tags === undefined) {
711
+ return [];
712
+ }
713
+
714
+ if (!isArray(tags)) {
715
+ throw new ValidationError('tags', 'Tags must be an array', typeof tags);
716
+ }
717
+
718
+ if (tags.length > 50) {
719
+ throw new ValidationError('tags', 'Too many tags (max 50)');
720
+ }
721
+
722
+ const validatedTags = [];
723
+ for (let i = 0; i < tags.length; i++) {
724
+ const tag = tags[i];
725
+ if (!isString(tag)) {
726
+ throw new ValidationError(`tags[${i}]`, 'Each tag must be a string', typeof tag);
727
+ }
728
+ const trimmed = tag.trim();
729
+ if (trimmed.length > 100) {
730
+ throw new ValidationError(`tags[${i}]`, 'Tag exceeds maximum length of 100 characters');
731
+ }
732
+ if (trimmed.length > 0) {
733
+ validatedTags.push(trimmed);
734
+ }
735
+ }
736
+
737
+ return validatedTags;
738
+ }
739
+
740
+ /**
741
+ * Validate ID array
742
+ * @param {*} ids - IDs to validate
743
+ * @returns {number[]} - Validated IDs array
744
+ * @throws {ValidationError}
745
+ */
746
+ export function validateIdArray(ids) {
747
+ if (ids === null || ids === undefined) {
748
+ return [];
749
+ }
750
+
751
+ if (!isArray(ids)) {
752
+ throw new ValidationError('related_ids', 'Related IDs must be an array', typeof ids);
753
+ }
754
+
755
+ if (ids.length > 100) {
756
+ throw new ValidationError('related_ids', 'Too many related IDs (max 100)');
757
+ }
758
+
759
+ const validatedIds = [];
760
+ for (let i = 0; i < ids.length; i++) {
761
+ const id = validateNumericRange(
762
+ ids[i],
763
+ `related_ids[${i}]`,
764
+ NUMERIC_LIMITS.MIN_ID,
765
+ NUMERIC_LIMITS.MAX_ID,
766
+ null
767
+ );
768
+ if (id !== null) {
769
+ validatedIds.push(Math.floor(id));
770
+ }
771
+ }
772
+
773
+ return validatedIds;
774
+ }
775
+
776
+ /**
777
+ * Validate generic string field
778
+ * @param {*} value - Value to validate
779
+ * @param {string} fieldName - Field name for error messages
780
+ * @returns {string} - Validated string
781
+ * @throws {ValidationError}
782
+ */
783
+ export function validateStringField(value, fieldName) {
784
+ if (value === null || value === undefined) {
785
+ return '';
786
+ }
787
+
788
+ if (!isString(value)) {
789
+ throw new ValidationError(fieldName, 'Value must be a string', typeof value);
790
+ }
791
+
792
+ if (value.length > CONTENT_LIMITS.MAX_STRING_FIELD_LENGTH) {
793
+ throw new ValidationError(
794
+ fieldName,
795
+ `Value exceeds maximum length of ${CONTENT_LIMITS.MAX_STRING_FIELD_LENGTH} characters`
796
+ );
797
+ }
798
+
799
+ return value;
800
+ }
801
+
802
+ // ============================================================================
803
+ // FILTER VALIDATORS
804
+ // ============================================================================
805
+
806
+ /**
807
+ * The filter keys validateFilters recognises. Everything else in a filters
808
+ * object is discarded silently by design - the validator is a whitelist - so a
809
+ * caller who passes an unrecognised key gets a full UNFILTERED page back and no
810
+ * error, which is indistinguishable from "the filter did not work". That
811
+ * symptom cost a bug hunt on 2026-09-08 (a recorded lesson). The list is exported
812
+ * so callers can NAME the dropped keys instead of swallowing them.
813
+ */
814
+ export const RECOGNISED_FILTER_KEYS = Object.freeze([
815
+ 'importance_min',
816
+ 'importance_max',
817
+ 'importance_bucket',
818
+ 'emotional_intensity_min',
819
+ 'emotional_intensity_max',
820
+ 'timestamp_after',
821
+ 'timestamp_before',
822
+ 'content_contains',
823
+ 'context_contains',
824
+ 'id'
825
+ ]);
826
+
827
+ /**
828
+ * Return the keys of a filters object that validateFilters will discard.
829
+ * Non-objects yield an empty list; shape errors are validateFilters's to throw.
830
+ * @param {*} filters
831
+ * @returns {string[]}
832
+ */
833
+ export function unrecognisedFilterKeys(filters) {
834
+ if (!isPlainObject(filters)) {
835
+ return [];
836
+ }
837
+ return Object.keys(filters).filter((key) => !RECOGNISED_FILTER_KEYS.includes(key));
838
+ }
839
+
840
+ /**
841
+ * Validate query filters object
842
+ * @param {*} filters - Filters to validate
843
+ * @returns {object} - Validated filters
844
+ * @throws {ValidationError}
845
+ */
846
+ export function validateFilters(filters) {
847
+ if (filters === null || filters === undefined) {
848
+ return {};
849
+ }
850
+
851
+ if (!isPlainObject(filters)) {
852
+ throw new ValidationError('filters', 'Filters must be an object', typeof filters);
853
+ }
854
+
855
+ const validated = {};
856
+
857
+ // Importance filters
858
+ if ('importance_min' in filters) {
859
+ validated.importance_min = validateNumericRange(
860
+ filters.importance_min,
861
+ 'filters.importance_min',
862
+ NUMERIC_LIMITS.MIN_IMPORTANCE,
863
+ NUMERIC_LIMITS.MAX_IMPORTANCE,
864
+ null
865
+ );
866
+ }
867
+
868
+ if ('importance_max' in filters) {
869
+ validated.importance_max = validateNumericRange(
870
+ filters.importance_max,
871
+ 'filters.importance_max',
872
+ NUMERIC_LIMITS.MIN_IMPORTANCE,
873
+ NUMERIC_LIMITS.MAX_IMPORTANCE,
874
+ null
875
+ );
876
+ }
877
+
878
+ if ('importance_bucket' in filters) {
879
+ const name = String(filters.importance_bucket).trim().toLowerCase();
880
+ const spec = IMPORTANCE_BUCKETS[name];
881
+ if (!spec) {
882
+ throw new ValidationError(
883
+ 'filters.importance_bucket',
884
+ `Unknown bucket '${filters.importance_bucket}'. Use constitutive, durable, binding, event, or scratch.`
885
+ );
886
+ }
887
+ validated.importance_bucket = name;
888
+ if (validated.importance_min === undefined) validated.importance_min = spec.range[0];
889
+ if (validated.importance_max === undefined) validated.importance_max = spec.range[1];
890
+ }
891
+
892
+ // Emotional intensity filters
893
+ if ('emotional_intensity_min' in filters) {
894
+ validated.emotional_intensity_min = validateNumericRange(
895
+ filters.emotional_intensity_min,
896
+ 'filters.emotional_intensity_min',
897
+ NUMERIC_LIMITS.MIN_EMOTIONAL_INTENSITY,
898
+ NUMERIC_LIMITS.MAX_EMOTIONAL_INTENSITY,
899
+ null
900
+ );
901
+ }
902
+
903
+ if ('emotional_intensity_max' in filters) {
904
+ validated.emotional_intensity_max = validateNumericRange(
905
+ filters.emotional_intensity_max,
906
+ 'filters.emotional_intensity_max',
907
+ NUMERIC_LIMITS.MIN_EMOTIONAL_INTENSITY,
908
+ NUMERIC_LIMITS.MAX_EMOTIONAL_INTENSITY,
909
+ null
910
+ );
911
+ }
912
+
913
+ // Timestamp filters
914
+ if ('timestamp_after' in filters) {
915
+ validated.timestamp_after = validateTimestamp(filters.timestamp_after, 'filters.timestamp_after');
916
+ }
917
+
918
+ if ('timestamp_before' in filters) {
919
+ validated.timestamp_before = validateTimestamp(filters.timestamp_before, 'filters.timestamp_before');
920
+ }
921
+
922
+ // Content search filters
923
+ if ('content_contains' in filters) {
924
+ validated.content_contains = validateStringField(filters.content_contains, 'filters.content_contains');
925
+ if (validated.content_contains.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
926
+ throw new ValidationError(
927
+ 'filters.content_contains',
928
+ `Search term exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH}`
929
+ );
930
+ }
931
+ }
932
+
933
+ if ('context_contains' in filters) {
934
+ validated.context_contains = validateStringField(filters.context_contains, 'filters.context_contains');
935
+ if (validated.context_contains.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
936
+ throw new ValidationError(
937
+ 'filters.context_contains',
938
+ `Search term exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH}`
939
+ );
940
+ }
941
+ }
942
+
943
+ // ID filter
944
+ if ('id' in filters) {
945
+ validated.id = validateId(filters.id);
946
+ }
947
+
948
+ // Cross-validation: min should not exceed max
949
+ if (validated.importance_min !== undefined && validated.importance_max !== undefined) {
950
+ if (validated.importance_min > validated.importance_max) {
951
+ throw new ValidationError(
952
+ 'filters',
953
+ 'importance_min cannot be greater than importance_max'
954
+ );
955
+ }
956
+ }
957
+
958
+ if (validated.emotional_intensity_min !== undefined && validated.emotional_intensity_max !== undefined) {
959
+ if (validated.emotional_intensity_min > validated.emotional_intensity_max) {
960
+ throw new ValidationError(
961
+ 'filters',
962
+ 'emotional_intensity_min cannot be greater than emotional_intensity_max'
963
+ );
964
+ }
965
+ }
966
+
967
+ if (validated.timestamp_after !== undefined && validated.timestamp_before !== undefined) {
968
+ if (validated.timestamp_after > validated.timestamp_before) {
969
+ throw new ValidationError(
970
+ 'filters',
971
+ 'timestamp_after cannot be greater than timestamp_before'
972
+ );
973
+ }
974
+ }
975
+
976
+ return validated;
977
+ }
978
+
979
+ // ============================================================================
980
+ // OPTIONS VALIDATORS
981
+ // ============================================================================
982
+
983
+ /**
984
+ * Validate query_layer options
985
+ * @param {*} options - Options to validate
986
+ * @returns {object} - Validated options
987
+ * @throws {ValidationError}
988
+ */
989
+ export function validateQueryOptions(options) {
990
+ if (options === null || options === undefined) {
991
+ return { limit: NUMERIC_LIMITS.DEFAULT_LIMIT };
992
+ }
993
+
994
+ if (!isPlainObject(options)) {
995
+ throw new ValidationError('options', 'Options must be an object', typeof options);
996
+ }
997
+
998
+ const validated = {};
999
+
1000
+ // Validate limit
1001
+ if ('limit' in options) {
1002
+ validated.limit = validateLimit(options.limit);
1003
+ } else {
1004
+ validated.limit = NUMERIC_LIMITS.DEFAULT_LIMIT;
1005
+ }
1006
+
1007
+ // Validate order_by (already sanitized in main code, but validate format here)
1008
+ if ('order_by' in options) {
1009
+ if (!isString(options.order_by)) {
1010
+ throw new ValidationError('options.order_by', 'order_by must be a string');
1011
+ }
1012
+ validated.order_by = options.order_by.trim();
1013
+ }
1014
+
1015
+ // Validate filters
1016
+ if ('filters' in options) {
1017
+ validated.filters = validateFilters(options.filters);
1018
+ // validateFilters is a whitelist: unrecognised keys vanish without error and
1019
+ // the query then runs unfiltered. Carry the dropped names up so the caller
1020
+ // can report them, mirroring the deprecated-'where' flag below.
1021
+ const droppedFilterKeys = unrecognisedFilterKeys(options.filters);
1022
+ if (droppedFilterKeys.length > 0) {
1023
+ validated._unknown_filter_keys = droppedFilterKeys;
1024
+ }
1025
+ }
1026
+
1027
+ // Legacy params support (deprecated but validated)
1028
+ if ('params' in options) {
1029
+ if (isArray(options.params)) {
1030
+ validated.params = options.params.slice(0, 10); // Limit params count
1031
+ }
1032
+ }
1033
+
1034
+ // Ignore deprecated 'where' field - log warning handled in main code
1035
+ if ('where' in options) {
1036
+ validated._deprecated_where_used = true;
1037
+ }
1038
+
1039
+ return validated;
1040
+ }
1041
+
1042
+ // ============================================================================
1043
+ // COMPLETE INPUT VALIDATORS (for tool handlers)
1044
+ // ============================================================================
1045
+
1046
+ /**
1047
+ * Validate save_to_layer input (the former 'remember' wrapper used this too)
1048
+ */
1049
+ export function validateRememberInput(args) {
1050
+ if (!isPlainObject(args)) {
1051
+ throw new ValidationError('args', 'Arguments must be an object');
1052
+ }
1053
+
1054
+ return {
1055
+ content: validateContent(args.content),
1056
+ layer: validateLayer(args.layer, false),
1057
+ metadata: validateMetadata(args.metadata)
1058
+ };
1059
+ }
1060
+
1061
+ /**
1062
+ * Validate 'recall' tool input
1063
+ */
1064
+ export function validateRecallInput(args) {
1065
+ if (!isPlainObject(args)) {
1066
+ throw new ValidationError('args', 'Arguments must be an object');
1067
+ }
1068
+
1069
+ return {
1070
+ query: validateQuery(args.query),
1071
+ layer: validateLayer(args.layer, false),
1072
+ limit: validateLimit(args.limit)
1073
+ };
1074
+ }
1075
+
1076
+ /**
1077
+ * Validate 'query_layer' tool input
1078
+ */
1079
+ export function validateQueryLayerInput(args) {
1080
+ if (!isPlainObject(args)) {
1081
+ throw new ValidationError('args', 'Arguments must be an object');
1082
+ }
1083
+
1084
+ return {
1085
+ layer: validateLayer(args.layer, true), // Layer is required for query_layer
1086
+ options: validateQueryOptions(args.options)
1087
+ };
1088
+ }
1089
+
1090
+ /**
1091
+ * Validate 'save_to_layer' tool input
1092
+ */
1093
+ export function validateSaveToLayerInput(args) {
1094
+ if (!isPlainObject(args)) {
1095
+ throw new ValidationError('args', 'Arguments must be an object');
1096
+ }
1097
+
1098
+ return {
1099
+ layer: validateLayer(args.layer, true), // Layer is required
1100
+ content: validateContent(args.content),
1101
+ metadata: validateMetadata(args.metadata)
1102
+ };
1103
+ }
1104
+
1105
+ // ============================================================================
1106
+ // EXPORT DEFAULT VALIDATOR OBJECT
1107
+ // ============================================================================
1108
+
1109
+ export default {
1110
+ // Constants
1111
+ CONTENT_LIMITS,
1112
+ NUMERIC_LIMITS,
1113
+ VALID_LAYERS,
1114
+ VALID_METADATA_FIELDS,
1115
+
1116
+ // Error class
1117
+ ValidationError,
1118
+
1119
+ // Type checkers
1120
+ isString,
1121
+ isNumber,
1122
+ isInteger,
1123
+ isPlainObject,
1124
+ isArray,
1125
+
1126
+ // Content validators
1127
+ validateContent,
1128
+ validateQuery,
1129
+ validateContext,
1130
+
1131
+ // Layer validators
1132
+ validateLayer,
1133
+
1134
+ // Numeric validators
1135
+ validateNumericRange,
1136
+ validateImportance,
1137
+ validateEmotionalIntensity,
1138
+ validateLimit,
1139
+ validateTimestamp,
1140
+ validateId,
1141
+
1142
+ // Metadata validators
1143
+ validateMetadata,
1144
+ relocatedFields,
1145
+ validatePositiveInteger,
1146
+ validateTags,
1147
+ validateIdArray,
1148
+ validateStringField,
1149
+ ASSERTION_ORIGINS,
1150
+ MARKER_FIELDS,
1151
+
1152
+ // Filter/Options validators
1153
+ validateFilters,
1154
+ RECOGNISED_FILTER_KEYS,
1155
+ unrecognisedFilterKeys,
1156
+ validateQueryOptions,
1157
+
1158
+ // Complete input validators
1159
+ validateRememberInput,
1160
+ validateRecallInput,
1161
+ validateQueryLayerInput,
1162
+ validateSaveToLayerInput
1163
+ };