Skip to content

Emit guard verdicts as JUnit evidence to a caller-named file #386

Closed

Description

@HackingGate

Problem

A guard run leaves no machine-readable account of what it decided.

  • UPHOLD_ALLOW=<id> prints one line to stderr (uphold guard: <id> bypassed by UPHOLD_ALLOW). It is gone with the terminal scrollback.
  • A pass, a refusal and a bypass are all prose on stderr, so anything that wants to publish or sign "this commit passed these rules" has to parse prose.
  • git commit --no-verify and git push --no-verify run no hook at all.

Why JUnit, and why the name is "evidence"

The fleet already has one evidence format. A gate tier in the private fleet writes JUnit XML to .test-evidence/<task>/<runner>.xml; the evidence publisher refuses a partial directory and publishes the rest as one Allure report; the gate recorder links that report from the gate/<repo> commit status and signs the gate identity. Nothing in that chain reads a format of uphold's own, and the recorder's outcomes file is {"task": "ran"|"restored"}, a tier map, not a rule log. An earlier draft of this issue said the recorder "reads a file of the same shape"; it does not.

JUnit's four testcase states are the four answers a guard already gives:

guard JUnit exit
pass <testcase> with no child 0
refuse <failure> 1
unknown (could not look) <error> 2
bypass <skipped message="bypassed by UPHOLD_ALLOW"> 0

So uphold speaks the evidence interface the gate already has, and "evidence" means here what it means in .test-evidence/. Inside uphold the word keeps its other meaning, a provider's facts to a policy (src/evidence.rs); the two do not meet.

Design

uphold decides; it does not record. Publishing and signing belong to whatever runs the gate, so uphold writes one JUnit document and the caller names where.

UPHOLD_JUNIT=/abs/path/pre-push.xml uphold guard --stage pre-push

Parts

  1. The file. Unset: nothing is written, behaviour unchanged. Set: one JUnit XML document per invocation, one <testsuite name="uphold guard <stage>">, one <testcase> per rule the stage evaluated. The file is created or replaced, never appended: a JUnit document is one tree, which is why the variable names a file per stage and not one per act. Standard: JUnit XML as Allure 3, nextest and pytest write and read it. Library: quick-junit (the nextest crate, Apache-2.0 OR MIT); nothing hand-serialized.
  2. The testcase. name is the rule id, classname is the bundled set or local. Properties: builtin (the dispatch name, since a repository may call a rule anything), set, stage, uphold (the binary version, because a bundled set's contents are a property of the binary). Bypass carries allowed=id|all: whether the id was named or all matched, which bypassed() conflates today and a reader may treat differently.
  3. No subject text. The failure and error messages are the rule id and the word; the report stays on stderr. No commit message, body, path or argv enters the file. No hostname and no user name: the fleet's no-running-os-identity-metadata rule refuses them, and a timestamp is the only run metadata kept.
  4. Open first. The file is opened before the first guard runs, before the no rule declares a git hook return. An unwritable path is exit 2 with nothing judged. A stage with no rules writes a suite with zero tests, so "ran, nothing declared" differs from "never ran". The path must be absolute, since git runs a hook from the top of the working tree and a relative path would land where the caller did not look; relative is exit 2 naming the value. Empty (UPHOLD_JUNIT=) is exit 2 too, the opposite reading from UPHOLD_ALLOW= on purpose: there blank-means-set switched every guard off silently, here it refuses one run loudly.
  5. error and the cut-short run. run stops at the first Fatal from evaluate. That rule gets an error; rules the loop never reached get no testcase; the file is still written and the exit is 2. Absence is not a claim. A Fatal before the loop (push told nothing, policy that will not load) leaves the file absent and exits 2.
  6. Other seams. --stage manual writes like any stage. uphold guard --text with the variable set is exit 2 until the shim follow-up: it reaches rules through text::judged, which returns refusals only, so it cannot honestly write a pass. The shim and uphold hook do not read the variable in this issue, so a variable exported around a whole act does not trip them.
  7. stdout and stderr unchanged. The prose stays as it is; the file is additive.

Where it composes

  • A gate tier: UPHOLD_JUNIT=$PWD/.test-evidence/check-guards/uphold.xml uphold guard --stage manual, declared in mise with that directory as outputs, published by the evidence publisher with no change there.
  • A hook run: the hook runner's environment reaches uphold guard, since git, prek, pre-commit and lefthook all pass theirs on. The caller names a file outside the tree, or inside .test-evidence/, which is ignored.
  • Signing: out of uphold. the gate recorder may later attest the JUnit file as an in-toto resource descriptor beside the gate identity; that is a predicate change ruled in its own issue there.

--no-verify needs no code here

No hook runs, so no file exists, so a recorder signs nothing for that commit. A verifier that expects evidence on every pushed commit sees the absence, held on the remote and out of reach of the machine that skipped the hooks.

Done when

  • pass, refuse, bypass and unknown each produce the documented testcase; the document parses and quick-junit reads it back. Check: tests/guard_cli.rs.
  • an unwritable path is exit 2 and the refusing guard never printed. Check: tests/guard_cli.rs.
  • relative and empty values are exit 2 naming the value. Check: tests/guard_cli.rs.
  • a stage with no rules writes a suite with zero tests. Check: tests/guard_cli.rs.
  • a Fatal inside evaluate writes error for that rule and nothing for the rest. Check: tests/guard_cli.rs.
  • --text with the variable set is exit 2. Check: tests/guard_cli.rs.
  • set on git push, a real pre-push hook writes the pre-push suite. Check: a case on the structural_test_git harness.
  • the file holds no hostname, user name, commit text or path. Check: a structural test over the serializer's output under no-running-os-identity-metadata.
  • the evidence publisher accepts a .test-evidence/ directory holding only this file. Check: a case in its own test suite, after the uphold release.
  • Documented: docs/REFERENCE.md beside "Overriding one", docs/DESIGN.md after "One override spelling" ("uphold decides; it does not record", with the --no-verify paragraph), USAGE in src/main.rs. Check: catalog-reference-current and the structural documentation test.

Scope

  1. uphold guard --stage <stage> (this issue).
  2. The shim and uphold hook seams, in a follow-up.
  3. The gate recorder attesting the file, in a design issue of its own.

Out of scope

Retention, signing, and where the file goes after the run: the caller's. A signing key per machine proves which machine signed, not that the checks ran there; a second holder is a recorder question.

Unknowns

  • Whether quick-junit writes a hostname attribute by default. If it does and cannot be told not to, the alternative is junit-report, not a hand-rolled writer.
  • Whether Allure 3 reads <properties> on a <testcase> or only on the suite. If only the suite, builtin and set move into classname as set.builtin, and uphold and stage stay on the suite.

Activity

  1. changed the title [-]Record every bypass: a local sink first, a remote store later[/-] [+]Emit guard verdicts to a caller-named file[/+] on Oct 17, 2026
  2. HackingGate commented on Oct 18, 2026

    @HackingGate
    OwnerAuthor

    Design, continued: what the code decides for us

    Read against src/guard/mod.rs (run, evaluate, bypassed), src/main.rs (guard_command), src/check.rs (Report) and docs/REFERENCE.md (uphold check --json).

    1. Where the sink lives

    guard_command reads UPHOLD_VERDICTS once, opens the file, and hands guard::run a writer through RunRequest. run writes one line per rule; nothing else in the binary reads the variable in this issue. The environment is read at the same boundary as --stage and --message, and run stays testable against an in-memory writer.

    2. Open before the first guard runs

    The file is opened for create+append before the loop, and before the no rule declares a git hook early return. Two consequences:

    • A path that cannot be written is exit 2 with no guard evaluated. The caller asked for a record and cannot have one, so nothing is judged that would go unrecorded.
    • A stage with nothing declared leaves an existing empty file. "Ran, nothing to evaluate" is then distinguishable from "never ran", which a verifier reading absence needs.

    3. The path must be absolute

    Git runs a hook with the working directory at the top of the working tree. A relative path the recorder set from a subdirectory would land beside the repository root, not where the recorder reads. Relative is exit 2, naming the value.

    Empty (UPHOLD_VERDICTS=) is also exit 2. This is deliberately the opposite reading from UPHOLD_ALLOW=, where blank is treated as unset: there, blank-means-set switched every guard off silently; here, blank-means-set only refuses one run, loudly. A caller that exported and blanked the variable learns at once.

    4. One line per rule, and what unknown covers

    run stops at the first Fatal from evaluate (the ?). The rule whose evaluation failed gets an unknown line, then the error propagates. Rules the loop never reached get no line. Absence is not a claim, and exit 2 tells the recorder the run was cut short. The complete list of rules a stage would evaluate is the policy's to answer, not the record's.

    A Fatal reached before the loop (runner::push told nothing, policy that will not load) produces an open file with no lines, or no file at all, and exit 2. Same reading.

    The unknown line carries no diagnostic text. A Fatal message can quote a path or a message line, and the record holds nothing about the subject.

    5. Fields

    {"schema_version":1,"uphold":"1.32.0","stage":"pre-push","rule":"prevent-public-push","builtin":"prevent-public-push","set":"unowned-push","outcome":"pass"}
    {"schema_version":1,"uphold":"1.32.0","stage":"pre-push","rule":"no-local-merge","builtin":"no-local-merge","set":"unreviewed-history","outcome":"bypass","by":"UPHOLD_ALLOW","allowed":"id"}
    • builtin is added. evaluate dispatches on the built-in name, not the id; a repository may call a rule anything. A verifier asking "did prevent-public-push run" means the built-in, and the id alone is a name the repository chose.
    • uphold is the binary's version. A bundled set's contents are a property of the binary, so "no-local-merge from unreviewed-history passed" is only a complete statement beside the version that defined the set. One short field per line keeps each line self-contained, which is what lets the recorder fold lines in verbatim.
    • set is omitted, not null, for a rule declared locally, matching how policy.set_of answers and how the human report names a refusal (refused_by).
    • by is present only on bypass. allowed says whether the id was named or all matched, because bypassed() conflates the two and a recorder may well treat "this gate was switched off whole" differently from "this one rule was waived". Cheap: bypassed returns an enum instead of a bool.
    • refuse carries no report text. The report goes to stderr as today.

    Evolution follows uphold check --json: fields may be added within version 1 and a consumer ignores keys it does not know. A new outcome value or a changed meaning bumps the version, because a consumer matching exhaustively on outcome breaks either way.

    6. stdout and stderr are unchanged

    The N guard(s) passed line, the bypassed by UPHOLD_ALLOW line and the refusal report keep their wording. The file is additive. Nothing is known to parse the prose, and this issue is what makes parsing it unnecessary.

    7. One act, several stages, one file

    git commit runs pre-commit then commit-msg; a recorder that keeps the variable set across the act gets both in one file, which is why every line names its stage. Each line is written with one write_all under append, so two writers interleave at line granularity. No two guard stages run concurrently today, and in this repository's lefthook pre-commit the guards job is the only emitter among its parallel siblings.

    The variable reaches the hook because git hands its environment to hooks, and lefthook, prek and pre-commit hand theirs to the commands they run. Setting it on the git push is the whole integration, as the issue says. A test should go through a real hook rather than calling uphold guard directly, so that claim is checked and not assumed.

    8. --text and --stage manual

    --stage manual emits like any stage. uphold guard --text with the variable set is exit 2 until the shim follow-up gives that seam lines. It evaluates rules through text::judged, which returns refusals only, so it cannot honestly write a pass, and a caller that asked for verdicts and got silence is the case the issue refuses to create. The shim does not read the variable at all in this issue, so a variable exported around a whole act does not trip the shim.

    9. Where the words go

    • docs/REFERENCE.md, under uphold guard, a subsection beside "Overriding one": the variable, the line shape, the three exit-2 cases, and the evolution rule.
    • docs/DESIGN.md, after "One override spelling": "uphold decides; it does not record", with the --no-verify paragraph from this issue.
    • USAGE in src/main.rs: one line under UPHOLD_ALLOW.

    10. Tests (tests/guard_cli.rs, plus one structural)

    1. pass, refuse and bypass each produce the documented line; every line parses; schema_version is 1.
    2. an unwritable path is exit 2 and the refusing guard never printed, so the open happened first.
    3. relative and empty values are exit 2, naming the value.
    4. a stage with no rules leaves an empty file.
    5. a Fatal inside evaluate writes unknown for that rule and nothing for the rest.
    6. --text with the variable set is exit 2.
    7. through a real hook (the structural_test_git harness): set on git push, the file holds the pre-push lines.
    8. structural: the example lines in REFERENCE.md carry exactly the keys the serializer emits.

    Estimate

    One PR, about a day: roughly 80 lines in guard/mod.rs and main.rs, eight tests, three doc edits. Follow-up issue for the shim and hook.rs seams once this lands.

  3. changed the title [-]Emit guard verdicts to a caller-named file[/-] [+]Emit guard verdicts as JUnit evidence to a caller-named file[/+] on Oct 18, 2026
  4. HackingGate commented on Oct 18, 2026

    @HackingGate
    OwnerAuthor

    Revised the body: JUnit XML to a caller-named file (UPHOLD_JUNIT), not JSON Lines of uphold's own shape.

    What changed against the comment above:

    • The format is JUnit, the evidence format the fleet's .test-evidence/ directories, their publisher and Allure already use. pass/refuse/unknown/bypass map to testcase/failure/error/skipped.
    • The claim that the gate recorder reads "a file of the same shape" was wrong. Its outcomes file is a tier map and its predicate holds tiers; attesting the JUnit file is a design issue of its own there.
    • One document per invocation, replaced not appended, so the variable names a file per stage.
    • Library named: quick-junit. Two unknowns recorded (hostname attribute, testcase properties in Allure).

    Everything else from the comment stands: open before the first guard, absolute and non-empty path, error for the rule whose evaluation failed, --text exit 2, no subject text, tests and doc locations.

  5. HackingGate commented on Oct 18, 2026

    @HackingGate
    OwnerAuthor

    Both unknowns are resolved; #388 implements the result and removes the JSON Lines record of #387.

    • quick-junit 0.8 writes no hostname unless one is placed in a suite's extra map, so it is the library. junit-report writes hostname="localhost" on every suite with no way out, and is not used.
    • Allure 3's JUnit reader (packages/reader/src/junitxml/index.ts in allure-framework/allure3) reads no <properties> on a testcase or a suite. It files a testcase by classname (the testClass label, and fullName = classname.name), the suite name as the suite label and the suite package as the package label. So classname stays the set or local, the suite carries package="uphold", and the properties serve a machine reader rather than the report.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions