context schemaThe 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, stating its type and version like every other taguru record.
{"type": "schema", "version": "2026-10-01", "mode": "strict", "closed_labels": false,
"types": {"Brewery": {"is_a": []}, "Person": {"is_a": []}},
"relations": {"杜氏": {"domain": ["Brewery"], "range": ["Person"]}}}
type is always "schema" — the document says what it is.
version is the file-format revision, the same date every other taguru
record carries (independent of IMAGE_VERSION). It is the one optional
field: leave it off and it means the running server's own revision; spell it and it
must equal that revision, or the document is refused by name (null is
not an omission). Whatever installs is stored and served with version
stated.
mode is one of "off", "warn", or
"strict" — see the three modes below. Every other top-level
field is required and unknown fields are refused, both on the wire
(PUT /contexts/{id}/schema) and at rest (the persisted
{stem}.schema.json): unlike the source stream'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/{id}/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/{id} (the ordinary metadata endpoint) cannot flip it —
changing mode is always a full PUT /contexts/{id}/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/{id}/schema only ever
changes what future writes are judged against; use
POST /contexts/{id}/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
contextis 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 source's own inlinelabelsdeclaration, and aPUT /contexts/{id}/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/{id}/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/{id}/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 source 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/{id}/schema | Read | Returns the installed document, or 404 if none. |
PUT /contexts/{id}/schema | Write | Installs (or replaces) the document — refuses on any of the caps or shape checks above. |
POST /contexts/{id}/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/{id}/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/{id}/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/{id}/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 schema stream record — see
Schema records in the source-file 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