feat(8.0): reserved-field enforcement — reservedFieldPolicy defaults to throw

An untyped (JS) caller that smuggles a Brainy-reserved field (confidence,
weight, subtype, visibility, service, createdBy, noun/verb, data, createdAt,
updatedAt, _rev) inside a write-path metadata bag previously got a silent
remap-or-drop — a class of bug where confidence-evolution writes no-oped for
weeks before being caught on read-back. 8.0 closes this with no silent failures.

- New BrainyConfig.reservedFieldPolicy: 'throw' | 'warn' | 'remap' (default 'throw').
  'throw' rejects the write naming every offending key + its correct write path;
  'warn' remaps with a one-shot per-key warning; 'remap' is the legacy silent path.
- Central enforceReservedPolicy gate wired into all four remap methods (add,
  update, relate, updateRelation) so live calls AND their transact()/with()
  mirrors honor it. Single-source reservedWritePath guidance shared by throw+warn.
- 'warn' now warns for EVERY reserved key (closes the gap where only
  system-managed fields warned). Dead warnDropped* helpers removed.
- Import pipeline migrated to route reserved values (confidence/weight/subtype)
  through dedicated params and strip reserved keys from extractor/customMetadata
  bags via the canonical split*MetadataRecord helpers — imports no longer trip
  the default throw.
- Tests: new reservedFieldPolicy matrix (throw/warn/remap across every write
  path + transact); remap-correctness suite reframed as opt-in 'remap'; shared
  test-factory no longer emits reserved keys in custom metadata.
This commit is contained in:
David Snelling 2026-06-20 15:37:21 -07:00
parent ae3fe82fd9
commit 54c7c39669
12 changed files with 605 additions and 190 deletions

View file

@ -23,6 +23,7 @@ import { SmartYAMLImporter } from '../importers/SmartYAMLImporter.js'
import { SmartDOCXImporter } from '../importers/SmartDOCXImporter.js'
import { VFSStructureGenerator } from '../importers/VFSStructureGenerator.js'
import { NounType, VerbType } from '../types/graphTypes.js'
import { splitNounMetadataRecord, splitVerbMetadataRecord } from '../types/reservedFields.js'
import { v4 as uuidv4 } from '../universal/uuid.js'
import * as fs from 'fs'
import * as path from 'path'
@ -834,7 +835,11 @@ export class ImportCoordinator {
type: 'media',
description: '',
confidence: 1.0,
metadata: { subtype: 'image' }
// `subtype` is a reserved field — keep it top-level on the extractor
// entity (read into the `subtype` param at add() time), never inside
// the metadata bag that gets spread into add({ metadata }).
subtype: 'image',
metadata: {}
},
relatedEntities: [],
relationships: []
@ -843,7 +848,8 @@ export class ImportCoordinator {
id: imageId,
name: imageName,
type: 'media',
metadata: { subtype: 'image' }
subtype: 'image',
metadata: {}
}],
relationships: [],
metadata: {},
@ -862,6 +868,38 @@ export class ImportCoordinator {
}
}
/**
* Strip Brainy-reserved entity keys out of an extractor-supplied metadata bag.
*
* Extractors (and consumer `customMetadata`) can carry reserved keys
* (`confidence`, `subtype`, `weight`, ) inside `metadata`. Brainy 8.0's
* default `reservedFieldPolicy` is `'throw'`, so spreading such a bag into
* `add({ metadata })` would reject the whole import. The import pipeline owns
* the correct write path: user-mutable reserved values are passed as dedicated
* `AddParams` params (see the call sites), so here we simply drop the reserved
* half of the bag and keep only the custom fields that belong in `metadata`.
*
* @param bag - The extractor/consumer metadata bag (may be undefined).
* @returns The custom-only metadata (reserved keys removed).
*/
private stripReservedFromBag(bag: Record<string, any> | undefined | null): Record<string, any> {
if (!bag || typeof bag !== 'object') return {}
return splitNounMetadataRecord(bag).custom
}
/**
* Relationship mirror of {@link stripReservedFromBag} strips reserved verb
* keys (`verb`, `confidence`, `weight`, `subtype`, ) out of an edge metadata
* bag so it carries only custom fields. Reserved values that have a dedicated
* `RelateParams` param are passed there by the call site instead.
* @param bag - The extractor/consumer edge metadata bag (may be undefined).
* @returns The custom-only edge metadata (reserved keys removed).
*/
private stripReservedFromRelationBag(bag: Record<string, any> | undefined | null): Record<string, any> {
if (!bag || typeof bag !== 'object') return {}
return splitVerbMetadataRecord(bag).custom
}
/**
* Create entities and relationships in knowledge graph
* Added sourceInfo parameter for document entity creation
@ -977,7 +1015,7 @@ export class ImportCoordinator {
importedAt: trackingContext.importedAt,
importFormat: trackingContext.importFormat,
importSource: trackingContext.importSource,
...trackingContext.customMetadata
...this.stripReservedFromBag(trackingContext.customMetadata)
})
}
})
@ -1005,10 +1043,14 @@ export class ImportCoordinator {
data: entity.description || entity.name,
type: entity.type,
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
// `confidence` is a reserved field — pass it as the dedicated param,
// never inside the metadata bag (8.0 reservedFieldPolicy defaults to 'throw').
confidence: entity.confidence,
metadata: {
...entity.metadata,
// Extractor/consumer bags may smuggle reserved keys — strip them so
// the bag carries only custom fields.
...this.stripReservedFromBag(entity.metadata),
name: entity.name,
confidence: entity.confidence,
vfsPath: vfsFile?.path,
importedFrom: 'import-coordinator',
imports: [importSource],
@ -1020,7 +1062,7 @@ export class ImportCoordinator {
importSource: trackingContext.importSource,
sourceRow: row.rowNumber,
sourceSheet: row.sheet,
...trackingContext.customMetadata
...this.stripReservedFromBag(trackingContext.customMetadata)
})
}
}
@ -1101,7 +1143,7 @@ export class ImportCoordinator {
importIds: [trackingContext.importId],
projectId: trackingContext.projectId,
importFormat: trackingContext.importFormat,
...trackingContext.customMetadata
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
})
}
}
@ -1132,10 +1174,12 @@ export class ImportCoordinator {
data: entity.description || entity.name,
type: entity.type,
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
// `confidence` is a reserved field — dedicated param, not metadata.
confidence: entity.confidence,
metadata: {
...entity.metadata,
// Strip any reserved keys an extractor smuggled into the bag.
...this.stripReservedFromBag(entity.metadata),
name: entity.name,
confidence: entity.confidence,
vfsPath: vfsFile?.path,
importedFrom: 'import-coordinator',
// Import tracking metadata
@ -1148,7 +1192,7 @@ export class ImportCoordinator {
importSource: trackingContext.importSource,
sourceRow: row.rowNumber,
sourceSheet: row.sheet,
...trackingContext.customMetadata
...this.stripReservedFromBag(trackingContext.customMetadata)
})
}
})
@ -1188,7 +1232,7 @@ export class ImportCoordinator {
importIds: [trackingContext.importId],
projectId: trackingContext.projectId,
importFormat: trackingContext.importFormat,
...trackingContext.customMetadata
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
})
}
})
@ -1243,7 +1287,7 @@ export class ImportCoordinator {
projectId: trackingContext.projectId,
importedAt: trackingContext.importedAt,
importFormat: trackingContext.importFormat,
...trackingContext.customMetadata
...this.stripReservedFromBag(trackingContext.customMetadata)
})
}
})
@ -1273,7 +1317,7 @@ export class ImportCoordinator {
projectId: trackingContext.projectId,
importedAt: trackingContext.importedAt,
importFormat: trackingContext.importFormat,
...trackingContext.customMetadata
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
})
}
})
@ -1371,8 +1415,12 @@ export class ImportCoordinator {
from: rel.from,
to: rel.to,
type: verbType, // Enhanced type
// confidence/weight are reserved — carry them as dedicated params,
// never inside the bag (they were collected top-level on each rel).
...(typeof (rel as any).confidence === 'number' && { confidence: (rel as any).confidence }),
...(typeof (rel as any).weight === 'number' && { weight: (rel as any).weight }),
metadata: {
...(rel.metadata || {}),
...this.stripReservedFromRelationBag(rel.metadata),
relationshipType: 'semantic', // Distinguish from VFS/provenance
inferredType: verbType !== rel.type, // Track if type was enhanced
originalType: rel.type