Skip to content

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.

--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/ and head/.

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 doctor as 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