Change contracts
Use schema 0.3 for scope, dependency and claim checks on a PR. 0.2 and 0.1 remain valid and evaluate exactly as before; 0.1 is the original static demonstration. The executable schema is contractSchema in core, a union selected by schema. A key from a newer schema in an older contract is an unknown key. Unknown object keys, duplicate YAML keys/tags, noncanonical paths, duplicate clause IDs and IDs beginning with $ are rejected. YAML aliases are bounded.
Required: schema, change: { name }, and (for 0.2) scope: { allowed: [...] }. Optional change.intent and clause descriptions are explanatory, not executable prose. Requires/forbids/preserves/evidence default to empty. At least one clause or explicit scope is required.
Intent and scope#
--approved-contract selects reviewed intent separately from --contract. Schema 0.2 or 0.3 without that selection returns UNKNOWN. A changed parsed candidate yields DRIFT and the engine still evaluates the selected approved contract's obligations. $intent, $scope, $budget and $dependencies are internal result IDs. Selection is the caller's trust decision, not a digital signature. Passing the same edited file twice provides no independent review history.
Every inventoried changed path must match scope.allowed; otherwise DRIFT. Patterns support * and ? within a segment and ** as an entire segment. Patterns are relative POSIX paths, never absolute or containing ... No negation, brace expansion or gitignore semantics. Exact paths are preferable for a small first change. Directory exclusions still apply; use Git mode for tracked generated files. Inventory scope is not semantic coverage.
Schema 0.3#
schema: '0.3'
change: { name: Member skills }
scope:
allow: ['src/lib/members/**', 'migrations/*.sql']
companions: ['test/**', '**/*.es.json']
budget: { files: 25, lines: 1200 }
dependencies:
allow: ['rrule', '@types/*']
preserves:
- id: migrations-immutable
match: { kind: file, path: 'migrations/**', change: [modify, delete] }
claims:
- id: saves-in-order
text: Two quick clicks cannot store the older set.
files: ['src/components/ProfileEditor.astro']
evidence:
- { kind: test, file: test/account-profile.test.ts, name: saves in order }
- { kind: manual, note: browser check }These checks read the file inventory and file text only. They work for every language. No analyzed code runs.
- Scope.
scopeis required.scope.allowandscope.companionsdefault to empty. The contract needsscope.allowor at least one claim withfiles.$scopeis DRIFTout-of-scopefor each changed path that matches noallow,companionsor claimfilesglob. Companions are support files: they never count as out of scope, and they never make a claim implemented. Glob rules are the same as in 0.2. - Budget. Optional.
$budgetcounts every changed path, companions included, and the added plus removed lines of each text file (Myers edit distance, asgit diff --numstatwithout its heuristics). A binary file counts 0 lines. A very large rewrite, above 10,000 edits in one file, uses a line-multiset count, which can be lower. Over budget is DRIFTover-budget; the details give the actual count and the budget. A line budget without file text is UNKNOWN. - Dependencies.
$dependenciesalways runs in 0.3;dependencies.allowdefaults to empty. It reads each added or modifiedpackage.json,go.mod,Cargo.toml,pyproject.tomlandrequirements*.txt, at any depth. A name in the head manifest that is absent from the base manifest and not allowed is DRIFTunapproved-dependency. Version changes and moves between sections are not new. Sections: npmdependencies,devDependencies,peerDependencies,optionalDependencies; Go directrequirelines (// indirectlines are skipped); Cargodependencies,dev-dependencies,build-dependencies, including target and workspace tables; PEP 621dependenciesandoptional-dependencies, PEP 735dependency-groups,[tool.uv] dev-dependenciesand Poetry dependency tables; requirement lines, including-e/--editableinstalls. A URL requirement uses its#egg=name, or the URL itself without one. A local editable path without#egg=has no package name, so it is recorded as-e <path>; a new one is DRIFT unless an allow entry names it. Other-options such as-r,-cand--index-urlare skipped. Python names compare after PEP 503 normalization. An allow entry is an exact name or a glob such as@types/*. A manifest that cannot be read is UNKNOWNmanifest-unreadable, never a silent pass. The TOML reader is deliberately small: it does not support multi-line strings. - File rules. A
preservesentry may usematch: { kind: file, path, change }.changelistsadd,modifyanddeleteand defaults to[modify, delete]. A matching changed path with a listed change is DRIFTpreserve-violated. A rename is a delete plus an add. A rule that matches no file in base or head is UNKNOWNrule-matches-nothing, which catches a mistyped pattern. Fact preserves work as in 0.2. - Claims.
{ id, text, files?, evidence? }.textis recorded, not interpreted. A claim whosefilesmatch no changed path is INCOMPLETEclaim-not-implemented. A claim with no evidence is INCOMPLETEclaim-without-evidence.testevidence{ kind: test, file?, name }passes when imported execution evidence (--evidence) shows the test passed; the name matches the full Vitest name (suites and test name) or the test's own name exactly, never an arbitrary suffix. More than one matching run is UNKNOWN. Without a matching run, it passes on a test definition with that literal name, which does not prove execution. A missing or failed test is INCOMPLETEevidence-missing.manualevidence{ kind: manual, note }is recorded and never passes by itself: a claim whose only evidence is manual is UNKNOWNmanual-evidence-only. A claim with a present test and a manual note passes and keeps the note in its details.
Claim, clause and evidence IDs share one namespace. Results come in a fixed order: $intent, $scope, $budget, $dependencies, requires, forbids, preserves, evidence, claims.
Fact clauses#
{ id, description?, match: { kind, file?, name?, from?, to?, typeOnly? } } uses exact, case-sensitive AND matching. Globs apply only to scope, not selectors. typeOnly is valid only for dependency selectors. Endpoint selectors apply only to relation kinds.
| Kind | Name / endpoints |
|---|---|
| symbol | Declaration name; nested names follow lexical owner |
| import | Written specifier; from file, to written specifier |
| call-site | Printed callee; from file#owner, to printed callee |
| test-definition | Literal scenario name |
| dependency | Resolved relative target or external:specifier; from source file, to target |
| resolved-call | Resolved target; from file#owner, to relative/file.ts#function |
| api | Exported name |
Requires passes on a head match; missing facts are INCOMPLETE when covered, UNKNOWN otherwise. It does not require the fact to be newly added. Forbids yields DRIFT on a head match, including preexisting facts, and passes on covered absence. A global configuration failure prevents a reliable positive resolution result too.
Preserves needs at least one baseline match. Baseline IDs and fingerprints must remain; additions are permitted. Missing/changed facts yield DRIFT; missing baseline or insufficient coverage yields UNKNOWN. Symbol fingerprints include bodies; API functions compare explicit signatures. Referenced types need separate clauses. No runtime equivalence is claimed.
Evidence#
{ id, stage, method, scenario, file? }. Supported combinations: test/definition and test/execution. Definition matches a recognized declaration only. Execution requires --evidence with an imported Vitest envelope bound to head inventory, Git revision in Git mode, and approved contract digest. A uniquely matched passing scenario in an overall successful run passes. Missing, skipped or failed scenarios yield INCOMPLETE. Missing/stale evidence, ambiguous scenarios and explicitly reported retried scenarios yield UNKNOWN. Prefer exact test file and full scenario name including suite names.
Design, implementation, deployment and runtime remain valid stage vocabulary but unsupported evidence combinations evaluate UNKNOWN. No monitor or deployment integration is implemented.
Results#
Failures combine in fixed order: INCOMPLETE, DRIFT, UNKNOWN. No failures means PASS. Exit 2 takes precedence for UNKNOWN; other failures use 1; PASS uses 0. Invalid input gives ERROR/2. Verification JSON schema is 0.3 for a 0.3 contract and 0.2 otherwise. It includes candidate/effective contract digests, source identities, per-clause results, facts and evidence provenance. Each result that is not PASS has a finding code and, where useful, details. The top-level findings list repeats them with the ID <result id>:<finding code>, for example $scope:out-of-scope or saves-in-order:evidence-missing. Codes that no rule above names: intent-changed, intent-not-approved, required-fact-missing, forbidden-fact-present, preserve-violated, evidence-missing and undecided for other UNKNOWN results.
Contract digest hashes validated, default-expanded JSON. Formatting does not matter; array order does. It is not an approval signature. Waivers, inheritance, natural-language evaluation and a broad allows language are deferred.