@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
package/server.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.dreamforgestudiollc/emet",
4
+ "title": "EMET",
5
+ "description": "Persistent shared memory for AI agents, with verified writes and a transcript tier.",
6
+ "version": "0.2.4",
7
+ "websiteUrl": "https://dreamforgestudiollc.github.io/EMET/",
8
+ "repository": {
9
+ "url": "https://github.com/dreamforgestudiollc/EMET",
10
+ "source": "github",
11
+ "id": "1402031262"
12
+ },
13
+ "icons": [
14
+ {
15
+ "src": "https://dreamforgestudiollc.github.io/EMET/assets/icon-48.png",
16
+ "mimeType": "image/png",
17
+ "sizes": ["48x48"]
18
+ }
19
+ ],
20
+ "packages": [
21
+ {
22
+ "registryType": "npm",
23
+ "registryBaseUrl": "https://registry.npmjs.org",
24
+ "identifier": "@dreamforgestudiollc/emet",
25
+ "version": "0.2.4",
26
+ "transport": {
27
+ "type": "stdio"
28
+ },
29
+ "environmentVariables": [
30
+ {
31
+ "name": "MONGODB_URI",
32
+ "description": "MongoDB connection string for the memory store.",
33
+ "isRequired": true,
34
+ "isSecret": true
35
+ }
36
+ ]
37
+ }
38
+ ]
39
+ }
package/src/aleph.js ADDED
@@ -0,0 +1,395 @@
1
+ /**
2
+ * EMET - Enduring Memory & Epistemic Tiers
3
+ * Copyright (c) 2026 Dreamforge Studio LLC
4
+ *
5
+ * Licensed under the MIT License - see LICENSE
6
+ *
7
+ * aleph - the read-back verification layer.
8
+ *
9
+ * EMET is אמת, Hebrew for truth. In the golem legend the word is inscribed to
10
+ * animate the body, and erasing its first letter leaves מת - met, dead. One
11
+ * character is the whole difference.
12
+ *
13
+ * That letter is aleph, and this is the layer named for it. Every write is read
14
+ * back and compared before it is reported as saved. A memory system without that
15
+ * step still returns answers, still reports healthy, and is quietly a corpse:
16
+ * confidently serving things it never stored. Aleph is also the FIRST letter,
17
+ * which matches the order the protocol insists on - read back before trusting,
18
+ * verify before retrying, verify the premise before applying the remedy.
19
+ *
20
+ * The rule this enforces: **a write is not done because the call returned.**
21
+ * A driver reports what it sent, not what the store kept. The two disagree on
22
+ * a failed upsert, a wrong-collection write, a silently rejected field, and a
23
+ * duplicate-id collision - and every one of those is invisible to the caller
24
+ * unless someone goes and looks.
25
+ *
26
+ * A verification failure raises AlephError rather than a database error, because
27
+ * the two mean different things and lead to different actions. A DatabaseError
28
+ * means the store could not be reached and the write probably did not happen. An
29
+ * AlephError means the call SUCCEEDED and the store does not contain what it
30
+ * should - the more serious of the two, and the one that must never be retried
31
+ * blindly.
32
+ */
33
+
34
+ import crypto from 'crypto';
35
+ import { EmetError, ErrorCodes, StatusCodes } from './database.js';
36
+
37
+ /** sha256 of a string, hex. The canonical digest for everything EMET stores. */
38
+ export function digest(text) {
39
+ return crypto.createHash('sha256').update(String(text), 'utf8').digest('hex');
40
+ }
41
+
42
+ export class AlephError extends EmetError {
43
+ constructor(message, operation = 'unknown', details = {}) {
44
+ super(message, ErrorCodes.WRITE_ERROR, StatusCodes.INTERNAL_ERROR, details);
45
+ this.name = 'AlephError';
46
+ this.operation = operation;
47
+ this.verified = false;
48
+ }
49
+
50
+ toSafeJSON() {
51
+ const base = super.toSafeJSON();
52
+ base.error.operation = this.operation;
53
+ base.error.verified = false;
54
+ return base;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Verify a document write.
60
+ *
61
+ * Compares the digest AND the version. Digest alone would pass if a concurrent
62
+ * writer had stored identical content under a different version, which is not
63
+ * the same document state and must not be reported as this write succeeding.
64
+ *
65
+ * @param {object} readBack the document as read back from the store
66
+ * @param {object} expected { doc_id, sha256, version }
67
+ * @returns {object} a receipt carrying verified: true
68
+ */
69
+ export function verifyDocument(readBack, expected) {
70
+ const { doc_id, sha256, version } = expected;
71
+
72
+ if (!readBack) {
73
+ throw new AlephError(
74
+ `Read-back found no document at ${doc_id} immediately after writing it. The write reported ` +
75
+ `success and the store does not contain it.`,
76
+ 'aleph.verifyDocument',
77
+ { doc_id }
78
+ );
79
+ }
80
+ if (readBack.sha256 !== sha256) {
81
+ throw new AlephError(
82
+ `Read-back of ${doc_id} does not match what was written: content digest differs. Do not ` +
83
+ `retry until the stored content has been inspected - a retry would overwrite whatever is ` +
84
+ `actually there.`,
85
+ 'aleph.verifyDocument',
86
+ { doc_id, expected_sha256: sha256, stored_sha256: readBack.sha256 }
87
+ );
88
+ }
89
+ if (version !== undefined && readBack.version !== version) {
90
+ throw new AlephError(
91
+ `Read-back of ${doc_id} is at version ${readBack.version}, not the ${version} this write ` +
92
+ `created. Another writer reached the document between the write and the read.`,
93
+ 'aleph.verifyDocument',
94
+ { doc_id, expected_version: version, stored_version: readBack.version }
95
+ );
96
+ }
97
+ return { verified: true, doc_id, sha256, version };
98
+ }
99
+
100
+ /**
101
+ * Verify a layer-entry write.
102
+ *
103
+ * The id matters as much as the content here. Ids are allocated from a counters
104
+ * collection, and a misconfigured prefix points that counter at an empty
105
+ * collection - so ids restart at 1 and collide with entries that already exist.
106
+ * The failure looks like a successful save and reads back as somebody else's
107
+ * memory, which is why this compares the CONTENT at the id rather than merely
108
+ * confirming that a record with that id exists.
109
+ *
110
+ * @param {object} readBack the row as read back from the store
111
+ * @param {object} expected { id, content, layer }
112
+ */
113
+ export function verifyLayerEntry(readBack, expected) {
114
+ const { id, content, layer } = expected;
115
+
116
+ if (!readBack) {
117
+ throw new AlephError(
118
+ `Read-back found no ${layer} entry at id ${id} immediately after writing it. The save ` +
119
+ `reported success and the store does not contain it.`,
120
+ 'aleph.verifyLayerEntry',
121
+ { layer, id }
122
+ );
123
+ }
124
+ const wrote = digest(content);
125
+ const stored = digest(readBack.content !== undefined && readBack.content !== null
126
+ ? readBack.content
127
+ : readBack.event);
128
+
129
+ // If the row carries its own sha256 column (written from 2026-09-05),
130
+ // it must agree with the content it sits beside. Rows written before the column
131
+ // existed carry none and are judged on content alone - absence is not a failure,
132
+ // but a PRESENT digest that disagrees is: the row is internally inconsistent.
133
+ const storedColumn = readBack.sha256 !== undefined && readBack.sha256 !== null ? String(readBack.sha256) : null;
134
+ if (storedColumn !== null && storedColumn !== stored) {
135
+ throw new AlephError(
136
+ `Read-back of ${layer} entry ${id} carries a sha256 column that does not match its own content. ` +
137
+ `The row is internally inconsistent - either the content or the digest was altered after the write.`,
138
+ 'aleph.verifyLayerEntry',
139
+ { layer, id, column_sha256: storedColumn, content_sha256: stored }
140
+ );
141
+ }
142
+
143
+ if (stored !== wrote) {
144
+ throw new AlephError(
145
+ `Read-back of ${layer} entry ${id} returned different content than was written. The most ` +
146
+ `likely cause is an id collision: ids come from a counters collection, and a counter read ` +
147
+ `from the wrong collection restarts at 1 and overwrites existing entries. Check the ` +
148
+ `collection prefix before writing anything else to this store.`,
149
+ 'aleph.verifyLayerEntry',
150
+ { layer, id, expected_sha256: wrote, stored_sha256: stored }
151
+ );
152
+ }
153
+ // C1 (2026-09-07): the metadata column, when present, must agree with the
154
+ // metadata it sits beside, and with what the caller says it wrote. Same
155
+ // rule as the content column: absence is a dated boundary, disagreement is
156
+ // an inconsistency. When the caller supplies `expected.metadata`, the stored
157
+ // metadata must also digest to the same value - a row whose metadata was
158
+ // rewritten between the insert and the read-back is not the row that was
159
+ // attested.
160
+ let metadataDigestStored = false;
161
+ let metadataSha256 = null;
162
+ const metaColumn = readBack.metadata_sha256 !== undefined && readBack.metadata_sha256 !== null
163
+ ? String(readBack.metadata_sha256) : null;
164
+ if (metaColumn !== null) {
165
+ const storedMeta = digestMetadata(readBack.metadata);
166
+ if (storedMeta !== metaColumn) {
167
+ throw new AlephError(
168
+ `Read-back of ${layer} entry ${id} carries a metadata_sha256 column that does not match its own metadata. ` +
169
+ `The row is internally inconsistent - either the metadata or its digest was altered after the write.`,
170
+ 'aleph.verifyLayerEntry',
171
+ { layer, id, column_metadata_sha256: metaColumn, metadata_sha256: storedMeta }
172
+ );
173
+ }
174
+ metadataDigestStored = true;
175
+ metadataSha256 = metaColumn;
176
+ }
177
+ if (expected.metadata !== undefined) {
178
+ const wroteMeta = digestMetadata(expected.metadata);
179
+ const storedMeta = digestMetadata(readBack.metadata);
180
+ if (storedMeta !== wroteMeta) {
181
+ throw new AlephError(
182
+ `Read-back of ${layer} entry ${id} returned different metadata than was written.`,
183
+ 'aleph.verifyLayerEntry',
184
+ { layer, id, expected_metadata_sha256: wroteMeta, stored_metadata_sha256: storedMeta }
185
+ );
186
+ }
187
+ metadataSha256 = wroteMeta;
188
+ }
189
+
190
+ return {
191
+ verified: true, layer, id, sha256: wrote, digest_stored: storedColumn !== null,
192
+ metadata_sha256: metadataSha256, metadata_digest_stored: metadataDigestStored
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Canonical JSON: keys sorted at every depth, no whitespace, so the same
198
+ * metadata produces the same bytes whatever order it was assembled in or
199
+ * returned in. undefined values and functions are dropped as JSON.stringify
200
+ * would drop them; a legacy metadata STRING (written before 2026-09-05) is parsed first so
201
+ * both stored shapes digest identically.
202
+ */
203
+ export function canonicalJson(value) {
204
+ const norm = (v) => {
205
+ if (v === null || typeof v !== 'object') return v;
206
+ if (Array.isArray(v)) return v.map(norm);
207
+ if (v instanceof Date) return v.toISOString();
208
+ const out = {};
209
+ for (const k of Object.keys(v).sort()) {
210
+ if (v[k] === undefined || typeof v[k] === 'function') continue;
211
+ out[k] = norm(v[k]);
212
+ }
213
+ return out;
214
+ };
215
+ return JSON.stringify(norm(value));
216
+ }
217
+
218
+ /** sha256 of the canonical JSON of a metadata object (C1, 2026-09-07). */
219
+ export function digestMetadata(metadata) {
220
+ let md = metadata;
221
+ if (typeof md === 'string') { try { md = JSON.parse(md); } catch { /* digest the string as given */ } }
222
+ if (md === null || md === undefined) md = {};
223
+ return digest(canonicalJson(md));
224
+ }
225
+
226
+ /**
227
+ * Metadata integrity of a stored row at READ time (C1). Same three values as
228
+ * integrityOf: true / false / 'unattested' (no metadata_sha256 column).
229
+ */
230
+ export function metadataIntegrityOf(row) {
231
+ if (!row || typeof row !== 'object') return 'unattested';
232
+ const stored = row.metadata_sha256;
233
+ if (typeof stored !== 'string' || stored.length === 0) return 'unattested';
234
+ return digestMetadata(row.metadata) === stored;
235
+ }
236
+
237
+ /**
238
+ * Integrity of a stored layer row at READ time (work list E2, 2026-09-07).
239
+ *
240
+ * verifyLayerEntry above runs once, at the write. The sha256 column it stores
241
+ * makes a second check possible every time the row is read, so a
242
+ * row altered after its write - by a migration, a hand edit, a bug - is
243
+ * flagged where it is used rather than discovered by accident. One hash per
244
+ * row; the cost is small and the alternative is trusting a receipt from the
245
+ * past.
246
+ *
247
+ * Returns:
248
+ * true - the content's digest equals the stored column
249
+ * false - the column exists and DISAGREES: the row is not what was
250
+ * attested at write. The row is still returned; the caller
251
+ * decides. Never silently dropped.
252
+ * 'unattested' - no sha256 column: written before 2026-09-05 (#12). Not a
253
+ * defect, a dated boundary; countable.
254
+ *
255
+ * The content column is `content`; older rows exposed it as `event` as well
256
+ * (both were written with the same value). Either is accepted, `content` first.
257
+ */
258
+ export function integrityOf(row) {
259
+ if (!row || typeof row !== 'object') return 'unattested';
260
+ const stored = row.sha256;
261
+ if (typeof stored !== 'string' || stored.length === 0) return 'unattested';
262
+ const text = typeof row.content === 'string' ? row.content
263
+ : typeof row.event === 'string' ? row.event : null;
264
+ if (text === null) return false;
265
+ return digest(text) === stored;
266
+ }
267
+
268
+ /** Per-layer hash chain. Forward-only: rows without chain_sha256 are unlinked, not broken. */
269
+ export function chainDigest(prevSha256, contentSha256) {
270
+ return digest(`${prevSha256 || ''}\n${contentSha256}`);
271
+ }
272
+
273
+ export function chainIntegrityOf(row) {
274
+ if (!row || typeof row !== 'object') return 'unlinked';
275
+ if (typeof row.chain_sha256 !== 'string' || !row.chain_sha256) return 'unlinked';
276
+ if (typeof row.sha256 !== 'string' || !row.sha256) return false;
277
+ return row.chain_sha256 === chainDigest(row.prev_sha256 || '', row.sha256);
278
+ }
279
+
280
+ /**
281
+ * Walk one layer's chain in id order (2026-09-29). chainIntegrityOf checks a
282
+ * row against ITSELF, so a row removed from the middle goes unseen while its
283
+ * neighbours stay self-consistent. At write time a row records as prev_sha256
284
+ * the sha256 of the highest-id row that carried one, so in id order every
285
+ * linked row's prev_sha256 must equal the sha256 of the nearest earlier row
286
+ * that has a sha256. When it does not, a row between them was removed, or two
287
+ * writes read the same head (a fork) - either way the record is no longer one
288
+ * line, and it is reported, never repaired.
289
+ *
290
+ * `rows` need {id, sha256, prev_sha256, chain_sha256}; order does not matter.
291
+ * `seedSha256`: the sha256 of the row just before the first one passed, when
292
+ * the walk covers only part of a layer.
293
+ * Returns counts, the live head, and up to `sample` ids per finding.
294
+ * Used by emet_gaps (every host) and scripts/chain-verify.mjs (the outside copy).
295
+ */
296
+ export function walkChain(rows, { seedSha256 = null, sample = 20 } = {}) {
297
+ const sorted = [...(Array.isArray(rows) ? rows : [])].filter((r) => r && r.id !== undefined).sort((a, b) => a.id - b.id);
298
+ const out = { total: sorted.length, linked: 0, unlinked: 0, broken: 0, discontinuities: 0, broken_ids: [], discontinuity_ids: [], head: null };
299
+ let prevSha = seedSha256 || null;
300
+ for (const row of sorted) {
301
+ const v = chainIntegrityOf(row);
302
+ if (v === 'unlinked') out.unlinked++;
303
+ else if (v === false) { out.broken++; if (out.broken_ids.length < sample) out.broken_ids.push(row.id); }
304
+ else {
305
+ out.linked++;
306
+ out.head = { id: row.id, chain_sha256: row.chain_sha256, sha256: row.sha256, timestamp: row.timestamp ?? null };
307
+ if (prevSha !== null && (row.prev_sha256 || null) !== prevSha) {
308
+ out.discontinuities++;
309
+ if (out.discontinuity_ids.length < sample) out.discontinuity_ids.push(row.id);
310
+ }
311
+ }
312
+ if (typeof row.sha256 === 'string' && row.sha256) prevSha = row.sha256;
313
+ }
314
+ return out;
315
+ }
316
+
317
+ /** Payload the service signs: the two digests, not the raw text. */
318
+ export function signaturePayload(contentSha256, metadataSha256) {
319
+ return `${contentSha256}\n${metadataSha256 || ''}`;
320
+ }
321
+
322
+ /** HMAC-SHA256 of the two digests. Key never stored in the database. */
323
+ export function signDigests(contentSha256, metadataSha256, key) {
324
+ if (!key) return null;
325
+ return crypto.createHmac('sha256', String(key))
326
+ .update(signaturePayload(contentSha256, metadataSha256), 'utf8')
327
+ .digest('hex');
328
+ }
329
+
330
+ /**
331
+ * true - signature present and matches the stored digests under this key
332
+ * false - signature present and does not match (row was altered after sign, or wrong key)
333
+ * 'unsigned' - no signature column (written before this control)
334
+ * 'no_key' - service has no signing key configured; cannot judge
335
+ */
336
+ export function signatureIntegrityOf(row, key) {
337
+ if (!row || typeof row !== 'object') return 'unsigned';
338
+ const sig = row.signature;
339
+ if (typeof sig !== 'string' || sig.length === 0) return 'unsigned';
340
+ if (!key) return 'no_key';
341
+ const expected = signDigests(row.sha256, row.metadata_sha256, key);
342
+ return expected === sig;
343
+ }
344
+
345
+ /**
346
+ * May a row be RE-SIGNED after one of the sanctioned metadata marks? (2026-10-02,
347
+ * the reviewer's finding: the marks re-attested metadata_sha256 but left the
348
+ * signature over the old metadata digest, so every superseded or corrected
349
+ * row read signed:false.)
350
+ *
351
+ * Re-signing is never blind. It is allowed only when the row's EXISTING
352
+ * signature verifies, under this key, against the row's content digest and
353
+ * the metadata as it stood before the sanctioned marks, i.e. the stored
354
+ * metadata with only the back-pointers the server itself manages rolled back:
355
+ * - superseded_by / superseded_at: as stored, or absent;
356
+ * - corrected_by: as stored, or any shorter prefix of it, or absent
357
+ * ($addToSet appends, so every earlier list is a prefix of the current one).
358
+ * Anything else (no key, no signature, a signature from another key, or any
359
+ * other metadata field changed since it was signed) returns false, and the
360
+ * caller leaves the signature alone, so the row keeps reading signed:false
361
+ * and the change stays visible.
362
+ */
363
+ export const SANCTIONED_MARK_FIELDS = ['superseded_by', 'superseded_at', 'corrected_by'];
364
+ export function signatureCoversPriorMetadata(row, key) {
365
+ if (!key || !row || typeof row !== 'object') return false;
366
+ const sig = row.signature;
367
+ if (typeof sig !== 'string' || sig.length === 0) return false;
368
+ if (typeof row.sha256 !== 'string' || row.sha256.length === 0) return false;
369
+ let md = row.metadata;
370
+ if (typeof md === 'string') { try { md = JSON.parse(md); } catch { md = null; } }
371
+ const ok = (candidate) => signDigests(row.sha256, digestMetadata(candidate), key) === sig;
372
+ // The signature already covers the stored metadata (nothing to roll back).
373
+ if (typeof row.metadata_sha256 === 'string' && signDigests(row.sha256, row.metadata_sha256, key) === sig
374
+ && metadataIntegrityOf(row) === true) return true;
375
+ if (!md || typeof md !== 'object' || Array.isArray(md)) return false;
376
+ const supersededVariants = [md];
377
+ if ('superseded_by' in md || 'superseded_at' in md) {
378
+ const { superseded_by, superseded_at, ...rest } = md;
379
+ supersededVariants.push(rest);
380
+ }
381
+ for (const base of supersededVariants) {
382
+ if (ok(base)) return true;
383
+ if (Array.isArray(base.corrected_by)) {
384
+ const list = base.corrected_by;
385
+ for (let k = list.length - 1; k >= 0; k--) {
386
+ if (ok({ ...base, corrected_by: list.slice(0, k) })) return true;
387
+ }
388
+ const { corrected_by, ...withoutCorrected } = base;
389
+ if (ok(withoutCorrected)) return true;
390
+ }
391
+ }
392
+ return false;
393
+ }
394
+
395
+ export default { digest, verifyDocument, verifyLayerEntry, integrityOf, canonicalJson, digestMetadata, metadataIntegrityOf, signDigests, signaturePayload, signatureIntegrityOf, signatureCoversPriorMetadata, SANCTIONED_MARK_FIELDS, AlephError };
@@ -0,0 +1,135 @@
1
+ /**
2
+ * EMET - Enduring Memory & Epistemic Tiers
3
+ * Copyright (c) 2026 Dreamforge Studio LLC
4
+ *
5
+ * Licensed under the MIT License - see LICENSE
6
+ *
7
+ * Tool annotations.
8
+ *
9
+ * MCP lets a server declare, per tool, what kind of operation it is. Hosts use
10
+ * these hints to decide what to approve automatically and what to put in front
11
+ * of a person. A server that declares nothing is treated as if every call might
12
+ * do anything, so hosts that ask before acting ask before EVERY call - including
13
+ * the reads that make up most of a session.
14
+ *
15
+ * These are hints, not enforcement. A host is free to ignore them, and a host
16
+ * that trusts them is trusting the server author. They are declared here, in one
17
+ * table rather than scattered through the tool definitions, because the honest
18
+ * version of this file is also its documentation: this is the complete statement
19
+ * of what EMET does to a store, and anyone deciding how much to auto-approve
20
+ * should be able to read it in one screen.
21
+ *
22
+ * The four hints, as the specification defines them:
23
+ * readOnlyHint - the tool does not modify anything.
24
+ * destructiveHint - the tool may replace or remove existing data (only
25
+ * meaningful when readOnlyHint is false).
26
+ * idempotentHint - calling it again with the same arguments changes nothing
27
+ * further.
28
+ * openWorldHint - the tool reaches systems outside this server's own store.
29
+ *
30
+ * openWorldHint is false everywhere. The tools talk to this server's own
31
+ * database and nothing else: no filesystem and no shell. The sign-in server
32
+ * fetches a client's metadata document, and only from a public address it has
33
+ * already checked. That is worth stating explicitly, because it is the main
34
+ * reason a memory server can be given standing permission when a shell server
35
+ * should not.
36
+ *
37
+ * WHY SO FEW TOOLS ARE MARKED DESTRUCTIVE: EMET's write model is additive by
38
+ * design. Layer entries are superseded rather than edited (supersede_never_mutate),
39
+ * and every document write archives the previous version to the history
40
+ * collection before replacing it. The replacing writes carry the flag, and so
41
+ * do the two doors that hand a new person access to the store: a recoverable
42
+ * overwrite is still an overwrite, and a code that grants access is not a
43
+ * harmless add.
44
+ *
45
+ * NOTHING IN THIS TABLE DELETES ANYTHING, and that is a design guarantee rather
46
+ * than an accident of the current tool set. Layer entries are superseded,
47
+ * documents are retired, transcripts are revised - every correction adds a
48
+ * record and marks the prior one obsolete. A host can read this table as the
49
+ * complete statement that EMET does not remove data.
50
+ */
51
+
52
+ const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
53
+ const ADD = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
54
+ const REPLACE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
55
+
56
+ export const TOOL_ANNOTATIONS = Object.freeze({
57
+ // --- Reads. Nothing in this group can change the store. -------------------
58
+ emet_initialize: { title: 'Start session', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
59
+ recall: { title: 'Search memory', ...READ },
60
+ semantic_recall: { title: 'Search memory by meaning', ...READ },
61
+ corpus_recall: { title: 'Search reference corpus', ...READ },
62
+ query_layer: { title: 'Query a memory layer', ...READ },
63
+ query_transcripts: { title: 'Read transcripts', ...READ },
64
+ read_doc: { title: 'Read a document', ...READ },
65
+ read_doc_history: { title: 'Read a retained document revision', ...READ },
66
+ list_docs: { title: 'List documents', ...READ },
67
+ emet_gaps: { title: 'Count the record\'s gaps', ...READ },
68
+ emet_floor: { title: 'Read the floor', ...READ },
69
+ // Checks a document against its template and writes nothing - not even the
70
+ // verdict. The write path stores that; this is the same check, borrowed.
71
+ validate_doc: { title: 'Check a document against its template', ...READ },
72
+
73
+ // --- Additive writes. These add; they do not remove or replace. -----------
74
+ // Was READ until 2026-09-27: "it does not write anything itself". Since the
75
+ // blocked-close cap: it records each blocked attempt in
76
+ // close_attempts, so a read-only hint would be untrue (code audit, 1.3).
77
+ emet_session_close: { title: 'Check session is saved', ...ADD },
78
+ emet_invite_create: { title: 'EMET Guest Access', ...REPLACE },
79
+ emet_member_access_create: { title: 'EMET Member Access', ...REPLACE },
80
+ save_to_layer: { title: 'Save to a memory layer', ...ADD },
81
+ revise_memory: { title: 'Supersede a memory', ...ADD },
82
+ save_transcript: { title: 'Save a transcript', ...ADD },
83
+ emet_session_open: { title: 'Open transcript session', ...ADD },
84
+ emet_transcript_append: { title: 'Append one transcript exchange', ...ADD },
85
+ revise_transcript: { title: 'Supersede a transcript part', ...ADD },
86
+
87
+ // --- Replacing writes. Prior versions are archived, but the visible
88
+ // content of an existing document is replaced. -------------------------
89
+ write_doc: { title: 'Write a document', ...REPLACE },
90
+ patch_doc: { title: 'Edit a document', ...REPLACE },
91
+ emet_setup_complete: { title: 'Complete setup', ...REPLACE },
92
+
93
+ // --- Obsolescence marks. NOT removals: this server has no delete verb for
94
+ // any store. The document keeps its id and its full content and read_doc
95
+ // still returns it, and list_docs keeps it after the current documents,
96
+ // marked with the date and what replaced it. Additive
97
+ // for the same reason revise_memory is - it marks, it does not destroy -
98
+ // and idempotent, because retiring an already-retired document reports
99
+ // the existing mark rather than making a second one. -------------------
100
+ retire_doc: { title: 'Mark a document obsolete', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
101
+ rename_doc: { title: 'Reissue a document under a new id', ...ADD },
102
+ restore_doc: { title: 'Write a retained revision back', ...REPLACE },
103
+ // Additive for the same reason retire_doc is: the nonconformance marker is
104
+ // kept and the acceptance is written beside it. Nothing is cleared, so
105
+ // accepting twice records the second reason rather than undoing anything.
106
+ accept_nonconformance: { title: 'Accept a nonconformance in writing', ...ADD },
107
+
108
+ // --- The one diagnostic. Reads by default; with probe: true it writes a
109
+ // probe document and removes it again, so it is not declared read-only -
110
+ // a health check that only reads cannot detect a broken write path.
111
+ // Replaced emet_setup_status, get_status and emet_verify_connection on
112
+ // 2026-09-27 (the owner: consolidate overlapping tools). -----------------------
113
+ emet_status: { title: 'Store status and write-path probe', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
114
+ });
115
+
116
+ /**
117
+ * Attach annotations to a list of tool definitions.
118
+ *
119
+ * Throws on any tool with no entry in the table. A tool that reaches a host
120
+ * unannotated is silently treated as maximally dangerous, so the failure needs
121
+ * to happen here, at build time, rather than as unexplained approval prompts
122
+ * months later.
123
+ */
124
+ export function annotate(tools) {
125
+ const missing = tools.filter((tool) => !TOOL_ANNOTATIONS[tool.name]).map((tool) => tool.name);
126
+ if (missing.length > 0) {
127
+ throw new Error(
128
+ `Tools declared with no annotation entry: ${missing.join(', ')}. ` +
129
+ `Add them to TOOL_ANNOTATIONS in src/annotations.js.`
130
+ );
131
+ }
132
+ return tools.map((tool) => ({ ...tool, annotations: TOOL_ANNOTATIONS[tool.name] }));
133
+ }
134
+
135
+ export default { TOOL_ANNOTATIONS, annotate };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * EMET - Enduring Memory & Epistemic Tiers
3
+ * Copyright (c) 2026 Dreamforge Studio LLC
4
+ *
5
+ * Licensed under the MIT License - see LICENSE
6
+ *
7
+ * Throttle for the passphrase form (security audit finding H4, 2026-09-19).
8
+ *
9
+ * The tool-call rate limiter runs inside /mcp, behind bearer auth. Nothing
10
+ * limited POST /oauth/approve, so the one secret that gates every device could
11
+ * be guessed at network speed. This counts FAILED passphrase attempts per
12
+ * client address inside a sliding window and refuses further attempts once
13
+ * the count is reached, until the oldest failure ages out. A correct
14
+ * passphrase clears the address. In memory only: a restart forgets, which is
15
+ * acceptable for a form a person fills in by hand.
16
+ */
17
+
18
+ export const DEFAULT_MAX_FAILURES = 5;
19
+ export const DEFAULT_WINDOW_MS = 15 * 60 * 1000;
20
+ const MAX_TRACKED = 10000;
21
+
22
+ export class ApproveThrottle {
23
+ constructor({ maxFailures = DEFAULT_MAX_FAILURES, windowMs = DEFAULT_WINDOW_MS, now = Date.now } = {}) {
24
+ this.maxFailures = maxFailures;
25
+ this.windowMs = windowMs;
26
+ this.now = now;
27
+ this.failures = new Map(); // ip -> [timestamps of failures inside the window]
28
+ }
29
+
30
+ _live(ip) {
31
+ const t = this.now();
32
+ const kept = (this.failures.get(ip) || []).filter((ts) => t - ts < this.windowMs);
33
+ if (kept.length) this.failures.set(ip, kept); else this.failures.delete(ip);
34
+ return kept;
35
+ }
36
+
37
+ /** @returns {{allowed: boolean, retryAfterS: number}} */
38
+ check(ip) {
39
+ const kept = this._live(String(ip));
40
+ if (kept.length < this.maxFailures) return { allowed: true, retryAfterS: 0 };
41
+ const retryAfterS = Math.max(1, Math.ceil((kept[0] + this.windowMs - this.now()) / 1000));
42
+ return { allowed: false, retryAfterS };
43
+ }
44
+
45
+ recordFailure(ip) {
46
+ const key = String(ip);
47
+ const kept = this._live(key);
48
+ kept.push(this.now());
49
+ this.failures.set(key, kept);
50
+ if (this.failures.size > MAX_TRACKED) this.failures.delete(this.failures.keys().next().value);
51
+ }
52
+
53
+ recordSuccess(ip) { this.failures.delete(String(ip)); }
54
+ }