> ## Documentation Index
> Fetch the complete documentation index at: https://docs-stg.outerlayer.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Write a validator file

> Declare a check of your own in .outerlayer/validators/: what it requires, where it applies, and whether CI, your host or a judge records it.

A validator is a file in `.outerlayer/validators/` that declares one check. This page shows how to write one. [Policy and validators](/policy-and-validators) covers the verdict, the policy file and the two starter checks.

The files are read from the pull request's base branch, or from the control plane when the factory has one. A change to a file takes effect when it merges. A check whose result someone records follows the rules in [Which result counts](/attach-evidence#which-result-counts).

## Custom validators

Each file in `.outerlayer/validators/` declares one check. Only `.yaml` and `.yml` files are read, and only the first twenty by name. More files raise a policy error row.

```yaml theme={"system"}
id: e2e-ran
kind: validation
row: "The end-to-end suite ran and passed in this session"
level: warn
when:
  paths: ["apps/web/**"]
require:
  session.ran:
    command: "playwright test"
    status: ok
```

| Key | Required | Meaning |
| - | - | - |
| `id` | yes | A lowercase letter, then lowercase letters, digits, `.`, `-` or `_`, up to 64 characters. Unique in the directory, and not a [reserved id](#reserved-ids). |
| `kind` | yes | `validation` renders a row. `signal` reserves the id and renders nothing. |
| `row` | for `validation` | The sentence the row shows. One line, at most 200 characters. Claim only what the requirement proves. |
| `name` | no | A short label for the row, at most 40 characters. |
| `level` | no | `warn` (default), `info` or `off`. |
| `when` | no | Scope. When it does not match, the check renders no row. Every key you set must match. |
| `require` or `run` | exactly one | What satisfies the check. |
| `needs` | no | A list of the recorded detail the check reads: `commands`, `edits` or both, as in `needs: [commands]`. A session recorded without it reads as not checkable, not failed. A `session.ran` requirement reads as not checkable without command detail even when `needs` is absent. |

### Scoping with `when`

| Key | Matches when |
| - | - |
| `paths` | Any changed file matches a glob (at most 20, each at most 200 characters). The paths are those of every live pull request of the item. |
| `repositories` | Any live pull request of the item is in one of the listed repositories, as `owner/repo` names (at most 20, compared without regard to case). |
| `issue.type` | The linked issue's type matches, case-insensitive. |
| `issue.labels` | The linked issue carries any of the labels. Labels match exactly, case included. |

A glob matches the whole path. `*` matches within one segment and `?` one character, neither crossing `/`. `**` matches any run of characters, `/` included. Every other character is literal, so braces such as `{ts,tsx}` match nothing. Because `**` stands for characters, not whole folders, `**/foo.ts` does not match a top-level `foo.ts`, and `apps/**/x` does not match `apps/x`. List both forms when you need both.

Use `repositories` when a check applies to some of a factory's repositories, such as the frontend and the mobile app, but not the rest. A control plane supplies one set of checks to every repository, so the check stays in one file and the list says where it runs:

```yaml theme={"system"}
id: e2e-ran
kind: validation
row: "The end-to-end suite ran and passed in this session"
when:
  repositories: [acme/web, acme/mobile]
require:
  session.ran:
    command: "playwright test"
    status: ok
```

A name the factory does not link produces a policy error row that names it. To scope by both, set both keys: `paths` and `repositories` must each match.

A file scoped on the issue renders no row when no issue is linked, or when the issue cannot be read. A file scoped on `paths` renders no row when the changed files cannot be read.

### Requirements

`require` names one requirement. To accept any of several, list them under `any`:

```yaml theme={"system"}
require:
  any:
    - session.ran:
        command: "yarn test"
    - validator: e2e-ran
    - emitted: e2e
```

| Alternative | Satisfied when |
| - | - |
| `session.ran` | A recorded session ran a command starting with those words, with the given `status` (`ok` by default, or `error`). Matching is whole-word prefix: `playwright` matches `playwright test e2e`. A run with `--help`, `--dry-run` or `--version` never counts. |
| `validator` | Another custom validator in the same directory passed on this pull request. When that one renders no row, because of its `when` or level `off`, this requirement is not checkable rather than failed. When it is waiting, so is this one. |
| `artifact` | An artifact of `kind` (`report`, `screenshot`, `video`, `log`, `test` or `file`), emitted with `--for` set to `for`, is attached to a live pull request of the work item. Both keys are required. `for` is 1 to 64 letters, digits, `_`, `.`, `:` or `-`. Nothing attached yet reads as waiting while a claim on the work item is live, and as failed afterwards. See [Bind it to a criterion](/attach-evidence#bind-it-to-a-criterion). |
| `emitted` | A check under that name was recorded as a pass with `outerlayer emit <name>`. A file in the same directory must declare the name in its `run:` block, with `where: ci` or `where: host`. Only that declaring check requires a runner's result; an `emitted:` requirement accepts a pass from any recorder. [Which result counts](/attach-evidence#which-result-counts) applies. Nothing recorded yet reads as waiting while a claim on the work item is live, and as failed once that claim is released or expires. |

### Checks that run in CI

`run` declares a check whose result comes from `outerlayer emit`:

```yaml theme={"system"}
id: e2e-in-ci
kind: validation
row: "The end-to-end suite passed in CI for this change"
run:
  where: ci
  emit: e2e
```

A `where: ci` check takes no `command`. OuterLayer never runs it. Record the result with `outerlayer emit e2e --result pass --item 412`, from CI or a shell. See [Record a check](/attach-evidence#record-a-check).

No check reads a CI result unless you declare one. The item page shows GitHub's own status instead.

The emit names `artifact`, `criteria`, `finding` and `findings` are reserved for `outerlayer emit` subcommands. A validator file that declares one fails to load. Choose another name.

### Checks that run on your host

`where: host` declares a command your own [runner](/reference/cli-runner) runs after a build:

```yaml theme={"system"}
id: unit-tests
kind: validation
row: "The unit tests pass on a clean checkout of the branch"
run:
  where: host
  emit: unit-tests
  command: "yarn test --ci"
```

`command` is required for `where: host` and not allowed for `where: ci`. It is a shell line. Exit status 0 is a pass and any other status is a fail.

A command can also report [check items](/policy-and-validators#check-items). Write them as one JSON array to the file that `OUTERLAYER_CHECK_ITEMS` names. A failing check item fails the check, even when the command exits 0. A file that is not JSON, or does not match the check item shape, fails the check and says why.

After a build succeeds, the runner runs each command in a new environment that holds no OuterLayer key. Only a pass recorded by a runner satisfies the check. A pass recorded under the same name by an item key, the build session or a person is ignored. A person's fail still stands, and fails the check whatever the runner recorded.

A `where: host` check renders no row on an item no runner has ever claimed, such as one built only in a local session.

In a build over several repositories, the checks come from the item's own repository, or from its control plane. Each check runs once, with every repository of the build checked out. `diff.patch` comes from the item's own repository. See [Host checks across repositories](/container-builds#host-checks-across-repositories). The runner reference describes [the steps, the time limit and each failure reason](/reference/build-reference#host-checks).

### A judge on your host

A `where: host` check can declare a `judge` in place of a `command`. The runner then has a fresh Claude Code agent judge the change, and records the agent's verdict. The starter [criteria judge check](/policy-and-validators#the-criteria-judge-check) is one: its `judge:` block names a `brief:`.

`brief` is the path, inside the repository, of the instructions the judge follows. In a repository that has not moved to the `AGENTS.md` layout, the starter brief is under `.outerlayer/skills/`. It must be a relative path of at most 300 characters with no `..` segment. A check declares `judge` or `command`, never both, and `judge` is only for `where: host`. A file that breaks this fails to load.

The judge reads only what the runner gives it. That is the criteria to judge, the tests that cite each one, the branch's diff and the pushed commit's files. Your brief can name them as `/outerlayer/judge/criteria.json`, `citations.json`, `diff.patch` and `tree`. The judge has no shell, so it cannot run a command or call OuterLayer. It treats repository content, comments included, as material to judge, never as instructions.

The runner tells the judge how to answer, so your brief says how to judge, never the shape of the answer. The answer becomes one check item per criterion, and the report is made from them. The check fails when any criterion fails, and passes when none does. The runner reference lists [each reason a judge check fails](/reference/build-reference#the-judge-stage). `outerlayer init` writes the `acceptance-criteria` check in this form.

`judge.subjects` says what the judge judges. `criteria`, the default, gives it the item's recorded criteria, as above. `none` gives it the change as a whole, so it needs no criteria:

```yaml theme={"system"}
id: security-review
kind: validation
row: "A security judge found no problem in this change"
run:
  where: host
  emit: security-review
  judge:
    brief: briefs/security-review.md
    subjects: none
```

A `subjects: none` judge reads the diff and the pushed commit, and gives one result for the change. It can add one check item per part it judged, such as a file.

### What a host check can read

A host command reads its input from a read-only folder, whose path is in `OUTERLAYER_EVAL_INPUT`. The environment holds no OuterLayer key, so read the files and call nothing.

| File | What it holds |
| - | - |
| `work-item.json` | The work item as OuterLayer returned it to the runner, as one JSON object. It is absent when the runner could not read the item. |
| `diff.patch` | The branch's diff, in git's patch format, from the merge base of the default-branch commit the build started from and the branch's head, to the head. A branch the build continued from before the default branch moved on shows only its own changes. It is empty when the runner could not read the commits. |
| `sessions/<trace-id>.jsonl` | One transcript per session, subagents included, as one JSON object per line. When `work-item.json` lists the session, its `traceId` is this trace id. |
| `activity.json` | The commands and edits of those sessions, and what could not be read. |

The transcripts come from the build environment, where the agent ran. Secrets are removed from them before the command can read them.

`activity.json` is one JSON object, with `schemaVersion: 1`. A script sees the same commands, in the same order, as a `session.ran` requirement does. The transcripts are complete, whatever capture tier your tenant stores. Under a lower tier, a rule sees fewer commands than the script does.

| Key | Value |
| - | - |
| `sessions` | One entry per session, sorted by `traceId`: `traceId`, `subagent`, `status` (`copied` or `missing`), `file` for a copied one, and `reason` for a missing one. `duplicates` counts further transcripts with the same session id that were left out. The runner copies at most 500 transcripts, 7 MiB each and 64 MiB in all, within two minutes. A transcript it could not copy, or one past those limits, is `missing`, and the command still runs. |
| `sessionsUnavailable` | Present only when the runner could not list the transcripts at all. It holds the reason, and `sessions` is empty. |
| `commands` | The commands that ran, in order. Each has `seq`, `traceId`, `turn`, `command`, `kind` (`test`, `lint`, `build`, `vcs`, `migration` or `other`) and `status` (`ok`, `error` or `rejected`). `command` has wrappers such as `cd ... &&`, `yarn` and `npx` removed. Each statement of a known kind in a compound command is its own entry, and they share one `seq`. |
| `edits` | The file edits, in order. Each has `seq`, `traceId`, `turn`, `file` and `status`. `seq` orders commands and edits together; it can skip numbers and repeat. `null` when `recorded.edits` is `false`. |
| `recorded` | `{ "commands": boolean, "edits": boolean }`. When `recorded.commands` is `false`, command detail was not recorded, and `commands` is `null`. The same holds for `edits`. Check both before reading the lists. Do not read `false` as "no command ran": a session that ran no command looks the same. |
| `diff` | `status` (`written` or `unavailable`), the `base` commit the build started from, the `head` commit, `baseSource`, and a `reason` when unavailable. |
| `workItem` | `status` (`written` or `unavailable`), and a `reason` when unavailable. |

## Reserved ids

`commits-from-sessions`, `red-then-green`, `no-test-tampering` and `tests-after-last-edit` are reserved. A `validators:` entry naming one is ignored. A custom validator file cannot use one as its `id` or name it in `require`.

## When a file fails to load

A validator file loads whole or not at all. A file that breaks any rule on this page is dropped. Examples are YAML that does not parse, an unknown key, or a value over its limit. A file whose `id` or emit name another file already declares is dropped too; the first file by name keeps it. A `require.validator` that names a missing, dropped or reserved check, or forms a cycle, drops the file too.

The evidence comment shows one policy error row for every problem in the policy file and the validator files. It names the first problem and its file, and counts the rest as "(and N more)". The rest of the comment still renders. Fix the files where they are read from: the base branch, or the control plane.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.