@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.
- package/.env.example +136 -0
- package/CHANGELOG.md +296 -0
- package/CHARTER.md +125 -0
- package/LICENSE +22 -0
- package/README.md +783 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +79 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +246 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
- package/docs/GUIDED-SETUP.md +296 -0
- package/docs/PERMISSIONS.md +118 -0
- package/docs/ROADMAP.md +59 -0
- package/docs/SETUP.md +259 -0
- package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
- package/package.json +70 -4
- package/server.json +39 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +135 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +811 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +279 -0
- package/src/env.js +52 -0
- package/src/floor.js +187 -0
- package/src/gaps.js +476 -0
- package/src/guides.js +48 -0
- package/src/http-remote.js +269 -0
- package/src/index.js +390 -0
- package/src/invites.js +167 -0
- package/src/layers.js +449 -0
- package/src/lessons-to-checks.js +366 -0
- package/src/logger.js +299 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +48 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/persons.js +116 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +231 -0
- package/src/request-log.js +124 -0
- package/src/scopes.js +97 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +945 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1313 -0
- package/src/startup-pages.js +354 -0
- package/src/startup-payload.js +126 -0
- package/src/tools.js +4271 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1163 -0
- package/src/version.js +2 -0
- package/templates/BOARD.md +72 -0
- package/templates/BOTHANDOFF.md +57 -0
- package/templates/CHARACTER.md +30 -0
- package/templates/HANDOFF.md +63 -0
- package/templates/IDENTITY.md +29 -0
- package/templates/INDEX.md +82 -0
- package/templates/INSTALL.md +29 -0
- package/templates/PROCEDURE.md +39 -0
- package/templates/ROSTER.md +43 -0
- package/templates/SEED.md +65 -0
- package/templates/STAGING.md +37 -0
- package/templates/STANDARD.md +99 -0
- package/templates/THREAD.md +43 -0
- 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
|
+
}
|