The graph contract¶
At a glance
- A graph declares its contract as an optional
schemaproperty on the root node (the request:schema.bodyforinput.body,schema.headerforinput.header) and on the end node (the response:output.body,output.header). Each part is a schema object in a closed subset of OpenAPI 3.0; the deployment gate refuses anything outside it.- Three uses. When the root carries a
schema, every run validates the request at the root before anything else runs.GET /api/openapi/{graph-id}answers a minimal OpenAPI 3.0 document derived from the model on demand. The Playground's Schema panel starts from what the engine discovers in the model and from the last dry run, so a contract is confirmed rather than typed.- The declaration is data on the graph: it travels with export, import, pack and
graph.extension, needs no code, and the engine assumes the validation step - nothing is written into the node for it.
Declare the contract¶
The root node describes the request and the end node the response - the node says which side it is.
Each schema has two optional parts that mirror the state machine's namespaces:
| Part | Describes | Shape |
|---|---|---|
schema.body |
input.body (root) or output.body (end) |
any schema object of the vocabulary below |
schema.header |
input.header (root) or output.header (end) |
an object schema whose properties are header names; a header is text |
The parts are ordinary nested properties, written through the node grammar's composite keys - so
create node, update node, edit node, the node editor and the Schema panel all carry them with
no new syntax:
update node root
with type Root
with properties
name=payment
purpose=A root schema validates the request before anything runs
schema.body.type=object
schema.body.required[]=amount
schema.body.required[]=currency
schema.body.properties.amount.type=number
schema.body.properties.amount.minimum=0
schema.body.properties.amount.exclusiveMinimum=true
schema.body.properties.amount.description=the amount to charge
schema.body.properties.currency.type=string
schema.body.properties.currency.enum[]=USD
schema.body.properties.currency.enum[]=EUR
schema.body.properties.note.type=string
schema.body.properties.note.maxLength=40
schema.body.properties.note.nullable=true
schema.header.required[]=X-Tenant
schema.header.properties.X-Tenant.type=string
schema.header.properties.X-Tenant.minLength=4
schema.header.properties.X-Retry.type=integer
schema.header.properties.X-Retry.minimum=0
A value the grammar stores as text (minimum=0, nullable=true) is read as the keyword expects. In
the exported JSON the same declaration is the nested object you would write by hand. The end node's
schema is documentary: it describes the response in the OpenAPI document and is never enforced,
headers included.
The vocabulary¶
The vocabulary is a closed subset of OpenAPI 3.0 keywords, and the gate refuses any other keyword:
| Keywords | Applies to |
|---|---|
type - object, array, string, number, integer, boolean |
every schema; a header is string, number, integer or boolean |
properties, required, additionalProperties |
object |
items, minItems, maxItems |
array |
enum, nullable |
any scalar (nullable not on a header) |
minimum, maximum, exclusiveMinimum, exclusiveMaximum |
number, integer |
minLength, maxLength, pattern |
string |
title, description, example, format |
documentary - carried, never validated |
The rules that follow from it:
- Strict JSON types. A numeric string is not a number and
trueis not1; an integral float (2.0) counts as an integer. Money istype: stringwith apattern, as theDECIMALstatement already advises - a JSON number is a double on the wire. - A header is text.
type: integer,numberorbooleanvalidates the parsed text ("12"passesinteger,"many"fails). Header names match case-insensitively, the engine's rule forinput.header.*; the declared spelling is kept in the document.properties,required,items,minItems,maxItems,additionalPropertiesandnullabledo not apply to a header. patternruns aftermaxLength, in the regular-expression subset common to the Java and the Rust engine: lookaround, atomic groups, backreferences and possessive quantifiers are refused at the gate, and character classes are Unicode-aware on both engines. A pattern comes from the author, never from the caller.additionalPropertieskeeps OpenAPI's default: extra fields pass unless the author declaresfalse(or a schema for them). Lengths count code points.- At most ten violations are reported per message, with the rest counted (
; and 3 more).
The deployment gate (CompileGraph, the graph packager, the graph-set loader and the Playground's
pre-run check) compiles both declarations and refuses the model when one is malformed, naming the
location:
Rejected graph payment - node root - schema.body.properties.amount: unknown keyword 'min' - the vocabulary is type, properties, required, items, enum, minimum, maximum, exclusiveMinimum, exclusiveMaximum, minLength, maxLength, pattern, minItems, maxItems, nullable and additionalProperties (title, description, example and format are documentary)
Rejected graph payment - node root - schema.header.properties.X-Count: 'pattern' does not apply to type integer
A keyword that cannot apply to the declared type is refused like an unknown one, because a constraint
the validator would silently ignore teaches that unflagged means safe. A mismatch between the
declaration and the model's data surface - a declared path the model never reads, or a read path the
declaration lacks - is a WARN at deploy and pack time (Graph payment - input.body.note is declared but
never referenced by the model), never a refusal: a declaration may describe what a whole-body
passthrough forwards.
Discovery and describe graph¶
The engine derives the contract from the model in three tiers and merges the declaration over it:
- The path scan - every
input.*andoutput.*reference in mappings, statements,for_eachsources and JSONPath$.input.body...expressions, with nesting and the array markers[],[*]and[0]. - Direct evidence - typed constants and wrappers (
int(…),boolean(…),text(…)), plugins with a known result (f:nowis a string,f:listOfMapa list),for_eachsources (lists),graph.mathresults and operands (arithmetic and ordered comparisons are numbers; a boolean is never a number). - One hop through a typed
model.*or{node}.result.*variable, and agraph.extensiontarget's declaredschema.bodyas itsresult.
The declaration wins: a discovered path the declaration lacks is appended untyped and flagged, and a
declared path the model never references is kept and flagged. describe graph {graph-id} prints the
merged surface with its types, [declared] on a path only the declaration knows, every staged
output.status, and the document's URL:
Input surface:
input.body.amount (number)
input.body.currency (string)
input.body.note (string) [declared]
input.header.X-Tenant (string)
input.header.X-Retry (integer)
Output surface:
output.body.charged (number)
output.body.currency (string)
output.body.status (string)
Declared schema: input
OpenAPI document: GET /api/openapi/payment
The same derivation is what the Schema panel and an agent read as JSON through
?view=contract (below): input.body.paths[] with path, type, origin (declared,
discovered or both), required and usedBy, and issues[] with the mismatches. Query
parameters are not part of a graph's API - /api/graph/{graph-id} is one endpoint URI for every
graph - so they are neither declared nor derived.
The OpenAPI document on demand¶
Dev-mode routes (app.env=dev, in the four rest.yaml copies the starter and the examples carry):
| Route | Answers |
|---|---|
GET /api/openapi/{graph-id} |
the document of a deployed graph, as a YAML attachment named {graph-id}.yaml |
GET /api/openapi/{graph-id}?format=json |
the same document as JSON inline |
GET /api/openapi/{graph-id}?view=contract |
the derived contract with its evidence, as JSON |
GET /api/openapi/session/{sessionId} |
the same three views for a session's draft, named after its root node's name (else draft) |
The document is derived from the model each time it is requested and never stored. It holds the graph's
one endpoint, POST /api/graph/{graph-id}, with the request body schema, the declared or discovered
input.header.* as header parameters (a declared header carries its required and its schema), the
200 response with its body schema and output.header.* as response headers, every status the model
stages with int(N) -> output.status as a response code, the engine's error shape
({type, status, message}) under default, info from the root node's purpose and the deployed
set's version (else the application's info.app.version), and servers from the request's Host
header, so a downloaded file points back at the engine that generated it:
curl -sS -o payment.yaml -D - http://127.0.0.1:8085/api/openapi/payment
# Content-Type: application/yaml; charset=utf-8
# Content-Disposition: attachment; filename="payment.yaml"
curl -sS 'http://127.0.0.1:8085/api/openapi/payment?view=contract' | jq '.issues'
An unknown graph or session answers 404; an unknown format or view answers 400.
Input validation at the root¶
When the root node carries a schema, input.body and input.header are validated against the root
node's schema as the first thing at the root on every run, and a failed validation never reaches the
root's own skill. The step is assumed by the engine - the walker sends one request to the
built-in function graph.schema.validator with {body, header, schema} and nothing is written into
the node, so there is no validator line to mistype and no second source of truth. A resumed
traversal (graph.resume) never validates again.
- Success is one line in the traversal log and on the Playground console:
Input validated by graph.schema.validator in 2 ms. - Failure is the standard task error path. The deployed endpoint answers 400 with every
violation in one message:
The Playground prints
{"type": "error", "status": 400, "message": "Input validation failed - input.body.amount: expected number, got string; input.body.currency: must be one of USD, EUR; input.body.note: must be at most 40 characters; input.header.X-Tenant: required"}Graph traversal aborted: Input validation failed - ... (node root). - A root
exception=handler takes over a failed validation with the generic exception context,error.message,error.source(root) anderror.code(400), so a graph can answer in its own shape - the shipped test graph answers 422 with areason. - The application property
graph.schema.validatornames a substitute function with the same contract (configuration reference; set it for one run with the-Dprogram argument); a direct caller gets{valid: true}or a 400 whose error is the message above, and a refused schemaInvalid schema - schema.body: unknown keyword 'min' - ....
A dry run in the Playground validates the input exactly as a deployed run does: instantiate graph,
Upload the mock body and headers, run, and the console shows the same line or the same refusal. The
Upload step's Headers rows (POST /api/mock/{sessionId}?namespace=header) are how a dry run
satisfies a header requirement.
Validation is a contract check, not business logic: it says what a well-formed request is. A rule
such as "a refund may not exceed the charge" belongs to a graph.math node that stages
int(400) -> output.status, as before.
The Schema panel¶
Tools → Graph schema - or the "Open the Schema panel…" link in the root or end node editor -
opens the panel in the console's slot. It has an Input tab (the root's schema) and an Output
tab (the end's), each with a body section (one row per path) and a header section (one row per
header name): the path or name, the type (an array also has an item type), req, the description,
an example, and a chip saying where the row came from:
| Chip | Meaning |
|---|---|
declared |
on the node; the tooltip names the nodes that also reference the path |
discovered |
referenced by the model, not declared yet |
from last run |
typed from the instance's actual value |
new |
added in the panel, saved with the declaration |
A second chip summarizes the keywords the panel does not edit (enum (2) · minimum 0 · pattern);
they are carried through a Save unchanged and edited in the node editor. The rows are pre-filled from
the contract view; Fill from last run reads the instance's input.body, input.header,
output.body and output.header after a dry run and fills the gaps - the only way to type a
graph.task's or a fetcher's result. Save writes the side's declaration through one update node
command, so every member of a collaborative session sees it and an agent does the same by command;
Download YAML saves the draft's document. What the gate or the grammar would refuse is flagged in
place before anything is sent, and the engine's declaration-versus-model issues are listed above the
rows. See help schema in the Playground.
Round trip through the API playground¶
The document is written for the tooling the field already uses - Swagger UI and OpenAPI 3.0
generators. The API playground (extensions/api-playground, cargo run -p api-playground after
fetching the Swagger UI assets as its README says) is Swagger UI in a Mercury page, served by a
Mercury app on port 8200 - the twin of the Java module. With a MiniGraph application in dev mode
(cargo run -p minigraph-playground):
- Load the engine's document directly. Press MiniGraph, keep host
127.0.0.1and port8085, enter the graph IDpayment(or a Playground session ID for the draft) and Load. The dev routes carry the wildcard CORS entry, so the browser may read it, andserverspoints at the engine, so Try it out posts the request body and the header parameters toPOST /api/graph/paymenton the engine - a bad request shows the 400 above. The URL field takes any OpenAPI document URL the same way. - Or keep the file. Press Deploy to save the loaded document into the playground's specs
folder (
api.playground.specs), where Specs offers it from then on; or downloadpayment.yaml(the attachment, or the Schema panel's Download YAML) and drop it on the page. A file kept this way is a point-in-time export; the engine's URL is always current. - Round trip. Correct the graph in the Playground - a type, a
required, a new path - Save in the Schema panel, and Load again in the dialog: the document follows the draft at/api/openapi/session/{sessionId}, and the deployed graph's at/api/openapi/{graph-id}after the next deploy.
Design rules¶
- Confirm, don't type. Start from
describe graphor the panel's discovery, then declare the types the model does not reveal - a task's or a fetcher's result - from a dry run. - Declare what you validate. A declared path the model never reads is a warning for a reason: a contract wider than the model is a promise the graph does not keep.
- Keep the response documentary. The end node's
schemadescribes; the engine never refuses its own response. - Money is text.
type: stringwith apattern, validated at the root and computed withDECIMAL:. - Validation is not rules. A business rule stays a
graph.mathstatement with a staged status; the schema says what a request is, not whether it may proceed.
See also¶
- MiniGraph command grammar -
describe graphand the node grammar the declaration is written in. - AI agent guide - the recipe an agent follows to declare a contract through the companion endpoint.
- Playground & AI companion - the Schema panel among the Tools.
- Configuration reference -
graph.schema.validator. - Built-in skills reference - the
DECIMALstatement for exact money arithmetic.
Adapted from the mercury-composable guide knowledge-graph/graph-contract.md; behavior verified
against this engine's copies of the shared vector files graph-contract-vectors.json and
graph-schema-vectors.json (Increments 171 and 172) and the Playground bundle it serves (Increment 173).