What contracts are
A contract is a rule about how your hypermedia app is wired — a fragment
points at a block that exists, anhx-targetselector resolves to a real id, an
OOB region matches a registered block, an app with mutating routes ships the
secure-by-default middleware.app.check()validates all of these against the
frozen app and reports anything broken.
The point is fail loud at startup, not silent at runtime. A typo in an
hx-targetor a renamed template block is the kind of bug that ships green and
breaks a swap in production. A contract catches it before the server starts
serving requests.
You rarely callapp.check()by hand. In debug mode it runs automatically when
you start the dev server, andchirp check myapp:appruns it in CI. This page
explains the model — what categories and severities mean, how severity changes
with the environment, and how to add your own checks. For the full list of every
category and its fix target, see the reference: Contract Category Reference.
The happy path: it just runs
WhenAppConfig(debug=True), freeze runs the full contract suite and prints a
report to stderr. An ERROR-severity issue exits before the server starts.
from chirp import App, AppConfig, Template
app = App(AppConfig(debug=True, template_dir="templates"))
@app.route("/")
def index():
return Template("page.html")
app.run() # freeze runs app.check(); ERRORs exit before serving
If everything is wired correctly you see a one-line summary and the server starts. If a fragment points at a missing block, you get an ERROR naming the template and block, and the process exits with code 1.
Categories vs severities
Two independent axes describe every issue.
A category is what kind of rule fired — a stable string handle like
fragment, routing, oob_registry, or security_stack. Use the category as
the key for CI policy: you promote or demote a whole category, never an
individual message.
A severity is how much it matters:ERROR, WARNING, or INFO.
| Severity | Meaning | Effect |
|---|---|---|
ERROR |
The app is broken — a swap will silently fail or a security floor is unmet. | Failsapp.check(); exits before serving in debug. |
WARNING |
Likely a mistake, but the app still runs. | Reported; fails only with--warnings-as-errors. |
INFO |
Informational — static analysis can't be sure (e.g. an orphan route reached only by dynamic navigation). | Reported; never fails the check. |
The category is the part you reach for when writing policy. The message is the concrete fix target — a good message names the route, template, block, selector, registration, or config field that needs to change.
Severity is environment-aware
Some rules fire at different severities depending onAppConfig(env=...), which
is one of"development", "staging", or "production". A missing
secure-by-default stack is the canonical example: an app with mutating routes
that omits CSRF/Session middleware is an ERROR in production, a WARNING in
staging, and silent in development — so dev apps and shipped examples stay
clean while a real deployment is held to the floor.
This is whychirp check --deployexists: it runs the env-aware rules with
production posture even when you're checking locally, so deploy-blocking
misconfigurations escalate to ERROR the way they would in prod. It does not
mutate your app — a genuinely deploy-ready app still passes.
# Local CI gate — fail on any error or drift
chirp check myapp:app --warnings-as-errors
# Deploy preflight — production posture, implies --warnings-as-errors
chirp check myapp:app --deploy
Custom checks
Third-party packages and apps extendapp.check()with their own rules. A check
is any callable taking(snapshot, result): read the frozen
ContractCheckSnapshot, append ContractIssues to result.issues.
from chirp import ContractIssue, Severity
def no_todos(snapshot, result):
for name, source in snapshot.template_sources.items():
if "TODO" in source:
result.issues.append(
ContractIssue(Severity.WARNING, "todo", f"TODO in {name}", template=name)
)
app.register_contract_check(no_todos)
Register during setup, before freeze — registering after freeze raises
RuntimeError. Exceptions inside a check are isolated: they become ERROR issues,
and the other checks still run. Both plain functions and callable class instances
satisfy theContractCheckprotocol.
Passing data to a check
app.set_contract_check_data("components", ["card", "modal"])
def component_check(snapshot, result):
components = snapshot.extras["components"] # → ["card", "modal"]
Overriding a category's severity
Promote or demote a whole category as a post-processing step after every check runs. This is your CI policy lever — apply it to built-in or custom categories alike.
from chirp import Severity
app.override_contract_severity("dead", Severity.ERROR) # dead-template INFO → ERROR
app.override_contract_severity("orphan", Severity.WARNING) # orphan-route INFO → WARNING
Opting out of the debug auto-run
The contract suite runs onapp.run() / app.freeze()only when
config.debug=True. To skip it entirely (for example, a fast test harness that
freezes many throwaway apps):
app = App(AppConfig(debug=True, skip_contract_checks=True))
Or set the environment variable CHIRP_SKIP_CONTRACT_CHECKS=1. Prefer fixing the
contract over skipping the check — the suite is what makes a typo'd swap a startup
error instead of a production incident.
Next steps
- Contract Category Reference — every category, its default severity, and its fix target.
- CLI reference —
chirp check,--deploy,--warnings-as-errors. - App Lifecycle — when freeze runs and why the app becomes immutable.