@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,945 @@
1
+ /**
2
+ * EMET - the session graph: the monorail under the guardrails (2026-09-29;
3
+ * the practice is docs/GUARDRAILS-AND-MONORAILS.md).
4
+ *
5
+ * The owner: "guardrails give a field of movement and execution to allow an AI to do
6
+ * processes where rails guide the AI through a process flow without it
7
+ * processing" (a recorded lesson). The floor, the character and the served
8
+ * instructions are the guardrails. This file is the monorail - the car cannot
9
+ * leave the beam; where a flow forks, a small switch owned by the track (never
10
+ * the model) picks the branch (designed 2026-09-29, not yet built): the next step of a
11
+ * session is decided by the session's STATE, read before every call, not by
12
+ * the model remembering it.
13
+ *
14
+ * The graph, for one session:
15
+ * (not open) --session_open / first append--> open
16
+ * open: turn k = any reads, writes that may declare `exchange: k`, then append k
17
+ * close chain, served order: board, episodic record, transcript, HANDOFF last
18
+ * emet_session_close returns complete --> closed (stop condition)
19
+ *
20
+ * Gates (each names the fix; nothing is written when one refuses):
21
+ * not_open a write names a session that was never opened
22
+ * turn_skipped a write or append declares exchange n while n-1 is not appended
23
+ * handoff_early HANDOFF.md written before the board, the episodic record
24
+ * and the transcript exist for this session; for a bot tag's
25
+ * own bots/<tag>/HANDOFF.md, before the episodic record and
26
+ * the transcript (no board; C1, 2026-10-01)
27
+ * no_session a write names no session at all (2026-10-03): open
28
+ * one with emet_session_open, or pass the session_id
29
+ * already opened, and resubmit the same write
30
+ * startup_pages emet_session_open with no load_id, or with pages of
31
+ * that load never fetched (2026-10-03)
32
+ * closed any write after a complete close, except the one
33
+ * closing exchange inside the grace window and a
34
+ * transcript correction (2026-10-01; the transcript doors
35
+ * enforce the same rule outright, below)
36
+ *
37
+ * The honest limit: the server sees tool calls, never the reply text. A host
38
+ * that answers without calling any tool is invisible until its next call; turn
39
+ * coverage is only as strong as the exchange numbers hosts report. Hosts we run
40
+ * ourselves (a Claude Code hook, an owned runner) can be railed completely.
41
+ *
42
+ * MODE: enforce only (2026-10-03). Every skipped step is refused before
43
+ * anything is written, as a ValidationError whose text says how to pass, and
44
+ * the refusal is recorded in the lab. There is no report mode and no off
45
+ * switch; EMET_SESSION_GRAPH is no longer read. A governed write that names a
46
+ * session fails closed when the graph state cannot be read (dispatch.js).
47
+ * A write that names no session still records verdict `unknown` and continues,
48
+ * because there is no session to protect.
49
+ */
50
+
51
+ import { setting } from './env.js';
52
+
53
+ export const GRAPH_MODES = Object.freeze(['enforce']);
54
+
55
+ /** Always 'enforce'. Kept as a function so every block and lab row names its mode. */
56
+ export function graphMode() {
57
+ return 'enforce';
58
+ }
59
+
60
+ /** Tools the graph governs: every tool that writes. Reads are never gated. */
61
+ export const GRAPH_WRITE_TOOLS = Object.freeze(new Set([
62
+ 'save_to_layer', 'revise_memory', 'write_doc', 'patch_doc', 'retire_doc', 'rename_doc', 'restore_doc',
63
+ 'accept_nonconformance', 'emet_transcript_append', 'save_transcript', 'revise_transcript',
64
+ 'emet_session_close'
65
+ ]));
66
+
67
+ export const HANDOFF_DOC = 'HANDOFF.md';
68
+ export const BOARD_DOC = 'STATE.md';
69
+
70
+ /**
71
+ * Per-bot close records (C1, 2026-10-01; the owner's decisions 1-7). The rule, stated once: a session written
72
+ * under a bot's source tag closes against that bot's own records
73
+ * (bots/<tag>/HANDOFF.md); only that tag's own revisions in the session window
74
+ * count; a bot tag never writes or cites the main HANDOFF.md or STATE.md.
75
+ *
76
+ * A tag T is a BOT TAG when bots/T/HANDOFF.md is listed in EMET_RECORD_DOCS,
77
+ * the list that already marks ids as supersede-only records (tools.js
78
+ * recordDocIds). No new setting: the owner adds one id per bot, and removing it
79
+ * puts that tag back on the host rules. Bot records are named by the source
80
+ * tag itself (decision 1), so no tag-to-name mapping exists. These are pure
81
+ * reads of settings - no database - so the graph can key its bot-handoff gate
82
+ * on a write's `source` without an extra read.
83
+ */
84
+ export function botRecordDir(tag) { return `bots/${tag}/`; }
85
+ export function botHandoffDoc(tag) { return `bots/${tag}/HANDOFF.md`; }
86
+
87
+ /** Whether `tag` is a bot tag: bots/<tag>/HANDOFF.md is in EMET_RECORD_DOCS. */
88
+ export function isBotTag(tag) {
89
+ if (typeof tag !== 'string' || !tag.trim() || tag.includes('/')) return false;
90
+ // The same comma list tools.js recordDocIds() reads.
91
+ const listed = String(setting('RECORD_DOCS', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
92
+ return listed.includes(botHandoffDoc(tag.trim()));
93
+ }
94
+
95
+ /** The hub's main records: the handoff and board ids session.js uses (EMET_HANDOFF_DOC / EMET_STATE_DOC). */
96
+ export function mainRecordIds() {
97
+ return [setting('HANDOFF_DOC', 'HANDOFF.md'), setting('STATE_DOC', 'STATE.md')];
98
+ }
99
+
100
+ /**
101
+ * The source tag a write names, read as dispatch.js sourceOf reads it
102
+ * (`source`, then the older aliases). Repeated here because dispatch imports
103
+ * this module. No default: a write with no source takes the host path in the
104
+ * graph.
105
+ */
106
+ export function writeSourceOf(args = {}) {
107
+ const v = args.source ?? args.updated_by ?? args.retired_by ?? args.accepted_by ?? args.channel ?? null;
108
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
109
+ }
110
+
111
+ /** Whether this call writes a bot's own handoff under that bot's tag: write_doc of bots/<source>/HANDOFF.md by a bot tag. */
112
+ export function isBotHandoffWrite(tool, args = {}) {
113
+ if (tool !== 'write_doc') return false;
114
+ const tag = writeSourceOf(args);
115
+ return !!tag && isBotTag(tag) && args.doc_id === botHandoffDoc(tag);
116
+ }
117
+
118
+ /** The base session id of a part id (2026-09-30_x_verbatim_part2 -> 2026-09-30_x). */
119
+ export function baseOf(sessionId) {
120
+ return String(sessionId || '').replace(/_verbatim_part\d+$/, '') || null;
121
+ }
122
+
123
+ /**
124
+ * A caller's id as a refusal echoes it: capped at 64 characters, so a long id
125
+ * cannot push the pass instruction past the 500-character error cap
126
+ * (database.js sanitizeErrorMessage).
127
+ */
128
+ export const ID_ECHO_MAX = 64;
129
+ export function echoId(id) {
130
+ const s = String(id ?? '');
131
+ const cps = Array.from(s); // whole characters: never a lone surrogate in the echo
132
+ return cps.length > ID_ECHO_MAX ? `${cps.slice(0, ID_ECHO_MAX).join('')}…` : s;
133
+ }
134
+
135
+ /**
136
+ * Every session base an id could belong to, the way the close check groups a
137
+ * session's documents (session.js inspectSession: ^base(_.*)?$). An id like
138
+ * `<base>_extra` is counted by the close as part of `<base>`, so the closed-
139
+ * session guard must treat it as `<base>` too (2026-10-01, the reviewer's gate
140
+ * (a)). Longest first: the id itself, then each prefix ending before a `_`.
141
+ */
142
+ export function candidateBases(sessionId) {
143
+ const sid = String(sessionId || '').trim();
144
+ if (!sid) return [];
145
+ const out = [sid];
146
+ for (let i = sid.length - 1; i > 0; i--) if (sid[i] === '_') out.push(sid.slice(0, i));
147
+ return [...new Set(out)];
148
+ }
149
+
150
+ /** Whether `id` is `base` itself or a part id the server mints for it (<base>_verbatim_partN). */
151
+ export function isMintedPartOf(id, base) {
152
+ const sid = String(id || '');
153
+ const prefix = `${base}_verbatim_part`;
154
+ return !!base && (sid === base || (sid.startsWith(prefix) && /^\d+$/.test(sid.slice(prefix.length))));
155
+ }
156
+
157
+ /** Whether `sessionId` is grouped under `base` by the close check (^base(_.*)?$). */
158
+ export function groupedUnder(sessionId, base) {
159
+ const sid = String(sessionId || '');
160
+ return !!base && (sid === base || sid.startsWith(`${base}_`));
161
+ }
162
+
163
+ /**
164
+ * The session a call names: `session_id`, else the transcript span in
165
+ * `metadata.derived_from` (revise_memory refuses metadata.session_id and names
166
+ * its session there - live-caught 2026-09-29, a recorded lesson), else - for
167
+ * emet_session_close, the only tool that takes `receipts` -
168
+ * `receipts.transcript_session_id`. The close tool's description documents
169
+ * that shape ("receipts.transcript_session_id is used when this is omitted")
170
+ * and sessionClose checks that session; until 2026-10-01 the graph read only
171
+ * `session_id`, so a close naming its session only in the receipts was never
172
+ * recorded and the session stayed open (the second reader's nit 3 on an earlier draft, the reviewer's
173
+ * condition 1). Receipts sent as JSON text are read as sessionClose reads
174
+ * them (session.js parseReceipts).
175
+ */
176
+ export function sessionOf(args = {}) {
177
+ const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
178
+ return pick(args.session_id) ||
179
+ pick(args.metadata && args.metadata.derived_from && args.metadata.derived_from.session_id) ||
180
+ pick(receiptsOf(args.receipts) && receiptsOf(args.receipts).transcript_session_id);
181
+ }
182
+
183
+ /** The close's receipts as an object (an object, or JSON text of one), else null. Mirrors session.js parseReceipts. */
184
+ function receiptsOf(value) {
185
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
186
+ if (typeof value === 'string' && value.trim().startsWith('{')) {
187
+ try {
188
+ const parsed = JSON.parse(value);
189
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
190
+ } catch { /* not JSON - treated as not supplied, as sessionClose does */ }
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * An exchange number as sent, normalized ONCE (2026-10-01, the second reader's re-check of
197
+ * an earlier draft). Hosts send numbers as numbers or as text ("4"). The guard read "4"
198
+ * as 4 while emet_transcript_append only took real numbers, so a text "4" was a
199
+ * "retry" to the guard and "the next exchange" to the tool. dispatch now runs
200
+ * every exchange_index through this before the graph, the guard and the tool,
201
+ * so all three see the same number.
202
+ * absent (undefined, null, '') -> null (the tool numbers it next)
203
+ * a whole number >= 1, or its text -> that number
204
+ * anything else -> {error}
205
+ */
206
+ export function normalizeExchangeNumber(v) {
207
+ if (v === undefined || v === null || (typeof v === 'string' && v.trim() === '')) return { value: null };
208
+ let n = v;
209
+ if (typeof n === 'string' && /^\s*\d+\s*$/.test(n)) n = Number(n);
210
+ if (typeof n === 'number' && Number.isInteger(n) && n >= 1) return { value: n };
211
+ return { error: `exchange_index must be a whole number, 1 or more (got ${JSON.stringify(v)})` };
212
+ }
213
+
214
+ /** The exchange a call declares, if any: `exchange` on writes, `exchange_index` on an append. */
215
+ export function declaredExchange(tool, args) {
216
+ let v = tool === 'emet_transcript_append' ? args.exchange_index : args.exchange;
217
+ // Hosts send numbers as numbers or as text ("18") depending on how they read
218
+ // the schema - found on the first live call, 2026-09-29. Both are the same claim.
219
+ if (typeof v === 'string' && /^\s*\d+\s*$/.test(v)) v = Number(v);
220
+ return Number.isInteger(v) && v > 0 ? v : null;
221
+ }
222
+
223
+ // Worded like the transcript doors' refusal (2026-10-01): "open a new session"
224
+ // pushed hosts into stray -N sessions opened just for one late write.
225
+ export const CLOSED_NEXT = 'none in this session: it is closed and locked, and nothing more is written to it. ' +
226
+ 'Further work belongs in the next session, opened the usual way at the next session start; do not open a session just for it';
227
+
228
+ /**
229
+ * What the state says the next step is. `state` is the stored record
230
+ * ({opened, board, episodic, handoff, closed}); `highest` is the transcript's
231
+ * highest appended exchange (0 when none).
232
+ */
233
+ export function nextStep(state = {}, highest = 0) {
234
+ const opened = !!state.opened || highest > 0;
235
+ if (!opened) return 'emet_session_open, then emet_transcript_append exchange 1 after your first reply';
236
+ if (state.closed) return CLOSED_NEXT;
237
+ if (state.handoff) return 'emet_session_close with receipts; the handoff is written';
238
+ return `emet_transcript_append exchange ${highest + 1} after this reply`;
239
+ }
240
+
241
+ /**
242
+ * The decision, pure. Returns null for a tool the graph does not govern, else
243
+ * {verdict: 'ok'|'refuse', gate, reason, next}.
244
+ */
245
+ export function evaluate(tool, args = {}, state = {}, highest = 0, now = new Date()) {
246
+ if (!GRAPH_WRITE_TOOLS.has(tool)) return null;
247
+ const next = nextStep(state, highest);
248
+ const refuse = (gate, reason, owed = next) => ({ verdict: 'refuse', gate, reason, next: owed });
249
+ const opened = !!state.opened || highest > 0;
250
+ const n = declaredExchange(tool, args);
251
+
252
+ if (!opened && !(tool === 'emet_transcript_append' && (n === null || n === 1))) {
253
+ if (tool === 'write_doc' && typeof args.doc_id === 'string' && args.doc_id.startsWith('drops/')) {
254
+ return { verdict: 'ok', gate: 'drop_only', reason: null, next: 'open a session to write anything other than a new drop' };
255
+ }
256
+ return refuse('not_open', `session ${echoId(baseOf(sessionOf(args)))} was never opened. Open it with emet_session_open ` +
257
+ '(with the load_id from your startup pages), then resubmit this same write with the session_id it returns. Until then this session may read, and may write a new drop only.');
258
+ }
259
+ const lateOk = tool === 'revise_transcript' ||
260
+ (tool === 'emet_transcript_append' && ['grace', 'retry'].includes(graceDecision(state, n, highest, now)));
261
+ if (state.closed && !lateOk) {
262
+ return refuse('closed', `session ${echoId(baseOf(sessionOf(args)))} is closed and locked, so nothing was written to it. ` +
263
+ 'This belongs in the next session you open the usual way, at the next session start; do not open a session just for it.',
264
+ 'the same write in the next session, at the next session start');
265
+ }
266
+ if (n !== null && n > highest + 1) {
267
+ return refuse('turn_skipped', `exchange ${highest + 1} of session ${echoId(baseOf(sessionOf(args)))} was never appended. Append it with ` +
268
+ `emet_transcript_append (this session_id, exchange_index ${highest + 1}, that turn's user message and reply)` +
269
+ `${highest + 2 < n ? `, and each exchange after it up to ${n - 1}` : ''}, then resubmit this same call.`);
270
+ }
271
+ // A bot's own handoff takes the same gate, without the board: a bot session
272
+ // has no board unless it keeps one as a working document (C1, decision 5).
273
+ const botHandoff = isBotHandoffWrite(tool, args);
274
+ if (tool === 'write_doc' && (args.doc_id === HANDOFF_DOC || botHandoff)) {
275
+ const missing = [];
276
+ if (!botHandoff && !state.board) missing.push(`the board (${BOARD_DOC}) with patch_doc`);
277
+ if (!state.episodic) missing.push('the episodic session record with save_to_layer (layer "episodic")');
278
+ if (!(highest > 0)) missing.push('the transcript with emet_transcript_append');
279
+ if (missing.length) {
280
+ return refuse('handoff_early', `the handoff is written last. First write ${missing.join(', ')}, each with this ` +
281
+ 'session_id, then resubmit this same handoff write.');
282
+ }
283
+ }
284
+ // An allowed call reports the step AFTER itself: an append of exchange n
285
+ // points to n+1, a handoff points to close. (Live-caught 2026-09-29: the
286
+ // pre-call value named the exchange just appended.) A refusal keeps the
287
+ // pre-call step, because that step is still the one owed.
288
+ let after = { state, highest };
289
+ if (tool === 'emet_transcript_append' && n !== null && n === highest + 1) after = { state: { ...state, opened: true }, highest: n };
290
+ if (tool === 'write_doc' && (args.doc_id === HANDOFF_DOC || botHandoff)) after = { state: { ...state, handoff: true }, highest };
291
+ return { verdict: 'ok', gate: null, reason: null, next: nextStep(after.state, after.highest) };
292
+ }
293
+
294
+ /**
295
+ * What a SUCCESSFUL call adds to the session's state. Returns the fields to
296
+ * set, or null. `data` is the tool's result payload.
297
+ */
298
+ export function recordStep(tool, args = {}, data = {}, now = new Date()) {
299
+ if (tool === 'emet_session_open') {
300
+ // A suffixed id records what it is the suffix of, so a real slug ending in
301
+ // -N is never handed the same id (and vice versa).
302
+ const of = data && data.suffix_applied && data.suffix_applied.requested;
303
+ return { opened: true, opened_at: now, ...(of ? { suffix_of: of } : {}) };
304
+ }
305
+ // An append never opens a board-editing session. The first append is still
306
+ // allowed by evaluate(); the board path requires a verified drops_seen array
307
+ // stamped only by emet_session_open.
308
+ if (tool === 'emet_transcript_append' || tool === 'save_transcript') return null;
309
+ if (tool === 'save_to_layer' && args.layer === 'episodic') return { episodic: true, episodic_id: data && data.id };
310
+ if ((tool === 'write_doc' || tool === 'patch_doc' || tool === 'restore_doc') && args.doc_id === BOARD_DOC) return { board: true, board_version: data && data.version };
311
+ if (tool === 'write_doc' && (args.doc_id === HANDOFF_DOC || isBotHandoffWrite(tool, args))) return { handoff: true, handoff_version: data && data.version };
312
+ if (tool === 'emet_session_close' && data && data.state === 'complete') return { closed: true, closed_at: now };
313
+ return null;
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // The thin store layer. The state lives in `<prefix>sessions`, one document per
318
+ // base session id. This file owns that collection and writes nothing else.
319
+ // ---------------------------------------------------------------------------
320
+
321
+ async function sessionsCollection(dbManager) {
322
+ const { COLLECTION_PREFIX } = await import('./database.js');
323
+ const db = await dbManager.ensureClient();
324
+ return db.collection(`${COLLECTION_PREFIX}sessions`);
325
+ }
326
+
327
+ /**
328
+ * A session's recorded state, for emet_session_open (2026-10-01): whether a
329
+ * row exists, whether it was closed complete, and - if the id was minted as a
330
+ * `-N` suffix - which id it is the suffix of. Throws when the store cannot be
331
+ * read; the caller must not treat "unknown" as "free".
332
+ */
333
+ export async function sessionStatus(base, dbManager) {
334
+ const col = await sessionsCollection(dbManager);
335
+ const row = await col.findOne({ _id: base }, { projection: { closed: 1, suffix_of: 1 } });
336
+ return { exists: !!row, closed: !!(row && row.closed), suffix_of: (row && row.suffix_of) || null };
337
+ }
338
+
339
+ /** Whether a session's state says it was closed complete. Throws when the store cannot be read. */
340
+ export async function isSessionClosed(base, dbManager) {
341
+ return (await sessionStatus(base, dbManager)).closed;
342
+ }
343
+
344
+ /**
345
+ * The closing exchange (2026-10-01, the owner's decision). In the closing turn the
346
+ * assistant writes the handoff, gets `complete` from emet_session_close, and
347
+ * only then replies - so the append of that last exchange always arrives
348
+ * after the close. A closed session therefore takes exactly ONE more append:
349
+ * numbered highest+1, within EMET_CLOSE_GRACE_MINUTES (default 5; 0 turns the
350
+ * window off) of the RECORDED close time `closed_at`. Once it is saved, or
351
+ * once the window passes, the session is locked. A retry of that same
352
+ * exchange number is answered as already saved, never as a second write:
353
+ * the guard answers it itself and the tool is not called, at any time after
354
+ * the close, so a retry can write nothing anywhere (2026-10-01, the second reader's blocker
355
+ * and the reviewer's gate on an earlier draft). The closing exchange itself is written
356
+ * into the session's last live part, whatever part or base id the caller sent.
357
+ *
358
+ * graceDecision is pure: state is the session row, declared the exchange the
359
+ * append names (null when none), highest the transcript's highest exchange.
360
+ * grace the one closing exchange may be saved now
361
+ * retry the closing exchange was saved; this is the same number again
362
+ * used the closing exchange was already saved
363
+ * expired the window has passed
364
+ * not_next the append names a number other than highest+1
365
+ * none no window applies (not closed, closed_at not recorded, or off)
366
+ */
367
+ export const DEFAULT_CLOSE_GRACE_MINUTES = 5;
368
+ export const MAX_CLOSE_GRACE_MINUTES = 60;
369
+
370
+ /**
371
+ * The window setting, checked (2026-10-01, the second reader's nit 4). Unset or blank: the
372
+ * default, 5. A number from 0 to 60: that many minutes (0 turns the window
373
+ * off). Anything else is NOT silently accepted: a negative or non-numeric value
374
+ * falls back to the default 5, and a value over 60 is capped at 60; both come
375
+ * back with a `note` that the guard logs as a warning. Only decimal numbers
376
+ * count: "0x10" or "0b11" is non-numeric here. Returns {minutes, note}.
377
+ *
378
+ * The name is EMET_CLOSE_GRACE_MINUTES. It is read through env.js setting(),
379
+ * so the legacy CASCADE_CLOSE_GRACE_MINUTES is still read as a deprecated
380
+ * fallback; EMET_ wins when both are set. (Not every EMET_ setting does this:
381
+ * a few read process.env directly - see
382
+ * docs/DEPLOY.md.)
383
+ */
384
+ function closeGraceName() {
385
+ return process.env.EMET_CLOSE_GRACE_MINUTES === undefined && process.env.CASCADE_CLOSE_GRACE_MINUTES !== undefined
386
+ ? 'CASCADE_CLOSE_GRACE_MINUTES (deprecated; use EMET_CLOSE_GRACE_MINUTES)'
387
+ : 'EMET_CLOSE_GRACE_MINUTES';
388
+ }
389
+
390
+ export function closeGraceSetting(raw = setting('CLOSE_GRACE_MINUTES'), name = closeGraceName()) {
391
+ if (raw === undefined || raw === null || String(raw).trim() === '') return { minutes: DEFAULT_CLOSE_GRACE_MINUTES, note: null };
392
+ // Decimal only: Number() also reads hex, octal and binary ("0x10" is 16),
393
+ // which no one means for minutes (the second reader's nit 5 on an earlier draft).
394
+ const text = String(raw).trim();
395
+ const n = /^[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i.test(text) ? Number(text) : NaN;
396
+ if (!Number.isFinite(n) || n < 0) {
397
+ return { minutes: DEFAULT_CLOSE_GRACE_MINUTES,
398
+ note: `${name}=${JSON.stringify(String(raw))} is not a number of minutes from 0 to ${MAX_CLOSE_GRACE_MINUTES}; the default ${DEFAULT_CLOSE_GRACE_MINUTES} is used.` };
399
+ }
400
+ if (n > MAX_CLOSE_GRACE_MINUTES) {
401
+ return { minutes: MAX_CLOSE_GRACE_MINUTES,
402
+ note: `${name}=${JSON.stringify(String(raw))} is over the ${MAX_CLOSE_GRACE_MINUTES}-minute cap; ${MAX_CLOSE_GRACE_MINUTES} is used.` };
403
+ }
404
+ return { minutes: n, note: null };
405
+ }
406
+
407
+ export function closeGraceMinutes(raw = setting('CLOSE_GRACE_MINUTES')) {
408
+ return closeGraceSetting(raw).minutes;
409
+ }
410
+
411
+ const graceNotesLogged = new Set();
412
+ function logGraceNote(note, logger) {
413
+ if (!note || graceNotesLogged.has(note)) return;
414
+ graceNotesLogged.add(note);
415
+ if (logger && typeof logger.warn === 'function') logger.warn(`closing exchange: ${note}`);
416
+ else console.error(`[emet] closing exchange: ${note}`); // stderr: stdout is the stdio transport's
417
+ }
418
+
419
+ export function graceDecision(state = {}, declared = null, highest = 0, now = new Date(), minutes = closeGraceMinutes()) {
420
+ if (!state || !state.closed) return 'none';
421
+ if (state.grace_used) return declared !== null && declared === state.grace_index ? 'retry' : 'used';
422
+ const at = state.closed_at ? new Date(state.closed_at).getTime() : NaN;
423
+ if (!(minutes > 0) || !Number.isFinite(at)) return 'none';
424
+ const elapsed = now.getTime() - at;
425
+ if (elapsed < 0 || elapsed > minutes * 60000) return 'expired';
426
+ if (declared !== null && declared !== highest + 1) return 'not_next';
427
+ return 'grace';
428
+ }
429
+
430
+ /**
431
+ * Claims (or, on `release`, gives back) the closing exchange, atomically: the
432
+ * claim matches only a closed row whose grace is unused, so two racing
433
+ * appends cannot both win. Released when the claimed append then fails or
434
+ * wrote nothing, so a failed write does not spend the window. Returns true
435
+ * when the claim landed. Its own collection, `<prefix>sessions` - never a
436
+ * layer, document or transcript.
437
+ */
438
+ export async function markCloseGrace(base, dbManager, { index = null, now = new Date(), release = false } = {}) {
439
+ const col = await sessionsCollection(dbManager);
440
+ if (release) {
441
+ await col.updateOne({ _id: base }, { $unset: { grace_used: '', grace_index: '', grace_used_at: '' } });
442
+ return true;
443
+ }
444
+ const res = await col.updateOne(
445
+ { _id: base, closed: true, grace_used: { $ne: true } },
446
+ { $set: { grace_used: true, grace_index: index, grace_used_at: now } }
447
+ );
448
+ return !!(res && res.modifiedCount === 1);
449
+ }
450
+
451
+ /**
452
+ * The transcript doors and a closed session (2026-10-01). Runs before the tool,
453
+ * so a refusal writes nothing. A closed session takes no new exchanges,
454
+ * except its one closing exchange (above).
455
+ *
456
+ * emet_transcript_append refused, except the closing exchange
457
+ * save_transcript refused (a whole-session save adds exchanges)
458
+ * revise_transcript ALLOWED - corrections supersede and delete
459
+ * nothing - but capped: a revision of a closed
460
+ * session's part may correct the exchanges that part
461
+ * holds, never add new ones, so the session's
462
+ * exchange count cannot grow.
463
+ *
464
+ * Fail-safe: if the session's state, its transcript
465
+ * position or the part to be revised cannot be read, nothing is written.
466
+ * Returns null (go ahead), or:
467
+ * {grace, base, index, part, notice} the closing exchange: dispatch writes it
468
+ * as exchange `index` into `part`, never
469
+ * where the caller pointed
470
+ * {retry, base, index, notice} a retry of the saved closing exchange:
471
+ * dispatch answers it and the tool is
472
+ * NOT called, so nothing can be written
473
+ * {revise, base, exchanges} a correction of a closed session:
474
+ * the exchanges, numbers normalized
475
+ */
476
+ export const TRANSCRIPT_WRITE_TOOLS = Object.freeze(new Set(['emet_transcript_append', 'save_transcript', 'revise_transcript']));
477
+
478
+ async function refusal(field, message) {
479
+ const { ValidationError } = await import('./validation.js');
480
+ return new ValidationError(field, message);
481
+ }
482
+
483
+ async function unreadable(tool, base, what, e) {
484
+ const { DatabaseError } = await import('./database.js');
485
+ return new DatabaseError(`${tool} could not read ${what} for session ${echoId(base)}, so nothing was written: ${e && e.message}. ` +
486
+ 'Nothing was lost; send the same call again.', tool);
487
+ }
488
+
489
+ export async function closedSessionGuard(tool, args = {}, dbManager, now = new Date(), logger = null) {
490
+ if (!TRANSCRIPT_WRITE_TOOLS.has(tool)) return null;
491
+ const sid = typeof args.session_id === 'string' ? args.session_id.trim() : '';
492
+ if (!sid) return null;
493
+
494
+ // The closed session this id belongs to, grouped as the close check groups
495
+ // it: <base>, <base>_verbatim_partN and any other <base>_... id alike.
496
+ let row = null;
497
+ let base = baseOf(sid);
498
+ try {
499
+ const col = await sessionsCollection(dbManager);
500
+ for (const b of candidateBases(sid)) {
501
+ const r = await col.findOne({ _id: b }, { projection: { closed: 1, closed_at: 1, grace_used: 1, grace_index: 1 } });
502
+ if (r && r.closed) { row = r; base = b; break; }
503
+ }
504
+ } catch (e) { throw await unreadable(tool, base, 'whether the session is closed', e); }
505
+ if (!row) return null;
506
+
507
+ const locked = (why) => refusal('session_id',
508
+ `session ${echoId(base)} is closed and locked${why}, so nothing was written to it. Nothing in it was lost or changed. ` +
509
+ 'This exchange belongs in the next session you open the usual way, at the next session start; do not open a session just for it.');
510
+
511
+ if (tool === 'revise_transcript') {
512
+ let part;
513
+ try {
514
+ const db = await dbManager.ensureClient();
515
+ const t = db.collection('transcripts');
516
+ part = await t.findOne({ session_id: sid, superseded_by: { $in: [null, undefined] } }) || await t.findOne({ session_id: sid });
517
+ } catch (e) { throw await unreadable(tool, base, 'the transcript part to be revised', e); }
518
+ if (!part) return null; // the tool itself refuses a revision of a part that does not exist
519
+ // A correction of a closed session keeps EXACTLY the exchange numbers the
520
+ // part holds (2026-10-01, the second reader's nit 2): none added, none dropped, none
521
+ // duplicated. Numbers sent as text are stored as numbers; the corrected
522
+ // part is stored in exchange order.
523
+ const held = [...new Set((part.exchanges || []).map((e) => Number(e && e.exchange_index)))].sort((a, b) => a - b);
524
+ const heldText = held.join(', ') || 'none';
525
+ const revised = Array.isArray(args.exchanges) ? args.exchanges : [];
526
+ const fail = async (what) => refusal('exchanges',
527
+ `session ${echoId(base)} is closed: a revision may correct the exchanges ${echoId(sid)} already holds (${held.length}: ${heldText}) ` +
528
+ `and must carry exactly those numbers: never add new ones, never drop or repeat one - ${what}. Nothing was written.`);
529
+ const numbered = [];
530
+ for (let i = 0; i < revised.length; i++) {
531
+ const ex = revised[i];
532
+ const raw = ex && ex.exchange_index !== undefined ? ex.exchange_index : i; // the tool's own default
533
+ const { value, error } = normalizeExchangeNumber(raw);
534
+ if (error || value === null) throw await fail(`exchange ${i + 1} of the revision has no valid exchange_index (${JSON.stringify(raw)})`);
535
+ numbered.push({ ...ex, exchange_index: value });
536
+ }
537
+ const nums = numbered.map((e) => e.exchange_index);
538
+ const heldSet = new Set(held);
539
+ const added = [...new Set(nums.filter((n) => !heldSet.has(n)))];
540
+ if (added.length) {
541
+ throw await fail(`exchange ${added.join(', ')} is not in that part` + (revised.length > held.length ? `, and the revision has ${revised.length}` : ''));
542
+ }
543
+ const repeated = [...new Set(nums.filter((n, i) => nums.indexOf(n) !== i))];
544
+ if (repeated.length) throw await fail(`exchange ${repeated.join(', ')} appears more than once`);
545
+ const dropped = held.filter((n) => !nums.includes(n));
546
+ if (dropped.length) throw await fail(`exchange ${dropped.join(', ')} is missing from the revision`);
547
+ return { revise: true, base, exchanges: numbered.sort((a, b) => a.exchange_index - b.exchange_index) };
548
+ }
549
+
550
+ if (tool === 'save_transcript') throw await locked(' (a whole-transcript save adds exchanges, which a closed session does not take)');
551
+
552
+ // emet_transcript_append. dispatch has already normalized exchange_index.
553
+ let pos;
554
+ try {
555
+ pos = typeof dbManager.transcriptPosition === 'function' ? await dbManager.transcriptPosition(base) : null;
556
+ if (!pos || typeof pos.highest !== 'number') throw new Error('the transcript position is not available');
557
+ } catch (e) { throw await unreadable(tool, base, 'the transcript position', e); }
558
+ const highest = pos.highest;
559
+ const declared = declaredExchange(tool, args);
560
+ const setting = closeGraceSetting();
561
+ logGraceNote(setting.note, logger);
562
+ const minutes = setting.minutes;
563
+ const d = graceDecision(row, declared, highest, now, minutes);
564
+
565
+ if (d === 'retry') {
566
+ // Answered HERE, and the tool is never called (2026-10-01, the second reader's blocker on
567
+ // an earlier draft): a retry handed to the tool with the caller's arguments could
568
+ // land in another part id, the base id, or - with the number sent as text -
569
+ // as a new exchange, while the reply said nothing was written.
570
+ return { retry: true, base, index: row.grace_index,
571
+ notice: `Exchange ${row.grace_index} is already saved as the closing exchange of session ${base}. Nothing new was written; the session is locked.` };
572
+ }
573
+ if (d === 'grace') {
574
+ const index = highest + 1;
575
+ // Pinned to the session's last live part (2026-10-01, the second reader's nit 3), never
576
+ // the part or base id the caller named. If that part is full, the append
577
+ // rolls to the next part number exactly as a live append does.
578
+ let part = typeof pos.last_part === 'string' && pos.last_part ? pos.last_part : null;
579
+ if (!part && highest === 0) part = `${base}_verbatim_part1`;
580
+ if (!part || !isMintedPartOf(part, base)) {
581
+ throw await unreadable(tool, base, 'the last part of the transcript', new Error('the last part is not available'));
582
+ }
583
+ let claimed;
584
+ try { claimed = await markCloseGrace(base, dbManager, { index, now }); }
585
+ catch (e) { throw await unreadable(tool, base, 'the closing-exchange record', e); }
586
+ if (claimed) {
587
+ return { grace: true, base, index, part,
588
+ notice: `Accepted as the closing exchange (exchange ${index}) of session ${base}, within ${minutes} minutes of its close. ` +
589
+ 'The session is now locked: no further exchanges are accepted.' };
590
+ }
591
+ throw await locked(' and its one closing exchange has already been saved');
592
+ }
593
+ const why = {
594
+ used: ' and its one closing exchange has already been saved',
595
+ expired: ` and the ${minutes}-minute window for its closing exchange has passed`,
596
+ not_next: ` - only exchange ${highest + 1} could be saved as its closing exchange`,
597
+ none: ''
598
+ }[d] || '';
599
+ throw await locked(why);
600
+ }
601
+
602
+ /**
603
+ * The no_session gate's words (2026-10-03). Nothing was written, so the words
604
+ * ask for the same write again, with a session.
605
+ */
606
+ export const VISITOR_REASON =
607
+ 'Nothing was written. A visitor may read and leave a drop (drops/<date>-<slug>.md). Full access needs initialize, every page, and a registered source.';
608
+ export const NO_SESSION_REASON =
609
+ 'this write names no session. Open one with emet_session_open (with the load_id from your startup pages), or use the ' +
610
+ 'session_id of the session you already opened, then resubmit this same write with that session_id. ' + VISITOR_REASON;
611
+ export const NO_SESSION_NEXT = 'emet_session_open with load_id, then resubmit this same write with session_id';
612
+ export function noSessionDecision() {
613
+ return { verdict: 'refuse', gate: 'no_session', reason: NO_SESSION_REASON, next: NO_SESSION_NEXT };
614
+ }
615
+
616
+ /**
617
+ * The startup_pages gate (2026-10-03). emet_session_open with pages of the
618
+ * paged startup never fetched is a skipped step and is refused before
619
+ * anything is written. An open must name its startup: with no load_id the
620
+ * pages cannot be checked, so the open is refused - never checked against
621
+ * another load. A named load the server has no record of (made up, expired,
622
+ * or from before a restart) cannot be checked either, so it is refused too,
623
+ * with the way back: a fresh emet_initialize. Returns null when every page of
624
+ * the named load was fetched.
625
+ */
626
+ export const STARTUP_PAGES_TOOL = 'emet_session_open';
627
+ export const NO_LOAD_ID_REASON =
628
+ 'emet_session_open needs load_id, so no session was opened and nothing was written. Call emet_session_open again with ' +
629
+ '"load_id" set to startup_page.load_id from your emet_initialize pages (every page carries it); if you have no startup ' +
630
+ 'pages, call emet_initialize {} and fetch every page first. ' + VISITOR_REASON;
631
+ export function noLoadIdDecision() {
632
+ return { verdict: 'refuse', gate: 'startup_pages', reason: NO_LOAD_ID_REASON, next: 'emet_session_open with "load_id" from your startup pages' };
633
+ }
634
+ /** Page numbers as ranges, once: [2,3,4,7,9,10] -> "2-4, 7, 9-10". */
635
+ export function pageRanges(pages = []) {
636
+ const out = [];
637
+ for (let i = 0; i < pages.length; i++) {
638
+ let j = i;
639
+ while (j + 1 < pages.length && pages[j + 1] === pages[j] + 1) j++;
640
+ out.push(j > i ? `${pages[i]}-${pages[j]}` : `${pages[i]}`);
641
+ i = j;
642
+ }
643
+ return out.join(', ');
644
+ }
645
+ // The pass instruction comes first, and the missing pages are named once, as
646
+ // ranges, so the refusal fits the 500-character error-message cap
647
+ // (database.js sanitizeErrorMessage); pages too scattered to list in 60
648
+ // characters are asked for again from page 1. Tests pin 20 and 40 missing.
649
+ export const PAGE_LIST_MAX = 60;
650
+ export function startupPagesDecision(c) {
651
+ const one = c.missing.length === 1;
652
+ const first = `emet_initialize {"page": ${c.missing[0]}, "load_id": "${c.load_id}"}`;
653
+ const ranges = pageRanges(c.missing);
654
+ if (ranges.length > PAGE_LIST_MAX) {
655
+ // Scattered pages too many to list: fetch every page again (harmless).
656
+ return { verdict: 'refuse', gate: 'startup_pages',
657
+ reason: `Fetch every startup page of load ${c.load_id}, 1 to ${c.of}: emet_initialize {"page": 1, "load_id": "${c.load_id}"}, then the ` +
658
+ `same call for each page after it; then call emet_session_open again with "load_id": "${c.load_id}". ${c.missing.length} of its ` +
659
+ `${c.of} pages were never fetched, so no session was opened and nothing was written. If told the startup changed, call emet_initialize {} and read every page.`,
660
+ next: 'every page, then emet_session_open again' };
661
+ }
662
+ return { verdict: 'refuse', gate: 'startup_pages',
663
+ reason: `Fetch startup page${one ? '' : 's'} ${ranges} of ${c.of} (load ${c.load_id}): ${first}` +
664
+ `${one ? '' : ', then the same call for each page after it in that list'}; then call emet_session_open again with ` +
665
+ `"load_id": "${c.load_id}". ${one ? 'That page was' : 'Those pages were'} never fetched, so no session was opened and nothing was written. ` +
666
+ 'If told the startup changed, call emet_initialize {} and read every page.',
667
+ next: 'the missing pages, then emet_session_open again' };
668
+ }
669
+ // A load_id with no record here (never served by this process, expired after
670
+ // 60 minutes without a page, or from before a restart) cannot be checked, so
671
+ // the open is refused: pages are never taken as checked when they were not.
672
+ export const UNKNOWN_LOAD_ID_REASON = (id) =>
673
+ 'Call emet_initialize {} and fetch every page, then call emet_session_open again with the load_id it returns. ' +
674
+ `Startup load ${echoId(id)} is not on record on this server (it was not served here, it expired after 60 minutes ` +
675
+ 'without a page, or the server restarted), so its pages cannot be checked: no session was opened and nothing was written.';
676
+ export function unknownLoadIdDecision(id) {
677
+ return { verdict: 'refuse', gate: 'startup_pages', reason: UNKNOWN_LOAD_ID_REASON(id), next: 'emet_initialize {}, every page, then emet_session_open with its load_id' };
678
+ }
679
+ // The fetched-page record the gate reads is held in THIS process only: pages
680
+ // served by another server instance are not on it, so enforce assumes EMET
681
+ // runs as one process. With no load_id the open is unchecked - never checked
682
+ // against the latest startup this server built, which may be another host's.
683
+ export async function startupPagesGate(args = {}, now = new Date()) {
684
+ const mode = graphMode();
685
+ const loadId = typeof args.load_id === 'string' ? args.load_id.trim() : '';
686
+ let base = null;
687
+ try {
688
+ const { sessionBaseId, sessionTimeZone } = await import('./transcript-session.js');
689
+ base = sessionBaseId(now, args.slug || 'session', sessionTimeZone().zone); // the id asked for; a closed one opens as -2
690
+ } catch { base = null; }
691
+ let decision; let startupPages;
692
+ if (!loadId) {
693
+ decision = noLoadIdDecision();
694
+ startupPages = { checked: false, load_id: null, reason: 'no load_id' };
695
+ } else {
696
+ let c = null;
697
+ try {
698
+ const { startupPageCompletion } = await import('./tools.js');
699
+ c = startupPageCompletion(loadId);
700
+ } catch { return null; }
701
+ if (!c) {
702
+ decision = unknownLoadIdDecision(loadId);
703
+ startupPages = { checked: false, load_id: echoId(loadId), reason: 'no record of that load' };
704
+ } else {
705
+ if (c.complete || !Array.isArray(c.missing) || !c.missing.length) return null;
706
+ decision = startupPagesDecision(c);
707
+ startupPages = { load_id: c.load_id, of: c.of, missing: c.missing };
708
+ }
709
+ }
710
+ const graph = { mode, base, highest: null, decision, startup_pages: startupPages };
711
+ const { ValidationError } = await import('./validation.js');
712
+ // The reason already names the exact calls that pass; no "Next:" repeat, so
713
+ // the message stays inside the 500-character error cap.
714
+ const err = new ValidationError('session_graph', decision.reason);
715
+ err.graph = graph; // so the lab records the refusal too
716
+ throw err;
717
+ }
718
+
719
+ /**
720
+ * Before a governed call: read the state, decide. Returns
721
+ * {mode, decision, base} or null when the graph does not apply.
722
+ * Throws a ValidationError when the decision is a refusal.
723
+ */
724
+ export async function graphBefore(tool, args, dbManager, now = new Date()) {
725
+ const mode = graphMode();
726
+ if (tool === STARTUP_PAGES_TOOL) return startupPagesGate(args, now);
727
+ if (!GRAPH_WRITE_TOOLS.has(tool)) return null;
728
+ const sid = sessionOf(args);
729
+ let base = null; let highest = null; let decision;
730
+ if (!sid) {
731
+ // A session that is not open is not fully initialized: read access, and a
732
+ // new drop, are the only writes. Everything else still refuses.
733
+ if (tool === 'write_doc' && typeof args.doc_id === 'string' && args.doc_id.startsWith('drops/')) {
734
+ return { mode, base: null, highest: 0, decision: { verdict: 'ok', gate: 'drop_only', reason: null, next: 'open a session to write anything other than a new drop' } };
735
+ }
736
+ decision = noSessionDecision();
737
+ } else {
738
+ base = baseOf(sid);
739
+ const col = await sessionsCollection(dbManager);
740
+ let state = (await col.findOne({ _id: base })) || {};
741
+ // A transcript id is grouped as the close check groups it (<base>_extra
742
+ // belongs to <base>), so a closed session is found under any of its ids.
743
+ if (TRANSCRIPT_WRITE_TOOLS.has(tool) && !state.closed) {
744
+ for (const b of candidateBases(sid)) {
745
+ if (b === base) continue;
746
+ const r = await col.findOne({ _id: b });
747
+ if (r && r.closed) { state = r; base = b; break; }
748
+ }
749
+ }
750
+ const pos = typeof dbManager.transcriptPosition === 'function' ? await dbManager.transcriptPosition(sid) : { highest: 0 };
751
+ highest = pos.highest || 0;
752
+ // A transcript write into a closed session is decided by closedSessionGuard,
753
+ // which always runs next and says why (the closing exchange already saved,
754
+ // its window passed, or which exchange could still close it).
755
+ decision = state.closed && TRANSCRIPT_WRITE_TOOLS.has(tool)
756
+ ? { verdict: 'ok', gate: null, reason: null, next: CLOSED_NEXT }
757
+ : evaluate(tool, args, state, highest, now);
758
+ }
759
+ if (decision && decision.verdict === 'refuse') {
760
+ const { ValidationError } = await import('./validation.js');
761
+ const said = /nothing was written/i.test(decision.reason || '');
762
+ const err = new ValidationError('session_graph', `${decision.reason}${said ? '' : ' Nothing was written.'} Next: ${decision.next}.`);
763
+ err.graph = { mode, base, highest, decision }; // so the lab records the refusal too
764
+ throw err;
765
+ }
766
+ return { mode, base, highest, decision };
767
+ }
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // Lab results (2026-09-29, the owner: "a 'lab results' location for storing the
771
+ // results in real time with time stamps so we can have a resource to look at
772
+ // after the probationary period"). Every check the monorail makes - ok,
773
+ // refuse, unknown (would_refuse in rows from before enforce-only) - is one timestamped row in
774
+ // `<prefix>graph_lab`, written as it happens. emet_gaps reads the summary.
775
+ // ---------------------------------------------------------------------------
776
+
777
+ export const LAB_SUFFIX = 'graph_lab';
778
+
779
+ /** One lab row, pure. `graph` is what graphBefore returned (or attached to its refusal). */
780
+ export function labRow(tool, args = {}, graph = null, now = new Date(), actor = {}) {
781
+ const block = graphBlock(graph);
782
+ if (!block) return null;
783
+ const d = graph.decision;
784
+ return {
785
+ ts: now,
786
+ session: graph.base || baseOf(sessionOf(args)),
787
+ tool,
788
+ // writeSourceOf reads source and its aliases; save_to_layer and
789
+ // revise_memory carry the tag in metadata.source (2026-10-03 lab fix).
790
+ source: writeSourceOf(args) ?? (args.metadata && typeof args.metadata.source === 'string' && args.metadata.source.trim() ? args.metadata.source.trim() : null),
791
+ person_id: actor.personId || null,
792
+ client_id: actor.clientId || 'stdio',
793
+ exchange_declared: declaredExchange(tool, args),
794
+ highest_appended: Number.isInteger(graph.highest) ? graph.highest : null,
795
+ mode: block.mode,
796
+ verdict: block.verdict,
797
+ gate: d.gate || null,
798
+ reason: d.reason || null,
799
+ ...(tool === 'write_doc' || tool === 'patch_doc' ? { doc_id: args.doc_id || null } : {}),
800
+ ...(graph.startup_pages ? { startup_pages: graph.startup_pages } : {})
801
+ };
802
+ }
803
+
804
+ /** Write the lab row. Never throws and never blocks the call: a lost row is logged. */
805
+ export async function labRecord(tool, args, graph, dbManager, logger = null) {
806
+ try {
807
+ const { currentActor } = await import('./persons.js');
808
+ const row = labRow(tool, args, graph, new Date(), currentActor());
809
+ if (!row) return;
810
+ const { COLLECTION_PREFIX } = await import('./database.js');
811
+ const db = await dbManager.ensureClient();
812
+ const lab = db.collection(`${COLLECTION_PREFIX}${LAB_SUFFIX}`);
813
+ try { await lab.createIndex({ client_id: 1 }, { sparse: true }); } catch { /* an index miss never blocks the row */ }
814
+ await lab.insertOne(row);
815
+ } catch (e) {
816
+ logger?.warn?.('session graph: lab row not recorded', { tool, error: e && e.message });
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Startup pages fetched after the session opened without them (2026-10-03):
822
+ * one lab row each, verdict `late`, gate `startup_pages`. Inserted, never an
823
+ * update of the open's row; labSummary pairs them up so a page fetched late
824
+ * is not counted as still missing. Never throws, never awaited by a page.
825
+ */
826
+ export function recordLatePages(late = [], dbManager, logger = null) {
827
+ if (!Array.isArray(late) || !late.length || !dbManager) return;
828
+ for (const e of late) {
829
+ const graph = {
830
+ mode: graphMode(), base: e.session, highest: null,
831
+ decision: { verdict: 'late', gate: 'startup_pages',
832
+ reason: `startup page ${e.page} of ${e.of} (load ${e.load_id}) was fetched after the session opened: late, not missing`, next: null },
833
+ startup_pages: { load_id: e.load_id, of: e.of, page: e.page, late: true }
834
+ };
835
+ labRecord('emet_initialize', {}, graph, dbManager, logger);
836
+ }
837
+ }
838
+
839
+ /**
840
+ * The lab summary emet_gaps serves, from a list of rows (pure, so it is
841
+ * tested without a database). Totals by verdict, gate, source and tool; the
842
+ * flagged rows (anything not ok) newest first, up to `limit`.
843
+ */
844
+ export function labSummary(rows = [], { limit = 50 } = {}) {
845
+ const count = (key) => rows.reduce((m, r) => { const k = r[key] ?? 'none'; m[k] = (m[k] || 0) + 1; return m; }, {});
846
+ // Startup pages (2026-10-03): a page fetched after the open (a `late` row)
847
+ // is not still missing. An open whose every missing page came late is not
848
+ // flagged; a `late` row is never flagged; an enforced refusal stays flagged.
849
+ const sp = (r) => (r.gate === 'startup_pages' && r.startup_pages ? r.startup_pages : null);
850
+ const lateKeys = new Set(rows.filter((r) => sp(r) && r.verdict === 'late').map((r) => `${r.session}|${r.startup_pages.load_id}|${r.startup_pages.page}`));
851
+ const stillMissing = (r) => (sp(r) && Array.isArray(r.startup_pages.missing)
852
+ ? r.startup_pages.missing.filter((p) => !lateKeys.has(`${r.session}|${r.startup_pages.load_id}|${p}`)) : null);
853
+ const settled = (r) => r.verdict === 'late' || (r.verdict === 'would_refuse' && sp(r) && stillMissing(r) && stillMissing(r).length === 0);
854
+ const flagged = rows.filter((r) => r.verdict !== 'ok' && !settled(r)).sort((a, b) => new Date(b.ts) - new Date(a.ts));
855
+ const opens = rows.filter((r) => sp(r) && r.tool === STARTUP_PAGES_TOOL && Array.isArray(r.startup_pages.missing));
856
+ const unchecked = rows.filter((r) => sp(r) && r.tool === STARTUP_PAGES_TOOL && r.startup_pages.checked === false);
857
+ const times = rows.map((r) => new Date(r.ts).getTime()).filter(Number.isFinite);
858
+ return {
859
+ location: `<prefix>${LAB_SUFFIX}`,
860
+ checks: rows.length,
861
+ first_at: times.length ? new Date(Math.min(...times)).toISOString() : null,
862
+ last_at: times.length ? new Date(Math.max(...times)).toISOString() : null,
863
+ sessions: new Set(rows.map((r) => r.session).filter(Boolean)).size,
864
+ by_verdict: count('verdict'),
865
+ by_gate: count('gate'),
866
+ by_source: count('source'),
867
+ flagged: flagged.length,
868
+ flagged_rows: flagged.slice(0, limit).map((r) => ({
869
+ ts: new Date(r.ts).toISOString(), session: r.session, source: r.source, tool: r.tool,
870
+ verdict: r.verdict, gate: r.gate, exchange_declared: r.exchange_declared,
871
+ highest_appended: r.highest_appended, reason: r.reason,
872
+ ...(r.doc_id !== undefined ? { doc_id: r.doc_id } : {}),
873
+ ...(r.startup_pages ? { startup_pages: { ...r.startup_pages, ...(stillMissing(r) ? { still_missing: stillMissing(r) } : {}) } } : {})
874
+ })),
875
+ ...(opens.length || lateKeys.size || unchecked.length ? { startup_pages: {
876
+ opens_refused_no_load_id: unchecked.filter((r) => r.verdict === 'refuse').length,
877
+ opens_with_missing: opens.filter((r) => r.verdict === 'would_refuse').length,
878
+ opens_refused: opens.filter((r) => r.verdict === 'refuse').length,
879
+ pages_still_missing: opens.filter((r) => r.verdict === 'would_refuse').reduce((n, r) => n + stillMissing(r).length, 0),
880
+ pages_late: lateKeys.size
881
+ } } : {}),
882
+ reading:
883
+ 'Each flagged row is either a real skipped step that was refused (the monorail working) or a gate that is wrong (fix it). ' +
884
+ 'Rows with verdict would_refuse are from before enforce-only (2026-10-03), when those writes were saved and flagged.'
885
+ };
886
+ }
887
+
888
+ /** The block a governed write's result carries. */
889
+ export function graphBlock(g) {
890
+ if (!g || !g.decision) return null;
891
+ const d = g.decision;
892
+ return {
893
+ mode: g.mode,
894
+ verdict: d.verdict,
895
+ ...(d.gate ? { gate: d.gate } : {}),
896
+ ...(d.reason ? { reason: d.reason } : {}),
897
+ next: d.next
898
+ };
899
+ }
900
+
901
+ /**
902
+ * After a successful call: record the step. Never throws - a failed record is
903
+ * logged, not fatal. Returns what happened, so the close can say so
904
+ * (2026-10-01, the second reader's nit 2 on an earlier draft, the reviewer's condition 2):
905
+ * null nothing to record (no session, or no step)
906
+ * {recorded: true, base} the step was written
907
+ * {recorded: false, base, error} the write failed (logged as a warning)
908
+ * The write is an idempotent $set, so a caller may simply call it again.
909
+ */
910
+ export async function graphAfter(tool, args, data, dbManager, logger = null, now = new Date()) {
911
+ let base = null;
912
+ try {
913
+ base = tool === 'emet_session_open' ? (data && data.base) : baseOf(sessionOf(args));
914
+ if (!base) return null;
915
+ const set = recordStep(tool, args, data, now);
916
+ if (!set) return null;
917
+ const col = await sessionsCollection(dbManager);
918
+ const { currentPersonId, currentClientId } = await import('./persons.js');
919
+ const listed = tool === 'emet_session_open' && data && Array.isArray(data.drops_seen) ? data.drops_seen.filter((id) => typeof id === 'string') : [];
920
+ const opened = tool === 'emet_session_open'
921
+ ? { opened_by: { person_id: currentPersonId() || null, client_id: currentClientId() || 'stdio' } }
922
+ : {};
923
+ // Merge, never overwrite: a reopen keeps drops already stamped.
924
+ // An unverified boot is stored as a flag, never as an empty seen-list.
925
+ // A verified empty list is a loaded list. $addToSet cannot store [], so
926
+ // leaving the field unset made the board guard treat "no unhandled drops"
927
+ // as "the list never loaded". Stamp [] only when the field is still
928
+ // missing (a fresh open, or a same-day reopen of a row that never recorded
929
+ // one). A failed load still stores the flag and no list.
930
+ const addDrops = tool === 'emet_session_open' && listed.length ? { $addToSet: { drops_seen: { $each: listed } } } : {};
931
+ const unverified = tool === 'emet_session_open' && data && data.drops_unverified === true ? { drops_unverified: true } : {};
932
+ const verifiedOpen = tool === 'emet_session_open' && data && data.drops_unverified !== true && Array.isArray(data.drops_seen);
933
+ const clearUnverified = verifiedOpen ? { $unset: { drops_unverified: '' } } : {};
934
+ await col.updateOne({ _id: base }, { $set: { ...set, ...unverified, updated_at: now }, $setOnInsert: { created_at: now, ...opened }, ...addDrops, ...clearUnverified }, { upsert: true });
935
+ if (verifiedOpen && listed.length === 0) {
936
+ await col.updateOne({ _id: base, drops_seen: { $exists: false } }, { $set: { drops_seen: [] } });
937
+ }
938
+ return { recorded: true, base };
939
+ } catch (e) {
940
+ logger?.warn?.('session graph: step not recorded', { tool, error: e && e.message });
941
+ return { recorded: false, base, error: (e && e.message) || String(e) };
942
+ }
943
+ }
944
+
945
+ export default { GRAPH_MODES, graphMode, ID_ECHO_MAX, echoId, pageRanges, PAGE_LIST_MAX, unknownLoadIdDecision, GRAPH_WRITE_TOOLS, STARTUP_PAGES_TOOL, noLoadIdDecision, startupPagesDecision, startupPagesGate, recordLatePages, botRecordDir, botHandoffDoc, isBotTag, mainRecordIds, writeSourceOf, isBotHandoffWrite, baseOf, candidateBases, groupedUnder, isMintedPartOf, sessionOf, normalizeExchangeNumber, declaredExchange, CLOSED_NEXT, nextStep, evaluate, recordStep, graphBefore, graphBlock, graphAfter, sessionStatus, isSessionClosed, DEFAULT_CLOSE_GRACE_MINUTES, MAX_CLOSE_GRACE_MINUTES, closeGraceSetting, closeGraceMinutes, graceDecision, markCloseGrace, TRANSCRIPT_WRITE_TOOLS, closedSessionGuard, LAB_SUFFIX, labRow, labRecord, labSummary };