bearout 0.2.0

A programmable contract engine for linked resources, documentation, and code
docs.rs failed to build bearout-0.2.0
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

Bearout

Bearout is a deterministic repository contract engine. A repository keeps prose and structured metadata together as resources; Bearout discovers them, validates their shape, resolves their relationships, applies the repository's own rules, and generates artifacts from the verified graph, with provenance.

A contract here is a machine-checkable agreement about the resources in a repository. It is not necessarily a legal contract.

[!WARNING] Bearout is an experiment. Its bootstrap, resource envelope, shape vocabulary, Starlark ABI (version 0), diagnostic codes, and generated outputs are not stable yet. Do not build a compatibility promise on the current syntax.

Two layers

  • The Rust kernel owns discovery, parsing, graph construction, diagnostics, resource limits, path confinement, and filesystem writes.
  • The repository owns every domain rule: schema identifiers and their JSON Schema shapes, validators, project checks, and generation plans in Starlark, and templates.

A schema identifier such as example/decision-records/decision@1 belongs to the repository that defines it. Nothing is registered with Bearout or compiled into the binary. See docs/design.md for the responsibilities, phases, and boundaries.

The bootstrap

bearout.toml is static and is the capability boundary of a project:

version = 1
entry = "bearout.star"

[resources]
roots = ["records"]

[rules]
root = "rules"          # `load()` resolves here; shapes live here

[templates]
root = "templates"

[outputs]
roots = ["generated"]   # the only places generation may write
license = "Apache-2.0"  # stamped into generated headers

[documents]             # optional; schema-less Markdown, read-only
roots = ["docs"]        # walked recursively for `*.md`
files = ["README.md"]   # named one by one

[fixtures]              # optional; contract fixture files for `bearout test`
files = ["contract-tests/log.test.toml"]

[limits]                # optional; see docs/design.md for which defaults are measured
ticks = 1000000
template_fuel = 2000000

Repository policy can register schemas, checks, and generators. It cannot widen the roots the bootstrap grants. Resource, rules, templates, and output roots are disjoint, none is the project root, and all filesystem access goes through a capability opened on the project root. The document grant is read-only and may overlap any of them.

Repository policy

The entry module registers what the project uses:

load("decision.star", "validate_decision")
load("log.star", "check_supersession_is_reciprocal")
load("decision-index.star", "plan_decision_index")

schema("example/decision-records/decision@1",
       shape = "decision.schema.toml", validate = validate_decision)
check("supersession-is-reciprocal", check_supersession_is_reciprocal)
generator("decision-index", plan_decision_index)

A validator receives one frozen resource view and returns findings; a check receives the project view; a generator returns outputs:

def validate_decision(resource):
    if resource["fields"]["status"] == "accepted" and not rulings(resource):
        return [error("an accepted record must carry a ruling", code = "rulings-required")]
    return []

load() resolves only beneath the rules root; escapes, symbolic links, and cycles are rejected with the import chain. Every module is linted and statically typechecked, and every evaluation runs under tick, heap, and call-stack limits with cancellation. Scripts have no filesystem, environment, network, clock, or random access. The full ABI is in docs/starlark-abi.md.

Resources and shapes

A resource is Markdown with TOML front matter, or a header-only TOML file:

+++
schema = "example/decision-records/decision@1"
id = "decision-0004"
title = "Records are numbered when merged"
status = "accepted"
date = "2026-09-02"
supersedes = ["decision-0002"]
+++

# Records are numbered when merged

## Context

### decision-0004-ruling-01

```toml bearout=ruling
id = "decision-0004-ruling-01"
text = "A record receives its sequence number when it is merged."
```

schema, id, and refs are the envelope keys the kernel owns. Every other key is validated by the schema's shape, a JSON Schema 2020-12 document authored in TOML with a small x-bearout vocabulary:

"$schema" = "https://json-schema.org/draft/2020-12/schema"
type = "object"
additionalProperties = false
required = ["title", "status", "date"]

[properties.supersedes]
type = "array"
items = { type = "string" }
"x-bearout" = { ref = "example/decision-records/decision@1" }  # typed relation

["x-bearout"]
sections = ["Context"]                                          # required heading

["x-bearout".fragments.ruling]                                  # a fragment kind
type = "object"
required = ["id", "text"]

The vocabulary itself is validated: an unknown x-bearout key, a relation on a non-string property, or a shape that declares an envelope key is an error. Markdown bodies are parsed with Comrak; headings get GFM anchors, fenced blocks tagged bearout=<kind> become typed fragments with project-wide identifiers, and every relative link and #anchor is resolved.

Schema-less documents

Ordinary Markdown files such as a README, governance notes, or design documents carry no envelope and get no schema, identifier, or shape. An explicit [documents] grant selects them: roots are walked recursively for *.md, never following links or entering submodules, and files are named one by one; nothing else is discovered, and a path that resource discovery already claims is processed once, as a resource. Documents are parsed with the same Comrak model as resource bodies: headings with GFM anchors, explicit <a id> and <a name> anchors, links with their visible text, and images with their alt text.

Every link and image of a resource or document is resolved against the project tree: relative targets from the source's directory, /-prefixed targets from the project root, #fragment within the source, and a fragment on another Markdown file against that file's heading and explicit anchors. A fragment on a Markdown file that is neither a resource nor a selected document is reported rather than assumed valid; an image must name an existing file; a broken reference is B011. Policy sees the documents as project["documents"] and may report findings against a document path and line. The document-references sample shows the whole slice. Bearout assigns no meaning to any document name or directory; which files matter is the repository's decision.

Phases

bootstrap, discovery, parsing, structural validation, graph construction, repository policy, generation planning, rendering, delivery. A resource that fails parsing or structural validation is never passed to a validator, and its identifiers still resolve so nothing cascades. Checks run only on an error-free graph; generation runs only on an error-free project.

Generation

Scripts never write files. A generator returns output(template, path, context) entries; the kernel validates every path against the output roots, renders every artifact into memory with MiniJinja in strict mode, computes BLAKE3 digests, and only then delivers each file through an atomic rename. bearout-state.toml records which outputs Bearout owns and their provenance; bearout generate --check reports missing, stale, unowned, orphaned, and re-owned outputs. A file the manifest does not own is never overwritten, even when its bytes already match; orphans are removed only when the manifest proves Bearout wrote them and they are unmodified. The report's outputs list names delivered or verified files only when generation succeeded. Third-party content is recorded in NOTICE.md.

Commands

bearout check [path]                    # exit 0 clean, 1 findings, 2 fatal
bearout generate [path]                 # check, then deliver outputs
bearout generate --check [path]         # check, then verify committed outputs
bearout --format json check [path]      # one JSON report for every outcome
bearout check --index [path]            # check what a commit would record
bearout check --revision v1.2 [path]    # check one commit, tag, branch, or tree
bearout generate --check --index [path] # verify outputs as staged
bearout check --index --baseline HEAD   # and compare with an exact revision
bearout --allow-formatters check [path] # also run the declared formatters
bearout --allow-formatters format [path] # rewrite selected files in place
bearout test [path]                     # run the declared contract fixtures
bearout test --index [path]             # the suite, policy, and payloads as staged
bearout history range [path] --base REV # check the commits in REV..HEAD
bearout history message [path] --file .git/COMMIT_EDITMSG  # the commit-msg hook

Diagnostics use stable codes and forward-slash project-relative paths on every platform; the catalog and its stability policy are in docs/diagnostics.md.

Sources

[!WARNING] The Git-backed sources are experimental and require the git executable on PATH. Their flags, semantics, and report fields may change.

Without a selection, Bearout reads the live working directory through its filesystem capability, as before; it makes no snapshot, so concurrent edits are visible to a run. Two read-only sources read a Git tree instead, and every input of the run comes from that tree and nothing else: the bootstrap, the entry module and everything it loads, shapes, resources, templates, bearout-state.toml, the generated outputs that generate --check verifies, and the files that links resolve against. A file that exists only in the working directory never satisfies a lookup in a Git-backed run.

  • --index reads the Git index of the repository that owns the project, from one private copy taken when the run starts: the tree a commit would record. Staged additions and modifications are present; unstaged modifications, untracked files, staged deletions, and intent-to-add entries are absent; a staged rename appears only at its destination. An unmerged index is a fatal outcome. In a partial-commit hook, Git's GIT_INDEX_FILE is honoured when it is a regular file directly inside the repository's own Git directory.
  • --revision <rev> reads one commit, tag, branch, or tree object. The name is resolved exactly once, at the start; the resolved tree identity is recorded in the JSON report's source field and used for the whole run even if the branch moves meanwhile. An unknown name is a fatal outcome.

Either way the JSON report's source carries a deterministic digest of the captured entries, equal for identical content from either source, so a report can be tied to exactly what it examined. Git runs with a fixed environment: variables that redirect the repository, its objects, or its configuration are dropped, replacement objects and lazy fetching are disabled, and nothing is fetched or written.

The project may sit below the repository root, including in a linked worktree; only paths beneath the project are exposed, and a symbolic link in a Git tree resolves only inside the tree it is read from. Submodules are never entered. Blobs are read exactly as Git stores them, without line-ending conversion or filters, and nothing is checked out.

Both sources are read-only: check and generate --check accept them, generate without --check refuses them with exit code 2. Git support does not make Bearout a security sandbox, and scripts do not learn which source a run reads.

Comparison

[!WARNING] Comparison is experimental, like the Git-backed sources it builds on.

--baseline <rev> (library: Options::baseline) makes a run compare its candidate, whatever source that is, with one exact Git revision of the same repository. Nothing is inferred: no HEAD, parent, merge base, or default branch. The name is resolved once, the candidate is checked as usual, and the baseline is read-only historical evidence that is never written and whose policy is never executed. The candidate's policy is the only policy that runs: the baseline's own bearout.toml decides which paths that revision classified as resources and documents, the candidate's limits bound both sides, and the candidate's schemas and shapes validate both. A revision without a bearout.toml is an empty history; a baseline whose history the current policy cannot interpret is reported on the baseline side, with "side": "baseline" in JSON and a baseline: prefix in text, and fails the run.

Policy sees project["comparison"], None without a baseline: the historical baseline view with the same resource and document shapes as the candidate, and changes, deterministic facts over the contract surface (the bootstrap, the discovered resources, and the discovered documents; not the whole repository) as added, removed, or modified paths with each side's classification, digest, and size. A rename is a removal plus an addition; resources pair through their ids. A check may report against either side with side="baseline", so a deleted record can be named. What is immutable, when, and what may still change is the repository's policy, never the kernel's; the decision-records sample shows one such rule. The report carries the resolved baseline identity as baseline.

Hygiene and formatting

[!WARNING] The hygiene grant, the formatter declarations, the format command, and the related report fields are experimental.

Bearout natively enforces the byte-level hygiene every text file shares and delegates syntax-aware formatting to programs the repository pins. An explicit [hygiene] grant selects the files: scope = "repository" is every file of the project as Git knows it (the captured index or revision tree; for the working directory the tracked plus untracked, non-ignored files), scope = "declared" is only the listed roots and files; exclude, binary, and text refine any selection by path, and no extension carries kernel meaning. A file is binary when declared so or when a NUL byte occurs in its first 8 KiB; binary files are never checked or rewritten.

[hygiene]
scope = "repository"
exclude = ["generated"]
binary = ["assets"]

[[formatters]]
name = "python"
command = ["ruff", "format", "--stdin-filename", "{path}", "-"]
extensions = ["py"]
support = ["ruff.toml"]

Text rules come from the .editorconfig files of the selected tree, never from the live checkout during an index or revision check: charset (utf-8 or utf-8-bom), end_of_line, insert_final_newline (exactly one final newline; an empty file is exempt and never changed), and trim_trailing_whitespace (set it to false for Markdown hard breaks). A value Bearout cannot enforce is reported, not guessed; the supported subset is documented in docs/design.md, and complete EditorConfig compatibility is not claimed.

A formatter is a strict byte transform: the selected file's exact bytes go to the program on standard input, its standard output is the canonical form, and a difference is one diagnostic. The program runs from an argument vector with {path} replaced by the project-relative path, from a private working directory holding only the declared support files read from the selected tree, without color, sequentially, with bounded streams and a timeout. Running formatters needs --allow-formatters, because a formatter is a trusted host program outside Starlark's capability model: Bearout confines what it sees, not what it can do, and checking formatter declarations from untrusted authors is not a supported security boundary. The formatter's version is an input to reproducibility; run Bearout inside the environment that pins it, such as mise exec -- bearout --allow-formatters check. Bearout never reads mise.toml and installs nothing. General linting is deferred: linters produce tool-specific findings that need a separate design.

bearout format rewrites selected files of the working directory after computing every change, applying native normalization before the formatter, replacing each file atomically with its permissions preserved and only if it still holds the bytes that were read, and undoing completed replacements if a later one fails. Nothing is created or deleted, links are never followed, index and revision sources and comparison baselines are never formatted, and generate never rewrites sources.

Contract fixtures

[!WARNING] The fixture vocabulary, the test command, and the test report are experimental.

bearout test proves a repository's policy against controlled mutations of the selected source without changing anything. An explicit [fixtures] files grant names the fixture files one by one; nothing is scanned for, and check, generate, and format never execute them. Each fixture file holds named cases:

[[cases]]
name = "deleting a rejected record is caught against the unmodified log"
expect = "diagnostics"          # or "clean" or "fatal"
baseline = true                 # compare with the unmodified source
match = "exact"                 # the default; or "contains"

[[cases.mutations]]
delete = "records/decision-0005.md"

[[cases.diagnostics]]
code = "B015"
side = "baseline"
path = "records/decision-0005.md"
rule = "protected-record-deleted"

A case derives its candidate from the selected source by applying its mutations in order through a read-only overlay: write replaces or creates one regular file from inline content or a project-relative payload file of the selected source, delete removes one regular file, and move relocates one to a path that does not exist. Each path is touched once per case and never above or beneath another touched path, nothing beneath a file or through a symbolic link is touched, and every conflict in any case is refused before the first case runs. Every case starts from the same unchanged source; the working directory, the index, Git objects, and the fixture files are never written.

expect names the outcome class: clean (no diagnostic at all), diagnostics, or fatal, optionally with fatal = "text" the fatal message must contain. Expected diagnostics are structured, never rendered text: code is required, and severity, path, line, side (candidate or baseline), the repository rule, and the exact message are optional. They are matched as a multiset, so a repeated diagnostic needs a repeated expectation. match = "exact", the default, also fails the case on any diagnostic it did not expect; match = "contains" allows unrelated diagnostics. A contract diagnostic is test data and fails a case only when unexpected.

With baseline = true the unmodified selected source is the comparison baseline and the overlaid candidate is the candidate, with the Phase 3 authority: the candidate's bootstrap and policy interpret both sides, and project["comparison"] holds the historical view and the change facts. Policy sees an ordinary project and an ordinary comparison; nothing exposes fixtures, mutations, or the overlay to Starlark.

The suite, payloads included, is read from the selected source before any mutation is applied, so --index and --revision <REV> test exactly what is staged or committed: an unstaged correction cannot hide a broken staged fixture, and an untracked payload cannot satisfy an index fixture. limits.fixture_cases, limits.fixture_mutations, and limits.fixture_bytes bound a suite. A bootstrap that declares formatters needs --allow-formatters before any case runs; nothing is authorized silently. There is no --baseline: each case decides.

The text report prints one line per case and the details of each failed case (the outcome mismatch, the fatal message, missing expectations, unexpected diagnostics); --format json prints the test report, a surface distinct from the contract report, with the source identity, counts, and every case in suite order. Exit 0 when every case passed, 1 when a well-formed case did not match, 2 when the suite could not run: a malformed fixture, an invalid mutation, a missing or linked payload, a repeated case name, an exceeded limit, or a source that cannot be opened. A broken suite is never reported as a passing one, and a project without [fixtures] is a fatal outcome rather than an empty pass.

Mutation-style tests written in a scripting language, which copy a repository, edit a file, run the checker, and grep its output, map onto fixtures case by case: the copy becomes the overlay, the edit becomes a write, delete, or move (a payload file holds a whole replacement), the grep becomes a structured expectation, and a test that asserted a crash becomes expect = "fatal". Tests that mutate directories, run shell commands, generate random edits, or inspect the checker's text are outside the vocabulary and stay where they are. No compatibility with any existing test suite is claimed; the decision-records sample shows the shape.

History and commit policy

[!WARNING] History checks, the history command, the history view, and the history report are experimental and require the git executable.

bearout history lets a repository enforce its own commit rules over exact Git facts. The kernel captures commits, identities, messages, parents, and changed paths; the repository writes the rules in Starlark. Conventional Commits headers, allowed types and scopes, header length, body separation, breaking-change footers, sign-off trailers that must match the author, and merge or autosquash exemptions are all policy the repository supplies; Bearout holds no Conventional Commits parser and no DCO semantics, and it does not verify the legal truth of a sign-off.

def commit_policy(history):
    findings = []
    for commit in history["commits"]:
        if commit["merge"]:
            continue                       # this repository's choice
        author = commit["author"]
        sign_off = "Signed-off-by: %s <%s>" % (author["name"], author["email"])
        if sign_off not in commit["message"].split("\n"):
            findings.append(error(
                "missing `%s`" % sign_off,
                commit = commit["key"],
                code = "sign-off",
            ))
    return findings

history_check("commit-policy", commit_policy)

bearout history range [PATH] [--base REV] [--head REV] checks the commits reachable from the head (default HEAD) but not from the base: Git's base..head, or everything reachable from the head without a base. Both names are resolved exactly once and recorded with their full identities; the base itself is excluded; merge commits are included and policy decides whether they matter. Nothing is read from BASE, HEAD, or any provider variable, and an all-zero base is not special: omit --base for a new branch. Commits are exposed oldest first in a deterministic topological order, the full object identity breaking ties among simultaneously eligible commits. Each commit's changes are relative to its first parent, or to the empty tree for a root commit, without rename detection: a rename is a removal plus an addition whose object identities policy may compare. Paths are repository-relative facts, with a project-relative form when the path lies inside the Bearout project.

bearout history message [PATH] --file FILE is the commit-msg hook path. It reads exactly the named message file, which must be a regular, non-linked file inside the repository's resolved Git directory (the linked worktree's own directory in a worktree), bounded before it is read, valid UTF-8, and free of NUL; comments, scissors lines, autosquash prefixes, and blank lines reach policy exactly as Git supplied them. The author's name and email are what Git would record, with no timestamp or timezone, since Git would only invent the current clock for a pending commit; the parents are HEAD and any merge in progress, established only from a HEAD and a MERGE_HEAD Git can read and prove (an unborn branch is recognized only as a symbolic HEAD to a branch that does not exist yet); and the staged changes come from the same captured index that supplies the policy. An empty message is an input to policy, and message lines end at CRLF, LF, or a lone CR alike.

Authority is explicit: a range reads bearout.toml, the entry module, and every loaded module from the resolved head's tree; a pending commit reads them from the captured index. An unstaged policy edit cannot change a commit-msg check, and the working tree cannot override a range check. Only history checks run: no resource discovery, documents, hygiene, ordinary checks, generators, formatters, or fixtures. Identities are the commit object's own, without .mailmap, case folding, or any inference that author and committer are one person; messages are byte-exact. Signed commits parse with their signature headers kept out of the message. Missing objects are never fetched, and a range that reaches a shallow boundary is refused rather than described as complete history.

A history finding targets a commit key from the view (pending for the pending commit) with an optional message line, or nothing for a range-wide finding; a commit target never combines with a resource, path, or comparison side, and ordinary checks cannot target commits. Accepted findings are B032 (error) and B033 (warning) in a distinct history report, rendered as commit <id>:<line>:B032[rule]: ..., commit pending:..., or range:..., with the registered check name as the rule identity unless the finding carries its own code. Script diagnostics sort first by path, then range-wide findings, then commit findings in commit order; within a target by line, code, rule, and message. Exit 0 with no finding, 1 with any finding, warnings included, 2 for invocation, Git, policy-loading, malformed-history, or limit failures. An invalid revision or incomplete history is never a policy finding.

limits.history_commits, limits.history_changes, limits.history_commit_bytes, and limits.history_bytes bound a run. Fixture cases may supply a synthetic pending message with [cases.history] and expect findings by commit, so Conventional Commit and sign-off policies are regression-tested without a Git repository; the commit-policy sample shows a complete policy with its fixtures.

A commit checker written in a scripting language, which runs git log or git show, parses the text, and prints failures, maps onto this model: the parsing becomes the history view, each rule becomes a branch of the history check reading subject, message, author, parents, and changes, each failure becomes error(..., commit = key), and the CI job becomes bearout history range --base <base> while the hook becomes bearout history message --file "$1". Rules that depend on branch names, remote state, signature verification, or provider APIs have no facts to read and stay where they are. No compatibility with any existing checker is claimed.

Samples

The repository's samples/ directory holds nine complete projects, from three linked notes to a spreadsheet-expression language whose Rust lexer, parser, and conformance tests are generated from the resource graph and compiled in CI. The samples index is the capability matrix. Every sample is checked, and its outputs verified, by the test suite. Samples are not part of the crates.io package.

Development

All required tools and versions are pinned in mise.toml.

mise run setup
mise run fmt
mise run check

See CONTRIBUTING.md, docs/design.md, and docs/technology-evaluation.md.

Trust

Bearout is a capability-confined host with resource limits. It is not a sandbox for hostile repositories; see SECURITY.md.

Licence

Licensed under the Apache License 2.0.

Copyright 2026 Ali Malekpour.