panproto
panproto compares schema versions, constructs migrations, and applies those migrations to data. Your first diff introduces that workflow with two ATProto Lexicon documents and the structural diff between them.
Choose a path
Choose a path based on the work you need to do:
| Experience | Start here | Continue with |
|---|---|---|
| New to panproto | Your first diff | Your first schema, then Your first migration |
| Adding panproto to a project | Install | Define a schema, build a migration, then add a breaking-change gate |
| Designing protocols or extending the system | Build a custom protocol | Find schema spans, study the architecture, then consult the denotational semantics |
The vocabulary in plain terms introduces unfamiliar terms. The glossary records their reference definitions.
Find the kind of answer you need
The book follows the four-part Diátaxis structure:
| Section | Use it when | What you will find |
|---|---|---|
| Tutorials | You are learning by doing | Guided sequences |
| How-to guides | You have a specific task | Procedures, verification steps, and common failures |
| Reference | You need an exact contract | Commands, signatures, configuration fields, and supported surfaces |
| Explanation | You need the reason or the model | Design arguments, categorical constructions, architecture, and semantics |
Begin with a tutorial if you need both context and a worked sequence. Use reference pages when you already know the operation and need its exact contract.
Tutorials
Begin with a structural diff. The remaining tutorials extend the same User example from a schema to a migration, a version history, and a conversion between protocols.
Follow the beginner path in order:
- Your first diff compares two ATProto Lexicon documents from the command line. This is the quick success path.
- Your first schema builds and validates the same model with the TypeScript SDK.
- Your first migration renames a field, converts a record, and checks the reverse trip.
The next two tutorials branch from that foundation. Schema version control basics is the intermediate path for commits, branches, and structural merge. Cross-protocol translation is the advanced path for an explicit conversion between schemas registered under different protocols.
| Tutorial | Result |
|---|---|
| Your first diff | One structural diff over two schema documents |
| Your first schema | A schema plus valid and invalid records |
| Your first migration | A checked rename with a round-trip assertion |
| Schema version control basics | A repository with a branch and fast-forward merge |
| Cross-protocol translation | A narrow forward conversion between structurally compatible schemas |
Tutorials are learning-oriented: each leaves you with a runnable result and explains only the concepts needed to produce it. The how-to guides collect task-specific procedures, the reference records the complete interfaces, and the explanation chapters develop the theory behind the commands.
Your first diff
This tutorial compares two ATProto Lexicon documents and reports one removed field and one added field.
You will create two versions of a User record and run one structural diff. The inputs remain ordinary Lexicon JSON rather than panproto’s internal schema representation.
Prerequisite
Install the schema binary by following Install the CLI, then confirm that schema --version succeeds.
Create two versions
The following block creates a fresh directory, a small manifest that identifies the document protocol, and both input files:
mkdir -p panproto-first-diff
cd panproto-first-diff
cat > panproto.toml <<'EOF'
[workspace]
name = "first-diff"
[[package]]
name = "lexicons"
path = "."
protocol = "atproto"
EOF
cat > user-v1.json <<'EOF'
{
"lexicon": 1,
"id": "com.example.user",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
}
}
}
}
}
EOF
cat > user-v2.json <<'EOF'
{
"lexicon": 1,
"id": "com.example.user",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"properties": {
"name": { "type": "string" },
"years": { "type": "integer" }
}
}
}
}
}
EOF
Listing 2.1: A manifest and two complete Lexicon inputs for the first structural diff.
The two files differ by one field name: age became years.
Run the diff
From panproto-first-diff/, run:
schema diff user-v1.json user-v2.json
The command uses panproto.toml to select the ATProto document parser, then compares the resulting schema graphs. Its report includes a removed com.example.user:body.age vertex and an added com.example.user:body.years vertex, together with the corresponding property edges. That report establishes the structural removal/addition pair. The rename interpretation requires the second pass below.
Rename detection is a second pass over that structural result:
schema diff user-v1.json user-v2.json --detect-renames
If a removed and added element clear the detector’s similarity threshold, the command adds them to a Detected renames section with confidence scores. This score is evidence for a possible correspondence. Your first migration later records the correspondence explicitly.
For a compact count rather than the element-by-element report, run:
schema diff user-v1.json user-v2.json --stat
The diff is structural in a precise sense: panproto compares parsed vertices, edges, and constraints rather than changed lines. The shared diff loader also accepts panproto schema JSON, source files supported by the tree-sitter registry, and manifest-backed directories.
Next
Your first schema builds the same User model through the SDK and validates records against it. If the command line is your main interface, Schema version control basics turns these source files into commits and branches. The vocabulary in plain terms defines vertex, edge, migration, and lens when you are ready for those names.
Your first schema
This tutorial builds a small User schema, parses two records, and reports a missing required field.
The walkthrough uses the TypeScript SDK. The Python and Rust how-to guides present the same construction through their native APIs.
Set up the project
Node.js 20 or later is required by @panproto/core. Create a project and install the SDK plus tsx, which runs the TypeScript file directly:
mkdir -p my-first-schema/src
cd my-first-schema
npm init -y
npm install @panproto/core tsx
Build and exercise the schema
Create src/main.ts with the complete program below:
import { Panproto } from '@panproto/core';
const p = await Panproto.init();
const atproto = p.protocol('atproto');
const schema = atproto.schema()
.vertex('user', 'object')
.vertex('user.name', 'string')
.vertex('user.age', 'integer')
.edge('user', 'user.name', 'prop', { name: 'name' })
.edge('user', 'user.age', 'prop', { name: 'age' })
.required('user', [
{ src: 'user', tgt: 'user.name', kind: 'prop', name: 'name' },
])
.build();
const alice = p.parseJson(schema, JSON.stringify({ name: 'Alice', age: 30 }));
const missingName = p.parseJson(schema, JSON.stringify({ age: 30 }));
console.log('Alice:', alice.validate());
console.log('Missing name:', missingName.validate());
console.log('JSON:', new TextDecoder().decode(alice.toJson()));
schema[Symbol.dispose]();
p[Symbol.dispose]();
Listing 3.1: A complete schema construction and required-field check.
Run it:
npx tsx src/main.ts
The first validation passes, the second reports a missing name edge, and the final line emits Alice as JSON. The exact error includes an internal node identifier, but the stable part of the output is:
Alice: { isValid: true, errors: [] }
Missing name: { isValid: false, errors: [ 'MissingRequiredEdge { ... }' ] }
JSON: {"age":30,"name":"Alice"}
Read the program from the outside in
The atproto protocol supplies the permitted vertex kinds and edge rules. From those rules, the program builds a schema with one object vertex, two value vertices, and two property edges, then parses two instances against it. validate() checks those records, including the required-edge condition used here.
SchemaBuilder is immutable in the TypeScript SDK: every call to vertex, edge, or required returns a new builder. build() sends the accumulated operations to the WebAssembly engine and returns a BuiltSchema. The SDK root and the built schema own WebAssembly handles, which is why the program disposes them explicitly. Instance stores encoded bytes rather than a WebAssembly handle and has no Symbol.dispose method.
Next
Your first migration evolves this schema by renaming age to years and moves Alice forward without losing the original record. Define a schema from TypeScript covers additional builder operations, while Schemas as theories explains why panproto represents a schema as a graph.
Your first migration
A structural diff records that age disappeared and years appeared. A migration adds the missing information: those two fields correspond. This tutorial declares that correspondence, checks it, converts Alice, and asserts that the reverse trip restores the original record.
Continue in the my-first-schema/ project from Your first schema.
Build both schemas and the mapping
Create src/migration.ts:
import assert from 'node:assert/strict';
import { Panproto } from '@panproto/core';
const p = await Panproto.init();
const atproto = p.protocol('atproto');
function userSchema(numericField: 'age' | 'years') {
return atproto.schema()
.vertex('user', 'object')
.vertex('user.name', 'string')
.vertex(`user.${numericField}`, 'integer')
.edge('user', 'user.name', 'prop', { name: 'name' })
.edge('user', `user.${numericField}`, 'prop', { name: numericField })
.required('user', [
{ src: 'user', tgt: 'user.name', kind: 'prop', name: 'name' },
])
.build();
}
const v1 = userSchema('age');
const v2 = userSchema('years');
const mapping = p.migration(v1, v2)
.map('user', 'user')
.map('user.name', 'user.name')
.map('user.age', 'user.years')
.mapEdge(
{ src: 'user', tgt: 'user.name', kind: 'prop', name: 'name' },
{ src: 'user', tgt: 'user.name', kind: 'prop', name: 'name' },
)
.mapEdge(
{ src: 'user', tgt: 'user.age', kind: 'prop', name: 'age' },
{ src: 'user', tgt: 'user.years', kind: 'prop', name: 'years' },
);
const existence = p.checkExistence(v1, v2, mapping);
if (!existence.valid) {
throw new Error(JSON.stringify(existence.errors));
}
const compatibility = p.diffFull(v1, v2).classify(atproto);
const migration = mapping.compile();
const original = { name: 'Alice', age: 30 };
const converted = migration.liftJson(original, 'user');
const { view, complement } = migration.getJson(original, 'user');
const restored = migration.putJson(view, complement, 'user');
assert.deepEqual(restored, original);
console.log('existence valid?', existence.valid);
console.log('compatible?', compatibility.isCompatible);
console.log('converted:', converted);
console.log('round trip:', restored);
migration[Symbol.dispose]();
v1[Symbol.dispose]();
v2[Symbol.dispose]();
p[Symbol.dispose]();
Listing 4.1: A checked field rename with forward and reverse data conversion.
Run the program:
npx tsx src/migration.ts
The expected output is:
existence valid? true
compatible? false
converted: { name: 'Alice', years: 30 }
round trip: { age: 30, name: 'Alice' }
What the checks establish
The structural classifier reports incompatibility because it sees a removed age field and an added years field. It does not consult the explicit migration. Every required target edge nevertheless has a source, so existence checking passes and compile() can produce the migration. Round-trip behavior is checked separately: getJson() produces the view and an opaque complement, and putJson() uses both to restore the source record. The assert.deepEqual call checks that result structurally, so JSON object key order does not affect it.
Next
Schema version control basics stores the v1 and v2 schemas as history. Cross-protocol translation carries the explicit-mapping pattern across two registered protocols. For computed field values rather than a rename, continue with Apply field transforms.
Schema version control basics
This tutorial uses the schema CLI to commit a TypeScript interface, create a feature branch, and merge the branch into main.
Prerequisite
Install the schema binary by following Install the CLI. The commands below create a new vcs-tutorial/ directory.
Commit the first version
Create the repository and its first source file:
mkdir -p vcs-tutorial/src
cd vcs-tutorial
schema init
cat > src/user.ts <<'EOF'
export interface User {
name: string;
age: number;
}
EOF
schema add src/user.ts
schema commit -m "v1 user schema"
schema log --oneline
Listing 5.1: Initializing a repository and committing a parsed TypeScript schema.
schema add parses src/user.ts through the tree-sitter registry and stages the resulting schema graph. The commit stores that graph under .panproto/. The source file remains an ordinary TypeScript file. Run schema add from the repository root so the command can find .panproto/.
Commit the rename
Replace the file with v2, inspect the staged diff, and commit it:
cat > src/user.ts <<'EOF'
export interface User {
name: string;
years: number;
email: string;
}
EOF
schema add src/user.ts
schema diff --staged
schema commit -m "rename age and add email"
schema log --oneline
Listing 5.2: Staging and committing the second schema state.
The staged diff compares schema structure rather than source lines. Full-AST parsers also preserve syntax-level structure needed for source round trips, so a source edit may produce more graph changes than the two interface fields alone suggest.
Branch and merge
Create and switch to a feature branch, add handle, then merge the branch into main:
schema checkout -b feature/handle
cat > src/user.ts <<'EOF'
export interface User {
name: string;
years: number;
email: string;
handle: string;
}
EOF
schema add src/user.ts
schema commit -m "add handle"
schema checkout main
schema merge feature/handle
schema log --oneline
Listing 5.3: Creating a feature branch and merging it into main.
main has not moved since the branch was created, so this merge is a fast-forward. The command reports Merge successful. and moves the main ref to the feature commit.
There is one operational difference from git: schema checkout moves the schema-history ref but does not rewrite src/user.ts. Panproto stores and merges parsed schemas. An editor, build system, or explicit emit step remains responsible for working-source files. schema log --graph currently accepts --graph but renders the ordinary log, so the examples use --oneline.
Next
The schema version control how-to covers non-fast-forward merges, data versioning, and the git bridge. Schema version control semantics describes the three-way structural merge and its commuting-cocone check. Pushouts and merge gives the categorical comparison and its limits.
Cross-protocol translation
A migration can connect schemas registered under different protocols when their relevant structure agrees. This tutorial converts a User record from a JSON Schema graph to an OpenAPI schema graph by mapping each object and property explicitly. It is the advanced continuation of Your first migration.
The example is deliberately narrow. JSON Schema and OpenAPI use compatible object, scalar, and property structure in this case. The TypeScript existence check selects the source schema’s registered protocol, so this example does not establish general cross-protocol checking. Protocol pairs with different structural rules require a repository-level bridge. Translate across protocols records the current boundary.
Build both endpoints
Continue in the my-first-schema/ project, where @panproto/core and tsx are already installed. Create src/cross.ts:
import assert from 'node:assert/strict';
import { Panproto } from '@panproto/core';
const p = await Panproto.init();
// Register the source protocol before running its existence check.
p.protocol('json-schema');
const openapi = p.protocol('openapi');
const source = p.parseSchemaDocument('json-schema', {
title: 'User',
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
},
required: ['name'],
});
const target = openapi.schema()
.vertex('user', 'object')
.vertex('user.displayName', 'string')
.vertex('user.years', 'integer')
.edge('user', 'user.displayName', 'prop', { name: 'displayName' })
.edge('user', 'user.years', 'prop', { name: 'years' })
.build();
const mapping = p.migration(source, target)
.map('root', 'user')
.map('root.name', 'user.displayName')
.map('root.age', 'user.years')
.mapEdge(
{ src: 'root', tgt: 'root.name', kind: 'prop', name: 'name' },
{
src: 'user',
tgt: 'user.displayName',
kind: 'prop',
name: 'displayName',
},
)
.mapEdge(
{ src: 'root', tgt: 'root.age', kind: 'prop', name: 'age' },
{ src: 'user', tgt: 'user.years', kind: 'prop', name: 'years' },
);
const existence = p.checkExistence(source, target, mapping);
if (!existence.valid) {
throw new Error(JSON.stringify(existence.errors));
}
const migration = mapping.compile();
const converted = migration.liftJson({ name: 'Alice', age: 30 }, 'root');
assert.deepEqual(converted, { displayName: 'Alice', years: 30 });
console.log('existence valid?', existence.valid);
console.log('converted:', converted);
migration[Symbol.dispose]();
source[Symbol.dispose]();
target[Symbol.dispose]();
p[Symbol.dispose]();
Listing 6.1: An explicit forward migration from a JSON Schema graph to an OpenAPI schema graph.
Run the program:
npx tsx src/cross.ts
The output is:
existence valid? true
converted: { displayName: 'Alice', years: 30 }
What crossed the protocol boundary
parseSchemaDocument('json-schema', ...) uses the JSON Schema document parser and produces a schema rooted at root. The target is built against the registered openapi protocol. The migration maps vertices and edges across those two schema handles, and liftJson() emits the target field names.
The existence report checks the explicit mapping with the source schema’s registered protocol. The assert.deepEqual call separately checks this forward result. Neither check proves that arbitrary JSON Schema and OpenAPI documents can be translated, and the example does not establish a reverse trip.
The target is an OpenAPI schema graph, not a complete emitted OpenAPI document. Document emission and constraint translation add structure beyond this two-field example.
Continue on the advanced path
Translate across protocols covers the operational choices for larger pairs, and Write lenses in the lens DSL covers hand-authored bridges. Composing protocols by colimit explains how shared theories are constructed. For the formal account, continue to Theory DSL: denotational semantics.
How-to guides
How-to guides assume that the goal is settled and the missing piece is a procedure. A first encounter with panproto belongs in the tutorials; an exact flag, signature, or grammar belongs in the reference.
First working path
A reader starting from an existing schema file can follow one short sequence: install a surface, define or load a schema, construct a migration with the migration guide, and convert data. Each guide includes a verification step before the next operation changes data or repository state.
Intermediate entry points
These guides assume that you can already load and validate a schema.
| Goal | Start here | Continue with |
|---|---|---|
| Control an inferred span | Find a span between two schemas | Apply field transforms |
| Run a lens in both directions | Use lenses | Write lenses in the lens DSL |
| Inspect or select records | Query instances | Expression-language reference |
| Preserve source formatting | Round-trip with format preservation | Decorate an abstract schema |
| Put schema changes under version control | Initialize and commit | Branch and merge |
| Reject incompatible changes automatically | Create a breaking-change gate | Run it in GitHub Actions |
Advanced entry points
These guides cover reusable transformations, language tooling, and protocol extension.
| Goal | Start here | Related contract |
|---|---|---|
| Reuse one lens across a family of schemas | Use protolenses | Lens combinators |
| Select an optic from schema structure | Use dependent optics | Lens combinators |
| Parse and migrate syntax trees | Parse full ASTs | Rust SDK |
| Translate between schema languages | Translate across protocols | Protocol catalog |
| Add a schema language | Build a custom protocol | Crate map |
| Version data with its schema | Version data alongside schemas | CLI reference |
Tasks by area
Setup and schema creation are collected under Install panproto and Define a schema. Migration work begins with Build a migration and extends through field transforms, lenses, protolenses, dependent optics, and the lens DSL.
Data tasks cover conversion, queries, format-preserving round trips, full-AST parsing, and schema decoration. Repository tasks are grouped under Schema version control and Continuous integration; Translate across protocols and Build a custom protocol cover extension across protocol boundaries.
Install panproto
panproto has six user-facing distributions. Install only the command-line or language surface that the project uses.
| Surface | Page | Package |
|---|---|---|
Command line (schema) | Install the CLI | panproto-cli (Homebrew, shell installer, cargo install) |
| Rust application | Install the Rust SDK | panproto-core (crates.io) |
| TypeScript / JavaScript application | Install the TypeScript SDK | @panproto/core (npm) |
| Python application | Install the Python SDK | panproto (PyPI) |
| Haskell application | Install the Haskell SDK | panproto (this repository) |
| Swift application | Install the Swift SDK | panproto (this repository, SwiftPM) |
The CLI and SDK packages are independent; installing one does not install the others.
Every release attaches checksums, a software bill of materials, and build attestations for its binary archives. Verify a release artifact covers checking them.
See also
- Reference: configuration for the
panproto.tomlmanifest. - Tutorial: your first schema once installed.
Install the CLI
The CLI is a single binary called schema. It is the entry point for the panproto-cli crate.
Prerequisites
A POSIX shell on macOS or Linux, or PowerShell on Windows. The binary releases cover the targets listed in the repository’s cargo-dist configuration. Other targets require a Rust toolchain.
Install
Homebrew (macOS, Linux)
brew install panproto/tap/schema
Shell installer (macOS, Linux, WSL)
curl --proto '=https' -LsSf https://github.com/panproto/panproto/releases/latest/download/panproto-cli-installer.sh | sh
PowerShell installer (Windows)
powershell -ExecutionPolicy ByPass -c "irm https://github.com/panproto/panproto/releases/latest/download/panproto-cli-installer.ps1 | iex"
From source
cargo install panproto-cli
Requires a Rust toolchain (1.85 or newer).
Verification
schema --version
prints the installed version. The full subcommand list is at Reference: CLI, or schema --help.
Common mistakes
- Installing through
cargo installwithout an up-to-date toolchain. panproto requires Rust 1.85 or later. - Mixing the Homebrew install with a from-source install on the same machine: only one
schemaends up first onPATH.
See also
Install the Rust SDK
Prerequisites
A Rust toolchain at edition 2024 (toolchain 1.85+).
Install
# Cargo.toml
[dependencies]
panproto-core = "0.72"
The facade has no default features. Enable full-parse, project, git, or tree-sitter only when the application uses that surface; project also enables full-parse, and git enables both.
Verification
use panproto_core::protocols::atproto;
use panproto_core::schema::SchemaBuilder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let proto = atproto::protocol();
let schema = SchemaBuilder::new(&proto)
.vertex("root", "record", Some("app.example.root"))?
.entry("root")
.build()?;
println!("built {} vertex(es)", schema.vertices.len());
Ok(())
}
cargo run builds and links against the panproto facade.
Common mistakes
- Pinning a stale toolchain.
panproto-corerequires Rust 1.85 or later. - Depending on lower-level crates (
panproto-schema,panproto-mig, …) directly without a strong reason. The facade re-exports the canonical surface; reach past it only when you need an internal API.
See also
- Reference: Rust SDK for feature flags and crate selection.
- Define a schema from Rust.
- Crate map.
Install the TypeScript SDK
Prerequisites
Node 20 or newer. A package manager (npm, pnpm, or yarn). A bundler with WASM support if targeting the browser (Vite, Rollup, esbuild, webpack 5+).
Install
npm install @panproto/core
# or
pnpm add @panproto/core
# or
yarn add @panproto/core
Verification
import { Panproto } from '@panproto/core';
const p = await Panproto.init();
console.log(p.listProtocols());
Panproto.init() loads the WASM module and returns the top-level handle. Calling p.listProtocols() enumerates the built-in protocols and confirms the binding is wired up.
Common mistakes
- Forgetting to
await Panproto.init(). The WASM load is asynchronous; using the handle beforeinitresolves throws. - Using a bundler without WebAssembly asset support. Configure the bundler to copy and load the package’s
.wasmasset. - Running under Node earlier than 20. The package metadata requires Node 20 or later.
See also
Install the Python SDK
Prerequisites
Python 3.13 or newer. A virtual environment is recommended.
Install
pip install panproto
The wheel includes native PyO3 bindings and the core tree-sitter grammar group. Additional grammar packs are installed separately:
pip install panproto-grammars-functional # Haskell, OCaml, Elm, Erlang, Elixir, ...
pip install panproto-grammars-web # HTML, CSS, Vue, Svelte, ...
pip install panproto-grammars-all # umbrella package
The full table of packs is in Reference: Python SDK.
Verification
import panproto
print(panproto.list_builtin_protocols()[:3])
The native module loads at import time (no async wrapper, unlike the TypeScript SDK). Listing a few of the built-in protocols confirms the linkage. The full top-level surface is in Reference: Python SDK.
Common mistakes
- Running under Python earlier than 3.13. The package metadata requires Python 3.13 or later.
- Importing each grammar pack manually.
AstParserRegistrydiscovers installed packs through theirpanproto.grammarsentry points.
See also
Install the Haskell SDK
Prerequisites
GHC 9.12.2 and Cabal (the binding builds with cabal-version: 3.8). The FFI backend also needs a Rust toolchain when building libpanproto_c from source; rustup is recommended.
Install
The panproto package currently lives under bindings/haskell/; build it from the repository. The default rust flag links the FFI backend against libpanproto_c, so the library has to be staged first. There are two ways to get it.
Build from source
bootstrap/dev-link.sh builds panproto-c from the workspace with cargo build -p panproto-c --release, compiles the panproto-glue C layer into a standalone libpanproto_glue.a, and stages both under bindings/haskell/.panproto-c/. It then writes a gitignored cabal.project.local carrying the absolute lib and include paths (cabal’s relative extra-lib-dirs propagate into ghc-pkg’s registration metadata, which rejects anything but absolute paths).
git clone https://github.com/panproto/panproto.git
cd panproto/bindings/haskell
./bootstrap/dev-link.sh # builds panproto-c, stages libs
cabal build
cabal test
Run dev-link.sh again after every change to panproto-c, to the C glue, or to the workspace Cargo.toml.
Prebuilt binaries
bootstrap/fetch-bindist.sh [version] downloads the prebuilt libpanproto_c for the host platform from the corresponding GitHub Release (it detects aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, and aarch64-unknown-linux-gnu), rebuilds the C glue against the fetched header, and writes the same cabal.project.local. No Rust toolchain is needed on this path.
git clone https://github.com/panproto/panproto.git
cd panproto/bindings/haskell
./bootstrap/fetch-bindist.sh v0.72.0 # pass the release tag that matches this checkout
cabal build
cabal test
Pass the release tag explicitly. The script’s fallback tag can lag the package version in a development checkout.
Native-only (no FFI)
For the pure-Haskell subset, disable the foreign-function interface (FFI) backend. This build needs no libpanproto_c and no Rust toolchain:
cabal build -f-rust
Verification
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DuplicateRecordFields #-}
import qualified Panproto.Schema as S
main :: IO ()
main = do
let s = S.buildSchema "geojson" $ do
S.vertex S.Vertex {S.id = "post", S.kind = "record", S.nsid = Nothing}
print (S.vertexCount s)
Place the example in an executable component or load it in cabal repl. Building a one-vertex structured schema and printing its vertex count exercises the pure value algebra without touching the engine, so the example also works with the FFI backend disabled.
Common mistakes
- Some Python distributions, including Anaconda, put an older
ldonPATH. On macOS arm64, that linker cannot read GHC’s response-file syntax and reportsld: file not found: @<tmp>/ghc_tmp_*.rsp.dev-link.shwarns whenldis not the system linker. Prepend/usr/bintoPATHand retry. - Skipping the bootstrap step on a default (
rust-flag) build. Withoutdev-link.shorfetch-bindist.sh, nocabal.project.localexists,extra-lib-dirsis unset, and the link fails on the missinglibpanproto_c. - A relative
extra-lib-dirs. The path incabal.project.localis absolute by design;ghc-pkgrefuses a relative one during package registration.
See also
- Reference: Haskell SDK for the capability classes, the effect layer, and the cabal flags.
- Crate map for
panproto-cand the rest of the workspace.
Install the Swift SDK
Prerequisites
Swift 6.1 or later. The package manifest uses Swift tools 6.1 and Swift 6 language mode.
Building libpanproto_c from source additionally needs a Rust toolchain; rustup is recommended. The prebuilt path below needs neither Rust nor a workspace checkout.
Install
The package is not yet on a registry; it lives at bindings/swift/ in the repository. Engine-backed products link libpanproto_c, the C application binary interface exposed by the panproto-c crate. Stage that library before building an engine-backed target. PanprotoStructural is a pure Swift product and does not call the engine.
Build from source
bootstrap/dev-link.sh runs cargo build -p panproto-c --release, stages the resulting library and header under bindings/swift/.panproto-c/, and syncs the vendored copy of panproto.h that the package compiles against. Package.swift looks in .panproto-c/lib by default, so nothing else needs configuring.
git clone https://github.com/panproto/panproto.git
cd panproto/bindings/swift
./bootstrap/dev-link.sh
swift build
swift test
Run dev-link.sh again after every change to panproto-c or the workspace Cargo.toml. Set PANPROTO_C_LIB_DIR to stage somewhere else.
Prebuilt binaries
bootstrap/fetch-bindist.sh [version] [variant] downloads the prebuilt library for the host platform from the corresponding GitHub Release. It detects aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, and aarch64-unknown-linux-gnu. Both arguments are optional and are read by shape rather than by position, so either can be given alone: the version defaults to whatever the checkout declares, and the variant (default or full, also spelled --default and --full) defaults to default.
cd panproto/bindings/swift
./bootstrap/fetch-bindist.sh
swift build
XCFramework
iOS builds go through the XCFramework, which carries a macOS-universal slice, an iOS device slice, and a universal simulator slice, together with the headers and a module map.
cd panproto/bindings/swift
./bootstrap/fetch-bindist.sh --xcframework
PANPROTO_SWIFT_XCFRAMEWORK=.panproto-c/panproto_c.xcframework swift build
To depend on the package from another project, point PANPROTO_SWIFT_XCFRAMEWORK_URL and PANPROTO_SWIFT_XCFRAMEWORK_CHECKSUM at the published artifact and its checksum, both of which the release attaches. That mode adds no linker flags of its own, which is what makes the package usable as a dependency; the dev-link.sh mode passes an unsafe -L flag and is for building the package directly.
Products
| Product | Contents |
|---|---|
PanprotoStructural | The pure value layer: schemas, chains, migrations, and instances as Swift values, plus the CBOR codec. No engine, no FFI. |
Panproto | The engine-backed core: protocols, schemas, instances, I/O codecs, compatibility checking, migrations, lenses, expressions, theories, enrichment, homomorphism search, graph fibers, and datasets. |
PanprotoVcs | Schematic version control. |
PanprotoParse | Full-AST source parsing. Feature-gated. |
PanprotoProject | Multi-file project assembly. Feature-gated. |
PanprotoGit | The git bridge. Feature-gated. |
Feature-gated tiers
The default libpanproto_c omits the parse, project, and git tiers. Using one of those tiers requires a library built with the matching Cargo features and a Swift build that compiles the gated shims:
PANPROTO_C_FEATURES=full ./bootstrap/dev-link.sh
swift build --traits PANPROTO_PARSE,PANPROTO_PROJECT,PANPROTO_GIT
Each tier is a package trait, and a trait defines a compilation condition of its own name, which is what the #if PANPROTO_PARSE blocks in the gated sources read.
The three gated products exist in the package graph either way, so a build that omits the features still resolves; their modules are simply empty. On the prebuilt path, fetch the full variant:
./bootstrap/fetch-bindist.sh full
swift build --traits PANPROTO_PARSE,PANPROTO_PROJECT,PANPROTO_GIT
Verification
import Foundation
import Panproto
import PanprotoStructural
let names = try await ProtocolHandle.builtinNames()
print(names.count, "builtin protocols")
let atproto = try await ProtocolHandle.builtin("atproto")
let lexicon = try Data(contentsOf: URL(fileURLWithPath: "app.bsky.feed.post.json"))
let schema = try await SchemaHandle.parseAtprotoLexicon(lexicon)
let value = try await schema.schema()
print(value.protocolName, value.vertexCount, "vertices")
let messages = try await schema.violations(against: atproto)
print(messages.isEmpty ? "valid" : messages.joined(separator: "\n"))
Engine calls run on the @PanprotoEngine global actor, which is pinned to one thread. Release long-lived handles explicitly when their work is complete.
Verify a release artifact
Every binary archive attached to a panproto release carries two independent claims: a checksum, which says the bytes are the ones that were built, and an attestation, which says which workflow built them and from which source revision. The first catches a truncated or corrupted download. Only the second distinguishes an archive panproto built from one someone else assembled and named the same thing.
Checksums
Each release attaches a SHA256SUMS file covering every .tar.gz and .zip, in the format sha256sum -c reads:
gh release download v0.72.1 --pattern 'panproto-c-*' --pattern 'SHA256SUMS'
sha256sum -c SHA256SUMS
On macOS, shasum -a 256 -c SHA256SUMS.
The Swift XCFramework is verified separately and automatically. Package.swift pins releaseXCFrameworkChecksum, and SwiftPM refuses to resolve if the downloaded artifact does not match, so a consumer adding the package as a dependency gets this check without asking for it.
Attestations
A checksum only says the file matches a hash published beside it. Both come from the same release, so an attacker able to replace one can replace the other. An attestation is signed by the workflow’s own OIDC identity at build time and records the repository, the workflow file, and the commit:
gh attestation verify panproto-c-aarch64-apple-darwin.tar.gz --repo panproto/panproto
This succeeds only for an archive built by this repository’s workflow. It needs no key on your side and no key is stored on ours: signing is keyless, tied to the job’s identity rather than to a secret that could leak.
What is in an archive
Each release also carries panproto-c.cdx.json, a CycloneDX software bill of materials listing every crate the C library was built from, at the versions Cargo.lock pinned. It is itself attested, so the inventory is as verifiable as the binary it describes.
To answer “does this release contain some vulnerable dependency”, read the SBOM rather than rebuilding:
gh release download v0.72.1 --pattern 'panproto-c.cdx.json'
jq -r '.components[] | "\(.name) \(.version)"' panproto-c.cdx.json | sort
Crates, wheels and npm packages
These do not need the steps above. crates.io, PyPI and npm are all published through OIDC Trusted Publishing, so the registry itself records which workflow published each version, and npm additionally carries provenance that npm audit signatures checks. No long-lived publication token exists for any of them.
See also
- Install the CLI for the installer scripts, which verify checksums themselves.
- Install the Swift SDK for the XCFramework pin.
Define a schema
Choose a surface according to where the schema enters the project. The CLI loads and inspects schema files; the five SDKs construct schemas inside an application.
| Surface | Page |
|---|---|
schema CLI | From the CLI |
| TypeScript SDK | From TypeScript |
| Python SDK | From Python |
| Rust SDK | From Rust |
| Haskell SDK | From Haskell |
| Swift SDK | From Swift |
The CLI’s schema-checking commands currently accept panproto’s internal schema JSON and resolve only the atproto protocol. Its shared loaders can parse an ATProto Lexicon when a manifest selects atproto, and its full-AST commands parse supported source files as syntax trees. Use a language SDK’s parseSchemaDocument or parseSchemaSource dispatch for the other external schema languages in the protocol catalog. Start with a language SDK when the application constructs the schema programmatically.
See also
- Reference: protocol catalog for the list of supported protocols.
- Schemas as theories for the model.
Define a schema from the CLI
Prerequisites
The schema binary installed (Install the CLI). A file in panproto’s internal schema JSON format. The current CLI protocol resolver accepts atproto.
The task
Validate an existing schema
schema validate --protocol atproto path/to/schema.json
The command loads panproto schema JSON, checks vertex kinds, edge rules, constraint sorts, required-edge references, and recursion references, then type-checks the registered protocol theories. It exits nonzero if either pass reports an error. It does not parse an ATProto Lexicon or another external schema document at this entry point.
Scaffold from an existing schema
schema scaffold --protocol atproto schemas/post.json
scaffold runs bounded free-model construction over panproto schema JSON and prints sample term assignments. Use --json for machine-readable output, and use --depth and --max-terms to set the bounds. A truncated run is a partial enumeration rather than proof that no other terms exist.
Inspect
schema diff schemas/post-v1.json schemas/post-v2.json
diff reports vertex and edge changes between two schemas. Inside a panproto repository, schema show <ref> resolves a commit, schema, or migration object and prints its contents.
Verification
After validation, run:
schema verify --protocol atproto path/to/schema.json
verify tests assignments for the equations in the registered protocol theories, up to --max-assignments per equation. The current command prints Verification passed even when a theory has type errors or an equation check is incomplete, so use schema validate as the CI gate and inspect the full verify output. A bounded pass is evidence from the checked assignments, not a proof over every possible assignment.
Common mistakes
- Passing an external schema-language document to
validate,verify, orscaffold. These commands deserialize panproto’s internal schema JSON. Parse or convert an external document first. - Running
schema validatewhen you meanschema check(the latter checks a migration, not a schema).
See also
- Reference: CLI for the full subcommand list.
- Tutorial: your first schema.
Define a schema from TypeScript
Prerequisites
@panproto/core installed (Install the TypeScript SDK).
The task
import { Panproto } from '@panproto/core';
const p = await Panproto.init();
const proto = p.protocol('atproto');
const schema = proto.schema()
.vertex('post', 'record', { nsid: 'app.bsky.feed.post' })
.vertex('post:body', 'object')
.vertex('post:body.text', 'string')
.edge('post', 'post:body', 'record-schema')
.edge('post:body', 'post:body.text', 'prop', { name: 'text' })
.build();
p.protocol(name) loads the named protocol. proto.schema() returns an immutable SchemaBuilder: each operation returns a new builder containing the added structure. .build() sends those operations to WebAssembly, where vertex and edge rules are checked, and returns a BuiltSchema handle. Run the separate validation pass below to check constraint sorts and other finished-schema conditions.
Verification
const result = schema.validate(proto);
if (!result.isValid) throw new Error(JSON.stringify(result.issues));
validate(protocol) returns a ValidationResult containing any issues. An empty issue list confirms the schema satisfies the protocol’s edge rules and obj-kinds.
Common mistakes
- Ignoring the builder returned by
.vertex()or.edge(). Builders are immutable, so the original value does not acquire the operation. - Treating
.build()as equation verification. It constructs a schema and enforces builder-level checks;schema.validate(proto)is the finished-schema structural validation pass. - Treating the returned
Schemahandle as a plain object. It is an opaque handle into the WASM heap; pass it to subsequent SDK calls, do not introspect it directly.
See also
- Reference: TypeScript SDK.
- Build a migration for what to do with the schema next.
- Tutorial: your first schema.
Define a schema from Python
Prerequisites
panproto installed (Install the Python SDK).
The task
import panproto
proto = panproto.get_builtin_protocol("atproto")
b = proto.schema()
b.vertex("user", "object", "app.example.user")
b.vertex("user:name", "string")
b.vertex("user:age", "integer")
b.edge("user", "user:name", "prop", "name")
b.edge("user", "user:age", "prop", "age")
schema = b.build()
panproto.get_builtin_protocol(name) returns the named protocol; .vertex(id, kind, nsid=None) and .edge(src, tgt, kind, name=None) each mutate the SchemaBuilder in place (returning None), and .build() validates and returns a Schema. The TypeScript SDK exposes the same operations as a chainable surface; the Python binding does not.
Call panproto.list_builtin_protocols() to see the registered protocol names. Treat the returned list as the source of truth rather than hard-coding a catalog count.
Verification
issues = schema.validate(proto)
assert not issues, issues
Schema.validate(protocol) returns protocol-level structural validation messages. An empty list means no vertex-kind, edge-rule, constraint-sort, required-edge, or recursion-reference error was found; it does not run equation checking.
Common mistakes
- Chaining the builder calls. The Python
SchemaBuilder.vertex(...)/edge(...)/constraint(...)methods mutate in place and returnNone; hold the builder in a variable and mutate it statement-by-statement, then call.build(). - Using a Python dict where the SDK expects a
Schemahandle. Conversion is deliberate; to materialize anInstancefrom bytes against a builtSchema, usepanproto.IoRegistry().parse(protocol, schema, data).
See also
Define a schema from Rust
Prerequisites
panproto-core in your Cargo.toml (Install the Rust SDK).
The task
use panproto_core::protocols::atproto;
use panproto_core::schema::SchemaBuilder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let proto = atproto::protocol();
let schema = SchemaBuilder::new(&proto)
.vertex("user", "object", Some("app.example.user"))?
.vertex("user:name", "string", None)?
.vertex("user:age", "integer", None)?
.edge("user", "user:name", "prop", Some("name"))?
.edge("user", "user:age", "prop", Some("age"))?
.entry("user")
.build()?;
println!("{} vertices, {} edges", schema.vertices.len(), schema.edges.len());
Ok(())
}
SchemaBuilder::new(&protocol) constructs the builder; each vertex and edge call validates against the protocol’s vertex kinds and edge rules. entry declares a vertex at which an instance may be rooted. build rejects an empty schema or an entry that names no vertex, computes adjacency indexes, and returns an owned Schema. Constraint-sort validation remains a separate pass.
Verification
use panproto_core::schema::{SchemaBuilder, validate};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let proto = panproto_core::protocols::atproto::protocol();
let schema = SchemaBuilder::new(&proto)
.vertex("user", "object", Some("app.example.user"))?
.entry("user")
.build()?;
let errors = validate(&schema, &proto);
assert!(errors.is_empty(), "validation errors: {errors:?}");
Ok(()) }
validate returns a Vec<ValidationError> for protocol-level structural failures such as an unknown vertex kind, invalid edge, unknown constraint sort, or dangling required edge. It does not evaluate theory equations.
Common mistakes
- Reaching past
panproto-coreto lower-level crates without a reason. The facade re-exports the canonical surface; do not depend onpanproto-schemadirectly unless you need an internal API. - Assuming
build()validates constraints. The builder records constraints without checking their sorts; callvalidate(&schema, &proto)before using an externally supplied constraint.
See also
Define a schema from Haskell
Prerequisites
The panproto package installed and linked against libpanproto_c (Install the Haskell SDK).
The task
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DuplicateRecordFields #-}
import Panproto.Schema (Schema)
import qualified Panproto.Schema as S
postSchema :: Schema
postSchema = S.buildSchema "geojson" $ do
S.vertex S.Vertex {S.id = "post", S.kind = "record", S.nsid = Nothing}
S.vertex S.Vertex {S.id = "text", S.kind = "string", S.nsid = Nothing}
S.vertex S.Vertex {S.id = "title", S.kind = "string", S.nsid = Nothing}
S.edge S.Edge {S.src = "post", S.tgt = "text", S.kind = "prop", S.name = Just "text"}
S.edge S.Edge {S.src = "post", S.tgt = "title", S.kind = "prop", S.name = Just "title"}
S.constraint "title" S.Constraint {S.sort = "maxLength", S.value = "120"}
buildSchema name runs the builder actions in the do block and returns an immutable Schema value. vertex, edge, and constraint add plain Haskell records; no Rust runtime starts during this construction. The protocol name is metadata at this stage, so validation still needs a separate protocol value.
A Schema built this way carries no protocol object of its own. To validate it you pair it with a protocol, which you can take straight from the canonical default with its name set:
{-# LANGUAGE OverloadedStrings #-}
import Panproto.Canonical (CanonicalProtocol (..), defaultProtocol)
geoProtocol :: CanonicalProtocol
geoProtocol = defaultProtocol {name = "geojson"}
fromTheories builds a protocol from explicit schema and instance theories. Use it only when defining a new protocol. Renaming defaultProtocol is enough for this value-level example, but it does not load the GeoJSON rules from the Rust registry.
Verification
Validation runs through the foreign-function interface backend. fromSchema (Proxy @Rust) and fromCanonical (Proxy @Rust) create engine handles; validateSchema returns the protocol-level complaints. An empty list reports that this pass found no structural violations.
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
import Control.Exception (bracket)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Panproto.Class (Rust, ProtocolBackend (..), SchemaBackend (..), SchemaValidate (..))
import Panproto.Rust () -- brings the Rust instances into scope
validate :: IO [Text]
validate =
bracket (fromCanonical (Proxy @Rust) geoProtocol)
releaseProtocol $ \proto ->
bracket (fromSchema (Proxy @Rust) postSchema)
releaseSchema $ \schema ->
validateSchema schema proto -- [] means valid
The bracket calls release the slab handles the Rust backend hands back, so the engine’s thread-local allocations are freed once validation returns. To recover the structured Schema from an ingested handle (the round-trip the test suite checks node-for-node), call toSchema on the SchemaRep Rust.
Common mistakes
- Chaining the builder operations as if they returned the schema.
vertex,edge, andconstraintareSchemaBuilderM ()actions sequenced in ado-block;buildSchemareturns theSchema, not the individual calls. - Holding a
SchemaRep Rustpast itsbracket. TheRustrepresentations areu32slab handles into a thread-local arena; use them inside thebracketand letreleaseSchemareclaim them, and do not share a handle across threads. - Using optional lens-adaptor packages to build structure. Schema construction goes through
SchemaBuilderM.
See also
- Reference: Haskell SDK.
- Install the Haskell SDK for the bootstrap scripts and toolchain prerequisites.
- Build a migration.
Define a schema from Swift
Prerequisites
The panproto Swift package, with libpanproto_c staged or pinned (Install the Swift SDK).
The task
Schema in PanprotoStructural is a value. Building one starts nothing, allocates no engine resource, and runs on whatever thread you are already on.
import PanprotoStructural
var post = Schema(protocol: "geojson")
post.addVertex(Vertex(id: "post", kind: "record"))
post.addVertex(Vertex(id: "text", kind: "string"))
post.addVertex(Vertex(id: "title", kind: "string"))
post.addEdge(Edge(src: "post", tgt: "text", kind: "prop", name: "text"))
post.addEdge(Edge(src: "post", tgt: "title", kind: "prop", name: "title"))
post.addConstraint(Constraint(sort: "maxLength", value: "120"), to: "title")
post.addEntry("post")
Every value here is a plain struct. A Vertex carries an id, a kind drawn from the protocol’s recognized vertex kinds, and an optional nsid. An Edge carries its src, tgt, structural kind (prop, item, variant), and an optional name. A Constraint attaches a sort and a value to one vertex, and addEntry declares which vertices an instance may be rooted at.
The three adjacency indices the Rust type precomputes are not stored on the Swift value: they are derivable from the edge set, so they are recomputed on the way to the engine and exposed here as accessors.
for edge in post.outgoingEdges(from: "post") {
print(edge.name ?? edge.kind, "->", edge.tgt)
}
Validating against a protocol
Validation is engine work, so it needs a handle on each side and it needs an await. SchemaHandle ingests the value; ProtocolHandle.builtin takes a registered codec by name.
import Panproto
import PanprotoStructural
let geojson = try await ProtocolHandle.builtin("geojson")
let handle = try await SchemaHandle.define(post)
let messages = try await handle.violations(against: geojson)
if messages.isEmpty {
print("valid")
} else {
for message in messages { print(message) }
}
Both handles free themselves when they go out of scope. Call release() when you want the slab entry back sooner, such as inside a loop over many candidate schemas.
To go the other way, ask a handle for its value:
let roundTripped = try await handle.schema()
precondition(roundTripped.vertexCount == post.vertexCount)
Building through the engine instead
SchemaBuilder accumulates the same operations and compiles them in the engine rather than in Swift, which is what you want when the protocol’s own build rules should be applied as you go rather than checked at the end.
import Panproto
var builder = geojson.schemaBuilder()
builder.vertex("post", kind: "record")
builder.vertex("text", kind: "string")
builder.edge(from: "post", to: "text", kind: "prop", name: "text")
builder.entry("post")
let built = try await builder.build()
Parsing one instead of writing it
Most schemas are not written by hand. An atproto lexicon parses directly:
import Foundation
import Panproto
import PanprotoStructural
let lexicon = try Data(contentsOf: lexiconURL)
let schema = try await SchemaHandle.parseAtprotoLexicon(lexicon)
print(try await schema.schema().vertexCount, "vertices")
Next steps
- Build a migration between two versions of this schema.
- Swift SDK reference for the engine actor, the handle taxonomy, and the error hierarchy.
Build a migration
A migration maps vertices and edges in a source schema to a target schema. Build or derive the mapping, check it, then apply it to representative data.
Prerequisites
Two panproto schema JSON files and the schema CLI. The current CLI protocol resolver supports atproto. The TypeScript SDK can check a narrow explicit mapping between two protocol-tagged schema handles, with the qualifications in Translate across protocols.
Derive a mapping
Inspect the inferred span first:
schema auto-migrate schemas/v1.json schemas/v2.json
Review its coverage and vertex map. Then save the right leg as a migration:
schema auto-migrate schemas/v1.json schemas/v2.json --monic --json \
> migrations/v1-to-v2.json
--monic prevents two apex vertices from mapping to the same target vertex. Omit it only when the contraction is intentional and a separate value-level rule will combine the values.
The generated file maps the matched apex into v2, not necessarily all of v1. Use --total if partial coverage is unacceptable:
schema auto-migrate schemas/v1.json schemas/v2.json --total --json \
> migrations/v1-to-v2.json
Check the mapping
schema check \
--src schemas/v1.json \
--tgt schemas/v2.json \
--mapping migrations/v1-to-v2.json \
--typecheck
The existence check validates the migration against the source, target, and protocol theories. --typecheck also validates the induced GAT morphism. The command exits non-zero when either enabled check reports an error.
A passing check does not establish source-wide coverage. The mapping may omit unmatched source vertices, so retain the auto-migrate report or require --total when all source data must move.
Apply the migration
schema lift \
--migration migrations/v1-to-v2.json \
--src-schema schemas/v1.json \
--tgt-schema schemas/v2.json \
data/user.json > data/user-v2.json
The default direction is restrict and the default instance type is wtype. In this command, restrict reads a source-shaped record, follows the compiled source-to-target migration, retains the part represented by mapped vertices, and serializes that result with the target schema. It is not the categorical precomposition functor (\Delta_F), which has the opposite instance-level direction, from a target instance to a source instance. Both other modes also run source to target. sigma uses the total extension path. For functor instances, pi forms Cartesian products from the values associated with each target vertex (its fiber). For W-type instances, pi only accepts vertex-injective mappings and relabels the tree; it does not construct the general right Kan extension. The vocabulary in plain terms gives the categorical directions. The command infers the record root from the migration’s mapped source vertices; a mapping with no vertex entries cannot be applied.
Build a mapping in TypeScript
const builder = p
.migration(srcSchema, tgtSchema)
.map('user', 'user')
.map('user:name', 'user:display_name');
const report = p.checkExistence(srcSchema, tgtSchema, builder);
if (!report.valid) throw new Error(JSON.stringify(report.errors));
using migration = builder.compile();
const migrated = migration.liftJson(oldRecord, 'user');
MigrationBuilder supports vertex maps, edge maps, and contraction resolvers. Per-field expressions are not part of this builder; use the field-transform guide for the supported TypeScript lens-document route.
Classify compatibility separately
Migration validity and compatibility answer different questions. To classify the schema change for CI, run:
schema compat schemas/v1.json schemas/v2.json --protocol atproto
The command exits 0 when it finds no breaking change, 1 for a breaking change, and 2 for a usage or load error. Add --format json for machine-readable output.
Limitations
schema checkdoes not measure how much of the source schema is mapped.- Automatic alignment ranks candidates; it does not certify that the selected correspondence matches domain intent. Review every generated map.
- A vertex map cannot split one value across several targets or compute a new value. Those operations require field transforms.
schema lift --direction restrictis named afterpanproto-mig’s surviving-fragment operation. Do not identify it withpanproto-inst::adjunction::w_deltaorf_delta, which implement target-to-source precomposition.
See also
- Find a span between two schemas for coverage and search constraints.
- Apply field transforms for value-level rewrites.
- Migrations as morphisms for the model.
- CLI reference for all migration commands.
Find a span between two schemas
Use a span search when a source schema may have only a partial correspondence in a target schema. The result contains an apex, which is the matched part of the source, and a migration from that apex into the target.
Prerequisites
The schema CLI or the Rust panproto-mig crate. Both schemas must name a registered protocol.
Search from the CLI
schema auto-migrate schemas/v1.json schemas/v2.json
The report includes the apex size, vertex coverage, quality, quality bounds, and the right-leg vertex and edge maps. The default command fails when the apex is empty. Add --span when an empty overlap is a useful answer:
schema auto-migrate schemas/v1.json schemas/v2.json --span
Use --total when every source vertex and every mappable source edge must be covered:
schema auto-migrate schemas/v1.json schemas/v2.json --total
--total and --span conflict. A partial optimal span does not show that no total morphism exists, so --total runs the total-morphism search when the first result is partial. It exits non-zero only when that search finds no total morphism or cannot run.
Add --monic when distinct apex vertices must map to distinct target vertices. This constrains vertex injectivity only; it does not promise an injective edge map.
Save the mapping
schema auto-migrate schemas/v1.json schemas/v2.json --json \
> migrations/v1-to-v2.json
JSON output is the span’s right leg, serialized as a Migration. Its source is the apex. Since apex vertices reuse source identifiers, the map keys are still names from v1; unmatched source vertices are absent. The JSON does not include coverage, quality, or the certificate, so review the human report before saving the mapping.
If the mapping will be lifted directly, combine --json with --monic to avoid a vertex contraction that has no built-in rule for combining two source values:
schema auto-migrate schemas/v1.json schemas/v2.json --monic --json \
> migrations/v1-to-v2.json
Search from Rust
find_span always returns a span when the search runs. Schemas with no common vertex produce an empty apex.
#![allow(unused)]
fn main() {
use panproto_mig::{SearchOptions, find_span};
use panproto_schema::{Protocol, Schema};
fn search(
source: &Schema,
target: &Schema,
protocol: &Protocol,
) -> Result<(), panproto_mig::SpanError> {
let options = SearchOptions {
monic: true,
..SearchOptions::default()
};
let span = find_span(source, target, protocol, &options)?;
println!("coverage: {:.1}%", span.apex_coverage * 100.0);
println!("quality bounds: {:?}", span.quality_bounds);
println!("total: {}", span.is_total());
println!("vertex map: {:?}", span.right.vertex_map);
Ok(())
}
}
Set SearchOptions::hard_pins only for correspondences the search may not reconsider. An incompatible pin can force that source vertex out of the apex. Soft evidence belongs in SpanSearch::with_evidence instead.
Read the result
apex_coverage counts matched source vertices. SchemaSpan::is_total() also checks the relevant source edges, so coverage of 1.0 does not by itself establish totality.
quality ranks alternatives for one fixed source schema. Do not compare it across different source schemas. Equal lower and upper quality bounds mean that the search proved the returned score optimal; otherwise, the interval records the unresolved range.
Before accepting a result, inspect the vertex map and confirm that the coverage matches the intended task. A saved mapping can pass schema check while remaining partial because the existence check validates the entries present in the mapping rather than requiring source-wide coverage.
Limitations
- A schema morphism maps one source vertex to at most one target vertex. Splits, joins, and other value computations require a field transform.
--monicprevents vertex collisions but does not establish edge injectivity. The overlap discovery used byschema integrate --auto-overlapperforms the stronger search required for a pushout.--jsonomits the apex and certificate. Keep the human report if later review needs the coverage or proof status.
See also
- Build a migration for checking and applying the saved mapping.
- Apply field transforms for value-level computations.
- Migrations as morphisms for the underlying model.
- CLI reference for the complete option list.
Apply field transforms
A field transform computes or rewrites values during a lens operation. Use one when a vertex map cannot express the change, such as deriving a field from a sibling or changing a field’s representation.
Prerequisites
The TypeScript SDK and a source BuiltSchema. The current TypeScript lens-document path retains value transforms through compilation and instantiation.
Transform an existing field
This document increments count on get and decrements it on put:
const document = {
id: 'dev.example.increment-count',
source: 'v1',
target: 'v2',
steps: [
{
apply_expr: {
field: 'count',
expr: 'add count 1',
inverse: 'sub count 1',
coercion: 'iso',
},
},
],
};
using chain = p.compileLensDocument(document, 'record:body');
using lens = chain.instantiate(sourceSchema);
const { view, complement } = lens.getJson(
{ count: 4 },
'record:body',
);
const restored = lens.putJson(view, complement, 'record:body');
apply_expr evaluates its expression with the named field bound in the expression environment. An inverse is required only when edits must propagate backward through the transform. Declare coercion: 'iso' only when the forward and inverse expressions round-trip for every accepted value.
Compute a field from its parent record
compute_field evaluates an expression over the scalar fields in the parent fiber and writes the result under target:
const document = {
id: 'dev.example.double-count',
source: 'v1',
target: 'v2',
steps: [
{
compute_field: {
target: 'double_count',
expr: 'mul count 2',
coercion: 'projection',
},
},
],
};
using chain = p.compileLensDocument(document, 'record:body');
using lens = chain.instantiate(sourceSchema);
const { view, complement } = lens.getJson(
{ count: 4 },
'record:body',
);
The computed field is derived data. With no inverse, putJson uses the complement to restore the original source fields; edits made only to double_count do not determine a new count.
Verify that compilation retained the transform
const transforms = chain.fieldTransforms();
if ((transforms['record:body'] ?? []).length === 0) {
throw new Error('field transform was not compiled');
}
const laws = lens.checkLaws(instanceBytes);
if (!laws.holds) throw new Error(laws.violation ?? 'lens law failed');
fieldTransforms() reports transforms by parent vertex. checkLaws accepts an encoded instance, while getJson and putJson provide the record-oriented path shown above.
Current serialization limitation
Value transforms are stored beside the structural ProtolensChain; they are not part of chain.toJson(). Reconstructing a handle with ProtolensChainHandle.fromJson(chain.toJson(), wasm) thus loses them.
The same distinction affects other surfaces. schema lens compile reports only the number of field-transform vertices and writes the structural chain. Python’s ProtolensChain.from_dsl_* constructors, the TypeScript compileLensDocument handle, and Rust’s panproto_lens_dsl::CompiledLens retain the value-level programs and their order relative to structural steps.
Migration mapping JSON has a separate expr_resolvers field, but panproto_mig::compile does not install those expressions as FieldTransform values. Do not place expression source in a migration mapping and expect schema lift to execute it.
Common failures
- Attach a transform to the parent vertex whose fields it reads. A transform anchored at a scalar child cannot see its siblings.
- Keep expressions within the evaluator’s step, depth, and list-length budgets. Evaluation errors fail the transform for that record.
- Classify lossy computations as
projectionoropaque, notiso. - Test boundary values for partial operations such as division, parsing,
head, and indexing.
See also
- Expression-language reference for syntax, builtins, and evaluator limits.
- Write lenses in the lens DSL for the document structure.
- Use lenses for
get,put, and law checking.
Use lenses
The lens API relates a source record to a target-shaped view. get constructs the view, put reconstructs a source-shaped record from an edited view, and the complement retains source data that get did not place in the view.
Prerequisites
A migration (Build a migration) or a hand-written lens via the lens DSL.
The task
A CompiledMigration exposes the lens operations directly. LensHandle represents a concrete auto-generated or DSL-compiled lens, while ProtolensChainHandle represents a schema-parameterized chain.
const { view, complement } = mig.getJson(oldRecord, "user:body");
const recordView = view as { age: number };
const editedView = { ...recordView, age: recordView.age + 1 };
const updatedOld = mig.putJson(editedView, complement, "user:body") as {
age: number;
};
mig.getJson returns the forward view together with the complement, which retains data discarded by the forward operation. mig.putJson consumes both values and reconstructs a JavaScript record with the edit applied. The law checks below test the round trip for a supplied instance.
To compose two compiled migrations sequentially:
const composed = p.compose(mig_ab, mig_bc);
To compose two concrete LensHandle values:
const composedLens = p.composeLenses(lensAB, lensBC);
To compose schema-parameterized chains, call the method on the first chain:
const composedChain = chainAB.compose(chainBC);
Panproto.compose and Panproto.composeLenses compose compiled migrations and concrete lenses, respectively. ProtolensChainHandle.compose handles protolens chains. Each operation throws if the intermediate schemas or theory transforms do not chain.
Verification
const result = lens.checkLaws(instanceBytes);
console.log(result.holds, result.violation);
// For individual laws:
const getput = lens.checkGetPut(instanceBytes);
const putget = lens.checkPutGet(instanceBytes);
LensHandle.checkLaws(instance) returns a LawCheckResult { holds, violation } covering GetPut and PutGet together. checkGetPut and checkPutGet test each law individually; the Rust property tests in panproto-lens cover PutPut as well, exercised continuously in CI.
Common mistakes
- Calling
putwith a complement produced for a different source schema.putcompares the complement’s source fingerprint with the lens source and returnsComplementMismatchwhen they differ. Recompute the complement with this lens. - Reading
getand then mutating the source before callingput. The complement is computed against the source as it was atgettime; if you mutate the source, the complement is stale. - Composing lenses whose intermediate schemas are isomorphic but not equal. The structural-equality check on
protolens_composablewill reject; rebuild one of the lenses against the other’s schema.
See also
- Reference: lens combinators.
- Lenses and round-trip laws.
- Lens DSL: denotational semantics.
- Use protolenses, Use dependent optics.
Use protolenses
A protolens applies the same transform to several schemas that satisfy one precondition. A single schema-parameterized declaration instantiates a lens for each matching schema.
Prerequisites
The Rust SDK (panproto-lens::protolens) or the lens DSL with parametric declarations enabled.
The task
Declare
A Protolens packages a precondition (a TheoryConstraint on the source theory) with a TheoryTransform. Build elementary protolenses via the elementary helpers, or compose them into a ProtolensChain:
use panproto_lens::protolens::{ProtolensChain, combinators};
fn main() {
let rename_legacy_id: ProtolensChain = combinators::rename_field(
"user", "user:legacy_id", "legacy_id", "id",
);
let _ = rename_legacy_id;
}
The chain captures a precondition on the source theory and a sequence of transforms. It does not yet know which concrete schema it will run against.
Apply (fused)
use panproto_lens::protolens::{ProtolensChain, combinators};
use panproto_core::schema::{Protocol, Schema, SchemaBuilder};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let rename_legacy_id: ProtolensChain = combinators::rename_field("user", "user:legacy_id", "legacy_id", "id");
let protocol: Protocol = panproto_core::protocols::atproto::protocol();
let user_schema: Schema = SchemaBuilder::new(&protocol)
.vertex("user", "object", None)?
.vertex("user:legacy_id", "string", None)?
.edge("user", "user:legacy_id", "prop", Some("legacy_id"))?
.entry("user")
.build()?;
let lens_for_users = rename_legacy_id.instantiate(&user_schema, &protocol)?;
let _ = lens_for_users;
Ok(()) }
The precondition requires the named property edge in addition to the user vertex. Instantiation produces a concrete Lens for any schema containing that structure. For a multi-step chain, the fused path compiles the composed transform in one pass and retains the migration metadata computed for the whole chain.
Apply (sequential)
use panproto_lens::protolens::{ProtolensChain, combinators};
use panproto_core::schema::{Protocol, Schema, SchemaBuilder};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let rename_legacy_id: ProtolensChain = combinators::rename_field("user", "user:legacy_id", "legacy_id", "id");
let protocol: Protocol = panproto_core::protocols::atproto::protocol();
let base_schema: Schema = SchemaBuilder::new(&protocol)
.vertex("user", "object", None)?
.vertex("user:legacy_id", "string", None)?
.edge("user", "user:legacy_id", "prop", Some("legacy_id"))?
.entry("user")
.build()?;
let stepwise = rename_legacy_id.instantiate_sequential(&base_schema, &protocol)?;
let _ = stepwise;
Ok(()) }
Sequential instantiation applies each step to the running schema and composes the resulting lenses. It returns one composed Lens, not a list of intermediate lenses. The implementation exists to exercise the stepwise path in tests; it does not expose the intermediate schemas to the caller.
Compose
use panproto_inst::Value;
use panproto_lens::protolens::{Protolens, vertical_compose, elementary};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let first: Protolens = elementary::rename_sort("string", "text");
let second: Protolens = elementary::add_sort("tags", "array", Value::Null);
let composed = vertical_compose(&first, &second)?;
let _ = composed;
Ok(()) }
vertical_compose requires the target endofunctor of first to structurally match the source endofunctor of second. A mismatch returns LensError.
Verification
After instantiate returns a Lens, exercise the round-trip laws on representative data via Lens::get / Lens::put (or use the higher-level lens-law harness in panproto_lens::laws). Property tests in crates/panproto-lens/tests/ are the canonical examples.
Common mistakes
- Composing protolenses whose intermediate schemas only happen to look the same.
protolens_composableenforces structural equality, not name equality; build one against the other to be safe. - Reaching for sequential instantiation in production. Use fused (
instantiate) by default; sequential exists for inspection and tests. - Treating preconditions as pure documentation. The precondition is checked at instantiation time; a schema that does not satisfy it raises
LensError::ProtolensErrorwith a message listing the unmet constraints (useProtolens::check_applicabilityfirst if you want to surface the reasons separately).
See also
- Reference: lens combinators.
- Protolens composition for the formal model.
- Use lenses for the non-parametric case.
Classify a scoped transform
A scoped transform applies an inner protolens to the sub-schema rooted at one vertex. The edge leading to that vertex determines whether the runtime focus behaves as a lens, traversal, or prism. The name is related to dependent optics (Vertechi 2023), but panproto implements a schema-edge-kind classifier rather than the paper’s indexed-category construction.
Prerequisites
The Rust SDK. The panproto-lens crate (re-exported from panproto-core::lens).
The task
Build the scoped transform
#![allow(unused)]
fn main() {
use panproto_lens::protolens::elementary;
let inner = elementary::rename_edge_name("post", "tags", "tags", "labels");
let scoped = elementary::scoped("post:tags", inner);
}
Because elementary::scoped constructs the protolens without inspecting a schema, scoped.optic_kind() returns the conservative theory-level classification inner_kind.compose(OpticKind::Lens). The incoming edge kind is unavailable at construction time.
Refine the classification
Read the incoming edge from the concrete schema, then pass its kind to refine_scoped_optic:
#![allow(unused)]
fn main() {
use panproto_lens::protolens::Protolens;
use panproto_lens::{OpticKind, refine_scoped_optic};
use panproto_schema::Schema;
fn classify_scoped(schema: &Schema, scoped: &Protolens) -> OpticKind {
let incoming = schema
.incoming_edges("post:tags")
.iter()
.find(|edge| edge.src.as_ref() == "post")
.expect("post:tags must have an incoming edge from post");
refine_scoped_optic(incoming.kind.as_ref(), scoped.optic_kind())
}
}
The result is Lens for a prop carrier, Traversal for an item or items carrier, and Prism for a variant carrier. The classifier does not inspect whether a property edge is required.
refine_scoped_optic uses Lens for prop and unrecognized edge kinds, Traversal for item and items, and Prism for variant. It composes that carrier with the inner kind.
Field-level combinators
The panproto_lens::protolens::combinators module exposes higher-level chains assembled from elementary steps. For instance, combinators::rename_field(parent, field, old_name, new_name) returns a ProtolensChain that renames a JSON property key. Use elementary::scoped or combinators::map_items to apply an inner transform to an array element vertex.
Verification
Instantiate the protolens against a concrete schema, then call panproto_lens::optic::check_optic_laws(kind, &lens, &instance). This checks the obligations implemented for the refined kind. The prism checker cannot test the full review law because this layer does not expose a review operation.
Common mistakes
- Treating
Protolens::optic_kind()as schema dependent. It classifies the stored theory transform only. Callrefine_scoped_opticwith the concrete edge kind for a scoped transform. - Assuming every non-
propspelling is rejected. The refinement function treats unknown kinds asLens; validate the schema against its protocol before relying on the classification. - Treating the classified kind as proof of the laws. Run
check_optic_lawson representative instances and handleOpticLawViolation.
See also
Write lenses in the lens DSL
The lens DSL describes schema-level steps and value-level field transforms in Nickel, JSON, or YAML. Exactly one body variant, such as steps, rules, compose, auto, from_diff, or symmetric, may be present.
Write a document
This JSON document renames one property key:
{
"id": "dev.example.user-v1-to-v2",
"description": "Rename the user name property",
"source": "dev.example.user.v1",
"target": "dev.example.user.v2",
"steps": [
{
"rename_field": {
"old": "name",
"new": "display_name"
}
}
]
}
Each step is a single-key object. --body-vertex supplies the parent vertex for field-level steps; its default is record:body.
Compile and apply in TypeScript
The TypeScript handle retains both the structural chain and any value transforms:
using chain = p.compileLensDocument(
document,
'record:body',
'json',
);
using lens = chain.instantiate(sourceSchema);
const { view, complement } = lens.getJson(
inputRecord,
'record:body',
);
const restored = lens.putJson(view, complement, 'record:body');
compileLensDocument accepts a JavaScript object, text, or UTF-8 bytes. JSON is the default format; pass 'yaml' for YAML. Nickel is not accepted by this WASM entry point because its imports require filesystem resolution.
Use chain.fieldTransforms() to confirm that apply_expr or compute_field steps survived compilation. Those transforms are stored beside the chain and do not appear in chain.toJson().
Compile Nickel or files in Rust
panproto-lens-dsl resolves the bundled Nickel contract and filesystem imports:
#![allow(unused)]
fn main() {
use std::path::Path;
let compiled = panproto_core::lens_dsl::load_and_compile(
Path::new("lenses/user-v1-to-v2.ncl"),
"record:body",
)?;
println!("{} structural steps", compiled.chain.steps.len());
println!("{} transform anchors", compiled.field_transforms.len());
Ok::<(), panproto_core::lens_dsl::LensDslError>(())
}
load_and_compile supports .ncl, .json, .yaml, and .yml. Named references in a compose body resolve against sibling documents in the same directory. auto and from_diff require the schema-aware compile_with_schemas entry point.
Compile and apply with the CLI
schema lens compile lenses/user-v1-to-v2.json \
--body-vertex record:body \
--out compiled.json
schema lens apply compiled.json user-v1.json \
--protocol atproto \
--schema user-v1.schema.json
schema lens compile writes a panproto-compiled-lens-v1 artifact. The file contains the structural chain, the value transforms, and an ordered stages array. The stages determine whether a value expression runs before or after a structural rename. schema lens apply reads this artifact directly and instantiates each stage against the schema produced by the preceding stage.
Raw ProtolensChain JSON remains accepted by schema lens apply, as does a target schema used for automatic lens generation. A file written by schema lens generate --save is a raw structural chain rather than a compiled-lens artifact. It can be applied, but it does not store value transforms derived during automatic generation.
Verification
Instantiate the document against the intended source schema, run get and put on representative instances, and use LensHandle.checkLaws or panproto_lens::laws::check_laws. Compilation validates document shape and expression syntax; it does not show that a value transform is total on production data.
See also
- Apply field transforms for
apply_exprandcompute_field. - Use lenses for runtime operations.
- Lens DSL: denotational semantics for the document model.
Query instances
The query engine selects nodes by schema anchor, follows edge kinds, filters with an expression, and returns selected fields. Rust and TypeScript expose the same operation.
Prerequisites
A Schema and a WInstance loaded against it. Predicates use the expression language.
Filter and project
panproto_inst::InstanceQuery uses the field names anchor, predicate, group_by, project, limit, and path. execute_query takes the query first, the instance second, and the schema third.
#![allow(unused)]
fn main() {
use panproto_core::gat::Name;
use panproto_core::inst::{InstanceQuery, QueryMatch, WInstance, execute_query};
use panproto_core::schema::Schema;
fn titles(schema: &Schema, instance: &WInstance) -> Vec<QueryMatch> {
let query = InstanceQuery {
anchor: Name::from("post"),
project: Some(vec!["id".into(), "title".into()]),
limit: Some(50),
..InstanceQuery::default()
};
execute_query(&query, instance, schema)
}
}
The anchor selects matching instance nodes. project limits each result’s fields map. It does not change the matched node’s value, identifier, or anchor.
Follow edges
path contains edge kinds, not target vertex names or property labels. The executor first selects nodes matching anchor, then follows each edge kind in order:
#![allow(unused)]
fn main() {
use panproto_core::gat::Name;
use panproto_core::inst::{InstanceQuery, WInstance, execute_query};
use panproto_core::schema::Schema;
fn run(schema: &Schema, instance: &WInstance) {
let query = InstanceQuery {
anchor: Name::from("user"),
path: vec![Name::from("authored")],
project: Some(vec!["title".into()]),
..InstanceQuery::default()
};
let posts = execute_query(&query, instance, schema);
let _ = posts;
}
}
Predicates are panproto_expr::Expr values. The evaluator binds a node’s extra fields, scalar child values reached by labeled edges, and the metadata variables _anchor, _id, _value, and _children_count. Instance-aware builtins such as Edge, Children, HasEdge, and EdgeCount receive the current instance and node.
TypeScript boundary
@panproto/core exports executeQuery(query, instance, wasm). The wrapper converts the public groupBy, projection, and nodeId names to and from Rust’s group_by, project, and node_id wire fields. It also supplies the schema handle retained by the Instance, so callers do not encode the schema separately.
The WASM module keeps execute_query(queryBytes, instanceBytes, schemaBytes) for direct byte-oriented callers. SDK code uses execute_query_with_schema_handle to avoid serializing a schema that is already in the WASM resource table.
Verification and limits
execute_query returns a vector and does not return predicate errors. A predicate evaluation that fails or does not produce true excludes that node. The executor accepts a schema argument but does not use it to reject an anchor absent from the schema. The byte-oriented WASM entry point rejects missing or malformed schema bytes, while the handle entry point rejects an invalid or non-schema handle. Neither entry point type-checks the query against the schema after resolving it.
Expression evaluation uses EvalConfig::default(). InstanceQuery exposes no budget field, and the query functions expose no configuration argument, so a caller cannot raise that budget at the query call site.
See also
Convert data between schemas
schema data convert auto-generates a lens between two schemas in one protocol and applies it to JSON records.
Prerequisites
The schema CLI, two files in panproto’s internal schema JSON format, and input records rooted at a vertex the CLI can infer from the source schema. The current command resolves only the atproto protocol.
Convert one file
schema data convert --protocol atproto \
--from schemas/user-v1.json \
--to schemas/user-v2.json \
data/user.json \
-o data/user-v2.json
--from and --to are schema paths. With no --chain, the command generates a lens using AutoLensConfig::default(), parses the input as a W-type instance, runs get, and serializes the view against the target schema.
Defaults use comma-separated key=value pairs:
schema data convert --protocol atproto \
--from schemas/user-v1.json \
--to schemas/user-v2.json \
--defaults status=active,locale=en \
data/user.json
The CLI parses all default values as strings. A key resolves against an added
target vertex ID first, then a unique incoming field label, and finally the
vertex kind. The generated lens adds the value only when the source-backed
parent does not already carry that field. Generation reports an error when a
default is unused, ambiguous, or cannot be placed from the target schema. With
--chain, defaults must already be embedded in the serialized chain;
--defaults cannot override them.
Convert a directory
Directory mode reads the immediate *.json files and requires an output directory:
schema data convert --protocol atproto \
--from schemas/user-v1.json \
--to schemas/user-v2.json \
data/users \
-o data/users-v2
Files that fail to load or convert are reported as skipped, and the final line prints converted and skipped counts. Inspect that count in automation; directory mode can finish after skipping individual files.
Convert from TypeScript
using lens = p.lens(srcSchema, tgtSchema);
const { view, complement } = lens.getJson(inputRecord, 'user');
const output = view as Record<string, unknown>;
Retain complement if the application may later call putJson to propagate an edited view backward.
Verify the conversion
Check the actual record with the SDK law checker:
const instance = p.parseJson(srcSchema, JSON.stringify(inputRecord));
const result = lens.checkLaws(instance._bytes);
if (!result.holds) {
throw new Error(result.violation ?? 'lens law failed');
}
schema lens verify <DATA> <SCHEMA> --protocol <PROTOCOL> parses the data with the supplied schema and runs the concrete GetPut and PutGet checks. A passing result applies to that record. It is not a proof over every instance of the schema.
Backward CLI conversion uses an empty complement. It can reconstruct only lenses that need no captured source data or for which defaults suffice. Use an SDK get/put pair when backward conversion depends on the complement produced from a specific source record.
--chain selects a saved chain instead of automatic lens generation. It still requires --from and --to, since a serialized ProtolensChain does not contain the concrete schemas needed for instantiation. schema lens generate --save writes this round-trippable chain format. The compiled artifact produced by schema lens compile --out also carries ordered value-transform stages and is intended for schema lens apply, not schema convert --chain.
See also
- Use lenses for bidirectional updates.
- Apply field transforms for computed values.
- Translate across protocols for the current cross-protocol limitation.
Round-trip with format preservation
Choose the format-preserving codec for a JSON, YAML, TOML, XML, or CSV round trip that must satisfy emit(parse(bytes)) == bytes. The codec records whitespace, comments, and ordering in a CST complement.
Source-code grammars use emit_pretty instead. Follow Parse full ASTs for that procedure and Source-code emission for its model.
Prerequisites
Format preservation is gated behind the tree-sitter feature flag on panproto-core (or directly on panproto-io). The shipped schema binary does not enable this feature, so its round-trips are not byte-for-byte: a format-preserving parse or emit requested from the default binary returns canonical output with no layout complement and prints a one-line notice to stderr. Byte preservation requires a build that turns the feature on, for instance a tool built against panproto-core with features = ["tree-sitter"], or a direct dependency on panproto-io with the same feature. The snippets below assume such a build.
The task
The format-preserving round-trip is exposed by the codec API, not by the shipped CLI. parse_wtype_preserving returns the instance together with a complement carrying the CST data the schema does not see, and emit_wtype_preserving reconstructs the byte-for-byte original from the pair.
In Rust:
use panproto_core::io::unified_codec::UnifiedCodec;
use panproto_core::schema::{Schema, SchemaBuilder};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let proto = panproto_core::protocols::atproto::protocol();
let schema: Schema = SchemaBuilder::new(&proto).vertex("root", "object", None)?.entry("root").build()?;
let bytes: &[u8] = b"{}\n";
let codec = UnifiedCodec::yaml("atproto")?;
let (instance, complement) = codec.parse_wtype_preserving(&schema, bytes)?;
let out = codec.emit_wtype_preserving(&schema, &instance, &complement)?;
assert_eq!(out, bytes);
Ok(()) }
The complement carries the CST data that the schema does not see. emit_wtype_preserving reconstructs the byte-for-byte original from (instance, complement). Constructors exist for JSON, XML, YAML, TOML, and CSV. UnifiedCodec::tsv additionally requires the table-vertex name.
Without the tree-sitter feature these constructors are compiled out. For code that must run in either build, ProtocolRegistry exposes parse_wtype_preserving_or_canonical and emit_wtype_preserving_or_canonical. With tree-sitter, these methods delegate to the preserving codecs. Without it, they return canonical output. Fallback parsing always prints a notice; fallback emission prints one only when a complement was supplied.
Verification
The byte equality is the verification. Property tests in CI check emit(parse(b)) == b against a corpus of JSON, YAML, TOML, XML, and CSV files.
Common mistakes
- Discarding the complement after modifying the instance. The complement contains layout rather than the authoritative field value. Reusing it preserves the original whitespace and ordering around values supplied by the modified instance.
- Mixing
format-preservingcodec output with non-preserving codec input. The two pipelines are separate; choose one consistently.
See also
- Reference: protocol catalog.
- Convert data between formats.
- Parse full ASTs for tree-sitter parsing of source code.
Parse full ASTs
Full-AST parsing converts source code into a panproto Schema derived from its tree-sitter syntax tree. The available languages are the grammars compiled into the current binary or SDK package.
Prerequisites
The schema CLI or the Rust SDK with full-parse. The default panproto-parse build enables the eleven-language core grammar group. Feature groups and companion Python packages can add more, up to the full grammar catalog.
Inspect a file from the CLI
schema parse file src/main.rs
The command prints a summary containing the detected language and the vertex and edge counts. It does not serialize the AST schema to stdout.
To inspect a directory:
schema parse project .
This command builds a project schema and prints aggregate counts plus the detected protocol for each recognized path. It also does not emit project-schema JSON. Use the Rust or Python API when the caller needs the Schema value itself.
Check source replay
schema parse emit src/main.rs > /tmp/main.replayed.rs
cmp src/main.rs /tmp/main.replayed.rs
parse emit parses the file and calls the parse-side emitter on the resulting schema. It writes only the emitted bytes to stdout, which makes the cmp check reliable.
Parse from Rust
use std::path::Path;
use panproto_core::parse::ParserRegistry;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = std::fs::read("src/main.rs")?;
let registry = ParserRegistry::new();
let schema = registry.parse_file(Path::new("src/main.rs"), &source)?;
println!("{} vertices", schema.vertex_count());
let explicit = registry.parse_with_protocol(
"rust",
&source,
"src/main.rs",
)?;
assert_eq!(schema.vertex_count(), explicit.vertex_count());
Ok(())
}
parse_file(path, content) detects the protocol from the path extension and returns ParseError::UnknownLanguage for an unregistered extension. parse_with_protocol(protocol, content, file_path) bypasses extension detection but still requires a registered protocol.
Parse from Python
from pathlib import Path
import panproto
source = Path("src/app.py").read_bytes()
registry = panproto.AstParserRegistry()
schema = registry.parse_file("src/app.py", source)
print(len(schema.vertices), len(schema.edges))
print(registry.protocol_names())
The core Python wheel discovers installed panproto-grammars-* companion packages when panproto.AstParserRegistry() is constructed. Install the group that contains the required language before creating the registry. For instance, panproto-grammars-functional adds Haskell, OCaml, and related grammars.
Read anonymous-token fields
Named tree-sitter children appear as schema edges. When a grammar attaches a field name to an unnamed token alternative, the walker stores the token text as a field:<name> constraint on the parent. The following QVR example requires panproto-grammars-all, which registers the qvr grammar. Use Schema.field_text to read the constraint:
schema = registry.parse_with_protocol(
"qvr",
b"let y = log(x)",
"demo.qvr",
)
let_call = next(v.id for v in schema.vertices if v.kind == "let_call")
assert schema.field_text(let_call, "func") == "log"
The Rust accessor is Schema::field_text(vertex_id, field_name) -> Option<&str>.
Verify pretty emission before relying on it
emit_pretty_with_protocol renders a schema without replaying its original layout. Query the protocol’s verification tier first:
#![allow(unused)]
fn main() {
use panproto_core::parse::{EmitVerificationStatus, ParserRegistry};
let registry = ParserRegistry::new();
match registry.emit_verification_status("rust") {
EmitVerificationStatus::Verified => {}
EmitVerificationStatus::Generic => {
eprintln!("pretty emission has no protocol-specific verification claim");
}
EmitVerificationStatus::Unsupported => {
eprintln!("pretty emission is unavailable for this protocol");
}
}
}
Verified records coverage by repository tests on representative input or a protocol corpus. It is not a proof over every byte sequence. Use schema parse emit on the actual files that enter a pipeline.
Limitations
- The AST schema contains syntactic structure. It does not add type checking, name resolution, or control-flow information.
- The CLI parse-inspection commands print summaries, not serializable schema values.
- Tree-sitter may include error nodes for malformed source, and panproto’s schema construction can still fail. Do not treat parsing as an unconditional total operation.
- Language availability depends on build features and installed grammar packs. Query
protocol_names()rather than relying on the maximum catalog size.
See also
- Decorate an abstract schema for rendering a layout-free schema.
- Source-code emission for replay and pretty-emission paths.
- Rust SDK for feature selection.
Decorate an abstract schema
Decoration renders an AbstractSchema with a grammar and parses the result again. The returned DecoratedSchema carries the layout constraints needed for replay emission.
Prerequisites
The Rust SDK with the full-parse feature. The requested grammar must be compiled into panproto-parse, and the abstract schema’s protocol field must equal the grammar name.
Obtain an abstract schema
SchemaBuilder::build_abstract() is the constructor for a hand-built layout-free schema. It rejects constraints in the layout fiber, such as start-byte, end-byte, interstitial-N, and chose-alt-*.
The following example begins with parsed JSON only to obtain a compact, valid grammar-shaped schema. A hand-built AbstractSchema for the same protocol can be substituted directly.
use panproto_core::parse::{LayoutPolicy, ParserRegistry};
use panproto_core::schema::DecoratedSchema;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let registry = ParserRegistry::new();
let parsed = registry.parse_with_protocol(
"json",
br#"{"k": 1}"#,
"input.json",
)?;
let abstract_schema = DecoratedSchema::wrap_unchecked(parsed).forget_layout();
let decorated = registry.decorate(
"json",
&abstract_schema,
&LayoutPolicy::default(),
)?;
let bytes = registry.emit_pretty_with_protocol(
"json",
decorated.as_schema(),
)?;
println!("{}", String::from_utf8(bytes)?);
Ok(())
}
decorate uses the supplied policy to produce canonical bytes, then parses those bytes to recover byte spans, interstitial text, and grammar-choice constraints. The parse step assigns fresh vertex identifiers.
Render without decoration
If only canonical bytes are needed, call pretty_with_protocol and skip the reparsing step:
#![allow(unused)]
fn main() {
use panproto_core::parse::{LayoutPolicy, ParserRegistry};
use panproto_core::schema::AbstractSchema;
fn render(
registry: &ParserRegistry,
schema: &AbstractSchema,
) -> Result<Vec<u8>, panproto_core::parse::ParseError> {
let policy = LayoutPolicy {
indent_width: 4,
newline: "\r\n".into(),
..LayoutPolicy::default()
};
registry.pretty_with_protocol(schema.protocol(), schema, &policy)
}
}
LayoutPolicy is an alias of FormatPolicy. Its fields are indent_width, separator, newline, line_break_after, indent_open, and indent_close.
Verify the result
Decoration preserves the abstract structure at the granularity tested by the library: after forget_layout, the vertex-kind and edge-shape multisets should match the input. It does not preserve vertex identifiers.
#![allow(unused)]
fn main() {
use panproto_core::schema::{edge_multiset, kind_multiset};
use panproto_core::schema::{AbstractSchema, DecoratedSchema};
fn check(input: &AbstractSchema, output: &DecoratedSchema) {
let round_trip = output.forget_layout();
assert_eq!(
kind_multiset(input.as_schema()),
kind_multiset(round_trip.as_schema()),
);
assert_eq!(
edge_multiset(input.as_schema()),
edge_multiset(round_trip.as_schema()),
);
}
}
Run the focused integration test with the grammar features used by the test:
cargo test -p panproto-parse --test decorate_section_law \
--features lang-json,lang-lilypond
ParserRegistry::emit_verification_status(protocol) reports Verified, Generic, or Unsupported. Verified means the repository has an explicit fixed-point or round-trip test for that protocol. Generic means the grammar-walker path is available but lacks a protocol-specific verification claim. Unsupported means the parser is missing or lacks the grammar data required for pretty emission.
Limitations
- A schema built for
atprotocannot be decorated with thejson,rust, orlilypondparser. Protocol mismatch returnsParseError::SchemaConstruction. build_decorated()andDecoratedSchema::wrap_unchecked()do not validate that a complete layout fiber is present. Reserve them for data produced by parsing or a trusted decoration path.- The section law concerns abstract structure, not byte equality with an earlier source file. A new
LayoutPolicymay produce different canonical bytes.
See also
- Parse full ASTs for parsing source into a decorated schema.
- Source-code emission for the emitter.
- Layout enrichment for the model.
Build a custom protocol
A custom protocol starts with a Protocol value and its schema and instance theories. Parsing, emission, migration, and language bindings are separate integration points. Implement only the surfaces the protocol will expose, and register each one explicitly.
Prerequisites
Familiarity with Schemas as theories and Composing protocols by colimit. The Rust toolchain.
The task
Declare the theory
The theory DSL provides one authoring path:
let T = import "panproto/theory.ncl" in
{
id = "dev.example.my-proto-schema",
description = "A directed graph whose edges carry permissions",
theory = "ThMyProtoSchema",
sorts = [
T.simple "Vertex",
T.simple "Edge",
T.val_sort "Permission" "string",
],
ops = [
T.unary "src" "Edge" "Vertex",
T.unary "tgt" "Edge" "Vertex",
T.unary "perm_of" "Edge" "Permission",
],
equations = [],
} | T.Theory
This document declares the schema-side theory directly. Give every document a stable id and description; the theory field supplies the name used by registrations and later compositions. Use a compose document when the schema theory should instead be a colimit of existing theories.
For finer control, declare the theory directly in Rust with the class! and inductive! macros from panproto-gat-macros.
From Python, the same DSL document loads via Theory.from_nickel(source), Theory.from_yaml(source), Theory.from_json(source), or Theory.from_path(path) (dispatches by extension). The loaders accept the theory, class, and inductive body variants; multi-body documents (morphism, composition, protocol, bundle) belong in panproto-theory-dsl::load_and_compile directly. For incremental authoring, panproto.TheoryBuilder mirrors class! in a chainable form. Round-trip the flat Theory shape via to_json / to_yaml paired with from_dict_json / from_dict_yaml.
Implement external-format boundaries
panproto-protocols has no common Parser or Emitter trait. Protocol modules expose format-specific free functions. A JSON document parser usually has the shape fn(&serde_json::Value) -> Result<Schema, ProtocolError>; a text-language parser usually accepts &str. Add the parser to parse_schema_document or parse_schema_source in crates/panproto-protocols/src/lib.rs, and add its canonical name to the corresponding document_parser_protocols or source_parser_protocols list.
Emitters are also format-specific functions in their protocol modules. Add one only when the protocol has a defined external representation and the application needs to produce it. Internal schema validation and migration operate on Schema values and do not require an external-format emitter.
Register
Each protocol module exposes protocol() -> Protocol and register_theories(&mut HashMap<String, Theory, _>). The internal module skeleton below is repository code, not a standalone Rust program:
// crates/panproto-protocols/src/my_proto.rs
use std::collections::HashMap;
use panproto_gat::Theory;
use panproto_schema::Protocol;
use crate::theories;
pub fn protocol() -> Protocol {
Protocol {
name: "my_proto".into(),
schema_theory: "ThMyProtoSchema".into(),
instance_theory: "ThMyProtoInstance".into(),
..Protocol::default()
}
}
pub fn register_theories<S: ::std::hash::BuildHasher>(
registry: &mut HashMap<String, Theory, S>,
) {
theories::register_constrained_multigraph_wtype(
registry,
"ThMyProtoSchema",
"ThMyProtoInstance",
);
}
Pick the theories::register_* helper that matches the intended schema and instance shapes (register_constrained_multigraph_wtype, register_typed_graph_wtype, register_hypergraph_functor, and related helpers). A helper is appropriate only when its constructed theories match the names and operations declared by the protocol.
Registration is surface-specific:
- Export the module from its category module in
panproto-protocols. - Add document or source parsing to the dispatch functions in
panproto-protocols::libwhen the protocol has an external parser. - Add the protocol name and constructor to
builtin_protocol_namesandlookup_builtin_protocolincrates/panproto-wasm/src/api/helpers.rsfor the TypeScript SDK. Add theory registration there as well if SDK operations need the theories; that registry currently has arms only foratproto,json-schema,graphql,sql, andprotobuf. - Add the constructor to
crates/panproto-py/src/protocols.rsfor the Python SDK. - Add the constructor and theory registration to
resolve_protocolandbuild_theory_registryincrates/panproto-cli/src/cmd/helpers.rsfor CLI commands. Both tables currently contain onlyatproto.
Other bindings that expose a fixed built-in table need their own entry. A Rust caller can use the module’s protocol() and register_theories() functions directly without a global lookup.
Verification
cargo test -p panproto-protocols my_proto
Add tests under the new protocol module with names containing my_proto; the command above runs that subset with Cargo alone. Cover every implemented surface. That means theory registration and schema validation for a theory-backed protocol, parsing for an external parser, and a parse/emit round trip only when an emitter exists. Add a migration test when the protocol claims migration support.
Common mistakes
- Declaring extensions before the colimit components are correct. Extensions interact with the colimit structure; if a building-block step is wrong, the extension may not even reach registration.
- Adding a module without updating its dispatch tables. The Rust module remains directly callable, but string-based SDK and CLI lookups will not find it.
- Assuming the CLI uses the same catalog as the language SDKs. Its resolver and theory registry are separate and currently recognize only
atproto. - Requiring an emitter for internal operations.
schema validatereads panproto schema JSON directly; it does not call a protocol document parser or emitter.
See also
Schema version control
These guides put schemas, migrations, and associated data under panproto version control. Begin with repository creation and commits; add branching, data migration, or a git bridge only when the project needs that workflow.
| Page | Purpose |
|---|---|
| Initialize and commit | Start a repository, then stage and commit schema changes. |
| Branch and merge | Diverge a feature branch, merge it back via pushout. |
| Version data alongside schemas | Stage data instances with commits and run explicit migrations between schema versions. |
| Bridge to git | Run panproto-vcs alongside git, or as a custom git remote. |
See also
- Schema version control semantics for the model.
- Pushouts and merge for the merge construction.
- Reference: CLI for the full subcommand list.
Init and commit
Initialize a panproto repository, stage a schema, and record the first content-addressed commit.
Prerequisites
The schema CLI installed. A directory containing schema files (or a fresh directory you are about to populate).
The task
cd my-schemas/
schema init # create .panproto/
schema add user.json
schema commit -m "initial user schema"
schema log # show history
init creates a .panproto/ object store and a main branch. It may also generate panproto.toml when package detection finds source packages. add parses or loads the supplied path and stages the resulting schema; commit records the staged schema. A path ending in .json is deserialized directly as panproto’s internal Schema representation. To stage an ATProto Lexicon bundle, point add at a directory whose manifest declares a homogeneous atproto package. Non-JSON source files use the full-AST parser.
To inspect the current state:
schema status
schema diff --staged # diff the staged schema against HEAD
schema show <commit-hash>
schema diff without --staged requires two file paths. A staged diff also requires an existing commit, so use it after the first commit.
Verification
schema log --oneline
prints one line per commit. The default long format includes the commit ID, schema ID, author, timestamp, and message.
Common mistakes
- Forgetting to
schema addbeforeschema commit. Like git, the staging area is explicit; commits only include staged changes. - Editing inside
.panproto/objects/directly. The store is content-addressed; manual edits break the hash invariants.
See also
Branch and merge
Create a branch to isolate a schema change, then merge it back through panproto’s schema-aware merge operation.
Prerequisites
A panproto repository with at least one commit (Init and commit).
The task
schema branch feature/add-handle
schema checkout feature/add-handle
# edit the schema, add a `handle` field
schema add user.json
schema commit -m "add handle"
schema checkout main
schema merge feature/add-handle
merge uses the current commit, the named branch, and their common ancestor. A fast-forward moves the current branch. A divergent merge performs a three-way structural merge and records a two-parent commit unless --no-commit or --squash changes that behavior. Before it records a normal clean merge commit, the repository verifies that the total migrations from the base through both parents to the result form a commuting cocone. When both branches add the same name with the same definition, structural merge identifies the additions. In that case the result is not a free pushout. The --no-commit and --squash paths do not run this commit-time cocone check.
If both branches make incompatible changes to the same structure, the command prints the detected conflicts and exits nonzero. The current CLI does not persist an editable conflict descriptor.
Resolve the source schemas on one branch, commit that resolution, and rerun the merge:
schema checkout feature/add-handle
# edit user.json to incorporate the intended result
schema add user.json
schema commit -m "resolve merge inputs"
schema checkout main
schema merge feature/add-handle
Verification
schema log
shows a Merge: line for a two-parent merge commit. The --graph option is accepted but currently ignored by the renderer.
Common mistakes
- Expecting checkout to rewrite source files. It moves the repository ref only. Edit or regenerate working files explicitly.
- Looking for a saved conflict object after a failed merge. Capture the printed conflict details; the CLI does not yet provide an interactive continuation flow.
See also
- Pushouts and merge for the categorical comparison.
- Schema version control semantics.
- Init and commit.
Version data alongside schemas
Stage data with its schema when a commit must retain both. Data migration is a separate operation over a commit range.
Prerequisites
A panproto repository with at least one schema and a corresponding data instance.
The task
Data is staged together with its schema via schema add --data:
schema add user.json --data records/
schema commit -m "v1 schema and seed data"
# Evolve the schema and re-stage with the same data directory.
schema add user-v2.json --data records/
schema commit -m "v2 schema"
# Sync the working directory across parent..HEAD.
schema data sync records/
schema add --data <DATA> stages each immediate JSON file in the directory. Each file’s records are parsed and checked against the schema being staged, so a file that is not JSON, or one whose records do not fit the schema, fails at add rather than at whatever later operation first tries to read it. Staging is all or nothing for the data files, so a failure leaves none of them staged. --skip-verify stages without the check and leaves the result pending, which a default commit then refuses; it does not skip reading the file, since a data set records which schema its data belongs to and bytes that cannot be read as records of that schema cannot be recorded under it. schema data sync compares a target commit with its first parent, generates a lens, and rewrites records it can migrate; failed records are reported as skipped.
Preview the default parent..HEAD range and run coverage without writing:
schema data migrate records/ --dry-run --coverage
--dry-run prints the selected plan without attempting the conversions. The coverage pass then tries each immediate JSON file and reports successes and failures. Use --range old..new to select another pair.
schema status --data records/
schema data status records/
Checkout changes only the repository ref. Pass --migrate to request a corresponding on-disk data migration:
schema checkout <commit> --migrate records/
Verification
schema data status records/
prints the number of immediate JSON files, the HEAD schema ID, and the number of data sets tracked by that commit. It does not parse the files or prove conformance.
schema data migrate records/ --dry-run --coverage is the non-writing per-record check.
Common mistakes
- Editing data inside the store directly. Like schemas, data objects are content-addressed.
- Skipping data when committing schema changes. If the v1 commit has no staged data, the later commit-range operation has no stored v1 records to migrate. The v2 commit may thus have no data even when records exist in the working directory.
- Assuming rebase or amend migrates working data. Those commands take no data-directory option. Run an explicit data migration after rewriting history.
See also
Bridge to git
The git bridge stores a panproto repository beside a source repository and can translate history between the two systems.
Prerequisites
A git repository (the host project) and either a .panproto/ directory inside it or a sibling panproto repository.
The task
Sidecar mode
The simplest setup: keep .panproto/ next to .git/. Both are tracked by their own tools; commits to either are independent.
git add .panproto/
git commit -m "snapshot panproto state"
The object names are content hashes, so unchanged objects deduplicate. Git can still report conflicts in refs or other mutable files under .panproto/; do not edit stored object contents by hand.
Bidirectional translation
schema git export writes the current panproto history into a destination git repository:
schema git export path/to/output-git-repo --repo .
schema git import path/to/git-repo HEAD currently imports into an in-memory store, prints the imported count and temporary panproto ID, then exits without updating .panproto/. Use it only as a diagnostic until the command accepts a persistent destination. Export opens --repo, creates or opens the destination, and writes the current HEAD.
git-remote helper
The panproto-git-remote crate ships git-remote-panproto, a remote helper for a panproto node reached over XRPC. After installing that binary, a node URL has the form panproto://did:plc:abc123/repository-name; a local filesystem path is not a valid substitute. See crates/panproto-git-remote.
Merge bridging
A git merge that touches .panproto/ merges files, not schemas. Perform schema-level work with schema merge, then export the resulting history. The CLI has no automatic translation from a git merge to a structural schema merge.
Verification
schema status
git status
Inspect both outputs independently. Panproto status concerns its current branch and staged schema; git status concerns files in the host repository. Either side can be dirty while the other is clean.
Common mistakes
- Three-way text-merging
.panproto/objects/files. Re-run the schema operation in panproto, then export the result. - Mixing the two modes. Choose sidecar or remote-bridge per project; mixing creates ambiguity about which DAG is the source of truth.
See also
Translate across protocols
panproto has two different cross-protocol cases. The TypeScript SDK can apply an explicit vertex-and-edge mapping between two protocol-tagged schema handles. It does not construct a shared theory or emit a complete target-language document. The CLI does not expose that limited two-protocol path: each --protocol argument selects one protocol for both schemas, and the current CLI resolver accepts only atproto.
Decide whether the task is supported
Use Convert data between schemas when both schemas use atproto and are already stored as panproto schema JSON. For a small explicit map in TypeScript, build both schema handles, map every relevant vertex and edge, and compile the migration as shown in Cross-protocol translation. checkExistence selects the source schema’s registered protocol, so that report does not establish validity under both protocols. The result of liftJson is target-shaped JSON, not a complete OpenAPI, Protobuf, or other target document.
A general bridge requires repository-level implementation: a shared protocol theory, explicit source and target translations into that theory, and format-specific parsing and emission at the boundaries.
The theory DSL can compile a colimit of building-block theories, but compilation alone does not register a runtime protocol or translate existing JSON Schema, Protobuf, ATProto, or other built-in schemas into the result. Thus a DSL compose document is only one component of a cross-protocol bridge.
Implement a bridge in Rust
The supported building blocks are available in Rust:
- Define the shared theory and its instance theory, then register both under stable names.
- Define a
Protocolwhoseschema_theoryandinstance_theoryuse those names. - Parse each source format with its protocol-specific parser, and write an explicit schema translation into the shared protocol.
- Build and check a migration between the translated schemas.
- Parse source records, apply the migration or lens, then serialize with the target format’s emitter.
Each translation in steps 3 and 5 is format-specific code. panproto does not infer those boundary translations from the fact that two protocol theories share sorts with names such as Vertex or Edge.
Verify the bridge
Test the three boundaries separately: source parsing into the shared schema, migration or lens laws over representative instances, and target emission followed by the target protocol’s parser. Constraints with no representation in the shared theory must be reported or handled explicitly; no current generic command detects and reports every such loss.
CLI boundaries
The following patterns do not provide cross-protocol translation:
schema data convert --protocol <name>accepts one protocol name and loads both schemas under it. The current resolver accepts onlyatproto.schema lens generate --protocol <name>likewise resolves one protocol for the entire lens, again onlyatprotoin the current CLI.schema theory compilevalidates and compiles a theory document but does not add it to the running CLI’s built-in protocol lookup.
See also
- Build a custom protocol for the repository changes needed to register a protocol.
- Composing protocols by colimit for the theory construction.
- Convert data between schemas for the supported single-protocol path.
Continuous integration
Add CI after schemas validate locally. The smallest useful gate classifies compatibility; local hooks and a hosted workflow apply the same checks earlier and on every pull request.
| Page | Purpose |
|---|---|
| Breaking-change gate | Fail CI when a schema change is classified as breaking unless the change is explicitly acknowledged. |
| GitHub Actions | Run schema validation and compatibility classification on pull requests. |
| Pre-commit hooks | Run schema validation before each local commit. |
See also
Breaking-change gate
A breaking-change gate fails CI when a pull request introduces a schema change that panproto classifies as breaking. The gate compares the proposed schema with the merge base on main.
Prerequisites
A panproto repository under git. A CI system that can run shell commands.
The task
schema compat classifies the diff between two schema versions and sets its exit code by tier: 0 for a non-breaking change, 1 for a breaking change, and 2 for a usage or load error. Pair it with schema check --typecheck when the repository also stores an explicit migration mapping; that second command checks the mapping’s existence conditions and theory-level types.
# In your CI script.
git fetch origin main
base_commit=$(git merge-base HEAD origin/main)
git show "$base_commit:schemas/user.json" > /tmp/user-base.json
# Classify the change; exit 1 means breaking, exit 2 a usage or load error.
schema compat /tmp/user-base.json schemas/user.json --protocol atproto
# Check the explicit mapping and its theory-level types.
schema check \
--src /tmp/user-base.json \
--tgt schemas/user.json \
--mapping migrations/user.json \
--typecheck
Either step’s non-zero exit fails the build. To allow an explicit override, gate on a commit-message marker or a PR label:
if git log -1 --format=%B | grep -q '\[breaking-change-acknowledged\]'; then
schema compat /tmp/user-base.json schemas/user.json --protocol atproto || true
schema check \
--src /tmp/user-base.json \
--tgt schemas/user.json \
--mapping migrations/user.json \
--typecheck || true
else
schema compat /tmp/user-base.json schemas/user.json --protocol atproto
schema check \
--src /tmp/user-base.json \
--tgt schemas/user.json \
--mapping migrations/user.json \
--typecheck
fi
For machine-readable output, add --format json to schema compat. The CLI JSON and the Rust and Python reports include a three-way classification field, breaking and non-breaking lists, and a compatible boolean. The TypeScript CompatReport exposes isCompatible, isBreaking, breakingChanges, and nonBreakingChanges; call toJson() when the three-way classification string is needed.
Verification
Open a PR that adds a backward-compatible field, and the gate passes. A PR that drops a required field fails. Adding [breaking-change-acknowledged] to the commit message lets the gate pass with a warning.
Common mistakes
- Running
schema checkwithout a maintained mapping file. Compatibility classification needs only the two schemas; mapping checks are an additional gate for repositories that version migrations. - Comparing against the wrong base. The gate must compare against the merge base of the PR and
main, not againstmain’s tip; otherwise force-pushes tomainmake every PR look breaking.
See also
- GitHub Actions for a hosted workflow.
- Pre-commit hooks for catching breakage before push.
- Build a migration.
GitHub Actions
Add this workflow to validate schemas and classify compatibility on every pull request.
Prerequisites
A panproto repository on GitHub. This example assumes schemas/*.json contains panproto’s serialized schema format rather than external schema-language documents.
The task
# .github/workflows/panproto.yml
name: panproto
on:
pull_request:
paths:
- 'schemas/**'
- 'migrations/**'
- 'panproto.toml'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need history for the diff base
- name: Install schema
run: cargo install panproto-cli --version 0.72.0 --locked
- name: Validate
run: |
for f in schemas/*.json; do
schema validate --protocol atproto "$f"
done
- name: Breaking-change gate
run: |
base=$(git merge-base origin/${{ github.base_ref }} HEAD)
git show "$base:schemas/user.json" > /tmp/base.json
schema compat /tmp/base.json schemas/user.json --protocol atproto
schema check --src /tmp/base.json --tgt schemas/user.json \
--mapping migrations/user.json --typecheck
The --protocol flag is required for every per-file schema validate. There is no --project flag. For an external document such as a Lexicon, use the appropriate parse path before this validation step.
The job has separate validation and breaking-change steps. Validation fails on a malformed schema; schema compat gives the gate its compatibility exit code, and schema check --typecheck rejects an invalid migration mapping.
Verification
After a pull request is pushed, validation exits zero when every schema passes. The compatibility gate prints its classification and exits nonzero for a breaking change.
Common mistakes
- Omitting
fetch-depth: 0. Shallow clones make the merge-base lookup fail; the gate then runs against the wrong base. - Leaving the CLI version unpinned. Update the pinned version deliberately and review any compatibility-classifier changes with the update.
See also
- Breaking-change gate for the underlying mechanic.
- Pre-commit hooks for the local-side equivalent.
Pre-commit hooks
A pre-commit hook rejects malformed staged schemas before they enter the history. This example validates the bytes in the index, so an unstaged working-tree edit cannot change the result.
Prerequisites
The schema CLI installed. A git repository.
The task
Plain git hook
# .git/hooks/pre-commit
#!/usr/bin/env bash
set -euo pipefail
changed=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^schemas/.*\.json$' || true)
[ -z "$changed" ] && exit 0
staged_file=$(mktemp)
base_file=$(mktemp)
trap 'rm -f "$staged_file" "$base_file"' EXIT
while IFS= read -r f; do
git show ":$f" > "$staged_file"
schema validate --protocol atproto "$staged_file"
# Optional warning against the tracked upstream copy.
if git show "@{u}:$f" > "$base_file" 2>/dev/null && \
! schema compat "$base_file" "$staged_file" --protocol atproto; then
echo "warning: compatibility check failed for $f" >&2
fi
done <<< "$changed"
chmod +x .git/hooks/pre-commit installs it.
With pre-commit framework
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: schema-validate
name: panproto schema validate
entry: schema validate --protocol atproto
language: system
files: '^schemas/.*\.json$'
The hook receives the staged file paths as positional arguments; the --protocol flag is required.
pre-commit install activates it.
Verification
Stage a malformed schema and try to commit. The hook rejects it. After the schema is fixed, the next commit passes.
Common mistakes
- Silently skipping a missing
schemabinary. Prefer a failing hook with an installation message; CI remains the authoritative gate when contributors may bypass hooks. - Validating every file on every commit. The script above scopes to staged
schemas/*.jsononly; broader scopes are noisy.
See also
- Breaking-change gate for the CI-side equivalent.
- Reference: CLI.
Reference
Use these pages to look up a flag, field, signature, protocol, or grammar. Procedures live in the how-to guides. The mathematical model lives in explanation.
Operational contracts
| Page | Contract |
|---|---|
| CLI | Every schema subcommand and its generated --help text. |
| Configuration | Fields and defaults in panproto.toml. |
| Protocol catalog | Registered protocols, their module categories, and emit support. |
SDK contracts
| Surface | Contract |
|---|---|
| Rust | Crate selection, feature flags, migration direction, and morphism-search types. |
| TypeScript | Package initialization, migration direction, facade objects, and the handle boundary. |
| Python | Native-module exports, migration direction, type stubs, and companion grammar packs. |
| Haskell | Capability classes, migration direction, backends, effects, and Cabal flags. |
| Swift | Products, migration direction, engine isolation, handles, errors, and feature gates. |
Intermediate lookup
The expression-language reference lists the surface grammar, types, builtins, and errors used by queries and field transforms. The lens-combinator reference lists optic kinds, constructor families, complement composition, and protolens instantiation.
Advanced lookup
The crate map groups the panproto-* workspace crates by dependency role and lists the main feature-gated edges. Use it when the facade does not expose the layer an extension needs.
CLI reference
Every schema subcommand, with its full --help text. This page is regenerated
from the live binary by xtask/src/bin/gen-cli-docs.rs; edit the CLI, not the
page.
To regenerate locally:
cargo run -p xtask --bin gen-cli-docs
CI runs the same command and fails if the result differs from what is checked in.
For the model that the commands operate on, see Schemas as theories, Migrations as morphisms, and Schema version control semantics.
Discovering a migration
schema auto-migrate runs one search over spans. Write the apex as ; the two
legs have the shape
The apex is the sub-schema of old whose vertices found a target in new. That
search never refuses for want of a match: leaving every source vertex out of the
apex is always feasible, so two schemas with nothing in common come back with an
empty apex rather than with an error. Two of the three flags below thus
select which of its answers counts as an answer, and the search underneath is the
same one either way; the third constrains the search itself.
--span accepts every answer, the empty apex included. Without it the command
accepts a span covering at least one source vertex and reports the empty apex as
a failure naming the two files.
--total accepts only a span whose left leg is onto, which is to say a total
morphism. Totality is a condition on the edges as well as the vertices, so an
apex holding every source vertex is not on its own enough: a source arc that
found no image in the target leaves the answer partial at full vertex coverage.
The command does not read that off the span alone. An optimal span that
drops a vertex is no evidence about whether a total morphism exists, because span
quality excludes the drop count while the objective is lexicographic in quality
first and drops second, so a span that drops a vertex can score strictly better
than a total morphism that keeps it. When the optimal span is not total, the
command runs the total-morphism search before giving up. The command fails only
when that second search returns no total morphism. Its error includes the
coverage reached by the span.
--total and --span conflict, and the pair is rejected before either search
runs.
--monic constrains the search rather than the acceptance, so it composes with
either of the others. It requires the vertex map to be injective, so that no two
source vertices land on the same target. Injectivity on vertices does not force
injectivity on edges, and a monic answer may still send two parallel source edges
to one target edge. When the answer is not injective on vertices, the command
says so on stderr, since a migration identifying two source vertices has no
well-defined lift without a rule for combining them.
The human report opens with the shape, the score, and the interval the search certified, then sizes the apex:
Found span (quality: 0.812, bounds: [0.812, 0.884]):
Apex: 7 of 9 vertices (77.8% coverage), 6 edges
The bounds collapse to a point exactly when the answer was proved optimal. When they do not, a following line records that the search stopped before it could rule out a better span, which is what separates a quality of 0.812 that nothing beats from a quality of 0.812 the search never got to improve on. Underneath come the right leg’s vertex map and, when it has one, its edge map. A total morphism recovered by the second search prints a shorter report, with no apex line and no bounds, because it carries neither.
--json writes the span’s right leg, a migration out of the apex, to stdout.
Warnings stay on stderr, so the output pipes.
schema
Schematic version control: schema migration toolkit based on generalized algebraic theories
Usage: schema [OPTIONS] <COMMAND>
Commands:
validate Validate a schema against a protocol
check Check existence conditions for a migration between two schemas
compat Classify backward-compatibility between two schema versions
scaffold Generate minimal test data from a protocol theory using free model construction
normalize Simplify a schema by merging equivalent elements
typecheck Type-check a migration between two schemas at the GAT level
verify Verify that a schema satisfies its protocol theory's equations
init Initialize a new panproto repository
add Stage a schema for the next commit
commit Create a new commit from staged changes
status Show repository status
log Show commit history
diff Diff two schemas or show staged changes
show Inspect a commit, schema, or migration object
branch Create, list, or delete branches
tag Create, list, or delete tags
checkout Switch to a branch or commit
merge Merge a branch into the current branch
rebase Replay current branch onto another
cherry-pick Apply a single commit's migration to the current branch
reset Move HEAD / unstage / restore
stash Save or restore working state
reflog Show ref mutation history
bisect Binary search for the commit that introduced a breaking change
blame Show which commit introduced a schema element
lift Apply a migration to a record, transforming it from source to target schema
integrate Integrate two schemas by computing their pushout
auto-migrate Automatically discover a migration between two schemas
gc Garbage collect unreachable objects
expr Evaluate, type-check, or interactively explore GAT expressions
enrich Add, list, or remove schema enrichments (defaults, coercions, mergers, policies)
remote Add, list, or remove remote repositories
push Push schemas to a remote repository
pull Pull schemas from a remote repository
fetch Fetch schemas from a remote repository
clone Clone a remote repository
data Data operations: migrate, convert, sync, and status
theory Theory DSL operations: define theories, morphisms, and protocols from data files
lens Bidirectional lens operations
parse Parse source files into full-AST schemas via tree-sitter
git Import/export between git repositories and panproto-vcs
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
-V, --version Print version
schema validate
Validate a schema against a protocol
Usage: schema validate [OPTIONS] --protocol <PROTOCOL> <SCHEMA>
Arguments:
<SCHEMA> Path to the schema JSON file
Options:
--protocol <PROTOCOL> The protocol name (e.g., "atproto")
-v, --verbose Enable verbose output
-h, --help Print help
schema check
Check existence conditions for a migration between two schemas
Usage: schema check [OPTIONS] --src <SRC> --tgt <TGT> --mapping <MAPPING>
Options:
--src <SRC> Path to the source schema JSON file
-v, --verbose Enable verbose output
--tgt <TGT> Path to the target schema JSON file
--mapping <MAPPING> Path to the migration mapping JSON file
--typecheck Also type-check the migration morphism at the GAT level
-h, --help Print help
schema compat
Classify backward-compatibility between two schema versions.
Runs a structural diff then classifies it against the named protocol, printing the changes grouped by tier. Exit codes: `0` when no breaking changes are found, `1` when at least one breaking change is found, and `2` on a usage or load error (unreadable file, unknown protocol, or bad `--format`).
Usage: schema compat [OPTIONS] --protocol <PROTOCOL> <OLD> <NEW>
Arguments:
<OLD>
Path to the old schema JSON file
<NEW>
Path to the new schema JSON file
Options:
--protocol <PROTOCOL>
The protocol name (e.g., "atproto")
-v, --verbose
Enable verbose output
--format <FORMAT>
Output format: `text` (default) or `json`
[default: text]
-h, --help
Print help (see a summary with '-h')
schema scaffold
Generate minimal test data from a protocol theory using free model construction
Usage: schema scaffold [OPTIONS] --protocol <PROTOCOL> <SCHEMA>
Arguments:
<SCHEMA> Path to the schema JSON file
Options:
--protocol <PROTOCOL> The protocol name (e.g., "atproto")
-v, --verbose Enable verbose output
--depth <DEPTH> Maximum term generation depth (default: 3) [default: 3]
--max-terms <MAX_TERMS> Maximum terms per sort (default: 1000) [default: 1000]
--json Output as JSON
-h, --help Print help
schema normalize
Simplify a schema by merging equivalent elements
Usage: schema normalize [OPTIONS] --protocol <PROTOCOL> <SCHEMA>
Arguments:
<SCHEMA> Path to the schema JSON file
Options:
--protocol <PROTOCOL> The protocol name (e.g., "atproto")
-v, --verbose Enable verbose output
--identify <IDENTIFICATIONS> Pairs of elements to identify, as "A=B"
--json Output as JSON
-h, --help Print help
schema typecheck
Type-check a migration between two schemas at the GAT level
Usage: schema typecheck [OPTIONS] --src <SRC> --tgt <TGT> --migration <MIGRATION>
Options:
--src <SRC> Path to the source schema JSON file
-v, --verbose Enable verbose output
--tgt <TGT> Path to the target schema JSON file
--migration <MIGRATION> Path to the migration mapping JSON file
-h, --help Print help
schema verify
Verify that a schema satisfies its protocol theory's equations
Usage: schema verify [OPTIONS] --protocol <PROTOCOL> <SCHEMA>
Arguments:
<SCHEMA>
Path to the schema JSON file
Options:
--protocol <PROTOCOL>
The protocol name (e.g., "atproto")
-v, --verbose
Enable verbose output
--max-assignments <MAX_ASSIGNMENTS>
Maximum assignments to check per equation (default: 10000)
[default: 10000]
--format <FORMAT>
Output format: `text` (default) or `json`
[default: text]
--allow-incomplete
Exit zero when a theory could not be checked at all.
A theory that does not typecheck, or one whose assignment enumeration exhausted `--max-assignments`, establishes nothing about the schema. By default that is an error, since treating it as a pass reports a schema verified that was never examined. This accepts it for exploratory use; the output still says the run was incomplete.
-h, --help
Print help (see a summary with '-h')
schema init
Initialize a new panproto repository
Usage: schema init [OPTIONS] [PATH]
Arguments:
[PATH] Directory to initialize (defaults to current dir) [default: .]
Options:
-b, --initial-branch <INITIAL_BRANCH> Use the given name for the initial branch
-v, --verbose Enable verbose output
-h, --help Print help
schema add
Stage a schema for the next commit
Usage: schema add [OPTIONS] <SCHEMA>
Arguments:
<SCHEMA>
Path to the schema JSON file
Options:
-n, --dry-run
Show what would be staged without actually staging
-v, --verbose
Enable verbose output
-f, --force
Force staging even if validation fails
--data <DATA>
Stage data files alongside the schema
--skip-verify
Skip GAT migration validation, and the check of staged data against its schema, while staging.
Leaves the stage pending, which a default `commit` refuses; the migration is still recorded, and data files are still parsed, since a data set cannot be recorded under a schema its bytes cannot be read against.
-h, --help
Print help (see a summary with '-h')
schema commit
Create a new commit from staged changes
Usage: schema commit [OPTIONS] --message <MESSAGE>
Options:
-m, --message <MESSAGE> Commit message
-v, --verbose Enable verbose output
--author <AUTHOR> Author name [default: anonymous]
--amend Amend the previous commit instead of creating a new one
--allow-empty Allow creating a commit with no changes
--skip-verify Skip GAT equation verification
-h, --help Print help
schema status
Show repository status
Usage: schema status [OPTIONS]
Options:
-s, --short Show output in short format
-v, --verbose Enable verbose output
--porcelain Show output in machine-readable format
-b, --branch Show branch information
--data <DATA> Show data staleness for files in this directory
-h, --help Print help
schema log
Show commit history
Usage: schema log [OPTIONS]
Options:
-n, --limit <LIMIT> Maximum number of commits to show
-v, --verbose Enable verbose output
--oneline Show each commit on a single line
--graph Show an ASCII graph of the branch structure
--all Show all branches
--format <FORMAT> Pretty-print commits using a format string
--author <AUTHOR> Filter commits by author
--grep <GREP> Filter commits whose message matches a pattern
--data Show data and complement IDs in commit history
-h, --help Print help
schema diff
Diff two schemas or show staged changes
Usage: schema diff [OPTIONS] [OLD] [NEW]
Arguments:
[OLD] Path to the old schema (or first ref)
[NEW] Path to the new schema (or second ref)
Options:
--stat Show a diffstat summary
-v, --verbose Enable verbose output
--name-only Show only names of changed elements
--name-status Show names and status (A/D/M) of changed elements
--staged Diff the staged schema against HEAD
--detect-renames Detect likely renames between schemas
--theory Show theory-level diff (sorts, operations, equations)
--lens Also generate a protolens chain between the schemas
--save <SAVE> Save the protolens chain to a file (requires --lens)
--optic-kind Show the optic classification of the diff
-h, --help Print help
schema show
Inspect a commit, schema, or migration object
Usage: schema show [OPTIONS] <TARGET>
Arguments:
<TARGET> Ref name or object ID
Options:
--format <FORMAT> Pretty-print using a format string
-v, --verbose Enable verbose output
--stat Show a diffstat summary for commits
-h, --help Print help
schema branch
Create, list, or delete branches
Usage: schema branch [OPTIONS] [NAME]
Arguments:
[NAME] Branch name to create. Lists branches if omitted
Options:
-d, --delete Delete the branch
-D Force-delete the branch even if not fully merged
-f, --force Force overwrite if branch already exists
-m, --move <RENAME> Rename a branch (value is the new name)
-v, --verbose Show commit info for each branch
-a, --all List both local and remote-tracking branches
-h, --help Print help
schema tag
Create, list, or delete tags
Usage: schema tag [OPTIONS] [NAME]
Arguments:
[NAME] Tag name to create. Lists tags if omitted
Options:
-d, --delete Delete the tag
-v, --verbose Enable verbose output
-a, --annotate Create an annotated tag
-m, --message <MESSAGE> Tag message (implies --annotate)
-l, --list List tags matching a pattern
-f, --force Force-replace an existing tag
-h, --help Print help
schema checkout
Switch to a branch or commit
Usage: schema checkout [OPTIONS] <TARGET>
Arguments:
<TARGET> Branch name or commit ID
Options:
-b Create a new branch with the given name at HEAD and switch to it
-v, --verbose Enable verbose output
--detach Detach HEAD at the target commit
--migrate <MIGRATE> Migrate data in this directory to match the target branch's schema
-h, --help Print help
schema merge
Merge a branch into the current branch
Usage: schema merge [OPTIONS] [BRANCH]
Arguments:
[BRANCH] Branch to merge
Options:
--author <AUTHOR> Author name [default: anonymous]
--no-commit Perform the merge but do not commit
--ff-only Refuse to merge unless fast-forward is possible
--no-ff Create a merge commit even for fast-forward merges
--squash Squash the branch into a single change set
--abort Abort an in-progress merge
-m, --message <MESSAGE> Custom merge commit message
-v, --verbose Show pullback-based overlap detection details
--migrate <MIGRATE> Migrate data in this directory through the merge
-h, --help Print help
schema rebase
Replay current branch onto another
Usage: schema rebase [OPTIONS] [ONTO]
Arguments:
[ONTO] Branch or commit to rebase onto
Options:
--author <AUTHOR> Author name [default: anonymous]
-v, --verbose Enable verbose output
--abort Abort the current rebase operation
--cont Continue a paused rebase after resolving conflicts
-h, --help Print help
schema cherry-pick
Apply a single commit's migration to the current branch
Usage: schema cherry-pick [OPTIONS] [COMMIT]
Arguments:
[COMMIT] Commit ID to cherry-pick
Options:
--author <AUTHOR> Author name [default: anonymous]
-v, --verbose Enable verbose output
-n, --no-commit Apply the change without committing
-x Append "(cherry picked from commit ...)" to the message
--abort Abort the current cherry-pick operation
-h, --help Print help
schema reset
Move HEAD / unstage / restore
Usage: schema reset [OPTIONS] <TARGET>
Arguments:
<TARGET> Target ref or commit ID
Options:
--soft Soft reset: move HEAD only, keep staged and working changes
-v, --verbose Enable verbose output
--hard Hard reset: move HEAD, discard all changes
--author <AUTHOR> Author name [default: anonymous]
-h, --help Print help
schema stash
Save or restore working state
Usage: schema stash [OPTIONS] <COMMAND>
Commands:
push Save the current staged schema
pop Restore the most recent stash
list List all stash entries
drop Drop the most recent stash
apply Apply a stash entry without removing it
show Show the contents of a stash entry
clear Remove all stash entries
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema stash push
Save the current staged schema
Usage: schema stash push [OPTIONS]
Options:
-m, --message <MESSAGE> Optional stash message
-v, --verbose Enable verbose output
--author <AUTHOR> Author name [default: anonymous]
-h, --help Print help
schema stash pop
Restore the most recent stash
Usage: schema stash pop [OPTIONS]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema stash list
List all stash entries
Usage: schema stash list [OPTIONS]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema stash drop
Drop the most recent stash
Usage: schema stash drop [OPTIONS]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema stash apply
Apply a stash entry without removing it
Usage: schema stash apply [OPTIONS] [INDEX]
Arguments:
[INDEX] Stash index to apply [default: 0]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema stash show
Show the contents of a stash entry
Usage: schema stash show [OPTIONS] [INDEX]
Arguments:
[INDEX] Stash index to inspect [default: 0]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema stash clear
Remove all stash entries
Usage: schema stash clear [OPTIONS]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema reflog
Show ref mutation history
Usage: schema reflog [OPTIONS] [REF_NAME]
Arguments:
[REF_NAME] Ref name (defaults to HEAD) [default: HEAD]
Options:
-n, --limit <LIMIT> Maximum entries to show
-v, --verbose Enable verbose output
--all Show reflogs for all refs
-h, --help Print help
schema bisect
Binary search for the commit that introduced a breaking change
Usage: schema bisect [OPTIONS] <GOOD> <BAD>
Arguments:
<GOOD> Known good commit
<BAD> Known bad commit
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema blame
Show which commit introduced a schema element
Usage: schema blame [OPTIONS] --element-type <ELEMENT_TYPE> <ELEMENT_ID>
Arguments:
<ELEMENT_ID> Element identifier (vertex ID, edge `"src->tgt"`, or `"vertex_id:sort"`)
Options:
--element-type <ELEMENT_TYPE> Element type: vertex, edge, or constraint
-v, --verbose Enable verbose output
--reverse Walk history from the first commit forward
-h, --help Print help
schema lift
Apply a migration to a record, transforming it from source to target schema
Usage: schema lift [OPTIONS] --migration <MIGRATION> --src-schema <SRC_SCHEMA> --tgt-schema <TGT_SCHEMA> <RECORD>
Arguments:
<RECORD> Path to the record JSON file
Options:
--migration <MIGRATION> Path to the migration mapping JSON file
-v, --verbose Enable verbose output
--src-schema <SRC_SCHEMA> Path to the source schema JSON file
--tgt-schema <TGT_SCHEMA> Path to the target schema JSON file
--direction <DIRECTION> Migration direction: restrict (default, filtered source-to-target), sigma (source-to-target `Sigma_F`), or pi (source-to-target; W-type `pi` only relabels vertex-injective mappings) [default: restrict]
--instance-type <INSTANCE_TYPE> Instance type: wtype (default) or functor [default: wtype]
-h, --help Print help
schema integrate
Integrate two schemas by computing their pushout
Usage: schema integrate [OPTIONS] <LEFT> <RIGHT>
Arguments:
<LEFT> Path to the left schema JSON file
<RIGHT> Path to the right schema JSON file
Options:
--auto-overlap Automatically discover the overlap between schemas
-v, --verbose Enable verbose output
--json Output the integrated schema as JSON
-h, --help Print help
schema auto-migrate
Automatically discover a migration between two schemas
Usage: schema auto-migrate [OPTIONS] <OLD> <NEW>
Arguments:
<OLD> Path to the old/source schema JSON file
<NEW> Path to the new/target schema JSON file
Options:
--monic Require injective (one-to-one) vertex mapping
-v, --verbose Enable verbose output
--total Require a total morphism; fail on a partial answer
--span Report the span even when it covers nothing at all
--json Output the span's right leg, a migration out of the apex, as JSON
-h, --help Print help
schema gc
Garbage collect unreachable objects
Usage: schema gc [OPTIONS]
Options:
--dry-run Show what would be deleted without actually deleting
-v, --verbose Enable verbose output
-h, --help Print help
schema expr
Evaluate, type-check, or interactively explore GAT expressions
Usage: schema expr [OPTIONS] <COMMAND>
Commands:
gat-eval Evaluate a JSON-encoded GAT term from a file
gat-check Type-check a JSON-encoded GAT term from a file
repl Interactive expression REPL
parse Parse a Haskell-style expression and print its AST
eval Parse and evaluate a Haskell-style expression, printing the result
fmt Parse an expression and pretty-print it back in canonical form
check Parse an expression and report any syntax errors
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema expr gat-eval
Evaluate a JSON-encoded GAT term from a file
Usage: schema expr gat-eval [OPTIONS] <FILE>
Arguments:
<FILE> Path to the JSON file containing a GAT term
Options:
--env <ENV> Path to a JSON file with variable bindings
-v, --verbose Enable verbose output
-h, --help Print help
schema expr gat-check
Type-check a JSON-encoded GAT term from a file
Usage: schema expr gat-check [OPTIONS] <FILE>
Arguments:
<FILE> Path to the JSON file containing term, theory, and context
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema expr repl
Interactive expression REPL
Usage: schema expr repl [OPTIONS]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema expr parse
Parse a Haskell-style expression and print its AST
Usage: schema expr parse [OPTIONS] <SOURCE>
Arguments:
<SOURCE> Expression source text
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema expr eval
Parse and evaluate a Haskell-style expression, printing the result
Usage: schema expr eval [OPTIONS] <SOURCE>
Arguments:
<SOURCE> Expression source text
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema expr fmt
Parse an expression and pretty-print it back in canonical form
Usage: schema expr fmt [OPTIONS] <SOURCE>
Arguments:
<SOURCE> Expression source text
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema expr check
Parse an expression and report any syntax errors
Usage: schema expr check [OPTIONS] <SOURCE>
Arguments:
<SOURCE> Expression source text
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema enrich
Add, list, or remove schema enrichments (defaults, coercions, mergers, policies)
Usage: schema enrich [OPTIONS] <COMMAND>
Commands:
add-default Add a default value expression to a vertex
add-coercion Add a coercion expression between two vertex kinds
add-merger Add a merger expression to a vertex
add-policy Add a conflict policy to a vertex
list List all enrichments on the HEAD schema
remove Remove an enrichment by name
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema enrich add-default
Add a default value expression to a vertex
Usage: schema enrich add-default [OPTIONS] --expr <EXPR> <VERTEX>
Arguments:
<VERTEX> Vertex name
Options:
--expr <EXPR> Default value as JSON
-v, --verbose Enable verbose output
-h, --help Print help
schema enrich add-coercion
Add a coercion expression between two vertex kinds
Usage: schema enrich add-coercion [OPTIONS] --expr <EXPR> <FROM> <TO>
Arguments:
<FROM> Source vertex kind
<TO> Target vertex kind
Options:
--expr <EXPR> Coercion expression as JSON
-v, --verbose Enable verbose output
-h, --help Print help
schema enrich add-merger
Add a merger expression to a vertex
Usage: schema enrich add-merger [OPTIONS] --expr <EXPR> <VERTEX>
Arguments:
<VERTEX> Vertex name
Options:
--expr <EXPR> Merger specification as JSON
-v, --verbose Enable verbose output
-h, --help Print help
schema enrich add-policy
Add a conflict policy to a vertex
Usage: schema enrich add-policy [OPTIONS] --strategy <STRATEGY> <VERTEX>
Arguments:
<VERTEX> Vertex name
Options:
--strategy <STRATEGY> Conflict resolution strategy name
-v, --verbose Enable verbose output
-h, --help Print help
schema enrich list
List all enrichments on the HEAD schema
Usage: schema enrich list [OPTIONS]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema enrich remove
Remove an enrichment by name
Usage: schema enrich remove [OPTIONS] <NAME>
Arguments:
<NAME> Enrichment name or vertex name to remove enrichments from
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema remote
Add, list, or remove remote repositories
Usage: schema remote [OPTIONS] <COMMAND>
Commands:
add Register a new remote
remove Remove a remote
list List configured remotes
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema remote add
Register a new remote
Usage: schema remote add [OPTIONS] <NAME> <URL>
Arguments:
<NAME> Remote name
<URL> Remote URL
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema remote remove
Remove a remote
Usage: schema remote remove [OPTIONS] <NAME>
Arguments:
<NAME> Remote name to remove
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema remote list
List configured remotes
Usage: schema remote list [OPTIONS]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema push
Push schemas to a remote repository
Usage: schema push [OPTIONS] [REMOTE] [BRANCH]
Arguments:
[REMOTE] Remote name
[BRANCH] Branch to push
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema pull
Pull schemas from a remote repository
Usage: schema pull [OPTIONS] [REMOTE] [BRANCH]
Arguments:
[REMOTE] Remote name
[BRANCH] Branch to pull
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema fetch
Fetch schemas from a remote repository
Usage: schema fetch [OPTIONS] [REMOTE]
Arguments:
[REMOTE] Remote name
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema clone
Clone a remote repository
Usage: schema clone [OPTIONS] <URL> [PATH]
Arguments:
<URL> Repository URL
[PATH] Local path
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema data
Data operations: migrate, convert, sync, and status
Usage: schema data [OPTIONS] <COMMAND>
Commands:
migrate Migrate data to match the current schema version
convert Convert data between schemas
sync Sync data to match a target schema version via VCS
status Report data staleness relative to the current schema version
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema data migrate
Migrate data to match the current schema version
Usage: schema data migrate [OPTIONS] <DATA>
Arguments:
<DATA> Data directory containing JSON files
Options:
--protocol <PROTOCOL> Protocol name (inferred from HEAD commit if omitted)
-v, --verbose Enable verbose output
--range <RANGE> Migrate between specific commits (default: parent..HEAD)
--dry-run Preview without modifying files
-o, --output <OUTPUT> Output directory (default: overwrite in place)
--backward Migrate backward (requires stored complement)
--coverage Apply migration and print coverage statistics
-h, --help Print help
schema data convert
Convert data between schemas
Usage: schema data convert [OPTIONS] --protocol <PROTOCOL> <DATA>
Arguments:
<DATA> Data file or directory of JSON files
Options:
--from <FROM> Source schema
-v, --verbose Enable verbose output
--to <TO> Target schema
--protocol <PROTOCOL> Protocol name
--chain <CHAIN> Pre-built protolens chain JSON to instantiate with --from and --to
-o, --output <OUTPUT> Output file or directory
--direction <DIRECTION> Direction: "forward" or "backward" [default: forward]
--defaults <DEFAULTS> Default values as key=value pairs
-h, --help Print help
schema data sync
Sync data to match a target schema version via VCS
Usage: schema data sync [OPTIONS] <DATA_DIR>
Arguments:
<DATA_DIR> Data directory
Options:
--edits Store an edit log object in the VCS
-v, --verbose Enable verbose output
--target <TARGET> Target ref (default: HEAD)
-h, --help Print help
schema data status
Report data staleness relative to the current schema version
Usage: schema data status [OPTIONS] <DATA_DIR>
Arguments:
<DATA_DIR> Data directory
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema theory
Theory DSL operations: define theories, morphisms, and protocols from data files
Usage: schema theory [OPTIONS] <COMMAND>
Commands:
validate Validate a theory document (load + typecheck)
compile Compile a theory document and print results
compile-dir Compile all theory documents in a directory
check-morphism Validate a morphism document
recompose Replay a composition and print the resulting theory
check-coercion-laws Run sample-based coercion law checks over every directed equation in a theory document. Exits non-zero when any declared coercion class is falsified by a sample
repl Interactive theory REPL with syntax highlighting and history
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema theory validate
Validate a theory document (load + typecheck)
Usage: schema theory validate [OPTIONS] <FILE>
Arguments:
<FILE> Path to the theory document file (.ncl, .json, .yaml)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema theory compile
Compile a theory document and print results
Usage: schema theory compile [OPTIONS] <FILE>
Arguments:
<FILE> Path to the theory document file
Options:
--json Output as JSON
-v, --verbose Enable verbose output
-h, --help Print help
schema theory compile-dir
Compile all theory documents in a directory
Usage: schema theory compile-dir [OPTIONS] <DIR>
Arguments:
<DIR> Path to the directory
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema theory check-morphism
Validate a morphism document
Usage: schema theory check-morphism [OPTIONS] <FILE>
Arguments:
<FILE> Path to the morphism document file
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema theory recompose
Replay a composition and print the resulting theory
Usage: schema theory recompose [OPTIONS] <FILE>
Arguments:
<FILE> Path to the composition document file
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema theory check-coercion-laws
Run sample-based coercion law checks over every directed equation in a theory document. Exits non-zero when any declared coercion class is falsified by a sample
Usage: schema theory check-coercion-laws [OPTIONS] <FILE>
Arguments:
<FILE> Path to the theory document file
Options:
--json Output the full report as JSON
-v, --verbose Enable verbose output
--var-name <VAR_NAME> Name under which each sample is bound in the evaluation environment. Defaults to `"x"`; override when a theory's equations bind a different free variable so the checker does not surface "unbound variable" errors on every sample [default: x]
-h, --help Print help
schema theory repl
Interactive theory REPL with syntax highlighting and history
Usage: schema theory repl [OPTIONS]
Options:
--load <PATH> Theory documents to load on startup. Same shape accepted by `:load` inside the REPL
-v, --verbose Enable verbose output
-h, --help Print help
schema lens
Bidirectional lens operations
Usage: schema lens [OPTIONS] <COMMAND>
Commands:
generate Generate a lens between two schemas
compile Compile a lens DSL document (.ncl/.json/.yaml) to a protolens chain
apply Apply a saved lens chain to data
compose Compose two protolens chains or schemas
verify Verify lens laws on test data
inspect Inspect a saved protolens chain
check Check applicability of a chain against schemas in a directory
lift Lift a chain along a theory morphism
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema lens generate
Generate a lens between two schemas
Usage: schema lens generate [OPTIONS] --protocol <PROTOCOL> <OLD> <NEW>
Arguments:
<OLD>
Path to the old/source schema
<NEW>
Path to the new/target schema
Options:
--protocol <PROTOCOL>
Protocol name
-v, --verbose
Enable verbose output
--json
Output as JSON
--chain
Output a reusable protolens chain (JSON to stdout)
--try-overlap
Try overlap-based alignment when direct morphism fails
--save <SAVE>
Save the generated protolens chain to a file
--defaults <DEFAULTS>
Default values as key=value pairs
--fuse
Fuse multi-step chain into single protolens
--requirements
Show complement requirements (defaults/data needed)
--hints <HINTS>
Path to a JSON hints file for guided auto-lens generation
--stringency <TIER>
Stringency tier governing which alignment strategies run.
Accepted case-insensitively for parity with the Python and WASM bindings, both of which trim and lowercase their input.
strict: `Exact`, `ExactSuffix`, and `EdgeLabel`; total morphism only. balanced: adds `Alias`, `TokenSimilarity`, and `DescriptionSimilarity`; total morphism only (default). lenient: adds `WrapUnwrap`, `TypeSignature`, `WlRefinement`, and `Neighborhood`; spans allowed. exploratory: adds `Structural` and `Coerce` proposals; spans allowed.
Possible values:
- strict: `Exact`, `ExactSuffix`, and `EdgeLabel`; total morphism only
- balanced: Adds `Alias`, `TokenSimilarity`, and `DescriptionSimilarity` (default)
- lenient: Adds `WrapUnwrap`, `TypeSignature`, `WlRefinement`, and `Neighborhood`; spans allowed
- exploratory: Adds `Structural` and `Coerce` proposals; spans allowed
--top-n <N>
Emit up to N ranked candidate lenses instead of the single best one. Output format switches to a JSON array when combined with `--json` or `--chain`
[default: 1]
--explain
Print per-step explanations (and confidences) for each emitted candidate
-h, --help
Print help (see a summary with '-h')
schema lens compile
Compile a lens DSL document (.ncl/.json/.yaml) to a protolens chain
Usage: schema lens compile [OPTIONS] <DOC>
Arguments:
<DOC> Path to the lens DSL document (`.ncl`, `.json`, `.yaml`, or `.yml`)
Options:
--body-vertex <BODY_VERTEX> Parent vertex under which field-level steps attach [default: record:body]
-v, --verbose Enable verbose output
--out <OUT> Write the chain JSON to this file instead of stdout
-h, --help Print help
schema lens apply
Apply a saved lens chain to data
Usage: schema lens apply [OPTIONS] --protocol <PROTOCOL> <CHAIN> <DATA>
Arguments:
<CHAIN> Path to the protolens chain JSON
<DATA> Path to the data file
Options:
--protocol <PROTOCOL> Protocol name
-v, --verbose Enable verbose output
--direction <DIRECTION> Direction: "forward" or "backward" [default: forward]
--complement <COMPLEMENT> Complement data for backward apply
--schema <SCHEMA> Schema for chain instantiation
-h, --help Print help
schema lens compose
Compose two protolens chains or schemas
Usage: schema lens compose [OPTIONS] --protocol <PROTOCOL> <CHAIN1> <CHAIN2>
Arguments:
<CHAIN1> First chain or schema file
<CHAIN2> Second chain or schema file
Options:
--protocol <PROTOCOL> Protocol name
-v, --verbose Enable verbose output
--json Output as JSON
--chain Output in chain format
-h, --help Print help
schema lens verify
Verify lens laws on test data
Usage: schema lens verify [OPTIONS] --protocol <PROTOCOL> <DATA> <SCHEMA>
Arguments:
<DATA> Path to test data file
<SCHEMA> Schema used to parse the test data
Options:
--protocol <PROTOCOL> Protocol name
-v, --verbose Enable verbose output
-h, --help Print help
schema lens inspect
Inspect a saved protolens chain
Usage: schema lens inspect [OPTIONS] --protocol <PROTOCOL> <CHAIN>
Arguments:
<CHAIN> Path to the protolens chain JSON
Options:
--protocol <PROTOCOL> Protocol name
-v, --verbose Enable verbose output
-h, --help Print help
schema lens check
Check applicability of a chain against schemas in a directory
Usage: schema lens check [OPTIONS] --protocol <PROTOCOL> <CHAIN> <SCHEMAS_DIR>
Arguments:
<CHAIN> Path to the protolens chain JSON
<SCHEMAS_DIR> Directory containing schema JSON files
Options:
--protocol <PROTOCOL> Protocol name
-v, --verbose Enable verbose output
--dry-run Report only, do not instantiate
-h, --help Print help
schema lens lift
Lift a chain along a theory morphism
Usage: schema lens lift [OPTIONS] <CHAIN> <MORPHISM>
Arguments:
<CHAIN> Path to the protolens chain JSON
<MORPHISM> Path to the theory morphism JSON
Options:
--json Output as JSON
-v, --verbose Enable verbose output
-h, --help Print help
schema parse
Parse source files into full-AST schemas via tree-sitter
Usage: schema parse [OPTIONS] <COMMAND>
Commands:
file Parse a single source file into a full-AST schema
project Parse all files in a directory into a unified project schema
emit Parse a file and emit it back to source (round-trip test)
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema parse file
Parse a single source file into a full-AST schema
Usage: schema parse file [OPTIONS] <PATH>
Arguments:
<PATH> Path to the source file
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema parse project
Parse all files in a directory into a unified project schema
Usage: schema parse project [OPTIONS] [PATH]
Arguments:
[PATH] Path to the project directory [default: .]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema parse emit
Parse a file and emit it back to source (round-trip test)
Usage: schema parse emit [OPTIONS] <PATH>
Arguments:
<PATH> Path to the source file
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema git
Import/export between git repositories and panproto-vcs
Usage: schema git [OPTIONS] <COMMAND>
Commands:
import Import a git repository's history into panproto-vcs
export Export panproto-vcs history to a git repository
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema git import
Import a git repository's history into panproto-vcs
Usage: schema git import [OPTIONS] <REPO> [REVSPEC]
Arguments:
<REPO> Path to the git repository
[REVSPEC] Git revspec (e.g. "HEAD", "main", "HEAD~10..HEAD") [default: HEAD]
Options:
-v, --verbose Enable verbose output
-h, --help Print help
schema git export
Export panproto-vcs history to a git repository
Usage: schema git export [OPTIONS] <DEST>
Arguments:
<DEST> Destination path for the git repository
Options:
--repo <REPO> Path to the panproto repository (default: current directory) [default: .]
-v, --verbose Enable verbose output
-h, --help Print help
Rust SDK reference
The Rust entry point is the panproto-core facade. Version 0.72 requires Rust 1.85 or later.
[dependencies]
panproto-core = "0.72"
The facade re-exports each component crate as a module. For instance, panproto_core::schema::Schema is the Schema type from panproto-schema. Depending on panproto-schema directly exposes the same type without the facade.
Modules
| Module | Re-exported crate | Principal surface |
|---|---|---|
check | panproto-check | Structural diffs, compatibility classification, and validation |
gat | panproto-gat | Theories, models, theory morphisms, and colimits |
schema | panproto-schema | Protocols, schemas, builders, morphisms, and pushouts |
inst | panproto-inst | Tree, relational, and graph instance representations |
mig | panproto-mig | Migration compilation, lifting, composition, and morphism search |
lens | panproto-lens | Lenses, protolenses, complements, and law checks |
protocols | panproto-protocols | Built-in protocol definitions |
io | panproto-io | Instance parsing and emission |
vcs | panproto-vcs | Schema and data version control |
expr, expr_parser | panproto-expr, panproto-expr-parser | Expression values, evaluation, parsing, and formatting |
lens_dsl, theory_dsl | panproto-lens-dsl, panproto-theory-dsl | Declarative lens and theory front ends |
The crate map lists lower-level workspace crates that are not re-exported from panproto-core.
Feature flags
panproto-core has no default features.
| Feature | Additional module or behavior | Implied features |
|---|---|---|
full-parse | Re-exports panproto-parse as parse | none |
project | Re-exports panproto-project as project | full-parse |
git | Re-exports panproto-git as git | project, hence full-parse |
tree-sitter | Enables the panproto-io/tree-sitter implementation | none |
full-parse adds the tree-sitter grammar build dependencies described in the crate manifest. It is separate from the tree-sitter feature on panproto-io.
Instance transport and direction
Let a compiled migration have schema direction (F:S\to T). The functorial data-migration names (\Sigma_F) and (\Delta_F) follow the usual source and target directions (Spivak 2012), but the plain lift API is a separate operation:
| Rust API | Instance direction | Implemented operation |
|---|---|---|
lift_wtype, lift_functor | (S\to T) | Runs the restrict pipeline. It retains the source fragment that survives the compiled mapping, remaps it into the target schema, and may prune data. |
lift_wtype_sigma, lift_functor_sigma | (S\to T) | Computes the left Kan extension (\Sigma_F). The functor form may then run the supplied chase dependencies. |
lift_wtype_pi | (S\to T) | Runs the pi-named W-type path. It accepts only vertex-injective maps and relabels the tree rather than constructing a general right Kan extension. Its max_product_nodes parameter is retained for signature compatibility and is unused. |
lift_functor_pi | (S\to T) | Computes the functor-instance right Kan extension (\Pi_F) by products over fibers and applies max_product_size. |
w_delta, f_delta | (T\to S) | Computes precomposition (\Delta_F). The W-type form is defined only for injective vertex and edge maps and for anchors in the image. The functor form also handles vertex-merging maps. |
Thus panproto_mig::lift_wtype is not (\Delta_F), despite its delegation to wtype_restrict. Instance::restrict has the same source-to-target surviving-fragment direction. Instance::extend is a total source-to-target extension path. The W-type (\Sigma_F) implementation delegates to that path, but the generic method does not establish an adjunction for every Instance variant.
Morphism search
The panproto_core::mig::hom_search module distinguishes partial overlap from total morphisms. A returned SchemaSpan has shape (S\leftarrow A\to T): (A) is the sub-schema of (S) induced by mapped source vertices, the left leg includes (A) into (S), and the right leg maps (A) into (T).
find_span(
src: &Schema,
tgt: &Schema,
protocol: &Protocol,
opts: &SearchOptions,
) -> Result<SchemaSpan, SpanError>
find_morphisms(
src: &Schema,
tgt: &Schema,
opts: &SearchOptions,
) -> Result<MorphismList, SpanError>
find_best_morphism(
src: &Schema,
tgt: &Schema,
opts: &SearchOptions,
) -> Result<Option<FoundMorphism>, SpanError>
find_span may return an empty apex when the schemas share no vertices. The protocol argument is used to validate the induced apex. Setting SearchOptions::epic is invalid for this partial search and returns SpanError::EpicIsNotASpanProperty.
find_morphisms returns total morphisms that attain the optimum, rather than every morphism or a sequence of lower-quality alternatives. MorphismList::truncated records whether enumeration of tied optima stopped at the engine cap. An empty morphisms vector means that no total morphism exists. A search that could not run returns Err.
SearchOptions::default() leaves monic, epic, and iso false, uses no hard pins, and sets max_results to zero. Here zero requests all optima the implementation will enumerate, subject to its safety cap. Use find_morphisms_budgeted or SpanSearch when the caller must supply a SearchBudget.
Ownership and errors
Rust values follow ordinary ownership and drop semantics. The Rust API does not expose the opaque-handle lifecycle used by foreign-language bindings. Fallible operations return the error type declared by their component crate. panproto-core does not replace those errors with a facade-wide error enum.
See also
- Install the Rust SDK
- Define a schema from Rust
- Find a span between two schemas
- Searching for a morphism
TypeScript SDK reference
The TypeScript package is @panproto/core. It requires Node.js 20 or later when run under Node and loads the engine through WebAssembly.
npm install @panproto/core
Initialization
import { Panproto } from '@panproto/core';
const panproto = await Panproto.init();
The initialization signature is:
static init(input?: string | URL | WasmGlueModule): Promise<Panproto>
With no argument, init loads the glue and WASM binary bundled with the package. A URL identifies the wasm-bindgen JavaScript glue module, rather than the .wasm file itself. Bundlers may instead pass a pre-imported WasmGlueModule.
Principal exports
The generated declaration file in the package is the signature authority. The table below is an index into that surface.
| Export | Contract |
|---|---|
Panproto | Engine initialization and convenience methods for protocols, parsing, diffs, migrations, lenses, instance I/O, VCS, and data sets |
Protocol | Protocol specification and schema(): SchemaBuilder |
SchemaBuilder | Immutable builder. Each mutation returns a new builder, and build() returns BuiltSchema. |
BuiltSchema | Engine-backed schema with structural metadata, normalization, and validation |
MigrationBuilder | Immutable vertex, edge, and resolver mapping builder |
CompiledMigration | lift, complement-carrying get and put, plus JSON convenience methods |
ProtolensChainHandle, LensHandle, SymmetricLensHandle | Chain construction, lens execution, composition, and law checks |
Instance, IoRegistry | Instance values and protocol-specific parse or emit operations |
FullDiffReport, CompatReport, ValidationResult | Diff, compatibility, and validation results |
TheoryHandle, TheoryBuilder | GAT construction, colimits, and morphism operations |
Repository, DataSetHandle | In-memory VCS and data-versioning resources |
parseExpr, evalExpr, formatExpr, ExprBuilder | Expression parsing, evaluation, formatting, and construction |
executeQuery, fiberAt, fiberDecomposition, polyHom | Instance-query and structural exports. executeQuery has the boundary mismatch described below. |
The source export list is bindings/typescript/src/index.ts. Package consumers should import from @panproto/core, since files under src are not package subpath exports.
Selected signatures
class SchemaBuilder {
vertex(id: string, kind: string, options?: VertexOptions): SchemaBuilder;
edge(src: string, tgt: string, kind: string, options?: EdgeOptions): SchemaBuilder;
constraint(vertexId: string, sort: string, value: string): SchemaBuilder;
build(): BuiltSchema;
}
class MigrationBuilder {
map(srcVertex: string, tgtVertex: string): MigrationBuilder;
mapEdge(srcEdge: Edge, tgtEdge: Edge): MigrationBuilder;
resolve(srcKind: string, tgtKind: string, resolvedEdge: Edge): MigrationBuilder;
compile(): CompiledMigration;
}
class CompiledMigration {
lift(record: unknown): LiftResult;
get(record: unknown): GetResult;
put(view: unknown, complement: Uint8Array): LiftResult;
}
LiftResult.data is unknown. GetResult contains view: unknown and complement: Uint8Array. The complement must be passed back unchanged unless an operation explicitly returns a replacement.
If the compiled mapping has schema direction (S\to T), lift accepts an (S)-record and returns the surviving fragment as a (T)-record. It calls Rust’s restrict-based lift_wtype. It is neither the left Kan extension (\Sigma_F) nor precomposition (\Delta_F). get has the same source-to-target direction and additionally captures the complement. put takes a (T)-view and that complement and reconstructs an (S)-record.
Resource ownership
Engine-backed wrappers implement Disposable. This includes Protocol, BuiltSchema, CompiledMigration, the three lens-handle classes, IoRegistry, TheoryHandle, Repository, and DataSetHandle. Dispose each owned wrapper after its last use, preferably with an explicit resource-management scope:
using schema = protocol.schema().vertex('root', 'object').build();
Disposal is idempotent. Accessing a disposed handle raises WasmError. A FinalizationRegistry frees a leaked handle as a fallback, but collection time is nondeterministic. Disposing Panproto releases its cached Protocol objects. It does not own every schema, migration, lens, registry, repository, or data-set wrapper created from it.
Instance, result objects, and SpanResponse are plain JavaScript data and do not implement Disposable.
Span search
span(
from: BuiltSchema,
to: BuiltSchema,
hints?: Readonly<Record<string, string>>,
): SpanResponse
The optional hints are fixed source-to-target vertex mappings. SpanResponse contains apex_vertices, apex_edges, vertex_map, quality, quality_bounds, apex_coverage, proven_optimal, is_total, and apex_digest. It contains no engine handle.
The WASM surface exposes span search but not the Rust total-morphism functions find_morphisms and find_best_morphism. Use span.is_total when a caller needs to know whether the returned apex covers the complete source.
Boundary limits
The SDK passes structured payloads through the WASM layer and stores live engine resources behind integer handles. The package does not expose the Rust panproto-parse full-AST registry, multi-file panproto-project builder, or panproto-git bridge. Schema-document and schema-source parsers available through Panproto.parseSchemaDocument and Panproto.parseSchemaSource are separate from that full-AST surface.
The current executeQuery wrapper does not match the current WASM entry point. TypeScript sends only a query and instance, while Rust requires query, instance, and schema payloads. The TypeScript wire fields also use projection, groupBy, and nodeId, while the Rust query types use project, group_by, and node_id. Treat executeQuery as unavailable until the binding and WASM signatures are aligned.
See also
- Install the TypeScript SDK
- Define a schema from TypeScript
- WASM boundary
- Find a span between two schemas
Python SDK reference
The Python package is panproto. It requires Python 3.13 or later and contains a native PyO3 extension.
python -m pip install panproto
Module surface
Public names are re-exported from panproto. There is no umbrella engine class. The package source lists those names in panproto.__all__, and the shipped _native.pyi file is the signature authority for the extension.
| Domain | Principal names |
|---|---|
| Protocols and schemas | get_builtin_protocol, list_builtin_protocols, define_protocol, Protocol, SchemaBuilder, Schema |
| Schema parsing | parse_atproto_lexicon, parse_schema_document, parse_schema_bundle, parse_schema_bundle_project, parse_schema_source |
| Migrations | MigrationBuilder, compile_migration, compose_migrations, invert_migration, CompiledMigration |
| Morphism search | find_span, find_morphisms, find_best_morphism, SchemaSpan, FoundMorphism |
| Checking | diff_schemas, diff_and_classify, check_existence, check_coverage |
| Lenses | Lens, ProtolensChain, auto_generate_lens, auto_generate_lens_candidates |
| Instances and I/O | Instance, IoRegistry |
| GATs | Theory, TheoryBuilder, TheoryMorphism, Model, colimit_theories |
| Expressions | Expr, parse_expr, pretty_print_expr |
| Version control | Repository, VcsRepository, BisectState |
| Full-AST parsing | AstParserRegistry, ParseEmitLens, parse_source_file, available_grammars |
| Projects and git | ProjectBuilder, ProjectSchema, parse_project, build_project, git_import |
Builder contracts
Python builders mutate in place. Their mutation methods return None, except where _native.pyi declares a fluent return type.
class SchemaBuilder:
def vertex(self, id: str, kind: str, nsid: str | None = ..., /) -> None: ...
def edge(
self,
src: str,
tgt: str,
kind: str,
name: str | None = ...,
) -> None: ...
def constraint(self, vertex_id: str, sort: str, value: str) -> None: ...
def build(self) -> Schema: ...
TheoryBuilder.sort, TheoryBuilder.op, and TheoryBuilder.eq return the builder and may be chained. Check _native.pyi before assuming that a builder follows either convention.
Migration direction
For a CompiledMigration whose schema mapping is (S\to T), lift(instance) accepts an (S)-instance and returns the surviving fragment as a (T)-instance. The method calls Rust’s restrict-based lift_wtype. It is neither the left Kan extension (\Sigma_F) nor precomposition (\Delta_F). get uses the same source-to-target operation and returns a complement with the view. put accepts the target view and complement and reconstructs a source instance.
Morphism search
find_span(
src: Schema,
tgt: Schema,
protocol: Protocol,
anchors: dict[str, str] | None = None,
monic: bool = False,
epic: bool = False,
iso: bool = False,
) -> SchemaSpan
find_morphisms(
src: Schema,
tgt: Schema,
anchors: dict[str, str] | None = None,
monic: bool = False,
epic: bool = False,
iso: bool = False,
max_results: int = 0,
) -> list[FoundMorphism]
find_span returns an empty apex when the schemas share no vertices. It requires a protocol because the induced apex is validated before it is returned. epic=True raises MigrationError for span search. Surjectivity is defined for the total-morphism functions.
find_morphisms returns total morphisms attaining the optimum. An empty list means that no total morphism exists, whereas a search failure raises MigrationError. The Python list does not carry the Rust MorphismList.truncated field, so this binding cannot report whether the engine stopped enumerating tied optima at its cap.
Grammar packs
AstParserRegistry() constructs a native registry and adds grammars advertised through installed panproto.grammars entry points. Discovery occurs when the factory is called. Importing a companion package is not required.
| Package | Group |
|---|---|
panproto-grammars-functional | Functional languages |
panproto-grammars-web | Web languages |
panproto-grammars-systems | Systems languages |
panproto-grammars-jvm | JVM languages |
panproto-grammars-scripting | Scripting languages |
panproto-grammars-data | Data and schema languages |
panproto-grammars-devops | Build and operations languages |
panproto-grammars-mobile | Mobile languages |
panproto-grammars-music | Music languages |
panproto-grammars-all | Aggregate pack |
panproto._native.AstParserRegistry bypasses companion discovery and constructs the registry supplied by the core extension alone.
Ownership, errors, and typing
PyO3 objects follow Python reference ownership. The public stub exposes no dispose, release, or close method for schema, migration, lens, theory, repository, parser, or project objects.
PanprotoError is the common exception base. Domain subclasses include SchemaValidationError, MigrationError, LensError, CheckError, ExistenceCheckError, ExprError, GatError, IoError, VcsError, ParseError, ProjectError, and GitBridgeError.
The wheel includes py.typed and _native.pyi. Repository tests compare the stub’s public declarations with the loaded extension and check callable signatures, so generated documentation should follow the stub rather than infer signatures from Rust names.
See also
- Install the Python SDK
- Define a schema from Python
- Find a span between two schemas
- Python binding source
Haskell SDK reference
The Haskell binding is the panproto package under bindings/haskell/. Its Cabal manifest uses GHC2024 and declares GHC 9.12.2 as the tested compiler.
The package links libpanproto_c when the rust flag is active. The repository bootstrap scripts and required library paths are covered in Install the Haskell SDK.
Imports and backends
import Panproto
Panproto re-exports the structural value modules, capability classes, domain modules, and effect adapter. With the default rust flag, it also brings the Rust-backed capability instances into scope.
Operations dispatch through associated representation families such as ProtocolRep back, SchemaRep back, InstanceRep back, and LensRep back. The backend tag is either Rust or Native.
| Backend | Implemented capability instances | Representation |
|---|---|---|
Rust | Protocol, schema, validation, instances, I/O, migrations, checks, morphism search, lenses, GATs, expressions, VCS, data sets, graphs, and the enabled parse, project, or git tiers | Opaque handles and CBOR exchange values over libpanproto_c |
Native | ProtocolBackend and SchemaBackend only | Pure canonical protocol and schema values |
Native has no SchemaValidate, migration, lens, instance, or search instance in the current source. The structural Schema, Migration, ProtolensChain, Theory, and Instance data types remain available as ordinary Haskell values, but a pure value type does not imply a runnable Native capability instance.
The Haskell Instance exchange type mirrors the W-type instance used by the C boundary. It is not the Rust panproto_inst::Instance enum over W-type, relational, and graph representations.
For a compiled schema mapping (S\to T), MigrationBackend.liftRecord accepts an (S)-instance and returns the surviving fragment as a (T)-instance. It wraps pp_mig_lift_record, which calls the restrict-based Rust function mig::lift_wtype. This operation is neither the left Kan extension (\Sigma_F) nor precomposition (\Delta_F). The Haskell capability class does not expose the separate Rust (\Sigma_F) and (\Pi_F) entry points.
Capability lookup
| Module | Main class or values |
|---|---|
Panproto.Class | ProtocolBackend, SchemaBackend, SchemaValidate, Rust, Native |
Panproto.Schema | Structured schema values and SchemaBuilderM |
Panproto.Instance | W-type instance, complement, codecs, and InstanceBackend |
Panproto.Migration | Migration values, MigrationBuilderM, and MigrationBackend |
Panproto.Lens | Protolens values and LensBackend |
Panproto.Hom | Search options, result values, and HomBackend |
Panproto.Gat, Panproto.Expr | Theory and expression values with their backend classes |
Panproto.Check, Panproto.Io | Diff, validation, parse, and emit capabilities |
Panproto.Vcs, Panproto.Data, Panproto.Graph | Repository, data-set, and graph capabilities |
Panproto.Effect | MonadPanproto and the optional effectful adapter |
Morphism search
HomBackend declares the following methods:
findMorphisms
:: SchemaRep back
-> SchemaRep back
-> SearchOptions
-> IO [FoundMorphism]
findBestMorphism
:: SchemaRep back
-> SchemaRep back
-> SearchOptions
-> IO (Maybe FoundMorphism)
findSpan
:: SchemaRep back
-> SchemaRep back
-> ProtocolRep back
-> SearchOptions
-> DomainConstraints
-> IO FoundSpan
defaultFindOpts sets monic, epic, and iso to False, maxResults to zero, and hardPins to the empty map. defaultDomainConstraints applies no domain restrictions or weight override.
findMorphisms returns total morphisms attaining the optimum, and findBestMorphism returns Nothing when no total morphism exists. The Haskell list omits the engine’s truncation field for tied optima. findSpan may return an empty apex. It rejects epic = True because a span’s right leg is partial.
Handle ownership
Rust-backed representations that own slab entries have matching release* methods on their capability classes. Examples include releaseProtocol, releaseSchema, releaseChain, releaseLens, releaseTheory, releaseModel, releaseRegistry, releaseRepo, and releaseDataSet. Release is idempotent at the C slab boundary.
Use a bracket helper where the binding provides one. Public helpers include withRustProtocol, withRustSchema, withCompiled, withRustTheory, withRepo, and withDataSet. Some exchange representations, including the Rust InstanceRep, carry no slab entry and have a no-op release method. The class method remains the ownership authority.
Errors
FFI failures are exceptions in IO. SomePanprotoError is the root wrapper, PanprotoError is the generic fallback, and the domain exception types include MigrationError, LensError, SchemaValidationError, CheckError, ExistenceCheckError, ExprError, GatError, IoError, VcsError, ParseError, ProjectError, and GitBridgeError. Each error carries a PpStatus and may carry a decoded ErrorEnvelope.
Cabal flags
| Flag | Default | Effect |
|---|---|---|
rust | on | Builds the FFI backend and links libpanproto_c |
native-only | off | Excludes Rust backend modules even if rust is enabled |
parse | off | Exposes Panproto.Parse. The Rust instance needs a library built with full-parse. |
project | off | Exposes Panproto.Project. The Rust instance needs a library built with project. |
git | off | Exposes Panproto.Git. The Rust instance needs a library built with git. |
optics-adaptors | off | Adds optics-core adaptors in Panproto.Lens.Optics |
lens-adaptors | off | Adds lens adaptors when optics-core is not selected |
effectful | off | Adds the Panproto effect, Eff instance, and runPanproto |
The parse, project, and git flags must match the features compiled into the linked C library. Enabling a Haskell module does not add missing symbols to libpanproto_c.
See also
- Install the Haskell SDK
- Define a schema from Haskell
- Find a span between two schemas
- Haskell package manifest
Swift SDK reference
The Swift package lives in bindings/swift/. It uses Swift 6 language mode and supports macOS 14 and iOS 17. The engine-backed products call panproto-c. PanprotoStructural has no FFI dependency.
See Install the Swift SDK for package and library setup.
Products
| Product | Public surface | Engine required |
|---|---|---|
PanprotoStructural | Codable value types, wire representations, and the CBOR codec | no |
Panproto | Protocols, schemas, instances, migrations, lenses, checks, theories, expressions, graph operations, I/O, and data sets | yes |
PanprotoVcs | In-memory schema version control | yes |
PanprotoParse | Full-AST parsing, behind PANPROTO_PARSE | yes |
PanprotoProject | Multi-file project assembly, behind PANPROTO_PROJECT | yes |
PanprotoGit | Git import, behind PANPROTO_GIT | yes |
The three gated products remain present when their traits are disabled, but their gated declarations are not compiled. Each trait also requires a libpanproto_c built with the corresponding Rust feature.
Values and handles
PanprotoStructural contains Swift values such as Schema, Instance, Migration, Complement, SchemaSpan, and FoundMorphism. These values use ordinary Swift ownership and do not require release calls.
Engine resources are subclasses of PanprotoHandle:
| Handle | Resource |
|---|---|
ProtocolHandle | A protocol specification loaded by the engine |
SchemaHandle | A schema stored in the engine |
MigrationHandle | A compiled migration payload without retained source and target schema handles. Operations reconstruct minimal schemas when needed. |
CompiledMigrationHandle | A migration compiled against source and target schemas |
ProtolensChainHandle, SymmetricLensHandle | Engine-backed lens resources |
IoRegistryHandle | Protocol-specific instance parsers and emitters |
TheoryHandle, ModelHandle | Generalized algebraic theories and models |
DataSetHandle | Versioned data associated with schema commits |
RepositoryHandle | A PanprotoVcs repository |
AstRegistryHandle | A PanprotoParse parser registry |
ProjectBuilderHandle, ProjectSchemaHandle | PanprotoProject resources |
SchemaBuilder and MigrationBuilder are Swift structs. Their mutating methods update the builder value. MigrationBuilder.build() returns a Migration, which can then be compiled against two schema handles.
Engine isolation
Every operation that consumes a handle is isolated to the PanprotoEngine global actor. The C ABI protects its resource slab and last-error slot with process-global mutexes, so a handle is valid from any thread. The error slot holds only one pending envelope, however, and an interleaved failure can overwrite it before Swift drains it. The actor’s pinned serial executor keeps each call and error drain together and makes the engine’s serial contention visible to Swift concurrency. Calls from outside the actor consequently use await:
let protocolHandle = try await ProtocolHandle.builtin("atproto")
let schema = try await SchemaHandle.parseAtprotoLexicon(lexicon)
PanprotoEngine.run can group several synchronous engine calls in one actor-isolated closure:
let result = try await PanprotoEngine.run {
try schema.violations(against: protocolHandle)
}
Engine calls perform synchronous work once scheduled. Task cancellation does not interrupt a call already executing in the C ABI.
Migration and lens operations
CompiledMigrationHandle supplies both migration and asymmetric-lens operations:
func lift(_ instance: Instance) throws(PanprotoError) -> Instance
func get(_ source: Instance) throws(PanprotoError) -> LensProjection
func put(view: Instance, complement: Complement) throws(PanprotoError) -> Instance
LensProjection carries the view and the complement captured during get. Pass that complement to put. Complements are tied to the source schema and may conflict when composed.
For a compiled schema mapping (S\to T), lift accepts an (S)-instance and returns the surviving fragment as a (T)-instance. It wraps the restrict-based Rust mig::lift_wtype. It is neither the left Kan extension (\Sigma_F) nor precomposition (\Delta_F). get has the same source-to-target direction and captures the complement. put accepts the target view and complement and reconstructs a source instance.
Law-checking methods return LawCheckResult rather than throwing when a law is false. They can still throw when the operation itself cannot be evaluated. checkLaws checks GetPut and a deterministic two-view PutGet smoke test at the supplied source instance. It is not a proof for all instances or edits.
Morphism and span search
Search methods are defined on SchemaHandle:
func findMorphisms(
to target: SchemaHandle,
options: MorphismSearchOptions = MorphismSearchOptions()
) throws(PanprotoError) -> [FoundMorphism]
func findBestMorphism(
to target: SchemaHandle,
options: MorphismSearchOptions = MorphismSearchOptions()
) throws(PanprotoError) -> FoundMorphism?
func findSpan(
to target: SchemaHandle,
in protocolHandle: ProtocolHandle,
options: MorphismSearchOptions = MorphismSearchOptions(),
constraints: MorphismDomainConstraints = MorphismDomainConstraints()
) throws(PanprotoError) -> SchemaSpan
findMorphisms returns total morphisms that attain the optimum. An empty array means that no total morphism exists. The Swift array does not expose the Rust MorphismList.truncated field, so callers cannot distinguish complete enumeration of tied optima from an answer stopped by the engine cap.
findSpan admits a partial match and may return an empty apex. The protocol handle is required because the induced apex is validated as a schema. The span result is a Swift value. Call SchemaSpan.overlap() when the identification pairs for a pushout are needed.
Ownership
Each PanprotoHandle owns one engine slab entry. release() returns that entry early and is idempotent. If a live handle reaches deinitialization, its release is queued onto the engine thread. Do not call engine operations on a handle after releasing it. The engine reports the slab index as invalid or may have reused it for another resource.
Errors
Public engine operations use typed throws with PanprotoError. Its cases identify the operation domain, including parse, migration, lens, schemaValidation, check, existenceCheck, expr, gat, io, vcs, gitBridge, and project. Each case carries a Detail containing the raw status, operation name, optional error envelope, and any recognized structured fault.
Package traits
| SwiftPM trait | Product declarations enabled | Required Rust feature |
|---|---|---|
PANPROTO_PARSE | PanprotoParse | full-parse |
PANPROTO_PROJECT | PanprotoProject | project |
PANPROTO_GIT | PanprotoGit | git |
The linked C library and selected package traits must agree. Enabling a trait while linking a library without the corresponding symbols fails at link time.
Boundary limits
PanprotoStructural can decode, encode, compare, and transform its value types without starting the engine. Validation, migration compilation, lens execution, search, law checks, and other semantic operations require an engine-backed product. The Swift API exposes only operations exported by panproto-c. Rust APIs with no C entry point are not available through this binding.
See also
Protocol catalog
A protocol names a schema language together with its schema and instance theories, structural rules, parser, and emitter. The semantic protocols in panproto-protocols compose reusable generalized algebraic theories (GATs) (Cartmell 1986). Source-code languages use the separate tree-sitter registry described below.
Semantic protocols
The generic dispatch functions accept 54 semantic protocols. Names in this table are the canonical hyphenated strings accepted by parse_schema_document or parse_schema_source. Underscore spellings are normalized before dispatch.
| Category module | Protocol names |
|---|---|
annotation | amr, bead, brat, concrete, conllu, decomp, elan, folia, fovea, iso-space, laf-graf, naf, nif, paula, tei, timeml, ucca, uima-cas, web-annotation |
api | asyncapi, graphql, jsonapi, openapi, raml |
config | ansible, cloudformation, k8s-crd |
data_schema | bson, cddl, json-schema |
data_science | arrow, dataframe, parquet |
database | cassandra, dynamodb, mongodb, neo4j, redis, sql |
domain | edi-x12, fhir, geojson, rss-atom, swift-mt, vcard-ical |
serialization | asn1, avro, bond, flatbuffers, msgpack-schema, protobuf |
web_document | atproto, docx, odf |
The raw_file module is the text-or-binary fallback used during project assembly. It is a protocol implementation, but is not listed by either generic schema-dispatch function.
Parser dispatch
| Entry point | Accepted input | Registered protocols |
|---|---|---|
parse_schema_document | serde_json::Value | 43 protocols |
parse_schema_source | text IDL, DDL, or annotation source | 11 protocols |
parse_schema_bundle | JSON document bundle | atproto only |
parse_schema_bundle_project | path and JSON pairs with per-file provenance | atproto only |
The exported document_parser_protocols, source_parser_protocols, bundle_parser_protocols, and bundle_project_protocols arrays are the lookup sources for these sets.
Protocol availability also depends on the surface. The current schema CLI resolves protocol theories only for atproto. The C and WebAssembly theory-registry helpers recognize atproto, json-schema, graphql, sql, and protobuf, while their protocol lookup tables expose the 54 names above.
Registration behavior
Each semantic protocol module exposes protocol() and register_theories(). Most registrars call shared theory-group constructors. Those constructors panic if a named pushout fails, rewrite analysis cannot complete, two rewrite paths fail to rejoin (a non-joining critical pair), or the lexicographic-path-order termination check fails. Registration thus treats these outcomes as internal theory-definition defects, not recoverable input errors.
The ATProto registrar is different. It inserts its five component theories first, then inserts each composed schema or instance theory only when the corresponding pushout_by_name call succeeds. Its register_theories() function has no Result, so a failed composition is omitted rather than returned to the caller. What panproto verifies gives the boundary between these construction-time gates and checks on user schemas.
The source tree is the catalog authority:
| Contract | Source |
|---|---|
| Protocol modules and parser dispatch | crates/panproto-protocols/src/lib.rs |
| Reusable theories and pushout helpers | crates/panproto-protocols/src/theories.rs |
| CLI theory lookup | crates/panproto-cli/src/cmd/helpers.rs |
Source-code grammars
panproto-grammars defines 261 individual lang-* features under group-all. A selected feature contributes a tree-sitter Language and its vendored AST metadata to ParserRegistry. The default group-core is a subset. Callers do not receive all grammars unless their selected feature set includes them.
emit_verification_status reports test coverage for the registered parser, not a proof about all inputs:
| Status | Meaning |
|---|---|
Verified | The protocol is in VERIFIED_EMIT_PROTOCOLS: 248 grammars pass the full vendored corpus oracle and seven more are covered by dedicated backend regressions. |
Generic | A parser is registered and uses the generic emitter, but the protocol is outside that verified set. |
Unsupported | No parser is registered under the supplied name. |
The verified set currently contains 255 names. Source-code emission identifies the seven backend cases and the six grammars outside the set.
Defining a protocol
Build a custom protocol covers theory declaration, registration, parsing, and emission. Adding a Rust module to panproto-protocols does not automatically extend the CLI, C, or WebAssembly lookup matches. Each exposed surface needs its own dispatch arm.
See also
Expression-language reference
panproto-expr evaluates a call-by-value expression AST with no I/O operations. Evaluation is bounded and fallible. The default limits are 100,000 reduction steps, recursion depth 256, and 10,000 elements in list literals and selected list-producing paths. Reaching a limit returns an ExprError. It does not establish that every expression terminates before the limit.
Builtin names are first-class values. A lexical environment binding shadows a builtin of the same name, and an unapplied builtin may be partially applied until it receives its declared arity. eval_with_resolver routes context-dependent builtins through the supplied resolver wherever they occur, including inside lambdas, bindings, and comprehensions.
The language is used for field transforms and instance queries. Expression language: denotational semantics gives the model behind the evaluator.
Surface grammar
panproto-expr-parser accepts a Haskell-style syntax and lowers it to Expr.
| Form | Syntax |
|---|---|
| Literals | 42, 3.5, "text", True, False, Nothing |
| Lambda and application | \x -> body, f x |
| Binding | let x = value in body. Layout or braces permit several bindings. |
| Conditional and match | if p then a else b and case value of { pattern -> body } |
| Records | { name = value, count }. The second field uses record punning. |
| Lists | [a, b], inclusive range [a..b], comprehension `[f x |
| Access | record.field, list[index], node->edge_name |
| Sequencing | list-oriented do notation and postfix where bindings |
Open-ended ranges such as [a..] are parse errors. The parser lowers map f xs, filter p xs, and flat_map f xs to an AST with the list first and function last. It lowers fold f z xs to [xs, z, f]. This AST argument order is the serialized compatibility contract.
Operators
From lower to higher precedence, the infix operators are pipe &, ||, &&, comparisons (==, /=, <, <=, >, >=), string concatenation ++, addition and subtraction, then multiplication, division, and remainder (*, /, %, div, mod). Unary - and not bind more tightly. And and Or evaluate both AST arguments before applying the builtin. They do not short-circuit.
Values and type tags
Runtime Literal values include booleans, 64-bit integers and floats, UTF-8 strings, bytes, null, records, lists, and closures. Lists can contain values of different kinds. The lightweight ExprType inference API has fewer tags:
ExprType | Meaning |
|---|---|
Int, Float, Str, Bool | Scalar tags. |
List | List with no element-type parameter. |
Record | Ordered string-keyed fields. |
Any | Unknown or polymorphic. Inference also uses it for null, bytes, and closures. |
Type inference is best effort. Application, field access, and indexing generally infer as Any, while evaluation still performs runtime type and arity checks.
Builtins
BuiltinOp currently has 60 variants.
| Family | Operations | Contract notes |
|---|---|---|
| Arithmetic and rounding | Add, Sub, Mul, Div, Mod, Neg, Abs, Floor, Ceil, Round | Integer arithmetic is checked. Division and remainder reject zero divisors. Float-to-integer rounding rejects NaN, infinity, and out-of-range results. |
| Comparison | Eq, Neq, Lt, Lte, Gt, Gte | Ordering accepts integer and float pairs, including mixed numeric pairs, or two strings. |
| Boolean | And, Or, Not | Arguments are eager. |
| String | Concat, Len, Slice, Upper, Lower, Trim, Split, Join, Replace, Contains | Len counts UTF-8 bytes, while Slice indexes Unicode scalar values. Contains also tests list membership. |
| List | Map, Filter, Fold, FlatMap, Append, Head, Tail, Reverse, Length, Range | Range includes both bounds and returns an empty list when stop < start. The evaluator enforces max_list_len for list literals, Map, FlatMap, and Range. The generic builtin handler does not apply that limit to every list-returning operation. |
| Record | MergeRecords, Keys, Values, HasField | In a merge, fields from the second record replace equal keys from the first. |
| Utility | DefaultVal, Clamp, TruncateStr | DefaultVal substitutes only for null. |
| Coercion | IntToFloat, FloatToInt, IntToStr, FloatToStr, StrToInt, StrToFloat | String parses can return ParseError. FloatToInt can return FloatNotRepresentable. |
| Inspection | TypeOf, IsNull, IsList | Inspect runtime values. |
| Instance traversal | Edge, Children, HasEdge, EdgeCount, Anchor | The pure evaluator returns NoInstanceContext. Use eval_with_instance, eval_with_element_ops, or another BuiltinResolver to supply graph context. |
Evaluation errors
ExprError is non-exhaustive.
| Variant | Cause |
|---|---|
StepLimitExceeded | The reduction budget was exhausted. |
DepthExceeded | Recursive evaluation exceeded max_depth. |
ListLengthExceeded | A list constructor or a budgeted list-producing path exceeded max_list_len. |
UnboundVariable | The environment has no binding for a variable. |
TypeError, ArityMismatch, NotAFunction | A value or call has the wrong runtime shape. |
IndexOutOfBounds, FieldNotFound, NonExhaustiveMatch | Lookup or pattern matching failed. |
DivisionByZero, Overflow, FloatNotRepresentable, ParseError | A numeric operation or coercion failed. |
NoInstanceContext | An instance-traversal builtin was evaluated without an instance resolver. |
InternalDispatch | A builtin reached the wrong internal family handler. |
Source
The AST and builtin enum live in expr.rs, builtin implementations in builtin.rs, evaluation and defaults in eval.rs, and the surface grammar in parser.rs.
See also
Resource limits
Reading a schema costs time and memory proportional to what the input asks for, and a public entry point reads input it did not author. Every such surface therefore parses within a budget: an allowance that is checked before work begins and drawn down as it proceeds.
What is bounded
| Resource | Default | What it counts |
|---|---|---|
input_bytes | 64 MiB | Bytes of input accepted in one operation |
bundle_entries | 4096 | Documents in one bundle |
graph_elements | 1,000,000 | Vertices, edges and nodes in a decoded schema or instance |
metadata_bytes | 16 MiB | Metadata attached to a schema or instance |
depth | 128 | Levels of nesting descended |
steps | 10,000,000 | Steps of evaluation or search |
output_bytes | 64 MiB | Bytes of output produced |
The depth bound matches the walk and extraction depths that already existed, so it governs nothing it did not govern before.
Two properties that make this a policy
Several subsystems already had sound local bounds: parser walk depth, CST extraction depth, expression evaluation steps, morphism search budget, model-check assignment counts. What they were not was a policy. What an input was allowed to cost depended on which door it came through, so the same document could be refused through one surface and accepted through another.
A budget is shared, not per-call. A nested operation draws from the same allowance as the operation containing it. Cloning a Budget shares its counters rather than copying them, so a caller is not charged once for a walk and again for each subwalk, and an oversized input cannot slip past a bound by being processed in pieces. A budget that reset per subsystem would bound each step and nothing overall, which is what several unrelated local limits already achieved.
A failure names the resource and the bound. LimitExceeded carries which allowance ran out and what it was set to. “Too deep” without a number tells a caller nothing about what to pass instead, and with seven separate allowances it does not even say which setting to change.
Defaults, configuration, and opting out
parse_schema_document, parse_schema_source and parse_schema_bundle apply ResourceLimits::defaults(). Every binding calls those, so the CLI, Python, C and WASM all inherit the same policy; none of them selects its own, and none can inherit an unbounded one by accident.
A Rust caller that needs different bounds uses the _within variants:
#![allow(unused)]
fn main() {
use panproto_core::expr::limits::{Budget, ResourceLimits};
use panproto_core::protocols::parse_schema_bundle_within;
let mut limits = ResourceLimits::defaults();
limits.bundle_entries = 32_768;
let budget = Budget::new(limits);
let docs = vec![serde_json::json!({
"lexicon": 1,
"id": "com.example.record",
"defs": { "main": { "type": "object", "properties": { "v": { "type": "string" } } } },
})];
let schema = parse_schema_bundle_within("atproto", &docs, &budget)?;
assert!(schema.vertex_count() > 0);
Ok::<(), Box<dyn std::error::Error>>(())
}
Passing the same budget to several calls has them share one allowance, which is how a caller bounds a whole pipeline rather than each stage of it.
ResourceLimits::unbounded() removes every bound, and a field set to 0 removes that one. This is a legitimate choice for a Rust caller processing input it produced itself. It is deliberately something to ask for: no FFI or command-line entry point selects it, because unbounded behaviour should never be inherited at a boundary that reads input from elsewhere.
See also
- What panproto verifies for the bounded model check, whose assignment budget is configured separately per invocation.
Lens combinator reference
panproto implements asymmetric lenses (Foster et al. 2007) with an explicit complement in the complementary-view tradition (Bancilhon and Spyratos 1981). For a fixed Lens, the data-level operations have the shapes
Both Rust functions return Result. get accepts an instance of the source schema and runs the source-to-target restrict pipeline, returning the target-schema view and its Complement. put accepts that target-schema view and complement and reconstructs a source or returns LensError.
Law-checking API
| Function | Check performed |
|---|---|
check_get_put | On one supplied source, put(get(s)) reconstructs the complete instance. |
check_put_get | Checks the original view and one deterministic scalar mutation. Comparison ignores fields marked as derived. |
check_laws | Runs the two checks above. It does not run PutPut. |
check_put_put | Compares a chained put with a direct put for one supplied source and second view. |
check_optic_laws | For every kind, checks GetPut and PutGet on the unedited view. Prism and Affine add preview stability. Traversal and Affine add one deterministic perturbed-view round trip. Iso also requires a complement with no recorded data loss. |
These functions are on-demand checks over their supplied values. Property tests exercise generated lenses, instances, and views, but a passing test run is not a proof for every constructor input. What panproto verifies records the corresponding limits.
Optic kinds
OpticKind is a structural classification derived from TheoryTransform. Classification itself does not run an optic-law checker.
| Kind | Classified shape | Recorded complement |
|---|---|---|
Iso | identity or rename | empty |
Lens | add, drop, pullback, coercion, or enrichment transform | data needed for reconstruction |
Prism | scoped focus through a variant edge | variant choice |
Affine | composition mixing Lens and Prism | both components |
Traversal | scoped focus through item or items | per-position data |
OpticKind::compose uses Iso as the identity and Traversal as an absorbing element. Lens composed with Prism, or either composed with Affine, yields Affine. This table describes the enum’s implementation. It does not certify the full laws of profunctor optics (Pickering et al. 2017).
Constructor modules
| Module | Main return types | Contents |
|---|---|---|
protolens::elementary | Protolens | add_sort, drop_sort, sort and operation renames, edge operations, equations, pullback, coercion, and scoped transforms. |
protolens::combinators | ProtolensChain or Protolens | rename_field, remove_field, add_field, hoist_field, nest_field, pipeline, and map_items. |
compose | Result<Lens, LensError> | Sequential composition of two concrete lenses. |
symmetric | SymmetricLens | Bidirectional transforms with a shared complement. |
fibration | checker results | Cartesian-lift and factorization checks over supplied data. |
enrichment_registry | registered trait objects | Cross-crate lookup for schema-enrichment synthesis, including layout. |
The full parameter types are the public Rust signatures in those module indexes. Several elementary constructors accept impl Into<Name>. Checked coercion constructors additionally return Result when their finite honesty samples fail. The elementary pullback constructor stores a supplied TheoryMorphism in TheoryTransform::Pullback. It does not construct a categorical pullback object or return a certificate of a pullback universal property.
Complement composition
ComplementCompose::compose(&left, &right) returns Result<Complement, LensError>. It rejects distinct nonzero source fingerprints with ComplementFingerprintMismatch and conflicting values under a shared key with ComplementConflict. ComplementCompose::is_compatible runs the same predicate without returning the merged value. The trait must be in scope because Complement is defined in panproto-inst.
Protolens composition and instantiation
A Protolens stores source and target theory endofunctors, a schema precondition, and a complement constructor. The intended natural-transformation structure uses the standard categorical definition (Eilenberg and Mac Lane 1945). Constructing a value does not verify naturality over every schema.
protolens_composable(eta, theta) accepts either structurally equal intermediate endofunctors or an identity transform on theta.source. The identity-source case retains theta’s source precondition in the composite. vertical_compose rejects other pairs with CompositionMismatch. horizontal_compose currently returns Ok without an additional compatibility check.
| Chain operation | Behavior |
|---|---|
check_applicability_with | Threads the running schema through every step and checks each precondition. |
instantiate | Fuses the chain and computes one migration. It does not call the applicability check automatically. |
instantiate_sequential | Checks each step against the running schema, instantiates it, and composes the concrete lenses. |
Call check_applicability_with before fused instantiation when preconditions must be enforced. Fused and sequential instantiation are compared in property tests. Neither return value is a proof of the lens laws for all instances.
See also
Configuration reference
The project manifest is panproto.toml in the project root. load_config returns Ok(None) when the file is absent and rejects malformed TOML as ProjectError::InvalidManifest.
Manifest shape
[workspace]
name = "my-project"
exclude = ["target", "build", "**/*.log"]
[[package]]
name = "user-api"
path = "schemas/user"
protocol = "json-schema"
[[package]]
name = "user-events"
path = "schemas/events"
[workspace]
| Field | Rust type | Required | Default | Meaning |
|---|---|---|---|---|
name | String | yes | none | Workspace name. |
exclude | Vec<String> | no | empty | Glob patterns compiled relative to the manifest directory. Invalid patterns return ProjectError::InvalidPattern. |
[[package]]
The top-level package array may be omitted and then defaults to empty.
| Field | Rust type | Required | Default | Meaning |
|---|---|---|---|---|
name | String | yes | none | Package label. The loader does not enforce uniqueness. |
path | PathBuf | yes | none | Package root relative to the manifest directory. |
protocol | Option<String> | no | None | Parser override for files below path. Without an override, detection uses the file path and parser registry. |
If an overridden parser rejects a file, project assembly falls back to raw_file. It does not retry ordinary language detection. Package paths supply protocol-prefix overrides, but do not restrict the directory walk to the declared packages.
Generated defaults
schema init [PATH] always initializes .panproto/. When package scanning finds at least one recognized package, it also writes panproto.toml with detected package entries and these exclusions:
exclude = ["target", "node_modules", "__pycache__", "build", "dist", ".git"]
When scanning finds no package markers, schema init does not create a manifest. The package scanner recognizes Cargo, npm, Go, Python, Gradle, Elixir, and CMake project markers. Programmatic callers can use generate_config and serialize_config.
Source
The manifest structs and defaults live in crates/panproto-project/src/config.rs. Project assembly applies them in crates/panproto-project/src/lib.rs.
See also
Crate map
The workspace contains 36 panproto-* crates. This page groups them by dependency role. The repository Cargo.toml is authoritative for membership, and each crate’s manifest is authoritative for features.
Theory, schemas, and migration
| Crate | Role |
|---|---|
panproto-gat | Generalized algebraic theory data, checking, morphisms, transforms, colimits, and finite-model evaluation (Cartmell 1986). |
panproto-gat-macros | class! and inductive! procedural macros targeting panproto-gat. |
panproto-schema | Schema graph, protocol rules, validation, induction, layout erasure, and canonical digests. |
panproto-inst | W-type, functor, and graph instances. It also provides compiled migration tables, source-to-target surviving-fragment restriction, (\Sigma_F), (\Pi_F), target-to-source (\Delta_F), complements, and instance-aware expression environments. |
panproto-mig | Migration existence and compilation, the plain source-to-target restrict-based lift, separate (\Sigma_F) and (\Pi_F) entry points, schema correspondence search, spans, and total-morphism search. |
panproto-check | Breaking-change classification and compatibility reports. |
panproto-protocols | Built-in semantic protocol definitions, parsers, emitters, and theory registration. |
panproto-mig::solve implements exact bucket elimination (Dechter 1999), budgeted hybrid search with EDAC* cost propagation [Allouche et al. (2015); Larrosa & Schiex (2004); de et al. (2005)], an all-different constraint for injective mappings (McCreesh and Prosser 2015), and McSplit partitioning for isomorphism requests (McCreesh et al. 2017). Searching for a morphism defines these paths. What panproto verifies states what their certificates establish.
Lenses, expressions, and DSLs
| Crate | Role |
|---|---|
panproto-lens | Concrete lenses, complements, composition, law checkers, protolenses, optics, and enrichment registration. |
panproto-expr | Bounded functional expression AST, evaluator, builtins, values, and lightweight type inference. |
panproto-expr-parser | Haskell-style lexer, parser, desugaring, and pretty printer for panproto-expr. |
panproto-dsl-eval | Shared Nickel, JSON, and YAML document evaluation for declarative DSLs. |
panproto-lens-dsl | Declarative lens compilation from Nickel, JSON, or YAML. |
panproto-theory-dsl | Declarative theories, morphisms, protocols, and composition from Nickel, JSON, or YAML. |
Parsing, I/O, and projects
| Crate | Role |
|---|---|
panproto-io | Instance-level codecs for native data formats, with optional tree-sitter integration. |
panproto-parse | Feature-selected tree-sitter full-AST parsing, layout preservation, source emission, and parser registry. |
panproto-grammars | Vendored tree-sitter grammar build and group-* / lang-* Cargo features. group-all currently names 261 grammars. |
panproto-project | Directory walking, package detection, manifest configuration, parsing cache, import resolution, and schema coproduct assembly. |
panproto-grammars-all | Python companion extension containing group-all. |
panproto-grammars-functional | Python companion extension for group-functional. |
panproto-grammars-web | Python companion extension for group-web. |
panproto-grammars-systems | Python companion extension for group-systems. |
panproto-grammars-jvm | Python companion extension for group-jvm. |
panproto-grammars-scripting | Python companion extension for group-scripting. |
panproto-grammars-data | Python companion extension for group-data. |
panproto-grammars-devops | Python companion extension for group-devops. |
panproto-grammars-mobile | Python companion extension for group-mobile. |
panproto-grammars-music | Python companion extension for group-music. |
Version control and transport
| Crate | Role |
|---|---|
panproto-vcs | Content-addressed schema history, refs, staging, commits, merge, verification status, and data versioning. |
panproto-git | Bidirectional translation between git and panproto-vcs. |
panproto-git-remote | Git remote helper for panproto:// push, pull, and clone. |
panproto-xrpc | XRPC client for panproto-node VCS operations. |
Facades and bindings
| Crate | Role |
|---|---|
panproto-core | Rust facade re-exporting 13 always-on libraries and three optional support crates. |
panproto-cli | The schema executable, including the theory REPL. |
panproto-wasm | Handle-based WebAssembly API used by the TypeScript SDK. |
panproto-py | Native Python extension built with PyO3. |
panproto-c | C ABI consumed by the Haskell and Swift bindings. |
Feature-gated dependency edges
| Crate | Feature | Effect |
|---|---|---|
panproto-core | full-parse | Adds and re-exports panproto-parse with its default grammar group. |
panproto-core | project | Adds panproto-project and implies full-parse. |
panproto-core | git | Adds panproto-git and implies project. |
panproto-core | tree-sitter | Enables panproto-io/tree-sitter for format-preserving codecs. |
panproto-parse, panproto-grammars | group-*, lang-* | Selects grammar groups or individual grammars. Both default to group-core. |
panproto-py | group-*, lang-* | Mirrors grammar selection into the Python extension. The default is group-core. |
panproto-c | full-parse, project, git, format-preserving, full | Adds the corresponding optional panproto-core surfaces. full enables all four. |
panproto-wasm | format-preserving | Enables panproto-core/tree-sitter. |
panproto-io | tree-sitter | Adds panproto-parse, selected data grammars, and tree-sitter support. |
xtask is a workspace member but not a panproto-* library. It contains repository maintenance commands, including CLI-document generation.
See also
Explanation
These chapters explain the representations and checks that underlie panproto. They assume that you can read a schema and a structural diff, but they do not assume category theory. Procedures belong in the how-to guides, while interface details belong in the reference.
Schemas, migrations, and lenses
Begin with What panproto solves, and consult The vocabulary in plain terms when an unfamiliar term appears. Schemas as theories first describes the common representation used for different protocols. Migrations as morphisms then explains a migration and the operations that move data along it. Search may return a partial correspondence as a span. Together, these chapters supply the background needed by the tutorials and most how-to guides.
When a conversion discards information or must support updates in both directions, continue from the glossary definition of a lens to Lenses and round-trip laws. What panproto verifies distinguishes runtime validation and test evidence from properties that the implementation assumes.
Search, composition, and version control
Searching for a morphism describes how panproto searches for a partial or total schema correspondence. Alignment evidence explains how names, types, and other evidence affect that search.
Composing protocols by colimit concerns the colimit construction used to describe protocol structure. Schema version control semantics concerns structural changes to concrete schemas and their histories. The two chapters use related categorical constructions at different levels of the system.
Layout enrichment and Source-code emission address parsing, canonical emission, and preservation of source layout. Architecture identifies the crates that implement these operations and the data that crosses their boundaries.
Denotational semantics
The denotational semantics chapters specify the expression language, both DSLs, protolens composition, merge, and the theory REPL. Begin with Shared notation; the remaining chapters assume familiarity with typed abstract syntax, inference rules, and elementary category theory.
Related work locates these constructions in the literature. It is best read after the chapters on schemas, migrations, and lenses.
Chapter map
| Question | Read |
|---|---|
| What problem and vocabulary do I need? | What panproto solves, then The vocabulary in plain terms |
| How are schemas and migrations represented? | Schemas as theories, then Migrations as morphisms |
| How does automatic correspondence work? | Searching for a morphism, then Alignment evidence |
| What makes a migration bidirectional? | Lenses and round-trip laws |
| How are protocols or branches combined? | Composing protocols by colimit, then Schema version control semantics |
| How does source-code round-tripping work? | Layout enrichment, then Source-code emission |
| Which claims are mechanically checked? | What panproto verifies |
| Where does the implementation live? | Architecture |
What panproto solves
Changing a schema usually creates a corresponding data problem. A renamed field may require only a direct correspondence, while a split record or retired variant may require a default, a value transform, or saved information. When those decisions live only in migration scripts, it can be difficult to compare them with the schemas that they connect.
panproto represents a schema change as structured data. It can compare two schemas, classify their compatibility, compile a migration between them, and apply that migration to schema-typed data. The same representations support histories of schema objects and structural three-way merge. The protocol catalog records the formats for which parsers or other protocol support are registered.
These operations depend on the protocol-theory model. A protocol identifies the theories used to describe its schema and instance structure. A parsed schema supplies the concrete types, fields, constraints, and related metadata governed by those theories. Schemas as theories develops this distinction.
Prerequisites: familiarity with fields, records, and schema versions. No category theory is assumed.
Compare and classify schema changes
A structural diff records additions, removals, renames, and modifications. The compatibility classifier then assigns the report one of three classifications: fully compatible, backward compatible, or breaking. A CompatReport retains the classification together with its breaking and non-breaking findings, which allows a CI job to reject changes under a chosen policy.
Compile and apply migrations
A migration records correspondences from a source schema to a target schema together with any required value transforms. Compilation checks that the correspondence preserves the relevant schema structure before producing the tables used to transform data. If a forward transformation discards source information, a lens may retain that information in a complement for a later backward update. Migrations as morphisms describes compilation and data movement; Lenses and round-trip laws describes complements and the available law checks.
Record and merge schema histories
The version-control layer stores schemas, migrations, data sets, and related metadata as content-addressed objects. Its commands expose commits, branches, tags, diffs, blame, and structural merge over those objects. When two branches make incompatible structural changes, merge returns typed conflict descriptions for explicit resolution. Schema version control semantics gives the details and limits of this construction.
Scope
panproto does not supply application behavior that a schema leaves unspecified, deploy a migration, or replace a database. It operates on schema documents and schema-typed data. Its outputs include structural reports, compiled migrations, converted data, and repository objects.
See also
- Schemas as theories for the common representation used across schema languages.
- Migrations as morphisms for the structure and execution of migrations.
- Schema version control semantics for structural history and merge.
The vocabulary in plain terms
panproto uses mathematical terms to distinguish operations that ordinary migration vocabulary often groups together. This page gives working translations and points to the chapters that define the terms more precisely.
Prerequisites: familiarity with schemas and data migration. This page also supports the intermediate route in the explanation reading guide.
Terms
| panproto says | Working meaning | Nearby familiar concept |
|---|---|---|
| protocol | A registered schema language, including the names of its schema and instance theories and its structural rules | An entry in a format registry, such as JSON Schema, Protobuf, or SQL DDL |
| theory (GAT) | A specification of the sorts, operations, and equations used to describe a family of structures | A typed algebraic signature with laws |
| schema | A schema document parsed into panproto’s common representation | An api.yaml or .proto file |
| instance | Data interpreted under a schema | A row or JSON document |
| vertex / edge | A schema type and a directed field or relation between types | Nodes and arrows in a schema diagram |
| migration (morphism) | A map from source schema elements to target schema elements, with optional value transforms | The structural part of a migration plan |
| lift | Applying a compiled migration to data | Running a data conversion; the concrete function determines its behavior |
| restrict | In lift_wtype, lift_functor, and schema lift --direction restrict, forwarding only the source fragment that survives in the target | A filtered source-to-target projection; this API name does not mean categorical |
In panproto_inst::adjunction, reindexing a target instance back to the source of | Precomposition, with direction | |
In panproto_inst::adjunction, carrying a source instance forward to the target | A left Kan extension, with direction | |
| lens | A forward transformation that returns a view and a complement, paired with reconstruction from that view and complement | A bidirectional converter with explicit saved state |
| complement | Information retained during the forward transformation so that reconstruction can restore it | An undo record |
| round-trip laws (GetPut, PutGet, PutPut) | Equations relating forward transformation and reconstruction | Properties checked on concrete inputs or generated test cases |
| protolens | A composable description from which a lens can be instantiated for matching schemas | A schema-indexed transformation template |
| dependent optic | A protolens step whose applicability depends on schema structure | A template operation with a structural precondition |
| colimit | A construction that combines theories along explicitly shared parts | Gluing typed specifications over a common interface |
| pushout | A colimit that combines two objects receiving maps from a common object | The categorical shape associated with a structural three-way merge |
| existence check | A finite validation that a proposed migration covers the required cases | A static precondition check rather than execution on sample data |
Tutorials chiefly use schema, migration, lens, and complement. How-to guides also refer to protocols and protolenses. Remaining terms appear in the explanation chapters where their distinctions affect an operation. The glossary provides shorter formal definitions, while Schemas as theories and Migrations as morphisms develop the central representations.
See also
- Glossary for formal definitions.
- What panproto solves for the problem statement.
- Schemas as theories and Migrations as morphisms for the underlying representations.
Schemas as theories
A JSON Schema object, a Protobuf message, and an ATProto Lexicon record use different surface syntax. Once parsed, each identifies types, directed fields between types, and constraints on those elements. panproto stores this information in a common Schema representation that retains graph structure, protocol-specific constraints and metadata, and derived adjacency indices. The linked Schema API gives the complete field inventory. panproto_schema::validate reports structural findings. Callers decide whether those findings reject an operation.
This concrete representation permits a structural comparison between documents from different schema languages. It does not erase their differences. Each schema retains its protocol identifier, and the protocol determines which structures are meaningful and which validation rules apply.
The vocabulary in plain terms introduces the graph terminology used below. The formal account distinguishes a specification from a structure that satisfies it.
Protocol theories and schema models
A registered Protocol names a schema theory and an instance theory. The registry associates those names with generalized algebraic theory (GAT) presentations (Cartmell 1986). A presentation may declare sorts, operations over those sorts, and equations or directed equations. The graph theory ThGraph, for instance, declares the sorts Vertex and Edge and the operations src and tgt from edges to vertices.
The protocols crate defines five foundational theories. ThGraph describes directed graphs, ThConstraint attaches constraints to vertices, and ThMulti adds edge labels. ThMeta describes discriminator and extra-field metadata. These four contain no equations. ThWType describes nodes, arcs, and values and contains two endpoint-coherence equations: an arc’s source and target nodes must be anchored at the endpoints of the schema edge named by that arc. Higher-level theories compose these foundations for particular groups of protocols. Composing protocols by colimit describes that composition.
A parsed schema has the intended mathematical reading of a model of its protocol’s schema theory: concrete vertices and edges interpret the corresponding sorts, while their endpoints interpret src and tgt. The Rust Schema type is a dedicated concrete representation, however, and does not implement panproto_gat::Model. panproto_schema::validate checks the protocol’s structural rules rather than evaluating every theory equation. Equation satisfaction uses a separately constructed finite Model and the bounded checker in panproto-gat.
The distinction also separates two kinds of map. A schema morphism maps the vertices and edges of one concrete schema to those of another while preserving endpoints and other required structure. A theory morphism maps the sorts and operations used to specify a family of structures. Migrations use the former. Cross-protocol theory composition uses the latter.
Representational limits
The theory-model account describes structure that a protocol registration exposes. It does not describe the running time of operations over a large schema or application behavior absent from the schema. Whitespace, comments, and other source-layout details also lie outside the ordinary schema model. The layout-enrichment and format-preserving layers can retain such details when the parser supplies them.
Validation reports consistency findings for the registered structural rules. Application invariants remain unchecked when neither the protocol theory nor the concrete schema represents them.
Related work
The schemas-as-theories account also draws on Spivak’s functorial data model (Spivak 2012), the algebraic-databases program [Schultz & Wisnesky (2017); Schultz et al. (2017)], attributed C-sets (Patterson et al. 2022), and Lu’s work on multi-model unification (Lu 2025). panproto assigns a GAT presentation to each protocol and can combine presentations by colimit. The attributed C-set and algebraic-database approaches instead fix a meta-theory and parameterize schemas within it. Related work develops the comparison.
See also
- Composing protocols by colimit for composition of foundational theories.
- Migrations as morphisms for maps between concrete schemas.
- Theory DSL: denotational semantics for the formal model of a GAT presentation.
- The bibliography entries Cartmell (1986) and Spivak & Wisnesky (2015) for the GAT and functorial-data-migration backgrounds.
Migrations as morphisms
Suppose a source schema contains a field named age and a target schema contains the corresponding field under the name years. A migration records the map from the source field to the target field. If the value also changes representation, the migration may associate that correspondence with an expression that computes the target value.
This representation separates a structural map from its execution. The map can be validated, stored, and composed before it is applied to data. A compiled migration contains the tables and value resolvers needed by the instance layer.
Maps between concrete schemas
Let and be concrete schemas. In the functorial account of data migration, a schema morphism maps source structure to target structure and induces operations between their instance categories [Spivak (2012); Spivak & Wisnesky (2015)]. In panproto’s concrete representation, maps source vertices and edges to target vertices and edges. It must preserve edge endpoints and land in the target schema. The free function panproto_mig::compile checks this mapped fragment and builds the tables used during instance migration. It does not run the separate migration-existence check.
Data can then move along the compiled map. The names lift and restrict are overloaded in the current APIs, so the function and its input direction matter.
lift_wtypeandlift_functortake an -instance and return a -instance containing the fragment that survives the compiled migration. When several source vertices map to one target vertex,lift_functorconcatenates their row sets. These functions are forward projections. They are not the categorical restriction .lift_wtype_sigmaandlift_functor_sigmaalso run from to . The W-type operation requires every source anchor to have an image. The functor operation appliesfunctor_extendand may then run the term-level chase supplied by its caller.lift_wtype_piis implemented only for vertex-injective migrations and relabels rather than constructing a product.lift_functor_picomputes Cartesian products over fibers and enforces its product-size limit.
The schema lift command always parses its input under --src-schema and emits under --tgt-schema. This direction is unchanged by --direction restrict, sigma, or pi. The default restrict label selects the forward surviving-fragment projection described above. It must not be read as .
The categorical vocabulary organizes a more specific fragment. Given , restriction is written
and its left adjoint is written
The explicit constructions live in panproto_inst::adjunction. For set-valued FInstances, f_sigma runs from to and f_delta runs from to . The implementation also supplies the unit, counit, and hom-set transposes for total vertex maps, including maps that merge vertices. For WInstance, w_sigma runs from to , while w_delta runs from to and requires vertex- and edge-injective maps whose target anchors lie in the image. Property tests exercise the triangle identities and hom-set transposes in these fragments. They do not prove an adjunction for arbitrary partial migrations.
Migration composition also acts on values. compose combines carried coercion expressions in execution order and uses partial-map semantics for structural elements omitted by the second migration. invert requires bijective coverage of target vertices, edges, and hyperedges, and it refuses a carried coercion without an inverse expression. It reverses the remaining coercions and swaps the recorded schema endpoints. Hand-built expr_resolvers are not inverted and are dropped from the inverse.
Value-level transforms are expressions in the expression language. They determine how a target value is computed when a structural correspondence alone is insufficient.
Partial correspondence as a span
A source schema and a target schema need not admit a total morphism. Search thus returns a span
where is the apex and the two arrows are its legs (Johnson and Rosebrugh 2014). Johnson and Rosebrugh use peak for the same object.
In panproto, is the sub-schema of induced by the source vertices that the search matched. The left leg is the resulting inclusion, and the right leg records the match into . panproto_schema::induce restricts the schema’s element tables and protocol metadata to this sub-schema, rebuilds its derived indices, and validates the result. Copying only the vertex and edge maps would leave other tables referring to removed elements.
The span is total when the inclusion covers all of . SchemaSpan::is_total tests this condition, after which the span can yield a total schema morphism. The empty apex is a feasible match when the schemas share no compatible structure. Search may still report malformed inputs or construction errors, but it does not fail solely because no nonempty match exists.
Classical span equivalence uses an isomorphism between apices that commutes with both legs (Johnson and Rosebrugh 2014). Because panproto’s left leg is an inclusion, the apex is determined by its selected source vertices. A returned span can consequently be represented by that selected sub-schema and its right-leg map, without a separate graph-isomorphism quotient.
The omitted portion of the source resembles a complement in the sense of Bancilhon & Spyratos (1981): it is information outside the selected view that may be needed to recover the source. This analogy motivates a preference for larger apices at equal search quality. It does not transfer the constant-complement results of that work to panproto’s schema spans.
Pushout and the two apices
A span supplies the input to a pushout:
The schema apex records shared schema structure. The search returns this schema span. It does not construct pairs of instances that agree on or establish a model-level pullback theorem; those are separate claims that the current search API does not check.
The implemented schema pushout requires an injective right leg on vertices. A default search result may map two apex vertices to one target vertex, which is a contracting right leg. SchemaSpan::pushout rejects that case. Callers that require a merge can request a monic or isomorphic search result. The underlying schema_pushout closes the supplied vertex and edge identifications to an equivalence relation and returns two SchemaMorphism values. This constructor does not run a separate universal-property checker.
The data-migration adjunction does not by itself construct a symmetric lens for this pushout. Johnson et al. (2012) relate c-lenses to Grothendieck opfibrations and use that structure to formulate universal view updates. panproto checks its scoped adjunction and its lens laws as separate implementation properties; it does not formalize that equivalence.
The length-1 fragment
In the broader functorial account, a schema translation may send a generating source arrow to a path in the target category. SchemaMorphism has a narrower representation: its edge_map maps each source edge to one target edge. Migration uses the same shape. These maps form the length-1 fragment of that account.
A one-to-many value correspondence thus does not appear as a schema morphism. FieldTransform::ComputeField can instead compute a target key with an expression. It may also carry an inverse expression, and its declared CoercionClass records the intended recovery behavior. Because callers supply that class, the presence or absence of an inverse does not by itself determine the classification.
Search also considers only a projection of the full Schema representation when scoring candidates. Other fields participate in feasibility checks or are restricted by induce, but they do not all affect the objective. The weights express a preference over alignments and have not been calibrated against a labeled corpus of correct matches. Searching for a morphism gives the exact objective and constraints.
Compatibility classification
CompatReport records a classification together with separate breaking and non_breaking findings. The classifier uses the following conditions:
| Classification | Condition |
|---|---|
fully-compatible | Both finding lists are empty. |
backward-compatible | The breaking list is empty and the non-breaking list is nonempty. |
breaking | The breaking list is nonempty. |
These labels classify structural change. They do not by themselves state that every source record can be transformed, since executable migration may also depend on defaults, value expressions, and protocol-specific requirements. The Breaking-change gate shows how to apply a compatibility policy in CI.
See also
- Searching for a morphism for span construction and its certificate.
- Find a span between two schemas for the corresponding command.
- Lenses and round-trip laws for bidirectional updates and complements.
- Pushouts and merge for the merge construction.
- Apply field transforms for value-level transforms.
- What panproto verifies for the scope of runtime checks.
Searching for a morphism
Two schemas may describe corresponding records without using the same vertex names or preserving every field. The morphism search chooses the correspondences that satisfy the structural conditions encoded by the schema pair, and it leaves a source vertex unmatched when no admissible image improves the result. The implementation represents this task as a finite optimization problem with explicit feasibility constraints.
This chapter covers:
- the span returned by the search
- the cost function network built from a schema pair
- the exact and bounded algorithms that solve the network
- the construction and certification of the returned span.
The same implementation supports partial overlaps, total morphisms, injective morphisms, and isomorphisms. These requests share an objective but do not always share an algorithm.
The span returned by the search
A span from a source schema to a target schema is a pair of schema morphisms with a common domain ,
The common domain is the apex, and and are the legs. The SchemaSpan returned by panproto gives these terms a specific implementation: is the sub-schema of induced by the source vertices that received target images, includes that sub-schema into , and carries the images chosen by the search. Migrations as morphisms develops the categorical account. Here the span is the concrete output assembled from a solved assignment.
Consider a source schema with an object vertex post, a string vertex post.text, and an integer vertex post.likes. Suppose the target has an object vertex article and a string vertex article.body, but no integer vertex. The object and string vertices exercise kind and edge constraints; the integer vertex introduces dropping.
The source object may take article as an image because the vertex kinds agree. It may not take article.body, since an object vertex cannot map to a string vertex. Kind equality is enforced before the optimizer sees any costs.
The source string may take article.body. If the source has a prop edge from post to post.text and the target has a prop edge from article to article.body, this pair of choices preserves the edge even when the edge names differ. The name difference affects the score, but it does not make the assignment infeasible.
The integer vertex has no target of the same kind. Its only choice is the distinguished value , which means that the vertex is dropped from the apex. If post and post.text are mapped while post.likes takes , the apex contains the first two source vertices and the edge between them. A required-edge declaration can forbid that partial choice: when post requires the edge to post.likes, keeping post also requires both endpoints of that edge to survive.
The symbol is the drop value. The subscript distinguishes it from the zero cost sometimes written in the valued-constraint literature. This chapter writes the cheapest cost as and reserves for the decision to omit a source vertex.
Building the cost function network
build_cfn converts an ordered pair into a Cfn, or cost function network. A cost function network is a finite collection of variables, finite domains, and cost tables over small groups of variables. An assignment chooses one value for every variable. Its total cost is the sum of the selected table entries, except that the distinguished cost is absorbing and denotes infeasibility. This is a valued constraint satisfaction problem (Schiex et al. 1995), with a closely related semiring reading (Bistarelli et al. 1997).
The builder creates one variable for every source vertex , ordered by source vertex name. Before caller restrictions are applied, its domain is
Target values are sorted by target vertex name. SearchOptions may replace the same-kind candidates with one hard pin, and DomainConstraints may intersect them with an allowed set or remove source and target vertices. An incompatible hard pin leaves as the only value. Excluding a source vertex has the same effect. The variable remains present, which keeps variable identifiers and the packed-cost radix functions of the source schema alone.
Every assignment determines a partial vertex map. A value maps to , whereas omits . The all-drop assignment is feasible for every network produced by this builder, so a successfully built span search always has an answer. A build can still fail if its cost tables exceed the memory budget, and inducing the chosen apex can still report an invalid source fragment. Neither failure means that the schemas have no overlap.
For the vertex-and-edge fragment, forbidding turns feasibility into the usual homomorphism question: each source vertex receives a same-kind target, and each source edge receives a same-kind target edge between the chosen endpoint images. Constraint satisfaction and homomorphism provide two formulations of this decision problem (Feder and Vardi 1998). Its dependence on the structure of the source side, including treewidth modulo homomorphic equivalence, is studied by Grohe (2007). The panproto network adds constraints for schema annotations that a bare graph homomorphism does not carry.
Hard constraints and apex closure
A finite cost ranks an assignment. A entry rejects it. The builder enforces seven constraint families. Kind equality is encoded by domain membership, while the other six use entries.
| Constraint | Condition enforced by the network |
|---|---|
| Vertex kind | A target of a different kind is absent from the domain. |
| Edge preservation | If both endpoints of a source edge are mapped, a target edge of the same kind must join their images. |
| Required edge | If the owner survives, both endpoints of each required edge survive. |
| Coproduct variant | If a coproduct survives, each recorded variant and its parent vertex survive. |
| Recursion point | If a fixpoint marker survives, its target vertex survives. |
| Schema-span annotation | The two vertex references stored in Schema::spans survive together or are dropped together. |
| Hyper-edge signature | All referenced signature vertices survive together or are dropped together. |
The sixth row concerns a span annotation stored inside one schema. It is distinct from the result span . A schema-span annotation is a pair of internal vertex references. The result span is the output of comparing two schemas.
The last five constraints make the chosen vertex set closed under source annotations that would otherwise dangle. Hyper-edge signatures are encoded as a clique of pairwise constraints, since partial survival occurs exactly when some pair disagrees about whether to survive. Recursion points and schema-span annotations can also connect variables that share no source edge. The graph used to choose a solver must thus be built after these constraints have been added.
Edge preservation and edge-name scoring share one lookup. For a source edge and mapped endpoint images and , the builder first seeks a target edge from to with the same kind and name. If none exists, it takes the least same-kind edge in the target’s stable edge order. The first case pays no edge penalty, the second pays the full share of the edge component, and the absence of any same-kind edge yields . When either endpoint takes , the source edge leaves the induced apex and pays the full edge penalty rather than .
Several source edges may constrain the same pair of variables. CfnBuilder merges their tables pointwise, so the finished network has at most one cost function for each scope. Self-loops are folded into the corresponding unary table. Scope uniqueness is needed by the fallback consistency algorithm as well as by the representation, since cost projection can oscillate when overlapping cost functions offer competing destinations for the same shifted cost (Lee and Leung 2012).
The objective
The network minimizes structural dissimilarity. Three components are unary, one is attached to source edges, and optional alignment evidence adds a fifth unary component.
| Component | Local term | Source-fixed denominator | Default weight |
|---|---|---|---|
| Vertex name | Byte-level edit distance divided by the longer name length | ||
| Edge name | Zero for an exact name match, one for a same-kind rename or a dropped endpoint | Number of source edges with two source endpoints | |
| Outgoing names | Jaccard distance between the sets of named outgoing edges | Number of source vertices with a named outgoing edge | |
| Out-degree | Absolute degree difference divided by the larger degree | ||
| Alignment evidence |
All denominators depend only on . We call this property source-fixed normalization (SFN). Without SFN, dropping a poorly matched vertex would remove it from an assignment-dependent denominator and could improve the average merely by shrinking the apex. Under SFN, a dropped vertex still occupies its share of the source normalization and receives the worst finite unary penalty for every component that applies to it.
The outgoing-name denominator needs separate treatment. Let be the source vertices with at least one named outgoing edge. The corresponding sum ranges over and divides by . A source leaf is thus outside that component regardless of whether its target image has children. This prevents the denominator from favoring a childless target for a reason unrelated to the correspondence.
Alignment evidence enters only through the last row. The builder validates every confidence as a number in , then adds
to the unary entry for mapping to . Evidence does not alter domains, produce , choose a variable order, or change a budget. It can change the optimum only when the anchor weight is nonzero. The default weight is zero, so the direct default search is structurally scored even when evidence is present. Alignment evidence describes how strategies produce and aggregate the confidence table.
Each local table entry is assembled in floating point while the network is built, then rounded once to integer units of . Every subsequent operation uses the Cost integer. This boundary makes later solver transformations independent of summation order, and it lets the fallback move cost between tables by exact subtraction. Projection and extension rely on that exact difference to preserve the cost of every assignment (Cooper and Schiex 2004).
One integer also carries the secondary preference for coverage. If is the quality cost in fixed-point units and is the number of dropped source vertices, the stored value is
Because , ordinary integer order is lexicographic order on . The search first minimizes quality cost, then chooses the assignment with fewer dropped vertices among assignments tied on quality. The reported quality is and excludes . The separate apex_coverage field reports , with value one for an empty source.
The primal graph and dispatch
The solver does not branch over complete maps. It first studies how the local tables connect the variables. The primal graph has one node for each variable and an edge between any two variables that occur in one cost-function scope. On the default path, each connected component can be solved independently because no table joins it to another component.
An elimination order processes the primal-graph nodes one at a time. When a node is eliminated, its remaining neighbors are joined into a clique. The largest number of such neighbors encountered along the order is the induced width. This width controls the arity of the intermediate tables created by bucket elimination (Dechter 1999), and it also characterizes the consistency needed for backtrack-free search on sparse constraint graphs [Freuder (1982); Dechter & Pearl (1987)].
For each component, the dispatcher compares descending source-name order with min-fill. Descending order tends to remove dotted-path leaves first; min-fill chooses the variable whose elimination adds the fewest edges. Smaller induced width wins, with descending order retained on a tie so that decoding proceeds in ascending source-name order.
Width selects an order, but the budget check uses the actual domain sizes. For a bucket that eliminates and sends a message over variables , the implementation prices stored entries and combine operations. These products are summed over the chosen order. Exact inference runs only when both the message memory and operation estimates fit the SearchBudget. Otherwise the component is routed to bounded search. A separate, earlier memory check covers the network’s original unary and local cost tables. Exceeding that build limit is an error because no in-memory network exists for either solver to consume.
Exact inference by bucket elimination
Bucket elimination is the ordinary path when its messages fit. Each original cost function is placed in the bucket of the first variable in its scope under the elimination order. To eliminate , the solver combines every function in ’s bucket and minimizes over . The resulting message is a table over the other variables in those functions, and it is placed in the next bucket that can consume it. A message with empty scope contributes to the constant cost.
After the last variable is eliminated, the constant is the optimum. Decoding then visits the elimination order in reverse. At each step it selects the least-cost value consistent with the values already decoded. The stored messages guarantee that each such choice extends to a global optimum, so decoding neither branches nor backtracks. This is the (min, sum) instance of the bucket-elimination scheme described by Dechter (1999).
The implementation allocates only the outgoing message for a bucket. It iterates over assignments to the message scope on the outside and values of the eliminated variable on the inside, so it never materializes the full join table. Argmin values are recomputed during decoding rather than stored beside every message cell. This trades a second scan of the domains for lower resident memory.
Exact inference does not consult the node budget and does not prune. The dispatcher has already established that its full tables and loop nests fit the memory and operation budgets before it begins. Completion thus proves optimality. Ties are broken by target name with ordered after every target, read in decode order. SpanSearch::optima can enumerate further assignments attaining the same optimum while the message tables are available.
The bounded fallback
A component whose elimination messages exceed the budget goes to hybrid best-first search. The outer search keeps a priority queue of unexplored subtrees, each with a certified lower bound. It removes the subtree with the lowest bound and explores it depth first for a bounded number of backtracks, then returns the unexplored branches to the queue. The least bound still present in the queue is a lower bound on the global optimum. This is the hybrid best-first scheme of Allouche et al. (2015).
The fallback maintains one mutable working network. An open node stores its decisions and bound, so revisiting that node resets the network and replays the decisions. Domains are copied at a branch, while changes to cost cells are recorded on a trail and restored to a mark. Local-consistency operations move cost from binary tables into unary tables and then into the zero-arity constant without changing the cost of any complete assignment. The constant is consequently a lower bound for every completion below the node. The default level is existential directional arc consistency, written . The implementation also provides node, arc, directional arc, and full directional arc consistency. These levels and their cost-shifting operations follow the weighted-CSP treatments in Larrosa (2002), Larrosa & Schiex (2004), Cooper et al. (2010), and de et al. (2005).
Branch and bound closes a node when its lower bound reaches the incumbent cost. Before an incumbent exists, value order is determined by the bound obtained after propagating each candidate. Afterward the search tries the incumbent’s saved value first. Variable order uses domain size divided by weighted degree, with additional weight assigned to cost functions that contributed most to a bound failure. These choices affect how soon a solution is found, but they do not change the objective or the certified bounds.
The fallback is bounded by elementary consistency operations and by search nodes. A caller may also set a wall-clock limit, though none is set by default. When a limit is reached, the outcome records the incumbent, the global lower bound, and the limit that stopped the search. A span can thus report a feasible answer without claiming it is optimal. A total-morphism search reports an error if it stops before finding any complete assignment, preserving the distinction between “no total morphism exists” and “the search did not finish.” What panproto verifies describes the property and oracle tests behind these claims. The external toulbar2 solver provides a useful point of comparison for the same family of cost-function-network algorithms.
Injective, surjective, and induced searches
The default network permits two source vertices to share a target. Search options that restrict the whole assignment cannot always be expressed by another local cost table, so they select specialized paths.
The monic option requires distinct surviving source vertices to take distinct target vertices. It runs the same bounded search with a counting Hall-set propagator (McCreesh and Prosser 2015). Variables whose domains still contain are excluded from the pigeonhole count because they may escape by dropping. Once variables must take target values, the propagator can detect an insufficient union of targets and can remove a saturated Hall set from other domains. A matching-based propagator could enforce stronger generalized arc consistency (Régin 1994), but the implementation does not maintain that additional state. The monic option concerns vertex injectivity only. Parallel source edges may still share one target edge.
The epic option requires a total morphism whose vertex map covers every target vertex. find_span rejects this option because a span deliberately permits a partial right leg. Total-morphism entry points first reject impossible cardinalities, then use branch and bound with surjectivity checked on complete assignments. The check occurs inside optimization rather than after it. Filtering the unconstrained optimum could miss a more expensive assignment that is surjective.
The iso option asks the span search for a common induced sub-schema that is optimal under the packed objective, rather than under cardinality alone. This requires the right leg to reflect arcs as well as preserve them: between mapped vertex pairs, source and target arcs must agree as multisets of direction and edge kind. The implementation adapts the partitioning algorithm of McCreesh et al. (2017) to the network objective. Initial classes use vertex kind and self-loop descriptors. Each mapped pair refines the remaining classes by the multiset of incoming and outgoing edge kinds relative to that pair. Edge names stay out of the labels because they belong to the approximate score rather than structural feasibility.
The induced search measures reward relative to the all-drop assignment and maximizes that reward, which is equivalent to minimizing the original packed cost after its preconditions have been checked. It also reads hard apex constraints when a drop decision makes a required partner unavailable. The use of and its consequences for propagation are closely related to the constraint model analyzed by McCreesh et al. (2016). For a total isomorphism request, the public entry point additionally requires full coverage of both vertex sets and constructs a bijective edge map.
Assembling the result
Once a solver returns an assignment, SpanSearch collects every source vertex assigned a target and calls induce_on_vertices. Induction restricts all schema fields to the chosen vertex set and validates the result against the supplied protocol. The left leg is the identity on the apex’s vertices and edges. The right leg uses the assignment for vertices and the same edge-selection function used by naturality and edge scoring.
The ordinary right leg chooses an exact kind-and-name edge when one exists and otherwise the least same-kind edge. The iso path instead constructs a kind-preserving bijection within each pair of mapped endpoints, preferring equal names before pairing the remaining parallel edges. This distinction is recorded because injectivity on vertices does not imply injectivity on edges.
The span carries both measurements and a certificate. quality reads the assignment’s primary cost, including the alignment-evidence component when its weight is nonzero. It does not include the packed secondary reward for retaining more vertices. apex_coverage records the fraction of source vertices retained. quality_bounds converts the solver’s lower and upper primary-cost bounds into the corresponding interval on the higher-is-better quality scale.
The certificate records whether optimality was proved, which solver path ran, and which limit was reached. Its shape distinguishes vertex injectivity from edge-image injectivity and records whether the left inclusion covers the whole source. Both legs are checked for functoriality. Separate existence reports are computed for the two codomains, conditional obligations being available when the caller supplied the relevant theory registry. The certificate also records whether the induced apex has an entry vertex, a content digest of the apex, and the decode order used for exact tie-breaking. Find a span between two schemas shows how these fields are read through the public interface.
Boundaries of the implementation
The search maps each source edge to one target edge. It does not map an edge to a target path, so it implements the length-one fragment of the functorial schema translations described by Spivak (2012). A correspondence that requires an intermediate target vertex or several target fields belongs in a value-level transform rather than in this vertex assignment. Migrations as morphisms develops that boundary.
The objective reads vertex identifiers, vertex kinds, outgoing edges, and edges between candidate endpoint images. Required edges, variants, recursion points, schema-span annotations, and hyper-edge signatures affect feasibility without affecting the finite score. Other schema fields, including value constraints, usage modes, defaults, and policies, are restricted during induction or checked later but do not distinguish two feasible assignments here. An existence report may consequently mark a selected leg invalid even when another equally scored assignment would have passed.
The five component weights have not been fitted to labeled correspondences. Work on schema and ontology matching shows that aggregation and extraction choices can materially change the resulting alignment [Do & Rahm (2002); Meilicke & Stuckenschmidt (2007); Faria et al. (2013)]. Exact optimization guarantees an argmin of the stated cost function. It does not establish that the cost function ranks the intended correspondence first.
The remaining input to that cost function is the confidence table. Alignment evidence explains how panproto constructs it and where hard pins still differ from soft evidence.
Alignment evidence
Automatic alignment begins with candidate pairs of source and target vertices. panproto calls each candidate an anchor. An anchor records the pair, a confidence, a strategy tag, the provenance of the comparison, and an explanation. The search has not accepted the pair merely because an anchor names it.
The implementation has two routes from anchors to a search. The EvidenceTable API can score every candidate pair and pass those scores to a span search. The automatic lens generator currently takes a different route: it selects a one-to-one seed map, places those pairs in SearchOptions::hard_pins, and then compares that pinned search with a second search in which the strategy pins have been released. The evidence-table route supplies soft scores, whereas auto-lens temporarily restricts the search with provisional pins and then compares the pinned and released results.
Active proposal strategies
The auto-lens pipeline calls twelve strategy emitters. Their schedule is fixed by Stringency.
| Tiers | Strategy | Input used |
|---|---|---|
| Every tier | Exact | Equal vertex identifiers with compatible kinds |
| Every tier | ExactSuffix | Equal terminal dot-segments with compatible kinds and constraints |
| Every tier | EdgeLabel | Child vertices reached by edges with the same label and edge kind |
| Balanced and above | Alias | The alias dictionary, applied to leaf identifiers or outgoing edge labels |
| Balanced and above | TokenSimilarity | Tokens and character bigrams from vertex identifiers |
| Balanced and above | DescriptionSimilarity | Text stored in a vertex’s description constraint |
| Lenient and above | WrapUnwrap | Corresponding field-label groups in flat and nested records |
| Lenient and above | TypeSignature | Multisets of outgoing edge kinds and target-vertex kinds |
| Lenient and above | WlRefinement | Singleton color classes after Weisfeiler-Leman refinement |
| Lenient and above | Neighborhood | Child pairs propagated from a selected parent-pair map |
| Exploratory | Structural | Degree and incident edge-kind profiles |
| Exploratory | Coerce | A registered coercion witness between different vertex kinds |
Three details qualify this table. First, ExactSuffix and EdgeLabel run even at Strict; strict mode thus runs more than exact identifier equality. Second, Coerce emits proposals and witness metadata, but the morphism-search domains still exclude kind-mismatched targets. A coerce proposal cannot steer the current search, though callers can inspect it in AutoLensResult::coerce_proposals. Third, neighborhood propagation is a second pass. Auto-lens aggregates and selects the other strategies to obtain parent seeds, emits neighborhood anchors from those seeds, and then aggregates the full pool again.
The StrategyTag enum has fourteen variants because it also reserves UserHint and Llm. Neither variant names an auto-lens strategy emitter. The hint-taking auto-lens APIs put caller mappings directly into hard_pins; after the search, they construct UserHint anchors for the returned explanations and candidate metadata. Llm is an extension point. No production function emits that tag, and no auto-lens configuration field accepts language-model proposals. A caller can still construct either kind of anchor and use the public evidence API directly.
From anchors to scores
The reducer in panproto_mig::align::evidence produces one score for each pair mentioned by at least one anchor. It discards anchors with a NaN confidence. Every other raw confidence is clamped to the unit interval and capped by its Provenance.
Under the default StrictPriority aggregation policy, the fourteen tags occupy priority bands of width . Let be the tag’s rank, with zero denoting UserHint and thirteen denoting Llm, and let be the clamped, provenance-capped confidence. The effective value is
The implementation performs this as one division. Adjacent bands share an endpoint, so a tag’s weakest value can equal the strongest value in the band immediately below it. The ordering is thus non-strict at those boundaries. The alternative ConfidenceFirst policy omits the bands and uses directly.
The reducer next groups anchors by the input from which they were computed. For each family it retains the largest effective value.
| Family | Tags in the family |
|---|---|
| User hint | UserHint |
| Identifier | Exact, ExactSuffix, Alias, TokenSimilarity |
| Edge label | EdgeLabel, WrapUnwrap |
| Documentation | DescriptionSimilarity |
| Structure | TypeSignature, Neighborhood, WlRefinement, Structural, Llm |
| Coercion | Coerce |
Alias is the one branch-sensitive case. A leaf alias compares identifiers and belongs to the identifier family. A composite alias compares outgoing edge labels and belongs to the edge-label family. The anchor’s provenance distinguishes these branches.
If is the maximum for family , with zero for a family that emitted nothing, the ordinary family mean is
The fixed divisor makes this mean monotone under literal pool inclusion: adding an anchor can increase a family maximum or leave it unchanged. A divisor that counted only the families that fired could fall when a weak new family appeared.
User hints receive one additional rule. If is the largest capped confidence among UserHint anchors for the pair, the reported score is
Thus the often useful bound applies only to a pair without the hint override. Evidence from ordinary families cannot exceed , while one full-confidence UserHint anchor yields a score of . The unit tests assert both cases.
Before neighborhood propagation, auto-lens also adjusts the confidences already in the pool by required-set agreement. It adds when both vertices are required, subtracts when only one is required, and clamps the result. UserHint anchors are exempt. Neighborhood anchors are appended after this adjustment and thus do not receive it.
Selection
Aggregation and selection are separate public operations. EvidenceTable::select accepts a configurable RowFilter and Cardinality rule. A row filter first applies an absolute threshold, then retains candidates within a relative delta of the best score for the same source. Strict permits one selected pair at each endpoint. Permissive admits a conflict when no previously accepted conflict has a strictly better score. Hybrid uses that permissive rule at lower confidence and permits up to card + 1 selected pairs at an endpoint above high_conf. The final greedy pass is deterministic because it sorts by score and then by the two identifiers.
Auto-lens does not expose those choices through AutoLensConfig. Both of its seed-selection passes use Cardinality::Strict with RowFilter::relative_only(). The resulting seed map has at most one pair at either endpoint, the absolute threshold is zero, and the relative delta is the library default of . The default absolute floor and the hybrid cardinality constants do not govern auto-lens seed selection.
Selection is also absent from the evidence-aware network builder. A caller that passes an evidence table to a span search gives the builder all pair scores; the solver chooses a complete assignment under the structural constraints and objective.
The evidence-aware span API
SpanSearch::with_evidence attaches an evidence table to the cost-function network. For source vertex , target vertex , and source vertex set , the builder adds the unary term
This term does not remove a target from a domain, add a hard constraint, or change the variable set. A score outside is rejected when the network is built. For a fixed non-negative weight, increasing a pair’s score can only lower the cost of assignments that use that pair.
The shipped anchor weight is . With the default CostWeights, every evidence table thus contributes the same zero-weight term and cannot change the selected span. Callers can supply a non-zero weight through SpanSearch::with_weights; the tier and monotonicity tests do so to exercise the evidence term.
This path is public, but it is not the route used by current production code in the repository. The ordinary find_span helpers construct SpanSearch with NoEvidence, and the auto-lens source does not call with_evidence. Current production behavior should thus be described through provisional pins, not through the reward-only term.
The auto-lens route
The single-result auto-lens pipeline resolves the strategy pool to a strict seed map and merges those seeds into SearchOptions::hard_pins without replacing caller pins. A hard pin collapses one source vertex’s domain to the named target, plus the option to drop that vertex. The first search thus gives selected strategy proposals the force of domain restrictions.
Auto-lens then runs a released search whenever the strategies added at least one pin. This second search retains the caller’s original pins and removes the strategy pins. The single-result APIs compare the two answers by the search objective: higher alignment quality wins, followed by more mapped source vertices. The pinned answer remains when both measures tie. Since releasing strategy pins adds domain values, the released search ranges over a superset of the pinned search when all other options are fixed.
The multi-candidate APIs use a different comparison. They choose between the pinned and released candidate lists by their best coverage, and they return a fully covering pinned list without running the released comparison. Claims about objective-based comparison should thus be limited to auto_generate and auto_generate_with_hints.
Caller hints remain hard on both attempts. The public hint parameter and SearchOptions::hard_pins express fixed correspondences in current auto-lens behavior. A soft user hint exists at the evidence-table level, but production auto-lens does not route hints there.
Monotonicity and stringency
Evidence aggregation is monotone under pool inclusion. If pool contains every anchor in , then every score produced from is at least the corresponding score from . This property follows from the per-family maxima, the fixed divisor, and the maximum with the hint confidence.
An evidence-aware network has the same feasible assignments for every evidence table. Under a non-zero anchor weight, pointwise domination of one evidence table by another gives a non-increasing cost for every assignment and hence for the optimum. Under the shipped zero weight, all such costs are equal.
Stringency tiers do not guarantee pool inclusion. WlRefinement uses two iterations at Lenient and three at Exploratory; another refinement round can split a color class and withdraw an earlier anchor. Neighborhood depends on a selected seed map, so a larger first-pass pool can change the seeds and withdraw propagated anchors. The integration tests include a concrete Lenient-to-Exploratory case in which a neighborhood anchor disappears, the evidence score falls, and an anchor-weighted optimum becomes worse. General tier monotonicity is thus false. The tests assert monotonicity only when the higher tier’s evidence table dominates pointwise, and they separately check that any shortfall comes from WlRefinement or Neighborhood.
Production auto-lens adds further tier-dependent behavior: Strict and Balanced ask for total morphisms, Lenient and Exploratory permit spans, and the latter tiers enable overlap retries by default. The pool-inclusion theorem for aggregate does not extend to the full auto-lens tier ladder.
Defaults and evidence for the design
panproto has no labeled corpus of intended schema correspondences. The priority order, family partition, provenance ceilings, strategy thresholds, scoring coefficients, and objective weights have not been fitted to panproto data.
The align::defaults module centralizes the provenance ceilings, the general selection defaults, and the shipped anchor weight. It does not contain every numeric choice in the alignment pipeline. Tier thresholds live in auto_lens.rs, while the required-set adjustment lives in align/mod.rs. Strategy-specific mixture weights, confidence floors, and fixed confidences live with their emitters. An audit of all alignment numbers thus spans several files.
Prior work supplies design precedents rather than a validation of these settings. COMA evaluated composite similarity aggregation and matrix-selection rules (Do and Rahm 2002). The analysis of mapping extraction by Meilicke & Stuckenschmidt (2007) supports treating selection as a separate stage. AgreementMakerLight supplies precedents for provenance weights and cardinality-aware selection, though panproto’s constants are not corpus fits (Faria et al. 2013). The reducer also avoids Dempster-Shafer combination because several strategies are deterministic readings of the same input rather than independent sources, the condition required by that combination rule (Dempster 1967). None of these results establishes that panproto’s current family partition or parameter values are optimal.
The evidence-aware span API provides the soft scoring route. Current auto-lens behavior still passes through selected provisional pins, which is why Searching for a morphism treats evidence and domain restrictions separately.
See also
- Searching for a morphism for the objective and span-search constraints.
- Find a span between two schemas for the public span-search interface.
- Migrations as morphisms for the morphisms produced by the search.
- What panproto verifies for the mechanically checked properties.
- CLI reference for the user-facing stringency and hint options.
Lenses and round-trip laws
Suppose a source record contains name and age, while a view exposes only name. Reading the view discards age, but a later update to name should preserve it. A lens coordinates this forward observation and backward reconstruction (Foster et al. 2007). In panproto, the forward operation returns both the view and a complement, a record of information needed to reconstruct the source. This explicit complement is related to the constant-complement view-update account and later symmetric lenses [Bancilhon & Spyratos (1981); Hofmann et al. (2011)].
A concrete Lens stores a compiled migration together with its source and target schemas. Its operations are fallible because migration execution or reconstruction may reject a concrete input. For a fixed lens, their mathematical shape is
In Rust, get returns a WInstance view and a Complement, and put consumes the edited view and complement to reconstruct a source WInstance. The complement represents the original source; callers do not pass it separately to put.
Round-trip laws
Three equations describe the expected interaction between these operations. Writing , GetPut requires . If , PutGet requires the view component of to equal . PutPut requires a later update to supersede an earlier update, with the intermediate complement threaded according to the lens.
The implementation provides checks with different scopes. check_laws runs GetPut and PutGet for a supplied lens and source instance. Its PutGet check uses the original view and one fixed scalar mutation, so it is a deterministic smoke check rather than a universal quantification over edits. panproto_lens::laws::check_put_put is a separate operation. It gets the original complement, performs an initial put, gets the complement of that intermediate source, and compares a sequential second put with a direct second put from the original complement.
Property tests exercise the equations over generated lens and instance families. These tests can reveal failures within those families, but they do not prove lawfulness for every migration or input. Constructing a Lens also does not run every law check automatically. What panproto verifies distinguishes these forms of evidence.
Complement composition
Composed lenses must also combine their complements. The ComplementCompose extension trait defines a checked partial composition through compose and is_compatible. Two nonzero source-schema fingerprints must agree; zero represents an unspecified fingerprint. Conflicting nonzero fingerprints produce ComplementFingerprintMismatch. If both complements store a value for the same key, those values must agree or composition returns ComplementConflict. Collection-valued fields are combined without duplicate elements.
These conditions prevent composition from selecting arbitrarily between incompatible saved states. They establish compatibility of the concrete complements being composed, rather than lawfulness of the underlying lenses.
Lens construction
Migrations as morphisms describes the compiled migration that supplies a concrete lens’s transformation tables. A migration and its endpoint schemas can be assembled into a Lens, after which callers may run the law checks above. This construction does not imply that every migration is lawful.
The panproto-lens-dsl crate compiles declarative Nickel, JSON, or YAML descriptions into lens combinators. The panproto-lens::protolens module describes schema-parameterized transformations that can be instantiated when their structural preconditions hold. Edge kinds select optic forms such as lenses, prisms, affine traversals, and traversals; this dispatch follows the profunctor-optics account of Pickering et al. (2017) and its categorical formulation in Clarke et al. (2024). The representation of schema migrations as a graph of lenses is related to Cambria (Litt et al. 2020).
EditLens::put_edit translates a view edit back to the source and updates the stored complement at the same time. Edit lenses model changes through edit monoids and actions (Hofmann et al. 2012). The subtree-complement rules here are panproto-specific: deleting a subtree clears its complement entries, while inserts, relabels, and field updates maintain the relevant saved state. The edit-law helpers compare this incremental result with whole-state get and put for one supplied edit.
SymmetricLens::from_span requires its two asymmetric legs to share a middle schema. The current equality check compares the protocol, vertices, edges, hyperedges, constraints, required edges, variants, orderings, recursion points, and nominal flags. It deliberately omits byte-layout constraints and usage modes, and it also does not compare NSIDs, entry lists, schema-span annotations, coercions, mergers, defaults, policies, or derived adjacency indices. Acceptance by this constructor is thus equality under that implemented projection, not complete Schema equality.
Layout enrichment
Layout preservation is described at the schema level rather than as an ordinary WInstance lens. The parse_emit_protolens construction strips EnrichmentKind::Layout from its source theory and adds the enrichment to its target theory. Its ComplementConstructor::Enrichment records the enrichment kind and synthesis driver. apply_theory_transform_to_schema interprets these transformations by removing layout constraints or dispatching to the registered driver that synthesizes them.
This protolens describes a relation between abstract and layout-decorated schemas. It can participate in protolens composition, but instantiating it does not itself parse or emit bytes. The operational parsing and decoration entry point is ParserRegistry::decorate; formatted output is produced by pretty_with_protocol and emit_pretty_with_protocol. Layout enrichment gives the byte-level and schema-level accounts.
See also
- Lens DSL: denotational semantics for the formal lens model and law statements.
- Protolens composition for vertical and sequential composition.
- Layout enrichment for abstract and decorated schemas.
- Lens combinator reference for the combinator algebra.
Layout enrichment
Parsing source code produces more than an abstract syntax tree. The panproto tree-sitter walker records the syntax tree together with source positions, text between named children, and traces used to replay grammar choices. This additional data is the layout enrichment. Removing it yields an abstract schema; adding it through decorate yields a schema that the source emitter can use.
This chapter covers the layout constraints, the forget_layout and decorate operations, the law exercised by their tests, and the registry that connects the parser and lens crates. Source-code emission describes the grammar walker that consumes the result.
Abstract and decorated schemas
AbstractSchema and DecoratedSchema wrap the same underlying Schema type. The distinction is enforced at their constructors. AbstractSchema::from_layout_free rejects a schema containing layout constraints, while the parser and ParserRegistry::decorate are the normal producers of decorated schemas.
The layout predicate is is_layout_sort. It includes start-byte, end-byte, doc-prefix, blank-lines-before, and every constraint whose sort begins with interstitial-, ptrace-, or chose-alt-. These constraints record byte spans, omitted text, anonymous grammar tokens, and evidence about the selected CHOICE branch.
Some parse-time constraints remain on the abstract schema because the canonical emitter treats them as content or syntax evidence. In particular, literal-value, pre-alias-symbol, and field:* are not layout sorts. Removing them would discard leaf text or the information needed to select an aliased or field-bound production.
DecoratedSchema::forget_layout removes exactly the constraints recognized by is_layout_sort and returns an AbstractSchema. The underlying Schema::forget_layout operation is idempotent and prunes empty per-vertex constraint entries. It does not modify vertices, edges, entry points, or non-layout constraints.
Decoration
ParserRegistry::decorate accepts a protocol name, an abstract schema, and a LayoutPolicy. Its implementation has two steps. First, emit_pretty_with_policy renders the abstract schema with the registered grammar. The registry then parses those bytes again, allowing the ordinary parse walker to attach byte spans, interstitials, and choice traces.
The reparse assigns new vertex identifiers. Some grammars also consolidate tokens that the emitter encountered separately, so decorate does not promise a vertex-for-vertex correspondence with its input. The implementation instead compares the multiset of vertex kinds and the multiset of edge shapes.
Writing for forget_layout and for decoration under policy , the tested section law is
The decorate_section_law integration test checks both equalities on JSON and LilyPond samples. A separate LilyPond regression test checks that ordered children remain interleaved through a repeated choice, since kind counts alone would miss a reordering. The JSON policy test also checks that non-default newline and indentation settings affect the rendered bytes. These are finite regression tests, not a proof for every registered grammar.
Decoration can fail before the reparse. The registry reports an unknown protocol, rejects a mismatch between the parser protocol and the schema protocol, and propagates emitter errors such as a missing grammar.json, an unknown vertex kind, or an unsatisfied required field. A parse error after emission indicates that the canonical output did not satisfy the registered grammar.
The policy surface
LayoutPolicy is an alias for the emitter’s FormatPolicy. It carries the indentation width, token separator, newline sequence, and the token sets that request line breaks or open and close indentation. LayoutPolicySpec is the serializable form used in a theory transform. Conversion between the two copies every field.
The policy supplies canonical layout when the abstract schema contains no replay evidence. It cannot reconstruct whitespace or comments that were removed by forget_layout. Byte-preserving reconstruction depends on retaining the original decorated schema or another complement that contains its layout constraints.
Cross-crate registration
Grammar-specific decoration lives in panproto-parse, while schema transforms live in panproto-lens. The dependency direction prevents the lens crate from calling the parse crate directly. LayoutEnricher is the narrow interface between them.
When ParserRegistry::register accepts a parser, it installs a LayoutEnricher under the pair (EnrichmentKind::Layout, protocol_name). Registration is process-global. Registering the same pair again replaces the previous driver, and poisoned registry locks are recovered before access continues.
parse_emit_protolens records this arrangement as a Protolens. Its source transform is StripEnrichment(Layout), its target transform is AddEnrichment with the selected driver and policy, and its complement constructor names the layout enrichment. Applying StripEnrichment removes layout sorts; applying AddEnrichment looks up the driver and runs the emit-and-parse procedure described above.
parse_emit_protolens describes the schema-level relation. Byte-level work remains with ParserRegistry::decorate, pretty_with_protocol, and emit_pretty_with_protocol; ordinary complements store discarded WInstance data rather than per-vertex layout constraints. The asymmetric get and put API is not the operational interface for parsing and emission.
Limits
decorate chooses canonical layout and cannot infer an absent original. Its section law ignores vertex identifiers and compares kind and edge multisets. The parse-emit protolens describes schema transforms but does not turn source bytes into a WInstance lens. Exact replay thus requires a retained decorated schema; decorate supplies canonical layout only.
See also
- Source-code emission describes production walking, layout replay,
CHOICEdispatch, and verification tiers. - Decorate an abstract schema gives the operational workflow.
- Round-trip with format preservation covers structured-data codecs whose guarantees differ from the tree-sitter path.
- Architecture explains the crate dependency direction behind the enrichment registry.
Source-code emission
A tree-sitter grammar specifies how source text is parsed, but it does not by itself define a printer. Whitespace is normally an extra, external scanners may recognize tokens whose spelling is absent from grammar.json, and several alternatives can produce the same named children. panproto’s emit_pretty handles this incomplete inverse by combining grammar structure with evidence stored during parsing.
There are two emission cases. A parsed schema may carry enough byte positions and interstitial text to replay source fragments. An abstract or hand-built schema has no such record, so the emitter walks the grammar and chooses canonical tokens and layout. These inputs determine whether emit_pretty attempts source replay or follows the canonical production walk.
The structured-data codecs described in Round-trip with format preservation use a separate path. This chapter concerns parsers registered through ParserRegistry with a vendored tree-sitter grammar.json.
The production model
At registration time, panproto deserializes grammar.json into a Production tree. The enum covers tree-sitter’s sequences, choices, repetitions, optional productions, fields, aliases, symbols, string and pattern terminals, token wrappers, precedence wrappers, reserved contexts, and blanks. Emission starts at the schema’s entry vertices and walks the production associated with each vertex kind.
The walker consumes schema edges through a cursor. Field productions look for an edge with the same field name, while ordinary symbols use child_of edges. Repetition advances through as many compatible unconsumed edges as its body accepts. Missing rules and unsatisfied required fields produce ParseError::EmitFailed rather than partial output.
The grammar constructor precomputes yield sets and a subtype relation for dispatch. Hidden rules and declared supertypes are expanded, named aliases contribute their exposed kinds, and an iterative Tarjan computation closes the dispatch graph. The emitter can thus test whether a concrete child kind is admitted at a symbol without recursively searching the grammar on every use.
Layout roles
Literal grammar tokens receive a structural TokenRole: bracket open or close, separator, keyword, operator, connector, terminal, or immediate token. The layout pass uses adjacent roles to decide whether a separator is needed. IMMEDIATE_TOKEN also emits an explicit NoSpace marker, which takes priority over ordinary separation.
Bracket recognition is partly structural and partly conventional. The positional classifier first looks for the standard pairs (), [], and {} within a sequence. A fallback recognizes first-and-last punctuation pairs, word-like pairs such as begin and end, and same-text delimiters when an IMMEDIATE_TOKEN supplies evidence that they are tight. Word-like delimiters receive bracket behavior for block structure but keyword behavior for spacing.
Indentation is deliberately narrower than bracket recognition. Word-like delimiter pairs open an indentation scope. For punctuation delimiters, a brace pair opens a scope when its body contains a repeated production, including a limited look-through for an optional repeated rule. Parentheses and square brackets remain inline even when they contain repeated arguments or items.
These rules provide defaults rather than a language formatter. A FormatPolicy controls separator text, newline bytes, indentation width, and configured break or indentation tokens. Language cassettes can override scanner facts that the production tree does not expose, including tight operators, newline-producing externals, and raw content that must abut its delimiters.
Replaying captured layout
The parse walker records start-byte and end-byte constraints, anchored interstitial-N fragments, choice traces, and leaf literal-value constraints. A leading byte run outside the document root, such as a byte-order mark, is stored as doc-prefix. Layout enrichment gives the complete division between layout and content constraints.
emit_pretty attempts verbatim subtree replay when the recorded fragments tile a vertex’s entire original byte span. It gathers literal and interstitial fragments from the reachable subtree, orders them by their recorded positions, rejects holes or inconsistent spans, and emits a Verbatim token only when the span cursor reaches the recorded end byte exactly. Fragment text may have changed length after parsing; coverage is determined from the original positions, while the edited text is what replay emits. If the check fails, the emitter returns to the production walk for that subtree.
External scanner text or a newly inserted child may leave a gap in the recorded span. Treating an incomplete fragment set as source would silently omit bytes; declining replay keeps the output on the grammar-derived path.
AstParser::emit is the direct position-fragment reconstruction API for a parsed schema. emit_pretty is the production-driven API used for hand-built schemas and by ParserRegistry::pretty_with_protocol. The latter can still exploit complete replay evidence when it is present.
Choosing a grammar alternative
The selector resolves a CHOICE from local evidence when a field name distinguishes the alternatives, a literal child matches one string alternative, or only one branch admits the first unconsumed edge. An internal acceptance predicate states this test inductively over production trees. It accounts for fields, symbols, aliases, nullable sequence prefixes, nested choices, and transparent wrappers.
Ambiguous choices require more evidence. Parsed schemas can carry anonymous token traces in ptrace-*, field-bound literal values in field:*, the pre-alias grammar symbol in pre-alias-symbol, positional interstitials, and chose-alt-* witnesses. The selector uses these constraints to reject alternatives that contradict a token set, an alias source, or the named children produced by the original parse. It also prevents one recorded separator from being consumed repeatedly at later choice sites.
When no trace settles the choice, the selector uses grammar-derived yield sets, required fields, nullable alternatives, and deterministic defaults. A blank branch is preferred when the child cursor is exhausted. If several yield-compatible alternatives remain, higher tree-sitter precedence wins. This process is deterministic, but it cannot recover a decision for which the schema and grammar carry no distinguishing fact.
External scanner tokens
External scanners are code, and grammar.json records their token names rather than all text they may produce. The emitter resolves an external token from the most specific available source.
An anonymous alias can supply literal text directly, and a choice pairing an external symbol with a string can identify an equivalent spelling. A parsed leaf may instead carry its actual literal-value. Remaining cases use a GrammarCassette.
The cassette lookup checks a per-grammar implementation first and then common_external_default. The common layer recognizes recurring conventions for newlines, automatic semicolons, immediate markers, scanner-state sentinels, and string or heredoc placeholders. A placeholder whose text depends on the source emits an empty default when no captured literal is available. Per-grammar implementations cover names or lexical requirements that do not follow those conventions.
Verification tiers
ParserRegistry::emit_verification_status reports Verified, Generic, or Unsupported. Unsupported means that the protocol is not registered. A registered protocol outside the verified allowlist is Generic; the grammar path exists, but the test suite does not make the stronger promise represented by Verified.
The verified allowlist has two admission routes. Corpus verification runs the grammar author’s corpus through a strict oracle. For source , define
The corpus oracle requires , equality of vertex-kind multisets between parse(s) and parse(e1), and equality of their edge-shape multisets. It does not require : canonical formatting may change the original bytes. The other admission route covers a transpilation backend with dedicated regression tests over the constructs that backend emits. A backend-verified protocol has not thereby passed every entry in its upstream grammar corpus.
The allowlist is kept in sorted order because the status lookup uses binary search. A single hand-written sample is insufficient for admission; the code comments record an earlier broad promotion that was reverted after corpus testing found failures.
Limits
Canonical emission has four material limits. First, a synthesized schema may omit the literal or field evidence needed to distinguish choice branches with the same children. The emitter then makes a deterministic default choice. Second, source-dependent external tokens such as heredoc bodies and raw-string content need captured literal-value constraints; without them, placeholder defaults may emit no text.
Third, the emitter does not add parentheses from an expression precedence analysis. A parsed schema can retain explicit parentheses through its syntax and layout evidence, but a hand-built expression can be ambiguous or reparse with a different tree. Finally, Generic status records that a grammar is available, not that arbitrary emitted output has passed a round-trip corpus oracle.
The tree-sitter walk that produces the schema has a separate nesting bound. WalkerConfig::max_depth defaults to 128 and returns ParseError::NestingTooDeep beyond that depth. Wide sibling lists do not consume this depth; a regression test covers a 20,000-element array and checks linear walking and byte-exact replay.
Exact source preservation and canonical generation thus have different inputs. Retain the decorated schema for replay. Use Decorate an abstract schema when an abstract schema should receive one canonical layout, and consult the verification status before treating a protocol’s emitter as a checked backend.
See also
- Layout enrichment documents
forget_layout,decorate, and their tested section law. - Decorate an abstract schema gives the synthesize-and-render workflow.
- Parse full ASTs describes the parser side.
- Reference: protocol catalog lists registered protocols.
Composing protocols by colimit
Several protocol registrations use the same small theories. ThGraph supplies Vertex, Edge, src, and tgt. ThConstraint supplies a dependent constraint sort and its target vertex. ThMulti adds edge labels to distinguish parallel edges. Separate instance theories, including ThWType, describe nodes, arcs, and values.
Rather than duplicate these declarations, panproto combines theory presentations over explicitly shared sorts and operations. The relevant construction is a colimit. Using colimits to assemble specifications from component theories follows the Clear structured-specification work [Burstall & Goguen (1977); Burstall & Goguen (1980)]. Given a diagram of theories and structure-preserving maps, its colimit identifies the specified common structure and retains the remaining declarations and equations.
The registered construction
The constrained-multigraph group illustrates the process. Its first pushout combines ThGraph with ThConstraint by identifying Vertex. A second pushout combines that result with ThMulti by identifying both Vertex and Edge. ThWType is registered separately as the group’s instance theory. Schema structure and instance structure thus remain distinct even when a protocol registration names both.
The helper pushout_by_name constructs identity-by-name inclusions for the shared sorts and operations. It first requires every requested name to exist on both sides, then invokes the GAT colimit construction. Construction checks that the resulting cocone commutes. The returned ColimitResult also exposes verify_universal for checking factorization against a supplied alternative cocone; that stronger check is not implicit in every call to pushout_by_name.
The shared registration helpers treat a failed built-in composition as a programming error and panic with a message naming the failed step. Other protocol-specific registration paths handle composition errors locally.
Universal characterization
Suppose compatible maps send each input theory into another theory . The universal property states that these maps factor uniquely through the colimit. This property characterizes which identifications the construction introduces: the mediating map is determined by the compatible input maps, rather than by an additional choice made during composition.
Runtime construction and universal characterization have different scopes. pushout_by_name validates names and constructs a commuting cocone. verify_universal evaluates the additional mediator condition for an alternative cocone supplied to it. Pushouts and merge states the formal condition and the scope of the implementation checks.
Reusable theories
The shared library is defined in crates/panproto-protocols/src/theories.rs.
| Theory | Declared structure |
|---|---|
ThGraph | Vertices and directed edges with source and target. |
ThConstraint | Vertex-indexed constraints and their targets. |
ThMulti | Edge labels for parallel edges. |
ThWType | Instance nodes, arcs, and values linked to schema structure, with two equations relating arc endpoints to schema-edge endpoints. |
ThMeta | Discriminators, extra fields, and values attached to nodes. |
ThGraph, ThConstraint, ThMulti, and ThMeta declare no equations. ThWType declares arc_src_anchor and arc_tgt_anchor. The library also defines composed or higher-level theories including ThSimpleGraph, ThHypergraph, ThInterface, ThFunctor, ThFlat, and ThGraphInstance. A protocol registration selects the theory group appropriate to its structures and separately supplies parsing, emission, and protocol-specific rules. Build a custom protocol describes that registration process.
Related work
Data exchange supplies universal solutions and chase-based composition [Fagin et al. (2005); Fagin et al. (2005)]. CQL treats schemas as algebraic theories or categories and uses pushouts for data integration [Schultz & Wisnesky (2017); Schultz et al. (2017)]. Apache Calcite (Begoli et al. 2018), Substrait, Apache Arrow, and MLIR provide related engineering precedents for intermediate representations. panproto applies GAT colimits to reusable descriptions of wire-format schemas. Related work develops these comparisons.
See also
- Schemas as theories for theory presentations and their models.
- Pushouts and merge for the universal property and its checks.
- Schema version control semantics for structural merge of concrete schemas.
Schema version control semantics
panproto-vcs stores immutable schema-related objects in a content-addressed directed acyclic graph (DAG). Mutable branch and tag references point into that graph. Its command vocabulary includes familiar operations such as init, add, commit, branch, merge, log, and diff, but the stored objects and merge algorithm operate on parsed schema structure rather than lines of source text.
This distinction changes the form of a conflict. A text merge reports overlapping edits to lines. A schema merge compares vertices, edges, constraints, and the other fields of the common representation, then reports incompatible structural edits as typed conflict values. Syntax and protocol validation still run separately; structural merge alone does not guarantee that every merged schema is valid.
Objects and references
Every object identifier is a BLAKE3 digest of the object’s type-specific canonical serialization. Objects are stored under .panproto/objects/, branch references under .panproto/refs/heads/, and tag references under .panproto/refs/tags/. The filesystem store writes objects and refs through a flushed sibling temporary file followed by a same-directory rename. It recomputes an object’s identifier when reading it and returns ObjectCorrupted if the stored object does not match its path. Ref names must consist only of ordinary relative path components.
| Object | Contents |
|---|---|
FileSchema, SchemaTree, FlatSchema | Per-file schema content, a tree root used by commits, and a flattened migration endpoint. |
Migration | A map between identified source and target schemas. |
Complement, CstComplement | Saved data for inverse migration and concrete-syntax reconstruction. |
DataSet | Instances associated with a particular schema. |
Protocol, Theory, TheoryMorphism, Expr, EditLog | Protocol and transformation metadata referenced by other objects. |
Commit | A SchemaTree root, parent commits, protocol and author metadata, and identifiers for associated migrations, data, complements, edit logs, theories, and renames. |
Tag | An annotated reference to another stored object. |
A branch is a mutable reference rather than an immutable object. A commit may have more than one parent, so the commit relation forms a DAG rather than a simple sequence.
Validation at stage and commit
Migration validation checks that mapped vertex and edge identifiers exist at both endpoints and that each mapped edge lands between the images of its source endpoints. The same structural obligation is checked during migration compilation, where a failure is reported as NotAMorphism. By default, migration errors make staged content invalid and block commit with VcsError::ValidationFailed.
Verification can be bypassed deliberately at two points, and both have to be taken for unverified material to reach a commit. AddOptions::skip_verify and AddDataOptions::skip_verify permit an object to remain pending at stage time; a default commit then refuses it, because “not checked” and “checked and passed” are different states and only the second is a pass. CommitOptions::skip_verify is what accepts a pending or invalid stage, and the resulting commit records on its unverified field what it accepted, so the bypass is legible in the history rather than only at the moment it was taken. These options weaken the repository invariant and should be treated as explicit overrides rather than ordinary workflow.
When a registered protocol theory contains equations, model validation checks the supplied finite model against them. The evaluator considers at most 10,000 variable assignments for an equation; exceeding that bound returns ModelCheckLimitExceeded instead of accepting the equation. If no theory is registered, validation records an advisory that no equations were checked while retaining the available structural checks. ThWType, used as an instance theory by several protocol groups, contains two equations connecting arc endpoints to schema-edge endpoints. The schema theory and instance theory remain separate, so the presence of those equations does not mean that panproto_schema::validate evaluates them over a Schema.
Structural three-way merge
Let be a common base and and the schemas on the two branches. The categorical account reads merge as a pushout of the divergent changes over their base (Mimram and Giusto 2013):
The implementation constructs with a field-by-field structural three-way merge. Compatible additions and modifications are combined. Incompatible edits become MergeConflict variants, and conflicted elements retain their base values until the caller supplies a resolution. apply_resolutions requires a choice of ours or theirs for every reported conflict and then verifies the resulting square.
Combining compatible additions means that two branches adding the same name compatibly contribute one element rather than one each, so is the pushout quotiented by same-name identification. MergeResult::identified_additions reports every name collapsed this way. Pushouts and merge states what that quotient costs.
The routine verify_pushout checks the generated cocone: both branch vertex maps must be total, every merged vertex must come from a branch, surviving base vertices must remain present, and the two paths from the base must agree on mapped vertices and mapped base edges. A failure returns VcsError::PushoutVerification. This is a cocone check, not a complete runtime proof of the universal property.
verify_pushout_universal provides an additional on-demand check against a caller-supplied alternative cocone. It constructs and checks a mediator on vertices. The current API does not establish edge-level factorization, and ordinary merge does not call this verifier. Pushouts and merge states the distinction formally.
Merge also computes a pullback of theory presentations derived from the base-to-branch diffs. The resulting PullbackOverlap is diagnostic metadata in MergeResult; the field-by-field merge does not use it to decide whether additions are identical or conflicting. If pullback construction fails, the result stores a pullback_error and the CLI reports it. The failure is not interpreted as an empty overlap.
Data associated with history
Commits may reference data sets and migration complements. During a committed merge, data from both parents is lifted to the merged schema, fresh complement objects are recorded, and duplicate migrated data sets are removed. Rebase and cherry-pick similarly lift the replayed commit’s data and call verify_square. Despite its name, that function checks one necessary condition: it migrates the lifted data backward through the saved complement and requires recovery of the original data. This is GetPut for the vertical migration, or the square condition with an identity horizontal edge. It does not compare the two paths around a general data-migration square. These operations follow the schema-evolution account of migrations connected by lenses in Cambria [Litt et al. (2020); Litt et al. (2021)].
Ordinary commit records data that was staged for that commit; it does not automatically copy or migrate all data from the previous commit. Amend preserves existing data identifiers unless the caller stages replacements. A schema change made through either operation thus does not by itself imply that associated data was transformed.
Related work
The categorical account of patches and merge also includes homotopical patch theory (Angiuli et al. 2014) and Darcs (Roundy 2005). Work on schema evolution includes the PRISM workbench and its schema-modification operators (Curino et al. 2008). panproto combines these ideas with content-addressed storage for protocols, schemas, data, migrations, and complements. Related work gives the broader comparison.
See also
- Init and commit for the practical workflow.
- Branch and merge for conflict handling.
- Bridge to git for using panproto-vcs alongside git.
- Pushouts and merge for the formal model.
- What panproto verifies for merge-time checks.
What panproto verifies
panproto uses verification for checks with different logical strengths. A runtime gate can reject one schema or migration. An exhaustive checker can establish a property of one finite model. Unit tests, property tests, and corpus sweeps supply evidence about the implementation, but none quantifies over every input. This distinction is the verification ladder: each result should be read at the level where it was obtained.
No part of panproto has been proved correct in a proof assistant, and the test suite is not a mathematical proof of the algorithms. The mechanically checked claims are narrower: malformed inputs are rejected at named boundaries, bounded searches report whether optimality was established, and enumerative test oracles exercise the implementations on enumerable cases.
Checks on a particular operation
Schemas, theories, and migrations
panproto_schema::validate checks five structural conditions on a supplied schema: recognized vertex kinds, permitted edge shapes, recognized constraint sorts, existing endpoints for required edges, and existing endpoints for recursion points. It returns findings; callers decide whether those findings block an operation. The schema validate command treats them as errors and also type-checks the equations in the registered protocol theories. It does not evaluate those equations in a model built from the schema.
Equation satisfaction is a separate finite-model check in panproto-gat/src/check_model.rs. For each equation, it enumerates the product of the variables’ finite carriers and compares both sides. The default limit is 10,000 assignments per equation. A carrier product above the limit returns ModelCheckLimitExceeded instead of passing a truncated check. Within the limit, an empty violation list establishes satisfaction for the supplied finite model and the interpretation of operations used to build it.
The VCS path in panproto-vcs/src/repo.rs runs that bounded equation check when a protocol theory is registered. Staging records an invalid status, a default commit rejects invalid staged diagnostics, and a clean automatic merge rejects equation errors before recording its commit. An unregistered protocol yields an advisory note because no protocol equations were checked. Staged data is parsed and checked against the schema it is being recorded under, so the schema_id a data set carries is an association the repository established rather than one it merely recorded. This gate is optional at two points, and both are explicit: AddOptions::skip_verify and AddDataOptions::skip_verify leave the stage pending, and a default commit refuses a pending stage rather than treating it as nonblocking, so bypassing the check at stage time is not on its own enough to commit. CommitOptions::skip_verify is the second override; it accepts pending and invalid staged diagnostics, and records what it accepted on the commit’s unverified field, so a commit made that way stays distinguishable afterwards from one whose contents were checked. Skipping the data check does not skip reading the data: bytes that cannot be read as records of the schema cannot be recorded under it at all.
The standalone schema verify command reports a three-valued outcome rather than a boolean. passed means every requested theory typechecked and every equation of it was enumerated exhaustively and held. failed means an equation was refuted. incomplete means at least one theory was never checked, because it does not typecheck or because its assignment enumeration exhausted --max-assignments; such a theory establishes nothing about the schema either way, so a run containing one cannot report the schema verified however many other theories passed. The exit status is nonzero for both failed and incomplete, and --format json carries the same status per theory and for the run. --allow-incomplete accepts an incomplete run for exploratory use; it exits zero but the output still names the theories that were not checked, and it never downgrades a refutation.
Migration existence and migration compilation are also separate. check_existence returns the unconditional structural findings and any conditional obligations available from the supplied theory registry; schema check turns an invalid report into a nonzero exit. compile checks that the mapped fragment is a theory morphism, including target landing and endpoint preservation for the mapped fragment. Compilation does not call check_existence, so compiling a migration establishes the mapped-fragment condition alone.
Protocol registration is another construction-time gate. The ATProto registrar composes theories through pushout_by_name and returns ProtocolError::TheoryRegistration naming the composition stage if a step fails, writing nothing into the destination registry unless every theory was built; callers propagate that failure rather than proceeding with a registry that is missing a composite. The remaining built-in registrars compose nothing and so cannot fail that way. Every registrar runs validate_rewrite_system and panics when analysis fails or reports a non-joining critical pair or a lexicographic-path-order violation. User-supplied theory compilation returns RewriteSystemCheck or UnsoundRewriteSystem for the corresponding failures. This gate establishes the implemented local-confluence and termination criteria, not every semantic property of the registered theory.
Search results
SpanSearch::run builds a cost function network, solves it, and induces an apex from the chosen source vertices. Induction restricts every schema field in its own key space, rebuilds the adjacency indices, and calls schema validation; a failing apex is returned as SpanError::Apex. Network construction and the isomorphism path can also refuse with explicit errors rather than reporting an absence of correspondence.
A returned SpanCertificate records the solver’s conclusion. When proven_optimal is true, the lower and upper quality bounds coincide. A budgeted search that has an incumbent may instead return proven_optimal: false, a widened quality_bounds, and the budget in limit_hit. The certificate is metadata produced by the solver; it is not an independently checkable proof object.
The same certificate records whether both span legs passed the mapped-fragment morphism check and carries separate existence reports for the two legs. Those fields report findings. Span construction does not reject a result whose right leg fails one of them. The induced apex, by contrast, is a gate because construction returns an error when induction cannot produce a schema accepted by validate.
Total-morphism entry points preserve a different distinction. Ok(None) and an empty MorphismList mean that a completed search found no total morphism, while a network that could not be built or a search that stopped before its first complete assignment returns Err(SpanError::...). find_morphisms returns optimal morphisms rather than the whole hom-set; every request is capped at DEFAULT_OPTIMA_CAP, currently 1,024, and MorphismList::truncated reports when exact enumeration found more optima than it returned.
Lenses, coercions, and expressions
check_laws is an on-demand runtime check of GetPut and PutGet on a supplied instance. GetPut compares the complete instance structure. PutGet is compared modulo derived fields and is exercised on the current view plus one deterministic mutation; this does not establish PutGet for every possible view. PutPut has a separate checker and broader property-test coverage. None of these checks runs automatically on every get or put.
The edit-lens checkers compare translate-then-apply with apply-then-get, and compare the updated complement with the complement obtained by a whole-state get. Both operate on one supplied edit and instance. Complement composition separately rejects source-fingerprint mismatches and conflicting stored values, while vertical protolens composition rejects unequal intermediate endofunctors. These are local composition preconditions rather than proofs of the lens laws.
Declared coercion classes receive sample-based checks. The checked elementary constructors, the lens DSL compiler, and the default theory DSL compiler reject an Iso or Retraction whose expressions fail the required round trip on their finite sample set. The theory DSL exposes compile_unchecked, and the elementary API retains unchecked constructors. Passing the checked path supplies evidence over those samples only.
Expression evaluation is bounded on every call by configurable step, recursion-depth, and list-length limits. The defaults are 100,000 steps, depth 256, and 10,000 list elements. Integer operations use checked arithmetic, and division and remainder reject zero divisors. A graph-traversal builtin evaluated without an instance returns NoInstanceContext; InternalDispatch is reserved for a builtin routed to the wrong internal category handler. These errors make evaluation fail explicitly. They do not prove termination without a bound or semantic correctness of the expression.
Pushouts and merges
colimit and pushout_by_name construct inclusion maps and check cocone commutativity. They deliberately do not run check_morphism on the inclusions at construction, since some building-block theories refer to sorts supplied only by a later composition. The raw colimit_by_name function returns only the amalgamated theory and performs no cocone check.
ColimitResult::verify_universal is an on-demand checker for one supplied alternative cocone. It constructs a mediator, checks that mediator as a theory morphism, and compares both factorization paths. Construction does not invoke this checker automatically, and checking a supplied cocone is not a formal proof over all possible cocones.
A clean automatic schema merge calls panproto_vcs::merge::verify_pushout before creating a merge commit. A clean merge requested with no_commit or squash does not take that commit path. apply_resolutions, rebase, and cherry-pick also call the verifier at their corresponding resolved boundaries. The function checks vertex-map totality of the two merge legs, coverage of merged vertices, survival of retained base vertices, and cocone commutativity on vertices and mapped base edges. verify_pushout_universal is not called by ordinary merge and checks only a supplied vertex-level alternative cocone; its API has no alternative edge maps, so it cannot establish edge-level universal factorization.
Evidence from the test suite
Solver agreement
The span solver has four principal paths. Bucket elimination computes an exact optimum when the width-derived memory budget fits (Dechter 1999). Otherwise, hybrid best-first search interleaves best-first selection with bounded depth-first branch and bound (Allouche et al. 2015). EDAC* cost propagation tightens the lower bounds used by that search [Larrosa & Schiex (2004); de et al. (2005)]. Injective searches require distinct target vertices through a counting all-different propagator (McCreesh and Prosser 2015). Isomorphism searches partition the two schemas to find a maximum common induced sub-schema (McCreesh et al. 2017).
The test oracle in solve::oracle::brute_force independently walks every assignment of a network whose domain product is at most 100,000. It uses the same cost-function network and evaluator as the solver, so it checks optimization and decoding after network construction; it is not an independent specification of the objective or of how schemas become networks. A separate property test compares the oracle’s domain walk with another enumeration.
Generated tests compare the solver paths with that oracle on the reported optimum, the returned assignment’s cost against an untouched network, membership in the oracle’s argmin set, and the canonical tie-break where the path promises one. Property tests also check that soft-consistency cost shifts preserve the cost of every assignment, the equivalence notion used for weighted constraints (Cooper et al. 2010), and exercise the saturating cost arithmetic near its boundaries. These are sampled tests with shrinkable counterexamples.
The checked-in ATProto corpus contains 77 lexicons, hence 5,852 ordered pairs. The ordinary correctness sweep searches every pair and requires proven_optimal; its network-shape snapshot records induced width 1 for 5,168 pairs and width 2 for 684. A scheduled corpus gate also compares the solver with brute force on the 2,773 pairs whose assignment products fit the oracle ceiling, across both the span and total-morphism networks. The remaining 3,079 pairs have a solver certificate but no exhaustive corpus oracle. Another scheduled gate repeats all 5,852 span searches in sixteen processes and compares the complete spans to detect dependence on hash seeding.
The corpus sweep contains a 50 ms per-pair assertion for release builds. The standard pull-request workflow runs the correctness test in a debug build, where that timing assertion is skipped. The number is thus a release-test threshold for this corpus, not a complexity bound or a per-commit performance guarantee.
Lens and emitter coverage
Lens property tests generate identity and projection lenses, nested instances, vertex and edge remaps, field transforms, put-side views, edit words, and complement constructors. They exercise GetPut, PutGet, PutPut, edit-action coherence, edit-lens consistency, complement coherence, and complement-cost composition. A passing run says that the generated cases passed under that run’s property-test configuration.
The source emitter has a programmatic two-basis status. VERIFIED_EMIT_PROTOCOLS contains 255 of the 261 vendored grammars. Of those, 248 appear in CORPUS_VERIFIED; the scheduled all-features corpus gate requires every vendored upstream corpus entry to reach an emit fixed point while preserving vertex-kind and edge-shape multisets. The other seven are admitted by dedicated backend regression tests over the constructs their transpilers emit. Thus the Verified status does not mean full-corpus coverage for all 255. Source-code emission defines the status and its two admission routes.
Claims outside the ladder
The checks above leave several properties open:
- The optimizer minimizes its implemented objective. The four structural weights have not been fitted to a labeled correspondence corpus, and the shipped anchor-evidence weight is zero. Solver correctness does not establish that the objective ranks mappings as a schema author would.
- The search objective and hard constraints omit schema-level value constraints such as
maxLength. Existence checking can reject a proposed migration that tightens such a constraint, but search does not use the constraint to choose another optimum. - The searched morphisms map an edge to one edge. A correspondence from one field to a path or to several fields is outside this search space, even though value-level transforms can compute such data.
- Exact-inference time and space depend exponentially on induced width. The corpus contains only widths 1 and 2, so its timing says nothing about wider pairs, lens composition, colimit construction, or migration application in general.
- Lens property tests do not make lossful transforms invertible. Dropped data round-trips only when the complement retains the information needed to reconstruct it.
- Theory pushout construction checks its cocone, and the optional checker handles a supplied alternative cocone. Schema merge checks a cocone plus a vertex-level factorization when explicitly asked. Neither path supplies a formal proof of the full universal property for arbitrary theory or schema inputs.
- Isomorphic protocol theories do not make two protocol parsers or emitters equivalent. Parser behavior, layout preservation, and application invariants remain properties of their respective implementations and declared constraints.
See also
- Find a span between two schemas describes the search interface and its certificates.
- Schema version control semantics gives the merge and commit behavior.
- Lenses and round-trip laws develops the instance-level laws.
- Migrations as morphisms separates morphism checking from existence.
- Pushouts and merge states the categorical construction.
Architecture
panproto is a Rust workspace with an acyclic Cargo dependency graph. panproto-expr sits below the GAT layer because directed equations and several policy types may contain expressions. The schema and instance crates depend on these foundations, while panproto-core provides a facade over the operations used by most language bindings and the CLI.
Read this chapter after Schemas as theories and Migrations as morphisms. Use the graph as a boundary map and the crate map for the crate-by-crate inventory.
Layering
graph TD
subgraph "Surfaces"
CLI[panproto-cli<br/>schema binary]
WASM[panproto-wasm]
PY[panproto-py]
C[panproto-c]
end
subgraph "Facade"
CORE[panproto-core]
end
subgraph "Higher operations"
VCS[panproto-vcs]
GIT[panproto-git]
XRPC[panproto-xrpc]
GITREMOTE[panproto-git-remote]
PROJ[panproto-project]
CHECK[panproto-check]
end
subgraph "Pipeline"
MIG[panproto-mig]
LENS[panproto-lens]
LENSDSL[panproto-lens-dsl]
IO[panproto-io]
PARSE[panproto-parse]
GRAMMARS["panproto-grammars<br/>+ grammars-{all,web,data,jvm,<br/>scripting,systems,functional,<br/>devops,mobile,music}"]
end
subgraph "DSLs and protocol definitions"
PROTOS[panproto-protocols]
THEORYDSL[panproto-theory-dsl]
EXPRPARSER[panproto-expr-parser]
DSLEVAL[panproto-dsl-eval]
end
subgraph "Theory and data model"
GAT[panproto-gat]
GATMACROS[panproto-gat-macros]
SCHEMA[panproto-schema]
INST[panproto-inst]
end
subgraph "Expression foundation"
EXPR[panproto-expr]
end
CLI --> CORE
WASM --> CORE
PY --> CORE
C --> CORE
CORE --> VCS
CORE --> GIT
CORE --> PROJ
CORE --> CHECK
CORE --> MIG
CORE --> LENS
CORE --> IO
CORE --> PARSE
VCS --> MIG
GIT --> VCS
XRPC --> VCS
XRPC --> SCHEMA
GITREMOTE --> VCS
GITREMOTE --> GIT
GITREMOTE --> XRPC
PROJ --> SCHEMA
CHECK --> MIG
LENS --> MIG
MIG --> SCHEMA
LENS --> SCHEMA
LENSDSL --> LENS
IO --> INST
PARSE --> LENS
PARSE --> GRAMMARS
LENS --> EXPR
MIG --> EXPR
EXPRPARSER --> EXPR
PROTOS --> SCHEMA
PROTOS --> INST
THEORYDSL --> GAT
THEORYDSL --> SCHEMA
THEORYDSL --> PROTOS
THEORYDSL --> EXPR
THEORYDSL --> EXPRPARSER
THEORYDSL --> LENS
THEORYDSL --> DSLEVAL
SCHEMA --> GAT
SCHEMA --> EXPR
INST --> GAT
INST --> SCHEMA
INST --> EXPR
GAT --> EXPR
An arrow points from a crate to one of its dependencies. The diagram omits many direct edges and feature-gated dependencies; each crate’s Cargo.toml is authoritative. The ten panproto-grammars-* pack crates are grouped together: each re-exports a subset of tree-sitter grammars under feature flags.
panproto-parse depends on panproto-lens; the lens crate does not depend on the parser crate. They meet through the enrichment_registry module in panproto-lens, which defines traits and a registry that downstream parser implementations populate. panproto-parse installs adapters so that protolens interpretation can request grammar-driven enrichment without introducing a tree-sitter dependency into panproto-lens. Layout enrichment describes this boundary.
The boundaries
The language bindings translate between panproto’s Rust representation and external runtimes. Each boundary uses a different ownership and serialization strategy.
WASM boundary
JavaScript reaches panproto-core through wasm-bindgen in panproto-wasm. Structured data crosses the boundary as MessagePack, while a slab of opaque integer handles retains Rust-owned resources. The TypeScript SDK (@panproto/core) manages initialization and handles and provides typed wrappers.
Python boundary
Python uses native PyO3 bindings in panproto-py. Its default Rust feature is group-core, and the wheel workflow builds with that default, which includes eleven core tree-sitter grammars. Companion panproto-grammars-* packs expose category-specific selections and a group-all selection containing all 261 declared grammars.
C boundary
panproto-c defines the C ABI with safer-ffi and serializes structured payloads with CBOR. The Haskell and Swift bindings call this interface.
The generated CLI reference
The CLI reference is generated from schema --help by xtask/src/bin/gen-cli-docs.rs. Publication regenerates the page and fails when the committed file differs.
Versioning
The publishable panproto-* crates inherit the workspace package version and are released together. The xtask tooling package is an exception with its own 0.0.0 version. Release checks keep language-binding metadata aligned with the workspace release. See the changelog for release history.
See also
- Crate map for one-line descriptions of every crate.
- What panproto verifies for the per-crate properties checked.
- Composing protocols by colimit for the GAT-engine consumer pattern.
Related work
panproto uses generalized algebraic theory presentations, schema maps, lens complements, and source-layout records. This chapter identifies the papers associated with those components and states where the implementation is narrower than the cited construction.
The comparisons are deliberately local. A shared categorical construction or round-trip law does not make two systems equivalent, and the implementation often checks a narrower property than the surrounding mathematical literature studies.
Schemas as theories
Generalized algebraic theories (GATs) provide the formal setting for dependent sorts, operations, and equations [Cartmell (1978); Cartmell (1986)]. The panproto-gat crate implements these ingredients as Sort, Operation, Equation, and Theory, with type checking for terms and equations. Its dependent SortExpr can apply a sort to argument terms. This is the concrete sense in which panproto uses GATs.
Categorical database work supplies two related accounts of schemas and instances. Spivak treats a schema as a category and an instance as a set-valued functor (Spivak 2012). Schultz and Wisnesky instead present database schemas and instances through equational theories, with the longer mathematical development given by Schultz and colleagues [Schultz & Wisnesky (2017); Schultz et al. (2017)]. Attributed C-sets develop functorial data structures for scientific computing (Patterson et al. 2022), while Lu studies several database models through a thin-category construction (Lu 2025). These systems establish useful points of comparison for schemas-as-mathematics, but their instance categories and migration operations should not be identified with panproto’s merely from the shared vocabulary.
The binary panproto-gat::colimit function combines two Theory values over explicit legs from a shared theory. It implements an amalgamated union, including compatible same-name identification outside the shared image, and rejects non-injective legs rather than constructing their quotient. pushout_by_name constructs identity-on-name legs before calling this function. Theory assembly by colimits has a longer history in the Clear specification language and in institutions [Burstall & Goguen (1977); Burstall & Goguen (1980); Goguen & Burstall (1992); Sannella & Tarlecki (2012)]. The repository does not define or verify an institution for its GAT representation.
Ologs provide another categorical account of labeled knowledge structures (Spivak and Kent 2012). panproto schemas also have named vertices and edges, but their labels are checked against protocol and schema structures rather than interpreted as the natural-language boxes and arrows of an olog. The resemblance is representational, not an implemented translation.
Protocol definitions in panproto-protocols are Rust values built from the GAT API. The catalog thus demonstrates that the same engine can host many theory presentations. The code does not construct one global theory automatically merely because those definitions coexist; callers must supply the diagram or shared theory used by colimit, colimit_by_name, or pushout_by_name. Composing protocols by colimit describes those operations in detail.
Bidirectional transformations
Bidirectional-transformation work includes asymmetric lenses, which pair a forward view function with an update function and impose round-trip laws (Foster et al. 2007), and relational lenses, which adapt that account to database views (Bohannon et al. 2006). The constant-complement view-update account preserves information outside a view (Bancilhon and Spyratos 1981), and symmetric lenses use complements to mediate updates between peers (Hofmann et al. 2011). Edit lenses instead model changes through edit monoids and actions (Hofmann et al. 2012), while delta lenses propagate changes rather than replacement states [Diskin et al. (2011); Pacheco et al. (2012)].
The panproto-lens crate implements an asymmetric get and put over WInstance values. get returns a view and a Complement; put uses both to reconstruct a source. The law helpers compare instances structurally, and check_optic_laws exercises the obligations available for a concrete lens and instance. These checks apply to their supplied cases. They do not prove the laws for every possible instance.
Profunctor optics organize lenses, prisms, traversals, and related optics by the structure required of the carrier [Pickering et al. (2017); Clarke et al. (2024)]. Dependent optics extend the account to indexed settings (Vertechi 2023). panproto’s OpticKind uses the familiar names Iso, Lens, Prism, Affine, and Traversal, but its classifier inspects TheoryTransform variants. A scoped transform is refined from its schema edge kind: item or items yields a traversal carrier, variant yields a prism carrier, and other edges use a lens carrier. This is a structural classifier, not an implementation of the indexed-category construction in the cited dependent-optics work; corresponding optic laws require a separate concrete check.
Point-free and delta-lens calculi study algebraic composition and calculation principles for bidirectional programs [Pacheco & Cunha (2011); Pacheco et al. (2012)]. ProtolensChain also composes transformations, and it offers fused and sequential instantiation. That shared concern does not establish an equivalence between its Rust representation and either calculus. The chain tests and check_optic_laws, rather than a transfer theorem from the cited calculi, determine which properties panproto currently checks.
Cambria organizes schema versions as a graph connected by bidirectional lenses [Litt et al. (2020); Litt et al. (2021)]. panproto likewise composes migrations and complements, though its schema nodes, migration representation, and storage layer are defined independently in the panproto-mig, panproto-lens, and panproto-vcs crates. Cambria is consequently relevant to the schema-version graph pattern without serving as a specification for those modules.
Format-preserving parse and emit
Format-preserving transformation has both lens-based and grammar-based precedents. Augeas applies lenses to configuration files (Lutterkort 2008). Boomerang adds resource-sensitive alignment to a lens language, and quotient lenses reason modulo selected equivalences [Bohannon et al. (2008); Foster et al. (2008)]. BiYacc derives a parser and reflective printer from a bidirectional grammar (Zhu et al. 2015). Token-origin tracking offers a different reconstruction method for preserving layout during tree transformation (Jonge and Visser 2012), while resugaring work studies when an expanded program can recover surface syntax (Pombrio and Krishnamurthi 2014).
panproto starts from tree-sitter grammars rather than a bidirectional grammar language (Brunsfeld 2018). The parse walker stores byte spans, interstitial text, literal values, and choice evidence as schema constraints. emit_pretty either replays a subtree whose fragments tile its span or walks grammar.json to produce canonical source. decorate renders an abstract schema and parses the rendered bytes to recover a fresh layout enrichment.
The implementation thus combines ideas that the cited systems separate: grammar-directed construction, stored source fragments, and complement-like metadata. Its guarantees remain those of its own tests. A verified corpus case must reach an emission fixed point and preserve vertex-kind and edge-shape multisets; byte equality with the original source is not part of that oracle. Direct fragment replay can be byte-exact when the recorded fragments completely tile the source span.
Structured version control
Patch theory supplies categorical accounts of files, edits, and merge. Mimram and Di Giusto model patches and study pushout-based reconciliation (Mimram and Giusto 2013). Homotopical patch theory places patch laws in a higher-dimensional setting (Angiuli et al. 2014), and Darcs provides an earlier algebra of patches in a working version-control system (Roundy 2005). These works motivate the use of spans, cocones, and universal properties when discussing merge.
The panproto-vcs merge implementation is a structural three-way merge over Schema fields. It computes diffs from a base to two branches, accepts compatible one-sided changes, and records typed conflicts for incompatible changes. It also computes a theory-level pullback overlap and stores the result as diagnostic metadata. The field merge does not use that overlap to make its decisions. A failed pullback is retained as diagnostic data and does not masquerade as an empty overlap.
After the caller supplies a resolution for every conflict, verify_pushout checks totality of both vertex maps, coverage of merged vertices, survival of retained base vertices, and cocone agreement on vertices and mapped base edges. Despite its name, this function checks necessary cocone conditions rather than the full universal property. verify_pushout_universal constructs and checks a unique mediator only on vertices; its documentation defers edge-level factorization. The code thus supports a delimited pushout account, not a proof that every merge result is a pushout in the full schema category.
Content addressing comes from the Merkle-tree and git traditions [Merkle (1988); Chacon & Straub (2014)]. CommitObject stores a schema-tree identifier, parent commits, an optional migration, protocol metadata, data-set identifiers, migration-complement identifiers, edit logs, theory identifiers, and CST-complement identifiers. The object store has distinct variants for data sets, migration complements, and CST complements. These fields are independently addressable objects linked by a commit; the code does not collapse them into a single four-field mathematical object.
Database schema-evolution systems provide a more operational comparison. PRISM associates schema modification operators with forward and backward dependencies for data and query migration (Curino et al. 2008). panproto’s existence checking, compilation, lifting, and inversion cover related questions for its Migration type, but neither the available operators nor their inverse classifications are identical to PRISM’s. In particular, CoercionClass classifies individual coercion witnesses as Iso, Retraction, Projection, or Opaque; it is not a reimplementation of PRISM’s operator table.
Conflict-free replicated data types guarantee convergence by constraining replicated operations (Shapiro et al. 2011). panproto-vcs instead computes a repository merge and may return conflicts requiring a resolution. The APIs solve different coordination problems, so convergence properties from CRDTs do not transfer to the VCS merge.
Cross-schema translation
Data exchange studies mappings between source and target schemas, universal solutions, cores, and the chase (Fagin et al. 2005). Work on composition shows why a mapping language may need second-order tuple-generating dependencies to remain closed under composition (Fagin et al. 2005). CQL develops migrations in an algebraic database setting [Schultz & Wisnesky (2017); Schultz et al. (2017)]. These results help distinguish existence, construction, composition, and information loss. panproto does not expose universal solutions or second-order tgds as aliases for its lens chains, and CoercionClass does not decide whether a target instance is a core.
The panproto-mig pipeline checks migration existence, compiles surviving vertices and edges, lifts instances, composes migrations, and inverts bijective migrations. Its hom_search API treats schema matching as an optimization problem. find_span can return a partial overlap, while find_morphisms and find_best_morphism restrict the same search to total morphisms. The current edge map sends each source edge to one target edge. It cannot map an edge to a longer path or express a one-to-many field correspondence, a limit documented on FoundMorphism.
Systems such as Apache Calcite separate a common relational representation from backend adapters (Begoli et al. 2018). panproto also separates protocol-specific definitions from shared algorithms, but its common values are schemas, theories, migrations, and instances rather than query plans. The adapter analogy describes software organization only; the repository does not implement Calcite’s relational algebra, optimizer rules, or calling conventions.
Language and construction semantics
These chapters specify the behavior of panproto’s expression language, DSL compilers, lens composition, pushout construction, and theory REPL. Formal notation is used where it makes an implemented rule more precise. A displayed equation is not, by itself, a claim that the implementation has been formally verified.
Shared notation introduces the symbols used in the other chapters. The vocabulary in plain terms and Schemas as theories introduce the mathematical vocabulary.
| Page | What it pins down |
|---|---|
| Shared notation | Judgment forms, environments, semantic functions, errors, and equality. |
| Expression language | panproto-expr: abstract syntax, best-effort type classification, and resource-bounded evaluation. |
| Lens DSL | panproto-lens-dsl: get and put with an explicit returned complement, the round-trip checks, and complement composition as a checked partial operation. |
| Theory DSL | panproto-theory-dsl: GAT presentations, compilation, typechecking, and the boundary of the CwF interpretation. |
| Pushouts and merge | The GAT colimit construction, its on-demand universal-property check, and the narrower checks run by schema merge. |
| Protolens composition | Protolenses as natural transformations between schema endofunctors, the structural-equality criterion for composition, sequential vs fused instantiation. |
| REPL command language | The REPL (schema theory repl, part of panproto-cli): state model, command interpretation, and the bare-term typecheck path. |
What panproto verifies records where each runtime check or test is applied and distinguishes exhaustive checks from sampled tests.
Shared notation
The semantics chapters use inference rules for static relations and equations for mathematical or operational functions. An inference rule has premises above a horizontal line and a conclusion below it. A semantic equation uses double brackets, as in , to give the value of syntax in an environment . These notations specify behavior; they do not imply that a theorem prover checks the specification.
A context (also called an environment) is a finite map from names to data. A typing context maps variable names to types. An evaluation context maps variable names to values. A theory context maps sort names to their definitions.
Environments
A typing environment, written , maps variables to types; extends it with a variable of type . A value environment, written , maps variables to values, and extends it with a value. A theory context, written , records the sorts available in a generalized algebraic theory (GAT) presentation.
A judgment of the form asserts that under typing context , the expression has type . The expression-language page does not assign this judgment to panproto-expr, whose current classifier is intentionally weaker.
Inference rules
An inference rule has the form
Each premise and the conclusion are judgments. The rule asserts that whenever the premises hold, the conclusion follows. A derivation is a tree of rule applications whose leaves are axioms (rules with no premises) and whose root is the judgment being proved.
Semantic functions
The semantic function for a syntactic category is written where is the semantic domain. The subscript is omitted when context determines the category. In the expression chapter, returns either a value or an ExprError under resource configuration . In the lens chapter, the denotation of a lens is a triple consisting of a forward function, a backward function, and a complement-producing function. A theory denotation is mathematical notation; the compiler returns a panproto_gat::Theory value rather than a separately represented semantic model.
Errors and partiality
The symbol denotes undefinedness only on pages that introduce it explicitly. panproto-expr instead returns a concrete error sum, including StepLimitExceeded and DepthExceeded. Keeping errors separate preserves distinctions that a single bottom element would erase.
Equality
Equality depends on the operation. Values ordinarily use the equality implemented by their Rust types. Some schema checks compare complete structures, while others compare identifiers, fingerprints, or specified multisets. Morphism checks compare endpoints and assignments. Each chapter states the equality used by the corresponding implementation check.
Expression language applies these conventions to the resource-bounded evaluator.
Expression language: operational meaning
panproto-expr evaluates pure value computations used by field transforms and queries. For a fixed expression, environment, instance context, and resource configuration, evaluation is deterministic and performs no external input or output. Resource exhaustion is reported through ExprError. The surface parser lowers Haskell-like notation to a small abstract syntax tree, but the accompanying type classifier detects only a subset of type errors.
Surface syntax
The grammar below is a fragment of the implemented parser:
expr ::= literal
| ident
| expr expr
| "\\" ident "->" expr
| "let" ident "=" expr "in" expr
| "if" expr "then" expr "else" expr
| "case" expr "of" alts
| expr "." ident
| expr "[" expr "]"
literal ::= int | float | str | bool | "Nothing"
| "[" expr,... "]"
| "{" ident ["=" expr],... "}"
The optional expression in a record field permits punning: {x} means {x = x}. The parser also implements operators, closed ranges, list comprehensions, do notation, and where clauses. Open-ended ranges such as [a..] are rejected. These forms lower to the smaller abstract syntax below; in particular, conditionals lower to Match, since Expr has no If variant.
Abstract syntax
The current Rust enum has the schematic shape below; omitted module paths and derives make the listing non-runnable.
Expr = Var(name)
| Lam(parameter, body)
| App(function, argument)
| Lit(literal)
| Record(fields)
| List(items)
| Field(record, name)
| Index(list, index)
| Match { scrutinee, arms }
| Let { name, value, body }
| Builtin(operation, arguments)
Match tries arms in source order. Patterns include wildcards, variables, literals, records, lists, and constructors. The authoritative definitions are in crates/panproto-expr/src/expr.rs.
Lightweight type classification
The classifier’s result type, ExprType, has seven cases. We write the possible cases as the type grammar :
List does not record an element type, Record does not record field types, and the classifier has no function type. Null, byte values, and closures classify as Any.
The function infer_type(e, env) classifies an expression under an environment from variable names to ExprType. Literals, records, lists, variables, and builtins with declared result signatures receive specific cases. Lambdas, applications, field access, and index access return Any. A match takes the class of its first arm’s body, while a let extends the environment with the class inferred for its bound expression. An unbound variable is an error. The classifier does not inspect a builtin application’s argument vector.
validate_coercion accepts Any because the classifier cannot reject an opaque expression. The classifier also does not apply the evaluator’s first-class-builtin fallback to Var: a builtin represented as a free variable is unbound for infer_type unless an earlier parser or compiler lowered it to Expr::Builtin or supplied an environment entry. Thus successful validation means that no detectable mismatch was found. It does not establish a typing judgment of the form , type preservation, or exhaustiveness.
Evaluation domain
Values are Rust Literals: integers, floats, strings, booleans, null, bytes, lists, records, and closures. Let be this set of values and the set of ExprError variants. A configuration sets the maximum number of evaluation steps, recursion depth, and output-list length. The defaults are 100,000 steps, depth 256, and 10,000 list elements. The evaluator has the operational type
The plus sign denotes a tagged result containing either a value from or an error from . Resource exhaustion is thus distinct from an unbound variable or invalid builtin argument.
Call-by-value evaluation
Evaluation is call by value (Plotkin 1975). Each recursive call checks the depth bound, then consumes one step through EvalState::tick. Lists and records evaluate their components from left to right. A let evaluates its bound expression before extending the lexical environment, and an application evaluates the function and argument before applying a captured closure.
Using for environment extension, representative equations are:
These equations suppress error propagation, the threaded resource state, and the builtin fallback for a variable absent from . The code passes one mutable budget through every subevaluation, so sibling expressions do not each receive a fresh copy of the original budget.
Pattern matching evaluates the scrutinee once, tries arms in order, extends the environment with bindings from the first matching pattern, and evaluates that arm. Exhaustion returns NonExhaustiveMatch. Higher-order list builtins apply captured closures through the same evaluator.
Builtins are first-class curried values. A free variable whose name denotes a builtin evaluates to the corresponding closure, and an explicit Builtin node with too few arguments evaluates to a closure over the remaining arguments. Lexical environment bindings take precedence, so a lambda or let binding can shadow a builtin name. Graph-traversal builtins use a BuiltinResolver at every application depth. Pure eval returns NoInstanceContext when one is reached; the instance-aware entry points provide the resolver that interprets it.
The exported substitute operation is capture-avoiding for lambda, let, and match-arm binders. It alpha-renames a binder when a free variable of the replacement would otherwise be captured. free_vars applies the same binding rules. These utilities support compiler transformations; the evaluator itself uses closure environments rather than substitution for ordinary function application.
Checked properties and boundaries
For fixed input and configuration, evaluation follows one call-by-value order. Each recursive invocation checks the depth bound and consumes from the shared step budget; list construction, ranges, map, and flatMap enforce max_list_len. Builtin arity and argument checks run during evaluation, after their arguments have been evaluated.
These checks do not establish type preservation for the language. infer_type returns Any for several constructs, chooses the first match arm without comparing the others, and does not validate builtin arguments. The resource bounds establish termination of a bounded evaluator invocation, not strong normalization of the language with those bounds removed.
The evaluator lives in crates/panproto-expr/src/eval.rs, and the classifier lives in crates/panproto-expr/src/typecheck.rs.
See also
- Expression-language reference for the builtin catalog.
- Apply field transforms for usage.
- Lens DSL for expressions inside lens specifications.
Lens DSL: denotational semantics
A lens DSL document describes a bidirectional transformation between schemas. A document may contain field or sort edits, value-level expressions, a composition, a symmetric pair of pipelines, or a schema-dependent generated body. Compilation produces a CompiledLens containing a protolens chain and any value-level field transforms. Deserialization and compilation do not establish the lens laws for every instance.
Lenses and round-trip laws supplies complements and the three laws used here. Expression language supplies the value-level computations embedded in a lens specification.
Surface syntax
Nickel is the canonical authoring form. JSON and YAML represent the same structures through serde.
{
id = "user.v3-to-v4",
description = "Rename `name` and replace `age` with `years`",
source = "dev.example.user.v3",
target = "dev.example.user.v4",
steps = [
{ rename_field = { old = "name", new = "display_name" } },
{ remove_field = "age" },
{ add_field = { name = "years", kind = "integer", default = 0, expr = "old.age" } },
],
}
Each step is a single-key object whose key selects the variant. The full step grammar is in crates/panproto-lens-dsl/src/document.rs.
Abstract syntax
The listing below is a schematic inventory of document fields and step variants. It is text because supporting types, derives, imports, and representation details are omitted.
pub struct LensDocument {
pub id: String,
pub description: String,
pub source: String,
pub target: String,
// Body: exactly one of the six variants is present.
pub steps: Option<Vec<Step>>,
pub rules: Option<Vec<Rule>>,
pub compose: Option<ComposeSpec>,
pub auto: Option<AutoSpec>,
pub from_diff: Option<FromDiffSpec>,
pub symmetric: Option<SymmetricSpec>,
// Modifier: oriented rewrites appended to the compiled chain.
pub directed_equations: Option<Vec<DirectedEquationSpec>>,
// Rule-variant metadata.
pub passthrough: Option<Passthrough>,
pub invertible: Option<bool>,
// Protocol-specific extension metadata.
pub extensions: HashMap<String, serde_json::Value>,
}
pub enum Step {
// High-level field combinators
RemoveField { remove_field: String },
RenameField { rename_field: RenameSpec },
AddField { add_field: AddFieldSpec },
// Value-level transforms
ApplyExpr { apply_expr: ApplyExprSpec },
ComputeField { compute_field: ComputeFieldSpec },
// Structural combinators
HoistField { hoist_field: HoistSpec },
NestField { nest_field: NestSpec },
Scoped { scoped: ScopedSpec },
Pullback { pullback: PullbackSpec },
// Sort-level coercions and merges
CoerceSort { coerce_sort: CoerceSortSpec },
MergeSorts { merge_sorts: MergeSortsSpec },
// Elementary theory operations
AddSort { add_sort: AddSortSpec },
DropSort { drop_sort: String },
RenameSort { rename_sort: RenameSpec },
AddOp { add_op: AddOpSpec },
DropOp { drop_op: String },
RenameOp { rename_op: RenameSpec },
AddEquation { add_equation: EquationSpec },
DropEquation { drop_equation: String },
}
The top-level type is LensDocument. Its source and target fields are schema identifiers, and exactly one of steps, rules, compose, symmetric, auto, or from_diff must be present. A directed_equations modifier may accompany that body and appends oriented rewrites to the compiled chain.
Compilation has two entry points. compile handles the schema-parametric bodies and rejects auto and from_diff with LensDslError::AutoRequiresSchemas. compile_with_schemas also handles those generated bodies. For every nonsymmetric body, it instantiates the chain at the supplied source schema and compares the declared target with the NSID of the output schema’s primary entry. A mismatch yields LensDslError::TargetMismatch. The check is skipped when the declared target is empty or the output schema has no primary-entry NSID, so successful compilation does not always verify the target identifier.
Semantic domain
For source instances , view instances , and complement values , panproto’s operational lens interface has the shape
The complement is returned by get; callers do not pass the original source separately to put. The concrete panproto_lens::Lens stores source and target schemas and a compiled migration containing the field transforms. This is an asymmetric-lens interface with explicit saved state, related to the models of Foster et al. (2007) and Litt et al. (2020).
For a steps body, compilation returns a schema-parameterized chain and a map of field transforms:
Instantiating the chain at a source schema produces a concrete lens. auto and from_diff require the source schema, target schema, and protocol during compilation as well.
The three laws
A lens is lawful when the following equations hold for the relevant source values and views. Write .
For an edited view , write and . Then
The current PutPut checker uses the original view for its first put. If , , and , it checks a supplied second view by comparing
panproto_lens::laws::check_get_put, check_put_get, and check_put_put check supplied instances deterministically. check_put_put obtains the intermediate complement as shown above. The checkers compare complete instance structure through the crate’s instance-equivalence predicate, but each invocation covers only its supplied case.
Semantic equations
Schema-level steps compile to protolenses, which are schema-parameterized lenses described in Protolens composition. Value-level apply_expr and compute_field steps instead compile to FieldTransform values keyed by the body vertex. An add_field step contributes a schema-level protolens and, when it has an expression, a ComputeField transform. The steps are processed from left to right.
Here , and is the target schema after step is applied to . The semicolon denotes sequential lens composition:
The step compiler is the authoritative mapping from every Step variant to these two outputs. Adjacent schema-level steps must satisfy protolens_composable; Protolens composition gives the exact predicate.
Complement composition
Sequential composition combines complements through the partial operation supplied by ComplementCompose. The empty complement is a two-sided identity. Property tests check commutativity and associativity on generated compatible complements. Composition rejects two nonzero, unequal source fingerprints with ComplementFingerprintMismatch; it rejects incompatible values stored under the same keyed complement field with ComplementConflict. Vector and set-like fields are merged with deduplication. ComplementCompose::is_compatible tests whether composition would succeed without allocating the result.
The fingerprint is a 64-bit hash computed by panproto_lens::asymmetric::schema_fingerprint. Compatibility means equality of the resulting nonzero fingerprints, not a proof that distinct schema values are isomorphic.
Checks and limits
Lawful lens composition preserves the three equations when its premises hold. Property tests in crates/panproto-lens/src/laws.rs and constructed DSL cases in crates/panproto-lens-dsl/tests/step_laws.rs test selected generated inputs and step constructors. Finite generated samples do not prove lawfulness for every document or instance.
A coerce_sort step and directed equations are also checked for honesty against registered samples. A detected violation returns LensDslError::CoercionNotHonest. Passing means that the sampled values satisfied the declared round-trip class.
The runtime checkers test one supplied case at a time, while property tests cover generated cases. Documents containing lossy or opaque transforms require scoped law claims; compilation alone does not prove them lawful. The semantics fixes the result of put, not its running time, and preserves step order without defining an equivalence on distinct documents that compile to the same lens.
See also
- Reference: lens combinators for the combinator algebra.
- How-to: write lenses in the lens DSL.
- Lenses and round-trip laws (plain-terms version).
- Protolens composition for schema-parameterized lenses.
- Foster et al. (2007) and Litt et al. (2020).
Theory DSL: compilation semantics
The theory DSL declares the sorts, operations, and equations of a schema language. Compilation produces concrete panproto_gat::Theory, TheoryMorphism, and Protocol values used by term typechecking, morphism checking, and theory composition.
The mathematical reading is a generalized algebraic theory (GAT) presentation. The Rust implementation stores and checks finite presentations. The categorical interpretation below is not represented by a runtime data type.
Shared notation fixes the symbols used below, while Schemas as theories supplies the intermediate-level motivation for the compiler model.
Surface syntax
Nickel is the canonical authoring form; JSON and YAML deserialize to the same document types. The following document declares a small graph theory with identity edges:
{
id = "dev.example.identity-graph",
description = "Directed graph with identity edges",
theory = "IdentityGraph",
sorts = [ { name = "Vertex" }, { name = "Edge" } ],
ops = [
{ name = "src", inputs = [{ name = "e", sort = "Edge" }], output = "Vertex" },
{ name = "tgt", inputs = [{ name = "e", sort = "Edge" }], output = "Vertex" },
{ name = "id", inputs = [{ name = "v", sort = "Vertex" }], output = "Edge" },
],
equations = [
{ name = "src-id", lhs = "src(id(v))", rhs = "v" },
{ name = "tgt-id", lhs = "tgt(id(v))", rhs = "v" },
],
}
This is an illustrative custom theory, not the built-in ThGraph, which contains only Vertex, Edge, src, and tgt. The full document grammar is defined in crates/panproto-theory-dsl/src/document.rs.
Document shapes
The deserialized Rust types have the following schematic shape. The listing omits representation details and is not runnable Rust.
TheoryDocument { id, description, body }
TheoryBody = Theory(TheorySpec)
| Morphism(MorphismSpec)
| Composition(CompositionBody)
| Protocol(ProtocolSpec)
| Bundle(BundleSpec)
| Class(ClassSpec)
| Instance(InstanceSpec)
| Inductive(InductiveSpec)
TheorySpec { theory, extends, imports, sorts, ops,
equations, directed_equations, policies }
TheorySpec and its supporting structs are deserialization targets. A theory body produces one theory. Other body variants may produce morphisms, protocols, composition specifications, or a bundle containing several definitions.
Well-formed presentations
Let be the set of declarations already available in the theory and a term-variable context. A sort is well formed when it is declared in :
An operation declaration is well formed when its input and output sort expressions are well formed, including dependent parameters:
An equation is well formed when both sides typecheck at the same sort:
compile_theory_inner constructs a Theory and calls panproto_gat::typecheck_theory. Term parsing and typechecking failures are returned as TheoryDslError. compile_with_source can attach a span found in JSON or YAML source to a typecheck diagnostic; Nickel evaluation does not retain source positions for this path.
Compilation by body variant
Write for compilation of document with resolver . The result is a finite set of named theories, morphisms, protocols, and composition specifications:
The dispatcher handles the eight variants shown above. Theory, class, and inductive bodies produce theories; morphism and instance bodies produce morphisms checked by check_morphism. Composition bodies resolve their inputs and replay specified colimit steps. Protocol bodies compile their theories and edge rules. Bundles process definitions in dependency order.
compile also sample-checks declared coercion laws using the default coercion registry. compile_with_registry substitutes caller-provided samples, while compile_unchecked skips this particular law check. All three routes still typecheck theories and gate their directed rewrite systems. A passing coercion sample check is evidence for the sampled values, not a proof of the declared coercion class.
Mathematical interpretation
Cartmell develops generalized algebraic theories as a categorical framework for algebraic structure (Cartmell 1986). A category with families (CwF) gives a related categorical model of dependent type theory (Dybjer 1996). In the finite-presentation interpretation used here, a theory morphism maps the sorts and operations of one presentation into another while preserving signatures and equations.
The runtime Theory type is a named collection of sorts, operations, undirected equations, directed equations, and policies with lookup indices. The implementation does not expose a CwF type, construct an initial CwF, or verify an equivalence between schemas and CwF morphisms into finite sets.
Composition and verified boundaries
Composition bodies use the colimit machinery described in Pushouts and merge. The colimit constructor checks cocone commutativity but does not run check_morphism on its inclusions, since a building-block instance theory may refer to sorts supplied only by the schema theory with which it is later combined. ColimitResult::verify_universal validates a constructed mediator for one caller-supplied alternative cocone. Built-in protocol registration assembles schema and instance theories from registered building blocks.
Compilation checks sort and operation references, types both sides of equations, validates resolved morphisms, and tests declared coercion laws on registered samples unless compile_unchecked is used. Passing a finite coercion sample is evidence, not proof. Theory compilation also calls validate_rewrite_system. An analysis failure returns RewriteSystemCheck. UnsoundRewriteSystem reports either divergent rewrite paths that do not rejoin (a non-joining critical pair) or failure of the lexicographic-path-order termination check. Built-in registration treats either result as an internal programming error. These finite checks are not a proof in a proof assistant.
The textual term parser rejects terms nested beyond MAX_TERM_NESTING_DEPTH, currently 128. This is an input-safety bound on the recursive parser and downstream traversals, not a bound on normalization steps. The REPL applies a separate 1,000-step normalization budget.
See also
- Pushouts and merge for the colimit construction.
- Composing protocols by colimit for the built-in theory components.
- Protocol catalog for registered protocols.
- Build a custom protocol for the operational workflow.
Pushouts and merge
When two schema branches diverge from a common ancestor, panproto models their combination using a pushout. Colimits and pushouts have been used both to assemble structured specifications (Burstall and Goguen 1977) and to model the merge of coinitial patches (Mimram and Giusto 2013). The categorical definition and the concrete checks must be kept separate: the GAT layer constructs an amalgamated union with explicit inclusion maps, while the VCS layer checks a finite set of cocone conditions on a resolved schema merge.
A pushout combines both branches while identifying their common image. Its universal property says that every other compatible cocone receives a unique morphism from the pushout. This property characterizes a pushout up to isomorphism; it does not prescribe a textual merge format or a conflict-resolution policy.
Theory DSL introduces the theory presentations and morphisms used below.
The categorical definition
Let be the category of GAT presentations and theory morphisms. Write for the base presentation, for the first branch, and for the second. Given morphisms and , a pushout consists of a presentation and morphisms and .
The cocone equation is
For any other presentation and compatible morphisms and , the universal property requires a unique mediator such that and . The corresponding square is
The GAT construction
The constructor identifies with for every base sort and operation , then merges the remaining sorts, operations, equations, directed equations, and policies. It is an amalgamated union, not an implementation of a general coproduct followed by a coequalizer. Same-name elements outside the base image are also identified when their signatures agree. Incompatible definitions produce SortConflict or OpConflict, and equations that are alpha-equivalent are deduplicated even when their names differ.
A leg that maps distinct base elements to one target element is rejected with NonInjectiveIdentification. The implementation does not compute the quotient required for such a non-injective span.
colimit returns a ColimitResult containing the combined theory and both inclusions. Construction checks the cocone equation. It does not call check_morphism on both inclusions, since some registered building-block instance theories refer to sorts supplied only after composition and are not standalone-total.
The raw colimit_by_name function returns only a merged theory. It builds no inclusion morphisms and thus performs no cocone or factorization check. pushout_by_name builds identity-on-name base legs and delegates to colimit.
The on-demand factorization check
ColimitResult::verify_universal takes a caller-supplied alternative cocone and constructs a mediator from the assignments made by its legs. It rejects conflicting assignments and any pushout generator not covered by an inclusion. It then validates the mediator with check_morphism and compares both factorization equations.
The function does not enumerate possible mediators. Uniqueness follows from coverage of the pushout generators by the inclusions, which determines every mediator assignment from and . Thus the runtime check validates the constructed mediator and its factorization for one supplied cocone; it is not a machine-checked proof over all alternative cocones.
verify_universal_identity applies this check to the canonical cocone and also requires the mediator to be the identity. It is suitable only when the combined theory and its inclusions are total.
Schema merge
At the resolved boundaries that call verify_pushout, both branch-to-merge vertex maps must be total, every merged vertex must have a preimage, each base vertex retained by either branch must survive, and the vertex and mapped-base-edge cocone paths must agree. apply_resolutions, rebase, cherry-pick, and the ordinary clean merge-commit path call this check. A clean merge requested with no_commit or squash does not use the merge-commit path. Repository callers wrap failure as VcsError::PushoutVerification.
These are necessary cocone conditions, not the complete universal property. The separate verify_pushout_universal function checks factorization through a caller-supplied alternative cocone, but only for vertices; the alternative-cocone API has no edge maps. Schema merge does not call it.
The schema merge identifies same-name additions, exactly as the GAT construction identifies same-name sorts and operations outside the base image. Two branches that independently add a vertex of the same kind, or an edge with the same endpoints, kind, and label, produce one element in the merged schema. A free pushout would produce two, so a cocone that keeps both copies distinct receives no mediator from the merge. The merge computes that free pushout modulo same-name identification, which matches the interpretation of a name-addressed schema. Additions that share a name but disagree about the element are not identified: they are reported as conflicts. MergeResult::identified_additions lists every identification the merge made, so a caller who needs the two copies kept apart can rename one side before merging.
The separate panproto_schema::schema_pushout function accepts explicit vertex and edge pairs. It closes their endpoint identifications to an equivalence relation, builds a quotient schema, and returns morphisms from both inputs. It rejects overlap pairs that name absent elements. SchemaSpan::pushout calls this function only after rejecting a right leg that contracts vertices. Neither constructor invokes the VCS verifier or an on-demand universal-property checker.
Limits
The pushout construction does not choose a resolution for incompatible edits. Repository histories are not themselves proved to present the base-to-branch span assumed above. The implementation also publishes no asymptotic or wall-clock bound for the complete merge procedure.
See also
Protolens composition
A lens converts data between two particular schemas. A protolens records a schema transformation before it is instantiated at a particular source. Field renaming is a protolens; renaming age to years in one concrete record schema is the lens obtained by instantiation.
A protolens thus carries source and target TheoryEndofunctor descriptions rather than two concrete schemas.
Composition ordinarily requires the first target endofunctor to equal the second source endofunctor structurally. The implementation also accepts a second protolens whose source transform is Identity, retaining its precondition for later applicability checks. Neither branch proves naturality.
Lens DSL defines the concrete recipe being composed.
Semantic domain
The categorical interpretation treats a protolens as a natural transformation between schema endofunctors, using the standard definition introduced by Eilenberg and Mac Lane (Eilenberg and Mac Lane 1945):
where and are functors on the category of schemas , and for each schema , is a lens
satisfying the lens laws described in Lens DSL. The Rust type stores data from which a family of component lenses can be instantiated. It does not certify the lens laws for every component or naturality for every schema morphism.
The naturality condition is: for every schema morphism , the square
commutes: . Applying the protolens then transporting along gives the same result as transporting then applying the protolens.
Composition
Two protolenses and compose vertically into pointwise, by :
In the categorical interpretation, composition requires the intermediate functors to match. The implementation checks this requirement with protolens_composable, whose schematic form appears below. Omitted module paths and surrounding declarations make the excerpt non-runnable.
pub fn protolens_composable(eta: &Protolens, theta: &Protolens) -> bool {
matches!(theta.source.transform, TheoryTransform::Identity)
|| theory_endofunctor_equiv(&eta.target, &theta.source)
}
A Protolens carries its source and target TheoryEndofunctors as public fields. theory_endofunctor_equiv compares preconditions and transforms while ignoring the human-readable name. When the source transform of is Identity, the predicate returns true regardless of ’s target.
vertical_compose enforces this predicate at construction time and returns the unit variant LensError::CompositionMismatch on failure. The error identifies the class of mismatch but does not carry an offending functor name.
Sequential and fused instantiation
For a nonempty chain, instantiate first fuses and instantiates the resulting protolens once. instantiate_sequential instead instantiates each step at the running schema and folds the resulting concrete lenses through compose. Both methods return one Lens, not a list of intermediate morphisms. The sequential route does not expose intermediate schemas through its return value, and its composed migration may lack metadata such as an expansion_path that fused construction computes globally. An empty chain instantiates to the identity lens, while fuse rejects an empty chain.
Tests compare the two routes on representative chains. They do not establish their equivalence for every chain.
Scope of the composition guard
Mathematically, if and are natural transformations, their pointwise composite is natural, and lawful component lenses compose to a lawful component lens. Structural endofunctor equivalence is the implementation’s evidence that the middle object matches in the ordinary case.
The identity-source branch is weaker. It accepts composition without establishing that ’s target equals ’s source as an endofunctor, then conjoins the second source precondition with the retained source precondition. Protolens::check_applicability and the chain APIs can reject a concrete schema that fails this retained obligation. protolens_composable is thus a construction guard, not a proof that every accepted composite is a natural transformation.
Vertical and horizontal composition have structural tests, but the implementation does not quantify over all schema morphisms to certify naturality. It also treats protolenses that compute the same transform through different intermediate forms as distinct. Tests compare fused and sequential instantiation on representative chains, while the two routes may preserve different metadata shapes.
See also
- Lens DSL: denotational semantics for the underlying lens model and the three laws.
- Reference: lens combinators for the protolens module and entry points.
- How-to: use protolenses.
- What panproto verifies for the
CompositionMismatchruntime check.
REPL command language
The theory REPL loads compiled theories and morphisms, selects an active theory, and applies GAT operations to terms. Its state consists of two finite maps and an optional active-theory name. Commands and bare terms are processed one line at a time.
Input syntax
line ::= command | term
command ::= ":" cmd args
cmd ::= "load" | "theories" | "use" | "sorts" | "ops"
| "type" | "normalize" | "model" | "instance"
| "quit" | "q"
A blank line produces no output. A nonblank line without a leading colon is parsed and typechecked as a term in the active theory. There is no REPL comment syntax: a nonblank comment-looking line is handled as a term and will ordinarily produce a parse error. Multiline commands and terms are not implemented.
The command arguments are parsed by the command handler rather than a shared grammar. In particular, :instance expects <class> in <target> { source = target; ... }, and :model accepts at most one decimal depth.
State
Let denote the REPL state. It contains a theory map , a morphism map , and an optional active name :
The hooked arrow denotes a finite partial map, and denotes the absence of an active theory. :load, :use, and a successful :instance may change this state. Inspection, term typechecking, normalization, and model enumeration do not.
Command behavior
:load p calls load_and_compile on path . Compilation completes before either map is changed. On success, all compiled theories and morphisms are inserted; if there was no active theory and at least one theory was loaded, one loaded theory becomes active. Protocols and composition specifications in the compiled set are not retained by the REPL state. On compilation failure, the state is unchanged.
:theories lists loaded theory names in sorted order and marks the active one. :use n sets to only when belongs to the domain of . :sorts and :ops render declarations from .
:type t parses term source and calls typecheck_term with an empty variable context. A bare term follows exactly the same path. Thus free variables are not introduced through REPL state.
:normalize t parses and calls normalize with the active theory’s directed equations and a rewrite budget of 1,000 steps. It does not typecheck the term first, and the normalizer returns the term reached when its budget is exhausted rather than a distinct REPL error saying that normalization was incomplete.
:model d calls free_model with maximum depth . The default depth is 3, and the REPL rejects values above 10 before calling the model builder. Each carrier display is truncated to five rendered elements. If free_model reports an incomplete model, the output includes a warning.
:instance C in T { B } compiles an instance morphism from class theory to target theory using the loaded theory map as its resolver. A successful result is inserted in under the generated name C_to_T. The binding parser splits entries at semicolons and the first equals sign; it does not implement quoting or nested syntax.
:quit and :q return the quit signal. Unknown commands and malformed arguments return ReplOutcome::Error strings.
Failure boundaries
Commands that require fail when no theory is active or when the active name is missing from the map. Parsing, typechecking, normalization, free-model construction, and instance compilation render their failures as ReplOutcome messages rather than exposing the underlying error enums to the caller.
The fixed normalization and model-depth bounds limit those particular operations. The implementation states no totality theorem for arbitrary input strings, and the REPL layer has no proof object for a successful typecheck or normalization.
Repl::handle_line implements this behavior. The surrounding rustyline driver supplies editing, command completion, and persistent history; those facilities are not part of the Repl state above.
See also
Glossary
This glossary fixes the meaning of terms as panproto uses them. Where a term names a Rust API type, the entry links to that type.
Protocol
A protocol identifies a schema language, names its schema and instance theories, and records its well-formedness rules and structural feature flags. The Rust type is panproto_schema::Protocol. Protocol-specific schema and instance codecs are registered separately.
Schema
A schema is panproto’s concrete representation of one protocol’s schema document. Its intended mathematical reading is a model of the protocol’s schema theory, but the Rust type is not panproto_gat::Model. panproto_schema::Schema stores graph elements and constraints, protocol and entry metadata, transformation policies, and derived adjacency indices.
Instance
An instance is data interpreted under a schema. panproto_inst::Instance has tree-shaped, functorial, and graph-shaped representations.
Structural diff
A structural diff records additions, removals, and modifications between two schemas. panproto_check::SchemaDiff is descriptive; compatibility classification is a separate operation that interprets the diff under a protocol.
Migration
A Migration maps source vertices, edges, hyperedges, and labels to a target schema. It may also carry binary, hyperedge, and expression resolvers, together with value coercions and optional domain and codomain identifiers. panproto_mig::compile checks the mapped fragment and builds a CompiledMigration. The separate existence checker covers obligations that compilation does not.
Morphism
A morphism is a structure-preserving map between objects of the same kind. TheoryMorphism maps sorts and operations and is checked for signature and equation preservation. SchemaMorphism and the structural part of Migration map vertices and single edges while preserving incidence. Search options such as monic, epic, and isomorphic impose additional shape conditions; they are not part of every schema morphism.
Span
A span between schemas and consists of an apex and morphisms and . In a returned SchemaSpan, is the sub-schema of induced by the matched source vertices, the left leg is its inclusion into , and the right leg records the selected images in .
Lens
A Lens stores source and target schemas with a compiled migration. get maps a source WInstance to a target view and a Complement; put maps that view and complement back to a source WInstance. Law checkers test supplied instances, and constructing a lens does not certify the laws for all inputs.
Complement
A Complement records source information and structural choices discarded by get. put uses the edited view together with this record to reconstruct a source instance.
Schema theory
A schema theory is the generalized algebraic theory that determines the sorts, operations, and equations available to schemas for one protocol. A Protocol names its schema theory in the theory registry.
Instance theory
An instance theory is the generalized algebraic theory that determines how data may inhabit schemas for one protocol. A Protocol names its instance theory alongside its schema theory.
Generalized algebraic theory (GAT)
A generalized algebraic theory (GAT) is a named collection of dependent sorts, operations, and equations. panproto represents one with panproto_gat::Theory and constructs protocol theories by composition.
Colimit
A colimit combines objects by identifying an explicitly shared part. panproto_gat::colimit implements a binary amalgamated union over two explicit theory morphisms and returns the combined theory with two inclusions. It also identifies compatible same-name declarations outside the shared image and rejects non-injective shared legs instead of constructing their quotient. Construction checks that the two inclusion paths agree on the shared part. For one caller-supplied alternative target, verify_universal constructs the induced map and checks both factorization paths.
Restrict / restriction
The migration functions wtype_restrict, functor_restrict, graph_restrict, lift_wtype, and lift_functor carry surviving source data forward to a target. In this API, restrict means filtered forward transport. It is not the categorical restriction .
, , and
For a schema map , categorical reindexes a -instance to an -instance. panproto_inst::adjunction::f_delta and w_delta implement this target-to-source direction on their documented domains. is the source-to-target left adjoint implemented by f_sigma and w_sigma. The migration crate also exposes sigma and pi lifting functions, but the W-type pi path is currently an injective relabeling rather than a general product construction; lift_functor_pi is the path that forms Cartesian products over fibers.
Pushout / pullback
A pushout is a colimit of two maps with a common domain. The GAT amalgamation helper, the explicit schema-overlap constructor, and VCS merge have different checks and identification conventions, so the term does not name one shared implementation. A pullback is the dual limit of two maps with a common codomain. panproto-vcs computes a theory-level pullback as merge diagnostic metadata; it does not use that result to resolve merge fields.
Protolens
A Protolens stores source and target TheoryEndofunctor descriptions, each mapping theory presentations to theory presentations, plus the data needed to instantiate component lenses. Its intended natural-transformation reading requires those components to commute with schema morphisms. The Rust value does not certify that condition or the lens laws.
Parser
A schema parser reads a protocol’s native schema syntax and constructs a Schema. ParserRegistry stores the full-AST parsers supplied by panproto-parse. Instance parsing belongs to the I/O registry instead.
Emitter
A schema emitter renders a Schema in a protocol’s native syntax. The full-AST emitter uses grammar structure and layout information to choose tokens and whitespace. Instance emission belongs to the I/O registry.
Abstract schema
An AbstractSchema is a Schema with no constraints recognized by panproto_gat::is_layout_sort. SchemaBuilder::build_abstract and AbstractSchema::from_layout_free check this invariant.
Decorated schema
A DecoratedSchema is the type used for a schema carrying layout enrichment. The type also provides wrap_unchecked, so the wrapper alone does not prove that the layout fiber is complete.
Decorate
ParserRegistry::decorate takes an AbstractSchema and a LayoutPolicy, renders canonical source bytes, and parses those bytes to recover a DecoratedSchema. The parse step may assign fresh vertex identifiers.
Forget layout
Schema::forget_layout returns a schema without layout constraints; forget_layout_in_place performs the same projection by mutation. DecoratedSchema::forget_layout returns an AbstractSchema.
Layout enrichment / Layout fiber
The layout fiber is the set of constraint sorts recognized by panproto_gat::is_layout_sort. It includes start-byte, end-byte, doc-prefix, blank-lines-before, and sorts with the prefixes chose-alt-, interstitial-, and ptrace-.
Layout policy
panproto_parse::LayoutPolicy configures pretty emission with indent_width, separator, newline, line_break_after, indent_open, and indent_close. panproto_gat::LayoutPolicySpec is its serializable theory-layer projection.
Layout enricher
panproto_lens::enrichment_registry::LayoutEnricher is the cross-crate interface for synthesizing a layout fiber from a schema and a layout policy. Implementations are registered by enrichment kind and enricher name.
Parse / emit lens
The parse / emit lens relates source bytes to schemas. Its implementation packages parse and pretty emission together, while check_emit_parse and check_parse_emit test preservation of vertex-kind and edge-shape multisets after layout information is stripped.
Parse / decorate / emit lens
The schema-level form relates DecoratedSchema and AbstractSchema. Its forward projection forgets layout; its backward operation decorates an abstract schema under a LayoutPolicy.
Grammar cassette
A GrammarCassette supplies grammar-specific behavior that cannot be recovered from grammar.json alone. This includes defaults for external scanner tokens and selected spacing or layout overrides.
Token role
TokenRole classifies a grammar literal for spacing. Its variants are BracketOpen, BracketClose, Separator, Keyword, Operator, Connector, Terminal, and Immediate.
Acceptance predicate
The emitter’s internal accepts_first_edge predicate tests whether a grammar production can consume the cursor’s first unconsumed edge. It combines field-name matching, symbol dispatch, alias handling, and yield-set admission. It is an implementation detail, not a public API.
Pre-alias symbol
The pre-alias-symbol constraint records a tree-sitter node’s grammar name when it differs from the post-alias kind. The emitter uses it to distinguish grammar alternatives that produce the same aliased kind.
Emit verification status
EmitVerificationStatus classifies a registered parser as Verified, Generic, or Unsupported for source emission. Verified means the repository has the required corpus or backend tests. Generic means the grammar-driven path exists without that verification tier. Unsupported means emission is unavailable.
Fixed-point law (emit)
The byte-level emission fixed point is emit(parse(emit(s))) == emit(s). Protocol-specific and corpus audit tests establish this property for their covered inputs; the existence of the equation does not imply that every registered grammar has passed those tests.
Section law
For an abstract schema and policy , the schema-level section property is:
The current checks compare vertex-kind and edge-shape multisets, allowing fresh vertex identifiers introduced by parsing. The law is established for the fixtures exercised by those checks.
See also
For longer treatments, see Source-code emission, Schemas as theories, Lenses and round-trip laws, and Layout enrichment.