CLI reference¶
assent is a single static binary. Every command below is dispatched by the shipped
binary — this page is pinned to the binary's own help table by a test, so it cannot
drift from the tool.
Install it first (see Install), then check what you have:
assent version
Help output¶
assent --help (also -h, -help, help) prints the command listing on stdout
and exits 0. A bare assent, or an unknown command, prints the same listing on
stderr and exits 2 — the contract wrapper scripts rely on.
assent — deterministic, policy-driven auto-merge for self-service repositories
Usage:
assent <command> [arguments]
Commands:
run
Evaluate a merge request against its policy and reconcile the decision on the forge
usage: GITLAB_TOKEN=<pat> assent run --project <id> --mr <iid> --subject file:<path> --bot-author <user> [flags]
doctor
Report whether this environment can arm auto-merge, and why not when it cannot
usage: assent doctor
lint
Check a repository's .assent/** policy tree for hard errors
usage: assent lint <dir>
test
Run a repository's .assent/tests/** adopter cases against the real engine
usage: assent test [--update] [--coverage] <repo>
compare
Replay a comparison suite of baseline vs candidate policy and apply the promotion gates
usage: assent compare <dir> | assent compare --suite <dir>
catalogue
Emit the generated rule catalogue for a policy tree as JSON on stdout
usage: assent catalogue <dir>
render
Render a committed finding fixture as markdown for local preview
usage: assent render --finding examples/render/<case> [--artifact finding-thread|summary] [--presentation-minimal|--presentation-full]
eval-input
Assemble the EvaluationInput and pinned SHAs from the CI environment
usage: assent eval-input
version
Print the assent version
usage: assent version
help
Print this help listing
usage: assent help
assent run -h, assent compare -h and assent render -h list their flags.
Full command reference: https://platformrelay.github.io/Assent/usage/cli/
assent run¶
Evaluate a merge request against its policy and reconcile the decision on the forge:
read the MR, load the policy from the target ref, diff → classify → aggregate →
build and schema-validate the DecisionRecord, emit the record, then reconcile against
GitLab. The record is emitted before any forge write, so a run whose emit fails
aborts without touching the forge (D-122 — no record, no action).
GITLAB_TOKEN=<pat> assent run --project <id> --mr <iid> --subject file:<path> --bot-author <user> [flags]
The GitLab personal access token is read from the GITLAB_TOKEN environment variable
and is never a flag; without it the command exits 2 before contacting the forge.
assent run -h prints the same flag list.
| Flag | Default | Meaning |
|---|---|---|
-project |
— | GitLab numeric project id (required) |
-mr |
— | merge-request IID (required) |
-subject |
— | governed-subject entryRef (file:<path>) — the file diffed for evaluation (required) |
-bot-author |
— | bot username for the author-identity filter (required) |
-gitlab-endpoint |
https://gitlab.com |
GitLab instance base URL |
-policy |
.assent/merge-policy.yaml |
MergePolicy path, loaded from the target ref |
-binding |
.assent/ruleset-binding.yaml |
RulesetBinding path, loaded from the target ref |
-config |
— | optional Config path; when set, provider posture is validated |
-pack |
— | optional Pack path; its spec.phase caps every rule's phase |
-checkout |
— | local checkout dir (base/ + head/ subtrees) used to enumerate the MR's full changed-file set. The tree must contain no symlinks — see Symlinks in the checkout tree below. When unset, the forge snapshot is the sole enumerator — see Checkout-less runs and enumeration completeness below |
-emit |
stdout | path to write the DecisionRecord JSON |
-arm |
off | advisory only — it gates nothing. Approve and merge are gated by the forge-probed arming preconditions, which assent doctor reports, not by this flag. See What gates approve and merge below |
Exit codes: 0 the run completed and produced a valid receipt (an advisory
REVIEW/BLOCK, or an APPROVE whose forge-probed arming preconditions are unmet, is
still a clean 0); 1 a hard error during orchestration; 2 a missing flag, a
missing GITLAB_TOKEN, or -h.
What gates approve and merge¶
Not --arm. The flag is a leftover from the walking skeleton, where it did gate the
writes. Since the forge-probe wiring landed it has had exactly one effect: it echoes into
the run summary's arm=<bool> token. Passing it, omitting it, and passing --arm=false
all produce the same forge writes. Read arm=false in a summary as the operator did not
pass the flag — never as nothing was written; the same line's trailing clause
(→ 3 forge operation(s) written) is the one that says what happened.
The real gate is the forge-probed arming precondition, computed from the snapshot's capability flags and default-deny — it is met only when all three hold:
| Precondition | Forge dossier | Refused when |
|---|---|---|
| CI configuration is external/protected, not author-editable in-repo | C17 | insecure-topology |
| the project's all discussions resolved merge gate is enabled | C3 / ADR-0009 | discussions-gate-missing |
| the tier exposes an enforced approval-rules API (not Free) | C6/C7 | tier-capability-gap |
Run assent doctor to see which of the three this environment meets.
When any is unmet the run degrades to advisory: nothing is approved or merged, the summary
reads advisory-only (arming precondition unmet, no approve/merge), and the exit code is
still 0. When all three are met, an APPROVE approves and merges — with or without
--arm.
Further guards refuse the writes even with the precondition met: a self-modifying
.assent/** merge request (BLOCK, zero writes — not even a thread); a fork/untrusted MR
context (advisory-only, ADR-0015 §8); a
controlling authorization fact past its maxAge at arming time
(ADR-0017 §4); and the pre-write SHA guard, which re-reads
the forge's current heads and refuses on drift. Each is a clean 0 with no merge.
How to keep assent advisory¶
There is no dry-run mode today. assent run has no --dry-run flag — passing one exits
2 with flag provided but not defined: -dry-run before the forge is contacted.
The only reliable lever is leaving one of the three arming preconditions above unmet.
Then every APPROVE degrades to advisory-only (arming precondition unmet, no approve/merge)
and no approve or merge is written.
"Advisory" means no approve and no merge — not no writes. The run still posts its summary
comment to the merge request, and on REVIEW or BLOCK one resolvable thread as well. The only
path that writes nothing at all is a self-modifying .assent/** merge request, which fails
closed for a separate reason.
Do not use a rollout phase as a safety switch. A pack's spec.phase
(ADR-0018) is a rollout control,
not a kill switch, and using it as one can have the exact opposite effect. observe and off
exclude the capped rules from the decision structurally — which removes the very findings
that were withholding approval. Measured on an enforcing rule that produces a BLOCK:
| binding | spec.phase ceiling |
decision |
|---|---|---|
require: [signal] |
enforce |
BLOCK |
require: [signal] |
observe |
REVIEW |
require: [signal] |
off |
REVIEW |
(no require:) |
enforce |
BLOCK |
(no require:) |
observe |
APPROVE — approves and merges |
(no require:) |
off |
APPROVE — approves and merges |
The saving grace in the top half is the binding's require: list: an uncovered required
obligation is what degrades the run to REVIEW, because only an enforce-phase rule can mark
one covered. require: is optional in the RulesetBinding schema — absent or empty means
"no required obligations, vacuously covered" — so a binding that has not declared one yet gets
the bottom half. That is the first-pack-rollout case, which is exactly when someone reaches for
observe.
spec.phase is also inert unless you pass --pack: without the flag the ceiling is
enforce and the manifest is never read. Editing the manifest alone changes nothing, so an
operator who edits it and reruns the CI snippet above — which passes no --pack — stays fully
enforcing.
Symlinks in the checkout tree¶
--checkout cannot judge a repository that contains a symlink — any symlink, anywhere
under base/ or head/. The restriction is not limited to symlinks the merge request adds
or touches. A link that predates the branch, lives in a directory unrelated to the governed
subject and to .assent/**, and that nobody has modified still stops the run: a plain
LICENSE -> LICENSES/Apache-2.0.txt present on the base side is enough.
The failure is loud and fail-closed — never a wrong verdict. assent run exits 1, writes
nothing to the forge (no thread, no approval, no merge), and prints an error on stderr
naming the offending path, the checkout side it was found on, and the reason:
assent run: enumerate changed-file set: list changed files: refusing "LICENSE" in checkout tree /tmp/co/base: reached through a symlink at "LICENSE" — the checkout is the content under judgment, so symlinks are refused, never followed
This is a trust boundary, not an oversight. With --checkout the local tree is the sole
authority (D-077) and head/ is the merge-request head — contributor-authored content under
judgment — so a symlink is refused rather than followed. Following one would let a link at a
governed path substitute an off-tree file for the document being judged, or drop a path from
the changed-file set and hide an .assent/** edit from the self-edit guard. See
ADR-0008 Amendment 2.
What to do about it:
- Run without
--checkout. The forge snapshot then enumerates the changed-file set and none of the above applies — symlinks in the repository become irrelevant. This is the supported way to evaluate a repository that legitimately contains one; the trade-off is the snapshot-completeness behaviour described below. Note that the symlink refusal hardens what the checkout may contain; it does not establish which commit the checkout is — see Known limitation: the checkout is not bound to the evaluated commit below. - Or provision a symlink-free checkout for
base/andhead/.
The two side directories base/ and head/ may themselves be symlinks — they are
operator-provisioned, and only what lies beneath them is contributor content.
Non-regular files (FIFOs, sockets, devices) under either side are refused the same way, for the same reason. Loosening the symlink refusal will mean folding it into the opaque / fail-safe REVIEW path, so such a repository gets a decision someone must look at — never by following the link. There is no release commitment for that today.
Known limitation: the checkout is not bound to the evaluated commit¶
assent judges the tree you hand it, and nothing verifies that tree is the commit the forge
will merge. With --checkout the local tree is the sole authority for the bytes under
judgment and for the changed-file set (D-077); the SHAs that pin the approval and the
compare-and-swap merge come from the forge's view of the merge request. The two are never
compared — assent run has no step that hashes the checkout or matches it against
pins.sourceSha. If they disagree, assent decides on one tree and the forge merges another.
This is a property of how the checkout is constructed, not a fault that fires on every run. Two operator obligations make it a non-issue, and assent performs neither of them for you:
- Construct
head/from the merge-request head commit — the SHA the pipeline was triggered for — rather than from a branch tip resolved at clone time. - Cancel superseded pipelines on a new push. A push landing between the clone and the
decision leaves assent judging the older tree while the forge merges the newer head.
This is a project setting on your forge; assent never reads it and never reports on it,
so do not treat a green
assent doctoras evidence that it is set.
Runs without --checkout are not exposed to this: the forge snapshot is then both the
enumerator and the thing the pins describe.
Checkout-less runs and enumeration completeness¶
Without -checkout, the forge snapshot's changed-file list is the only thing that can
see a .assent/** policy edit outside the governed subject — so an incomplete list
would silently starve the self-edit guard. Per ADR-0020
the adapter must therefore prove completeness (paginated /diffs, cross-checked
against the MR's changes_count, below a page ceiling). When it cannot, the run does
not guess and does not fail silently: the change set is marked opaque and the decision
degrades to REVIEW with finding code changeset.undecidable, carrying the gap
reason. A DecisionRecord is still emitted and a thread still posted; approve and
merge are impossible on that path. A .assent/** path that is visible in a partial
list still dominates to BLOCK.
With -checkout the local tree is the sole authority (D-077) and snapshot completeness
is not consulted.
assent doctor¶
Report whether this environment can arm auto-merge, and why not when it cannot.
assent doctor
"Armed" here means assent itself may approve and merge — immediately and SHA-pinned, on a run that decides APPROVE. It does not mean assent hands the forge a deferred merge-when-pipeline-succeeds instruction to fire later; it never does that. See What gates approve and merge above, and Step 6 of the walkthrough.
With GITLAB_TOKEN set (plus CI_PROJECT_ID and CI_MERGE_REQUEST_IID) it probes the
forge for verified capabilities. Without a token it falls back to an env-only
diagnostic and prints an explicit INSECURE banner, because env self-assertions are
spoofable by an author-editable CI job.
Exit codes: 0 arming precondition met; 1 not armed (each blocking reason is
listed); 2 the forge probe could not run.
assent lint¶
Check a repository's .assent/** policy tree for hard errors. The directory argument
is the repository root — assent joins .assent itself.
assent lint <dir>
Exit codes: 0 no error diagnostics; 1 at least one error diagnostic; 2 usage or
discovery failure.
assent test¶
Run a repository's .assent/tests/** adopter cases against the real engine: each case
diffs its base/ ↔ head/ trees with the production differ, stubs facts.yaml into
the resolved-fact envelope, evaluates the pack, and asserts the produced decision
equals expect.yaml.
assent test [--update] [--coverage] <repo>
| Flag | Meaning |
|---|---|
--update |
rewrite each failing case's expect.yaml from the produced actual. Refused when a CI environment variable is set — auto-accepting actuals in CI would ratify a regression. Run it locally and review the diff. |
--coverage |
read-only both-polarity completeness gate. Never writes goldens, and supersedes --update when both are passed. |
Exit codes: 0 every case matched (or, under --update, every failing case was
refreshed); 1 a mismatch, write or load error; 2 usage, discovery or CI-guard
refusal.
assent compare¶
Replay a comparison suite of baseline vs candidate policy and apply the promotion gates: load immutable replay bundles, evaluate both sides through the same engine, classify the deltas, and gate the promotion.
assent compare <dir> | assent compare --suite <dir>
| Flag | Meaning |
|---|---|
--suite |
PolicyComparisonSuite directory, or a suite.yaml/suite.json path |
--baseline-profile |
baseline PolicyProfile name (overrides the suite default) |
--candidate-profile |
candidate PolicyProfile name (overrides the suite default) |
--record |
directory to write one ComparisonRecord JSON per case id |
Exit codes are the promotion-gate contract: 0 all gates pass; 1 a missed
destructive change; 2 a missed authorization/ownership change; 3 an unexpected
obligation removal; 4 auto-merge widening beyond the bound; 5 deltas that were not
explicitly accepted; 6 fail-closed — a load, schema, digest or classification error.
Two paths sit outside that contract and are easy to misread. 6 is also what an
unreadable or missing input directory returns, so a wrapper invoking
assent compare "$DIR" with $DIR unset reports fail-closed rather than a usage
error — check the stderr message before assuming a classification failure. And
assent compare -h prints the flag list and exits 0, which here means "help was
printed", not "all gates pass".
assent catalogue¶
Emit the generated rule catalogue for a policy tree as JSON on stdout, for the docs
pipeline. Catalogue generation is a docs artifact, not a gate, so it is a separate
command rather than a lint flag.
assent catalogue <dir>
Exit codes: 0 catalogue emitted; 2 usage, discovery or load failure.
assent render¶
Render a committed finding fixture as markdown for local preview, without a live merge request.
assent render --finding examples/render/<case> [--artifact finding-thread|summary] [--presentation-minimal|--presentation-full]
| Flag | Default | Meaning |
|---|---|---|
-finding |
— | render fixture directory under examples/render/<case> (required) |
-artifact |
finding-thread |
artifact to render: finding-thread or summary |
-presentation-minimal |
off | omit evaluation details (verbosity minimal) |
-presentation-full |
off | show all evaluation detail blocks (verbosity full) |
Exit codes: 0 markdown emitted; 1 the fixture failed to load or validate; 2 a
usage error.
assent eval-input¶
Assemble the EvaluationInput and pinned SHAs from the CI environment and print a one-line summary. This is the CI-adapter smoke path: it proves the environment boundary end-to-end without touching a policy.
assent eval-input
Exit codes: 0 assembled; 1 a required CI variable is missing or empty.
assent version¶
Print the assent version.
assent version
Release archives and the Homebrew build stamp the real semver at link time. A binary
built with go install reports 0.0.0-dev — see Install.
assent help¶
Print the command listing shown above on stdout and exit 0.
assent help