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

@ -56,6 +56,10 @@ import type {
Relation,
Result
} from '../types/brainy.types.js'
import {
splitNounMetadataRecord,
splitVerbMetadataRecord
} from '../types/reservedFields.js'
import { v4 as uuidv4 } from '../universal/uuid.js'
import { SpeculativeOverlayError } from './errors.js'
import type { GenerationStore } from './generationStore.js'
@ -544,20 +548,38 @@ export class Db<T = any> {
for (const op of ops) {
switch (op.op) {
case 'add': {
// Reserved-field normalization — mirror of the brain.transact()
// write path: user-settable fields lift to their dedicated field
// (top-level wins), system-managed fields drop, and the entity's
// metadata bag carries ONLY custom fields. Speculative views skip
// the one-shot warnings — committing the same ops through
// `brain.transact()` warns on the real write path.
const { reserved, custom } = splitNounMetadataRecord(
op.metadata as Record<string, unknown> | undefined
)
const confidence =
op.confidence ?? (typeof reserved.confidence === 'number' ? reserved.confidence : undefined)
const weight =
op.weight ?? (typeof reserved.weight === 'number' ? reserved.weight : undefined)
const subtype =
op.subtype ?? (typeof reserved.subtype === 'string' ? reserved.subtype : undefined)
const service =
op.service ?? (typeof reserved.service === 'string' ? reserved.service : undefined)
const id = op.id ?? uuidv4()
const now = Date.now()
overlay.nouns.set(id, {
id,
vector: op.vector ?? [],
type: op.type,
...(op.subtype !== undefined && { subtype: op.subtype }),
...(subtype !== undefined && { subtype }),
data: op.data,
metadata: (op.metadata ?? {}) as T,
...(op.service !== undefined && { service: op.service }),
metadata: custom as T,
...(service !== undefined && { service }),
createdAt: now,
updatedAt: now,
...(op.confidence !== undefined && { confidence: op.confidence }),
...(op.weight !== undefined && { weight: op.weight }),
...(confidence !== undefined && { confidence }),
...(weight !== undefined && { weight }),
_rev: 1
})
break
@ -567,18 +589,28 @@ export class Db<T = any> {
if (!base) {
throw new Error(`with(): entity ${op.id} not found at generation ${this.gen}`)
}
// Same reserved-field normalization as the committed update path.
const { reserved, custom } = splitNounMetadataRecord(
op.metadata as Record<string, unknown> | undefined
)
const confidence =
op.confidence ?? (typeof reserved.confidence === 'number' ? reserved.confidence : undefined)
const weight =
op.weight ?? (typeof reserved.weight === 'number' ? reserved.weight : undefined)
const subtype =
op.subtype ?? (typeof reserved.subtype === 'string' ? reserved.subtype : undefined)
const mergedMetadata =
op.merge !== false
? ({ ...(base.metadata as object), ...(op.metadata as object) } as T)
: ((op.metadata ?? base.metadata) as T)
? ({ ...(base.metadata as object), ...custom } as T)
: ((op.metadata !== undefined ? custom : base.metadata) as T)
overlay.nouns.set(op.id, {
...base,
...(op.type !== undefined && { type: op.type }),
...(op.subtype !== undefined && { subtype: op.subtype }),
...(subtype !== undefined && { subtype }),
...(op.data !== undefined && { data: op.data }),
...(op.vector !== undefined && { vector: op.vector }),
...(op.confidence !== undefined && { confidence: op.confidence }),
...(op.weight !== undefined && { weight: op.weight }),
...(confidence !== undefined && { confidence }),
...(weight !== undefined && { weight }),
metadata: mergedMetadata,
updatedAt: Date.now(),
_rev: (base._rev ?? 1) + 1
@ -617,17 +649,32 @@ export class Db<T = any> {
}
if (duplicate) break
// Reserved-field normalization — relationship mirror of the add
// op above (and of the committed relate() path).
const { reserved, custom } = splitVerbMetadataRecord(
op.metadata as Record<string, unknown> | undefined
)
const confidence =
op.confidence ?? (typeof reserved.confidence === 'number' ? reserved.confidence : undefined)
const weight =
op.weight ?? (typeof reserved.weight === 'number' ? reserved.weight : undefined)
const subtype =
op.subtype ?? (typeof reserved.subtype === 'string' ? reserved.subtype : undefined)
const service =
op.service ?? (typeof reserved.service === 'string' ? reserved.service : undefined)
const id = uuidv4()
overlay.verbs.set(id, {
id,
from: op.from,
to: op.to,
type: op.type,
...(op.subtype !== undefined && { subtype: op.subtype }),
weight: op.weight ?? 1.0,
...(subtype !== undefined && { subtype }),
weight: weight ?? 1.0,
...(confidence !== undefined && { confidence }),
...(op.data !== undefined && { data: op.data }),
metadata: op.metadata,
...(op.service !== undefined && { service: op.service }),
metadata: custom as T,
...(service !== undefined && { service }),
createdAt: Date.now()
})
if (op.bidirectional) {
@ -637,11 +684,12 @@ export class Db<T = any> {
from: op.to,
to: op.from,
type: op.type,
...(op.subtype !== undefined && { subtype: op.subtype }),
weight: op.weight ?? 1.0,
...(subtype !== undefined && { subtype }),
weight: weight ?? 1.0,
...(confidence !== undefined && { confidence }),
...(op.data !== undefined && { data: op.data }),
metadata: op.metadata,
...(op.service !== undefined && { service: op.service }),
metadata: custom as T,
...(service !== undefined && { service }),
createdAt: Date.now()
})
}