@dreamforgestudiollc/emet 0.2.4 → 0.2.6

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 (101) hide show
  1. package/.env.example +53 -0
  2. package/CHANGELOG.md +67 -3
  3. package/CHARTER.md +19 -7
  4. package/README.md +106 -21
  5. package/docs/AUTHORSHIP.md +3 -3
  6. package/docs/BOTS.md +4 -0
  7. package/docs/COVERAGE.md +2 -2
  8. package/docs/DEPLOY.md +9 -4
  9. package/docs/GUARDRAILS-AND-MONORAILS.md +2 -1
  10. package/docs/GUIDED-SETUP.md +8 -3
  11. package/docs/PERMISSIONS.md +6 -3
  12. package/docs/ROADMAP.md +25 -22
  13. package/docs/SETUP.md +8 -2
  14. package/docs/SUBKEYS.md +59 -0
  15. package/gems/artifact-index.json +12 -0
  16. package/gems/bot-team-backup/floor.json +15 -0
  17. package/gems/bot-team-backup/gem.json +43 -0
  18. package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
  19. package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
  20. package/gems/fieldnote/floor.json +14 -0
  21. package/gems/fieldnote/gem.json +70 -0
  22. package/gems/fieldnote/runes/fieldnote.js +4 -0
  23. package/gems/lean-six-sigma/CHANGELOG.md +10 -0
  24. package/gems/lean-six-sigma/README.md +92 -0
  25. package/gems/lean-six-sigma/floor.json +31 -0
  26. package/gems/lean-six-sigma/gem.json +1296 -0
  27. package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
  28. package/gems/lean-six-sigma/guides/BELTS.md +52 -0
  29. package/gems/lean-six-sigma/guides/DMADV.md +56 -0
  30. package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
  31. package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
  32. package/gems/lean-six-sigma/guides/LEAN.md +97 -0
  33. package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
  34. package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
  35. package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
  36. package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
  37. package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
  38. package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
  39. package/gems/neurendipity/floor.json +65 -0
  40. package/gems/neurendipity/gem.json +42 -0
  41. package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
  42. package/{guides → gems/neurendipity/guides}/NEURODIVERGENT_SUPPORT.md +17 -8
  43. package/guides/README.md +3 -0
  44. package/package.json +11 -5
  45. package/server.json +44 -4
  46. package/src/aleph.js +4 -4
  47. package/src/annotations.js +38 -3
  48. package/src/atlas-role.js +1 -1
  49. package/src/attribution.js +555 -0
  50. package/src/conformance.js +2 -2
  51. package/src/database.js +6 -5
  52. package/src/dispatch.js +116 -13
  53. package/src/env.js +54 -9
  54. package/src/floor.js +82 -41
  55. package/src/gaps.js +39 -6
  56. package/src/gem-check.js +1359 -0
  57. package/src/guides.js +31 -4
  58. package/src/host-reach.js +23 -0
  59. package/src/http-remote.js +46 -2
  60. package/src/index.js +9 -2
  61. package/src/layers.js +1 -1
  62. package/src/lessons-to-checks.js +3 -3
  63. package/src/logger.js +2 -1
  64. package/src/mongo-connect.js +45 -2
  65. package/src/oauth-provider.js +2 -2
  66. package/src/owner-approvals.js +281 -0
  67. package/src/persons.js +20 -0
  68. package/src/redact.js +10 -4
  69. package/src/request-log.js +1 -1
  70. package/src/rune-dispatch.js +360 -0
  71. package/src/rune-registry.js +71 -0
  72. package/src/rune-scope.js +42 -0
  73. package/src/scopes.js +3 -0
  74. package/src/session-graph.js +25 -23
  75. package/src/session.js +9 -9
  76. package/src/setup.js +297 -30
  77. package/src/socket.js +545 -0
  78. package/src/startup-pages.js +23 -6
  79. package/src/startup-payload.js +9 -6
  80. package/src/subkey-store.js +357 -0
  81. package/src/subkeys.js +947 -0
  82. package/src/tools.js +299 -62
  83. package/src/transcript-session.js +1 -1
  84. package/src/validation.js +26 -2
  85. package/src/version.js +1 -1
  86. package/templates/BOARD.md +2 -2
  87. package/templates/BOTHANDOFF.md +2 -2
  88. package/templates/CHARACTER.md +1 -1
  89. package/templates/HANDOFF.md +1 -1
  90. package/templates/IDENTITY.md +1 -1
  91. package/templates/INDEX.md +19 -19
  92. package/templates/INSTALL.md +1 -1
  93. package/templates/PROCEDURE.md +1 -1
  94. package/templates/ROSTER.md +2 -2
  95. package/templates/SEED.md +2 -2
  96. package/templates/STAGING.md +2 -2
  97. package/templates/STANDARD.md +5 -5
  98. package/templates/THREAD.md +1 -1
  99. package/templates/USER.md +4 -1
  100. package/verification/load-gate/corpus.json +1765 -0
  101. package/verification/load-gate/load-gate.mjs +1246 -0
package/src/tools.js CHANGED
@@ -14,7 +14,20 @@
14
14
  import { connectMongo } from './mongo-connect.js';
15
15
  import { resolveModulePath } from './module-path.js';
16
16
  import { replacedByOf } from './setup.js';
17
- import { currentActor, writerStamp, boundSourceTag, LOCAL_SUBJECT } from './persons.js';
17
+ import { currentActor, rawActor, writerStamp, boundSourceTag, declaredAttribution, LOCAL_SUBJECT } from './persons.js';
18
+ import { approvedTags, APPROVALS_DOC } from './attribution.js';
19
+ import { sealAttestation, frozenAttestation } from './rune-dispatch.js';
20
+
21
+ /** Approvals record io for attribution.js (server-kept doc, written as emet-server). */
22
+ export const approvalsDeps = {
23
+ readDoc: (id) => readDoc(id),
24
+ // Compare-and-set on the stored version (the reviewer 3). mutateApprovals serializes
25
+ // writers in this process; this check catches a writer elsewhere.
26
+ writeDoc: async (id, content, expectedVersion) => {
27
+ // Atomic across processes: writeDoc filters the update on { doc_id, version: expected }.
28
+ return writeDoc(id, content, 'emet-server', null, null, { serverProvision: true, approvalsRecord: true, expectedVersion: expectedVersion ?? null });
29
+ }
30
+ };
18
31
  import {
19
32
  ValidationError,
20
33
  VALID_LAYERS,
@@ -449,7 +462,7 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
449
462
  // The tier-1 -> tier-2 pointer is pulled out BEFORE metadata validation and
450
463
  // stored as top-level columns, not inside metadata. When this was written
451
464
  // metadata was a JSON string the store could not filter into; it is a
452
- // subdocument now, but the pointer stays top-level because it is
465
+ // subdocument now (EMET ref 6), but the pointer stays top-level because it is
453
466
  // indexed there and the reverse lookup depends on that index.
454
467
  const { derived_from: rawProvenance, ...restMetadata } = metadata || {};
455
468
  let provenance = validateProvenance(rawProvenance);
@@ -495,7 +508,7 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
495
508
  const keywordLayer = determineLayer(validatedContent, validatedMetadata);
496
509
  const targetLayer = validatedLayer || FALLBACK_LAYER;
497
510
  // Lesson fields are refused at the door when the reader would never count
498
- // them (2026-09-29, a decision record): a malformed key, a key off the meta
511
+ // them (2026-09-29, procedural ref 397): a malformed key, a key off the meta
499
512
  // layer, or a disposition that settles nothing. Checked on the caller's
500
513
  // metadata as sent, before anything is written.
501
514
  {
@@ -530,6 +543,11 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
530
543
  }
531
544
  } else {
532
545
  validatedMetadata.source = sourceDecision.tag;
546
+ if (sourceDecision.from === 'declared' || sourceDecision.from === 'auto' || sourceDecision.from === 'subkey') {
547
+ validatedMetadata.source_basis = sourceDecision.from;
548
+ if (sourceDecision.login) validatedMetadata.source_login = sourceDecision.login;
549
+ if (sourceDecision.from === 'subkey' && sourceDecision.key_id) validatedMetadata.key_id = sourceDecision.key_id;
550
+ }
533
551
  delete validatedMetadata.source_defaulted;
534
552
  if (sourceDecision.tag) delete validatedMetadata.source_missing;
535
553
  else validatedMetadata.source_missing = true;
@@ -555,6 +573,12 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
555
573
  }
556
574
  validatedMetadata.client_id = stamp.client_id;
557
575
  }
576
+ await sealAttestation(validatedMetadata, {
577
+ session_id: options && options.session_id,
578
+ inherited: options && options.inheritedAttestation === true,
579
+ db: dbManager && dbManager.db,
580
+ dbManager
581
+ });
558
582
 
559
583
  // A4: importance is a named bucket. The float is the bucket midpoint
560
584
  // unless the caller named a degree (low/mid/high) or still sent a number.
@@ -572,7 +596,7 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
572
596
  }
573
597
 
574
598
  // A5: the charter says whether this layer wants a provenance pointer.
575
- // 2026-09-29 (a decision record): the pointer is STAMPED when the caller names
599
+ // 2026-09-29 (procedural ref 398): the pointer is STAMPED when the caller names
576
600
  // its transcript session, and a layer whose charter REQUIRES provenance
577
601
  // (semantic, procedural) REFUSES a write that has no source at all. The
578
602
  // transport is stateless, so the server never guesses which host's session
@@ -677,7 +701,7 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
677
701
  const importance = validatedMetadata.importance;
678
702
 
679
703
  const columns = provenanceColumns(provenance);
680
- // 2026-09-05: the digest is computed BEFORE the write and stored
704
+ // EMET ref 12 (2026-09-05): the digest is computed BEFORE the write and stored
681
705
  // as a column, not only compared in the read-back receipt. Without it the
682
706
  // aleph claim held for exactly one instant - verified at write, never again.
683
707
  const contentSha256 = digest(validatedContent);
@@ -705,7 +729,7 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
705
729
  context: validatedMetadata.context || '',
706
730
  emotional_intensity: emotionalIntensity,
707
731
  importance,
708
- // 2026-09-05: stored as a real subdocument, not a JSON string.
732
+ // EMET ref 6 (2026-09-05): stored as a real subdocument, not a JSON string.
709
733
  // The promoted columns below stay because they are indexed and the vector
710
734
  // filter reads them.
711
735
  metadata: validatedMetadata,
@@ -730,7 +754,7 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
730
754
  chain_sha256: chainSha256
731
755
  };
732
756
 
733
- // Option A correction back-pointer (the owner, 2026-10-02, the owner's decision record item 4): a new entry may name the
757
+ // Option A correction back-pointer (Alex, 2026-10-02, procedural ref 405 item 4): a new entry may name the
734
758
  // entry it partly corrects. The parent must exist in the same layer, and is checked BEFORE the insert so a
735
759
  // refused correction writes nothing.
736
760
  if (validatedMetadata.corrects !== undefined && validatedMetadata.corrects !== null) {
@@ -753,7 +777,7 @@ export async function saveMemory(dbManager, content, layer = null, metadata = {}
753
777
  const correction = (validatedMetadata.corrects !== undefined && validatedMetadata.corrects !== null)
754
778
  ? await markCorrectedBy(dbManager, targetLayer, validatedMetadata.corrects, memoryId)
755
779
  : null;
756
- // the second reader N4: a correction whose parent could not be marked is still saved,
780
+ // the reviewer N4: a correction whose parent could not be marked is still saved,
757
781
  // but the caller is told in words, not only by a boolean.
758
782
  if (correction && correction.marked !== true) {
759
783
  warnings.push(`corrects: ${correction.parent_id} - the parent entry was not marked corrected_by (missing in ${targetLayer}, or the mark did not hold); the correction is saved, the link is one-sided until the parent is marked`);
@@ -946,8 +970,8 @@ export async function resolveSupersessionChain(read, entry, maxHops = 50) {
946
970
  }
947
971
 
948
972
  /**
949
- * Option A correction back-pointer (the owner, 2026-10-02, a decision record item 4; the reviewer's sketch in his
950
- * second read of an episodic record). Episodic entries are immutable events, so a PARTIAL correction cannot
973
+ * Option A correction back-pointer (Alex, 2026-10-02, procedural ref 405 item 4; the tester's sketch in his
974
+ * second read of episodic ref 604). Episodic entries are immutable events, so a PARTIAL correction cannot
951
975
  * supersede its parent: supersession would put an end date on the whole parent and present every item
952
976
  * in it as replaced. Instead the new entry carries metadata.corrects = <parent id>, and the parent is MARKED:
953
977
  * its corrected_by array gains the new id. The parent's content, digest and is_live are untouched; its
@@ -960,7 +984,7 @@ export async function resolveSupersessionChain(read, entry, maxHops = 50) {
960
984
  * corrected_by mark): the new metadata digest and, when a signing key is set
961
985
  * and the row's existing signature verifies against its metadata before the
962
986
  * marks (aleph.js signatureCoversPriorMetadata), a fresh signature over the
963
- * same content digest and the new metadata digest (2026-10-02, the reviewer's
987
+ * same content digest and the new metadata digest (2026-10-02, the tester's
964
988
  * finding: the marks left signed:false behind). With no key, or a signature
965
989
  * that does not verify, the signature is left exactly as it is: never re-signed
966
990
  * blindly, so a row changed outside the sanctioned path keeps reading
@@ -980,7 +1004,7 @@ export async function markCorrectedBy(dbManager, layer, parentId, childId) {
980
1004
  const parent = await col.findOne({ id: parentId });
981
1005
  if (!parent) return { parent_id: parentId, marked: false };
982
1006
  const cid = Number(childId);
983
- // Race-safe (the second reader N3 / the reviewer, 2026-10-02): the id is ADDED atomically
1007
+ // Race-safe (the reviewer N3 / the tester, 2026-10-02): the id is ADDED atomically
984
1008
  // ($addToSet on the column and on the metadata copy), never written back
985
1009
  // from a stale read, so two simultaneous corrections of one parent both
986
1010
  // land. A legacy string-metadata row falls back to read-modify-write.
@@ -996,7 +1020,7 @@ export async function markCorrectedBy(dbManager, layer, parentId, childId) {
996
1020
  // Re-attest from what is actually stored. The digest is set only if the
997
1021
  // stored metadata is still exactly the metadata it was computed from: the
998
1022
  // compare-and-set filter is the WHOLE metadata value, not only corrected_by
999
- // (the reviewer, 2026-10-02), so a concurrent change to any metadata field in
1023
+ // (the tester, 2026-10-02), so a concurrent change to any metadata field in
1000
1024
  // between (another mark, a superseded_by, anything) makes the filter miss,
1001
1025
  // and the loop re-reads and re-digests. The last re-attest therefore always
1002
1026
  // covers what is stored. The value compared is the one just read back, so
@@ -1043,15 +1067,6 @@ export async function reviseMemory(dbManager, layer, target_id, content, metadat
1043
1067
  if (metadata !== null && metadata !== undefined && !isPlainObject(metadata)) {
1044
1068
  throw new ValidationError('metadata', 'Metadata must be an object', typeof metadata);
1045
1069
  }
1046
- // 2026-09-29 (a decision record): a revision with no source of its own cites
1047
- // the entry it revises, so revising a semantic or procedural entry is never
1048
- // refused for want of a pointer. A caller-supplied derived_from wins.
1049
- const revisionMetadata = {
1050
- ...(metadata || {}),
1051
- supersedes: targetId,
1052
- ...((metadata && metadata.derived_from) ? {} : { derived_from: { entries: [targetId] } })
1053
- };
1054
-
1055
1070
  await dbManager.getConnection(validatedLayer);
1056
1071
  const col = dbManager.collectionFor(validatedLayer);
1057
1072
 
@@ -1063,15 +1078,29 @@ export async function reviseMemory(dbManager, layer, target_id, content, metadat
1063
1078
  if (isMemorySuperseded(targetMd)) {
1064
1079
  throw new ValidationError('target_id', `Entry ${targetId} is already superseded by ${targetMd.superseded_by} - revise the CURRENT entry, not the archived one`);
1065
1080
  }
1081
+ // 2026-09-29 (procedural ref 398): a revision with no source of its own cites
1082
+ // the entry it revises, so revising a semantic or procedural entry is never
1083
+ // refused for want of a pointer. A caller-supplied derived_from wins.
1084
+ // Attestation copied from the parent wins over a caller value. A different
1085
+ // value was already refused.
1086
+ const priorAttestation = frozenAttestation(targetMd, metadata);
1087
+ const revisionMetadata = {
1088
+ ...(metadata || {}),
1089
+ ...(priorAttestation || {}),
1090
+ supersedes: targetId,
1091
+ ...((metadata && metadata.derived_from) ? {} : { derived_from: { entries: [targetId] } })
1092
+ };
1066
1093
 
1067
- const saved = await saveMemory(dbManager, validatedContent, validatedLayer, revisionMetadata, logger, null);
1094
+ const saved = await saveMemory(dbManager, validatedContent, validatedLayer, revisionMetadata, logger, null, {
1095
+ inheritedAttestation: Boolean(priorAttestation)
1096
+ });
1068
1097
 
1069
1098
  targetMd.superseded_by = saved.id;
1070
1099
  targetMd.superseded_at = saved.timestamp;
1071
1100
  // Write BOTH the metadata copy and the promoted top-level column. The
1072
1101
  // top-level one is what a reader follows (indexed); the metadata copy
1073
1102
  // stays for audit. Always written as a subdocument - a parent still
1074
- // carrying the legacy string shape is converted by this touch.
1103
+ // carrying the legacy string shape is converted by this touch (EMET ref 6).
1075
1104
  // is_live is left as it was. Supersession is the end date and the link
1076
1105
  // to the new entry, not a flag that hides the old one.
1077
1106
  await col.updateOne({ id: targetId }, { $set: {
@@ -1626,7 +1655,7 @@ export async function queryLayer(dbManager, layer, options = {}, logger = null,
1626
1655
  // there is not even one to read - that transport's logger discards warn().
1627
1656
  // So the ignored keys ride back in the response envelope, where the person
1628
1657
  // who wrote the bad filter will actually see them. This is the surface that
1629
- // would have caught the 2026-09-08 phantom defect as it happened.
1658
+ // would have caught the 2026-09-08 phantom defect as it happened (ref 259).
1630
1659
  if (validatedOptions._unknown_filter_keys) {
1631
1660
  attachWarnings(results, [
1632
1661
  `Ignored unrecognised filter key(s): ${validatedOptions._unknown_filter_keys.join(', ')}. `
@@ -2053,6 +2082,7 @@ export async function sessionOpen({ slug, load_id } = {}, logger = null, dbManag
2053
2082
  : 'Nothing was merged into another session.')
2054
2083
  : null;
2055
2084
  logger?.info?.('transcript session open', { session_id, ...(suffixed ? { requested } : {}) });
2085
+ const session_handle = await subkeyHandleForOpen(rawActor(), base, now);
2056
2086
  // Startup page completion (2026-10-03). Through dispatch, an open with no
2057
2087
  // load_id or with pages missing is refused before this runs (the
2058
2088
  // startup_pages gate); a direct call is told what was checked.
@@ -2077,6 +2107,7 @@ export async function sessionOpen({ slug, load_id } = {}, logger = null, dbManag
2077
2107
  return {
2078
2108
  session_id,
2079
2109
  base,
2110
+ ...(session_handle ? { session_handle } : {}),
2080
2111
  drops_seen,
2081
2112
  ...(drops_unverified ? { drops_unverified: true } : {}),
2082
2113
  next_exchange_index: 1,
@@ -2130,6 +2161,19 @@ export function startupPagesCheck(loadId, logger = null, sessionId = null, now =
2130
2161
  return { load_id: c.load_id, checked: true, complete: false, of: c.of, served: c.served, missing: c.missing, notice };
2131
2162
  }
2132
2163
 
2164
+ /** The one-time session handle for a sub-key open. Null when this open did not present a key. Never logged. */
2165
+ async function subkeyHandleForOpen(actor, base, now) {
2166
+ if (!actor || setting('SUBKEYS') !== '1') return null;
2167
+ const { takeOpenToken, commitLiveSession } = await import('./subkeys.js');
2168
+ if (!takeOpenToken(actor)) return null;
2169
+ try {
2170
+ return await commitLiveSession(approvalsDeps, actor, base, now);
2171
+ } catch (e) {
2172
+ if (e && e.attributionRefusal) throw new ValidationError('subkey', e.message);
2173
+ throw e;
2174
+ }
2175
+ }
2176
+
2133
2177
  export async function transcriptAppend(args = {}, logger = null) {
2134
2178
  const session_id = args.session_id;
2135
2179
  const user_message = args.user_message;
@@ -2181,6 +2225,11 @@ export async function transcriptAppend(args = {}, logger = null) {
2181
2225
  person_id: stamp.person_id,
2182
2226
  client_id: stamp.client_id
2183
2227
  };
2228
+ const attr = declaredAttribution();
2229
+ if (attr && attr.basis === 'subkey' && attr.keyId) {
2230
+ row.key_id = attr.keyId;
2231
+ row.source_basis = 'subkey';
2232
+ }
2184
2233
 
2185
2234
  // Build 1 (2026-09-27): the first append of a session must carry the
2186
2235
  // measured machine line, or say there is no local shell. The rule lived in
@@ -2497,6 +2546,8 @@ export async function readDoc(doc_id, logger = null, opts = {}) {
2497
2546
  return { doc_id: doc.doc_id, content, sha256: doc.sha256, version: doc.version,
2498
2547
  updated_at: doc.updated_at, updated_by: doc.updated_by || null,
2499
2548
  client_id: doc.client_id || null, session_id: doc.session_id || null,
2549
+ ...(doc.key_id ? { key_id: doc.key_id } : {}),
2550
+ ...(doc.source_basis ? { source_basis: doc.source_basis } : {}),
2500
2551
  path_hint: doc.path_hint || null, size: doc.content.length,
2501
2552
  size_unit: 'characters', size_bytes: Buffer.byteLength(doc.content, 'utf8'),
2502
2553
  ...(page ? { page } : {}),
@@ -2539,6 +2590,8 @@ export async function readDocHistory(doc_id, version = null, logger = null) {
2539
2590
  const retained = await hist.find({ doc_id }, { projection: { content: 0 } }).sort({ version: 1 }).toArray();
2540
2591
  const rows = retained.map(r => ({ version: r.version, sha256: r.sha256 || null, size: r.size ?? null,
2541
2592
  updated_at: r.updated_at || null, updated_by: r.updated_by || null, client_id: r.client_id || null, session_id: r.session_id || null, archived_at: r.archived_at || null,
2593
+ ...(r.key_id ? { key_id: r.key_id } : {}),
2594
+ ...(r.source_basis ? { source_basis: r.source_basis } : {}),
2542
2595
  conformance: r.conformance ? r.conformance.verdict : null }));
2543
2596
  return { doc_id, current_version: current ? current.version : null, retained_count: rows.length, retained: rows,
2544
2597
  note: current ? null : 'No current document by this id; only retained revisions, if any.' };
@@ -2547,6 +2600,8 @@ export async function readDocHistory(doc_id, version = null, logger = null) {
2547
2600
  return { doc_id, version, is_current: true, content: current.content, sha256: current.sha256, size: current.content.length,
2548
2601
  updated_at: current.updated_at, updated_by: current.updated_by || null,
2549
2602
  client_id: current.client_id || null, session_id: current.session_id || null,
2603
+ ...(current.key_id ? { key_id: current.key_id } : {}),
2604
+ ...(current.source_basis ? { source_basis: current.source_basis } : {}),
2550
2605
  conformance: current.conformance || null };
2551
2606
  }
2552
2607
  // More than one archived row can carry a version when a write was retried;
@@ -2557,6 +2612,8 @@ export async function readDocHistory(doc_id, version = null, logger = null) {
2557
2612
  return { doc_id, version, is_current: false, content: row.content, sha256: row.sha256 || null, size: row.content.length,
2558
2613
  updated_at: row.updated_at || null, updated_by: row.updated_by || null, archived_at: row.archived_at || null,
2559
2614
  client_id: row.client_id || null, session_id: row.session_id || null,
2615
+ ...(row.key_id ? { key_id: row.key_id } : {}),
2616
+ ...(row.source_basis ? { source_basis: row.source_basis } : {}),
2560
2617
  conformance: row.conformance || null,
2561
2618
  // Re-hashed on read, as a layer row is: a retained revision that
2562
2619
  // does not match its own digest is returned and flagged, never hidden.
@@ -2569,7 +2626,7 @@ export async function readDocHistory(doc_id, version = null, logger = null) {
2569
2626
  }
2570
2627
 
2571
2628
  /**
2572
- * Records are superseded, never amended (2026-09-26, the owner: "enforce
2629
+ * Records are superseded, never amended (2026-09-26, Alex: "enforce
2573
2630
  * supersession ISO practices"). ISO 9001 7.5.3 / ISO 13485 4.2.5 separate
2574
2631
  * DOCUMENTS, which are revised under control, from RECORDS, which are evidence
2575
2632
  * of what happened and are not altered. The handoff is a record: it attests to
@@ -2606,17 +2663,18 @@ function _refuseRecordAmendment(doc_id, tool) {
2606
2663
  }
2607
2664
 
2608
2665
  /**
2609
- * The main records are the hub's (C1, 2026-10-01). write_doc and patch_doc refuse the main HANDOFF.md or STATE.md when the
2666
+ * The main records are the hub's (C1, 2026-10-01; review note section
2667
+ * 3.5). write_doc and patch_doc refuse the main HANDOFF.md or STATE.md when the
2610
2668
  * write's source is a bot tag (bots/<tag>/HANDOFF.md listed in
2611
2669
  * EMET_RECORD_DOCS), and nothing is written. So do the other doors that can
2612
- * change a main record (the reviewer's F1 in review): retire_doc, rename_doc (from
2670
+ * change a main record (the tester's F1 on PR 2): retire_doc, rename_doc (from
2613
2671
  * or onto one) and accept_nonconformance - retire and rename set retired_by,
2614
2672
  * not updated_by, so the close alone could not see them. The source is the one the door
2615
2673
  * stamps as updated_by: the caller's, else EMET_SOURCE_TAG. A records rule like
2616
2674
  * the record-amendment refusal above, so it applies in every graph mode.
2617
2675
  *
2618
- * Two further guards on the same doors (2026-10-01, the owner's approval to
2619
- * make the bot tags fully functional; the reviewer's review of the bot tags):
2676
+ * Two further guards on the same doors (2026-10-01, the owner's 4:17 PM approval to
2677
+ * make the bot tags fully functional; the tester's review of PR 3):
2620
2678
  * - REGISTERED HOSTS ONLY on the main records. With a source registry
2621
2679
  * configured (EMET_SOURCE_TAGS, parsed as save_to_layer parses it), the
2622
2680
  * main handoff and board take writes only from a registered tag. An
@@ -2630,7 +2688,21 @@ function _refuseRecordAmendment(doc_id, tool) {
2630
2688
  * Hosts (the hub) keep writing anywhere under bots/.
2631
2689
  */
2632
2690
  export function sourceRegistry() {
2633
- return String(setting('SOURCE_TAGS') ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '');
2691
+ const env = String(setting('SOURCE_TAGS') ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '');
2692
+ // Attribution v2: tags approved in emet/CLIENT_APPROVALS.md count as
2693
+ // registered once a registry exists (a fresh install with no registry stays open).
2694
+ return env.length ? [...new Set([...env, ...approvedTags()])] : env;
2695
+ }
2696
+
2697
+ /**
2698
+ * Attribution v2 (the reviewer A3): a declared tag never gains rights beyond the login's
2699
+ * tag. A guard is run for every tag returned here and the write must pass all.
2700
+ */
2701
+ export function rightsTags(tag) {
2702
+ const d = declaredAttribution();
2703
+ if (d && d.basis === 'subkey' && d.effective && tag === d.effective && d.login && d.login !== tag) return [tag, d.login];
2704
+ if (d && d.declared && tag === d.declared && d.login && d.login !== tag) return [tag, d.login];
2705
+ return [tag];
2634
2706
  }
2635
2707
 
2636
2708
  /**
@@ -2645,20 +2717,27 @@ export function sourceRegistry() {
2645
2717
  export function resolvedSourceTag(caller) {
2646
2718
  const bound = boundSourceTag();
2647
2719
  const tag = typeof caller === 'string' ? caller.trim() : '';
2648
- if (typeof bound === 'string' && bound) return { tag: bound, from: 'bound' };
2649
2720
  if (bound === undefined) return { tag: tag || null, from: 'caller' };
2721
+ // Attribution v2: the per-request tag decided by attribution.preflight is the
2722
+ // stamp (declared, or generated for a login with none). Rights stay the
2723
+ // login's: see rightsTags().
2724
+ const d = declaredAttribution();
2725
+ if (d && d.basis === 'subkey' && d.effective) return { tag: d.effective, from: 'subkey', login: bound || null, key_id: d.keyId || null };
2726
+ if (d && d.declared && d.basis === 'declared' && tag === d.declared) return { tag, from: 'declared', login: bound || null };
2727
+ if (typeof bound === 'string' && bound) return { tag: bound, from: 'bound' };
2650
2728
  if (tag && sourceRegistry().includes(tag)) return { tag, from: 'legacy' };
2729
+ if (d && d.basis === 'auto' && d.declared) return { tag: d.declared, from: 'auto' };
2651
2730
  return { tag: null, from: 'legacy' };
2652
2731
  }
2653
2732
 
2654
- // Look-alike doc ids (the second reader's recheck d1-d5, 2026-10-02): a `.` or `..` segment,
2733
+ // Look-alike doc ids (the reviewer's recheck d1-d5, 2026-10-02): a `.` or `..` segment,
2655
2734
  // a leading `/`, or a `bots/` prefix in the wrong case would let a write pass
2656
2735
  // the prefix checks below while naming a record they are meant to fence (d1:
2657
- // bots/tester-bot/../intake-bot/HANDOFF.md passed the own-dir check). Doc
2736
+ // bots/reviewer-bot/../intake-bot/HANDOFF.md passed the own-dir check). Doc
2658
2737
  // ids are flat strings, never resolved as paths, so these shapes are refused
2659
2738
  // outright, before any guard, on every door that calls this.
2660
2739
  //
2661
- // Hardening (the reviewer's scope on the doc-id fix, 2026-10-02): also refused are
2740
+ // Hardening (the tester's scope on the doc-id fix, 2026-10-02): also refused are
2662
2741
  // control and invisible-format characters (\p{Cc}, \p{Cf}) anywhere; leading or
2663
2742
  // trailing whitespace on the id or on any segment; a backslash; the `/`, `.`/`..`
2664
2743
  // and `bots/` checks run on the NFKC form too (so full-width dots and slashes,
@@ -2705,7 +2784,7 @@ export function refuseLookalikeDocId(doc_id, tool) {
2705
2784
  }
2706
2785
  }
2707
2786
 
2708
- // Cleanup path (the reviewer's scope): a look-alike id may already exist in a store
2787
+ // Cleanup path (the tester's scope): a look-alike id may already exist in a store
2709
2788
  // written before these checks. retire_doc, and rename_doc FROM such an id, are let
2710
2789
  // through for a registered host tag only: EMET_SOURCE_TAGS set, the tag listed,
2711
2790
  // and not a bot tag. Never for a write, a patch, a rename onto it, or a bot.
@@ -2715,7 +2794,27 @@ function mayCleanUpLookalike(tag) {
2715
2794
  }
2716
2795
 
2717
2796
  export function refuseBotMainRecord(doc_id, updated_by, tool, options = {}) {
2797
+ for (const t of rightsTags(updated_by)) refuseBotMainRecordOne(doc_id, t, tool, options);
2798
+ }
2799
+ function refuseBotMainRecordOne(doc_id, updated_by, tool, options = {}) {
2718
2800
  const tag = updated_by || setting('SOURCE_TAG') || null;
2801
+ // The client-approvals record is server-only (the reviewer 4): no host writes,
2802
+ // patches, retires, renames, restores or accepts it.
2803
+ if (typeof doc_id === 'string' && FOLD(doc_id.normalize('NFKC')) === FOLD(APPROVALS_DOC) && !(options && options.approvalsRecord === true)) {
2804
+ throw new ValidationError(`${tool}: ${doc_id} is kept by the server (client approvals). Decide clients with emet_client_approve. Nothing was written.`);
2805
+ }
2806
+ // A3 without a registry (the reviewer re-review): a declared or generated tag writes
2807
+ // under bots/ only inside bots/<its own tag>/ and only once approved.
2808
+ {
2809
+ const d = declaredAttribution();
2810
+ if (d && d.declared && tag === d.declared && (d.basis === 'declared' || d.basis === 'auto') &&
2811
+ typeof doc_id === 'string' && doc_id.startsWith('bots/')) {
2812
+ const approved = approvedTags().includes(tag) || sourceRegistry().includes(tag);
2813
+ if (!doc_id.startsWith(botRecordDir(tag)) || !approved) {
2814
+ throw new ValidationError(`${tool}: ${doc_id} is a bot record and "${tag}" is a ${approved ? 'declared tag outside its own folder' : 'declared tag not yet approved'} - a declared tag writes only bots/${tag}/ once approved. Nothing was written.`);
2815
+ }
2816
+ }
2817
+ }
2719
2818
  if (!(options.cleanup === true && mayCleanUpLookalike(tag))) refuseLookalikeDocId(doc_id, tool);
2720
2819
  const bot = !!tag && isBotTag(tag);
2721
2820
  if (bot && typeof doc_id === 'string' && doc_id.startsWith('bots/') && !doc_id.startsWith(botRecordDir(tag))) {
@@ -2725,7 +2824,7 @@ export function refuseBotMainRecord(doc_id, updated_by, tool, options = {}) {
2725
2824
  `Hand it to the hub instead. Nothing was written.`
2726
2825
  );
2727
2826
  }
2728
- // Registry guard on bots/ too (the reviewer's review of the bot guards; the second reader's fix (a), 2026-10-02): with EMET_SOURCE_TAGS
2827
+ // Registry guard on bots/ too (the tester's PR 5 review; the reviewer's fix (a), 2026-10-02): with EMET_SOURCE_TAGS
2729
2828
  // set, a non-bot write under bots/ is refused unless its tag is registered; a missing source counts as
2730
2829
  // unregistered. Without this, a misspelled or unregistered tag is not a bot tag, so the cross-bot guard
2731
2830
  // above never fires and it can write any bot's records or the roster. A listed bot is already fenced to
@@ -2756,28 +2855,31 @@ export function refuseBotMainRecord(doc_id, updated_by, tool, options = {}) {
2756
2855
  }
2757
2856
 
2758
2857
  /**
2759
- * retire_doc and rename_doc are not bot tools at all (the owner, 2026-10-01,
2760
- * decision 1, as relayed: "retire_doc isn't needed for bots: keep it
2761
- * the owner/hub only and out of every bot profile"; the owner's decision record; stage 1 of the
2762
- * host-aware startup amendment, admitted by the reviewer 2026-10-02). A profile is not a
2858
+ * retire_doc and rename_doc are not bot tools at all (Alex, 2026-10-01 4:27 PM
2859
+ * ET, decision 1, as relayed: "retire_doc isn't needed for bots: keep it
2860
+ * owner/hub only and out of every bot profile"; procedural ref 407; stage 1 of the
2861
+ * ref 358 startup amendment, admitted by the tester 2026-10-02). A profile is not a
2763
2862
  * security boundary, so the door refuses them under ANY bot tag, for every
2764
- * doc_id - not only the main records refuseBotMainRecord covers. The owner's decision record (item 1)
2765
- * covers retire_doc only; rename_doc stays owner-only under a later decision record
2766
- * (owner-confirmed 2026-10-02: every other owner-only tool is
2863
+ * doc_id - not only the main records refuseBotMainRecord covers. ref 407(1)
2864
+ * covers retire_doc only; rename_doc stays owner-only under procedural ref 409
2865
+ * (owner-confirmed 2026-10-02 the recorded time: every other owner-only tool is
2767
2866
  * unchanged), and it retires its old id internally, so it is refused too. The tag is the
2768
2867
  * one the door would stamp: the caller's, else EMET_SOURCE_TAG. Hosts and the
2769
2868
  * hub have no bots/<tag>/HANDOFF.md record and are unaffected. Nothing is
2770
2869
  * written when refused.
2771
2870
  */
2772
2871
  export function refuseBotRetireRename(tag, tool) {
2872
+ for (const t of rightsTags(tag)) refuseBotRetireRenameOne(t, tool);
2873
+ }
2874
+ function refuseBotRetireRenameOne(tag, tool) {
2773
2875
  const t = tag || setting('SOURCE_TAG') || null;
2774
2876
  if (!t || !isBotTag(t)) return;
2775
2877
  throw new ValidationError(
2776
2878
  'source',
2777
2879
  `${tool} is not a bot tool: ${t} is a bot tag, and ` +
2778
2880
  (tool === 'retire_doc'
2779
- ? `retiring documents is the owner's and the hub's (decision 1, the owner's decision record item 1)`
2780
- : `renaming documents stays owner-only (the owner's decision record)`) +
2881
+ ? `retiring documents is the owner's and the hub's (decision 1, procedural ref 407 item 1)`
2882
+ : `renaming documents stays owner-only (procedural ref 409)`) +
2781
2883
  `. Ask the hub. Nothing was changed.`
2782
2884
  );
2783
2885
  }
@@ -2786,7 +2888,7 @@ export async function writeDoc(doc_id, rawContent, updated_by = null, path_hint
2786
2888
  try {
2787
2889
  if (!doc_id || typeof doc_id !== 'string') throw new ValidationError('doc_id must be a non-empty string');
2788
2890
  // write_doc, patch_doc (which lands here) and rename_doc's copy (reissued_from).
2789
- refuseBotMainRecord(doc_id, updated_by, options && options.reissued_from ? 'rename_doc' : 'write_doc', options && options.serverProvision === true ? { serverProvision: true } : {});
2891
+ refuseBotMainRecord(doc_id, updated_by, options && options.reissued_from ? 'rename_doc' : 'write_doc', options && options.serverProvision === true ? { serverProvision: true, ...(options.approvalsRecord === true ? { approvalsRecord: true } : {}) } : {});
2790
2892
  if (typeof rawContent !== 'string' || rawContent.length === 0) throw new ValidationError('content must be a non-empty string');
2791
2893
  if (rawContent.length > 2000000) throw new ValidationError('content exceeds 2MB limit');
2792
2894
  // patch_doc lands here too, so one
@@ -2800,6 +2902,11 @@ export async function writeDoc(doc_id, rawContent, updated_by = null, path_hint
2800
2902
  try {
2801
2903
  const prev = await col.findOne({ doc_id });
2802
2904
  const version = (prev && prev.version ? prev.version : 0) + 1;
2905
+ // Compare-and-set for the client-approvals record (the reviewer round 3): checked
2906
+ // here before anything is archived, and again atomically in the update filter.
2907
+ const casOn = options && options.approvalsRecord === true && Object.prototype.hasOwnProperty.call(options, 'expectedVersion');
2908
+ const casConflict = () => { const e = new ValidationError(`${doc_id} changed under this write (compare-and-set). Nothing was written.`); e.versionConflict = true; return e; };
2909
+ if (casOn && ((prev ? prev.version : null) ?? null) !== (options.expectedVersion ?? null)) throw casConflict();
2803
2910
  // A record in force is superseded only by a revision that names it.
2804
2911
  // Checked before anything is archived, so a refused write leaves no trace.
2805
2912
  if (prev && isRecordDoc(doc_id) && options && options.restore_of && (options.supersedes === undefined || options.supersedes === null)) {
@@ -2827,11 +2934,10 @@ export async function writeDoc(doc_id, rawContent, updated_by = null, path_hint
2827
2934
  }
2828
2935
  }
2829
2936
  const registry = sourceRegistry();
2830
- const tag = updated_by || '';
2831
- const registered = registry.length === 0 || registry.includes(tag);
2832
- const ownBotFolder = isBotTag(updated_by) && doc_id.startsWith(`bots/${updated_by}/`);
2833
2937
  const serverProvision = options && options.serverProvision === true;
2834
- if (!registered && !ownBotFolder && !serverProvision && !(isDropDoc(doc_id) && !prev)) {
2938
+ // Attribution v2 (A3): the declared tag and the login's tag must both pass.
2939
+ const passes = (t) => registry.length === 0 || registry.includes(t || '') || (isBotTag(t) && doc_id.startsWith(`bots/${t}/`));
2940
+ if (!rightsTags(updated_by).every(passes) && !serverProvision && !(isDropDoc(doc_id) && !prev)) {
2835
2941
  throw new ValidationError(`write_doc: ${updated_by || 'no source'} is not a registered source. This session may read, and may write a new drop only. Nothing was written.`);
2836
2942
  }
2837
2943
  if (isDropDoc(doc_id) && !prev) {
@@ -2843,7 +2949,7 @@ export async function writeDoc(doc_id, rawContent, updated_by = null, path_hint
2843
2949
  options = { ...options, drop_bucket: bucket };
2844
2950
  }
2845
2951
  const floorExempt = options && options.provision;
2846
- const internal = options && (options.provision || options.edited || options.setupReissue || options.restore_of);
2952
+ const internal = options && (options.provision || options.edited || options.setupReissue || options.restore_of || (options.approvalsRecord === true && options.serverProvision === true && doc_id === APPROVALS_DOC));
2847
2953
  if (options && options.provision && prev && doc_id === stateDoc()) {
2848
2954
  throw new ValidationError('The board already exists. Provision does not replace it. Nothing was written.');
2849
2955
  }
@@ -2916,7 +3022,7 @@ export async function writeDoc(doc_id, rawContent, updated_by = null, path_hint
2916
3022
  const now = new Date();
2917
3023
  // Validation runs HERE: after redaction, on the exact text that will be
2918
3024
  // stored, with the previous revision already in hand for the
2919
- // carry-forward comparison. It marks and never rejects, so its
3025
+ // carry-forward comparison. It marks and never rejects (ref 304), so its
2920
3026
  // result is a field on the row rather than a branch in this function.
2921
3027
  // Writing the field whole also enforces L6 - the fresh object carries no
2922
3028
  // acceptance, so an acceptance covers the one revision it was given to.
@@ -2957,11 +3063,31 @@ export async function writeDoc(doc_id, rawContent, updated_by = null, path_hint
2957
3063
  retired: false,
2958
3064
  size_note: 'baseline is the size when this session first touched the document'
2959
3065
  };
3066
+ const attr = declaredAttribution();
3067
+ if (attr && attr.basis === 'subkey' && attr.keyId) {
3068
+ writtenRow.key_id = attr.keyId;
3069
+ writtenRow.source_basis = 'subkey';
3070
+ }
2960
3071
  if (options && options.provision && doc_id === stateDoc()) {
2961
3072
  const ins = await col.updateOne({ doc_id }, { $setOnInsert: writtenRow }, { upsert: true });
2962
3073
  if (ins && ins.upsertedCount === 0) {
2963
3074
  throw new ValidationError('The board already exists. Provision does not replace it. Nothing was written.');
2964
3075
  }
3076
+ } else if (casOn) {
3077
+ let res;
3078
+ try {
3079
+ res = options.expectedVersion == null
3080
+ // the reviewer delta M1: documents.doc_id has no unique index, so the first
3081
+ // create keys on a deterministic _id; _id is always unique, so a
3082
+ // second racing insert matches it (or hits a duplicate key) and loses.
3083
+ ? await col.updateOne({ _id: `${APPROVALS_DOC}#current` }, { $setOnInsert: writtenRow }, { upsert: true })
3084
+ : await col.updateOne({ doc_id, version: options.expectedVersion }, { $set: writtenRow }, { upsert: false });
3085
+ } catch (e) {
3086
+ if (e && (e.code === 11000 || /duplicate key/i.test(String(e.message)))) throw casConflict();
3087
+ throw e;
3088
+ }
3089
+ const won = options.expectedVersion == null ? (res && res.upsertedCount === 1) : (res && res.matchedCount === 1);
3090
+ if (!won) throw casConflict();
2965
3091
  } else {
2966
3092
  await col.updateOne({ doc_id }, { $set: writtenRow }, { upsert: true });
2967
3093
  }
@@ -2974,6 +3100,7 @@ export async function writeDoc(doc_id, rawContent, updated_by = null, path_hint
2974
3100
  redacted: redaction.hits, conformance };
2975
3101
  } finally { await client.close(); }
2976
3102
  } catch (error) {
3103
+ if (error && error.versionConflict) throw error;
2977
3104
  if (error instanceof ValidationError || error instanceof EmetError) throw error;
2978
3105
  throw new DatabaseError(`Failed to write document: ${error.message}`, 'write_doc');
2979
3106
  }
@@ -3421,7 +3548,7 @@ export async function patchDoc(doc_id, old_str, new_str, updated_by = null, expe
3421
3548
  }
3422
3549
  }
3423
3550
 
3424
- // Restored 2026-09-19: both constants were dropped in an earlier commit while the function
3551
+ // Restored 2026-09-19: both constants were dropped in commit-685d822 while the function
3425
3552
  // kept referencing them, which made every semantic_recall call throw
3426
3553
  // "SEMANTIC_RECALL_LAYERS is not defined".
3427
3554
  const SEMANTIC_RECALL_LAYERS = ['episodic', 'semantic', 'procedural', 'meta', 'identity', 'working'];
@@ -3682,7 +3809,8 @@ const TOOL_DEFINITIONS = [
3682
3809
  properties: {
3683
3810
  recent_limit: { type: "number", description: "How many recent context entries to return (default 5)." },
3684
3811
  page: { description: "The startup is served in pages when it is larger than one result (each page fits under 20,000 bytes). Omit for page 1; every page but the last says which page to ask for next - call again with that page, before replying, until a page says it is the last. \"all\" returns the whole startup in one result, for a host with no size limit." },
3685
- load_id: { type: "string", description: "The load_id your startup returned in `startup_page.load_id`: every emet_initialize result carries one (each page of a paged startup, the whole startup with page \"all\" or when it fits one result, and a store that is not set up yet). Pass it with every later page so all pages come from the same startup, and pass the same load_id to emet_session_open so its startup page check runs on this startup." }
3812
+ load_id: { type: "string", description: "The load_id your startup returned in `startup_page.load_id`: every emet_initialize result carries one (each page of a paged startup, the whole startup with page \"all\" or when it fits one result, and a store that is not set up yet). Pass it with every later page so all pages come from the same startup, and pass the same load_id to emet_session_open so its startup page check runs on this startup." },
3813
+ source: { type: "string", description: "Your own source tag (the bot or host you are). The startup then reports the tag your writes will carry and its approval state." }
3686
3814
  }
3687
3815
  }
3688
3816
  },
@@ -3692,13 +3820,14 @@ const TOOL_DEFINITIONS = [
3692
3820
  inputSchema: {
3693
3821
  type: "object",
3694
3822
  properties: {
3695
- probe: { type: "boolean", description: "Also write and read back a probe document (default false). Use after a deploy or a configuration change." }
3823
+ probe: { type: "boolean", description: "Also write and read back a probe document (default false). Use after a deploy or a configuration change." },
3824
+ source: { type: "string", description: "Your own source tag. The status then reports the tag your writes will carry and its approval state." }
3696
3825
  }
3697
3826
  }
3698
3827
  },
3699
3828
  {
3700
3829
  name: "emet_setup_complete",
3701
- description: "First-time setup, the owner's only: record the user's interview answers as bootstrap/IDENTITY.md, bootstrap/CHARACTER.md and bootstrap/USER.md and as an identity-layer entry, and create the owner person. Every write is read-back verified. Refused under a bot tag. On a store that is already set up it is refused unless reissue is true (the owner's explicit word): a re-run writes new versions of the user's identity documents; prior versions are archived, never destroyed.",
3830
+ description: "First-time setup, the owner's only: record the user's interview answers as bootstrap/IDENTITY.md, bootstrap/CHARACTER.md and bootstrap/USER.md and as an identity-layer entry, and create the owner person. Every write is read-back verified. Refused under a bot tag. On a store that is already set up it is refused unless reissue is true (the owner's explicit word): a re-run writes new versions of the user's identity documents; prior versions are archived, never destroyed. answers.more_than_one_bot is optional. Absent or no leaves sub-keys off. yes writes EMET_SUBKEYS=1, a generated pepper and EMET_SUBKEY_PEPPER_VERSION=1 into .env only when that file is the config source, after the store writes succeed, and does not turn the flag on in this process. A hosted server refuses yes and returns the three variables to set, then redeploy. See docs/SUBKEYS.md. answers.bot_team_backup is optional. Yes or no, in the user's words, the same way as more_than_one_bot. Yes and Yes. record the flag. yes is the recommended choice. Absent or no leaves bot-team backup off. Anything else, including a non-string, is refused before a write. Setup does not copy prompts or settings.",
3702
3831
  inputSchema: {
3703
3832
  type: "object",
3704
3833
  properties: {
@@ -3711,7 +3840,9 @@ const TOOL_DEFINITIONS = [
3711
3840
  user_name: { type: "string" },
3712
3841
  user_context: { type: "string" },
3713
3842
  user_preferences: { type: "string" },
3714
- source_tag: { type: "string" }
3843
+ source_tag: { type: "string" },
3844
+ more_than_one_bot: { type: "string", description: "Optional. yes or no, in the user's words. Absent or no leaves sub-keys off. yes turns them on only when this server reads .env. Do not invent this answer." },
3845
+ bot_team_backup: { type: "string", description: "Optional. Yes or no, in the user's words. Yes and Yes. count as yes. yes is the recommended choice and records bot-team backup as on. Absent or no leaves it off. A non-string is refused. Do not invent this answer." }
3715
3846
  },
3716
3847
  required: ["assistant_name", "assistant_character", "user_name", "user_context"]
3717
3848
  },
@@ -4146,15 +4277,31 @@ const TOOL_DEFINITIONS = [
4146
4277
  },
4147
4278
  {
4148
4279
  name: "emet_floor",
4149
- description: "Read the floor: the standing description of how this assistant engages with the person using it. It ships in the server code, so setup cannot overwrite it and no install can lose it - the character document holds the user's modifiers on top of it. emet_initialize already returns the whole floor at session start; call this to read it again without re-running initialize. Use `compact: true` for the short form partway through a long session, when the start of the conversation is far behind. Read-only, and re-reading is not the same as being bound by it.",
4280
+ description: "Read the floor: the standing description of how this assistant engages with the person using it. It ships in the server code, so setup cannot overwrite it and no install can lose it - the character document holds the user's modifiers on top of it. emet_initialize already returns the whole floor at session start; call this to read it again without re-running initialize. Use `compact: true` for the short form partway through a long session, when the start of the conversation is far behind. Pass `gem` to read one installed GEM's floor; reading does not turn that GEM on. Read-only, and re-reading is not the same as being bound by it.",
4150
4281
  inputSchema: {
4151
4282
  type: "object",
4152
4283
  properties: {
4153
4284
  compact: { type: "boolean", description: "Return the short re-assertion form instead of the whole floor." },
4154
- section: { type: "string", description: "Return one section only, e.g. 'neurodivergent_support'. Call with no arguments to see the section names." }
4285
+ section: { type: "string", description: "Return one section only, e.g. 'voice'. Call with no arguments to see the section names." },
4286
+ gem: { type: "string", description: "Read one installed GEM's floor by id, for example 'neurendipity'. Reading does not turn the GEM on. Omit it to read the core floor." }
4155
4287
  }
4156
4288
  }
4157
4289
  },
4290
+ {
4291
+ name: "emet_client_approve",
4292
+ description: "Approve or deny a new client (a bot, or a single-instance AI such as Claude or ChatGPT) that EMET flagged on its first write. A new client is read-limited until approved. Only the owner, or the chief-of-staff bot named by EMET_COS_TAG, may decide. Pending clients are listed in emet_initialize and emet_status as client_approvals.",
4293
+ inputSchema: {
4294
+ type: "object",
4295
+ properties: {
4296
+ tag: { type: "string", description: "The pending client's tag, as listed in client_approvals.pending" },
4297
+ decision: { type: "string", enum: ["approve", "deny"] },
4298
+ reason: { type: "string", description: "Optional note kept with the decision" },
4299
+ as: { type: "string", enum: ["rename", "add"], description: "Required when the client_id already has approved tags: add keeps them all (several bots on one shared client); rename retires one" },
4300
+ from: { type: "string", description: "With as=rename on a client that has several approved tags: the tag being replaced" }
4301
+ },
4302
+ required: ["tag", "decision"]
4303
+ }
4304
+ },
4158
4305
  {
4159
4306
  name: "emet_invite_create",
4160
4307
  description: "EMET Guest Access: create a one-time code for a guest. They paste it on the connect screen instead of the passphrase. Default access is read-only. Only the owner can create a guest code. EMET Member Access is a different door.",
@@ -4212,6 +4359,43 @@ const TOOL_DEFINITIONS = [
4212
4359
  }
4213
4360
  ];
4214
4361
 
4362
+ /** Registered only when EMET_SUBKEYS=1. Flag off, a host's tool list does not include them. */
4363
+ const SUBKEY_TOOL_DEFINITIONS = [
4364
+ {
4365
+ name: "emet_subkey_request",
4366
+ description: "Request a delegated client sub-key for one registered source tag. Only an authenticated HTTP login of the owner may call it, and only when EMET_SUBKEYS=1. The result carries a one-time claim nonce. The server stores only its HMAC. Approve or deny the request with emet_client_approve from the owner's own login.",
4367
+ inputSchema: { type: "object", properties: {
4368
+ tag: { type: "string", description: "The source tag this key will stamp. An exact member of EMET_SOURCE_TAGS when a registry is set." }
4369
+ }, required: ["tag"] }
4370
+ },
4371
+ {
4372
+ name: "emet_subkey_claim",
4373
+ description: "Claim an approved sub-key with the key_id and the one-time nonce from emet_subkey_request. The result carries the emk_ key once. A second claim, a wrong nonce, or a missing pepper is refused. The bot keeps the key as a local secret; it is never written into EMET content.",
4374
+ inputSchema: { type: "object", properties: {
4375
+ key_id: { type: "string", description: "The key_id from emet_subkey_request." },
4376
+ nonce: { type: "string", description: "The one-time emn_ nonce from emet_subkey_request." }
4377
+ }, required: ["key_id", "nonce"] }
4378
+ },
4379
+ {
4380
+ name: "emet_subkey_revoke",
4381
+ description: "Revoke a client sub-key by tag or key_id. The owner's own login can always revoke. The key record is kept. A later write with its session handle is refused, and earlier writes stay as written.",
4382
+ inputSchema: { type: "object", properties: {
4383
+ tag: { type: "string", description: "Revoke the live key for this tag. Pass tag or key_id." },
4384
+ key_id: { type: "string", description: "Revoke this key. Pass tag or key_id." },
4385
+ reason: { type: "string", description: "Why it was revoked. Kept on the record." }
4386
+ } }
4387
+ },
4388
+ {
4389
+ name: "emet_subkey_end_session",
4390
+ description: "End a sub-key's live session without revoking the key. Owner only. Use it when the single live session is stuck and the key should stay usable. The old session handle stops working; the bot opens a new session with the same key.",
4391
+ inputSchema: { type: "object", properties: {
4392
+ tag: { type: "string", description: "The tag whose live session to end. Pass tag or key_id." },
4393
+ key_id: { type: "string", description: "The key whose live session to end. Pass tag or key_id." },
4394
+ reason: { type: "string", description: "Why the session was ended. Kept on the record." }
4395
+ } }
4396
+ }
4397
+ ];
4398
+
4215
4399
  /**
4216
4400
  * The tool list as hosts receive it, with MCP annotations attached.
4217
4401
  *
@@ -4220,7 +4404,7 @@ const TOOL_DEFINITIONS = [
4220
4404
  * acting, exactly as risky as an arbitrary shell command.
4221
4405
  */
4222
4406
  /**
4223
- * The session graph's two inputs on every write tool (2026-09-29):
4407
+ * The session graph's two inputs on every write tool (2026-09-29, board 120):
4224
4408
  * `session_id` names the session the write belongs to, `exchange` the turn.
4225
4409
  * Added here, once, rather than hand-copied into each schema; a schema that
4226
4410
  * already declares one keeps its own description.
@@ -4236,8 +4420,61 @@ for (const t of TOOL_DEFINITIONS) {
4236
4420
  t.inputSchema.properties = t.inputSchema.properties || {};
4237
4421
  for (const [k, v] of Object.entries(GRAPH_INPUTS)) if (!t.inputSchema.properties[k]) t.inputSchema.properties[k] = v;
4238
4422
  }
4423
+ const SESSION_HANDLE_INPUT = {
4424
+ type: 'string',
4425
+ description: 'Required on every write in a session opened with a sub-key. The session_handle emet_session_open returned once. It is not repeated in receipts, logs, or session_graph.'
4426
+ };
4427
+ const SUBKEY_OPEN_INPUT = {
4428
+ type: 'string',
4429
+ description: 'The bot\'s emk_ sub-key. Presented once here. The result\'s session_handle is what later writes in this session pass.'
4430
+ };
4431
+ const SUBKEY_CLOSE_INPUT = {
4432
+ type: 'string',
4433
+ description: 'The emk_ sub-key that opened this session. A close releases that sub-key session only when this and session_handle are the ones that opened it. The handle is checked against receipts.transcript_session_id.'
4434
+ };
4435
+
4436
+ function cloneTool(t) {
4437
+ const schema = t.inputSchema ? {
4438
+ ...t.inputSchema,
4439
+ properties: { ...(t.inputSchema.properties || {}) },
4440
+ ...(Array.isArray(t.inputSchema.required) ? { required: [...t.inputSchema.required] } : {})
4441
+ } : t.inputSchema;
4442
+ return { ...t, inputSchema: schema };
4443
+ }
4239
4444
 
4240
- export const TOOLS = annotate(TOOL_DEFINITIONS);
4445
+ let toolCache = { flag: null, tools: null };
4446
+
4447
+ /** The tool list a host receives. The four sub-key tools and their schema fields exist only when EMET_SUBKEYS=1. */
4448
+ export function hostTools() {
4449
+ const on = setting('SUBKEYS') === '1';
4450
+ if (toolCache.tools && toolCache.flag === on) return toolCache.tools;
4451
+ const defs = TOOL_DEFINITIONS.map(cloneTool);
4452
+ if (on) {
4453
+ const open = defs.find((t) => t.name === 'emet_session_open');
4454
+ if (open && open.inputSchema) open.inputSchema.properties.subkey = SUBKEY_OPEN_INPUT;
4455
+ const close = defs.find((t) => t.name === 'emet_session_close');
4456
+ if (close && close.inputSchema) close.inputSchema.properties.subkey = SUBKEY_CLOSE_INPUT;
4457
+ for (const t of defs) {
4458
+ const props = t.inputSchema && t.inputSchema.properties;
4459
+ if (!props || !props.session_id || props.session_handle) continue;
4460
+ props.session_handle = SESSION_HANDLE_INPUT;
4461
+ }
4462
+ defs.push(...SUBKEY_TOOL_DEFINITIONS.map(cloneTool));
4463
+ }
4464
+ const tools = annotate(defs);
4465
+ toolCache = { flag: on, tools };
4466
+ return tools;
4467
+ }
4468
+
4469
+ /** Live view of hostTools(), so a test that flips EMET_SUBKEYS sees the list for that flag. */
4470
+ export const TOOLS = new Proxy([], {
4471
+ get(_target, prop) {
4472
+ const list = hostTools();
4473
+ if (prop === Symbol.iterator) return list[Symbol.iterator].bind(list);
4474
+ const value = list[prop];
4475
+ return typeof value === 'function' ? value.bind(list) : value;
4476
+ }
4477
+ });
4241
4478
 
4242
4479
 
4243
4480
  // ============================================