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.
Declaring one
Section titled “Declaring one”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.
Field types
Section titled “Field types”| 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.
Descriptions are load-bearing
Section titled “Descriptions are load-bearing”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.
Keep it small
Section titled “Keep it small”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.
Promoting what emerged
Section titled “Promoting what emerged”The better path for most teams: write documents and untyped records for a few weeks, then look at what shape your data actually took.
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:
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.
Evolving
Section titled “Evolving”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.
Bringing a model you already have
Section titled “Bringing a model you already have”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.
# 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.