Rules
Tip
Each finding names a rule id.
spec explain <id>says why the rule exists and how to fix a finding;spec check --rule <id>runs just that rule.
Structure
| rule | checks that |
|---|---|
| S1 | frontmatter parses and has the required keys |
| S2 | id matches the path |
| S3 | every file in a spec directory belongs to a document |
| S4 | status is one the type allows |
| S5 | dates are ISO and updated is not before created |
| S6 | a status has the fields it requires |
| S7 | a document has the sections its type requires (spec new writes them; the finding names the spec edit --new-section command) |
Identity
| rule | checks that |
|---|---|
| I1 | ids are unique across every root |
| I2 | aliases do not shadow ids and point at one document |
| I3 | the alias table gains no rows |
Links
| rule | checks that |
|---|---|
| L1 | every [[reference]] resolves to one document |
| L2 | documents are cited by id, not by path |
| L3 | relations resolve and point at the right types |
| L4 | anchors are unique within a document |
| L5 | governed paths exist |
| L6 | citations outside documents still resolve |
| L7 | pinned remote documents are unchanged |
A reference or relation written <name>:<id> names a document in another repository listed
under [remotes.<name>]. L1 and L3 check it when that directory is on disk, with the same type
rules as local targets, and skip it when the directory is absent, so a CI job without that
checkout does not fail. spec show <name>:<id> reads it; nothing writes there.
L6 checks [[<name>:<id>]] in files listed under [cites] the same way. For checking only, a
translation may name a remote source (rel.translation_of: [org:guide:root/setup]); R1 compares
its source_rev with the remote body, and spec set <id> --source-rev records it. spec new
--translation does not start from a remote source, because nothing is copied out of a remote:
copy the document into this repository first, or translate it in its own repository. spec doctor lists every remote,
its directory and whether it is present.
A document can pin a remote document by content when it depends on that exact text:
spec set <id> --pin org:guide:root/setup records pins: {org:guide:root/setup: sha256:…}, the
same body hash as a translation’s source_rev. L7 warns when the remote body has changed; review
the change, then pin again. spec set <id> --unpin org:guide:root/setup removes a pin. A pin of a document that does not exist there is an error, and an
absent remote directory is skipped.
Lifecycle
| rule | checks that |
|---|---|
| T1 | superseded documents have exactly one successor |
| T2 | status only moves forward |
| T3 | sealed documents keep their body |
| T4 | an edited body moves its updated date |
| T5 | exceptions are reviewed by their date |
A document that departs from another on purpose names it with rel.exception_of and sets
review_by: YYYY-MM-DD. T5 warns when review_by is missing or not a date, and once the date has
passed (UTC). Renew it with spec set <id> review_by=<date> (set refuses a value that is not a date). When the
rule is kept again, retire the exception with status=rejected, or supersede it with a newer one.
Superseded and rejected exceptions are not checked. An exception that names itself is refused by
spec set and warned by T5. spec audit --kind exception lists the overdue ones. A local exception
lives in .spec.local, so it stays out of the shared repository.
T5 is a warning by default. To fail the check on an overdue exception, raise it in spec.toml:
[check.severity]
T5 = "error"
Visibility
| rule | checks that |
|---|---|
| V1 | no spec documents under public paths |
| V2 | committed documents do not cite local ones |
| V3 | external notes cite documents that exist |
Placement and attachments
| rule | checks that |
|---|---|
| P1 | documents live with the owner of the code they govern |
| P2 | every attachment is registered |
| P3 | repository attachments stay small |
| E1 | recorded attachments carry a matching hash |
| E2 | stored attachments exist in the store |
Retired layouts
| rule | checks that |
|---|---|
| Z1 | retired layouts and identifiers stay retired |
Verification
| rule | checks that |
|---|---|
| X1 | a source of truth’s verify commands pass |
| X2 | an active source of truth says how it is verified |
Generated documents
| rule | checks that |
|---|---|
| G1 | generated documents match their generator |
Guards
| rule | checks that |
|---|---|
| U1 | guards pass |
Agent assets
| rule | checks that |
|---|---|
| A1 | agent assets have the frontmatter their tools read |
| A2 | asset names are unique within a tool |
| A3 | paths and spec commands in agent assets exist |
| A4 | agent assets use repository paths, not machine paths |
A3 does not read URLs, ids, globs, placeholders such as <id>, or git refs such as origin/main and refs/heads/main as paths. A4 reports home directories (/Users/<name>, /home/<name>, C:\Users\<name>) but not the hosted CI runner’s /home/runner/, which is the same on every run; /home/runner2/ is still reported.
Translations
| rule | checks that |
|---|---|
| R1 | translations are current with their source |
| R2 | translations are where the [translations] layout expects them |
A translation names its source with rel.translation_of and records source_rev, a hash of the source body (sha256: and 12 hex digits) with frontmatter removed and whitespace collapsed, so it needs no git history and reformatting does not count. For a bundle, the hash covers the main file and then its chapters in path order. When the source body changes, R1 warns until the translation is updated and spec set <id> --source-rev records the source again. When git still has a commit with the recorded revision, the warning names the sections that changed since, for example in Install (1 paragraph changed), Support (new); chapter sections are prefixed with the chapter file name. A source that no longer exists is an L3 finding. spec census --translations lists every translation’s state, the changed sections of those behind, and the sources each language lacks. It exits 1 when anything is behind or missing; --fail-on stale fails only on translations that are behind or unrecorded, and --fail-on none reports without failing.
R1 also checks the values. lang must be a language tag (BCP 47, such as ko, ja, pt-BR). source_rev must be sha256: and 12 hex digits; spec set refuses other values. One source should have one translation per language. A translation must name an original: naming itself or another translation is an error, and the message names the original to use. spec census --translations lists only originals as sources. A translation’s frontmatter looks like this:
lang: ko
rel:
translation_of: [guide:shop/setup]
source_rev: sha256:3f2a9c0b1d4e
A translation can also be paired by place, without frontmatter, when spec.toml sets [translations] layout. With layout = "suffix", guide:shop/setup-ko is the ko translation of guide:shop/setup: same type and spec directory, slug ending in -<lang>. With layout = "dir", the translation has the source’s slug under an owner in a <lang> directory of the source’s owner, for example services/shop/ko/.spec/guide/setup.guide.md; that directory must be an owner ([[owners.kinds]] pattern = "services/*/ko"). <lang> is one of languages. Timestamp-keyed translations keep their own timestamp and are paired by slug. rel.translation_of and lang in frontmatter win over the layout. R2 reports a document placed as a translation with no source, or more than one, in the matching place; rel.translation_of that names another document than the layout; and lang that differs from the language of its place. R2 warns; strict = true makes it an error. spec fix --translations previews writing rel.translation_of and lang into every pair the layout infers, and --write applies it.
spec new --translation <lang> --from <id> starts a translation: it takes the source’s type, gets its own key, is placed by the layout (<slug>-<lang> beside the source without one), copies the source body (and the chapters and files of a bundle), and records lang, rel.translation_of and source_rev. It starts as draft where the type has that status. The tag in the name is lower case (setup-pt-br); lang keeps the BCP 47 form (pt-BR). A source that is itself a translation is refused with the original’s id.
Numbers
| rule | checks that |
|---|---|
| N1 | counts in spec:count markers are current |
Hidden characters
| rule | checks that |
|---|---|
| H1 | no invisible characters in documents, declared agent assets, or well-known agent files |
Well-known agent files are checked even when they are not declared: AGENTS.md, CLAUDE.md, GEMINI.md, .github/copilot-instructions.md, .agents/skills/*/SKILL.md, .claude/skills/*/SKILL.md, .claude/agents/*.md, .claude/commands/*.md, and .cursor/rules/*.mdc. Joiners used inside emoji (U+200C, U+200D) are allowed. Output never prints the characters themselves: messages, fixes, previews, and editor quick fixes show them as \u{XXXX}, and in an editor the warning covers just the one character. spec fix shows each changed line before and after.
A4 (machine-local paths) checks the same files as H1: declared agent assets and the well-known agent files, each once.
Plugin rules are named x.<plugin>.<id> and are explained the same way.