@soulcraft/cor 3.0.17 → 3.0.19

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.
@@ -86,6 +86,14 @@ export declare class GraphAdjacencyIndex implements GraphIndexProvider {
86
86
  */
87
87
  private canonicalUnindexed;
88
88
  private isRebuilding;
89
+ /** Membership mutated since the last persisted blob (write-side dirty bit). */
90
+ private membershipDirty;
91
+ /** Cached stamp content (minus committedAt) — identical payload = no I/O on idle ticks. */
92
+ private lastMembershipStampBody;
93
+ /** Byte length of the last persisted membership blob (stamp needs it without re-serializing). */
94
+ private lastMembershipBlobBytes;
95
+ /** How this boot obtained membership: restored (stamp), healed (walk), or fresh (no verbs). */
96
+ private membershipBootState;
89
97
  private flushTimer?;
90
98
  private rebuildStartTime;
91
99
  private totalRelationshipsIndexed;
@@ -174,6 +182,32 @@ export declare class GraphAdjacencyIndex implements GraphIndexProvider {
174
182
  * queryable edges report "not ready".
175
183
  */
176
184
  private openGraphLsmEngine;
185
+ /**
186
+ * SHA-256 over the tree's durable SSTable identity (sorted `id:level`
187
+ * lines). The stamp records one per tree; a mismatch at open means the
188
+ * trees advanced (or tore) after the last membership persist, so the
189
+ * blob cannot be trusted and the walk heals. Content-relevant fields
190
+ * only — deliberately NOT the raw manifest JSON (timestamps and field
191
+ * ordering must not fail coherence).
192
+ */
193
+ private treeFingerprint;
194
+ /**
195
+ * Persist the membership family: blob (only when membership mutated or
196
+ * `force`) then stamp LAST (§7 — the stamp commits the set). Identical
197
+ * stamp payloads are skipped entirely, so idle auto-flush ticks cost
198
+ * zero I/O. Failures log loudly and leave the previous stamp in place —
199
+ * a stale stamp heals at next open; a silent half-persist would not.
200
+ */
201
+ private persistVerbMembership;
202
+ /**
203
+ * Restore membership from the stamped blob. Returns `true` only when the
204
+ * FULL coherence chain holds: stamp present → blob present with the exact
205
+ * stamped byte length → every tree fingerprint matches the durable state
206
+ * just loaded → the native parser accepts every framed section. Any break
207
+ * returns `false` (absent stamp silently — that is every pre-3.0.19
208
+ * brain; a broken chain with a warn) and the caller's walk heals.
209
+ */
210
+ private loadVerbMembershipFromStorage;
177
211
  private populateVerbIdSetFromStorage;
178
212
  getNeighbors(id: bigint, optionsOrDirection?: {
179
213
  direction?: 'in' | 'out' | 'both';
@@ -13,6 +13,7 @@
13
13
  * since it coordinates cross-subsystem caching and requires async storage access.
14
14
  */
15
15
  import { dirname } from 'node:path';
16
+ import { createHash } from 'node:crypto';
16
17
  import { TypeUtils, VerbType } from '@soulcraft/brainy/types/graphTypes';
17
18
  import { loadNativeModule } from '../native/index.js';
18
19
  import { composeInvariantReport } from '../utils/invariantReport.js';
@@ -45,6 +46,18 @@ const VERB_FALLBACK_INDEX = TypeUtils.getVerbIndex(VerbType.RelatedTo) ?? 3;
45
46
  * (`verb-uuid-to-int.mkv`, `verb-int-to-uuid.bin`, `verb-endpoints/`).
46
47
  */
47
48
  const GRAPH_VERB_NS_PROBE_KEY = '_graph_verb_ns/x';
49
+ /**
50
+ * Persisted verb-membership family (3.0.19). The roster / per-type unions /
51
+ * per-(type, subtype) buckets / count families were RAM-only, costing every
52
+ * boot of every verb-bearing brain an O(all-canonical-verbs) walk and
53
+ * silently emptying subtype-filtered traversal after restarts. The blob is
54
+ * the serialized structures; the stamp ties the blob to the exact durable
55
+ * tree state (per-tree SSTable fingerprints) so a torn write is DETECTED at
56
+ * open and heals via the walk — §7 coherent-or-rebuild, same discipline as
57
+ * the vector family.
58
+ */
59
+ const GRAPH_MEMBERSHIP_BLOB_KEY = 'graph-verb-membership';
60
+ const GRAPH_MEMBERSHIP_STAMP_KEY = 'graph-membership-stamp';
48
61
  /**
49
62
  * Resolve a verb-type string to its canonical `VerbTypeEnum` index,
50
63
  * falling back to {@link VERB_FALLBACK_INDEX} (`VerbType.RelatedTo`)
@@ -91,6 +104,14 @@ export class GraphAdjacencyIndex {
91
104
  */
92
105
  canonicalUnindexed = false;
93
106
  isRebuilding = false;
107
+ /** Membership mutated since the last persisted blob (write-side dirty bit). */
108
+ membershipDirty = false;
109
+ /** Cached stamp content (minus committedAt) — identical payload = no I/O on idle ticks. */
110
+ lastMembershipStampBody = '';
111
+ /** Byte length of the last persisted membership blob (stamp needs it without re-serializing). */
112
+ lastMembershipBlobBytes = -1;
113
+ /** How this boot obtained membership: restored (stamp), healed (walk), or fresh (no verbs). */
114
+ membershipBootState = 'fresh';
94
115
  flushTimer;
95
116
  rebuildStartTime = 0;
96
117
  totalRelationshipsIndexed = 0;
@@ -293,13 +314,126 @@ export class GraphAdjacencyIndex {
293
314
  for (const treeName of treeNames) {
294
315
  await this.loadTreeFromStorage(treeName);
295
316
  }
317
+ // Persisted-membership restore (3.0.19): coherent stamp + blob loads the
318
+ // roster/buckets/counts in O(bitmap) — no canonical walk, subtype filters
319
+ // intact. `restored` also covers the legitimately-empty-roster case (all
320
+ // verbs removed) so the walk below can't re-fire on it every boot.
321
+ const restored = await this.loadVerbMembershipFromStorage();
296
322
  // Defensive check: if LSM-trees have data but verbIdSet is empty, populate.
297
- // This is the deferred membership fast-follow it does NOT gate isReady().
323
+ // This is the membership HEAL path (pre-3.0.19 brains, torn/missing
324
+ // stamp) — it does NOT gate isReady().
298
325
  const sourceSize = this.native.size();
299
- if (sourceSize > 0 && this.native.verbIdCount() === 0) {
326
+ if (!restored && sourceSize > 0 && this.native.verbIdCount() === 0) {
300
327
  prodLog.warn(`GraphAdjacencyIndex: LSM-trees have ${sourceSize} relationships but verbIdSet is empty. ` +
301
328
  `Triggering auto-rebuild to restore consistency.`);
302
329
  await this.populateVerbIdSetFromStorage();
330
+ this.membershipBootState = 'healed';
331
+ // Persist immediately so the NEXT boot restores instead of re-walking.
332
+ await this.persistVerbMembership(true);
333
+ }
334
+ else if (!restored && this.native.verbIdCount() > 0) {
335
+ // Verbs arrived without a stamp (e.g. mid-process rebuild) — treat as
336
+ // healed so the invariant reports honestly.
337
+ this.membershipBootState = 'healed';
338
+ }
339
+ }
340
+ /**
341
+ * SHA-256 over the tree's durable SSTable identity (sorted `id:level`
342
+ * lines). The stamp records one per tree; a mismatch at open means the
343
+ * trees advanced (or tore) after the last membership persist, so the
344
+ * blob cannot be trusted and the walk heals. Content-relevant fields
345
+ * only — deliberately NOT the raw manifest JSON (timestamps and field
346
+ * ordering must not fail coherence).
347
+ */
348
+ treeFingerprint(treeName) {
349
+ const ids = this.native.getManifestSstableIds(treeName);
350
+ const lines = ids
351
+ .map((id) => `${id}:${this.native.getManifestSstableLevel(treeName, id) ?? '?'}`)
352
+ .sort();
353
+ return createHash('sha256').update(lines.join('\n')).digest('hex');
354
+ }
355
+ /**
356
+ * Persist the membership family: blob (only when membership mutated or
357
+ * `force`) then stamp LAST (§7 — the stamp commits the set). Identical
358
+ * stamp payloads are skipped entirely, so idle auto-flush ticks cost
359
+ * zero I/O. Failures log loudly and leave the previous stamp in place —
360
+ * a stale stamp heals at next open; a silent half-persist would not.
361
+ */
362
+ async persistVerbMembership(force = false) {
363
+ if (!this.hasBinaryBlobs)
364
+ return;
365
+ try {
366
+ if (this.membershipDirty || force || this.lastMembershipBlobBytes < 0) {
367
+ const blob = this.native.serializeVerbMembership();
368
+ await this.storage.saveBinaryBlob(GRAPH_MEMBERSHIP_BLOB_KEY, Buffer.from(blob));
369
+ this.lastMembershipBlobBytes = blob.length;
370
+ this.membershipDirty = false;
371
+ }
372
+ const trees = {};
373
+ for (const treeName of this.native.treeNames()) {
374
+ trees[treeName] = this.treeFingerprint(treeName);
375
+ }
376
+ const body = JSON.stringify({ v: 1, blobBytes: this.lastMembershipBlobBytes, trees });
377
+ if (body === this.lastMembershipStampBody)
378
+ return;
379
+ const stamp = {
380
+ v: 1,
381
+ committedAt: Date.now(),
382
+ blobBytes: this.lastMembershipBlobBytes,
383
+ trees,
384
+ };
385
+ await this.storage.saveMetadata(GRAPH_MEMBERSHIP_STAMP_KEY, {
386
+ noun: 'thing',
387
+ data: stamp,
388
+ });
389
+ this.lastMembershipStampBody = body;
390
+ }
391
+ catch (error) {
392
+ prodLog.error('GraphAdjacencyIndex: failed to persist verb membership — next open will heal via the canonical walk', error);
393
+ }
394
+ }
395
+ /**
396
+ * Restore membership from the stamped blob. Returns `true` only when the
397
+ * FULL coherence chain holds: stamp present → blob present with the exact
398
+ * stamped byte length → every tree fingerprint matches the durable state
399
+ * just loaded → the native parser accepts every framed section. Any break
400
+ * returns `false` (absent stamp silently — that is every pre-3.0.19
401
+ * brain; a broken chain with a warn) and the caller's walk heals.
402
+ */
403
+ async loadVerbMembershipFromStorage() {
404
+ if (!this.hasBinaryBlobs)
405
+ return false;
406
+ try {
407
+ const meta = await this.storage.getMetadata(GRAPH_MEMBERSHIP_STAMP_KEY);
408
+ const stamp = meta?.data;
409
+ if (!stamp || stamp.v !== 1)
410
+ return false;
411
+ const blob = await this.storage.loadBinaryBlob(GRAPH_MEMBERSHIP_BLOB_KEY);
412
+ if (!blob || blob.length !== stamp.blobBytes) {
413
+ prodLog.warn(`GraphAdjacencyIndex: membership blob ${blob ? `is ${blob.length} bytes but the stamp committed ${stamp.blobBytes}` : 'is MISSING under a present stamp'} — healing via the canonical walk`);
414
+ return false;
415
+ }
416
+ for (const treeName of this.native.treeNames()) {
417
+ const now = this.treeFingerprint(treeName);
418
+ if (stamp.trees?.[treeName] !== now) {
419
+ prodLog.warn(`GraphAdjacencyIndex: membership stamp is stale for tree '${treeName}' (durable SSTables changed after the last persist) — healing via the canonical walk`);
420
+ return false;
421
+ }
422
+ }
423
+ this.native.loadVerbMembership(blob);
424
+ this.lastMembershipBlobBytes = blob.length;
425
+ this.lastMembershipStampBody = JSON.stringify({
426
+ v: 1,
427
+ blobBytes: stamp.blobBytes,
428
+ trees: stamp.trees,
429
+ });
430
+ this.membershipBootState = 'restored';
431
+ prodLog.info(`GraphAdjacencyIndex: verb membership restored from stamp (${this.native.verbIdCount()} live verbs, no canonical walk)`);
432
+ return true;
433
+ }
434
+ catch (error) {
435
+ prodLog.warn('GraphAdjacencyIndex: membership restore failed — healing via the canonical walk', error);
436
+ return false;
303
437
  }
304
438
  }
305
439
  async populateVerbIdSetFromStorage() {
@@ -313,12 +447,16 @@ export class GraphAdjacencyIndex {
313
447
  pagination: { limit: 10000, cursor }
314
448
  });
315
449
  for (const verb of result.items) {
316
- // Cor 3.0 Piece D: trackVerbIdByIndex was deleted along with
317
- // the other strict-index variants. trackVerbId takes the verb-
318
- // type as a string; unknown strings fall back to RelatedTo —
319
- // acceptable on the rebuild slow path because the membership
320
- // bitmap converges to the same set either way.
321
- this.native.trackVerbId(verb.id, verb.verb);
450
+ // Full-fidelity tracking (3.0.19): the membership-only walk used
451
+ // to drop subtype state, silently emptying findConnectedSubtype
452
+ // after every restart. Canonical carries the subtype (brainy
453
+ // 7.30.0+) a METADATA field on verbs, surfaced top-level on
454
+ // hydrated items; read both shapes. Unknown type strings fall
455
+ // back to RelatedTo — acceptable on the rebuild slow path
456
+ // because the membership bitmap converges either way.
457
+ const v = verb;
458
+ const subtype = v.subtype ?? v.metadata?.subtype ?? '';
459
+ this.native.trackVerbIdFull(verb.id, resolveVerbTypeIndex(verb.verb), subtype);
322
460
  count++;
323
461
  }
324
462
  hasMore = result.hasMore;
@@ -437,6 +575,7 @@ export class GraphAdjacencyIndex {
437
575
  // coalesce absent subtype to '' here.
438
576
  const subtype = verb.subtype ?? '';
439
577
  const result = this.native.addVerbWithEndpointsByIndex(verb.id, sourceInt, targetInt, verbTypeIndex, subtype, generation);
578
+ this.membershipDirty = true;
440
579
  // Flush any trees that hit their threshold
441
580
  const flushPromises = [];
442
581
  if (result.needsFlushSource) {
@@ -465,8 +604,19 @@ export class GraphAdjacencyIndex {
465
604
  await this.ensureInitialized();
466
605
  // Load verb to get type + subtype info
467
606
  const verb = await this.getVerbCached(verbId);
468
- if (!verb)
607
+ if (!verb) {
608
+ // REMOVAL LAW (3.0.19): removal must never require reading the thing
609
+ // being removed. A ghost verb (canonical row unreadable) used to be
610
+ // silently SKIPPED here — its graph rows survived forever, the exact
611
+ // shape of brainy's replace-path defect one layer down. The id-only
612
+ // native path resolves type/subtype from cor-owned state instead.
613
+ const removed = this.native.removeVerbByIdOnly(verbId, generation);
614
+ if (removed) {
615
+ this.membershipDirty = true;
616
+ prodLog.warn(`GraphAdjacencyIndex: removed ghost verb ${verbId} via the id-only path (canonical row unreadable — removal law)`);
617
+ }
469
618
  return;
619
+ }
470
620
  const startTime = performance.now();
471
621
  const verbTypeIndex = resolveVerbTypeIndex(verb.verb);
472
622
  // Native subtype param is `String` with '' = "no subtype" (the empty-string
@@ -475,6 +625,7 @@ export class GraphAdjacencyIndex {
475
625
  // coalesce absent subtype to '' here.
476
626
  const subtype = verb.subtype ?? '';
477
627
  this.native.removeVerb(verbId, verbTypeIndex, subtype, generation);
628
+ this.membershipDirty = true;
478
629
  const elapsed = performance.now() - startTime;
479
630
  if (elapsed > 5.0) {
480
631
  prodLog.warn(`GraphAdjacencyIndex: Slow removeVerb for ${verbId}: ${elapsed.toFixed(2)}ms`);
@@ -600,6 +751,19 @@ export class GraphAdjacencyIndex {
600
751
  : 'no canonical-vs-durable coverage gap detected',
601
752
  heal: 'rebuild',
602
753
  }),
754
+ () => ({
755
+ name: 'membership-stamp',
756
+ // Every boot path lands in an honest state: restored (stamp
757
+ // coherent), healed (walk ran — first boot on 3.0.19, or a torn
758
+ // stamp), or fresh (no verbs anywhere). A verb-bearing graph
759
+ // whose membership is EMPTY while trees hold edges is the only
760
+ // failing shape — subtype filters would silently serve nothing.
761
+ holds: !(this.native.size() > 0 && this.native.verbIdCount() === 0),
762
+ detail: this.native.size() > 0 && this.native.verbIdCount() === 0
763
+ ? 'trees hold edges but the live-verb roster is EMPTY — membership restore AND heal both failed'
764
+ : `membership ${this.membershipBootState} this boot (${this.native.verbIdCount()} live verbs)`,
765
+ heal: 'rebuild',
766
+ }),
603
767
  ]);
604
768
  }
605
769
  isReady() {
@@ -719,8 +883,18 @@ export class GraphAdjacencyIndex {
719
883
  for (const verb of result.items) {
720
884
  totalVerbs++;
721
885
  const verbType = verb.verb ?? 'relatedTo';
886
+ const verbTypeIndex = resolveVerbTypeIndex(verbType);
887
+ // Native subtype param is `String` with '' = "no subtype" (the
888
+ // empty-string escape; graph_adjacency.rs guards on
889
+ // `!subtype.is_empty()`). JS null throws "Null into String" at the
890
+ // napi boundary, so coalesce absent subtype to ''. Subtype is a
891
+ // metadata field on verbs — read both hydration shapes.
892
+ const vv = verb;
893
+ const subtype = vv.subtype ?? vv.metadata?.subtype ?? '';
722
894
  if (!reconstructAdjacency) {
723
- this.native.trackVerbId(verb.id, verbType);
895
+ // Full-fidelity membership (3.0.19): subtype rides the walk so
896
+ // filtered traversal survives the rebuild.
897
+ this.native.trackVerbIdFull(verb.id, verbTypeIndex, subtype);
724
898
  continue;
725
899
  }
726
900
  const sourceId = verb.sourceId;
@@ -730,16 +904,10 @@ export class GraphAdjacencyIndex {
730
904
  if (sourceInt === undefined || targetInt === undefined) {
731
905
  // Endpoint not in the mapper — still record membership so the verb
732
906
  // isn't lost; its adjacency can't be rebuilt without the int.
733
- this.native.trackVerbId(verb.id, verbType);
907
+ this.native.trackVerbIdFull(verb.id, verbTypeIndex, subtype);
734
908
  unresolvedEndpoints++;
735
909
  continue;
736
910
  }
737
- const verbTypeIndex = resolveVerbTypeIndex(verbType);
738
- // Native subtype param is `String` with '' = "no subtype" (the
739
- // empty-string escape; graph_adjacency.rs guards on
740
- // `!subtype.is_empty()`). JS null throws "Null into String" at the
741
- // napi boundary, so coalesce absent subtype to ''.
742
- const subtype = verb.subtype ?? '';
743
911
  const addResult = this.native.addVerbWithEndpointsByIndex(verb.id, BigInt(sourceInt), BigInt(targetInt), verbTypeIndex, subtype, REBUILD_GENERATION);
744
912
  edgesRebuilt++;
745
913
  // Respect memtable flush thresholds mid-walk so RAM stays bounded.
@@ -759,6 +927,15 @@ export class GraphAdjacencyIndex {
759
927
  cursor = result.nextCursor;
760
928
  }
761
929
  this.totalRelationshipsIndexed = totalVerbs;
930
+ // The walk rebuilt membership through the native handle directly (the
931
+ // wrapper's dirty bit never fired) — mark it so the persist below and
932
+ // any later flush write the fresh blob.
933
+ this.membershipDirty = true;
934
+ if (!reconstructAdjacency) {
935
+ // Membership-only rebuild writes no memtables, so no flush runs —
936
+ // persist the family directly or the walk repeats next boot.
937
+ await this.persistVerbMembership(true);
938
+ }
762
939
  if (reconstructAdjacency) {
763
940
  // Persist partially-filled memtables from the final batch.
764
941
  await this.flush();
@@ -800,6 +977,9 @@ export class GraphAdjacencyIndex {
800
977
  if (flushPromises.length > 0) {
801
978
  await Promise.all(flushPromises);
802
979
  }
980
+ // Membership family rides every flush cycle: blob only when membership
981
+ // mutated, stamp only when the payload changed — idle ticks cost nothing.
982
+ await this.persistVerbMembership();
803
983
  const elapsed = Date.now() - startTime;
804
984
  prodLog.debug(`GraphAdjacencyIndex: Flush completed in ${elapsed}ms`);
805
985
  }
@@ -1031,6 +1211,11 @@ export class GraphAdjacencyIndex {
1031
1211
  });
1032
1212
  }));
1033
1213
  });
1214
+ // Compaction changed the durable SSTable identity, so the
1215
+ // membership stamp's fingerprint for this tree is now stale —
1216
+ // refresh it (stamp-only unless membership also mutated). Without
1217
+ // this, the next open would needlessly heal via the walk.
1218
+ await this.persistVerbMembership();
1034
1219
  const elapsed = Date.now() - startTime;
1035
1220
  prodLog.info(`GraphAdjacencyIndex: Compaction ${treeName} L${level} → L${result.newLevel} complete in ${elapsed}ms`);
1036
1221
  if (result.newLevel < 6 && this.native.needsCompaction(treeName, result.newLevel)) {
@@ -458,6 +458,17 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
458
458
  getPersistMode(): 'immediate' | 'deferred';
459
459
  /** Absolute path of a segment file (lives next to the base indexPath). */
460
460
  private segPath;
461
+ /**
462
+ * Move poisoned L0 segments out of the live set into
463
+ * `<indexDir>/quarantine/` — preserved for forensics, NEVER deleted
464
+ * (accord's 545 poisoned segments were the evidence that convicted the
465
+ * June dim bug). Crash-safe order: the manifest stops referencing the
466
+ * segments FIRST (a crash after that leaves orphaned, de-referenced
467
+ * files — a harmless leak), then the files move. The dropped native
468
+ * handles close with their references; nothing reads a de-referenced
469
+ * segment.
470
+ */
471
+ private quarantineSegments;
461
472
  /** Membership across the base + every L0 segment (int → any slot). */
462
473
  private hasIntAnywhere;
463
474
  /**
@@ -75,8 +75,8 @@ import { declareDerivedFamilies, VECTOR_FAMILIES } from '../utils/derivedFamilie
75
75
  import { statSync, readFileSync } from 'node:fs';
76
76
  import { loadNativeModule } from '../native/index.js';
77
77
  import { prodLog } from '@soulcraft/brainy/internals';
78
- import { mkdirSync, existsSync, rmSync } from 'node:fs';
79
- import { dirname, join } from 'node:path';
78
+ import { mkdirSync, existsSync, rmSync, renameSync } from 'node:fs';
79
+ import { dirname, join, basename } from 'node:path';
80
80
  import { autoModeForHeader, selectModeFromResourceManager, } from './AdaptiveDiskAnnModeSelector.js';
81
81
  /**
82
82
  * Node-count threshold above which `useMmapAdjacency: 'auto'`
@@ -1174,13 +1174,50 @@ export class NativeDiskAnnWrapper {
1174
1174
  // into the new base. Their entries are read back through the existing
1175
1175
  // readVectorChunk/slotIdsForSlots surface (each segment is bounded-small
1176
1176
  // by construction, ≤ the delta budget, so this stays bounded RAM).
1177
- const builtL0 = [...this.l0Segments];
1178
- const l0Data = builtL0.map((seg) => ({
1179
- ints: (seg.nodeCount > 0
1180
- ? seg.native.slotIdsForSlots(Array.from({ length: seg.nodeCount }, (_, i) => i))
1181
- : []),
1182
- vecs: seg.native.readVectorChunk(0, seg.nodeCount),
1183
- }));
1177
+ //
1178
+ // SEGMENT QUARANTINE (3.0.19, accord 2026-07-14): a segment whose
1179
+ // read-back throws (June-class dim poisoning — "Range … out of bounds")
1180
+ // used to abort the WHOLE fold, forever: 545 live segments accumulated,
1181
+ // the write brake engaged, and the board refused writes for 20 hours.
1182
+ // A poisoned segment is now dropped from the fold LOUDLY and its files
1183
+ // moved to <indexDir>/quarantine/ (preserved for forensics, never
1184
+ // deleted); the fold completes with the healthy segments and the same
1185
+ // pass restores the lost entries from canonical (hydrate below), so the
1186
+ // rebuilt base is COMPLETE — serving-truth holds without a second heal.
1187
+ const builtL0 = [];
1188
+ const l0Data = [];
1189
+ const poisonedL0 = [];
1190
+ for (const seg of this.l0Segments) {
1191
+ try {
1192
+ const ints = (seg.nodeCount > 0
1193
+ ? seg.native.slotIdsForSlots(Array.from({ length: seg.nodeCount }, (_, i) => i))
1194
+ : []);
1195
+ const vecs = seg.native.readVectorChunk(0, seg.nodeCount);
1196
+ builtL0.push(seg);
1197
+ l0Data.push({ ints, vecs });
1198
+ }
1199
+ catch (error) {
1200
+ poisonedL0.push(seg);
1201
+ prodLog.error(`NativeDiskAnnWrapper: L0 segment ${seg.file} is UNREADABLE ` +
1202
+ `(${error instanceof Error ? error.message : String(error)}) — ` +
1203
+ `QUARANTINED (${seg.nodeCount} entries; restored from canonical in this fold)`);
1204
+ }
1205
+ }
1206
+ if (poisonedL0.length > 0) {
1207
+ await this.quarantineSegments(poisonedL0);
1208
+ if (typeof this.storage?.getNouns === 'function') {
1209
+ // Quarantine fold: the poisoned segments' entries exist ONLY in
1210
+ // canonical now — hydrate the delta BEFORE the dedup sets below are
1211
+ // computed, so newest-wins exclusion sees the full canonical set
1212
+ // (base slots excluded for delta ids, healthy-L0 rows deduped
1213
+ // against the delta — no duplicate ints, complete output).
1214
+ // O(canonical) reads, paid only on an actual corruption event —
1215
+ // the alternative (a base silently missing entities) violates
1216
+ // serving-truth. A scoped missing-ids-only backfill rides the M3
1217
+ // fact-log scan path.
1218
+ await this.hydrateDeltaFromStorage(this.config.dimensions);
1219
+ }
1220
+ }
1184
1221
  // Tombstone ints (used to exclude entries from BOTH the base and the L0
1185
1222
  // read-back). Ids never indexed have no int — nothing to exclude.
1186
1223
  const tombIntKeys = new Set();
@@ -1256,11 +1293,14 @@ export class NativeDiskAnnWrapper {
1256
1293
  let dim = this.config.dimensions ??
1257
1294
  (this.native ? this.native.header().dim : undefined) ??
1258
1295
  builtL0.find((s) => s.nodeCount > 0)?.native.header().dim;
1259
- if (liveOldSlots.length === 0 && typeof this.storage?.getNouns === 'function') {
1296
+ if (liveOldSlots.length === 0 &&
1297
+ poisonedL0.length === 0 &&
1298
+ typeof this.storage?.getNouns === 'function') {
1260
1299
  const probe = await this.storage.getNouns({ pagination: { limit: 1 } });
1261
1300
  if (probe.totalCount === undefined || probe.totalCount > this.delta.size) {
1262
1301
  // Cold/restore rebuild: hydrate also adopts the dim from canonical data
1263
1302
  // when we still don't have it, and returns the width it used.
1303
+ // (A quarantine fold already hydrated ABOVE, before the dedup sets.)
1264
1304
  dim = await this.hydrateDeltaFromStorage(dim);
1265
1305
  }
1266
1306
  }
@@ -1461,6 +1501,37 @@ export class NativeDiskAnnWrapper {
1461
1501
  segPath(file) {
1462
1502
  return `${dirname(this.config.indexPath)}/${file}`;
1463
1503
  }
1504
+ /**
1505
+ * Move poisoned L0 segments out of the live set into
1506
+ * `<indexDir>/quarantine/` — preserved for forensics, NEVER deleted
1507
+ * (accord's 545 poisoned segments were the evidence that convicted the
1508
+ * June dim bug). Crash-safe order: the manifest stops referencing the
1509
+ * segments FIRST (a crash after that leaves orphaned, de-referenced
1510
+ * files — a harmless leak), then the files move. The dropped native
1511
+ * handles close with their references; nothing reads a de-referenced
1512
+ * segment.
1513
+ */
1514
+ async quarantineSegments(poisoned) {
1515
+ const drop = new Set(poisoned.map((s) => s.id));
1516
+ this.l0Segments = this.l0Segments.filter((s) => !drop.has(s.id));
1517
+ await this.saveSegmentManifest();
1518
+ const qDir = join(dirname(this.config.indexPath), 'quarantine');
1519
+ for (const seg of poisoned) {
1520
+ try {
1521
+ mkdirSync(qDir, { recursive: true });
1522
+ const src = this.segPath(seg.file);
1523
+ const family = [src, this.siblingPath(src, 'slotmap'), this.siblingPath(src, 'slotrev')];
1524
+ for (const p of family) {
1525
+ if (existsSync(p))
1526
+ renameSync(p, join(qDir, basename(p)));
1527
+ }
1528
+ }
1529
+ catch (error) {
1530
+ prodLog.error(`NativeDiskAnnWrapper: quarantine move failed for ${seg.file} — ` +
1531
+ `segment stays de-referenced on disk (manifest no longer lists it)`, error);
1532
+ }
1533
+ }
1534
+ }
1464
1535
  /** Membership across the base + every L0 segment (int → any slot). */
1465
1536
  hasIntAnywhere(int) {
1466
1537
  if (this.native && this.native.hasInt(int))
@@ -751,6 +751,31 @@ export interface NativeGraphAdjacencyInstance {
751
751
  * when LSM-trees are already loaded from persisted SSTables.
752
752
  */
753
753
  trackVerbId(verbId: string, verbType: string): void;
754
+ /**
755
+ * Full-fidelity membership tracking for the boot heal walk (3.0.19):
756
+ * roster + per-type union + counts + per-(type, subtype) bucket. The
757
+ * membership-only walk dropped subtype state, silently emptying
758
+ * `findConnectedSubtype` after every restart.
759
+ */
760
+ trackVerbIdFull(verbId: string, verbTypeIndex: number, subtype: string): void;
761
+ /**
762
+ * Serialize every RAM-resident membership structure (roster, per-type
763
+ * unions, per-(type, subtype) buckets, both count families) to one
764
+ * strictly-framed buffer for stamped persistence (3.0.19).
765
+ */
766
+ serializeVerbMembership(): Buffer;
767
+ /**
768
+ * Restore membership from a `serializeVerbMembership` buffer. Throws on
769
+ * ANY malformation (magic/version/id-space/truncation/trailing bytes) —
770
+ * the caller falls back to the canonical heal walk. Idempotent overwrite.
771
+ */
772
+ loadVerbMembership(buf: Buffer): void;
773
+ /**
774
+ * Removal-law removal (3.0.19): removes a verb resolving type/subtype
775
+ * from cor-owned state (endpoint store / membership buckets) — never
776
+ * reads canonical. Returns false when unknown/already dead (idempotent).
777
+ */
778
+ removeVerbByIdOnly(verbId: string, generation: bigint): boolean;
754
779
  /** Clear membership bitmap + relationship counts (rebuild prelude). */
755
780
  clearVerbTracking(): void;
756
781
  /**
@@ -976,6 +1001,15 @@ export interface NativeMetadataIndexInstance {
976
1001
  * Never throws; empty index → `true`. Backs the wrapper's `probeConsistency`.
977
1002
  */
978
1003
  probeConsistency(): boolean;
1004
+ /**
1005
+ * Suspect-surfacing probe (3.0.19): same bounded walk, but returns the
1006
+ * first suspicious `{field, uuid}` as JSON (null = clean) so the wrapper
1007
+ * can adjudicate it with one canonical read instead of guessing from the
1008
+ * sample MIX.
1009
+ */
1010
+ probeConsistencySuspect(): string | null;
1011
+ /** Mark a field learned-multi-valued after canonical adjudication (3.0.19). */
1012
+ markFieldMultiValue(field: string): void;
979
1013
  /**
980
1014
  * Scan for the cross-bucket phantom signature (a query-visible int in >1
981
1015
  * value bucket of a field) and return up to `maxCandidates` (0 = unlimited)
@@ -133,6 +133,21 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
133
133
  * init() (`columnStore.init` / `warmCache` / `lazyLoadCounts`) on shared state.
134
134
  */
135
135
  private epochMigrationPending;
136
+ /**
137
+ * TRUE when {@link wireIdMapper}'s factory CREATED the mmap mapper fresh
138
+ * this open (both `_id_mapper/*` stores were absent). Benign on a truly
139
+ * fresh brain; POISON when durable int-keyed postings survive alongside
140
+ * it (a lost/deleted `_id_mapper/` directory) — every surviving posting
141
+ * references ints the new mapper never assigned, and minting would pair
142
+ * NEW ints against OLD-int postings silently. Detected right after the
143
+ * LSM cold-open; the response is the coordinated full rebuild.
144
+ */
145
+ private idMapperCreatedFresh;
146
+ /** TRUE when the lost-mapper guard fired this open: the coordinated
147
+ * rebuild must complete BEFORE init returns (blocking), because until
148
+ * vector+graph re-mint, their durable state pairs old ints with a
149
+ * mapper that never assigned them — strictly unservable. */
150
+ private lostMapperHeal;
136
151
  private lastFlushTime;
137
152
  private autoFlushThreshold;
138
153
  private dirtyFields;
@@ -35,6 +35,7 @@ const BUCKETED_INDEX_FIELDS = new Set([
35
35
  import { TypeUtils, NOUN_TYPE_COUNT, VERB_TYPE_COUNT } from '@soulcraft/brainy/types/graphTypes';
36
36
  import { loadNativeModule } from '../native/index.js';
37
37
  import { composeInvariantReport } from './invariantReport.js';
38
+ import { opsLine } from './opsLog.js';
38
39
  import { declareDerivedFamilies, METADATA_FAMILIES } from './derivedFamilies.js';
39
40
  import { openOrCreateBinaryIdMapper } from './binaryIdMapperFactory.js';
40
41
  import { NativeColumnStore } from './NativeColumnStore.js';
@@ -222,6 +223,21 @@ export class MetadataIndexManager {
222
223
  * init() (`columnStore.init` / `warmCache` / `lazyLoadCounts`) on shared state.
223
224
  */
224
225
  epochMigrationPending = false;
226
+ /**
227
+ * TRUE when {@link wireIdMapper}'s factory CREATED the mmap mapper fresh
228
+ * this open (both `_id_mapper/*` stores were absent). Benign on a truly
229
+ * fresh brain; POISON when durable int-keyed postings survive alongside
230
+ * it (a lost/deleted `_id_mapper/` directory) — every surviving posting
231
+ * references ints the new mapper never assigned, and minting would pair
232
+ * NEW ints against OLD-int postings silently. Detected right after the
233
+ * LSM cold-open; the response is the coordinated full rebuild.
234
+ */
235
+ idMapperCreatedFresh = false;
236
+ /** TRUE when the lost-mapper guard fired this open: the coordinated
237
+ * rebuild must complete BEFORE init returns (blocking), because until
238
+ * vector+graph re-mint, their durable state pairs old ints with a
239
+ * mapper that never assigned them — strictly unservable. */
240
+ lostMapperHeal = false;
225
241
  lastFlushTime = Date.now();
226
242
  autoFlushThreshold = 10;
227
243
  dirtyFields = new Set();
@@ -1123,6 +1139,36 @@ export class MetadataIndexManager {
1123
1139
  // with NO O(N) rebuild. Runs after wireIdMapper (the engine's posting
1124
1140
  // lists reference the mapper's entity ints) and before any read/mutation.
1125
1141
  await this.openLsmEngine();
1142
+ // LOST-MAPPER GUARD (MEMORY-COR-IDMAP-MIGRATION, root-caused
1143
+ // 2026-07-14): the ONE genuinely dangerous open-state — the mmap
1144
+ // mapper was just created EMPTY while durable int-keyed postings
1145
+ // survive. (Empirically: 3.0.x-born brains carry their mapper and
1146
+ // upgrade cleanly; this cell arises from a lost/deleted `_id_mapper/`
1147
+ // directory, e.g. deleter-era damage.) Serving would resolve every
1148
+ // surviving posting through ints the mapper never assigned, and the
1149
+ // 3.0.16 mint would pair NEW ints against OLD-int postings silently.
1150
+ // Response: loud, then the coordinated full rebuild (metadata →
1151
+ // vector → graph under the migration lock) so re-minted ints are
1152
+ // globally consistent; strandDetected is the belt when no
1153
+ // coordinator is wired (raw/test usage) so the metadata leg at least
1154
+ // reports not-ready instead of serving wrong pairings.
1155
+ // The trigger is EMPTINESS, not who-created-it: TWO entry points open
1156
+ // `_id_mapper/*` (this manager and the plugin's entityIdMapper
1157
+ // provider), and whichever runs first re-creates the directories — a
1158
+ // created-fresh flag from one path is blind to the other. An EMPTY
1159
+ // mapper alongside durable postings is the poison regardless of how
1160
+ // it got empty (deleted directory, truncated store).
1161
+ const mapperEmpty = this.native.entityIdMapperSize() === 0;
1162
+ if ((this.idMapperCreatedFresh || mapperEmpty) && this.lsmHasDurableState()) {
1163
+ opsLine(`LOST-MAPPER: _id_mapper/ was recreated EMPTY while durable ` +
1164
+ `int-keyed postings survive — the surviving index state is ` +
1165
+ `unresolvable and would serve WRONG pairings. Forcing the ` +
1166
+ `coordinated full rebuild from canonical (metadata + vector + ` +
1167
+ `graph; ints re-mint consistently).`);
1168
+ this.strandDetected = true;
1169
+ this.epochMigrationPending = true;
1170
+ this.lostMapperHeal = true;
1171
+ }
1126
1172
  // **#18 read-side epoch guard.** AFTER the cortex-2.x legacy gate and the
1127
1173
  // LSM cold-open, classify the brain's derived-index epoch against this
1128
1174
  // build's expectation. The common case (current epoch) is a single tiny
@@ -1158,6 +1204,13 @@ export class MetadataIndexManager {
1158
1204
  prodLog.info('[NativeMetadataIndex] Field registry missing but the LSM engine ' +
1159
1205
  'recovered durable state (SSTables/memtable) — serving from it, ' +
1160
1206
  'NO rebuild.');
1207
+ // DETECTOR COVERAGE (memory-vm 2026-07-14): this early serve
1208
+ // path previously SKIPPED detectDerivedStrand entirely — a
1209
+ // brain with lost postings that also lost its field registry
1210
+ // served the gap SILENTLY (posted < canonical, zero log
1211
+ // lines). The strand check is boot-path-invariant: it runs
1212
+ // here too, with the same verified-unpostable allowance.
1213
+ await this.detectDerivedStrand();
1161
1214
  this.readinessInitialized = true;
1162
1215
  return;
1163
1216
  }
@@ -1235,7 +1288,24 @@ export class MetadataIndexManager {
1235
1288
  this.readinessInitialized = true;
1236
1289
  if (this.epochMigrationPending) {
1237
1290
  this.epochMigrationPending = false;
1238
- this.migrationCoordinatorGetter?.()?.start().catch(() => { });
1291
+ const coordinator = this.migrationCoordinatorGetter?.();
1292
+ if (this.lostMapperHeal) {
1293
+ // Lost-mapper heal BLOCKS init: until vector+graph re-mint, their
1294
+ // durable state pairs old ints with a mapper that never assigned
1295
+ // them — brainy must not get an initialized brain before the
1296
+ // coordinated rebuild completes. (The epoch path below stays
1297
+ // fire-and-forget per the rc.9 lock contract — brainy sees the
1298
+ // stale marker and waits on the lock itself.)
1299
+ this.lostMapperHeal = false;
1300
+ if (coordinator) {
1301
+ await coordinator.start();
1302
+ }
1303
+ // No coordinator (raw/test usage): strandDetected already gates
1304
+ // the metadata leg; the loud LOST-MAPPER ops line stands.
1305
+ }
1306
+ else {
1307
+ coordinator?.start().catch(() => { });
1308
+ }
1239
1309
  }
1240
1310
  }
1241
1311
  /**
@@ -1301,24 +1371,33 @@ export class MetadataIndexManager {
1301
1371
  async verifiedUnpostableAllowance() {
1302
1372
  try {
1303
1373
  const stamp = (await this.storage.getMetadata(UNPOSTABLE_STAMP_KEY));
1304
- if (!stamp || typeof stamp.nounCount !== 'number' || stamp.nounCount <= 0)
1374
+ if (!stamp)
1305
1375
  return 0;
1376
+ // Completion-gap portion (3.0.19): canonical rows a COMPLETED walk
1377
+ // could not even enumerate (no ids exist to re-verify) — count-only
1378
+ // by nature, refreshed on every completed rebuild. The detector's
1379
+ // "FULLY explained by" line reports it whenever it carries the floor.
1380
+ const gap = typeof stamp.completionGap === 'number' && stamp.completionGap > 0
1381
+ ? stamp.completionGap
1382
+ : 0;
1383
+ if (typeof stamp.nounCount !== 'number' || stamp.nounCount <= 0)
1384
+ return gap;
1306
1385
  if (Array.isArray(stamp.nounIds) && stamp.nounIds.length === stamp.nounCount) {
1307
1386
  const getMeta = this.storage.getNounMetadata;
1308
1387
  if (typeof getMeta !== 'function')
1309
- return stamp.nounCount;
1388
+ return stamp.nounCount + gap;
1310
1389
  let stillUnpostable = 0;
1311
1390
  for (const id of stamp.nounIds) {
1312
1391
  const md = await getMeta.call(this.storage, id).catch(() => undefined);
1313
1392
  if (md == null)
1314
1393
  stillUnpostable++;
1315
1394
  }
1316
- return stillUnpostable;
1395
+ return stillUnpostable + gap;
1317
1396
  }
1318
1397
  prodLog.warn(`[NativeMetadataIndex] unpostable stamp is count-only ` +
1319
1398
  `(${stamp.nounCount} > id cap) — the posted-count floor carries its ` +
1320
1399
  `staleness until the next completed rebuild refreshes it.`);
1321
- return stamp.nounCount;
1400
+ return stamp.nounCount + gap;
1322
1401
  }
1323
1402
  catch {
1324
1403
  return 0;
@@ -1435,9 +1514,9 @@ export class MetadataIndexManager {
1435
1514
  `so the cold-open gate rebuilds from canonical.`);
1436
1515
  }
1437
1516
  else if (unpostable > 0 && posted < canonical) {
1438
- prodLog.info(`[NativeMetadataIndex] posted ${posted} < canonical ${canonical} is ` +
1439
- `FULLY explained by ${unpostable} verified metadata-less canonical ` +
1440
- `entities (stamped by the last completed rebuild) — no strand, no heal.`);
1517
+ opsLine(`posted ${posted} < canonical ${canonical} is FULLY explained by ` +
1518
+ `${unpostable} verified metadata-less canonical entities (stamped ` +
1519
+ `by the last completed rebuild) — no strand, no heal.`);
1441
1520
  }
1442
1521
  }
1443
1522
  }
@@ -1468,6 +1547,7 @@ export class MetadataIndexManager {
1468
1547
  idSpace: 'u64',
1469
1548
  });
1470
1549
  this.idMapperNative = handle.mapper;
1550
+ this.idMapperCreatedFresh = handle.created;
1471
1551
  this.native.setIdMapper(handle.mapper);
1472
1552
  }
1473
1553
  async loadFieldRegistry() {
@@ -2776,7 +2856,53 @@ export class MetadataIndexManager {
2776
2856
  await this.ensureFieldLoaded(field);
2777
2857
  loaded++;
2778
2858
  }
2779
- return this.native.probeConsistency();
2859
+ // Adjudicated probe (3.0.19): the native walk surfaces ONE suspect
2860
+ // {field, uuid}; a single canonical read decides it. ≥2 current
2861
+ // values = a mixed-length ARRAY field, not a phantom — learned,
2862
+ // persisted, and the probe re-runs (each pass retires one field
2863
+ // permanently, so this converges in ≤ field count; bounded harder
2864
+ // below). Anything else = a genuine phantom → false, exactly the old
2865
+ // detection strength. This replaces the MIX guess whose verdict
2866
+ // depended on which ints the sample happened to draw (accord's brain:
2867
+ // clean boots 1–2, failing boot 3, identical bytes).
2868
+ if (typeof this.native.probeConsistencySuspect !== 'function') {
2869
+ // Pre-3.0.19 binary: no adjudication surface — use the sampled MIX
2870
+ // verdict. WITHOUT this explicit fallback the call below throws into
2871
+ // the outer catch, which assumes healthy — i.e. a stale binary would
2872
+ // silently DISABLE phantom detection (the 3.0.12–3.0.15 class).
2873
+ return this.native.probeConsistency();
2874
+ }
2875
+ const MAX_ADJUDICATIONS = 8;
2876
+ for (let i = 0; i <= MAX_ADJUDICATIONS; i++) {
2877
+ const suspectJson = this.native.probeConsistencySuspect();
2878
+ if (!suspectJson)
2879
+ return true;
2880
+ if (i === MAX_ADJUDICATIONS)
2881
+ return false;
2882
+ const { field, uuid } = JSON.parse(suspectJson);
2883
+ let currentValues = [];
2884
+ try {
2885
+ const noun = await this.storage.getNoun(uuid);
2886
+ if (noun) {
2887
+ const raw = resolveEntityField(noun, field);
2888
+ if (raw !== undefined && raw !== null) {
2889
+ currentValues = Array.isArray(raw) ? raw : [raw];
2890
+ }
2891
+ }
2892
+ }
2893
+ catch {
2894
+ // Unreadable canonical = cannot exonerate — keep the phantom verdict.
2895
+ }
2896
+ if (currentValues.length < 2)
2897
+ return false;
2898
+ this.native.markFieldMultiValue(field);
2899
+ const json = this.native.saveFieldIndex(field);
2900
+ if (json) {
2901
+ await this.storage.saveMetadata(`__metadata_field_index__field_${field}`, JSON.parse(json));
2902
+ }
2903
+ prodLog.info(`[probeConsistency] field '${field}' adjudicated multi-valued against canonical (${currentValues.length} current values) — learned + persisted, exempt from phantom checks`);
2904
+ }
2905
+ return false;
2780
2906
  }
2781
2907
  catch (error) {
2782
2908
  // A probe must never throw into the read path — assume healthy.
@@ -2872,6 +2998,14 @@ export class MetadataIndexManager {
2872
2998
  purged++;
2873
2999
  repairedFields.add(field);
2874
3000
  }
3001
+ // MULTI-VALUE LEARNING persistence (3.0.19): ≥2 current values
3002
+ // proves the field is array-shaped — the native side just learned
3003
+ // it (probe/detector exemption); persist the flag with the field
3004
+ // index or the next cold boot re-flags the same candidates
3005
+ // forever (accord's 166-loop, second mechanism).
3006
+ if (currentValues.length >= 2) {
3007
+ repairedFields.add(field);
3008
+ }
2875
3009
  }
2876
3010
  catch (error) {
2877
3011
  prodLog.warn(`[purgeCrossBucketPhantoms] repair of ${uuid}.${field} failed: ${error}`);
@@ -3002,13 +3136,53 @@ export class MetadataIndexManager {
3002
3136
  // detector uses the honest floor instead of re-flagging the same
3003
3137
  // cohort forever. Refreshed on every completed rebuild; an empty
3004
3138
  // set clears the stamp.
3139
+ //
3140
+ // COMPLETION GAP (3.0.19, memory's 52): canonical rows the walk
3141
+ // CANNOT enumerate — dangling counter rows with no readable record —
3142
+ // never appear in items, so the per-id stamp stayed EMPTY while the
3143
+ // detector's canonical count still included them: every detector
3144
+ // boot re-healed (~11 min on memory-vm) forever. A COMPLETED walk is
3145
+ // the strongest possible statement of "everything enumerable is
3146
+ // posted", so the residual canonical-minus-walked difference is
3147
+ // stamped as a count-only allowance (invisible rows have no ids).
3148
+ // Bounded staleness: refreshed on every completed walk; brainy's
3149
+ // counter recount (8.3.2 repairIndex) removes the gap permanently.
3150
+ const walked = nounResult.processed + nounResult.noMetadata;
3151
+ let completionGap = 0;
3152
+ let canonicalAtCompletion;
3153
+ try {
3154
+ const getNouns = this.storage.getNouns;
3155
+ if (typeof getNouns === 'function') {
3156
+ const probe = await getNouns.call(this.storage, { pagination: { limit: 1 } });
3157
+ const canonical = probe?.totalCount;
3158
+ if (typeof canonical === 'number' &&
3159
+ Number.isFinite(canonical) &&
3160
+ canonical < 0xffff_ffff) {
3161
+ canonicalAtCompletion = canonical;
3162
+ completionGap = Math.max(0, canonical - walked);
3163
+ }
3164
+ }
3165
+ }
3166
+ catch {
3167
+ // Gap probe is advisory — a failed canonical read stamps no gap
3168
+ // (may over-heal next boot, never under-detects).
3169
+ }
3005
3170
  await this.storage.saveMetadata(UNPOSTABLE_STAMP_KEY, {
3006
3171
  nounCount: nounResult.noMetadata,
3007
3172
  nounIds: nounResult.noMetadataIds,
3008
3173
  verbCount: verbResult.noMetadata,
3174
+ completionGap,
3175
+ canonicalAtCompletion,
3009
3176
  verifiedAt: Date.now(),
3010
3177
  });
3011
- prodLog.info(`Metadata index rebuild completed! Processed ${nounResult.processed} nouns and ` +
3178
+ if (completionGap > 0) {
3179
+ opsLine(`rebuild: canonical reports ${canonicalAtCompletion} nouns but only ` +
3180
+ `${walked} were enumerable by a COMPLETED walk — the ${completionGap}-entity ` +
3181
+ `gap is stamped as a completion-gap allowance (unenumerable canonical ` +
3182
+ `rows, e.g. inflated counters; a brainy counter recount removes them ` +
3183
+ `permanently). Boots stop re-healing over this gap.`);
3184
+ }
3185
+ opsLine(`Metadata index rebuild completed! Processed ${nounResult.processed} nouns and ` +
3012
3186
  `${verbResult.processed} verbs` +
3013
3187
  (nounResult.noMetadata > 0
3014
3188
  ? ` (${nounResult.noMetadata} canonical noun(s) verified metadata-less — stamped for the strand detector)`
@@ -3081,8 +3255,8 @@ export class MetadataIndexManager {
3081
3255
  if (processed - lastProgressAt >= 500) {
3082
3256
  lastProgressAt = processed;
3083
3257
  const elapsedS = (Date.now() - startedAt) / 1000;
3084
- prodLog.info(`[NativeMetadataIndex] rebuild(${kind}): ${processed} posted ` +
3085
- `in ${elapsedS.toFixed(1)}s (${(processed / Math.max(elapsedS, 0.001)).toFixed(0)}/s)`);
3258
+ opsLine(`rebuild(${kind}): ${processed} posted in ${elapsedS.toFixed(1)}s ` +
3259
+ `(${(processed / Math.max(elapsedS, 0.001)).toFixed(0)}/s)`);
3086
3260
  }
3087
3261
  if (++sinceFlush >= 5000) {
3088
3262
  await this.flushRebuildDirty();
@@ -3099,11 +3273,11 @@ export class MetadataIndexManager {
3099
3273
  await this.yieldToEventLoop();
3100
3274
  }
3101
3275
  const totalS = (Date.now() - startedAt) / 1000;
3102
- prodLog.info(`[NativeMetadataIndex] rebuild(${kind}): COMPLETE — ${processed} posted ` +
3276
+ opsLine(`rebuild(${kind}): COMPLETE — ${processed} posted ` +
3103
3277
  `in ${totalS.toFixed(1)}s (${(processed / Math.max(totalS, 0.001)).toFixed(0)}/s)` +
3104
3278
  (noMetadata > 0 ? `, ${noMetadata} without metadata` : ''));
3105
3279
  if (noMetadata > 0) {
3106
- prodLog.warn(`[NativeMetadataIndex] rebuild(${kind}): ${noMetadata} enumerated ` +
3280
+ opsLine(`rebuild(${kind}): ${noMetadata} enumerated ` +
3107
3281
  `entit${noMetadata === 1 ? 'y' : 'ies'} carried no canonical ` +
3108
3282
  `metadata (verified by direct read) and were not indexed — ` +
3109
3283
  `expected only for metadata-less legacy records. First ids: ` +
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @module utils/opsLog
3
+ * @description The always-visible operational channel (CORTEX-RESTART-STRAND,
4
+ * 2026-07-14). brainy's prodLog defaults to ERROR level in production, which
5
+ * silenced ALL heal narration on memory-vm — an operator could not tell a
6
+ * converging heal from a hang until BRAINY_LOG_LEVEL=info was set by hand.
7
+ * Index lifecycle events (heal start / progress / completion, verified
8
+ * anomaly accounting) are OPERATIONAL, not debug: they must reach the
9
+ * journal regardless of logger configuration, exactly like the version
10
+ * banner. One writer, `console.error`, `[cor]`-prefixed — never routed
11
+ * through prodLog (no duplicate lines on boxes that set info-level).
12
+ */
13
+ /** Emit one always-visible operational line. */
14
+ export declare function opsLine(message: string): void;
15
+ //# sourceMappingURL=opsLog.d.ts.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @module utils/opsLog
3
+ * @description The always-visible operational channel (CORTEX-RESTART-STRAND,
4
+ * 2026-07-14). brainy's prodLog defaults to ERROR level in production, which
5
+ * silenced ALL heal narration on memory-vm — an operator could not tell a
6
+ * converging heal from a hang until BRAINY_LOG_LEVEL=info was set by hand.
7
+ * Index lifecycle events (heal start / progress / completion, verified
8
+ * anomaly accounting) are OPERATIONAL, not debug: they must reach the
9
+ * journal regardless of logger configuration, exactly like the version
10
+ * banner. One writer, `console.error`, `[cor]`-prefixed — never routed
11
+ * through prodLog (no duplicate lines on boxes that set info-level).
12
+ */
13
+ /** Emit one always-visible operational line. */
14
+ export function opsLine(message) {
15
+ console.error(`[cor] ${message}`);
16
+ }
17
+ //# sourceMappingURL=opsLog.js.map
package/dist/version.d.ts CHANGED
@@ -11,5 +11,5 @@
11
11
  * `check:brainy`-style drift between the two fails the release.
12
12
  */
13
13
  /** The @soulcraft/cor version this build was cut from. */
14
- export declare const COR_VERSION = "3.0.17";
14
+ export declare const COR_VERSION = "3.0.19";
15
15
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -11,5 +11,5 @@
11
11
  * `check:brainy`-style drift between the two fails the release.
12
12
  */
13
13
  /** The @soulcraft/cor version this build was cut from. */
14
- export const COR_VERSION = '3.0.17';
14
+ export const COR_VERSION = '3.0.19';
15
15
  //# sourceMappingURL=version.js.map
Binary file
package/native/index.d.ts CHANGED
@@ -722,6 +722,18 @@ export declare class NativeGraphAdjacencyIndex {
722
722
  * solely for legacy-data remove paths.
723
723
  */
724
724
  removeVerb(verbId: string, verbTypeIndex: number, subtype: string, generation: bigint): void
725
+ /**
726
+ * **Removal law (3.0.19)**: removal must never require reading the
727
+ * thing being removed. The JS remove path read the verb's CANONICAL
728
+ * row to learn (type, subtype) and silently SKIPPED removal when that
729
+ * row was unreadable — a ghost's graph rows survived forever (the
730
+ * third instance of the anti-pattern, this one cor's own). This
731
+ * variant resolves everything from cor-owned state: type from the
732
+ * endpoint store (or the per-type unions for membership-only verbs),
733
+ * subtype by scanning that type's buckets for the interned id.
734
+ * Returns `false` when the id is unknown or already dead (idempotent).
735
+ */
736
+ removeVerbByIdOnly(verbId: string, generation: bigint): boolean
725
737
  /**
726
738
  * **Look up a verb's endpoints by verb-ID.** Returns
727
739
  * `{sourceInt, targetInt}` (as u64 BigInts) when the verb was
@@ -999,6 +1011,43 @@ export declare class NativeGraphAdjacencyIndex {
999
1011
  * counts converge to the same totals).
1000
1012
  */
1001
1013
  trackVerbId(verbId: string, verbType: string): void
1014
+ /**
1015
+ * Membership-only tracking that ALSO restores the per-(type, subtype)
1016
+ * bucket and subtype count — the full-fidelity variant of
1017
+ * [`track_verb_id`](Self::track_verb_id) for the boot heal walk.
1018
+ * The membership-only walk used to drop subtype state entirely, so
1019
+ * `findConnectedSubtype` silently returned nothing after a restart
1020
+ * until a full adjacency rebuild happened to run (3.0.19 find).
1021
+ * Same empty-string escape as the add path: `subtype: ''` tracks
1022
+ * type-level membership only, never an empty-string bucket.
1023
+ */
1024
+ trackVerbIdFull(verbId: string, verbTypeIndex: number, subtype: string): void
1025
+ /**
1026
+ * Serialize EVERY RAM-resident membership structure to one buffer so
1027
+ * the JS wrapper can persist it as a stamped family member (3.0.19:
1028
+ * the roster/buckets/counts were RAM-only, costing every boot an
1029
+ * O(all-canonical-verbs) walk and silently emptying subtype filters).
1030
+ * Format v1, little-endian, strictly length-framed:
1031
+ * magic 'CVMB' u32 | version u8 | idSpace u8 (0=U32, 1=U64) |
1032
+ * roster (u32 len + portable bytes) |
1033
+ * u8 nUnions { u8 typeIdx, u32 len, bytes } |
1034
+ * u32 nSubBuckets { u8 typeIdx, u16 subLen, utf8, u32 len, bytes } |
1035
+ * u8 nCounts { u8 typeIdx, u64 count } |
1036
+ * u32 nSubCounts { u8 typeIdx, u16 subLen, utf8, u64 count }
1037
+ * Subtype keys are emitted sorted so identical state yields identical
1038
+ * bytes (the stamp records the exact byte size).
1039
+ */
1040
+ serializeVerbMembership(): Buffer
1041
+ /**
1042
+ * Restore every membership structure from a
1043
+ * [`serialize_verb_membership`](Self::serialize_verb_membership)
1044
+ * buffer. Strict total parse: ANY malformation (bad magic/version,
1045
+ * id-space mismatch with this index, truncation, trailing bytes,
1046
+ * out-of-range type index) throws so the caller falls back to the
1047
+ * canonical heal walk — a torn blob must never half-load silently.
1048
+ * Clears existing membership state first (idempotent overwrite).
1049
+ */
1050
+ loadVerbMembership(buf: Buffer): void
1002
1051
  /**
1003
1052
  * Clear membership bitmap + relationship counts. Used before
1004
1053
  * rebuild to ensure clean state. The verb-ID namespace's
@@ -1870,6 +1919,23 @@ export declare class NativeMetadataIndex {
1870
1919
  * read path.
1871
1920
  */
1872
1921
  probeConsistency(): boolean
1922
+ /**
1923
+ * The suspect-surfacing probe (3.0.19): same bounded walk as
1924
+ * `probeConsistency`, but instead of a bare verdict it returns the FIRST
1925
+ * suspicious `{field, uuid}` as JSON (None = clean). The TS wrapper
1926
+ * ADJUDICATES the suspect with one canonical read — ≥2 current values
1927
+ * proves the field is array-shaped (learned + persisted, probe re-runs),
1928
+ * anything else is a genuine phantom (probe fails). This replaces the
1929
+ * MIX guess whose verdict depended on WHICH ints the sample drew —
1930
+ * accord's brain passed boots 1–2 and failed boot 3 on identical bytes.
1931
+ */
1932
+ probeConsistencySuspect(): string | null
1933
+ /**
1934
+ * Mark a field as learned-multi-valued (3.0.19). Called by the TS
1935
+ * wrapper after canonical adjudication proves an entity legitimately
1936
+ * holds ≥2 values for the field. The caller persists the field index.
1937
+ */
1938
+ markFieldMultiValue(field: string): void
1873
1939
  /**
1874
1940
  * **Phantom triad — state-level scan (matrix 7.8b).** Find every
1875
1941
  * query-visible int that sits in MORE THAN ONE value bucket of a field —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulcraft/cor",
3
- "version": "3.0.17",
3
+ "version": "3.0.19",
4
4
  "description": "Native Rust acceleration for Brainy — SIMD distance, vector quantization, zero-copy mmap, native embeddings. Free tier for storage, Pro license for compute acceleration.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -82,7 +82,7 @@
82
82
  },
83
83
  "devDependencies": {
84
84
  "@napi-rs/cli": "^3.0.0",
85
- "@soulcraft/brainy": "8.3.0",
85
+ "@soulcraft/brainy": "8.3.2",
86
86
  "@types/node": "^22.0.0",
87
87
  "pg": "^8.21.0",
88
88
  "tsx": "^4.21.0",