Brainy Brainy
Docs Brainy

The Brainy API Contract — version 2

In this section

Brainy ships as two engines. Open Brainy (@soulcraftlabs/brainy, MIT) is the reference implementation. Brainy (@soulcraft/brainy) is the native engine. Until now they were coupled as a pair: a native release was pinned to one reference-engine version, and the fleet gated on that tuple. A pair is not a contract — it says these two builds were tested together, never this is what a Brainy is.

This page is the contract. It states what every conforming implementation must do, in terms no implementation owns. The version number below is what the fleet gates on.

contract version: 2

Read it from code instead of from this page:

import engine from '@soulcraft/brainy'
engine.contractVersion()          // 1 — the engine plugin's door

import { Brainy } from '@soulcraft/brainy'
new Brainy(config).contractVersion()   // 1 — the API layer's doorTS

and from packaging:

// @soulcraft/brainy's package.json
{ "brainyContract": 1 }JSONC

The machine-readable form of everything below is docs/api-contract.json, emitted from src/contract/contract.ts by scripts/contract/emit-manifest.mjs. That TypeScript module — not this page, and not either engine's .d.ts — is the source of truth; this page is its prose, and the JSON is its wire form.

§0 What "conforming" means

An implementation conforms to contract 2 when:

  1. Every REQUIRED door in §2–§10 exists with the stated shape.

  2. Every stated semantic holds — the ordering laws, the merge law, the cascade law, the visibility law, the refusal law.

  3. Every error class in §11 is exported under its stated name and thrown under its stated condition.

  4. The operator law (§12), the field-addressing law (§13) and the health law (§14) hold exactly, including their documented divergences.

  5. It answers contractVersion() === 2.

An OPTIONAL door may be absent. Optional is not "second class" — it is the honest marker for a door the convergence program has not yet committed to owning in both engines. Promoting an optional door to required is a MAJOR change (§1).

What conformance does NOT promise

  • Not identical ids. relate() mints a relation id per call. Two engines seeded with identical inputs agree on (from, to, type, subtype), never on the relation's id. Compare edges by that tuple.

  • Not identical tie-breaks in vector top-k. Two rows at exactly equal distance may be ordered either way. What IS promised: the result COUNT is the same, and every returned id is a member of the tied set — never a different row.

  • Not identical performance. The contract is about answers.

§1 The compatibility rule

Change

Version move

A new OPTIONAL door

minor — additive

An operator moving from refused to served

minor — additive

A new error class

minor — additive

A door removed

major

A door's answers narrowed

major

An ordering or merge law changed

major

An OPTIONAL door promoted to REQUIRED

major

An operator moving from served to refused

major

contractVersion() answers the MAJOR number — that is the number a fleet service gates on, because a minor move can never break it. Additive changes within a major are visible in the emitted manifest's door list, which src/contract/contract.test.ts diffs against a fresh emit; a contract change that forgets to re-emit reds the gate.

Contract 2 is additive for CALLERS. The move from 1 to 2 promoted three temporal doors (asOf, diff, history) from optional to required. That is a major move because it raises the bar on an IMPLEMENTATION — an engine that omitted them no longer conforms — but it takes nothing away from a caller: every door that answered under contract 1 answers the same way under contract 2, and code written against 1 needs no change. The frozen reference engine (Open Brainy 10.4.13) declares contract 1 and stays there under decision open-brainy-freeze; it remains the format's exit door, read at the contract it shipped, and check:contract reports that divergence as information rather than failure.

§2 Lifecycle

Door

Shape

Req

init

init(overrides?: Partial<BrainyConfig>): Promise<void>

required

close

close(): Promise<void>

required

flush

flush(): Promise<void>

required

clear

clear(): Promise<void>

required

use

use(plugin: BrainyPlugin): this

required

getActivePlugins

getActivePlugins(): string[]

required

isInitialized

get isInitialized(): boolean

required

ready

get ready(): Promise<void>

required

newId

newId(): string

required

init() is idempotent and polymorphic. Calling it twice is a no-op, and every door that needs an open brain reaches it lazily through the same override chain — so a subclass that gates on init() gates the lazy path too.

close() releases every hold on the storage root: the writer lock, open file descriptors, and mmap regions. A conforming implementation can be reopened on the same root by a fresh process immediately after close() resolves.

flush() is the durability boundary. Once it resolves, every accepted write is visible to a fresh reader of the same root. Writes are durable at their own cadence too; flush() is how a caller demands the boundary now.

§3 Write

Door

Shape

Req

add

add(params: AddParams<T>): Promise<string>

required

update

update(params: UpdateParams<T>): Promise<void>

required

remove

remove(id: string): Promise<void>

required

addMany

addMany(params: AddManyParams<T>): Promise<BatchResult<string>>

required

updateMany

updateMany(params: UpdateManyParams<T>): Promise<BatchResult<string>>

required

removeMany

removeMany(params: RemoveManyParams): Promise<BatchResult<string>>

required

The id law. A caller-supplied id is honored exactly. An omitted one is minted by the engine.

The merge law. update() MERGES: keys present in the supplied metadata replace their stored values, keys absent from it survive untouched, and _rev advances by exactly one. There is no replace-mode update.

The cascade law. remove(id) deletes the entity AND every relation incident to it. After it resolves, related({ node: id }) answers [] — not a dangling edge, not a relation pointing at a missing endpoint. Removing an id that does not exist is a no-op, never an error.

The projection law. When add() resolves, every derived projection the engine serves reflects the write — with ONE exception: the semantic (vector) projection when the vector is embedded asynchronously. waitForIndexed() (§10) is the door for that case; the alternative — a read that silently answers from a projection that has not caught up — is banned.

§4 Read

Door

Shape

Req

get

get(id: string, options?: GetOptions): Promise<Entity<T> \| null>

required

batchGet

batchGet(ids: string[], options?: GetOptions): Promise<Map<string, Entity<T>>>

required

find

find(query: string \| FindParams<T>): Promise<Result<T>[]>

required

similar

similar(params: SimilarParams<T>): Promise<Result<T>[]>

required

counts

get counts(): { entities(); relationships(); byType(); bySubtype(); topTypes(); … }

required

explain

explain(params: FindParams<T>): Promise<{ query; fieldPlan; warnings }>

required

pagination

get pagination(): { find(); count(); meta() }

optional

streaming

get streaming(): { entities(); search(); relationships(); pipeline(); process() }

optional

The absence law. get() answers null for a row that does not exist — it does not throw, and it never answers a partially-populated entity. batchGet() OMITS missing ids from its map rather than mapping them to null.

The three-state law for find() (this is the load-bearing one):

A query that cannot be answered THROWS. If you get [], the query resolved and honestly matched nothing. If you get results, they are ordered by what you asked for. There is no third state.

An unresolvable field name, an unserviceable operator, or an option the engine cannot honor is a typed refusal (§11, §12, §13) — never an empty array and never a silent fallback to insertion order. Accepted-and-ignored options are banned under the same law.

The ordering law.

  • Entities MISSING the orderBy field (or holding null) sort last in both directions — never dropped from the result.

  • Ties break by id ascending, in both directions. Ordering is fully deterministic.

  • With no orderBy and no vector, result order is unspecified. Compare result SETS across implementations, not sequences.

The visibility law. Every entity carries one of three tiers:

Tier

Default find()

Reached by

public

visible

always

internal

hidden

{ includeInternal: true }

system

hidden

{ includeSystem: true }

system is the engine's own tier. A consumer cannot create a system-tier row through AddParams.visibility; the VFS root (00000000-0000-0000-0000-000000000000) is the well-known member every conforming brain holds.

counts.entities() and counts.relationships() are O(1) ledger reads, not scans, and the by-dimension doors read the same ledger. A count that would require a store walk to be exact is a health question (§14), not a count.

§5 Graph

Door

Shape

Req

relate

relate(params: RelateParams<T>): Promise<string>

required

unrelate

unrelate(id: string): Promise<void>

required

updateRelation

updateRelation(params: UpdateRelationParams<T>): Promise<void>

required

related

related(paramsOrId?: string \| RelatedParams): Promise<Relation<T>[]>

required

relateMany

relateMany(params: RelateManyParams<T>): Promise<string[]>

required

graph

get graph(): GraphApi<T>

optional

auditGraph

auditGraph(options?): Promise<GraphAuditReport>

optional

Traversal directionality. related({ node }) is UNDIRECTED — every edge touching the node, in either direction. related({ from }) and related({ to }) are directed. type and subtype narrow the edge set. A node with no edges answers [].

Relation identity. relate() mints the relation id; a caller cannot supply one. Identity is the full four-part tuple (from, to, type, subtype) — an ABSENT subtype is its own identity value, never confused with any particular non-empty subtype string. A call naming a subtype matches only an edge carrying that exact subtype; a call with no subtype matches only an edge with none. relate() (and relateMany(), and transact()'s relate op) is IDEMPOTENT on this identity: a call whose identity already exists returns the EXISTING relation's id — never an error, never a second edge. Cross-implementation comparison is by the same tuple (see §0).

Reads and removals that name no subtype address EVERY edge of the named type. related({ from, to, type }) with no subtype returns every edge of that type between the pair, whatever subtype (or none) each carries; adding subtype narrows to the one identity. The same rule governs a removal door shaped (from, to, type, subtype?): omitting subtype removes every edge of type; naming one removes only that edge. unrelate(id) is unaffected — it already addresses one relation by its own id, never by identity.

updateRelation() MOVES an edge's identity; it never merges or duplicates. Changing type and/or subtype moves the row onto the new identity in place — same id, same metadata, same history, now answering to (from, to, newType, newSubtype). If another relation already holds that target identity, the move is refused with RelationIdentityConflictError (§11) and NEITHER edge is touched: never a silent merge into the existing edge, never a second row beside it. A plain weight / confidence / data / metadata update, changing neither type nor subtype, never consults this check.

§6 Transact

Door

Shape

Req

transact

transact(ops: TxOperation<T>[], options?: TransactOptions): Promise<Db<T>>

required

now

now(): Db<T>

required

generation

generation(): number

required

asOf

asOf(target, options?): Promise<Db<T>>

required

diff

diff(a, b): Promise<DiffResult>

required

history

history(id, options?): Promise<EntityHistory>

required

The batch law. transact() applies its operations in order and atomically: either every operation commits or none does. Later operations in one batch OBSERVE earlier ones — [{op:'add', id, …}, {op:'update', id, …}] commits one row carrying the merged result, and the returned Db's receipt names both operation ids.

The generation law. generation() is monotonic and advances on every committed write. now() returns an immutable Db value pinned to the current generation; a Db never changes underneath its holder.

The time-travel law. asOf, diff and history are REQUIRED doors. A read through an asOf view answers as of its generation on EVERY leg — the metadata filter sees the metadata committed then, the graph hop sees the edges that existed then, and the vector leg ranks by the embeddings carried then. A row born after the pinned generation is absent; a row removed after it is present. An implementation that cannot answer a requested generation REFUSES (GenerationCompactedError, or a typed retention refusal naming the oldest answerable generation) — it never answers from current state without saying so.

The Db value itself. now(), transact() and asOf() all return a Db<T> — a readonly view pinned at one generation. It serves the FULL find() surface at that pin, not a reduced one: metadata filtering, graph traversal, vector and semantic search, cursor pagination, AND find({ aggregate }) all answer as of the pinned generation, exactly as they would against the live brain at "now". A Db pin holds compactHistory() back from reclaiming that generation's history until the pin is released — call release() on the Db when you are done with it. A FinalizationRegistry backstop releases a leaked pin at garbage-collection time, but explicit release is what makes compactHistory()'s timing deterministic rather than dependent on the collector.

These three were optional through contract 1. They are required from contract 2 because the product engine now serves the whole of asOf natively, including the unfiltered semantic leg, and a database whose history is optional is not one callers can build on. The frozen reference engine (Open Brainy 10.4.13) implements the PREVIOUS contract (1), where they were optional; it remains the format's exit door and is not expected to move — read it as the reader of last resort, not as a conforming implementation of contract 2.

CAS. A write carrying an expected _rev that no longer matches the stored revision throws RevisionConflictError — it never overwrites. A transaction committed against a generation that has since advanced incompatibly throws GenerationConflictError. A pinned generation that has been compacted away throws GenerationCompactedError. All three are refusals, never silent last-write-wins.

§7 Aggregate

Door

Shape

Req

defineAggregate

defineAggregate(def: AggregateDefinition): void

required

queryAggregate

queryAggregate(name, params?): Promise<AggregateResult[]>

required

removeAggregate

removeAggregate(name: string): void

required

trackField

trackField(name, options?): void

optional

An aggregate is incrementally maintained, not computed at query time: a write that changes a member row updates the aggregate's state on the write path. queryAggregate() reads that state.

The where inside an AggregateSource obeys the SAME operator law as find() (§12) — identical vocabulary, identical verdicts, identical refusals. An operator that refuses in find() refuses here too.

§8 Embed

Door

Shape

Req

embed

embed(data: any): Promise<Vector>

required

embedBatch

embedBatch(texts: string[], options?): Promise<Vector[]>

required

similarity

similarity(textA: string, textB: string): Promise<number>

optional

embed() produces exactly the vector add() would store for the same data. embedBatch() returns one vector per input, in input order.

The contract does NOT fix the embedding model or the vector dimensionality — those are per-brain configuration, and two brains with different embedders are both conforming. What the contract fixes is that a brain's embedder is consistent with itself: the vector embed(x) returns is the vector a subsequent add({ data: x }) stores.

§9 VFS

Door

Shape

Req

vfs

get vfs(): VirtualFileSystem

required

The virtual filesystem is stored in the same brain as system-tier entities, rooted at the well-known id 00000000-0000-0000-0000-000000000000. Its rows are therefore reachable through find({ includeSystem: true }) on any conforming implementation — which is exactly why the root id is part of the contract rather than an implementation detail.

Working-state files

vfs.writeFile(path, data, { retention: 'latest-only' }) — and appendFile, which shares the same option — is a second write CLASS for a path, alongside the default 'history' class every path has had until now. It exists for a row written often and never needed historically: a co-editing session's live CRDT snapshot, a presence heartbeat, anything whose only interesting state is the newest one.

A 'latest-only' write:

  • Replaces the content and releases the prior blob at once. After N overwrites, exactly one blob is retained for the path — verified through the blob store's own reference accounting, never the filesystem.

  • Keeps no before-image and no history generation record for the row. readFile(path, { asOf }) and history(path) refuse by name (WorkingStateHasNoHistoryError, §11) rather than fabricate a temporal answer they cannot honestly give; the store-wide temporal reads (diff, changedBetween) simply never mention the row, for the same reason — there is nothing to report, not something withheld.

  • Never embeds — not even the lightweight descriptor a binary file's overwrite gets under the default class.

  • Emits no change-feed event for the write. The generation itself still advances (ordering and durability are exactly as strong as any other write); only the retention is skipped.

  • Carries workingState: true in the row's metadata, so a reader can tell.

The class is sticky per path, and the first write decides it. Once a path is 'latest-only', a later write that does not repeat retention: 'latest-only' — including an ordinary writeFile(path, data) call with no options, which is what every other path has always accepted — refuses by name rather than silently starting (or silently continuing to skip) retention. retention: 'history' is the explicit, one-time door back to the default class.

The same primitive is available one level down, off the VFS surface, as retention?: 'history' | 'latest-only' on AddParams/UpdateParams: it controls history retention and the change-feed event for that one write, with none of writeFile's per-path stickiness (a bare add()/update() call has no path to be sticky about).

§10 Health and repair

Door

Shape

Req

health

health(): Promise<{ overall; checks }>

required

getIndexStatus

getIndexStatus(): Promise<{ initialized; projections; … }>

required

repairIndex

repairIndex(options?): Promise<RepairReport>

required

diagnostics

diagnostics(): DiagnosticsResult

required

requireProviders

requireProviders(keys: string[]): void

required

storageAdapter

get storageAdapter(): BaseStorage

required

warm

warm(): Promise<WarmReport>

optional

maintenanceDebt

maintenanceDebt(): Promise<MaintenanceDebtReport>

optional

waitForIndexed

waitForIndexed(path?, opts?): Promise<void>

optional

stats

stats(): Promise<BrainyStats>

optional

The semantics are ADR-008's, restated here as contract rather than as one engine's design (docs/ADR-008-index-health-and-repair.md):

  • Health is accounting, not sampling (D1). Every projection maintains an exact coverage ledger on its write path and reports health as a subtraction. A sampled probe may be a diagnostic; it may never be a verdict.

  • Verdicts are graded and named (D2). health().checks names every condition; isReady()-style readiness is a VIEW of the same report. There are no unnamed latches.

  • Rebuild is online and generational (D3). New structures are built beside the serving ones and swapped atomically. Reads never go dark for a rebuild.

  • Heal is incremental, and never read-triggered (D4). The common cure is re-posting the named missing items — O(missing), not O(store). A read path may refuse loudly; it may never start a store walk. repairIndex() is the one explicit operator door.

  • A throwing probe is heal: 'none' (D5), with the error in detail. Flakiness can never buy a rebuild.

The serving law (the D2/D4 amendment of 2026-08-26, co-signed by both engines): heal grades one question only — could an answer be WRONG? — never how expensive is the fix? Serving is withheld ONLY by these named, rebuild-graded invariants:

index-initialized · durable-state-present · manifest-residency ·
replay-clean · strand-latch

strand-latch withholds serving only for the lost-mapper, torn-manifest, replay-unclean and archive-orphan classes. Everything else — a saturated vector repair, a coverage shortfall, a short generation during an online rebuild — is repair-graded: healthy: false, the row red with the number, and serving: true. An implementation that withholds serving for any other reason is non-conforming; that exact defect took two fleet services down in one day before the amendment.

requireProviders(keys) THROWS when any named key fell back to the engine default. It is the structural gate the native package's loud-refusal law is built on — diagnostics() reports, requireProviders() enforces.

Relation-identity duplicates (12.9.1) are counted, never scanned for on a read path. repairIndex() (the one explicit operator door, per D4) walks every relation grouped by (from, to, type, subtype) and counts groups holding more than one edge — data that predates the identity fix above, or a concurrent-write race the fix's read-then-write duplicate check cannot close. health() reports that pass's own numbers as the relation-identity- duplicates check (warn when a pass found any, pass with the count when a pass found none, and a named "no pass yet" pass before the first repairIndex() run) — it never re-walks itself. This is accounting, not repair: a duplicate group is never dropped or merged, and there is no repair door for it yet.

§11 Error classes

Every name below is an EXPORTED class of a conforming implementation's public surface — a caller cannot instanceof-narrow a refusal it cannot import. BrainyError is the base every engine refusal extends.

Class

Thrown when

BrainyError

base class for every engine refusal

EntityNotFoundError

a door requiring an existing entity gets an id that does not exist (not get(), which answers null)

RelationNotFoundError

a door requiring an existing relation gets an id that does not exist

RelationIdentityConflictError

updateRelation() changes type/subtype and the resulting (from, to, type, subtype) identity is already held by a DIFFERENT relation

UnresolvableFieldError

a field name resolves to neither user metadata nor one of the ten system.* scalars

InvalidFieldAddressError

a field address is not addressable at all (an unknown system.*, or a plumbing name)

UnsupportedFindOptionError

a find() option the engine cannot honor is supplied

RevisionConflictError

a CAS write loses the race

GenerationConflictError

a transaction is committed against an incompatibly-advanced generation

GenerationCompactedError

a pinned generation has been compacted away

SpeculativeOverlayError

a speculative overlay could not be reconciled with the committed log

StoreInconsistentError

canonical storage and the committed log disagree irreconcilably

PendingFlushDurabilityError

durability was requested for writes a pending flush could not make durable

CanonicalEnumerationUnavailableError

a maintenance walk cannot enumerate canonical rows

VectorIndexNotReadyError

the vector projection cannot be trusted to answer

MetadataIndexNotReadyError

the metadata projection cannot be trusted to answer

GraphIndexNotReadyError

the graph projection cannot be trusted to answer

DerivedArtifactMissingError

a required derived artifact is absent and cannot be rebuilt in place

ProtectedArtifactError

a write would damage an engine-protected artifact

MigrationInProgressError

a door is called while an on-disk migration holds the brain

WaitForIndexedTimeoutError

waitForIndexed() reached its deadline

TornRecordError

a canonical record on disk is partially written

BrainyUnlicensedError

the licensed product's own layer refuses to open unlicensed, invalid, or past its coverage window — Open Brainy never throws it (no license concept), but exports the class so a caller can instanceof-narrow it against either engine

StorePersistenceLostError

a memtable shard's persistence layer proves unreliable (its directory is gone, or its append log I/O-faulted) and the native engine poisons that one brain for writes rather than corrupting the recovery contract or aborting the process for every brain it serves — Open Brainy never throws it (no native memtable, nothing that poisons this way), but exports the class for the same instanceof-narrowing reason as BrainyUnlicensedError; reads keep serving, only writes refuse, until the brain is reopened

WorkingStateHasNoHistoryError

vfs.readFile(path, { asOf }) or vfs.history(path) is called on a working-state path (WriteOptions.retention: 'latest-only') — one that by construction never retained a before-image or a history generation record for any write — refused by name rather than a fabricated temporal answer

MemoryAuthorMismatchError

a brain.memory write names an author (or a legacy by) that disagrees with the identity the call is actually attributed to — a hosted body claiming a different author than the credential authenticated, or one call's author and by naming two different actors — Open Brainy never throws it (no brain.memory namespace at all), but exports the class for the same instanceof-narrowing reason as BrainyUnlicensedError; nothing is written when it refuses

EntitlementExpiredError

a minted bk1 token's optional entitlement claim reads "expired" and the door it was presented to mutates the store — every WRITE door, and every RELAY door (a session door, which wants the same write posture). Reads and export are never gated by it, and a token carrying no entitlement claim at all is unaffected — absence is today's law, unchanged. On a token minted for someone other than the brain's own owner (a grant), the claim already carries the OWNER's own plan, set by the issuer at mint time. Open Brainy never throws it (no entitlement concept), but exports the class for the same instanceof-narrowing reason as BrainyUnlicensedError

EmbedServiceNotAdmittedError

a discovered embed service (the conventional fleet/customer name, or BRAINY_EMBED_ENDPOINT) answered 401/403 to the identity handshake, and the store's first bulk embed call since — addMany()/import()'s bulk door, or the re-embed ceremony — asks it to embed something; names the endpoint, the status and BRAINY_EMBED_TOKEN, never a silent CPU fallback ("I found a service but can't authenticate" and "there is no service here" are different facts) — the explicit opt-out is BRAINY_EMBED_ENDPOINT=local — Open Brainy never throws it (no discovered-embedding-service concept at all), but exports the class for the same instanceof-narrowing reason as BrainyUnlicensedError; nothing is embedded, locally or remotely, when it refuses. The per-row deferred-embed drain and a single add()'s synchronous embed stay on the query path (local by default regardless of admission) and never reach this refusal.

§12 The operator law

Full accounting: docs/filter-operator-conformance.md. The contract's binding statement is the jointly frozen set and its three verdicts — served, served beyond the baseline, refused by name. There is no fourth verdict, and a quiet wrong answer is never one of them.

Served (both engines answer identically) — 18 tokens:

eq · equals · ne · notEquals · in · oneOf · gt · greaterThan ·
gte · greaterThanOrEqual · lt · lessThan · lte · lessThanOrEqual ·
between · contains · exists · missing

Combinators (clause-level, served by both) — 3 tokens:

allOf · anyOf · not

Served beyond the baseline — the native engine answers these three posting-set expressions exactly (hasAll = intersection, noneOf = universe minus union, excludes = negation of contains); the reference engine's index path answers all three with [] today. The native engine is the correct side, and this is a RULED divergence, pinned on both sides:

hasAll · noneOf · excludes

Refused by name — the native engine THROWS, naming the operator and the field; the reference engine's find() resolves with []:

startsWith · endsWith · length · matches

The refusal is a proof, not a shortfall: the metadata index stores normalized posting keys (lowercased, trimmed, one posting per array element, canonical numbers), which provably erases the distinctions those four operators read. matches carries a second, independent reason — JavaScript RegExp and Rust's regex are different dialects. The durable cure is to make the predicate's answer an indexed value at write time and filter on it with a served operator.

The vocabulary is closed. An operator token outside these 25 value operators is not "unsupported" — it is a typo, and a conforming implementation refuses it by name rather than matching nothing.

Known divergence — negation with case or padding. The native engine's metadata index normalizes a posted string (lowercase + trim); the reference engine's does not. Positive operators are blind to this (the index answers a superset, find() narrows it exactly), but a NEGATION (ne / noneOf / excludes) on a string field whose only difference from the operand is letter case or surrounding whitespace drops rows the reference engine keeps. Ruled, documented, pinned on both sides.

§13 The field-addressing law

Full text: docs/field-addressing.md. One rule, no exceptions:

A bare field name always means user metadata. Engine scalars are reached explicitly as system.<field>.

Origin — never name — decides the storage key. An engine scalar can only land on a system.* key; a user metadata field can only land on a bare key. The two never merge, which is what makes the rule enforceable at the storage layer rather than by resolution priority. A user field named level, createdAt or type is ordinary user data and always works.

The ten entity scalars (system.<name>):

id · type · subtype · createdAt · updatedAt ·
confidence · weight · visibility · service · createdBy

The relation scalars (system.<name>):

verb · sourceId · targetId · subtype · createdAt · updatedAt ·
confidence · weight · visibility · service · createdBy

Invisible plumbing — never resolvable, never indexable, never orderable, not bare and not through system.:

vector · connections · level · data · _rev

Reserved namespace. A user metadata field whose name would flatten into system.* (a field literally named system.foo, or an object field named system) is REFUSED at write time. A scalar field named exactly system is ordinary user data.

The refusal names both candidates. system.<anything else> does not exist and refuses. An unresolvable bare name refuses with a message naming both the missing user field and the system.* correction.

§17 Profile doors — the accord, memory and session profiles

A store that DECLARES a record profile gains doors for it. declareProfile() persists the shape with the store (_system/record-profiles.json), and the engine then serves that shape's own namespace: brain.accords.* for the accord profile, thirteen doors over the coordination record — threads, rounds, decisions, actions and mandates.

These are contract terms with one structural difference from every door in §2–§10, which is why they are a separate family in the manifest (profileDoors, profileErrors) rather than a flag on the existing one:

  • They are not members of Brainy.prototype. A profile door hangs off a namespace accessor and exists only for a store that declared the profile.

  • They are the PRODUCT engine's. The frozen reference engine (Open Brainy 10.4.13) declares contract 1 and will never grow one. Declaring them among the engine doors would assert something untrue about a shipped surface, and the drift pin that proves every door exists on the reference prototype would have to be weakened to let them through — which is exactly the pin worth keeping.

They are OPTIONAL. That is the honest requirement today: they land natively in the write execution's own release (Mon 2026-09-28), and a build that declared them REQUIRED before it served them would be claiming conformance it does not have. A new optional door is a MINOR move under §1's table, so publishing these shapes does not move the version at all; promoting them to REQUIRED is the MAJOR move, and it is taken when the native doors ship, not when their shapes are written down.

The doors

Door

Kind

Enforces

Refuses with

postRound

write

R1 R2 R3 R4 R7 R9 R11 R12 R17 S1 S6

RoundsMovedError, NotMyFlagError, IllegalTransitionError, AskUnaddressedError, InvalidDateError, ConcurrentWriteError, DanglingCitationError, RecordProfileError

postUpdate

write

R1 R9 R12 R17 S1 S6

AskUnaddressedError, InvalidDateError, ConcurrentWriteError, DanglingCitationError, RecordProfileError

file

write

R4 R6 R9 R11 R12 R17 S1 S2 S6

IdPrefixError, NotMyFlagError, AskUnaddressedError, InvalidDateError, ConcurrentWriteError, DanglingCitationError, RecordProfileError

decide

write

R9 R12 S1 S2 S3 S6 S8

RecommendationError, InvalidDateError, ConcurrentWriteError, DanglingCitationError, RecordProfileError

vote

write

R12 S1 S4

ConcurrentWriteError, RecordProfileError

record

write

R9 R10 R12 R13 R14 S7

OverrideForbiddenError, AutopilotStaleError, DeletionRefusedError, MandateAmbiguousError, InvalidDateError, ConcurrentWriteError

resolve

write

R5 R7 R9 R10 R12 R15 R18 S2

ConsentIncompleteError, NotMyResolveError, IllegalTransitionError, NotALabRecordError, DeletionRefusedError, InvalidDateError, ConcurrentWriteError, RecordProfileError

escalate

write

R7 R8 R10 R12 S2

IllegalTransitionError, DeletionRefusedError, ConcurrentWriteError, RecordProfileError

inbox

read

R8 R16 S7

MandateAmbiguousError, NotMyPresenceError

dashboard

read

R8 S7

MandateAmbiguousError

search

read

read

read

S5

TimelineCorruptError

timeline

read

S5

TimelineCorruptError

A RESOLVED thread freezes rounds. Posting to one is refused by name, carrying the thread's id. The one deliberate move out is a round that carries reopen: true and a reason: it flips the thread back to OPEN, records who reopened it and why in the timeline, and lands the round — all in the same transaction, so a thread's history and its status can never disagree. A reopen with no reason is refused, and anything about a NEW matter files a new record.

The rule ids are the accord protocol's own, written up in docs/accords-profile.md §R. The manifest carries each profile door's FIELD TABLE — every argument as a named field, with its type, whether it is required and a one-line description — as the source of truth; the door's printed signature is GENERATED from that table, never hand-typed beside it. This table is the readable form of the same data, and src/contract/contract.test.ts proves the two agree, including a round-trip pin per door: table → generated schema → a fixture built from the table validates against that schema and comes back unchanged.

One vocabulary, spoken once

The accord profile's doors speak one vocabulary: actor · id · text · body · title · kind · reason · resolution · via · generation · related · schedule · query · where · limit · scope — the same sixteen words a consumer meets at every door, rather than a different word per door for the same concept. Five fields changed name to reach it (door-names 12.6); the older spellings below are read as an alias of the field beside them, through 12.x:

Older spelling

Current spelling

Where

author

actor

postRound, postUpdate

participant

actor

vote, resolve, escalate

toParticipant (inside asks[])

actor

file (thread filing)

summary

title

file (action filing)

record (on the write receipt)

id

postRound, postUpdate

An alias is never a second, disagreeing field: at most one of a pair need be supplied, and the field table above lists which spelling a generated client's schema currently marks required — the older one, where a caller already built against it, per door.

The refusals

Eighteen classes, one per rule that can refuse — RecommendationError is the one name two rules share (S3 and S8 are the same law about the same field, so a caller writes one handler for both). Every one carries fields beyond its message, because a refusal a caller cannot act on is a crash with extra steps: RoundsMovedError carries the rounds you missed, ConsentIncompleteError carries who has not cleared, IllegalTransitionError carries the transitions that were legal, AskUnaddressedError carries who you named beside who the parties are, and NotMyResolveError carries the owners whose stamp is actually owed. The full list, with what each carries and the status it maps to, is profileErrors in the manifest and §R of the profile page.

The memory profile

A second profile, two doors. brain.memory.turn() records what one exchange was about — the running gist of a conversation, so a later session can find it without replaying it. It is not remember(): a memory is something the brain learned, and a turn is the record of an exchange, addressed by the pair it happened at.

Door

Kind

Enforces

Refuses with

turn

write

M1 M2 M3 M4 M5

StateRequiredError, GistTooLongError, TokenCounterAbsentError, TurnAlreadyRecordedError, SpeakerUnknownError

abstract

write

M6 M7 M8

AbsorbsEmptyError, AbsorbedRowMissingError, AbstractionMovedError

Its five laws, written up in src/memory/rules.ts:

  • M1 — the pair addresses the turn. A turn's id is turn:{conversationId}:{turn}, DERIVED by the engine and never supplied, and the position must be an integer at or above zero. A door handed no record answers StateRequiredError naming the record to read, rather than leaving a caller to compose the id — an id law re-derived by a caller is an id law with two implementations.

  • M2 — an over-long gist is refused, never truncated. Above 512 tokens the call refuses with what was measured and what the budget is. A gist is a claim about what a turn was about; a cut claim reads as a whole one.

  • M3 — a turn happened once. An identical re-send is absorbed as a no-op success, because a network that dropped the ack is indistinguishable from a call that never landed. A DIFFERENT gist for the same pair refuses with TurnAlreadyRecordedError, carrying the gist that is recorded.

  • M4 — the speaker vocabulary is closed: person, agent, tool, system. A recall that filters by who spoke cannot filter on a vocabulary that grows per caller.

Its row is an event with subtype conversation-turn, and asked / done / open are FREE TEXT, each optional and omitted when there is nothing to say — a turn that asked nothing has no answer to the question rather than an empty one. The field names and their closed sets are fixed by the standalone-parity ruling, so a client written against the user's hosted Self reaches this door with the names it already uses.

  • M5 — the counter is the caller's, and it must exist. Token counts are tokenizer-specific, so a door that counted for itself would count in a vocabulary the caller's model does not use. A call with no measuredTokens refuses with TokenCounterAbsentError rather than being measured wrong.

brain.memory.abstract() is the write side of the abstraction tier that context() and collapseTree() already read. It writes a rollup and absorbs the rows it stands for — the abstraction row, the processing.abstractedIntoMemoryId on every absorbed row, and the abstractedInto edges — in ONE transaction pinned to the generation the decision was made at. The words are the caller's: no language model runs inside the engine, so the door lands prose a service produced rather than composing it.

Its three laws:

  • M6 — an abstraction absorbs at least one row. absorbs: [] refuses with AbsorbsEmptyError. A rollup with no members is indistinguishable, to every walk that reads it, from a rollup whose members were lost — accepting one puts a permanent false negative in the store.

  • M7 — every id must resolve, and the refusal names all that do not. AbsorbedRowMissingError carries every missing id rather than the first, so a caller absorbing forty episodes fixes its list in one round trip. Nothing is written.

  • M8 — all three facts land together or none do. A store that committed between the decision and the write refuses with AbstractionMovedError carrying the pinned generation, rather than retrying: one of the rows being absorbed may have been retired in between, and only the caller can decide about that. Without the pin a kill mid-sequence leaves an episode absorbed in the field and not in the graph — which reads as data rather than as damage, and nothing refuses or heals.

Like the accord doors both are OPTIONAL, and for the same reason. Unlike them they execute in the engine's own process today; over a wire they are refused by name, because brainy serve attaches every store read-only and holds no transaction to commit a write in.

The session profile

A third profile, four doors, one for each hook the terminal plugin fires against across a session's life: start (SessionStart), capture (PostToolUse), save (Stop and PreCompact), wrap (SessionEnd). Ruled S1–S5 on the record (action ANIMA-OWNER-LAYER-BEHIND-THE-DOOR) — labelled SN1–SN5 in this manifest's own rule-id namespace only, because S1/S3/S5 already name accord rules above; the ruling's own numbering and every law are otherwise unchanged.

Door

Kind

Enforces

Refuses with

start

relay

SN1 SN2 SN3 SN4 SN5

SessionHookUnknownError, SessionPayloadTooLargeError

capture

relay

SN1 SN2 SN3 SN4 SN5

SessionHookUnknownError, SessionPayloadTooLargeError

save

relay

SN1 SN2 SN3 SN4 SN5

SessionHookUnknownError, SessionPayloadTooLargeError

wrap

relay

SN1 SN2 SN3 SN4 SN5

SessionHookUnknownError, SessionPayloadTooLargeError

A third door KIND, relay — neither read nor write: a session door's body is composed by the key's OWNER LAYER, never by this store, so it writes no entity and needs no transaction to commit one in. write and relay enforce the identical ceremony (a non-empty rule table, a non-empty refusal list) and both want write scope from a minted token — a read-scoped token is refused BRAINY_KEY_SCOPE for either kind, on every server this engine ships. What differs is disposition: a write door may bind NotYetNative or InProcessOnly (there is no transaction here to commit one in) but never Served; a relay door binds ONLY Served — it answers { served: true, … } (the hosted door only) or { served: false, reason } (every self-hosted build) from the engine's own rules, with nothing to commit either way. Unlike the accord and memory profiles, no build of this engine composes a session yet — session composition is the HOSTED door's own work; the shape a relay door answers is the contract term here, not the behavior behind it. Its laws, written up in src/session/rules.ts:

  • SN1 — answer by name. Every session door answers served: true or served: false, never a silent no-op and never a 404 for a known door. A hook outside the five the harness fires refuses SessionHookUnknownError.

  • SN2 — door-side redaction. What a hook sends is redacted by the door before any row is stored, on top of (never instead of) the client's own fixed denylist — the door never asks the plugin for semantics.

  • SN3 — the key decides. What a door composes, captures or saves follows the credential it sees — owner, grant scope, self-hosted host — never anything the request claims about itself.

  • SN4 — thin relay. A hook prints only what the door returns, verbatim; the plugin carries no logic of its own.

  • SN5 — the harness is never wedged. A door failure or timeout is one named stderr line and exit 0 at the hook; the door itself bounds the request at 262,144 bytes (256 KiB) serialized and refuses over-size with SessionPayloadTooLargeError, carrying what was measured and the limit, rather than reading an unbounded body.

Like the other two profiles, OPTIONAL, and for the reason above: promising a required door this build cannot compose would be a false claim.

§14 What the contract does not cover

Deliberately outside contract 2, so it cannot be mistaken for a promise:

  • On-disk layout. Canonical storage layout is docs/canonical-layout-spec.md's subject, versioned separately. Contract 1 is an API contract; two conforming implementations may hold different bytes. The canonical seam (below) is the bridge until Stage 2 moves canonical storage into the native core.

  • Neural import, MCP, integrations, OData. Shipped by the reference engine and re-exported by the native package, but not contract-mandated at version 1.

  • Performance. docs/performance-budget.md carries the CI-enforced latency budget for the native engine. It is a product commitment, not a contract term.

§15 The canonical seam

Until Stage 2 lands the segment-log canonical inside the native core, the native package reaches canonical storage through the reference engine's FileSystemStorage. That dependency is ONE named module — src/api/canonicalSeam.ts — which enumerates the exact adapter methods the engine calls and asserts every one is present at open, refusing by name if the reference engine drops one.

The seam is named so that it can be RETIRED, not so that it can grow. Its surface is frozen at contract 2; adding a method to it is a deliberate, reviewed change, and Stage 2 deletes the module.

§16 How this page stays true

  • src/contract/contract.ts is the source of truth; this page is its prose.

  • scripts/contract/emit-manifest.mjs emits docs/api-contract.json from it.

  • src/contract/contract.test.ts fails red when the committed manifest and a fresh emit disagree, AND when any door named here is absent from the shipped reference-engine surface, AND when the field-addressing vocabulary drifts from its shared catalog, AND when a §17 profile door names a rule or a refusal the profile's own rule table (src/accords/rules.ts) does not declare — in either direction.

  • scripts/contract/check-operator-vocabulary.mjs (part of check:contract, §8 below) fails red when the operator vocabulary drifts from the engine's own VALUE_OPERATORS source — this proof runs at gate time, against a swappable source path, rather than inside a unit test that must read the vendored TypeScript.

  • src/conformance/oracle.test.ts runs one workload against three subjects — the reference engine, the native provider stack, and the one-install package constructed the public way — and asserts the same answers.

  • scripts/check-contract.mjs (npm run check:contract) verifies both engines answer contract 2 before a release.

Finding: four alias spellings in the operator page are not in the shipped vocabulary

docs/filter-operator-conformance.md's table lists is, isNot, greaterEqual and lessEqual as served aliases. Open Brainy 10.4.3's shipped VALUE_OPERATORS set declares 25 tokens and none of those four is among them — the engine throws BrainyError('INVALID_QUERY') on an unrecognized operator, so those four spellings refuse rather than serve. This contract publishes the SHIPPED vocabulary (read from the engine at gate time by scripts/contract/check-operator-vocabulary.mjs, npm run check:contract §8), not the four extra spellings. Filed for the operator page to correct; recorded here so the difference is never discovered as a surprise.