8.4 KiB
title: Field addressing: your fields and system fields slug: concepts/field-addressing public: true category: concepts template: concept order: 7 description: The one rule for every query-surface field name — a bare name always means your metadata, system. reaches the ten engine scalars explicitly, and anything else refuses by name. next:
- concepts/consistency-model
Field addressing: your fields and system fields
Every query surface in Brainy — find()'s where, orderBy, aggregation
groupBy, and aggregation source.where — resolves field names by one rule,
with no exceptions:
A bare field name always means your metadata.
system.<field>reaches an engine scalar, and only when you spell it explicitly.
await brain.find({ orderBy: 'level' }) // reads entity.metadata.level — YOUR field
await brain.find({ orderBy: 'system.createdAt' }) // reads the engine's createdAt scalar
await brain.find({ orderBy: 'metadata.level' }) // identical to bare 'level' — explicit scope
There is no priority list, no "try the system field, fall back to metadata"
behavior, and no name that resolves differently depending on what else
happens to exist on your entities. A field called level, score,
createdAt, or type in your own metadata is read as your field, every
time, by its bare name.
Why this rule exists
An internal report from a production deployment found that a user metadata
field literally named level was being silently shadowed by the engine's
own internal index layer field of the same name — every sort by level
returned insertion order, with no error raised. This rule makes that class of
bug structurally impossible: bare names belong to you, unconditionally, and
anything that isn't yours has to be spelled out.
The system scalars
system.<field> addresses exactly ten scalars on an entity — no more, no
fewer:
| System field | What it is |
|---|---|
system.id |
The entity's id |
system.type |
The entity's NounType |
system.subtype |
The per-app sub-classification passed to add() |
system.createdAt |
When the entity was created |
system.updatedAt |
When the entity was last written |
system.confidence |
The confidence param (0–1) |
system.weight |
The weight param |
system.visibility |
'public' / 'internal' (see the visibility tiers in Consistency Model) |
system.service |
The multi-tenancy service tag |
system.createdBy |
Who/what created the entity |
Relationships mirror the same eight shared scalars (subtype, createdAt,
updatedAt, confidence, weight, visibility, service, createdBy)
plus three of their own:
| System field (relationship) | What it is |
|---|---|
system.verb |
The relationship's VerbType |
system.sourceId |
The id of the entity the relationship starts from |
system.targetId |
The id of the entity the relationship points to |
Anything not on these two lists is not a system scalar — system.<name> for
any other name refuses (see "Refusal semantics" below), even if that name
sounds like it should be engine-owned.
Invisible plumbing — never addressable, in either spelling
Five names are pure engine internals. They are not reachable as a bare name,
and not reachable as system.<name> either — they simply have no place on
the query surface:
vector— the stored embedding. It participates in similarity search (query,near, vectorfind()), never inwhere/orderBy/groupBy.connections— graph adjacency. Reached throughconnectedandbrain.related(), not through field addressing.level— the internal index layer number used by the nearest-neighbor graph. It is pure index plumbing with no query-surface meaning at all — which is exactly why a user field of the same name must never be shadowed by it.levelas a bare name is always yours; there is no engine-owned spelling of it to compete with.data— your entity's content payload, not a scalar. It can be a string, a number, or an arbitrary object, so sorting or filtering it as a single comparable value would lie about its actual shape. Content is reached through the content/text-search APIs (query,searchMode: 'text'), not throughwhere/orderBy._rev— the per-entity revision counter used for optimistic concurrency (ifRev). It is a CAS token, not a queryable dimension.
system.level, system.vector, and system.data all refuse for the same
reason: they are not in the ten-scalar system map, full stop.
metadata.<field> — the explicit spelling of "mine"
Prefix any field with metadata. to say the same thing a bare name already
says, spelled out. The two are interchangeable everywhere a field name is
accepted, including orderBy:
await brain.find({ where: { 'customer.tier': 'gold' } })
await brain.find({ where: { 'metadata.customer.tier': 'gold' } }) // identical
await brain.find({ orderBy: 'metadata.score', order: 'desc' }) // identical to orderBy: 'score'
Reach for the explicit spelling when it reads more clearly next to a
system. field in the same query — for example, sorting by your own score
while filtering on system.confidence.
Refusal semantics
A name that resolves to neither your metadata nor a system scalar is a typed refusal, not a silent empty result and not a guess. Refusals name both candidates, so the fix is always in the error text:
await brain.find({ orderBy: 'createdAt' })
// UnresolvableFieldError: no metadata field 'createdAt' — did you mean
// system.createdAt or metadata.createdAt?
UnresolvableFieldError is exported from the package root:
import { UnresolvableFieldError } from '@soulcraft/brainy'
try {
await brain.find({ orderBy: 'createdAt' })
} catch (err) {
if (err instanceof UnresolvableFieldError) {
// err.message names both candidates — usually enough to fix the call site.
}
}
A handful of find() options are not implemented yet: cursor,
includeRelations, and writeOnly. Rather than accepting them and quietly
ignoring the option, find() refuses with UnsupportedFindOptionError —
also exported from the package root — so a call site can never believe an
unimplemented option took effect when it didn't.
The ordering contract
orderBy behaves identically regardless of which engine (the pure-TypeScript
path or a native accelerator) is serving the query:
- An entity missing the
orderByfield, or holdingnullon it, sorts LAST — in bothascanddesc. It is never treated as "smaller than everything" in one direction and "larger than everything" in the other; it is simply last, either way. - Rows are never dropped from an ordered read because they lack the field — a missing value changes position, never presence.
- Ties on the
orderByfield break by id ascending, regardless of the primary sort direction.
// employees: [{ score: 9 }, { score: 5 }, { /* no score field */ }]
await brain.find({ orderBy: 'score', order: 'desc' }) // [9, 5, missing] — missing is last
await brain.find({ orderBy: 'score', order: 'asc' }) // [5, 9, missing] — missing is STILL last
Migrating existing call sites
If you have call sites written before this rule shipped that rely on a bare
system name — orderBy: 'createdAt', where: { confidence: { greaterThan: 0.8 } }, and similar — they now refuse instead of silently resolving to the
engine field. The fix is always in the error: swap the bare name for
system.<field> (or metadata.<field> if you actually meant your own field
of that name, and it happens to share a name with a system scalar):
// Before: bare 'createdAt' silently meant the engine's timestamp.
await brain.find({ orderBy: 'createdAt' })
// After: say which one you meant.
await brain.find({ orderBy: 'system.createdAt' }) // the engine timestamp
await brain.find({ orderBy: 'metadata.createdAt' }) // your own field named createdAt, if you have one
There is no silent migration path by design — every ambiguous call site surfaces as a refusal naming its own fix, once, the first time it runs against the new rule.
Where to go next
- Consistency Model — the separate (and
longer-standing) contract for reserved fields: which names may never
appear inside a
metadatabag at write time, distinct from this page's read-time addressing rule.