Repository navigation
Emit guard verdicts as JUnit evidence to a caller-named file #386
Description
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-verifyandgit push --no-verifyrun 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-pushParts
- 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. - The testcase.
nameis the rule id,classnameis the bundled set orlocal. 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 carriesallowed=id|all: whether the id was named orallmatched, whichbypassed()conflates today and a reader may treat differently. - No subject text. The
failureanderrormessages 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'sno-running-os-identity-metadatarule refuses them, and a timestamp is the only run metadata kept. - Open first. The file is opened before the first guard runs, before the
no rule declares a git hookreturn. 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 fromUPHOLD_ALLOW=on purpose: there blank-means-set switched every guard off silently, here it refuses one run loudly. errorand the cut-short run.runstops at the firstFatalfromevaluate. That rule gets anerror; rules the loop never reached get no testcase; the file is still written and the exit is 2. Absence is not a claim. AFatalbefore the loop (push told nothing, policy that will not load) leaves the file absent and exits 2.- Other seams.
--stage manualwrites like any stage.uphold guard --textwith the variable set is exit 2 until the shim follow-up: it reaches rules throughtext::judged, which returns refusals only, so it cannot honestly write a pass. The shim anduphold hookdo not read the variable in this issue, so a variable exported around a whole act does not trip them. - 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 asoutputs, 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-junitreads 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
Fatalinsideevaluatewriteserrorfor that rule and nothing for the rest. Check:tests/guard_cli.rs. -
--textwith the variable set is exit 2. Check:tests/guard_cli.rs. - set on
git push, a real pre-push hook writes thepre-pushsuite. Check: a case on thestructural_test_githarness. - 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.mdbeside "Overriding one",docs/DESIGN.mdafter "One override spelling" ("uphold decides; it does not record", with the--no-verifyparagraph),USAGEinsrc/main.rs. Check:catalog-reference-currentand the structural documentation test.
Scope
uphold guard --stage <stage>(this issue).- The shim and
uphold hookseams, in a follow-up. - 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-junitwrites ahostnameattribute by default. If it does and cannot be told not to, the alternative isjunit-report, not a hand-rolled writer. - Whether Allure 3 reads
<properties>on a<testcase>or only on the suite. If only the suite,builtinandsetmove intoclassnameasset.builtin, andupholdandstagestay on the suite.
Activity
[-]Record every bypass: a local sink first, a remote store later[/-][+]Emit guard verdicts to a caller-named file[/+]Emit guard verdicts to a caller-named file (#387)
HackingGate commentedon Oct 18, 2026
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) anddocs/REFERENCE.md(uphold check --json).1. Where the sink lives
guard_commandreadsUPHOLD_VERDICTSonce, opens the file, and handsguard::runa writer throughRunRequest.runwrites one line per rule; nothing else in the binary reads the variable in this issue. The environment is read at the same boundary as--stageand--message, andrunstays 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 hookearly return. Two consequences: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 fromUPHOLD_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
unknowncoversrunstops at the firstFatalfromevaluate(the?). The rule whose evaluation failed gets anunknownline, 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
Fatalreached before the loop (runner::pushtold nothing, policy that will not load) produces an open file with no lines, or no file at all, and exit 2. Same reading.The
unknownline carries no diagnostic text. AFatalmessage 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"}builtinis added.evaluatedispatches 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.upholdis the binary's version. A bundled set's contents are a property of the binary, so "no-local-mergefromunreviewed-historypassed" 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.setis omitted, notnull, for a rule declared locally, matching howpolicy.set_ofanswers and how the human report names a refusal (refused_by).byis present only onbypass.allowedsays whether the id was named orallmatched, becausebypassed()conflates the two and a recorder may well treat "this gate was switched off whole" differently from "this one rule was waived". Cheap:bypassedreturns an enum instead of a bool.refusecarries 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 newoutcomevalue or a changed meaning bumps the version, because a consumer matching exhaustively onoutcomebreaks either way.6. stdout and stderr are unchanged
The
N guard(s) passedline, thebypassed by UPHOLD_ALLOWline 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 commitrunspre-committhencommit-msg; a recorder that keeps the variable set across the act gets both in one file, which is why every line names itsstage. Each line is written with onewrite_allunder append, so two writers interleave at line granularity. No two guard stages run concurrently today, and in this repository's lefthookpre-committheguardsjob 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 pushis the whole integration, as the issue says. A test should go through a real hook rather than callinguphold guarddirectly, so that claim is checked and not assumed.8.
--textand--stage manual--stage manualemits like any stage.uphold guard --textwith the variable set is exit 2 until the shim follow-up gives that seam lines. It evaluates rules throughtext::judged, which returns refusals only, so it cannot honestly write apass, 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, underuphold 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-verifyparagraph from this issue.USAGEinsrc/main.rs: one line underUPHOLD_ALLOW.10. Tests (
tests/guard_cli.rs, plus one structural)schema_versionis 1.Fatalinsideevaluatewritesunknownfor that rule and nothing for the rest.--textwith the variable set is exit 2.structural_test_githarness): set ongit push, the file holds thepre-pushlines.Estimate
One PR, about a day: roughly 80 lines in
guard/mod.rsandmain.rs, eight tests, three doc edits. Follow-up issue for the shim andhook.rsseams once this lands.[-]Emit guard verdicts to a caller-named file[/-][+]Emit guard verdicts as JUnit evidence to a caller-named file[/+]HackingGate commentedon Oct 18, 2026
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:
.test-evidence/directories, their publisher and Allure already use. pass/refuse/unknown/bypass map to testcase/failure/error/skipped.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,
errorfor the rule whose evaluation failed,--textexit 2, no subject text, tests and doc locations.HackingGate commentedon Oct 18, 2026
Both unknowns are resolved; #388 implements the result and removes the JSON Lines record of #387.
quick-junit0.8 writes nohostnameunless one is placed in a suite'sextramap, so it is the library.junit-reportwriteshostname="localhost"on every suite with no way out, and is not used.packages/reader/src/junitxml/index.tsin allure-framework/allure3) reads no<properties>on a testcase or a suite. It files a testcase byclassname(thetestClasslabel, andfullName = classname.name), the suitenameas the suite label and the suitepackageas the package label. Soclassnamestays the set orlocal, the suite carriespackage="uphold", and the properties serve a machine reader rather than the report.