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:
parent
ae3fe82fd9
commit
54c7c39669
12 changed files with 605 additions and 190 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue