Commit graph

1003 commits

Author SHA1 Message Date
d60619c83b chore(release): 8.5.0 2026-07-15 12:49:09 -07:00
4dc0a928fe test: tolerant timing assertion in the execution-time measure test
setTimeout can fire a few ms early under load (timer coalescing) — a
10ms sleep measured 9ms and failed the >=10 assertion, tripping a
release gate. Sleep 25ms, assert >=20: the test verifies time is
MEASURED, not the OS timer's precision.
2026-07-15 12:46:33 -07:00
d1ecee10f0 feat: committedGeneration capability + pinned durability/stability contracts
- storage.committedGeneration(): the committed watermark exposed on the
  fact-scan capability, so a provider compares its stamp's
  sourceGeneration against the store's truth without parsing the
  private manifest format (the capability source now carries both the
  live fact log and the committed closure).
- Pinned contract tests: fsync-before-ack (transact passes today; the
  single-op path is pinned via it.fails as the documented target —
  group commit must become LATENCY batching, ack waiting on the shared
  fsync, never durability skipping; the pin flips red when that lands
  so there is no cliff to discover) and scan-stability-under-rotation
  (a scan snapshot yields exactly its facts — no gaps, duplicates, or
  bleed-in — while segments rotate beneath it; the reclaim-during-scan
  variant lands with fact compaction, which does not exist yet).
- FactLog accepts a rotateBytes option so tests exercise real rotation.
2026-07-15 12:44:52 -07:00
e4f37cd32b docs: RELEASES.md entry for 8.5.0 (provider fact-log access + shared verifier) 2026-07-15 12:37:17 -07:00
352e356fd5 feat: provider access to the fact log + shared stamp verifier via internals
Three additive surfaces for native index providers, which hold only
`storage` and must never construct their own fact-log reader (the log's
open path is writer-side — it reconciles by truncating/rewriting):

- Fact-scan capability on the storage adapter (the getBinaryBlobPath
  pattern): the host brain wires a closure over its LIVE fact log at
  init; providers call storage.scanFacts()/factLogHeadGeneration()/
  factSegmentPaths() and fall back to the enumeration walk on null.
  Restore/reopen swaps the underlying log transparently.
- The family-stamp trio (readFamilyStamp/writeFamilyStamp/
  verifyFamilyStamp + types) exported from @soulcraft/brainy/internals,
  so 'one verifier' is literally one function shared with the native
  side, never two synchronized copies.
- Rollup invariants widened to number|string: content fingerprints
  (e.g. a per-tree SHA-256) are valid invariant values; strict equality
  either way, and a type mismatch reads as incoherence, never a pass.
2026-07-15 12:36:27 -07:00
70886da548 chore(release): 8.4.0 2026-07-15 11:24:48 -07:00
4a60b43983 docs: RELEASES.md entry for 8.4.0 (generation fact log + family stamp) 2026-07-15 11:18:27 -07:00
2888ae6b40 feat: entity-tree family stamp — sourceGeneration + rollup coherence at open
The canonical entity tree now carries a FAMILY STAMP
(_system/family-stamps/entity-tree.json, JSON — forensics stay
terminal-readable): which committed generation the tree reflects
(sourceGeneration) plus the rollup invariants (entity/relationship
counts) that verify a millions-of-files projection whole where per-file
checks cannot scale. Written at flush/close boundaries; open-time
coherence is a COMPARISON, not a walk:

- coherent / absent (legacy store) → silent
- behind → benign for the tree (it is written BY the commit; only the
  stamp is stale after a crash between commit and flush) — refreshes at
  the next flush
- incoherent (counts diverged at equal generation, or a stamp AHEAD of
  the log head) → loud, names the failing invariant; repairIndex()'s
  unconditional recount heals and RE-STAMPS so repair leaves a coherent
  stamp behind
- a fault reading the stamp is UNVERIFIABLE — never conflated with
  absence

One verifier (verifyFamilyStamp, exported) reads both member modes:
enumerated (exact byte size per member, bounded families) and rollup
(invariants, unbounded families). New exports: readFamilyStamp,
verifyFamilyStamp, ENTITY_TREE_STAMP_PATH, FamilyStamp, StampMembers,
StampVerdict.
2026-07-15 10:54:20 -07:00
38b0041464 feat: generation fact log — after-image commit records, dual-written at every commit point
Every committed generation now also appends a FACT — an after-image
commit record (what each touched entity/relationship became, or a
body-less tombstone for a removal) — to an append-only, crc32c-framed
segment log under _generations/facts/. The before-image history and the
canonical tree remain authoritative; the fact log gives consumers ONE
sequential, self-verifying stream (index heals, incremental replays)
in place of a per-entity directory walk.

- Wire format: positional msgpack facts [generation, timestamp, ops,
  meta, blobHashes]; op = [kind u8, id bin16, record | nil tombstone];
  32-byte segment header (magic, formatVersion, firstGeneration,
  zeroed+verified reserved); length+crc32c frame per fact; zero-padded
  segment names so lexicographic order == generation order; JSON
  manifest with an atomic rename flip, manifest-first rotation.
- Commit protocol: facts append+fsync BEFORE the commit point inside
  the existing durability window, so a crash can only leave the log
  AHEAD of committed truth — open() truncates back (torn tails detected
  by CRC). Absent generation = never committed; a scan can never see an
  uncommitted fact. transact() facts are durable-on-return; single-op
  facts ride the group-commit flush exactly like buffered history. A
  fact-append failure fails the write, loudly — a silent gap would be a
  lie a later replay discovers.
- New public surface: brain.scanFacts() (sequential batches with heal
  telemetry: head/segments/approx up front, per-batch generation range
  + bytes + segment id, loud abort on gaps, summary cross-check) and
  brain.factSegmentPaths() (immutable sealed segments for zero-copy
  consumers; the mutable tail excluded). Exported types CommitFact,
  FactOp, FactScanBatch, FactScanHandle.
- Storage: optional binary raw-byte primitives (appendRawBytes,
  readRawBytes, writeRawBytes, rawByteSize) on StorageAdapter —
  feature-detected; filesystem + memory adapters implement them; an
  adapter without them hosts no fact log. Fact segments are byte-copied
  (never hard-linked) into snapshots. The _generations/facts/ namespace
  is registered as a protected family (rebuildable: false): no sweeper
  or GC may delete under it.
- New crc32c (Castagnoli) utility with RFC known-answer tests.
2026-07-15 10:49:02 -07:00
92299f27be chore(release): 8.3.3 2026-07-15 09:48:01 -07:00
c3feafdc47 docs: RELEASES.md entry for 8.3.3 (rename containment fix + repair) 2026-07-15 09:42:29 -07:00
4fb41f9a7c test: lens-consistency regression — combined vs subtype-only vs canonical ground truth
Ports the fresh-brain probe that closed the type+subtype lens-drop
investigation into the permanent suite: a 7-pair corpus seeded through the
real write API (never restored bytes), every lens checked id-for-id against
an unfiltered canonical scan, warm AND after a cold reopen, plus the
type+subtype update-flip leg. Guards the under-inclusion class the egress
integrity guard structurally cannot catch.
2026-07-15 09:25:36 -07:00
af8c1795bd fix: VFS rename moves the containment edge — no ghost in the old directory
A cross-directory rename added the new parent's Contains edge but skipped
removing the old one (a 'not critical' shortcut), leaving the moved entity
a child of BOTH directories: readdir(oldDir) kept listing it, re-creating
the old path showed the name twice, and tree-walking consumers saw the
file in two places. Reported live by a production deployment (37 stale
containment ghosts; blocks tree-sync integrations).

- rename() now removes the old parent's vfs containment edge(s) by edge
  id resolved from the graph's own adjacency (the removal law: removal
  never requires reading the removed thing), and a move to the root gets
  its containment edge (previously skipped -> orphaned from readdir('/')).
- New vfs.repairContainment(): reconciles every VFS entity's containment
  edges against canonical metadata.path — removes stale/duplicate vfs
  edges, restores missing ones, never touches user knowledge edges. Wired
  into repairIndex() so the one operator ritual heals existing ghosts.
- Regression: tests/integration/vfs-rename-containment.test.ts (7).
2026-07-15 09:25:21 -07:00
1a3a493c27 chore(release): 8.3.2 2026-07-14 11:57:00 -07:00
0932ecde37 docs: RELEASES.md entry for 8.3.2 (honest counters) 2026-07-14 11:51:51 -07:00
2e2ba9c9aa fix: honest counters — removal never re-reads the removed record + repairIndex recounts and persists all rollups
Persisted entity totals inflated permanently: a delete's count decrement
was sourced from re-reading the record being removed, so a null read
(replace race, or a ghost left by a pre-8.3.1 partial delete) silently
skipped the decrement while the paired add had counted — and
Math.max(persistedTotal, scanned) meant no disk cleanup could ever
lower the reported total. Reported by a production deployment with a
write→delete→re-create repro minting drift within hours.

- The caller's pre-delete read now rides through the entire delete path
  (remove()/removeMany()/plan → DeleteNoun/DeleteVerb operations →
  deleteNoun/deleteVerb → deleteNounMetadata/deleteVerbMetadata): a
  null internal read falls back to the known prior record instead of
  skipping the decrement. StorageAdapter.deleteNoun/deleteVerb gain an
  optional priorMetadata parameter (additive).
- rebuildTypeCounts() rebuilt only the type-statistics arrays and
  computed the total just to LOG it — the persisted scalar
  (counts.json) survived every rebuild. One canonical walk now rebuilds
  every counter rollup (scalar totals + per-type maps + type
  statistics) and persists them; repairIndex() runs the recount
  unconditionally, since counters can be inflated over clean shelves.
2026-07-14 11:51:44 -07:00
d9fa3be648 chore(release): 8.3.1 2026-07-14 10:14:38 -07:00
c0c68ac6a6 docs: RELEASES.md entry for 8.3.1 (full-removal deletes + family-scoped gate) 2026-07-14 10:12:00 -07:00
366f9a91f5 fix: full-removal canonical deletes + family-scoped migration gate
Two production-reported spine fixes:

- Canonical delete is a FULL removal: remove()/removeMany() deleted the
  metadata leg but never the canonical vectors.json leg or the <id>/
  directory, leaving ghost rows that inflated enumerated counts forever,
  read as damage scars, and caused duplicate readdir entries for
  re-created VFS paths (the stale-unpost path). The delete operation now
  routes through storage.deleteNoun — both legs + the entity container —
  with a full two-leg before-image rollback; deleteVerb symmetric; the
  blind 'no metadata file' catch that masked real faults is gone. The
  generation log still holds the delete's before-image, so asOf() history
  is unchanged. repairIndex() gains a conservative, loud orphan-prune
  sweep (containers with no metadata content leg) + count recompute for
  stores damaged by earlier versions.

- Family-scoped migration gate: every read blocked on the whole-brain
  migration lock even when the migrating index was irrelevant. The gate
  now scopes to the index families a read actually consults — canonical
  reads never wait; find() waits only on its query shape's families;
  graph traversals wait only on the graph family. Writes keep the
  conservative whole-brain wait. A read that needs the migrating family
  still blocks (bounded) with the retryable MigrationInProgressError.
2026-07-14 10:11:53 -07:00
1d26988963 docs: cite the cross-layer integrity contract generically in comments and notes 2026-07-14 10:11:41 -07:00
c40a89e649 chore(release): 8.3.0 2026-07-13 15:21:52 -07:00
7692c6f4ef docs: RELEASES.md entry for 8.3.0 (heal-cost + ADR-004 Pass 2/3)
Consumer summary of the 8.3.0 minor: 16-way parallel canonical enumeration +
id-only getNounIdsWithPagination (index-heal speedup, standalone); the ADR-004
cross-layer integrity contract (validateInvariants delegation + repairIndex
native rebuild); and the registered-blob family contract (declared index blobs
undeletable). The two contract pieces are inert until a native provider
implements the matching hooks.
2026-07-13 15:18:09 -07:00
ec5b93339a perf: parallel + id-only canonical enumeration (heal-cost dominant term)
The canonical enumeration walk (getNounsWithPagination) hydrated each entity's
vector + metadata ONE-AT-A-TIME inside the shard loop — every enumeration paid
N x per-op-latency serially, and every index heal enumerates canonical, so this
was the dominant multiplier in the measured multi-minute heals (cortex heal-cost
decomposition).

- Hydration is now 16-way bounded-concurrency (matching the wave cor's native
  rebuild uses): heal wall-clock becomes ~2xN/16 x per-op instead of N x per-op.
  Order, cursor resume (skipped nouns still never read), filters, peek/hasMore
  and totalCount are all preserved — pages are byte-identical to the serial walk.
- New getNounIdsWithPagination(): the id-only opt-out for callers that own their
  IO schedule (an index heal). Unfiltered = ids straight from the shard paths,
  ZERO per-entity reads; filtered hydrates metadata only (16-way). Same
  cursor/offset/nextCursor contract, so it is page-compatible with the hydrating
  walk.

Regression: pagination-parallel-hydration.test.ts pins paged==big-page identity,
ids==items order, zero-read id-only, and filter parity. 103 storage tests green.
2026-07-13 15:15:01 -07:00
bfa1762107 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.
2026-07-13 14:52:06 -07:00
6bcb54f0d9 feat: validateIndexConsistency delegates to provider invariants (ADR-004 Pass 3)
validateIndexConsistency() was blind to native providers — it only ran the JS
metadata index's own check, so a native provider whose manifest/segments/counts
had diverged still read as "healthy". Per ADR-004 §6 it now feature-detects and
aggregates each provider's optional validateInvariants() (a never-throwing,
<50ms self-report of its own cross-layer invariants), names any failing
invariant with its numbers in the recommendation, and exposes the per-provider
reports. A provider that violates the never-throw contract is surfaced as
unhealthy, never swallowed.

repairIndex() now reconciles NATIVE derived state from canonical too: it consults
each provider's invariants and calls rebuild() on any whose failing invariant
asks for heal:'rebuild' — the native counterpart of detectAndRepairCorruption().

New provider surface: optional validateInvariants(); new exported types
ProviderInvariantReport / InvariantResult / InvariantHeal. Additive, no break.
Cor implements the hook in 3.0.15 (M2); brainy builds against the shape now
(feature-detected, inert until a provider exposes it). 5 tests.
2026-07-13 14:43:29 -07:00
7b75f932d4 chore(release): 8.2.8 2026-07-13 13:26:41 -07:00
d0f69c731f fix: honest index readiness — no silently-empty queries on a cold index
Closes the "dishonest readiness proxy" anti-pattern (Pattern A): size()>0 /
isInitialized were treated as "this index serves queries", but a cold native
index can load its COUNT before its SERVING structure, so a query returned a
silent [] indistinguishable from "no such data". A shared assessIndexReadiness()
now reads only the provider's honest isReady() signal (never size()), applied at
every site:

- Vector: a one-shot verifyVectorLive() guard on the semantic/proximity search
  path (a pure semantic find({query}) has no filter, so nothing guarded it). It
  prefers isReady(), else a known-vector self-match probe; self-heals via rebuild
  or throws the new VectorIndexNotReadyError instead of a silent [].
- Graph: getVerbsBySource/ByTarget skip the fast path when the provider reports
  not-ready (falling to the canonical shard scan), plus a one-shot probe that
  self-heals a no-isReady provider whose adjacency did not cold-load.
- getIndexStatus(): folds in per-index honest `ready` (making `populated`
  honest) + rebuildFailed/rebuildError/degradedIds, so a readiness probe never
  200s a brain that is still warming up or degraded.

Unblocked by the native providers now reporting serving-truth (graph via
SSTable-residency readiness, vector via durableBaseLoadFailed). New export:
VectorIndexNotReadyError. getIndexStatus gains additive fields. No breaking API.
13 new tests; existing readiness guards green.
2026-07-13 13:17:56 -07:00
36d4e80ba2 chore(release): 8.2.7 2026-07-13 12:18:26 -07:00
b6c7039769 fix: restore loadBinaryBlob fault-propagation (native column-store lockstep)
The Pass-1 hardening that makes loadBinaryBlob distinguish genuine absence
(ENOENT -> null) from a real fault (EIO/EACCES/... -> throw) was held out of
8.2.6 because the native accelerator's two column-store read sites still relied
on null-on-error. That accelerator release has now hardened those sites to
handle the throw (a faulted segment read marks the field unavailable and throws
a named error), so the fault-propagating form is restored and ships lockstep.

A present-but-unreadable index blob no longer masquerades as absent (which drove
needless rebuilds / empty reads). Adds the loadBinaryBlob leg to the blob
durability suite (absent -> null; present -> bytes; real fault -> throws).
2026-07-13 12:09:55 -07:00
76843b782e chore(release): 8.2.6 2026-07-13 10:59:55 -07:00
a873852e61 docs: RELEASES.md entry for 8.2.6 (write/index-spine hardening)
Consumer-facing summary of the Pass-1 durability + integrity release: durable
blob-write honesty, single-op history durability (PendingFlushDurabilityError),
degraded-index surfacing, full-footprint clear(), count symmetry, and loud
index-maintenance / aggregation failures. loadBinaryBlob fault-propagation is
held for the native lockstep and deliberately omitted here.
2026-07-13 10:45:39 -07:00
36c10c1e20 chore: hold loadBinaryBlob fault-propagation for the cortex column-store lockstep
The Pass-1 hardening made loadBinaryBlob distinguish genuine absence (ENOENT →
null) from a real fault (EIO/EACCES/… → throw), so a present-but-unreadable blob
stops masquerading as "absent". The native column-store still has two call sites
that rely on the old null-on-error contract; publishing the throw ahead of them
would break a consumer running new brainy + old cortex.

Per the cortex coordination (accept: cor first, then brainy), this temporarily
restores the shipped 8.2.5 swallow-on-fault behavior for loadBinaryBlob ONLY, so
the rest of Pass 1 — saveBinaryBlob write-honesty, clear() native wipe,
pending-flush durability, count symmetry, degraded surfacing, aggregation
loudness — can release now. The method carries an inline restore guide; the
throwing form goes back in and ships lockstep with the native hardening. No other
Pass-1 change depends on this behavior (ColumnStore's own fault test uses a
direct-throwing storage stub), so the split is behaviour-neutral for every
consumer versus 8.2.5.
2026-07-13 10:44:11 -07:00
02eff64b78 fix: aggregation surfaces materialize/state-load failures loudly
The debounced materialization caught its failure with an empty `catch (() => {})`,
silently leaving the materialized Measurement entity stale; the aggregation-index
init() state-load failure was swallowed the same way, leaving aggregates reading
empty with no signal. Both are non-fatal (values rebuild via backfill-on-query),
but a silent stale/empty read violates loud-errors-never-quiet-losses. Both now
emit a loud warning naming the affected group / cause.
2026-07-13 09:49:38 -07:00
ba958d97b5 fix: surface a degraded derived index on reads instead of serving it silently
Two known-degraded states were recorded but never consulted by the read paths, so
a partial result looked authoritative:

- commitSingleOp returns `degraded` ids on an adopt-forward failed-rollback
  recovery (the canonical record is durable but its derived-index entry may be
  incomplete). persistSingleOp dropped that list on the floor — no health flag,
  no read signal. It now records them in a queryable degraded set.
- A non-fatal index-rebuild failure at init (_indexRebuildFailed) was folded into
  checkHealth()/validateIndexConsistency() but no read consulted it.

find() and get() now emit ONE loud warning per degraded window (reads still
return — canonical is the source of truth — but the caller is told results may be
partial and to run repairIndex()). Both degraded sources fold into the two health
surfaces, and repairIndex() reconciles from canonical and clears them.
2026-07-13 09:49:38 -07:00
7feba49d94 fix: saveBinaryBlob never acks a durable write that stored nothing
With the unique per-writer temp suffix, a rename ENOENT can no longer mean "a
concurrent idempotent writer already renamed it" — nobody else holds this
writer's temp. It means our just-written temp vanished before the rename, so the
bytes did NOT land. The old code returned success on that ENOENT, acknowledging
a write that persisted nothing; the native provider mmaps these blobs, so a
phantom-acked blob is a silent-loss.

saveBinaryBlob now retries once with a fresh temp (self-healing a transient
external-sweeper/crash-cleanup race), and if the temp vanishes again it throws
loud rather than acknowledging a store-nothing write. Non-ENOENT rename faults
propagate verbatim as before. The unique-temp suffix already eliminated the
concurrent same-key collision that originally motivated the ENOENT shortcut.
2026-07-13 09:35:30 -07:00
54c183668c fix: refuse writes when single-op history cannot be made durable
The async group-commit flush that persists single-op generation history
swallowed persist failures as a bare warn: writes kept succeeding while their
before-images piled up in memory, never durable and unbounded, with no signal.

The generation store now accounts for every failed flush at one place
(flushPendingSingleOps), tolerates a transient blip (retry with capped
exponential backoff), and after PENDING_FLUSH_FAILURE_THRESHOLD consecutive
failures LATCHES a durability failure and refuses further single-op and transact
writes with a typed, exported PendingFlushDurabilityError — rather than promise a
durability it cannot deliver. Live canonical data is untouched; only the
immutable history is stuck. The latch self-heals: the moment a flush succeeds
(a retry, or an explicit flush()/close()) it lifts and writes resume.

Loud errors, never quiet losses.
2026-07-13 09:31:25 -07:00
d8301f8d08 fix: clear() wipes the full native/derived footprint, not a subset
clear() removed entities, indexes, system and _cas but left three top-level
trees on disk: _blobs (raw HNSW/LSM segment bytes and the native dkann index),
_id_mapper (the native shared mmap id-mapper), and _column_index (column-store
manifests). A cleared brain therefore re-read stale native state.

Worse, the column store splits its state across two of those trees — manifests
under _column_index/ and their segment bytes under _blobs/_column_index/ — so
removing one without the other stranded a manifest listing segments that no
longer exist, which the hardened segment-load path now (correctly) refuses with
ColumnSegmentLoadError. The three trees now fall together as a set, exactly as
_cas already does. locks/ is deliberately preserved: it is live coordination
state, not data.
2026-07-13 09:18:36 -07:00
af5d2f389b fix: surface segment/entity read faults loudly instead of masking as absent
ColumnStore silently skipped a manifest-listed segment it could not load: a
corrupt or missing segment dropped every one of its entities out of
filter/rangeQuery/sortTopK with no error, so an inconsistent index read as a
merely short result. It now throws a typed ColumnSegmentLoadError when a listed
segment yields undecodable or no bytes, and lets a genuine storage IO fault
propagate verbatim. Only a field with no manifest at all stays benign (nothing
was ever written for it).

baseStorage.getNoun_internal / getVerb_internal likewise caught every error and
returned null, reporting a present-but-unreadable entity as "not found". They
now return null only for genuine ENOENT-class absence (via isAbsentError) and
rethrow real faults and deserialize errors.

Pattern-B (blind catch) hardening: absence -> null, fault -> loud.
2026-07-13 09:14:52 -07:00
119087a75c fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.

- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
  scalar total symmetrically — deleteNounMetadata was decrementing only the
  per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
  getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
  pagination via Math.max and is persisted). Invariant now holds:
  scalar total === Σ per-type across add/update/delete and reopen.

- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
  longer publishes the manifest's full relationship count as healthy. Any
  per-SSTable load failure throws after the batch, which resets to honest-empty
  and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
  can no longer lie about a partial load.

- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
  dirty nodes whose connections failed to persist — failed nodes stay in the
  retry set, and flush() throws HnswFlushError instead of returning a lying
  node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
  addItem() rejects rather than returning an id for a rootless index.

- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
  helper (utils/errorClassification, ENOENT-only absence) applied to
  loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
  now propagates loudly instead of masquerading as "absent", which had driven
  needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).

Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
eb9c4eb963 test: pin the read-your-writes contract under the single writer
A returned write must be immediately readable by id, metadata filter, vector
search, and graph traversal — no await, delay, or retry between the write
returning and the read. This holds by construction because every projection
(canonical + HNSW + metadata index + graph index) commits inside the write's
transaction; the generation counter is the {seq} a caller can pin. The test
locks that guarantee so index maintenance can never quietly move off the commit
path (which would turn a returned write into a not-yet-queryable one).

First brainy chapter of the write/index-spine hardening program.
2026-07-12 18:24:31 -07:00
ffd81ea206 chore(release): 8.2.5 2026-07-12 12:27:01 -07:00
a7c7aa5102 docs: RELEASES.md entry for 8.2.5 (honest rollback-failure response) 2026-07-12 12:19:09 -07:00
711d2f046a fix: honest response when a transaction rollback cannot complete
When a transaction failed and its rollback ALSO failed to undo a canonical
write (retries exhausted), the old path logged, continued, threw
TransactionRollbackError, and set state='rolled_back' — while the record it
could not undo stayed durable on disk. The caller got an error implying the
write was undone; a read-back showed the record. A failed rollback had no
truthful response (the post-commit response lie).

Give a failed rollback a two-branch honest contract, decided by observation:
both commit paths already hold byte-identical before-images, so after a failed
rollback the store compares current canonical state to them and classifies each
touched record as reconciled, an additive orphan (present when it should be
gone), or a restorative loss (gone/wrong when it should have been restored).

- Adopt-forward: a single-op write whose only damage is a durably-present
  orphan — the record the caller asked for — is committed forward (its
  generation buffered) and returns success with a loud warning that the derived
  index may be incomplete for that id until the next rebuild/repairIndex(). No
  error, no double-write; the record is durable and get-able immediately.
- Fail loud + quarantine: a multi-op batch, or ANY restorative loss, throws the
  new StoreInconsistentError naming every unreconciled record and its
  disposition, and puts the brain into write-quarantine (reads keep working,
  writes refused via assertWritable) until repairIndex() reconciles the derived
  indexes against canonical and lifts it. The counter is not advanced, and
  Transaction.rollback now reports state 'inconsistent' instead of the
  'rolled_back' lie when any undo failed.

New: StoreInconsistentError + UnreconciledRecord exported from the root;
repairIndex() forces a rebuild and clears the quarantine. Regression
tests/integration/rollback-trapdoor.test.ts injects index-add-throws +
canonical-delete-undo-fails and pins adopt-forward (durable, get-able, not
quarantined), fail-loud (StoreInconsistentError + quarantine + reads work +
repairIndex lifts it), and the error's record naming.
2026-07-12 12:19:09 -07:00
036e56c9f9 chore(release): 8.2.4 2026-07-12 09:21:02 -07:00
457469593a docs: RELEASES.md entry for 8.2.4 (non-destructive restore) 2026-07-12 09:10:35 -07:00
a2f4f6a550 fix: non-destructive, crash-resumable restore
restore() removed the entire live brain directory and then fs.cp'd the
snapshot in, so any copy failure left the store destroyed with only a partial
copy. The sharpest edge: fs.cp materialized the holes of sparse mmap blob
files, so a snapshot that fits on disk could balloon and ENOSPC mid-copy — the
recovery tool destroying the brain it was asked to recover.

Rewrite restoreFromDirectory as stage → verify → atomic swap:
- Copy the snapshot into a _restore_staging area BEFORE touching live data,
  sparse-aware (copyTreeSparse/copyFileSparse skip all-zero 4 MiB chunks, so a
  mostly-hole store restores at its true allocated size, not its apparent size).
- On any copy failure (ENOSPC included) remove only the half-written staging
  area and throw — the live store is left exactly as it was.
- Only after the copy succeeds, fsync a completion marker naming the staged
  entries, then swapStagedRestoreIn(): an idempotent per-entry
  rm-old + rename-staged-in (same-filesystem renames that cannot ENOSPC),
  removing stale live entries the snapshot lacks.
- completeInterruptedRestore(), wired into init() right after the root dir is
  ensured, finishes a crash mid-swap FORWARD (resume a committed swap) or
  discards an uncommitted staging area (live still authoritative).

_restore_staging is excluded from snapshots. persist() (hard-link snapshot)
was already safe and is unchanged; no public API change.

Regression (tests/integration/restore-nondestructive.test.ts): a forced copy
failure leaves live data fully intact and cleans staging; a normal restore
round-trips to the snapshot; an interrupted-but-committed restore completes on
reopen; an uncommitted staging area is discarded on open; the sparse copy is
byte-identical with allocated blocks far below apparent size.
2026-07-12 09:10:35 -07:00
b3e8d47d46 chore(release): 8.2.3 2026-07-12 08:59:28 -07:00
be5ce0bcc3 docs: RELEASES.md entry for 8.2.3 (transact durability barrier) 2026-07-12 08:54:14 -07:00
3b8fa51161 fix: transact durability barrier — committed transactions are durable on return
A committed transact() reported success while its canonical entity writes were
still only in the OS page cache (tmp+rename, not fsync'd), even though the
generation counter and manifest were fsync'd. A hard kill in that window could
leave the durable counter ahead of the persisted entity bytes — phantom
progress for any generation-based consumer resuming from the counter. Reported
from a downstream migration's crash-lifecycle forensics.

Add an optional transaction durability barrier to GenerationStorage
(beginWriteBarrier / flushWriteBarrier). FileSystemStorage implements it:
writeObjectToPath records each successful canonical write and
deleteObjectFromPath records each delete's parent dir, between begin and flush;
flush fsyncs every recorded write (contents + rename dir entries, via
syncRawObjects) and the parent dir of every delete. commitTransaction opens the
barrier before running the planned operations and flushes it after, BEFORE
persisting the counter and manifest — so the batch's entire canonical footprint
is durable before the generation stamp advances. The barrier is optional: the
generation store calls it through optional chaining, so in-memory and
durable-per-call (cloud object-PUT) adapters treat it as a no-op.

The single-op group-commit path is unchanged (deferred durability is the
Model-B design that avoids a 3-5x per-write fsync regression), but its
durability contract is now documented explicitly on commitSingleOp: transact =
durable on return; single-op = durable at the next flush()/close(), with the
counter buffered alongside the data so a crash loses both together (never a
torn counter-ahead-of-state store).

Regression (tests/integration/transact-durability-barrier.test.ts): the entity
writes fsync in an earlier syncRawObjects batch than the manifest for single-op
and multi-op (add+relate) transactions; a precommit-rejected batch opens no
barrier and advances nothing; MemoryStorage exposes no barrier (optional-chain
no-op).
2026-07-12 08:54:14 -07:00
a9fc1f3f9b chore(release): 8.2.2 2026-07-11 13:48:47 -07:00