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

@ -12,6 +12,7 @@
import { Brainy } from '../brainy.js'
import { VirtualFileSystem } from '../vfs/VirtualFileSystem.js'
import { NounType, VerbType } from '../types/graphTypes.js'
import { splitNounMetadataRecord } from '../types/reservedFields.js'
import { SmartExcelImporter, SmartExcelOptions, SmartExcelResult } from './SmartExcelImporter.js'
import { SmartPDFImporter, SmartPDFOptions, SmartPDFResult } from './SmartPDFImporter.js'
import { SmartCSVImporter, SmartCSVOptions, SmartCSVResult } from './SmartCSVImporter.js'
@ -201,7 +202,9 @@ export class SmartImportOrchestrator {
.subtype ?? options.defaultSubtype ?? 'imported',
confidence: extracted.entity.confidence, // reserved field — dedicated param, not metadata
metadata: {
...extracted.entity.metadata,
// Strip reserved keys an extractor may have smuggled into the bag
// (8.0 reservedFieldPolicy defaults to 'throw').
...splitNounMetadataRecord(extracted.entity.metadata).custom,
name: extracted.entity.name,
importedFrom: 'smart-import'
}
@ -245,7 +248,7 @@ export class SmartImportOrchestrator {
}
// Collect all relationship parameters
const relationshipParams: Array<{from: string; to: string; type: VerbType; subtype?: string; metadata?: any}> = []
const relationshipParams: Array<{from: string; to: string; type: VerbType; subtype?: string; confidence?: number; metadata?: any}> = []
for (const extracted of result.extraction.rows) {
for (const rel of extracted.relationships) {
@ -288,8 +291,8 @@ export class SmartImportOrchestrator {
subtype:
(rel as typeof rel & { subtype?: string }).subtype ??
options.defaultSubtype ?? 'imported',
confidence: rel.confidence, // reserved field — dedicated param, not metadata
metadata: {
confidence: rel.confidence,
evidence: rel.evidence
}
})
@ -615,7 +618,7 @@ export class SmartImportOrchestrator {
(extracted.entity as typeof extracted.entity & { subtype?: string })
.subtype ?? options.defaultSubtype ?? 'imported',
confidence: extracted.entity.confidence, // reserved field — dedicated param, not metadata
metadata: { ...extracted.entity.metadata, name: extracted.entity.name, importedFrom: 'smart-import' }
metadata: { ...splitNounMetadataRecord(extracted.entity.metadata).custom, name: extracted.entity.name, importedFrom: 'smart-import' }
})
result.entityIds.push(entityId)
result.stats.entitiesCreated++