Excluded from the correctness gate (tests/performance/**, run only via
npm run test:perf) but still leaked: brainMemory was created in a
beforeEach with no matching afterEach.
A second, structural pass of the honest scan (any local helper that
constructs a Brainy directly, not just ones named like openBrain/makeBrain,
plus support for new Brainy<T>(...) generics) surfaced two more real leaks
outside the first 93-file list: writer-lock-fencing.test.ts's `second`
(a rejected-init() brain never pushed into the file's own tracked array)
and plugin-version-coupling.test.ts's last case (a rejected-init() brain
with no close at all).
id-normalization.test.ts's makeBrain() and degraded-reads-surfaced.test.ts's
per-test brains had nothing tracking them — both now use a describe-scoped
opened[] array drained by afterEach.
find-hybrid-filter-before-hydrate.test.ts had two beforeAll-built brains
(one per describe block) with no matching afterAll.
multi-process-safety.test.ts and plugin-autodetect.test.ts/plugin.test.ts
left a brain whose init() was expected to reject (a rejected init() still
registers the instance in Brainy's global instance registry — the
constructor does that unconditionally — so it still needs close() to
deregister, or the process-level shutdown hooks never see the registry go
idle for the rest of the run).
Each file opened one or more Brainy instances (beforeEach, or a small
per-test helper like migration-gate-family-scoped's module-level seed())
and never closed them. migration-gate-family-scoped.test.ts now tracks
every brain seed() hands back in a describe-scoped array drained by
afterEach, since the helper itself lives outside the describe block.
Each file opened a Brainy in beforeAll/beforeEach (or a single it()) and
never closed it. related-verb-array.test.ts and vfs-containment-batched.test.ts
were real bugs: their afterAll discarded the brain with `brain = null as any`
without ever calling close() first.
tests/integration/find-*.test.ts and tests/unit/brainy/find*.test.ts each
opened one or more Brainy instances (via beforeAll/beforeEach) and never
closed them — the leaked instance's cadence timer stays armed for the rest
of the single-forked vitest run and keeps narrating into every later file.
find-unified-integration.test.ts was a real bug, not just a missing hook:
its afterAll called a no-op TestCleanup().cleanup() (nothing was ever
registered with it) and then discarded the brain reference with
`brain = null` — the brain was never actually closed.
The self-diagnosis from the last round worked: the box says this brain's own
providers were NOT called, so the flush pairs inside the 90 s window belong to
another brain in the same process. It could not say WHICH, and the advice it
gave — read the 'stdout | <file> > <test>' prefix — cannot work here: vitest
tags a stdout block with the test that is RUNNING, and these lines are captured
by this test's own console hook anyway. Teeing them through would only ever
print this test's name.
The call stack does name the driver, so it is captured beside each line and the
first one is reported: `kickBackgroundFlush('idle')` under `armIdleFlushTimer`
is some brain's cadence timer, the deferred-embed worker's commit path is a
brain still landing vectors, and a bare `flush()` is an explicit caller.
Why that distinction settles it. A flush only narrates PAST the dirty gate, and
`_dirtySinceLastFlush` is set in exactly three places — `noteWriteForPersistence()`
(both commit paths, and the deferred-embed worker lands its vectors through the
single-op one), `clear()`, and `repairIndex()`. So a narrating flush is a flush
whose brain really did commit a write; "0 ms" is the flush being cheap, not the
flush being empty. That reading rules OUT the re-arming-follow-up theory: the
queued follow-up is armed only by a concurrent flush() caller, cleared before
promotion, and a promoted run over a clean brain returns at the dirty gate
without touching a provider or printing a line.
Context the message now carries: the suite runs every file in ONE process, and
a create-versus-close scan puts 67 test files above the line — more brains made
than closed. This assertion is downstream of that, and the next red arrives with
the stack that names which one.
TWO THINGS, both about the same file.
THE LEAK, which is a defect of the test. `afterAll` set `brain = null`. That
does not close a brain — it only makes it unreachable from here. The instance
stayed open and registered with its unref'd cadence timer running, and the gate
config runs the whole suite in ONE process (pool: 'forks', singleFork: true —
two files report the same process.pid), so a brain leaked in this file goes on
narrating its flushes into every file that runs after it. This one holds 151
entities and 30 relations. It is closed now.
It is not the only leaker in the suite — a create-versus-close scan turns up 67
files with the same shape, and this is one of them, not the cause of anything on
its own. Fixing the file I was already in.
THE DIAGNOSTIC. 'walks the vector leg over the neighbours only' went red on the
gate box (1 row of a requested 5) while passing here in isolation eight runs out
of eight, beside its own box predecessor, and under a perturbed random stream —
and it passed on the box one gate earlier behind the IDENTICAL predecessor. So
the cause is process state accumulated by the time this file runs, and a bare
count mismatch says nothing about which half broke.
The case now runs the same query without the vector leg first, as a control,
and reports both counts: both short means the neighbour set or the filter, only
the vector leg short means the walk — which matters here because every row in
this corpus carries an IDENTICAL vector, so the walk is ranking an exact tie and
a tie has no defined order to return 5 of.
The assertion is unchanged: still exactly 5, still every row a neighbour.
"An idle brain prints nothing" is pinned two ways in this case, and only one of
them is attributable. The spies are bound to THIS brain's providers, so they
answer "did this brain flush?" exactly. The console filters cannot: the gate
config runs the whole suite in ONE process (pool: 'forks', singleFork: true —
verified, two files report the same process.pid), so console.log carries the
narration of every brain alive in that process, including one a previous file
opened and never closed whose unref'd cadence timer is still doing honest work.
Ordered as it was, a neighbour's honest flush and this engine breaking its own
law produced the same red, with a message that truncated the evidence to
"[ …(4) ]" — no way to tell which had happened, and nothing to chase.
So the spies assert first: their failure means the engine broke the law. The
console assertion follows, keeps both patterns, and carries the captured lines
in its message. vitest prefixes each stdout block with "stdout | <file> >
<test>", so the lines plus the surrounding log name the brain that printed
them, and the next red is diagnosable from the log alone.
No assertion is removed and no window is widened — the same two laws are
pinned, in the order that makes a failure readable.
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
64 cleared tags, authors and labels, but not the shape that actually turns up
in production metadata: a long keyword or participant list. 256 clears those
and still refuses every embedding this engine will ever meet — the narrowest
model it ships is 384-dimensional, so the two populations still do not overlap
and nobody has to tune anything. A vector parked in metadata throws by name;
a 200-keyword list writes and indexes.
The number lives in ONE place, `MAX_INDEXED_ARRAY_LENGTH`, and every message,
warning and pin derives it from there. Two pins still carried a literal:
metadata-vector-exclusion refused an array of exactly 100 — which sits UNDER
the new bound, so the case would have asserted a refusal that no longer
happens — and the array-bound suite named "all 64 elements" in a title and
picked its middle element as a hardcoded 't31'. Both derive from the constant
now, so the pins follow it wherever it goes rather than silently inverting the
next time it moves.
releases/open-brainy.json follows brainy.json out — the shared repo
(soulcraftlabs/releases on The Source) is now the one home for both
products' release notes; this repo hosts neither. The releases/
directory is gone.
RELEASES.md gains a pointer, under the heading, to the two raw URLs HQ's
/hq/releases door reads (this file stays as the human-readable quick
reference; those files are the source of truth).
The rail used to write releases/open-brainy.json (and, before that,
also carried the product engine's releases/brainy.json) in this repo.
It now clones (or refreshes a cached clone of) soulcraftlabs/releases on
The Source, prepends the derived entry to open-brainy.json there
(replacing any entry for the same version so a re-run is idempotent),
and pushes main directly. Any failure — clone, shape validation, commit,
or a rejected push — exits non-zero naming the cure; nothing is ever
skipped.
Both wall files are gone from this repo — the shared repo is the one
home HQ reads. --dry-run derives and prints the entry without touching
any clone or remote. Tests point --remote/--cache-dir at a throwaway
local bare repo and cache dir, never the real ones.
Every release used to get its releases/open-brainy.json entry typed by hand
after the fact. scripts/wall-entry.mjs derives it from the CHANGELOG entry
release.sh just composed (headline = first bullet, items = every bullet,
hash stripped) and prepends it, refusing by name on a duplicate version and
validating the whole file's shape + newest-first ordering before and after
it writes.
release.sh now runs it as its own step, between the CHANGELOG update and
the release commit, and stages releases/open-brainy.json into that commit.
The product engine's rail runs this identical script against its own
releases/brainy.json, unchanged — each repo's wall file lives beside the
CHANGELOG it derives from; there is no cross-repo step.
A --check mode validates a wall file's exact key set, field types, and
newest-first ordering with no duplicates, read-only. tests/unit/release/wall-entry.test.ts
covers derivation, prepend, duplicate refusal, and --check's shape/ordering
checks over temp copies — never the real files. --check also runs green
against both releases/open-brainy.json and releases/brainy.json as they
stand today.
The #-private rationale landed spliced into the middle of each method's
description, cutting one sentence in half. Same words, moved below the
behaviour they annotate.
The 10.4.11 flush single-flight work added `startFlushLeader` and
`promoteQueuedFlush` as TypeScript `private` methods. `private` is erased at
compile time, so both still land on the prototype — and the contract manifest
emitter reads the surface the BUILD exposes, skipping only names that start with
an underscore. On the next regeneration both would have been emitted as contract
doors, obliging every engine implementing contract 1 to provide the flush gate's
own bookkeeping. A door is a promise; these are internals.
Converted to ECMAScript-private (`#`), which keeps them off the prototype
entirely, and the reason is recorded on both so the next internal is not written
as `private` by habit. `_runFlush` — the flush body itself — was already safe by
the emitter's underscore rule.
Verified: `npm run build && node scripts/emit-contract-manifest.mjs` then
`--check` green at 302 doors, with neither name present.
TWO MANIFEST NOTES, both deliberate and neither hidden:
1. The regenerated manifest gains `MetadataArrayTooLargeError`. The emitter
lists every `*Error` export from brainyError.js, and that class is the write
door's refusal for an over-bound metadata array (this branch's array-bound
commit). It is a real addition to the engine's error surface, so the manifest
is right to carry it — flagged here because it is a contract-surface change
that the cut should accept knowingly, not a side effect that slipped in.
2. `armIdleFlushTimer` and `kickBackgroundFlush` are TypeScript `private` in
src and ARE already in the committed manifest as doors — the same leak, one
release older. They are left exactly as they are: removing a name the
manifest already publishes is a contract deletion, not a hygiene fix, and it
belongs to whoever owns contract 1 rather than to this branch.
`readonly-close-no-marker` closed the clean-shutdown-marker half of this law and
named the rest as a known residual. This is that residual, closed.
MEASURED on the base: a read-only open → read → close rewrote FOUR files —
`_system/__metadata_field_registry__.json.gz`, `type-statistics.json.gz`,
`subtype-statistics.json.gz` and `verb-subtype-statistics.json.gz`. An IDLE
reader that only opened and closed rewrote all four as well.
The cause was not the closes the marker fix guarded. It was Phase 1 of
closeDurableSteps, where every component flush ran unconditionally. A flush is a
write by definition: MetadataIndexManager#flush() saves the field registry "even
with no dirty fields" (its own comment), and the storage adapter's count flush
re-stamps the three statistics files. A session that committed nothing re-stamped
all four. Phase 2's closes were ungated too — the graph index's close drains both
LSM MemTables to SSTables and stamps its watermark, and the optional
vector/metadata `close` hooks (unimplemented in the reference engine, filled in
by a native provider) persist buffered state.
Every one of those calls now carries the same `!isReadOnly` guard the generation
store already had.
A reader still RELEASES what it holds, so Phase 2 is a branch rather than a skip:
GraphAdjacencyIndex gains `stopBackgroundFlush()`, the non-writing half of its
close, which clears the auto-flush interval that would otherwise outlive the
session. `close()` now calls it too, so there is one place that owns the timer.
Why this matters beyond tidiness: `_system/` is where a store keeps its evidence
about itself — what the writer committed, what the projections have seen. A
reader that rewrites any of it vouches for a state it only observed, and on
shared or snapshot storage it mutates bytes another process owns.
The pin hashes every file under `_system/` (and, in one case, the whole store)
across a reader's open → read → close, names the four paths that used to move so
a regression says which subsystem did it, and asserts the asymmetry holds in the
other direction — a WRITER's close still persists.
An array-valued metadata field indexes one posting per element, so the index has
always carried a ceiling. It was 10, and it was applied by a bare `continue`
deep inside field extraction:
if (Array.isArray(value) && value.length > 10) continue
A row whose `tags` array held ELEVEN entries therefore had that field skipped
entirely — no posting, no error, no warning. The row then failed to match every
filtered search on `tags`, including a query for a tag it demonstrably held, and
the caller had no way to tell that from "no row matches". Eleven tags is not an
exotic shape; the eleventh tag made the row invisible. Measured on the pin here:
the where-clause returns [] on the base for all eleven values.
The ceiling is not the defect. The silence was.
THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no
knob), sitting far above every legitimate multi-value field — tags, authors,
categories, labels, participants — and far below any real embedding width, so
the two populations do not overlap and nobody has to tune it. Arrays of scalars
index in full up to the bound. Above it the WRITE IS REFUSED by name:
MetadataArrayTooLargeError carries the field (its full dotted address), the
length and the bound, and names the three cures. It fires at all four write
doors — add, update, relate, updateRelation — beside the existing forged-system-
key rejection, and walks nested bags because a nested field indexes under its
dotted address exactly like a top-level one.
THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an
older engine under the old rule and read back by a rebuild, a catch-up fold or a
remove. extractIndexableFields serves all three, so refusing there would make an
existing store un-rebuildable — the row is admitted and the skipped field is
NARRATED with the field, the length and the bound. Never silent, either way.
tests/integration/metadata-vector-exclusion.test.ts carried the old law as a
green assertion ("should skip indexing large arrays (>10 elements)"). It is
rewritten to the new one, plus a case proving a 64-element array indexes in full
and its eleventh element is searchable. The original bug that suite exists for —
per-dimension numeric field explosion — is still asserted on both paths.
`getIdsForRange` routes two ways. The column store compares RAW values and is
correct. The legacy sparse chunk index — the pre-7.20.0 fallback, still read for
workspaces that have not been rebuilt — compared normalizeValue() output, and
normalizeValue carries an escape hatch that destroys order on purpose: a string
over 100 characters becomes a short hash so it can serve as a filesystem-safe
key. Ordering hashes ranks rows by digest.
Two shapes, both silent:
(a) A LONG BOUND against ordinary values. `{ gte: <a 120-character string> }`
collapsed the BOUND to `__HASH_…`, whose leading underscores sort below
every letter — so a bound that should have excluded everything matched the
entire field instead. Measured on the fixture here: 3 of 3 rows returned
where 0 is correct. This shape reaches a caller who never stored a long
value at all.
(b) LONG VALUES in the index. The field was persisted hashed, so its order is
not recoverable from this index. The old code compared the digests anyway
and returned a subset chosen by hash — 1 of 3 rows, the wrong one.
Bounds are now normalized WITHOUT the hash escape hatch, so a long bound stays
comparable and (a) is simply fixed. Where the persisted KEY is a hash the order
does not exist to be computed, and the query throws a typed
BrainyError('INVALID_QUERY') naming the field and the cure. The refusal is
checked before chunk SELECTION as well as during the scan: selection orders the
bounds against each chunk's zone map, and its failure mode is an empty answer —
the quietest wrong answer of all. Equality on a hashed field is untouched; only
ordering is refused.
KNOWN, NAMED DIVERGENCE, recorded in the doc comment rather than papered over:
the persisted keys are also lower-cased and trimmed, so this path's string
ranges are case-INSENSITIVE where the column store's are not. The raw values are
not in the index to compare — that is a property of the bytes a pre-7.20.0
engine wrote, and it ends when the column store adopts the field.
The pin builds a genuine legacy index through the same ChunkManager /
SparseIndex doors that engine wrote through, into a field the column store does
not serve. The chunk write path was removed in 11be039, so that is the only way
to build the shape this read path exists for.
`find({ where, orderBy })` answered in field order; `find({ query, where,
orderBy })` and `find({ vector, where, orderBy })` answered in SCORE order.
The vector/filter block ranks the fused candidates by score, cuts the page and
returns early — and the tail's orderBy sort sits below that early return, so on
those paths it never ran. Nothing threw and nothing warned: the ordering request
was dropped in silence, and the two paths disagreed about what "ordered by rank"
means.
Where `connected` or `fusion` kept the tail alive the defect changed shape
rather than disappearing. The block had already CUT the page by score, so the
tail ordered the rows relevance had chosen — a correctly sorted page of the
wrong rows.
The early cut fires only once the candidate set reaches offset+limit rows, which
is why it read green for so long: below that threshold the block falls through
and the tail's sort does apply. An ordering that is correct until there is
enough data to matter.
THE LAW: an explicit orderBy displaces score as the ordering key on every path.
The candidate set the path produced is ordered IN FULL and the page is cut from
that ordering — "page last", the graph-first law applied to ordering rather than
to filtering. Score-ranked early paging stays exactly as it was for the default
case, where score IS the requested order.
The pin is differential against the metadata-only path, the one path that always
honoured orderBy. It is sized so the hybrid legs (each bounded at limit*2)
provably cover the filter universe, and that covering is asserted from the leg's
own output rather than assumed — orderBy orders the candidate set, it does not
enlarge it, and the pin claims nothing about recall.
The seam hydrates the metadata and graph page paths; a vector or text leg builds
its own entities and is trimmed after the integrity guard instead. That is a
COST difference, and this pin exists so it can never quietly become an ANSWER
difference.
TypeScript's `private` is compile-time only, so the seam's helpers were real
prototype methods and the generated contract manifest listed them as public
DOORS — which would have obliged every other engine to implement an internal
detail. They are `#`-private now and the manifest is unchanged by this branch.
Found while checking that: docs/api-contract.json was ALREADY stale at v10.4.11
— promoteQueuedFlush and startFlushLeader are in src and absent from the
manifest, so they leaked the same way and were never re-emitted. Left alone
here rather than folded into this branch; it is someone's to fix deliberately,
and the fix is the same # conversion.
docs/FIND_SYSTEM.md gains the projection: the rules, why a missing field is
absent rather than an error, where the values come from and what a field the
column cannot serve costs.
A list view that shows a title and a slug hydrates the whole record for every
row, document bodies included, and discards almost all of it. find/get({ fields })
names what is wanted; the column store serves it; the canonical record is opened
only for fields the index cannot supply.
The provider grows an optional getScalarsForIds(ids, fields) door, batched: it
walks each column ONCE and picks out every requested id, rather than re-walking
per row. The column store grows the primitive that was missing — valuesForIds —
because every other read door there answers which entities have a value, and a
projection asks the opposite.
It reads the COLUMN store, never the sparse index: the column keeps raw values,
the sparse index keeps a bucketed form built for range queries, and a projection
served from the latter would return a value that differs from the record's. A
field the column cannot serve is omitted rather than approximated — omission
costs a read, a wrong value is a wrong answer nobody can see.
Two laws the pins hold: fields absent is byte-identical to today, and a missing
field is simply absent rather than an error — so this path deliberately avoids
the strict address resolver, whose UnresolvableFieldError is right for orderBy
and wrong here.
related() takes no fields: a Relation carries from/to as ids and hydrates no
record, so the param would be decorative.
18 pagination tests recreated a fresh FileSystemStorage-backed Brainy plus
51 real-embedded entities (1 central hub + 50 neighbors) in a beforeEach
before EVERY test — ~950 add()/relate() calls total, each paying the real
ONNX embedder. Measured before this change: 303.69s (fresh run, this
session). None of these tests exercise similarity search, only graph
pagination, so three changes cut the cost without touching an assertion:
- vector: [] on every add() — add()'s `params.vector || embed(...)` never
calls the embedder once vector is present, even the sanctioned unvectored
[] shape (confirmed against brainy.ts's zero-norm-law comment: the
dimension-pinning gate is `vector.length > 0`, so [] never poisons
dimensions for a later real embed).
- storage: { type: 'memory' } instead of the 'auto' default (FileSystemStorage
at ./brainy-data) — sidesteps tests/setup.ts's global per-test
`rm -rf brainy-data`, which would otherwise corrupt a brain shared across
a describe's beforeAll out from under it.
- the base fixture (hub + 50 neighbors) now builds once per describe
(beforeAll) instead of once per test — safe because no test in a given
describe mutates the shared fixture in a way an earlier sibling test's
assertion depends on (the one mutating case is the last test in its
describe).
Measured after: 416ms for all 18 tests (2.35s wall including vitest
startup), all 18 still passing.
vfs.unit.test.ts's 'Performance > should handle many files efficiently'
(100 writes + readdir, 5.5s write budget) is a wall-clock flake: 121ms
alone, 16.5s under the gate's sibling-file contention — the code never
caused it. Same pattern already used for storage-batch-operations.test.ts's
batch-vs-individual timing case: ctx.skip(!process.env.BRAINY_PERF_LANE,
reason) inside the test, and the file added to vitest.perf.config.ts's
include list (it stays in the unit gate's *.unit.test.ts match too, so
every other test in the file keeps running there).
tests/performance/triple-intelligence-scale.test.ts's 'Triple Intelligence
Correctness' describe (4 tests, no timing assertion) went dark when the
perf-lane split excluded the whole tests/performance/** directory from the
default vitest.config.ts gate — it ran nowhere since. Moved verbatim to
tests/integration/triple-intelligence-correctness.test.ts, which the gate
does collect.
Every expect() is byte-for-byte the original. Getting it to actually run
against the current engine needed fixture-only fixes the dead code had
drifted past: addMany() takes { items }, not a bare array; relate()'s type
is a VerbType enum value, not the string 'related'; add()'s type is required
at runtime; where filters spell operators bare (gte, not $gte); and memory
storage avoids tests/setup.ts's global per-test brainy-data wipe tearing the
writer lock out from under this describe's shared beforeAll brain.
Two of the four tests are it.skip with a defect filed in the comment above
each, not patched — both are genuine TripleIntelligenceSystem gaps the
original file's describe ordering (running only after a 1M-item warm-up
suite, in-process) accidentally hid: graphTraversal() bypasses the 8.0
id-normalization law for a natural-key `connected.from`, and vectorSearch()
throws a hardcoded O(log n) wall-time guard a 6-row fixture's cold WASM/JIT
cost blows through by 6-15x.
10.4.11 gave shutdown one owner and one path — close() — and wired all three
process listeners to it. That is right for SIGTERM and SIGINT. It is wrong for
'beforeExit', which Node emits whenever the event loop has no REF'd work left:
not when the process is ending, and with no signal involved. A healthy script
reaches that state routinely, because this engine unref's its idle and cadence
timers ("an idle brain costs nothing"), so a script awaiting anything those
timers drive is, for that instant, a process with no ref'd work and an open
brain.
MEASURED on the 11.1 rehearsal lane against a copy of a real store: after the
heal phase the log printed "Shutdown signal received - flushing pending
data..." and "Flushed successfully (1 instance)" with no signal ever sent, and
the script's very next add() threw "Brainy instance is not initialized: it was
closed via close(). Create a new instance." The engine had closed a live brain
out from under a running script.
The beforeExit listener now runs its own pass, which closes nothing,
deregisters nothing, releases no writer lock, and never force-exits: it runs
flush() — the engine's own non-closing durability door — on each live brain and
leaves every one of them open and usable. flush() persists derived state only
(count ledger, projections, generation counter, aggregation, entity-tree
stamp); the clean-shutdown marker is generationStore.close()'s word about
itself, reached only from close(). Running it concurrently with live writes is
the engine's ordinary steady state — noteWriteForPersistence() kicks the same
call off an unref'd timer on every busy brain — and it is single-flight, so
there is no new race. A throw is reported per instance and the pass continues:
canonical data is durable at ack via the fact log, so a failed derived-state
flush costs the next open a rebuild, never the caller their brain.
The listener is no longer self-deregistered. It does not need to be: a flush on
a clean brain schedules no I/O, so the emit after it does no event-loop work
and the process exits on its own. A one-shot listener spent on a spurious
mid-script drain would leave the genuine end-of-script drain with nothing. The
drained-loop notice is printed once per registration cycle, because a
console.log to a pipe is itself event-loop work.
exitIfSoleShutdownOwner() stays on the signal path alone, and its contract now
says so: beforeExit suppresses no default behaviour, so exiting from it would
end a live script at code 0 mid-work.
THE NAMED TRADE: a script that opens a brain and never closes it now exits with
its writer lock still on disk and no clean-shutdown marker, so its next open
overwrites a stale lock and folds the log. That is the honest cost of never
closing, and the narration names the cure. Closing a live brain to avoid it was
the worse half of the trade.
Pins: tests/integration/beforeexit-never-closes.test.ts — a script that drains
the loop with a brain open keeps a working brain (add + find succeed, the lock
is still held, the process still exits 0), the pass flushed and wrote neither
of close()'s markers, and repeated drains are idempotent. Both cases fail on
10.4.11's handler with the exact production shape ("add() after the drain
failed", "pass 1 closed the brain"). Re-run green: shutdown-single-owner,
writer-lock-clean-close, idle-costs-nothing, shutdown-hooks-lifecycle.
docs/concepts/multi-process.md no longer claims beforeExit releases the lock.
Every release used to get its releases/open-brainy.json entry typed by hand
after the fact. scripts/wall-entry.mjs derives it from the CHANGELOG entry
release.sh just composed (headline = first bullet, items = every bullet,
hash stripped) and prepends it, refusing by name on a duplicate version and
validating the whole file's shape + newest-first ordering before and after
it writes.
release.sh now runs it as its own step, between the CHANGELOG update and
the release commit, and stages releases/open-brainy.json into that commit.
The product engine's rail runs this identical script against its own
releases/brainy.json, unchanged — each repo's wall file lives beside the
CHANGELOG it derives from; there is no cross-repo step.
A --check mode validates a wall file's exact key set, field types, and
newest-first ordering with no duplicates, read-only. tests/unit/release/wall-entry.test.ts
covers derivation, prepend, duplicate refusal, and --check's shape/ordering
checks over temp copies — never the real files. --check also runs green
against both releases/open-brainy.json and releases/brainy.json as they
stand today.
Only the open engine's own wall (releases/open-brainy.json) belongs in the
public reference project. The product's notes are served from the product's
own repository.
Gate: final tip 27759a1b vs a8c5fbf9 (10.4.10) control — collected
3,212, 0 new reds after two fix cycles (coverage-guard registration +
perf-lane classification; a real budget flake in the batch-size test
switched to unvectored items). shasum ffc33df95b2709dfcc8c67ac961991e3153f8883.
The single-flight gate queued its follow-up as
`leader.catch().then(() => this.flush())`. That waiter is settled ONLY by
resolving the very promise the leader is being awaited through, so the moment
anything inside a flush body awaits flush(), the promise graph closes on itself
and nobody resolves — an unbounded hang, not a slow flush, presenting exactly
like a bulk write timing out. No current call site awaits a flush from inside
one, so this is a latent cycle rather than an observed one; the gate should not
depend on that staying true.
The queue is now a bare deferred. The leader's finally opens the gate and
PROMOTES the waiter to a new leader, settling the deferred from that run; the
finally returns nothing, so the leader never awaits its own follower. Every
exit runs the same promotion — the leader resolving, the leader rejecting, the
promoted run rejecting — so a queued caller is settled exactly once on every
path, and a synchronous failure starting the promoted run is reported to the
waiter instead of thrown into the leader's finally. close() drains both handles.
tests/unit/brainy/flush-single-flight.test.ts pins the invariant on each path
that must settle a waiter: many callers during one flush all resolve within a
bound (one body, one follow-up, peak concurrency 1); a REJECTING leader still
runs and settles the queued waiter; a rejecting follow-up settles its waiter
and leaves the gate open; and the leader returns without waiting for a
deliberately slower follower.
The wall-clock ratio (batch faster than N individual gets) started failing
under the exclusive release gate because individual gets got faster on
this candidate (open-path/hydration changes), not because batchGet
regressed — a perf assertion misclassified into a correctness file.
Skip it under the default gate via a BRAINY_PERF_LANE env marker the perf
config sets for itself; the file joins the perf config's include list so
the case still runs (with every other test in the file) under
`npm run test:perf`.
tests/configs/vitest.perf.config.ts (npm run test:perf) is a real gate,
not a manual-only slot, so inGate() now recognizes its include list
(tests/performance/** plus the four named files) directly. The 7 files
already correctly listed as perf move out of MANUAL_ONLY, which is now
reserved for files no automated lane covers.
That alone left the guard red: tests/vfs/vfs-search-path-scope.test.ts
was a genuine new orphan (added this cycle, named without the
.unit.test.ts suffix its siblings use) — it ran under the broad root
gate but silently missed test:unit. Renamed to match the sibling
convention in tests/vfs/, which puts it back in the unit gate.
302 doors (17 added, executeGraphSearch removed), 7 error classes, 25
operators (4 refused by the index path). --check verified green against
this candidate tip.
reservedGensAsc() documented but never enforced that pending single-op
generations must sort above every committed one. A direct
commitTransaction() call bypassing Brainy.transact()'s flush-first step
could commit a fresh generation into committedRanges above lower,
still-pending ones, unsorting the committed-then-pending walk
resolveManyAt relies on and returning a wrong before-image for a
point-in-time read — silently. commitTransaction() now refuses via a new
PendingSingleOpsUnflushedError when the pending tier is non-empty, before
any staging I/O. Behavior-neutral: both sanctioned callers
(Brainy.transact(), Brainy.compactHistory()) already flush first.
Four pins in real child processes under real SIGTERM, following the
writer-lock-clean-close spawn pattern:
(a) A host owner registered on SIGTERM closes two brains while the engine's
hooks are live: exactly one close entered and one close body run per brain,
the writer lock given up exactly ONCE per brain, the handler announcing
that it stepped aside, no "Writer fence lost", no failed instance, both
durability markers written, exit 0, and both reopens adopting rather than
folding. The release count is the discriminating assertion — against the
old handler it reads {a: 2, b: 2}, one release from the owner's close and
one from the handler's own finally.
(b) No host owner: the engine's handler closes every instance by the same
path — one close each, markers written, clean exit, clean reopen.
(c) Two concurrent close() callers share one promise (by identity) and one
execution; a third call after they settle runs nothing.
(d) Eight kicks during a running flush — five through the cadence door, three
direct — arm exactly ONE follow-up: two flush bodies total, and the
concurrency high-water mark stays at 1.
The counts come out of the child through a file written synchronously on the
way out: the engine calls process.exit(0) when it is the sole shutdown owner,
and a console.log to a pipe can be dropped by that exit.
MEASURED IN PRODUCTION. A host that owns its own shutdown — one SIGTERM
listener calling close() on every pooled store — ran head-on into the engine's
own signal handler, which iterated every live instance, flushed its components
in parallel, and released its writer lock in its own finally. Two teardowns of
the same brain at the same moment: "Shutdown signal received - flushing pending
data...", 148s of silence, "Flushed successfully (1 instance)", and the host's
pool close of that same store returning 1s later — 149s against 24s for the six
stores with no engine work in flight. The same race reproduced locally as
"Failed to flush one Brainy instance on shutdown: Writer fence lost … the lock
file is gone": the handler observing a lock the close it was racing had already
released.
Three changes, one law — a brain's teardown belongs to whoever started it.
1. close() is idempotent and re-entrant. The first call stores its promise
synchronously in _closeInFlight and every later or concurrent caller gets
that same promise back; the teardown runs once. close() is no longer async
so the promise is shared by identity, not just outcome. The state is
observable: isClosing (begun) and isClosed (finished).
2. The signal handler defers one macrotask, then per instance either steps
aside (a close has begun or finished — its owner owns the flush, the markers
and the lock) or awaits instance.close(): the same settle/flush/attest/
marker/lock path any caller gets. Its old parallel per-component flush and
separate lock release are gone; the three laws that block carried are each
satisfied by close(), verified line by line and recorded in the new comment.
Per-instance isolation stays here, in the loop's try/catch.
Sole-owner exit now reads the listener count WHEN THE SIGNAL ARRIVES.
Asking afterwards reads a process that has already torn itself down —
closing the last brain deregisters the engine's own listeners, so a host's
single remaining listener would look like "<= 1" and be force-exited out of
its own graceful shutdown.
3. Flush is single-flight with a queue one deep. It did not coalesce: the
cadence's guard covered only the flushes the cadence started, so a
cross-process flush request or an application flush() overlapped it freely —
production showed two "Flushing Brainy indexes…" runs 3s apart, walls
growing 295ms to 4.9s. The gate now lives in flush() itself and covers every
caller: run, or join the ONE queued follow-up. A follow-up rather than
joining the running flush, because a caller flushes to make ITS writes
durable and those may have landed after the running flush read its state; it
costs nothing when there is nothing new. close() drains that chain too.
The idle law is untouched: a clean brain's flush still returns immediately, and
an idle brain still flushes zero times.
`vfs.search({ path })` built its scope as `path: { $startsWith: path }`.
`$startsWith` is not in the filter vocabulary at all, and the `$`-less spelling
is REFUSED by the metadata index's served-operator law — an equality/range
posting index cannot evaluate a substring without reading every row, so it
refuses rather than answering an empty page. Every path-scoped VFS search threw
on this engine line; the two pins in tests/vfs/vfs.unit.test.ts that exercise it
have been red since the operator law landed.
The scope is now a half-open range over `metadata.path`:
`[dir + '/', dir + '0')`. Every descendant path begins with `dir + '/'`, and '0'
is the code point directly after '/', so membership in the range is EXACTLY
"carries that prefix" — and because the bounds differ at one ASCII position the
answer is identical under code-unit and code-point collation. Siblings fall out
correctly for the same reason: `/scope-sibling/x` sorts below the lower bound
and `/scope0` sits at the open upper bound. `recursive: false` narrows to the
directory's own identity instead — `parent`, an indexed equality. The root
adds no clause, because every VFS entity is under it.
`path` is the VFS's truth (write and rename maintain it; the `Contains` edges
are a projection of it), it is already indexed on every VFS entity, and
`explain()` reports the range as `column-store` — "O(log n) binary search +
roaring bitmap". So the scope narrows the search before it runs: no tree walk,
no migration, no backfill, and nothing fetched that the scope then discards.
Two other shapes were considered and rejected. A graph-scoped walk over
`Contains` reads the projection rather than the truth and costs O(subtree)
adjacency lookups per search, with the subtree's height as an unknown `depth`.
An indexed `ancestors: string[]` field cannot be implemented honestly today:
the index extractor skips arrays longer than ten elements, so a path more than
ten levels deep would silently drop out of every scoped search — and it needs
a backfill besides.
Pinned in tests/vfs/vfs-search-path-scope.test.ts (all eight red before this
change): descendants at three depths and never a sibling, including the
`/scope-sibling` and `/scope0` prefix traps; a trailing or doubled slash names
the same scope; the root scope equals the unscoped search; `recursive: false`
is the immediate children and refuses a missing directory by name; every
operator the search emits is ANSWERED by the index's own door rather than
refused; the id universe the index resolves for the search is already the
scope; and the range agrees with walking the tree.