@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 @@
17
17
  */
18
18
  import type { StorageAdapter } from '@soulcraft/brainy';
19
19
  import { NounType, VerbType } from '@soulcraft/brainy/types/graphTypes';
20
+ import type { ProviderInvariantReport } from '@soulcraft/brainy';
20
21
  import { NativeColumnStore } from './NativeColumnStore.js';
21
22
  import type { EntityIdMapperLike } from './columnStoreTypes.js';
22
23
  import type { MetadataIndexProvider } from '../providerContracts.js';
@@ -115,6 +116,14 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
115
116
  * everything from canonical. Cleared by a completed {@link rebuild}.
116
117
  */
117
118
  private strandDetected;
119
+ /**
120
+ * SSTable ids actually REGISTERED into the live engine (cold-open
121
+ * registration + post-flush registration). The `manifest-residency`
122
+ * invariant (ADR-004 §6) compares the persisted manifest's claims
123
+ * against this set — the manifest is a claim; registration is the truth.
124
+ * Cleared on the rebuild's fresh re-open.
125
+ */
126
+ private lsmRegisteredSstableIds;
118
127
  /**
119
128
  * **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
120
129
  * auto-migrate enabled) and read once at the END of {@link init}, which kicks the
@@ -554,6 +563,16 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
554
563
  * same rebuild). Skipped at/above the u32 saturation point of
555
564
  * `getTotalEntityCount` (a saturated count would make equality lie).
556
565
  */
566
+ /**
567
+ * The verified-unpostable allowance for posted-vs-canonical floors: how
568
+ * many canonical noun rows the last COMPLETED rebuild PROVED carry no
569
+ * readable metadata (each verified by direct read at rebuild time, each
570
+ * RE-VERIFIED here so an id that has since gained metadata stops
571
+ * counting). Beyond {@link UNPOSTABLE_ID_CAP} the stamp is count-only
572
+ * and the allowance carries a bounded, logged staleness caveat.
573
+ * Advisory: any fault reads as 0 (may over-heal, never under-detects).
574
+ */
575
+ private verifiedUnpostableAllowance;
557
576
  private detectDerivedStrand;
558
577
  /**
559
578
  * **#72 Phase C.** Build the shared mmap `BinaryIdMapper` from the
@@ -803,7 +822,28 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
803
822
  getAllVFSEntityCounts(): Promise<Map<string, number>>;
804
823
  getTotalVFSEntityCount(): Promise<number>;
805
824
  getCountForCriteria(field: string, value: any): Promise<number>;
825
+ /**
826
+ * ADR-004 §6 — the provider's self-report of its cross-layer invariants.
827
+ * NEVER throws; bounded (residency + O(1) count reads, one limit-1
828
+ * canonical probe); safe on a live brain. Brainy's
829
+ * `validateIndexConsistency` feature-detects and aggregates this;
830
+ * `repairIndex` maps `heal: 'rebuild'` failures to {@link rebuild}.
831
+ */
832
+ validateInvariants(): Promise<ProviderInvariantReport>;
833
+ /**
834
+ * Serializes every {@link flush} pass. Two flushes interleaving at the
835
+ * awaits is NOT hypothetical — memory-vm runs a periodic flusher AND
836
+ * brainy's shutdown hook flushes on SIGTERM — and the flushLsm pair
837
+ * both read `nextSstableId(manifest)` before either pushes: SAME id →
838
+ * same blob key → the second write OVERWRITES the first SSTable →
839
+ * postings lost with clean logs and NO replay anomaly (the exact
840
+ * acceptance-#1 signature on CORTEX-RESTART-STRAND, 2026-07-13).
841
+ * Chaining gives each caller its own full pass covering everything
842
+ * written before its call — the semantics a shutdown flush needs.
843
+ */
844
+ private flushChain;
806
845
  flush(): Promise<void>;
846
+ private flushOnce;
807
847
  /**
808
848
  * Persist all state and release the column store's resources. Brainy's
809
849
  * shutdown path calls `metadataIndex.close()` when present; flushing here
@@ -34,6 +34,8 @@ const BUCKETED_INDEX_FIELDS = new Set([
34
34
  ]);
35
35
  import { TypeUtils, NOUN_TYPE_COUNT, VERB_TYPE_COUNT } from '@soulcraft/brainy/types/graphTypes';
36
36
  import { loadNativeModule } from '../native/index.js';
37
+ import { composeInvariantReport } from './invariantReport.js';
38
+ import { declareDerivedFamilies, METADATA_FAMILIES } from './derivedFamilies.js';
37
39
  import { openOrCreateBinaryIdMapper } from './binaryIdMapperFactory.js';
38
40
  import { NativeColumnStore } from './NativeColumnStore.js';
39
41
  import { ResourceManager } from '../resource/ResourceManager.js';
@@ -71,6 +73,20 @@ const LSM_MANIFEST_VERSION = 2;
71
73
  * field registry already depends on.
72
74
  */
73
75
  const LSM_MANIFEST_KEY = '__metadata_lsm_manifest__';
76
+ /**
77
+ * Durable stamp of the entities a COMPLETED rebuild verified as
78
+ * unpostable (canonical rows with NO readable metadata — verified by
79
+ * direct read, not invented postings). The strand detector's
80
+ * posted-count floor accounts for them: without this, a fixed cohort of
81
+ * metadata-less canonical rows makes EVERY boot read as stranded and
82
+ * re-triggers a pointless full heal forever (memory-vm's constant-52,
83
+ * CORTEX-RESTART-STRAND 2026-07-13 — four consecutive boots).
84
+ */
85
+ const UNPOSTABLE_STAMP_KEY = '__metadata_verified_unpostable__';
86
+ /** Above this many ids the stamp stores count-only (the boot-time
87
+ * re-verify would stop being cheap); detection then carries a bounded
88
+ * staleness caveat, logged when it engages. */
89
+ const UNPOSTABLE_ID_CAP = 1000;
74
90
  /**
75
91
  * **LSM durability (step 5).** Lower clamp (32 MiB) for the bounded-memtable
76
92
  * size-flush trigger. Even when the dynamic 'metadata' budget is tiny, the
@@ -189,6 +205,14 @@ export class MetadataIndexManager {
189
205
  * everything from canonical. Cleared by a completed {@link rebuild}.
190
206
  */
191
207
  strandDetected = false;
208
+ /**
209
+ * SSTable ids actually REGISTERED into the live engine (cold-open
210
+ * registration + post-flush registration). The `manifest-residency`
211
+ * invariant (ADR-004 §6) compares the persisted manifest's claims
212
+ * against this set — the manifest is a claim; registration is the truth.
213
+ * Cleared on the rebuild's fresh re-open.
214
+ */
215
+ lsmRegisteredSstableIds = new Set();
192
216
  /**
193
217
  * **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
194
218
  * auto-migrate enabled) and read once at the END of {@link init}, which kicks the
@@ -485,6 +509,7 @@ export class MetadataIndexManager {
485
509
  throw new Error(`openLsmEngine: getBinaryBlobPath returned null for persisted SSTable '${key}'`);
486
510
  }
487
511
  engine.registerSstablePath(path);
512
+ this.lsmRegisteredSstableIds.add(entry.id);
488
513
  }
489
514
  }
490
515
  // Cold-open replay forensics (CORTEX-RESTART-STRAND). The engine's
@@ -699,6 +724,7 @@ export class MetadataIndexManager {
699
724
  });
700
725
  this.lsmEngine = engine;
701
726
  this.lsmManifest = newEmptyLsmManifest();
727
+ this.lsmRegisteredSstableIds.clear();
702
728
  this.native.setLsmEngine(engine);
703
729
  // Re-apply the current admission ceiling immediately (the subscriber
704
730
  // callback only refreshes it on the next rebalance/pressure tick). The
@@ -783,6 +809,7 @@ export class MetadataIndexManager {
783
809
  throw new Error(`flushLsm: getBinaryBlobPath returned null for '${key}'`);
784
810
  }
785
811
  engine.registerSstablePath(path);
812
+ this.lsmRegisteredSstableIds.add(id);
786
813
  // (4) Retire the memtable log below the flushed epoch. The `.sst` bytes
787
814
  // AND the manifest referencing them are already durable here, so a failure
788
815
  // at THIS step is non-fatal per the ordering invariant above: the records
@@ -1030,15 +1057,29 @@ export class MetadataIndexManager {
1030
1057
  getUuid: (intId) => native.intToUuid(intId) ?? undefined,
1031
1058
  getInt: (uuid) => native.uuidToInt(uuid) ?? undefined,
1032
1059
  getOrAssign: (uuid) => {
1033
- // The native index assigns ids inside addToIndex. If a caller resolves an
1034
- // id before the entity is indexed, surface the existing mapping; an
1035
- // unmapped UUID has no int id yet and is reported as such rather than
1036
- // fabricating one (which would desync the canonical native mapper).
1060
+ // The native index assigns ids inside addToIndex; a mapped UUID
1061
+ // resolves to its existing int. An UNMAPPED uuid here is a
1062
+ // canonical-committed entity whose index ops were lost (the
1063
+ // restart-strand class memory's boot heal hit exactly this,
1064
+ // CORTEX-RESTART-STRAND 2026-07-13): the cold vector rebuild
1065
+ // walks canonical and must be able to MINT the id through the
1066
+ // SAME allocator addToIndex uses (durable via the mapper's delta
1067
+ // log; a later addToIndex reuses it) instead of aborting the
1068
+ // whole heal. The entity's missing metadata postings stay
1069
+ // visible to posted-count-floor — this masks nothing.
1037
1070
  const existing = native.uuidToInt(uuid);
1038
1071
  if (existing !== null)
1039
1072
  return existing;
1040
- throw new Error(`EntityIdMapper.getOrAssign: '${uuid}' has no native id yet — ids are ` +
1041
- `assigned by addToIndex. Index the entity before resolving its int id.`);
1073
+ if (typeof native.getOrAssignInt === 'function') {
1074
+ prodLog.warn(`EntityIdMapper.getOrAssign: '${uuid}' had no native id at resolve ` +
1075
+ `time (canonical-committed entity with lost index ops) — assigned ` +
1076
+ `through the canonical allocator; its metadata postings heal via ` +
1077
+ `the posted-count invariant / rebuild.`);
1078
+ return Number(native.getOrAssignInt(uuid));
1079
+ }
1080
+ throw new Error(`EntityIdMapper.getOrAssign: '${uuid}' has no native id yet and the ` +
1081
+ `loaded native binary predates 3.0.16's getOrAssignInt — upgrade ` +
1082
+ `the paired .node build.`);
1042
1083
  },
1043
1084
  intsIterableToUuids: (ints) => native.intsToUuids([...ints]),
1044
1085
  // Brainy 8.0 `restore()` calls this BEFORE the metadata/vector/graph
@@ -1073,6 +1114,9 @@ export class MetadataIndexManager {
1073
1114
  // UUID ↔ entity-int allocator. Must run before columnStore.init (which
1074
1115
  // resolves ids through getIdMapper()) and before any mutation.
1075
1116
  this.wireIdMapper();
1117
+ // ADR-004 §7: declare this provider's protected families (feature-
1118
+ // detected on brainy ≥8.3.0; idempotent upsert; loud-but-non-blocking).
1119
+ await declareDerivedFamilies(this.storage, METADATA_FAMILIES);
1076
1120
  // LSM durability (steps 3-4): open the native LSM engine here (not in the
1077
1121
  // constructor) so it can be seeded from the on-disk manifest and cold-load
1078
1122
  // every persisted SSTable into its live read set — the entire restore,
@@ -1114,6 +1158,7 @@ export class MetadataIndexManager {
1114
1158
  prodLog.info('[NativeMetadataIndex] Field registry missing but the LSM engine ' +
1115
1159
  'recovered durable state (SSTables/memtable) — serving from it, ' +
1116
1160
  'NO rebuild.');
1161
+ this.readinessInitialized = true;
1117
1162
  return;
1118
1163
  }
1119
1164
  // **Tripwire — a rebuild here on a PREVIOUSLY-HEALTHY brain is a bug,
@@ -1142,11 +1187,21 @@ export class MetadataIndexManager {
1142
1187
  }
1143
1188
  console.warn(`[NativeMetadataIndex] Field registry missing but ${probe.totalCount ?? 'unknown'} entities exist — rebuilding index`);
1144
1189
  await this.rebuild();
1190
+ // A completed rebuild IS the readiness determination for this
1191
+ // open (sweep finding: these early exits previously left
1192
+ // readinessInitialized false — a fresh-rebuilt brain reported
1193
+ // not-ready forever, and brainy's gate re-rebuilt every boot).
1194
+ this.readinessInitialized = true;
1145
1195
  return;
1146
1196
  }
1197
+ // Probe says genuinely empty — an empty brain is READY by contract.
1198
+ this.readinessInitialized = true;
1147
1199
  }
1148
1200
  catch {
1149
- // Storage probe failed — genuinely empty
1201
+ // Storage probe failed — treated as empty per current semantics;
1202
+ // readiness stays UNINITIALIZED (conservative: a faulted probe must
1203
+ // not certify an unverified brain as ready — the fault-vs-absent
1204
+ // discrimination for this probe is tracked ADR-004 §4 M-work).
1150
1205
  }
1151
1206
  return;
1152
1207
  }
@@ -1234,6 +1289,41 @@ export class MetadataIndexManager {
1234
1289
  * same rebuild). Skipped at/above the u32 saturation point of
1235
1290
  * `getTotalEntityCount` (a saturated count would make equality lie).
1236
1291
  */
1292
+ /**
1293
+ * The verified-unpostable allowance for posted-vs-canonical floors: how
1294
+ * many canonical noun rows the last COMPLETED rebuild PROVED carry no
1295
+ * readable metadata (each verified by direct read at rebuild time, each
1296
+ * RE-VERIFIED here so an id that has since gained metadata stops
1297
+ * counting). Beyond {@link UNPOSTABLE_ID_CAP} the stamp is count-only
1298
+ * and the allowance carries a bounded, logged staleness caveat.
1299
+ * Advisory: any fault reads as 0 (may over-heal, never under-detects).
1300
+ */
1301
+ async verifiedUnpostableAllowance() {
1302
+ try {
1303
+ const stamp = (await this.storage.getMetadata(UNPOSTABLE_STAMP_KEY));
1304
+ if (!stamp || typeof stamp.nounCount !== 'number' || stamp.nounCount <= 0)
1305
+ return 0;
1306
+ if (Array.isArray(stamp.nounIds) && stamp.nounIds.length === stamp.nounCount) {
1307
+ const getMeta = this.storage.getNounMetadata;
1308
+ if (typeof getMeta !== 'function')
1309
+ return stamp.nounCount;
1310
+ let stillUnpostable = 0;
1311
+ for (const id of stamp.nounIds) {
1312
+ const md = await getMeta.call(this.storage, id).catch(() => undefined);
1313
+ if (md == null)
1314
+ stillUnpostable++;
1315
+ }
1316
+ return stillUnpostable;
1317
+ }
1318
+ prodLog.warn(`[NativeMetadataIndex] unpostable stamp is count-only ` +
1319
+ `(${stamp.nounCount} > id cap) — the posted-count floor carries its ` +
1320
+ `staleness until the next completed rebuild refreshes it.`);
1321
+ return stamp.nounCount;
1322
+ }
1323
+ catch {
1324
+ return 0;
1325
+ }
1326
+ }
1237
1327
  async detectDerivedStrand() {
1238
1328
  if (!this.lsmEngine || !this.lsmMetaDir)
1239
1329
  return;
@@ -1323,13 +1413,31 @@ export class MetadataIndexManager {
1323
1413
  // smaller than the (1–2 entity) system skew is masked here and
1324
1414
  // falls to the archive census above; the exact invariant (a
1325
1415
  // persisted posted-count stamp) is CORTEX-BILLION-SCALE-SPINE work.
1326
- if (posted < canonical) {
1416
+ //
1417
+ // UNPOSTABLE ALLOWANCE (the constant-52, CORTEX-RESTART-STRAND):
1418
+ // canonical rows with NO readable metadata cannot carry a
1419
+ // posting, and a completed rebuild PROVES which those are (each
1420
+ // verified by direct read) and stamps them. Without the
1421
+ // allowance the same cohort re-flags every boot and re-triggers
1422
+ // a pointless full heal forever. The stamp's ids are RE-VERIFIED
1423
+ // here (an id that has since gained metadata stops counting), so
1424
+ // the floor stays exact; beyond UNPOSTABLE_ID_CAP the stamp is
1425
+ // count-only and the allowance carries a bounded, logged
1426
+ // staleness caveat.
1427
+ const unpostable = await this.verifiedUnpostableAllowance();
1428
+ if (posted + unpostable < canonical) {
1327
1429
  this.strandDetected = true;
1328
1430
  prodLog.error(`[NativeMetadataIndex] STRAND: canonical holds ${canonical} ` +
1329
- `entities but the index has postings for only ${posted}` +
1330
- `${canonical - posted}+ entities are durable in canonical but ` +
1331
- `UNQUERYABLE (lost postings). Reporting not-ready so the ` +
1332
- `cold-open gate rebuilds from canonical.`);
1431
+ `entities but the index has postings for only ${posted}` +
1432
+ (unpostable > 0 ? ` (+${unpostable} verified-unpostable allowance)` : '') +
1433
+ ` ${canonical - posted - unpostable}+ entities are durable in ` +
1434
+ `canonical but UNQUERYABLE (lost postings). Reporting not-ready ` +
1435
+ `so the cold-open gate rebuilds from canonical.`);
1436
+ }
1437
+ 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.`);
1333
1441
  }
1334
1442
  }
1335
1443
  }
@@ -1946,6 +2054,14 @@ export class MetadataIndexManager {
1946
2054
  const resultJson = this.native.removeFromIndex(id);
1947
2055
  const result = JSON.parse(resultJson);
1948
2056
  await this.persistMutationResult(result);
2057
+ // Count symmetry (sweep finding; ADR-004 anti-pattern C): this path
2058
+ // has no metadata to know WHICH type to decrement, and skipping the
2059
+ // decrement permanently inflated totalEntitiesByType /
2060
+ // entityCountsByTypeFixed until restart. Instead of guessing,
2061
+ // RECOMPUTE the counters from native ground truth — bounded
2062
+ // (per-type O(1) bitmap-cardinality reads, no id materialization).
2063
+ await this.lazyLoadCounts();
2064
+ this.syncTypeCountsToFixed();
1949
2065
  }
1950
2066
  // Tombstone in the column store so the entity drops out of sort/filter/range.
1951
2067
  if (intId !== null) {
@@ -2278,7 +2394,203 @@ export class MetadataIndexManager {
2278
2394
  // ==========================================================================
2279
2395
  // Flush
2280
2396
  // ==========================================================================
2397
+ /**
2398
+ * ADR-004 §6 — the provider's self-report of its cross-layer invariants.
2399
+ * NEVER throws; bounded (residency + O(1) count reads, one limit-1
2400
+ * canonical probe); safe on a live brain. Brainy's
2401
+ * `validateIndexConsistency` feature-detects and aggregates this;
2402
+ * `repairIndex` maps `heal: 'rebuild'` failures to {@link rebuild}.
2403
+ */
2404
+ async validateInvariants() {
2405
+ return composeInvariantReport('metadata', () => this.isReady(), [
2406
+ // manifest-residency: every persisted-manifest-named SSTable is
2407
+ // actually registered in the live engine.
2408
+ () => {
2409
+ const manifest = this.lsmManifest;
2410
+ if (manifest === null || this.lsmMetaDir === null) {
2411
+ return {
2412
+ name: 'manifest-residency',
2413
+ holds: true,
2414
+ detail: 'in-RAM engine or fresh brain — no persisted SSTables to verify',
2415
+ heal: 'none',
2416
+ };
2417
+ }
2418
+ const missing = manifest.sstables
2419
+ .map((e) => e.id)
2420
+ .filter((id) => !this.lsmRegisteredSstableIds.has(id));
2421
+ return {
2422
+ name: 'manifest-residency',
2423
+ holds: missing.length === 0,
2424
+ detail: missing.length === 0
2425
+ ? `${manifest.sstables.length} manifest-named SSTable(s), all registered`
2426
+ : `manifest names ${manifest.sstables.length} SSTable(s) but ` +
2427
+ `${missing.length} never registered (ids: ${missing.join(', ')}) — ` +
2428
+ `the index serves a subset of its durable postings`,
2429
+ expected: manifest.sstables.length,
2430
+ actual: manifest.sstables.length - missing.length,
2431
+ heal: 'rebuild',
2432
+ };
2433
+ },
2434
+ // posted-count-floor: Σ posted noun counts ≥ canonical noun count.
2435
+ async () => {
2436
+ const getNouns = this.storage.getNouns;
2437
+ if (typeof getNouns !== 'function') {
2438
+ return {
2439
+ name: 'posted-count-floor',
2440
+ holds: true,
2441
+ detail: 'adapter exposes no getNouns — floor unverifiable, skipped',
2442
+ heal: 'none',
2443
+ };
2444
+ }
2445
+ let canonical;
2446
+ try {
2447
+ const probe = await getNouns.call(this.storage, { pagination: { limit: 1 } });
2448
+ canonical = probe?.totalCount;
2449
+ }
2450
+ catch (error) {
2451
+ return {
2452
+ name: 'posted-count-floor',
2453
+ holds: false,
2454
+ detail: `canonical count probe FAULTED — divergence unverifiable: ${String(error)}`,
2455
+ heal: 'none',
2456
+ };
2457
+ }
2458
+ if (typeof canonical !== 'number' || !Number.isFinite(canonical) || canonical >= 0xffff_ffff) {
2459
+ return {
2460
+ name: 'posted-count-floor',
2461
+ holds: true,
2462
+ detail: `canonical count not exactly countable (${String(canonical)}) — floor skipped by design`,
2463
+ heal: 'none',
2464
+ };
2465
+ }
2466
+ const { NounType } = await import('@soulcraft/brainy');
2467
+ let posted = 0;
2468
+ for (const t of Object.values(NounType)) {
2469
+ posted += this.native.getEntityCountByType(t);
2470
+ }
2471
+ // Same verified-unpostable allowance as the strand detector — a
2472
+ // metadata-less canonical row (proven by the last completed
2473
+ // rebuild) cannot carry a posting and must not read as damage
2474
+ // here either, or brainy's repairIndex loops the same heal the
2475
+ // detector fix just broke.
2476
+ const unpostable = await this.verifiedUnpostableAllowance();
2477
+ const floor = posted + unpostable;
2478
+ return {
2479
+ name: 'posted-count-floor',
2480
+ holds: floor >= canonical,
2481
+ detail: floor >= canonical
2482
+ ? `posted ${posted}${unpostable > 0 ? ` (+${unpostable} verified-unpostable)` : ''} ≥ canonical ${canonical}`
2483
+ : `canonical holds ${canonical} entities but only ${posted} are ` +
2484
+ `posted (${unpostable} verified-unpostable allowed) — ` +
2485
+ `${canonical - floor} durable-but-unqueryable`,
2486
+ expected: canonical,
2487
+ actual: floor,
2488
+ heal: 'rebuild',
2489
+ };
2490
+ },
2491
+ // replay-clean: the cold open recovered nothing anomalous.
2492
+ () => {
2493
+ const engine = this.lsmEngine;
2494
+ if (!engine || typeof engine.replayStatsJson !== 'function') {
2495
+ return {
2496
+ name: 'replay-clean',
2497
+ holds: true,
2498
+ detail: 'replay stats unavailable (in-RAM engine or pre-3.0.12 binary)',
2499
+ heal: 'none',
2500
+ };
2501
+ }
2502
+ const s = JSON.parse(engine.replayStatsJson());
2503
+ const unreadable = s.archivesUnreadable ?? 0;
2504
+ const recreated = s.liveLogsRecreated ?? 0;
2505
+ const clean = unreadable === 0 && recreated === 0;
2506
+ return {
2507
+ name: 'replay-clean',
2508
+ holds: clean,
2509
+ detail: clean
2510
+ ? 'cold-open replay clean (no unreadable archives, no recreated live logs)'
2511
+ : `cold-open replay anomalies: ${recreated} live log(s) were missing ` +
2512
+ `(recreated), ${unreadable} archive(s) unreadable`,
2513
+ heal: 'rebuild',
2514
+ };
2515
+ },
2516
+ // registry-discoverable: every field this process knows is in the
2517
+ // persisted registry a cold open would discover.
2518
+ async () => {
2519
+ if (this.knownFields.size === 0) {
2520
+ return {
2521
+ name: 'registry-discoverable',
2522
+ holds: true,
2523
+ detail: 'no fields registered yet',
2524
+ heal: 'none',
2525
+ };
2526
+ }
2527
+ let persisted = [];
2528
+ try {
2529
+ const raw = await this.storage.getMetadata('__metadata_field_registry__');
2530
+ persisted = Array.isArray(raw?.fields)
2531
+ ? (raw.fields)
2532
+ : Array.isArray(raw)
2533
+ ? raw
2534
+ : [];
2535
+ }
2536
+ catch (error) {
2537
+ return {
2538
+ name: 'registry-discoverable',
2539
+ holds: false,
2540
+ detail: `persisted registry read FAULTED — discoverability unverifiable: ${String(error)}`,
2541
+ heal: 'none',
2542
+ };
2543
+ }
2544
+ const persistedSet = new Set(persisted);
2545
+ const undiscoverable = [...this.knownFields].filter((f) => !persistedSet.has(f));
2546
+ return {
2547
+ name: 'registry-discoverable',
2548
+ holds: undiscoverable.length === 0,
2549
+ detail: undiscoverable.length === 0
2550
+ ? `${this.knownFields.size} known field(s), all in the persisted registry`
2551
+ : `${undiscoverable.length} known field(s) missing from the persisted ` +
2552
+ `registry (would be undiscoverable after restart): ${undiscoverable.join(', ')}`,
2553
+ expected: this.knownFields.size,
2554
+ actual: this.knownFields.size - undiscoverable.length,
2555
+ heal: 'repair',
2556
+ };
2557
+ },
2558
+ ]);
2559
+ }
2560
+ /**
2561
+ * Serializes every {@link flush} pass. Two flushes interleaving at the
2562
+ * awaits is NOT hypothetical — memory-vm runs a periodic flusher AND
2563
+ * brainy's shutdown hook flushes on SIGTERM — and the flushLsm pair
2564
+ * both read `nextSstableId(manifest)` before either pushes: SAME id →
2565
+ * same blob key → the second write OVERWRITES the first SSTable →
2566
+ * postings lost with clean logs and NO replay anomaly (the exact
2567
+ * acceptance-#1 signature on CORTEX-RESTART-STRAND, 2026-07-13).
2568
+ * Chaining gives each caller its own full pass covering everything
2569
+ * written before its call — the semantics a shutdown flush needs.
2570
+ */
2571
+ flushChain = Promise.resolve();
2281
2572
  async flush() {
2573
+ // Flush-during-rebuild guard (CORTEX-RESTART-STRAND, memory-vm's
2574
+ // 80-minute heal): a consumer's periodic flush cycle running DURING a
2575
+ // rebuild drains the growing memtable + rewrites field indexes on
2576
+ // every pass — O(current-index-size) IO per pass, interleaved with the
2577
+ // walk (measured live: 572 msync/s + escalating 29s→45s flush passes).
2578
+ // The rebuild's own completion flush owns durability (canonical is the
2579
+ // source of truth throughout), so a concurrent flush only multiplies
2580
+ // IO. Loud no-op, never silent.
2581
+ if (this.isRebuilding) {
2582
+ prodLog.warn('[NativeMetadataIndex] flush() during an active rebuild — deferred ' +
2583
+ '(the rebuild\'s completion flush owns durability; a concurrent ' +
2584
+ 'flush would multiply heal IO).');
2585
+ return;
2586
+ }
2587
+ const run = this.flushChain.then(() => this.flushOnce());
2588
+ // Keep the chain alive on failure — the NEXT flush must still run; the
2589
+ // failure itself propagates to THIS caller below.
2590
+ this.flushChain = run.catch(() => { });
2591
+ return run;
2592
+ }
2593
+ async flushOnce() {
2282
2594
  // Always save field registry + msync the entity ID mapper — even with
2283
2595
  // no dirty fields. The field registry is the critical file init() needs
2284
2596
  // to discover persisted indices. Without it, the metadata index appears
@@ -2667,11 +2979,11 @@ export class MetadataIndexManager {
2667
2979
  'function';
2668
2980
  if (bulkCapable)
2669
2981
  this.lsmEngine.setBulkLoad(true);
2670
- let totalNounsProcessed;
2671
- let totalVerbsProcessed;
2982
+ let nounResult;
2983
+ let verbResult;
2672
2984
  try {
2673
- totalNounsProcessed = await this.rebuildEntityMetadata('noun');
2674
- totalVerbsProcessed = await this.rebuildEntityMetadata('verb');
2985
+ nounResult = await this.rebuildEntityMetadata('noun');
2986
+ verbResult = await this.rebuildEntityMetadata('verb');
2675
2987
  }
2676
2988
  finally {
2677
2989
  // Leaving bulk mode performs the walk's single durability point
@@ -2679,10 +2991,28 @@ export class MetadataIndexManager {
2679
2991
  if (bulkCapable)
2680
2992
  this.lsmEngine.setBulkLoad(false);
2681
2993
  }
2682
- // Final flush
2994
+ // Final flush. isRebuilding drops FIRST so the completion flush
2995
+ // passes the flush-during-rebuild guard above; readiness is still
2996
+ // gated by strandDetected until the flush lands and clears it below.
2997
+ this.isRebuilding = false;
2683
2998
  await this.flushRebuildDirty();
2684
2999
  await this.flush();
2685
- prodLog.info(`Metadata index rebuild completed! Processed ${totalNounsProcessed} nouns and ${totalVerbsProcessed} verbs`);
3000
+ // Persist what this COMPLETED walk PROVED unpostable (each id
3001
+ // verified by direct canonical read) so the next boot's strand
3002
+ // detector uses the honest floor instead of re-flagging the same
3003
+ // cohort forever. Refreshed on every completed rebuild; an empty
3004
+ // set clears the stamp.
3005
+ await this.storage.saveMetadata(UNPOSTABLE_STAMP_KEY, {
3006
+ nounCount: nounResult.noMetadata,
3007
+ nounIds: nounResult.noMetadataIds,
3008
+ verbCount: verbResult.noMetadata,
3009
+ verifiedAt: Date.now(),
3010
+ });
3011
+ prodLog.info(`Metadata index rebuild completed! Processed ${nounResult.processed} nouns and ` +
3012
+ `${verbResult.processed} verbs` +
3013
+ (nounResult.noMetadata > 0
3014
+ ? ` (${nounResult.noMetadata} canonical noun(s) verified metadata-less — stamped for the strand detector)`
3015
+ : ''));
2686
3016
  // A completed rebuild re-posted every canonical entity into a freshly
2687
3017
  // re-opened engine (reopenLsmEngineFresh wiped the stale shard set) —
2688
3018
  // any cold-open strand verdict is now satisfied.
@@ -2710,6 +3040,7 @@ export class MetadataIndexManager {
2710
3040
  let processed = 0;
2711
3041
  let sinceFlush = 0;
2712
3042
  let noMetadata = 0;
3043
+ const noMetadataIds = [];
2713
3044
  // Heal observability (CORTEX-RESTART-STRAND, memory's ask): a rebuild
2714
3045
  // must never be silent for minutes — the operator cannot distinguish
2715
3046
  // converging from stuck. Every 500 posted entities logs progress with
@@ -2760,6 +3091,8 @@ export class MetadataIndexManager {
2760
3091
  }
2761
3092
  else {
2762
3093
  noMetadata++;
3094
+ if (noMetadataIds.length < UNPOSTABLE_ID_CAP)
3095
+ noMetadataIds.push(it.id);
2763
3096
  }
2764
3097
  }
2765
3098
  hasMore = page.hasMore;
@@ -2773,9 +3106,10 @@ export class MetadataIndexManager {
2773
3106
  prodLog.warn(`[NativeMetadataIndex] rebuild(${kind}): ${noMetadata} enumerated ` +
2774
3107
  `entit${noMetadata === 1 ? 'y' : 'ies'} carried no canonical ` +
2775
3108
  `metadata (verified by direct read) and were not indexed — ` +
2776
- `expected only for metadata-less legacy records.`);
3109
+ `expected only for metadata-less legacy records. First ids: ` +
3110
+ noMetadataIds.slice(0, 10).join(', '));
2777
3111
  }
2778
- return processed;
3112
+ return { processed, noMetadata, noMetadataIds };
2779
3113
  }
2780
3114
  /**
2781
3115
  * Rebuild-path verify-read for a single entity's canonical metadata.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @module utils/derivedFamilies
3
+ * @description ADR-004 §7 — cor's registered-blob family declarations
4
+ * (adopted 2026-07-13). Each provider declares the artifact families it
5
+ * owns through brainy ≥8.3.0's `registerDerivedFamily` (feature-detected;
6
+ * inert on older adapters). Once declared, the storage layer REFUSES to
7
+ * delete a member (`ProtectedArtifactError` — an in-process GC/sweeper is
8
+ * structurally incapable of removing a load-bearing file), and
9
+ * `checkDerivedFamiliesPresent` names any missing-on-open member (the
10
+ * external-deleter catch).
11
+ *
12
+ * Scope note (deliberate split, per §7): declarations cover
13
+ * KEY-ADDRESSABLE artifacts — members resolve through the same adapter
14
+ * key→path function on both sides, so consistency is by construction.
15
+ * Path-level siblings the native layer writes directly (the vector
16
+ * base's `.slotmap`/`.slotrev` sidecars) are outside the key space; their
17
+ * set-coherence is cor's own `familygen` stamp (the §7 atomic set-swap),
18
+ * not the storage contract. Growing sets use `namespace: true` prefixes.
19
+ *
20
+ * Registration is idempotent (upsert by family name) and runs on every
21
+ * open. A registration failure is LOUD but never blocks an open — the
22
+ * contract is protective, not load-bearing.
23
+ */
24
+ import type { DerivedFamilyDeclaration } from '@soulcraft/brainy';
25
+ /**
26
+ * Declare families on the adapter when it supports the contract. Never
27
+ * throws; failures are loud. Returns how many were registered (0 when the
28
+ * adapter predates the contract — callers may log that once at debug).
29
+ */
30
+ export declare function declareDerivedFamilies(storage: unknown, families: DerivedFamilyDeclaration[]): Promise<number>;
31
+ /** The vector index's key-addressable families. */
32
+ export declare const VECTOR_FAMILIES: DerivedFamilyDeclaration[];
33
+ /** The metadata LSM's families (engine dir + SSTables share the key prefix). */
34
+ export declare const METADATA_FAMILIES: DerivedFamilyDeclaration[];
35
+ /** The column store's family (fully key-addressable — API delete-refusal bites here). */
36
+ export declare const COLUMN_FAMILIES: DerivedFamilyDeclaration[];
37
+ /** The graph index's families (LSM SSTables + verb namespace stores). */
38
+ export declare const GRAPH_FAMILIES: DerivedFamilyDeclaration[];
39
+ /**
40
+ * Owner-sanctioned retirement window (the pattern brainy's
41
+ * ProtectedArtifactError names): unregister the family, run the owner's
42
+ * legitimate delete work, re-register in a finally. The unprotected
43
+ * window is small and self-healing — every provider re-declares its
44
+ * families on init, so even a crash inside the window is repaired at the
45
+ * next open. On adapters without the contract, just runs `fn`.
46
+ */
47
+ export declare function withFamilyRetirement<T>(storage: unknown, family: DerivedFamilyDeclaration, fn: () => Promise<T>): Promise<T>;
48
+ //# sourceMappingURL=derivedFamilies.d.ts.map