Skip to content
Beta. This surface may change before GA; breaking changes are dated in the changelog.

Custom schemas

Custom schemas are for when you know your ontology and want it queryable. If you do not yet, stay on the default nicia-base and write records without a kind — you lose nothing by deciding later.

Terminal window
curl -X PUT "$API/v1/brains/tenant:acme" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{
"name": "Acme",
"schema": {
"extends": "nicia-base",
"kinds": [
{
"name": "deployment",
"description": "A release of our software into one environment.",
"fields": [
{ "name": "environment", "type": "enum", "values": ["dev", "staging", "prod"],
"description": "Where it went." },
{ "name": "released_at", "type": "string", "description": "ISO 8601 timestamp." },
{ "name": "version", "type": "string" },
{ "name": "rollback", "type": "boolean" }
]
}
],
"links": [
{ "name": "deployed_by", "from": ["deployment"], "to": ["person"] },
{ "name": "affects", "from": ["deployment"], "to": ["concept"] }
]
}
}'

extends: "nicia-base" keeps person, note, and the rest of the default vocabulary available. Omit it only if you want a closed vocabulary built from nothing but your own kinds. An untyped record — no kind at all — still works either way; declaring a schema never takes away the option to leave a record untyped.

Type Notes
string The default. Also used for ISO timestamps.
number Integers and decimals.
boolean
enum Requires values. Widening later is safe.
string_list An array of strings.

Every field is optional. A record may carry any subset.

description is not documentation. It is the instruction the extractor follows when it reads your documents looking for these records. “Where it went” produces better extraction than no description, and “The deployment target environment; prod means customer-facing” produces better extraction still.

The strongest predictor of good extraction is a small vocabulary. A handful of kinds with a handful of fields each beats a sprawling ontology, because every additional kind is another decision the extractor can get wrong.

Start with the two or three kinds you would actually filter on. Add more when a query you want to write is impossible without them.

The better path for most teams: write documents and untyped records for a few weeks, then look at what shape your data actually took.

Terminal window
curl "$API/v1/brains/tenant:acme/schema/candidates" \
-H "authorization: Bearer $NICIA_KEY"
{
"data": {
"predicates": [
{
"predicate": "health_score",
"occurrences": 1840,
"type": "number",
"subjects": "organization",
"coverage": 0.94,
"suggestion": "promote to a field on organization"
}
],
"kinds": [
{
"name": "deployment",
"occurrences": 412,
"fields": [
{
"name": "environment",
"type": "enum",
"values": ["dev", "staging", "prod"],
"coverage": 0.97
}
]
}
]
}
}

coverage is the fraction of candidate records that carried the field — a low number means it is not really part of the shape. Promote what looks right:

Terminal window
curl -X POST "$API/v1/brains/tenant:acme/schema/promote" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "predicates": ["health_score"] }'

Existing record fields become typed in place. Nothing is re-extracted and nothing is lost.

Terminal window
curl -X PATCH "$API/v1/brains/tenant:acme/schema" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "kinds": [{ "name": "deployment",
"fields": [{ "name": "duration_seconds", "type": "number" }] }] }'
Change Result
Add a kind Safe, immediate
Add a field Safe, immediate
Widen an enum Safe, immediate
Add a link Safe, immediate
Narrow an enum 409 conflict if any record uses a removed value
Change a field’s type 409 conflict
Remove a kind holding records 409 conflict

Nicia will not silently discard data you already wrote. When you genuinely want a breaking change, add the new kind alongside, migrate, then remove the old one.

Records written before a field existed keep their values. Undeclared keys round-trip on the way in and become queryable the moment you declare them — so promoting a field is retroactive, not forward-only.

If your domain model mostly works and you want it here for the governance, versioning, and provenance rather than because our shape is better, declare only the parts you want queryable today and send everything else anyway.

Terminal window
# Declare the three kinds you filter on. Send twenty fields per record.
curl -X PUT "$API/v1/brains/tenant:acme" \
-H "authorization: Bearer $NICIA_KEY" \
-H "content-type: application/json" \
-d '{ "name": "Acme", "schema": { "extends": "nicia-base",
"kinds": [{ "name": "account", "fields": [
{ "name": "arr", "type": "number" },
{ "name": "segment", "type": "enum", "values": ["smb", "mid", "ent"] }
]}] } }'

The seventeen fields you did not declare still arrive, still round-trip, and come back in fields on every record you read; each write that states one names it in undeclared, so you always know which of them are not queryable yet. Declaring one later makes it filterable across everything already written — there is no re-import, and no moment where your model has to be finished before it is useful.

That is the point of the lossless guarantee: an incomplete model is a valid starting state, not a migration you have to finish before you begin.

  • Schemas — the tiers and the default.
  • Records — writing against your vocabulary.