@soulcraft/cor 3.0.15 → 3.0.17

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.
@@ -17,6 +17,7 @@ import type { NativeGraphAdjacencyInstance } from '../native/types.js';
17
17
  import type { GraphIndexProvider } from '../providerContracts.js';
18
18
  import type { EntityIdMapperLike } from '../utils/columnStoreTypes.js';
19
19
  import type { MigrationCoordinator, MigrationStatus } from '../migration/MigrationCoordinator.js';
20
+ import type { ProviderInvariantReport } from '@soulcraft/brainy';
20
21
  export interface GraphIndexConfig {
21
22
  maxIndexSize?: number;
22
23
  rebuildThreshold?: number;
@@ -262,6 +263,12 @@ export declare class GraphAdjacencyIndex implements GraphIndexProvider {
262
263
  * genuinely EMPTY — distinguished from "edges exist on disk but didn't load"
263
264
  * (which must still rebuild) via the persisted SSTable + memtable counts.
264
265
  */
266
+ /**
267
+ * ADR-004 §6 — the graph provider's invariant self-report. NEVER throws;
268
+ * bounded (one native stats read + two flags). Brainy aggregates via
269
+ * `validateIndexConsistency`; `heal: 'rebuild'` maps to {@link rebuild}.
270
+ */
271
+ validateInvariants(): Promise<ProviderInvariantReport>;
265
272
  isReady(): boolean;
266
273
  /**
267
274
  * @description **#18 migration lock — feature-detected by Brainy.** `true` while
@@ -15,6 +15,8 @@
15
15
  import { dirname } from 'node:path';
16
16
  import { TypeUtils, VerbType } from '@soulcraft/brainy/types/graphTypes';
17
17
  import { loadNativeModule } from '../native/index.js';
18
+ import { composeInvariantReport } from '../utils/invariantReport.js';
19
+ import { declareDerivedFamilies, withFamilyRetirement, GRAPH_FAMILIES } from '../utils/derivedFamilies.js';
18
20
  import { getGlobalCache, prodLog } from '@soulcraft/brainy/internals';
19
21
  /**
20
22
  * Fallback verb-type index for unknown / missing verb-type strings.
@@ -216,6 +218,8 @@ export class GraphAdjacencyIndex {
216
218
  * an index that is never `init()`-ed still cold-loads on first touch.
217
219
  */
218
220
  async init() {
221
+ // ADR-004 §7: declare the graph families (feature-detected ≥8.3.0).
222
+ await declareDerivedFamilies(this.storage, GRAPH_FAMILIES);
219
223
  await this.ensureInitialized();
220
224
  }
221
225
  /**
@@ -561,6 +565,43 @@ export class GraphAdjacencyIndex {
561
565
  * genuinely EMPTY — distinguished from "edges exist on disk but didn't load"
562
566
  * (which must still rebuild) via the persisted SSTable + memtable counts.
563
567
  */
568
+ /**
569
+ * ADR-004 §6 — the graph provider's invariant self-report. NEVER throws;
570
+ * bounded (one native stats read + two flags). Brainy aggregates via
571
+ * `validateIndexConsistency`; `heal: 'rebuild'` maps to {@link rebuild}.
572
+ */
573
+ async validateInvariants() {
574
+ return composeInvariantReport('graph', () => this.isReady(), [
575
+ () => {
576
+ const raw = this.native.getStats();
577
+ const s = (typeof raw === 'string' ? JSON.parse(raw) : raw);
578
+ if (s.loadComplete === undefined) {
579
+ return {
580
+ name: 'manifest-residency',
581
+ holds: true,
582
+ detail: 'binary predates residency stats (pre-3.0.13) — unverifiable here',
583
+ heal: 'none',
584
+ };
585
+ }
586
+ return {
587
+ name: 'manifest-residency',
588
+ holds: s.loadComplete,
589
+ detail: s.loadComplete
590
+ ? 'every manifest-named SSTable resident across all four trees'
591
+ : 'a manifest-named SSTable is NOT resident — the graph would serve a subset of its edges',
592
+ heal: 'rebuild',
593
+ };
594
+ },
595
+ () => ({
596
+ name: 'canonical-coverage',
597
+ holds: !this.canonicalUnindexed,
598
+ detail: this.canonicalUnindexed
599
+ ? 'canonical verbs exist with no durable graph state (migration/restore shape) — a rebuild is required'
600
+ : 'no canonical-vs-durable coverage gap detected',
601
+ heal: 'rebuild',
602
+ }),
603
+ ]);
604
+ }
564
605
  isReady() {
565
606
  if (this.native.isReady())
566
607
  return true;
@@ -974,13 +1015,22 @@ export class GraphAdjacencyIndex {
974
1015
  // just leaves orphans, never a missing-blob error.
975
1016
  await this.saveTreeManifest(treeName, prefix);
976
1017
  // 3. Delete old blobs (best-effort orphan cleanup AFTER the
977
- // manifest no longer references them). Failures here are
978
- // harmless the worst outcome is leaked disk space until
979
- // a future operator-level GC sweep.
980
- for (const oldId of result.oldIds) {
981
- const oldBlobKey = this.blobKey(treeName, oldId);
982
- this.storage.deleteBinaryBlob(oldBlobKey).catch(() => { });
983
- }
1018
+ // manifest no longer references them) inside an owner-
1019
+ // sanctioned retirement window (ADR-004 §7): compaction is the
1020
+ // ONE legitimate deleter of graph-lsm members, so the family is
1021
+ // unregistered around exactly this loop and re-registered in a
1022
+ // finally (self-healing: every init re-declares). Failures stay
1023
+ // harmless the worst outcome is leaked disk space.
1024
+ await withFamilyRetirement(this.storage, GRAPH_FAMILIES[0], async () => {
1025
+ await Promise.all(result.oldIds.map((oldId) => {
1026
+ const oldBlobKey = this.blobKey(treeName, oldId);
1027
+ return this.storage
1028
+ .deleteBinaryBlob(oldBlobKey)
1029
+ .catch(() => {
1030
+ /* Orphaned blobs acceptable */
1031
+ });
1032
+ }));
1033
+ });
984
1034
  const elapsed = Date.now() - startTime;
985
1035
  prodLog.info(`GraphAdjacencyIndex: Compaction ${treeName} L${level} → L${result.newLevel} complete in ${elapsed}ms`);
986
1036
  if (result.newLevel < 6 && this.native.needsCompaction(treeName, result.newLevel)) {
@@ -70,7 +70,7 @@
70
70
  * brainy 8.0's canonical `'vector'` provider key. Whenever cor is
71
71
  * installed, brainy's vector index slot resolves to Adaptive DiskANN.
72
72
  */
73
- import type { Vector, VectorDocument, DistanceFunction, StorageAdapter } from '@soulcraft/brainy';
73
+ import type { Vector, VectorDocument, DistanceFunction, StorageAdapter, ProviderInvariantReport } from '@soulcraft/brainy';
74
74
  import type { VectorIndexProvider } from '../providerContracts.js';
75
75
  import type { EntityIdMapperLike } from '../utils/columnStoreTypes.js';
76
76
  import type { MigrationCoordinator, MigrationStatus } from '../migration/MigrationCoordinator.js';
@@ -237,6 +237,14 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
237
237
  * index. Set in {@link tryOpenExisting}; cleared by a successful rebuild.
238
238
  */
239
239
  private durableBaseLoadFailed;
240
+ /**
241
+ * L0 segments named by the persisted manifest that FAILED to load at
242
+ * cold open (skipped with a warn; entries remain in canonical and heal
243
+ * via consolidation). Feeds the ADR-004 §6 `segments-resident`
244
+ * invariant so the deliberately-tolerated gap is VISIBLE cross-layer
245
+ * instead of only a boot-time warn line.
246
+ */
247
+ private skippedManifestSegments;
240
248
  /** Serializes manifest writes: the storage adapter has no per-key write
241
249
  * ordering, so two in-flight saves (a flush racing a consolidation) could
242
250
  * physically land out of order and persist a STALE segment list — losing a
@@ -377,6 +385,14 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
377
385
  * (see {@link loadSegmentManifest}).
378
386
  * @returns `true` when a cold-open rebuild would be redundant.
379
387
  */
388
+ /**
389
+ * ADR-004 §6 — the vector provider's invariant self-report. NEVER
390
+ * throws; bounded (flags + fault-discriminating stats on ≤257 sidecar
391
+ * paths — the L0 hard cap bounds the loop). `heal: 'rebuild'` maps to
392
+ * {@link rebuild}; the skipped-segment gap is deliberately
393
+ * consolidation-healed and reports `heal: 'none'`.
394
+ */
395
+ validateInvariants(): Promise<ProviderInvariantReport>;
380
396
  isReady(): boolean;
381
397
  /**
382
398
  * @description **#18 migration lock — structured progress** Brainy relays into
@@ -504,6 +520,30 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
504
520
  private flushDeltaToL0;
505
521
  /** The flush body — see {@link flushDeltaToL0}. */
506
522
  private runFlushToL0;
523
+ /**
524
+ * ADR-004 §7 — verify the `main.familygen` stamp the native publish
525
+ * writes (3.0.16+). The stamp commits the base + sidecar SET with each
526
+ * member's exact byte size; a member missing or at the wrong size means
527
+ * the family on disk is torn (interrupted swap or external mutation)
528
+ * and must NOT be served. Stamp absence is a pre-stamp layout — legacy
529
+ * per-file tolerance applies. A read/stat FAULT is `unverifiable`, never
530
+ * conflated with absence (§4): we proceed on the per-file checks and
531
+ * report it, but never trigger a spurious rebuild from a flaky probe.
532
+ */
533
+ /**
534
+ * Sibling path with the LAST extension swapped — the exact semantics of
535
+ * Rust's `Path::set_extension`, which names every family member. This
536
+ * MUST NOT assume the base ends in `.dkann`: production indexPaths come
537
+ * from `getBinaryBlobPath` and FileSystemStorage appends `.bin`
538
+ * (`main.dkann.bin`), so the sidecars/stamp are `main.dkann.slotmap` /
539
+ * `main.dkann.familygen` there, while bare `main.dkann` paths (tests,
540
+ * benchmarks) yield `main.slotmap` / `main.familygen`. A `.dkann`-only
541
+ * regex silently no-ops on the production shape — the 3.0.16 gate
542
+ * caught the stamp verifier READING THE BINARY BASE as its own stamp
543
+ * (incoherent verdict on every healthy brain ⇒ rebuild loop).
544
+ */
545
+ private siblingPath;
546
+ private verifyFamilyStamp;
507
547
  private tryOpenExisting;
508
548
  /**
509
549
  * Remove any leftover `main.slots.json` written by a pre-#72 RC build.
@@ -70,10 +70,13 @@
70
70
  * brainy 8.0's canonical `'vector'` provider key. Whenever cor is
71
71
  * installed, brainy's vector index slot resolves to Adaptive DiskANN.
72
72
  */
73
+ import { composeInvariantReport } from '../utils/invariantReport.js';
74
+ import { declareDerivedFamilies, VECTOR_FAMILIES } from '../utils/derivedFamilies.js';
75
+ import { statSync, readFileSync } from 'node:fs';
73
76
  import { loadNativeModule } from '../native/index.js';
74
77
  import { prodLog } from '@soulcraft/brainy/internals';
75
78
  import { mkdirSync, existsSync, rmSync } from 'node:fs';
76
- import { dirname } from 'node:path';
79
+ import { dirname, join } from 'node:path';
77
80
  import { autoModeForHeader, selectModeFromResourceManager, } from './AdaptiveDiskAnnModeSelector.js';
78
81
  /**
79
82
  * Node-count threshold above which `useMmapAdjacency: 'auto'`
@@ -374,6 +377,14 @@ export class NativeDiskAnnWrapper {
374
377
  * index. Set in {@link tryOpenExisting}; cleared by a successful rebuild.
375
378
  */
376
379
  durableBaseLoadFailed = false;
380
+ /**
381
+ * L0 segments named by the persisted manifest that FAILED to load at
382
+ * cold open (skipped with a warn; entries remain in canonical and heal
383
+ * via consolidation). Feeds the ADR-004 §6 `segments-resident`
384
+ * invariant so the deliberately-tolerated gap is VISIBLE cross-layer
385
+ * instead of only a boot-time warn line.
386
+ */
387
+ skippedManifestSegments = [];
377
388
  /** Serializes manifest writes: the storage adapter has no per-key write
378
389
  * ordering, so two in-flight saves (a flush racing a consolidation) could
379
390
  * physically land out of order and persist a STALE segment list — losing a
@@ -851,6 +862,126 @@ export class NativeDiskAnnWrapper {
851
862
  * (see {@link loadSegmentManifest}).
852
863
  * @returns `true` when a cold-open rebuild would be redundant.
853
864
  */
865
+ /**
866
+ * ADR-004 §6 — the vector provider's invariant self-report. NEVER
867
+ * throws; bounded (flags + fault-discriminating stats on ≤257 sidecar
868
+ * paths — the L0 hard cap bounds the loop). `heal: 'rebuild'` maps to
869
+ * {@link rebuild}; the skipped-segment gap is deliberately
870
+ * consolidation-healed and reports `heal: 'none'`.
871
+ */
872
+ async validateInvariants() {
873
+ return composeInvariantReport('vector', () => this.isReady(), [
874
+ () => ({
875
+ name: 'base-resident',
876
+ holds: !this.durableBaseLoadFailed,
877
+ detail: this.durableBaseLoadFailed
878
+ ? 'a durable base index exists but failed to load (corrupt base or missing sidecar) — searches would silently miss its vectors'
879
+ : 'durable base (if any) loaded',
880
+ heal: 'rebuild',
881
+ }),
882
+ () => ({
883
+ name: 'segments-resident',
884
+ holds: this.skippedManifestSegments.length === 0,
885
+ detail: this.skippedManifestSegments.length === 0
886
+ ? `${this.l0Segments.length} manifest-named L0 segment(s), all loaded`
887
+ : `${this.skippedManifestSegments.length} manifest-named L0 segment(s) ` +
888
+ `failed to load (${this.skippedManifestSegments.join(', ')}) — their ` +
889
+ `entries remain in canonical and heal via consolidation`,
890
+ expected: this.l0Segments.length + this.skippedManifestSegments.length,
891
+ actual: this.l0Segments.length,
892
+ heal: 'none',
893
+ }),
894
+ () => {
895
+ // sidecars-coherent: base + every loaded segment has both sidecars.
896
+ // Fault-discriminating stat (ADR §4): a stat FAULT is reported as a
897
+ // failing check, never as absence.
898
+ const missing = [];
899
+ const faulted = [];
900
+ const probe = (path) => {
901
+ try {
902
+ statSync(path);
903
+ }
904
+ catch (error) {
905
+ const code = error.code;
906
+ if (code === 'ENOENT')
907
+ missing.push(path);
908
+ else
909
+ faulted.push(`${path} (${code})`);
910
+ }
911
+ };
912
+ // Last-extension swap (Rust set_extension semantics) — a
913
+ // .dkann-only regex no-ops on production's `.dkann.bin` paths and
914
+ // made this check vacuously probe the base file twice.
915
+ const sidecarsOf = (dkannPath) => [
916
+ this.siblingPath(dkannPath, 'slotmap'),
917
+ this.siblingPath(dkannPath, 'slotrev'),
918
+ ];
919
+ if (this.native && this.config.indexPath) {
920
+ for (const s of sidecarsOf(this.config.indexPath))
921
+ probe(s);
922
+ }
923
+ for (const seg of this.l0Segments) {
924
+ for (const s of sidecarsOf(this.segPath(seg.file)))
925
+ probe(s);
926
+ }
927
+ if (faulted.length > 0) {
928
+ return {
929
+ name: 'sidecars-coherent',
930
+ holds: false,
931
+ detail: `sidecar stat FAULTED (not absent) — coherence unverifiable: ${faulted.join(', ')}`,
932
+ heal: 'none',
933
+ };
934
+ }
935
+ return {
936
+ name: 'sidecars-coherent',
937
+ holds: missing.length === 0,
938
+ detail: missing.length === 0
939
+ ? 'every loaded base/segment has both sidecars on disk'
940
+ : `${missing.length} sidecar(s) missing for LOADED members: ${missing.join(', ')} — ` +
941
+ `the family is incoherent on disk (a restart would fail these loads)`,
942
+ heal: 'rebuild',
943
+ };
944
+ },
945
+ () => {
946
+ // family-coherent (§7): the familygen stamp — when the 3.0.16+
947
+ // publish wrote one — must find every member it committed at its
948
+ // EXACT byte size, live on disk. Absence is a pre-stamp layout,
949
+ // not a violation; a stat/read fault is unverifiable (heal
950
+ // 'none' — a flaky probe never triggers a spurious rebuild).
951
+ const family = this.verifyFamilyStamp();
952
+ switch (family.state) {
953
+ case 'coherent':
954
+ return {
955
+ name: 'family-coherent',
956
+ holds: true,
957
+ detail: `familygen stamp verified — every member at its committed size (generation ${family.generation})`,
958
+ heal: 'rebuild',
959
+ };
960
+ case 'absent':
961
+ return {
962
+ name: 'family-coherent',
963
+ holds: true,
964
+ detail: 'no familygen stamp (pre-3.0.16 publish) — per-file checks apply',
965
+ heal: 'rebuild',
966
+ };
967
+ case 'unverifiable':
968
+ return {
969
+ name: 'family-coherent',
970
+ holds: false,
971
+ detail: `family coherence UNVERIFIABLE: ${family.violations.join('; ')}`,
972
+ heal: 'none',
973
+ };
974
+ case 'incoherent':
975
+ return {
976
+ name: 'family-coherent',
977
+ holds: false,
978
+ detail: `familygen stamp violated — the family on disk is torn: ${family.violations.join('; ')}`,
979
+ heal: 'rebuild',
980
+ };
981
+ }
982
+ },
983
+ ]);
984
+ }
854
985
  isReady() {
855
986
  if (this.durableBaseLoadFailed)
856
987
  return false;
@@ -1355,6 +1486,9 @@ export class NativeDiskAnnWrapper {
1355
1486
  * still gets the segments lazily; only the pre-gate timing needs init().)
1356
1487
  */
1357
1488
  async init() {
1489
+ // ADR-004 §7: declare the vector families (feature-detected ≥8.3.0;
1490
+ // idempotent; loud-but-non-blocking).
1491
+ await declareDerivedFamilies(this.storage, VECTOR_FAMILIES);
1358
1492
  await this.ensureSegments();
1359
1493
  }
1360
1494
  /** Memoized segment-manifest cold-load (+ canonical-coverage probe). */
@@ -1431,6 +1565,7 @@ export class NativeDiskAnnWrapper {
1431
1565
  }
1432
1566
  if (!manifest)
1433
1567
  return;
1568
+ this.skippedManifestSegments = [];
1434
1569
  this.manifestNextId = Math.max(1, Number(manifest.nextId) || 1);
1435
1570
  const bindings = loadNativeModule();
1436
1571
  const NativeDiskANN = bindings.NativeDiskAnn;
@@ -1444,6 +1579,7 @@ export class NativeDiskAnnWrapper {
1444
1579
  this.l0Segments.push({ ...entry, native });
1445
1580
  }
1446
1581
  catch (error) {
1582
+ this.skippedManifestSegments.push(entry.file);
1447
1583
  prodLog.warn(`NativeDiskAnnWrapper: skipping unreadable L0 segment ${entry.file} ` +
1448
1584
  `(${error instanceof Error ? error.message : String(error)}) — its ` +
1449
1585
  `entries remain in canonical storage; consolidation heals the set`);
@@ -1592,7 +1728,107 @@ export class NativeDiskAnnWrapper {
1592
1728
  });
1593
1729
  }
1594
1730
  }
1731
+ /**
1732
+ * ADR-004 §7 — verify the `main.familygen` stamp the native publish
1733
+ * writes (3.0.16+). The stamp commits the base + sidecar SET with each
1734
+ * member's exact byte size; a member missing or at the wrong size means
1735
+ * the family on disk is torn (interrupted swap or external mutation)
1736
+ * and must NOT be served. Stamp absence is a pre-stamp layout — legacy
1737
+ * per-file tolerance applies. A read/stat FAULT is `unverifiable`, never
1738
+ * conflated with absence (§4): we proceed on the per-file checks and
1739
+ * report it, but never trigger a spurious rebuild from a flaky probe.
1740
+ */
1741
+ /**
1742
+ * Sibling path with the LAST extension swapped — the exact semantics of
1743
+ * Rust's `Path::set_extension`, which names every family member. This
1744
+ * MUST NOT assume the base ends in `.dkann`: production indexPaths come
1745
+ * from `getBinaryBlobPath` and FileSystemStorage appends `.bin`
1746
+ * (`main.dkann.bin`), so the sidecars/stamp are `main.dkann.slotmap` /
1747
+ * `main.dkann.familygen` there, while bare `main.dkann` paths (tests,
1748
+ * benchmarks) yield `main.slotmap` / `main.familygen`. A `.dkann`-only
1749
+ * regex silently no-ops on the production shape — the 3.0.16 gate
1750
+ * caught the stamp verifier READING THE BINARY BASE as its own stamp
1751
+ * (incoherent verdict on every healthy brain ⇒ rebuild loop).
1752
+ */
1753
+ siblingPath(indexPath, ext) {
1754
+ return indexPath.replace(/\.[^./\\]+$/, `.${ext}`);
1755
+ }
1756
+ verifyFamilyStamp() {
1757
+ const indexPath = this.config.indexPath;
1758
+ if (!indexPath)
1759
+ return { state: 'absent' };
1760
+ const stampPath = this.siblingPath(indexPath, 'familygen');
1761
+ let raw;
1762
+ try {
1763
+ raw = readFileSync(stampPath, 'utf8');
1764
+ }
1765
+ catch (error) {
1766
+ const code = error.code;
1767
+ if (code === 'ENOENT')
1768
+ return { state: 'absent' };
1769
+ return {
1770
+ state: 'unverifiable',
1771
+ violations: [`stamp read FAULTED (${code}) — coherence unverifiable`],
1772
+ };
1773
+ }
1774
+ let stamp;
1775
+ try {
1776
+ stamp = JSON.parse(raw);
1777
+ }
1778
+ catch {
1779
+ return {
1780
+ state: 'incoherent',
1781
+ violations: [`stamp ${stampPath} present but unparseable — cannot prove family coherence`],
1782
+ };
1783
+ }
1784
+ if (typeof stamp?.members !== 'object' || stamp.members === null) {
1785
+ return {
1786
+ state: 'incoherent',
1787
+ violations: [`stamp ${stampPath} has no members map — cannot prove family coherence`],
1788
+ };
1789
+ }
1790
+ const dir = dirname(indexPath);
1791
+ const violations = [];
1792
+ const faults = [];
1793
+ for (const [name, expected] of Object.entries(stamp.members)) {
1794
+ try {
1795
+ const actual = statSync(join(dir, name)).size;
1796
+ if (actual !== expected) {
1797
+ violations.push(`${name}: size ${actual} != committed ${expected}`);
1798
+ }
1799
+ }
1800
+ catch (error) {
1801
+ const code = error.code;
1802
+ if (code === 'ENOENT') {
1803
+ violations.push(`${name}: missing (committed ${expected} bytes)`);
1804
+ }
1805
+ else {
1806
+ faults.push(`${name}: stat FAULTED (${code})`);
1807
+ }
1808
+ }
1809
+ }
1810
+ if (violations.length > 0)
1811
+ return { state: 'incoherent', violations };
1812
+ if (faults.length > 0)
1813
+ return { state: 'unverifiable', violations: faults };
1814
+ return { state: 'coherent', generation: stamp.generation ?? 0 };
1815
+ }
1595
1816
  tryOpenExisting() {
1817
+ // §7: an incoherent family must not be served — refuse before the
1818
+ // open so a torn set reads as a FAILED durable load (not-ready ⇒
1819
+ // brainy's gate rebuilds), never as a quietly-wrong index.
1820
+ const family = this.verifyFamilyStamp();
1821
+ if (family.state === 'incoherent') {
1822
+ prodLog.error(`NativeDiskAnnWrapper: familygen stamp says the vector family is ` +
1823
+ `INCOHERENT — refusing to open. ${family.violations.join('; ')}`);
1824
+ this.native = null;
1825
+ this.durableBaseLoadFailed = true;
1826
+ return;
1827
+ }
1828
+ if (family.state === 'unverifiable') {
1829
+ prodLog.error(`NativeDiskAnnWrapper: family coherence UNVERIFIABLE (stat/read fault) — ` +
1830
+ `opening on per-file checks only. ${family.violations.join('; ')}`);
1831
+ }
1596
1832
  try {
1597
1833
  const bindings = loadNativeModule();
1598
1834
  // napi-rs exports the class as `NativeDiskAnn` (PascalCase
@@ -1664,7 +1900,20 @@ export class NativeDiskAnnWrapper {
1664
1900
  */
1665
1901
  removeLegacySlotsSidecar() {
1666
1902
  try {
1667
- const legacy = this.config.indexPath.replace(/\.dkann$/, '.slots.json');
1903
+ // THE DELETER (CORTEX-BLOB-LIFECYCLE-CONTRACT, identified 2026-07-13
1904
+ // by the §7 family-coherent gate): this derivation was a
1905
+ // `.dkann$`-anchored replace, which NO-OPS on production paths
1906
+ // (getBinaryBlobPath appends `.bin` → `main.dkann.bin`) — so
1907
+ // `legacy === indexPath` and every successful cold open DELETED THE
1908
+ // BASE INDEX it had just mmap-opened. The live inode kept serving,
1909
+ // the next open found no base, and the brain rebuilt — the exact
1910
+ // main.dkann-missing/sidecars-present signature on all four
1911
+ // memory-vm brains. Last-extension swap + a hard never-the-base
1912
+ // guard make the recurrence structurally impossible.
1913
+ const indexPath = this.config.indexPath;
1914
+ const legacy = this.siblingPath(indexPath, 'slots.json');
1915
+ if (legacy === indexPath)
1916
+ return;
1668
1917
  if (existsSync(legacy))
1669
1918
  rmSync(legacy, { force: true });
1670
1919
  }
@@ -923,6 +923,13 @@ export interface NativeMetadataIndexInstance {
923
923
  uuidToInt(uuid: string): number | null;
924
924
  intToUuid(intId: number): string | null;
925
925
  intsToUuids(ints: number[]): string[];
926
+ /**
927
+ * Resolve an entity int, ASSIGNING through the canonical allocator when
928
+ * unmapped (3.0.16+; the same allocator addToIndex uses, durable via the
929
+ * mapper's delta log). Lets a boot-heal vector rebuild mint ids for
930
+ * canonical-committed entities whose index ops were lost.
931
+ */
932
+ getOrAssignInt(uuid: string): bigint;
926
933
  loadFieldRegistry(json: string): void;
927
934
  saveFieldRegistry(): string;
928
935
  loadFieldIndex(field: string, json: string): void;
@@ -30,6 +30,7 @@
30
30
  * `.cidx` segment value types and ordering. See ADR-001 for the native string
31
31
  * collation contract (code-point order, matching `compareCodePoints`).
32
32
  */
33
+ import type { ProviderInvariantReport } from '@soulcraft/brainy';
33
34
  import type { StorageAdapter } from '@soulcraft/brainy';
34
35
  import type { ColumnStoreProvider, EntityIdMapperLike } from './columnStoreTypes.js';
35
36
  import { ValueType } from './columnStoreTypes.js';
@@ -142,6 +143,11 @@ export declare class NativeColumnStore implements ColumnStoreProvider {
142
143
  * would have been invisible.
143
144
  * @param field - Field about to be served.
144
145
  */
146
+ /**
147
+ * ADR-004 §6 — the column store's invariant self-report. NEVER throws;
148
+ * bounded (set sizes + per-field segment counts, no IO).
149
+ */
150
+ validateInvariants(): Promise<ProviderInvariantReport>;
145
151
  private assertFieldServable;
146
152
  /**
147
153
  * @description Point filter: entity int ids whose `field` equals `value`.
@@ -31,6 +31,8 @@
31
31
  * collation contract (code-point order, matching `compareCodePoints`).
32
32
  */
33
33
  import { existsSync } from 'node:fs';
34
+ import { composeInvariantReport } from './invariantReport.js';
35
+ import { declareDerivedFamilies, withFamilyRetirement, COLUMN_FAMILIES } from './derivedFamilies.js';
34
36
  import { prodLog } from '@soulcraft/brainy/internals';
35
37
  import { ValueType } from './columnStoreTypes.js';
36
38
  import { ColumnManifest } from './ColumnManifest.js';
@@ -145,6 +147,10 @@ export class NativeColumnStore {
145
147
  'Use brainy FileSystemStorage or another adapter implementing BinaryBlobStorage.');
146
148
  }
147
149
  this.storage = storage;
150
+ // ADR-004 §7: declare the column family (feature-detected ≥8.3.0) —
151
+ // the one family whose members are written via saveBinaryBlob, so the
152
+ // storage layer's delete-refusal actively bites here.
153
+ await declareDerivedFamilies(storage, COLUMN_FAMILIES);
148
154
  // Discover fields by listing manifest files under the column-index prefix.
149
155
  let paths;
150
156
  try {
@@ -241,6 +247,43 @@ export class NativeColumnStore {
241
247
  * would have been invisible.
242
248
  * @param field - Field about to be served.
243
249
  */
250
+ /**
251
+ * ADR-004 §6 — the column store's invariant self-report. NEVER throws;
252
+ * bounded (set sizes + per-field segment counts, no IO).
253
+ */
254
+ async validateInvariants() {
255
+ return composeInvariantReport('column', () => this.storage !== null && this.faultedFields.size === 0, [
256
+ () => ({
257
+ name: 'fields-servable',
258
+ holds: this.faultedFields.size === 0,
259
+ detail: this.faultedFields.size === 0
260
+ ? `${this.fieldTypes.size} field(s), none faulted`
261
+ : `${this.faultedFields.size} field(s) UNAVAILABLE after faulted ` +
262
+ `loads (queries throw): ${[...this.faultedFields].join(', ')}`,
263
+ expected: this.fieldTypes.size,
264
+ actual: this.fieldTypes.size - this.faultedFields.size,
265
+ heal: 'rebuild',
266
+ }),
267
+ () => {
268
+ // manifest-residency: every field's manifest-listed segments are
269
+ // attached in the native engine (hasField is the cheap proxy for
270
+ // a field whose segments loaded; per-segment ids are not exposed
271
+ // by the native surface, so the count-level check is the honest
272
+ // bounded version — a faulted segment already trips
273
+ // fields-servable above).
274
+ const unattached = [...this.manifests.keys()].filter((field) => !this.faultedFields.has(field) && !this.native.hasField(field));
275
+ return {
276
+ name: 'manifest-residency',
277
+ holds: unattached.length === 0,
278
+ detail: unattached.length === 0
279
+ ? `${this.manifests.size} field manifest(s), all attached`
280
+ : `${unattached.length} field(s) have persisted manifests but no ` +
281
+ `attached native state: ${unattached.join(', ')}`,
282
+ heal: 'rebuild',
283
+ };
284
+ },
285
+ ]);
286
+ }
244
287
  assertFieldServable(field) {
245
288
  if (this.faultedFields.has(field)) {
246
289
  throw new Error(`NativeColumnStore: column index for field '${field}' is UNAVAILABLE — ` +
@@ -464,18 +507,25 @@ export class NativeColumnStore {
464
507
  // Delete persisted column-index files (manifests + segment/deleted blobs)
465
508
  // before dropping the in-memory manifests we need to locate them.
466
509
  if (this.storage) {
467
- for (const [field, manifest] of this.manifests) {
468
- try {
469
- await this.storage.deleteObjectFromPath(manifest.manifestPath());
470
- }
471
- catch {
472
- /* manifest already gone */
473
- }
474
- for (const seg of manifest.getAllSegments()) {
475
- await this.storage.deleteBinaryBlob(segmentBlobKey(field, seg.level, seg.id));
510
+ // Owner-sanctioned retirement (ADR-004 §7): this reset legitimately
511
+ // deletes the family's own members (a rebuild reassigns entity ints,
512
+ // invalidating old segments), so the protection window opens and
513
+ // closes around exactly this loop.
514
+ const storage = this.storage;
515
+ await withFamilyRetirement(storage, COLUMN_FAMILIES[0], async () => {
516
+ for (const [field, manifest] of this.manifests) {
517
+ try {
518
+ await storage.deleteObjectFromPath(manifest.manifestPath());
519
+ }
520
+ catch {
521
+ /* manifest already gone */
522
+ }
523
+ for (const seg of manifest.getAllSegments()) {
524
+ await storage.deleteBinaryBlob(segmentBlobKey(field, seg.level, seg.id));
525
+ }
526
+ await storage.deleteBinaryBlob(deletedBlobKey(field));
476
527
  }
477
- await this.storage.deleteBinaryBlob(deletedBlobKey(field));
478
- }
528
+ });
479
529
  }
480
530
  // Drop all native state by replacing the engine, then clear TS-side state.
481
531
  // Faults clear too: the rebuild that follows repopulates every field