Date: Thu, 23 Jul 2026 11:09:43 -0700
Subject: [PATCH 015/175] =?UTF-8?q?chore:=20the=20forge=20is=20the=20addre?=
=?UTF-8?q?ss=20=E2=80=94=20retire=20the=20archived=20mirror=20from=20ever?=
=?UTF-8?q?y=20live=20surface?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ruled today: the project's one public home is source.soulcraft.com. The
old public repo is archived history and no longer part of any release.
- package.json repository/homepage/bugs now point at the forge (this is
what the npm page links as Repository/Homepage/Issues)
- README CI badge reads the forge pipeline; CONTRIBUTING drops the
mirror paragraph (forge account or email patch were already the ruled
contribution paths)
- release.sh: mirror push + external release step removed; publishes go
forge-first (box-held write token, temp userconfig so the token never
hits argv; a forge-publish failure aborts before the storefront so the
pair can never diverge), then npmjs with the scope-override pin (the
fleet npmrc maps @soulcraft to the forge and scope mappings beat
--registry); release page created via forge API when a token is
present, loud skip otherwise; changelog compare links point home
- dead external CI workflow removed (.forgejo/workflows/ci.yml is the
live pipeline)
Historical CHANGELOG links to the archive stay as written - history is
history and the archive serves them read-only.
---
.github/workflows/ci.yml | 40 ----------------------
CONTRIBUTING.md | 4 ---
README.md | 2 +-
package.json | 6 ++--
scripts/release.sh | 71 +++++++++++++++++++++++++---------------
5 files changed, 48 insertions(+), 75 deletions(-)
delete mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index cdb2ab14..00000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: CI
-
-on:
- push:
- pull_request:
-
-jobs:
- node:
- name: Node ${{ matrix.node-version }}
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- node-version: ['22', '24']
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: ${{ matrix.node-version }}
- cache: npm
- - run: npm ci
- - run: npm run test:unit
-
- bun:
- name: Bun (latest)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: '22'
- cache: npm
- - uses: oven-sh/setup-bun@v2
- with:
- bun-version: latest
- - run: npm ci
- # test:bun imports the built dist/, so build first.
- - run: npm run build
- # Bun as a runtime is the supported Bun story (`bun add` / `bun run`).
- - run: npm run test:bun
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ef9c4a51..d277091d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,10 +10,6 @@ The source of truth is a self-hosted forge: **source.soulcraft.com/soulcraft/bra
It's anonymously readable and cloneable — no account needed to browse, clone,
or build.
-**github.com/soulcraftlabs/brainy** is a public read-only mirror. It's a fine
-place to read code or star the project, but issues and pull requests opened
-there won't be picked up — please use one of the paths below instead.
-
## How to contribute
**Found a bug, or have an idea?** Email **brainy@soulcraft.com**. No account,
diff --git a/README.md b/README.md
index 2fc42060..2caf6493 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/package.json b/package.json
index e4bc8144..a3ece83c 100644
--- a/package.json
+++ b/package.json
@@ -128,13 +128,13 @@
"publishConfig": {
"access": "public"
},
- "homepage": "https://github.com/soulcraftlabs/brainy",
+ "homepage": "https://source.soulcraft.com/soulcraft/brainy",
"bugs": {
- "url": "https://github.com/soulcraftlabs/brainy/issues"
+ "url": "https://source.soulcraft.com/soulcraft/brainy/issues"
},
"repository": {
"type": "git",
- "url": "git+https://github.com/soulcraftlabs/brainy.git"
+ "url": "git+https://source.soulcraft.com/soulcraft/brainy.git"
},
"files": [
"dist/**/*.js",
diff --git a/scripts/release.sh b/scripts/release.sh
index 42f5b345..43fa50bd 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -142,7 +142,7 @@ else
fi
# Create new changelog entry
-CHANGELOG_ENTRY="### [${NEW_VERSION}](https://github.com/soulcraftlabs/brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
+CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraft/brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
${COMMITS}
"
@@ -175,42 +175,59 @@ echo -e "${BLUE}7️⃣ Creating git tag v${NEW_VERSION}...${NC}"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
echo -e "${GREEN}✅ Tag created${NC}\n"
-# Step 9: Push to origin (source of truth) and the public GitHub mirror
+# Step 9: Push to origin — the forge is the one home (ruled 2026-07-23; the
+# old public GitHub repo is archived history, no longer part of any release).
echo -e "${BLUE}8️⃣ Pushing to origin...${NC}"
git push --follow-tags origin "$CURRENT_BRANCH"
echo -e "${GREEN}✅ Pushed to origin${NC}\n"
-# The public GitHub repo is a mirror of origin with an unknown sync cadence.
-# `gh release create` below targets GitHub directly: if the new tag hasn't
-# reached GitHub yet, gh would CREATE it — pointed at GitHub's default-branch
-# head, i.e. the wrong commit. Push branch+tag to GitHub explicitly, then
-# verify the tag resolves there to the same commit before any release is cut.
-GITHUB_URL="https://github.com/soulcraftlabs/brainy.git"
-echo -e "${BLUE}8️⃣½ Pushing to the public GitHub mirror...${NC}"
-git push --follow-tags "$GITHUB_URL" "$CURRENT_BRANCH"
-LOCAL_TAG_SHA="$(git rev-parse "v${NEW_VERSION}^{}")"
-GITHUB_TAG_SHA="$(git ls-remote --tags "$GITHUB_URL" "v${NEW_VERSION}^{}" | cut -f1)"
-if [ "$LOCAL_TAG_SHA" != "$GITHUB_TAG_SHA" ]; then
- echo -e "${RED}❌ Tag v${NEW_VERSION} on GitHub (${GITHUB_TAG_SHA:-absent}) does not match local (${LOCAL_TAG_SHA}) — aborting before npm publish. Fix the mirror, then re-run.${NC}"
+# Step 10: Publish — forge FIRST (home), npmjs second (the world's storefront).
+# The fleet-wide ~/.npmrc maps the @soulcraft scope to the forge registry, and
+# a scope mapping BEATS `--registry` on the command line — so each publish
+# names its registry via the scope override explicitly. Nothing implicit.
+FORGE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/npm/"
+FORGE_NPM_TOKEN_FILE="$HOME/.config/soulcraft/npm-publish-brainy.token"
+echo -e "${BLUE}9️⃣ Publishing to the forge registry (home)...${NC}"
+if [ -f "$FORGE_NPM_TOKEN_FILE" ]; then
+ TMPRC="$(mktemp)"
+ chmod 600 "$TMPRC"
+ {
+ echo "@soulcraft:registry=${FORGE_NPM_REG}"
+ echo "//source.soulcraft.com/api/packages/soulcraft/npm/:_authToken=$(cat "$FORGE_NPM_TOKEN_FILE")"
+ } > "$TMPRC"
+ if npm publish --tag "$NPM_TAG" --userconfig "$TMPRC"; then
+ echo -e "${GREEN}✅ Published to the forge${NC}\n"
+ else
+ rm -f "$TMPRC"
+ echo -e "${RED}❌ Forge publish FAILED — aborting before npmjs so the pair never diverges. Fix and re-run.${NC}"
+ exit 1
+ fi
+ rm -f "$TMPRC"
+else
+ echo -e "${RED}❌ Forge publish token missing (${FORGE_NPM_TOKEN_FILE}) — aborting. The forge is home; publish it first or restage the token.${NC}"
exit 1
fi
-echo -e "${GREEN}✅ GitHub mirror has the tag at the right commit${NC}\n"
-# Step 10: Publish to npm
-echo -e "${BLUE}9️⃣ Publishing to npm (dist-tag: ${NPM_TAG})...${NC}"
-npm publish --tag "$NPM_TAG"
+echo -e "${BLUE}9️⃣½ Publishing to npmjs (storefront, dist-tag: ${NPM_TAG})...${NC}"
+npm publish --tag "$NPM_TAG" "--@soulcraft:registry=https://registry.npmjs.org/"
# Brainy is the only PUBLIC @soulcraft package — verify visibility after every publish.
-npm access get status @soulcraft/brainy || true
-echo -e "${GREEN}✅ Published to npm${NC}\n"
+npm access get status @soulcraft/brainy "--@soulcraft:registry=https://registry.npmjs.org/" || true
+echo -e "${GREEN}✅ Published to npmjs${NC}\n"
-# Step 11: Create GitHub release
-echo -e "${BLUE}🔟 Creating GitHub release...${NC}"
-if [ "$PRERELEASE" = true ]; then
- gh release create "v${NEW_VERSION}" --generate-notes --prerelease
+# Step 11: Release object on the forge (presentational — the tag, CHANGELOG,
+# and RELEASES.md are the record; this just gives the forge UI a release page).
+echo -e "${BLUE}🔟 Creating forge release...${NC}"
+if [ -n "${FORGEJO_RELEASE_TOKEN:-}" ]; then
+ if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraft/brainy/releases" \
+ -H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" -H "Content-Type: application/json" \
+ -d "{\"tag_name\":\"v${NEW_VERSION}\",\"name\":\"v${NEW_VERSION}\",\"prerelease\":${PRERELEASE}}" >/dev/null; then
+ echo -e "${GREEN}✅ Forge release created${NC}\n"
+ else
+ echo -e "${RED}⚠️ Forge release API call failed — tag + CHANGELOG remain the record; create the release page via the forge UI if wanted${NC}\n"
+ fi
else
- gh release create "v${NEW_VERSION}" --generate-notes
+ echo -e "${RED}⚠️ FORGEJO_RELEASE_TOKEN unset — no release page created; tag + CHANGELOG remain the record${NC}\n"
fi
-echo -e "${GREEN}✅ GitHub release created${NC}\n"
# Step 12: Push public docs to the soulcraft.com docs ingest door
# (VENUE-DOCS-RELEASE-PUSH). Skips with a loud warning when
@@ -229,4 +246,4 @@ echo -e "${GREEN}🎉 Release ${NEW_VERSION} complete!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "📦 npm: ${BLUE}https://www.npmjs.com/package/@soulcraft/brainy/v/${NEW_VERSION}${NC}"
-echo -e "🐙 GitHub: ${BLUE}https://github.com/soulcraftlabs/brainy/releases/tag/v${NEW_VERSION}${NC}"
+echo -e "🏠 Forge: ${BLUE}https://source.soulcraft.com/soulcraft/brainy/releases/tag/v${NEW_VERSION}${NC}"
From 22702b81c0557df6e0f10713f958beb956d06044 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Thu, 23 Jul 2026 11:09:43 -0700
Subject: [PATCH 016/175] =?UTF-8?q?chore:=20the=20forge=20is=20the=20addre?=
=?UTF-8?q?ss=20=E2=80=94=20retire=20the=20archived=20mirror=20from=20ever?=
=?UTF-8?q?y=20live=20surface?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ruled today: the project's one public home is source.soulcraft.com. The
old public repo is archived history and no longer part of any release.
- package.json repository/homepage/bugs now point at the forge (this is
what the npm page links as Repository/Homepage/Issues)
- README CI badge reads the forge pipeline; CONTRIBUTING drops the
mirror paragraph (forge account or email patch were already the ruled
contribution paths)
- release.sh: mirror push + external release step removed; publishes go
forge-first (box-held write token, temp userconfig so the token never
hits argv; a forge-publish failure aborts before the storefront so the
pair can never diverge), then npmjs with the scope-override pin (the
fleet npmrc maps @soulcraft to the forge and scope mappings beat
--registry); release page created via forge API when a token is
present, loud skip otherwise; changelog compare links point home
- dead external CI workflow removed (.forgejo/workflows/ci.yml is the
live pipeline)
Historical CHANGELOG links to the archive stay as written - history is
history and the archive serves them read-only.
---
.github/workflows/ci.yml | 40 ----------------------
CONTRIBUTING.md | 4 ---
README.md | 2 +-
package.json | 6 ++--
scripts/release.sh | 71 +++++++++++++++++++++++++---------------
5 files changed, 48 insertions(+), 75 deletions(-)
delete mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index cdb2ab14..00000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: CI
-
-on:
- push:
- pull_request:
-
-jobs:
- node:
- name: Node ${{ matrix.node-version }}
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- node-version: ['22', '24']
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: ${{ matrix.node-version }}
- cache: npm
- - run: npm ci
- - run: npm run test:unit
-
- bun:
- name: Bun (latest)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: '22'
- cache: npm
- - uses: oven-sh/setup-bun@v2
- with:
- bun-version: latest
- - run: npm ci
- # test:bun imports the built dist/, so build first.
- - run: npm run build
- # Bun as a runtime is the supported Bun story (`bun add` / `bun run`).
- - run: npm run test:bun
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ef9c4a51..d277091d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,10 +10,6 @@ The source of truth is a self-hosted forge: **source.soulcraft.com/soulcraft/bra
It's anonymously readable and cloneable — no account needed to browse, clone,
or build.
-**github.com/soulcraftlabs/brainy** is a public read-only mirror. It's a fine
-place to read code or star the project, but issues and pull requests opened
-there won't be picked up — please use one of the paths below instead.
-
## How to contribute
**Found a bug, or have an idea?** Email **brainy@soulcraft.com**. No account,
diff --git a/README.md b/README.md
index 2fc42060..2caf6493 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/package.json b/package.json
index e4bc8144..a3ece83c 100644
--- a/package.json
+++ b/package.json
@@ -128,13 +128,13 @@
"publishConfig": {
"access": "public"
},
- "homepage": "https://github.com/soulcraftlabs/brainy",
+ "homepage": "https://source.soulcraft.com/soulcraft/brainy",
"bugs": {
- "url": "https://github.com/soulcraftlabs/brainy/issues"
+ "url": "https://source.soulcraft.com/soulcraft/brainy/issues"
},
"repository": {
"type": "git",
- "url": "git+https://github.com/soulcraftlabs/brainy.git"
+ "url": "git+https://source.soulcraft.com/soulcraft/brainy.git"
},
"files": [
"dist/**/*.js",
diff --git a/scripts/release.sh b/scripts/release.sh
index 42f5b345..43fa50bd 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -142,7 +142,7 @@ else
fi
# Create new changelog entry
-CHANGELOG_ENTRY="### [${NEW_VERSION}](https://github.com/soulcraftlabs/brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
+CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraft/brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
${COMMITS}
"
@@ -175,42 +175,59 @@ echo -e "${BLUE}7️⃣ Creating git tag v${NEW_VERSION}...${NC}"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
echo -e "${GREEN}✅ Tag created${NC}\n"
-# Step 9: Push to origin (source of truth) and the public GitHub mirror
+# Step 9: Push to origin — the forge is the one home (ruled 2026-07-23; the
+# old public GitHub repo is archived history, no longer part of any release).
echo -e "${BLUE}8️⃣ Pushing to origin...${NC}"
git push --follow-tags origin "$CURRENT_BRANCH"
echo -e "${GREEN}✅ Pushed to origin${NC}\n"
-# The public GitHub repo is a mirror of origin with an unknown sync cadence.
-# `gh release create` below targets GitHub directly: if the new tag hasn't
-# reached GitHub yet, gh would CREATE it — pointed at GitHub's default-branch
-# head, i.e. the wrong commit. Push branch+tag to GitHub explicitly, then
-# verify the tag resolves there to the same commit before any release is cut.
-GITHUB_URL="https://github.com/soulcraftlabs/brainy.git"
-echo -e "${BLUE}8️⃣½ Pushing to the public GitHub mirror...${NC}"
-git push --follow-tags "$GITHUB_URL" "$CURRENT_BRANCH"
-LOCAL_TAG_SHA="$(git rev-parse "v${NEW_VERSION}^{}")"
-GITHUB_TAG_SHA="$(git ls-remote --tags "$GITHUB_URL" "v${NEW_VERSION}^{}" | cut -f1)"
-if [ "$LOCAL_TAG_SHA" != "$GITHUB_TAG_SHA" ]; then
- echo -e "${RED}❌ Tag v${NEW_VERSION} on GitHub (${GITHUB_TAG_SHA:-absent}) does not match local (${LOCAL_TAG_SHA}) — aborting before npm publish. Fix the mirror, then re-run.${NC}"
+# Step 10: Publish — forge FIRST (home), npmjs second (the world's storefront).
+# The fleet-wide ~/.npmrc maps the @soulcraft scope to the forge registry, and
+# a scope mapping BEATS `--registry` on the command line — so each publish
+# names its registry via the scope override explicitly. Nothing implicit.
+FORGE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/npm/"
+FORGE_NPM_TOKEN_FILE="$HOME/.config/soulcraft/npm-publish-brainy.token"
+echo -e "${BLUE}9️⃣ Publishing to the forge registry (home)...${NC}"
+if [ -f "$FORGE_NPM_TOKEN_FILE" ]; then
+ TMPRC="$(mktemp)"
+ chmod 600 "$TMPRC"
+ {
+ echo "@soulcraft:registry=${FORGE_NPM_REG}"
+ echo "//source.soulcraft.com/api/packages/soulcraft/npm/:_authToken=$(cat "$FORGE_NPM_TOKEN_FILE")"
+ } > "$TMPRC"
+ if npm publish --tag "$NPM_TAG" --userconfig "$TMPRC"; then
+ echo -e "${GREEN}✅ Published to the forge${NC}\n"
+ else
+ rm -f "$TMPRC"
+ echo -e "${RED}❌ Forge publish FAILED — aborting before npmjs so the pair never diverges. Fix and re-run.${NC}"
+ exit 1
+ fi
+ rm -f "$TMPRC"
+else
+ echo -e "${RED}❌ Forge publish token missing (${FORGE_NPM_TOKEN_FILE}) — aborting. The forge is home; publish it first or restage the token.${NC}"
exit 1
fi
-echo -e "${GREEN}✅ GitHub mirror has the tag at the right commit${NC}\n"
-# Step 10: Publish to npm
-echo -e "${BLUE}9️⃣ Publishing to npm (dist-tag: ${NPM_TAG})...${NC}"
-npm publish --tag "$NPM_TAG"
+echo -e "${BLUE}9️⃣½ Publishing to npmjs (storefront, dist-tag: ${NPM_TAG})...${NC}"
+npm publish --tag "$NPM_TAG" "--@soulcraft:registry=https://registry.npmjs.org/"
# Brainy is the only PUBLIC @soulcraft package — verify visibility after every publish.
-npm access get status @soulcraft/brainy || true
-echo -e "${GREEN}✅ Published to npm${NC}\n"
+npm access get status @soulcraft/brainy "--@soulcraft:registry=https://registry.npmjs.org/" || true
+echo -e "${GREEN}✅ Published to npmjs${NC}\n"
-# Step 11: Create GitHub release
-echo -e "${BLUE}🔟 Creating GitHub release...${NC}"
-if [ "$PRERELEASE" = true ]; then
- gh release create "v${NEW_VERSION}" --generate-notes --prerelease
+# Step 11: Release object on the forge (presentational — the tag, CHANGELOG,
+# and RELEASES.md are the record; this just gives the forge UI a release page).
+echo -e "${BLUE}🔟 Creating forge release...${NC}"
+if [ -n "${FORGEJO_RELEASE_TOKEN:-}" ]; then
+ if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraft/brainy/releases" \
+ -H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" -H "Content-Type: application/json" \
+ -d "{\"tag_name\":\"v${NEW_VERSION}\",\"name\":\"v${NEW_VERSION}\",\"prerelease\":${PRERELEASE}}" >/dev/null; then
+ echo -e "${GREEN}✅ Forge release created${NC}\n"
+ else
+ echo -e "${RED}⚠️ Forge release API call failed — tag + CHANGELOG remain the record; create the release page via the forge UI if wanted${NC}\n"
+ fi
else
- gh release create "v${NEW_VERSION}" --generate-notes
+ echo -e "${RED}⚠️ FORGEJO_RELEASE_TOKEN unset — no release page created; tag + CHANGELOG remain the record${NC}\n"
fi
-echo -e "${GREEN}✅ GitHub release created${NC}\n"
# Step 12: Push public docs to the soulcraft.com docs ingest door
# (VENUE-DOCS-RELEASE-PUSH). Skips with a loud warning when
@@ -229,4 +246,4 @@ echo -e "${GREEN}🎉 Release ${NEW_VERSION} complete!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "📦 npm: ${BLUE}https://www.npmjs.com/package/@soulcraft/brainy/v/${NEW_VERSION}${NC}"
-echo -e "🐙 GitHub: ${BLUE}https://github.com/soulcraftlabs/brainy/releases/tag/v${NEW_VERSION}${NC}"
+echo -e "🏠 Forge: ${BLUE}https://source.soulcraft.com/soulcraft/brainy/releases/tag/v${NEW_VERSION}${NC}"
From 003e2a74ea7dd2598022b2ca6ee3784d85214662 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Fri, 24 Jul 2026 16:01:41 -0700
Subject: [PATCH 017/175] fix: transaction timeouts are a typed no-hot-retry
contract; engine-side non-retry pinned; dead transaction path removed
A production incident: a native-provider op ground 38-40s inside a transaction,
blew the apply-phase budget, rolled back, and a downstream pipeline hot-retried
the identical operation into a 6-minute CPU storm. Brainy itself never
auto-retried the timeout; the gap was that TransactionTimeoutError only said
"retryable" in prose, with nothing machine-readable for a caller to branch on.
- TransactionTimeoutError gains two typed, always-true fields: retryable
(a later attempt may succeed once the slowness resolves or the budget is
raised) and hotRetryUnsafe (an immediate identical retry re-pays the full
cost that just timed out and can cascade into a CPU storm -- callers must
latch and back off, never loop). context's existing telemetry fields
(timeoutMs, operationIndex, elapsedMs, totalOperations, operationName) are
now documented as the caller's backoff inputs.
- Updated the "retryable" doc-prose sites (transact()'s timeoutMs option,
transactionBudgetFloorMs, Transaction.execute()'s contract) to point at
the new fields instead of bare prose.
- Regression pin (tests/unit/transaction/timeout-never-internally-retried.test.ts):
an execution counter proves the engine never re-drives a timed-out
operation, through both the single-op engine TransactionManager/Transaction
drives for every single-record write, and add()'s upsert-race retry loop
(which must exit on the first TransactionTimeoutError, never treat it like
the lost-insert-race signal it retries on).
- Removed TransactionManager.executeTransactionWithResult -- zero callers
anywhere in the codebase.
---
src/db/types.ts | 10 +-
src/transaction/Transaction.ts | 7 +-
src/transaction/TransactionManager.ts | 29 ----
src/transaction/errors.ts | 45 +++++-
src/types/brainy.types.ts | 6 +-
.../TransactionManager.unit.test.ts | 37 -----
.../timeout-never-internally-retried.test.ts | 141 ++++++++++++++++++
7 files changed, 199 insertions(+), 76 deletions(-)
create mode 100644 tests/unit/transaction/timeout-never-internally-retried.test.ts
diff --git a/src/db/types.ts b/src/db/types.ts
index 4c8a4957..a7f86a89 100644
--- a/src/db/types.ts
+++ b/src/db/types.ts
@@ -121,9 +121,13 @@ export interface TransactOptions {
* with the batch: `max(30 000, opCount × 2 000)` — production imports on
* network-attached disks measure ~2 s per operation, so a flat 30 s budget
* silently capped honest bulk work at ~15 operations. A tripped budget
- * rolls the whole batch back and throws a retryable
- * `TransactionTimeoutError` naming the operation it stopped at, the batch
- * size, and the elapsed/budget times.
+ * rolls the whole batch back and throws a `TransactionTimeoutError` naming
+ * the operation it stopped at, the batch size, and the elapsed/budget
+ * times. That error is retryable-with-latch, never hot-retry: its
+ * `retryable` field says a later attempt may succeed, its
+ * `hotRetryUnsafe` field says an immediate identical retry re-pays the
+ * full cost that just timed out — callers must latch and back off, never
+ * loop.
*/
timeoutMs?: number
}
diff --git a/src/transaction/Transaction.ts b/src/transaction/Transaction.ts
index 79b53006..ede65e83 100644
--- a/src/transaction/Transaction.ts
+++ b/src/transaction/Transaction.ts
@@ -62,9 +62,10 @@ const DEFAULT_BUDGET_FLOOR_MS = 30_000
* NEXT operation may start (see {@link Transaction.execute}), never whether
* already-completed work is rolled back after the fact. A trip mid-batch
* still rolls back every operation applied so far, atomically, and throws a
- * retryable, fully-labeled TransactionTimeoutError — that zero-loss guarantee
- * doesn't change; only the point at which the clock stops mattering does (at
- * the last operation, not one check later).
+ * fully-labeled `TransactionTimeoutError` — retryable-with-latch, never
+ * hot-retry (see its `retryable` and `hotRetryUnsafe` fields) — that
+ * zero-loss guarantee doesn't change; only the point at which the clock
+ * stops mattering does (at the last operation, not one check later).
*
* @param opCount - Number of operations in the batch.
* @param override - A full override for this call; wins over everything else.
diff --git a/src/transaction/TransactionManager.ts b/src/transaction/TransactionManager.ts
index 0f13b6a3..5abf48ba 100644
--- a/src/transaction/TransactionManager.ts
+++ b/src/transaction/TransactionManager.ts
@@ -19,7 +19,6 @@
import { Transaction } from './Transaction.js'
import {
TransactionFunction,
- TransactionResult,
TransactionOptions
} from './types.js'
import { TransactionError } from './errors.js'
@@ -105,34 +104,6 @@ export class TransactionManager {
}
}
- /**
- * Execute a transaction and return detailed result
- */
- async executeTransactionWithResult(
- fn: TransactionFunction,
- options?: TransactionOptions
- ): Promise> {
- const startTime = Date.now()
- const transaction = new Transaction(options)
-
- try {
- const value = await fn(transaction)
- await transaction.execute()
-
- const executionTimeMs = Date.now() - startTime
-
- return {
- value,
- operationCount: transaction.getOperationCount(),
- executionTimeMs
- }
-
- } catch (error) {
- // Transaction failed
- throw error
- }
- }
-
/**
* Get transaction statistics
*/
diff --git a/src/transaction/errors.ts b/src/transaction/errors.ts
index c270d0ed..d8382a4b 100644
--- a/src/transaction/errors.ts
+++ b/src/transaction/errors.ts
@@ -73,14 +73,47 @@ export class InvalidTransactionStateError extends TransactionError {
/**
* Error for transaction timeout
+ *
+ * Machine-readable no-hot-retry contract: {@link retryable} and
+ * {@link hotRetryUnsafe} are both always `true` on this class — they exist
+ * so a caller can branch on the *shape* of the error instead of parsing
+ * message text. Read them together: the operation may eventually succeed,
+ * but never by looping on it immediately.
+ *
+ * `context` (inherited from {@link TransactionError}) carries the caller's
+ * backoff inputs — see the field docs below.
*/
export class TransactionTimeoutError extends TransactionError {
+ /**
+ * The failed operation MAY succeed on a later attempt — once the
+ * underlying slowness resolves (e.g. a cold page cache warms up) or the
+ * budget is deliberately raised (`transactionBudgetFloorMs`, or a larger
+ * `timeoutMs` override on the batch). This is a statement about eventual
+ * retryability, not a license to retry now — see {@link hotRetryUnsafe}.
+ */
+ public readonly retryable = true
+
+ /**
+ * An immediate, identical retry re-pays the FULL cost of the work that
+ * just timed out — it does not resume partway. Looping on this error
+ * (hot-retrying) repeats that full cost every attempt and can cascade
+ * into a CPU/resource storm on the caller's side. Callers MUST latch: on
+ * this error, record `{ at: Date.now(), error }`, surface one loud
+ * failure to their own caller, and hold a cooldown window before any
+ * re-attempt (clearing the latch only on success). Never retry this error
+ * in a tight loop.
+ */
+ public readonly hotRetryUnsafe = true
+
constructor(
timeoutMs: number,
operationIndex: number,
telemetry?: {
+ /** Milliseconds elapsed in the transaction when the budget tripped. */
elapsedMs?: number
+ /** Total number of operations in the batch that timed out. */
totalOperations?: number
+ /** Name of the operation the batch was about to start when it tripped, if named. */
operationName?: string
}
) {
@@ -93,8 +126,16 @@ export class TransactionTimeoutError extends TransactionError {
telemetry?.elapsedMs !== undefined ? `${telemetry.elapsedMs}ms elapsed, ` : ''
super(
`Transaction timed out at operation ${progress}${name} — ${elapsed}budget ${timeoutMs}ms. ` +
- `The batch rolled back atomically; retry with a higher timeoutMs or a smaller batch.`,
- { timeoutMs, operationIndex, ...telemetry }
+ `The batch rolled back atomically; retryable after the underlying slowness resolves or ` +
+ `the budget is raised, but hot-retry-unsafe — latch and back off, never loop.`,
+ {
+ // Caller backoff inputs — all present on every instance:
+ /** Configured budget (ms) that was exceeded. */
+ timeoutMs,
+ /** Index of the operation the batch was about to start when it tripped. */
+ operationIndex,
+ ...telemetry
+ }
)
this.name = 'TransactionTimeoutError'
}
diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts
index 8bede8d3..b5f286ed 100644
--- a/src/types/brainy.types.ts
+++ b/src/types/brainy.types.ts
@@ -1658,8 +1658,10 @@ export interface BrainyConfig {
* **start** — never whether already-completed work gets rolled back after
* the fact (a single-op write can never time out post-hoc: it either runs
* or it commits). A trip mid-batch still rolls back every applied operation
- * atomically and throws a retryable `TransactionTimeoutError`; only the
- * floor of the formula is configurable here.
+ * atomically and throws a `TransactionTimeoutError` that is
+ * retryable-with-latch, never hot-retry (see its `retryable` and
+ * `hotRetryUnsafe` fields); only the floor of the formula is configurable
+ * here.
*
* Raise this when a cold store's first writes after a restart legitimately
* take longer than 30s per operation (e.g. page-cache-cold canonical writes
diff --git a/tests/transaction/TransactionManager.unit.test.ts b/tests/transaction/TransactionManager.unit.test.ts
index 86b1692c..29e7f7ae 100644
--- a/tests/transaction/TransactionManager.unit.test.ts
+++ b/tests/transaction/TransactionManager.unit.test.ts
@@ -5,7 +5,6 @@
* - High-level transaction API
* - Statistics tracking
* - Error handling
- * - Result wrapping
*/
import { describe, it, expect, beforeEach } from 'vitest'
@@ -84,42 +83,6 @@ describe('TransactionManager', () => {
})
})
- describe('executeTransactionWithResult', () => {
- it('should return detailed result', async () => {
- const result = await manager.executeTransactionWithResult(async (tx) => {
- tx.addOperation({
- execute: async () => {
- await new Promise(resolve => setTimeout(resolve, 1))
- return async () => {}
- }
- })
- tx.addOperation({ execute: async () => undefined })
- return 'success'
- })
-
- expect(result.value).toBe('success')
- expect(result.operationCount).toBe(2)
- expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
- })
-
- it('should measure execution time', async () => {
- const result = await manager.executeTransactionWithResult(async (tx) => {
- tx.addOperation({
- execute: async () => {
- await new Promise(resolve => setTimeout(resolve, 25))
- return async () => {}
- }
- })
- return 'done'
- })
-
- // Timer coalescing can fire a setTimeout up to a few ms EARLY under
- // load, so assert well below the sleep — this tests that time is
- // MEASURED, not the OS timer's precision.
- expect(result.executionTimeMs).toBeGreaterThanOrEqual(20)
- })
- })
-
describe('Statistics Tracking', () => {
it('should track total transactions', async () => {
await manager.executeTransaction(async (tx) => {
diff --git a/tests/unit/transaction/timeout-never-internally-retried.test.ts b/tests/unit/transaction/timeout-never-internally-retried.test.ts
new file mode 100644
index 00000000..a43a81a8
--- /dev/null
+++ b/tests/unit/transaction/timeout-never-internally-retried.test.ts
@@ -0,0 +1,141 @@
+/**
+ * @module tests/unit/transaction/timeout-never-internally-retried
+ * @description Regression pin for the no-hot-retry contract (8.10.1).
+ *
+ * A production incident: a native-provider op ground 38-40s inside a
+ * transaction, blew the ~32s budget, was rolled back, and a CONSUMER pipeline
+ * hot-retried the identical operation into a 6-minute 100%-CPU storm.
+ * Investigation established brainy itself never auto-retries a
+ * `TransactionTimeoutError` — the storm was entirely the consumer's hot-retry
+ * loop, driven by a "retryable" doc-prose claim with no machine-readable
+ * contract. This file pins the brainy-side half of that story so it can never
+ * regress silently:
+ *
+ * (i) the underlying engine (`TransactionManager.executeTransaction()` →
+ * `Transaction.execute()`) — the exact machinery every single-record
+ * write (`add`/`update`/`remove`/...) drives via
+ * `Brainy.persistSingleOp()` — never internally re-executes a timed-out
+ * operation, and the error it surfaces carries `retryable === true` and
+ * `hotRetryUnsafe === true` (see `src/transaction/errors.ts`).
+ *
+ * Constructed directly (mirrors the existing
+ * `tests/unit/transaction/timeout-rollback.test.ts` pattern) rather than
+ * through a real `brain.add()` call: `transactTimeoutBudget()` floors
+ * every single-op write's budget at `opCount * 2000`ms with NO override
+ * seam (`transactionBudgetFloorMs` only RAISES that floor — it cannot
+ * lower it below the per-op-count term), so getting a real `add()` to
+ * time out requires a multi-second sleep. The engine-level
+ * `options.timeout` override used here is the exact same
+ * `TransactionManager`/`Transaction` code `persistSingleOp` calls —
+ * pinning it here pins add()'s guarantee without paying that wall-clock
+ * cost.
+ *
+ * (ii) `Brainy.add()`'s upsert-race retry loop (src/brainy.ts,
+ * `MAX_UPSERT_ATTEMPTS = 10`) — proving the loop's `catch` treats a
+ * `TransactionTimeoutError` as terminal (immediate rethrow) rather than
+ * the `InsertPreconditionExistsSignal` it retries on, so a mid-flight
+ * timeout can never be silently swallowed and re-attempted up to 10
+ * times.
+ */
+import { describe, it, expect } from 'vitest'
+import { TransactionManager } from '../../../src/transaction/TransactionManager.js'
+import type { Operation, RollbackAction } from '../../../src/transaction/types.js'
+import { TransactionTimeoutError } from '../../../src/transaction/errors.js'
+import { Brainy } from '../../../src/brainy.js'
+import { NounType } from '../../../src/types/graphTypes.js'
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
+
+// Brainy's ValidationConfig fixes vectors at exactly 384 dimensions
+// (src/utils/paramValidation.ts) — match it so add() doesn't reject test data.
+const DIM = 384
+const V = (): number[] => Array(DIM).fill(0.1)
+
+/** An operation that counts every `execute()` invocation — the re-drive detector. */
+function countingOp(opts: { delayMs?: number; name: string }): Operation & { calls: number } {
+ const op = {
+ name: opts.name,
+ calls: 0,
+ async execute(): Promise {
+ op.calls++
+ if (opts.delayMs) await sleep(opts.delayMs)
+ return async () => {}
+ }
+ }
+ return op
+}
+
+describe('transaction timeouts are never internally re-driven (8.10.1 no-hot-retry contract)', () => {
+ it('(i) the single-op engine (TransactionManager.executeTransaction / Transaction.execute — what add() drives via persistSingleOp) runs the overrun operation EXACTLY once and surfaces ONE retryable+hotRetryUnsafe error', async () => {
+ const manager = new TransactionManager()
+ const op0 = countingOp({ name: 'op0-overruns-budget', delayMs: 30 })
+ const op1 = countingOp({ name: 'op1-must-never-start' })
+
+ let caught: unknown
+ try {
+ await manager.executeTransaction(
+ async (tx) => {
+ tx.addOperation(op0)
+ tx.addOperation(op1)
+ },
+ // Tiny explicit override — the same override seam `transact()`
+ // exposes as `options.timeoutMs`; wins outright over the
+ // opCount*2000 floor that gates every real single-op write
+ // (transactTimeoutBudget()'s override semantics).
+ { timeout: 5 }
+ )
+ } catch (err) {
+ caught = err
+ }
+
+ expect(caught).toBeInstanceOf(TransactionTimeoutError)
+ const err = caught as TransactionTimeoutError
+ // The machine-readable contract callers branch on instead of parsing
+ // message text (src/transaction/errors.ts).
+ expect(err.retryable).toBe(true)
+ expect(err.hotRetryUnsafe).toBe(true)
+
+ // The re-drive assertion: op0 (the one that overran) executed EXACTLY
+ // once — nothing inside TransactionManager/Transaction looped back and
+ // re-ran it — and op1 never started at all (the budget gate stopped it
+ // before it began, per Transaction.execute()'s per-operation loop).
+ expect(op0.calls).toBe(1)
+ expect(op1.calls).toBe(0)
+ })
+
+ it('(ii) add()\'s upsert-race retry loop (MAX_UPSERT_ATTEMPTS=10) exits on the FIRST TransactionTimeoutError — attempt counter stays at 1, never mistaken for the lost-insert-race signal it retries on', async () => {
+ const brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' },
+ silent: true
+ })
+ await brain.init()
+
+ let persistSingleOpCalls = 0
+ const timeoutError = new TransactionTimeoutError(5, 1, {
+ elapsedMs: 6,
+ totalOperations: 2,
+ operationName: 'SaveNounMetadata'
+ })
+ // Stub the private commit seam add() drives (persistSingleOp) to throw
+ // the exact error type the real engine surfaces on a mid-flight timeout.
+ // This test pins the upsert loop's EXCEPTION-HANDLING contract (does it
+ // retry a TransactionTimeoutError like it retries
+ // InsertPreconditionExistsSignal?), not the timing mechanics of a real
+ // timeout — those are pinned by test (i) and by
+ // tests/unit/transaction/timeout-rollback.test.ts.
+ ;(brain as any).persistSingleOp = async (): Promise => {
+ persistSingleOpCalls++
+ throw timeoutError
+ }
+
+ await expect(
+ brain.add({ data: 'a', type: NounType.Thing, vector: V() })
+ ).rejects.toBe(timeoutError)
+
+ // The loop's attempt counter: exactly one call, never retried up to
+ // MAX_UPSERT_ATTEMPTS.
+ expect(persistSingleOpCalls).toBe(1)
+ await brain.close()
+ })
+})
From 5b2cbf74e568d4bb1f89cd7019d8d70c05999188 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Fri, 24 Jul 2026 16:02:01 -0700
Subject: [PATCH 018/175] fix: warm() metadata surface routes through the
active provider (warm hook added to the metadata contract); add
maintenanceDebt() observability surface
A production deployment's warm report showed metadata: 'unavailable' under a
native metadata provider. brain.warm()'s metadata leg only duck-typed the
built-in JS manager's hydrateAll() method, which a native provider has no
reason to implement.
- MetadataIndexProvider (src/plugin.ts) gains an optional warm?(): Promise
hook, mirroring the existing vector and graph provider hooks. brain.warm()
now checks the active provider's own warm() FIRST, falls back to the JS
manager's hydrateAll() when absent, and reports 'unavailable' only when
neither exists -- never init() as a stand-in, since a native provider's
init() may be a cheap verify rather than a real warm.
- Tests (tests/unit/brainy/warm.test.ts): a live provider instance shaped to
have warm() reports 'warmed' and the hook called with no hydrateAll
fallback; shaped to have neither hook reports 'unavailable' (pins the
honest branch); the unmodified built-in JS manager still reports 'warmed'
via hydrateAll(), unchanged.
Additive scope agreed mid-flight with the native-provider team: a
maintenance-debt observability seam so an operator sees a grind coming
instead of discovering it as a CPU storm.
- New optional maintenanceDebt?(): Promise hook on
all three provider contracts (vector, metadata, graph -- the same three
warm?() lives on). ProviderMaintenanceDebt is fields-all-optional: a
provider reports only what it truly measures (pendingBytes, pendingItems,
lastPassCompletedAt, lastPassOutcome, converging), never an estimate
dressed as fact.
- New public brain.maintenanceDebt(): a pure passthrough -- for each surface
it calls only the active provider's own hook and reports the payload
verbatim, or 'unavailable' when absent. No thresholds, no polling, no
JS-side estimation; the provider owns the numbers, the operator owns the
policy.
- ProviderMaintenanceDebt, MaintenanceDebtReport, and MaintenanceDebtOutcome
are exported from the package root.
- Tests (tests/unit/brainy/maintenance-debt.test.ts): hook present reports
'reported' with the exact payload passed through; hook absent reports
'unavailable' on every surface; mixed surfaces resolve independently of
each other.
RELEASES.md gains the 8.10.1 entry covering both fixes above and this
feature, including the no-hot-retry contract from the prior commit.
---
RELEASES.md | 62 +++++++++++
src/brainy.ts | 112 ++++++++++++++++++-
src/index.ts | 10 ++
src/plugin.ts | 83 ++++++++++++++
tests/unit/brainy/maintenance-debt.test.ts | 122 +++++++++++++++++++++
tests/unit/brainy/warm.test.ts | 88 +++++++++++++++
6 files changed, 471 insertions(+), 6 deletions(-)
create mode 100644 tests/unit/brainy/maintenance-debt.test.ts
diff --git a/RELEASES.md b/RELEASES.md
index 89bf38e7..03d7283a 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -31,6 +31,68 @@ is sometimes cited as a 7.x removal — those methods never existed on 7.x; the
---
+## v8.10.1 — 2026-07-24 (the no-hot-retry contract + warm()'s metadata surface under native providers)
+
+From a production incident: a native-provider op ground 38-40s inside a transaction,
+blew the ~32s apply-phase budget, was rolled back (zero loss, by design), and a
+downstream pipeline hot-retried the identical operation into a 6-minute, 100%-CPU
+storm. Investigation confirmed Brainy itself never auto-retries a timed-out
+transaction — the storm was entirely the consumer's own retry loop, driven by a
+"retryable" doc-prose claim with no machine-readable contract to branch on. This
+release closes that contract gap and, separately, fixes a real `warm()` reporting gap
+surfaced by the same investigation.
+
+- **`TransactionTimeoutError` is now a machine-readable no-hot-retry contract.** Two
+ new typed, always-`true` fields replace prose-only guidance:
+ - `retryable: true` — the operation MAY succeed on a later attempt, once the
+ underlying slowness resolves or the budget is deliberately raised
+ (`transactionBudgetFloorMs`, or a batch's own `timeoutMs` override).
+ - `hotRetryUnsafe: true` — an immediate, identical retry re-pays the FULL cost of
+ the work that just timed out (it does not resume partway) and can cascade into
+ exactly the CPU storm above. **Never loop on this error.** The documented pattern
+ is a latch, not a retry loop:
+ ```
+ on TransactionTimeoutError:
+ record { at: Date.now(), error }
+ rethrow loudly to your own caller
+ hold a cooldown window before any re-attempt
+ clear the latch only on a subsequent success
+ ```
+ - `context` (unchanged, now fully documented) carries the backoff inputs:
+ `timeoutMs`, `operationIndex`, `elapsedMs`, `totalOperations`, `operationName`.
+ - Every "retryable" doc-prose site referencing this error (`transact()`'s
+ `timeoutMs` option, `transactionBudgetFloorMs`, `Transaction.execute()`) now
+ points at these fields instead of bare prose.
+ - Regression-pinned: the engine never internally re-drives a timed-out operation
+ (verified via an execution counter through both the single-op write path and
+ `add()`'s upsert-race retry loop), so this has always been true — it is now
+ provable and typed.
+- **Dead code removed**: `TransactionManager.executeTransactionWithResult()` had zero
+ callers in this codebase and is deleted.
+- **`brain.warm()`'s metadata surface now routes through the ACTIVE provider.** A
+ production deployment's warm report showed `metadata: 'unavailable'` under a native
+ metadata provider — the previous logic only duck-typed the built-in JS manager's
+ `hydrateAll()` method, which a native provider has no reason to implement. The
+ metadata provider contract (`MetadataIndexProvider`, `src/plugin.ts`) gains an
+ optional `warm?(): Promise` hook, mirroring the existing vector and graph
+ provider hooks. `brain.warm()` now checks the active provider's own `warm()` FIRST,
+ falls back to the JS manager's `hydrateAll()` when absent, and only reports
+ `'unavailable'` when neither exists — never `init()` as a stand-in, since a native
+ provider's `init()` may be a cheap verify rather than a real warm. A native
+ provider lights this surface up the same way `@soulcraft/cor` already lights the
+ vector and graph surfaces: implement `warm()` on its metadata provider.
+- **New: `brain.maintenanceDebt()`** — the observability seam so an operator sees a
+ provider's outstanding background maintenance work (pending bytes/items, last pass
+ outcome, whether it's converging) BEFORE it grinds into the kind of budget-busting
+ op this release's timeout contract exists for, instead of discovering it as a CPU
+ storm. It is a pure passthrough: brainy applies no thresholds, no polling, and no
+ estimation — it calls each active provider's own optional `maintenanceDebt?()` hook
+ (vector, metadata, graph — the same three contracts `warm?()` lives on) and reports
+ the payload verbatim, or `'unavailable'` when a surface's provider doesn't track
+ debt. Useful as a pre-warm/post-warm check or a boot gate. `@soulcraft/cor` does not
+ yet implement the hook as of this release — expect it on cor's next release; until
+ then all three surfaces honestly report `'unavailable'`.
+
## Unreleased (the warm contract: cold-restart writes stop paying demand-load latency)
From a production deployment's cold-restart incident: the FIRST writes after every
diff --git a/src/brainy.ts b/src/brainy.ts
index 37b6e491..007cdb32 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -65,7 +65,8 @@ import type {
OpaqueIdSet,
AtGenerationVectors,
VectorIndexProvider,
- GraphIndexProvider
+ GraphIndexProvider,
+ ProviderMaintenanceDebt
} from './plugin.js'
import type {
BrainyPlugin,
@@ -424,6 +425,30 @@ export interface WarmReport {
totalDurationMs: number
}
+/**
+ * @description Result of {@link Brainy.maintenanceDebt}: one outcome per
+ * index surface, mirroring {@link WarmReport}'s shape.
+ * - `'reported'` — the active provider for this surface implements
+ * `maintenanceDebt?()` and its {@link ProviderMaintenanceDebt} payload is
+ * attached verbatim under `debt`.
+ * - `'unavailable'` — the active provider does not implement the hook, so
+ * nothing is known; brainy never estimates or infers a payload on its
+ * behalf.
+ */
+export type MaintenanceDebtOutcome = 'reported' | 'unavailable'
+
+/**
+ * @description Per-surface result of {@link Brainy.maintenanceDebt}. Brainy
+ * performs no thresholding, polling, or estimation over this data — it is a
+ * pure passthrough of each active provider's own self-report (the provider
+ * owns the numbers; the operator owns the policy).
+ */
+export interface MaintenanceDebtReport {
+ vector: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt }
+ metadata: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt }
+ graph: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt }
+}
+
/**
* How long a failed aggregation-backfill walk suppresses fresh walk attempts.
* Within the window, queries rethrow the recorded failure instantly (loud,
@@ -14313,9 +14338,16 @@ export class Brainy implements BrainyInterface {
* *some* backing storage as a side effect but is reported honestly as
* `'probed'`, never `'warmed'`. An empty index or unknown dimension has
* nothing to probe (`'unavailable'`).
- * - **Metadata**: full hydration — every persisted field's sparse index is
- * loaded from storage (`MetadataIndexManager.hydrateAll()`), not just the
- * heuristic common-fields subset `init()` warms.
+ * - **Metadata**: calls the provider's own `warm?()` when the active
+ * `'metadataIndex'` provider implements it (`'warmed'`) — the seam a
+ * native metadata provider lights up so it is not duck-typed against the
+ * JS manager's method. Otherwise falls back to full hydration on the
+ * built-in JS manager — every persisted field's sparse index is loaded
+ * from storage (`MetadataIndexManager.hydrateAll()`), not just the
+ * heuristic common-fields subset `init()` warms — and reports `'warmed'`.
+ * Neither seam present → `'unavailable'` (honest: `init()` is never used
+ * as a substitute here, since a native provider's `init()` may be a
+ * cheap verify rather than a real warm).
* - **Graph**: calls the provider's own `warm?()` when the active graph
* provider implements it; otherwise re-runs its existing eager cold-load
* `init()` seam (idempotent — the JS adjacency index's `init()` already
@@ -14371,12 +14403,23 @@ export class Brainy implements BrainyInterface {
// --- Metadata --------------------------------------------------------
const metadataStart = Date.now()
let metadataOutcome: WarmOutcome
+ const metadataProvider = this.metadataIndex as unknown as MetadataIndexProvider
const metadataWithHydrate = this.metadataIndex as unknown as { hydrateAll?: () => Promise }
- if (typeof metadataWithHydrate.hydrateAll === 'function') {
+ if (typeof metadataProvider.warm === 'function') {
+ // Active provider (e.g. a native metadata index) declares its own warm
+ // seam — route through it FIRST so a native provider's warmth is
+ // reported honestly instead of being duck-typed against the JS
+ // manager's hydrateAll(), which a native provider does not implement.
+ await metadataProvider.warm()
+ metadataOutcome = 'warmed'
+ } else if (typeof metadataWithHydrate.hydrateAll === 'function') {
+ // Built-in JS manager path — full sparse-index hydration.
await metadataWithHydrate.hydrateAll()
metadataOutcome = 'warmed'
} else {
- // No hydration seam on this metadata provider — nothing to run.
+ // No hydration seam on this metadata provider — nothing to run. (No
+ // init() fallback here: init() on a native provider may be a cheap
+ // verify, and reporting that as warmth would lie.)
metadataOutcome = 'unavailable'
}
const metadataDurationMs = Date.now() - metadataStart
@@ -14410,6 +14453,63 @@ export class Brainy implements BrainyInterface {
}
}
+ /**
+ * Read each index surface's self-reported outstanding maintenance work —
+ * the observability seam so an operator sees a grind coming (rising
+ * pending bytes/items, a stalled background pass) instead of discovering
+ * it as a CPU storm or a transaction blowing its budget mid-flight (see
+ * {@link TransactionTimeoutError}).
+ *
+ * PURE PASSTHROUGH: for each of vector/metadata/graph, this calls ONLY the
+ * ACTIVE provider's own `maintenanceDebt?()` hook (the same per-surface
+ * provider resolution {@link Brainy.warm} uses) and reports its
+ * {@link ProviderMaintenanceDebt} payload verbatim. There is no JS-side
+ * fallback computation, no threshold evaluation, and no polling — brainy
+ * surfaces the truth the provider measured; the provider owns the numbers
+ * and the operator owns the policy (what threshold matters, what action to
+ * take). A surface whose active provider does not implement the hook
+ * reports `'unavailable'` — never a guessed or zeroed payload.
+ *
+ * @returns A {@link MaintenanceDebtReport}: per-surface outcome + payload.
+ * @example
+ * ```typescript
+ * const debt = await brain.maintenanceDebt()
+ * if (debt.metadata.outcome === 'reported' && debt.metadata.debt?.pendingBytes) {
+ * console.log('metadata pending bytes:', debt.metadata.debt.pendingBytes)
+ * }
+ * ```
+ */
+ async maintenanceDebt(): Promise {
+ await this.ensureInitialized({ needs: ['vector', 'metadata', 'graph'] })
+
+ // --- Vector ---------------------------------------------------------
+ const vectorProvider = this.index as VectorIndexProvider & {
+ maintenanceDebt?: () => Promise
+ }
+ const vector =
+ typeof vectorProvider.maintenanceDebt === 'function'
+ ? { outcome: 'reported' as const, debt: await vectorProvider.maintenanceDebt() }
+ : { outcome: 'unavailable' as const }
+
+ // --- Metadata --------------------------------------------------------
+ const metadataProvider = this.metadataIndex as unknown as MetadataIndexProvider
+ const metadata =
+ typeof metadataProvider.maintenanceDebt === 'function'
+ ? { outcome: 'reported' as const, debt: await metadataProvider.maintenanceDebt() }
+ : { outcome: 'unavailable' as const }
+
+ // --- Graph -------------------------------------------------------------
+ const graphProvider = this.graphIndex as GraphIndexProvider & {
+ maintenanceDebt?: () => Promise
+ }
+ const graph =
+ typeof graphProvider.maintenanceDebt === 'function'
+ ? { outcome: 'reported' as const, debt: await graphProvider.maintenanceDebt() }
+ : { outcome: 'unavailable' as const }
+
+ return { vector, metadata, graph }
+ }
+
/**
* Explicitly warm up the embedding engine
*
diff --git a/src/index.ts b/src/index.ts
index 00adc191..e60739f7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -31,6 +31,11 @@ export type { DiagnosticsResult } from './brainy.js'
// brain.warm() — eager index/storage readiness report (per-surface honest
// outcome + timing). See the WarmReport JSDoc in brainy.ts.
export type { WarmReport, WarmOutcome } from './brainy.js'
+// brain.maintenanceDebt() — per-surface passthrough of each active
+// provider's self-reported background maintenance debt. See the
+// MaintenanceDebtReport JSDoc in brainy.ts and ProviderMaintenanceDebt in
+// plugin.ts for the measure-only-what-you-track contract.
+export type { MaintenanceDebtReport, MaintenanceDebtOutcome } from './brainy.js'
export type {
GraphAuditReport,
GraphAuditDiscrepancy
@@ -227,6 +232,11 @@ export type { FamilyStamp, StampMembers, StampVerdict } from './db/familyStamp.j
export { isVersionedIndexProvider } from './plugin.js'
export type { VersionedIndexProvider } from './plugin.js'
export type { ProviderInvariantReport, InvariantResult, InvariantHeal } from './plugin.js'
+// Optional provider self-report of outstanding background maintenance work
+// (compaction, deferred writes, etc.) — the payload type for
+// brain.maintenanceDebt(). See the measure-only-what-you-track contract on
+// ProviderMaintenanceDebt in plugin.ts.
+export type { ProviderMaintenanceDebt } from './plugin.js'
// Optional native graph-acceleration engine (cor 3.0) — the published provider
// contract + its columnar wire types. Brainy feature-detects an implementation
// and falls back to its pure-TS adjacency when absent.
diff --git a/src/plugin.ts b/src/plugin.ts
index 02639955..ce973386 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -171,6 +171,37 @@ export interface ProviderInvariantReport {
durationMs: number
}
+/**
+ * @description A provider's self-report of its own outstanding background
+ * maintenance work (compaction, deferred writes, a build-new→verify→swap in
+ * flight, etc.) — the observability seam so an operator sees a grind coming
+ * (rising pending bytes/items, a stalled pass) instead of discovering it as a
+ * CPU storm or a timeout under transaction budget pressure. Every field is
+ * OPTIONAL and every field is a MEASUREMENT: a provider reports ONLY what it
+ * actually tracks, never an estimate dressed up as a fact. Absence of the
+ * {@link VectorIndexProvider.maintenanceDebt} /
+ * {@link GraphIndexProvider.maintenanceDebt} /
+ * {@link MetadataIndexProvider.maintenanceDebt} hook itself means the
+ * provider does not track debt at all — brainy reports that surface
+ * `'unavailable'` rather than inventing zeros. Brainy performs NO threshold
+ * checks, NO polling, and NO JS-side estimation over this payload — it is a
+ * pure passthrough via {@link Brainy.maintenanceDebt}; the provider owns the
+ * numbers and the operator owns the policy (what threshold matters, what to
+ * do about it).
+ */
+export interface ProviderMaintenanceDebt {
+ /** Bytes of outstanding/unmerged work, if the provider measures it (e.g. unflushed writes, unmerged segments). */
+ pendingBytes?: number
+ /** Count of outstanding items (records, segments, nodes) awaiting the provider's background pass. */
+ pendingItems?: number
+ /** Epoch millis when the provider's last maintenance pass finished, if it tracks one. */
+ lastPassCompletedAt?: number
+ /** How the last pass ended, if the provider tracks pass outcomes. */
+ lastPassOutcome?: 'completed' | 'partial' | 'failed'
+ /** `true` if the provider's own measurements show debt trending down (making progress); `false` if flat or growing; omitted if the provider can't tell. */
+ converging?: boolean
+}
+
/**
* The `'metadataIndex'` provider — a drop-in for `MetadataIndexManager`.
* Brainy calls this surface via `this.metadataIndex.*` (see `brainy.ts`) and
@@ -181,6 +212,34 @@ export interface MetadataIndexProvider {
flush(): Promise
rebuild(): Promise
+ /**
+ * @description OPTIONAL. Eagerly load/fault-in backing storage (e.g. mmap
+ * pretouch, full sparse-index hydration) so first queries run at
+ * steady-state cost. Optional; absence means the provider demand-loads.
+ * Mirrors {@link GraphIndexProvider.warm} / the vector provider's `warm?()`
+ * (`src/plugin.ts` VectorIndexProvider). Distinct from `init()`: `init` is
+ * required and runs once automatically during brain startup; `warm` is a
+ * separate, explicit step a caller opts into via `brain.warm()` (or
+ * `warmOnOpen`) to pre-pay demand-load cost `init` left lazy. Idempotent —
+ * calling it more than once must be safe and cheap on a brain that is
+ * already warm. A provider that already loads everything eagerly in
+ * `init()` may implement `warm` as a no-op or omit it — `brain.warm()`
+ * falls back to the built-in JS manager's `hydrateAll()` duck-type when
+ * absent, and to an honest `'unavailable'` when neither exists.
+ */
+ warm?(): Promise
+
+ /**
+ * @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} —
+ * the observability seam so an operator sees outstanding background
+ * maintenance work (e.g. unmerged postings) BEFORE it grinds a transaction
+ * into a budget-busting op. Absence means this provider does not track
+ * debt; `brain.maintenanceDebt()` reports this surface `'unavailable'`
+ * rather than guessing. See {@link ProviderMaintenanceDebt} for the
+ * measure-only-what-you-track contract.
+ */
+ maintenanceDebt?(): Promise
+
/**
* @description OPTIONAL honest durability signal (readiness contract,
* mirrors `isReady?()` on the graph and vector providers). `true` ⇔ the
@@ -395,6 +454,18 @@ export interface GraphIndexProvider {
*/
warm?(): Promise
+ /**
+ * @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} —
+ * the observability seam so an operator sees outstanding background
+ * maintenance work (e.g. a build-new→verify→swap in flight, unmerged
+ * adjacency segments) BEFORE it grinds a transaction into a
+ * budget-busting op. Absence means this provider does not track debt;
+ * `brain.maintenanceDebt()` reports this surface `'unavailable'` rather
+ * than guessing. See {@link ProviderMaintenanceDebt} for the
+ * measure-only-what-you-track contract.
+ */
+ maintenanceDebt?(): Promise
+
/**
* @description OPTIONAL. A native provider returns true from the moment its
* `init()` detects a large epoch-drift until its background
@@ -1057,6 +1128,18 @@ export interface VectorIndexProvider {
*/
warm?(): Promise
+ /**
+ * @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} —
+ * the observability seam so an operator sees outstanding background
+ * maintenance work (e.g. unflushed writes, a pending rebuild) BEFORE it
+ * grinds a transaction into a budget-busting op. Absence means this
+ * provider does not track debt; `brain.maintenanceDebt()` reports this
+ * surface `'unavailable'` rather than guessing. See
+ * {@link ProviderMaintenanceDebt} for the measure-only-what-you-track
+ * contract.
+ */
+ maintenanceDebt?(): Promise
+
/**
* @description OPTIONAL honest durability signal (readiness contract,
* mirrors {@link GraphIndexProvider.isReady}). `true` ⇔ the persisted
diff --git a/tests/unit/brainy/maintenance-debt.test.ts b/tests/unit/brainy/maintenance-debt.test.ts
new file mode 100644
index 00000000..4026d655
--- /dev/null
+++ b/tests/unit/brainy/maintenance-debt.test.ts
@@ -0,0 +1,122 @@
+/**
+ * @module tests/unit/brainy/maintenance-debt
+ * @description Coverage for `brain.maintenanceDebt()` (8.10.1) — the
+ * observability seam so an operator sees a provider's outstanding background
+ * maintenance work (compaction, deferred writes, a build-new→verify→swap in
+ * flight, ...) BEFORE it grinds a transaction into a budget-busting op, the
+ * same failure class documented on `TransactionTimeoutError`
+ * (src/transaction/errors.ts). Sibling to tests/unit/brainy/warm.test.ts,
+ * which establishes this file's technique: shape the probe points brain.ts
+ * reads (`typeof provider.maintenanceDebt === 'function'`) directly on the
+ * REAL, live provider instances rather than hand-rolling full fakes for the
+ * larger `MetadataIndexProvider` / `GraphIndexProvider` interfaces.
+ *
+ * `brain.maintenanceDebt()` is a PURE PASSTHROUGH: no thresholds, no
+ * polling, no JS-side estimation — these tests pin exactly that by asserting
+ * the returned payload is the provider's object, verbatim.
+ */
+import { describe, it, expect } from 'vitest'
+import { Brainy } from '../../../src/brainy.js'
+import { NounType } from '../../../src/types/graphTypes.js'
+import type { ProviderMaintenanceDebt } from '../../../src/plugin.js'
+
+// Brainy's ValidationConfig fixes vectors at exactly 384 dimensions
+// (src/utils/paramValidation.ts) — match it so add() doesn't reject test data.
+const DIM = 384
+const V = (seed = 1): number[] => Array.from({ length: DIM }, (_, i) => Math.sin(seed + i))
+
+async function freshBrain(): Promise> {
+ const brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' },
+ silent: true
+ })
+ await brain.init()
+ await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
+ return brain
+}
+
+describe('brain.maintenanceDebt()', () => {
+ it('reports "unavailable" for every surface when no active provider implements maintenanceDebt() (the built-in JS stack today)', async () => {
+ const brain = await freshBrain()
+
+ const report = await brain.maintenanceDebt()
+
+ expect(report.vector.outcome).toBe('unavailable')
+ expect(report.vector.debt).toBeUndefined()
+ expect(report.metadata.outcome).toBe('unavailable')
+ expect(report.metadata.debt).toBeUndefined()
+ expect(report.graph.outcome).toBe('unavailable')
+ expect(report.graph.debt).toBeUndefined()
+
+ await brain.close()
+ })
+
+ it('reports "reported" + the exact payload when the active provider implements maintenanceDebt() (verbatim passthrough, no thresholding)', async () => {
+ const brain = await freshBrain()
+
+ const vectorDebt: ProviderMaintenanceDebt = {
+ pendingBytes: 4_096,
+ pendingItems: 12,
+ lastPassCompletedAt: 1_700_000_000_000,
+ lastPassOutcome: 'completed',
+ converging: true
+ }
+ ;(brain as any).index.maintenanceDebt = async () => vectorDebt
+
+ const report = await brain.maintenanceDebt()
+
+ expect(report.vector.outcome).toBe('reported')
+ // Verbatim passthrough — the exact object, not a re-derived copy.
+ expect(report.vector.debt).toBe(vectorDebt)
+ // Untouched surfaces stay honestly 'unavailable'.
+ expect(report.metadata.outcome).toBe('unavailable')
+ expect(report.graph.outcome).toBe('unavailable')
+
+ await brain.close()
+ })
+
+ it('mixed surfaces: each surface\'s outcome depends ONLY on its OWN active provider — one surface reporting never leaks into another', async () => {
+ const brain = await freshBrain()
+
+ const metadataDebt: ProviderMaintenanceDebt = {
+ pendingItems: 3,
+ lastPassOutcome: 'partial',
+ converging: false
+ }
+ const graphDebt: ProviderMaintenanceDebt = {
+ pendingBytes: 0,
+ converging: true
+ }
+ ;(brain as any).metadataIndex.maintenanceDebt = async () => metadataDebt
+ ;(brain as any).graphIndex.maintenanceDebt = async () => graphDebt
+ // Vector is deliberately left unpatched.
+
+ const report = await brain.maintenanceDebt()
+
+ expect(report.vector.outcome).toBe('unavailable')
+ expect(report.vector.debt).toBeUndefined()
+
+ expect(report.metadata.outcome).toBe('reported')
+ expect(report.metadata.debt).toBe(metadataDebt)
+
+ expect(report.graph.outcome).toBe('reported')
+ expect(report.graph.debt).toBe(graphDebt)
+
+ await brain.close()
+ })
+
+ it('an empty ProviderMaintenanceDebt object (every field omitted) is still honestly "reported" — presence of the hook, not the payload\'s richness, drives the outcome', async () => {
+ const brain = await freshBrain()
+
+ const emptyDebt: ProviderMaintenanceDebt = {}
+ ;(brain as any).graphIndex.maintenanceDebt = async () => emptyDebt
+
+ const report = await brain.maintenanceDebt()
+
+ expect(report.graph.outcome).toBe('reported')
+ expect(report.graph.debt).toEqual({})
+
+ await brain.close()
+ })
+})
diff --git a/tests/unit/brainy/warm.test.ts b/tests/unit/brainy/warm.test.ts
index ce213696..8a0bb4da 100644
--- a/tests/unit/brainy/warm.test.ts
+++ b/tests/unit/brainy/warm.test.ts
@@ -307,4 +307,92 @@ describe('brain.warm()', () => {
expect(report.totalDurationMs).toBeGreaterThanOrEqual(0)
await brain.close()
})
+
+ // --- Metadata leg routes through the ACTIVE provider (8.10.1) -----------
+ //
+ // `MetadataIndexProvider` is a ~50-method interface (src/plugin.ts) — far
+ // too large to hand-write a compliant fake class the way `FakeVectorProvider`
+ // fakes the ~8-method `VectorIndexProvider` above. Test (c) already
+ // establishes this file's pattern for the metadata leg: exercise the REAL
+ // `MetadataIndexManager` instance and shape just the probe points brain.ts
+ // reads (`typeof provider.warm === 'function'` /
+ // `typeof provider.hydrateAll === 'function'`) directly on that instance.
+ // Shadowing an own property on the live object stands in for "a different
+ // provider implementation" without needing a hand-rolled full fake — the
+ // rest of the real manager (used by add()/init() above) is untouched.
+ describe('metadata leg — warm() routes through the active provider', () => {
+ it('(f) calls the ACTIVE metadata provider\'s warm() when present and reports "warmed", never falling back to hydrateAll', async () => {
+ const brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' },
+ silent: true
+ })
+ await brain.init()
+ await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
+
+ const metadataIndex = (brain as any).metadataIndex
+ let warmCalls = 0
+ let hydrateAllCalls = 0
+ const origHydrateAll = metadataIndex.hydrateAll.bind(metadataIndex)
+ metadataIndex.hydrateAll = async (...args: unknown[]) => {
+ hydrateAllCalls++
+ return origHydrateAll(...args)
+ }
+ // Simulates a native metadata provider declaring the optional `warm()`
+ // hook added to `MetadataIndexProvider` (src/plugin.ts) in 8.10.1.
+ metadataIndex.warm = async () => {
+ warmCalls++
+ }
+
+ const report = await brain.warm()
+
+ expect(warmCalls).toBe(1)
+ expect(hydrateAllCalls).toBe(0) // warm() ran — no hydrateAll fallback
+ expect(report.metadata.outcome).toBe('warmed')
+ await brain.close()
+ })
+
+ it('reports "unavailable" when the active metadata provider implements neither warm() nor hydrateAll() (the honest branch a native provider without either hook must hit)', async () => {
+ const brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' },
+ silent: true
+ })
+ await brain.init()
+ await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
+
+ const metadataIndex = (brain as any).metadataIndex
+ // Shadow away BOTH optional hooks — models a genuinely native provider
+ // that (unlike the built-in JS manager) offers neither seam. This must
+ // never fall back to calling init() as a stand-in for warmth.
+ metadataIndex.warm = undefined
+ metadataIndex.hydrateAll = undefined
+
+ const report = await brain.warm()
+
+ expect(report.metadata.outcome).toBe('unavailable')
+ await brain.close()
+ })
+
+ it('the built-in JS manager (no warm()) still reports "warmed" via its existing hydrateAll() duck-type — unchanged by the new provider hook', async () => {
+ const brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' },
+ silent: true
+ })
+ await brain.init()
+ await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) })
+
+ // No patching at all — the default built-in MetadataIndexManager has
+ // hydrateAll() but no warm(), exactly as it did before this change.
+ const metadataIndex = (brain as any).metadataIndex
+ expect(typeof metadataIndex.warm).not.toBe('function')
+ expect(typeof metadataIndex.hydrateAll).toBe('function')
+
+ const report = await brain.warm()
+
+ expect(report.metadata.outcome).toBe('warmed')
+ await brain.close()
+ })
+ })
})
From edf123a5e232919881ae9d5bfaa4877c7ee457ee Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Fri, 24 Jul 2026 16:04:41 -0700
Subject: [PATCH 019/175] refactor: remove the orphaned transaction-result type
left behind by the dead-path removal
---
src/transaction/types.ts | 20 --------------------
1 file changed, 20 deletions(-)
diff --git a/src/transaction/types.ts b/src/transaction/types.ts
index 9a3a2eaa..6cbc56ca 100644
--- a/src/transaction/types.ts
+++ b/src/transaction/types.ts
@@ -66,26 +66,6 @@ export interface TransactionContext {
*/
export type TransactionFunction = (ctx: TransactionContext) => Promise
-/**
- * Transaction execution result
- */
-export interface TransactionResult {
- /**
- * Result value from user function
- */
- value: T
-
- /**
- * Number of operations executed
- */
- operationCount: number
-
- /**
- * Execution time in milliseconds
- */
- executionTimeMs: number
-}
-
/**
* Transaction execution options
*/
From d9cc7b9024aff3fbffeb2fee658543d81fb4c0c9 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Fri, 24 Jul 2026 16:09:47 -0700
Subject: [PATCH 020/175] chore(release): 8.10.1
---
CHANGELOG.md | 8 ++++++++
package-lock.json | 4 ++--
package.json | 2 +-
3 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6dd91fd..a5f344cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
+### [8.10.1](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.0...v8.10.1) (2026-07-24)
+
+- refactor: remove the orphaned transaction-result type left behind by the dead-path removal (edf123a5)
+- fix: warm() metadata surface routes through the active provider (warm hook added to the metadata contract); add maintenanceDebt() observability surface (5b2cbf74)
+- fix: transaction timeouts are a typed no-hot-retry contract; engine-side non-retry pinned; dead transaction path removed (003e2a74)
+- chore: the forge is the address — retire the archived mirror from every live surface (22702b81)
+
+
### [8.10.0](https://github.com/soulcraftlabs/brainy/compare/v8.9.0...v8.10.0) (2026-07-23)
- docs: adoption storefront — contributing guide, security policy, README support + cor section (9a99a7b)
diff --git a/package-lock.json b/package-lock.json
index 37aeb81d..d0c7b9d9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@soulcraft/brainy",
- "version": "8.10.0",
+ "version": "8.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@soulcraft/brainy",
- "version": "8.10.0",
+ "version": "8.10.1",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
diff --git a/package.json b/package.json
index a3ece83c..ce670369 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@soulcraft/brainy",
- "version": "8.10.0",
+ "version": "8.10.1",
"description": "Universal Knowledge Protocol™ - World's first Triple Intelligence database unifying vector, graph, and document search in one API. Stage 3 CANONICAL: 42 nouns × 127 verbs covering 96-97% of all human knowledge.",
"main": "dist/index.js",
"module": "dist/index.js",
From 999d0ebbcfb94984ff066534863e532ed7133f80 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Wed, 22 Jul 2026 16:31:45 +0200
Subject: [PATCH 021/175] ci: run the pipeline on the forge
---
.forgejo/workflows/ci.yml | 40 +++++++++++++++++++++++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 .forgejo/workflows/ci.yml
diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 00000000..cdb2ab14
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,40 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ node:
+ name: Node ${{ matrix.node-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: ['22', '24']
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: npm
+ - run: npm ci
+ - run: npm run test:unit
+
+ bun:
+ name: Bun (latest)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ - run: npm ci
+ # test:bun imports the built dist/, so build first.
+ - run: npm run build
+ # Bun as a runtime is the supported Bun story (`bun add` / `bun run`).
+ - run: npm run test:bun
From 4d196af41bd041c63a24b00b74541781dd1aeb54 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 27 Jul 2026 11:08:19 -0700
Subject: [PATCH 022/175] =?UTF-8?q?feat:=20canonical=20enumeration=20mode?=
=?UTF-8?q?=20for=20export=20=E2=80=94=20storage-walked,=20canon-complete,?=
=?UTF-8?q?=20with=20an=20index-drift=20report?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
RELEASES.md | 31 +++
src/db/db.ts | 22 +-
src/db/errors.ts | 64 +++++-
src/db/portableGraph.ts | 280 ++++++++++++++++++++++--
src/index.ts | 4 +-
tests/unit/db/db-portable-graph.test.ts | 162 +++++++++++++-
6 files changed, 543 insertions(+), 20 deletions(-)
diff --git a/RELEASES.md b/RELEASES.md
index 03d7283a..dee17a44 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -31,6 +31,37 @@ is sometimes cited as a 7.x removal — those methods never existed on 7.x; the
---
+## Unreleased (canonical enumeration mode for export — storage-walked, canon-complete)
+
+From a fleet data-migration program's requirement for whole-brain exports that are
+provably canon-complete: `export()`'s default enumeration for a whole-brain/predicate
+selector is a generation-correct paginated `find()` walk — a projection query riding
+the metadata index as an acceleration structure. Production has documented both of the
+index's failure classes: a lost/stale posting can silently OMIT a canonical record from
+an export, and a stale posting can silently INCLUDE a phantom row. Neither is visible
+to the caller today.
+
+- **New: `export(selector, { enumeration: 'canonical' })`** (default remains `'index'` —
+ unchanged behavior on this release). Canonical mode walks every live noun/verb
+ directly off the storage adapter's canonical shard layout (`storage.getNouns()` /
+ `getVerbs()` — the same primitive `repairIndex()`'s recount and every index-heal
+ walk use) instead of the metadata/graph indexes, then applies the selector as a
+ plain predicate over the walked records. This guarantees canon-completeness — index
+ corruption cannot hide a live record from the export — at the cost of an O(N) walk
+ regardless of selector selectivity. Relations are also walked canonically in this
+ mode, for every selector, not just the whole-brain case. Requires the LIVE current
+ generation: called on a historical `asOf()` view or a speculative `with()` overlay it
+ throws `CanonicalEnumerationUnavailableError` rather than silently mixing generations
+ or missing an overlay's own entities — `enumeration: 'index'` (the default) is
+ unaffected and still composes with `asOf()`/`with()` as before.
+- **New: `export(selector, { enumeration: 'canonical', reportIndexDrift: true })`** —
+ also runs the index-based enumeration and diffs it against canonical ground truth,
+ attaching `PortableGraph.drift: { canonicalOnly: string[], indexOnly: string[] }`
+ (canon-present ids the index missed; index-visible ids canon-absent — phantoms).
+ Migration-audit evidence, not a repair: nonzero drift is reported loudly
+ (`console.warn` with the counts) and nothing is auto-healed — run `brain.repairIndex()`
+ to reconcile the metadata index once drift is confirmed.
+
## v8.10.1 — 2026-07-24 (the no-hot-retry contract + warm()'s metadata surface under native providers)
From a production incident: a native-provider op ground 38-40s inside a transaction,
diff --git a/src/db/db.ts b/src/db/db.ts
index ac927fc5..ca9c133b 100644
--- a/src/db/db.ts
+++ b/src/db/db.ts
@@ -66,7 +66,7 @@ import {
import { v4 as uuidv4 } from '../universal/uuid.js'
import { coerceNewEntityId, resolveEntityId, ORIGINAL_ID_KEY } from '../utils/idNormalization.js'
import { EntityNotFoundError } from '../errors/notFound.js'
-import { SpeculativeOverlayError } from './errors.js'
+import { SpeculativeOverlayError, CanonicalEnumerationUnavailableError } from './errors.js'
import type { GenerationStore } from './generationStore.js'
import type { ChangedIds, TransactReceipt, TxOperation } from './types.js'
import { entityMatchesFind, resolveEntityField, UnsupportedWhereOperatorError } from './whereMatcher.js'
@@ -520,14 +520,32 @@ export class Db {
* (no generation history) — distinct from `persist()` (native whole-brain snapshot
* that preserves history). Restore with `brain.import(backup)`.
*
+ * `options.enumeration: 'canonical'` (default: `'index'`) walks the storage
+ * adapter's canonical noun/verb layout directly instead of the metadata/graph
+ * indexes, guaranteeing canon-completeness against index corruption — see
+ * {@link ExportOptions.enumeration}. It requires the LIVE, current-generation
+ * view: called on a historical `asOf()` pin or a speculative `with()` overlay it
+ * throws {@link CanonicalEnumerationUnavailableError} rather than silently mixing
+ * generations or missing the overlay's own entities.
+ *
* @param selector - WHAT to export (omit for the whole brain). See {@link ExportSelector}.
- * @param options - HOW to export (vectors / VFS bytes / edge policy). See {@link ExportOptions}.
+ * @param options - HOW to export (vectors / VFS bytes / edge policy / enumeration mode). See {@link ExportOptions}.
* @returns A versioned, portable `PortableGraph` document.
+ * @throws {@link CanonicalEnumerationUnavailableError} if `enumeration:'canonical'` is
+ * requested on a historical or speculative-overlay view.
* @example
* const backup = await brain.now().export({ collection: id }, { includeVectors: true })
+ * @example
+ * // Canon-complete audit export, with an index-drift report attached.
+ * const audit = await brain.now().export({}, { enumeration: 'canonical', reportIndexDrift: true })
+ * if (audit.drift) console.log(audit.drift.canonicalOnly, audit.drift.indexOnly)
*/
async export(selector: ExportSelector = {}, options: ExportOptions = {}): Promise {
this.assertUsable('export')
+ if (options.enumeration === 'canonical') {
+ if (this.overlay) throw new CanonicalEnumerationUnavailableError(this.gen, 'overlay')
+ if (this.isHistorical()) throw new CanonicalEnumerationUnavailableError(this.gen, 'historical')
+ }
return exportGraph(this, this.host.storage, selector, options)
}
diff --git a/src/db/errors.ts b/src/db/errors.ts
index 22f405be..e20488f8 100644
--- a/src/db/errors.ts
+++ b/src/db/errors.ts
@@ -23,8 +23,12 @@
* serve the full query surface via at-generation index materialization.
* - {@link GenerationCompactedError} — `asOf()` asked for a generation whose
* immutable records were reclaimed by `compactHistory()`.
+ * - {@link CanonicalEnumerationUnavailableError} — `export()`'s
+ * `enumeration:'canonical'` mode was called on a historical `asOf()` view or a
+ * speculative `with()` overlay; the canonical storage walk only ever answers
+ * "what is live right now."
*
- * All three are exported from the package root (`@soulcraft/brainy`).
+ * All are exported from the package root (`@soulcraft/brainy`).
*/
/**
@@ -160,6 +164,64 @@ export class GenerationCompactedError extends Error {
}
}
+/**
+ * @description Thrown by `db.export(selector, { enumeration: 'canonical' })` when
+ * the `Db` it is called on is not the live, current-generation view: a historical
+ * `brain.asOf(g)` pin, or a speculative `db.with()` overlay.
+ *
+ * Canonical enumeration mode walks the storage adapter's canonical shard layout
+ * directly (`storage.getNouns()`/`getVerbs()`) instead of the metadata/graph
+ * indexes — but that walk has no generation parameter, it can only ever answer
+ * "what is live right now." Serving it against a historical pin would silently
+ * mix generations (today's canonical records under yesterday's selector), and
+ * against a speculative overlay it would silently miss the overlay's own
+ * in-memory entities (which never touched storage). Both are exactly the kind of
+ * silently-wrong result canonical mode exists to prevent elsewhere — so this
+ * boundary throws instead.
+ *
+ * `enumeration: 'index'` (the default) is unaffected: it composes with
+ * `asOf()`/`with()` exactly as before, via the generation-correct `find()` walk.
+ *
+ * @example
+ * const past = await brain.asOf(g1)
+ * try {
+ * await past.export({}, { enumeration: 'canonical' })
+ * } catch (err) {
+ * if (err instanceof CanonicalEnumerationUnavailableError) {
+ * // Time-travel export: use the default index-based enumeration instead.
+ * await past.export({}, { enumeration: 'index' })
+ * }
+ * }
+ */
+export class CanonicalEnumerationUnavailableError extends Error {
+ /** The view's pinned generation. */
+ public readonly generation: number
+ /** Why canonical mode cannot serve this view. */
+ public readonly reason: 'historical' | 'overlay'
+
+ /**
+ * @param generation - The view's pinned generation.
+ * @param reason - `'historical'` (a past `asOf()` pin) or `'overlay'` (a speculative `with()`).
+ */
+ constructor(generation: number, reason: 'historical' | 'overlay') {
+ const what =
+ reason === 'historical'
+ ? `a historical view pinned at generation ${generation}`
+ : `a speculative with() overlay (base generation ${generation})`
+ super(
+ `export()'s enumeration:'canonical' requires the live, current-generation view — ` +
+ `it was called on ${what}. The canonical storage walk has no generation parameter, ` +
+ `so it can only answer "what is live right now"; serving it here would silently ` +
+ `mix generations (historical) or miss the overlay's own in-memory entities ` +
+ `(overlay). Use enumeration:'index' (the default) for a time-travel or what-if ` +
+ `export, or pin brain.now() for a live canonical export.`
+ )
+ this.name = 'CanonicalEnumerationUnavailableError'
+ this.generation = generation
+ this.reason = reason
+ }
+}
+
/** One entity/relationship left in an unreconciled state by a failed rollback. */
export interface UnreconciledRecord {
/** The entity or relationship id. */
diff --git a/src/db/portableGraph.ts b/src/db/portableGraph.ts
index d8f57b78..4c50ef5b 100644
--- a/src/db/portableGraph.ts
+++ b/src/db/portableGraph.ts
@@ -27,7 +27,7 @@
import { Entity, Relation, Result } from '../types/brainy.types.js'
import { NounType, VerbType } from '../types/graphTypes.js'
-import { StorageAdapter } from '../coreTypes.js'
+import { StorageAdapter, HNSWVerbWithMetadata } from '../coreTypes.js'
import { getBrainyVersion } from '../utils/version.js'
import { TxOperation } from './types.js'
@@ -96,6 +96,67 @@ export interface ExportOptions {
includeSystem?: boolean
/** Which edges to include (default: `'induced'`). */
edges?: 'induced' | 'incident' | 'none'
+ /**
+ * How the whole-brain / predicate selector (no `ids`/`collection`/`connected`/
+ * `vfsPath`) resolves its candidate id set:
+ *
+ * - `'index'` (DEFAULT — unchanged behavior) — the generation-correct
+ * paginated `find()` walk. Fast (O(matches), not O(N)) but rides the
+ * metadata index as an acceleration structure: a lost/stale index posting
+ * can silently OMIT a canonical record, and a stale posting pointing at a
+ * record that no longer matches can silently produce a phantom (dropped
+ * later by the same predicate re-check `'canonical'` mode also runs, so
+ * phantoms never reach `entities` — but they ARE lost silently unless
+ * {@link reportIndexDrift} is set).
+ * - `'canonical'` — walks every live noun/verb directly off the storage
+ * adapter's canonical shard layout (`storage.getNouns()`/`getVerbs()` —
+ * the same primitive `repairIndex()`'s recount and every index-heal walk
+ * use), then applies the selector as a plain predicate over the walked
+ * records. This GUARANTEES canon-completeness — the metadata/graph
+ * indexes are never consulted, so their corruption cannot hide a live
+ * record — at the cost of an O(N) walk regardless of selector
+ * selectivity (unlike the index path's O(matches)). Structural selectors
+ * (`ids`/`collection`/`connected`/`vfsPath`) resolve their node set
+ * exactly as in `'index'` mode either way (they never rode the metadata
+ * index); `'canonical'` additionally walks relations canonically for
+ * EVERY selector, since a lost adjacency-index posting can hide a
+ * relation regardless of how the node set was produced. Requires a
+ * storage adapter and the CURRENT generation — throws on a historical
+ * `asOf()` view or a speculative `with()` overlay (see
+ * {@link CanonicalEnumerationUnavailableError}), because the canonical
+ * walk has no notion of "as of a past generation."
+ */
+ enumeration?: 'index' | 'canonical'
+ /**
+ * Only meaningful with `enumeration:'canonical'` (ignored otherwise): ALSO
+ * run the `'index'` enumeration in parallel and diff it against the
+ * canonical ground truth, attaching the result as {@link PortableGraph.drift}.
+ * Never auto-heals anything — this is migration-audit evidence, reported
+ * loudly (`console.warn` with the counts) whenever either list is non-empty,
+ * never silently. Default: false.
+ */
+ reportIndexDrift?: boolean
+}
+
+/**
+ * @description Index-vs-canonical drift for one `export({ enumeration: 'canonical',
+ * reportIndexDrift: true })` call. Only populated for the whole-brain / predicate
+ * selector (structural selectors never consulted the metadata index for their node
+ * set, so there is nothing to diff — both lists are empty for those).
+ */
+export interface ExportIndexDrift {
+ /**
+ * Ids the canonical storage walk confirmed (live, selector-matching) that the
+ * index-based `find()` enumeration did NOT return — canonical records the
+ * metadata index has lost track of.
+ */
+ canonicalOnly: string[]
+ /**
+ * Ids the index-based `find()` enumeration returned for this selector that
+ * canonical ground truth (the storage walk + the same predicate check) does
+ * NOT support — phantom index rows (stale or cross-bucket postings).
+ */
+ indexOnly: string[]
}
/** Controls how a `PortableGraph` is applied on `import()`. */
@@ -151,6 +212,8 @@ export interface PortableGraph {
relations: PortableGraphRelation[]
blobs?: Record
danglingIds?: string[]
+ /** Present only when `export()` was called with `reportIndexDrift: true`. */
+ drift?: ExportIndexDrift
stats: { entityCount: number; relationCount: number; blobCount: number; vectorDimensions?: number }
}
@@ -271,11 +334,19 @@ export function validatePortableGraph(data: unknown): PortableGraphValidation {
/**
* @description Serialize part or all of a graph (read through `reader` at its pinned
* generation) into a portable `PortableGraph` document.
+ *
+ * `enumeration:'canonical'` (see {@link ExportOptions.enumeration}) requires a
+ * storage adapter and the current generation: it throws
+ * {@link CanonicalEnumerationUnavailableError} if `storage` is absent, and the
+ * caller (`Db.export()`) throws the same error before this runs if the view is
+ * historical or a speculative overlay — the canonical storage walk has no
+ * generation parameter, so it can only ever answer "as of right now."
+ *
* @param reader - Generation-correct read surface (`Db` or `Brainy`).
- * @param storage - Storage adapter (used only for VFS blob bytes when `includeContent`).
+ * @param storage - Storage adapter (VFS blob bytes when `includeContent`; the
+ * canonical noun/verb walk when `enumeration:'canonical'`).
* @param selector - WHAT to export (omit for the whole brain).
- * @param options - HOW to export (vectors / file bytes / edge policy).
- * @param dimensions - Embedding dimensionality for the manifest.
+ * @param options - HOW to export (vectors / file bytes / edge policy / enumeration mode).
*/
export async function exportGraph(
reader: PortableGraphReader,
@@ -287,13 +358,34 @@ export async function exportGraph(
includeVectors = false,
includeContent = false,
includeSystem = false,
- edges = 'induced'
+ edges = 'induced',
+ enumeration = 'index',
+ reportIndexDrift = false
} = options
- // 1. Resolve the node-id set.
- const idSet = await resolveSelector(reader, selector, includeSystem)
+ if (enumeration === 'canonical' && !storage) {
+ throw new Error(
+ `export(): enumeration:'canonical' requires a storage adapter, but none was supplied ` +
+ `to this reader. Use enumeration:'index' (the default), or export through a Db/Brainy ` +
+ `that carries its storage adapter.`
+ )
+ }
+ const wantDrift = enumeration === 'canonical' && reportIndexDrift
+
+ // 1. Resolve the node-id set (+ the index's raw candidate set, only when diffing it).
+ const { idSet, indexCandidateIds } = await resolveSelector(
+ reader,
+ storage,
+ selector,
+ includeSystem,
+ enumeration,
+ wantDrift
+ )
// 2. Read canonical entities (reserved fields top-level), applying any predicate filter.
+ // Identical for both enumeration modes: 'canonical' only changes WHICH ids reach this
+ // loop, never how a candidate is verified — so the two modes can disagree on candidacy,
+ // never on what counts as a match.
const usePredicate = hasPredicate(selector)
const entityMap = new Map>()
const entities: PortableGraphEntity[] = []
@@ -307,8 +399,31 @@ export async function exportGraph(
}
const keptIds = new Set(entityMap.keys())
- // 3. Edges per policy.
- const { relations, danglingIds } = await collectEdges(reader, keptIds, edges)
+ // 2b. Finalize the drift report now that ground truth (keptIds) is known.
+ let drift: ExportIndexDrift | undefined
+ if (wantDrift) {
+ const indexIds = indexCandidateIds ?? new Set()
+ const canonicalOnly = [...keptIds].filter((id) => !indexIds.has(id))
+ const indexOnly = [...indexIds].filter((id) => !keptIds.has(id))
+ drift = { canonicalOnly, indexOnly }
+ if (canonicalOnly.length > 0 || indexOnly.length > 0) {
+ console.warn(
+ `[Brainy] export() index drift: ${canonicalOnly.length} canonical-only id(s) ` +
+ `(canon-present, the index-based enumeration missed them) and ${indexOnly.length} ` +
+ `index-only id(s) (index-visible, canon-absent — phantom rows). ` +
+ `See the returned PortableGraph's 'drift' field for the exact ids. Nothing was ` +
+ `auto-healed — run brain.repairIndex() to reconcile the metadata index.`
+ )
+ }
+ }
+
+ // 3. Edges per policy. Canonical mode ALSO walks verbs canonically for every
+ // selector (not just whole-brain) — a lost adjacency-index posting can hide a
+ // relation regardless of how the node set was produced.
+ const { relations, danglingIds } =
+ enumeration === 'canonical'
+ ? await collectEdgesCanonical(storage!, keptIds, edges)
+ : await collectEdges(reader, keptIds, edges)
// 4. VFS blob bytes (only when requested).
let blobs: Record | undefined
@@ -330,6 +445,7 @@ export async function exportGraph(
relations,
...(blobs && blobCount > 0 ? { blobs } : {}),
...(danglingIds && danglingIds.length > 0 ? { danglingIds } : {}),
+ ...(drift ? { drift } : {}),
stats: {
entityCount: entities.length,
relationCount: relations.length,
@@ -477,12 +593,30 @@ function hasPredicate(s: ExportSelector): boolean {
)
}
+/**
+ * @param reader - Generation-correct read surface.
+ * @param storage - Storage adapter (only touched when `enumeration:'canonical'`
+ * resolves the whole-brain/predicate branch).
+ * @param s - The export selector.
+ * @param includeSystem - Whether `visibility:'system'` entities are wanted.
+ * @param enumeration - `'index'` (default) or `'canonical'` — see {@link ExportOptions.enumeration}.
+ * Only affects the whole-brain/predicate branch (the `else` below): structural
+ * selectors (`ids`/`collection`/`connected`/`vfsPath`) never rode the metadata
+ * index for their node set, so they resolve identically either way.
+ * @param wantIndexCandidates - When true (only meaningful with `enumeration:'canonical'`
+ * on the whole-brain/predicate branch), ALSO run the index-based walk and return
+ * its raw candidate set as `indexCandidateIds`, for {@link ExportIndexDrift}.
+ */
async function resolveSelector(
reader: PortableGraphReader,
+ storage: StorageAdapter | undefined,
s: ExportSelector,
- includeSystem: boolean
-): Promise> {
+ includeSystem: boolean,
+ enumeration: 'index' | 'canonical',
+ wantIndexCandidates: boolean
+): Promise<{ idSet: Set; indexCandidateIds?: Set }> {
let idSet: Set
+ let indexCandidateIds: Set | undefined
if (s.ids && s.ids.length) {
idSet = new Set(s.ids)
} else if (s.collection ?? s.memberOf) {
@@ -491,15 +625,23 @@ async function resolveSelector(
idSet = await resolveConnected(reader, s.connected)
} else if (s.vfsPath) {
idSet = await resolveVfsPath(reader, s.vfsPath, s.recursive ?? true, s.depth)
+ } else if (enumeration === 'canonical') {
+ idSet = await enumerateAllCanonical(storage!)
+ if (wantIndexCandidates) indexCandidateIds = await enumerateAllIndexed(reader, s)
} else {
- idSet = await enumerateAll(reader, s)
+ idSet = await enumerateAllIndexed(reader, s)
}
if (!includeSystem) idSet.delete(VFS_ROOT_ID)
- return idSet
+ return { idSet, indexCandidateIds }
}
-/** Whole-brain / predicate enumeration via generation-correct paginated `find()`. */
-async function enumerateAll(reader: PortableGraphReader, s: ExportSelector): Promise> {
+/**
+ * @description Whole-brain / predicate enumeration via generation-correct
+ * paginated `find()`. The metadata index is an acceleration structure over
+ * this candidate set — see {@link enumerateAllCanonical} for the storage-level
+ * counterpart that never consults it.
+ */
+async function enumerateAllIndexed(reader: PortableGraphReader, s: ExportSelector): Promise> {
const params: any = {}
if (s.type !== undefined) params.type = s.type
if (s.subtype !== undefined) params.subtype = s.subtype
@@ -517,6 +659,46 @@ async function enumerateAll(reader: PortableGraphReader, s: ExportSelector
return ids
}
+/**
+ * @description Canonical (storage-level) counterpart of {@link enumerateAllIndexed}:
+ * walks every live noun directly off the storage adapter's canonical shard layout
+ * (`storage.getNouns()` — the same primitive `repairIndex()`'s recount and every
+ * index-heal walk use) instead of going through the metadata index. Guarantees
+ * canon-completeness — a lost or stale metadata-index posting cannot cause a
+ * canonical record to be silently missing from the returned set — at the cost of
+ * an O(N) walk regardless of selector selectivity (unlike the index path's
+ * O(matches)). Returns the RAW candidate id set; `exportGraph`'s caller applies
+ * `matchesPredicate` per-entity via `reader.get()` afterward, exactly as the index
+ * path does, so both paths share one predicate-evaluation code path and can only
+ * disagree on candidacy, never on what a match means.
+ *
+ * Mirrors `find()`'s default hidden-tier policy (always hides `'internal'` and
+ * `'system'` here — `enumerateAllIndexed` never opts either back in via `find()`
+ * either, since `ExportOptions.includeSystem` is applied later, per-entity, and
+ * only reachable for ids a selector already named directly) so the two
+ * enumeration modes produce identical id sets when the index is healthy.
+ */
+async function enumerateAllCanonical(storage: StorageAdapter): Promise> {
+ const ids = new Set()
+ let offset = 0
+ let cursor: string | undefined
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ const page = await storage.getNouns({ pagination: { limit: ENUM_PAGE, offset, cursor } })
+ for (const item of page.items) {
+ if (item.visibility === 'internal' || item.visibility === 'system') continue
+ ids.add(item.id)
+ }
+ if (!page.hasMore || page.items.length === 0) break
+ if (page.nextCursor !== undefined) {
+ cursor = page.nextCursor
+ } else {
+ offset += ENUM_PAGE
+ }
+ }
+ return ids
+}
+
async function resolveCollectionSubtree(
reader: PortableGraphReader,
rootId: string,
@@ -718,6 +900,74 @@ async function collectEdges(
return dangling.size > 0 ? { relations, danglingIds: Array.from(dangling) } : { relations }
}
+/** Converts a canonical verb record (as returned by `storage.getVerbs()`) into the wire shape. */
+function hnswVerbToPortableGraphRelation(v: HNSWVerbWithMetadata): PortableGraphRelation {
+ const br: PortableGraphRelation = { id: v.id, from: v.sourceId, to: v.targetId, type: v.verb as string }
+ if (v.subtype !== undefined) br.subtype = v.subtype
+ if (v.visibility !== undefined && v.visibility !== 'public') br.visibility = v.visibility
+ if (v.weight !== undefined) br.weight = v.weight
+ if (v.confidence !== undefined) br.confidence = v.confidence
+ if (v.metadata && Object.keys(v.metadata as any).length) br.metadata = v.metadata
+ return br
+}
+
+/**
+ * @description Canonical (storage-level) counterpart of {@link collectEdges}:
+ * walks every live verb directly off `storage.getVerbs()` — the same primitive
+ * `repairIndex()`'s recount and every index-heal walk use — instead of the graph
+ * adjacency index (`reader.related()`), so a lost/stale adjacency posting cannot
+ * cause a canonical relationship to be silently dropped from the export. Used for
+ * EVERY selector in `enumeration:'canonical'` mode, not just the whole-brain
+ * branch: relations can be blinded by adjacency-index corruption regardless of
+ * how `idSet` (the kept node ids) was produced.
+ *
+ * Mirrors `related()`'s default hidden-tier policy (always hides `'internal'`
+ * and `'system'` — `collectEdges` never opts either back in via `related()`
+ * either) so the two enumeration modes produce identical relation sets when the
+ * index is healthy.
+ */
+async function collectEdgesCanonical(
+ storage: StorageAdapter,
+ idSet: Set,
+ edges: 'induced' | 'incident' | 'none'
+): Promise<{ relations: PortableGraphRelation[]; danglingIds?: string[] }> {
+ if (edges === 'none') return { relations: [] }
+
+ const relations: PortableGraphRelation[] = []
+ const dangling = new Set()
+ const seen = new Set()
+ let offset = 0
+ let cursor: string | undefined
+
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ const page = await storage.getVerbs({ pagination: { limit: ENUM_PAGE, offset, cursor } })
+ for (const v of page.items) {
+ if (seen.has(v.id)) continue
+ if (v.visibility === 'internal' || v.visibility === 'system') continue
+ const fromIn = idSet.has(v.sourceId)
+ const toIn = idSet.has(v.targetId)
+ if (edges === 'induced') {
+ if (!fromIn || !toIn) continue
+ } else if (!fromIn && !toIn) {
+ continue // 'incident': neither endpoint kept — irrelevant to this export
+ }
+ if (fromIn && !toIn) dangling.add(v.targetId)
+ if (toIn && !fromIn) dangling.add(v.sourceId)
+ seen.add(v.id)
+ relations.push(hnswVerbToPortableGraphRelation(v))
+ }
+ if (!page.hasMore || page.items.length === 0) break
+ if (page.nextCursor !== undefined) {
+ cursor = page.nextCursor
+ } else {
+ offset += ENUM_PAGE
+ }
+ }
+
+ return dangling.size > 0 ? { relations, danglingIds: Array.from(dangling) } : { relations }
+}
+
async function collectBlobs(
storage: StorageAdapter | undefined,
entityMap: Map>
diff --git a/src/index.ts b/src/index.ts
index e60739f7..10922adb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -185,6 +185,7 @@ export type {
PortableGraphRelation,
ExportSelector,
ExportOptions,
+ ExportIndexDrift,
ImportOptions,
ImportResult,
PortableGraphValidation
@@ -194,7 +195,8 @@ export {
SpeculativeOverlayError,
GenerationCompactedError,
StoreInconsistentError,
- PendingFlushDurabilityError
+ PendingFlushDurabilityError,
+ CanonicalEnumerationUnavailableError
} from './db/errors.js'
export type { UnreconciledRecord } from './db/errors.js'
export type {
diff --git a/tests/unit/db/db-portable-graph.test.ts b/tests/unit/db/db-portable-graph.test.ts
index abb89553..d70836b5 100644
--- a/tests/unit/db/db-portable-graph.test.ts
+++ b/tests/unit/db/db-portable-graph.test.ts
@@ -9,7 +9,7 @@
* subtype-required default.
*/
-import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
@@ -19,6 +19,7 @@ import { createTestConfig } from '../../helpers/test-factory'
import { NounType, VerbType } from '../../../src/types/graphTypes'
import { validatePortableGraph } from '../../../src/db/portableGraph'
import type { PortableGraph } from '../../../src/db/portableGraph'
+import { CanonicalEnumerationUnavailableError } from '../../../src/db/errors'
describe('8.0 portable graph export/import (PortableGraph v1)', () => {
let brain: Brainy
@@ -293,3 +294,162 @@ describe('8.0 export includeContent (VFS blobs, filesystem)', () => {
}
})
})
+
+describe('8.0 export enumeration:"canonical" — canon-complete against index blindness', () => {
+ let brain: Brainy
+
+ beforeEach(async () => {
+ brain = new Brainy(createTestConfig())
+ await brain.init()
+ })
+
+ afterEach(async () => {
+ await brain.close()
+ })
+
+ it('(i) equals the index-based export when the index is healthy — same entity ids, relations, vectors', async () => {
+ const a = await brain.add({ data: 'Alice', type: NounType.Person, subtype: 'employee' })
+ const b = await brain.add({ data: 'Bob', type: NounType.Person, subtype: 'employee' })
+ const c = await brain.add({ data: 'Acme', type: NounType.Organization, subtype: 'vendor' })
+ await brain.relate({ from: a, to: b, type: VerbType.FriendOf, subtype: 'close' })
+ await brain.relate({ from: a, to: c, type: VerbType.WorksWith, subtype: 'full-time' })
+
+ const indexExport = await brain.export({}, { includeVectors: true, enumeration: 'index' })
+ const canonicalExport = await brain.export({}, { includeVectors: true, enumeration: 'canonical' })
+
+ expect(canonicalExport.entities.map((e) => e.id).sort()).toEqual(
+ indexExport.entities.map((e) => e.id).sort()
+ )
+ expect(canonicalExport.relations.map((r) => r.id).sort()).toEqual(
+ indexExport.relations.map((r) => r.id).sort()
+ )
+ expect(canonicalExport.entities.map((e) => e.id).sort()).toEqual([a, b, c].sort())
+ for (const e of canonicalExport.entities) {
+ expect(e.vector?.length).toBeGreaterThan(0)
+ }
+ expect(canonicalExport.drift).toBeUndefined() // reportIndexDrift not requested
+ })
+
+ it('(ii) survives simulated metadata-index blindness; the index export misses the record; drift names it canonicalOnly', async () => {
+ const staff = await brain.add({
+ data: 'Staff',
+ type: NounType.Person,
+ subtype: 'employee',
+ metadata: { role: 'staff' }
+ })
+ const other = await brain.add({
+ data: 'Other',
+ type: NounType.Person,
+ subtype: 'employee',
+ metadata: { role: 'staff' }
+ })
+
+ // Surgically poison the metadata index (the lowest-level seam the existing
+ // find() phantom-row guard tests use — see find-index-integrity-guard.test.ts,
+ // which does the mirror-image ADD case) so the predicate query
+ // enumeration:'index' issues (find({ type: Person })) never returns `staff` —
+ // a real canonical record the index has lost track of, the exact
+ // canon-present/index-missing state canonical mode exists to survive.
+ const mi = (brain as any).metadataIndex
+ const original = mi.getIdsForFilter.bind(mi)
+ mi.getIdsForFilter = async (filter: any, opts?: any): Promise => {
+ const ids: string[] = await original(filter, opts)
+ return ids.filter((id: string) => id !== staff)
+ }
+
+ try {
+ const indexExport = await brain.export({ type: NounType.Person }, { enumeration: 'index' })
+ expect(indexExport.entities.map((e) => e.id)).not.toContain(staff)
+ expect(indexExport.entities.map((e) => e.id)).toContain(other)
+
+ const canonicalExport = await brain.export(
+ { type: NounType.Person },
+ { enumeration: 'canonical', reportIndexDrift: true }
+ )
+ expect(canonicalExport.entities.map((e) => e.id)).toContain(staff)
+ expect(canonicalExport.entities.map((e) => e.id)).toContain(other)
+ expect(canonicalExport.drift?.canonicalOnly).toEqual([staff])
+ expect(canonicalExport.drift?.indexOnly).toEqual([])
+ } finally {
+ mi.getIdsForFilter = original
+ }
+ })
+
+ it('(iii) drift report shape + loud console.warn only when nonzero', async () => {
+ const staff = await brain.add({ data: 'Staff', type: NounType.Person, subtype: 'employee' })
+ await brain.add({ data: 'Other', type: NounType.Person, subtype: 'employee' })
+
+ const mi = (brain as any).metadataIndex
+ const original = mi.getIdsForFilter.bind(mi)
+ mi.getIdsForFilter = async (filter: any, opts?: any): Promise => {
+ const ids: string[] = await original(filter, opts)
+ return ids.filter((id: string) => id !== staff)
+ }
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ try {
+ const drifted = await brain.export(
+ { type: NounType.Person },
+ { enumeration: 'canonical', reportIndexDrift: true }
+ )
+ expect(drifted.drift).toEqual({ canonicalOnly: [staff], indexOnly: [] })
+ expect(warnSpy).toHaveBeenCalledTimes(1)
+ expect(warnSpy.mock.calls[0].join(' ')).toMatch(/drift/i)
+ } finally {
+ mi.getIdsForFilter = original
+ warnSpy.mockClear()
+ }
+
+ // Healthy index: drift is reported (both lists present) but never warned about.
+ try {
+ const healthy = await brain.export(
+ { type: NounType.Person },
+ { enumeration: 'canonical', reportIndexDrift: true }
+ )
+ expect(healthy.drift).toEqual({ canonicalOnly: [], indexOnly: [] })
+ expect(warnSpy).not.toHaveBeenCalled()
+ } finally {
+ warnSpy.mockRestore()
+ }
+ })
+
+ it('(iv) throws CanonicalEnumerationUnavailableError on a historical asOf() view and a speculative with() overlay', async () => {
+ const a = '22222222-2222-4222-8222-222222222222'
+ const b = '33333333-3333-4333-8333-333333333333'
+ await brain.transact([{ op: 'add', id: a, data: 'First', type: NounType.Thing, subtype: 'x' }])
+ const g1 = brain.generation()
+ await brain.transact([{ op: 'add', id: b, data: 'Second', type: NounType.Thing, subtype: 'x' }])
+
+ const past = await brain.asOf(g1)
+ try {
+ await expect(past.export({}, { enumeration: 'canonical' })).rejects.toThrow(
+ CanonicalEnumerationUnavailableError
+ )
+ // The default (index) mode is unaffected — still a valid time-travel export.
+ const backup = await past.export()
+ expect(backup.entities.map((e) => e.id)).toContain(a)
+ } finally {
+ await past.release()
+ }
+
+ const speculativeId = '11111111-1111-4111-8111-111111111111'
+ const view = await brain.now().with([
+ { op: 'add', id: speculativeId, data: 'Speculative', type: NounType.Thing, subtype: 'x' }
+ ])
+ try {
+ await expect(view.export({}, { enumeration: 'canonical' })).rejects.toThrow(
+ CanonicalEnumerationUnavailableError
+ )
+ } finally {
+ await view.release()
+ }
+ })
+
+ it('throws a plain Error when enumeration:"canonical" has no storage adapter to walk', async () => {
+ const { exportGraph } = await import('../../../src/db/portableGraph')
+ const readerOnly = { get: async () => null, find: async () => [], related: async () => [] }
+ await expect(
+ exportGraph(readerOnly as any, undefined, {}, { enumeration: 'canonical' })
+ ).rejects.toThrow(/enumeration:'canonical' requires a storage adapter/)
+ })
+})
From 3e4a17dcdfed0836d07a9222f9f9a65fefb33547 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 27 Jul 2026 11:11:53 -0700
Subject: [PATCH 023/175] feat(release): the forge publish leg moves to CI on
the tag push; the laptop verifies by readback and keeps the
abort-before-storefront guard
---
.forgejo/workflows/publish-forge.yml | 67 ++++++++++++++++++++++++++++
RELEASES.md | 3 ++
scripts/release.sh | 45 ++++++++++---------
3 files changed, 94 insertions(+), 21 deletions(-)
create mode 100644 .forgejo/workflows/publish-forge.yml
diff --git a/.forgejo/workflows/publish-forge.yml b/.forgejo/workflows/publish-forge.yml
new file mode 100644
index 00000000..fb7428bf
--- /dev/null
+++ b/.forgejo/workflows/publish-forge.yml
@@ -0,0 +1,67 @@
+name: Publish (forge)
+
+# Datacenter-side forge publish, moved off the laptop: an 87MB tarball PUT
+# over the laptop's WAN times out; the forge's own runner does it in seconds.
+# scripts/release.sh tags + pushes, then polls this workflow's result (npm
+# view against the forge registry) before it ever touches the npmjs leg —
+# see the "delegation contract" in scripts/release.sh's forge-publish step.
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ publish:
+ name: Publish to the forge registry
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm ci
+ - run: npm run build
+ - name: Publish + readback-verify on the forge registry
+ env:
+ FORGE_NPM_TOKEN: ${{ secrets.FORGE_NPM_TOKEN }}
+ run: |
+ set -eo pipefail
+
+ FORGE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/npm/"
+ VERSION="$(node -p "require('./package.json').version")"
+ echo "Publishing @soulcraft/brainy@${VERSION} to the forge registry..."
+
+ TMPRC="$(mktemp)"
+ chmod 600 "$TMPRC"
+ {
+ echo "@soulcraft:registry=${FORGE_NPM_REG}"
+ echo "//source.soulcraft.com/api/packages/soulcraft/npm/:_authToken=${FORGE_NPM_TOKEN}"
+ } > "$TMPRC"
+
+ # The release script bumps package.json's version before it tags, so
+ # this tag's checkout already carries the version being published —
+ # nothing here re-derives it from the tag name.
+ PUBLISH_OK=true
+ if ! npm publish --tag latest --userconfig "$TMPRC"; then
+ PUBLISH_OK=false
+ fi
+
+ # Readback verify is the source of truth, run regardless of the publish
+ # exit code: a benign duplicate publish (a prior run, or a mirror, already
+ # landed this exact version) reports failure even though the registry
+ # already holds the right content.
+ LANDED_VERSION="$(npm view "@soulcraft/brainy@${VERSION}" version --userconfig "$TMPRC" 2>/dev/null || echo "")"
+ rm -f "$TMPRC"
+
+ if [ "$LANDED_VERSION" != "$VERSION" ]; then
+ echo "::error::Readback verify FAILED — the forge registry reports version '${LANDED_VERSION:-}', expected '${VERSION}'. This is a genuine publish failure, not a benign duplicate."
+ exit 1
+ fi
+
+ if [ "$PUBLISH_OK" = true ]; then
+ echo "Published and verified @soulcraft/brainy@${VERSION} on the forge registry."
+ else
+ echo "::warning::npm publish reported failure, but readback confirms @soulcraft/brainy@${VERSION} is already live on the forge (a prior run or mirror landed it) — treating this run as successful, since the registry content is correct. Any OTHER failure mode would have failed the readback check above instead."
+ fi
diff --git a/RELEASES.md b/RELEASES.md
index dee17a44..da8fa134 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -61,6 +61,9 @@ to the caller today.
Migration-audit evidence, not a repair: nonzero drift is reported loudly
(`console.warn` with the counts) and nothing is auto-healed — run `brain.repairIndex()`
to reconcile the metadata index once drift is confirmed.
+- **Ops note (consumer-invisible): the release pipeline's forge-registry publish now runs
+ on CI**, triggered by the release tag, instead of PUTting the tarball from the laptop
+ over WAN — no change to what gets published or how a consumer installs it.
## v8.10.1 — 2026-07-24 (the no-hot-retry contract + warm()'s metadata surface under native providers)
diff --git a/scripts/release.sh b/scripts/release.sh
index 43fa50bd..c64d6c5e 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -181,30 +181,33 @@ echo -e "${BLUE}8️⃣ Pushing to origin...${NC}"
git push --follow-tags origin "$CURRENT_BRANCH"
echo -e "${GREEN}✅ Pushed to origin${NC}\n"
-# Step 10: Publish — forge FIRST (home), npmjs second (the world's storefront).
-# The fleet-wide ~/.npmrc maps the @soulcraft scope to the forge registry, and
-# a scope mapping BEATS `--registry` on the command line — so each publish
-# names its registry via the scope override explicitly. Nothing implicit.
+# Step 10: Forge publish is CI's job now, not the laptop's — a tag push (just
+# above) triggers .forgejo/workflows/publish-forge.yml, which builds and
+# publishes on the forge's own runner (datacenter-side: seconds, not the
+# laptop's WAN timing out on an 87MB tarball PUT). The laptop holds no forge
+# publish credential anymore; it only waits for CI's result before trusting
+# the forge/npmjs pair enough to publish the storefront leg.
FORGE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/npm/"
-FORGE_NPM_TOKEN_FILE="$HOME/.config/soulcraft/npm-publish-brainy.token"
-echo -e "${BLUE}9️⃣ Publishing to the forge registry (home)...${NC}"
-if [ -f "$FORGE_NPM_TOKEN_FILE" ]; then
- TMPRC="$(mktemp)"
- chmod 600 "$TMPRC"
- {
- echo "@soulcraft:registry=${FORGE_NPM_REG}"
- echo "//source.soulcraft.com/api/packages/soulcraft/npm/:_authToken=$(cat "$FORGE_NPM_TOKEN_FILE")"
- } > "$TMPRC"
- if npm publish --tag "$NPM_TAG" --userconfig "$TMPRC"; then
- echo -e "${GREEN}✅ Published to the forge${NC}\n"
- else
- rm -f "$TMPRC"
- echo -e "${RED}❌ Forge publish FAILED — aborting before npmjs so the pair never diverges. Fix and re-run.${NC}"
- exit 1
+FORGE_POLL_INTERVAL_S=15
+FORGE_POLL_MAX_ATTEMPTS=40 # 40 × 15s = 10 minutes
+echo -e "${BLUE}9️⃣ Waiting for CI to publish v${NEW_VERSION} to the forge registry (home)...${NC}"
+FORGE_LANDED=false
+for ((attempt = 1; attempt <= FORGE_POLL_MAX_ATTEMPTS; attempt++)); do
+ LANDED_VERSION=$(npm view "@soulcraft/brainy@${NEW_VERSION}" version "--@soulcraft:registry=${FORGE_NPM_REG}" 2>/dev/null || echo "")
+ if [ "$LANDED_VERSION" = "$NEW_VERSION" ]; then
+ FORGE_LANDED=true
+ break
fi
- rm -f "$TMPRC"
+ echo -e "${YELLOW} … not yet on the forge (attempt ${attempt}/${FORGE_POLL_MAX_ATTEMPTS}); retrying in ${FORGE_POLL_INTERVAL_S}s${NC}"
+ sleep "$FORGE_POLL_INTERVAL_S"
+done
+
+if [ "$FORGE_LANDED" = true ]; then
+ echo -e "${GREEN}✅ CI published v${NEW_VERSION} to the forge${NC}\n"
else
- echo -e "${RED}❌ Forge publish token missing (${FORGE_NPM_TOKEN_FILE}) — aborting. The forge is home; publish it first or restage the token.${NC}"
+ echo -e "${RED}❌ CI forge publish did not land — check the workflow run on The Source; the pair must not diverge.${NC}"
+ echo -e "${RED} v${NEW_VERSION} was tagged and pushed, but @soulcraft/brainy@${NEW_VERSION} never became visible on the${NC}"
+ echo -e "${RED} forge registry after ${FORGE_POLL_MAX_ATTEMPTS} attempts, ${FORGE_POLL_INTERVAL_S}s apart. Aborting before npmjs.${NC}"
exit 1
fi
From 63c1eeb9022e0bbdef1c1249e7282218f9eb67ad Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 27 Jul 2026 11:22:25 -0700
Subject: [PATCH 024/175] =?UTF-8?q?feat:=20includeHidden=20=E2=80=94=20exp?=
=?UTF-8?q?ort=20carries=20every=20visibility=20tier=20for=20migration-gra?=
=?UTF-8?q?de=20canon=20completeness?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
RELEASES.md | 9 ++
src/db/db.ts | 5 +
src/db/portableGraph.ts | 123 ++++++++++++++++++------
tests/unit/db/db-portable-graph.test.ts | 95 ++++++++++++++++++
4 files changed, 205 insertions(+), 27 deletions(-)
diff --git a/RELEASES.md b/RELEASES.md
index da8fa134..9e7dec6f 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -61,6 +61,15 @@ to the caller today.
Migration-audit evidence, not a repair: nonzero drift is reported loudly
(`console.warn` with the counts) and nothing is auto-healed — run `brain.repairIndex()`
to reconcile the metadata index once drift is confirmed.
+- **New: `export(selector, { includeHidden: true })`** (default: false — unchanged
+ behavior). Without it, a whole-brain/predicate export could never carry a
+ `visibility:'internal'` or `'system'` row, in EITHER `enumeration` mode — a real gap
+ for a bulk-migration fold auditing per-visibility-tier, where a hidden tier is real
+ user data, not noise to drop. `includeHidden` admits both tiers into candidacy in
+ both modes (and implies `includeSystem`; `includeSystem` alone keeps its narrower,
+ pre-existing meaning). **Migration-grade exports set `includeHidden: true`** — a
+ complete-canon export must carry every visibility tier; consumer-facing exports
+ leave it off.
- **Ops note (consumer-invisible): the release pipeline's forge-registry publish now runs
on CI**, triggered by the release tag, instead of PUTting the tarball from the laptop
over WAN — no change to what gets published or how a consumer installs it.
diff --git a/src/db/db.ts b/src/db/db.ts
index ca9c133b..c5cbad8b 100644
--- a/src/db/db.ts
+++ b/src/db/db.ts
@@ -528,6 +528,11 @@ export class Db {
* throws {@link CanonicalEnumerationUnavailableError} rather than silently mixing
* generations or missing the overlay's own entities.
*
+ * `options.includeHidden: true` admits BOTH hidden visibility tiers
+ * (`'internal'` and `'system'`) into a whole-brain/predicate export, in EITHER
+ * `enumeration` mode — see {@link ExportOptions.includeHidden}. Migration-grade
+ * exports set this; consumer-facing exports leave it off (default: false).
+ *
* @param selector - WHAT to export (omit for the whole brain). See {@link ExportSelector}.
* @param options - HOW to export (vectors / VFS bytes / edge policy / enumeration mode). See {@link ExportOptions}.
* @returns A versioned, portable `PortableGraph` document.
diff --git a/src/db/portableGraph.ts b/src/db/portableGraph.ts
index 4c50ef5b..f3134325 100644
--- a/src/db/portableGraph.ts
+++ b/src/db/portableGraph.ts
@@ -94,6 +94,22 @@ export interface ExportOptions {
includeContent?: boolean
/** Include `visibility:'system'` entities (e.g. the VFS root) (default: false). */
includeSystem?: boolean
+ /**
+ * Admit BOTH hidden visibility tiers — `'internal'` AND `'system'` — into the
+ * whole-brain/predicate candidate set, in EITHER `enumeration` mode (default:
+ * false — today's behavior is byte-identical). `includeSystem` alone only ever
+ * reached `'system'` for a structural selector's per-entity gate; whole-brain/
+ * predicate enumeration never forwarded it into the candidate walk at all, so a
+ * hidden-tier row could never survive a whole-brain export regardless of any
+ * flag — the gap this option closes. `includeHidden: true` IMPLIES
+ * `includeSystem: true` (both tiers are admitted together; there is no
+ * "system but not internal" combination via this flag) — `includeSystem` on
+ * its own keeps its narrower, pre-existing meaning for back-compat.
+ *
+ * Migration-grade exports set `includeHidden: true` — a complete-canon export
+ * must carry every visibility tier; consumer-facing exports leave it off.
+ */
+ includeHidden?: boolean
/** Which edges to include (default: `'induced'`). */
edges?: 'induced' | 'incident' | 'none'
/**
@@ -358,6 +374,7 @@ export async function exportGraph(
includeVectors = false,
includeContent = false,
includeSystem = false,
+ includeHidden = false,
edges = 'induced',
enumeration = 'index',
reportIndexDrift = false
@@ -371,15 +388,23 @@ export async function exportGraph(
)
}
const wantDrift = enumeration === 'canonical' && reportIndexDrift
+ // includeHidden IMPLIES includeSystem (see ExportOptions.includeHidden's JSDoc) — every
+ // system-tier gate below reads THIS combined value, never the raw option, so
+ // `includeHidden` alone is always sufficient to see system-tier rows too.
+ const effectiveIncludeSystem = includeSystem || includeHidden
// 1. Resolve the node-id set (+ the index's raw candidate set, only when diffing it).
+ // Both `enumerateAllCanonical` and `enumerateAllIndexed` receive the SAME
+ // `effectiveIncludeSystem`/`includeHidden` pair below, so a drift diff can never
+ // contain tier-policy noise — only genuine index-vs-canonical disagreement.
const { idSet, indexCandidateIds } = await resolveSelector(
reader,
storage,
selector,
- includeSystem,
+ effectiveIncludeSystem,
enumeration,
- wantDrift
+ wantDrift,
+ includeHidden
)
// 2. Read canonical entities (reserved fields top-level), applying any predicate filter.
@@ -392,7 +417,7 @@ export async function exportGraph(
for (const id of idSet) {
const e = await reader.get(id, { includeVectors })
if (!e) continue
- if (!includeSystem && (e as any).visibility === 'system') continue
+ if (!effectiveIncludeSystem && (e as any).visibility === 'system') continue
if (usePredicate && !matchesPredicate(e, selector)) continue
entityMap.set(id, e)
entities.push(toPortableGraphEntity(e, includeVectors))
@@ -422,8 +447,8 @@ export async function exportGraph(
// relation regardless of how the node set was produced.
const { relations, danglingIds } =
enumeration === 'canonical'
- ? await collectEdgesCanonical(storage!, keptIds, edges)
- : await collectEdges(reader, keptIds, edges)
+ ? await collectEdgesCanonical(storage!, keptIds, edges, effectiveIncludeSystem, includeHidden)
+ : await collectEdges(reader, keptIds, edges, includeHidden)
// 4. VFS blob bytes (only when requested).
let blobs: Record | undefined
@@ -598,7 +623,9 @@ function hasPredicate(s: ExportSelector): boolean {
* @param storage - Storage adapter (only touched when `enumeration:'canonical'`
* resolves the whole-brain/predicate branch).
* @param s - The export selector.
- * @param includeSystem - Whether `visibility:'system'` entities are wanted.
+ * @param includeSystem - The ALREADY-COMBINED `includeSystem || includeHidden` value
+ * (see `exportGraph`'s `effectiveIncludeSystem`) — whether `visibility:'system'`
+ * entities are wanted.
* @param enumeration - `'index'` (default) or `'canonical'` — see {@link ExportOptions.enumeration}.
* Only affects the whole-brain/predicate branch (the `else` below): structural
* selectors (`ids`/`collection`/`connected`/`vfsPath`) never rode the metadata
@@ -606,6 +633,9 @@ function hasPredicate(s: ExportSelector): boolean {
* @param wantIndexCandidates - When true (only meaningful with `enumeration:'canonical'`
* on the whole-brain/predicate branch), ALSO run the index-based walk and return
* its raw candidate set as `indexCandidateIds`, for {@link ExportIndexDrift}.
+ * @param includeHidden - Whether `visibility:'internal'` entities are ALSO wanted
+ * (see {@link ExportOptions.includeHidden}). Threaded to BOTH enumeration
+ * functions identically so a drift diff never contains tier-policy noise.
*/
async function resolveSelector(
reader: PortableGraphReader,
@@ -613,7 +643,8 @@ async function resolveSelector(
s: ExportSelector,
includeSystem: boolean,
enumeration: 'index' | 'canonical',
- wantIndexCandidates: boolean
+ wantIndexCandidates: boolean,
+ includeHidden: boolean
): Promise<{ idSet: Set; indexCandidateIds?: Set }> {
let idSet: Set
let indexCandidateIds: Set | undefined
@@ -626,10 +657,10 @@ async function resolveSelector(
} else if (s.vfsPath) {
idSet = await resolveVfsPath(reader, s.vfsPath, s.recursive ?? true, s.depth)
} else if (enumeration === 'canonical') {
- idSet = await enumerateAllCanonical(storage!)
- if (wantIndexCandidates) indexCandidateIds = await enumerateAllIndexed(reader, s)
+ idSet = await enumerateAllCanonical(storage!, includeSystem, includeHidden)
+ if (wantIndexCandidates) indexCandidateIds = await enumerateAllIndexed(reader, s, includeHidden)
} else {
- idSet = await enumerateAllIndexed(reader, s)
+ idSet = await enumerateAllIndexed(reader, s, includeHidden)
}
if (!includeSystem) idSet.delete(VFS_ROOT_ID)
return { idSet, indexCandidateIds }
@@ -640,13 +671,30 @@ async function resolveSelector(
* paginated `find()`. The metadata index is an acceleration structure over
* this candidate set — see {@link enumerateAllCanonical} for the storage-level
* counterpart that never consults it.
+ *
+ * @param includeHidden - When true, forwards `includeInternal: true` AND
+ * `includeSystem: true` into the SAME `find()` call — `find()` supports both
+ * flags simultaneously (confirmed via `FindParams.includeInternal`/`includeSystem`
+ * and `Brainy`'s `resolveHiddenIds`/`excludedVisibilityTiers`), so ONE pass
+ * reaches both hidden tiers; no per-tier union pass is needed. When false
+ * (default), neither flag is forwarded — the pre-existing behavior, preserved
+ * byte-identically for back-compat (`ExportOptions.includeSystem` alone never
+ * reached this far; see {@link ExportOptions.includeHidden}'s JSDoc).
*/
-async function enumerateAllIndexed(reader: PortableGraphReader, s: ExportSelector): Promise> {
+async function enumerateAllIndexed(
+ reader: PortableGraphReader,
+ s: ExportSelector,
+ includeHidden = false
+): Promise> {
const params: any = {}
if (s.type !== undefined) params.type = s.type
if (s.subtype !== undefined) params.subtype = s.subtype
if (s.where !== undefined) params.where = s.where
if (s.service !== undefined) params.service = s.service
+ if (includeHidden) {
+ params.includeInternal = true
+ params.includeSystem = true
+ }
const ids = new Set()
let offset = 0
// eslint-disable-next-line no-constant-condition
@@ -672,13 +720,18 @@ async function enumerateAllIndexed(reader: PortableGraphReader, s: ExportS
* path does, so both paths share one predicate-evaluation code path and can only
* disagree on candidacy, never on what a match means.
*
- * Mirrors `find()`'s default hidden-tier policy (always hides `'internal'` and
- * `'system'` here — `enumerateAllIndexed` never opts either back in via `find()`
- * either, since `ExportOptions.includeSystem` is applied later, per-entity, and
- * only reachable for ids a selector already named directly) so the two
- * enumeration modes produce identical id sets when the index is healthy.
+ * Mirrors `find()`'s hidden-tier policy given the SAME `includeSystem`/`includeHidden`
+ * pair (see {@link enumerateAllIndexed}) so the two enumeration modes produce
+ * identical id sets when the index is healthy, at ANY tier-visibility setting.
+ *
+ * @param includeSystem - The ALREADY-COMBINED `includeSystem || includeHidden` value.
+ * @param includeHidden - Whether `'internal'`-tier nouns are ALSO admitted.
*/
-async function enumerateAllCanonical(storage: StorageAdapter): Promise> {
+async function enumerateAllCanonical(
+ storage: StorageAdapter,
+ includeSystem = false,
+ includeHidden = false
+): Promise> {
const ids = new Set()
let offset = 0
let cursor: string | undefined
@@ -686,7 +739,8 @@ async function enumerateAllCanonical(storage: StorageAdapter): Promise(r: Relation): PortableGraphRelation {
return br
}
+/**
+ * @param includeHidden - When true, forwards `includeInternal`/`includeSystem` into
+ * every `related()` call so hidden-tier relations reach candidacy too — mirrors
+ * {@link enumerateAllIndexed}'s `includeHidden` handling, and preserves back-compat
+ * when false/omitted (the pre-existing, unconditional hidden-tier exclusion).
+ */
async function collectEdges(
reader: PortableGraphReader,
idSet: Set,
- edges: 'induced' | 'incident' | 'none'
+ edges: 'induced' | 'incident' | 'none',
+ includeHidden = false
): Promise<{ relations: PortableGraphRelation[]; danglingIds?: string[] }> {
if (edges === 'none') return { relations: [] }
+ const tierOptIn = includeHidden ? { includeInternal: true, includeSystem: true } : {}
const relations: PortableGraphRelation[] = []
const dangling = new Set()
const seen = new Set()
for (const id of idSet) {
- const rels = await reader.related({ from: id, limit: RELATION_FETCH_LIMIT })
+ const rels = await reader.related({ from: id, limit: RELATION_FETCH_LIMIT, ...tierOptIn })
for (const r of rels) {
if (seen.has(r.id)) continue
const toIn = idSet.has(r.to)
@@ -885,7 +947,7 @@ async function collectEdges(
if (edges === 'incident') {
for (const id of idSet) {
- const rels = await reader.related({ to: id, limit: RELATION_FETCH_LIMIT })
+ const rels = await reader.related({ to: id, limit: RELATION_FETCH_LIMIT, ...tierOptIn })
for (const r of rels) {
if (seen.has(r.id)) continue
if (!idSet.has(r.from)) {
@@ -921,15 +983,21 @@ function hnswVerbToPortableGraphRelation(v: HNSWVerbWithMetadata): PortableGraph
* branch: relations can be blinded by adjacency-index corruption regardless of
* how `idSet` (the kept node ids) was produced.
*
- * Mirrors `related()`'s default hidden-tier policy (always hides `'internal'`
- * and `'system'` — `collectEdges` never opts either back in via `related()`
- * either) so the two enumeration modes produce identical relation sets when the
- * index is healthy.
+ * Mirrors `related()`'s default hidden-tier policy (hides `'internal'` and
+ * `'system'` unless `includeHidden`/`includeSystem` say otherwise) so the two
+ * enumeration modes produce identical relation sets when the index is healthy.
+ *
+ * @param includeSystem - Whether `'system'`-tier verbs are admitted (the
+ * caller passes the ALREADY-combined `includeSystem || includeHidden` value —
+ * see `exportGraph`'s `effectiveIncludeSystem`).
+ * @param includeHidden - Whether `'internal'`-tier verbs are ALSO admitted.
*/
async function collectEdgesCanonical(
storage: StorageAdapter,
idSet: Set,
- edges: 'induced' | 'incident' | 'none'
+ edges: 'induced' | 'incident' | 'none',
+ includeSystem = false,
+ includeHidden = false
): Promise<{ relations: PortableGraphRelation[]; danglingIds?: string[] }> {
if (edges === 'none') return { relations: [] }
@@ -944,7 +1012,8 @@ async function collectEdgesCanonical(
const page = await storage.getVerbs({ pagination: { limit: ENUM_PAGE, offset, cursor } })
for (const v of page.items) {
if (seen.has(v.id)) continue
- if (v.visibility === 'internal' || v.visibility === 'system') continue
+ if (v.visibility === 'internal' && !includeHidden) continue
+ if (v.visibility === 'system' && !includeSystem) continue
const fromIn = idSet.has(v.sourceId)
const toIn = idSet.has(v.targetId)
if (edges === 'induced') {
diff --git a/tests/unit/db/db-portable-graph.test.ts b/tests/unit/db/db-portable-graph.test.ts
index d70836b5..1de9983d 100644
--- a/tests/unit/db/db-portable-graph.test.ts
+++ b/tests/unit/db/db-portable-graph.test.ts
@@ -453,3 +453,98 @@ describe('8.0 export enumeration:"canonical" — canon-complete against index bl
).rejects.toThrow(/enumeration:'canonical' requires a storage adapter/)
})
})
+
+describe('8.0 export includeHidden — every visibility tier for migration-grade canon completeness', () => {
+ // The fixed-id VFS root Brainy.init() always creates is the one 'system'-visibility
+ // entity a consumer can rely on existing (visibility:'system' is not settable via the
+ // public add() API — "intentionally not accepted", per AddParams.visibility's doc).
+ const VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000'
+
+ let brain: Brainy
+
+ beforeEach(async () => {
+ brain = new Brainy(createTestConfig())
+ await brain.init()
+ })
+
+ afterEach(async () => {
+ await brain.close()
+ })
+
+ it('canonical + includeHidden carries an internal row AND the system row; round-trips through import', async () => {
+ const publicId = await brain.add({ data: 'Public', type: NounType.Thing, subtype: 'x' })
+ const internalId = await brain.add({
+ data: 'Internal',
+ type: NounType.Thing,
+ subtype: 'x',
+ visibility: 'internal'
+ })
+
+ const migrationExport = await brain.export({}, { enumeration: 'canonical', includeHidden: true })
+ const ids = migrationExport.entities.map((e) => e.id)
+ expect(ids).toContain(publicId)
+ expect(ids).toContain(internalId)
+ expect(ids).toContain(VFS_ROOT_ID)
+ expect(migrationExport.entities.find((e) => e.id === internalId)?.visibility).toBe('internal')
+ expect(migrationExport.entities.find((e) => e.id === VFS_ROOT_ID)?.visibility).toBe('system')
+
+ const target = new Brainy(createTestConfig())
+ await target.init()
+ try {
+ const result = await target.import(migrationExport)
+ expect(result.errors).toHaveLength(0)
+ expect((await target.get(internalId))?.visibility).toBe('internal')
+ } finally {
+ await target.close()
+ }
+ })
+
+ it('default export (includeHidden omitted) still excludes both hidden tiers — pins today\'s behavior', async () => {
+ const publicId = await brain.add({ data: 'Public', type: NounType.Thing, subtype: 'x' })
+ const internalId = await brain.add({
+ data: 'Internal',
+ type: NounType.Thing,
+ subtype: 'x',
+ visibility: 'internal'
+ })
+
+ for (const opts of [{ enumeration: 'index' as const }, { enumeration: 'canonical' as const }]) {
+ const backup = await brain.export({}, opts)
+ const ids = backup.entities.map((e) => e.id)
+ expect(ids).toContain(publicId)
+ expect(ids).not.toContain(internalId)
+ expect(ids).not.toContain(VFS_ROOT_ID)
+ }
+ })
+
+ it('index mode + includeHidden also reaches both tiers — find() takes includeInternal + includeSystem in one pass', async () => {
+ const publicId = await brain.add({ data: 'Public', type: NounType.Thing, subtype: 'x' })
+ const internalId = await brain.add({
+ data: 'Internal',
+ type: NounType.Thing,
+ subtype: 'x',
+ visibility: 'internal'
+ })
+
+ const indexExport = await brain.export({}, { enumeration: 'index', includeHidden: true })
+ const canonicalExport = await brain.export({}, { enumeration: 'canonical', includeHidden: true })
+
+ const indexIds = indexExport.entities.map((e) => e.id).sort()
+ const canonicalIds = canonicalExport.entities.map((e) => e.id).sort()
+ expect(indexIds).toEqual(canonicalIds)
+ expect(indexIds).toContain(publicId)
+ expect(indexIds).toContain(internalId)
+ expect(indexIds).toContain(VFS_ROOT_ID)
+ })
+
+ it('drift stays pure under includeHidden — no tier-policy noise when the index is healthy', async () => {
+ await brain.add({ data: 'Public', type: NounType.Thing, subtype: 'x' })
+ await brain.add({ data: 'Internal', type: NounType.Thing, subtype: 'x', visibility: 'internal' })
+
+ const audited = await brain.export(
+ {},
+ { enumeration: 'canonical', includeHidden: true, reportIndexDrift: true }
+ )
+ expect(audited.drift).toEqual({ canonicalOnly: [], indexOnly: [] })
+ })
+})
From 91ef1c8b6da954fa303399514a1602d855e3dee1 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 27 Jul 2026 11:23:11 -0700
Subject: [PATCH 025/175] docs: the last two archived-host links point home
---
README.md | 2 +-
RELEASES.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 2caf6493..ca558340 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
Brainy
diff --git a/RELEASES.md b/RELEASES.md
index 9e7dec6f..2e137e4f 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -1,7 +1,7 @@
# @soulcraft/brainy — Release Notes for Consumers
This file is the **quick reference for downstream sessions** tracking Brainy changes.
-Full auto-generated changelog: `CHANGELOG.md` · Releases: https://github.com/soulcraftlabs/brainy/releases
+Full auto-generated changelog: `CHANGELOG.md` · Releases: https://source.soulcraft.com/soulcraft/brainy/releases
**How to use:** Brainy is the underlying data engine for downstream applications. Read this when:
- Upgrading `@soulcraft/brainy` in your application
From 246f5a311a0212fcd38414f3ba3daa7f23e4b5cf Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 27 Jul 2026 11:59:40 -0700
Subject: [PATCH 026/175] chore(release): 8.11.0
---
CHANGELOG.md | 9 +++++++++
package-lock.json | 4 ++--
package.json | 2 +-
3 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5f344cc..04283b67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
+### [8.11.0](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.1...v8.11.0) (2026-07-27)
+
+- docs: the last two archived-host links point home (91ef1c8b)
+- feat: includeHidden — export carries every visibility tier for migration-grade canon completeness (63c1eeb9)
+- feat(release): the forge publish leg moves to CI on the tag push; the laptop verifies by readback and keeps the abort-before-storefront guard (3e4a17dc)
+- feat: canonical enumeration mode for export — storage-walked, canon-complete, with an index-drift report (4d196af4)
+- ci: run the pipeline on the forge (999d0ebb)
+
+
### [8.10.1](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.0...v8.10.1) (2026-07-24)
- refactor: remove the orphaned transaction-result type left behind by the dead-path removal (edf123a5)
diff --git a/package-lock.json b/package-lock.json
index d0c7b9d9..29be914a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@soulcraft/brainy",
- "version": "8.10.1",
+ "version": "8.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@soulcraft/brainy",
- "version": "8.10.1",
+ "version": "8.11.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
diff --git a/package.json b/package.json
index ce670369..cfb05486 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@soulcraft/brainy",
- "version": "8.10.1",
+ "version": "8.11.0",
"description": "Universal Knowledge Protocol™ - World's first Triple Intelligence database unifying vector, graph, and document search in one API. Stage 3 CANONICAL: 42 nouns × 127 verbs covering 96-97% of all human knowledge.",
"main": "dist/index.js",
"module": "dist/index.js",
From 64049631bc0141d00da8d618fc1450292d7868cb Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 27 Jul 2026 12:13:08 -0700
Subject: [PATCH 027/175] =?UTF-8?q?fix(release):=20double=20the=20forge-pu?=
=?UTF-8?q?blish=20poll=20budget=20=E2=80=94=20the=20runner=20executes=20j?=
=?UTF-8?q?obs=20sequentially=20and=20the=20publish=20run=20queues=20behin?=
=?UTF-8?q?d=20the=20ci=20matrix?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/release.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/release.sh b/scripts/release.sh
index c64d6c5e..7233412f 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -189,7 +189,7 @@ echo -e "${GREEN}✅ Pushed to origin${NC}\n"
# the forge/npmjs pair enough to publish the storefront leg.
FORGE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/npm/"
FORGE_POLL_INTERVAL_S=15
-FORGE_POLL_MAX_ATTEMPTS=40 # 40 × 15s = 10 minutes
+FORGE_POLL_MAX_ATTEMPTS=80 # 80 × 15s = 20 minutes — the runner is sequential; the publish run queues behind ci.yml jobs
echo -e "${BLUE}9️⃣ Waiting for CI to publish v${NEW_VERSION} to the forge registry (home)...${NC}"
FORGE_LANDED=false
for ((attempt = 1; attempt <= FORGE_POLL_MAX_ATTEMPTS; attempt++)); do
From cb717be2752054a8c35893271ae700263ab84241 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Wed, 29 Jul 2026 10:42:50 -0700
Subject: [PATCH 028/175] =?UTF-8?q?fix:=20metadata-only=20update()=20never?=
=?UTF-8?q?=20rewrites=20the=20noun=20record=20=E2=80=94=20the=20unconditi?=
=?UTF-8?q?onal=20whole-vector=20save=20turned=20per-entity=20stat=20touch?=
=?UTF-8?q?es=20into=20full=20rewrites+fsync,=20amplifying=20read-heavy=20?=
=?UTF-8?q?sweeps=20into=20disk=20saturation=20on=20a=20production=20deplo?=
=?UTF-8?q?yment?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Also: idle PathResolver stats tick no longer logs NaN% every minute (logs
only on new traffic, via prodLog); graph-lsm-* key family recognized as
system resources (kills the per-boot unknown-key warning on provider-backed
brains). Four regression pins in tests/integration/update-write-granularity.
---
src/brainy.ts | 27 ++--
src/storage/baseStorage.ts | 4 +
src/vfs/PathResolver.ts | 13 +-
.../update-write-granularity.test.ts | 126 ++++++++++++++++++
4 files changed, 155 insertions(+), 15 deletions(-)
create mode 100644 tests/integration/update-write-granularity.test.ts
diff --git a/src/brainy.ts b/src/brainy.ts
index f6b09e25..5bb77d05 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -3161,18 +3161,23 @@ export class Brainy implements BrainyInterface {
new UpdateNounMetadataOperation(this.storage, params.id, updatedMetadata)
)
- // Operation 2: Update vector data (will use updated type cache)
- tx.addOperation(
- new SaveNounOperation(this.storage, {
- id: params.id,
- vector,
- connections: new Map(),
- level: 0
- })
- )
-
- // Operation 3-4: Update HNSW index (remove and re-add if reindexing needed)
+ // Operations 2-4: vector-record write + HNSW reindex — ONLY when the
+ // vector side actually changed (new data/vector/type). A metadata-only
+ // update must never rewrite the noun record: the record carries the
+ // full vector, so an unconditional save turned every metadata touch
+ // into a whole-vector rewrite + fsync — under a read-heavy consumer
+ // sweep that bumps per-entity stats, this amplified into disk
+ // saturation on a production deployment (SELF-ENGINE-RESTART-GRIND,
+ // 2026-07-29: 5.8GB written in 40min from ~50 recalls/min).
if (needsReindexing) {
+ tx.addOperation(
+ new SaveNounOperation(this.storage, {
+ id: params.id,
+ vector,
+ connections: new Map(),
+ level: 0
+ })
+ )
tx.addOperation(
new RemoveFromVectorIndexOperation(this.index, params.id, existing.vector)
)
diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts
index 6daf09c0..1d3e245d 100644
--- a/src/storage/baseStorage.ts
+++ b/src/storage/baseStorage.ts
@@ -382,6 +382,10 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// identical to the unknown-key fallback these keys hit
// before being listed here — this only kills the
// per-boot "Unknown key format" warning)
+ id.startsWith('graph-lsm-') || // Graph-LSM store manifests written through storage by
+ // an active native graph provider — same
+ // warn-then-route fallback as above; listing the family
+ // silences the per-boot warning on provider-backed brains
isSingletonSystemKey(id) // Known singletons (e.g. brainy:entityIdMapper) hit the
// same warn-then-route fallback without this — the
// routing below already handles them identically
diff --git a/src/vfs/PathResolver.ts b/src/vfs/PathResolver.ts
index e496c834..502c95f0 100644
--- a/src/vfs/PathResolver.ts
+++ b/src/vfs/PathResolver.ts
@@ -57,6 +57,7 @@ export class PathResolver {
// Statistics
private cacheHits = 0
private cacheMisses = 0
+ private lastLoggedLookups = 0 // last total the maintenance tick logged stats at
private metadataIndexHits = 0
private metadataIndexMisses = 0
private graphTraversalFallbacks = 0
@@ -519,10 +520,14 @@ export class PathResolver {
}
}
- // Log cache statistics (in production, send to monitoring)
- const hitRate = this.cacheHits / (this.cacheHits + this.cacheMisses)
- if ((this.cacheHits + this.cacheMisses) % 1000 === 0) {
- console.log(`[PathResolver] Cache stats: ${Math.round(hitRate * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`)
+ // Log cache statistics only when there is new traffic to report — an
+ // idle resolver stays silent. 0/0 lookups previously rendered
+ // "NaN% hit rate" (and the %1000 gate passes at zero), which spammed
+ // production journals once a minute on every idle VFS.
+ const totalLookups = this.cacheHits + this.cacheMisses
+ if (totalLookups > 0 && totalLookups !== this.lastLoggedLookups && totalLookups % 1000 === 0) {
+ this.lastLoggedLookups = totalLookups
+ prodLog.debug(`[PathResolver] Cache stats: ${Math.round((this.cacheHits / totalLookups) * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`)
}
}, 60000) // Every minute
// Cache maintenance must never keep the host process alive.
diff --git a/tests/integration/update-write-granularity.test.ts b/tests/integration/update-write-granularity.test.ts
new file mode 100644
index 00000000..234df334
--- /dev/null
+++ b/tests/integration/update-write-granularity.test.ts
@@ -0,0 +1,126 @@
+/**
+ * @module tests/integration/update-write-granularity
+ * @description Write-granularity law for update() (SELF-ENGINE-RESTART-GRIND,
+ * 2026-07-29): a metadata-only update must NEVER rewrite the noun record —
+ * the record carries the full vector, so an unconditional save turns every
+ * metadata touch into a whole-vector rewrite + fsync. Under a read-heavy
+ * consumer sweep bumping per-entity stats this amplified into disk saturation
+ * on a production deployment. Laws:
+ * (1) metadata-only update() → zero saveNoun calls (metadata leg only);
+ * (2) data/vector/type-changing update() → saveNoun runs (the vector leg and
+ * HNSW reindex still happen when the vector side actually changed);
+ * (3) the metadata-only path still lands: merged metadata readable, _rev
+ * bumped, find() by the new field sees the entity.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+
+const stubEmbedding = async (text: string): Promise => {
+ const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
+ return new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
+}
+
+describe('update() write granularity', () => {
+ let brain: Brainy
+
+ beforeEach(async () => {
+ brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' as const },
+ embeddingFunction: stubEmbedding
+ })
+ await brain.init()
+ })
+
+ afterEach(async () => {
+ await brain.close()
+ })
+
+ it('metadata-only update never rewrites the noun record (no vector rewrite)', async () => {
+ const id = await brain.add({
+ data: 'granularity law subject',
+ type: NounType.Concept,
+ metadata: { touched: 0 }
+ })
+
+ const storage = (brain as any).storage
+ const saveNounSpy = vi.spyOn(storage, 'saveNoun')
+
+ await brain.update({ id, metadata: { touched: 1 } })
+
+ expect(saveNounSpy).not.toHaveBeenCalled()
+ saveNounSpy.mockRestore()
+
+ // The metadata leg still landed with full semantics.
+ const after = await brain.get(id, { includeVectors: true })
+ expect(after?.metadata?.touched).toBe(1)
+ expect(after?._rev).toBe(2)
+ expect(Array.isArray(after?.vector) && after!.vector!.length).toBe(384)
+
+ const found = await brain.find({ where: { touched: 1 } })
+ expect(found.some((r: any) => r.id === id)).toBe(true)
+ })
+
+ it('confidence/weight/subtype-only updates also skip the noun record', async () => {
+ const id = await brain.add({
+ data: 'reserved-field touch subject',
+ type: NounType.Concept,
+ metadata: {}
+ })
+
+ const storage = (brain as any).storage
+ const saveNounSpy = vi.spyOn(storage, 'saveNoun')
+
+ await brain.update({ id, confidence: 0.5, weight: 2, subtype: 'note' })
+
+ expect(saveNounSpy).not.toHaveBeenCalled()
+ saveNounSpy.mockRestore()
+
+ const after = await brain.get(id)
+ expect(after?.confidence).toBe(0.5)
+ expect(after?.subtype).toBe('note')
+ })
+
+ it('data-changing update still writes the noun record and reindexes', async () => {
+ const id = await brain.add({
+ data: 'original embedded text',
+ type: NounType.Concept,
+ metadata: {}
+ })
+
+ const before = await brain.get(id, { includeVectors: true })
+
+ const storage = (brain as any).storage
+ const saveNounSpy = vi.spyOn(storage, 'saveNoun')
+
+ await brain.update({ id, data: 'completely different embedded text' })
+
+ expect(saveNounSpy).toHaveBeenCalled()
+ saveNounSpy.mockRestore()
+
+ const after = await brain.get(id, { includeVectors: true })
+ expect(after?.data).toBe('completely different embedded text')
+ expect(after?.vector).not.toEqual(before?.vector)
+ })
+
+ it('explicit-vector update still writes the noun record', async () => {
+ const id = await brain.add({
+ data: 'vector swap subject',
+ type: NounType.Concept,
+ metadata: {}
+ })
+
+ const storage = (brain as any).storage
+ const saveNounSpy = vi.spyOn(storage, 'saveNoun')
+
+ const newVector = new Array(384).fill(0).map((_, i) => Math.cos(i))
+ await brain.update({ id, vector: newVector })
+
+ expect(saveNounSpy).toHaveBeenCalled()
+ saveNounSpy.mockRestore()
+
+ const after = await brain.get(id, { includeVectors: true })
+ expect(after?.vector?.[0]).toBeCloseTo(1) // cos(0)
+ })
+})
From 1a09be0628f49978369ad2a1b7a7862f6e965d9d Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 11:57:32 -0700
Subject: [PATCH 029/175] =?UTF-8?q?fix:=20user=20metadata=20named=20'level?=
=?UTF-8?q?'=20is=20a=20real=20field=20everywhere=20=E2=80=94=20the=20engi?=
=?UTF-8?q?ne-internal=20node=20layer=20no=20longer=20shadows=20it=20in=20?=
=?UTF-8?q?sort/filter/aggregation,=20and=20the=20indexing=20views=20stop?=
=?UTF-8?q?=20stamping=20a=20phantom=200=20into=20its=20column;=20index=20?=
=?UTF-8?q?epoch=202=20rebuilds=20existing=20brains=20at=20first=20open?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Also completes the v8.10.2 write-granularity law for the transact() plan
path: a metadata-only batch update never rewrites the vector-bearing noun
record (planUpdate staged the unconditional save the update() fix removed).
Seven pins in tests/integration/level-field-shadow.test.ts including the
reporting consumer's exact repro rows; orderBy JSDoc documents the ordering
contract and the announced field-addressing law.
---
RELEASES.md | 55 +++++++
src/brainy.ts | 33 ++--
src/coreTypes.ts | 7 +-
src/storage/brainFormat.ts | 7 +-
src/types/brainy.types.ts | 18 ++-
tests/integration/level-field-shadow.test.ts | 147 ++++++++++++++++++
tests/integration/orderby-sort-bug.test.ts | 5 +-
tests/unit/brainy/migration-deference.test.ts | 4 +-
8 files changed, 259 insertions(+), 17 deletions(-)
create mode 100644 tests/integration/level-field-shadow.test.ts
diff --git a/RELEASES.md b/RELEASES.md
index 2e137e4f..41d99dd9 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -74,6 +74,61 @@ to the caller today.
on CI**, triggered by the release tag, instead of PUTting the tarball from the laptop
over WAN — no change to what gets published or how a consumer installs it.
+## Unreleased (natural field names stop colliding with engine internals)
+
+From a production report: sorting by a user metadata field named `level` silently
+returned insertion order — the engine's internal HNSW node layer (also called
+`level`) shadowed the user's field in every by-name read, and the indexing path
+stamped a hardcoded `0` into the same index column (multi-valued poison). `level`
+is a perfectly natural field name (game characters, priorities, floors); the
+engine was wrong, not the caller.
+
+- **`level` is user data now, everywhere.** Engine plumbing no longer resolves by
+ name, never shadows metadata, and never enters the indexed views. `orderBy:
+ 'level'`, `where: { level: 9 }`, `groupBy: ['level']` all read YOUR field.
+ Regression pins: `tests/integration/level-field-shadow.test.ts` (the reporting
+ consumer's exact repro rows).
+- **Index epoch 2.** The derived posting set changed, so every existing brain
+ rebuilds its metadata index from canonical at first open — poisoned columns
+ heal automatically; no manual step. First open after upgrade pays one rebuild
+ (observable via `getIndexStatus()`); pair this release with the same-day
+ native-accelerator release, which makes `level` indexable on the native path.
+- **`transact()` metadata-only updates stop rewriting the vector record** — the
+ v8.10.2 write-granularity law now covers the batch/plan path too (it was
+ fixed for `update()` but the transact plan builder still staged the
+ unconditional save). If you batch stat touches through `transact()`, this is
+ your write-amplification fix.
+- Coming next (announced so parsers and call sites can prepare): one
+ field-addressing law — bare names = user metadata, `system.` for
+ engine fields, typed refusals for unresolvable names. Ships as its own
+ release with a migration advisory; nothing changes in this release.
+
+---
+
+## v8.10.2 — 2026-07-29 (metadata-only updates stop rewriting the vector record)
+
+From a production incident on a large deployment: a read-heavy sweep that bumped
+per-entity stats (metadata-only `update()` calls) saturated the disk — 5.8GB written
+in 40 minutes — because every `update()` unconditionally re-persisted the WHOLE noun
+record, unchanged vector included, fsynced.
+
+- **`update()` write granularity fixed at the core.** A metadata-only update (no new
+ `data`, `vector`, or `type`) now writes the metadata leg and index deltas ONLY —
+ the vector-bearing noun record is never rewritten. Vector-side writes and HNSW
+ reindexing still happen exactly when the vector side actually changed. Regression
+ pins: `tests/integration/update-write-granularity.test.ts`.
+- **Consumer guidance:** per-entity stat touches are now cheap, but batch them anyway
+ (one `transact()` instead of N `update()` calls) — granularity fixes the cost per
+ touch; batching fixes the count.
+- Idle VFS `PathResolver` no longer logs `NaN% hit rate` once a minute (stats log
+ only on new traffic, at debug level).
+- Native graph providers' `graph-lsm-*` storage keys are recognized as system
+ resources — the per-boot `Unknown key format` warning for them is gone.
+
+Pairs with the native accelerator's same-day patch release; adopt as one bump.
+
+---
+
## v8.10.1 — 2026-07-24 (the no-hot-retry contract + warm()'s metadata surface under native providers)
From a production incident: a native-provider op ground 38-40s inside a transaction,
diff --git a/src/brainy.ts b/src/brainy.ts
index 5bb77d05..d8eca08b 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -2123,11 +2123,13 @@ export class Brainy implements BrainyInterface {
// If undefined values are included as explicit keys, extractIndexableFields indexes
// them as '__NULL__' entries that removeFromIndex can never clean up (storageMetadata
// omits those keys entirely via conditional spreading, so the fields don't match).
+ // No `level` here: engine plumbing never enters the indexing view — a
+ // hardcoded level:0 landed in the SAME flattened index column as user
+ // metadata named `level`, poisoning it multi-valued ([0, real]).
const entityForIndexing = {
id,
vector,
connections: new Map(),
- level: 0,
type: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
...(params.visibility !== undefined &&
@@ -3102,12 +3104,13 @@ export class Brainy implements BrainyInterface {
})
}
- // Build entity structure for metadata index (with top-level fields)
+ // Build entity structure for metadata index (with top-level fields).
+ // No `level`: engine plumbing never enters the indexing view (it
+ // poisoned the flattened user `level` column — VENUE-BRAINY-ORDERBY-NOOP).
const entityForIndexing = {
id: params.id,
vector,
connections: new Map(),
- level: 0,
type: params.type || existing.type,
subtype: params.subtype !== undefined ? params.subtype : existing.subtype,
...(((params.visibility ?? existing.visibility) ?? 'public') !== 'public' && {
@@ -9377,7 +9380,7 @@ export class Brainy implements BrainyInterface {
id,
vector,
connections: new Map(),
- level: 0,
+ // no `level` — plumbing never enters the indexing view
type: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
...(params.visibility !== undefined &&
@@ -9528,7 +9531,7 @@ export class Brainy implements BrainyInterface {
id: params.id,
vector,
connections: new Map(),
- level: 0,
+ // no `level` — plumbing never enters the indexing view
type: params.type || existing.type,
subtype: params.subtype !== undefined ? params.subtype : existing.subtype,
...(((params.visibility ?? existing.visibility) ?? 'public') !== 'public' && {
@@ -9556,16 +9559,22 @@ export class Brainy implements BrainyInterface {
}
plan.operations.push(
- new UpdateNounMetadataOperation(this.storage, params.id, updatedMetadata),
- new SaveNounOperation(this.storage, {
- id: params.id,
- vector,
- connections: new Map(),
- level: 0
- })
+ new UpdateNounMetadataOperation(this.storage, params.id, updatedMetadata)
)
+ // Noun-record write + HNSW reindex ONLY when the vector side actually
+ // changed — the same write-granularity law as update(): a metadata-only
+ // patch must never rewrite the whole vector record. This plan path is the
+ // one transact() updates ride, so an unconditional save here would
+ // re-open the read-sweep disk-saturation amplifier for exactly the
+ // consumers batching their stat touches through transact().
if (needsReindexing) {
plan.operations.push(
+ new SaveNounOperation(this.storage, {
+ id: params.id,
+ vector,
+ connections: new Map(),
+ level: 0
+ }),
new RemoveFromVectorIndexOperation(this.index, params.id, existing.vector),
new AddToVectorIndexOperation(this.index, params.id, vector)
)
diff --git a/src/coreTypes.ts b/src/coreTypes.ts
index e0248d17..90fc4462 100644
--- a/src/coreTypes.ts
+++ b/src/coreTypes.ts
@@ -284,7 +284,12 @@ export const STANDARD_ENTITY_FIELDS: ReadonlySet = new Set([
'id',
'vector',
'connections',
- 'level',
+ // 'level' is deliberately ABSENT: it is HNSW plumbing, not an entity field.
+ // Listing it here made every by-name read of a user metadata field called
+ // `level` resolve to the engine's internal node layer instead — a silent
+ // shadow that broke sort/filter/aggregation on a perfectly natural field
+ // name (VENUE-BRAINY-ORDERBY-NOOP). Engine plumbing is invisible to the
+ // query surface; a bare `level` reads `entity.metadata.level`.
'type',
'subtype',
'visibility',
diff --git a/src/storage/brainFormat.ts b/src/storage/brainFormat.ts
index 2e6488e9..a1241fe0 100644
--- a/src/storage/brainFormat.ts
+++ b/src/storage/brainFormat.ts
@@ -69,7 +69,12 @@ export const BRAIN_FORMAT_PATH = '_system/brain-format.json'
* (the 8.0 GA baseline). An on-disk `indexEpoch` that differs from this — or an
* absent marker — triggers a full derived-index rebuild on open.
*/
-export const EXPECTED_INDEX_EPOCH = 1
+// Epoch 2 (2026-08-03, paired with the native accelerator's same-day release):
+// user metadata fields named `level` become indexable on both engines — the
+// derived posting set changed, so every pre-fix brain must rebuild its
+// metadata index from canonical at first open (poisoned multi-valued `level`
+// columns heal through this rebuild; no bespoke heal path).
+export const EXPECTED_INDEX_EPOCH = 2
/**
* @description The data-layer format string this build writes and runs as.
diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts
index b5f286ed..89be78b9 100644
--- a/src/types/brainy.types.ts
+++ b/src/types/brainy.types.ts
@@ -551,7 +551,23 @@ export interface FindParams {
cursor?: string // Cursor-based pagination
// Sorting
- orderBy?: string // Field to sort by (e.g., 'createdAt', 'title', 'metadata.priority')
+ /**
+ * Field to sort by. User metadata fields sort by their stored values —
+ * including natural names like `level`, `rank`, or `score` (an engine-internal
+ * field can never shadow your metadata; fixed 2026-08 after a production
+ * report). System timestamps (`createdAt`, `updatedAt`) sort by entity age.
+ *
+ * Ordering contract (identical on the pure-JS engine and the native
+ * accelerator): entities missing the field sort LAST in both directions —
+ * they are never dropped from the result; ties break deterministically.
+ *
+ * NOTE — the field-addressing law is changing (announced 2026-08): bare
+ * names will mean user metadata ALWAYS, and system fields will be reached
+ * explicitly as `system.` (e.g. `system.createdAt`), with typed
+ * refusals for unresolvable names. Until that release, bare `createdAt`
+ * and friends keep resolving to the system fields as documented above.
+ */
+ orderBy?: string
order?: 'asc' | 'desc' // Sort direction: 'asc' (default) or 'desc'
// Advanced options
diff --git a/tests/integration/level-field-shadow.test.ts b/tests/integration/level-field-shadow.test.ts
new file mode 100644
index 00000000..d50593ff
--- /dev/null
+++ b/tests/integration/level-field-shadow.test.ts
@@ -0,0 +1,147 @@
+/**
+ * @module tests/integration/level-field-shadow
+ * @description The reserved-name shadow fix (VENUE-BRAINY-ORDERBY-NOOP,
+ * 2026-08-03): `level` is HNSW plumbing, not an entity field — it must never
+ * shadow user metadata of the same name. Pre-fix, STANDARD_ENTITY_FIELDS
+ * listed `level`, so every by-name read returned the engine's internal 0
+ * (all-equal → stable sort → insertion order, silently), and the indexing
+ * views stamped level:0 into the same flattened column as user values
+ * (multi-valued [0, real] poison). Laws:
+ * (1) venue's exact repro sorts: three adds with metadata.level 3/9/6 →
+ * find({orderBy:'level'}) returns 9,6,3 desc and 3,6,9 asc;
+ * (2) where {level: N} matches through filter AND egress guard;
+ * (3) the index column carries the user value only (no 0 poison);
+ * (4) update() keeps `level` readable (the update indexing view is clean too);
+ * (5) the transact() update path never rewrites the noun record on a
+ * metadata-only patch (the planUpdate granularity completion).
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import { EXPECTED_INDEX_EPOCH } from '../../src/storage/brainFormat.js'
+
+const stubEmbedding = async (text: string): Promise => {
+ const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
+ return new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
+}
+
+describe('level field shadow — user metadata named level is a real field', () => {
+ let brain: Brainy
+
+ beforeEach(async () => {
+ brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' as const },
+ embeddingFunction: stubEmbedding
+ })
+ await brain.init()
+ })
+
+ afterEach(async () => {
+ await brain.close()
+ })
+
+ async function addProbeRows(): Promise {
+ const ids: string[] = []
+ for (const level of [3, 9, 6]) {
+ ids.push(
+ await brain.add({
+ data: `probe character level ${level}`,
+ type: NounType.Person,
+ subtype: 'probe-char',
+ metadata: { name: `char-${level}`, level }
+ })
+ )
+ }
+ return ids
+ }
+
+ it("venue's exact repro: orderBy 'level' sorts desc and asc", async () => {
+ await addProbeRows()
+
+ const desc = await brain.find({
+ type: NounType.Person,
+ subtype: 'probe-char',
+ orderBy: 'level',
+ order: 'desc',
+ limit: 100
+ })
+ expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3])
+
+ const asc = await brain.find({
+ type: NounType.Person,
+ subtype: 'probe-char',
+ orderBy: 'level',
+ order: 'asc',
+ limit: 100
+ })
+ expect(asc.map((r: any) => r.metadata?.level)).toEqual([3, 6, 9])
+ })
+
+ it('ordered reads are COMPLETE — no row dropped (the 2-of-3 face)', async () => {
+ const ids = await addProbeRows()
+ const desc = await brain.find({
+ type: NounType.Person,
+ subtype: 'probe-char',
+ orderBy: 'level',
+ order: 'desc',
+ limit: 100
+ })
+ expect(desc).toHaveLength(3)
+ expect(new Set(desc.map((r: any) => r.id))).toEqual(new Set(ids))
+ })
+
+ it('where {level: N} matches through the filter and the egress guard', async () => {
+ const ids = await addProbeRows()
+ const hit = await brain.find({ where: { level: 9 } })
+ expect(hit).toHaveLength(1)
+ expect(hit[0].id).toBe(ids[1])
+ expect(hit[0].metadata?.level).toBe(9)
+ })
+
+ it('the index column carries ONLY the user value (no 0 poison)', async () => {
+ const ids = await addProbeRows()
+ const metadataIndex = (brain as any).metadataIndex
+ const value = await metadataIndex.getFieldValueForEntity(ids[1], 'level')
+ expect(value).toBe(9)
+
+ // Zero must not match anything — pre-fix every entity carried a phantom 0.
+ const phantom = await brain.find({ where: { level: 0 } })
+ expect(phantom).toHaveLength(0)
+ })
+
+ it('update() keeps level readable (the update indexing view is clean)', async () => {
+ const ids = await addProbeRows()
+ await brain.update({ id: ids[0], metadata: { level: 12 } })
+ const desc = await brain.find({
+ type: NounType.Person,
+ subtype: 'probe-char',
+ orderBy: 'level',
+ order: 'desc',
+ limit: 100
+ })
+ expect(desc.map((r: any) => r.metadata?.level)).toEqual([12, 9, 6])
+ })
+
+ it('transact() metadata-only update never rewrites the noun record', async () => {
+ const ids = await addProbeRows()
+ const storage = (brain as any).storage
+ const saveNounSpy = vi.spyOn(storage, 'saveNoun')
+
+ await brain.transact([
+ { op: 'update', id: ids[0], metadata: { level: 4 } },
+ { op: 'update', id: ids[2], metadata: { level: 7 } }
+ ])
+
+ expect(saveNounSpy).not.toHaveBeenCalled()
+ saveNounSpy.mockRestore()
+
+ const after = await brain.get(ids[0], { includeVectors: true })
+ expect(after?.metadata?.level).toBe(4)
+ expect(Array.isArray(after?.vector) && after!.vector!.length).toBe(384)
+ })
+
+ it('this build runs index epoch 2 (the paired level-indexability rebuild)', () => {
+ expect(EXPECTED_INDEX_EPOCH).toBe(2)
+ })
+})
diff --git a/tests/integration/orderby-sort-bug.test.ts b/tests/integration/orderby-sort-bug.test.ts
index 9c28b1c9..db40fe12 100644
--- a/tests/integration/orderby-sort-bug.test.ts
+++ b/tests/integration/orderby-sort-bug.test.ts
@@ -215,7 +215,6 @@ describe('resolveEntityField helper', () => {
'id',
'vector',
'connections',
- 'level',
'type',
'confidence',
'weight',
@@ -228,5 +227,9 @@ describe('resolveEntityField helper', () => {
for (const field of expected) {
expect(STANDARD_ENTITY_FIELDS.has(field)).toBe(true)
}
+ // `level` is deliberately NOT resolvable: it is HNSW plumbing, and listing
+ // it here shadowed user metadata named `level` in every by-name read
+ // (the reserved-name shadow bug). Plumbing stays out of the resolver.
+ expect(STANDARD_ENTITY_FIELDS.has('level')).toBe(false)
})
})
diff --git a/tests/unit/brainy/migration-deference.test.ts b/tests/unit/brainy/migration-deference.test.ts
index 6471b4ef..f03bba9c 100644
--- a/tests/unit/brainy/migration-deference.test.ts
+++ b/tests/unit/brainy/migration-deference.test.ts
@@ -245,7 +245,9 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b
it('the brain-format marker module exports the compiled epoch + data-format constants', () => {
// cor imports these from '@soulcraft/brainy/brain-format' (Hook 3) so both
// sides share ONE source of truth — no duplicated constant to drift.
- expect(EXPECTED_INDEX_EPOCH).toBe(1)
+ // Epoch 2: user metadata named `level` became indexable (the reserved-name
+ // shadow fix, 2026-08-03) — pre-fix brains rebuild derived indexes at open.
+ expect(EXPECTED_INDEX_EPOCH).toBe(2)
expect(CURRENT_DATA_FORMAT).toBe('8.0')
})
})
From 0b059ac5debe62a876098cd6579f49a1c356be37 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 12:16:05 -0700
Subject: [PATCH 030/175] =?UTF-8?q?docs:=20port=20the=208.10.2=20backport-?=
=?UTF-8?q?release=20changelog=20entry=20to=20main=20=E2=80=94=20release?=
=?UTF-8?q?=20branches=20carry=20the=20version=20bump,=20main=20carries=20?=
=?UTF-8?q?the=20durable=20record?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04283b67..9be875d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,12 @@ All notable changes to this project will be documented in this file. See [standa
- ci: run the pipeline on the forge (999d0ebb)
+### [8.10.2](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.1...v8.10.2) (2026-07-29)
+
+- docs: 8.10.2 consumer release notes — update() write granularity, PathResolver idle-log fix, graph-lsm key recognition (a0123b5b)
+- fix: metadata-only update() never rewrites the noun record — the unconditional whole-vector save turned per-entity stat touches into full rewrites+fsync, amplifying read-heavy sweeps into disk saturation on a production deployment (5b65eb82)
+
+
### [8.10.1](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.0...v8.10.1) (2026-07-24)
- refactor: remove the orphaned transaction-result type left behind by the dead-path removal (edf123a5)
From f6b14d21c02468904b3d233a126b345ce78a59f1 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 13:04:41 -0700
Subject: [PATCH 031/175] docs: port the 8.10.3 backport-release changelog
entry to main
---
CHANGELOG.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9be875d0..5d71d3a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,12 @@ All notable changes to this project will be documented in this file. See [standa
- ci: run the pipeline on the forge (999d0ebb)
+### [8.10.3](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.2...v8.10.3) (2026-08-03)
+
+- docs: dedupe the 8.10.2 release-notes entry the cherry doubled onto the branch (8c956608)
+- fix: user metadata named 'level' is a real field everywhere — the engine-internal node layer no longer shadows it in sort/filter/aggregation, and the indexing views stop stamping a phantom 0 into its column; index epoch 2 rebuilds existing brains at first open (958a0859)
+
+
### [8.10.2](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.1...v8.10.2) (2026-07-29)
- docs: 8.10.2 consumer release notes — update() write granularity, PathResolver idle-log fix, graph-lsm key recognition (a0123b5b)
From 8f9a9989e947c6b3a714f3f2c7452a2b27762c28 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 13:27:36 -0700
Subject: [PATCH 032/175] =?UTF-8?q?feat(namespace):=20the=20one=20field-ad?=
=?UTF-8?q?dressing=20law=20as=20a=20single=20source=20of=20truth=20?=
=?UTF-8?q?=E2=80=94=20parseFieldAddress=20+=20the=20ruled=20ten-scalar=20?=
=?UTF-8?q?system=20maps=20+=20plumbing=20invisibility=20+=20refusal=20bui?=
=?UTF-8?q?lders=20(module=20only;=20query=20surfaces=20wire=20in=20next)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/db/fieldAddressing.ts | 246 ++++++++++++++++++++++++++++++++++++++
1 file changed, 246 insertions(+)
create mode 100644 src/db/fieldAddressing.ts
diff --git a/src/db/fieldAddressing.ts b/src/db/fieldAddressing.ts
new file mode 100644
index 00000000..0ee09a05
--- /dev/null
+++ b/src/db/fieldAddressing.ts
@@ -0,0 +1,246 @@
+/**
+ * @module db/fieldAddressing
+ * @description The one field-addressing law for every query surface (find()'s
+ * `where` / `orderBy` / `groupBy`, aggregation `source.where`), ruled
+ * 2026-08-03 after a production incident in which a user metadata field
+ * named `level` was silently shadowed by the engine's internal HNSW node
+ * layer (VENUE-BRAINY-ORDERBY-NOOP — thread id kept verbatim as the audit
+ * key; it names no product):
+ *
+ * 1. A BARE field name addresses the user's metadata field. Always.
+ * No priority resolution, no fallback chain — `orderBy: 'level'`
+ * reads `entity.metadata.level`, full stop.
+ * 2. `system.` addresses an engine scalar, reachable ONLY with the
+ * explicit prefix. The entity map is exactly ten scalars; the relation
+ * map mirrors it with `verb`/`sourceId`/`targetId` as the structural
+ * members.
+ * 3. Engine plumbing (`vector`, `connections`, `level`, `data`, `_rev`) is
+ * INVISIBLE to the query surface in either spelling — `system.level`
+ * refuses; bare `level` is the user's field.
+ * 4. `metadata.` is the explicit spelling of the bare form —
+ * identical semantics on every path.
+ * 5. Anything unresolvable refuses with a TYPED error naming both
+ * candidate spellings — an accepted name either works or refuses;
+ * there is no third state.
+ *
+ * This module is the SINGLE source of truth for the law: parsing, the maps,
+ * and the refusal builders live here so the JS engine, the provider seams,
+ * and the cross-engine conformance suite can never drift on the contract.
+ */
+
+import type { HNSWNounWithMetadata, HNSWVerbWithMetadata } from '../coreTypes.js'
+
+/**
+ * @description The entity-side `system.*` map — EXACTLY the ten engine
+ * scalars David ruled queryable (2026-08-03). Adding a name here is a
+ * cross-engine contract change: the native accelerator's conformance suite
+ * pins this list verbatim, so any edit must ship as a paired release.
+ */
+export const SYSTEM_ENTITY_SCALARS: ReadonlySet = new Set([
+ 'id',
+ 'type',
+ 'subtype',
+ 'createdAt',
+ 'updatedAt',
+ 'confidence',
+ 'weight',
+ 'visibility',
+ 'service',
+ 'createdBy'
+])
+
+/**
+ * @description The relation-side `system.*` map — the verb mirror of
+ * {@link SYSTEM_ENTITY_SCALARS}: `verb`, `sourceId`, `targetId` are the
+ * structural members beside the eight shared scalars. Same one law, same
+ * pairing rule for edits.
+ */
+export const SYSTEM_RELATION_SCALARS: ReadonlySet = new Set([
+ 'verb',
+ 'sourceId',
+ 'targetId',
+ 'subtype',
+ 'createdAt',
+ 'updatedAt',
+ 'confidence',
+ 'weight',
+ 'visibility',
+ 'service',
+ 'createdBy'
+])
+
+/**
+ * @description Engine plumbing — never addressable from the query surface in
+ * ANY spelling. `level` is the HNSW node layer (the incident field: listing
+ * it as resolvable shadowed real user data); `data` is the payload container,
+ * not a scalar — content is reached through the content/text-search APIs,
+ * and addressing it as a sortable field would lie about its shape.
+ */
+export const PLUMBING_FIELDS: ReadonlySet = new Set([
+ 'vector',
+ 'connections',
+ 'level',
+ 'data',
+ '_rev'
+])
+
+/** @description Which record kind a field address is being resolved against. */
+export type FieldAddressKind = 'entity' | 'relation'
+
+/**
+ * @description A parsed, law-valid field address. `scope` says which side of
+ * the record the name lives on; `field` is the unprefixed name to read.
+ */
+export interface FieldAddress {
+ /** 'metadata' = the user's field (bare or `metadata.`-prefixed); 'system' = an engine scalar. */
+ scope: 'metadata' | 'system'
+ /** The field name with any scope prefix removed. */
+ field: string
+ /** The exact spelling the caller used — preserved for error text and telemetry. */
+ raw: string
+}
+
+/**
+ * Parse a query-surface field name under the one law. Pure and data-blind:
+ * this validates the ADDRESS (spelling + map membership), not whether any
+ * row actually carries the field — data-aware refusals (the did-you-mean
+ * for a bare system-scalar name no row carries) belong to the query layer,
+ * which calls {@link buildUnresolvableMessage} with index knowledge.
+ *
+ * @param raw - The field name as the caller wrote it (`level`,
+ * `metadata.level`, `system.createdAt`, …)
+ * @param kind - Entity or relation resolution (selects the system map)
+ * @returns The parsed {@link FieldAddress}
+ * @throws {InvalidFieldAddressError} for a `system.*` name outside the ruled
+ * map (including every plumbing field) or a malformed spelling — the error
+ * text enumerates the valid system scalars so the fix is in the message.
+ *
+ * @example
+ * parseFieldAddress('level', 'entity') // { scope: 'metadata', field: 'level' }
+ * parseFieldAddress('metadata.level', 'entity') // { scope: 'metadata', field: 'level' }
+ * parseFieldAddress('system.createdAt', 'entity') // { scope: 'system', field: 'createdAt' }
+ * parseFieldAddress('system.level', 'entity') // throws — plumbing is invisible
+ */
+export function parseFieldAddress(
+ raw: string,
+ kind: FieldAddressKind
+): FieldAddress {
+ const systemMap =
+ kind === 'entity' ? SYSTEM_ENTITY_SCALARS : SYSTEM_RELATION_SCALARS
+
+ if (raw.startsWith('system.')) {
+ const field = raw.slice('system.'.length)
+ if (!systemMap.has(field)) {
+ throw new InvalidFieldAddressError(raw, kind, systemMap)
+ }
+ return { scope: 'system', field, raw }
+ }
+
+ if (raw.startsWith('metadata.')) {
+ const field = raw.slice('metadata.'.length)
+ if (field.length === 0) {
+ throw new InvalidFieldAddressError(raw, kind, systemMap)
+ }
+ return { scope: 'metadata', field, raw }
+ }
+
+ if (raw.length === 0) {
+ throw new InvalidFieldAddressError(raw, kind, systemMap)
+ }
+
+ // Bare name = the user's metadata field. Always. Even when the same name
+ // exists in the system map — `confidence` as a bare name is the user's
+ // metadata field named confidence; the engine scalar is system.confidence.
+ return { scope: 'metadata', field: raw, raw }
+}
+
+/**
+ * Read the addressed value off an entity. The ONLY sanctioned way a query
+ * surface turns a {@link FieldAddress} into a value — direct property reads
+ * against records re-create the shadow class this module exists to kill.
+ *
+ * @returns The value, or `undefined` when the record does not carry it
+ * (missing values sort LAST in both directions per the ordering contract —
+ * they are never grounds for dropping a row).
+ */
+export function readEntityFieldAddress(
+ entity: HNSWNounWithMetadata,
+ address: FieldAddress
+): unknown {
+ if (address.scope === 'system') {
+ return (entity as unknown as Record)[address.field]
+ }
+ return entity.metadata?.[address.field]
+}
+
+/**
+ * Relation twin of {@link readEntityFieldAddress}. The stored flat record
+ * keys the relation type under `verb`; public Relation shapes may carry it
+ * as `type` — both spellings of the record are read, the ADDRESS is always
+ * `system.verb`.
+ */
+export function readRelationFieldAddress(
+ verb: HNSWVerbWithMetadata,
+ address: FieldAddress
+): unknown {
+ if (address.scope === 'system') {
+ const rec = verb as unknown as Record
+ if (address.field === 'verb') return rec.verb ?? rec.type
+ return rec[address.field]
+ }
+ return verb.metadata?.[address.field]
+}
+
+/**
+ * Build the ruled did-you-mean refusal text for a bare name that resolved to
+ * metadata but is UNKNOWN to the index — the data-aware half of the law,
+ * called by the query layer once it has consulted the known-field set:
+ *
+ * "no metadata field 'createdAt' — did you mean system.createdAt or
+ * metadata.createdAt?"
+ *
+ * When the bare name is NOT a system scalar the system candidate is omitted
+ * (there is only one thing the caller could have meant; the refusal exists
+ * because refusing beats silently sorting nothing).
+ */
+export function buildUnresolvableMessage(
+ raw: string,
+ kind: FieldAddressKind
+): string {
+ const systemMap =
+ kind === 'entity' ? SYSTEM_ENTITY_SCALARS : SYSTEM_RELATION_SCALARS
+ if (systemMap.has(raw)) {
+ return (
+ `no metadata field '${raw}' — did you mean system.${raw} or metadata.${raw}? ` +
+ `(bare names always address your metadata; engine fields need the system. prefix)`
+ )
+ }
+ return (
+ `no metadata field '${raw}' on this store — nothing carries it, so an ordered or ` +
+ `filtered read against it cannot mean anything. Spell it metadata.${raw} once the ` +
+ `field exists, or check the field name.`
+ )
+}
+
+/**
+ * @description Refusal for a malformed or out-of-map field ADDRESS —
+ * `system.` (including all plumbing), an empty
+ * name, or a bare `metadata.` prefix. The message carries the full valid
+ * system map so the fix never needs a docs lookup.
+ */
+export class InvalidFieldAddressError extends Error {
+ public readonly raw: string
+ public readonly kind: FieldAddressKind
+
+ constructor(raw: string, kind: FieldAddressKind, systemMap: ReadonlySet) {
+ const valid = [...systemMap].map((f) => `system.${f}`).join(', ')
+ super(
+ `'${raw}' is not an addressable ${kind} field. Bare names address your own ` +
+ `metadata fields; engine fields are exactly: ${valid}. Engine plumbing ` +
+ `(vector, connections, level, data, _rev) is not part of the query surface.`
+ )
+ this.name = 'InvalidFieldAddressError'
+ this.raw = raw
+ this.kind = kind
+ }
+}
From d8d0b55f9d85bf044c80a464a692db8931b2b595 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 13:36:05 -0700
Subject: [PATCH 033/175] =?UTF-8?q?test(namespace)+docs:=20the=20cross-eng?=
=?UTF-8?q?ine=20conformance=20suite=20(self-arming=20=E2=80=94=20skips=20?=
=?UTF-8?q?until=20the=20resolver=20exports=20land)=20+=20the=20public=20f?=
=?UTF-8?q?ield-addressing=20docs=20page;=20sidebar=20order=20deconflicted?=
=?UTF-8?q?=20to=207?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/concepts/field-addressing.md | 196 ++++++++++
tests/conformance/namespace-law.test.ts | 484 ++++++++++++++++++++++++
2 files changed, 680 insertions(+)
create mode 100644 docs/concepts/field-addressing.md
create mode 100644 tests/conformance/namespace-law.test.ts
diff --git a/docs/concepts/field-addressing.md b/docs/concepts/field-addressing.md
new file mode 100644
index 00000000..863f7474
--- /dev/null
+++ b/docs/concepts/field-addressing.md
@@ -0,0 +1,196 @@
+---
+title: Field addressing: your fields and system fields
+slug: concepts/field-addressing
+public: true
+category: concepts
+template: concept
+order: 7
+description: The one rule for every query-surface field name — a bare name always means your metadata, system. reaches the ten engine scalars explicitly, and anything else refuses by name.
+next:
+ - concepts/consistency-model
+---
+
+# Field addressing: your fields and system fields
+
+Every query surface in Brainy — `find()`'s `where`, `orderBy`, aggregation
+`groupBy`, and aggregation `source.where` — resolves field names by one rule,
+with no exceptions:
+
+> **A bare field name always means your metadata. `system.` reaches an
+> engine scalar, and only when you spell it explicitly.**
+
+```typescript
+await brain.find({ orderBy: 'level' }) // reads entity.metadata.level — YOUR field
+await brain.find({ orderBy: 'system.createdAt' }) // reads the engine's createdAt scalar
+await brain.find({ orderBy: 'metadata.level' }) // identical to bare 'level' — explicit scope
+```
+
+There is no priority list, no "try the system field, fall back to metadata"
+behavior, and no name that resolves differently depending on what else
+happens to exist on your entities. A field called `level`, `score`,
+`createdAt`, or `type` in your own `metadata` is read as *your* field, every
+time, by its bare name.
+
+## Why this rule exists
+
+An internal report from a production deployment found that a user metadata
+field literally named `level` was being silently shadowed by the engine's
+own internal index layer field of the same name — every sort by `level`
+returned insertion order, with no error raised. This rule makes that class of
+bug structurally impossible: bare names belong to you, unconditionally, and
+anything that isn't yours has to be spelled out.
+
+## The system scalars
+
+`system.` addresses exactly ten scalars on an entity — no more, no
+fewer:
+
+| System field | What it is |
+|---|---|
+| `system.id` | The entity's id |
+| `system.type` | The entity's `NounType` |
+| `system.subtype` | The per-app sub-classification passed to `add()` |
+| `system.createdAt` | When the entity was created |
+| `system.updatedAt` | When the entity was last written |
+| `system.confidence` | The `confidence` param (0–1) |
+| `system.weight` | The `weight` param |
+| `system.visibility` | `'public'` / `'internal'` (see the visibility tiers in [Consistency Model](./consistency-model.md)) |
+| `system.service` | The multi-tenancy `service` tag |
+| `system.createdBy` | Who/what created the entity |
+
+Relationships mirror the same eight shared scalars (`subtype`, `createdAt`,
+`updatedAt`, `confidence`, `weight`, `visibility`, `service`, `createdBy`)
+plus three of their own:
+
+| System field (relationship) | What it is |
+|---|---|
+| `system.verb` | The relationship's `VerbType` |
+| `system.sourceId` | The id of the entity the relationship starts from |
+| `system.targetId` | The id of the entity the relationship points to |
+
+Anything not on these two lists is not a system scalar — `system.` for
+any other name refuses (see "Refusal semantics" below), even if that name
+sounds like it should be engine-owned.
+
+## Invisible plumbing — never addressable, in either spelling
+
+Five names are pure engine internals. They are not reachable as a bare name,
+and not reachable as `system.` either — they simply have no place on
+the query surface:
+
+- **`vector`** — the stored embedding. It participates in similarity search
+ (`query`, `near`, vector `find()`), never in `where`/`orderBy`/`groupBy`.
+- **`connections`** — graph adjacency. Reached through `connected` and
+ `brain.related()`, not through field addressing.
+- **`level`** — the internal index layer number used by the nearest-neighbor
+ graph. It is pure index plumbing with no query-surface meaning at all —
+ which is exactly why a user field of the same name must never be shadowed
+ by it. `level` as a bare name is always yours; there is no engine-owned
+ spelling of it to compete with.
+- **`data`** — your entity's content payload, not a scalar. It can be a
+ string, a number, or an arbitrary object, so sorting or filtering it as a
+ single comparable value would lie about its actual shape. Content is
+ reached through the content/text-search APIs (`query`, `searchMode:
+ 'text'`), not through `where`/`orderBy`.
+- **`_rev`** — the per-entity revision counter used for optimistic
+ concurrency (`ifRev`). It is a CAS token, not a queryable dimension.
+
+`system.level`, `system.vector`, and `system.data` all refuse for the same
+reason: they are not in the ten-scalar system map, full stop.
+
+## `metadata.` — the explicit spelling of "mine"
+
+Prefix any field with `metadata.` to say the same thing a bare name already
+says, spelled out. The two are interchangeable everywhere a field name is
+accepted, including `orderBy`:
+
+```typescript
+await brain.find({ where: { 'customer.tier': 'gold' } })
+await brain.find({ where: { 'metadata.customer.tier': 'gold' } }) // identical
+await brain.find({ orderBy: 'metadata.score', order: 'desc' }) // identical to orderBy: 'score'
+```
+
+Reach for the explicit spelling when it reads more clearly next to a
+`system.` field in the same query — for example, sorting by your own `score`
+while filtering on `system.confidence`.
+
+## Refusal semantics
+
+A name that resolves to neither your metadata nor a system scalar is a typed
+refusal, not a silent empty result and not a guess. Refusals name **both**
+candidates, so the fix is always in the error text:
+
+```typescript
+await brain.find({ orderBy: 'createdAt' })
+// UnresolvableFieldError: no metadata field 'createdAt' — did you mean
+// system.createdAt or metadata.createdAt?
+```
+
+`UnresolvableFieldError` is exported from the package root:
+
+```typescript
+import { UnresolvableFieldError } from '@soulcraft/brainy'
+
+try {
+ await brain.find({ orderBy: 'createdAt' })
+} catch (err) {
+ if (err instanceof UnresolvableFieldError) {
+ // err.message names both candidates — usually enough to fix the call site.
+ }
+}
+```
+
+A handful of `find()` options are not implemented yet: `cursor`,
+`includeRelations`, and `writeOnly`. Rather than accepting them and quietly
+ignoring the option, `find()` refuses with `UnsupportedFindOptionError` —
+also exported from the package root — so a call site can never believe an
+unimplemented option took effect when it didn't.
+
+## The ordering contract
+
+`orderBy` behaves identically regardless of which engine (the pure-TypeScript
+path or a native accelerator) is serving the query:
+
+- An entity missing the `orderBy` field, or holding `null` on it, sorts
+ **LAST — in both `asc` and `desc`**. It is never treated as "smaller than
+ everything" in one direction and "larger than everything" in the other; it
+ is simply last, either way.
+- Rows are **never dropped** from an ordered read because they lack the
+ field — a missing value changes position, never presence.
+- Ties on the `orderBy` field break by **id ascending**, regardless of the
+ primary sort direction.
+
+```typescript
+// employees: [{ score: 9 }, { score: 5 }, { /* no score field */ }]
+await brain.find({ orderBy: 'score', order: 'desc' }) // [9, 5, missing] — missing is last
+await brain.find({ orderBy: 'score', order: 'asc' }) // [5, 9, missing] — missing is STILL last
+```
+
+## Migrating existing call sites
+
+If you have call sites written before this rule shipped that rely on a bare
+system name — `orderBy: 'createdAt'`, `where: { confidence: { greaterThan:
+0.8 } }`, and similar — they now refuse instead of silently resolving to the
+engine field. The fix is always in the error: swap the bare name for
+`system.` (or `metadata.` if you actually meant your own field
+of that name, and it happens to share a name with a system scalar):
+
+```typescript
+// Before: bare 'createdAt' silently meant the engine's timestamp.
+await brain.find({ orderBy: 'createdAt' })
+
+// After: say which one you meant.
+await brain.find({ orderBy: 'system.createdAt' }) // the engine timestamp
+await brain.find({ orderBy: 'metadata.createdAt' }) // your own field named createdAt, if you have one
+```
+
+There is no silent migration path by design — every ambiguous call site
+surfaces as a refusal naming its own fix, once, the first time it runs
+against the new rule.
+
+## Where to go next
+
+- [Consistency Model](./consistency-model.md) — the separate (and
+ longer-standing) contract for *reserved* fields: which names may never
+ appear inside a `metadata` bag at write time, distinct from this page's
+ read-time addressing rule.
diff --git a/tests/conformance/namespace-law.test.ts b/tests/conformance/namespace-law.test.ts
new file mode 100644
index 00000000..91227587
--- /dev/null
+++ b/tests/conformance/namespace-law.test.ts
@@ -0,0 +1,484 @@
+/**
+ * @module tests/conformance/namespace-law
+ * @description Conformance suite for the ruled field-addressing contract
+ * announced in RELEASES.md ("Coming next... one field-addressing law — bare
+ * names = user metadata, `system.` for engine fields, typed refusals
+ * for unresolvable names"). This suite is the drift-proof shared by this
+ * engine and its native accelerator: both must satisfy every test here
+ * bit-for-bit, because they implement the SAME contract independently.
+ *
+ * The rule, in full:
+ * 1. A bare field name in `where` / `orderBy` / `groupBy` / aggregation
+ * `source.where` ALWAYS means the caller's own `metadata` field. No
+ * priority resolution, no engine fallback — ever.
+ * 2. `system.` reaches an engine scalar, and ONLY an engine scalar,
+ * and ONLY when spelled explicitly. The addressable entity map is exactly
+ * ten names: id, type, subtype, createdAt, updatedAt, confidence, weight,
+ * visibility, service, createdBy. The relationship map is system.verb,
+ * system.sourceId, system.targetId, plus the eight scalars shared with
+ * entities.
+ * 3. Some names are invisible plumbing and are never addressable in either
+ * spelling: vector, connections, level, data, _rev. `system.level`,
+ * `system.vector`, and `system.data` all refuse — they are not in the
+ * system map. Bare `level` is a perfectly ordinary user field.
+ * 4. `metadata.` is the explicit-user-scope spelling: identical
+ * semantics to the bare spelling, valid everywhere the bare spelling is.
+ * 5. Anything that resolves to neither a user field nor a system scalar is a
+ * typed refusal naming both candidates (`UnresolvableFieldError`).
+ * Unimplemented `find()` options (`cursor`, `includeRelations`,
+ * `writeOnly`) refuse with `UnsupportedFindOptionError` instead of being
+ * silently accepted and ignored.
+ * 6. Ordering is identical on both engines: rows missing/null on the
+ * `orderBy` field sort LAST in BOTH directions and are never dropped;
+ * ties break by id ascending.
+ *
+ * The motivating incident (told generically — see CLAUDE.md naming rule): an
+ * internal report from a production deployment showed a user metadata field
+ * literally named `level` silently shadowed by the engine's internal HNSW
+ * node layer, breaking sort order with zero errors raised. This contract
+ * makes that class of bug impossible, and testable forever.
+ *
+ * SELF-SKIP: the resolver this suite pins is being built in a parallel
+ * session and has not landed on every branch yet. Rather than going red on
+ * a branch that simply hasn't caught up, the suite detects whether the
+ * contract is live by the one thing any conformant implementation must
+ * export — `UnresolvableFieldError` from the package root — and skips
+ * loudly (never silently) until it does. This is the house pattern: a
+ * sibling engine's gate once went red because a test armed before its
+ * feature existed.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import * as brainyExports from '../../src/index.js'
+
+const stubEmbedding = async (text: string): Promise => {
+ const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
+ return new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
+}
+
+// Detected purely by the exported error-class NAME — never by reaching into
+// implementation internals. Both engines building this contract must export
+// it from the package root, so this is a legitimate, implementation-agnostic
+// readiness probe.
+const lawActive = 'UnresolvableFieldError' in brainyExports
+const UnresolvableFieldError = (brainyExports as Record).UnresolvableFieldError as new (
+ ...args: any[]
+) => Error
+const UnsupportedFindOptionError = (brainyExports as Record)
+ .UnsupportedFindOptionError as new (...args: any[]) => Error
+
+// Always runs, regardless of lawActive — the loud signal that the rest of
+// this file was skipped, and why.
+it('namespace law armed?', () => {
+ if (!lawActive) {
+ console.warn(
+ '[conformance] namespace-law suite SKIPPED — UnresolvableFieldError not exported yet; arms when the resolver lands'
+ )
+ }
+ expect(true).toBe(true)
+})
+
+/**
+ * Awaits `promise`, asserting it rejects with an instance of `ErrorClass`
+ * whose `.message` contains every string in `mustContain`. Fails loudly if
+ * the promise resolves instead of rejecting.
+ */
+async function expectRefusal(
+ promise: Promise,
+ ErrorClass: new (...args: any[]) => Error,
+ ...mustContain: string[]
+): Promise {
+ let threw = false
+ try {
+ await promise
+ } catch (err) {
+ threw = true
+ expect(err).toBeInstanceOf(ErrorClass)
+ for (const fragment of mustContain) {
+ expect((err as Error).message).toContain(fragment)
+ }
+ }
+ expect(threw).toBe(true)
+}
+
+describe.skipIf(!lawActive)('namespace law — bare/system/metadata field addressing', () => {
+ let brain: Brainy
+
+ beforeEach(async () => {
+ brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' as const },
+ embeddingFunction: stubEmbedding
+ })
+ await brain.init()
+ })
+
+ afterEach(async () => {
+ await brain.close()
+ })
+
+ /** The star case from the motivating incident: metadata.level 3/9/6. */
+ async function addLevelRows(): Promise {
+ const ids: string[] = []
+ for (const level of [3, 9, 6]) {
+ ids.push(
+ await brain.add({
+ data: `probe level ${level}`,
+ type: NounType.Person,
+ subtype: 'ns-law-level',
+ metadata: { name: `p-${level}`, level }
+ })
+ )
+ }
+ return ids
+ }
+
+ // -------------------------------------------------------------------
+ // Rule 1 — bare field name = the user's metadata field, always.
+ // -------------------------------------------------------------------
+
+ it("bare orderBy 'level' reads user metadata, desc and asc (the star case)", async () => {
+ await addLevelRows()
+
+ const desc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-level',
+ orderBy: 'level',
+ order: 'desc',
+ limit: 100
+ })
+ expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3])
+
+ const asc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-level',
+ orderBy: 'level',
+ order: 'asc',
+ limit: 100
+ })
+ expect(asc.map((r: any) => r.metadata?.level)).toEqual([3, 6, 9])
+ })
+
+ it("bare where { level: N } matches the user's field", async () => {
+ const ids = await addLevelRows()
+ const hit = await brain.find({ type: NounType.Person, subtype: 'ns-law-level', where: { level: 9 } })
+ expect(hit).toHaveLength(1)
+ expect(hit[0].id).toBe(ids[1])
+ expect(hit[0].metadata?.level).toBe(9)
+ })
+
+ // -------------------------------------------------------------------
+ // Rule 4 — metadata. is the explicit-user-scope spelling,
+ // identical semantics to bare, valid on every path including orderBy.
+ // -------------------------------------------------------------------
+
+ it("'metadata.level' resolves identically to bare 'level'", async () => {
+ await addLevelRows()
+ const desc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-level',
+ orderBy: 'metadata.level',
+ order: 'desc',
+ limit: 100
+ })
+ expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3])
+ })
+
+ // -------------------------------------------------------------------
+ // Rule 2 — system. reaches an engine scalar explicitly.
+ // -------------------------------------------------------------------
+
+ it('system.createdAt sorts by entity age', async () => {
+ const ids: string[] = []
+ for (const name of ['first', 'second', 'third']) {
+ ids.push(
+ await brain.add({
+ data: `aged ${name}`,
+ type: NounType.Person,
+ subtype: 'ns-law-aged',
+ metadata: { name }
+ })
+ )
+ // Guarantee distinct createdAt timestamps between adds.
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+
+ const asc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-aged',
+ orderBy: 'system.createdAt',
+ order: 'asc',
+ limit: 100
+ })
+ expect(asc.map((r: any) => r.id)).toEqual(ids)
+
+ const desc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-aged',
+ orderBy: 'system.createdAt',
+ order: 'desc',
+ limit: 100
+ })
+ expect(desc.map((r: any) => r.id)).toEqual([...ids].reverse())
+ })
+
+ it('where on system.confidence filters by the engine scalar', async () => {
+ const highId = await brain.add({
+ data: 'high confidence row',
+ type: NounType.Person,
+ subtype: 'ns-law-confidence',
+ confidence: 0.95,
+ metadata: { name: 'hi' }
+ })
+ await brain.add({
+ data: 'low confidence row',
+ type: NounType.Person,
+ subtype: 'ns-law-confidence',
+ confidence: 0.4,
+ metadata: { name: 'lo' }
+ })
+
+ const hit = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-confidence',
+ where: { 'system.confidence': 0.95 }
+ })
+ expect(hit).toHaveLength(1)
+ expect(hit[0].id).toBe(highId)
+ })
+
+ it('groupBy on system.subtype groups by the engine scalar, not user metadata', async () => {
+ await brain.add({ data: 'i1', type: NounType.Document, subtype: 'invoice' })
+ await brain.add({ data: 'i2', type: NounType.Document, subtype: 'invoice' })
+ await brain.add({ data: 'r1', type: NounType.Document, subtype: 'receipt' })
+
+ brain.defineAggregate({
+ name: 'ns_law_by_subtype_system',
+ source: { type: NounType.Document },
+ groupBy: ['system.subtype'],
+ metrics: { count: { op: 'count' } }
+ })
+
+ const groups = await brain.queryAggregate('ns_law_by_subtype_system')
+ const invoiceGroup = groups.find((g) => Object.values(g.groupKey).includes('invoice'))
+ const receiptGroup = groups.find((g) => Object.values(g.groupKey).includes('receipt'))
+ expect(invoiceGroup?.metrics.count).toBe(2)
+ expect(receiptGroup?.metrics.count).toBe(1)
+ })
+
+ // -------------------------------------------------------------------
+ // Rule 1 (groupBy face) — bare groupBy dimensions read user metadata,
+ // never the engine's own notion of the same-sounding name.
+ // -------------------------------------------------------------------
+
+ it('groupBy on a bare user metadata field groups by that field', async () => {
+ await brain.add({
+ data: 'd1',
+ type: NounType.Document,
+ subtype: 'ns-law-group-bare',
+ metadata: { team: 'alpha' }
+ })
+ await brain.add({
+ data: 'd2',
+ type: NounType.Document,
+ subtype: 'ns-law-group-bare',
+ metadata: { team: 'alpha' }
+ })
+ await brain.add({
+ data: 'd3',
+ type: NounType.Document,
+ subtype: 'ns-law-group-bare',
+ metadata: { team: 'beta' }
+ })
+
+ brain.defineAggregate({
+ name: 'ns_law_by_team_bare',
+ source: { type: NounType.Document, where: { subtype: 'ns-law-group-bare' } },
+ groupBy: ['team'],
+ metrics: { count: { op: 'count' } }
+ })
+
+ const groups = await brain.queryAggregate('ns_law_by_team_bare')
+ const alphaGroup = groups.find((g) => Object.values(g.groupKey).includes('alpha'))
+ const betaGroup = groups.find((g) => Object.values(g.groupKey).includes('beta'))
+ expect(alphaGroup?.metrics.count).toBe(2)
+ expect(betaGroup?.metrics.count).toBe(1)
+ })
+
+ it('where on a bare user metadata field filters normally (score, not a system name)', async () => {
+ await brain.add({
+ data: 'high score',
+ type: NounType.Person,
+ subtype: 'ns-law-score',
+ metadata: { score: 42 }
+ })
+ await brain.add({
+ data: 'low score',
+ type: NounType.Person,
+ subtype: 'ns-law-score',
+ metadata: { score: 7 }
+ })
+
+ const hit = await brain.find({ type: NounType.Person, subtype: 'ns-law-score', where: { score: 42 } })
+ expect(hit).toHaveLength(1)
+ expect(hit[0].metadata?.score).toBe(42)
+ })
+
+ // -------------------------------------------------------------------
+ // Rule 5 — typed refusals, naming both candidates.
+ // -------------------------------------------------------------------
+
+ it("bare orderBy 'createdAt' refuses when no such metadata field exists — names both candidates", async () => {
+ await brain.add({
+ data: 'no metadata.createdAt here',
+ type: NounType.Person,
+ subtype: 'ns-law-refuse-createdAt',
+ metadata: { name: 'x' }
+ })
+
+ await expectRefusal(
+ brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-refuse-createdAt',
+ orderBy: 'createdAt',
+ limit: 10
+ }),
+ UnresolvableFieldError,
+ 'system.createdAt',
+ 'metadata.createdAt'
+ )
+ })
+
+ // -------------------------------------------------------------------
+ // Rule 3 — invisible plumbing refuses in either spelling; system.
+ // for a name that isn't in the ten-scalar map is unresolvable.
+ // -------------------------------------------------------------------
+
+ it('system.level refuses — level is invisible plumbing, never a system scalar', async () => {
+ await brain.add({
+ data: 'has a level metadata field',
+ type: NounType.Person,
+ metadata: { level: 5 }
+ })
+ await expectRefusal(brain.find({ orderBy: 'system.level', limit: 10 }), UnresolvableFieldError)
+ })
+
+ it('system.vector refuses — vector is invisible plumbing, never a system scalar', async () => {
+ await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
+ await expectRefusal(brain.find({ orderBy: 'system.vector', limit: 10 }), UnresolvableFieldError)
+ })
+
+ it('system.data refuses — data is a payload container, never a system scalar', async () => {
+ await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
+ await expectRefusal(brain.find({ orderBy: 'system.data', limit: 10 }), UnresolvableFieldError)
+ })
+
+ // -------------------------------------------------------------------
+ // Rule 6 — the ordering contract.
+ // -------------------------------------------------------------------
+
+ async function addOrderingProbeRows(): Promise<{ ranked: string[]; missing: string }> {
+ const low = await brain.add({
+ data: 'low score',
+ type: NounType.Person,
+ subtype: 'ns-law-ordering',
+ metadata: { score: 5 }
+ })
+ const high = await brain.add({
+ data: 'high score',
+ type: NounType.Person,
+ subtype: 'ns-law-ordering',
+ metadata: { score: 9 }
+ })
+ const missing = await brain.add({
+ data: 'no score field at all',
+ type: NounType.Person,
+ subtype: 'ns-law-ordering',
+ metadata: { name: 'no-score' }
+ })
+ return { ranked: [low, high], missing }
+ }
+
+ it('a row missing the orderBy field sorts LAST in desc — and is never dropped', async () => {
+ const { ranked, missing } = await addOrderingProbeRows()
+ const desc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-ordering',
+ orderBy: 'score',
+ order: 'desc',
+ limit: 100
+ })
+ expect(desc).toHaveLength(3)
+ expect(desc.map((r: any) => r.id)).toEqual([ranked[1], ranked[0], missing])
+ })
+
+ it('a row missing the orderBy field sorts LAST in asc too — and is never dropped', async () => {
+ const { ranked, missing } = await addOrderingProbeRows()
+ const asc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-ordering',
+ orderBy: 'score',
+ order: 'asc',
+ limit: 100
+ })
+ expect(asc).toHaveLength(3)
+ expect(asc.map((r: any) => r.id)).toEqual([ranked[0], ranked[1], missing])
+ })
+
+ it('ties on the orderBy field break by id ascending, in BOTH directions', async () => {
+ const tiedIds: string[] = []
+ for (let i = 0; i < 4; i++) {
+ tiedIds.push(
+ await brain.add({
+ data: `tied ${i}`,
+ type: NounType.Person,
+ subtype: 'ns-law-ties',
+ metadata: { score: 5 }
+ })
+ )
+ }
+ const expectedOrder = [...tiedIds].sort()
+
+ const asc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-ties',
+ orderBy: 'score',
+ order: 'asc',
+ limit: 100
+ })
+ expect(asc.map((r: any) => r.id)).toEqual(expectedOrder)
+
+ const desc = await brain.find({
+ type: NounType.Person,
+ subtype: 'ns-law-ties',
+ orderBy: 'score',
+ order: 'desc',
+ limit: 100
+ })
+ // Same tie-break ordering regardless of the primary direction — the
+ // contract states one universal rule ("id ascending"), not "reverse of
+ // the primary order".
+ expect(desc.map((r: any) => r.id)).toEqual(expectedOrder)
+ })
+
+ // -------------------------------------------------------------------
+ // Rule 5 (options face) — unimplemented find() options refuse loudly
+ // instead of being accepted and silently ignored.
+ // -------------------------------------------------------------------
+
+ it('find({ cursor }) refuses with UnsupportedFindOptionError', async () => {
+ await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
+ await expectRefusal(brain.find({ cursor: 'anything', limit: 10 }), UnsupportedFindOptionError)
+ })
+
+ it('find({ includeRelations }) refuses with UnsupportedFindOptionError', async () => {
+ await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
+ await expectRefusal(brain.find({ includeRelations: true, limit: 10 }), UnsupportedFindOptionError)
+ })
+
+ it('find({ writeOnly }) refuses with UnsupportedFindOptionError', async () => {
+ await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
+ await expectRefusal(brain.find({ writeOnly: true, limit: 10 }), UnsupportedFindOptionError)
+ })
+})
From 56deb2e8883f9c879caf3b4d8b5850461893d967 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 14:05:26 -0700
Subject: [PATCH 034/175] =?UTF-8?q?fix(namespace):=20the=20JS=20sorted=20f?=
=?UTF-8?q?allback=20honors=20the=20ruled=20ordering=20contract=20?=
=?UTF-8?q?=E2=80=94=20nulls=20last=20in=20BOTH=20directions=20(was=20null?=
=?UTF-8?q?s-first=20on=20desc)=20+=20deterministic=20id-ascending=20tie-b?=
=?UTF-8?q?reak?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/utils/metadataIndex.ts | 28 +++++++++++++++++++---------
1 file changed, 19 insertions(+), 9 deletions(-)
diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts
index fdb17c22..cf5d521e 100644
--- a/src/utils/metadataIndex.ts
+++ b/src/utils/metadataIndex.ts
@@ -2260,20 +2260,30 @@ export class MetadataIndexManager implements MetadataIndexProvider {
}
idValuePairs.sort((a, b) => {
- if (a.value == null && b.value == null) return 0
- if (a.value == null) return order === 'asc' ? 1 : -1
- if (b.value == null) return order === 'asc' ? -1 : 1
- if (a.value === b.value) return 0
+ // Ordering contract (cross-engine, ruled 2026-08-03): missing/null
+ // values sort LAST in BOTH directions — the direction flip never moves
+ // them to the front — and ties break by id ascending, so an ordered
+ // read is deterministic and identical on both engines. Rows are never
+ // dropped for lacking the field.
+ const aNull = a.value == null
+ const bNull = b.value == null
+ if (aNull || bNull) {
+ if (aNull && bNull) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
+ return aNull ? 1 : -1
+ }
// Numbers compare numerically; everything else by code-point (UTF-8 byte) order.
// This makes the JS fallback sort match cor's native column store exactly
// (numeric i64/f64 vs code-point strings) and stay deterministic across
// environments, unlike the `<` operator's UTF-16 ordering for strings.
- let comparison: number
- if (typeof a.value === 'number' && typeof b.value === 'number') {
- comparison = a.value < b.value ? -1 : 1
- } else {
- comparison = compareCodePoints(String(a.value), String(b.value))
+ let comparison = 0
+ if (a.value !== b.value) {
+ if (typeof a.value === 'number' && typeof b.value === 'number') {
+ comparison = a.value < b.value ? -1 : 1
+ } else {
+ comparison = compareCodePoints(String(a.value), String(b.value))
+ }
}
+ if (comparison === 0) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
return order === 'asc' ? comparison : -comparison
})
From 5502abcdd8f60e7484940cb00445c624a087df96 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 14:39:06 -0700
Subject: [PATCH 035/175] =?UTF-8?q?test(namespace):=20unit=20pins=20for=20?=
=?UTF-8?q?the=20pure=20law=20=E2=80=94=20the=20ruled=20maps=20verbatim=20?=
=?UTF-8?q?(incl.=20the=20relation=20mirror,=20unpinnable=20via=20public?=
=?UTF-8?q?=20API),=20plumbing=20refusals=20both=20kinds,=20did-you-mean?=
=?UTF-8?q?=20text?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tests/unit/db/fieldAddressing.test.ts | 141 ++++++++++++++++++++++++++
1 file changed, 141 insertions(+)
create mode 100644 tests/unit/db/fieldAddressing.test.ts
diff --git a/tests/unit/db/fieldAddressing.test.ts b/tests/unit/db/fieldAddressing.test.ts
new file mode 100644
index 00000000..f7ca1cbe
--- /dev/null
+++ b/tests/unit/db/fieldAddressing.test.ts
@@ -0,0 +1,141 @@
+/**
+ * @module tests/unit/db/fieldAddressing
+ * @description Unit pins for the one field-addressing law (ruled 2026-08-03).
+ * These pin the PURE half of the law — parsing, the ruled maps, plumbing
+ * invisibility, refusal text — including the RELATION map, which cannot be
+ * pinned through the public query API today (related() carries no
+ * field-addressing options): the verb mirror is contract-tested here at the
+ * module level so the two engines cannot drift on it.
+ */
+import { describe, it, expect } from 'vitest'
+import {
+ SYSTEM_ENTITY_SCALARS,
+ SYSTEM_RELATION_SCALARS,
+ PLUMBING_FIELDS,
+ parseFieldAddress,
+ buildUnresolvableMessage,
+ InvalidFieldAddressError
+} from '../../../src/db/fieldAddressing.js'
+
+describe('field-addressing law — pure module pins', () => {
+ it('the entity system map is EXACTLY the ruled ten scalars', () => {
+ expect([...SYSTEM_ENTITY_SCALARS].sort()).toEqual(
+ [
+ 'confidence',
+ 'createdAt',
+ 'createdBy',
+ 'id',
+ 'service',
+ 'subtype',
+ 'type',
+ 'updatedAt',
+ 'visibility',
+ 'weight'
+ ].sort()
+ )
+ })
+
+ it('the relation system map is the ruled verb mirror', () => {
+ expect([...SYSTEM_RELATION_SCALARS].sort()).toEqual(
+ [
+ 'verb',
+ 'sourceId',
+ 'targetId',
+ 'confidence',
+ 'createdAt',
+ 'createdBy',
+ 'service',
+ 'subtype',
+ 'updatedAt',
+ 'visibility',
+ 'weight'
+ ].sort()
+ )
+ })
+
+ it('plumbing is exactly the ruled five, and none of it leaks into a system map', () => {
+ expect([...PLUMBING_FIELDS].sort()).toEqual(
+ ['_rev', 'connections', 'data', 'level', 'vector'].sort()
+ )
+ for (const field of PLUMBING_FIELDS) {
+ expect(SYSTEM_ENTITY_SCALARS.has(field)).toBe(false)
+ expect(SYSTEM_RELATION_SCALARS.has(field)).toBe(false)
+ }
+ })
+
+ it('bare names address user metadata — even when the name matches a system scalar', () => {
+ expect(parseFieldAddress('level', 'entity')).toEqual({
+ scope: 'metadata',
+ field: 'level',
+ raw: 'level'
+ })
+ expect(parseFieldAddress('confidence', 'entity').scope).toBe('metadata')
+ expect(parseFieldAddress('createdAt', 'entity').scope).toBe('metadata')
+ expect(parseFieldAddress('verb', 'relation').scope).toBe('metadata')
+ })
+
+ it('metadata.-prefix is the explicit spelling of the bare form', () => {
+ expect(parseFieldAddress('metadata.level', 'entity')).toEqual({
+ scope: 'metadata',
+ field: 'level',
+ raw: 'metadata.level'
+ })
+ })
+
+ it('system.-prefix reaches exactly the map — entity and relation', () => {
+ for (const field of SYSTEM_ENTITY_SCALARS) {
+ expect(parseFieldAddress(`system.${field}`, 'entity')).toEqual({
+ scope: 'system',
+ field,
+ raw: `system.${field}`
+ })
+ }
+ for (const field of SYSTEM_RELATION_SCALARS) {
+ expect(parseFieldAddress(`system.${field}`, 'relation').scope).toBe('system')
+ }
+ // The structural relation members are NOT entity scalars.
+ expect(() => parseFieldAddress('system.verb', 'entity')).toThrow(InvalidFieldAddressError)
+ expect(() => parseFieldAddress('system.sourceId', 'entity')).toThrow(InvalidFieldAddressError)
+ })
+
+ it('plumbing refuses in the system spelling, on both record kinds', () => {
+ for (const field of PLUMBING_FIELDS) {
+ expect(() => parseFieldAddress(`system.${field}`, 'entity')).toThrow(
+ InvalidFieldAddressError
+ )
+ expect(() => parseFieldAddress(`system.${field}`, 'relation')).toThrow(
+ InvalidFieldAddressError
+ )
+ }
+ })
+
+ it('refusal text carries the whole valid map — the fix lives in the message', () => {
+ try {
+ parseFieldAddress('system.level', 'entity')
+ expect.unreachable('should have thrown')
+ } catch (e) {
+ const msg = (e as Error).message
+ for (const field of SYSTEM_ENTITY_SCALARS) {
+ expect(msg).toContain(`system.${field}`)
+ }
+ expect(msg).toContain('plumbing')
+ }
+ })
+
+ it('malformed addresses refuse: empty name, bare metadata. prefix', () => {
+ expect(() => parseFieldAddress('', 'entity')).toThrow(InvalidFieldAddressError)
+ expect(() => parseFieldAddress('metadata.', 'entity')).toThrow(InvalidFieldAddressError)
+ })
+
+ it('the did-you-mean names BOTH candidates for a system-colliding bare name', () => {
+ const msg = buildUnresolvableMessage('createdAt', 'entity')
+ expect(msg).toContain('system.createdAt')
+ expect(msg).toContain('metadata.createdAt')
+ })
+
+ it('a non-colliding unknown bare name gets the single-candidate refusal', () => {
+ const msg = buildUnresolvableMessage('scoore', 'entity')
+ expect(msg).not.toContain('system.scoore')
+ expect(msg).toContain('metadata.scoore')
+ })
+})
From fcb24ab627a63e69df0286ea77d1522df32ba2fc Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 15:11:24 -0700
Subject: [PATCH 036/175] =?UTF-8?q?docs(namespace):=20the=20d.ts=20JSDoc?=
=?UTF-8?q?=20wave=20=E2=80=94=20the=20sealed=20field-addressing=20law=20o?=
=?UTF-8?q?n=20the=20full=20find=20+=20aggregation=20surface,=20present-te?=
=?UTF-8?q?nse,=20with=20the=20refusal=20semantics=20and=20migration=20not?=
=?UTF-8?q?e=20inline=20(comment-only;=20verified=20zero=20code=20lines=20?=
=?UTF-8?q?changed)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/types/brainy.types.ts | 123 ++++++++++++++++++++++++++++++++------
1 file changed, 106 insertions(+), 17 deletions(-)
diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts
index 89be78b9..6c133cf0 100644
--- a/src/types/brainy.types.ts
+++ b/src/types/brainy.types.ts
@@ -498,6 +498,43 @@ export interface UpdateRelationParams {
* - **Graph:** `connected` for relationship traversal (via GraphAdjacencyIndex)
*
* See also: [Query Operators](../../docs/QUERY_OPERATORS.md) for all `where` operators.
+ *
+ * @remarks
+ * **Field-addressing law.** Governs every query-surface field name — `where`
+ * and `orderBy` on this interface, plus `AggregateSource.where` and
+ * `AggregateDefinition.groupBy` in the aggregation engine:
+ *
+ * 1. A bare name (e.g. `'level'`, `'rank'`, `'score'`) always means the
+ * caller's own metadata field — it reads `entity.metadata.`. There
+ * is no fallback to an engine-internal field of the same name and no
+ * priority resolution between the two; metadata wins unconditionally.
+ * 2. `system.` reaches an engine scalar, explicitly, and only for
+ * these ten: `id`, `type`, `subtype`, `createdAt`, `updatedAt`,
+ * `confidence`, `weight`, `visibility`, `service`, `createdBy`.
+ * 3. `vector`, `connections`, `level` (the engine-internal node field — a
+ * different thing from a user metadata field also named `level`),
+ * `data`, and `_rev` are invisible plumbing: neither spelling can
+ * address them from a query surface.
+ * 4. `metadata.` is the explicit spelling of the bare form and means
+ * exactly the same thing as rule 1.
+ * 5. A name that matches none of the above — most often a bare name that
+ * collides with one of the ten system-scalar names in rule 2 — REFUSES
+ * with a typed {@link UnresolvableFieldError} naming both candidates,
+ * e.g. `no metadata field 'createdAt' — did you mean system.createdAt or
+ * metadata.createdAt?`. The same loud-refusal principle covers whole
+ * options: the previously accepted-and-silently-ignored `cursor`,
+ * `includeRelations`, and `writeOnly` now throw
+ * {@link UnsupportedFindOptionError} instead of doing nothing.
+ * 6. **Ordering contract** (identical on the pure-JS engine and the native
+ * accelerator): rows missing or `null` on the `orderBy` field sort LAST
+ * in BOTH `asc` and `desc` order and are never dropped from the result;
+ * ties break by `id` ascending.
+ *
+ * Migration note: a call site written against the old rule — e.g.
+ * `orderBy: 'createdAt'` or `where: { visibility: 'internal' }` meaning the
+ * engine scalar — now refuses instead of silently reading the wrong field.
+ * The thrown error names the exact fix (`system.createdAt`). A loud
+ * refusal with the fix in hand beats a silent behavior flip.
*/
export interface FindParams {
// Vector Intelligence
@@ -516,7 +553,18 @@ export interface FindParams {
* `{ exists: true }`, `{ missing: true }`) use `where: { subtype: { …operators… } }`.
*/
subtype?: string | string[]
- /** Metadata filters using BFO operators (e.g., `{ year: { greaterThan: 2020 } }`) */
+ /**
+ * Metadata filters using BFO operators (e.g., `{ year: { greaterThan: 2020 } }`).
+ * Field names follow the field-addressing law — see the `@remarks` on
+ * {@link FindParams}: a bare key is always the caller's metadata field;
+ * an engine scalar needs the explicit `system.` form.
+ *
+ * @example
+ * ```typescript
+ * await brain.find({ where: { level: { greaterThan: 5 } } }) // metadata.level
+ * await brain.find({ where: { 'system.visibility': 'internal' } }) // engine scalar
+ * ```
+ */
where?: Partial
// Visibility
@@ -548,29 +596,49 @@ export interface FindParams {
// Control options
limit?: number // Max results (default: 10)
offset?: number // Skip N results
+ /**
+ * @deprecated Not implemented. Passing `cursor` throws
+ * {@link UnsupportedFindOptionError} — it used to be accepted and
+ * silently ignored, which masked that no cursor pagination ever ran. Use
+ * `offset` / `limit` until cursor pagination ships.
+ */
cursor?: string // Cursor-based pagination
// Sorting
/**
- * Field to sort by. User metadata fields sort by their stored values —
- * including natural names like `level`, `rank`, or `score` (an engine-internal
- * field can never shadow your metadata; fixed 2026-08 after a production
- * report). System timestamps (`createdAt`, `updatedAt`) sort by entity age.
+ * Field to sort by. Follows the field-addressing law (see the `@remarks`
+ * on {@link FindParams}): a bare name (`'level'`, `'rank'`, `'score'`, …)
+ * always sorts by that metadata field; the ten engine scalars sort only
+ * via the explicit `system.` form (e.g. `'system.createdAt'`); a
+ * name that resolves to neither throws {@link UnresolvableFieldError}
+ * naming the fix.
*
* Ordering contract (identical on the pure-JS engine and the native
- * accelerator): entities missing the field sort LAST in both directions —
- * they are never dropped from the result; ties break deterministically.
+ * accelerator): rows missing or `null` on this field sort LAST in BOTH
+ * `asc` and `desc` order and are never dropped from the result; ties
+ * break by `id` ascending.
*
- * NOTE — the field-addressing law is changing (announced 2026-08): bare
- * names will mean user metadata ALWAYS, and system fields will be reached
- * explicitly as `system.` (e.g. `system.createdAt`), with typed
- * refusals for unresolvable names. Until that release, bare `createdAt`
- * and friends keep resolving to the system fields as documented above.
+ * @example
+ * ```typescript
+ * await brain.find({ orderBy: 'level', order: 'desc' }) // metadata.level
+ * await brain.find({ orderBy: 'system.createdAt', order: 'desc' }) // engine scalar
+ * ```
*/
orderBy?: string
+ /**
+ * Sort direction: `'asc'` (default) or `'desc'`. Per the ordering
+ * contract on `orderBy`, rows missing/`null` on the sorted field sort
+ * LAST in both directions — `order` never moves them to the front.
+ */
order?: 'asc' | 'desc' // Sort direction: 'asc' (default) or 'desc'
// Advanced options
+ /**
+ * @deprecated Not implemented. Passing `includeRelations` throws
+ * {@link UnsupportedFindOptionError} — it used to be accepted and
+ * silently ignored, so no relationships were ever attached. Fetch
+ * relationships separately via `brain.related()`.
+ */
includeRelations?: boolean // Include entity relationships
excludeVFS?: boolean // Exclude VFS entities from results (default: false - VFS included)
service?: string // Multi-tenancy filter
@@ -603,6 +671,11 @@ export interface FindParams {
}
// Performance options
+ /**
+ * @deprecated Not implemented. Passing `writeOnly` throws
+ * {@link UnsupportedFindOptionError} — it used to be accepted and
+ * silently ignored, so validation was never actually skipped.
+ */
writeOnly?: boolean // Skip validation for high-speed ingestion
// Aggregation
@@ -1352,7 +1425,10 @@ export type GroupByDimension =
export interface AggregateSource {
/** Filter by entity type(s) */
type?: NounType | NounType[]
- /** Metadata filter (same syntax as find({ where })) */
+ /**
+ * Metadata filter — same syntax and field-addressing law as find()'s
+ * `where` (see the `@remarks` on {@link FindParams}).
+ */
where?: Record
/** Multi-tenancy service filter */
service?: string
@@ -1366,7 +1442,11 @@ export interface AggregateDefinition {
name: string
/** Which entities contribute to this aggregate */
source: AggregateSource
- /** Dimensions to group by */
+ /**
+ * Dimensions to group by — field names follow the same field-addressing
+ * law as find()'s `where` / `orderBy` (see the `@remarks` on
+ * {@link FindParams}).
+ */
groupBy: GroupByDimension[]
/** Named metrics to compute */
metrics: Record
@@ -1425,16 +1505,25 @@ export interface AggregateGroupState {
export interface AggregateQueryParams {
/** Name of the aggregate to query */
name: string
- /** Filter aggregate groups by their key values */
+ /**
+ * Filter aggregate groups by their key values — same field-addressing
+ * law as find() (see the `@remarks` on {@link FindParams}).
+ */
where?: Record
/**
* Filter groups by their computed METRIC values (SQL HAVING). Same BFO operators as
* `where`, but applied to the derived metric results plus `count`, e.g.
* `{ revenue: { greaterThan: 1000 } }`. Evaluated per group (O(groups), independent of
- * entity count), before sort/pagination.
+ * entity count), before sort/pagination. Metric names and `count` are looked up
+ * directly, not field-addressed; a group-KEY field used here follows the same
+ * field-addressing law as find() (see the `@remarks` on {@link FindParams}).
*/
having?: Record
- /** Sort by metric name or group key field */
+ /**
+ * Sort by metric name (a key from `metrics`, looked up directly) or by a
+ * group key field — a group key field follows the same field-addressing
+ * law as find()'s `orderBy` (see the `@remarks` on {@link FindParams}).
+ */
orderBy?: string
/** Sort direction */
order?: 'asc' | 'desc'
From 11c724bc865646f46d87a68933b7ea5a9f273f32 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 15:28:24 -0700
Subject: [PATCH 037/175] =?UTF-8?q?feat(namespace):=20the=20index=20speaks?=
=?UTF-8?q?=20the=20frozen=20keys=20=E2=80=94=20record-frame=20scalars=20i?=
=?UTF-8?q?ndex=20under=20literal=20'system.'=20(legacy=20'noun'=20?=
=?UTF-8?q?spelling=20folds=20into=20system.type;=20plumbing=20never=20ind?=
=?UTF-8?q?exed=20from=20a=20record=20frame),=20user=20fields=20stay=20bar?=
=?UTF-8?q?e=20in=20every=20shape;=20filter=20+=20sorted=20paths=20route?=
=?UTF-8?q?=20every=20address=20through=20parseFieldAddress;=20storage=20f?=
=?UTF-8?q?allbacks=20read=20the=20addressed=20side=20of=20the=20record?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/utils/metadataIndex.ts | 144 +++++++++++++++++++++++++------------
1 file changed, 99 insertions(+), 45 deletions(-)
diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts
index cf5d521e..bfde5fe9 100644
--- a/src/utils/metadataIndex.ts
+++ b/src/utils/metadataIndex.ts
@@ -5,6 +5,7 @@
*/
import { StorageAdapter, resolveEntityField, NounMetadata, VerbMetadata } from '../coreTypes.js'
+import { SYSTEM_ENTITY_SCALARS, parseFieldAddress } from '../db/fieldAddressing.js'
import { ColumnStore } from '../indexes/columnStore/ColumnStore.js'
import type { MetadataIndexProvider } from '../plugin.js'
import { MetadataIndexCache, MetadataIndexCacheConfig } from './metadataIndexCache.js'
@@ -43,8 +44,8 @@ import { BrainyError } from '../errors/brainyError.js'
* bucketed field is added (e.g. a compressed float), add it here too.
*/
const BUCKETED_INDEX_FIELDS: ReadonlySet = new Set([
- 'createdAt',
- 'updatedAt'
+ 'system.createdAt',
+ 'system.updatedAt'
])
export interface MetadataIndexEntry {
@@ -1218,12 +1219,56 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// the reserved entity-identity field, resolved specially by find().)
const NEVER_INDEX = new Set(['vector', 'embedding', 'embeddings', 'connections', 'id'])
- const extract = (obj: any, prefix = ''): void => {
+ // THE FROZEN INDEX KEY FORMAT (cross-engine, sealed 2026-08-03; the native
+ // accelerator keys identically — epoch 3 rebuilds every brain onto it):
+ // user fields index under BARE keys exactly as the caller wrote them;
+ // the ten system scalars index under literal 'system.' keys — the
+ // key IS the query address, so the two namespaces can never collide
+ // inside the index again. `origin` tracks which side of the record a key
+ // came from: 'record' = the entity/stored-record frame (system scalars,
+ // plumbing, and the metadata bag live here — the WRITE PATH's reserved-
+ // name remap guarantees a record-frame key matching a system name IS the
+ // system value); 'user' = inside the flattened metadata bag (everything
+ // is the user's, including natural names like `level` and `data`).
+ // Frame kinds: 'entity-record' = entityForIndexing shape (user fields
+ // nested under `metadata`; stray top-level keys are DROPPED, not guessed —
+ // epoch-3's rebuild-from-canonical normalizes historical shapes);
+ // 'flat-record' = the stored metadata-record shape (user fields FLAT
+ // beside the reserved ones — the write path's reserved-name remap
+ // guarantees a key matching a system name IS the system value, so
+ // non-system keys here are the user's and index bare); 'user' = inside
+ // the metadata bag (everything is the user's, including natural names
+ // like `level` and `data`).
+ type Frame = 'entity-record' | 'flat-record' | 'user'
+ const extract = (obj: any, prefix = '', frame: Frame = 'entity-record'): void => {
for (const [key, value] of Object.entries(obj)) {
- const fullKey = prefix ? `${prefix}.${key}` : key
+ let fullKey = prefix ? `${prefix}.${key}` : key
- // Skip fields in never-index list (CRITICAL: prevents vector indexing bug + HNSW fields)
- if (!prefix && NEVER_INDEX.has(key)) continue
+ if (!prefix && frame !== 'user') {
+ if (key === 'metadata' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ extract(value, '', 'user') // the user's namespace: bare keys
+ continue
+ }
+ if (key === 'type' || key === 'noun') {
+ fullKey = 'system.type' // legacy 'noun' spelling folds into the frozen key
+ } else if (SYSTEM_ENTITY_SCALARS.has(key) && key !== 'id') {
+ fullKey = `system.${key}`
+ } else if (
+ key === 'data' || key === '_rev' || key === 'level' || NEVER_INDEX.has(key)
+ ) {
+ continue // plumbing / identity / bulk payloads — never indexed from a record frame
+ } else if (frame === 'entity-record') {
+ continue // stray entity-frame key: dropped, not guessed
+ }
+ // flat-record fallthrough: a non-system, non-plumbing key IS a user
+ // field (flat beside the reserved ones) — indexes bare via fullKey.
+ } else if (!prefix && NEVER_INDEX.has(key)) {
+ // User frame: only the bulk-payload guards apply — natural names
+ // like `level` and `data` are real user fields here. (`id` as a
+ // user metadata field remains un-indexed this train — documented
+ // limitation; system.id resolves via the id mapper, never a column.)
+ continue
+ }
// Skip purely numeric field names (array indices converted to object keys)
// Legitimate field names should never be purely numeric
@@ -1233,21 +1278,12 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// Skip fields based on user configuration
if (!this.shouldIndexField(fullKey)) continue
- // Special handling for metadata field at top level
- // Flatten metadata fields to top-level (no prefix) for cleaner queries
- // Standard fields are already at top-level, custom fields go in metadata
- // By flattening here, queries can use { category: 'B' } instead of { 'metadata.category': 'B' }
- if (key === 'metadata' && !prefix && typeof value === 'object' && !Array.isArray(value)) {
- extract(value, '') // Flatten to top-level, no prefix
- continue
- }
-
// Skip large arrays (> 10 elements) - likely vectors or bulk data
if (Array.isArray(value) && value.length > 10) continue
if (value && typeof value === 'object' && !Array.isArray(value)) {
- // Recurse into nested objects (but not arrays)
- extract(value, fullKey)
+ // Recurse into nested objects (but not arrays), keeping the frame
+ extract(value, fullKey, frame)
} else if (Array.isArray(value) && value.length <= 10) {
// Small arrays: index as multi-value field (all with same field name)
// Example: tags: ["javascript", "node"] → field="tags", value="javascript" + field="tags", value="node"
@@ -1258,16 +1294,21 @@ export class MetadataIndexManager implements MetadataIndexProvider {
}
}
} else {
- // Primitive value: index it
- // Map 'type' → 'noun' for backward compatibility
- const indexField = (!prefix && key === 'type') ? 'noun' : fullKey
- fields.push({ field: indexField, value })
+ // Primitive value: index it under the frozen key computed above.
+ // (The legacy 'type'→'noun' remap is gone — 'noun' columns die at
+ // the epoch-3 rebuild; system.type is the one spelling.)
+ fields.push({ field: fullKey, value })
}
}
}
if (data && typeof data === 'object') {
- extract(data)
+ // Shape detection for the top frame: an object carrying a nested
+ // `metadata` bag is the entityForIndexing shape; anything else is the
+ // flat stored-record shape (user fields flat beside reserved ones).
+ const entityShaped =
+ 'metadata' in data && typeof data.metadata === 'object' && data.metadata !== null
+ extract(data, '', entityShaped ? 'entity-record' : 'flat-record')
}
// Extract words for hybrid text search
@@ -1911,22 +1952,15 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// Skip logical operators
if (rawField === 'allOf' || rawField === 'anyOf' || rawField === 'not') continue
- // Metadata is FLATTENED at index time (metadata.entry.title indexes as
- // entry.title), so a `metadata.`-prefixed where key is almost always
- // the caller spelling the STORAGE shape rather than the index shape.
- // Accept both spellings: when the key as spelled is unindexed but its
- // stripped spelling is, query the stripped one. A literal nested
- // custom key named `metadata` still wins when indexed as spelled
- // (checked first), so that rare shape keeps working.
- let field = rawField
- if (
- rawField.startsWith('metadata.') &&
- this.columnStore &&
- !this.columnStore.hasField(rawField) &&
- this.columnStore.hasField(rawField.slice('metadata.'.length))
- ) {
- field = rawField.slice('metadata.'.length)
- }
+ // THE ONE ADDRESSING LAW (sealed 2026-08-03): every filter key routes
+ // through parseFieldAddress — bare and 'metadata.'-prefixed spellings
+ // address the user's fields (indexed under BARE keys), 'system.'
+ // addresses the ten engine scalars (indexed under their literal
+ // 'system.' keys). A malformed address (system.,
+ // plumbing in the system spelling) throws typed BEFORE any index read —
+ // an accepted name either works or refuses.
+ const address = parseFieldAddress(rawField, 'entity')
+ const field = address.scope === 'system' ? `system.${address.field}` : address.field
let fieldResults: string[] = []
@@ -2207,9 +2241,18 @@ export class MetadataIndexManager implements MetadataIndexProvider {
order: 'asc' | 'desc' = 'asc',
topK?: number
): Promise {
+ // THE ONE ADDRESSING LAW — the orderBy address routes through the same
+ // parse the filter path uses (the historical asymmetry where the filter
+ // path understood 'metadata.' but the sorted path never did is dead).
+ // Bare / 'metadata.' → the user's bare index key; 'system.' → the
+ // literal frozen key; malformed addresses throw typed before any read.
+ const orderAddress = parseFieldAddress(orderBy, 'entity')
+ const orderKey =
+ orderAddress.scope === 'system' ? `system.${orderAddress.field}` : orderAddress.field
+
// Column store path: O(K log S) sort via k-way merge across segments.
// No per-entity storage reads, no precision loss from bucketing.
- if (this.columnStore && this.columnStore.hasField(orderBy)) {
+ if (this.columnStore && this.columnStore.hasField(orderKey)) {
// Get filtered IDs from existing roaring bitmap path
const hasFilter = filter && Object.keys(filter).length > 0
const filteredIds = hasFilter ? await this.getIdsForFilter(filter) : []
@@ -2229,12 +2272,12 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// log K) heap, not a full sort materialization.
const k = topK !== undefined ? Math.min(topK, filteredIds.length) : filteredIds.length
sortedIntIds = await this.columnStore.filteredSortTopK(
- filterBitmap, orderBy, order, k
+ filterBitmap, orderKey, order, k
)
} else {
// Unfiltered sort — column store handles the full entity set efficiently
sortedIntIds = await this.columnStore.sortTopK(
- orderBy, order, topK !== undefined ? Math.min(topK, this.idMapper.size) : this.idMapper.size
+ orderKey, order, topK !== undefined ? Math.min(topK, this.idMapper.size) : this.idMapper.size
)
}
@@ -2255,7 +2298,7 @@ export class MetadataIndexManager implements MetadataIndexProvider {
const idValuePairs: Array<{ id: string, value: any }> = []
for (const id of filteredIds) {
- const value = await this.getFieldValueForEntity(id, orderBy)
+ const value = await this.getFieldValueForEntity(id, orderKey)
idValuePairs.push({ id, value })
}
@@ -2320,10 +2363,17 @@ export class MetadataIndexManager implements MetadataIndexProvider {
* @public (called from brainy.ts for sorted queries)
*/
async getFieldValueForEntity(entityId: string, field: string): Promise {
- // Path 1: Bucketed fields need the actual value from storage.
+ // `field` arrives as a FROZEN INDEX KEY (bare = user metadata;
+ // 'system.' = engine scalar). Storage fallbacks read the matching
+ // side of the record — a system key reads the record scalar, a bare key
+ // reads the user's metadata bag; the two can never shadow each other.
+ const systemInner = field.startsWith('system.') ? field.slice('system.'.length) : null
+
+ // Path 1: Bucketed fields need the actual (un-bucketed) value from storage.
if (BUCKETED_INDEX_FIELDS.has(field)) {
const noun = await this.storage.getNoun(entityId)
- return noun ? resolveEntityField(noun, field) : undefined
+ if (!noun) return undefined
+ return (noun as unknown as Record)[systemInner as string]
}
// Path 3 precondition: entity must be in the id mapper for bitmap lookup.
@@ -2340,7 +2390,11 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// yet indexed. resolveEntityField handles the shape contract.
if (!sparseIndex) {
const noun = await this.storage.getNoun(entityId)
- return noun ? resolveEntityField(noun, field) : undefined
+ if (!noun) return undefined
+ if (systemInner !== null) {
+ return (noun as unknown as Record)[systemInner]
+ }
+ return (noun as { metadata?: Record }).metadata?.[field]
}
// Path 3: Search sparse index chunks for this entity's value.
From 7a28a94639e4ce9777c3e4a11c032f665ab2ccb0 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 15:33:01 -0700
Subject: [PATCH 038/175] =?UTF-8?q?feat(namespace):=20find's=20own=20filte?=
=?UTF-8?q?r=20builders=20speak=20the=20frozen=20keys=20=E2=80=94=20params?=
=?UTF-8?q?.type/subtype/service=20become=20system.*=20index=20keys=20at?=
=?UTF-8?q?=20every=20construction=20site=20(three=20pipelines=20+=20the?=
=?UTF-8?q?=20canonical=20buildMetadataFilter);=20the=20where.type?=
=?UTF-8?q?=E2=86=92noun=20alias=20is=20dead=20(bare=20'type'=20belongs=20?=
=?UTF-8?q?to=20the=20user=20now)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/brainy.ts | 64 ++++++++++++++++++++++-----------------------------
1 file changed, 28 insertions(+), 36 deletions(-)
diff --git a/src/brainy.ts b/src/brainy.ts
index d8eca08b..a8df56bd 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -6148,20 +6148,18 @@ export class Brainy implements BrainyInterface {
// Build filter for metadata index
let filter: any = {}
if (params.where) {
+ // Where keys pass through UNTOUCHED — the addressing law parses
+ // them at the index boundary. The old where.type→noun alias is
+ // dead: bare 'type' is the user's own field now.
Object.assign(filter, params.where)
- // Alias: where.type → where.noun (storage field name for entity type)
- if ('type' in filter && !('noun' in filter)) {
- filter.noun = filter.type
- delete filter.type
- }
}
- if (params.service) filter.service = params.service
+ if (params.service) filter['system.service'] = params.service
// Subtype (top-level standard field — fast path, not metadata fallback).
// Must be assigned BEFORE the type-array expansion below so the spread
// into each anyOf branch carries it through.
if (params.subtype !== undefined) {
- filter.subtype = Array.isArray(params.subtype)
+ filter['system.subtype'] = Array.isArray(params.subtype)
? { oneOf: params.subtype }
: params.subtype
}
@@ -6169,11 +6167,11 @@ export class Brainy implements BrainyInterface {
if (params.type) {
const types = Array.isArray(params.type) ? params.type : [params.type]
if (types.length === 1) {
- filter.noun = types[0]
+ filter['system.type'] = types[0]
} else {
filter = {
anyOf: types.map(type => ({
- noun: type,
+ 'system.type': type,
...filter
}))
}
@@ -11388,27 +11386,26 @@ export class Brainy implements BrainyInterface {
if (params.where || params.subtype || params.service) {
let filter: any = {}
if (params.where) {
+ // Where keys pass through UNTOUCHED — the one addressing law
+ // parses them at the index boundary (bare = user metadata,
+ // system.* = engine scalars). The old where.type→noun alias is
+ // dead: a bare 'type' is the user's own field now.
Object.assign(filter, params.where)
- // Alias: where.type → where.noun (storage field name for entity type)
- if ('type' in filter && !('noun' in filter)) {
- filter.noun = filter.type
- delete filter.type
- }
}
- if (params.service) filter.service = params.service
+ if (params.service) filter['system.service'] = params.service
if (params.subtype !== undefined) {
- filter.subtype = Array.isArray(params.subtype)
+ filter['system.subtype'] = Array.isArray(params.subtype)
? { oneOf: params.subtype }
: params.subtype
}
if (params.type) {
const types = Array.isArray(params.type) ? params.type : [params.type]
if (types.length === 1) {
- filter.noun = types[0]
+ filter['system.type'] = types[0]
} else {
const baseFilter = { ...filter }
filter = {
- anyOf: types.map(type => ({ noun: type, ...baseFilter }))
+ anyOf: types.map(type => ({ 'system.type': type, ...baseFilter }))
}
}
}
@@ -11458,27 +11455,24 @@ export class Brainy implements BrainyInterface {
// Use MetadataIndexManager for efficient filtered streaming
let filterObj: any = {}
if (filter.where) {
+ // Where keys pass through — the addressing law parses them at
+ // the index boundary; the type→noun alias is dead.
Object.assign(filterObj, filter.where)
- // Alias: where.type → where.noun (storage field name for entity type)
- if ('type' in filterObj && !('noun' in filterObj)) {
- filterObj.noun = filterObj.type
- delete filterObj.type
- }
}
- if (filter.service) filterObj.service = filter.service
+ if (filter.service) filterObj['system.service'] = filter.service
if (filter.subtype !== undefined) {
- filterObj.subtype = Array.isArray(filter.subtype)
+ filterObj['system.subtype'] = Array.isArray(filter.subtype)
? { oneOf: filter.subtype }
: filter.subtype
}
if (filter.type) {
const types = Array.isArray(filter.type) ? filter.type : [filter.type]
if (types.length === 1) {
- filterObj.noun = types[0]
+ filterObj['system.type'] = types[0]
} else {
const baseFilterObj = { ...filterObj }
filterObj = {
- anyOf: types.map(type => ({ noun: type, ...baseFilterObj }))
+ anyOf: types.map(type => ({ 'system.type': type, ...baseFilterObj }))
}
}
}
@@ -13605,14 +13599,12 @@ export class Brainy implements BrainyInterface {
}
let filter: any = {}
if (params.where) {
+ // Where keys pass through UNTOUCHED — the one addressing law parses
+ // them at the index boundary (bare = user metadata, system.* = engine
+ // scalars, typed refusal otherwise). The old type→noun alias is dead.
Object.assign(filter, params.where)
- // Alias: where.type → where.noun (storage field name for entity type)
- if ('type' in filter && !('noun' in filter)) {
- filter.noun = filter.type
- delete filter.type
- }
}
- if (params.service) filter.service = params.service
+ if (params.service) filter['system.service'] = params.service
if (params.excludeVFS === true) {
filter.vfsType = { exists: false }
filter.isVFSEntity = { ne: true }
@@ -13620,14 +13612,14 @@ export class Brainy implements BrainyInterface {
// Subtype (top-level standard field — fast path). Assigned BEFORE the type-array
// expansion below so the spread into each anyOf branch carries it through.
if (params.subtype !== undefined) {
- filter.subtype = Array.isArray(params.subtype) ? { oneOf: params.subtype } : params.subtype
+ filter['system.subtype'] = Array.isArray(params.subtype) ? { oneOf: params.subtype } : params.subtype
}
if (params.type) {
const types = Array.isArray(params.type) ? params.type : [params.type]
if (types.length === 1) {
- filter.noun = types[0]
+ filter['system.type'] = types[0]
} else {
- filter = { anyOf: types.map((type) => ({ noun: type, ...filter })) }
+ filter = { anyOf: types.map((type) => ({ 'system.type': type, ...filter })) }
}
}
return filter
From 4679c89458aa5faabcea931862b9052030f35120 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 15:37:04 -0700
Subject: [PATCH 039/175] =?UTF-8?q?fix(namespace):=20noun-record=20updates?=
=?UTF-8?q?=20preserve=20legacy=20inline=20HNSW=20adjacency=20=E2=80=94=20?=
=?UTF-8?q?the=20placeholder-adjacency=20write=20stamped=20out=20pre-codec?=
=?UTF-8?q?=20records'=20stored=20connections=20(crash-window=20unreachabi?=
=?UTF-8?q?lity);=20codec-era=20records=20were=20never=20at=20risk=20(empt?=
=?UTF-8?q?y=20field=20is=20the=20blob=20marker);=20pin=20covers=20the=20l?=
=?UTF-8?q?egacy=20shape?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../operations/StorageOperations.ts | 23 ++++++++-
tests/integration/level-field-shadow.test.ts | 47 +++++++++++++++++++
2 files changed, 68 insertions(+), 2 deletions(-)
diff --git a/src/transaction/operations/StorageOperations.ts b/src/transaction/operations/StorageOperations.ts
index 316f1ac0..9858219b 100644
--- a/src/transaction/operations/StorageOperations.ts
+++ b/src/transaction/operations/StorageOperations.ts
@@ -77,8 +77,27 @@ export class SaveNounOperation implements Operation {
? null
: await this.storage.getNoun(this.noun.id)
- // Save new noun
- await this.storage.saveNoun(this.noun)
+ // PRESERVE stored graph state on updates. Callers stage this op with
+ // placeholder adjacency ({connections: empty, level: 0}) because the
+ // vector index owns those values and persists them at flush. Codec-era
+ // records (2.4.0+) carry an empty connections field by design (adjacency
+ // lives in a separate compressed blob — the placeholder is harmless), but
+ // LEGACY pre-codec records store adjacency INLINE: writing the
+ // placeholder over one stamped out its stored connections, leaving a
+ // crash window (until the next flush) where a reload found the node
+ // unreachable. Stale adjacency in that window is tolerable — HNSW
+ // self-corrects at the reindex flush; EMPTY adjacency is silent recall
+ // loss. The read above is already paid for rollback; preservation is free.
+ const toSave: HNSWNoun =
+ previousNoun && this.noun.connections.size === 0
+ ? {
+ ...this.noun,
+ connections: previousNoun.connections || this.noun.connections,
+ level: previousNoun.level ?? this.noun.level
+ }
+ : this.noun
+
+ await this.storage.saveNoun(toSave)
// Return rollback action
return async () => {
diff --git a/tests/integration/level-field-shadow.test.ts b/tests/integration/level-field-shadow.test.ts
index d50593ff..ab5ffb9a 100644
--- a/tests/integration/level-field-shadow.test.ts
+++ b/tests/integration/level-field-shadow.test.ts
@@ -145,3 +145,50 @@ describe('level field shadow — user metadata named level is a real field', ()
expect(EXPECTED_INDEX_EPOCH).toBe(2)
})
})
+
+describe('noun-record writes never stamp over stored graph state', () => {
+ let brain: Brainy
+
+ beforeEach(async () => {
+ brain = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'memory' as const },
+ embeddingFunction: stubEmbedding
+ })
+ await brain.init()
+ })
+
+ afterEach(async () => {
+ await brain.close()
+ })
+
+ it('a data-changing update preserves LEGACY inline connections in the record', async () => {
+ // Codec-era records carry an EMPTY connections field by design (the
+ // adjacency lives in a separate compressed blob) — the clobber window
+ // exists only for legacy pre-codec records whose adjacency is inline.
+ // Simulate one: write the record with inline connections directly.
+ const id = await brain.add({
+ data: 'legacy-shaped node',
+ type: NounType.Concept,
+ metadata: { n: 1 }
+ })
+ const storage = (brain as any).storage
+ const rec = await storage.getNoun(id)
+ const legacy = {
+ ...rec,
+ connections: new Map([[0, new Set(['00000000-0000-4000-8000-00000000aaaa'])]]),
+ level: 1
+ }
+ await storage.saveNoun(legacy)
+ const before = await storage.getNoun(id)
+ expect(before.connections.size).toBeGreaterThan(0)
+
+ // A data-changing update stages SaveNounOperation with placeholder
+ // adjacency — the legacy inline connections must survive the write.
+ await brain.update({ id, data: 'completely re-embedded text' })
+
+ const after = await storage.getNoun(id)
+ expect(after.connections.size).toBeGreaterThan(0)
+ expect(after.level).toBe(1)
+ })
+})
From c2fb28a2f7c261dd055677b6042803e2afd8de3d Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 15:51:14 -0700
Subject: [PATCH 040/175] =?UTF-8?q?feat(namespace):=20egress=20guard=20+?=
=?UTF-8?q?=20validation=20speak=20the=20law=20=E2=80=94=20whereMatcher's?=
=?UTF-8?q?=20resolver=20reads=20system.*=20from=20the=20record=20and=20ba?=
=?UTF-8?q?re=20names=20from=20the=20metadata=20bag=20only=20(the=20bare-s?=
=?UTF-8?q?ystem=20switch=20is=20dead);=20validateFindParams=20refuses=20c?=
=?UTF-8?q?ursor/includeRelations/writeOnly=20typed=20(accepted-and-ignore?=
=?UTF-8?q?d=20dies=20as=20a=20class),=20validates=20order,=20and=20parses?=
=?UTF-8?q?=20every=20orderBy=20address?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/db/fieldAddressing.ts | 39 ++++++++++++++++++++
src/db/whereMatcher.ts | 69 +++++++++++++++++++-----------------
src/utils/paramValidation.ts | 29 +++++++++++++--
3 files changed, 101 insertions(+), 36 deletions(-)
diff --git a/src/db/fieldAddressing.ts b/src/db/fieldAddressing.ts
index 0ee09a05..cd63871c 100644
--- a/src/db/fieldAddressing.ts
+++ b/src/db/fieldAddressing.ts
@@ -244,3 +244,42 @@ export class InvalidFieldAddressError extends Error {
this.kind = kind
}
}
+
+/**
+ * @description Refusal for a syntactically valid address that resolves to
+ * NOTHING — a bare name no user field carries. Carries the did-you-mean
+ * (both candidate spellings when the name collides with a system scalar) so
+ * the fix ships inside the error. Thrown by the query layer with index
+ * knowledge, never by the pure parser.
+ */
+export class UnresolvableFieldError extends Error {
+ public readonly raw: string
+ public readonly kind: FieldAddressKind
+
+ constructor(raw: string, kind: FieldAddressKind) {
+ super(buildUnresolvableMessage(raw, kind))
+ this.name = 'UnresolvableFieldError'
+ this.raw = raw
+ this.kind = kind
+ }
+}
+
+/**
+ * @description Refusal for a find() option that is accepted by the type
+ * surface but NOT implemented — an accepted option must work or refuse;
+ * accepted-and-ignored died as a class (sealed 2026-08-03). Names the
+ * option and the honest state so nobody discovers a no-op by measurement.
+ */
+export class UnsupportedFindOptionError extends Error {
+ public readonly option: string
+
+ constructor(option: string) {
+ super(
+ `find() option '${option}' is not implemented — it used to be silently ` +
+ `ignored, which read as working. Remove it from the call (or track the ` +
+ `feature request); it will be honored or refused, never swallowed.`
+ )
+ this.name = 'UnsupportedFindOptionError'
+ this.option = option
+ }
+}
diff --git a/src/db/whereMatcher.ts b/src/db/whereMatcher.ts
index c5469209..8dab02fd 100644
--- a/src/db/whereMatcher.ts
+++ b/src/db/whereMatcher.ts
@@ -61,41 +61,44 @@ export class UnsupportedWhereOperatorError extends Error {
* @returns The field's value, or `undefined` when absent.
*/
export function resolveEntityField(entity: Entity, field: string): unknown {
- switch (field) {
- case 'noun':
- case 'type':
- return entity.type
- case 'subtype':
- return entity.subtype
- case 'id':
- return entity.id
- case 'createdAt':
- return entity.createdAt
- case 'updatedAt':
- return entity.updatedAt
- case 'service':
- return entity.service
- case 'createdBy':
- return entity.createdBy
- case 'confidence':
- return entity.confidence
- case 'weight':
- return entity.weight
- case '_rev':
- return entity._rev
- case 'data':
- return entity.data
+ // THE ONE ADDRESSING LAW (sealed 2026-08-03): `system.` reads the
+ // entity scalar; bare and `metadata.`-prefixed names read the user's
+ // metadata bag (dotted paths traverse INSIDE the bag). The old bare-name
+ // switch over system fields is dead — bare `createdAt` is the user's own
+ // field now; the engine scalar is `system.createdAt`. Plumbing (vector,
+ // connections, level, data, _rev) is invisible: no spelling reaches it.
+ if (field.startsWith('system.')) {
+ switch (field.slice('system.'.length)) {
+ case 'type':
+ return entity.type
+ case 'subtype':
+ return entity.subtype
+ case 'id':
+ return entity.id
+ case 'createdAt':
+ return entity.createdAt
+ case 'updatedAt':
+ return entity.updatedAt
+ case 'service':
+ return entity.service
+ case 'createdBy':
+ return entity.createdBy
+ case 'confidence':
+ return entity.confidence
+ case 'weight':
+ return entity.weight
+ case 'visibility':
+ return (entity as unknown as Record).visibility
+ }
+ // Out-of-map system spelling: parse refuses these upstream with a typed
+ // error; reaching here (internal callers only) reads as absent.
+ return undefined
}
- if (field.includes('.')) {
- // Dotted path: resolve against the whole entity first (`metadata.x`),
- // then against the metadata bag (`address.city` on nested metadata).
- const fromEntity = resolvePath(entity as unknown as Record, field)
- if (fromEntity !== undefined) return fromEntity
- return resolvePath((entity.metadata ?? {}) as Record, field)
- }
-
- return ((entity.metadata ?? {}) as Record)[field]
+ const path = field.startsWith('metadata.') ? field.slice('metadata.'.length) : field
+ const bag = (entity.metadata ?? {}) as Record
+ if (!path.includes('.')) return bag[path]
+ return resolvePath(bag, path)
}
/** Walk a dotted path through nested plain objects. */
diff --git a/src/utils/paramValidation.ts b/src/utils/paramValidation.ts
index ca439524..fd018a04 100644
--- a/src/utils/paramValidation.ts
+++ b/src/utils/paramValidation.ts
@@ -17,6 +17,7 @@ import { findCallerLocation } from './callerLocation.js'
// fallback branches that no supported runtime can reach.
import * as os from 'node:os'
import * as fs from 'node:fs'
+import { parseFieldAddress, UnsupportedFindOptionError } from '../db/fieldAddressing.js'
const getSystemMemory = (): number => {
if (os) {
@@ -466,9 +467,31 @@ export function validateFindParams(params: FindParams): void {
throw new Error('cannot specify both query and vector - they are mutually exclusive')
}
- // Universal truth: can't use both cursor and offset pagination
- if (params.cursor !== undefined && params.offset !== undefined) {
- throw new Error('cannot use both cursor and offset pagination simultaneously')
+ // ACCEPTED-AND-IGNORED DIED AS A CLASS (sealed 2026-08-03): options the
+ // engine does not implement REFUSE with a typed error instead of silently
+ // doing nothing — a production consumer discovered a no-op by measurement
+ // once; never again.
+ if (params.cursor !== undefined) {
+ throw new UnsupportedFindOptionError('cursor')
+ }
+ if ((params as Record).includeRelations !== undefined) {
+ throw new UnsupportedFindOptionError('includeRelations')
+ }
+ if ((params as Record).writeOnly !== undefined) {
+ throw new UnsupportedFindOptionError('writeOnly')
+ }
+
+ // THE ONE ADDRESSING LAW: the orderBy address must PARSE (bare/metadata. =
+ // user field, system. = the ruled map, anything else refuses typed
+ // with the valid map in the message) and order must be a real direction.
+ if (params.orderBy !== undefined) {
+ if (typeof params.orderBy !== 'string') {
+ throw new Error('orderBy must be a string field address')
+ }
+ parseFieldAddress(params.orderBy, 'entity') // throws InvalidFieldAddressError on a bad address
+ }
+ if (params.order !== undefined && params.order !== 'asc' && params.order !== 'desc') {
+ throw new Error(`order must be 'asc' or 'desc', got '${String(params.order)}'`)
}
// Auto-limit query length based on memory
From 7492b6cb59362a88e3af8f739001a4e0926860d7 Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 15:53:13 -0700
Subject: [PATCH 041/175] =?UTF-8?q?feat(namespace):=20aggregation=20reads?=
=?UTF-8?q?=20under=20the=20law=20+=20epoch=203=20(the=20key-split=20rebui?=
=?UTF-8?q?ld)=20+=20THE=20ARMING=20COMMIT=20=E2=80=94=20the=20capability?=
=?UTF-8?q?=20constant,=20the=20law=20module,=20and=20the=20typed=20refusa?=
=?UTF-8?q?ls=20export=20from=20the=20package=20root;=20both=20engines'=20?=
=?UTF-8?q?conformance=20suites=20light=20on=20this=20signal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/aggregation/AggregationIndex.ts | 31 ++++++++++++++-----
src/brainy.ts | 11 +++++--
src/db/fieldAddressing.ts | 8 +++++
src/index.ts | 19 ++++++++++++
src/storage/brainFormat.ts | 15 +++++----
tests/integration/level-field-shadow.test.ts | 4 +--
tests/unit/brainy/migration-deference.test.ts | 7 +++--
7 files changed, 73 insertions(+), 22 deletions(-)
diff --git a/src/aggregation/AggregationIndex.ts b/src/aggregation/AggregationIndex.ts
index f9382218..407b1fe0 100644
--- a/src/aggregation/AggregationIndex.ts
+++ b/src/aggregation/AggregationIndex.ts
@@ -14,7 +14,22 @@
*/
import type { StorageAdapter, HNSWNounWithMetadata } from '../coreTypes.js'
-import { resolveEntityField } from '../coreTypes.js'
+import { parseFieldAddress, readEntityFieldAddress } from '../db/fieldAddressing.js'
+import type { HNSWNounWithMetadata as AddressedEntity } from '../coreTypes.js'
+
+/**
+ * Read a user-supplied field name under the one addressing law (sealed
+ * 2026-08-03): bare / `metadata.` = the user's metadata field, `system.` =
+ * the ruled engine scalar, malformed = typed refusal. The aggregation engine
+ * NEVER resolves names any other way — the pre-law resolver made bare
+ * `subtype`/`confidence` read engine scalars, silently shadowing user fields.
+ */
+function readAddressed(e: unknown, name: string): unknown {
+ return readEntityFieldAddress(
+ e as AddressedEntity,
+ parseFieldAddress(name, 'entity')
+ )
+}
import type {
AggregateDefinition,
AggregateGroupState,
@@ -97,7 +112,7 @@ function matchesSource(entity: Record, source: AggregateDefinit
const e = entity as unknown as HNSWNounWithMetadata
const resolved: Record = {}
for (const key of Object.keys(source.where)) {
- resolved[key] = resolveEntityField(e, key)
+ resolved[key] = readAddressed(e, key)
}
if (!matchesMetadataFilter(resolved, source.where)) return false
}
@@ -129,11 +144,11 @@ function computeGroupKeys(
for (const dim of groupBy) {
if (typeof dim === 'string') {
- const val = resolveEntityField(e, dim)
+ const val = readAddressed(e, dim)
const v = val !== undefined && val !== null ? String(val) : '__null__'
for (const k of keys) k[dim] = v
} else if ('unnest' in dim) {
- const val = resolveEntityField(e, dim.field)
+ const val = readAddressed(e, dim.field)
const raw = Array.isArray(val) ? val : val !== undefined && val !== null ? [val] : []
// Distinct elements: an entity with duplicate tags counts once per distinct tag.
const elems = Array.from(new Set(raw.map(x => String(x))))
@@ -145,7 +160,7 @@ function computeGroupKeys(
keys = next
} else {
// Time-windowed field
- const val = resolveEntityField(e, dim.field)
+ const val = readAddressed(e, dim.field)
const v = typeof val === 'number' ? bucketTimestamp(val, dim.window) : '__null__'
for (const k of keys) k[dim.field] = v
}
@@ -174,7 +189,7 @@ function computeGroupKey(
* in metadata are both handled in one place.
*/
function getNumericField(entity: Record, field: string): number | undefined {
- const val = resolveEntityField(entity as unknown as HNSWNounWithMetadata, field)
+ const val = readAddressed(entity as unknown as HNSWNounWithMetadata, field)
if (typeof val === 'number' && !isNaN(val)) return val
if (typeof val === 'string') {
const num = parseFloat(val)
@@ -990,7 +1005,7 @@ export class AggregationIndex {
// distinctCount tracks distinct values of ANY type (strings, numbers, booleans),
// keyed by their string form — NOT numeric-coerced, since its primary use is
// categorical (distinct categories / users / tags), not numeric columns.
- const raw = resolveEntityField(entity as unknown as HNSWNounWithMetadata, metricDef.field!)
+ const raw = readAddressed(entity as unknown as HNSWNounWithMetadata, metricDef.field!)
if (raw !== undefined && raw !== null) {
if (!state.valueCounts) state.valueCounts = {}
const key = String(raw)
@@ -1034,7 +1049,7 @@ export class AggregationIndex {
state.count = Math.max(0, state.count - 1)
state.sum = Math.max(0, state.sum - 1)
} else if (metricDef.op === 'distinctCount') {
- const raw = resolveEntityField(entity as unknown as HNSWNounWithMetadata, metricDef.field!)
+ const raw = readAddressed(entity as unknown as HNSWNounWithMetadata, metricDef.field!)
if (raw !== undefined && raw !== null && state.valueCounts) {
const key = String(raw)
const c = state.valueCounts[key]
diff --git a/src/brainy.ts b/src/brainy.ts
index a8df56bd..3dd8ef93 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -5619,7 +5619,7 @@ export class Brainy implements BrainyInterface {
this._aggregationIndex!.defineAggregate({
name: aggregateName,
source: {},
- groupBy: perType ? [name, 'noun'] : [name],
+ groupBy: perType ? [name, 'system.type'] : [name],
metrics: { count: { op: 'count' } }
})
}
@@ -5630,7 +5630,12 @@ export class Brainy implements BrainyInterface {
* and `counts.byField()` agree on the convention.
*/
private fieldCountsAggregateName(name: string): string {
- return `__fieldCounts__${name}`
+ // v2 suffix: the per-type dimension moved from the legacy 'noun' alias to
+ // 'system.type' under the addressing law — a NEW name makes the ensure
+ // block re-define and BACKFILL from canonical instead of silently serving
+ // the old-dim definition (whose 'noun' key now reads user metadata and
+ // would drift). The v1 rows are derived state, superseded not lost.
+ return `__fieldCounts_v2__${name}`
}
/**
@@ -11852,7 +11857,7 @@ export class Brainy implements BrainyInterface {
// don't have the tracked field at all (e.g. the VFS root) bucket under
// '__null__' and would otherwise pollute the count map.
if (value === undefined || value === null || value === '__null__') continue
- if (options?.type !== undefined && row.groupKey?.['noun'] !== options.type) continue
+ if (options?.type !== undefined && row.groupKey?.['system.type'] !== options.type) continue
const key = String(value)
result[key] = (result[key] || 0) + (typeof row.metrics?.count === 'number' ? row.metrics.count : row.count)
}
diff --git a/src/db/fieldAddressing.ts b/src/db/fieldAddressing.ts
index cd63871c..056ba3f2 100644
--- a/src/db/fieldAddressing.ts
+++ b/src/db/fieldAddressing.ts
@@ -283,3 +283,11 @@ export class UnsupportedFindOptionError extends Error {
this.option = option
}
}
+
+/**
+ * @description The capability signal both engines' conformance suites arm on
+ * (never a version guess): its presence at the package root means the one
+ * field-addressing law is LIVE on every query surface — bare = user metadata,
+ * `system.*` = the ruled scalars, plumbing invisible, refusals typed.
+ */
+export const FIELD_ADDRESSING_CAPABILITY = 'field-addressing/v1'
diff --git a/src/index.ts b/src/index.ts
index ae8eef5c..3876a903 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -106,6 +106,25 @@ export type {
// Export Aggregation Engine
export { AggregationIndex, AggregateMaterializer, bucketTimestamp, parseBucketRange } from './aggregation/index.js'
+// THE ONE FIELD-ADDRESSING LAW (sealed 2026-08-03) — the arming surface both
+// engines' conformance suites detect: bare names = user metadata, system.* =
+// the ten ruled scalars, plumbing invisible, refusals typed with the fix in
+// the message. See docs/concepts/field-addressing.md.
+export {
+ FIELD_ADDRESSING_CAPABILITY,
+ SYSTEM_ENTITY_SCALARS,
+ SYSTEM_RELATION_SCALARS,
+ PLUMBING_FIELDS,
+ parseFieldAddress,
+ readEntityFieldAddress,
+ readRelationFieldAddress,
+ buildUnresolvableMessage,
+ InvalidFieldAddressError,
+ UnresolvableFieldError,
+ UnsupportedFindOptionError
+} from './db/fieldAddressing.js'
+export type { FieldAddress, FieldAddressKind } from './db/fieldAddressing.js'
+
// Export Neural Import (AI data understanding)
export { NeuralImport } from './neural/neuralImport.js'
export type {
diff --git a/src/storage/brainFormat.ts b/src/storage/brainFormat.ts
index a1241fe0..6ef913d4 100644
--- a/src/storage/brainFormat.ts
+++ b/src/storage/brainFormat.ts
@@ -69,12 +69,15 @@ export const BRAIN_FORMAT_PATH = '_system/brain-format.json'
* (the 8.0 GA baseline). An on-disk `indexEpoch` that differs from this — or an
* absent marker — triggers a full derived-index rebuild on open.
*/
-// Epoch 2 (2026-08-03, paired with the native accelerator's same-day release):
-// user metadata fields named `level` become indexable on both engines — the
-// derived posting set changed, so every pre-fix brain must rebuild its
-// metadata index from canonical at first open (poisoned multi-valued `level`
-// columns heal through this rebuild; no bespoke heal path).
-export const EXPECTED_INDEX_EPOCH = 2
+// Epoch 3 (2026-08-03, the namespace-law pair): the index key format split
+// the two namespaces — user fields keep bare flattened keys, the ten system
+// scalars moved to literal 'system.' keys (the legacy 'noun' column
+// spelling died with them). Every brain rebuilds its derived indexes from
+// canonical at first open onto the frozen keys.
+// Epoch 2 (2026-08-03, same day, the interim pair): user metadata fields
+// named `level` became indexable on both engines; poisoned multi-valued
+// `level` columns healed through the rebuild.
+export const EXPECTED_INDEX_EPOCH = 3
/**
* @description The data-layer format string this build writes and runs as.
diff --git a/tests/integration/level-field-shadow.test.ts b/tests/integration/level-field-shadow.test.ts
index ab5ffb9a..cfe34c13 100644
--- a/tests/integration/level-field-shadow.test.ts
+++ b/tests/integration/level-field-shadow.test.ts
@@ -141,8 +141,8 @@ describe('level field shadow — user metadata named level is a real field', ()
expect(Array.isArray(after?.vector) && after!.vector!.length).toBe(384)
})
- it('this build runs index epoch 2 (the paired level-indexability rebuild)', () => {
- expect(EXPECTED_INDEX_EPOCH).toBe(2)
+ it('this build runs index epoch 3 (the namespace-law key split rebuild)', () => {
+ expect(EXPECTED_INDEX_EPOCH).toBe(3)
})
})
diff --git a/tests/unit/brainy/migration-deference.test.ts b/tests/unit/brainy/migration-deference.test.ts
index f03bba9c..5968c620 100644
--- a/tests/unit/brainy/migration-deference.test.ts
+++ b/tests/unit/brainy/migration-deference.test.ts
@@ -245,9 +245,10 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b
it('the brain-format marker module exports the compiled epoch + data-format constants', () => {
// cor imports these from '@soulcraft/brainy/brain-format' (Hook 3) so both
// sides share ONE source of truth — no duplicated constant to drift.
- // Epoch 2: user metadata named `level` became indexable (the reserved-name
- // shadow fix, 2026-08-03) — pre-fix brains rebuild derived indexes at open.
- expect(EXPECTED_INDEX_EPOCH).toBe(2)
+ // Epoch 3: the namespace-law key split (bare user keys · literal
+ // 'system.' scalars, 2026-08-03) — every brain rebuilds onto the
+ // frozen keys at first open. (Epoch 2 same day: `level` indexability.)
+ expect(EXPECTED_INDEX_EPOCH).toBe(3)
expect(CURRENT_DATA_FORMAT).toBe('8.0')
})
})
From 8e962dabdaec6dabef88ebfce5d47afee463588e Mon Sep 17 00:00:00 2001
From: David Snelling
Date: Mon, 3 Aug 2026 16:01:02 -0700
Subject: [PATCH 042/175] =?UTF-8?q?feat(namespace):=20conformance=20green?=
=?UTF-8?q?=2019/19=20=E2=80=94=20data-aware=20did-you-mean=20on=20unindex?=
=?UTF-8?q?ed=20bare=20addresses,=20ordering=20contract=20on=20the=20colum?=
=?UTF-8?q?n=20top-K=20path=20(never=20drop,=20nulls=20last,=20ties=20by?=
=?UTF-8?q?=20id),=20shape-complete=20addressed=20reads=20(entity=20views?=
=?UTF-8?q?=20AND=20raw=20storage=20shapes,=20shadow-proof=20both=20scopes?=
=?UTF-8?q?),=20per-key=20source=20matching=20for=20dotted=20addresses;=20?=
=?UTF-8?q?refusal=20classes=20unified=20under=20UnresolvableFieldError?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/aggregation/AggregationIndex.ts | 12 ++-
src/db/fieldAddressing.ts | 81 +++++++++++++-------
src/utils/metadataIndex.ts | 98 +++++++++++++++++--------
tests/conformance/namespace-law.test.ts | 4 +-
4 files changed, 134 insertions(+), 61 deletions(-)
diff --git a/src/aggregation/AggregationIndex.ts b/src/aggregation/AggregationIndex.ts
index 407b1fe0..ca44ac8b 100644
--- a/src/aggregation/AggregationIndex.ts
+++ b/src/aggregation/AggregationIndex.ts
@@ -110,11 +110,15 @@ function matchesSource(entity: Record, source: AggregateDefinit
// live in the custom bag, so those filters could never match anything.
if (source.where && Object.keys(source.where).length > 0) {
const e = entity as unknown as HNSWNounWithMetadata
- const resolved: Record = {}
- for (const key of Object.keys(source.where)) {
- resolved[key] = readAddressed(e, key)
+ for (const [key, condition] of Object.entries(source.where)) {
+ // Evaluate ONE field at a time under a neutral key: the address may be
+ // dotted ('system.subtype'), and the filter evaluator would otherwise
+ // walk dots as a nested path instead of treating the key as an address.
+ const value = readAddressed(e, key)
+ if (!matchesMetadataFilter({ v: value }, { v: condition } as Record)) {
+ return false
+ }
}
- if (!matchesMetadataFilter(resolved, source.where)) return false
}
return true
diff --git a/src/db/fieldAddressing.ts b/src/db/fieldAddressing.ts
index 056ba3f2..98c81e5f 100644
--- a/src/db/fieldAddressing.ts
+++ b/src/db/fieldAddressing.ts
@@ -167,10 +167,39 @@ export function readEntityFieldAddress(
entity: HNSWNounWithMetadata,
address: FieldAddress
): unknown {
+ const rec = entity as unknown as Record
+ const bag =
+ rec.metadata && typeof rec.metadata === 'object'
+ ? (rec.metadata as Record)
+ : null
+
if (address.scope === 'system') {
- return (entity as unknown as Record)[address.field]
+ // Entity views carry system scalars top-level; raw storage shapes carry
+ // them inside the stored metadata record (where `type` is spelled `noun`).
+ // Read top-level first, then the record — never the user's namespace.
+ const top = rec[address.field]
+ if (top !== undefined) return top
+ if (bag) {
+ if (address.field === 'type') return bag.type ?? bag.noun
+ return bag[address.field]
+ }
+ return undefined
}
- return entity.metadata?.[address.field]
+
+ // User scope. The write-path remap guarantees the user can never OWN a
+ // field named like a system scalar (those lift top-level at write), so a
+ // bare system name reads as ABSENT — reading the stored record's reserved
+ // key here would re-create the shadow this module exists to kill. Same for
+ // plumbing and the legacy 'noun' spelling.
+ if (
+ SYSTEM_ENTITY_SCALARS.has(address.field) ||
+ PLUMBING_FIELDS.has(address.field) ||
+ address.field === 'noun'
+ ) {
+ return undefined
+ }
+ if (bag) return bag[address.field]
+ return rec[address.field]
}
/**
@@ -222,29 +251,6 @@ export function buildUnresolvableMessage(
)
}
-/**
- * @description Refusal for a malformed or out-of-map field ADDRESS —
- * `system.