policy.toml reference
A repository opts into gating by placing exactly one file at
.vidi/policy.toml. No policy file means no gating at all: the verdict is
NEUTRAL and the gate exits 0, except for the two integrity failures that are
not policy questions (see Unit states).
Source of truth: crates/vidi-core/src/policy/.
Discovery
The path is fixed: .vidi/policy.toml, relative to the repository root.
Discovery does not consult home directories, environment variables, ancestor
folders, or legacy filenames. There is exactly one place a policy can live, so
no repository can be gated by a file you cannot see in its diff.
A .vidi/policy.toml that resolves outside the repository root (a symlink
escape) is refused rather than followed.
The file is a trust boundary
Two consequences worth internalising before the key tables:
Unknown keys are a hard error. Every table is deny_unknown_fields. A typo
in a security key fails the load; it is never parsed and ignored.
A policy that gates nothing will not load. A file with no [[cover]], no
[[scope]], and default = "advisory" is rejected as vacuous. Either declare
an obligation or delete the file; “present but inert” is not a state you can
reach by accident.
Load failures are refusals, not warnings, and each points at the offending bytes.
Top-level keys
| Key | Required | Type | Meaning |
|---|---|---|---|
schema | yes | "MAJOR.MINOR" | Schema version. This engine enforces "1.0". |
default | yes | "advisory" or accept-row list | Posture for paths matched by no gating [[scope]]. |
[axes] | no | table | The two ranked reviewer ladders. |
[reviewers] | no | table | The reviewer directory: identity → role. |
[[cover]] | no | array | Proactive coverage obligations. |
[[scope]] | no | array | Reactive path-scoped requirements. |
[severity] | no | table | Advisory thresholds. Never gates. |
[[exemptions]] | no | array | Audited exclusions from the unreviewed set. |
schema
Exactly two dotted integers. No patch component, no zero-fill, no partial parse:
"1", "1.2.3" and "1.x" are all rejected.
Version skew fails closed in three of four directions:
| File | Engine | Result |
|---|---|---|
1.0 | 1.0 | loads |
1.0 | 1.4 | loads; the file uses a subset of what the engine knows |
1.7 | 1.0 | refused; the file may carry a gating field this engine cannot enforce |
0.9 / 2.0 | 1.0 | refused; a major mismatch in either direction |
A newer minor is refused rather than best-effort honoured, because the failure mode of guessing is a build that passes while a rule goes unenforced.
default
Either the string "advisory" or a list of accept rows.
default = "advisory" # unmatched paths are report-only
default = [ { human = "reviewer" } ] # unmatched paths must meet this
default = "advisory" is the recommended starting posture: paths matched by no
gating [[scope]] are reported but never fail the build. A strict default gates
the entire repository, including files nobody has thought about yet.
A default = [] with zero rows is a load error, not “allow everything”.
[axes]: the ranked ladders
Two ladders, human and machine. Each is a weakest-first list; a name’s
index is its rank.
[axes]
human.order = ["contributor", "reviewer", "maintainer"]
machine.order = ["coderabbit", "cubic", "claude-sonnet-5", "claude-opus-5"]
The names are yours. Vidi attaches no meaning to "maintainer" beyond “index 2,
therefore outranks index 1”. A requirement of human = "reviewer" is satisfied
by anyone at that rung or above.
The two ladders never substitute for each other: a tool review supplies a machine rank and never pads the human axis, however capable the tool. This is lane discipline, and it is the point of having two ladders rather than one.
The machine rank resolves against the review’s model first, then its tool,
so you can rank a specific model or fall back to ranking the vendor.
aliases: renaming a rung
[axes]
human.order = ["contributor", "reviewer", "maintainer"]
human.aliases = { "owner" = "maintainer" }
An alias lets a renamed rung keep old ledger stamps and directory entries resolvable. Three rules:
- One hop, never chained. An alias pointing at another alias resolves nothing. You get a load-time warning, and the review supplies no rank.
- A real rung wins over a same-named alias.
- Only role names are alias-resolved. An accept-row minimum must name a
rung that actually exists in
order.
unranked is reserved on both ladders and as an alias name. It is the
record-layer sentinel for “vouched with no directory standing”, so it can never
double as a real rank.
[reviewers]: the directory
[reviewers]
"vidi:p:018f3c2e-9d41-7c3a-b1f2-4a5d6e7f8a9b" = { role = "maintainer", name = "Ada" }
| Field | Required | Meaning |
|---|---|---|
role | yes | Must name a rung on the human ladder. |
name | no | Display only. Never load-bearing for a gate. |
Only people listed here have a standing that can satisfy a rank
requirement. A review from an identity absent from [reviewers] supplies no
human rank at all. It is not an error; it simply does not count. That is
deliberate: departure fully weakens, with no ledger rewrite required.
The key must match the recorded identity exactly
This is the detail that most often makes a correct-looking policy gate nothing.
The key is matched as a literal string against the attesterIdentity.id
stored in the review record. Which string that is depends on how you logged in:
| Login | Recorded identity | [reviewers] key to use |
|---|---|---|
Hosted (vidi login) | a person GUID | "vidi:p:<guid>" |
Local (vidi login --local) | the identity you established, by default your git config user.email | that exact string, e.g. "[email protected]" |
Check what your ledger actually recorded before writing the key: the
attesterIdentity.id field of any line in .vidi/reviews.jsonl is the string to
copy.
A mismatch is silent by design: an unknown identity is a stranger, and strangers supply no rank. If a vouch you know exists is not satisfying a requirement, this is the first thing to check.
roleAtReview: rank at vouch time
A review stamps the reviewer’s standing at the moment of the ceremony. The rank it supplies afterwards is the weaker of the stamped and the current standing:
| Stamp | Supplies |
|---|---|
| a role name | min(stamped, current) |
| absent (legacy line) | the current directory role |
unranked | no rank, ever |
| a name that resolves to no rung | no rank (fail-closed) |
So a promotion never retroactively upgrades old vouches, a demotion weakens them immediately, and a vouch made with no standing cannot be converted into coverage later by adding that person to the directory. They re-vouch at the new standing to count.
Accept rows
An accept row is a conjunction of minima, one per axis. Every field is optional individually, but a row with no minimum on any axis is a load error; it would pass with zero review.
{ human = "maintainer", machine = "claude-opus-5", assurance = "session-verified" }
| Axis | Compared against |
|---|---|
human | The reviewer’s human-ladder rank (see above). |
machine | The tool review’s machine-ladder rank. |
assurance | The grade a verified receipt proves. Not a policy ladder; the vocabulary is fixed. |
Rows in a list are OR’d; minima within a row are AND’d. So accept reads as
“any one of these combinations”.
The grammar admits only >= minima: no negation, no maxima. Policy is
therefore monotone by construction: more review, or stronger review, can only
ever flip a unit from fail to pass. You cannot write a rule that a further vouch
breaks.
The assurance vocabulary
Fixed and server-set, because a client cannot attest to its own assurance:
| Rung | Meaning |
|---|---|
self-asserted | The floor. What a receiptless review proves. |
session-verified | Stronger. |
linked | Stronger still. |
presence-verified | The strongest. |
In self-hosted use with no notary, every review sits at self-asserted. An
accept row demanding more is unmeetable. It fails every unit rather than
passing them, which is the correct direction, but it is not a useful gate until
receipts are in play.
[[cover]] vs [[scope]]
The two obligation types differ in a way the names understate.
[[cover]] is proactive. Every unit under paths must carry a fresh,
approved review meeting require. Absence is a failure. Use it for a pinned
trusted set you intend to hold at full coverage.
[[cover]]
name = "crypto-tcb"
paths = ["crates/vidi-core/src/crypto/**"]
require = { human = "maintainer" }
| Field | Required | Notes |
|---|---|---|
name | yes | Names the obligation in diagnostics. |
paths | yes | Explicit globs. An empty list is a load error. |
require | yes | A single accept row, not a list. |
[[scope]] is reactive. Units under path that are present must meet one
of the accept rows.
[[scope]]
path = "crates/vidi-core/src/crypto/**"
accept = [ { human = "maintainer" }, { human = "reviewer", machine = "claude-opus-5" } ]
| Field | Required | Notes |
|---|---|---|
path | yes | A single glob. An empty string is a load error. |
accept | yes | A list of rows, OR’d. An empty list is a load error. |
Shortfalls from the two surface under different words in the summary line:
[[cover]] and [[scope]] produce requirement failures, while paths falling
through to a strict default produce default shortfalls.
An obligation that can never match any path (an empty paths list, an
empty-string glob) is refused at load. It would otherwise satisfy the
“policy present ⇒ gates something” check from the inside while gating nothing.
Path globs
Matching is segment-wise over /, byte-exact and case-sensitive:
| Pattern | Matches |
|---|---|
** | zero or more whole path segments |
* | any run of bytes within a single segment |
? [ { | literal characters, not metacharacters |
crates/**/billing/** matches at any depth, including crates/billing/mod.rs
(** spans zero segments). src/*.rs matches src/lib.rs but not
src/a/b.rs.
There is no brace expansion and no character classes. This is a deliberately small grammar; a policy glob should not be able to surprise its author.
[severity] and [[exemptions]]
[severity]
major = 0.7
[[exemptions]]
unit = "crates/vidi-core/src/legacy.rs::fn:shim"
reason = "scheduled for deletion, tracked in VIDI-214"
[severity] sets advisory band thresholds. It never gates: significance
and severity order the re-review queue and nothing else.
[[exemptions]] deliberately excludes one unit from the unreviewed set; it is
removed from the denominator and reads as Exempt. Both fields are required;
the mandatory reason gives every exemption its own audit trail in the diff.
A minimal starting policy
Everything advisory except one directory that genuinely requires review:
schema = "1.0"
default = "advisory"
[axes]
human.order = ["contributor", "reviewer", "maintainer"]
[reviewers]
"[email protected]" = { role = "maintainer", name = "Ada" }
[[scope]]
path = "crates/vidi-core/src/crypto/**"
accept = [ { human = "maintainer" } ]
Expect the requirement-failure count to jump the moment you add that scope: every unit under it is unreviewed until someone vouches. That is the policy working, not a misconfiguration. Widen scopes as coverage grows; a policy that gates the whole repository on day one just fails every build, and a gate that always fails teaches people to ignore it.
Load errors
Every one of these refuses the load outright, and a refusal is exit 2, not the exit 1 of a failed gate. The distinction matters in CI: exit 1 means “the code needs review”, exit 2 means “your policy is broken and nothing was checked”. A job that treats them alike will read a malformed policy as a review backlog.
| Error | Cause |
|---|---|
| parse error | Malformed TOML, an unknown key, or a mistyped field. |
| schema major mismatch | MAJOR differs from the engine’s, either direction. |
| schema minor ahead | File MINOR is ahead of the engine’s. |
| empty accept-row list | A [[scope]].accept or strict default with zero rows. |
| accept row states no minimum | A row with no axis set. |
| unknown ladder rung | A reviewer role or accept-row minimum naming no rung. |
| reserved ladder rung | A rung or alias named unranked. |
| vacuous policy | Present, but declares no obligation. |
| unmatchable obligation | A [[cover]]/[[scope]] glob that can match nothing. |
| policy path escapes the repository | .vidi/policy.toml resolves outside the root. |
The one non-fatal case is an alias pointing at a non-rung: a warning, and the alias resolves nothing.