Commit graph

1029 commits

Author SHA1 Message Date
1201e25543 feat: two-tier history reads + the repacker + generationDigest — D1+D3 wired end-to-end
The packed tier goes live inside GenerationStore:

- Two-tier reads: getDelta / readBeforeImage / readGenerationRecords
  fall through live-tier → sealed segments (live-tier-wins: a crash
  mid-fold leaves a duplicate representation, never a gap). Cold-open
  seeds committedRanges from the segment manifest via interval merge —
  packed generations resolve without their directories existing.
- repackHistory({timeBudgetMs, batchGenerations}): folds cold
  generations (older than the newest 1024) oldest-first into sealed
  segments, deleting per-generation directories only after segment +
  manifest are durable. Public API + automatic time-bounded pass at
  close() (before compaction, so reclaim can drop whole segments);
  re-representation only — the sole history transform under the
  archival profile. Early stop = consistent prefix, next pass resumes.
- compact(): packed generations reclaim logically in the loop and
  physically at whole-segment boundaries via dropSegmentsBelow (the
  frozen partial-segments-wait rule).
- generationDigest(g) (D8): deterministic content digest through g —
  sealed-segment checksum chain + live-tier delta hashes; O(segments +
  live window); RangeError out of range, GenerationCompactedError
  below the horizon (a gate can never silently pin reclaimed history).

Four end-to-end pins: asOf answers byte-identical across fold + cold
reopen with folded dirs physically gone; repack+reclaim composition;
digest reopen-stability/divergence/loud-horizon; budget no-op+resume.
2026-07-19 16:26:10 -07:00
d8acb3776b feat: generation-segment store — the D1+D3 packed-tier file format
First stage of the co-frozen D1+D3+repacking unit: the format core,
self-contained under _generations/segments/.

- seg-<firstGen,20pad>.bgs: append-once packs of consecutive
  generations (magic BGS1; frame = u32 len + u32 crc32c + msgpack
  [generation, timestamp, delta, records, flags]; flags reserves
  compressed-payload evolution without a format break). Sealed
  segments are immutable — fold refuses overlap with sealed ranges.
- seg-<firstGen>.idx: DERIVED sidecar (per-generation frame offsets +
  per-id generation postings + checksums); lost/corrupt sidecars
  rebuild from their segment loudly; a damaged segment (frame CRC
  mismatch) fails loudly, never serves wrong bytes.
- manifest.json: the one discovery path — open() reads it and never
  lists the packed backlog (the scan-wedge class's cure); refuses a
  newer manifest version rather than serving partial history.
- D3 semantics: dropSegmentsBelow reclaims WHOLE segments at
  boundaries only and bumps compactedBelow durably; archival-profile
  enforcement stays with the caller per the co-freeze.
- D8 rider: digestThroughPacked(g) — deterministic crc32c chain over
  sealed-segment checksums (+ frame-level prefix mid-segment),
  O(segments), reopen-stable.

Also fixes a cross-adapter contract bug the suite caught: memory
storage's deleteObjectFromPath ignored the raw-bytes store, so
deleteRawObject on a raw-bytes path (fact-log or segment files)
silently no-op'd — deletes now match filesystem unlink semantics.

Six pins. Wiring into GenerationStore (two-tier reads, the repacker,
cold-open manifest path) lands with the rest of the unit before its
release; cortex's fact-record/stamp shapes reconcile the sidecar
keying when they post.
2026-07-19 15:14:27 -07:00
f8e6da2b66 feat: scanFacts liveness contract — first batch or loud failure within a documented bound
Stage-2 D1 contract item (co-frozen): a fact scan may be slow, never
silent. batches() now races its FIRST pull against
SCANFACTS_FIRST_BATCH_MS (10s, exported; test-overridable) — a wedged
or unreadably slow store produces a loud abort naming the contract
instead of a consumer hanging indistinguishably from progress (the
production shape: a heal against a generations-backlogged brain
wedged silently on the first segment read).

Only the first pull is raced: the bound is time-to-first-batch (proof
the producer is alive), not per-batch pacing, and it runs only while
a pull is pending — consumer think-time between pulls never counts
against the producer (pinned).

Three pins: wedged-store loud failure within the bound, healthy scan
untouched end-to-end, slow-consumer immunity.
2026-07-19 14:54:36 -07:00
d08679fc84 chore(release): 8.9.0 2026-07-19 14:02:25 -07:00
5cabd784f4 docs: measured performance envelopes v1 (per-op p50/p95 at 1k and 10k, pure-JS floor)
First edition of the per-release performance-envelope contract: every
number measured against the built dist on stated hardware, never
projected. Sub-0.1ms get/related (adjacency O(degree), scale-flat),
1-9ms indexed metadata finds, ~178ms semantic (query embedding
dominates), ~167ms durability-priced single-op writes flat across
scale, 8-45ms steady-state flush independent of history backlog
(the 8.9.0 change). Two weak spots stated honestly: addMany commits
per-item today (batched chunk commits belong to the unified-commit
roadmap), and pure-JS warm open grows with corpus (4.9s at 10k) —
the native accelerator's reason to exist. Refresh rule: any release
touching a measured path re-measures in the same release.
2026-07-19 13:35:04 -07:00
70e4bc8a79 fix: release drains in-flight writer-lock heartbeat — no phantom lock after unlink
clearInterval() stops future heartbeat ticks but not one already in
flight: a straggler tick past its ownership guards could land its
atomic lock rewrite AFTER releaseWriterLock()'s unlink, re-creating
the lock file as a phantom that blocks the next writer until the
stale TTL expires (~60s) — the pool-eviction reopen case. Found as an
ENOENT heartbeat warning during benchmark teardown; the quiet variant
is the harmful one.

releaseWriterLock() now awaits the in-flight tick (tracked per tick,
self-clearing) before reading/unlinking, so a straggler's write always
lands BEFORE the unlink and gets removed with everything else. The
heartbeat's ENOENT is also now benign-by-contract (lock or directory
removed under us — the next acquire recreates it); other errors stay
loud.

Pin: straggler-past-guards simulation — lock file absent after close,
directory immediately claimable (fails on the undrained code).
2026-07-19 12:52:24 -07:00
300d9f2a16 feat: flush() never compacts — history maintenance moves to close() with bounded passes
flush() is durability work: it must cost what the current window's
deltas cost, never what the history backlog costs. Under adaptive
retention the byte budget derives from free memory, so bulk-load
pressure shrank the budget exactly at peak write volume and flush paid
actual reclaim inline — a production deployment measured single writes
blocked 25-191s behind reclaim-on-flush.

- flush() no longer calls autoCompactHistory(); close() is THE
  auto-compaction site (already ran there; now alone).
- Every auto pass is time-bounded (CLOSE_COMPACTION_BUDGET_MS = 5s):
  reclamation is oldest-first, so an early stop is a consistent prefix
  and the next pass resumes. Explicit compactHistory() gains an
  optional timeBudgetMs for caller-chosen maintenance windows.
- Documented trade stated where operators read: a long-lived writer
  that never closes accumulates history until its next explicit
  compactHistory() — predictable writes, explicit maintenance.

Pins: flush-never-reclaims + close-reclaims-durably (db-mvcc), bounded
pass stops-then-resumes as a consistent prefix (generationStore unit).
2026-07-19 12:04:39 -07:00
a16567d626 chore(release): 8.8.2 2026-07-19 11:18:18 -07:00
945d92d29e fix: one field-resolution law across aggregation hooks, source.where, removeMany, and find() spellings
Four fixes from a consumer conformance report, one root disease — two
field-resolution regimes where there must be one:

- The delete/update aggregation hooks fed the engine a partial entity
  view (type/service/data/metadata only), so a reserved-field groupBy
  (subtype, visibility, ...) resolved to a nonexistent group on the way
  down: counts drifted upward forever after deletes, and updates moving
  an entity between reserved-field groups double-counted. The hooks now
  pass the full-fidelity view via entityForAggFromRawRecord (every
  reserved field top-level, mirroring the add path); the update sites
  pass the full get() view instead of a hand-rolled subset.
- Aggregation source.where resolved fields only against the custom
  metadata bag, so where on a reserved field silently matched nothing.
  The matcher now resolves each filtered field through
  resolveEntityField — the same single source of truth groupBy uses.
- removeMany() with no usable selector (bare array passed positionally,
  empty params, ids: []) resolved successfully having deleted nothing.
  All three now throw; the two legacy tests that pinned the silent
  no-op as 'graceful' now pin the refusal.
- find() where keys accept both spellings: a metadata.-prefixed key
  falls back to its flattened spelling when the prefixed one is not
  indexed (metadata is flattened at index time). A literal nested custom
  key named metadata still wins when indexed as spelled.

Five regression pins in aggregate-reserved-fields.test.ts (4 of 5 vary
red on the unfixed code).
2026-07-19 10:54:36 -07:00
42037d0cd0 chore: push public docs to the soulcraft.com ingest door on release
scripts/push-docs.js collects docs/**/*.md with public:true frontmatter
and POSTs them (batches of 10, idempotent per slug) to the docs ingest
door after npm publish — release.sh step 12. Absent secret = loud skip
(publish already happened; the serving side can interim-sync); a failed
push exits non-zero so the docs site never silently trails npm. The
combined /docs landing index is deliberately NOT pushed per-repo — it
spans both engine corpora and is authored on the serving side.
2026-07-18 14:02:23 -07:00
a544225872 chore(release): 8.8.1 2026-07-18 10:57:30 -07:00
6207e48b51 fix: O(1) adaptive retention accounting + historyStats fleet audit
Under default adaptive retention, every flush() recomputed total history
bytes by walking EVERY committed generation's delta — O(all generations)
with disk re-reads past the 4096-entry delta-cache bound. On a production
brain with 70,000+ accumulated generations this turned every write into a
full-tail scan (60-100s writes, escalating with history growth), even
though the free-RAM budget never tripped and nothing was ever reclaimed
(SELF-GENERATIONS-GROWTH).

historyBytes() now maintains a running total: seeded by one walk on first
use, then updated incrementally at both commit paths (+bytes) and the
compaction reclaim loop (−bytes), dropped on reopenAfterRestore. The
adaptive retention check on every flush is O(1). Invariant regression-
pinned: running total ≡ fresh walk through transact commits, single-op
group commits, and compaction.

New brain.historyStats() (exported HistoryStats): read-only generation
count / bytes / generation+timestamp range / horizon / retention mode /
effective budget — the one-call per-brain fleet audit for retention
exposure.
2026-07-18 10:51:37 -07:00
4fcef7b8ed fix: import dedup off-switch honesty + brain-owned lifecycle for the background pass
The post-import background deduplication pass (a merge-DELETE writer,
debounced ~5 minutes after import) had four lifecycle defects:

- enableDeduplication: false did not gate the background schedule — an
  import that explicitly opted out could still have entities auto-removed
  minutes later. The flag now gates both the inline and background passes.
- Each import() constructed its own coordinator-owned deduplicator, so the
  debounce never spanned imports (N imports = N delete timers). The brain
  now owns a single lazy instance (getBackgroundDeduplicator).
- close() never cancelled pending dedup; a delete pass could fire against
  a closed brain. close() now cancels it first.
- The 5-minute timer held the process open (exit-hang class); now unref'd.

Four regression tests pin the contract (background-dedup-lifecycle);
import guides document that false disables both passes.
2026-07-18 10:40:45 -07:00
120205b69c chore(release): 8.8.0 2026-07-17 18:41:27 -07:00
16a73b8475 feat: OS-limit detection for pool-scale deployments
- New src/utils/osLimits.ts: reads RLIMIT_NOFILE (soft/hard, from
  /proc/self/limits) and vm.max_map_count at open — once per process,
  Linux-only, measurement-only — and warns loudly when either sits below the
  pool-scale floors (soft NOFILE < 65536, max_map_count < 262144), with the
  exact raise commands. On stock defaults the failure otherwise arrives as
  EMFILE or a failed mmap deep inside an index open, long after the cause
  stopped being visible. An unreadable limit produces NO warning — no
  measurement, no claim — so non-Linux platforms stay silent.
- Exported for ops doors: checkOsLimits() returns the full OsLimitsReport;
  floors exported as constants. Wired fire-and-forget in performInit after
  storage init; the check can never affect open.
- Unit tests pin the parser (incl. 'unlimited'), the floor thresholds, the
  null-never-warns rule, and the off-Linux silent path.
2026-07-17 17:51:54 -07:00
dcd5036fe9 chore(release): 8.7.1 2026-07-17 17:15:13 -07:00
01a3b46ade fix: race-proof writer-lock acquisition + machine-readable conflict through init
- acquireWriterLock now CLAIMS with an atomic create-exclusive write (O_EXCL)
  inside a bounded retry loop: two processes racing an absent lock can never
  both succeed (the old read-then-tmp-rename flow let the loser keep running
  unlocked, silently). An EEXIST loser re-evaluates and either throws loudly
  with the winner's details or performs a verified stale-takeover (re-read
  before unlink so a lock that changed hands mid-deliberation is never
  clobbered). Exhausted contention fails loudly instead of degrading into a
  lockless open.
- BRAINY_WRITER_LOCKED passes through init() unwrapped: the error documents a
  machine-readable contract (err.code + err.lockInfo with the holder's
  pid/host/heartbeat), but init's blanket error wrapping stripped both,
  leaving consumers a message to regex against.
- Two contract tests added: stale-foreign takeover installs OUR lock via the
  atomic claim; the conflict error carries code + lockInfo at the public
  init() surface.
2026-07-17 17:11:46 -07:00
e450e0eedf chore(release): 8.7.0 2026-07-17 16:54:40 -07:00
6ef9fcb7a2 feat: scaled transact budgets + labeled timeout diagnostics + envelope docs
- The transact apply budget scales with the batch — max(30s, opCount x 2s) —
  or is exactly the caller's new TransactOptions.timeoutMs. A flat 30s cap
  silently limited honest bulk work to ~15 operations on network-attached
  storage (~2s/op measured in a production import) while looking generous for
  small batches. Internal batch paths (removeMany chunks) get the same
  scaling via the shared transactTimeoutBudget helper.
- TransactionTimeoutError now reports the operation it stopped at as i/N with
  the operation's name, elapsed vs budgeted time, and states the batch rolled
  back atomically and is retryable; context carries the fields
  programmatically.
- The transact operational envelope is documented (optimistic-concurrency
  guide): budget math, chunking with ifAbsent idempotency, when to reach for
  addMany vs transact, and the precompute pattern (embedBatch + per-op
  vector) that keeps inference out of the commit path.
- Embedding claims made honest per an end-to-end probe of the built dist:
  batch and single embedding paths produce bit-identical vectors and a
  vector-supplied add is fully searchable, but batch throughput on the
  default WASM engine measures comparable to sequential (~160ms/text) — the
  5-10x speedup claim in the addMany JSDoc is replaced with the measured
  reality and the actual win (inference outside the budgeted write path).
2026-07-17 16:49:38 -07:00
e0f6e7722f chore(release): 8.6.0 2026-07-17 16:38:18 -07:00
2a03fae0e2 feat: brain.auditGraph() — read-only graph-truth audit
- New public API: walks every canonical relationship record, queries the same
  read path applications use (related() with all visibility tiers included),
  and classifies every discrepancy into its failure family: missingFromReads
  (records the read path omits — stale adjacency), danglingEndpoints
  (endpoint entity gone — the partial-delete scar class), readOnlyVerbIds
  (read-path edges with no canonical record — ghosts). Design-hidden
  internal/system edges are counted separately so intentional hiding is never
  misclassified as loss. Counts exact; example lists capped with an explicit
  truncatedExamples flag; loud narration on incoherence; mutates nothing.
- Classification core lives in src/graph/graphAudit.ts behind injected seams
  (canonical walks + the end-to-end read) so the discrepancy logic is testable
  in isolation; brainy wires the seams to storage walks and related().
- getNounIdsWithPagination now refuses an undecodable resume cursor loudly —
  the third and final pagination walk brought under the 8.5.2 cursor contract.
- Types exported: GraphAuditReport, GraphAuditDiscrepancy. Docs: the
  inspection guide gains the audit -> repair -> audit verification ritual.
2026-07-17 16:34:45 -07:00
07144ead86 chore(release): 8.5.2 2026-07-17 16:03:37 -07:00
a77b064bd7 fix: exception-safe aggregation backfill + generation-verified adoption + loud open-path guards
- Aggregation backfill walks build into a STAGING map and swap in atomically
  on completion. A mid-walk failure drops the staging map — the previous live
  state keeps serving, the aggregate stays flagged pending, and the storage
  error surfaces to the failing query. Previously the walk wiped live state
  before a scan that could throw, never cleared the pending flag on failure,
  and re-ran a full walk on every subsequent query: a silent wipe/walk/throw
  loop at the caller's retry rate.
- Failed walks are latched: retries within a 30s cooldown rethrow the recorded
  error instantly instead of re-walking, so a tight caller-side retry loop
  costs one loud error per query, never a full store walk per query.
- Persisted aggregation state is stamped with the store's committed generation
  at flush; reopen adoption requires stamp equality. Stale state (unclean
  shutdown) or over-counting state (a log truncation on a copied store pulled
  the watermark back) triggers exactly one loud rescan, never a silent adopt.
- The backfill/adoption path narrates: adoption decisions, walk start/finish
  with counts and duration, and failures all log by default.
- getNouns/getVerbs refuse a supplied-but-undecodable pagination cursor with a
  loud error instead of silently restarting the walk at offset 0 (which
  re-served page 1 forever to any while(hasMore) caller).
- The graph cold-load verb walk aborts loudly on a missing or non-advancing
  cursor with hasMore=true.
- A versioned index provider whose generation is AHEAD of the committed
  watermark (torn copy / crash-recovery truncation) is now named loudly at
  open, alongside the existing behind-direction message.
2026-07-17 16:00:11 -07:00
01a7f3dd01 chore(release): 8.5.1 2026-07-17 09:29:58 -07:00
da55be7520 fix: aggregation state adoption on reopen + single-flight backfill + query-cap ratchet removal
- AggregationIndex: defineAggregate before init no longer forces a backfill.
  init reconciles instead of clobbering: the app definition wins, persisted
  state is adopted on hash match, a write landing pre-adoption forces an exact
  rescan, and a successful state load clears the backfill flag. New ready()
  settles every adoption decision before query paths consult backfill state.
- brainy: backfills are single-flight and batched. Concurrent queries share one
  store walk and every pending aggregate fills from that same walk; the old
  behavior let each concurrent query wipe the others' partial state and start
  its own full walk, so a store under steady aggregate traffic never converged.
  queryAggregate also waits for persisted definitions before deciding an
  aggregate does not exist.
- paramValidation: recordQuery is telemetry-only. The duration-based cap
  ratchet (x0.8 per recorded query while lifetime-average exceeded 1s, floored
  at 1000 - below the documented 10000 auto floor, reported under a stale
  basis label) is removed; the cap comes from its construction-time basis or
  explicit overrides alone.
- storage: __aggregation_* and singleton system keys (brainy:entityIdMapper)
  are recognized before the unknown-key warning fires; routing is unchanged.
- docs: find-limits cap-immutability note, aggregation reopen/adoption
  semantics, RELEASES.md 8.5.1 entry.
2026-07-17 09:20:09 -07:00
593bb8b0f9 docs: external-backups/sparse-storage guide + generation fact log concept
- New public guide (guides/external-backups): the sparse-mmap reality
  for external backup tooling — why a brain directory can show 100+ GB
  apparent size on a small disk, which files are sparse, tar czSf /
  rsync --sparse / cp --sparse=always, live-store caveats, and what
  persist()/restore() already handle natively.
- New public concept (concepts/generation-fact-log): what facts are
  (after-image commit records, body-less tombstones), the crash-safety
  model (checksummed frames, reconcile-to-committed, absent = never
  committed), the scanFacts()/factSegmentPaths() surfaces with the
  telemetry shape, family stamps + open-time coherence, and the
  storage capability seam for plugin authors.
- Cross-link from the snapshots guide; sparse notes added to the
  public persist()/restore() JSDoc (the operator-facing sites).
2026-07-16 10:30:32 -07:00
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