Skip to content

Check a file before anyone reviews it

Status: fully working. Everything on this page runs offline against the SDK you have installed. No gate is contacted.

You have content — a runbook, a release note, a skill file — and a rule about what it must contain. You want that rule enforced mechanically, in CI or a pre-commit hook, before it reaches a human. That is what a check manifest and evaluate_file do.

What a check result is, and is not

A clean outcome means one thing: the manifest's literal required strings were present in the file, matched case-sensitively, with no regex and no execution.

It is not an admission decision, and it does not discharge review. The manifest's human_review value travels into every result for exactly that reason — a clean check that dropped it would read like an approval.

The starter

examples/starters/checks.py evaluates any number of files against one manifest and prints a machine result.

$ python examples/starters/checks.py --help
$ python examples/starters/checks.py
$ python examples/starters/checks.py --format text
$ python examples/starters/checks.py --manifest my.gate.json --artifact-type memory notes/*.md

Run with no arguments it uses the bundled fixtures in examples/starters/data/: one release note that satisfies the manifest and one that omits the Rollback: line.

{
  "artifact_type": "memory",
  "counts": {"clean": 1, "flagged": 1, "unevaluated": 0},
  "files": [
    {
      "path": ".../release-note.clean.md",
      "status": "clean",
      "artifact_type": "memory",
      "outcome": "clean",
      "name": "release-note",
      "version": "1.0.0",
      "digest": "cfd60e994148aa55…",
      "human_review": "required",
      "findings": []
    },
    {
      "path": ".../release-note.flagged.md",
      "status": "flagged",
      "artifact_type": "memory",
      "outcome": "flagged",
      "name": "release-note",
      "version": "1.0.0",
      "digest": "cfd60e994148aa55…",
      "human_review": "required",
      "findings": [
        {"kind": "required-text", "severity": "warning",
         "detail": "required_text_missing: check 1",
         "start": 0, "end": 0, "excerpt": ""}
      ]
    }
  ],
  "human_review_satisfied": false,
  "is_admission_decision": false,
  "manifest": ".../release-note.gate.json"
}

Paths are abridged above; the real run prints them in full. Both file entries carry the same digest because both were evaluated against the same manifest — the digest identifies the manifest, never the file it was applied to.

Note the empty excerpt. The bundled evaluator emits fixed diagnostics and never quotes your content back at you, so a check result is safe to log.

Exit statuses

Status Meaning
0 every file was clean
1 at least one file was flagged
2 at least one file or the manifest could not be read

That is the whole CI integration:

- run: python examples/starters/checks.py --manifest gates/release.gate.json docs/release/*.md

A file that cannot be evaluated does not abort the run. It becomes an unevaluated entry carrying the SDK's fixed diagnostic kind (unreadable_file, not_regular_file, unsafe_path, invalid_utf8, input_too_large, …), so one bad file does not hide the verdict on the rest. A content file over the manifest's max_input_bytes reports input_too_large, not file_too_large — the latter is the manifest's own 65,536-byte bound.

The manifest

examples/starters/data/release-note.gate.json:

{
  "schema_version": "0.1",
  "sdk_version": "0.1",
  "name": "release-note",
  "version": "1.0.0",
  "artifact_types": ["memory"],
  "checks": [
    {"capability": "required-text", "text": "Owner:"},
    {"capability": "required-text", "text": "Rollback:"}
  ],
  "max_input_bytes": 4096,
  "human_review": "required",
  "egress": [],
  "secret_refs": []
}

egress and secret_refs are bounded to zero items by the schema. A manifest is declarative data: nothing in it can open a connection or name a credential.

For the manifest schema in full, see Manifest; for the underlying calls, see Load a check manifest and Evaluation.

Doing it in your own code

The starter above is a CLI. The same work in Python is one call:

from doublegate_sdk import evaluate_file

evaluation = evaluate_file("gates/release.gate.json", "docs/release/2026.09.md",
                           artifact_type="memory")

evaluate_file returns a frozen FileEvaluation. These are its fields, all of them, with the values a real run against the bundled fixtures produces:

Field Type Value for release-note.flagged.md
outcome str 'flagged' (the other value is 'clean')
findings tuple[Finding, ...] one required-text finding
gate_name str 'release-note'
gate_version str '1.0.0'
artifact_type str 'memory'
human_review str 'required'
manifest_digest str 'cfd60e994148aa5537f7203e49d9570cab83b6df142a8efa727028426156a7bf'

Plus .flagged (a property, outcome == 'flagged') and .to_payload() (the JSON-ready dict the starter prints). manifest_digest identifies the manifest, so every file evaluated against one manifest carries the same digest.

Each Finding carries kind, severity, detail, start, end and excerpt — and excerpt is always '' from the bundled evaluator, so findings are safe to log:

for finding in evaluation.findings:
    print(finding.kind, finding.severity, finding.detail)
# required-text warning required_text_missing: check 1

What next. Nothing, remotely — this is offline evidence. human_review still says required, and that obligation is yours to route to a person. If you want the content in a gate, that is a separate propose call; see Ingestion.

Handling the failures

Everything the SDK refuses arrives as a DoublegateError subclass with a fixed kind. Catch the base class and read kind; do not parse the message.

from doublegate_sdk.errors import DoublegateError

try:
    evaluation = evaluate_file(manifest_path, content_path, artifact_type="memory")
except DoublegateError as error:
    kind = error.kind        # 'unreadable_file', 'invalid_utf8', 'input_too_large', …
kind Raised by Cause
unsafe_path, not_regular_file, unreadable_file PackageError the manifest or content path
invalid_json, duplicate_json_key PackageError the manifest document
file_too_large PackageError the manifest over 65,536 bytes
input_too_large EvaluationError the content over the manifest's max_input_bytes
invalid_utf8 EvaluationError the content is not UTF-8
unsupported_artifact_type EvaluationError the manifest does not declare that type

file_too_large and input_too_large are different bounds on different files. Mixing them up is the most common misreading of a check failure.

The importable recipe

examples/recipes/offline_checks.py is the same logic as small named functions that return values instead of printing, so you can call them from your own code:

from recipes.offline_checks import check_many_files, summarize_checks

results = check_many_files("gates/release.gate.json",
                           ["docs/release/2026.09.md", "docs/release/2026.10.md"],
                           artifact_type="memory")
summary = summarize_checks(results)

check_many_files never stops at the first bad file: a path the SDK refuses comes back as a CheckedFile with status='unevaluated' and the SDK's kind under error_kind. summarize_checks returns exactly this, for the bundled fixtures:

{'counts': {'clean': 1, 'flagged': 1, 'unevaluated': 0},
 'total': 2,
 'human_review': ['required'],
 'is_admission_decision': False,
 'human_review_satisfied': False}

The last two members are constants. They are emitted anyway so nothing downstream has to infer admission from an outcome string.

blocking_findings(evaluation, severities=...) filters findings to the severities you treat as blocking. The default is {'error'}, and the bundled manifest emits warning — so the default returns () for it. Whether a warning should fail your pipeline is a policy decision the SDK does not make for you.

To apply one already-loaded manifest to many bodies, skip evaluate_file and use load_package + evaluate directly — the composition exists for convenience, not as a gate around the lower-level calls.