feat(8.0): reserved-field contract — one canonical location, typed prevention, unified read/write

Brainy-owned field names (noun/verb, subtype, createdAt, updatedAt,
confidence, weight, service, data, createdBy, _rev) now have exactly one
home — top level — enforced by three layers driven from a single source of
truth, src/types/reservedFields.ts (RESERVED_ENTITY_FIELDS /
RESERVED_RELATION_FIELDS, exported):

1. Compile time — AddParams/UpdateParams/RelateParams/UpdateRelationParams
   metadata (and the transact() ops that extend them) reject a literal
   reserved key as a TypeScript error while keeping generic T ergonomics
   (typed bags, untyped brains, index-signature shapes, and a documented
   exemption for T-declared reserved keys). Pinned by @ts-expect-error
   type tests run under vitest typecheck mode on every unit run.

2. Write time — the 7.x update() remap is ported to 8.0 and extended to
   every write path: add/update/relate/updateRelation, their transact()
   mirrors, and db.with() overlays. User-settable fields lift to their
   dedicated param (top-level wins when both are supplied — closes the 7.x
   trap where update({metadata:{confidence}}) silently no-oped), and
   system-managed fields drop with a one-shot warning naming the right
   path. A remapped subtype satisfies subtype-pairing enforcement exactly
   like a top-level one.

3. Read time — every storage combine goes through one canonical hydration
   helper (hydrateNounWithMetadata / hydrateVerbWithMetadata over
   splitNoun/VerbMetadataRecord), so reserved fields surface ONLY top-level
   and entity/relation.metadata carry ONLY custom fields on live reads,
   batch reads, paginated listings, getRelations by source/target, streamed
   verbs, and historical asOf() materialization alike.

Read-path echoes found and fixed (previously the full stored record —
including the verb type key — leaked inside metadata): noun pagination,
verb pagination, getVerbsBySource/ByTarget (adjacency + shard fallback),
getVerbsBySourceBatch (which also dropped subtype/data), and the
filesystem verb stream. getRelations() results now surface
confidence/updatedAt top-level via verbsToRelations, updateRelation() no
longer erases service/createdBy, relate() persists its top-level
confidence/service params, and the dead convertHNSWVerbToGraphVerb echo
path is deleted. Import paths (CLI extract, deduplicator, coordinators,
neural import) write confidence through the dedicated param instead of the
bag. UpdateRelationParams is now exported from the package root.

Documented for consumers in docs/concepts/consistency-model.md ("Reserved
fields") and RELEASES.md. Regression tests ported from the 7.x fix and
extended to the full 8.0 contract (17 runtime tests + 41 type-level
assertions); full unit suite 1427/1427, db-mvcc integration 24/24.
This commit is contained in:
David Snelling 2026-06-11 13:12:50 -07:00
parent c44678390e
commit 970e08c466
18 changed files with 1688 additions and 452 deletions

View file

@ -30,6 +30,32 @@ import { BlobStorage, type BlobStoreAdapter } from './blobStorage.js'
import { unwrapBinaryData } from './binaryDataCodec.js'
import { prodLog } from '../utils/logger.js'
import { MetadataWriteBuffer } from '../utils/metadataWriteBuffer.js'
import {
splitNounMetadataRecord,
splitVerbMetadataRecord
} from '../types/reservedFields.js'
/**
* Normalize a stored timestamp value to epoch milliseconds. Brainy 8.0 writes
* plain numbers; records written by pre-8.0 cloud adapters may carry the
* `{ seconds, nanoseconds }` object form. Anything else falls back to now
* matching the long-standing `|| Date.now()` combine behavior.
* @param value - The raw `createdAt`/`updatedAt` value from a stored metadata record.
* @returns Epoch milliseconds.
*/
function normalizeStoredTimestamp(value: unknown): number {
if (typeof value === 'number' && value > 0) {
return value
}
if (
value !== null &&
typeof value === 'object' &&
typeof (value as { seconds?: unknown }).seconds === 'number'
) {
return (value as { seconds: number }).seconds * 1000
}
return Date.now()
}
/**
* Storage key analysis result
@ -969,6 +995,74 @@ export abstract class BaseStorage extends BaseStorageAdapter {
await this.saveNoun_internal(noun)
}
/**
* Hydrate a deserialized noun (pure HNSW vector data) with its stored flat
* metadata record THE canonical noun combine for every storage read path.
* The record is split through `splitNounMetadataRecord` (single source of
* truth: src/types/reservedFields.ts): reserved fields surface ONLY at
* top level and `metadata` carries ONLY the consumer's custom fields.
* Adding a combine site that bypasses this helper reintroduces the
* reserved-field echo bug don't.
*
* @param noun - The deserialized HNSW noun (id/vector/connections/level).
* @param metadata - The stored flat metadata record (reserved + custom keys).
* @returns The combined noun with reserved fields top-level, custom fields in `metadata`.
*/
protected hydrateNounWithMetadata(
noun: HNSWNoun,
metadata: Record<string, unknown> | null | undefined
): HNSWNounWithMetadata {
const { reserved, custom } = splitNounMetadataRecord(metadata)
return {
...noun,
// Standard fields at top-level
type: (reserved.noun as NounType) || NounType.Thing,
subtype: reserved.subtype as string | undefined,
createdAt: normalizeStoredTimestamp(reserved.createdAt),
updatedAt: normalizeStoredTimestamp(reserved.updatedAt),
confidence: reserved.confidence as number | undefined,
weight: reserved.weight as number | undefined,
service: reserved.service as string | undefined,
data: reserved.data as Record<string, any> | undefined,
createdBy: reserved.createdBy as HNSWNounWithMetadata['createdBy'],
_rev: typeof reserved._rev === 'number' ? reserved._rev : 1,
// Only custom user fields remain in metadata
metadata: custom
}
}
/**
* Hydrate a deserialized verb (structural core) with its stored flat
* metadata record THE canonical verb combine, the relationship mirror of
* {@link hydrateNounWithMetadata}. Splitting through
* `splitVerbMetadataRecord` extracts `verb` too, so the type key never
* echoes inside `metadata`.
*
* @param verb - The deserialized HNSW verb (id/vector/connections/verb/sourceId/targetId).
* @param metadata - The stored flat metadata record (reserved + custom keys).
* @returns The combined verb with reserved fields top-level, custom fields in `metadata`.
*/
protected hydrateVerbWithMetadata(
verb: HNSWVerb,
metadata: Record<string, unknown> | null | undefined
): HNSWVerbWithMetadata {
const { reserved, custom } = splitVerbMetadataRecord(metadata)
return {
...verb,
// Standard fields at top-level
subtype: reserved.subtype as string | undefined,
createdAt: normalizeStoredTimestamp(reserved.createdAt),
updatedAt: normalizeStoredTimestamp(reserved.updatedAt),
confidence: reserved.confidence as number | undefined,
weight: reserved.weight as number | undefined,
service: reserved.service as string | undefined,
data: reserved.data as Record<string, any> | undefined,
createdBy: reserved.createdBy as HNSWVerbWithMetadata['createdBy'],
// Only custom user fields remain in metadata
metadata: custom
}
}
/**
* Get a noun from storage (returns combined HNSWNounWithMetadata)
* @param id Entity ID
@ -990,28 +1084,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return null
}
// Combine into HNSWNounWithMetadata - Extract standard fields to top-level
const { noun, subtype, createdAt, updatedAt, confidence, weight, service, data, createdBy, _rev, ...customMetadata } = metadata
return {
id: vector.id,
vector: vector.vector,
connections: vector.connections,
level: vector.level,
// Standard fields at top-level
type: (noun as NounType) || NounType.Thing,
subtype: subtype as string | undefined,
createdAt: (createdAt as number) || Date.now(),
updatedAt: (updatedAt as number) || Date.now(),
confidence: confidence as number | undefined,
weight: weight as number | undefined,
service: service as string | undefined,
data: data as Record<string, any> | undefined,
createdBy,
_rev: typeof _rev === 'number' ? _rev : 1,
// Only custom user fields remain in metadata
metadata: customMetadata
}
return this.hydrateNounWithMetadata(vector, metadata)
}
/**
@ -1025,29 +1098,12 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// Internal method returns HNSWNoun[], need to combine with metadata
const nouns = await this.getNounsByNounType_internal(nounType)
// Combine each noun with its metadata - Extract standard fields to top-level
// Combine each noun with its metadata via the canonical hydration helper
const nounsWithMetadata: HNSWNounWithMetadata[] = []
for (const noun of nouns) {
const metadata = await this.getNounMetadata(noun.id)
if (metadata) {
const { noun: nounType, subtype, createdAt, updatedAt, confidence, weight, service, data, createdBy, _rev, ...customMetadata } = metadata
nounsWithMetadata.push({
...noun,
// Standard fields at top-level
type: (nounType as NounType) || NounType.Thing,
subtype: subtype as string | undefined,
createdAt: (createdAt as number) || Date.now(),
updatedAt: (updatedAt as number) || Date.now(),
confidence: confidence as number | undefined,
weight: weight as number | undefined,
service: service as string | undefined,
data: data as Record<string, any> | undefined,
createdBy,
_rev: typeof _rev === 'number' ? _rev : 1,
// Only custom user fields in metadata
metadata: customMetadata
})
nounsWithMetadata.push(this.hydrateNounWithMetadata(noun, metadata))
}
}
@ -1109,28 +1165,7 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return null
}
// Combine into HNSWVerbWithMetadata - Extract standard fields to top-level
const { subtype, createdAt, updatedAt, confidence, weight, service, data, createdBy, _rev, ...customMetadata } = metadata
return {
id: verb.id,
vector: verb.vector,
connections: verb.connections,
verb: verb.verb,
sourceId: verb.sourceId,
targetId: verb.targetId,
// Standard fields at top-level
subtype: subtype as string | undefined,
createdAt: (createdAt as number) || Date.now(),
updatedAt: (updatedAt as number) || Date.now(),
confidence: confidence as number | undefined,
weight: weight as number | undefined,
service: service as string | undefined,
data: data as Record<string, any> | undefined,
createdBy,
// Only custom user fields remain in metadata
metadata: customMetadata
}
return this.hydrateVerbWithMetadata(verb, metadata)
}
/**
@ -1181,98 +1216,15 @@ export abstract class BaseStorage extends BaseStorageAdapter {
const metadataData = metadataResults.get(metadataPath)
if (vectorData && metadataData) {
// Deserialize verb
// Deserialize, then combine via the canonical hydration helper
const verb = this.deserializeVerb(vectorData)
// Extract standard fields to top-level
const { subtype, createdAt, updatedAt, confidence, weight, service, data, createdBy, _rev, ...customMetadata } = metadataData
results.set(id, {
id: verb.id,
vector: verb.vector,
connections: verb.connections,
verb: verb.verb,
sourceId: verb.sourceId,
targetId: verb.targetId,
// Standard fields at top-level
subtype: subtype as string | undefined,
createdAt: (createdAt as number) || Date.now(),
updatedAt: (updatedAt as number) || Date.now(),
confidence: confidence as number | undefined,
weight: weight as number | undefined,
service: service as string | undefined,
data: data as Record<string, any> | undefined,
createdBy,
// Only custom user fields remain in metadata
metadata: customMetadata
})
results.set(id, this.hydrateVerbWithMetadata(verb, metadataData))
}
}
return results
}
/**
* Convert an HNSW verb (raw vector store entry) into a `GraphVerb` shape by
* combining with the verb's metadata. Used by internal code paths that need
* the graph-layer shape rather than the storage-layer shape.
*/
protected async convertHNSWVerbToGraphVerb(hnswVerb: HNSWVerb): Promise<GraphVerb | null> {
try {
// Load metadata
const metadata = await this.getVerbMetadata(hnswVerb.id)
// Create default timestamp in Firestore format
const defaultTimestamp = {
seconds: Math.floor(Date.now() / 1000),
nanoseconds: (Date.now() % 1000) * 1000000
}
// Create default createdBy if not present
const defaultCreatedBy = {
augmentation: 'unknown',
version: '1.0'
}
// Convert flexible timestamp to Firestore format for GraphVerb
const normalizeTimestamp = (ts: any) => {
if (!ts) return defaultTimestamp
if (typeof ts === 'number') {
return {
seconds: Math.floor(ts / 1000),
nanoseconds: (ts % 1000) * 1000000
}
}
return ts
}
return {
id: hnswVerb.id,
vector: hnswVerb.vector,
// CORE FIELDS from HNSWVerb
verb: hnswVerb.verb,
sourceId: hnswVerb.sourceId,
targetId: hnswVerb.targetId,
// Alias for ergonomic access
type: hnswVerb.verb,
// Optional fields from metadata file
weight: metadata?.weight || 1.0,
metadata: metadata as any || {},
createdAt: normalizeTimestamp(metadata?.createdAt),
updatedAt: normalizeTimestamp(metadata?.updatedAt),
createdBy: metadata?.createdBy || defaultCreatedBy,
data: metadata?.data as Record<string, any> | undefined,
embedding: hnswVerb.vector
}
} catch (error) {
prodLog.error(`Failed to convert HNSWVerb to GraphVerb for ${hnswVerb.id}:`, error)
return null
}
}
/**
* Internal method for loading all verbs - used by performance optimizations
* @internal - Do not use directly, use getVerbs() with pagination instead
@ -1571,27 +1523,10 @@ export abstract class BaseStorage extends BaseStorageAdapter {
}
}
// 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
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata.service,
data: metadata.data as Record<string, any> | undefined,
createdBy: metadata.createdBy,
metadata: metadata || ({} as NounMetadata)
})
// Combine noun + metadata via the canonical hydration helper —
// reserved fields top-level, ONLY custom fields in `metadata`
// (this site previously echoed the full flat record).
collectedNouns.push(this.hydrateNounWithMetadata(deserialized, metadata))
}
}
} catch (error) {
@ -1723,22 +1658,10 @@ export abstract class BaseStorage extends BaseStorageAdapter {
}
}
// Combine verb + metadata
collectedVerbs.push({
...verb,
subtype: (metadata as any)?.subtype as string | undefined,
weight: metadata?.weight,
confidence: metadata?.confidence,
createdAt: metadata?.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata?.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata?.service,
createdBy: metadata?.createdBy,
metadata: metadata || ({} as VerbMetadata)
})
// Combine verb + metadata via the canonical hydration helper —
// reserved fields top-level, ONLY custom fields in `metadata`
// (this site previously echoed the full flat record).
collectedVerbs.push(this.hydrateVerbWithMetadata(verb, metadata))
} catch (error) {
// Skip verbs that fail to load
}
@ -2571,31 +2494,9 @@ export abstract class BaseStorage extends BaseStorageAdapter {
const metadataData = metadataResults.get(metadataPath)
if (vectorData && metadataData) {
// Deserialize noun
// Deserialize, then combine via the canonical hydration helper
const noun = this.deserializeNoun(vectorData)
// Extract standard fields to top-level
const { noun: nounType, subtype, createdAt, updatedAt, confidence, weight, service, data, createdBy, _rev, ...customMetadata } = metadataData
results.set(id, {
id: noun.id,
vector: noun.vector,
connections: noun.connections,
level: noun.level,
// Standard fields at top-level
type: (nounType as NounType) || NounType.Thing,
subtype: subtype as string | undefined,
createdAt: (createdAt as number) || Date.now(),
updatedAt: (updatedAt as number) || Date.now(),
confidence: confidence as number | undefined,
weight: weight as number | undefined,
service: service as string | undefined,
data: data as Record<string, any> | undefined,
createdBy,
_rev: typeof _rev === 'number' ? _rev : 1,
// Only custom user fields remain in metadata
metadata: customMetadata
})
results.set(id, this.hydrateNounWithMetadata(noun, metadataData))
}
}
@ -3734,24 +3635,11 @@ export abstract class BaseStorage extends BaseStorageAdapter {
const metadata = metadataMap.get(metadataPath)
if (rawVerb && metadata) {
// CRITICAL - Deserialize connections Map from JSON storage format
// CRITICAL - Deserialize connections Map from JSON storage format,
// then combine via the canonical hydration helper (reserved fields
// top-level, ONLY custom fields in `metadata`).
const verb = this.deserializeVerb(rawVerb)
results.push({
...verb,
subtype: (metadata as any).subtype as string | undefined,
weight: metadata.weight,
confidence: metadata.confidence,
createdAt: metadata.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata.service,
createdBy: metadata.createdBy,
metadata: metadata || {} as VerbMetadata
})
results.push(this.hydrateVerbWithMetadata(verb, metadata))
}
}
@ -3791,22 +3679,9 @@ export abstract class BaseStorage extends BaseStorageAdapter {
if (verb.sourceId === sourceId) {
const metadataPath = getVerbMetadataPath(verb.id)
const metadata = await this.readCanonicalObject(metadataPath)
results.push({
...verb,
subtype: (metadata as any)?.subtype as string | undefined,
weight: metadata?.weight,
confidence: metadata?.confidence,
createdAt: metadata?.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata?.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata?.service,
createdBy: metadata?.createdBy,
metadata: metadata || {} as VerbMetadata
})
// Canonical hydration — reserved fields top-level, ONLY custom
// fields in `metadata`.
results.push(this.hydrateVerbWithMetadata(verb, metadata))
}
} catch (error) {
// Skip verbs that fail to load
@ -3919,20 +3794,10 @@ export abstract class BaseStorage extends BaseStorageAdapter {
const metadataPath = getVerbMetadataPath(verbId)
const metadata = metadataMap.get(metadataPath) || {}
const hydratedVerb: HNSWVerbWithMetadata = {
...verbData,
weight: metadata?.weight,
confidence: metadata?.confidence,
createdAt: metadata?.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata?.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata?.service,
createdBy: metadata?.createdBy,
metadata: metadata as VerbMetadata
}
// Canonical hydration — reserved fields top-level (including
// subtype/data, which this site previously dropped), ONLY custom
// fields in `metadata`.
const hydratedVerb = this.hydrateVerbWithMetadata(verbData, metadata)
// Add to results for this sourceId
const sourceVerbs = results.get(verbData.sourceId)!
@ -3976,21 +3841,9 @@ export abstract class BaseStorage extends BaseStorageAdapter {
const metadata = await this.getVerbMetadata(verbId)
if (verb && metadata) {
results.push({
...verb,
subtype: (metadata as any).subtype as string | undefined,
weight: metadata.weight,
confidence: metadata.confidence,
createdAt: metadata.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata.service,
createdBy: metadata.createdBy,
metadata: metadata || {} as VerbMetadata
})
// Canonical hydration — reserved fields top-level, ONLY custom
// fields in `metadata`.
results.push(this.hydrateVerbWithMetadata(verb, metadata))
}
}
@ -4023,22 +3876,9 @@ export abstract class BaseStorage extends BaseStorageAdapter {
if (verb.targetId === targetId) {
const metadataPath = getVerbMetadataPath(verb.id)
const metadata = await this.readCanonicalObject(metadataPath)
results.push({
...verb,
subtype: (metadata as any)?.subtype as string | undefined,
weight: metadata?.weight,
confidence: metadata?.confidence,
createdAt: metadata?.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata?.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata?.service,
createdBy: metadata?.createdBy,
metadata: metadata || {} as VerbMetadata
})
// Canonical hydration — reserved fields top-level, ONLY custom
// fields in `metadata`.
results.push(this.hydrateVerbWithMetadata(verb, metadata))
}
} catch (error) {
// Skip verbs that fail to load
@ -4079,32 +3919,15 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// Filter by verb type
if (hnswVerb.verb !== verbType) continue
// Load metadata separately (optional)
// Load metadata separately (optional), then combine via the
// canonical hydration helper (defensive vector copy preserved)
const metadata = await this.getVerbMetadata(hnswVerb.id)
// Extract standard fields from metadata to top-level
const metadataObj = (metadata || {}) as VerbMetadata
const { subtype, createdAt, updatedAt, confidence, weight, service, data, createdBy, _rev, ...customMetadata } = metadataObj
const verbWithMetadata: HNSWVerbWithMetadata = {
id: hnswVerb.id,
vector: [...hnswVerb.vector],
connections: hnswVerb.connections, // Already deserialized
verb: hnswVerb.verb,
sourceId: hnswVerb.sourceId,
targetId: hnswVerb.targetId,
subtype: subtype as string | undefined,
createdAt: (createdAt as number) || Date.now(),
updatedAt: (updatedAt as number) || Date.now(),
confidence: confidence as number | undefined,
weight: weight as number | undefined,
service: service as string | undefined,
data: data as Record<string, any> | undefined,
createdBy,
metadata: customMetadata
}
verbs.push(verbWithMetadata)
verbs.push(
this.hydrateVerbWithMetadata(
{ ...hnswVerb, vector: [...hnswVerb.vector] },
metadata
)
)
} catch (error) {
// Skip verbs that fail to load
}