feat: registered-blob family contract — declared index blobs are undeletable (ADR-004 Pass 2)
The class-killer for the lost-main.dkann incident (ADR-004 §7). A provider can declare a derived-index blob FAMILY (a set of members that are load-bearing together, e.g. vector-base = main.dkann + main.slotmap + main.slotrev). Once declared: - deleteBinaryBlob / removeRawPrefix REFUSE to remove a declared member, throwing the new ProtectedArtifactError — an in-process GC / sweeper is now INCAPABLE of deleting a load-bearing index file (COLD != DEAD). Intentional retirement is an explicit unregisterDerivedFamily(name) first. - The declaration persists to _system/derived-artifacts.json, so protection survives a reopen; clear() resets it with the rest of the derived footprint. - checkDerivedFamiliesPresent() names any member missing on open (the catch for an EXTERNAL deleter that bypasses the in-process refusal) → rebuild from canonical. - Transients (*.tmp.*, *.rebuild-tmp, *.rotate-tmp) are never protected; a namespace family protects a growing prefix (seg-*). New StorageAdapter surface (optional): registerDerivedFamily / unregisterDerivedFamily / listDerivedFamilies + DerivedFamilyDeclaration; new exported errors ProtectedArtifactError / DerivedArtifactMissingError. Enforcement is inert until a provider declares a family (no regression). Cor declares its 6 families + does the atomic set-swap in 3.0.15 (M2); brainy builds the contract now. 8 tests.
This commit is contained in:
parent
6bcb54f0d9
commit
bfa1762107
7 changed files with 429 additions and 5 deletions
130
tests/unit/storage/registered-blob-contract.test.ts
Normal file
130
tests/unit/storage/registered-blob-contract.test.ts
Normal file
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue