The laranex-schema contract, version 2.0
One framework-neutral JSON document describes a project's schema for every Laranex schema viewer: the Laravel, Python and Node.js packages produce it, and the shared viewer, MCP tools and exporters read it. This page is the normative description; contract/schema.json (JSON Schema 2020-12), src/contract/types.ts and validateDocument() implement it, and the fixtures in contract/ are the shared test suite.
Principles
- One contract, no adapters. Producers serialize their ORM's and database's facts into this document. Readers never detect or convert framework formats; conversion of a producer's older snapshots belongs to that producer.
- Facts, not guesses. Physical database structures (foreign keys, indexes, constraints) and ORM declarations (models, relationships) are separate entities. A relationship never implies a foreign key, and naming conventions never create a relationship. What could not be read is a diagnostic, not a default.
- Explicit provenance. Every physical entity says which sources observed it, and each connection says what each source covered. An incomplete inspection is never reported as complete.
- Opaque, stable ids. Every entity has an id, unique among entities of its kind (connections, tables, indexes, foreign keys, constraints, models, relationships, links, diagnostics, comparisons, differences, migration tracks, inferred associations), and refers to others by id. Consumers compare ids; they never parse them.
- Facts, inferences and comparisons stay apart. Physical foreign keys, ORM-declared relationships and inferred associations are three collections; an inference is never a relationship and never adds a link. Comparisons and migration tracks are computed by producers with the shared algorithms below; viewers display them and never compute them.
- The contract is the serialization boundary. Producers keep ORM and database extraction, a normalized in-memory schema model, and canonical serialization separate: extractors fill the model, and only the serializer knows this document's field names. Nothing converts between versions or formats.
- Deterministic bytes. Equal schemas serialize to equal bytes: no timestamps, canonical key order, specified array order.
Versions
format is "laranex-schema"; version is "major.minor", currently "2.0". 2.0 replaces 1.0; nothing reads or converts 1.0 documents (see Changes from 1.0).
- A minor version only adds optional fields or new enum-like string values documented as open (diagnostic kinds, relationship types, ambiguities, providers). A major version is any other change.
- Producers and tests validate strictly (
validateDocument(doc, { strict: true })): the version must be exactly the one the validator implements ("2.0"), and unknown properties are errors. - Readers accept every minor of their major and ignore properties they do not know (the default, lenient mode), so a deployed viewer keeps reading documents from a newer producer. Any other major, 1.x included, is rejected with one clear error.
Document
| Field | Type | Meaning |
|---|---|---|
format | "laranex-schema" | |
version | string | "2.0" |
source | declared | database | combined | See Source modes |
generator | { name, orm } | The producing package and the ORM (null for database-only). No versions or times, so upgrading a producer does not change equal schemas' bytes |
note | string | null | The project's note |
connections | Connection[] | Database connections or ORM namespaces, each with its tables |
models | Model[] | ORM models |
relationships | Relationship[] | ORM-declared relationships |
links | Link[] | Graph edges between column sets; see Graph |
derivedLinks | DerivedLink[] | Through relationships as edges |
diagnostics | Diagnostic[] | What could not be read, what disagrees, and advice |
comparisons | Comparison[] | Schema comparisons between two scopes; see Comparisons |
migrations | MigrationTrack[] | Migration history and status per scope; see Migrations |
inferredAssociations | InferredAssociation[] | Associations foreign keys imply without a declared relationship; see Inferred associations |
extensions | object, optional | See Extensions |
Host-specific viewer settings (changes since the last build, prepared downloads, editor links, the connection to open on) are not part of the document; hosts pass them beside it as ViewerConfig.
Source modes
source | Tables, columns, indexes, foreign keys, constraints | Models and relationships | provenance allowed | Connection coverage |
|---|---|---|---|---|
declared | From ORM metadata | From ORM metadata | ["declared"] | declared set, database null |
database | From database introspection | None | ["database"] | database set, declared null |
combined | Database introspection, matched with ORM metadata | From ORM metadata | any | both set |
In combined mode the database is authoritative for everything it inspected:
- An entity both sources report has
provenance: ["database", "declared"]; its fields hold the database's values, and each field the ORM disagrees on is listed indriftas{ field, database, declared }, with adriftdiagnostic. - An entity only the ORM declares (a foreign key the database does not enforce, a column it lacks) stays in the document with
provenance: ["declared"]and adeclared_onlyreconciliation diagnostic. It is never presented as a physical structure: viewers mark it and exporters leave it out of DDL. - An entity only the database has carries
["database"], with adatabase_onlyortable_without_modeldiagnostic where the producer reconciles it. - Missing database metadata is never filled in from declarations.
Coverage
Each connection's coverage has one entry per source: { complete, schemas, features }.
featuresmaps each feature (tables,columns,columnTypes,defaults,comments,primaryKeys,foreignKeys,referentialActions,indexes,indexExpressions,indexPredicates,includedColumns,uniqueConstraints,checkConstraints,models,relationships) tocomplete,partial,unavailableornot_applicable. A feature left out is unknown.completeis true only when every listed feature iscompleteornot_applicableand every schema in scope was read. The validator rejectscomplete: truebeside apartialorunavailablefeature.schemaslists the schemas read, or null when the source has no notion of them.- Why a feature is partial or unavailable is an
inspectiondiagnostic.
Connections and tables
A document is multi-connection: connections holds every named database connection (or ORM bind) the producer read, each with its own driver, default schema, coverage and tables, in any mix of dialects. A single-connection document is the one-element case; nothing in the contract assumes there is only one.
Connection { id, name, driver, defaultSchema, coverage, tables, extensions? }. name is unique. driver is the lower-case dialect, or null when the source does not know it. The list is open, but these databases have one spelling: postgresql, mysql, mariadb, sqlite, sqlsrv, oracle. Producers map framework names to them (Laravel's pgsql is postgresql); the validator rejects the known aliases pgsql, postgres, mssql and sqlserver.
- Table ids are unique across the whole document, so producers qualify them by connection (
analytics:users). Table names, and schema-qualified names, may repeat across connections:usersin two connections are two tables, told apart by id and never merged. Readers look tables up by id, never by name. - A model's
tablesand a relationship'stable,relatedTable,pivot.tableandthroughmay be in any connection: an ORM relationship may cross connections. - A foreign key never crosses connections: its
foreignTableis in the same connection as the table that declares it, because a constraint lives in one database. The validator rejects a foreign key to a table in another connection (Foreign key crosses connections).
Table { id, name, schema, comment, note, owner, primaryKey, columns, indexes, foreignKeys, constraints, provenance, drift?, extensions? }
schemanull means unqualified (resolved throughdefaultSchema), not a guess at the default.owneris{ kind: application | framework | package, package }, for tables a framework or a package owns.primaryKeylists the key columns in key order; empty when there is none.commentis the database's (or ORM's) comment;noteis a project note from the producer's configuration.
Column { name, type, typeName, nullable, autoIncrement, timezone, collation, enum, default, ormDefault, generated, comment, note, provenance, drift?, extensions? }
typeis the full type as the source spells it;typeNamethe base name, or null.autoIncrementnull means the database or ORM decides.enumis{ name, values }: the values in declaration order, and the named type when the database has one.defaultis the database default;valueis always SQL text, so a literal keeps its type ('draft',0,true,NULL).kindisliteral,expression,sequence,identityorraw(unclassified text).ormDefaultis a default the ORM applies in application code:scalar,callable(named, never called during extraction),sequenceorexpression.generatedis{ kind: stored | virtual | null, expression }for generated columns.
Indexes
Indexes are first-class entities, never drawn as graph nodes by default.
Index { id, name, parts, expression, unique, backs, method, partial, predicate, included, dialectOptions?, provenance, drift?, extensions? }
partslists every key part in order. A part is a column or an expression, never both:{ column, expression, order, nulls, length, collation, opclass }, withorderasc/desc,nullsfirst/last,lengtha prefix length (MySQLname(10)), andopclassan operator class (PostgreSQLtext_pattern_ops).expressionis true when any key part is an expression, including one whose text the source does not report (the part is then left out ofparts).- The primary key's index is the one with
backs.kindprimary_key: it is unique, has no expression parts, and its column parts are exactly the table'sprimaryKey. backsis the constraint the index enforces:{ kind: primary_key | unique_constraint, constraint }, whereconstraintis the unique constraint's id when the document has it as an entity. An index created on its own hasbacks: null, so a unique index and a unique constraint stay distinguishable.methodisbtree,hash,gin,gist,brin,fulltext,spatial, … or null.partialwithpredicate(the WHERE clause, or null when unknown);includedlists INCLUDE columns.dialectOptionsholds options declared per dialect, mostly by ORM metadata that targets several dialects:{ postgresql: { predicate, method, included, options } }. Neutral fields hold what applies everywhere.
Constraints and foreign keys
Constraint { id, kind: unique | check, name, columns, expression, provenance, drift?, extensions? }. A unique constraint lists its columns; a check constraint has its expression and, when known, the columns it reads.
ForeignKey { id, name, columns, foreignTable, foreignColumns, onUpdate, onDelete, deferrable, initially, provenance, drift?, extensions? }
columnsandforeignColumnsare ordered pairs; composite keys list several.- Referential actions are upper-case SQL (
CASCADE,SET NULL,SET DEFAULT,RESTRICT,NO ACTION), or null when not declared or not reported. - A target table or column outside the document is allowed only with an
unresolved_foreign_keydiagnostic whose subject is the foreign key. - The target table is in the same connection as the foreign key's table; see Connections and tables.
Models and relationships
Model { id, className, tables, primaryKey, source, extensions? }. A model maps to zero tables (unresolved, with a diagnostic), one, or several (inheritance). source is { file, line } relative to the project root.
Relationship { id, name, model, table, relatedModel, relatedTable, type, cardinality, collection, readOnly, keys, pivot, polymorphic, through, join, constraints, inverse, note, source, extensions? }
typeis the ORM's own word (HasMany,MANYTOONE,@relation,ManyToOne);cardinalityis the neutral reading from the declaring model's side:one-to-one,one-to-many,many-to-one,many-to-many.collectionis whether it holds a collection;readOnlywhether the ORM writes through it.keysis oriented like a foreign key, whichever side declares the relationship:fromholds the referencing columns,tothe referenced ones.pivotis{ table, parentColumns, parentPivotColumns, relatedColumns, relatedPivotColumns, columns }for many-to-many: pivot columns reference the declaring side'sparentColumnsand the related side'srelatedColumns;columnsare extra pivot columns the relationship exposes.polymorphicis{ typeColumn, idColumn, typeValue }. A polymorphic relationship with no single target hasrelatedModelandrelatedTablenull.throughlists the intermediate table ids of a through relationship, in order; such a relationship has nokeys.joinkeeps join conditions as the ORM states them,{ primary, secondary };constraintskeeps extra query constraints as text.relatedModelnull withoutpolymorphicrequires anunresolved_relationshipdiagnostic.
Graph
links and derivedLinks are stored in the document. Producers build them with the shared rules below. The TypeScript buildGraph() implements them, and contract/conformance/graph/ holds the cases every implementation must pass.
- Every foreign key whose endpoints resolve (the target table and every column are in the document, in the foreign key's own connection) is a link from its columns to the referenced columns. A foreign key naming a table in another connection, which a valid document never has, has no link.
- Every relationship's
keysare a link the same way. Keys equal to a foreign key's, compared as unordered column pairs, join that foreign key's link; otherwise the link has no foreign key. A relationship never creates a constraint. - A pivot relationship is two links, both from the pivot table:
parentPivotColumns → parentColumnson the relationship'stable, andrelatedPivotColumns → relatedColumnson itsrelatedTable. - A through relationship is a derived link
{ from: table, to: relatedTable, via: through, relationship }, never matched to a foreign key. - Nothing without resolvable endpoints has a link. Links come only from foreign keys within a connection and from relationships; a relationship whose ends are in two connections is a
relationshiplink and never gains a foreign key. - When several foreign keys have the same column pairs, the smallest id stands for them; a link's endpoints follow its foreign key's column order, or the first relationship's when there is none.
stateisboth,foreign_keyorrelationship;polymorphicis true when any of its relationships is;relationshipsis sorted;noteis the note of the first relationship, by id, that has one.- Ids: a link is
"l"+ the first 16 hex digits of the SHA-256 of the compact JSON["link", fromTable, toTable, pairs], wherepairsare the[fromColumn, toColumn]pairs sorted by code point. A derived link is"d"+ the same digest of["derived", relationshipId]. Compact JSON has no whitespace, and neither slashes nor non-ASCII characters are escaped: Pythonjson.dumps(v, separators=(",", ":"), ensure_ascii=False), PHPjson_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE). - Links and derived links are sorted by id.
Diagnostics
Diagnostic { id, kind, category, severity, certainty, subject, message, extensions? }
idis"n"+ the first 16 hex digits of the SHA-256 of["diagnostic", kind, subject.type, subject.id, subject.columns, message], so two equal diagnostics cannot both be listed and gate reports can name one.category:extraction(ORM metadata could not be read),inspection(database metadata could not be read),reconciliation(one source has what the other lacks),drift(both have it and disagree),lint(advice).severity:error,warning,info.certainty:certainfor a confirmed fact read from metadata,likelyfor a heuristic recommendation inferred from names, conventions or usage. Viewers show the two apart.subject:{ type, id, columns, label }, wheretypeisdocument,connection,table,column,index,foreignKey,constraint,model,relationship,link,comparison,migrationorinferredAssociation. Forcolumn,idis the table's id andcolumnsnames at least one column; other subjects may name columns as context.idis null fordocument, and must exist in the document otherwise, except forunresolved_*kinds, which name what is missing. Alinksubject may be a link or a derived link.labelis a readable name for when the id does not resolve.Named kinds producers share (open list):
unresolved_foreign_key,unresolved_relationship,unsupported_metadata,inspection_unavailable,model_without_table,table_without_model,declared_only,database_only,column_drift,index_drift,foreign_key_drift,missing_index,duplicate_index,redundant_index,relationship_without_foreign_key,foreign_key_without_relationship, and for the sections belowcomparison_incomplete(inspection, subjectcomparison; exactly as Storing comparisons defines it),migration_history_unavailable(extraction),migration_version_table_unreadable(inspection),migration_multiple_heads(lint),migration_unknown_revision(reconciliation), all with subjectmigration, andinference_ambiguous(lint, subjectinferredAssociation).
Comparisons
A comparison lists how one scope's schema differs from another's: two tenant schemas of one connection, a connection against another (a primary and a replica, PostgreSQL against MySQL), or a release against production. Producers compute it with the algorithm below, which compareScopes() implements and contract/conformance/comparison/ checks. Viewers display stored comparisons and never compute them.
Scope { connection, schema }: a connection id, and a schema of it or null. A table is in scope when its schema equals schema, or is null while the connection's defaultSchema equals schema. With schema null, the tables whose schema is null or the default schema are in scope. A scope must not hold two tables of one name.
Comparison { id, baseline, target, complete, omitted, summary, differences, extensions? }
idis"c"+ the digest of["comparison", baseline.connection, baseline.schema, target.connection, target.schema]. The two scopes differ.summary{ missing, extra, changed }counts every difference found, listed or not;omittedis how many were left out ofdifferences(a producer's limit, in difference order). The validator checkssummarysums todifferences.length + omittedand covers the listed ones.completeis true exactly whenomittedis 0 and neither side has a coverage gap. An incomplete comparison is never reported as passing; see Gate reports.
Difference { id, change, objectType, key, baseline, target, fields, severity, certainty, extensions? }
change:missing(in the baseline, not the target;targetnull),extra(in the target only;baselinenull),changed(both sides;fieldslists the differing fields, sorted byfield, at least one). Missing and extra differences list no fields, and a missing or extra table's contents are not listed.objectType:table,column,primaryKey,index,foreignKey,constraint.keyis the normalized identity both sides share. It is built from object names and never from a schema name, so tenant schemas are never drift. Its first element is the name of each side's table, and a column difference's second is a column of each side's table; the validator checks both. A table, column or index may itself be named like a compared schema.baselineandtargetare refs{ table, id, label }: the table id in that side's scope;idthe index, foreign-key or constraint id (null for table, column and primary-key differences);labelthe readable name:schema.table(ortable), with.column,primary key, or.name(.definitionwhen unnamed).idis"x"+ the digest of["difference", comparisonId, objectType, key].- Differences are sorted by object type in the order above, then
keyelement-wise, then change (missing,extra,changed).
Coverage gaps
A side's coverage gaps say why it cannot be compared completely (coverageGaps(document, scope); contract/conformance/coverage-gaps/ checks them). The side's coverage is its connection's database coverage, or declared coverage in a declared document. The gaps, in this order:
{ kind: "coverage_unknown" }when that coverage is null; it is then the only gap.{ kind: "feature", feature, state }for each feature the comparison reads whose state is notcompleteornot_applicable, in this order:tables,columns,columnTypes,defaults,comments,primaryKeys,foreignKeys,referentialActions,indexes,indexExpressions,indexPredicates,uniqueConstraints,checkConstraints(COMPARISON_FEATURES).stateispartial,unavailable, orunknownfor a feature the coverage leaves out.not_applicableis never a gap, nor is any other feature (includedColumns,models,relationships).{ kind: "schema_not_read", schema }when the scope's schema (schema, else the connection'sdefaultSchema) and the coverage'sschemasare both known andschemasdoes not list it.
A side is covered (scopeCovered()) when it has no gaps. Messages spell a gap as coverage unknown, {feature} {state} or schema {schema} not read (coverageGapText()).
Requested comparisons
Users ask for comparisons by connection name, as they configure them: { baseline: { connection, schema }, target: { connection, schema } }, with schema a schema or null for the unqualified scope. Producers resolve their configured comparisons, and the gate its requested ones, with expandComparisonRequests(document, requests) (contract/conformance/requests/ checks it), which returns { pairs, errors } and never throws:
- Requests are taken sorted by baseline connection name, baseline schema, target connection name and target schema (null before any string, strings in code point order). A request equal to an earlier one (same names and schemas) is not expanded again and gives one
duplicateerror, however often it repeats:Requested comparison {request} is listed more than once. - Each connection name the document does not have gives an
unknown_connectionerror, once per name:Requested comparison {request} names connection {name}, which the document does not have.Nothing more is done for that request. - A target schema
"*"(WILDCARD) stands for every schema the target connection's coverage (as for coverage gaps) lists inschemas, each once, in code point order, leaving out the baseline's own scope: on the same connection, the schema equal to the baseline's, or, with a null baseline schema, the connection'sdefaultSchema. Coverage or itsschemasnull: ano_schemaserror,Requested comparison {request} cannot be expanded: the inspection of connection {name} lists no schemas.Nothing left: ano_matcherror,Requested comparison {request} matches no schema.Only a target expands:"*"in a baseline is an ordinary schema name. A wildcard never yields a null schema. - Any other request is one pair: both names resolved to connection ids, schemas as given. Expansion consults neither coverage nor the stored comparisons.
- When an explicit request and a
"*"expansion give the same pair, the pair is listed once with the explicit request, and that is no error. No other two different requests can give one pair. pairs{ baseline, target, request }: the scopes (connection ids) and the request that gave them, sorted by baseline connection id, baseline schema, target connection id and target schema (null first).errors{ kind, request, message }, in the order of the sorted requests, a request's own errors in the order above.
{request} spells each scope as name or name:schema (spellScope(name, schema); the request's own names and schemas, so name:* for a wildcard), baseline and target joined by → (U+2192 with single spaces). A producer reports every error as a configuration error and writes no comparison for that request. A request whose two scopes are equal is a configuration error producers reject when they read their configuration; expansion passes it through.
The comparison algorithm
In a combined document only entities whose provenance includes database take part. SQL text is compared normalized: lower case; the side's own schema qualifier removed (tenant_a.f(x) and "tenant_a".f(x) become f(x)); on MariaDB octet_length( read as length(; casts (::type) removed; whitespace, parentheses, backticks and double quotes removed.
- Tables match by name. Key
[table]. A matched table's field:comment. - Columns of matched tables match by name. Key
[table, column]. Fields, each compared as stated:type: on two connections with the same non-null driver, the text lower-cased with whitespace collapsed (certain); otherwise the normalized type (likely): lower case, whitespace collapsed,unsigned/signed/zerofilldropped, the base name mapped through the synonyms below,timestampwithtimezone: trueastimestamptz,tinyint(1)asboolean, and arguments dropped for integer types anddouble(53). A type that does not parse compares by its collapsed text.nullable;default: identity and sequence defaults, and no default withautoIncrement: true, are one value (auto), whatever the sequence; other defaults compare as normalized SQL (likely);collation, when both are known, case-insensitively;enumvalues in order;generatedkind and normalized expression (likely);comment.
- Primary key:
[table], compared by its ordered columns. Missing when only the baseline has one, extra when only the target has one, else fieldcolumns. - Indexes match by name, then the rest by signature,
unique(when unique) +(+ the parts joined by,+), a part being its column orexpr:+ normalized expression, withdescfor descending order, and,?for an expression part the source did not report. Indexes backing the primary key, or a unique constraint present as an entity, are compared as those, not as indexes. Keys:[table, "name", name]for a name match or an unmatched named index,[table, "parts", signature]otherwise. Fields:name(signature match, both named),unique,parts(the signature withoutunique),method(both known, case-insensitive),predicate(partial flag or normalized predicate; likely),included(as sets). Apartschange involving an expression part is likely. - Foreign keys match by signature
columns,…->referencedTableName(referencedColumns,…)(the referenced table by name, never id or schema), then the rest by name. Keys:[table, "references", signature]for a signature match,[table, "name", name]for a name match or an unmatched named key, else the signature form. Fields:name(signature match, both named);columns,foreignTable(the name),foreignColumns(name match);onDelete,onUpdate, where null isNO ACTIONand, when either side is MySQL or MariaDB,RESTRICTisNO ACTION;deferrableandinitiallywhen both are known. Certain. - Constraints match by kind and name, then the rest by kind and definition (a unique constraint's columns joined by
,, else the normalized expression). Keys:[table, kind, "name", name]or[table, kind, "definition", definition]. Fields:columns(unique, name match),expression(check, name match; likely),name(definition match, both named). - Pairs within a pass are made in entity-id order. Keys that still coincide (unnamed duplicates) get
"#2","#3", … appended in the order of their entity ids.
Certainty: missing and extra differences are certain; a changed one is likely when any of its fields was compared as normalized text as marked above, else certain.
Type synonyms: int, int4, integer, mediumint, serial → integer; bigint, int8, bigserial → bigint; smallint, int2, smallserial → smallint; tinyint; bool, boolean → boolean; character varying, varchar, nvarchar → varchar; character, char, bpchar, nchar → char; text, clob → text; datetime, timestamp, timestamp without time zone → timestamp; timestamptz, timestamp with time zone → timestamptz; time without time zone → time; time with time zone, timetz → timetz; float, float8, double, double precision → double; real, float4 → real; numeric, decimal → numeric; blob, bytea, varbinary, largebinary → blob.
Severity
Producers assign severity from these defaults and let users override them per (objectType, change) or (objectType, change, field); a field override wins over a general one. A changed difference takes its most severe field.
| Object | missing / extra | changed |
|---|---|---|
| table | error | info (comment) |
| column | error | warning; comment: info |
| primaryKey | error | error |
| index, foreignKey, constraint | warning | warning |
An override is { objectType, change, field, severity }, field optional (absent or null for a general override). The fields a changed difference can list, from the algorithm above, are exactly the fields an override may name (DIFFERENCE_FIELDS):
| Object | Fields |
|---|---|
| table | comment |
| column | collation, comment, default, enum, generated, nullable, type |
| primaryKey | columns |
| index | included, method, name, parts, predicate, unique |
| foreignKey | columns, deferrable, foreignColumns, foreignTable, initially, name, onDelete, onUpdate |
| constraint | columns, expression, name |
validateSeverityOverrides(overrides) returns these errors, empty when the overrides are valid (contract/conformance/overrides/ checks them); {i} is the override's index. Producers reject a configuration with any of them, and compareScopes() throws Invalid severity overrides: {errors joined by "; "}.
| Rule | Message |
|---|---|
| Not an array | overrides must be an array (the only error) |
| An entry that is not an object | overrides[{i}] must be an object (nothing more for it) |
| Keys other than the four | overrides[{i}] has unknown keys: {keys}, sorted, joined by , |
objectType not an object type | overrides[{i}].objectType must be one of table, column, primaryKey, index, foreignKey, constraint |
change not a change | overrides[{i}].change must be one of missing, extra, changed |
severity missing or not a severity | overrides[{i}].severity must be one of error, warning, info |
field neither a string nor null | overrides[{i}].field must be a string or null |
A field with a valid change other than changed | overrides[{i}].field applies only to changed differences, not {change} |
A field the valid objectType does not have, on changed | overrides[{i}].field must be one of {fields} for objectType {objectType} |
The (objectType, change, field) of an earlier override {j}, both valid in those three | overrides[{i}] repeats overrides[{j}] |
Errors are listed per override in index order, each override's in the order of the table. A field is judged only against a valid object type and change.
Storing comparisons
A producer writes the comparisons it computed into a document with storeComparisons(document, comparisons) (contract/conformance/store/ checks it):
- Two comparisons with one id are an error (
Comparison {id} is listed more than once). - Stored comparisons with the id of a new one are dropped, with every diagnostic whose subject is such a comparison (
subject.typecomparison, whatever its kind). Other comparisons and diagnostics are kept. - Each new comparison is added with its diagnostics (
comparisonDiagnostics(document, comparison), checked bycontract/conformance/comparison-diagnostics/): none when it is complete, else onecomparison_incompletediagnostic:categoryinspection,severitywarning,certaintycertain;subject{ type: "comparison", id: <the comparison id>, columns: null, label },labelbeing{baseline}={target}, each scope spellednameorname:schemawith its connection's name;messageComparison {label} is incomplete: {reasons}, the reasons joined by;: first{omitted} of {total} differences are left out by the limit of {listed}whenomitted> 0 (totalthe summary's sum,listedthe number of listed differences, which is the limit applied); thenthe baseline {scope} is not completely covered ({gaps})andthe target {scope} is not completely covered ({gaps})for each side with coverage gaps, the gaps spelled as there and joined by,. (A comparison stored as incomplete with neither reason, which the algorithm never produces, getsComparison {label} is incomplete.)idby the diagnostic id rule.
- Comparisons are sorted by id; diagnostics by
(category, kind, subject.type, subject.id, message), a null subject id as the empty string, strings in code point order, and a diagnostic id listed once (the first in that order). Nothing else in the document changes.
Storing the comparisons a document already holds gives the same document.
Migrations
A migration track is one migration tool's history and the database's position in it, for one scope (connection, schema, as for comparisons): one track per tenant schema when each tenant has its own version table. Producers compute status, pending and unknownRevisions with the rules below (migrationState(), contract/conformance/migrations/); the validator recomputes them, and viewers display them.
MigrationTrack { id, provider, connection, schema, versionTable, history, current, status, pending, unknownRevisions, coverage, extensions? }
idis"m"+ the digest of["migration", provider, connection, schema].provideris the tool, lower case (alembic; open).versionTableis the version table's id, in the track's scope, or null when the database has none; thencurrentis[].history{ revisions, heads, missing }or null when the scripts could not be read.revisionsare{ id, parents, branchLabels }sorted by id, with sorted parents and labels.headsare exactly the revisions no revision names as a parent (several when branches are not merged);missingthe parents named but not present (a partial history, which is valid). Both sorted.currentis the sorted revisions the version table records,[]when it records none, or null when it could not be read.
Status, in order (an ancestor-or-equal of x is x or any revision reachable through parents, missing ones included):
currentnull:unknown.currentempty:uninitialized;pendingis every ancestor-or-equal of a head (null whenhistoryis null or that includes a missing revision).historynull:unknown.- A current revision not in
revisions:unknown, listed inunknownRevisions(sorted). It is never a validation error: a database ahead of the scripts is representable. - A current revision that is an ancestor of another current revision (an overlapping version table):
diverged. pending: the ancestors-or-equal of the heads that are no current revision's ancestor-or-equal, sorted. If it holds a missing revision:unknown. Otherwisebehindwhen non-empty,up_to_datewhen empty (the current revisions are then exactly the heads).
pending is null whenever it cannot be determined (unknown, diverged); unknownRevisions is empty except in rule 4.
Coverage rows { table, name, declared, autogenerate, migrationManaged, certainty, evidence } say, per table, whether it is under migration control, sorted by (name, table). table is the table id in the track's scope, or null for a table only migrations know (created and dropped). declared: an ORM model declares it. autogenerate (included, excluded, unknown) is whether the tool's autogenerate compares it; migrationManaged (yes, no, unknown) whether migration scripts create it. They are separate facts: excluded never means "not migrated". A row with an unknown answer is likely. evidence { kind, detail } (create_table, drop_table, include_filter, version_table, model) is sorted by kind and detail.
Inferred associations
An inferred association is what an uncovered foreign key implies between two ORM models that declare no relationship over it. It is never a relationship, never adds or changes a link, and viewers present it as "inferred, no ORM attribute declared". Producers compute them with the algorithm below (inferAssociations(), contract/conformance/inference/); check.mjs recomputes them.
InferredAssociation { id, kind, model, relatedModel, table, relatedTable, via, cardinality, foreignKeys, links, confidence, evidence, ambiguities, extensions? }
idis"i"+ the digest of["inferred", kind, sortedForeignKeyIds, via].kindforeign_keyhas one foreign key,vianull, and cardinalitymany-to-oneorone-to-onefrom the referencing model;association_tablehas two foreign keys,viathe association table, andmany-to-many.foreignKeysandlinks(the foreign keys' existing links) are sorted. No declared relationship covers any of the foreign keys: the validator rejects one whose link has relationships.evidence{ kind, id, confirmed }, sorted by kind and id:foreign_key(each foreign key),primary_key(the table id),unique_constraint,unique_index,model_mapping(each model mapping either table).confirmedis whether the fact was read from metadata: provenance includesdatabase, or the document isdeclared. Model mappings are confirmed.confidence(high,medium,low) is the inference's:high, one level lower for each ambiguity and each unconfirmed foreign key, at mostlow. Confirmed evidence and inferred confidence are separate.ambiguities(sorted, open):multiple_foreign_keys,self_reference,multiple_models.
The inference algorithm
- Candidates are the links with a foreign key and no relationships (each link's foreign key stands for duplicates). A table is mapped when a model lists it; its models are taken in id order.
- Keys of a table: its primary key; its unique constraints with columns; its unique indexes that are not partial, have no expression part and back neither the primary key nor a unique-constraint entity.
- Association tables: for each unmapped table, each pair of its candidates (in foreign-key id order) with disjoint columns whose union is exactly a key of the table, both referenced tables mapped, is an
association_tablebetween the first and second referenced tables, with the matching keys as evidence. Their foreign keys are used. - Every other candidate whose table and referenced table are both mapped is a
foreign_keyinference from its table:one-to-onewhen its columns are exactly a key of its table (the keys are evidence), elsemany-to-one.multiple_foreign_keyswhen more than one foreign-key link goes from its table to the referenced table. self_referencewhen both tables are one;multiple_modelswhen either is mapped by several models (modelandrelatedModelare then the smallest ids). Nothing involving an unmapped table is inferred, apart from step 3.
Gate reports
A gate checks a document in CI and prints a laranex-schema-gate report, a separate format with its own JSON Schema (contract/gate-report.schema.json) and validator (validateGateReport()). Every producer's gate follows the rules below, which gateDocument(input, { thresholds, requested }) implements and contract/conformance/gate/ checks: equal inputs give byte-equal reports in canonical form (see Canonical serialization). A gate reads only the document: never a database, ORM metadata or application code.
{ format: "laranex-schema-gate", version: "1.0", passed, exitCode, thresholds, requested, comparisons, counts, findings, errors }
Input
- The document, as parsed JSON. Input that could not be parsed is reported, not thrown (
gateDocument(undefined)). - Thresholds
{ severity, certainty }, each defaulting separately towarningandcertain. A finding qualifies when its severity is at leastseverity(info<warning<error) and, with certaintycertain, it is certain; withlikely, certain and likely findings both qualify. - Requested comparisons, a list of
{ baseline: { connection, schema }, target: { connection, schema } }, empty or absent when none was requested or configured, as in Requested comparisons (connection names; a target schema"*"). The report'srequestedlists them without duplicates, sorted by baseline connection, baseline schema, target connection and target schema (null before any string). Producers reject duplicate requests before calling the gate, whether configured or given as arguments: runexpandComparisonRequestsover them and treat eachduplicateerror as a configuration or usage error (a CI gate exits 2 with its message). The gate itself drops duplicates and never reports them, so it cannot tell a producer's configuration mistake apart from a repeated request.
Validation
The gate validates the document itself before anything else, in this order; the first that applies gives one invalid_document error, and the report then has no other error, no findings and no checked comparisons:
| Input | invalid_document message |
|---|---|
| Not parseable as JSON | The input could not be parsed as JSON. |
Not an object with format "laranex-schema" and a string version | The input is not a laranex-schema document. |
version whose text before the first . is not 2 | The document is laranex-schema {version}; the gate reads 2.x. |
"2.0" failing strict validation, or a newer 2.x failing lenient validation | The document fails laranex-schema {version} validation. |
A valid document of a newer minor (any 2.x other than 2.0), which only lenient validation accepted, is checked as below with the error validation_incomplete: The document is laranex-schema {version}; only 2.0 is validated strictly, so what {version} adds was not checked. The validator's own messages are not part of the report; a gate prints them beside it.
Checked comparisons
- No requested comparisons: every stored comparison is checked. Zero comparisons is valid.
- Requested comparisons: only the requested ones are checked; other stored comparisons are neither findings nor errors. The gate expands the report's
requested(so without duplicates) withexpandComparisonRequests; each of its errors is acomparison_not_founderror with the same message. Each resulting pair must be a stored comparison with exactly those scopes (connection ids, schemas): otherwiseRequested comparison {pair} is not in the document.
{pair} spells the pair's scopes by connection name, as {request} does. The report's comparisons lists the checked comparisons' ids, sorted. Producers pass requests to the gate as configured; the gate expands them itself.
Findings
findings { source, id, kind, severity, certainty, label, message }. Only these are findings:
| Source | What | id | kind | Severity, certainty | label | message |
|---|---|---|---|---|---|---|
comparison | Every listed difference of every checked comparison | The difference's | <object_type>_<change> in snake case: column_changed, primary_key_missing, … | The difference's | {pair} · {ref} | Only in the baseline (missing), Only in the target (extra), Differs in {fields} (changed) |
diagnostic | Every diagnostic of category drift or reconciliation | The diagnostic's | The diagnostic's | The diagnostic's | See below | The diagnostic's message |
migration | Every track with status behind, diverged or uninitialized | The track's | migration_<status> | behind, diverged: error, certain; uninitialized: warning, certain | {connection} · {schema}, or {connection} when schema is null | See below |
- A comparison finding's
{pair}spells the comparison's scopes by connection name as above;{ref}is the difference's baseline label, or its target label when it has no baseline (an extra object);{fields}the changed fields in their stored order, joined by,. - A diagnostic finding's label is the subject's
labelwhen not null; else the name its id resolves to in the document: a connection's name; a table as{connection} · {table}(schema.table, ortable); a column as that plus.and the subject's columns joined by,; an index, foreign key or constraint as its table's form plus.and its name (its id when unnamed); a model's class name; a relationship as{model class}.{name}; a comparison's{pair}; a migration track's label. Otherwise the subject's id, ordocumentfor the document. - Migration messages, with
{provider}the track's provider:behind:{provider} revisions not applied: {pending}, the pending revisions joined by,.diverged:The {provider} version table records overlapping revisions, one an ancestor of another: {current}, the current revisions joined by,. This is the status rule's overlapping version table, not multiple heads (amigration_multiple_headsdiagnostic, categorylint, which is no finding).uninitialized:The database has no {provider} version tablewhenversionTableis null, elseThe {provider} version table records no revision.up_to_dateis no finding;unknownis an error.
- Nothing else is a finding. Diagnostics of category
extraction(such asunresolved_foreign_key),lintandinspectionnever qualify and never fail the gate; an inspection diagnostic of severityerroris an error (below).
Findings are sorted by source, id and kind. counts { findings, qualifying, error, warning, info } count all findings, the qualifying ones, and each severity.
Errors
errors { kind, message } say why the check is incomplete; an incomplete check never passes. The kinds are a closed list:
| Kind | When | Message |
|---|---|---|
invalid_document | See Validation | As listed there |
validation_incomplete | A valid newer 2.x minor | As listed there |
comparison_not_found | A requested comparison is not in the document | See Checked comparisons |
comparison_incomplete | A checked comparison with complete false | Comparison {pair} is incomplete: {reasons}., or Comparison {pair} is incomplete. without reasons |
coverage_incomplete | In a database or combined document, a connection whose database coverage has tables or columns other than complete (left out counts as unknown), or any feature unavailable | Connection {name}: the database inspection is incomplete: {gaps}. |
inspection_failed | Each diagnostic of category inspection with severity error | {kind} on {label}: {message}, with the diagnostic's kind, finding label and message |
migration_unknown | Each migration track with status unknown | Migration track {label} ({provider}) has an unknown status: {reason}. |
comparison_incompletereasons, joined by;:{omitted} of {total} differences omittedwhenomitted> 0 (totalthe summary's sum); thenthe baseline {scope} was not inspected completelyandthe target {scope} was not inspected completelyfor each side with coverage gaps.coverage_incompletegaps:{feature} {state}(stateunknownwhen left out, as for coverage gaps) for each failing feature in the coverage feature order, joined by,.partialfeatures other than tables and columns (a dialect's known limit, such as SQLite check constraints) andnot_applicableones never fail a connection; the comparisons that read them are judged by their owncomplete.declaredcoverage never fails the gate.migration_unknownreason, the first that applies:the version table could not be read(currentnull),the migration scripts could not be read(historynull),the database records revisions the scripts do not have: {unknownRevisions}(joined by,), elsethe history is missing revisions it needs.
Errors are sorted by kind and message; equal errors are listed once.
Exit code
| Exit code | When | passed |
|---|---|---|
| 2 | errors is not empty, whatever the findings | false |
| 1 | No error, and at least one finding qualifies | false |
| 0 | Otherwise (findings below the thresholds are still listed) | true |
Snapshot scanning
A snapshot describes a private schema: table and column names, comments, notes, constraints and model classes. It must not be committed or published by accident, in a private repository too. The scanner finds snapshots by content, not by file name, in what a repository tracks, what it would add, its build output and, on request, its history, and fails CI on anything it cannot vouch for. scanContent() and scanFiles() implement the rules below, contract/scan.mjs is the command line every producer's CI runs, and contract/conformance/scan/ checks them. A scan reads only file content and Git objects: no network, no database, no application code.
The scanner is a guard against accidents, not against someone hiding a snapshot on purpose: a renamed, encoded or compressed file is not found.
Scopes
| Scope | Files | Mode |
|---|---|---|
tracked | Every regular file in the Git index (git ls-files -s), read from the index (git cat-file), so the staged bytes are scanned whatever the working tree holds, and a file tracked before it was ignored is still found. Symbolic links and submodules are not files. | tree |
untracked | Files Git would add: git ls-files --others --exclude-standard, read from the working tree. Ignored files are not scanned here. | tree |
artifacts | Every file under a given directory (a build output, an unpacked package, a site), ignore rules not applied. A symbolic link to a file is read; one to a directory is not followed and makes the scan incomplete. | artifact |
history | On request only: every version of a candidate file that a commit reachable from any ref introduced (git log --all --raw -m --root, merges included), once per path and blob, with the first commit git log lists for it. A shallow clone has no complete history: the scan fails. | tree |
A file is a candidate by its extension, compared case-insensitively: .json, .html, .htm in tree mode, and also .js, .mjs, .cjs, .map in artifact mode (source code in a repository names the format legitimately; built JavaScript holding a document does not). Other files are not read. Paths are POSIX paths relative to the scan root, the Git work tree's top level (else the current directory); an artifact file's path is <artifact-dir>/<relative>, the directory as given relative to the root (absolute when outside it, <relative> alone for the root itself).
Content signature
A candidate larger than 64 MiB (67 108 864 bytes, SCAN_SIZE_LIMIT) is not read: the scan is incomplete. Content is read as UTF-8, a leading byte order mark ignored. A document is a JSON object whose format is exactly "laranex-schema", whose version is a string matching ^\d+\.\d+$ (any major and minor, 1.0 and 3.0 included) and whose connections is an array.
Legacy formats (temporary; each rule is removed once its producer has migrated, with no compatibility code left): Laravel's envelope, format the number 2 with document.connections an array; the Python and Node.js v1 viewer format, format exactly "laranex-schema-viewer" with tables an array.
A .json candidate is parsed as JSON:
- A document at the top level: kind
document, reasondocument. A legacy snapshot at the top level: kindlegacy, reasonlaravel_envelopeorviewer_v1. - Else a document at depth 1 to 6 (the number of keys and array indexes from the top-level value to it, every array item included): kind
embedded, reasonnested_document; else a legacy snapshot there:nested_legacy. A legacy snapshot is not searched further. Depth 7 and beyond is not searched. - JSON that does not parse is
uncertain(unparseable_json) when the text holds the format pair (below), or aconnectionskey beside one of"derivedLinks","comparisons","inferredAssociations","relationships","models"as a quoted key; otherwise it is clean.
Any other candidate is text. The format pair is the key format with the value exactly laranex-schema, either quoted, "format": "laranex-schema" (each quote may be preceded by up to three backslashes, for JSON inside a JavaScript string or a source map), or as an object literal key, {format: "laranex-schema", or ,format:'laranex-schema'} (single or double quotes, escaped likewise, followed by , or }). A pair inside an object that is the value of a contract key (the viewer manifest's contract: { format, major }) does not count.
- Text with a format pair and a
connectionskey followed by[(quoted or not, escaped likewise), anywhere in the file: kindembedded, reasonviewer_globalwhen the text assignswindow.__SCHEMA_VIEWER__ = {, elseembedded_jsonfor a quoted pair andobject_literalfor a literal one. This finds the shared viewer's offline page (itsscript#schema-viewer-dataJSON is plain), pages that inline a document orViewerConfig, bundles that inline one asJSON.parse('…')or as an object, and source maps whosesourcesContentholds one. - Text with a format pair and no
connections:uncertain(missing_connections), for example a fragment. - Otherwise clean.
Never a snapshot, by exact equality: laranex-schema-gate (gate reports are sensitive CI artifacts, kept with restricted access and short retention, but not snapshots), a nested contract.format (the viewer manifest), and a JSON Schema's "format": { "const": "laranex-schema" }. A version that is not a string, or JSON without connections, is no document.
Not detectable by content, and so not scanned: SVG, PNG, DBML, Mermaid and Markdown exports; compressed or encoded payloads, such as base64+gzip offline HTML exports (Laravel's id="schema-viewer-config" page), which are neither decoded nor flagged; JavaScript and source maps in the repository tree; files with other extensions. These remain sensitive artifacts: users protect them and keep them out of version control (git-ignore them) themselves.
Allowlist
An approved synthetic fixture, or a snapshot committed on purpose, is listed by exact path and digest in a laranex-schema-allowlist file (contract/allowlist.schema.json); nothing inside a document marks it as synthetic. Allowlisting is an explicit review decision, not proof that the content is harmless. The default file is .laranex-schema-allowlist.json at the scan root.
{ "format": "laranex-schema-allowlist", "version": "1.0",
"entries": [{ "path": "tests/fixtures/blog.json", "sha256": "…" }],
"include": [{ "path": "node_modules/laranex-schema-viewer-ui/contract/synthetic.json", "root": "node_modules/laranex-schema-viewer-ui" }] }entries[].pathis a relative POSIX path: no leading/, no\, no empty,.or..segment, no trailing/. It names a repository-relative path, or<artifact-dir>/<relative>. One path may be listed with several digests (versions in the history); a path and digest pair is listed once per file.sha256is the lower-case hex SHA-256 of the file's bytes with every CRLF read as LF (allowlistDigest()), so a Windows checkout matches.include[]names another allowlist by a path relative to the including file's directory; its entries apply withroot(a relative path, or.) prepended, and nested roots compose. A cycle, an unreadable file or any invalid allowlist fails the scan. Allowlists are always validated strictly: unknown properties and any other version are errors.contract/synthetic.jsonlists every synthetic document the package ships, by package-relative path; a producer includes it withrootset to where the package is installed.
Each detected file is judged in this order:
| Status | When | Counts as |
|---|---|---|
allowed | An entry has its path and digest | clean |
tampered | Entries have its path, none its digest | confirmed |
moved | No entry has its path, one has its digest | confirmed |
confirmed / uncertain | Otherwise, by kind (uncertain for an uncertain kind) | confirmed / uncertain |
An entry that matched no file is stale (a warning; the exit code ignores it) when the scan read a clean file at its path (not_detected), or when it names no file in what the scan covered (not_found): under a scanned artifact directory, or anywhere Git does not ignore when both tracked and untracked were scanned. Other entries are not judged. History scans never make an entry stale.
Report
scan.mjs --report FILE (or --json) writes a laranex-schema-scan report in canonical form (contract/scan-report.schema.json, validateScanReport()):
{ format: "laranex-schema-scan", version: "1.0", exitCode, scopes, limit, counts, findings, errors }
scopes: the scopes scanned, sorted.limit: the size limit in bytes.counts{ files, confirmed, tampered, moved, uncertain, allowed, incomplete, stale }:filesthe candidates considered, the others the findings of each status.findings{ scope, status, kind, path, commit, sha256, reason }, sorted by path, commit (null as empty), scope, status and reason in code point order.scopeis a scan scope, orallowlistfor a stale entry;kindisdocument,legacy,embedded,uncertain,too_largeorunreadable(statusincomplete), orallowlist_entry(statusstale);commitis the full commit id for a history finding, else null;sha256the file's digest, the stale entry's, or null when the file was not read.reasonis one ofdocument,laravel_envelope,viewer_v1,nested_document,nested_legacy,embedded_json,object_literal,viewer_global,unparseable_json,missing_connections,size_limit,read_error,symlinked_directory,not_detected,not_found.errors{ kind, message }, sorted, each listed once:not_a_repository,git_failed,allowlist_invalid,history_unavailable,artifact_missing. A usage error exits 2 without a report.
A report never holds file content: findings carry only paths, commits, digests and fixed codes. The command's own output is a one-line summary of the counts; locations are only in the report.
Exit code
| Exit code | When |
|---|---|
| 2 | Scan failure or incomplete scan: any error (usage, not a Git work tree, Git failed, an invalid or unreadable allowlist, history requested but unavailable, a missing artifact directory) or any incomplete finding (unreadable, over the size limit, a symbolic link to a directory) |
| 1 | Otherwise, any confirmed, tampered or moved finding |
| 3 | Otherwise, any uncertain finding |
| 0 | Otherwise (stale entries are warnings) |
Precedence is 2 > 1 > 3 > 0: an incomplete scan cannot vouch for anything. CI fails on every non-zero exit.
npm install --no-save laranex-schema-viewer-ui@<version>
node node_modules/laranex-schema-viewer-ui/contract/scan.mjs --tracked --untracked --artifacts distExtensions
extensions is an object keyed by a lower-case namespace (eloquent, laravel, sqlalchemy, django, prisma, typeorm, alembic, …) holding any JSON object. It is allowed on the document, connections, tables, columns, indexes, constraints, foreign keys, models, relationships, diagnostics, comparisons, differences, migration tracks and inferred associations.
- Extensions carry what only one ORM or framework means, such as Eloquent casts, fillable attributes, SQLAlchemy's
autoincrement="ignore_fk"or a Laravel hint's ignore rule. - Anything with a neutral meaning goes in a neutral field, never only in an extension.
- Viewers render extensions generically, and may render a known namespace more richly. They never require one.
Canonical serialization
Producers write the document as UTF-8 JSON with:
- object keys sorted recursively by Unicode code point;
- two-space indentation,
": "after keys, no trailing spaces; - slashes and non-ASCII characters unescaped;
- a trailing newline;
- no
undefinedor absent-versus-null ambiguity: required fields are always present, null when unknown.
Arrays keep the order this page gives them. Where it gives none, producers sort by id (tables within a connection, models, relationships, comparisons, migration tracks, inferred associations), keep declaration order for columns and key parts, and sort diagnostics by (category, kind, subject.type, subject.id, message). "Sorted" strings are in Unicode code point order; string lists compare element-wise, a prefix first. A document's revision is the SHA-256 of its canonical bytes (documentRevision()).
Changes from 1.0
For the record; nothing reads or converts 1.0.
versionis"2.0"; validators reject 1.x, and strict validation accepts exactly 2.0.- New required arrays
comparisons,migrations,inferredAssociations(empty allowed), with new diagnostic kinds and subject typescomparison,migration,inferredAssociation(andlinksubjects may name derived links). - Diagnostics have an
idby rule, so reports can name one and duplicates are impossible. Index.columnsis gone: it repeated the column parts ofparts, and the validator had to keep them in step.Index.primaryis gone: it repeatedbacks.kind == "primary_key". The primary index must also have exactly the primary key's columns.Connection.driverhas one spelling per database (postgresql, notpgsql), since comparisons depend on equal drivers; known aliases are rejected.- A column diagnostic must name its columns.
Checking a producer
npm install --no-save laranex-schema-viewer-ui@<version>
node node_modules/laranex-schema-viewer-ui/contract/check.mjs path/to/snapshot.jsoncheck.mjs runs strict validation (which checks comparison ids, order and summaries and recomputes migration statuses), compares the stored links and inferred associations with the shared builders, and checks canonical form. Producers in other languages also run contract/conformance/{graph,comparison,requests,coverage-gaps,overrides,comparison-diagnostics,store,migrations,inference,gate,scan}/*.json through their own implementations and contract/fixtures/invalid/*.json through their own validators, where they have them.