diff --git a/src/coreTypes.ts b/src/coreTypes.ts index cce06e8a..4b1ebfb9 100644 --- a/src/coreTypes.ts +++ b/src/coreTypes.ts @@ -754,6 +754,39 @@ export interface Change { data?: HNSWNounWithMetadata | HNSWVerbWithMetadata } +/** + * @description A declared derived-index blob FAMILY (ADR-004 §7 — the + * registered-blob contract). A family names the set of on-disk blobs that a + * derived index needs AS A SET (e.g. the vector base = `main.dkann` + + * `main.slotmap` + `main.slotrev`): losing ANY member corrupts the index. Once a + * family is declared: + * - its members are UNDELETABLE through the storage layer — `deleteBinaryBlob` / + * `removeRawPrefix` refuse with a `ProtectedArtifactError`, so an in-process + * GC/sweeper cannot remove a load-bearing file (intentional retirement = + * `unregisterDerivedFamily` first); + * - a member missing on open is a loud `DerivedArtifactMissingError` → rebuild. + * `COLD ≠ DEAD`: a write-once segment or a recovery-critical archive is + * load-bearing even when it has not been touched in a long time. + */ +export interface DerivedFamilyDeclaration { + /** Stable family id, e.g. `'vector-base'` / `'metadata-sstables'`. */ + name: string + /** + * The logical blob keys that make up the family. When {@link namespace} is + * set, each entry is a PREFIX protecting every key beneath it (for growing + * sets like `seg-*`); otherwise each entry is an exact member key. + */ + members: string[] + /** When true, {@link members} are prefixes (protect all keys beneath each). */ + namespace?: boolean + /** + * Whether a missing family can be rebuilt from the canonical records (default + * `true`). `false` marks an irreplaceable family (a missing member is data + * loss, not a rebuild). + */ + rebuildable?: boolean +} + export interface StorageAdapter { init(): Promise @@ -1049,6 +1082,33 @@ export interface StorageAdapter { */ getBinaryBlobPath(key: string): string | null + /** + * @description OPTIONAL (ADR-004 §7 registered-blob contract). Declare a + * derived-index blob {@link DerivedFamilyDeclaration | family} whose members + * become UNDELETABLE through this adapter — a subsequent `deleteBinaryBlob` / + * `removeRawPrefix` that would remove a declared member throws a + * `ProtectedArtifactError`. Providers declare their families on create; the + * declaration is persisted so protection survives a reopen. Idempotent per + * `name` (re-declaring replaces). + * @param family - The family to protect. + */ + registerDerivedFamily?(family: DerivedFamilyDeclaration): Promise + + /** + * @description OPTIONAL. Remove a family's protection so its members can be + * deleted again — the explicit, auditable step for intentional retirement of a + * derived index (the ONLY way a declared member becomes deletable). + * @param name - The {@link DerivedFamilyDeclaration.name} to unregister. + */ + unregisterDerivedFamily?(name: string): Promise + + /** + * @description OPTIONAL. List the currently-declared derived-index families — + * the source of truth for what `clear()` must wipe and what a + * missing-on-open check verifies. + */ + listDerivedFamilies?(): Promise + /** * Save statistics data * @param statistics The statistics data to save diff --git a/src/errors/brainyError.ts b/src/errors/brainyError.ts index c2667009..6aa1f919 100644 --- a/src/errors/brainyError.ts +++ b/src/errors/brainyError.ts @@ -15,6 +15,8 @@ export type BrainyErrorType = | 'GRAPH_INDEX_NOT_READY' | 'METADATA_INDEX_NOT_READY' | 'VECTOR_INDEX_NOT_READY' + | 'PROTECTED_ARTIFACT' + | 'DERIVED_ARTIFACT_MISSING' | 'MIGRATION_IN_PROGRESS' /** @@ -307,6 +309,60 @@ export class VectorIndexNotReadyError extends BrainyError { } } +/** + * Thrown when a delete (`deleteBinaryBlob` / `removeRawPrefix`) would remove a + * blob that is a declared member of a protected derived-index FAMILY (ADR-004 §7 + * registered-blob contract). Declared derived artifacts are undeletable through + * the storage layer — this makes an in-process GC / sweeper INCAPABLE of removing + * a load-bearing index file (the lost-`main.dkann` class). Intentional retirement + * is the explicit `unregisterDerivedFamily(name)` step, then the delete. + */ +export class ProtectedArtifactError extends BrainyError { + /** The blob key the delete targeted. */ + public readonly key: string + /** The protected family the key belongs to. */ + public readonly family: string + constructor(key: string, family: string) { + super( + `Refused to delete '${key}': it is a declared member of the protected ` + + `derived-index family '${family}'. Declared derived artifacts are undeletable ` + + `through the storage layer (COLD ≠ DEAD) — unregisterDerivedFamily('${family}') ` + + `first if retirement is intentional.`, + 'PROTECTED_ARTIFACT', + false + ) + this.name = 'ProtectedArtifactError' + this.key = key + this.family = family + } +} + +/** + * Raised (loudly) when a declared derived-index family is missing one or more of + * its members on open — i.e. a load-bearing blob was deleted OUTSIDE the write + * path (an external sweeper the in-process refusal cannot stop). The index must + * be rebuilt from canonical; "healthy-while-broken" is impossible because the + * missing member is named, not silently tolerated. + */ +export class DerivedArtifactMissingError extends BrainyError { + /** The family with missing members. */ + public readonly family: string + /** The member keys that are absent. */ + public readonly missing: string[] + constructor(family: string, missing: string[]) { + super( + `Derived-index family '${family}' is missing ${missing.length} declared ` + + `member(s) on open (${missing.join(', ')}) — deleted outside the write path. ` + + `The index must be rebuilt from canonical.`, + 'DERIVED_ARTIFACT_MISSING', + false + ) + this.name = 'DerivedArtifactMissingError' + this.family = family + this.missing = missing + } +} + /** * Thrown when a data-plane read or write is issued against a brain that is * running its one-time, automatic 7.x → 8.0 on-disk upgrade — the coordinated diff --git a/src/index.ts b/src/index.ts index c64d39df..b0a95fe1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,7 +150,7 @@ export { EntityNotFoundError, RelationNotFoundError } from './errors/notFound.js // Base error + typed migration-lock error — thrown by any data-plane call while a // brain runs its one-time 7.x→8.0 upgrade; catch to answer HTTP 503 + Retry-After. -export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError } from './errors/brainyError.js' +export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError } from './errors/brainyError.js' export type { BrainyErrorType } from './errors/brainyError.js' // ============= 8.0 Db API — generational MVCC ============= @@ -301,7 +301,8 @@ import type { HNSWNoun, HNSWVerb, HNSWConfig, - StorageAdapter + StorageAdapter, + DerivedFamilyDeclaration } from './coreTypes.js' // Export vector index implementation (the JS HNSW path) @@ -319,7 +320,8 @@ export type { HNSWNoun, HNSWVerb, HNSWConfig, - StorageAdapter + StorageAdapter, + DerivedFamilyDeclaration } // Export graph types diff --git a/src/storage/adapters/fileSystemStorage.ts b/src/storage/adapters/fileSystemStorage.ts index 9ff0539e..c2352d2f 100644 --- a/src/storage/adapters/fileSystemStorage.ts +++ b/src/storage/adapters/fileSystemStorage.ts @@ -630,6 +630,9 @@ export class FileSystemStorage extends BaseStorage { */ public override async removeRawPrefix(prefix: string): Promise { await this.ensureInitialized() + // Registered-blob contract: a prefix-nuke must not take out a protected + // family member (ADR-004 §7). Throws if the prefix intersects one. + await this.assertPrefixNotProtected(prefix) await fs.promises.rm(path.join(this.rootDir, prefix), { recursive: true, force: true }) } @@ -1250,6 +1253,11 @@ export class FileSystemStorage extends BaseStorage { */ public async deleteBinaryBlob(key: string): Promise { await this.ensureInitialized() + // Registered-blob contract (ADR-004 §7): refuse to delete a declared + // derived-index family member — an in-process GC/sweeper cannot remove a + // load-bearing index file. Throws ProtectedArtifactError; no-op when no + // families are registered. + await this.assertBlobKeyDeletable(key) try { await fs.promises.unlink(this.blobPath(key)) } catch { diff --git a/src/storage/adapters/memoryStorage.ts b/src/storage/adapters/memoryStorage.ts index 05dfb0b4..6b2564d5 100644 --- a/src/storage/adapters/memoryStorage.ts +++ b/src/storage/adapters/memoryStorage.ts @@ -183,6 +183,9 @@ export class MemoryStorage extends BaseStorage { * @param key - The blob key. */ public async deleteBinaryBlob(key: string): Promise { + // Registered-blob contract (ADR-004 §7) — parity with the filesystem adapter: + // a declared family member is undeletable (throws ProtectedArtifactError). + await this.assertBlobKeyDeletable(key) this.blobStore.delete(key) } diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index 159a0593..8a491e3e 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -15,7 +15,8 @@ import { VerbMetadata, HNSWNounWithMetadata, HNSWVerbWithMetadata, - StatisticsData + StatisticsData, + DerivedFamilyDeclaration } from '../coreTypes.js' import { BaseStorageAdapter } from './adapters/baseStorageAdapter.js' import { validateNounType, validateVerbType } from '../utils/typeValidation.js' @@ -31,7 +32,7 @@ import { BlobStorage, type BlobStoreAdapter } from './blobStorage.js' import { unwrapBinaryData } from './binaryDataCodec.js' import { prodLog } from '../utils/logger.js' import { isAbsentError } from '../utils/errorClassification.js' -import { BrainyError } from '../errors/brainyError.js' +import { BrainyError, ProtectedArtifactError, DerivedArtifactMissingError } from '../errors/brainyError.js' import { MetadataWriteBuffer } from '../utils/metadataWriteBuffer.js' import { splitNounMetadataRecord, @@ -251,6 +252,16 @@ export abstract class BaseStorage extends BaseStorageAdapter { protected isInitialized = false /** One-shot guard so the graph fast-path cold-load probe runs once per adapter. */ private _graphFastPathProbed = false + /** + * Registered-blob contract (ADR-004 §7): declared derived-index families, keyed + * by name. Members are undeletable through the blob delete seams. Loaded lazily + * from `_system/derived-artifacts.json` and re-persisted on every change. + */ + private _derivedFamilies = new Map() + /** One-shot guard for loading the persisted family registry. */ + private _derivedFamiliesLoaded = false + /** Storage-root-relative path of the persisted family registry. */ + private static readonly DERIVED_FAMILIES_KEY = '_system/derived-artifacts.json' protected graphIndex?: GraphAdjacencyIndex protected graphIndexPromise?: Promise /** @@ -1041,6 +1052,154 @@ export abstract class BaseStorage extends BaseStorageAdapter { await this.writeObjectToPath(path, data) } + // ========================================================================== + // Registered-blob contract (ADR-004 §7) — declared derived-index families are + // undeletable through the blob delete seams. Shared here so every adapter that + // extends BaseStorage inherits the same enforcement; the concrete + // deleteBinaryBlob / removeRawPrefix call assertBlobKeyDeletable / + // assertPrefixNotProtected before removing anything. + // ========================================================================== + + /** Load the persisted family registry once (lazy). */ + private async ensureDerivedFamiliesLoaded(): Promise { + if (this._derivedFamiliesLoaded) return + const stored = await this.readRawObject(BaseStorage.DERIVED_FAMILIES_KEY).catch(() => null) + const families = (stored as { families?: DerivedFamilyDeclaration[] } | null)?.families + if (Array.isArray(families)) { + for (const f of families) { + if (f && typeof f.name === 'string' && Array.isArray(f.members)) this._derivedFamilies.set(f.name, f) + } + } + this._derivedFamiliesLoaded = true + } + + /** Persist the current family registry (fsync'd via the raw-object write). */ + private async persistDerivedFamilies(): Promise { + await this.writeRawObject(BaseStorage.DERIVED_FAMILIES_KEY, { + families: [...this._derivedFamilies.values()] + }) + } + + public async registerDerivedFamily(family: DerivedFamilyDeclaration): Promise { + await this.ensureInitialized() + await this.ensureDerivedFamiliesLoaded() + this._derivedFamilies.set(family.name, { + name: family.name, + members: [...family.members], + ...(family.namespace !== undefined ? { namespace: family.namespace } : {}), + ...(family.rebuildable !== undefined ? { rebuildable: family.rebuildable } : {}) + }) + await this.persistDerivedFamilies() + } + + public async unregisterDerivedFamily(name: string): Promise { + await this.ensureInitialized() + await this.ensureDerivedFamiliesLoaded() + if (this._derivedFamilies.delete(name)) await this.persistDerivedFamilies() + } + + public async listDerivedFamilies(): Promise { + await this.ensureInitialized() + await this.ensureDerivedFamiliesLoaded() + return [...this._derivedFamilies.values()] + } + + /** + * @description A blob key that is a transient write-scratch file (a `*.tmp.*` + * temp, a `*.rebuild-tmp`, a `*.rotate-tmp`). Its OWNER renames/removes it as + * part of an atomic write; it is never a protected family member (ADR-004 §7). + */ + private isTransientBlobKey(key: string): boolean { + return /\.rebuild-tmp$|\.rotate-tmp$|\.tmp(\.|$)/.test(key) + } + + /** + * @description The name of the protected family a blob key belongs to, or + * `null`. A `namespace` member protects every key beneath it; a plain member + * protects that exact key. + */ + private protectingFamilyOf(key: string): string | null { + for (const family of this._derivedFamilies.values()) { + for (const member of family.members) { + if (family.namespace ? key === member || key.startsWith(member) : key === member) { + return family.name + } + } + } + return null + } + + /** + * @description Enforcement point for `deleteBinaryBlob`: refuse (throw + * {@link ProtectedArtifactError}) when the key is a declared family member. + * Transients pass through. An undeclared blob delete under an ACTIVE contract + * (families are registered) is logged loudly — nothing under `_blobs/` should + * vanish unremarked once the contract is in force. + */ + protected async assertBlobKeyDeletable(key: string): Promise { + await this.ensureDerivedFamiliesLoaded() + if (this._derivedFamilies.size === 0) return // contract inactive — no enforcement + if (this.isTransientBlobKey(key)) return + const family = this.protectingFamilyOf(key) + if (family) throw new ProtectedArtifactError(key, family) + prodLog.warn( + `[BaseStorage] deleteBinaryBlob('${key}') removes an UNDECLARED blob while the ` + + `registered-blob contract is active — permitted, but surfaced so no _blobs/ file ` + + `disappears silently.` + ) + } + + /** + * @description Enforcement point for `removeRawPrefix`: refuse when the prefix + * would take out a protected family member (a prefix-nuke must not remove a + * declared blob). Transients are ignored. + */ + protected async assertPrefixNotProtected(prefix: string): Promise { + await this.ensureDerivedFamiliesLoaded() + if (this._derivedFamilies.size === 0) return + for (const family of this._derivedFamilies.values()) { + for (const member of family.members) { + // Under the shared `_blobs/` root, member keys resolve beneath it; a + // prefix intersects a member when either contains the other. + const memberBlobPath = `_blobs/${member}` + if ( + memberBlobPath.startsWith(prefix) || + prefix.startsWith(memberBlobPath) || + member.startsWith(prefix) || + prefix.startsWith(member) + ) { + if (!this.isTransientBlobKey(member)) throw new ProtectedArtifactError(member, family.name) + } + } + } + } + + /** + * @description Verify every declared family has all its members present on + * disk (ADR-004 §7 missing-on-open catch for an EXTERNAL deleter that bypasses + * the in-process refusal). Returns the incomplete families (name + missing + * members) — the caller decides how to heal (rebuild from canonical). Loud by + * construction: a missing load-bearing blob is named, never silently tolerated. + */ + public async checkDerivedFamiliesPresent(): Promise> { + await this.ensureInitialized() + await this.ensureDerivedFamiliesLoaded() + const incomplete: Array<{ name: string; missing: string[]; rebuildable: boolean }> = [] + for (const family of this._derivedFamilies.values()) { + if (family.namespace) continue // a growing prefix has no fixed member set to verify + const missing: string[] = [] + for (const member of family.members) { + const blob = await this.loadBinaryBlob(member).catch(() => null) + if (!blob) missing.push(member) + } + if (missing.length > 0) { + prodLog.warn(new DerivedArtifactMissingError(family.name, missing).message) + incomplete.push({ name: family.name, missing, rebuildable: family.rebuildable !== false }) + } + } + return incomplete + } + /** * Delete a raw object at a storage-root-relative path (no-op if absent). * @@ -1224,6 +1383,12 @@ export abstract class BaseStorage extends BaseStorageAdapter { */ protected async reloadDerivedState(): Promise { this.clearWriteCache() + // Registered-blob registry: clear() wipes the persisted `_system/` copy and + // the family members under `_blobs/`, so drop the in-memory cache too — a + // reset brain must not keep stale family protection or report their members + // "missing" on the next check. + this._derivedFamilies.clear() + this._derivedFamiliesLoaded = false this.nounCountsByType.fill(0) this.verbCountsByType.fill(0) this.subtypeCountsByType.clear() diff --git a/tests/unit/storage/registered-blob-contract.test.ts b/tests/unit/storage/registered-blob-contract.test.ts new file mode 100644 index 00000000..ef450163 --- /dev/null +++ b/tests/unit/storage/registered-blob-contract.test.ts @@ -0,0 +1,130 @@ +/** + * @module tests/unit/storage/registered-blob-contract + * @description Pass 2 (ADR-004 §7): declared derived-index blob FAMILIES are + * undeletable through the storage layer — an in-process GC/sweeper cannot remove + * a load-bearing index file (the lost-main.dkann class). Covers declare → + * protected-delete-throws; unregister → delete-ok; transient passthrough; + * namespace prefix protects children; removeRawPrefix refuses a protected + * intersection; persistence across reopen; missing-on-open detection. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' +import { FileSystemStorage } from '../../../src/storage/adapters/fileSystemStorage.js' +import { ProtectedArtifactError } from '../../../src/index.js' + +describe('registered-blob family contract (Pass 2, ADR-004 §7)', () => { + let storage: any + + beforeEach(async () => { + storage = new MemoryStorage() + await storage.init() + }) + + const seedVectorFamily = async () => { + await storage.saveBinaryBlob('_system/vector-index/main.dkann', Buffer.from([1])) + await storage.saveBinaryBlob('_system/vector-index/main.slotmap', Buffer.from([2])) + await storage.saveBinaryBlob('_system/vector-index/main.slotrev', Buffer.from([3])) + await storage.registerDerivedFamily({ + name: 'vector-base', + members: [ + '_system/vector-index/main.dkann', + '_system/vector-index/main.slotmap', + '_system/vector-index/main.slotrev' + ] + }) + } + + it('a declared member is undeletable (throws ProtectedArtifactError)', async () => { + await seedVectorFamily() + await expect(storage.deleteBinaryBlob('_system/vector-index/main.dkann')).rejects.toBeInstanceOf( + ProtectedArtifactError + ) + // The blob is still there. + expect(await storage.loadBinaryBlob('_system/vector-index/main.dkann')).not.toBeNull() + }) + + it('unregistering the family makes its members deletable again', async () => { + await seedVectorFamily() + await storage.unregisterDerivedFamily('vector-base') + await expect(storage.deleteBinaryBlob('_system/vector-index/main.dkann')).resolves.toBeUndefined() + expect(await storage.loadBinaryBlob('_system/vector-index/main.dkann')).toBeNull() + }) + + it('an undeclared blob is still deletable (contract does not lock everything)', async () => { + await seedVectorFamily() + await storage.saveBinaryBlob('graph-lsm/source/sstable-1', Buffer.from([9])) + await expect(storage.deleteBinaryBlob('graph-lsm/source/sstable-1')).resolves.toBeUndefined() + }) + + it('a transient (*.tmp.*) is deletable even if it sits under a protected namespace', async () => { + await storage.registerDerivedFamily({ + name: 'vector-ns', + members: ['_system/vector-index/'], + namespace: true + }) + await storage.saveBinaryBlob('_system/vector-index/main.dkann.tmp.123', Buffer.from([1])) + await expect( + storage.deleteBinaryBlob('_system/vector-index/main.dkann.tmp.123') + ).resolves.toBeUndefined() + }) + + it('a namespace family protects a growing child key', async () => { + await storage.registerDerivedFamily({ + name: 'vector-segments', + members: ['_system/vector-index/'], + namespace: true + }) + await storage.saveBinaryBlob('_system/vector-index/seg-42', Buffer.from([7])) + await expect(storage.deleteBinaryBlob('_system/vector-index/seg-42')).rejects.toBeInstanceOf( + ProtectedArtifactError + ) + }) + + it('checkDerivedFamiliesPresent() names a member deleted outside the write path', async () => { + await seedVectorFamily() + // Simulate an EXTERNAL deleter (bypasses the in-process refusal): drop a + // member straight from the underlying store. + ;(storage as any).blobStore.delete('_system/vector-index/main.slotmap') + const incomplete = await storage.checkDerivedFamiliesPresent() + expect(incomplete).toHaveLength(1) + expect(incomplete[0].name).toBe('vector-base') + expect(incomplete[0].missing).toContain('_system/vector-index/main.slotmap') + expect(incomplete[0].rebuildable).toBe(true) + }) + + it('no families registered → deleteBinaryBlob behaves exactly as before (no enforcement)', async () => { + await storage.saveBinaryBlob('some/blob', Buffer.from([1])) + await expect(storage.deleteBinaryBlob('some/blob')).resolves.toBeUndefined() + }) +}) + +describe('registered-blob family protection survives a reopen (FileSystemStorage)', () => { + let dir: string + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-regblob-')) + }) + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })) + + it('a family declared in one session protects members after reopen', async () => { + const s1: any = new FileSystemStorage(dir) + await s1.init() + await s1.saveBinaryBlob('_system/vector-index/main.dkann', Buffer.from([1])) + await s1.registerDerivedFamily({ name: 'vector-base', members: ['_system/vector-index/main.dkann'] }) + + // Reopen — the registry is loaded from _system/derived-artifacts.json. + const s2: any = new FileSystemStorage(dir) + await s2.init() + expect(await s2.listDerivedFamilies()).toHaveLength(1) + await expect(s2.deleteBinaryBlob('_system/vector-index/main.dkann')).rejects.toBeInstanceOf( + ProtectedArtifactError + ) + // removeRawPrefix nuking the vector dir is also refused. + await expect(s2.removeRawPrefix('_blobs/_system/vector-index')).rejects.toBeInstanceOf( + ProtectedArtifactError + ) + }) +})