@dreamforgestudiollc/emet 0.0.0-stage → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.env.example +136 -0
  2. package/CHANGELOG.md +296 -0
  3. package/CHARTER.md +125 -0
  4. package/LICENSE +22 -0
  5. package/README.md +783 -2
  6. package/docs/AUTHORSHIP.md +26 -0
  7. package/docs/BENCHMARKS.md +261 -0
  8. package/docs/BOOTSTRAP.md +127 -0
  9. package/docs/BOTS.md +79 -0
  10. package/docs/COVERAGE.md +64 -0
  11. package/docs/DEPLOY.md +246 -0
  12. package/docs/DISCIPLINES.md +29 -0
  13. package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
  14. package/docs/GUIDED-SETUP.md +296 -0
  15. package/docs/PERMISSIONS.md +118 -0
  16. package/docs/ROADMAP.md +59 -0
  17. package/docs/SETUP.md +259 -0
  18. package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
  19. package/package.json +70 -4
  20. package/server.json +39 -0
  21. package/src/aleph.js +395 -0
  22. package/src/annotations.js +135 -0
  23. package/src/approve-throttle.js +54 -0
  24. package/src/atlas-role.js +58 -0
  25. package/src/conformance.js +688 -0
  26. package/src/content_analyzer.js +450 -0
  27. package/src/database.js +811 -0
  28. package/src/disciplines.js +26 -0
  29. package/src/dispatch.js +279 -0
  30. package/src/env.js +52 -0
  31. package/src/floor.js +187 -0
  32. package/src/gaps.js +476 -0
  33. package/src/guides.js +48 -0
  34. package/src/http-remote.js +269 -0
  35. package/src/index.js +390 -0
  36. package/src/invites.js +167 -0
  37. package/src/layers.js +449 -0
  38. package/src/lessons-to-checks.js +366 -0
  39. package/src/logger.js +299 -0
  40. package/src/metadata.js +61 -0
  41. package/src/module-path.js +32 -0
  42. package/src/mongo-connect.js +48 -0
  43. package/src/named-items.js +208 -0
  44. package/src/oauth-provider.js +838 -0
  45. package/src/persons.js +116 -0
  46. package/src/provenance.js +257 -0
  47. package/src/redact.js +231 -0
  48. package/src/request-log.js +124 -0
  49. package/src/scopes.js +97 -0
  50. package/src/security-headers.js +26 -0
  51. package/src/session-graph.js +945 -0
  52. package/src/session.js +1097 -0
  53. package/src/setup.js +1313 -0
  54. package/src/startup-pages.js +354 -0
  55. package/src/startup-payload.js +126 -0
  56. package/src/tools.js +4271 -0
  57. package/src/transcript-session.js +106 -0
  58. package/src/validation.js +1163 -0
  59. package/src/version.js +2 -0
  60. package/templates/BOARD.md +72 -0
  61. package/templates/BOTHANDOFF.md +57 -0
  62. package/templates/CHARACTER.md +30 -0
  63. package/templates/HANDOFF.md +63 -0
  64. package/templates/IDENTITY.md +29 -0
  65. package/templates/INDEX.md +82 -0
  66. package/templates/INSTALL.md +29 -0
  67. package/templates/PROCEDURE.md +39 -0
  68. package/templates/ROSTER.md +43 -0
  69. package/templates/SEED.md +65 -0
  70. package/templates/STAGING.md +37 -0
  71. package/templates/STANDARD.md +99 -0
  72. package/templates/THREAD.md +43 -0
  73. package/templates/USER.md +29 -0
@@ -0,0 +1,688 @@
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
+ * conformance - template validation for controlled documents.
8
+ *
9
+ * A template is a control: it puts the answer in front of whoever fills it
10
+ * before the moment a field could be skipped. That only holds if the filled
11
+ * record is actually checked against it, so this module does the checking and
12
+ * `writeDoc()` calls it on every document write. There is no way to ask it not
13
+ * to (STANDARD 9a L2): an optional control is a reminder, and an instruction to
14
+ * be careful is not a control (charter 3, commitment 5).
15
+ *
16
+ * WHAT IT DOES NOT DO. A nonconforming write is never rejected and never
17
+ * altered - the user's decision, 2026-09-10: "Mark for audit and review."
18
+ * The document lands, carries a visible marker naming what failed, and stays
19
+ * counted at session start until it is corrected by a later revision or
20
+ * accepted with a written reason. A validator that could block a write would be
21
+ * a validator that could lose one.
22
+ *
23
+ * WHAT IT CANNOT DO, stated so no one assumes otherwise: it checks FORM and
24
+ * CONTINUITY, never truth. Whether a "none" states its scope honestly, and
25
+ * whether any answer is true, remain the author's and the reader's.
26
+ *
27
+ * The templates are their own specification: required sections are read from
28
+ * the template document's own headings, so the rule lives once, in the
29
+ * template, and never in a second copy here (charter 3, commitment 4).
30
+ */
31
+
32
+ import fs from 'fs';
33
+ import path from 'path';
34
+ import { resolveModulePath } from './module-path.js';
35
+ import { setting } from './env.js';
36
+
37
+ const HERE = resolveModulePath(import.meta.url);
38
+
39
+ /**
40
+ * L1: the SHIPPED copy governs. Required sections and status are read from the
41
+ * copy that ships with the release, never from the store copy - otherwise a
42
+ * template can be loosened by editing the store, or validation switched off by
43
+ * flipping a template to DRAFT. The store copy is still compared against this
44
+ * one, and a difference is itself a nonconformance.
45
+ */
46
+ export const SHIPPED_TEMPLATES_DIR = path.resolve(HERE, '..', 'templates');
47
+
48
+ export const STATUS = Object.freeze({ DRAFT: 'DRAFT', IN_FORCE: 'IN FORCE', RETIRED: 'RETIRED' });
49
+
50
+ export const VERDICT = Object.freeze({
51
+ CONFORMS: 'conforms',
52
+ NONCONFORMING: 'nonconforming',
53
+ UNTEMPLATED: 'untemplated', // no template maps to this doc_id
54
+ UNGATED: 'ungated' // a template maps, but it is not IN FORCE yet
55
+ });
56
+
57
+ // ============================================
58
+ // PARSING - a template is its own specification
59
+ // ============================================
60
+
61
+ /** `## §3 OPEN BOARD` -> { num: '3', title: 'OPEN BOARD' }. The section number
62
+ * may carry a letter suffix (9a), which the standard itself uses. */
63
+ const SECTION_RE = /^##[ \t]*§([0-9]+[a-z]?)[ \t]*(.*)$/gm;
64
+
65
+ /** The control line, template or record. Both open with a bolded label. */
66
+ const CONTROL_RE = /^\*\*(?:Template|Record) control:\*\*(.*)$/m;
67
+
68
+ /** Field inside a control line: `status: IN FORCE`, up to the next separator. */
69
+ function controlField(controlLine, name) {
70
+ if (!controlLine) return null;
71
+ const re = new RegExp(`${name}\\s*:\\s*([^·|\\n]+)`, 'i');
72
+ const m = re.exec(controlLine);
73
+ return m ? m[1].trim() : null;
74
+ }
75
+
76
+ /**
77
+ * A doc id written as `templates/BOARD.md`, in backticks or bare.
78
+ *
79
+ * ⚠ THE `id` TOKEN MUST BE BOUNDED, and it was not. Written bare, the pattern
80
+ * matched the letters inside "val-id-ation-v1-pricing-2026-09.md" (without the
81
+ * hyphens) and captured the tail, reporting a declared template of
82
+ * "ation-v1-pricing-2026-09.md" - a filename mentioned in passing turning into
83
+ * a template the record never declared. Found by running the validator against
84
+ * the reference install's own board, 2026-09-11.
85
+ *
86
+ * The boundary is written as lookaround rather than \b on purpose: `_` is a
87
+ * word character, so \bid\b would refuse a control line that writes `doc_id:`.
88
+ * Lookaround on [A-Za-z0-9] rejects "validation" and "identifier" while still
89
+ * accepting `id`, `doc_id` and `Record id`.
90
+ */
91
+ const CONTROL_ID_RE = /(?<![A-Za-z0-9])id(?![A-Za-z0-9])\s*:?\s*[`']?([A-Za-z0-9_./-]+\.md)[`']?/i;
92
+ const CONTROL_TEMPLATE_RE = /Template\s*:\s*[`']?([A-Za-z0-9_./-]+\.md)[`']?/i;
93
+
94
+ function controlDocId(controlLine) {
95
+ if (!controlLine) return null;
96
+ const m = CONTROL_ID_RE.exec(controlLine) || CONTROL_TEMPLATE_RE.exec(controlLine);
97
+ return m ? m[1] : null;
98
+ }
99
+
100
+ /**
101
+ * ⚠ ORDER IS LOAD-BEARING, and it is not obvious. The conventional way to write
102
+ * an unadopted template's status is `status: DRAFT, not in force` - and
103
+ * "NOT IN FORCE" CONTAINS "IN FORCE". Testing for IN FORCE first therefore reads
104
+ * every draft as adopted, which is the precise failure the gating exists to
105
+ * prevent: validation firing against templates nobody approved. Caught by
106
+ * tests/conformance.test.js on the first run, 2026-09-11.
107
+ *
108
+ * RETIRED and DRAFT are checked first because they are unambiguous, and an
109
+ * explicit negation is honoured before anything else.
110
+ */
111
+ function normaliseStatus(raw) {
112
+ if (!raw) return null;
113
+ const s = raw.toUpperCase();
114
+ if (s.includes('RETIRED')) return STATUS.RETIRED;
115
+ if (s.includes('DRAFT')) return STATUS.DRAFT;
116
+ if (/\bNOT IN FORCE\b/.test(s)) return STATUS.DRAFT;
117
+ if (s.includes('IN FORCE')) return STATUS.IN_FORCE;
118
+ return null;
119
+ }
120
+
121
+ /** Sections in document order, with the body that follows each heading. */
122
+ export function parseSections(text) {
123
+ const src = String(text || '');
124
+ const found = [];
125
+ SECTION_RE.lastIndex = 0;
126
+ let m;
127
+ while ((m = SECTION_RE.exec(src)) !== null) {
128
+ found.push({ num: m[1], title: m[2].trim(), start: m.index, bodyStart: m.index + m[0].length });
129
+ }
130
+ return found.map((s, i) => ({
131
+ num: s.num,
132
+ title: s.title,
133
+ first_word: (s.title.split(/[\s—-]+/)[0] || '').toUpperCase(),
134
+ body: src.slice(s.bodyStart, i + 1 < found.length ? found[i + 1].start : src.length).trim()
135
+ }));
136
+ }
137
+
138
+ /**
139
+ * Parse a template document into the specification it is.
140
+ * `source` is 'shipped' or 'store' - which copy this came from, so a caller
141
+ * can never confuse the authority (L1) with the copy being audited.
142
+ */
143
+ export function parseTemplate(text, { doc_id = null, source = 'shipped' } = {}) {
144
+ const src = String(text || '');
145
+ const control = (CONTROL_RE.exec(src) || [])[1] || null;
146
+ const sections = parseSections(src);
147
+ return {
148
+ template_id: controlDocId(control) || doc_id,
149
+ status: normaliseStatus(controlField(control, 'status')),
150
+ approved: controlField(control, 'approved'),
151
+ revision: controlField(control, 'revision'),
152
+ owner: controlField(control, 'owner'),
153
+ source,
154
+ required_sections: sections.map((s) => ({ num: s.num, title: s.title, first_word: s.first_word })),
155
+ placeholder_count: countPlaceholders(src),
156
+ body: src
157
+ };
158
+ }
159
+
160
+ // Front matter is the record control block for templates that declare it that way
161
+ // - a seed is the case in hand. It must open the file: anything indented, or sitting
162
+ // below prose, is body text and is not read. This is deliberately strict, because a
163
+ // trigger buried in a fenced block inside the body is exactly what an automated scan
164
+ // cannot see, and four seeds in the reference store are invisible for that reason.
165
+ const FRONT_MATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
166
+
167
+ export function parseFrontMatter(text) {
168
+ const m = FRONT_MATTER_RE.exec(String(text || ''));
169
+ return m ? m[1] : null;
170
+ }
171
+
172
+ const SEED_KINDS = new Set(['DATE', 'ROW', 'SIGNAL', 'TOPIC']);
173
+ const SEED_STATUSES = new Set(['DORMANT', 'MET', 'SHELVED', 'ACTED ON']);
174
+
175
+ /** Field checks for a seed whose record control is YAML front matter.
176
+ * Presence of the block is not enough: trigger kind and status have to be
177
+ * values the template names, or a seed can pass while remaining invisible
178
+ * to a startup scan. */
179
+ export function checkSeedFrontMatter(block) {
180
+ const failures = [];
181
+ const src = String(block || '');
182
+ const kinds = [];
183
+ const tests = [];
184
+ let status = null, resumes = null, planted = null, plantedBy = null, session = null;
185
+ for (const raw of src.split(/\r?\n/)) {
186
+ const line = raw.trim().replace(/^-\s+/, '');
187
+ const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/);
188
+ if (!kv) continue;
189
+ const key = kv[1].toLowerCase();
190
+ const val = kv[2].trim();
191
+ if (key === 'kind') kinds.push(val.split(/\s+/)[0].toUpperCase());
192
+ else if (key === 'test') tests.push(val);
193
+ else if (key === 'status') status = val.toUpperCase();
194
+ else if (key === 'resumes_when') resumes = val;
195
+ else if (key === 'planted') planted = val;
196
+ else if (key === 'planted_by') plantedBy = val;
197
+ else if (key === 'session') session = val;
198
+ }
199
+ if (kinds.length === 0) failures.push('seed front matter names no trigger kind');
200
+ for (const k of kinds) {
201
+ if (!SEED_KINDS.has(k)) failures.push(`seed trigger kind "${k}" is not DATE, ROW, SIGNAL or TOPIC`);
202
+ }
203
+ if (tests.length === 0 || tests.every((t) => !t || /^_+$/.test(t))) {
204
+ failures.push('seed front matter trigger has no test');
205
+ }
206
+ if (!status) failures.push('seed front matter names no status');
207
+ else if (!SEED_STATUSES.has(status)) failures.push(`seed status "${status}" is not DORMANT, MET, SHELVED or ACTED ON`);
208
+ if (status === 'SHELVED' && (!resumes || /^_+$/.test(resumes))) {
209
+ failures.push('shelved seed names no resumes_when');
210
+ }
211
+ if (!planted || /^_+$/.test(planted)) failures.push('seed front matter names no planted date');
212
+ if (!plantedBy || /^_+$/.test(plantedBy)) failures.push('seed front matter names no planted_by');
213
+ if (!session || /^_+$/.test(session)) failures.push('seed front matter names no session');
214
+ return failures;
215
+ }
216
+
217
+ /**
218
+ * An unfilled placeholder is a run of two or more underscores that is not part
219
+ * of a longer word (snake__case is not a placeholder). Limit stated plainly:
220
+ * markdown's `__bold__` would also match, so templates and records use `**` for
221
+ * emphasis. Flagged rather than silently tolerated - a half-filled record
222
+ * reported as done is the failure this check exists for.
223
+ */
224
+ const PLACEHOLDER_RE = /(?<![A-Za-z0-9])_{2,}(?![A-Za-z0-9])/g;
225
+
226
+ export function countPlaceholders(text) {
227
+ const m = String(text || '').match(PLACEHOLDER_RE);
228
+ return m ? m.length : 0;
229
+ }
230
+
231
+ // ============================================
232
+ // THE MAP - which documents are templated
233
+ // ============================================
234
+ //
235
+ // L3: settings may ADD a mapping; they may never remove or redirect a base one.
236
+ // The board and handoff ids come from the same settings emet_initialize and
237
+ // emet_session_close use, so the document the startup reads is always the
238
+ // document that is validated - it cannot be unmapped by renaming it.
239
+ //
240
+ // Leaving the record control block out does not evade the check: the map is
241
+ // consulted by doc_id, independently of what the record declares.
242
+
243
+ /** The base map. Exact ids first, then prefixes. Never removable. */
244
+ export function baseMap() {
245
+ return {
246
+ exact: {
247
+ [setting('HANDOFF_DOC', 'HANDOFF.md')]: 'templates/HANDOFF.md',
248
+ [setting('STATE_DOC', 'STATE.md')]: 'templates/BOARD.md',
249
+ 'bootstrap/IDENTITY.md': 'templates/IDENTITY.md',
250
+ 'bootstrap/CHARACTER.md': 'templates/CHARACTER.md',
251
+ 'bootstrap/SOUL.md': 'templates/CHARACTER.md',
252
+ 'bootstrap/PERSONALITY.md': 'templates/CHARACTER.md',
253
+ 'bootstrap/USER.md': 'templates/USER.md',
254
+ [setting('INSTALL_DOC', 'bootstrap/INSTALL.md')]: 'templates/INSTALL.md'
255
+ },
256
+ prefix: {
257
+ 'seeds/': 'templates/SEED.md',
258
+ 'threads/': 'templates/THREAD.md',
259
+ 'staging/': 'templates/STAGING.md',
260
+ 'procedures/': 'templates/PROCEDURE.md'
261
+ }
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Additional mappings from EMET_TEMPLATE_MAP, formatted `id=template,...`.
267
+ * Additive only: an entry that would redirect or remove a base mapping is
268
+ * IGNORED and reported, never applied (L3).
269
+ */
270
+ export function extraMappings(raw = setting('TEMPLATE_MAP')) {
271
+ const added = {}, refused = [];
272
+ const base = baseMap();
273
+ for (const pair of String(raw ?? '').split(',')) {
274
+ const [k, v] = pair.split('=').map((s) => (s || '').trim());
275
+ if (!k || !v) continue;
276
+ const collides = Object.prototype.hasOwnProperty.call(base.exact, k)
277
+ || Object.keys(base.prefix).some((p) => k === p);
278
+ if (collides) { refused.push(`${k} is a base mapping and cannot be redirected (L3)`); continue; }
279
+ added[k] = v;
280
+ }
281
+ return { added, refused };
282
+ }
283
+
284
+ /** Which template governs this doc_id, or null. */
285
+ export function templateFor(doc_id) {
286
+ if (!doc_id) return null;
287
+ const base = baseMap();
288
+ if (base.exact[doc_id]) return base.exact[doc_id];
289
+ const { added } = extraMappings();
290
+ if (added[doc_id]) return added[doc_id];
291
+ for (const [prefix, tpl] of Object.entries(base.prefix)) {
292
+ if (doc_id.startsWith(prefix)) return tpl;
293
+ }
294
+ for (const [k, tpl] of Object.entries(added)) {
295
+ if (k.endsWith('/') && doc_id.startsWith(k)) return tpl;
296
+ }
297
+ return null;
298
+ }
299
+
300
+ /** Base templates and the charter - the documents L1 and L5 protect. */
301
+ export function isGovernedByShippedCopy(doc_id) {
302
+ return doc_id === 'CHARTER.md' || /^templates\/[A-Z]+\.md$/.test(String(doc_id || ''));
303
+ }
304
+
305
+ /** A drop id. Retired drops still count. */
306
+ export function isDropDoc(doc_id) {
307
+ return String(doc_id || '').startsWith('drops/');
308
+ }
309
+
310
+ // ============================================
311
+ // THE SHIPPED COPIES (L1)
312
+ // ============================================
313
+
314
+ const shippedCache = new Map();
315
+
316
+ /** Read a shipped template from the release. Absence is reported, never thrown:
317
+ * a release built without its templates must still serve writes. */
318
+ export function loadShipped(template_id, { dir = SHIPPED_TEMPLATES_DIR, cache = true } = {}) {
319
+ // The directory is part of the key. Caching by template id alone would let a
320
+ // lookup against one directory answer a lookup against another, which is
321
+ // wrong in exactly the case that matters: two copies of the same template id
322
+ // that differ.
323
+ const key = `${dir}|${template_id}`;
324
+ if (cache && shippedCache.has(key)) return shippedCache.get(key);
325
+ const file = path.join(dir, path.basename(template_id));
326
+ let parsed = null;
327
+ try {
328
+ parsed = parseTemplate(fs.readFileSync(file, 'utf8'), { doc_id: template_id, source: 'shipped' });
329
+ } catch {
330
+ parsed = null;
331
+ }
332
+ if (cache) shippedCache.set(key, parsed);
333
+ return parsed;
334
+ }
335
+
336
+ /** Test seam: forget what was read from disk. */
337
+ export function clearShippedCache() { shippedCache.clear(); }
338
+
339
+ // ============================================
340
+ // THE CHECKS (STANDARD 9)
341
+ // ============================================
342
+
343
+ const ID_RE = /\bB-\d{3,}\b/g;
344
+
345
+ /** The cells of a markdown table row. `| a | b |` splits to ['', ' a ', ' b ', ''];
346
+ * the outer empties are dropped so cells[i] is the i-th column. */
347
+ function cells(row) {
348
+ const parts = String(row || '').split('|');
349
+ if (parts.length > 2) return parts.slice(1, -1).map((c) => c.trim());
350
+ return [String(row || '').trim()];
351
+ }
352
+
353
+ /** The id cell of a row. The id column is located by its HEADER ("Id"), not by
354
+ * position: the user asked (2026-09-15) that the id, which means nothing to
355
+ * him in conversation, sit at the END of each row rather than lead it. A
356
+ * template that still puts it first is read the same way. No "Id" header at
357
+ * all falls back to the first cell, which is what every board before this
358
+ * revision had. */
359
+ function idCell(row, idIndex) {
360
+ const c = cells(row);
361
+ return (idIndex !== null && idIndex < c.length ? c[idIndex] : c[0]) || '';
362
+ }
363
+
364
+ /**
365
+ * Row ids taken from the ID COLUMN of each section's table.
366
+ *
367
+ * ⚠ NOT SCRAPED FROM THE SECTION BODY, and that is the whole point. Reading
368
+ * every B-### in the body made an id MENTIONED IN PROSE inside another row -
369
+ * a closed row named in a note, an item cross-referenced in a next action -
370
+ * read as a row of its own, so the carry-forward check reported it missing
371
+ * from the next revision. Found by running the validator against the reference
372
+ * install's own board, 2026-09-11: two rows were both closed on the prior
373
+ * revision and appeared only inside another row's text.
374
+ *
375
+ * The failure direction matters and is recorded so no one "fixes" this by
376
+ * loosening it: the bug made the check NOISY, never permissive. Nothing passed
377
+ * that should have failed. A row that genuinely leaves the board undispositioned
378
+ * is still caught, because a real row has a cell in the id column.
379
+ */
380
+ function rowIds(sections, nums) {
381
+ const wanted = new Set(nums);
382
+ const ids = new Set();
383
+ for (const s of sections) {
384
+ if (!wanted.has(s.num)) continue;
385
+ const idIndex = idColumnIndex(s.body);
386
+ for (const row of tableRows(s.body)) {
387
+ for (const id of idCell(row, idIndex).match(ID_RE) || []) ids.add(id);
388
+ }
389
+ }
390
+ return ids;
391
+ }
392
+
393
+ /** Index of the column headed "Id" in the first table of a section body, or
394
+ * null when no such header exists. */
395
+ function idColumnIndex(body) {
396
+ const header = String(body || '').split('\n').map((l) => l.trim()).find((l) => l.startsWith('|'));
397
+ if (!header) return null;
398
+ const i = cells(header).findIndex((h) => /^id$/i.test(h));
399
+ return i === -1 ? null : i;
400
+ }
401
+
402
+ /** Data rows of the first markdown table in a section body. */
403
+ function tableRows(body) {
404
+ const lines = String(body || '').split('\n').map((l) => l.trim());
405
+ const rows = [];
406
+ let seenHeader = false, seenRule = false;
407
+ for (const line of lines) {
408
+ if (!line.startsWith('|')) { if (seenRule && rows.length) break; continue; }
409
+ if (!seenHeader) { seenHeader = true; continue; }
410
+ if (!seenRule) { seenRule = /^\|[\s:|-]+\|$/.test(line); continue; }
411
+ rows.push(line);
412
+ }
413
+ return rows;
414
+ }
415
+
416
+ /**
417
+ * Check one record against one parsed template.
418
+ *
419
+ * Returns failures (nonconformances) and warnings (reported, not counted).
420
+ * Pure: no database, no disk, no clock beyond `checked_at` supplied by the
421
+ * caller - so every check is assertable in a test without a live store.
422
+ */
423
+ export function checkRecord(content, template, { previous = null, doc_id = null } = {}) {
424
+ const failures = [], warnings = [];
425
+ const text = String(content || '');
426
+ const sections = parseSections(text);
427
+ const byNum = new Map(sections.map((s) => [s.num, s]));
428
+
429
+ // 1. Record control: a §0 section that names the template it was filled from -
430
+ // or, where the record control IS the front matter, the front matter itself.
431
+ //
432
+ // This demanded a `## §0` heading unconditionally until 2026-09-13. A seed's
433
+ // record control is YAML front matter by design, and front matter cannot sit
434
+ // underneath a heading and remain front matter, so no correctly-written seed
435
+ // could satisfy it. Every seed in the reference store was marked nonconforming
436
+ // for obeying its own template, and a check that cannot be passed is worse than
437
+ // no check: it marks everything, and a marker that is always on is one nobody
438
+ // reads. Nothing is relaxed here - a record carrying neither still fails.
439
+ const zero = byNum.get('0');
440
+ const control = zero ? zero.body : parseFrontMatter(text);
441
+ if (!control) {
442
+ failures.push('record control block missing: no `## §0` section and no front matter');
443
+ } else if (!controlDocId(control) && !/Template\s*:/i.test(control)) {
444
+ failures.push('record control block names no template id');
445
+ }
446
+
447
+ // Seed record control is front matter by design (templates/SEED.md product
448
+ // note). Presence of the block was already required above; kind and status
449
+ // are checked here so a seed can fail for a bad trigger rather than only
450
+ // for a missing heading.
451
+ if (template.template_id === 'templates/SEED.md') {
452
+ const fm = parseFrontMatter(text) || (!zero ? control : null);
453
+ if (fm) failures.push(...checkSeedFrontMatter(fm));
454
+ }
455
+
456
+ // 2. Every required section present, in order.
457
+ const present = sections.map((s) => s.num);
458
+ let cursor = -1;
459
+ for (const req of template.required_sections) {
460
+ const at = present.indexOf(req.num);
461
+ if (at === -1) { failures.push(`required section §${req.num} ${req.title} is missing`); continue; }
462
+ if (at < cursor) failures.push(`section §${req.num} appears out of order`);
463
+ cursor = Math.max(cursor, at);
464
+ const got = byNum.get(req.num);
465
+ // Heading drift is a warning, never a failure: matching on the § number and
466
+ // the first word keeps a retitled section from reading as an absent one.
467
+ if (req.first_word && got.first_word && got.first_word !== req.first_word) {
468
+ warnings.push(`§${req.num} is titled "${got.title}"; the template says "${req.title}"`);
469
+ }
470
+ // 3. No empty required section.
471
+ if (!got.body || got.body.replace(/[\s|:-]/g, '') === '') {
472
+ failures.push(`required section §${req.num} ${req.title} is empty`);
473
+ }
474
+ }
475
+
476
+ // 4. No unfilled placeholder survived from the template.
477
+ const left = countPlaceholders(text);
478
+ if (left > 0) failures.push(`${left} unfilled template placeholder${left === 1 ? '' : 's'} left in the record`);
479
+
480
+ // 5. NOT COMPLETED carries a reason.
481
+ for (const m of text.match(/NOT (?:COMPLETED|CHECKED)[^\n]*/g) || []) {
482
+ if (!/NOT (?:COMPLETED|CHECKED)\s*[—–-]\s*\S/.test(m)) {
483
+ failures.push(`"${m.trim().slice(0, 60)}" states no reason`);
484
+ }
485
+ }
486
+
487
+ // 6. The board's own check: the §0 counts must match the rows they describe.
488
+ if (template.template_id === 'templates/BOARD.md' && zero) {
489
+ const counted = {
490
+ 'Half-state': tableRows((byNum.get('1') || {}).body).length,
491
+ 'Closed this revision': tableRows((byNum.get('4') || {}).body).length
492
+ };
493
+ const declared = tableRows(zero.body)[0];
494
+ if (declared) {
495
+ const cells = declared.split('|').map((c) => c.trim()).filter((c) => c !== '');
496
+ const headerCells = (zero.body.split('\n').find((l) => l.includes('Half-state')) || '')
497
+ .split('|').map((c) => c.trim()).filter((c) => c !== '');
498
+ for (const [label, actual] of Object.entries(counted)) {
499
+ const i = headerCells.findIndex((h) => h.toLowerCase() === label.toLowerCase());
500
+ if (i === -1) continue;
501
+ const stated = Number(cells[i]);
502
+ if (Number.isFinite(stated) && stated !== actual) {
503
+ failures.push(`§0 states ${label} ${stated}; §${label === 'Half-state' ? '1' : '4'} holds ${actual} row${actual === 1 ? '' : 's'}`);
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ // 7. CARRY-FORWARD. Every row id on the previous revision's open sections is
510
+ // still present in this one, somewhere - open, or dispositioned in §4.
511
+ // The prior revision is already in hand at the door, so this is a
512
+ // comparison and not a judgment. This check alone would have caught the
513
+ // two job-application rows that left the board with nobody deciding they
514
+ // should (reference install, September 2026).
515
+ if (previous && typeof previous === 'string') {
516
+ const before = rowIds(parseSections(previous), ['1', '2', '3']);
517
+ const after = rowIds(sections, ['1', '2', '3', '4']);
518
+ const dropped = [...before].filter((id) => !after.has(id));
519
+ if (dropped.length) {
520
+ failures.push(
521
+ `${dropped.length} row${dropped.length === 1 ? '' : 's'} on the previous revision ` +
522
+ `left this one with no disposition: ${dropped.sort().join(', ')}`
523
+ );
524
+ }
525
+ }
526
+
527
+ return { failures, warnings };
528
+ }
529
+
530
+ // ============================================
531
+ // THE DOOR'S ENTRY POINT
532
+ // ============================================
533
+
534
+ /**
535
+ * Validate one document write. Called from writeDoc() on every write, with no
536
+ * argument that can turn it off (L2).
537
+ *
538
+ * NEVER THROWS for nonconformance and never alters the content. A validator
539
+ * fault is itself reported as a warning rather than failing the write: the
540
+ * write path's job is to store the document, and a check that can block one is
541
+ * a check that can lose one.
542
+ *
543
+ * @param {string} doc_id
544
+ * @param {string} content the content about to be written, post-redaction
545
+ * @param {string|null} previous the prior revision's content, already in hand
546
+ * @returns {object} the conformance record stored on the row and returned in
547
+ * the receipt.
548
+ */
549
+ export function validateDocument(doc_id, content, previous = null, { dir } = {}) {
550
+ const checked_at = new Date().toISOString();
551
+ try {
552
+ const failures = [], warnings = [];
553
+
554
+ // L1/L5: a base template or the charter, written into the store. The
555
+ // shipped copy is the authority; the store copy is for reading. Drift is a
556
+ // nonconformance on the STORE copy and changes nothing about the shipped one.
557
+ if (isGovernedByShippedCopy(doc_id)) {
558
+ // The charter ships at the repository ROOT, beside the templates
559
+ // directory, not inside it. Until 2026-09-15 this lookup joined the
560
+ // templates directory with the charter's basename, found nothing, and
561
+ // reported "no shipped copy" as a warning - so the L1 lock the charter's
562
+ // own section 9 promises had never once fired. Found by the store write
563
+ // of the revised charter returning that warning.
564
+ const shippedDir = dir ? dir : (doc_id === 'CHARTER.md' ? path.dirname(SHIPPED_TEMPLATES_DIR) : SHIPPED_TEMPLATES_DIR);
565
+ const shipped = loadShipped(doc_id, { dir: shippedDir });
566
+ if (!shipped) {
567
+ warnings.push(`no shipped copy of ${doc_id} found in this release; the store copy cannot be checked against it (L1)`);
568
+ } else if (String(shipped.body).replace(/\r\n/g, '\n').trim() !== String(content).replace(/\r\n/g, '\n').trim()) {
569
+ failures.push(`store copy of ${doc_id} differs from the copy shipped with this release; the shipped copy governs (L1)`);
570
+ }
571
+ return {
572
+ template: null, template_status: null, verdict: failures.length ? VERDICT.NONCONFORMING : VERDICT.CONFORMS,
573
+ governed_by: 'shipped release copy', failures, warnings, checked_at
574
+ };
575
+ }
576
+
577
+ const mapped_id = templateFor(doc_id);
578
+ const { refused } = extraMappings();
579
+ for (const r of refused) warnings.push(r);
580
+
581
+ if (!mapped_id) {
582
+ return { template: null, template_status: null, verdict: VERDICT.UNTEMPLATED, failures: [], warnings, checked_at };
583
+ }
584
+
585
+ const template = loadShipped(mapped_id, dir ? { dir } : {});
586
+ if (!template) {
587
+ return {
588
+ template: mapped_id, template_status: null, verdict: VERDICT.UNGATED,
589
+ failures: [], checked_at,
590
+ warnings: [...warnings, `${mapped_id} is not present in this release, so ${doc_id} could not be validated`]
591
+ };
592
+ }
593
+
594
+ // Gating: nothing is marked until the user adopts a template. Before
595
+ // that, the build is silent - which is what let it ship ahead of adoption.
596
+ if (template.status !== STATUS.IN_FORCE) {
597
+ return {
598
+ template: mapped_id, template_status: template.status, verdict: VERDICT.UNGATED,
599
+ failures: [], warnings, checked_at,
600
+ note: `${mapped_id} is ${template.status || 'unstated'}; records are checked but not marked until it is IN FORCE.`
601
+ };
602
+ }
603
+ // A template cannot be IN FORCE without its approval recorded (STANDARD 6;
604
+ // ISO 13485 4.2.4 - review and approval before issue).
605
+ if (!template.approved) {
606
+ warnings.push(`${mapped_id} is IN FORCE with no approval recorded (who · date · decision record)`);
607
+ }
608
+
609
+ const result = checkRecord(content, template, { previous, doc_id });
610
+ failures.push(...result.failures);
611
+ warnings.push(...result.warnings);
612
+
613
+ // L4: a record that declares a DIFFERENT template is checked against BOTH.
614
+ // Declaring a weaker local template adds a check; it never replaces one.
615
+ const declared = controlDocId((parseSections(content).find((s) => s.num === '0') || {}).body || '');
616
+ if (declared && declared !== mapped_id) {
617
+ warnings.push(`record declares ${declared}; the map says ${mapped_id}. Validated against both (L4).`);
618
+ const alt = loadShipped(declared, dir ? { dir } : {});
619
+ if (alt && alt.status === STATUS.IN_FORCE) {
620
+ const second = checkRecord(content, alt, { previous, doc_id });
621
+ for (const f of second.failures) if (!failures.includes(f)) failures.push(`against ${declared}: ${f}`);
622
+ }
623
+ }
624
+
625
+ return {
626
+ template: mapped_id, template_status: template.status,
627
+ verdict: failures.length ? VERDICT.NONCONFORMING : VERDICT.CONFORMS,
628
+ failures, warnings, checked_at
629
+ };
630
+ } catch (error) {
631
+ // The check failed, not the write. Say so and store the document.
632
+ return {
633
+ template: null, template_status: null, verdict: VERDICT.UNTEMPLATED, failures: [], checked_at,
634
+ warnings: [`conformance check could not run: ${error && error.message ? error.message : String(error)}`]
635
+ };
636
+ }
637
+ }
638
+
639
+ // ============================================
640
+ // ACCEPTANCE (L6)
641
+ // ============================================
642
+ //
643
+ // A marker is never cleared silently. It leaves in one of two ways: the next
644
+ // revision passes, or a person accepts it in writing. An acceptance binds to
645
+ // the revision it names and to nothing after it - the next revision is
646
+ // validated fresh, and accepted markers stay counted at session start,
647
+ // separately from open ones, so an acceptance is a disposition and not a
648
+ // disappearance.
649
+
650
+ export const MIN_ACCEPTANCE_REASON = 10;
651
+
652
+ export function buildAcceptance({ reason, by = null, version = null, person_id = null, client_id = null, session_id = null }) {
653
+ const text = String(reason || '').trim();
654
+ if (text.length < MIN_ACCEPTANCE_REASON) {
655
+ throw new Error(
656
+ `An acceptance needs a written reason of at least ${MIN_ACCEPTANCE_REASON} characters. ` +
657
+ `It is the disposition of record for a nonconformance and is read by whoever audits it later.`
658
+ );
659
+ }
660
+ return {
661
+ reason: text, accepted_by: by, accepted_at: new Date().toISOString(), covers_version: version,
662
+ person_id, client_id: client_id || 'stdio', session_id
663
+ };
664
+ }
665
+
666
+ /** True when this row's marker is open: nonconforming, and not accepted FOR
667
+ * THIS revision. An acceptance from an earlier version does not carry. */
668
+ export function isOpenNonconformance(conformance, version = null) {
669
+ if (!conformance || conformance.verdict !== VERDICT.NONCONFORMING) return false;
670
+ const a = conformance.accepted;
671
+ if (!a) return true;
672
+ if (a.covers_version === null || a.covers_version === undefined) return false;
673
+ return Number(a.covers_version) !== Number(version);
674
+ }
675
+
676
+ /** True when the marker stands but has been dispositioned for this revision. */
677
+ export function isAcceptedNonconformance(conformance, version = null) {
678
+ if (!conformance || conformance.verdict !== VERDICT.NONCONFORMING) return false;
679
+ return Boolean(conformance.accepted) && !isOpenNonconformance(conformance, version);
680
+ }
681
+
682
+ export default {
683
+ SHIPPED_TEMPLATES_DIR, STATUS, VERDICT, MIN_ACCEPTANCE_REASON,
684
+ parseSections, parseTemplate, countPlaceholders,
685
+ baseMap, extraMappings, templateFor, isGovernedByShippedCopy,
686
+ loadShipped, clearShippedCache, checkRecord, validateDocument,
687
+ buildAcceptance, isOpenNonconformance, isAcceptedNonconformance
688
+ };