SchemaLock ships two independent implementations that consume the same declarative YAML contract and must produce identical results:
schemalock/— the reference Python implementation. Reads a declarative YAML contract, fires HTTP requests at a target backend withhttpx, runs a fixed set of built-in checks against each response, and emits a pass/fail result plus an optional JSON report.rust/— a from-scratch Rust port of the same engine (single static binary, no runtime), kept behaviorally equivalent to Python via the parity gate in §6.
Python flow:
┌────────────────────┐
│ schemalock.yaml │
└─────────┬──────────┘
│ loaded & validated
▼
┌────────────────────┐
│ config.py │ (dataclasses / validation,
│ │ no external schema lib needed)
└─────────┬──────────┘
│ Config object
▼
┌────────────────────┐ ┌───────────────────┐
│ runner.py │──────▶│ httpx.Client │
│ (orchestrates │ │ (real HTTP calls) │
│ requests + checks) │◀──────┘───────────────────┘
└─────────┬──────────┘
│ CheckResult[]
▼
┌────────────────────┐
│ checks/ │
│ error_envelope.py │
│ auth.py │
│ status_patterns.py │
└─────────┬──────────┘
│
▼
┌────────────────────┐
│ report.py │──▶ stdout summary (pytest-style)
│ │──▶ JSON report file
└────────────────────┘
- Parses
schemalock.yamlwithPyYAML. - Validates structure with plain Python dataclasses (
Config,Endpoint,ErrorEnvelope) rather than pulling in a heavy schema-validation dependency — keeps Phase 1 dependency footprint tohttpx+PyYAML+ stdlib. - Raises
ConfigErrorwith a precise path (endpoints[2].expect.status) on any malformed input, so config mistakes are cheap to debug.
Runner.run()iterates endpoints, builds the request (method, path, headers, optional auth token/omission), executes it via a sharedhttpx.Client, and passes the response into each applicable check.- Checks are independent and composable: each endpoint can opt into
error_envelope,auth_required, and/orstatuschecks. An endpoint failing one check doesn't block the others from running (all findings surface, not just the first). - Network/timeout errors are captured as a distinct
CheckResult(ERROR), not conflated with aFAILassertion — a target that's simply unreachable should read differently in CI logs than a target that responded with the wrong shape.
- Given a JSON error body and an
ErrorEnvelopedefinition (required_fields,field_types), asserts presence and (loose) type match. - Deliberately forgiving on extra fields (contracts should allow additive changes) but strict on missing fields or type changes (that's the breaking change class this tool exists to catch).
- For endpoints marked
auth_required: true, replays the request with the configured auth header stripped (or replaced with an invalid token) and asserts the response is401or403— never200–3xx, never404(which would leak resource existence to unauthenticated callers), never500.
- Straightforward expected-status assertion, but implemented as its own check module (rather than inline in the runner) so Phase 2 can extend it with pattern-based rules (e.g. "any 5xx must return the standard envelope") without touching the runner.
render_console(results)— pytest-stylePASS/FAILlines with a one-line reason per failure, and a final summary count. Exit code is1if any check isFAILorERROR,0otherwise — CI-friendly by default, no flags required.render_json(results, path)— machine-readable report for CI artifact upload / future drift-detection (Phase 5).
- Thin
argparse-based entrypoint:schemalock test --config <path> --base-url <url> [--json-report <path>] [--auth-header "Authorization: Bearer <token>"] [--timeout <seconds>]. - No hidden global state — base URL and auth are always explicit on the command line or config, matching the "run in CI, run on a laptop, get the same result" requirement.
- A dependency-light Cargo crate that reimplements the same pipeline: YAML
config → validated config model →
reqwestHTTP calls → the same three check modules → pytest-style console output and the same JSON report shape. - Produces a single static binary (
rust/target/release/schemalock) so Wave-funded backends written in Rust can gate on contracts without a Python runtime in their image. - Check logic lives in
rust/src/checks/mirroringschemalock/checks/, and is exercised byrust/tests/(unit + e2e against the same bundled mock server inexamples/). - Behavioral equivalence with Python is enforced by the parity gate (§6), not by a shared codebase.
name: string # human label, shown in report header
base_url: string # optional; --base-url flag overrides this
auth_header: string # optional default, e.g. "Authorization: Bearer xyz"
error_envelopes:
<envelope_name>:
required_fields: [string]
field_types: { field_name: "string"|"number"|"boolean"|"object"|"array" }
endpoints:
- name: string
method: GET|POST|PUT|PATCH|DELETE
path: string # supports {placeholders} for path params
body: object # optional request JSON body
auth_required: bool # default false
expect:
status: int | [int, ...] # single status or list of acceptable statuses
error_envelope: <envelope_name> # only checked if response is 4xx/5xx- httpx over requests: native async-ready client, HTTP/2 support, and it's already the library named in the project brief — keeps Phase 2 (concurrent checks) a non-breaking upgrade.
- Dataclasses over Pydantic for config: Phase 1 has no need for a heavyweight
validation dependency; keeps
pip install schemalockfast and the dependency surface auditable, which matters for a security-adjacent CI tool. - No pytest dependency in Phase 1 core: the CLI works standalone. Pytest-style
output formatting is provided without requiring the caller's project to run
under pytest at all — SchemaLock should work in a bare Docker CI step just as
well as inside a Python test suite. (A real
pytestplugin/fixture wrapper is a natural Phase 2/3 add-on once the core check engine is stable.) - Mock server bundled in
examples/: Phase 1 must be independently verifiable without a real Wave backend on hand — the example escrow/GraphQL- style mock server is what CI runs SchemaLock against.
tests/uses realpytestto test SchemaLock itself (unit tests for config parsing and each check module, plus one end-to-end test that boots the mock server as a subprocess and runs the CLI against it).rust/tests/mirrors that coverage for the Rust port, including an e2e test against the same mock server.- CI (
.github/workflows/ci.yml) runs the Python suite on every push/PR, plus a smoke test that runs the packaged CLI directly against the example config. - CI (
.github/workflows/rust-ci.yml) runscargo fmt/clippy/test/auditon the Rust crate, plus the same smoke tests against the example config. - The cross-language parity gate (below) runs on every push/PR in
ci.yml.
Both implementations must produce identical JSON reports for the same config
and target. This is enforced by scripts/parity_check.py, which:
- Boots the bundled mock server from
examples/. - Runs the Python CLI and the compiled Rust binary against the same configs
and fixtures (including broken-contract fixtures) with
--json-report. - Asserts the two reports are byte-for-byte equal.
This gate is what keeps the Rust port honest: any change to check semantics, error messages, or report structure in one implementation must be mirrored in the other or CI fails.