The context schema — an optional type and relation ontology
A schema is an opt-in contract layered on top of one context's ordinary associations: a
closed set of entity types, an is_a hierarchy between them, and domain/range
constraints on relation labels. Installing one changes nothing by itself — off
is the default and a schema-free context behaves exactly as it always has. This reference
covers the document, the three modes, the reserved schema:type label, what
happens at write time, the HTTP/MCP surface, type-aware reads, and the metrics that show
whether strict/warn are actually doing anything.
The document
One JSON object per context, independent of the batch/group/image format versions.
{"schema": 1, "mode": "strict", "closed_labels": false,
"types": {"Brewery": {"is_a": []}, "Person": {"is_a": []}},
"relations": {"杜氏": {"domain": ["Brewery"], "range": ["Person"]}}}
schema is SCHEMA_VERSION (currently 1), independent
of BATCH_VERSION/GROUP_VERSION/IMAGE_VERSION — the
document has its own version lane because it changes on its own schedule.
mode is one of "off", "warn", or
"strict" — see the three modes below. Every top-level
field is required and unknown fields are refused, both on the wire
(PUT /contexts/{name}/schema) and at rest (the persisted
{stem}.schema.json): unlike a batch's group record, nothing here defaults,
because a silently-dropped field would be indistinguishable from under-enforcing
strict. types maps a type name to its declared parents
(is_a, possibly empty); relations maps a relation label to the
type names allowed on its subject (domain) and object (range)
side.
| Cap | Value |
|---|---|
| Declared types | 500 |
| Declared relations | 500 |
| Domain/range type names, per side of one relation | 64 |
is_a chain depth | 8 |
| Type/relation/name byte length | the same MAX_NAME_BYTES every other name in Taguru shares |
A schema document that fails these checks — including a cycle in is_a, or a
relation named the reserved schema:type label — refuses at
PUT /contexts/{name}/schema before anything installs; the context's previous
schema (if any) is untouched. A schema recorded on disk but currently unreadable is never
treated as schema-free — every write entrance refuses conservatively rather than silently
skipping enforcement.
Three modes, and what changing one does not do
off / warn / strict — stored inside the document itself, not in the context's ordinary metadata.
mode lives inside the schema document, not on
ContextMeta: a mode without a document to enforce is meaningless, and
PATCH /contexts/{name} (the ordinary metadata endpoint) cannot flip it —
changing mode is always a full PUT /contexts/{name}/schema.
off(the default) — the document may exist and be edited, but no write is ever judged against it. Useful for drafting types and relations before turning enforcement on.warn— every write is judged; a violation never blocks it. The violatingIssues ride the success response instead.strict— a write that would violate the schema refuses before anything lands.
strict means "from now on," not "from the beginning." Turning a context
strict never sweeps the graph — existing associations that would violate the new schema are
grandfathered, untouched and unflagged. PUT /contexts/{name}/schema only ever
changes what future writes are judged against; use
POST /contexts/{name}/schema/audit to find what already
disagrees with a schema you are about to tighten.
The reserved schema:type label
A type assertion is an ordinary association under a label the schema layer reserves for itself.
There is no separate "type" store. Saying a concept has a type is just an association:
{"subject": "青嶺酒造", "label": "schema:type", "object": "Brewery"} — which
means it is free through compaction, the WAL, export/import, replication, and every cache
lane for nothing extra. A concept can carry a set of types (possibly empty, possibly
more than one), and an untyped concept is legal in every mode including strict
— strict only judges the relations a write actually asserts, never demands
that every concept be typed. Asserting a type name the document never declared is always
accepted, as its own singleton is_a closure — a schema never refuses an
unrecognized type, only a domain/range mismatch against a declared relation.
is_a forms a shallow DAG, declared only in the document (cycles refused at
PUT, depth capped at 8), with the ancestor closure precomputed once so a
subtype satisfies a relation's parent-type domain/range for free.
Three guards keep schema:type from colliding with an ordinary label:
- The label is inert with no schema installed — asserting it on a schema-free context is just another fact, judged by nothing.
- No alias path may ever resolve any spelling to
schema:typeonce a schema exists —add_label_alias, an import batch's own inlinelabelsdeclaration, and aPUT /contexts/{name}/schema-time check over already-persisted aliases all enforce it. This refusal fires regardless of mode, evenoff: it is a namespace-ownership rule, not something enforcement toggles. PUT /contexts/{name}/schemaitself refuses a document whoserelationsdeclares an entry namedschema:type.
And a set of exclusions keeps it out of surfaces that were never meant to see it: it is never
traversed by activate/explore or by
unreachable_from's coverage audit (where a shared type name would otherwise
bridge disconnected facts and hide genuine orphans), it never appears in the extraction
vocabulary block or list_labels's default page, and type-name concepts are
excluded from audit_vocabulary's twin sweep once a schema exists. The single
gate for all of this is "an installed schema document exists," never "mode !=
off."
closed_labels: true (default false) additionally refuses a
relation label the document never declared — scoped only to ordinary fact
associations, never to schema:type itself, and reported as the existing
unknown_reference issue kind rather than a new one.
What happens at write time
Every write entrance shares one judgment function, so they cannot drift apart.
POST /contexts/{name}/associations, POST /import /
taguru import, and ?dry_run=true/preview all run the identical
check — the union of live graph state and this write's own operations, judged once. A
strict refusal happens before a single byte writes
(integrity: "nothing_written" on the response); a warn pass rides
the exact same Issue values out through the success envelope's
issues array plus a schema_violations count — the values are
byte-identical between the two modes, only the HTTP status differs.
Two new Issue.kind tokens carry a domain/range mismatch:
"domain" when the subject's type disagrees, "range" when the
object's does — two tokens rather than one because Issue has a single
path, and the caller needs to know which side failed.
retryable_after_correction is always true: a schema violation is
by definition correctable by changing the write, unlike an internal error.
A reserved-label conflict (guard 2 above) and a domain/range violation are reported and dispatched the same way regardless of which one a batch triggers, but they mean different things: the former is a namespace collision (409, like an alias conflict), the latter a refused value (400). Both refuse the whole write before anything lands.
HTTP and MCP surface
Four endpoints, four matching MCP tools.
| Route | Role | What it does |
|---|---|---|
GET /contexts/{name}/schema | Read | Returns the installed document, or 404 if none. |
PUT /contexts/{name}/schema | Write | Installs (or replaces) the document — refuses on any of the caps or shape checks above. |
POST /contexts/{name}/schema/validate | Read | Dry-runs a proposed document against the live graph without installing it — the way to see what tightening a schema would break before committing to it. |
POST /contexts/{name}/schema/audit | Read | Judges the graph as it stands today against the installed document — reads as strict even when the document's own mode is off, so a draft schema's real-world fit is visible before enforcement is ever turned on. |
Both /schema/validate and /schema/audit are diagnostics, not write
gates — they report untyped concepts, unknown labels, undeclared types, and domain/range
candidates for review, never verdicts that block anything. Neither one counts toward the
write-time metrics in Operating a schema below: only a check that
actually gates a write does.
The MCP tools get_schema, put_schema, validate_schema,
and audit_schema round-trip onto these same four routes, as do the Python and
TypeScript SDKs' get_schema/put_schema/validate_schema/
audit_schema (getSchema/putSchema/… in TypeScript);
add_associations and import inherit write-time enforcement for free,
since MCP is a pure mapping onto the HTTP surface.
For routing without a second call, GET /contexts's directory rows also carry a
read-only schema_mode — the installed document's own mode, or
null for a context that never installed one (never a bare off
standing in for "no document", the same distinction GET /schema's own 404
draws).
Type-aware reads
Gated by whether a document exists, never by its mode.
describe returns every live schema:type object on a concept as
types. resolve attaches types to its top candidates
the same way it already attaches gloss —
resolve_label never does, since a relation label has no type. query
(and cross POST /query) gain optional subject_types/
object_types: an OR-set of declared type names, expanded through the same
is_a ancestor closure a write-time check uses, so a filter can never disagree
with what strict itself would treat as a concept's type.
A type filter narrows results; it is not itself a valid query anchor — leaving subject/label/object all unset is still refused even with types set. A schema-free target answers empty for a non-empty type filter rather than erroring. Using declared types to score or rank candidates is explicitly out of scope — this is a filter, nothing more.
Operating a schema
Two Prometheus families, and what taguru inspect does and does not say.
# HELP taguru_schema_checks_total Schema pre-write checks by outcome…
# TYPE taguru_schema_checks_total counter
taguru_schema_checks_total{outcome="ok"} 412
taguru_schema_checks_total{outcome="warned"} 7
taguru_schema_checks_total{outcome="refused"} 2
# HELP taguru_context_schema_violations_total Schema violations recorded per context since boot…
# TYPE taguru_context_schema_violations_total counter
taguru_context_schema_violations_total{context="sake"} 9
taguru_schema_checks_total{outcome} is a fixed three-label counter
(ok / warned / refused, always rendered even at
zero) counted only at the entrances that actually gate a write —
POST /contexts/{name}/associations and a real (non-preview)
POST /import/taguru import apply — for a context that has an
installed schema document. A schema-free context never touches it, so ok
means "checked, no violation," not "no schema." ?dry_run=true/preview and the
/schema/validate//schema/audit diagnostics are deliberately
excluded: counting them too would let a validate-then-apply workflow double-count the same
refusal.
taguru_context_schema_violations_total{context} is the per-context breakdown,
behind the same TAGURU_METRICS_PER_CONTEXT opt-in-and-bounded knob every other
taguru_context_* family uses (off / all /
N largest by disk size) — client-minted context names never become metric
labels uninvited. A context with an installed schema that has never failed a check still
renders zero here, the same zeros-included discipline the rest of this file's fixed
families follow.
taguru inspect only ever raises a notice for the schema file's own
health — corrupt bytes, a digest mismatch, a version this build cannot read. It never
reports graph-level violations; that is what the two families above, and
POST /contexts/{name}/schema/audit, are for.
The schema document rides in context_files like every other per-context file
(last in the array, so a lagging or missing schema file never blocks a rename), and travels
through export/import as its own taguru_schema stream record — see
Schema records in the batch contract for the record shape,
and the CHANGELOG's #379/#384 entries for the downgrade hazard an older binary's shorter
context_files array creates.
Taguru