Records
Not everything you know is prose. A record is a thing you can filter and sort on — optionally typed as a person, an organization, an event.
curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "kind": "person", "fields": { "name": "Bob Smith", "role": "Head of Platform", "email": "bob@acme.example" }, "links": { "works_at": ["acme"] } }'{ "data": { "record": { "id": "bob-smith", "kind": "person", "undeclared": ["email", "name", "role"], "token": "brain-record-token-v1:9f2c4b6a1d0e8f735a4c2b9e1d6f0a3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a", "updatedAt": "2026-08-14T09:14:02.118Z" }, "change": { "id": "chg_44a1e0", "status": "applied", "fields": ["role"], "records": [ { "id": "bob-smith", "kind": "person", "set": { "role": "Head of Platform" } } ], "createdAt": "2026-08-14T09:14:02.118Z" } }}A write answers what it did, not what the record holds. You get the
address, the kind it is filed under, the opaque token to send back as
If-Match (also returned as the response ETag),
which of your fields moved, and — in undeclared — the ones you sent that are
not declared yet. You do not get the record’s values back, including the ones
you just sent: a write key can hold write without read, and values leave a
Brain only under a receipt. Read them through
/context, /query, or the record read.
A body that states no kind, label, fields, or links is a 400. There
is nothing to write in it, and it is not a way to read the record back. A key the
write does not know is a 400 naming it. label is the record’s display name, a
person’s full name or a company’s name; a role goes in a field such as
job_title, because title is reserved for the label.
A Brain you created thirty seconds ago with no schema step still accepts this.
Every Brain starts on nicia-base, which declares the person kind and the
works_at link but no fields at all — so every field in the write above,
name included, is stored, returned, and named in undeclared rather than
refused. That is why undeclared lists all three: it names what is outside the
governed vocabulary, not what is unwelcome. Declaring one later makes values
that were already there governed, returned, and filterable in /query; an
undeclared field is not queryable. See Schemas.
Fields are the part nothing rejects. A kind and a link name are vocabulary:
one your Brain’s schema does not declare is a 400 naming what it does
declare, and so is a link to a record the Brain does not hold yet — see
Links.
kind is optional too. Omit it and the record is filed under your schema’s
free-form kind — note on both built-ins, which is what it reads back as —
rather than under a kind you never chose. Naming a kind later is a retype and
never changes the record’s address, so nothing you built against it breaks.
The id is yours
Section titled “The id is yours”bob-smith in the path is the record’s identity, and it is a string you choose.
Use the id you already have — user-4471, a lowercase UUID, crm-account-4471
— and writes become idempotent for free: the address is the identity, so sending
the same body twice is one write.
An id is a lowercase kebab identifier: letters and digits, joined by single
hyphens. That is the address the record is stored under, so an id outside the
rule is a 400 rather than a record you cannot reach later. If your own id has
other punctuation — crm:account:4471 — lowercase it and join it with hyphens
once, at your edge, and it is still a pure function of the id you already hold.
Send what is true, not what changed
Section titled “Send what is true, not what changed”PATCH merges: the fields you send are set, the fields you omit are left alone.
Nicia computes the diff, so your sync job never has to read before it writes.
# Bob got promoted. You know one field. Send one field.curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "role": "VP Platform" } }'The response’s change.fields tells you what actually moved — an empty array
means your write was a no-op, which is the normal case for a nightly sync and
worth counting.
Use PUT when you are the authority on the whole record and the declared
fields you omit should be cleared:
curl -X PUT "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -d '{ "fields": { "name": "Bob Smith", "role": "VP Platform" } }'To clear one field with PATCH, send it as null.
PUT clears what your schema declares and you omitted — so on a Brain
whose schema declares no fields, it clears nothing. That includes the default:
nicia-base declares no fields, so the PUT above on a fresh Brain leaves
email exactly where it was and is indistinguishable from the PATCH before
it. PUT becomes the destructive verb only once a schema declares the fields
you mean to be authoritative over. Until then, clear a field the way PATCH
does — send it as null.
Frontmatter the schema does not declare — a source_url a markdown import left
behind, an imported_by a colleague wrote — survives untouched for the same
reason and always will, because it is stored data no response on this surface
shows you, and a write should not delete what it never let you see.
Because the address is the identity, PUT and PATCH are naturally idempotent:
sending the same body twice is one write, and the second answers
change.fields: []. That covers a retry of an UPDATE completely. It cannot
cover a create whose answer you never received — nothing is at the address
either way — so send an Idempotency-Key on a create if a duplicate would cost
you something, on any write verb. Nicia will not invent one for you: the only
key it could derive is a hash of your request, and two writes you meant
separately are byte-identical to a retry. See
Idempotency.
Relationships go in links, separately from fields:
{ "fields": { "name": "Bob Smith", "team": "support/billing" }, "links": { "works_at": ["acme"] }}fields are values and links are edges, always. Nicia never infers a
relationship from what a value looks like — "support/billing" above is a
string, and stays one, because team is a field and not a declared link.
Only names your schema declares as links are accepted in links; anything else
is a 400, and so is a target the Brain does not hold yet — an edge is written
against both endpoints, so a link to a record that does not exist would be an
edge that silently never materializes. Write the target first, or send both in
one POST /batch, where records may reference each
other.
An edge you send replaces the whole set on that name, so PATCH with
"links": { "works_at": [] } removes the relationship, and PATCH with a
different target repoints it. A PUT removes every link name it does not send
— all of them, with no undeclared exception, because links only ever accepts
names your schema declares. You never name the old target — the Brain reads
what the record currently holds and writes the difference.
Cite your sources
Section titled “Cite your sources”When a value comes from a document the Brain already holds, say which one.
evidence names the document, the words in it that ground the write, and your
own locator for where they are:
{ "kind": "finding", "fields": { "status": "open", "summary": "Site lies on a migratory flyway." }, "evidence": [ { "document": { "id": "environmental-report" }, "quote": "The project area lies within the Pacific Flyway.", "locator": { "page": 14, "section": "4.2" } } ]}The quote is checked, not trusted: it must occur in the document’s current
version, where only whitespace may differ — a wrapped line or a table cell’s
padding still matches, and so does a sentence that continues across a rendered
page break (a form feed and one running header or footer line); a paraphrase
does not. A quote the document does not
contain, or a document the Brain does not serve, is a 400 and nothing is
written. A document waiting in review is not served, so it cannot be cited yet, and a quote that continues across a page break must match as one run of text, so a form feed or running footer inside it makes it fail. For a document of a named source, add its
source next to the id you gave it there.
To rest a value on a part of the document rather than on a sentence, add an
anchor: § <heading> for everything under a heading (a longer path, § Parent › Child, disambiguates), or L12-L14 for a line range of the stored
text. The Brain resolves it over the version it serves and refuses one that
names no heading, matches more than one, or does not contain your quote. The
response and later reads show it resolved to its full heading path. A
statement that cites an anchor is monitored: a
standing answer that
depends on it is flagged when that part of the document changes. Cite one
anchored part per statement. A statement that cites one document and sets
its own sourceAnchor field is treated as if it had sent that as the anchor.
{ "kind": "statement", "fields": { "body": "A client qualifies only above two million in revenue." }, "evidence": [ { "document": { "id": "frameworks/qualification.md" }, "anchor": "§ Qualification", "quote": "annual revenue exceeds two million dollars" } ]}Each citation pins the version of the document it quoted, so revising the
document later does not change what an earlier citation says. It does not hide
the revision either: once the document no longer contains the cited words, or is
withdrawn, reads serve the record with
support saying so. The next write
that cites it must quote the version the Brain serves then. In a
batch, each record keeps its own citations. At most 10 per record,
2,000 characters per quote, and 16 locator keys; startByte, endByte,
sourceAnchor and anchorHash are the Brain’s own and cannot be sent.
Reads return what you cited on the record’s evidence, and a /context
citation for the record carries the same list, so an answer shows the passage
behind each value. A write that waits in review keeps its citations, and they
carry into the record once a person approves and publishes it.
A citation counts as grounding only when it quotes the document and someone
else wrote the version it quotes: a person, or a key under a different name. A
key that writes a document and then cites it is still asserting the value on
its own word, so the Brain’s policy treats the write as it treats one with no
citation, and a reviewed Brain stages it with the reason
brain_policy_self_cited_agent_stake. A citation with no quote names a
document without showing that it says anything about the value, so it never
counts as grounding either. Both kinds of citation are still stored and
served.
Records without a kind
Section titled “Records without a kind”You do not have to know what a record is before writing it. Omit kind, keep
your own stable id, and send the fields you already have. The record is filed
under your schema’s free-form kind — note on both built-ins — and is
addressable and readable straight away; name a kind later when you need
declared fields for governance, returned shape, and /query filters. Its
address does not change.
Batches
Section titled “Batches”When several writes must land together — or not at all — send them as one batch:
curl -X POST "$API/v1/brains/tenant:acme/batch" \ -H "authorization: Bearer $NICIA_KEY" \ -H "content-type: application/json" \ -H "idempotency-key: sync:acme:2026-08-14" \ -d '{ "records": [ { "id": "acme", "kind": "company", "fields": { "name": "Acme", "domain": "acme.example" } }, { "id": "bob-smith", "kind": "person", "fields": { "name": "Bob Smith" }, "links": { "works_at": ["acme"] } } ] }'{ "data": { "records": [ { "id": "acme", "kind": "company", "token": "brain-record-token-v1:3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a9f2c4b6a1d0e8f735a4c2b9e1d6f0a" }, { "id": "bob-smith", "kind": "person", "token": "brain-record-token-v1:9d1a7c0e5b3f2a4c6e8d0b1f3a5c7e9d2b4f6a8c0e1d3b5f7a9c2e4d6f8b0a1c" } ], "change": { "id": "chg_5f21b8", "status": "applied", "createdAt": "2026-08-14T09:20:11Z" } }}A batch is validated and applied as one unit: everything lands, everything
stages, or everything is rejected. Records in the same batch can link to each
other. records echoes each written record in request order with the token
the Brain accepts there now — absent where a staged create left nothing
accepted — so the next conditional write needs no re-read.
A batch carries at most 20 records, and an update counts for more than a
create: a record Nicia does not hold yet is one entry in the canonical change,
while updating one costs an entry per field that actually changes and per link
added or removed, plus one when its kind moves, against a ceiling of 100
entries and 2 MiB per batch. A batch that crosses either ceiling is rejected
whole, naming the ceiling it crossed, so a partial write is never the thing you
have to detect. For a bulk load, send several batches — or, when the records
are a list you keep elsewhere (a CSV of leads, an account export), add it to a
source snapshot as a table: each row
becomes a record, and the next upload of the list retires the rows it no
longer holds.
The same ceiling applies to a single PATCH or PUT, because it is a ceiling
on entries and not on records: updating a hundred-and-twenty fields of one
record crosses it on its own. Split that write across requests — it is the one
case where sending fewer records is no help at all.
Reading a record back
Section titled “Reading a record back”curl "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY"{ "data": { "record": { "id": "bob-smith", "kind": "person", "fields": { "name": "Bob Smith", "role": "VP Platform" }, "links": { "works_at": ["acme"] }, "token": "brain-record-token-v1:3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a9f2c4b6a1d0e8f735a4c2b9e1d6f0a", "updatedAt": "2026-08-14T09:20:11Z" }, "staged": 0, "warnings": [], "receipt": "rcp_019fff51" }}When a record’s values came from a document — extracted, or written with
evidence — sources names the ids of the documents
behind them, one array per record, so each one round-trips into
GET /documents/{documentId}, and evidence carries
each citation’s quote and locator. bob-smith here was written directly
through PATCH with no citation, so both are absent. links comes back the way you sent it —
relationships are their own key, never mixed into fields, and never inferred
from a value that happens to look like one.
This is exactly what POST /query returns for this record —
one record instead of a page, same projection — so it is a read like any other:
receipted, with staged and warnings[] saying whether the answer is the whole
truth. Keep the receipt and GET /receipts/{receiptId} will
tell you which version you were shown. An address the Brain holds nothing at is
a 404.
Who said a value
Section titled “Who said a value”Every accepted write records who said each field’s value: a person, an agent, an API key, or a source. Ask a field for its look-behind:
curl "$API/v1/brains/tenant:acme/records/acme/fields/status" \ -H "authorization: Bearer $NICIA_KEY"{ "data": { "recordId": "acme", "field": "status", "status": "present", "value": "contacted", "support": [ { "sourceId": "internal:user:ana", "producer": { "kind": "human", "id": "ana" }, "recordedAt": "2026-09-27T10:04:00.000Z" } ], "dissent": [ { "value": "new", "sources": [ { "sourceId": "internal:agent:crm-sync", "producer": { "kind": "agent", "id": "crm-sync" }, "recordedAt": "2026-09-27T09:00:00.000Z" } ] } ], "history": [ { "op": "assert", "value": "new", "sourceId": "internal:agent:crm-sync", "producer": { "kind": "agent", "id": "crm-sync" }, "recordedAt": "2026-09-27T09:00:00.000Z" }, { "op": "assert", "value": "contacted", "sourceId": "internal:user:ana", "producer": { "kind": "human", "id": "ana" }, "recordedAt": "2026-09-27T10:04:00.000Z" } ], "receipt": "rcp_019fff52" }}The response has four parts:
valueis what the field holds now.supportlists the producers whose latest word on the field is that value.dissentlists the other values producers still assert, and who asserts each. Here, the CRM’s value lost to a person’s edit.historyis every assertion and retraction, in the order they were recorded. A change the Brain published through Review names itschangeId.
Add asOf (an ISO instant) to answer as the Brain had recorded the field at that moment. @kind and @body are the record’s kind and a hash of its prose, so a retype or a prose edit is attributed too. Records accepted before field support was recorded name internal:system:legacy.
Two limits on what a producer count means:
- It is not independence. Two keys that sync from one system are two producers, and nothing here claims they are independent.
- A person’s value is protected. An agent’s write that would change a value a person set waits for review. Agents update each other’s values freely. So does a person’s deletion: an agent’s write that would create a record a person deleted waits for review instead of bringing it back.
- A decided proposal is not asked twice. When an agent re-sends a value a reviewer already rejected, the write answers with that rejected change (
status: "rejected",reason: "already_rejected: …") and nothing is staged. When the same value is already waiting, it answers with the waiting change (status: "staged",reason: "already_staged: …"). A different value, the same value from another agent, or the same value re-sent with different evidence (another cited document, passage or quote) is a new proposal. In a batch, only the repeated proposals are set aside; the rest of the batch goes through. - A key’s id is its identity; its name is only the label shown. A write made with a Brain key is attributed to that key’s id, not its name. Two keys with the same name, including a revoked key and its replacement, are different producers in look-behind and in history. Read the source id from look-behind rather than guessing it.
Create vs update
Section titled “Create vs update”Create — the Brain holds nothing at this address yet. Send your fields (and
optionally kind, label, links). Omit If-Match; there is no token to
pin.
Update on the records plane — the Brain already holds a record at this
address. Send only what changed; Nicia merges under PATCH or replaces declared
fields under PUT. Pin the token you read last as If-Match — from a prior
GET (its body token or its ETag header), from the record.token on a
write response, or from a receipted read. A stale or cross-branch token is
409 conflict. Re-read, re-apply your delta, resend.
Update on a batch — POST /batch takes an optional ifMatch on each
record: the token you last read for it. Every precondition is checked before
anything is written, so one stale token refuses the whole batch with 409 conflict naming that record, and nothing is applied. A record that does not
exist yet cannot carry a token. The response returns each written record’s new
token, so the next batch chains without a re-read. A malformed ifMatch is
400 validation. See Concurrency.
Concurrency
Section titled “Concurrency”Writes are last-write-wins by default, which is what a sync job wants. When you
need optimistic concurrency, send back the record’s opaque token as
If-Match (a read’s ETag is the same value):
curl -X PATCH "$API/v1/brains/tenant:acme/records/bob-smith" \ -H "authorization: Bearer $NICIA_KEY" \ -H 'if-match: "brain-record-token-v1:3c8b5e7d2a4f6c9b1e3d5a7f0c2b4e6d8a9f2c4b6a1d0e8f735a4c2b9e1d6f0a"' \ -H "content-type: application/json" \ -d '{ "fields": { "role": "CTO" } }'The token is opaque and branch-scoped: it identifies the Brain generation, the
active branch and its incarnation, the record and its incarnation, and the
editable revision. A token read on one branch never authorizes a write on
another, and A → B → A edits or delete/recreate do not revive an old token. A
stale or cross-branch token is 409 conflict; a malformed header — an integer,
an unquoted value, a weak tag, the * wildcard, or a list — is 400 validation naming the header rather than a precondition that quietly did
nothing. Re-read, re-apply, resend. PATCH, PUT, and DELETE all honour the
header; a batch takes the same token as ifMatch on each record.
When writes wait
Section titled “When writes wait”In reviewed mode the response is the same shape with a different status:
{ "data": { "records": [{ "id": "acme", "kind": "company" }], "change": { "id": "chg_44a1e0", "status": "staged", "createdAt": "2026-08-14T09:22:47Z" } }}Branch on change.status, always: applied, staged, or rejected. A
rejection is a 200 — the request was well-formed and the Brain decided against
it. See Review.