fix: internal subtype consistency + brain.audit() diagnostic + improved enforcement errors

Brainy 7.30 shipped opt-in subtype enforcement; SDK 3.20.0 then registered
SDK_CORE_VOCABULARY on every consumer's brain (Event, Collection, Message,
Contract, Media, Document NounTypes). On 2026-06-08 Venue's /book flow went 500
because their brain.add({ type: NounType.Event, ... }) call sites lacked
subtype. An audit of Brainy's OWN source revealed 14 HIGH-risk internal write
paths that also omit subtype — any consumer running the same vocabulary would
have hit Brainy's infrastructure paths next. 7.30.1 closes both gaps before
8.0 makes strict mode the default.

Additive across the board. Zero behavior change for consumers not using strict
mode. Every change is JS-side — Cortex needs no work for 7.30.1.

NEW — brain.audit() diagnostic
- Read-only method walking storage.getNouns() / getVerbs() pagination
- Returns { entitiesWithoutSubtype: { type: count }, relationshipsWithoutSubtype,
  total, scanned, recommendation }
- VFS infrastructure entities excluded by default (they bypass enforcement via
  isVFSEntity marker); pass { includeVFS: true } to surface them
- The companion to migrateField (7.x) and fillSubtypes (8.0): tells consumers
  exactly what would break under strict enforcement, deterministically

NEW — Improved enforcement error messages
- Caller's source location extracted from Error().stack so users see their own
  call site, not a Brainy internal frame
- Specific guidance branches: registered vocabulary → "Pass one of: a, b, c";
  brain-wide strict mode → mentions the except clause; otherwise → registration
  recipe via brain.requireSubtype()
- Documentation link to the canonical migration recipe
- Same shape for noun and verb enforcement

NEW — CLI --subtype flag
- brainy add and brainy relate gain -s/--subtype <value>
- Defaults to 'cli-add' / 'cli-relate' so the CLI works against strict-mode
  brains without the user needing to know the vocabulary in advance

INTERNAL — every Brainy write path now sets subtype
- VFS Contains edges (5 sites at lines 503/905/1694/1772/1886) → 'vfs-contains'
- VFS symlink entity → 'vfs-symlink' (NEW — distinct from 'vfs-file')
- VFS copy-file → preserves source subtype, falls back to 'vfs-file'
- VFS symlink also adopts the isVFSEntity infrastructure marker so it bypasses
  enforcement in strict mode
- Aggregation materializer (Measurement entities) → 'materialized-aggregate'
- ImportCoordinator (3 sites): document → 'import-source'; entities →
  options.defaultSubtype ?? 'imported'; placeholder → 'import-placeholder'
- SmartImportOrchestrator (4 entity sites + 2 batch relate sites): same
  precedence (extractor → options.defaultSubtype → 'imported')
- EntityDeduplicator → candidate.subtype ?? 'imported'
- UniversalImportAPI → extractor → 'extracted' for both entities and relations
- NeuralImport → adds defaultSubtype to NeuralImportOptions; precedence same
- GoogleSheetsIntegration → request body 'subtype' ?? 'imported-from-sheets'
- ODataIntegration → request body 'Subtype' ?? 'imported-from-odata'
- MCP client message storage → 'mcp-message' (also fixes pre-existing missing
  data field and missing type by aliasing from the prior text field)

Side-effect fix: storage.getNouns() paginated now surfaces subtype to top-level
- Single-noun getNoun() already did this in 7.30; the paginated path was missed
- Without this fix brain.audit() saw missing subtype on entities that actually
  had one (caught by the strict-mode self-test before release)

NEW — tests/integration/strict-mode-self-test.test.ts (13 tests)
- Creates a brain under the exact SDK_CORE_VOCABULARY shape Venue hit + brain-
  wide strict mode
- Exercises every internal Brainy path: VFS root + mkdir + writeFile + cp + mv
  + ln + symlink; aggregation engine; audit diagnostic with includeVFS toggle
- Validates error message UX: caller location, vocabulary guidance, brain-wide
  strict mode guidance, off-vocabulary value reporting

Docs
- New "Strict mode in practice" section in docs/guides/subtypes-and-facets.md
  covering the SDK_CORE_VOCABULARY pattern, 4-step migration recipe
  (audit → migrateField → hand-fix → re-audit), the Brainy-internal label
  reference table, and an 8.0 forward-look on fillSubtypes()
- docs/api/README.md: new audit() entry, strict-mode tips on add() and relate()
- RELEASES.md: full 7.30.1 entry

Cortex parity (forward-looking, not blocking 7.30.1)
- 6th open question added to .strategy/BRAINY-8.0-SUBTYPE-CONTRACT.md: native
  fast path for audit() and fillSubtypes() via column-store null-subtype
  bitmap for billion-scale brains
- Cortex should add a parity test mirroring strict-mode-self-test.test.ts
  against their native paths to catch any latent bug where native writes
  bypass JS validation
- Brainy-internal subtype labels become a documented part of the 8.0 contract
  (useful for Cortex telemetry surfacing Brainy-managed infrastructure %)

Verification
- npx tsc --noEmit: clean
- npm test: 1468/1468 unit
- 7.29 noun integration suite: 26/26 (no regression)
- 7.30 verb subtype + enforcement integration suite: 30/30 (no regression)
- New strict-mode-self-test integration suite: 13/13
- npm run build: clean
- Closed-source product reference audit: clean

Addresses VE-SUBTYPE-MIGRATION (Venue's reported request) and ships internal
labels Venue did NOT ask for but that would have broken them next under their
own vocabulary registration.
This commit is contained in:
David Snelling 2026-06-08 11:31:47 -07:00
parent a82c3339df
commit 5f3a2ca7d5
18 changed files with 999 additions and 37 deletions

View file

@ -11,6 +11,174 @@ Collective. The SDK wraps it — most products never call Brainy directly. Read
--- ---
## v7.30.1 — 2026-06-08
**Affected products:** anyone running a brain where a platform layer (SDK, framework wrapper)
has registered `brain.requireSubtype()` rules on common NounTypes, AND anyone preparing for
the upcoming Brainy 8.0 default-on strict mode. Additive; drop-in from 7.30.0. No behavior
change for consumers not using strict-mode enforcement.
### Why
Production incident 2026-06-08: a consumer using SDK 3.20.0 (which registers
`requireSubtype()` rules on `NounType.{Event, Collection, Message, Contract, Media, Document}`)
saw their booking flow start returning 500s because `brain.add({ type: NounType.Event, ... })`
calls in their codebase lacked `subtype`. An audit of Brainy's OWN source revealed 14 HIGH-risk
internal write paths that also omit subtype — VFS move/copy/symlink edges, aggregation
materializer, neural extraction, importers, integrations (Sheets/OData), MCP client, CLI.
Any consumer running `requireSubtype()` rules on those NounTypes was one step away from breaking
Brainy's own infrastructure paths, not just their own code. 7.30.1 closes both gaps before
8.0 ships and makes strict mode the default.
### New — `brain.audit()` diagnostic
Find entities and relationships missing a `subtype` value, grouped by type. The companion to
`migrateField()` (and to 8.0's `fillSubtypes()`): answers "what would break if I enabled
strict subtype enforcement?".
```typescript
const report = await brain.audit()
// {
// entitiesWithoutSubtype: { event: 24, document: 3 },
// relationshipsWithoutSubtype: { relatedTo: 1402 },
// total: 1429,
// scanned: 8400,
// recommendation: 'Found 1429 entries without subtype. Migrate via `brain.migrateField()`
// (7.x) — or wait for `brain.fillSubtypes()` (8.0) which closes the same
// gap with caller-supplied rules.'
// }
```
VFS infrastructure entities are excluded by default (they bypass enforcement via
`metadata.isVFSEntity` markers). Pass `{ includeVFS: true }` to surface them.
### New — Improved enforcement error messages
The error fired when subtype enforcement rejects a write now includes:
1. **The caller's source location** — extracted from the JavaScript stack so you see your own
call site, not a Brainy internal frame. Eliminates the "grep your repo for `brain.add`" step.
2. **Specific guidance** — points at the registered vocabulary when one exists; mentions
brain-wide strict mode and the `except` escape valve when not; otherwise the
`brain.requireSubtype()` registration recipe.
3. **A documentation link**`https://soulcraft.com/docs/guides/subtypes-and-facets#strict-mode`
for the canonical migration recipe.
Before:
```
add(): NounType.Event requires subtype but got undefined. Register vocabulary via brain.requireSubtype().
```
After:
```
add(): NounType.event requires subtype but got undefined.
at BookingDraftService.getOrCreateByToken (/app/src/booking/draft.ts:42:23)
Pass one of: booking, session, milestone.
Migration recipe: https://soulcraft.com/docs/guides/subtypes-and-facets#strict-mode
```
### Internal subtype labels — Brainy's own infrastructure paths
Every internal Brainy write path now sets a stable, queryable `subtype`. Consumers don't need
to do anything for these — they're documented here so you can query Brainy-managed data:
| Code path | NounType / VerbType | Subtype label |
|---|---|---|
| VFS root directory `/` | `Collection` | `'vfs-root'` |
| VFS subdirectories | `Collection` | `'vfs-directory'` |
| VFS files | mime-driven (e.g. `Document`/`Code`/`Image`) | `'vfs-file'` |
| VFS symlinks | `File` | `'vfs-symlink'` (NEW — distinct from `'vfs-file'`) |
| VFS Contains edges (create + move/copy/symlink/batch) | `Contains` | `'vfs-contains'` |
| Aggregation materialized output | `Measurement` | `'materialized-aggregate'` |
| Import-source provenance entity | `Document` | `'import-source'` |
| Importer-extracted entities | extractor-driven type | `'imported'` |
| Importer placeholder targets | `Thing` | `'import-placeholder'` |
| Neural extraction | extractor-driven type | `'extracted'` |
| GoogleSheets API entity writes | request-driven | `'imported-from-sheets'` |
| OData API entity writes | request-driven | `'imported-from-odata'` |
| MCP message storage | `Message` | `'mcp-message'` |
| `brainy add` CLI default | user-supplied type | `'cli-add'` |
| `brainy relate` CLI default | user-supplied verb | `'cli-relate'` |
Query examples:
```typescript
// Every VFS-managed file in your brain
await brain.find({ subtype: 'vfs-file' })
// Document breakdown — distinguishes import-source from extracted/imported/user content
brain.counts.bySubtype(NounType.Document)
// → { 'import-source': 12, 'imported': 847, 'extracted': 34, 'vfs-file': 102, ... }
```
### Caller-supplied `defaultSubtype` on importers and extraction
Importer + extraction paths now accept a caller-supplied `defaultSubtype` config so consumers
can tag a whole batch with their own provenance label instead of the Brainy default:
```typescript
// SmartImportOrchestrator / ImportCoordinator / NeuralImport all accept this
await brain.importer.import(file, {
defaultSubtype: 'customer-upload-2026q2', // your batch label
// ...
})
```
Precedence: extractor-set subtype (highest) → caller's `defaultSubtype` → Brainy default
(`'imported'` for importers, `'extracted'` for extractors).
### CLI `--subtype` flag
`brainy add` and `brainy relate` gain a `--subtype <value>` (`-s`) flag for use with
strict-mode brains:
```bash
brainy add "Avery Brooks — runs the AI lab" --type person --subtype employee
brainy relate alice manages bob --subtype direct
```
When the flag isn't supplied, the CLI uses `'cli-add'` / `'cli-relate'` as defaults so
ad-hoc CLI usage still works against strict-mode brains.
### Cortex compatibility
**No Cortex changes required for 7.30.1.** Every change is JS-side: internal subtype labels are
arbitrary strings stored transparently by Cortex; `brain.audit()` runs purely on JS via existing
`storage.getNouns()` / `getVerbs()` pagination; the CLI is JS-only; error messages fire in
Brainy before any native call.
**For Cortex 3.0 (forward-looking, not blocking):**
- **Native `audit()` proxy.** For billion-scale brains, `audit()` walks every entity (O(N)).
A native implementation reading from a "null-subtype" bitmap in the column store would be
O(buckets). Listed as the 6th open question in the
[Brainy 8.0 spec doc](`/media/dpsifr/storage/home/Projects/brainy/.strategy/BRAINY-8.0-SUBTYPE-CONTRACT.md`).
- **Strict-mode parity test.** Cortex should mirror Brainy's new
`tests/integration/strict-mode-self-test.test.ts` against their native paths to catch any
latent bug where native writes bypass JS validation.
- **Reserved-label awareness (optional).** Brainy's internal labels (`'vfs-*'`,
`'materialized-aggregate'`, `'imported'`, `'extracted'`, `'mcp-message'`, `'cli-*'`) become
a documented part of the 8.0 contract; useful for telemetry that surfaces "X% of entities are
Brainy-managed infrastructure".
### Docs
Updated `docs/guides/subtypes-and-facets.md` with a new "Strict mode in practice" section
covering the SDK_CORE_VOCABULARY pattern, a 4-step migration recipe, the Brainy-internal label
reference table, and an 8.0 forward-look. `docs/api/README.md` documents `brain.audit()` and
adds a strict-mode tip to the `add()` / `relate()` reference entries.
### Tests
- **New `tests/integration/strict-mode-self-test.test.ts`** (13 tests): creates a brain under
the exact SDK_CORE_VOCABULARY shape Venue hit + brain-wide strict mode, then exercises every
internal Brainy path (VFS root/mkdir/writeFile/cp/mv/ln, aggregation engine, audit
diagnostic, error-message UX). Zero rejections expected.
- Existing 7.30.0 + 7.29.0 integration suites unchanged: 26/26 + 30/30.
- Unit suite unchanged: 1468/1468.
---
## v7.30.0 — 2026-06-05 ## v7.30.0 — 2026-06-05
**Affected products:** consumers modeling typed relationships with sub-classification **Affected products:** consumers modeling typed relationships with sub-classification

View file

@ -131,6 +131,8 @@ const id = await brain.add({
> **`data`** is embedded into vectors for semantic search. **`metadata`** is indexed for `where` filters. See [Data Model](../DATA_MODEL.md). > **`data`** is embedded into vectors for semantic search. **`metadata`** is indexed for `where` filters. See [Data Model](../DATA_MODEL.md).
> **Strict-mode tip:** if a vocabulary is registered for your `type` (via `brain.requireSubtype()` or by an SDK that wraps Brainy), you must pass a matching `subtype`. Run `await brain.audit()` to inventory pre-existing gaps before enabling strict mode; see the [migration recipe](../guides/subtypes-and-facets.md#strict-mode-in-practice-for-sdk-style-vocabulary-consumers).
**Returns:** `Promise<string>` - Entity ID **Returns:** `Promise<string>` - Entity ID
--- ---
@ -629,6 +631,8 @@ const relId = await brain.relate({
- `bidirectional?`: `boolean` - Create reverse edge too (default: false) - `bidirectional?`: `boolean` - Create reverse edge too (default: false)
- `confidence?`: `number` - Relationship certainty (0-1) - `confidence?`: `number` - Relationship certainty (0-1)
> **Strict-mode tip:** same as `add()` — if a vocabulary is registered for your `type`, pass a matching `subtype`. Run `await brain.audit()` first to surface pre-existing gaps.
**Returns:** `Promise<string>` - Relationship ID **Returns:** `Promise<string>` - Relationship ID
--- ---
@ -2049,6 +2053,28 @@ brain.relationshipSubtypesOf(VerbType.ReportsTo)
// → ['direct', 'dotted-line'] // → ['direct', 'dotted-line']
``` ```
#### `audit(options?)``Promise<AuditReport>` (7.30.1+)
Diagnostic — find entities and relationships missing a `subtype` value, grouped by type. The companion to `migrateField()` / `fillSubtypes()` — answers "what would break if I enabled strict subtype enforcement?".
```typescript
const report = await brain.audit()
// {
// entitiesWithoutSubtype: { event: 24, document: 3 },
// relationshipsWithoutSubtype: { relatedTo: 1402 },
// total: 1429,
// scanned: 8400,
// recommendation: 'Found 1429 entries without subtype. ...'
// }
```
**Parameters:**
- `options.includeVFS?`: `boolean` — When `false` (default), VFS infrastructure entities (`metadata.isVFSEntity` / `metadata.isVFS`) are excluded. They bypass enforcement anyway, so counting them is noise.
- `options.batchSize?`: `number` — Pagination batch size (default 200).
- `options.onProgress?`: `(progress: { scanned, missingSubtype }) => void` — Progress callback per batch.
Run before adopting an SDK that registers `requireSubtype()` rules, or before upgrading to Brainy 8.0 (which makes strict mode the default). See the [Strict mode in practice](../guides/subtypes-and-facets.md#strict-mode-in-practice-for-sdk-style-vocabulary-consumers) guide for the full migration recipe.
#### `requireSubtype(type, options?)``void` #### `requireSubtype(type, options?)``void`
Register subtype enforcement for a specific `NounType` or `VerbType`. Unified API for nouns and verbs. Composes with the brain-wide `requireSubtype` constructor flag. Register subtype enforcement for a specific `NounType` or `VerbType`. Unified API for nouns and verbs. Composes with the brain-wide `requireSubtype` constructor flag.

View file

@ -460,6 +460,82 @@ brain.requireSubtype(NounType.Person, {
await brain.add({ type: NounType.Person, subtype: 'employee', data: '...' }) await brain.add({ type: NounType.Person, subtype: 'employee', data: '...' })
``` ```
## Strict mode in practice (for SDK-style vocabulary consumers)
When a platform layer like the Soulcraft SDK registers `requireSubtype()` rules on behalf of every consumer's brain, every downstream product that calls `brain.add()` / `brain.relate()` against those types must pass a matching `subtype`. Skipping the field — or passing one outside the registered vocabulary — throws at the boundary.
This pattern is powerful but surfaces a class of latent bug: any `brain.add()` call site that was written before strict-mode adoption starts rejecting writes. The Venue team hit this in production on 2026-06-08 when their `/book` flow 500'd on every request because `BookingDraftService.getOrCreateByToken` called `brain.add({ type: NounType.Event, ... })` without subtype.
The fix is a four-step migration recipe — and Brainy 7.30.1+ ships diagnostic tools to make it deterministic.
### Migration recipe
1. **Inventory the gap with `brain.audit()`** — returns the deterministic list of which NounTypes and VerbTypes have entities/relationships missing subtype, grouped by type:
```typescript
const report = await brain.audit()
// {
// entitiesWithoutSubtype: { event: 24, document: 3, ... },
// relationshipsWithoutSubtype: { relatedTo: 1402 },
// total: 1429,
// scanned: 8400,
// recommendation: 'Found 1429 entries without subtype. ...'
// }
```
By default, VFS infrastructure entities are excluded (they bypass enforcement anyway via the `metadata.isVFSEntity` marker). Pass `{ includeVFS: true }` to surface them too.
2. **Bulk-migrate any existing convention** with `brain.migrateField()` if a legacy field can be lifted:
```typescript
// Venue chose subtype = same string as metadata.entityType:
await brain.migrateField({
from: 'metadata.entityType',
to: 'subtype',
readBoth: true // safety: keep the source field readable during cutover
})
```
3. **Hand-fix the remaining call sites.** The exact list is in `report.entitiesWithoutSubtype`. For each call site, add `subtype: '<value>'` to the `brain.add()` / `brain.relate()` params. Choose a stable convention (Venue chose `subtype = metadata.entityType`; any rule that's deterministic from the data works).
4. **Verify with `brain.audit()` again.** Re-run; total should be `0`. If you turn on brain-wide strict mode at this point, all future writes are protected.
### Brainy's own infrastructure subtype labels (reference)
Brainy's internal write paths set subtype on every entity and edge they create. Consumers don't need to do anything for these — they're documented here so you understand the data shape:
| Code path | NounType / VerbType | Subtype label |
|---|---|---|
| VFS root directory `/` | `NounType.Collection` | `'vfs-root'` |
| VFS subdirectories | `NounType.Collection` | `'vfs-directory'` |
| VFS files | (mime-driven, e.g. `Document`/`Code`/`Image`) | `'vfs-file'` |
| VFS symlinks | `NounType.File` | `'vfs-symlink'` |
| VFS Contains edges | `VerbType.Contains` | `'vfs-contains'` |
| Aggregation materialized output | `NounType.Measurement` | `'materialized-aggregate'` |
| Import-document provenance entity | `NounType.Document` | `'import-source'` |
| Importer-extracted entities (no caller default) | extractor-driven | `'imported'` |
| Importer placeholder targets | `NounType.Thing` | `'import-placeholder'` |
| Neural extraction (no caller default) | extractor-driven | `'extracted'` |
| GoogleSheets API entity writes | request-driven | `'imported-from-sheets'` |
| OData API entity writes | request-driven | `'imported-from-odata'` |
| MCP client message storage | `NounType.Message` | `'mcp-message'` |
| `brainy add` CLI (no `--subtype` flag) | user-supplied type | `'cli-add'` |
| `brainy relate` CLI (no `--subtype` flag) | user-supplied verb | `'cli-relate'` |
You can query these directly: `await brain.find({ subtype: 'vfs-file' })` returns every VFS-managed file regardless of NounType. `await brain.counts.bySubtype(NounType.Document)` shows you the import-source / imported / extracted / vfs-file breakdown.
Importer and extraction paths accept a caller-supplied `defaultSubtype` option so you can tag a whole batch with your own provenance label (e.g. `'customer-upload-2026q2'`) instead of the Brainy default `'imported'` / `'extracted'`.
### Looking ahead — Brainy 8.0
Brainy 8.0 ships:
- **`brain.fillSubtypes(rules)`** — the bulk migration helper that pairs with `audit()`. Given caller-supplied rules per NounType / VerbType, it walks the brain and fills in missing subtypes via `update()`. Pre-8.0 brains run this once before upgrading to clear migration debt.
- **`subtype: string` (non-optional)** on `AddParams<T>` and `RelateParams<T>`. TypeScript catches missing subtype at compile time, not just runtime.
- **`new Brainy({ requireSubtype: true })` becomes the default.** Consumers explicitly opt out with `{ requireSubtype: false }` during migration.
7.30.1's `audit()` is the diagnostic; 8.0's `fillSubtypes()` is the bulk fixer. Together they close the migration gap deterministically.
## Reference ## Reference
### Layer 1 — `subtype` (nouns) ### Layer 1 — `subtype` (nouns)

View file

@ -23,6 +23,8 @@ export interface MaterializerBrainAccess {
add(params: { add(params: {
data: string data: string
type: NounType type: NounType
/** Sub-classification of the materialized entity (7.30.1+). */
subtype?: string
metadata: Record<string, unknown> metadata: Record<string, unknown>
id?: string id?: string
service?: string service?: string
@ -31,6 +33,7 @@ export interface MaterializerBrainAccess {
update(params: { update(params: {
id: string id: string
data?: string data?: string
subtype?: string
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
merge?: boolean merge?: boolean
}): Promise<void> }): Promise<void>
@ -197,9 +200,15 @@ export class AggregateMaterializer {
} else { } else {
// Create new materialized entity // Create new materialized entity
// NounType.Measurement = 'measurement' // NounType.Measurement = 'measurement'
// Subtype `materialized-aggregate` distinguishes engine-emitted Measurement entities
// from any user-authored ones, and makes them queryable: `brain.find({ type: 'measurement',
// subtype: 'materialized-aggregate' })` enumerates every active aggregate output. Also
// ensures the aggregation engine itself doesn't trip enforcement when a consumer
// registers a vocabulary on NounType.Measurement (added 7.30.1).
const id = await this.brain.add({ const id = await this.brain.add({
data: dataString, data: dataString,
type: 'measurement' as NounType, type: 'measurement' as NounType,
subtype: 'materialized-aggregate',
metadata, metadata,
service: 'brainy:aggregation' service: 'brainy:aggregation'
}) })

View file

@ -642,9 +642,13 @@ export class UniversalImportAPI {
let entitiesProcessed = 0 let entitiesProcessed = 0
for (const entity of neuralResults.entities.values()) { for (const entity of neuralResults.entities.values()) {
// Subtype precedence: extractor-set → `'extracted'` (this is the universal
// neural-extraction path, so `'extracted'` is the honest provenance label).
// Added 7.30.1 so enforcement consumers don't get rejected on auto-extraction.
const id = await this.brain.add({ const id = await this.brain.add({
data: entity.data, data: entity.data,
type: entity.type, type: entity.type,
subtype: (entity as any).subtype ?? 'extracted',
metadata: entity.metadata, metadata: entity.metadata,
vector: entity.vector vector: entity.vector
}) })
@ -682,8 +686,10 @@ export class UniversalImportAPI {
total: neuralResults.relationships.size total: neuralResults.relationships.size
}) })
// Collect all relationship parameters // Collect all relationship parameters. Subtype `extracted` matches the
const relationshipParams: Array<{from: string; to: string; type: VerbType; weight?: number; metadata?: any}> = [] // entity-side label so consumers can query "everything from this neural pass"
// via `(type, subtype: 'extracted')` (added 7.30.1).
const relationshipParams: Array<{from: string; to: string; type: VerbType; subtype?: string; weight?: number; metadata?: any}> = []
for (const relation of neuralResults.relationships.values()) { for (const relation of neuralResults.relationships.values()) {
// Map to actual entity IDs // Map to actual entity IDs
@ -697,6 +703,7 @@ export class UniversalImportAPI {
from: sourceEntity.id, from: sourceEntity.id,
to: targetEntity.id, to: targetEntity.id,
type: relation.type, type: relation.type,
subtype: (relation as any).subtype ?? 'extracted',
weight: relation.weight, weight: relation.weight,
metadata: relation.metadata metadata: relation.metadata
}) })

View file

@ -2672,8 +2672,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
if (subtype === undefined || subtype === null || subtype === '') { if (subtype === undefined || subtype === null || subtype === '') {
if ((rule?.required || strict) && !isInfra) { if ((rule?.required || strict) && !isInfra) {
throw new Error( throw new Error(
`${op}(): NounType.${type} requires subtype but got ${subtype === undefined ? 'undefined' : 'empty'}. ` + this.formatSubtypeError({
(rule?.values ? `Allowed values: [${Array.from(rule.values).join(', ')}].` : 'Register vocabulary via brain.requireSubtype().') op,
kind: 'noun',
typeName: String(type),
issue: subtype === undefined ? 'undefined' : 'empty',
rule,
strict
})
) )
} }
return return
@ -2681,8 +2687,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
if (rule?.values && !rule.values.has(subtype)) { if (rule?.values && !rule.values.has(subtype)) {
throw new Error( throw new Error(
`${op}(): NounType.${type} subtype '${subtype}' is not in registered vocabulary ` + this.formatSubtypeError({
`[${Array.from(rule.values).join(', ')}].` op,
kind: 'noun',
typeName: String(type),
issue: 'off-vocabulary',
offValue: subtype,
rule,
strict
})
) )
} }
} }
@ -2707,8 +2720,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
if (subtype === undefined || subtype === null || subtype === '') { if (subtype === undefined || subtype === null || subtype === '') {
if ((rule?.required || strict) && !isInfra) { if ((rule?.required || strict) && !isInfra) {
throw new Error( throw new Error(
`${op}(): VerbType.${verb} requires subtype but got ${subtype === undefined ? 'undefined' : 'empty'}. ` + this.formatSubtypeError({
(rule?.values ? `Allowed values: [${Array.from(rule.values).join(', ')}].` : 'Register vocabulary via brain.requireSubtype().') op,
kind: 'verb',
typeName: String(verb),
issue: subtype === undefined ? 'undefined' : 'empty',
rule,
strict
})
) )
} }
return return
@ -2716,12 +2735,97 @@ export class Brainy<T = any> implements BrainyInterface<T> {
if (rule?.values && !rule.values.has(subtype)) { if (rule?.values && !rule.values.has(subtype)) {
throw new Error( throw new Error(
`${op}(): VerbType.${verb} subtype '${subtype}' is not in registered vocabulary ` + this.formatSubtypeError({
`[${Array.from(rule.values).join(', ')}].` op,
kind: 'verb',
typeName: String(verb),
issue: 'off-vocabulary',
offValue: subtype,
rule,
strict
})
) )
} }
} }
/**
* Build the user-facing enforcement-error message.
*
* Three goals:
*
* 1. Diagnose: name the operation, the type, and what went wrong (missing,
* empty, or off-vocabulary).
* 2. Locate: include the first non-Brainy frame from the current stack so
* the caller knows which line of THEIR code triggered the rejection
* eliminates the "grep your repo for `brain.add`" debugging step.
* 3. Teach: include the canonical migration recipe URL so the next consumer
* learns the SDK-vocabulary pattern from the error, not a postmortem.
*
* Added 7.30.1.
*/
private formatSubtypeError(opts: {
op: string
kind: 'noun' | 'verb'
typeName: string
issue: 'undefined' | 'empty' | 'off-vocabulary'
offValue?: string
rule: { required: boolean; values?: Set<string> } | null
strict: boolean
}): string {
const typeLabel = opts.kind === 'noun' ? 'NounType' : 'VerbType'
const callSite = this.findCallerLocation()
const vocab = opts.rule?.values ? Array.from(opts.rule.values).join(', ') : null
let head: string
if (opts.issue === 'off-vocabulary') {
head = `${opts.op}(): ${typeLabel}.${opts.typeName} subtype '${opts.offValue}' is not in registered vocabulary [${vocab}].`
} else {
head = `${opts.op}(): ${typeLabel}.${opts.typeName} requires subtype but got ${opts.issue}.`
}
const callerLine = callSite ? ` at ${callSite}` : null
let guidance: string
if (vocab) {
// The consumer (or a platform layer like the SDK) registered a specific
// vocabulary. Tell them what to pass.
guidance = ` Pass one of: ${vocab}.`
} else if (opts.strict) {
// Brain-wide strict mode is on without per-type values. Caller picks the
// subtype string but it must be non-empty.
guidance = ' Brain-wide strict mode (`requireSubtype: true`) is on — pass a non-empty `subtype` on every write, or add this type to `requireSubtype.except`.'
} else {
guidance = ' Register vocabulary via `brain.requireSubtype()` or pass a `subtype` value.'
}
const docLink = ' Migration recipe: https://soulcraft.com/docs/guides/subtypes-and-facets#strict-mode'
return [head, callerLine, guidance, docLink].filter(Boolean).join('\n')
}
/**
* Extract the first non-Brainy frame from the current stack so error messages
* can point at the consumer's call site instead of Brainy internals. Returns
* `null` if the stack isn't available (some runtimes) or only contains Brainy
* frames.
*/
private findCallerLocation(): string | null {
const stack = new Error().stack
if (!stack) return null
const lines = stack.split('\n').slice(1) // drop the `Error` line
for (const raw of lines) {
const line = raw.trim()
// Skip frames inside Brainy's own files. We don't want to point the user
// at `brainy.ts:XXXX` — they need their own call site.
if (line.includes('/src/brainy.ts') || line.includes('/dist/brainy.js')) continue
if (line.includes('enforceSubtypeOn') || line.includes('formatSubtypeError')) continue
if (line.includes('findCallerLocation')) continue
// Strip leading `at ` if present so the caller can format consistently.
return line.replace(/^at /, '')
}
return null
}
/** /**
* Validate a metadata bag (or top-level field assignment) against any registered * Validate a metadata bag (or top-level field assignment) against any registered
* value whitelists. Called from `add()`/`update()` after the standard zero-config * value whitelists. Called from `add()`/`update()` after the standard zero-config
@ -6867,6 +6971,125 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return Array.from(inner.keys()).sort() return Array.from(inner.keys()).sort()
} }
/**
* Find entities and relationships missing a `subtype` value, grouped by type.
*
* The diagnostic pair to `migrateField()` / `fillSubtypes()` answers the
* question "what would break if I enabled strict subtype enforcement?". Run
* this before adopting an SDK that registers `requireSubtype()` rules on
* common NounTypes, or before upgrading to Brainy 8.0 (which makes
* `requireSubtype: true` the default).
*
* Streams the brain via the same paginated `storage.getNouns()` /
* `storage.getVerbs()` pattern `migrateField()` uses safe for large brains
* but linear in `O(N)`. Cortex 3.0+ may proxy this through the native
* column-store null-subtype bitmap for sub-linear performance on billion-scale
* brains (tracked in `CTX-SUBTYPE-8.0-CONTRACT`).
*
* @param options.includeVFS - When `false` (default), entities marked with
* `metadata.isVFSEntity` or `metadata.isVFS` are excluded from the report
* these bypass enforcement anyway, so counting them is noise. Pass `true`
* to include them.
* @param options.batchSize - Pagination batch size (default `200`).
* @param options.onProgress - Optional progress callback invoked after each batch.
* @returns Report with per-type counts of entities/relationships without a
* subtype, plus the overall total and a one-line recommendation pointing at
* `migrateField()` (7.x) or `fillSubtypes()` (8.0).
*
* @example Find pre-existing gaps before turning on strict mode
* const report = await brain.audit()
* if (report.total > 0) {
* console.warn('Found ' + report.total + ' entities/edges without subtype:')
* console.warn(report.entitiesWithoutSubtype)
* console.warn(report.relationshipsWithoutSubtype)
* }
*
* @since 7.30.1
*/
async audit(options: {
includeVFS?: boolean
batchSize?: number
onProgress?: (progress: { scanned: number; missingSubtype: number }) => void
} = {}): Promise<{
entitiesWithoutSubtype: Record<string, number>
relationshipsWithoutSubtype: Record<string, number>
total: number
scanned: number
recommendation: string
}> {
await this.ensureInitialized()
const includeVFS = options.includeVFS === true
const batchSize = Math.max(1, options.batchSize ?? 200)
const entitiesWithoutSubtype: Record<string, number> = {}
const relationshipsWithoutSubtype: Record<string, number> = {}
let scanned = 0
let missingSubtype = 0
const reportProgress = (): void => {
if (options.onProgress) options.onProgress({ scanned, missingSubtype })
}
// Scan nouns
let offset = 0
while (true) {
const page = await this.storage.getNouns({ pagination: { offset, limit: batchSize } })
if (page.items.length === 0) break
for (const noun of page.items) {
scanned++
const n = noun as any
// Skip VFS infrastructure entities unless includeVFS is set — they
// bypass enforcement via the isVFSEntity marker anyway, so listing
// them in the report would mislead consumers into thinking they have
// a migration gap they actually don't.
if (!includeVFS && (n.metadata?.isVFSEntity === true || n.metadata?.isVFS === true)) continue
const subtype = typeof n.subtype === 'string' ? n.subtype : undefined
if (!subtype || subtype.length === 0) {
missingSubtype++
const typeKey = String(n.type ?? n.noun ?? 'unknown')
entitiesWithoutSubtype[typeKey] = (entitiesWithoutSubtype[typeKey] || 0) + 1
}
}
reportProgress()
if (!page.hasMore) break
offset += page.items.length
}
// Scan verbs
offset = 0
while (true) {
const page = await this.storage.getVerbs({ pagination: { offset, limit: batchSize } })
if (page.items.length === 0) break
for (const verb of page.items) {
scanned++
const v = verb as any
if (!includeVFS && (v.metadata?.isVFSEntity === true || v.metadata?.isVFS === true)) continue
const subtype = typeof v.subtype === 'string' ? v.subtype : undefined
if (!subtype || subtype.length === 0) {
missingSubtype++
const verbKey = String(v.verb ?? v.type ?? 'unknown')
relationshipsWithoutSubtype[verbKey] = (relationshipsWithoutSubtype[verbKey] || 0) + 1
}
}
reportProgress()
if (!page.hasMore) break
offset += page.items.length
}
const recommendation = missingSubtype === 0
? 'No subtype gaps detected — this brain is strict-mode-ready.'
: 'Found ' + missingSubtype + ' entries without subtype. Migrate via `brain.migrateField()` (7.x) — or wait for `brain.fillSubtypes()` (8.0) which closes the same gap with caller-supplied rules.'
return {
entitiesWithoutSubtype,
relationshipsWithoutSubtype,
total: missingSubtype,
scanned,
recommendation
}
}
/** /**
* Stream-and-rewrite a field across every entity in the brain. * Stream-and-rewrite a field across every entity in the brain.
* *

View file

@ -21,6 +21,13 @@ interface AddOptions extends CoreOptions {
id?: string id?: string
metadata?: string metadata?: string
type?: string type?: string
/**
* Sub-classification within the noun type. Defaults to `'cli-add'` when not
* supplied so the CLI works against strict-mode brains; production-style
* ingestion should pass `--subtype` explicitly to use the consumer's
* registered vocabulary. Added 7.30.1.
*/
subtype?: string
confidence?: string confidence?: string
weight?: string weight?: string
} }
@ -51,6 +58,12 @@ interface GetOptions extends CoreOptions {
interface RelateOptions extends CoreOptions { interface RelateOptions extends CoreOptions {
weight?: string weight?: string
metadata?: string metadata?: string
/**
* Sub-classification within the verb type. Defaults to `'cli-relate'` when
* not supplied so the CLI works against strict-mode brains; production-style
* ingestion should pass `--subtype` explicitly. Added 7.30.1.
*/
subtype?: string
} }
interface ExportOptions extends CoreOptions { interface ExportOptions extends CoreOptions {
@ -153,10 +166,15 @@ export const coreCommands = {
spinner.text = `No type specified, using default: ${nounType}` spinner.text = `No type specified, using default: ${nounType}`
} }
// Add with explicit type // Add with explicit type. Subtype precedence: caller-supplied
// `--subtype <value>` → Brainy default `'cli-add'`. The default ensures
// CLI invocations against a strict-mode brain succeed without the user
// needing to know the vocabulary in advance — for production-style
// ingestion, pass `--subtype` explicitly (added 7.30.1).
const addParams: any = { const addParams: any = {
data: text, data: text,
type: nounType, type: nounType,
subtype: options.subtype ?? 'cli-add',
metadata metadata
} }
@ -589,11 +607,15 @@ export const coreCommands = {
metadata.weight = parseFloat(options.weight) metadata.weight = parseFloat(options.weight)
} }
// Create the relationship // Create the relationship. Subtype precedence: caller-supplied
// `--subtype <value>` → Brainy default `'cli-relate'`. Same rationale
// as `brainy add` — guarantees CLI works under strict-mode brains
// (added 7.30.1).
const result = await brain.relate({ const result = await brain.relate({
from: source, from: source,
to: target, to: target,
type: verb as any, type: verb as any,
subtype: options.subtype ?? 'cli-relate',
metadata metadata
}) })

View file

@ -93,6 +93,9 @@ program
.option('-i, --id <id>', 'Specify custom ID') .option('-i, --id <id>', 'Specify custom ID')
.option('-m, --metadata <json>', 'Add metadata') .option('-m, --metadata <json>', 'Add metadata')
.option('-t, --type <type>', 'Specify noun type') .option('-t, --type <type>', 'Specify noun type')
.option('-s, --subtype <subtype>', 'Specify sub-classification within the noun type (e.g. employee, customer, invoice). Required under strict-mode brains; defaults to "cli-add" otherwise.')
.option('--confidence <number>', 'Type classification confidence (0-1)')
.option('--weight <number>', 'Entity importance/salience (0-1)')
.action(coreCommands.add) .action(coreCommands.add)
program program
@ -132,6 +135,7 @@ program
.description('Create a relationship between items (interactive if parameters missing)') .description('Create a relationship between items (interactive if parameters missing)')
.option('-w, --weight <number>', 'Relationship weight') .option('-w, --weight <number>', 'Relationship weight')
.option('-m, --metadata <json>', 'Relationship metadata') .option('-m, --metadata <json>', 'Relationship metadata')
.option('-s, --subtype <subtype>', 'Specify sub-classification within the verb type (e.g. direct, dotted-line). Required under strict-mode brains; defaults to "cli-relate" otherwise.')
.action(coreCommands.relate) .action(coreCommands.relate)
program program

View file

@ -215,10 +215,13 @@ export class EntityDeduplicator {
return await this.mergeEntity(duplicate.existingId, candidate, importSource) return await this.mergeEntity(duplicate.existingId, candidate, importSource)
} }
// No duplicate found, create new entity // No duplicate found, create new entity. Preserve any subtype the candidate
// carried (set by the extractor or upstream importer), else fall back to
// `'imported'` so enforcement doesn't fire (added 7.30.1).
const entityId = await this.brain.add({ const entityId = await this.brain.add({
data: candidate.description || candidate.name, data: candidate.description || candidate.name,
type: candidate.type, type: candidate.type,
subtype: (candidate as any).subtype ?? 'imported',
metadata: { metadata: {
...candidate.metadata, ...candidate.metadata,
name: candidate.name, name: candidate.name,

View file

@ -143,6 +143,23 @@ export interface ValidImportOptions {
*/ */
customMetadata?: Record<string, any> customMetadata?: Record<string, any>
/**
* Default subtype for imported entities when the extractor doesn't set one.
*
* The importer resolves subtype in this precedence order:
*
* 1. Extractor-set subtype on the extracted entity (highest priority the
* extractor knows the entity's true sub-classification).
* 2. `defaultSubtype` from this option (caller's choice useful for tagging
* a whole import batch, e.g. `'customer-upload-2026q2'`).
* 3. Brainy-default `'imported'` (lowest priority safety net so enforcement
* doesn't fire on entities the consumer forgot to classify).
*
* Added 7.30.1 so importers behave correctly under brain-wide strict mode and
* SDK_CORE_VOCABULARY-style enforcement consumers register.
*/
defaultSubtype?: string
/** /**
* Progress callback for tracking import progress * Progress callback for tracking import progress
* *
@ -946,9 +963,14 @@ export class ImportCoordinator {
if (sourceInfo && options.createProvenanceLinks !== false) { if (sourceInfo && options.createProvenanceLinks !== false) {
console.log(`📄 Creating document entity for import source: ${sourceInfo.sourceFilename}`) console.log(`📄 Creating document entity for import source: ${sourceInfo.sourceFilename}`)
// Subtype `import-source` distinguishes the synthetic Document entity that
// represents the import operation itself (the file being imported) from
// entities extracted from its contents. Also satisfies enforcement when a
// consumer registers a vocabulary on NounType.Document (added 7.30.1).
documentEntityId = await this.brain.add({ documentEntityId = await this.brain.add({
data: sourceInfo.sourceFilename, data: sourceInfo.sourceFilename,
type: NounType.Document, type: NounType.Document,
subtype: 'import-source',
metadata: { metadata: {
name: sourceInfo.sourceFilename, name: sourceInfo.sourceFilename,
sourceFile: sourceInfo.sourceFilename, sourceFile: sourceInfo.sourceFilename,
@ -981,7 +1003,9 @@ export class ImportCoordinator {
// FAST PATH: Batch creation without deduplication (recommended for imports > 100 entities) // FAST PATH: Batch creation without deduplication (recommended for imports > 100 entities)
const importSource = vfsResult.rootPath const importSource = vfsResult.rootPath
// Prepare all entity parameters upfront // Prepare all entity parameters upfront. Mirror the subtype resolution from
// the deduplication path above: preserve extractor-set subtype if any, else
// fall back to caller-supplied default, else `'imported'` (added 7.30.1).
const entityParams = rows.map((row: any) => { const entityParams = rows.map((row: any) => {
const entity = row.entity || row const entity = row.entity || row
const vfsFile = vfsResult.files.find((f: any) => f.entityId === entity.id) const vfsFile = vfsResult.files.find((f: any) => f.entityId === entity.id)
@ -989,6 +1013,7 @@ export class ImportCoordinator {
return { return {
data: entity.description || entity.name, data: entity.description || entity.name,
type: entity.type, type: entity.type,
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
metadata: { metadata: {
...entity.metadata, ...entity.metadata,
name: entity.name, name: entity.name,
@ -1091,10 +1116,14 @@ export class ImportCoordinator {
let entityId: string let entityId: string
// No deduplication during import (12-24x speedup) // No deduplication during import (12-24x speedup)
// Background deduplication runs 5 minutes after import completes // Background deduplication runs 5 minutes after import completes.
// Preserves any subtype the extractor already set on the entity; falls back
// to the caller-supplied `options.defaultSubtype` or to the Brainy-default
// `'imported'` so enforcement doesn't fire (added 7.30.1).
entityId = await this.brain.add({ entityId = await this.brain.add({
data: entity.description || entity.name, data: entity.description || entity.name,
type: entity.type, type: entity.type,
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
metadata: { metadata: {
...entity.metadata, ...entity.metadata,
name: entity.name, name: entity.name,
@ -1186,11 +1215,15 @@ export class ImportCoordinator {
} }
// STEP 3: If still not found, create placeholder entity ONCE // STEP 3: If still not found, create placeholder entity ONCE
// The placeholder is added to entities array, so future searches will find it // The placeholder is added to entities array, so future searches will find it.
// Subtype `import-placeholder` marks these as synthetic targets (not real
// imports) so downstream queries can distinguish them and dedup runs can
// safely consolidate them with real entities later (added 7.30.1).
if (!targetEntityId) { if (!targetEntityId) {
targetEntityId = await this.brain.add({ targetEntityId = await this.brain.add({
data: rel.to, data: rel.to,
type: NounType.Thing, type: NounType.Thing,
subtype: 'import-placeholder',
metadata: { metadata: {
name: rel.to, name: rel.to,
placeholder: true, placeholder: true,

View file

@ -37,6 +37,14 @@ export interface SmartImportOptions extends SmartExcelOptions {
/** Source filename */ /** Source filename */
filename?: string filename?: string
/**
* Default subtype tag for entities + relationships this importer creates when
* the extractor doesn't set one. See `ValidImportOptions.defaultSubtype`
* same semantics, same precedence (extractor > caller default > `'imported'`).
* Added 7.30.1.
*/
defaultSubtype?: string
} }
export interface SmartImportProgress { export interface SmartImportProgress {
@ -180,10 +188,12 @@ export class SmartImportOrchestrator {
const extracted = result.extraction.rows[i] const extracted = result.extraction.rows[i]
try { try {
// Create main entity // Create main entity. Subtype precedence: extractor-set → caller default
// → Brainy default `'imported'` (added 7.30.1).
const entityId = await this.brain.add({ const entityId = await this.brain.add({
data: extracted.entity.description, data: extracted.entity.description,
type: extracted.entity.type, type: extracted.entity.type,
subtype: (extracted.entity as any).subtype ?? options.defaultSubtype ?? 'imported',
metadata: { metadata: {
...extracted.entity.metadata, ...extracted.entity.metadata,
name: extracted.entity.name, name: extracted.entity.name,
@ -230,7 +240,7 @@ export class SmartImportOrchestrator {
} }
// Collect all relationship parameters // Collect all relationship parameters
const relationshipParams: Array<{from: string; to: string; type: VerbType; metadata?: any}> = [] const relationshipParams: Array<{from: string; to: string; type: VerbType; subtype?: string; metadata?: any}> = []
for (const extracted of result.extraction.rows) { for (const extracted of result.extraction.rows) {
for (const rel of extracted.relationships) { for (const rel of extracted.relationships) {
@ -247,11 +257,14 @@ export class SmartImportOrchestrator {
} }
} }
// If not found, create a placeholder entity // If not found, create a placeholder entity. `import-placeholder` marks
// these as synthetic targets so consumers can distinguish them from real
// imports and downstream dedup can consolidate (added 7.30.1).
if (!toEntityId) { if (!toEntityId) {
toEntityId = await this.brain.add({ toEntityId = await this.brain.add({
data: rel.to, data: rel.to,
type: NounType.Thing, type: NounType.Thing,
subtype: 'import-placeholder',
metadata: { metadata: {
name: rel.to, name: rel.to,
placeholder: true, placeholder: true,
@ -261,11 +274,13 @@ export class SmartImportOrchestrator {
result.entityIds.push(toEntityId) result.entityIds.push(toEntityId)
} }
// Collect relationship parameter // Collect relationship parameter. Subtype precedence: extractor-set rel
// subtype → caller default → Brainy default `'imported'` (added 7.30.1).
relationshipParams.push({ relationshipParams.push({
from: extracted.entity.id, from: extracted.entity.id,
to: toEntityId, to: toEntityId,
type: rel.type, type: rel.type,
subtype: (rel as any).subtype ?? options.defaultSubtype ?? 'imported',
metadata: { metadata: {
confidence: rel.confidence, confidence: rel.confidence,
evidence: rel.evidence evidence: rel.evidence
@ -582,9 +597,11 @@ export class SmartImportOrchestrator {
for (let i = 0; i < result.extraction.rows.length; i++) { for (let i = 0; i < result.extraction.rows.length; i++) {
const extracted = result.extraction.rows[i] const extracted = result.extraction.rows[i]
try { try {
// Subtype precedence: extractor → caller default → `'imported'` (7.30.1).
const entityId = await this.brain.add({ const entityId = await this.brain.add({
data: extracted.entity.description, data: extracted.entity.description,
type: extracted.entity.type, type: extracted.entity.type,
subtype: (extracted.entity as any).subtype ?? options.defaultSubtype ?? 'imported',
metadata: { ...extracted.entity.metadata, name: extracted.entity.name, confidence: extracted.entity.confidence, importedFrom: 'smart-import' } metadata: { ...extracted.entity.metadata, name: extracted.entity.name, confidence: extracted.entity.confidence, importedFrom: 'smart-import' }
}) })
result.entityIds.push(entityId) result.entityIds.push(entityId)
@ -600,7 +617,7 @@ export class SmartImportOrchestrator {
onProgress?.({ phase: 'creating', message: 'Preparing relationships...', processed: 0, total: result.extraction.rows.length, entities: result.entityIds.length, relationships: 0 }) onProgress?.({ phase: 'creating', message: 'Preparing relationships...', processed: 0, total: result.extraction.rows.length, entities: result.entityIds.length, relationships: 0 })
// Collect all relationship parameters // Collect all relationship parameters
const relationshipParams: Array<{from: string; to: string; type: VerbType; metadata?: any}> = [] const relationshipParams: Array<{from: string; to: string; type: VerbType; subtype?: string; metadata?: any}> = []
for (const extracted of result.extraction.rows) { for (const extracted of result.extraction.rows) {
for (const rel of extracted.relationships) { for (const rel of extracted.relationships) {
@ -613,10 +630,12 @@ export class SmartImportOrchestrator {
} }
} }
if (!toEntityId) { if (!toEntityId) {
toEntityId = await this.brain.add({ data: rel.to, type: NounType.Thing, metadata: { name: rel.to, placeholder: true, extractedFrom: extracted.entity.name } }) // Subtype `import-placeholder` marks synthetic targets (7.30.1).
toEntityId = await this.brain.add({ data: rel.to, type: NounType.Thing, subtype: 'import-placeholder', metadata: { name: rel.to, placeholder: true, extractedFrom: extracted.entity.name } })
result.entityIds.push(toEntityId) result.entityIds.push(toEntityId)
} }
relationshipParams.push({ from: extracted.entity.id, to: toEntityId, type: rel.type, metadata: { confidence: rel.confidence, evidence: rel.evidence } }) // Relationship subtype precedence: extractor → caller default → `'imported'` (7.30.1).
relationshipParams.push({ from: extracted.entity.id, to: toEntityId, type: rel.type, subtype: (rel as any).subtype ?? options.defaultSubtype ?? 'imported', metadata: { confidence: rel.confidence, evidence: rel.evidence } })
} catch (error: any) { } catch (error: any) {
result.errors.push(`Failed to prepare relationship: ${error.message}`) result.errors.push(`Failed to prepare relationship: ${error.message}`)
} }

View file

@ -448,8 +448,12 @@ export class ODataIntegration extends IntegrationBase implements HTTPIntegration
return this.errorResponse(400, 'Missing required field: Type') return this.errorResponse(400, 'Missing required field: Type')
} }
// Honor caller-supplied `Subtype` from the OData request; fall back to the
// integration-default `'imported-from-odata'` so enforcement consumers
// don't get rejected on OData-driven writes (added 7.30.1).
const entity = await this.context.brain.add({ const entity = await this.context.brain.add({
type: body.Type as NounType, type: body.Type as NounType,
subtype: (body.Subtype as string | undefined) ?? 'imported-from-odata',
data: body.Data ? JSON.parse(body.Data) : undefined, data: body.Data ? JSON.parse(body.Data) : undefined,
metadata: this.extractMetadata(body), metadata: this.extractMetadata(body),
confidence: body.Confidence, confidence: body.Confidence,
@ -616,10 +620,12 @@ export class ODataIntegration extends IntegrationBase implements HTTPIntegration
) )
} }
// Same subtype precedence as entity writes (added 7.30.1).
const relation = await this.context.brain.relate({ const relation = await this.context.brain.relate({
from: body.FromId, from: body.FromId,
to: body.ToId, to: body.ToId,
type: body.Type, type: body.Type,
subtype: (body.Subtype as string | undefined) ?? 'imported-from-odata',
weight: body.Weight, weight: body.Weight,
confidence: body.Confidence, confidence: body.Confidence,
metadata: body.Metadata ? JSON.parse(body.Metadata) : undefined, metadata: body.Metadata ? JSON.parse(body.Metadata) : undefined,

View file

@ -500,8 +500,12 @@ export class GoogleSheetsIntegration
return this.errorResponse(400, 'Missing required field: type') return this.errorResponse(400, 'Missing required field: type')
} }
// Honor caller-supplied `subtype` from the Sheets request; fall back to
// the integration-default `'imported-from-sheets'` so enforcement
// consumers don't get rejected on Sheets-driven writes (added 7.30.1).
const entity = await this.context.brain.add({ const entity = await this.context.brain.add({
type: body.type as NounType, type: body.type as NounType,
subtype: (body.subtype as string | undefined) ?? 'imported-from-sheets',
data: body.data, data: body.data,
metadata: body.metadata, metadata: body.metadata,
confidence: body.confidence, confidence: body.confidence,
@ -570,8 +574,10 @@ export class GoogleSheetsIntegration
try { try {
switch (op.action) { switch (op.action) {
case 'add': case 'add':
// Same subtype-precedence as the single-entity handler (7.30.1).
const added = await this.context.brain.add({ const added = await this.context.brain.add({
type: op.type as NounType, type: op.type as NounType,
subtype: (op.subtype as string | undefined) ?? 'imported-from-sheets',
data: op.data, data: op.data,
metadata: op.metadata metadata: op.metadata
}) })
@ -628,10 +634,13 @@ export class GoogleSheetsIntegration
return this.errorResponse(400, 'Missing required fields: from, to, type') return this.errorResponse(400, 'Missing required fields: from, to, type')
} }
// Same subtype precedence as entity writes: caller-supplied → default
// `'imported-from-sheets'` (added 7.30.1).
const relation = await this.context.brain.relate({ const relation = await this.context.brain.relate({
from: body.from, from: body.from,
to: body.to, to: body.to,
type: body.type as VerbType, type: body.type as VerbType,
subtype: (body.subtype as string | undefined) ?? 'imported-from-sheets',
weight: body.weight, weight: body.weight,
metadata: body.metadata metadata: body.metadata
}) })

View file

@ -7,6 +7,7 @@
import WebSocket from 'ws' import WebSocket from 'ws'
import { Brainy } from '../brainy.js' import { Brainy } from '../brainy.js'
import { NounType } from '../types/graphTypes.js'
import { v4 as uuidv4 } from '../universal/uuid.js' import { v4 as uuidv4 } from '../universal/uuid.js'
interface ClientOptions { interface ClientOptions {
@ -119,19 +120,25 @@ export class BrainyMCPClient {
* Handle incoming message * Handle incoming message
*/ */
private async handleMessage(message: Message) { private async handleMessage(message: Message) {
// Store in Brainy for persistent memory // Store in Brainy for persistent memory. Subtype `'mcp-message'` marks
// these as MCP-protocol messages so consumers can filter / count them via
// `find({ type: NounType.Message, subtype: 'mcp-message' })` and so
// enforcement consumers registering a vocabulary on NounType.Message don't
// reject MCP traffic (added 7.30.1; also fixes the pre-existing missing
// `data` field by aliasing from the prior `text` field).
if (this.brainy && message.type === 'message') { if (this.brainy && message.type === 'message') {
try { try {
await this.brainy.add({ await this.brainy.add({
text: `${message.from}: ${JSON.stringify(message.data)}`, data: `${message.from}: ${JSON.stringify(message.data)}`,
type: NounType.Message,
subtype: 'mcp-message',
metadata: { metadata: {
messageId: message.id, messageId: message.id,
from: message.from, from: message.from,
to: message.to, to: message.to,
timestamp: message.timestamp, timestamp: message.timestamp,
type: message.type, messageType: message.type,
event: message.event, event: message.event
category: 'Message'
} }
}) })
} catch (error) { } catch (error) {
@ -142,15 +149,16 @@ export class BrainyMCPClient {
// Handle sync messages (receive history) // Handle sync messages (receive history)
if (message.type === 'sync' && message.data.history) { if (message.type === 'sync' && message.data.history) {
console.log(`📜 ${this.options.name} received ${message.data.history.length} historical messages`) console.log(`📜 ${this.options.name} received ${message.data.history.length} historical messages`)
// Store history in Brainy // Store history in Brainy with the same subtype as live messages.
if (this.brainy) { if (this.brainy) {
for (const histMsg of message.data.history) { for (const histMsg of message.data.history) {
await this.brainy.add({ await this.brainy.add({
text: `${histMsg.from}: ${JSON.stringify(histMsg.data)}`, data: `${histMsg.from}: ${JSON.stringify(histMsg.data)}`,
type: NounType.Message,
subtype: 'mcp-message',
metadata: { metadata: {
...histMsg, ...histMsg
category: 'Message'
} }
}) })
} }

View file

@ -77,6 +77,14 @@ export interface NeuralImportOptions {
validateOnly: boolean validateOnly: boolean
categoryFilter?: string[] categoryFilter?: string[]
skipDuplicates: boolean skipDuplicates: boolean
/**
* Default subtype tag for entities + relationships when the neural extractor
* doesn't set one. Precedence: extractor this default Brainy default
* `'extracted'`. Use this to tag a whole neural pass (e.g.
* `'extracted-from-uploads'`) so consumers can query its output later.
* Added 7.30.1.
*/
defaultSubtype?: string
} }
/** /**
@ -776,11 +784,14 @@ export class NeuralImport {
const spinner = ora(`${this.emojis.gear} Executing neural import...`).start() const spinner = ora(`${this.emojis.gear} Executing neural import...`).start()
try { try {
// Add entities to Brainy // Add entities to Brainy. Subtype precedence: extractor-set → caller's
// `options.defaultSubtype` → Brainy default `'extracted'` so enforcement
// consumers don't get rejected on neural-extraction writes (added 7.30.1).
for (const entity of result.detectedEntities) { for (const entity of result.detectedEntities) {
await this.brainy.add({ await this.brainy.add({
data: this.extractMainText(entity.originalData), data: this.extractMainText(entity.originalData),
type: entity.nounType as NounType, type: entity.nounType as NounType,
subtype: (entity as any).subtype ?? options.defaultSubtype ?? 'extracted',
metadata: { metadata: {
...entity.originalData, ...entity.originalData,
confidence: entity.confidence, confidence: entity.confidence,
@ -789,12 +800,13 @@ export class NeuralImport {
}) })
} }
// Add relationships to Brainy // Add relationships to Brainy. Same subtype precedence as the entity side.
for (const relationship of result.detectedRelationships) { for (const relationship of result.detectedRelationships) {
await this.brainy.relate({ await this.brainy.relate({
from: relationship.sourceId, from: relationship.sourceId,
to: relationship.targetId, to: relationship.targetId,
type: relationship.verbType as VerbType, type: relationship.verbType as VerbType,
subtype: (relationship as any).subtype ?? options.defaultSubtype ?? 'extracted',
weight: relationship.weight, weight: relationship.weight,
metadata: { metadata: {
confidence: relationship.confidence, confidence: relationship.confidence,

View file

@ -1479,10 +1479,14 @@ export abstract class BaseStorage extends BaseStorageAdapter {
} }
} }
// Combine noun + metadata // Combine noun + metadata. Subtype is surfaced to top-level here
// (mirrors what `getNoun()` already does) so callers — including
// the 7.30.1 `brain.audit()` diagnostic — see a consistent shape
// regardless of which getter path they reach.
collectedNouns.push({ collectedNouns.push({
...deserialized, ...deserialized,
type: (metadata.noun || 'thing') as NounType, type: (metadata.noun || 'thing') as NounType,
subtype: (metadata as any).subtype as string | undefined,
confidence: metadata.confidence, confidence: metadata.confidence,
weight: metadata.weight, weight: metadata.weight,
createdAt: metadata.createdAt createdAt: metadata.createdAt

View file

@ -501,6 +501,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
from: parentId, from: parentId,
to: existingId, to: existingId,
type: VerbType.Contains, type: VerbType.Contains,
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
metadata: { isVFS: true } // Mark as VFS relationship metadata: { isVFS: true } // Mark as VFS relationship
}) })
} }
@ -903,6 +904,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
from: parentId, from: parentId,
to: entity, to: entity,
type: VerbType.Contains, type: VerbType.Contains,
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
metadata: { metadata: {
isVFS: true, // Mark as VFS relationship isVFS: true, // Mark as VFS relationship
relationshipType: 'vfs' // Standardized relationship type metadata relationshipType: 'vfs' // Standardized relationship type metadata
@ -1692,6 +1694,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
from: newParentId, from: newParentId,
to: entityId, to: entityId,
type: VerbType.Contains, type: VerbType.Contains,
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
metadata: { isVFS: true } // Mark as VFS relationship metadata: { isVFS: true } // Mark as VFS relationship
}) })
} }
@ -1747,9 +1750,13 @@ export class VirtualFileSystem implements IVirtualFileSystem {
} }
private async copyFile(srcEntity: Entity, destPath: string, options?: CopyOptions): Promise<void> { private async copyFile(srcEntity: Entity, destPath: string, options?: CopyOptions): Promise<void> {
// Create new entity with same content but different path // Create new entity with same content but different path. Preserve the source
// entity's subtype when it has one (so a vfs-file stays vfs-file); fall back
// to 'vfs-file' for the rare case of a VFS entity without subtype (pre-7.30
// legacy data path that hits the copy operation).
const newEntity = await this.brain.add({ const newEntity = await this.brain.add({
type: srcEntity.type, type: srcEntity.type,
subtype: (srcEntity as any).subtype ?? 'vfs-file',
data: srcEntity.data, data: srcEntity.data,
vector: options?.preserveVector ? srcEntity.vector : undefined, vector: options?.preserveVector ? srcEntity.vector : undefined,
metadata: { metadata: {
@ -1770,6 +1777,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
from: parentId, from: parentId,
to: newEntity, to: newEntity,
type: VerbType.Contains, type: VerbType.Contains,
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
metadata: { isVFS: true } // Mark as VFS relationship metadata: { isVFS: true } // Mark as VFS relationship
}) })
} }
@ -1884,6 +1892,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
from: parentId, from: parentId,
to: result.successful[i], to: result.successful[i],
type: VerbType.Contains, type: VerbType.Contains,
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
metadata: { isVFS: true } metadata: { isVFS: true }
} }
}) })
@ -1939,6 +1948,8 @@ export class VirtualFileSystem implements IVirtualFileSystem {
name, name,
parent: parentId, parent: parentId,
vfsType: 'symlink', vfsType: 'symlink',
isVFS: true, // Infrastructure-bypass marker for strict-mode enforcement
isVFSEntity: true,
symlinkTarget: target, symlinkTarget: target,
size: 0, size: 0,
permissions: 0o777, permissions: 0o777,
@ -1951,6 +1962,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
const entity = await this.brain.add({ const entity = await this.brain.add({
data: `symlink:${target}`, data: `symlink:${target}`,
type: NounType.File, // Symlinks are special files type: NounType.File, // Symlinks are special files
subtype: 'vfs-symlink', // Distinct from 'vfs-file' so consumers can find symlinks (7.30.1+)
metadata metadata
}) })
@ -1959,6 +1971,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
from: parentId, from: parentId,
to: entity, to: entity,
type: VerbType.Contains, type: VerbType.Contains,
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
metadata: { isVFS: true } // Mark as VFS relationship metadata: { isVFS: true } // Mark as VFS relationship
}) })

View file

@ -0,0 +1,320 @@
/**
* @module tests/integration/strict-mode-self-test
* @description Brainy's OWN internal write paths must work under brain-wide
* strict mode AND under the SDK_CORE_VOCABULARY shape that the SDK 3.20.0
* registers on every consumer's brain. This suite is the canary that detects
* any internal path which silently omits subtype.
*
* The SDK_CORE_VOCABULARY shape that surfaced Venue's `/book` 500 (2026-06-08)
* registers `brain.requireSubtype()` rules on six NounTypes:
* - NounType.Event
* - NounType.Collection
* - NounType.Message
* - NounType.Contract
* - NounType.Media
* - NounType.Document
*
* If Brainy's own VFS / aggregation / extraction / importer / integration /
* MCP paths skip subtype anywhere, the enforcement hook fires and Brainy itself
* starts rejecting its own infrastructure writes. This test exercises every
* such path under the exact shape Venue hit + brain-wide strict mode, and
* asserts: zero rejections.
*
* @since 7.30.1
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy'
import { NounType, VerbType } from '../../src/types/graphTypes'
describe('Brainy strict-mode self-test (7.30.1)', () => {
let brain: Brainy<any>
beforeEach(async () => {
// Brain-wide strict mode ON + the exact SDK_CORE_VOCABULARY shape that
// Venue hit. If any internal Brainy path skips subtype, the writes here
// will throw and fail the test.
brain = new Brainy({
storage: { type: 'memory' },
silent: true,
requireSubtype: true
})
await brain.init()
// Register per-type rules with values whitelists so we catch both the
// "missing subtype" failure mode AND any path that sets the wrong subtype.
brain.requireSubtype(NounType.Event, {
values: ['booking', 'session', 'milestone', 'imported', 'extracted'],
required: true
})
brain.requireSubtype(NounType.Collection, {
values: ['vfs-root', 'vfs-directory', 'workbench', 'imported', 'extracted'],
required: true
})
brain.requireSubtype(NounType.Message, {
values: ['mcp-message', 'imported', 'extracted'],
required: true
})
brain.requireSubtype(NounType.Contract, {
values: ['imported', 'extracted'],
required: true
})
brain.requireSubtype(NounType.Media, {
values: ['imported', 'extracted', 'vfs-file'],
required: true
})
brain.requireSubtype(NounType.Document, {
values: ['import-source', 'imported', 'extracted', 'vfs-file'],
required: true
})
})
afterEach(async () => {
await brain.close()
})
describe('VFS operations under strict mode', () => {
it('VFS root creation succeeds', async () => {
// Root creation happens during brain.init() — if it failed, beforeEach
// would have thrown. Confirm the root entity exists with the right subtype.
const root = await brain.get('00000000-0000-0000-0000-000000000000')
expect(root).not.toBeNull()
expect(root!.subtype).toBe('vfs-root')
})
it('mkdir creates a vfs-directory Collection', async () => {
await brain.vfs.mkdir('/projects')
const found = await brain.find({
type: NounType.Collection,
subtype: 'vfs-directory'
})
expect(found.length).toBeGreaterThan(0)
})
it('writeFile creates a vfs-file entity + vfs-contains edge', async () => {
await brain.vfs.mkdir('/notes')
await brain.vfs.writeFile('/notes/hello.txt', 'hello world')
// The file's NounType is mime-driven, but its subtype is 'vfs-file'
const files = await brain.find({ subtype: 'vfs-file' })
expect(files.length).toBeGreaterThan(0)
// And the Contains edge carries 'vfs-contains'
const containsCount = brain.counts.byRelationshipSubtype(
VerbType.Contains,
'vfs-contains'
)
expect(containsCount).toBeGreaterThan(0)
})
it('copy preserves source subtype and creates vfs-contains edge', async () => {
await brain.vfs.mkdir('/src-dir')
await brain.vfs.mkdir('/dst-dir')
await brain.vfs.writeFile('/src-dir/a.txt', 'a')
await brain.vfs.copy('/src-dir/a.txt', '/dst-dir/a.txt')
const files = await brain.find({ subtype: 'vfs-file' })
// Both source and destination should be vfs-file
expect(files.length).toBeGreaterThanOrEqual(2)
})
it('move re-parents with a vfs-contains edge on the new parent', async () => {
await brain.vfs.mkdir('/from')
await brain.vfs.mkdir('/to')
await brain.vfs.writeFile('/from/m.txt', 'move me')
await brain.vfs.move('/from/m.txt', '/to/m.txt')
// Both old + new Contains edges exist with vfs-contains subtype
const containsEdges = brain.counts.byRelationshipSubtype(
VerbType.Contains,
'vfs-contains'
)
expect(containsEdges).toBeGreaterThan(0)
})
it('symlink creates a vfs-symlink entity (distinct from vfs-file)', async () => {
await brain.vfs.mkdir('/links')
await brain.vfs.writeFile('/links/target.txt', 'target')
// Register vfs-symlink in the File vocabulary since this is a brain-wide
// strict mode test (symlinks use NounType.File which isn't in the
// SDK_CORE_VOCABULARY by default, but brain-wide strict still applies).
brain.requireSubtype(NounType.File, {
values: ['vfs-file', 'vfs-symlink'],
required: true
})
await brain.vfs.symlink('/links/target.txt', '/links/alias.txt')
const symlinks = await brain.find({ subtype: 'vfs-symlink' })
expect(symlinks.length).toBeGreaterThan(0)
})
})
describe('Aggregation engine under strict mode', () => {
it('defining an aggregate + adding source entities does not reject under strict mode', async () => {
// Register Measurement's vocabulary in case the materializer fires
brain.requireSubtype(NounType.Measurement, {
values: ['materialized-aggregate'],
required: true
})
// Define an aggregate with materialize:true. The materializer code path
// in `aggregation/materializer.ts` is labeled `'materialized-aggregate'`
// so when it IS wired (8.0+), emitted Measurement entities pass
// enforcement automatically. The self-test guarantees no part of the
// aggregate-engine code path throws under strict mode.
expect(() => {
brain.defineAggregate({
name: 'session-count-by-room',
source: { type: NounType.Event },
groupBy: ['room'],
metrics: { sessions: { op: 'count' } },
materialize: { debounceMs: 0 }
})
}).not.toThrow()
// Generate Events to populate the aggregate's running totals — these
// writes go through `add()` which routes through the strict-mode check.
await expect(brain.add({
type: NounType.Event,
subtype: 'session',
data: 'session A',
metadata: { room: 'r1' }
})).resolves.toBeDefined()
await expect(brain.add({
type: NounType.Event,
subtype: 'session',
data: 'session B',
metadata: { room: 'r1' }
})).resolves.toBeDefined()
// queryAggregate is the live read path — confirm it works under strict mode
const rows = await brain.queryAggregate('session-count-by-room')
expect(rows.length).toBeGreaterThan(0)
})
})
describe('brain.audit() diagnostic', () => {
it('returns a 0-gap report on a brain with all paths labeled', async () => {
// Exercise a representative cross-section of internal paths to populate
// the brain with infrastructure entities
await brain.vfs.mkdir('/audit-test')
await brain.vfs.writeFile('/audit-test/file.txt', 'content')
const report = await brain.audit()
// VFS entities are excluded by default — and none of our other writes
// should have skipped subtype either.
expect(report.total).toBe(0)
expect(report.entitiesWithoutSubtype).toEqual({})
expect(report.relationshipsWithoutSubtype).toEqual({})
expect(report.recommendation).toMatch(/strict-mode-ready/)
})
it('flags entities written without subtype (using includeVFS: true to surface even VFS gaps)', async () => {
// Switch to a non-strict brain so we can deliberately create a gap to test detection
await brain.close()
brain = new Brainy({ storage: { type: 'memory' }, silent: true })
await brain.init()
// Deliberately add an entity without a subtype — only allowed because
// we're not in strict mode for this assertion
await brain.add({
type: NounType.Person,
data: 'no subtype here'
})
const report = await brain.audit()
expect(report.total).toBeGreaterThanOrEqual(1)
expect(report.entitiesWithoutSubtype['person']).toBe(1)
expect(report.recommendation).toMatch(/Migrate via/)
})
it('excludes VFS entities by default (they bypass enforcement anyway)', async () => {
await brain.close()
brain = new Brainy({ storage: { type: 'memory' }, silent: true })
await brain.init()
// VFS root + VFS directories all have subtype set, but even if they
// didn't, the audit would exclude them by default because they carry
// isVFSEntity / isVFS markers.
await brain.vfs.mkdir('/vfs-only')
const defaultReport = await brain.audit()
// No non-VFS entities exist, so the default report is empty
expect(defaultReport.total).toBe(0)
// includeVFS: true surfaces them; should still be 0 because they're all
// properly labeled
const includedReport = await brain.audit({ includeVFS: true })
expect(includedReport.total).toBe(0)
})
})
describe('Error message UX', () => {
it('error message includes caller location and migration link', async () => {
await brain.close()
brain = new Brainy({ storage: { type: 'memory' }, silent: true })
await brain.init()
brain.requireSubtype(NounType.Person, {
values: ['employee', 'customer'],
required: true
})
try {
await brain.add({ type: NounType.Person, data: 'no subtype' })
throw new Error('expected enforcement to fire')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Diagnose
expect(msg).toMatch(/NounType\.person/)
expect(msg).toMatch(/requires subtype/)
// Vocabulary guidance
expect(msg).toMatch(/Pass one of:.*employee.*customer/)
// Documentation link
expect(msg).toMatch(/Migration recipe.*subtypes-and-facets/)
}
})
it('off-vocabulary message shows the offending value + allowed vocab', async () => {
await brain.close()
brain = new Brainy({ storage: { type: 'memory' }, silent: true })
await brain.init()
brain.requireSubtype(NounType.Person, {
values: ['employee', 'customer'],
required: true
})
try {
await brain.add({
type: NounType.Person,
subtype: 'vendor',
data: 'wrong vocab'
})
throw new Error('expected enforcement to fire')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
expect(msg).toMatch(/subtype 'vendor' is not in registered vocabulary/)
expect(msg).toMatch(/employee.*customer|customer.*employee/)
}
})
it('brain-wide strict mode (no per-type vocab) shows the correct guidance', async () => {
await brain.close()
brain = new Brainy({
storage: { type: 'memory' },
silent: true,
requireSubtype: true
})
await brain.init()
try {
await brain.add({ type: NounType.Person, data: 'missing subtype' })
throw new Error('expected enforcement to fire')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
expect(msg).toMatch(/Brain-wide strict mode/)
expect(msg).toMatch(/requireSubtype\.except/)
}
})
})
})