@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/src/gaps.js ADDED
@@ -0,0 +1,476 @@
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
+ * emet_gaps - the record's honesty about its own holes, as a query (work list E4).
8
+ *
9
+ * Everything the door writes as a visible marker (E3), every digest it stores
10
+ * (C1), every pointer it keeps (provenance), and every supersession
11
+ * link exists so that ABSENCE can be counted. This module counts it. It
12
+ * identifies and never fills: a gap that is honestly recorded and honestly
13
+ * left is a stronger record than a backfilled one, and the discipline for
14
+ * closing a gap (revise_memory with a real pointer, on a revisable layer, when
15
+ * the source is actually knowable) belongs to a person, not to this tool.
16
+ *
17
+ * Read-only. The write-path invariant test confirms it: no write verb here.
18
+ *
19
+ * Two shapes: `findGaps` returns counts AND the ids behind them (the tool);
20
+ * `gapCounts` returns counts only (the startup line in emet_initialize, a
21
+ * sibling of layer_health - the same move layers.js made for the silent meta
22
+ * layer).
23
+ */
24
+
25
+ import { connectMongo } from './mongo-connect.js';
26
+ import { MONGODB_URI, MONGODB_DB, COLLECTION_PREFIX, DatabaseError } from './database.js';
27
+ import { LAYERS, policyFor } from './layers.js';
28
+ import { integrityOf, metadataIntegrityOf, walkChain } from './aleph.js';
29
+ import { parseMetadata } from './metadata.js';
30
+ import { crossCheckProvenance } from './provenance.js';
31
+ import { isOpenNonconformance, isAcceptedNonconformance } from './conformance.js';
32
+ import { setting } from './env.js';
33
+
34
+ const TRANSCRIPTS = 'transcripts';
35
+ const DOCUMENTS = 'documents';
36
+
37
+ /** The gap classes, in the order they are reported. Names are the contract. */
38
+ export const GAP_CLASSES = Object.freeze({
39
+ unsourced_assertions: 'semantic/procedural entries with no provenance pointer (no session span, no consolidated-from ids)',
40
+ unattributed_inferences: 'semantic/procedural entries whose assertion_origin is assistant or unknown AND that carry no provenance pointer - an inference the record merely holds',
41
+ ambiguous_attribution: 'entries whose source tag was defaulted, is missing, or is not in the registered list (EMET_SOURCE_TAGS) - grouped by tag and by session',
42
+ uncertain_routing: 'entries the door routed itself (routing.method other than explicit) - no instance chose the layer',
43
+ unattestable: 'entries written before the content digest existed (2026-09-05) - a dated boundary, not a defect',
44
+ digest_mismatch: 'entries whose stored content or metadata digest disagrees with the row - the alarm; expected zero',
45
+ broken_chain: 'entries whose chain digest does not match their own content digest and recorded predecessor - altered after writing; expected zero',
46
+ chain_discontinuities: 'linked entries whose recorded predecessor is not the entry just before them in id order - a row between them was removed, or two writes forked the chain; expected zero',
47
+ pointer_disagreements: 'entries whose provenance pointer sits outside the window of the session it names, or names a session whose transcript was never written although later sessions have closed since',
48
+ pointers_pending_transcript: 'entries pointing at a session whose transcript has not been written yet - expected while that session is still open, and it resolves at close. Not a defect, and counted apart so it never inflates the disagreement count',
49
+ broken_supersession: 'a parent marked superseded by an id that does not exist, or a live child whose parent is still live',
50
+ broken_correction: 'a parent whose corrected_by names an id that does not exist in its layer or does not name it back in metadata.corrects, or a correction (metadata.corrects) whose parent is missing or not marked - option A back-pointer, 2026-10-02',
51
+ expired_but_live: 'entries past valid_until that are still flagged live. They stay in recall and queries, marked with that end date',
52
+ consolidation_candidates: 'families of three to eight live entries in one layer sharing three or more tags - identified for a person to consolidate, never consolidated here (approximate: tag overlap, not semantic similarity; larger families are topics, not candidates, and are not reported)',
53
+ redacted_spans: 'entries and transcripts stored with secret spans masked - which host keeps pasting secrets',
54
+ nonconforming_documents: 'controlled documents carrying an OPEN nonconformance marker - form or continuity failed against the template in force, and it has not been corrected by a later revision or accepted in writing',
55
+ accepted_nonconformances: 'nonconformance markers dispositioned in writing for the revision they name - counted separately because an acceptance is a disposition, not a disappearance (STANDARD 9a L6)'
56
+ });
57
+
58
+ const ASSERTING = new Set(['semantic', 'procedural']);
59
+
60
+ /**
61
+ * What a pointer finding actually IS.
62
+ *
63
+ * ⚠ A POINTER NAMING A SESSION WITH NO TRANSCRIPT IS NOT A DISAGREEMENT WHILE
64
+ * THAT SESSION IS STILL OPEN. A transcript is written at close, so every entry
65
+ * written DURING a session points at a session the store cannot know about yet.
66
+ * Reported as a disagreement, that made the count rise through a working
67
+ * session and fall again at wrap-up - noise that looked like a fault, and it
68
+ * trained a reader to discount the one class that is meant to be read. The
69
+ * user's framing, 2026-09-12: establish the pointer at write time, judge it
70
+ * at wrap-up.
71
+ *
72
+ * THE DISCRIMINATOR IS THE STORE ITSELF, NOT A CLOCK AND NOT A TOLERANCE.
73
+ * `closedThroughSec` is the newest moment any transcript covers. An entry
74
+ * written AFTER that names a session that plausibly has not closed yet, so the
75
+ * absence is expected. An entry written BEFORE it names a session that should
76
+ * have been saved, because other sessions have closed since - that absence is a
77
+ * transcript that was never written, and it is a real finding. No magic number
78
+ * is involved, which is deliberate: a tolerance in hours would have to be
79
+ * guessed, and would be wrong for both a ten-minute session and a long one.
80
+ *
81
+ * Pure, so the rule is assertable without a live store.
82
+ *
83
+ * @param {object} entry the stored row (needs source_session_id, timestamp)
84
+ * @param {object|null} window the session's transcript window, or null if absent
85
+ * @param {number|null} closedThroughSec newest moment covered by any transcript
86
+ * @returns {{kind: 'agrees'|'pending'|'disagrees', finding?: string}}
87
+ */
88
+ export function classifyPointer(entry, window, closedThroughSec = null) {
89
+ if (!entry || !entry.source_session_id) return { kind: 'agrees' };
90
+ const sid = entry.source_session_id;
91
+
92
+ if (!window) {
93
+ const at = typeof entry.timestamp === 'number' ? entry.timestamp : null;
94
+ // No transcript has ever been written, or this entry is newer than the
95
+ // newest one: the session it names may still be running.
96
+ if (closedThroughSec === null || at === null || at > closedThroughSec) {
97
+ return {
98
+ kind: 'pending',
99
+ finding: `Entry points at session "${sid}", whose transcript has not been written yet. Expected while that session is open; it resolves at close.`
100
+ };
101
+ }
102
+ return {
103
+ kind: 'disagrees',
104
+ finding: `Entry points at session "${sid}", which has no transcript even though later sessions have closed since. The transcript was never written.`
105
+ };
106
+ }
107
+
108
+ const check = crossCheckProvenance(entry, window);
109
+ if (check.checked && check.agrees === false) return { kind: 'disagrees', finding: check.finding };
110
+ return { kind: 'agrees' };
111
+ }
112
+
113
+ function registeredTags() {
114
+ return String(setting('SOURCE_TAGS') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
115
+ }
116
+
117
+ /**
118
+ * Classify ONE row. Pure: takes the row and what it needs to know about the
119
+ * world (registry, now) and returns the gap-class names that apply. Row shape
120
+ * is the stored document: top-level columns + `metadata` (subdocument or
121
+ * legacy string).
122
+ */
123
+ export function classifyRow(layer, row, { registry = [], nowSec = Date.now() / 1000 } = {}) {
124
+ const gaps = [];
125
+ if (!row || typeof row !== 'object') return gaps;
126
+ const md = parseMetadata(row.metadata) || {};
127
+ const live = row.is_live !== false && !row.superseded_by && !md.superseded_by;
128
+
129
+ const hasPointer = Boolean(row.source_session_id) || (Array.isArray(row.derived_from_ids) && row.derived_from_ids.length > 0);
130
+
131
+ if (ASSERTING.has(layer) && !hasPointer && policyFor(layer).provenance === 'required') {
132
+ gaps.push('unsourced_assertions');
133
+ const origin = md.assertion_origin;
134
+ if (origin === 'assistant' || origin === null || origin === undefined) gaps.push('unattributed_inferences');
135
+ }
136
+
137
+ const source = md.source;
138
+ if (md.source_defaulted === true || md.source_missing === true || source === undefined || source === null || source === '' ||
139
+ md.source_unregistered === true || (registry.length > 0 && typeof source === 'string' && !registry.includes(source))) {
140
+ gaps.push('ambiguous_attribution');
141
+ }
142
+
143
+ if (md.routing && typeof md.routing === 'object' && md.routing.method && md.routing.method !== 'explicit') {
144
+ gaps.push('uncertain_routing');
145
+ }
146
+
147
+ const ci = integrityOf(row);
148
+ const mi = metadataIntegrityOf(row);
149
+ if (ci === 'unattested') gaps.push('unattestable');
150
+ if (ci === false || mi === false) gaps.push('digest_mismatch');
151
+
152
+ const vu = typeof row.valid_until === 'number' ? row.valid_until
153
+ : typeof md.valid_until === 'number' ? md.valid_until : null;
154
+ if (vu !== null && vu < nowSec && live) gaps.push('expired_but_live');
155
+
156
+ if ((typeof md.redacted_spans === 'number' && md.redacted_spans > 0)) gaps.push('redacted_spans');
157
+
158
+ return gaps;
159
+ }
160
+
161
+ /**
162
+ * Consolidation candidates by tag overlap. Families of >= minFamily live rows
163
+ * that pairwise share >= minShared tags. Approximate on purpose - the honest
164
+ * version of "autonomous consolidation" is a list for a person.
165
+ */
166
+ export function tagFamilies(rows, { minShared = 3, minFamily = 3, maxFamily = 8 } = {}) {
167
+ const items = rows
168
+ .map((r) => ({ id: r.id, tags: new Set((parseMetadata(r.metadata) || {}).tags || []) }))
169
+ .filter((x) => x.tags.size >= minShared);
170
+ const parent = new Map(items.map((x) => [x.id, x.id]));
171
+ const find = (a) => { while (parent.get(a) !== a) { parent.set(a, parent.get(parent.get(a))); a = parent.get(a); } return a; };
172
+ const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); };
173
+ for (let i = 0; i < items.length; i++) {
174
+ for (let j = i + 1; j < items.length; j++) {
175
+ let shared = 0;
176
+ for (const t of items[i].tags) if (items[j].tags.has(t)) { shared++; if (shared >= minShared) break; }
177
+ if (shared >= minShared) union(items[i].id, items[j].id);
178
+ }
179
+ }
180
+ const groups = new Map();
181
+ for (const x of items) { const r = find(x.id); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(x.id); }
182
+ // A family larger than maxFamily is a TOPIC (a batch import sharing five
183
+ // tags across forty rows), not a consolidation candidate. Dry run on the
184
+ // 2026-09-07 corpus produced families of 44 and 26 that way. They are
185
+ // dropped here, not reported: a candidate list a person will not read is
186
+ // no better than none.
187
+ return [...groups.values()]
188
+ .filter((g) => g.length >= minFamily && g.length <= maxFamily)
189
+ .map((g) => g.sort((a, b) => a - b));
190
+ }
191
+
192
+ async function connect() {
193
+ if (!MONGODB_URI) throw new DatabaseError('MONGODB_URI is not set; cannot assess gaps.', 'emet_gaps');
194
+ const client = await connectMongo(MONGODB_URI, { serverSelectionTimeoutMS: 15000 });
195
+ return { client, db: client.db(MONGODB_DB) };
196
+ }
197
+
198
+ /**
199
+ * Run every gap class over the store. Returns counts and ids (capped at
200
+ * `limit` per class) plus the groupings that make a count actionable.
201
+ */
202
+ /**
203
+ * broken_correction, both directions (option A, 2026-10-02). Pure apart
204
+ * from the row lookup, so the suite can drive it with a layer-honouring fake.
205
+ * corrected: [{ ref, layer, id, child }] - a parent's corrected_by entries:
206
+ * the child must exist in the SAME layer and name the parent in
207
+ * metadata.corrects;
208
+ * corrections: [{ ref, layer, id, parent }] - a child's metadata.corrects: the
209
+ * parent must exist in the same layer and list the child in
210
+ * corrected_by (column first, else the metadata copy).
211
+ * findRow(layer, id) returns the row or null. Returns the finding texts.
212
+ */
213
+ export async function checkCorrectionLinks(corrected, corrections, findRow) {
214
+ const out = [];
215
+ const mdOf = (row) => (typeof row.metadata === 'string' ? (() => { try { return JSON.parse(row.metadata); } catch { return {}; } })() : (row.metadata || {}));
216
+ for (const c of corrected) {
217
+ const child = await findRow(c.layer, c.child);
218
+ if (!child) out.push(`${c.ref} -> corrected_by ${c.child} (missing)`);
219
+ else if (Number(mdOf(child).corrects) !== Number(c.id)) out.push(`${c.ref} -> corrected_by ${c.child} (it does not name ${c.id} in corrects)`);
220
+ }
221
+ for (const c of corrections) {
222
+ const parent = await findRow(c.layer, c.parent);
223
+ if (!parent) { out.push(`${c.ref} corrects ${c.parent} (parent missing)`); continue; }
224
+ const pmd = mdOf(parent);
225
+ const list = Array.isArray(parent.corrected_by) ? parent.corrected_by : (Array.isArray(pmd.corrected_by) ? pmd.corrected_by : []);
226
+ if (!list.map(Number).includes(Number(c.id))) out.push(`${c.ref} corrects ${c.parent} (parent not marked)`);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ export async function findGaps({ layer = null, since = null, limit = 50 } = {}) {
232
+ const layers = layer ? [layer] : [...LAYERS];
233
+ if (layer && !LAYERS.includes(layer)) throw new DatabaseError(`Unknown layer '${layer}'`, 'emet_gaps');
234
+ const cap = Math.max(1, Math.min(500, Number(limit) || 50));
235
+ const nowSec = Date.now() / 1000;
236
+ const registry = registeredTags();
237
+ const sinceSec = typeof since === 'number' ? since : null;
238
+
239
+ const classes = {};
240
+ for (const name of Object.keys(GAP_CLASSES)) classes[name] = { count: 0, ids: [] };
241
+ const byTag = {}, bySession = {}, redactedBySource = {};
242
+ const push = (name, ref) => { const c = classes[name]; c.count++; if (c.ids.length < cap) c.ids.push(ref); };
243
+
244
+ // Declared outside the try: the return below reads it (live-caught 2026-09-29,
245
+ // "chain is not defined", in a live deploy).
246
+ const chain = {};
247
+ let graphLab = null;
248
+ const { client, db } = await connect();
249
+ try {
250
+ const pointers = []; // { ref, entry }
251
+ const idsByLayer = {};
252
+ const parentsToCheck = []; // { ref, layer, superseded_by }
253
+ const childrenToCheck = []; // { ref, layer, supersedes }
254
+ const correctedToCheck = []; // { ref, layer, id, child } - a parent's corrected_by entries
255
+ const correctionsToCheck = []; // { ref, layer, id, parent } - a child's metadata.corrects
256
+ const liveRowsByLayer = {};
257
+ // The full chain walk (2026-09-29): until today it lived only in
258
+ // scripts/chain-verify.mjs, runnable from a machine with the repo and the
259
+ // Railway CLI - no host could ask for it. It rides on this scan, so it
260
+ // costs no extra pass; the script stays as the outside auditor's copy.
261
+ const chainRowsByLayer = {};
262
+
263
+ for (const l of layers) {
264
+ const col = db.collection(`${COLLECTION_PREFIX}${l}`);
265
+ const filter = sinceSec !== null ? { timestamp: { $gte: sinceSec } } : {};
266
+ const ids = new Set();
267
+ const liveRows = [];
268
+ const chainRows = [];
269
+ for await (const row of col.find(filter)) {
270
+ const ref = `${l}#${row.id}`;
271
+ ids.add(row.id);
272
+ chainRows.push({ id: row.id, sha256: row.sha256, prev_sha256: row.prev_sha256, chain_sha256: row.chain_sha256, timestamp: row.timestamp });
273
+ const md = parseMetadata(row.metadata) || {};
274
+ const rowGaps = classifyRow(l, row, { registry, nowSec });
275
+ for (const g of rowGaps) push(g, ref);
276
+ if (rowGaps.includes('ambiguous_attribution')) {
277
+ const tag = md.source === undefined || md.source === null || md.source === '' ? '(none)' : String(md.source);
278
+ byTag[tag] = (byTag[tag] || 0) + 1;
279
+ const s = row.source_session_id || '(no session)';
280
+ bySession[s] = (bySession[s] || 0) + 1;
281
+ }
282
+ if (typeof md.redacted_spans === 'number' && md.redacted_spans > 0) {
283
+ const s = md.source || '(none)'; redactedBySource[s] = (redactedBySource[s] || 0) + md.redacted_spans;
284
+ }
285
+ if (row.source_session_id) pointers.push({ ref, entry: row });
286
+ if (row.superseded_by) parentsToCheck.push({ ref, layer: l, superseded_by: Number(row.superseded_by) });
287
+ const cby = Array.isArray(row.corrected_by) ? row.corrected_by : md.corrected_by;
288
+ if (Array.isArray(cby)) for (const c of cby) correctedToCheck.push({ ref, layer: l, id: row.id, child: Number(c) });
289
+ if (md.corrects !== undefined && md.corrects !== null) correctionsToCheck.push({ ref, layer: l, id: row.id, parent: Number(md.corrects) });
290
+ const sup = md.supersedes ?? (md.custom && md.custom.supersedes);
291
+ const live = row.is_live !== false && !row.superseded_by && !md.superseded_by;
292
+ if (live && sup) childrenToCheck.push({ ref, layer: l, supersedes: Number(sup) });
293
+ if (live) liveRows.push(row);
294
+ }
295
+ idsByLayer[l] = ids;
296
+ liveRowsByLayer[l] = liveRows;
297
+ chainRowsByLayer[l] = chainRows;
298
+ }
299
+
300
+ for (const l of layers) {
301
+ const rows = chainRowsByLayer[l];
302
+ let seedSha256 = null;
303
+ if (sinceSec !== null && rows.length) {
304
+ // A partial walk starts mid-chain: seed it with the digest of the row
305
+ // just before the first one scanned.
306
+ const minId = Math.min(...rows.map((r) => r.id));
307
+ const before = await db.collection(`${COLLECTION_PREFIX}${l}`)
308
+ .find({ id: { $lt: minId }, sha256: { $exists: true, $nin: [null, ''] } }, { projection: { sha256: 1 } })
309
+ .sort({ id: -1 }).limit(1).toArray();
310
+ seedSha256 = before[0] ? before[0].sha256 : null;
311
+ }
312
+ const w = walkChain(rows, { seedSha256, sample: cap });
313
+ for (const id of w.broken_ids) push('broken_chain', `${l}#${id}`);
314
+ classes.broken_chain.count += w.broken - w.broken_ids.length;
315
+ for (const id of w.discontinuity_ids) push('chain_discontinuities', `${l}#${id}`);
316
+ classes.chain_discontinuities.count += w.discontinuities - w.discontinuity_ids.length;
317
+ chain[l] = { total: w.total, linked: w.linked, unlinked: w.unlinked, broken: w.broken, discontinuities: w.discontinuities, head: w.head };
318
+ }
319
+
320
+ // Broken supersession: the referenced id must exist in the same layer
321
+ // (fetched only when the scan was filtered, since the set is then partial).
322
+ for (const p of parentsToCheck) {
323
+ let exists = idsByLayer[p.layer].has(p.superseded_by);
324
+ if (!exists && sinceSec !== null) exists = Boolean(await db.collection(`${COLLECTION_PREFIX}${p.layer}`).findOne({ id: p.superseded_by }, { projection: { id: 1 } }));
325
+ if (!exists) push('broken_supersession', `${p.ref} -> superseded_by ${p.superseded_by} (missing)`);
326
+ }
327
+ for (const f of await checkCorrectionLinks(correctedToCheck, correctionsToCheck,
328
+ (layer, id) => db.collection(`${COLLECTION_PREFIX}${layer}`).findOne({ id }, { projection: { id: 1, metadata: 1, corrected_by: 1 } }))) {
329
+ push('broken_correction', f);
330
+ }
331
+ for (const c of childrenToCheck) {
332
+ const parent = await db.collection(`${COLLECTION_PREFIX}${c.layer}`).findOne({ id: c.supersedes }, { projection: { id: 1, is_live: 1, superseded_by: 1 } });
333
+ if (parent && parent.is_live !== false && !parent.superseded_by) push('broken_supersession', `${c.ref} supersedes ${c.supersedes} (parent still live)`);
334
+ if (!parent) push('broken_supersession', `${c.ref} supersedes ${c.supersedes} (parent missing)`);
335
+ }
336
+
337
+ // Pointer disagreements: resolve each distinct session once.
338
+ const sessions = [...new Set(pointers.map((p) => p.entry.source_session_id))];
339
+ const windows = {};
340
+ if (sessions.length) {
341
+ const tcol = db.collection(TRANSCRIPTS);
342
+ for (const sid of sessions) {
343
+ const esc = sid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
344
+ const parts = await tcol.find({ session_id: { $regex: `^${esc}(_(?:verbatim_)?part\\d+)?$` } }, { projection: { session_start: 1, session_end: 1 } }).toArray();
345
+ if (!parts.length) { windows[sid] = null; continue; }
346
+ const starts = parts.map((p) => p.session_start).filter(Boolean);
347
+ const ends = parts.map((p) => p.session_end).filter(Boolean);
348
+ windows[sid] = { session_start: new Date(Math.min(...starts.map((d) => new Date(d).getTime()))), session_end: new Date(Math.max(...ends.map((d) => new Date(d).getTime()))) };
349
+ }
350
+ }
351
+ // How far the transcript store has been written through. Read once, and
352
+ // only when something actually needs it: it is the discriminator between a
353
+ // session that has not closed yet and a transcript that was never written.
354
+ let closedThroughSec = null;
355
+ if (sessions.some((sid) => !windows[sid])) {
356
+ let newestMs = null;
357
+ for await (const t of db.collection(TRANSCRIPTS).find({}, { projection: { session_start: 1, session_end: 1 } })) {
358
+ for (const v of [t.session_end, t.session_start]) {
359
+ if (!v) continue;
360
+ const ms = new Date(v).getTime();
361
+ if (Number.isFinite(ms) && (newestMs === null || ms > newestMs)) newestMs = ms;
362
+ }
363
+ }
364
+ if (newestMs !== null) closedThroughSec = newestMs / 1000;
365
+ }
366
+
367
+ for (const p of pointers) {
368
+ const verdict = classifyPointer(p.entry, windows[p.entry.source_session_id], closedThroughSec);
369
+ if (verdict.kind === 'disagrees') push('pointer_disagreements', `${p.ref}: ${verdict.finding}`);
370
+ else if (verdict.kind === 'pending') push('pointers_pending_transcript', `${p.ref}: ${verdict.finding}`);
371
+ }
372
+
373
+ // Consolidation candidates, per layer, live rows only.
374
+ for (const l of layers) {
375
+ for (const fam of tagFamilies(liveRowsByLayer[l])) push('consolidation_candidates', `${l}: ${fam.map((id) => '#' + id).join(', ')}`);
376
+ }
377
+
378
+ // Redacted transcripts.
379
+ const tfilter = { redacted_spans: { $gt: 0 } };
380
+ for await (const t of db.collection(TRANSCRIPTS).find(tfilter, { projection: { session_id: 1, channel: 1, redacted_spans: 1 } })) {
381
+ push('redacted_spans', `transcript ${t.session_id} (${t.channel || '?'}): ${t.redacted_spans}`);
382
+ const s = t.channel || '(none)'; redactedBySource[s] = (redactedBySource[s] || 0) + (t.redacted_spans || 0);
383
+ }
384
+
385
+ // Controlled documents. This is the count the board's "Nonconformance
386
+ // markers open" column asks for, which until the validator existed could
387
+ // only answer "validator not built". A marker leaves in one of two ways -
388
+ // a later revision that passes, or a written acceptance - and the second
389
+ // is counted separately rather than folded into the first, because an
390
+ // acceptance is a disposition and not a disappearance (L6).
391
+ const dfilter = { conformance: { $exists: true } };
392
+ if (sinceSec !== null) dfilter.updated_at = { $gte: new Date(sinceSec * 1000) };
393
+ for await (const d of db.collection(DOCUMENTS).find(dfilter,
394
+ { projection: { doc_id: 1, version: 1, conformance: 1 } })) {
395
+ const c = d.conformance;
396
+ if (isOpenNonconformance(c, d.version)) {
397
+ const n = (c.failures || []).length;
398
+ push('nonconforming_documents',
399
+ `${d.doc_id} v${d.version}: ${n} failure${n === 1 ? '' : 's'}${c.template ? ` against ${c.template}` : ''}`);
400
+ } else if (isAcceptedNonconformance(c, d.version)) {
401
+ push('accepted_nonconformances',
402
+ `${d.doc_id} v${d.version}: ${String(c.accepted && c.accepted.reason).slice(0, 90)}`);
403
+ }
404
+ }
405
+ // Lab results (2026-09-29): the monorail's own record of every check it
406
+ // made, for the probationary review before enforce. Read, never written here.
407
+ try {
408
+ const { labSummary, LAB_SUFFIX } = await import('./session-graph.js');
409
+ const lfilter = sinceSec !== null ? { ts: { $gte: new Date(sinceSec * 1000) } } : {};
410
+ const labRows = await db.collection(`${COLLECTION_PREFIX}${LAB_SUFFIX}`)
411
+ .find(lfilter, { projection: { _id: 0 } }).toArray();
412
+ graphLab = labSummary(labRows, { limit: cap });
413
+ } catch (e) {
414
+ graphLab = { assessed: false, error: e && e.message ? e.message : String(e) };
415
+ }
416
+ } finally {
417
+ await client.close();
418
+ }
419
+
420
+ const total = Object.values(classes).reduce((s, c) => s + c.count, 0);
421
+ return {
422
+ assessed_at: new Date().toISOString(),
423
+ scope: { layers, since: sinceSec, id_limit_per_class: cap },
424
+ total_findings: total,
425
+ classes: Object.fromEntries(Object.entries(classes).map(([k, v]) => [k, { description: GAP_CLASSES[k], ...v }])),
426
+ chain,
427
+ graph_lab: graphLab,
428
+ ambiguous_attribution_by_tag: byTag,
429
+ ambiguous_attribution_by_session: bySession,
430
+ redacted_spans_by_source: redactedBySource,
431
+ discipline:
432
+ 'Identify, never fill. Where the true source is knowable (the writer is still in session; the span is unambiguous) ' +
433
+ 'the fix is revise_memory with a real derived_from on a revisable layer. Where it is not knowable, or the layer is episodic, ' +
434
+ 'the gap stands as a fact and the count stops growing from that date. unattestable is a dated boundary; digest_mismatch ' +
435
+ 'is the alarm; consolidation_candidates is a list for a person.'
436
+ };
437
+ }
438
+
439
+ /** Counts only - the startup line. Never throws: a failed assessment is itself reported. */
440
+ export async function gapCounts(options = {}) {
441
+ try {
442
+ const full = await findGaps({ ...options, limit: 1 });
443
+ const counts = Object.fromEntries(Object.entries(full.classes).map(([k, v]) => [k, v.count]));
444
+ const out = { assessed: true, total_findings: full.total_findings, counts };
445
+ // THE BASELINE (2026-09-15). A lifetime total is dominated by rows written
446
+ // before the door and the pointer existed - 1,086 of the first 1,087 rows
447
+ // in the reference install carry no provenance, by rule, forever. A count
448
+ // that can never fall is not a control. EMET_GAP_BASELINE (ISO date or
449
+ // Unix seconds) names the date from which the record is held to the
450
+ // current rules; startup then reports gaps since that date beside the
451
+ // lifetime figure, and the since-baseline figure is the one that should
452
+ // trend to zero. No baseline set: the block is absent, not defaulted -
453
+ // a product must not encode one install's history.
454
+ const baselineSec = parseBaseline(setting('GAP_BASELINE'));
455
+ if (baselineSec !== null && (typeof options.since !== 'number')) {
456
+ const since = await findGaps({ ...options, since: baselineSec, limit: 1 });
457
+ out.since_baseline = {
458
+ baseline: new Date(baselineSec * 1000).toISOString(),
459
+ total_findings: since.total_findings,
460
+ counts: Object.fromEntries(Object.entries(since.classes).map(([k, v]) => [k, v.count]))
461
+ };
462
+ }
463
+ return out;
464
+ } catch (e) {
465
+ return { assessed: false, error: e && e.message ? e.message : String(e) };
466
+ }
467
+ }
468
+
469
+ /** EMET_GAP_BASELINE as Unix seconds, or null when unset or unparseable. Exported for its test. */
470
+ export function parseBaseline(raw) {
471
+ if (raw === undefined || raw === null || String(raw).trim() === '') return null;
472
+ const str = String(raw).trim();
473
+ if (/^\d{9,11}$/.test(str)) return Number(str);
474
+ const ms = Date.parse(str);
475
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
476
+ }
package/src/guides.js ADDED
@@ -0,0 +1,48 @@
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
+ * Shipped guides.
8
+ *
9
+ * Reference documents that ship with the release and are read on demand with
10
+ * read_doc('guides/<NAME>.md') - never pasted into emet_initialize. The floor
11
+ * carries the rules; a guide carries the understanding and method behind them.
12
+ * Like the charter, the shipped copy governs: an install's store cannot
13
+ * overwrite or lose it, and a brand-new store can read it before any setup.
14
+ *
15
+ * Only a bare file name under guides/ is accepted, so a doc_id cannot walk out
16
+ * of the folder.
17
+ */
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import crypto from 'crypto';
22
+ import { resolveModulePath } from './module-path.js';
23
+
24
+ export const GUIDES_DIR = resolveModulePath(import.meta.url, ['..', 'guides']);
25
+ const GUIDE_ID = /^guides\/([A-Za-z0-9_-]+\.md)$/;
26
+
27
+ /** The shipped guide for this doc_id, or null when it is not a shipped guide. */
28
+ export function readShippedGuide(doc_id, { dir = GUIDES_DIR } = {}) {
29
+ const m = typeof doc_id === 'string' ? GUIDE_ID.exec(doc_id) : null;
30
+ if (!m) return null;
31
+ let content;
32
+ try { content = fs.readFileSync(path.join(dir, m[1]), 'utf8'); } catch { return null; }
33
+ return {
34
+ doc_id,
35
+ content,
36
+ sha256: crypto.createHash('sha256').update(content, 'utf8').digest('hex'),
37
+ size: content.length,
38
+ source: 'release',
39
+ governed_by: 'the copy shipped with this release',
40
+ note: 'A shipped guide: read on demand, never loaded at startup. The release copy governs.'
41
+ };
42
+ }
43
+
44
+ /** Names of the guides this release ships. */
45
+ export function shippedGuides({ dir = GUIDES_DIR } = {}) {
46
+ try { return fs.readdirSync(dir).filter(f => /\.md$/.test(f)).map(f => `guides/${f}`).sort(); }
47
+ catch { return []; }
48
+ }