Taguru
reference · context schema

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, 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.

CapValue
Declared types500
Declared relations500
Domain/range type names, per side of one relation64
is_a chain depth8
Type/relation/name byte lengththe 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 violating Issues 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:

  1. The label is inert with no schema installed — asserting it on a schema-free context is just another fact, judged by nothing.
  2. No alias path may ever resolve any spelling to schema:type once a schema exists — add_label_alias, an import source's own inline labels declaration, and a PUT /contexts/{id}/schema-time check over already-persisted aliases all enforce it. This refusal fires regardless of mode, even off: it is a namespace-ownership rule, not something enforcement toggles.
  3. PUT /contexts/{id}/schema itself refuses a document whose relations declares an entry named schema: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.

RouteRoleWhat it does
GET /contexts/{id}/schemaReadReturns the installed document, or 404 if none.
PUT /contexts/{id}/schemaWriteInstalls (or replaces) the document — refuses on any of the caps or shape checks above.
POST /contexts/{id}/schema/validateReadDry-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/auditReadJudges 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.