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:
parent
a82c3339df
commit
5f3a2ca7d5
18 changed files with 999 additions and 37 deletions
|
|
@ -23,6 +23,8 @@ export interface MaterializerBrainAccess {
|
|||
add(params: {
|
||||
data: string
|
||||
type: NounType
|
||||
/** Sub-classification of the materialized entity (7.30.1+). */
|
||||
subtype?: string
|
||||
metadata: Record<string, unknown>
|
||||
id?: string
|
||||
service?: string
|
||||
|
|
@ -31,6 +33,7 @@ export interface MaterializerBrainAccess {
|
|||
update(params: {
|
||||
id: string
|
||||
data?: string
|
||||
subtype?: string
|
||||
metadata?: Record<string, unknown>
|
||||
merge?: boolean
|
||||
}): Promise<void>
|
||||
|
|
@ -197,9 +200,15 @@ export class AggregateMaterializer {
|
|||
} else {
|
||||
// Create new materialized entity
|
||||
// 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({
|
||||
data: dataString,
|
||||
type: 'measurement' as NounType,
|
||||
subtype: 'materialized-aggregate',
|
||||
metadata,
|
||||
service: 'brainy:aggregation'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -642,9 +642,13 @@ export class UniversalImportAPI {
|
|||
|
||||
let entitiesProcessed = 0
|
||||
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({
|
||||
data: entity.data,
|
||||
type: entity.type,
|
||||
subtype: (entity as any).subtype ?? 'extracted',
|
||||
metadata: entity.metadata,
|
||||
vector: entity.vector
|
||||
})
|
||||
|
|
@ -682,8 +686,10 @@ export class UniversalImportAPI {
|
|||
total: neuralResults.relationships.size
|
||||
})
|
||||
|
||||
// Collect all relationship parameters
|
||||
const relationshipParams: Array<{from: string; to: string; type: VerbType; weight?: number; metadata?: any}> = []
|
||||
// Collect all relationship parameters. Subtype `extracted` matches the
|
||||
// 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()) {
|
||||
// Map to actual entity IDs
|
||||
|
|
@ -697,6 +703,7 @@ export class UniversalImportAPI {
|
|||
from: sourceEntity.id,
|
||||
to: targetEntity.id,
|
||||
type: relation.type,
|
||||
subtype: (relation as any).subtype ?? 'extracted',
|
||||
weight: relation.weight,
|
||||
metadata: relation.metadata
|
||||
})
|
||||
|
|
|
|||
239
src/brainy.ts
239
src/brainy.ts
|
|
@ -2672,8 +2672,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
if (subtype === undefined || subtype === null || subtype === '') {
|
||||
if ((rule?.required || strict) && !isInfra) {
|
||||
throw new Error(
|
||||
`${op}(): NounType.${type} requires subtype but got ${subtype === undefined ? 'undefined' : 'empty'}. ` +
|
||||
(rule?.values ? `Allowed values: [${Array.from(rule.values).join(', ')}].` : 'Register vocabulary via brain.requireSubtype().')
|
||||
this.formatSubtypeError({
|
||||
op,
|
||||
kind: 'noun',
|
||||
typeName: String(type),
|
||||
issue: subtype === undefined ? 'undefined' : 'empty',
|
||||
rule,
|
||||
strict
|
||||
})
|
||||
)
|
||||
}
|
||||
return
|
||||
|
|
@ -2681,8 +2687,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
|
||||
if (rule?.values && !rule.values.has(subtype)) {
|
||||
throw new Error(
|
||||
`${op}(): NounType.${type} subtype '${subtype}' is not in registered vocabulary ` +
|
||||
`[${Array.from(rule.values).join(', ')}].`
|
||||
this.formatSubtypeError({
|
||||
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 ((rule?.required || strict) && !isInfra) {
|
||||
throw new Error(
|
||||
`${op}(): VerbType.${verb} requires subtype but got ${subtype === undefined ? 'undefined' : 'empty'}. ` +
|
||||
(rule?.values ? `Allowed values: [${Array.from(rule.values).join(', ')}].` : 'Register vocabulary via brain.requireSubtype().')
|
||||
this.formatSubtypeError({
|
||||
op,
|
||||
kind: 'verb',
|
||||
typeName: String(verb),
|
||||
issue: subtype === undefined ? 'undefined' : 'empty',
|
||||
rule,
|
||||
strict
|
||||
})
|
||||
)
|
||||
}
|
||||
return
|
||||
|
|
@ -2716,12 +2735,97 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
|
||||
if (rule?.values && !rule.values.has(subtype)) {
|
||||
throw new Error(
|
||||
`${op}(): VerbType.${verb} subtype '${subtype}' is not in registered vocabulary ` +
|
||||
`[${Array.from(rule.values).join(', ')}].`
|
||||
this.formatSubtypeError({
|
||||
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
|
||||
* 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ interface AddOptions extends CoreOptions {
|
|||
id?: string
|
||||
metadata?: 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
|
||||
weight?: string
|
||||
}
|
||||
|
|
@ -51,6 +58,12 @@ interface GetOptions extends CoreOptions {
|
|||
interface RelateOptions extends CoreOptions {
|
||||
weight?: 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 {
|
||||
|
|
@ -153,10 +166,15 @@ export const coreCommands = {
|
|||
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 = {
|
||||
data: text,
|
||||
type: nounType,
|
||||
subtype: options.subtype ?? 'cli-add',
|
||||
metadata
|
||||
}
|
||||
|
||||
|
|
@ -589,11 +607,15 @@ export const coreCommands = {
|
|||
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({
|
||||
from: source,
|
||||
to: target,
|
||||
type: verb as any,
|
||||
subtype: options.subtype ?? 'cli-relate',
|
||||
metadata
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ program
|
|||
.option('-i, --id <id>', 'Specify custom ID')
|
||||
.option('-m, --metadata <json>', 'Add metadata')
|
||||
.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)
|
||||
|
||||
program
|
||||
|
|
@ -132,6 +135,7 @@ program
|
|||
.description('Create a relationship between items (interactive if parameters missing)')
|
||||
.option('-w, --weight <number>', 'Relationship weight')
|
||||
.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)
|
||||
|
||||
program
|
||||
|
|
|
|||
|
|
@ -215,10 +215,13 @@ export class EntityDeduplicator {
|
|||
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({
|
||||
data: candidate.description || candidate.name,
|
||||
type: candidate.type,
|
||||
subtype: (candidate as any).subtype ?? 'imported',
|
||||
metadata: {
|
||||
...candidate.metadata,
|
||||
name: candidate.name,
|
||||
|
|
|
|||
|
|
@ -143,6 +143,23 @@ export interface ValidImportOptions {
|
|||
*/
|
||||
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
|
||||
*
|
||||
|
|
@ -946,9 +963,14 @@ export class ImportCoordinator {
|
|||
if (sourceInfo && options.createProvenanceLinks !== false) {
|
||||
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({
|
||||
data: sourceInfo.sourceFilename,
|
||||
type: NounType.Document,
|
||||
subtype: 'import-source',
|
||||
metadata: {
|
||||
name: sourceInfo.sourceFilename,
|
||||
sourceFile: sourceInfo.sourceFilename,
|
||||
|
|
@ -981,7 +1003,9 @@ export class ImportCoordinator {
|
|||
// FAST PATH: Batch creation without deduplication (recommended for imports > 100 entities)
|
||||
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 entity = row.entity || row
|
||||
const vfsFile = vfsResult.files.find((f: any) => f.entityId === entity.id)
|
||||
|
|
@ -989,6 +1013,7 @@ export class ImportCoordinator {
|
|||
return {
|
||||
data: entity.description || entity.name,
|
||||
type: entity.type,
|
||||
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
name: entity.name,
|
||||
|
|
@ -1091,10 +1116,14 @@ export class ImportCoordinator {
|
|||
let entityId: string
|
||||
|
||||
// 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({
|
||||
data: entity.description || entity.name,
|
||||
type: entity.type,
|
||||
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
name: entity.name,
|
||||
|
|
@ -1186,11 +1215,15 @@ export class ImportCoordinator {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
targetEntityId = await this.brain.add({
|
||||
data: rel.to,
|
||||
type: NounType.Thing,
|
||||
subtype: 'import-placeholder',
|
||||
metadata: {
|
||||
name: rel.to,
|
||||
placeholder: true,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ export interface SmartImportOptions extends SmartExcelOptions {
|
|||
|
||||
/** Source filename */
|
||||
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 {
|
||||
|
|
@ -180,10 +188,12 @@ export class SmartImportOrchestrator {
|
|||
const extracted = result.extraction.rows[i]
|
||||
|
||||
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({
|
||||
data: extracted.entity.description,
|
||||
type: extracted.entity.type,
|
||||
subtype: (extracted.entity as any).subtype ?? options.defaultSubtype ?? 'imported',
|
||||
metadata: {
|
||||
...extracted.entity.metadata,
|
||||
name: extracted.entity.name,
|
||||
|
|
@ -230,7 +240,7 @@ export class SmartImportOrchestrator {
|
|||
}
|
||||
|
||||
// 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 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) {
|
||||
toEntityId = await this.brain.add({
|
||||
data: rel.to,
|
||||
type: NounType.Thing,
|
||||
subtype: 'import-placeholder',
|
||||
metadata: {
|
||||
name: rel.to,
|
||||
placeholder: true,
|
||||
|
|
@ -261,11 +274,13 @@ export class SmartImportOrchestrator {
|
|||
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({
|
||||
from: extracted.entity.id,
|
||||
to: toEntityId,
|
||||
type: rel.type,
|
||||
subtype: (rel as any).subtype ?? options.defaultSubtype ?? 'imported',
|
||||
metadata: {
|
||||
confidence: rel.confidence,
|
||||
evidence: rel.evidence
|
||||
|
|
@ -582,9 +597,11 @@ export class SmartImportOrchestrator {
|
|||
for (let i = 0; i < result.extraction.rows.length; i++) {
|
||||
const extracted = result.extraction.rows[i]
|
||||
try {
|
||||
// Subtype precedence: extractor → caller default → `'imported'` (7.30.1).
|
||||
const entityId = await this.brain.add({
|
||||
data: extracted.entity.description,
|
||||
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' }
|
||||
})
|
||||
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 })
|
||||
|
||||
// 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 rel of extracted.relationships) {
|
||||
|
|
@ -613,10 +630,12 @@ export class SmartImportOrchestrator {
|
|||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
result.errors.push(`Failed to prepare relationship: ${error.message}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -448,8 +448,12 @@ export class ODataIntegration extends IntegrationBase implements HTTPIntegration
|
|||
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({
|
||||
type: body.Type as NounType,
|
||||
subtype: (body.Subtype as string | undefined) ?? 'imported-from-odata',
|
||||
data: body.Data ? JSON.parse(body.Data) : undefined,
|
||||
metadata: this.extractMetadata(body),
|
||||
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({
|
||||
from: body.FromId,
|
||||
to: body.ToId,
|
||||
type: body.Type,
|
||||
subtype: (body.Subtype as string | undefined) ?? 'imported-from-odata',
|
||||
weight: body.Weight,
|
||||
confidence: body.Confidence,
|
||||
metadata: body.Metadata ? JSON.parse(body.Metadata) : undefined,
|
||||
|
|
|
|||
|
|
@ -500,8 +500,12 @@ export class GoogleSheetsIntegration
|
|||
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({
|
||||
type: body.type as NounType,
|
||||
subtype: (body.subtype as string | undefined) ?? 'imported-from-sheets',
|
||||
data: body.data,
|
||||
metadata: body.metadata,
|
||||
confidence: body.confidence,
|
||||
|
|
@ -570,8 +574,10 @@ export class GoogleSheetsIntegration
|
|||
try {
|
||||
switch (op.action) {
|
||||
case 'add':
|
||||
// Same subtype-precedence as the single-entity handler (7.30.1).
|
||||
const added = await this.context.brain.add({
|
||||
type: op.type as NounType,
|
||||
subtype: (op.subtype as string | undefined) ?? 'imported-from-sheets',
|
||||
data: op.data,
|
||||
metadata: op.metadata
|
||||
})
|
||||
|
|
@ -628,10 +634,13 @@ export class GoogleSheetsIntegration
|
|||
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({
|
||||
from: body.from,
|
||||
to: body.to,
|
||||
type: body.type as VerbType,
|
||||
subtype: (body.subtype as string | undefined) ?? 'imported-from-sheets',
|
||||
weight: body.weight,
|
||||
metadata: body.metadata
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import WebSocket from 'ws'
|
||||
import { Brainy } from '../brainy.js'
|
||||
import { NounType } from '../types/graphTypes.js'
|
||||
import { v4 as uuidv4 } from '../universal/uuid.js'
|
||||
|
||||
interface ClientOptions {
|
||||
|
|
@ -119,19 +120,25 @@ export class BrainyMCPClient {
|
|||
* Handle incoming 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') {
|
||||
try {
|
||||
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: {
|
||||
messageId: message.id,
|
||||
from: message.from,
|
||||
to: message.to,
|
||||
timestamp: message.timestamp,
|
||||
type: message.type,
|
||||
event: message.event,
|
||||
category: 'Message'
|
||||
messageType: message.type,
|
||||
event: message.event
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
@ -142,15 +149,16 @@ export class BrainyMCPClient {
|
|||
// Handle sync messages (receive history)
|
||||
if (message.type === 'sync' && message.data.history) {
|
||||
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) {
|
||||
for (const histMsg of message.data.history) {
|
||||
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: {
|
||||
...histMsg,
|
||||
category: 'Message'
|
||||
...histMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,14 @@ export interface NeuralImportOptions {
|
|||
validateOnly: boolean
|
||||
categoryFilter?: string[]
|
||||
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()
|
||||
|
||||
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) {
|
||||
await this.brainy.add({
|
||||
data: this.extractMainText(entity.originalData),
|
||||
type: entity.nounType as NounType,
|
||||
subtype: (entity as any).subtype ?? options.defaultSubtype ?? 'extracted',
|
||||
metadata: {
|
||||
...entity.originalData,
|
||||
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) {
|
||||
await this.brainy.relate({
|
||||
from: relationship.sourceId,
|
||||
to: relationship.targetId,
|
||||
type: relationship.verbType as VerbType,
|
||||
subtype: (relationship as any).subtype ?? options.defaultSubtype ?? 'extracted',
|
||||
weight: relationship.weight,
|
||||
metadata: {
|
||||
confidence: relationship.confidence,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
...deserialized,
|
||||
type: (metadata.noun || 'thing') as NounType,
|
||||
subtype: (metadata as any).subtype as string | undefined,
|
||||
confidence: metadata.confidence,
|
||||
weight: metadata.weight,
|
||||
createdAt: metadata.createdAt
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
from: parentId,
|
||||
to: existingId,
|
||||
type: VerbType.Contains,
|
||||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||||
metadata: { isVFS: true } // Mark as VFS relationship
|
||||
})
|
||||
}
|
||||
|
|
@ -903,6 +904,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
from: parentId,
|
||||
to: entity,
|
||||
type: VerbType.Contains,
|
||||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||||
metadata: {
|
||||
isVFS: true, // Mark as VFS relationship
|
||||
relationshipType: 'vfs' // Standardized relationship type metadata
|
||||
|
|
@ -1692,6 +1694,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
from: newParentId,
|
||||
to: entityId,
|
||||
type: VerbType.Contains,
|
||||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||||
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> {
|
||||
// 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({
|
||||
type: srcEntity.type,
|
||||
subtype: (srcEntity as any).subtype ?? 'vfs-file',
|
||||
data: srcEntity.data,
|
||||
vector: options?.preserveVector ? srcEntity.vector : undefined,
|
||||
metadata: {
|
||||
|
|
@ -1770,6 +1777,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
from: parentId,
|
||||
to: newEntity,
|
||||
type: VerbType.Contains,
|
||||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||||
metadata: { isVFS: true } // Mark as VFS relationship
|
||||
})
|
||||
}
|
||||
|
|
@ -1884,6 +1892,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
from: parentId,
|
||||
to: result.successful[i],
|
||||
type: VerbType.Contains,
|
||||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||||
metadata: { isVFS: true }
|
||||
}
|
||||
})
|
||||
|
|
@ -1939,6 +1948,8 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
name,
|
||||
parent: parentId,
|
||||
vfsType: 'symlink',
|
||||
isVFS: true, // Infrastructure-bypass marker for strict-mode enforcement
|
||||
isVFSEntity: true,
|
||||
symlinkTarget: target,
|
||||
size: 0,
|
||||
permissions: 0o777,
|
||||
|
|
@ -1951,6 +1962,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
const entity = await this.brain.add({
|
||||
data: `symlink:${target}`,
|
||||
type: NounType.File, // Symlinks are special files
|
||||
subtype: 'vfs-symlink', // Distinct from 'vfs-file' so consumers can find symlinks (7.30.1+)
|
||||
metadata
|
||||
})
|
||||
|
||||
|
|
@ -1959,6 +1971,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
from: parentId,
|
||||
to: entity,
|
||||
type: VerbType.Contains,
|
||||
subtype: 'vfs-contains', // Standard subtype for VFS containment edges (7.30+)
|
||||
metadata: { isVFS: true } // Mark as VFS relationship
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue