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

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/{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 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/{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 strictstrict 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 batch's own inline labels declaration, and a PUT /contexts/{name}/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/{name}/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/{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.

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